// Grilles ADAPTATIVES des pages d'images : chaque « famille » de grille se // construit selon le nombre d'images posées (2 images = 2 cases, jamais de // case vide) et l'orientation du lot. Le damier vise le format écran 16:9 // (majorité des paysages) ou affiche (~1/√2) pour les lots portrait. // Les compositions du gabarit (p.2/4/6/8) restent les familles « grande à // gauche + bandeaux » et « file + grande à droite ». La même définition sert // au rendu (CSS grid) et aux icônes SVG du composeur. export interface GridCell { c: number // colonne de départ (0-based) r: number // ligne de départ cs?: number // colspan rs?: number // rowspan } export interface GridDef { id: string label: string cols: number[] // proportions (fr) rows: number[] cells: GridCell[] } export interface GridFamily { id: string label: string max: number // nombre maximal d'images } export const GRID_FAMILIES: GridFamily[] = [ { id: 'mosaic', label: 'Damier (s\'adapte au format)', max: 8 }, { id: 'rows', label: 'Pleine largeur, empilées', max: 4 }, { id: 'cols', label: 'Pleine hauteur, côte à côte', max: 4 }, { id: 'hero-left', label: 'Grande à gauche + bandeaux', max: 6 }, { id: 'hero-right', label: 'File + grande à droite', max: 5 }, ] // Anciens ids de grilles fixes → famille équivalente (portfolios existants) const LEGACY_IDS: Record = { 'full': 'mosaic', 'duo': 'cols', 'cols-3': 'cols', 'rows-2': 'rows', 'rows-3': 'rows', 'large-left-2': 'hero-left', 'large-left-3': 'hero-left', 'large-left-4': 'hero-left', 'stack3-large': 'hero-right', 'quad': 'mosaic', 'six': 'mosaic', 'land-2x3': 'mosaic', 'port-4x2': 'mosaic', 'tall-5': 'hero-left', 'tall-banners': 'hero-left', } export function gridFamily(id: string): GridFamily { const fid = LEGACY_IDS[id] ?? id return GRID_FAMILIES.find(f => f.id === fid) ?? GRID_FAMILIES[0] } export type ImageOrientation = 'landscape' | 'portrait' | 'mixed' /** * Orientation dominante d'un lot d'images (≥ 70 % dans le même sens), * d'après leurs ratios largeur/hauteur. */ export function dominantOrientation(ratios: number[]): ImageOrientation { const known = ratios.filter(r => r > 0) if (!known.length) return 'mixed' const landscape = known.filter(r => r >= 1.15).length const portrait = known.filter(r => r <= 0.87).length if (landscape / known.length >= 0.7) return 'landscape' if (portrait / known.length >= 0.7) return 'portrait' return 'mixed' } // Zone utile A3 paysage hors marges, en mm (sert aux calculs de ratios) const INNER_W_MM = 404 const INNER_H_MM = 281 function pgcd(a: number, b: number): number { return b ? pgcd(b, a % b) : a } /** * Damier : choisit le nombre de colonnes qui maximise la surface d'une image * au ratio cible (16:9 en paysage/mixte — la majorité des paysages sont des * écrans —, ~1/√2 en portrait). Les images de la dernière rangée incomplète * se partagent toute la largeur (pas de case vide). */ function mosaicDef(n: number, orientation: ImageOrientation): GridDef { const target = orientation === 'portrait' ? 0.707 : 16 / 9 let bestC = 1 let bestArea = 0 for (let c = 1; c <= Math.min(n, 4); c++) { const r = Math.ceil(n / c) const w = INNER_W_MM / c const h = INNER_H_MM / r const iw = Math.min(w, h * target) const area = iw * (iw / target) if (area > bestArea) { bestArea = area bestC = c } } const C = bestC const R = Math.ceil(n / C) const rem = n % C const cells: GridCell[] = [] if (!rem || R === 1) { for (let i = 0; i < n; i++) cells.push({ c: i % C, r: Math.floor(i / C) }) return { id: 'mosaic', label: 'Damier', cols: Array(C).fill(1), rows: Array(R).fill(1), cells } } // Dernière rangée incomplète : rem images se partagent la largeur. // Grille exprimée en L = ppcm(C, rem) colonnes pour des spans entiers. const L = (C * rem) / pgcd(C, rem) const fullSpan = L / C const remSpan = L / rem const full = n - rem for (let i = 0; i < full; i++) { cells.push({ c: (i % C) * fullSpan, r: Math.floor(i / C), cs: fullSpan }) } for (let i = 0; i < rem; i++) { cells.push({ c: i * remSpan, r: R - 1, cs: remSpan }) } return { id: 'mosaic', label: 'Damier', cols: Array(L).fill(1), rows: Array(R).fill(1), cells } } /** * Définition effective d'une grille : famille × nombre d'images × orientation. */ export function gridDef(id: string, count = 1, orientation: ImageOrientation = 'mixed'): GridDef { const fam = gridFamily(id) const n = Math.max(1, Math.min(count, fam.max)) if (n === 1) { return { id: fam.id, label: fam.label, cols: [1], rows: [1], cells: [{ c: 0, r: 0 }] } } switch (fam.id) { case 'rows': return { id: fam.id, label: fam.label, cols: [1], rows: Array(n).fill(1), cells: Array.from({ length: n }, (_, i) => ({ c: 0, r: i })), } case 'cols': return { id: fam.id, label: fam.label, cols: Array(n).fill(1), rows: [1], cells: Array.from({ length: n }, (_, i) => ({ c: i, r: 0 })), } case 'hero-left': { // Grande image pleine hauteur à gauche + bandeaux empilés à droite. // Image de gauche à largeur maximale (gabarit p.2/p.8 : 60/40 puis 70/30) // et marges toujours égales entre les bandeaux (rangées 1fr). const cols = n <= 3 ? [60, 40] : [70, 30] return { id: fam.id, label: fam.label, cols, rows: Array(n - 1).fill(1), cells: [ { c: 0, r: 0, rs: n - 1 }, ...Array.from({ length: n - 1 }, (_, i) => ({ c: 1, r: i })), ], } } case 'hero-right': // Gabarit p.4 : file à gauche (30 %), grande à droite (70 %) return { id: fam.id, label: fam.label, cols: [30, 70], rows: Array(n - 1).fill(1), cells: [ ...Array.from({ length: n - 1 }, (_, i) => ({ c: 0, r: i })), { c: 1, r: 0, rs: n - 1 }, ], } default: return mosaicDef(n, orientation) } } /** * Famille par défaut : compositions du gabarit en lot mixte, damier * auto-optimisé (16:9 ou affiche) sinon. */ export function defaultGridFor(count: number, orientation: ImageOrientation = 'mixed'): string { if (count <= 1) return 'mosaic' if (orientation === 'mixed') return count <= 6 ? 'hero-left' : 'mosaic' return 'mosaic' } /** * Rectangles des cellules en fractions [0..1] de la zone utile (pour les * icônes SVG et le calcul des largeurs d'affichage). Les gouttières sont * exprimées en fraction de la largeur (gapX) et de la hauteur (gapY) utiles. */ export function gridCellRects(def: GridDef, gapX = 0.0095, gapY = gapX): { x: number; y: number; w: number; h: number }[] { const colTotal = def.cols.reduce((a, b) => a + b, 0) const rowTotal = def.rows.reduce((a, b) => a + b, 0) const availW = 1 - gapX * (def.cols.length - 1) const availH = 1 - gapY * (def.rows.length - 1) const colW = def.cols.map(c => (c / colTotal) * availW) const rowH = def.rows.map(r => (r / rowTotal) * availH) const colX = colW.map((_, i) => colW.slice(0, i).reduce((a, b) => a + b, 0) + gapX * i) const rowY = rowH.map((_, i) => rowH.slice(0, i).reduce((a, b) => a + b, 0) + gapY * i) return def.cells.map(cell => ({ x: colX[cell.c], y: rowY[cell.r], w: colW.slice(cell.c, cell.c + (cell.cs ?? 1)).reduce((a, b) => a + b, 0) + gapX * ((cell.cs ?? 1) - 1), h: rowH.slice(cell.r, cell.r + (cell.rs ?? 1)).reduce((a, b) => a + b, 0) + gapY * ((cell.rs ?? 1) - 1), })) }