Files
jellytau/src/lib/components/library/mosaic.ts
T
dtourolle 3363ff7f08 Merge branch 'master' into worktree-mosaic-library
# Conflicts:
#	scripts/extract-traces.test.ts
2026-08-16 00:51:46 +02:00

204 lines
7.0 KiB
TypeScript

// 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 extends MosaicInput = MosaicInput> = T & {
width: number;
height: number;
};
/** One row of tiles, all sharing `height`. */
export interface MosaicRow<T extends MosaicInput = MosaicInput> {
height: number;
tiles: MosaicTile<T>[];
}
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<T extends MosaicInput>(
items: T[],
height: number,
gap: number,
justifyTo: number | null,
): MosaicRow<T> {
const h = Math.max(1, Math.round(height));
const tiles: MosaicTile<T>[] = 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<T extends MosaicInput>(
items: T[],
options: MosaicOptions,
): MosaicRow<T>[] {
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<T>[] = [];
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<T extends MosaicInput>(
items: T[],
height: number,
options: Pick<MosaicOptions, "minRatio" | "maxRatio"> = {},
): MosaicTile<T>[] {
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)) };
});
}