Files
jellytau/src/lib/components/library/MosaicGrid.svelte
T
dtourolle ad48d89dfe chore(format): run prettier over src/ and scripts/
Formatting was configured but never enforced: `bun run format:check`
reported 199 unformatted files and ran in no workflow and in no git hook,
so .prettierrc (printWidth 100, trailing commas) described an intention
rather than the tree.

This is the one-time sweep that makes the check gateable. Whitespace and
token-reflow only -- no behavioural change: `bun run check` reports 0
errors and all 1053 frontend tests pass before and after.

Kept out of every other commit on purpose. A 199-file diff mixed with
real changes is unreviewable, and the next commit turns format:check
into a hard CI gate so this cannot silently accumulate again.
2026-08-21 17:41:44 +02:00

95 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}