feat(library,home): lay libraries out as a mosaic, with favourites per category

The library overview and the home shortcut strip showed artwork of three
different shapes — square music covers, 16:9 library backdrops, 2:3 posters —
in grids that pick one box and crop everything to it. The home strip said so
in a comment: it forced `aspect="video"` on music libraries so the row would
line up, which lined it up by cutting the covers down.

Both surfaces are now justified mosaics: rows share one height and each tile is
as wide as its own artwork. `layoutMosaic` is a pure module — it packs tiles
until the height needed to fill the container drops to the target, justifies the
row by absorbing the rounding remainder into its widest tile, and deliberately
leaves the last row unstretched so one leftover tile does not inflate into a
banner. The component supplies only what the DOM knows: the measured container
width, and the artwork's *decoded* aspect ratio (via a new `onNaturalSize` on
CachedImage), committed in one debounced batch so the grid does not reshuffle
once per image as artwork lands.

Favourites gain a tile per category beside the library it belongs to, alongside
the existing cross-library entry. Which collection type maps to which category
is Jellyfin vocabulary, so it is derived in Rust — `SearchScope::for_collection_type`,
stamped onto every `Library` by a new constructor and carried over as an optional
`favoritesScope`. Deriving it in Svelte would have rebuilt the exact leak
`SearchScope::item_types` was extracted to close. A category shows one tile
however many libraries share it, and a library kind favourites do not carve up
(Live TV, channels, books) gets none.

Also corrects the requirements-count test, which the UR-074 commit left one
behind.

Spec: docs/specs/library-mosaic.md
TRACES: UR-075, UR-067 | DR-163, DR-164 | UT-158..UT-162
This commit is contained in:
2026-08-15 23:57:09 +02:00
parent d49d027020
commit 0861523015
18 changed files with 6111 additions and 4303 deletions
+13 -1
View File
@@ -2053,7 +2053,19 @@ export type JRayActor = { name: string; imdb_id?: string; tmdb_id?: string; jell
/**
* Library (media collection)
*/
export type Library = { id: string; name: string; collectionType: string; imageTag?: string | null }
export type Library = { id: string; name: string; collectionType: string; imageTag?: string | null;
/**
* The favourites scope this library's contents fall under, or `None` for a
* library kind favourites does not carve up (Live TV, channels, books…).
*
* Derived here rather than in the UI: which collection type maps to which
* scope is Jellyfin vocabulary, and the frontend must not hold a
* collection-type → category table any more than an item-type one. See
* `SearchScope::for_collection_type`.
*
* TRACES: UR-075 | DR-164
*/
favoritesScope?: SearchScope | null }
/**
* Live stream information returned from opening a Live TV / channel stream.
*
+19 -1
View File
@@ -11,6 +11,13 @@
maxHeight?: number;
class?: string;
alt?: string;
/**
* Called once the bitmap is decoded, with its intrinsic pixel size. Lets a
* layout that sizes boxes from artwork (the mosaic) use the shape the image
* actually has rather than the one its item type suggests.
* TRACES: UR-075 | DR-163
*/
onNaturalSize?: (width: number, height: number) => void;
}
let {
@@ -21,6 +28,7 @@
maxHeight,
class: className = "",
alt = "",
onNaturalSize,
}: Props = $props();
let imageUrl = $state<string | null>(null);
@@ -86,5 +94,15 @@
</svg>
</div>
{:else}
<img src={imageUrl} {alt} class={className} />
<img
src={imageUrl}
{alt}
class={className}
onload={(e) => {
const img = e.currentTarget as HTMLImageElement;
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
onNaturalSize?.(img.naturalWidth, img.naturalHeight);
}
}}
/>
{/if}
@@ -0,0 +1,97 @@
<!--
Justified mosaic of tiles: rows of a shared height, each tile as wide as its
own aspect ratio says it should be.
The geometry is `mosaic.ts` (pure, unit-tested); this component supplies the
two things only the DOM knows — how wide the container is, and what shape the
artwork turned out to be — and renders whatever the caller's `tile` snippet
draws.
Measured ratios are committed in one batch rather than per image: artwork
arrives over a few hundred milliseconds, and re-packing on each arrival would
shuffle the grid under the viewer's cursor several times over.
TRACES: UR-075 | DR-163
-->
<script lang="ts" generics="T extends { key: string; ratio: number }">
import type { Snippet } from "svelte";
import { onDestroy } from "svelte";
import {
layoutMosaic,
layoutMosaicStrip,
mosaicTargetHeight,
type MosaicTile,
} from "./mosaic";
interface Props {
items: T[];
/** Row height. Defaults to one suited to the container's width. */
targetHeight?: number;
gap?: number;
/**
* "rows" wraps into justified rows and fills the container.
* "strip" keeps one row at a fixed height and scrolls sideways — the same
* no-distortion rule applied to a shelf.
*/
layout?: "rows" | "strip";
tile: Snippet<[MosaicTile<T> & { reportRatio: (ratio: number) => void }]>;
}
let { items, targetHeight, gap = 8, layout = "rows", tile }: Props = $props();
let containerWidth = $state(0);
let measured = $state<Record<string, number>>({});
let pending: Record<string, number> = {};
let commitTimer: ReturnType<typeof setTimeout> | null = null;
const COMMIT_DELAY_MS = 120;
/** Below this, a measured ratio isn't worth a re-pack. */
const RATIO_EPSILON = 0.02;
function reportRatio(key: string, ratio: number) {
if (!Number.isFinite(ratio) || ratio <= 0) return;
const known = measured[key] ?? items.find((i) => i.key === key)?.ratio;
if (known !== undefined && Math.abs(known - ratio) / known < RATIO_EPSILON) return;
pending[key] = ratio;
if (commitTimer !== null) return;
commitTimer = setTimeout(() => {
commitTimer = null;
measured = { ...measured, ...pending };
pending = {};
}, COMMIT_DELAY_MS);
}
onDestroy(() => {
if (commitTimer !== null) clearTimeout(commitTimer);
});
const height = $derived(targetHeight ?? mosaicTargetHeight(containerWidth));
const sized = $derived(items.map((item) => ({ ...item, ratio: measured[item.key] ?? item.ratio })));
const rows = $derived(
layout === "strip"
? [{ height, tiles: layoutMosaicStrip(sized, height) }]
: layoutMosaic(sized, { containerWidth, targetHeight: height, gap }),
);
</script>
{#if layout === "strip"}
<!-- A strip is measured by the viewport it scrolls in, not by its content. -->
<div bind:clientWidth={containerWidth} class="overflow-x-auto pb-2">
<div class="flex w-max items-start" style="gap: {gap}px;">
{#each rows[0].tiles as placed (placed.key)}
{@render tile({ ...placed, reportRatio: (r: number) => reportRatio(placed.key, r) })}
{/each}
</div>
</div>
{:else}
<div bind:clientWidth={containerWidth} class="flex flex-col" style="gap: {gap}px;">
{#each rows as row, i (i)}
<div class="flex" style="gap: {gap}px;">
{#each row.tiles as placed (placed.key)}
{@render tile({ ...placed, reportRatio: (r: number) => reportRatio(placed.key, r) })}
{/each}
</div>
{/each}
</div>
{/if}
@@ -0,0 +1,90 @@
<!--
One tile of a mosaic: artwork at an exact pixel box, with its label written
over the bottom of the image rather than beneath it.
The label lives on the artwork on purpose — a caption below would add height
outside the box the layout computed, and a row whose captions wrap to two
lines would no longer line up with its neighbours. Keeping everything inside
the box is what lets `layoutMosaic` own the geometry completely.
TRACES: UR-075 | DR-163
-->
<script lang="ts">
import type { Snippet } from "svelte";
import CachedImage from "$lib/components/common/CachedImage.svelte";
interface Props {
label: string;
width: number;
height: number;
/** Item whose Primary image is the artwork. Omit for an icon-only tile. */
itemId?: string;
imageTag?: string | null;
/** Drawn instead of artwork — favourites tiles have no image of their own. */
icon?: Snippet;
/** Tints an icon-only tile so it reads as a destination, not a broken image. */
accent?: boolean;
onclick?: () => void;
/**
* Reports the artwork's true aspect ratio once decoded, so the grid can
* re-pack against the shape the image actually has.
*/
onRatio?: (ratio: number) => void;
}
let {
label,
width,
height,
itemId,
imageTag,
icon,
accent = false,
onclick,
onRatio,
}: Props = $props();
// Request an image comfortably larger than the box so a wide tile is not
// upscaled, without refetching every time the container resizes (CachedImage
// keys its fetch on the item, not on this number).
const REQUEST_WIDTH = 480;
</script>
<button
type="button"
{onclick}
aria-label={label}
class="group/tile relative overflow-hidden rounded-lg bg-[var(--color-surface)] shadow-md
transition-transform duration-200 hover:z-10 hover:scale-[1.03] hover:shadow-2xl
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-jellyfin)]"
style="width: {width}px; height: {height}px;"
>
{#if icon}
<div
class="absolute inset-0 flex items-center justify-center
{accent
? 'bg-gradient-to-br from-[var(--color-jellyfin)]/40 to-[var(--color-jellyfin)]/5'
: 'bg-[var(--color-surface)]'}"
>
{@render icon()}
</div>
{:else if itemId}
<CachedImage
{itemId}
imageType="Primary"
tag={imageTag}
maxWidth={REQUEST_WIDTH}
alt={label}
class="absolute inset-0 w-full h-full object-cover transition-transform duration-300 group-hover/tile:scale-105"
onNaturalSize={(w, h) => onRatio?.(w / h)}
/>
{/if}
<!-- Legibility wash: only as tall as the caption needs, so artwork stays
artwork. -->
<div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/45 to-transparent pt-6 pb-2 px-2.5">
<p class="truncate text-left text-sm font-semibold text-white drop-shadow group-hover/tile:text-[var(--color-jellyfin)] transition-colors">
{label}
</p>
</div>
</button>
@@ -0,0 +1,98 @@
import { describe, it, expect } from "vitest";
import type { Library } from "$lib/api/types";
import { buildLibraryMosaic, assumedLibraryRatio } from "./libraryMosaic";
function lib(
id: string,
name: string,
collectionType: string,
favoritesScope?: Library["favoritesScope"],
): Library {
return { id, name, collectionType, favoritesScope } as Library;
}
const MOVIES = lib("1", "Movies", "movies", "movies");
const SHOWS = lib("2", "Shows", "tvshows", "tv");
const MUSIC = lib("3", "Music", "music", "music");
const LIVETV = lib("4", "Live TV", "livetv");
describe("buildLibraryMosaic", () => {
it("leads with the cross-library favourites entry", () => {
const entries = buildLibraryMosaic([MOVIES]);
expect(entries[0]).toMatchObject({
kind: "favorites",
scope: "all",
label: "Favourites",
href: "/library/favorites",
});
});
it("puts each library's own favourites tile right after it", () => {
const entries = buildLibraryMosaic([MOVIES, MUSIC]);
expect(entries.map((e) => e.label)).toEqual([
"Favourites",
"Movies",
"Favourite Movies",
"Music",
"Favourite Music",
]);
});
it("links a category tile to that category's favourites tab", () => {
const entries = buildLibraryMosaic([SHOWS]);
const tile = entries.find((e) => e.label === "Favourite Shows");
expect(tile).toMatchObject({ kind: "favorites", scope: "tv", href: "/library/favorites?scope=tv" });
});
it("offers a category's favourites once, however many libraries share it", () => {
const entries = buildLibraryMosaic([MOVIES, lib("5", "Kids Films", "movies", "movies")]);
expect(entries.filter((e) => e.kind === "favorites" && e.scope === "movies")).toHaveLength(1);
expect(entries.map((e) => e.label)).toEqual([
"Favourites",
"Movies",
"Favourite Movies",
"Kids Films",
]);
});
it("gives no favourites tile to a library kind favourites do not carve up", () => {
const entries = buildLibraryMosaic([LIVETV]);
expect(entries.map((e) => e.label)).toEqual(["Favourites", "Live TV"]);
});
it("ignores a scope the favourites page does not offer as a tab", () => {
const odd = lib("6", "Books", "books", "books" as Library["favoritesScope"]);
const entries = buildLibraryMosaic([odd]);
expect(entries.map((e) => e.label)).toEqual(["Favourites", "Books"]);
});
it("keeps every library, and keys tiles uniquely", () => {
const entries = buildLibraryMosaic([MOVIES, SHOWS, MUSIC, LIVETV]);
expect(entries.filter((e) => e.kind === "library")).toHaveLength(4);
expect(new Set(entries.map((e) => e.key)).size).toBe(entries.length);
});
it("has nothing but the favourites entry when there are no libraries", () => {
expect(buildLibraryMosaic([]).map((e) => e.key)).toEqual(["favorites:all"]);
});
it("gives a category tile the shape of the library it follows", () => {
const entries = buildLibraryMosaic([MUSIC, MOVIES]);
const musicFavorites = entries.find((e) => e.label === "Favourite Music")!;
const movieFavorites = entries.find((e) => e.label === "Favourite Movies")!;
expect(musicFavorites.ratio).toBe(assumedLibraryRatio(MUSIC));
expect(movieFavorites.ratio).toBe(assumedLibraryRatio(MOVIES));
});
});
describe("assumedLibraryRatio", () => {
it("assumes a square cover for music and a wide backdrop otherwise", () => {
expect(assumedLibraryRatio(MUSIC)).toBe(1);
expect(assumedLibraryRatio(MOVIES)).toBeCloseTo(16 / 9);
expect(assumedLibraryRatio(LIVETV)).toBeCloseTo(16 / 9);
});
});
@@ -0,0 +1,87 @@
// What the library overview mosaic is made of, and in what order.
//
// Pure: takes the libraries, returns the tiles to draw. No DOM, no stores — so
// the ordering and the de-duplication rules below are unit-testable rather than
// buried in markup.
//
// Note what is NOT decided here: which favourites category a library belongs to.
// That is Jellyfin vocabulary and arrives on the library itself as
// `favoritesScope` (Rust: `SearchScope::for_collection_type`). This file only
// decides what to *call* it and where to put it.
//
// TRACES: UR-075, UR-067 | DR-163, DR-164 | UT-162
import type { Library } from "$lib/api/types";
import {
FAVORITE_SCOPE_LABELS,
asFavoritesScope,
favoritesRouteUrl,
type FavoritesScope,
} from "$lib/utils/favoritesView";
/** Artwork shapes, as the source images generally arrive. A measured image
* overrides these (see MosaicGrid); they are the shape assumed until then. */
const SQUARE = 1;
const WIDE = 16 / 9;
export type LibraryMosaicEntry = {
/** Stable identity for the layout and for `{#each}` keying. */
key: string;
/** Assumed width / height until the artwork reports its own. */
ratio: number;
label: string;
} & (
| { kind: "library"; library: Library }
| { kind: "favorites"; scope: FavoritesScope; href: string }
);
/**
* A music library's artwork is a square cover; everything else is a wide
* backdrop. Presentation, not taxonomy: this is the shape of a picture, and it
* is a starting guess that the decoded image is allowed to overrule.
*/
export function assumedLibraryRatio(lib: Library): number {
return lib.collectionType === "music" ? SQUARE : WIDE;
}
/**
* The mosaic's tiles, in order: the cross-library favourites entry first, then
* each library followed by its own favourites tile.
*
* A category's favourites tile appears **once**, after the first library of that
* category — two movie libraries ("Films", "Kids") share one favourites list, so
* a tile each would be two tiles going to the same place.
*/
export function buildLibraryMosaic(libraries: Library[]): LibraryMosaicEntry[] {
const entries: LibraryMosaicEntry[] = [
{
key: "favorites:all",
kind: "favorites",
scope: "all",
href: favoritesRouteUrl("all"),
ratio: WIDE,
label: "Favourites",
},
];
const seenScopes = new Set<FavoritesScope>(["all"]);
for (const lib of libraries) {
const ratio = assumedLibraryRatio(lib);
entries.push({ key: `library:${lib.id}`, kind: "library", library: lib, ratio, label: lib.name });
const scope = asFavoritesScope(lib.favoritesScope);
if (!scope || seenScopes.has(scope)) continue;
seenScopes.add(scope);
entries.push({
key: `favorites:${scope}`,
kind: "favorites",
scope,
href: favoritesRouteUrl(scope),
ratio,
label: `Favourite ${FAVORITE_SCOPE_LABELS[scope]}`,
});
}
return entries;
}
+179
View File
@@ -0,0 +1,179 @@
import { describe, it, expect } from "vitest";
import {
layoutMosaic,
layoutMosaicStrip,
mosaicTargetHeight,
type MosaicInput,
type MosaicRow,
} from "./mosaic";
const VIDEO = 16 / 9;
const SQUARE = 1;
const POSTER = 2 / 3;
function tiles(...ratios: number[]): MosaicInput[] {
return ratios.map((ratio, i) => ({ key: `t${i}`, ratio }));
}
function rowWidth(row: MosaicRow, gap: number): number {
return row.tiles.reduce((sum, t) => sum + t.width, 0) + gap * (row.tiles.length - 1);
}
/** Every tile bar the one that absorbs the rounding remainder keeps its ratio. */
function offRatioTiles(row: MosaicRow, tolerancePx = 1): number {
return row.tiles.filter((t) => Math.abs(t.width - t.ratio * t.height) > tolerancePx).length;
}
describe("layoutMosaic", () => {
const opts = { containerWidth: 1000, targetHeight: 160, gap: 8 };
it("fills the container width exactly on every row but the last", () => {
const rows = layoutMosaic(tiles(VIDEO, SQUARE, POSTER, VIDEO, SQUARE, VIDEO, POSTER), opts);
expect(rows.length).toBeGreaterThan(1);
for (const row of rows.slice(0, -1)) {
expect(rowWidth(row, opts.gap)).toBe(opts.containerWidth);
}
});
it("never overflows the container, last row included", () => {
const rows = layoutMosaic(tiles(VIDEO, SQUARE, POSTER, VIDEO, SQUARE, VIDEO, POSTER), opts);
for (const row of rows) {
expect(rowWidth(row, opts.gap)).toBeLessThanOrEqual(opts.containerWidth);
}
});
it("gives every tile in a row the same height", () => {
const rows = layoutMosaic(tiles(VIDEO, SQUARE, POSTER, VIDEO, SQUARE, VIDEO), opts);
for (const row of rows) {
for (const tile of row.tiles) {
expect(tile.height).toBe(row.height);
}
}
});
it("honours each tile's aspect ratio — widths vary, nothing is squashed", () => {
const rows = layoutMosaic(tiles(VIDEO, SQUARE, POSTER, VIDEO, SQUARE, VIDEO), opts);
for (const row of rows) {
// At most the single remainder-absorbing tile may be off, and only by the
// few pixels the row was short of the container width.
expect(offRatioTiles(row)).toBeLessThanOrEqual(1);
}
// A 16:9 tile is meaningfully wider than a 2:3 tile at the same height.
const all = rows.flatMap((r) => r.tiles);
const video = all.find((t) => t.key === "t0")!;
const poster = all.find((t) => t.key === "t2")!;
expect(video.width).toBeGreaterThan(poster.width * 2);
});
it("keeps rows at or below the target height", () => {
const rows = layoutMosaic(tiles(...Array(12).fill(VIDEO)), opts);
for (const row of rows) {
expect(row.height).toBeLessThanOrEqual(opts.targetHeight);
}
});
it("does not stretch a short last row across the whole container", () => {
// Two 16:9 tiles cannot fill 1000px at 160px tall (they want ~569px), so the
// last row must stay at the target height rather than blow up to fill.
const rows = layoutMosaic(tiles(VIDEO, VIDEO), opts);
expect(rows).toHaveLength(1);
expect(rows[0].height).toBe(opts.targetHeight);
expect(rowWidth(rows[0], opts.gap)).toBeLessThan(opts.containerWidth);
});
it("shrinks a last row that would otherwise overflow", () => {
// Five 16:9 tiles at 160px tall want ~1454px; the row has to come down.
const rows = layoutMosaic(tiles(VIDEO, VIDEO, VIDEO, VIDEO, VIDEO), {
...opts,
targetHeight: 400,
});
for (const row of rows) {
expect(rowWidth(row, opts.gap)).toBeLessThanOrEqual(opts.containerWidth);
}
});
it("clamps an extreme ratio instead of letting it own a row", () => {
const rows = layoutMosaic(tiles(20, SQUARE, SQUARE), { ...opts, maxRatio: 2.5 });
const panorama = rows.flatMap((r) => r.tiles).find((t) => t.key === "t0")!;
expect(panorama.width / panorama.height).toBeLessThanOrEqual(2.6);
});
it("treats a missing or nonsensical ratio as square rather than collapsing", () => {
const rows = layoutMosaic(
[
{ key: "nan", ratio: Number.NaN },
{ key: "zero", ratio: 0 },
{ key: "neg", ratio: -2 },
],
opts,
);
for (const tile of rows.flatMap((r) => r.tiles)) {
expect(tile.width).toBeCloseTo(tile.height, -1);
}
});
it("renders nothing before the container has been measured", () => {
expect(layoutMosaic(tiles(VIDEO, SQUARE), { ...opts, containerWidth: 0 })).toEqual([]);
expect(layoutMosaic(tiles(VIDEO, SQUARE), { ...opts, targetHeight: 0 })).toEqual([]);
expect(layoutMosaic([], opts)).toEqual([]);
});
it("places every tile exactly once, in order", () => {
const input = tiles(VIDEO, SQUARE, POSTER, VIDEO, SQUARE, VIDEO, POSTER, SQUARE);
const placed = layoutMosaic(input, opts).flatMap((r) => r.tiles.map((t) => t.key));
expect(placed).toEqual(input.map((t) => t.key));
});
it("re-packs when the container narrows", () => {
const input = tiles(VIDEO, SQUARE, POSTER, VIDEO, SQUARE, VIDEO);
const wide = layoutMosaic(input, { ...opts, containerWidth: 1400 });
const narrow = layoutMosaic(input, { ...opts, containerWidth: 420 });
expect(narrow.length).toBeGreaterThan(wide.length);
});
});
describe("mosaicTargetHeight", () => {
it("still fits two 16:9 tiles across a phone", () => {
const width = 360;
const height = mosaicTargetHeight(width);
const rows = layoutMosaic(tiles(VIDEO, VIDEO, VIDEO), {
containerWidth: width,
targetHeight: height,
gap: 8,
});
expect(rows[0].tiles.length).toBeGreaterThanOrEqual(2);
});
it("grows with the container but stays within bounds", () => {
const widths = [0, 320, 600, 900, 1400, 3000];
const heights = widths.map(mosaicTargetHeight);
for (const h of heights) {
expect(h).toBeGreaterThanOrEqual(96);
expect(h).toBeLessThanOrEqual(190);
}
for (let i = 1; i < heights.length; i++) {
expect(heights[i]).toBeGreaterThanOrEqual(heights[i - 1]);
}
});
});
describe("layoutMosaicStrip", () => {
it("gives one height and ratio-derived widths", () => {
const strip = layoutMosaicStrip(tiles(VIDEO, SQUARE, POSTER), 140);
expect(strip.map((t) => t.height)).toEqual([140, 140, 140]);
expect(strip[0].width).toBe(Math.round(VIDEO * 140));
expect(strip[1].width).toBe(140);
expect(strip[2].width).toBe(Math.round(POSTER * 140));
});
it("returns nothing for a height it cannot draw", () => {
expect(layoutMosaicStrip(tiles(VIDEO), 0)).toEqual([]);
});
});
+203
View File
@@ -0,0 +1,203 @@
// 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-163 | 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)) };
});
}
+19
View File
@@ -33,6 +33,25 @@ export function resolveFavoritesScope(raw: string | null | undefined): Favorites
return (FAVORITE_SCOPES as readonly string[]).includes(raw) ? (raw as FavoritesScope) : "all";
}
/**
* Narrow a scope the backend supplied (e.g. `Library.favoritesScope`) to one
* this page actually offers as a tab, or `null` if it doesn't.
*
* Unlike `resolveFavoritesScope`, an unrecognised scope is *rejected* rather
* than folded into "all": a caller asking "which category is this?" wants no
* answer, not the cross-category one.
*
* TRACES: UR-075 | DR-164
*/
export function asFavoritesScope(
scope: SearchScope | null | undefined,
): FavoritesScope | null {
if (!scope) return null;
return (FAVORITE_SCOPES as readonly string[]).includes(scope)
? (scope as FavoritesScope)
: null;
}
/** URL for a tab. The default scope is omitted, keeping the base URL clean. */
export function favoritesRouteUrl(scope: FavoritesScope): string {
return scope === "all" ? "/library/favorites" : `/library/favorites?scope=${scope}`;