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

98 lines
3.4 KiB
Svelte

<!--
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-174
-->
<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}