204 lines
7.0 KiB
TypeScript
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)) };
|
|
});
|
|
}
|