// Justified ("mosaic") tile layout — pure geometry, no DOM. // // The library overview and the home "Your Libraries" strip both show artwork of // mixed shapes: square music covers next to 16:9 library backdrops next to 2:3 // posters. A CSS grid forces one box shape on all of them, so every tile that // isn't that shape is cropped or letterboxed. This packs tiles into rows of a // *shared height* and lets each keep its own width, so each tile is displayed at // its own aspect ratio and nothing is distorted. // // Presentation only — nothing here knows what a library or a media item is. // // TRACES: UR-075 | DR-174 | UT-158, UT-159, UT-160 /** A tile to place: an opaque key and the aspect ratio (width / height) to honour. */ export interface MosaicInput { key: string; /** width / height. 1 = square, 16/9 ≈ 1.78, 2/3 ≈ 0.67. */ ratio: number; } /** * A tile with its resolved pixel box. Generic so callers can hang whatever they * need to render (the library, the label, a route) off the same object. */ export type MosaicTile = T & { width: number; height: number; }; /** One row of tiles, all sharing `height`. */ export interface MosaicRow { height: number; tiles: MosaicTile[]; } export interface MosaicOptions { /** Usable width in px (already net of the container's own padding). */ containerWidth: number; /** The height rows aim for. Rows land at or below it; see `layoutMosaic`. */ targetHeight: number; /** Gap between tiles in a row, in px. Rows are justified around it. */ gap?: number; /** * Ratios outside this band are clamped. An extreme tile would otherwise take a * whole row to itself (very wide) or shrink to a sliver (very tall); clamping * costs a little crop on the outliers and keeps the mosaic readable. */ minRatio?: number; maxRatio?: number; } const DEFAULTS = { gap: 8, minRatio: 0.5, maxRatio: 2.5, } as const; function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } /** A ratio we can lay out: finite and positive, clamped into the band. */ function usableRatio(ratio: number, min: number, max: number): number { if (!Number.isFinite(ratio) || ratio <= 0) return 1; return clamp(ratio, min, max); } /** * Give a row its pixel boxes. * * `justifyTo` is the width the row must fill *exactly* — rounding each tile * independently leaves the row a pixel or two short or long, which reads as a * ragged right edge, so the remainder is absorbed by the widest tile (where one * pixel is least visible). A `null` justifies nothing: the last row keeps its * natural width and is left-aligned. */ function buildRow( items: T[], height: number, gap: number, justifyTo: number | null, ): MosaicRow { const h = Math.max(1, Math.round(height)); const tiles: MosaicTile[] = items.map((item) => ({ ...item, height: h, width: Math.max(1, Math.round(item.ratio * h)), })); if (justifyTo !== null && tiles.length > 0) { const used = tiles.reduce((sum, t) => sum + t.width, 0) + gap * (tiles.length - 1); const delta = justifyTo - used; if (delta !== 0) { let widest = 0; for (let i = 1; i < tiles.length; i++) { if (tiles[i].width > tiles[widest].width) widest = i; } tiles[widest].width = Math.max(1, tiles[widest].width + delta); } } return { height: h, tiles }; } /** * Pack `items` into justified rows. * * Tiles are added to a row until the height needed to fill `containerWidth` has * fallen to `targetHeight` — at which point the row is closed at that height, so * rows come out at or slightly below the target rather than above it. The final * row is never stretched to fill the width: with one tile left over, justifying * would blow it up to the full container width. It sits at `targetHeight` * instead (or lower, if its natural fit is already shorter), left-aligned. * * Returns `[]` for a container with no width — a first paint before the element * has been measured, which must render nothing rather than a row of 1px tiles. */ export function layoutMosaic( items: T[], options: MosaicOptions, ): MosaicRow[] { const { containerWidth, targetHeight } = options; const gap = options.gap ?? DEFAULTS.gap; const minRatio = options.minRatio ?? DEFAULTS.minRatio; const maxRatio = options.maxRatio ?? DEFAULTS.maxRatio; if (containerWidth <= 0 || targetHeight <= 0 || items.length === 0) return []; const normalized = items.map((item) => ({ ...item, ratio: usableRatio(item.ratio, minRatio, maxRatio), })); const rows: MosaicRow[] = []; let current: T[] = []; let ratioSum = 0; for (const item of normalized) { current.push(item); ratioSum += item.ratio; // Width left for artwork once this row's gaps are paid for. const available = containerWidth - gap * (current.length - 1); const height = available / ratioSum; if (height <= targetHeight) { rows.push(buildRow(current, height, gap, containerWidth)); current = []; ratioSum = 0; } } if (current.length > 0) { const available = containerWidth - gap * (current.length - 1); const natural = available / ratioSum; rows.push(buildRow(current, Math.min(natural, targetHeight), gap, null)); } return rows; } /** Row height bounds — a phone must still fit two tiles, a desktop must not * turn each library into a billboard. */ const MIN_TARGET_HEIGHT = 96; const MAX_TARGET_HEIGHT = 190; /** Roughly this many tiles per row, before ratios pull the count around. */ const TILES_PER_ROW = 4; const MIN_TILE_WIDTH = 150; const MAX_TILE_WIDTH = 300; /** The width/height a "typical" tile is sized against. */ const NOMINAL_RATIO = 1.6; /** * A row height that suits the container it is drawn in: tall enough on a desktop * to be worth looking at, short enough on a phone that two tiles still fit side * by side. Callers may override it; this is what the grid picks unasked. */ export function mosaicTargetHeight(containerWidth: number): number { if (containerWidth <= 0) return MIN_TARGET_HEIGHT; const tileWidth = clamp(containerWidth / TILES_PER_ROW, MIN_TILE_WIDTH, MAX_TILE_WIDTH); return Math.round(clamp(tileWidth / NOMINAL_RATIO, MIN_TARGET_HEIGHT, MAX_TARGET_HEIGHT)); } /** * Lay tiles out as a single fixed-height row — the shape a horizontally * scrolling strip wants. Same principle as `layoutMosaic`: one height, natural * widths, no distortion. */ export function layoutMosaicStrip( items: T[], height: number, options: Pick = {}, ): MosaicTile[] { const minRatio = options.minRatio ?? DEFAULTS.minRatio; const maxRatio = options.maxRatio ?? DEFAULTS.maxRatio; if (height <= 0) return []; const h = Math.max(1, Math.round(height)); return items.map((item) => { const ratio = usableRatio(item.ratio, minRatio, maxRatio); return { ...item, ratio, height: h, width: Math.max(1, Math.round(ratio * h)) }; }); }