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