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.
95 lines
3.4 KiB
Svelte
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}
|