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