Focus canvas

An endless WebGL collage you drag, fling and wrap around; click a piece and the camera flies in to focus it while everything else dims.

Show code
focus-canvas.tsx
"use client";

import { useEffect, useId, useRef, type RefObject } from "react";
import { useReducedMotion } from "framer-motion";
import {
  Group,
  Mesh,
  MeshBasicMaterial,
  NoToneMapping,
  OrthographicCamera,
  PlaneGeometry,
  Raycaster,
  Scene,
  SRGBColorSpace,
  Texture,
  Vector2,
  Vector3,
  WebGLRenderer,
} from "three";
import { cn } from "@/lib/utils";
import { PlaceholderText } from "@/components/placeholder";
import { buildScrambledLogicalIndices, shuffle } from "@/lib/laofrenda/scramble";

export type FocusCanvasImage = {
  id: number;
  /** Omit to draw the piece as a flat grey block (a loading-state stand-in). */
  src?: string;
  /** width / height; used when there's no src, or before it loads. */
  ratio?: number;
  /** Shown under the piece while it's focused. */
  title?: string;
};

/** Drive the canvas from outside, e.g. on-screen key commands. */
export type FocusCanvasControls = {
  /** Step focus to the neighbouring piece (entering focus if needed). */
  step: (dx: -1 | 0 | 1, dy: -1 | 0 | 1) => void;
  /** Focus the piece nearest the centre. */
  focusNearest: () => void;
  /** Leave focus and fly back to where you were. */
  exit: () => void;
  /** Move keyboard focus into the canvas so its keys work next. */
  engage: () => void;
};

/** Gap between tiles, in CSS px (the camera is orthographic, so world units are px). */
const GAP = 12;

const IDLE_OPACITY = 0.8;
const HOVER_OPACITY = 1;
const OPACITY_LERP = 0.14;
const FOCUS_BG_OPACITY = 0.2;
const FOCUS_FG_OPACITY = 1;
/** A focused piece fills 60% of the frame on its tighter axis. */
const FOCUS_FILL = 0.6;
const ZOOM_LERP = 0.1;
const DRAG_THRESHOLD_MOUSE = 5;
const DRAG_THRESHOLD_TOUCH = 15;

const CAMERA_Z = 800;
const KEY_PAN_SPEED = 14;
/** Camera follow smoothing for keys, fling and focus. A drag doesn't smooth: it tracks the hand 1:1. */
const CAMERA_LERP = 0.22;
const WHEEL_SENS = 0.6;

/* Fling on release */
const MOMENTUM_FRICTION = 3;
const MOMENTUM_SAMPLE_LERP = 0.4;
const MOMENTUM_MAX_SPEED = 7000;
const MOMENTUM_MIN_SPEED = 18;
const MOMENTUM_STALE_MS = 90;
const MOMENTUM_DT_MIN_MS = 8;
const MOMENTUM_DT_MAX_MS = 64;

/* Center-out entrance, opacity only */
const ENTER_FADE_DUR = 0.4;
const ENTER_STAGGER_SPAN = 0.3;

const TEXTURE_UPLOADS_PER_FRAME = 3;
const NARROW_PX = 768;

type ImageDim = { w: number; h: number };
type LoadedImage = ImageDim & { el: HTMLImageElement | null };

/** Fetch and decode once; the decoded element becomes the texture, so there's no second decode. */
function preloadImages(imgs: FocusCanvasImage[]): Promise<LoadedImage[]> {
  return Promise.all(
    imgs.map(
      (img) =>
        new Promise<LoadedImage>((resolve) => {
          if (!img.src) {
            resolve({ w: Math.round((img.ratio ?? 1) * 1000), h: 1000, el: null });
            return;
          }
          const el = new window.Image();
          el.crossOrigin = "anonymous";
          el.decoding = "async";
          const done = () => resolve({ w: el.naturalWidth || 1, h: el.naturalHeight || 1, el });
          el.onload = () => (typeof el.decode === "function" ? el.decode().then(done, done) : done());
          el.onerror = () => resolve({ w: 1, h: 1, el: null });
          el.src = img.src;
        }),
    ),
  );
}

/** Resolve any CSS color (oklch included) to a hex three.js can read. */
function cssColorToHex(color: string): number {
  const ctx = document.createElement("canvas").getContext("2d");
  if (!ctx) return 0xeeeeee;
  ctx.fillStyle = color;
  ctx.fillRect(0, 0, 1, 1);
  const [r = 238, g = 238, b = 238] = ctx.getImageData(0, 0, 1, 1).data;
  return (r << 16) | (g << 8) | b;
}

/** Phones get smaller tiles so more of the collage fits. */
function tileWidthFor(frameW: number, base: number) {
  if (frameW > NARROW_PX) return base;
  return Math.round(base * (frameW <= 420 ? 0.48 : 0.62));
}

export function FocusCanvas({
  images,
  tileWidth = 200,
  onFocusChange,
  label = "Canvas",
  titlePlaceholder = false,
  startFocused = false,
  controlsRef,
  className,
}: {
  images: FocusCanvasImage[];
  /** Tile width in CSS px on wide frames; narrow frames scale it down. */
  tileWidth?: number;
  /** Fires when a piece enters or leaves focus, so surrounding chrome can step back. */
  onFocusChange?: (focused: boolean) => void;
  /** Accessible name for the canvas region. */
  label?: string;
  /** Show a grey text bar under untitled pieces instead of nothing. */
  titlePlaceholder?: boolean;
  /** Open already focused on the piece nearest the centre. */
  startFocused?: boolean;
  /** Receives step / focusNearest / exit / engage once the scene is built. */
  controlsRef?: RefObject<FocusCanvasControls | null>;
  className?: string;
}) {
  const reduceMotion = useReducedMotion() === true;
  const wrapRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const titleRef = useRef<HTMLDivElement>(null);
  const announceRef = useRef<HTMLParagraphElement>(null);
  const swatchRef = useRef<HTMLSpanElement>(null);
  const onFocusChangeRef = useRef(onFocusChange);
  const hintId = useId();

  useEffect(() => {
    onFocusChangeRef.current = onFocusChange;
  });

  useEffect(() => {
    const wrap = wrapRef.current;
    const surface = canvasRef.current;
    const title = titleRef.current;
    const announce = announceRef.current;
    const swatch = swatchRef.current;
    if (!wrap || !surface || !title || !announce || !swatch || images.length === 0) return;
    // Untextured pieces are a mid grey from the theme (40% muted-foreground
    // into the background), so placeholders read clearly in either theme.
    const blockColor = cssColorToHex(getComputedStyle(swatch).backgroundColor);

    const shuffled = shuffle([...images]);
    let alive = true;
    let teardown: (() => void) | null = null;
    const controls = controlsRef;

    preloadImages(shuffled).then((assets) => {
      if (!alive) return;
      const scene = buildScene({
        wrap,
        surface,
        title,
        announce,
        blockColor,
        titlePlaceholder,
        images: shuffled,
        assets,
        baseTileW: tileWidth,
        reduceMotion,
        startFocused,
        notifyFocus: (focused) => onFocusChangeRef.current?.(focused),
      });
      teardown = scene.teardown;
      if (controls) controls.current = scene.controls;
    });

    return () => {
      alive = false;
      if (controls) controls.current = null;
      teardown?.();
    };
  }, [images, tileWidth, reduceMotion, titlePlaceholder, startFocused, controlsRef]);

  return (
    <div
      ref={wrapRef}
      role="region"
      aria-label={label}
      aria-describedby={hintId}
      tabIndex={0}
      className={cn(
        "relative size-full overflow-hidden outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
        className,
      )}
    >
      <canvas ref={canvasRef} aria-hidden className="block size-full touch-none" />
      <div ref={titleRef} className="group/title pointer-events-none absolute left-1/2 hidden -translate-x-1/2">
        <p data-title-text="" className="text-pretty font-serif text-base font-medium italic leading-5 tracking-[0.1px] text-foreground group-data-[untitled]/title:hidden" />
        <PlaceholderText lines={[0.4]} className="hidden group-data-[untitled]/title:flex" />
      </div>
      <p ref={announceRef} aria-live="polite" className="sr-only" />
      <span ref={swatchRef} aria-hidden className="hidden bg-[color-mix(in_oklab,var(--muted-foreground)_40%,var(--background))]" />
      <p id={hintId} className="sr-only">
        Drag, scroll or use the arrow keys to pan. Click a piece, or press Enter or Shift and an arrow key, to focus it. Escape
        returns to the canvas.
      </p>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/*  Scene — returns its own teardown                                   */
/* ------------------------------------------------------------------ */

function buildScene({
  wrap,
  surface,
  title,
  announce,
  blockColor,
  titlePlaceholder,
  images,
  assets,
  baseTileW,
  reduceMotion,
  startFocused,
  notifyFocus,
}: {
  wrap: HTMLDivElement;
  surface: HTMLCanvasElement;
  title: HTMLDivElement;
  announce: HTMLParagraphElement;
  blockColor: number;
  titlePlaceholder: boolean;
  images: FocusCanvasImage[];
  assets: LoadedImage[];
  baseTileW: number;
  reduceMotion: boolean;
  startFocused: boolean;
  notifyFocus: (focused: boolean) => void;
}): { teardown: () => void; controls: FocusCanvasControls } {
  const n = images.length;
  const dims: ImageDim[] = assets.map((a) => ({ w: a.w, h: a.h }));

  let viewW = Math.max(64, wrap.clientWidth);
  let viewH = Math.max(64, wrap.clientHeight);

  let tileWPx = tileWidthFor(viewW, baseTileW);
  let pitchX = tileWPx + GAP;
  let planeHeights = dims.map((d) => tileWPx * (d.h / d.w));
  let pitchY = Math.max(...planeHeights, tileWPx * 0.5) + GAP;

  const scene = new Scene();
  const camera = new OrthographicCamera(-1, 1, 1, -1, 0.1, 20000);
  camera.position.z = CAMERA_Z;

  // Transparent clear: the frame's own background shows through, in either theme.
  const renderer = new WebGLRenderer({ canvas: surface, alpha: true, antialias: true, powerPreference: "high-performance" });
  renderer.setClearColor(0x000000, 0);
  renderer.outputColorSpace = SRGBColorSpace;
  renderer.toneMapping = NoToneMapping;

  function updateFrustum() {
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, viewW <= NARROW_PX ? 1.5 : 2));
    renderer.setSize(viewW, viewH, false);
    camera.left = -viewW / 2;
    camera.right = viewW / 2;
    camera.top = viewH / 2;
    camera.bottom = -viewH / 2;
    camera.updateProjectionMatrix();
  }

  let cols = 3;
  let rows = 1;
  let tileW = 0;
  let tileH = 0;
  let sharedGeoms: PlaneGeometry[] = planeHeights.map((h) => new PlaneGeometry(tileWPx, h));

  function syncTileDimensions() {
    const next = tileWidthFor(viewW, baseTileW);
    if (next === tileWPx) return;
    tileWPx = next;
    pitchX = tileWPx + GAP;
    planeHeights = dims.map((d) => tileWPx * (d.h / d.w));
    pitchY = Math.max(...planeHeights, tileWPx * 0.5) + GAP;
    for (const g of sharedGeoms) g.dispose();
    sharedGeoms = planeHeights.map((h) => new PlaneGeometry(tileWPx, h));
  }

  /* --- Interaction state --- */

  const target = new Vector3();
  const camPos = new Vector3();
  const pointerNdc = new Vector2(2, 2);
  let pointerInCanvas = false;
  const lastClient = new Vector2();
  const velocity = new Vector2();
  let lastDragMoveT = 0;
  let dragging = false;
  let hovered: Mesh | null = null;
  const keysDown = new Set<string>();
  const dragStartPos = new Vector2();
  let focused = false;
  let focusedLi = -1;
  let targetZoom = 1;
  const preFocusTarget = new Vector3();
  let lastPointerWasTouch = false;
  const panDeltaOut = new Vector2();

  /** Touch and narrow frames pan with the content; mouse and wheel move the viewport. */
  function applyPanDelta(dx: number, dy: number, touch = false): Vector2 {
    const natural = touch || viewW <= NARROW_PX;
    const wx = natural ? -dx : dx;
    const wy = natural ? dy : -dy;
    target.x += wx;
    target.y += wy;
    return panDeltaOut.set(wx, wy);
  }

  let entranceStart = -1;
  let entranceComplete = reduceMotion;

  /* --- Meshes: a 3×3 ring of copies of one tile, recentred under the camera --- */

  const groups9: Group[] = [];
  const allMeshes: Mesh[] = [];
  const meshesByLogical: Mesh[][] = Array.from({ length: n }, () => []);
  const baseMat = new MeshBasicMaterial({ color: blockColor, transparent: true, opacity: IDLE_OPACITY, depthWrite: false });
  const raycaster = new Raycaster();
  const textureCache: (Texture | null)[] = new Array<Texture | null>(n).fill(null);

  // Uploading every texture in one frame spikes the main thread; drain a few per frame.
  const uploadQueue: number[] = [];
  for (let li = 0; li < n; li++) if (assets[li]?.el) uploadQueue.push(li);

  function applyTexture(li: number, tex: Texture) {
    for (const mesh of meshesByLogical[li]!) {
      const m = mesh.material as MeshBasicMaterial;
      m.map = tex;
      m.color.setHex(0xffffff);
      m.needsUpdate = true;
    }
  }

  function drainUploadQueue() {
    let uploaded = 0;
    while (uploadQueue.length > 0 && uploaded < TEXTURE_UPLOADS_PER_FRAME) {
      const li = uploadQueue.shift()!;
      if (textureCache[li]) continue;
      const el = assets[li]?.el;
      if (!el) continue;
      const tex = new Texture(el);
      tex.colorSpace = SRGBColorSpace;
      tex.needsUpdate = true;
      textureCache[li] = tex;
      applyTexture(li, tex);
      uploaded++;
    }
  }

  function clearGroups() {
    hovered = null;
    for (const g of groups9.splice(0)) {
      scene.remove(g);
      while (g.children.length > 0) {
        const ch = g.children[0] as Mesh;
        ch.removeFromParent();
        (ch.material as MeshBasicMaterial).dispose();
      }
    }
    allMeshes.length = 0;
    for (let i = 0; i < n; i++) meshesByLogical[i] = [];
  }

  function buildGroups() {
    clearGroups();
    syncTileDimensions();

    cols = Math.max(3, Math.ceil(viewW / pitchX) + 1);
    rows = Math.max(Math.ceil(n / cols), Math.ceil(viewH / pitchY) + 1);
    tileW = cols * pitchX;
    tileH = rows * pitchY;

    const cellLogical = buildScrambledLogicalIndices(cols, rows, n);
    const halfDiag = Math.hypot(viewW, viewH) / 2 || 1;

    for (let dj = -1; dj <= 1; dj++) {
      for (let dk = -1; dk <= 1; dk++) {
        const g = new Group();
        groups9.push(g);
        scene.add(g);
        for (let ci = 0; ci < cols * rows; ci++) {
          const li = cellLogical[ci]!;
          const x = (ci % cols) * pitchX + tileWPx / 2 - tileW / 2;
          const y = tileH / 2 - (Math.floor(ci / cols) * pitchY + pitchY / 2);
          const mat = baseMat.clone();
          const mesh = new Mesh(sharedGeoms[li]!, mat);
          mesh.position.set(x, y, 0);
          mesh.userData.logicalIndex = li;
          mesh.userData.baseOpacity = IDLE_OPACITY;
          // Distance from the starting camera sets the reveal order: center out.
          mesh.userData.enterDelay = Math.min(1, Math.hypot(x + dk * tileW, y + dj * tileH) / halfDiag) * ENTER_STAGGER_SPAN;
          g.add(mesh);
          allMeshes.push(mesh);
          meshesByLogical[li]!.push(mesh);
        }
      }
    }

    camPos.set(0, 0, 0);
    target.set(0, 0, 0);
    camera.position.set(0, 0, CAMERA_Z);
    updateRepetitionOrigins();
    for (let li = 0; li < n; li++) {
      const tex = textureCache[li];
      if (tex) applyTexture(li, tex);
    }
  }

  function updateRepetitionOrigins() {
    const gx = Math.floor(camPos.x / tileW);
    const gy = Math.floor(camPos.y / tileH);
    let i = 0;
    for (let dj = -1; dj <= 1; dj++) {
      for (let dk = -1; dk <= 1; dk++) groups9[i++]?.position.set((gx + dk) * tileW, (gy + dj) * tileH, 0);
    }
  }

  function setCursor() {
    if (focused) surface.style.cursor = "zoom-out";
    else if (!pointerInCanvas) surface.style.cursor = "";
    else if (dragging) surface.style.cursor = "grabbing";
    else if (hovered) surface.style.cursor = "zoom-in";
    else surface.style.cursor = "grab";
  }

  /* --- Focus: a camera move, not an overlay --- */

  const titleText = title.querySelector<HTMLElement>("[data-title-text]")!;

  function showTitle(li: number) {
    const text = images[li]?.title;
    titleText.textContent = text ?? "";
    title.toggleAttribute("data-untitled", !text);
    title.style.display = text || titlePlaceholder ? "block" : "none";
    announce.textContent = text ?? `Piece ${li + 1} of ${n}`;
  }

  function hideTitle() {
    title.style.display = "none";
    titleText.textContent = "";
    announce.textContent = "";
  }

  function focusOn(mesh: Mesh) {
    focusedLi = mesh.userData.logicalIndex as number;
    const worldPos = new Vector3();
    mesh.getWorldPosition(worldPos);
    target.set(worldPos.x, worldPos.y, 0);
    const ph = planeHeights[focusedLi] ?? tileWPx;
    targetZoom = Math.min((viewW * FOCUS_FILL) / tileWPx, (viewH * FOCUS_FILL) / ph);
    showTitle(focusedLi);
  }

  function enterFocus(mesh: Mesh) {
    if (!focused) preFocusTarget.copy(target);
    focused = true;
    velocity.set(0, 0);
    focusOn(mesh);
    setCursor();
    notifyFocus(true);
  }

  function exitFocus() {
    focused = false;
    focusedLi = -1;
    target.copy(preFocusTarget);
    targetZoom = 1;
    hideTitle();
    setCursor();
    notifyFocus(false);
  }

  /** The tile nearest to a point one pitch away from the current one. */
  function nearestMesh(dirX: number, dirY: number): Mesh | null {
    const nx = target.x + dirX * pitchX;
    const ny = target.y + dirY * pitchY;
    let best: Mesh | null = null;
    let bestDist = Infinity;
    const wp = new Vector3();
    for (const mesh of allMeshes) {
      mesh.getWorldPosition(wp);
      const d = (wp.x - nx) ** 2 + (wp.y - ny) ** 2;
      if (d < bestDist) {
        bestDist = d;
        best = mesh;
      }
    }
    return best;
  }

  /* --- Input --- */

  function onWheel(ev: WheelEvent) {
    // The page keeps its scroll until the canvas is engaged (clicked or tabbed into).
    if (!wrap.contains(document.activeElement)) return;
    ev.preventDefault();
    if (focused) return;
    velocity.set(0, 0);
    let dx = ev.deltaX * WHEEL_SENS;
    let dy = ev.deltaY * WHEEL_SENS;
    if (ev.shiftKey) [dx, dy] = [dy, dx];
    if (ev.deltaMode === 1) {
      dx *= 16;
      dy *= 16;
    } else if (ev.deltaMode === 2) {
      dx *= viewW * 0.5;
      dy *= viewH * 0.5;
    }
    applyPanDelta(dx, dy);
  }

  function updatePointer(ev: PointerEvent) {
    const r = surface.getBoundingClientRect();
    const lx = ev.clientX - r.left;
    const ly = ev.clientY - r.top;
    pointerInCanvas = lx >= 0 && lx <= r.width && ly >= 0 && ly <= r.height;
    pointerNdc.set((lx / r.width) * 2 - 1, -(ly / r.height) * 2 + 1);
  }

  function onPointerDown(ev: PointerEvent) {
    if (ev.button !== 0) return;
    // Engage the canvas (for wheel and keys) without showing the keyboard ring.
    wrap.focus({ preventScroll: true, focusVisible: false } as FocusOptions);
    lastPointerWasTouch = ev.pointerType === "touch";
    dragStartPos.set(ev.clientX, ev.clientY);
    if (focused) return;
    dragging = true;
    hovered = null;
    velocity.set(0, 0);
    lastDragMoveT = performance.now();
    lastClient.set(ev.clientX, ev.clientY);
    setCursor();
    try {
      surface.setPointerCapture(ev.pointerId);
    } catch {
      /* noop */
    }
  }

  function onPointerMove(ev: PointerEvent) {
    updatePointer(ev);
    if (!dragging) {
      lastClient.set(ev.clientX, ev.clientY);
      return;
    }
    // Screen px → world px: the surface may be drawn scaled (a CSS transform on
    // an ancestor), and the content must still stay under the finger.
    const k = viewW / (surface.getBoundingClientRect().width || viewW);
    const world = applyPanDelta((ev.clientX - lastClient.x) * k, (ev.clientY - lastClient.y) * k, lastPointerWasTouch);
    lastClient.set(ev.clientX, ev.clientY);
    const now = performance.now();
    const dtMs = Math.min(MOMENTUM_DT_MAX_MS, Math.max(MOMENTUM_DT_MIN_MS, now - lastDragMoveT));
    lastDragMoveT = now;
    const inv = 1000 / dtMs;
    velocity.x += (world.x * inv - velocity.x) * MOMENTUM_SAMPLE_LERP;
    velocity.y += (world.y * inv - velocity.y) * MOMENTUM_SAMPLE_LERP;
  }

  function onPointerUp(ev: PointerEvent) {
    if (ev.button !== 0) return;
    updatePointer(ev);

    const dragDist = Math.hypot(ev.clientX - dragStartPos.x, ev.clientY - dragStartPos.y);
    const threshold = lastPointerWasTouch ? DRAG_THRESHOLD_TOUCH : DRAG_THRESHOLD_MOUSE;
    const wasClick = focused ? dragDist < threshold : !dragging || dragDist < threshold;

    // Fling only a real drag that was still moving at release.
    const stale = performance.now() - lastDragMoveT > MOMENTUM_STALE_MS;
    if (wasClick || focused || stale) velocity.set(0, 0);
    else if (velocity.length() > MOMENTUM_MAX_SPEED) velocity.setLength(MOMENTUM_MAX_SPEED);

    dragging = false;
    try {
      surface.releasePointerCapture(ev.pointerId);
    } catch {
      /* noop */
    }

    if (focused && wasClick) {
      exitFocus();
      return;
    }
    if (wasClick) {
      raycaster.setFromCamera(pointerNdc, camera);
      const hit = raycaster.intersectObjects(allMeshes, false)[0];
      if (hit && hit.object instanceof Mesh) {
        enterFocus(hit.object);
        return;
      }
    }
    setCursor();
  }

  function onPointerLeave() {
    pointerInCanvas = false;
    hovered = null;
    setCursor();
  }

  const ARROWS: Record<string, [number, number]> = {
    ArrowLeft: [-1, 0],
    ArrowRight: [1, 0],
    ArrowUp: [0, 1],
    ArrowDown: [0, -1],
  };

  function onKeyDown(ev: KeyboardEvent) {
    if (ev.key === "Escape" && focused) {
      ev.preventDefault();
      exitFocus();
      return;
    }
    if (ev.key === "Enter" && !focused) {
      const mesh = nearestMesh(0, 0);
      if (mesh) {
        ev.preventDefault();
        enterFocus(mesh);
      }
      return;
    }
    const dir = ARROWS[ev.key];
    if (!dir) return;
    ev.preventDefault();
    if (ev.shiftKey || focused) {
      // Step to the neighbouring piece; from the canvas, this enters focus.
      const mesh = nearestMesh(...(focused ? dir : ([0, 0] as [number, number])));
      if (mesh) enterFocus(mesh);
      return;
    }
    keysDown.add(ev.key);
  }

  function onKeyUp(ev: KeyboardEvent) {
    keysDown.delete(ev.key);
  }

  function onBlur() {
    keysDown.clear();
  }

  function onResize() {
    const w = Math.max(64, wrap.clientWidth);
    const h = Math.max(64, wrap.clientHeight);
    if (w === viewW && h === viewH) return;
    viewW = w;
    viewH = h;
    velocity.set(0, 0);
    // Like a browser window: a resize relays the grid, and a focused piece
    // stays focused (the one nearest the centre after the relayout).
    const wasFocused = focused;
    focused = false;
    focusedLi = -1;
    targetZoom = 1;
    camera.zoom = 1;
    updateFrustum();
    buildGroups();
    if (wasFocused) focusInstantly();
  }

  /* --- Render loop, paused while the frame is off screen --- */

  let raf = 0;
  let running = false;
  let lastT = performance.now();

  function tick(now: number) {
    const dt = Math.min(0.05, (now - lastT) / 1000 || 0);
    lastT = now;

    drainUploadQueue();

    if (!focused) {
      const keyD = KEY_PAN_SPEED * dt * 60;
      let keyPan = false;
      for (const key of keysDown) {
        const dir = ARROWS[key];
        if (!dir) continue;
        target.x += dir[0] * keyD;
        target.y += dir[1] * keyD;
        keyPan = true;
      }
      if (keyPan) velocity.set(0, 0);
      if (!dragging && (velocity.x !== 0 || velocity.y !== 0)) {
        target.x += velocity.x * dt;
        target.y += velocity.y * dt;
        velocity.multiplyScalar(Math.exp(-MOMENTUM_FRICTION * dt));
        if (velocity.lengthSq() < MOMENTUM_MIN_SPEED ** 2) velocity.set(0, 0);
      }
    }

    // Under reduced motion, focus jumps instead of flying; panning still tracks input.
    const followLerp = dragging || reduceMotion ? 1 : CAMERA_LERP;
    camPos.lerpVectors(camPos, target, 1 - (1 - followLerp) ** (dt * 60));
    camera.position.set(camPos.x, camPos.y, CAMERA_Z);

    const zoomAlpha = reduceMotion ? 1 : 1 - (1 - ZOOM_LERP) ** (dt * 60);
    const zDiff = targetZoom - camera.zoom;
    if (Math.abs(zDiff) > 0.001) camera.zoom += zDiff * zoomAlpha;
    else camera.zoom = targetZoom;
    camera.updateProjectionMatrix();

    updateRepetitionOrigins();

    if (!focused && !dragging && pointerInCanvas) {
      raycaster.setFromCamera(pointerNdc, camera);
      const hit = raycaster.intersectObjects(allMeshes, false)[0];
      const next = hit && hit.object instanceof Mesh ? hit.object : null;
      if (next !== hovered) {
        hovered = next;
        setCursor();
      }
    } else if (!dragging && !pointerInCanvas) {
      hovered = null;
    }

    if (!entranceComplete && entranceStart < 0) entranceStart = now;
    const entranceElapsed = entranceComplete ? Infinity : (now - entranceStart) / 1000;
    let allEntered = true;

    for (const mesh of allMeshes) {
      const mat = mesh.material as MeshBasicMaterial;
      const want = focused
        ? (mesh.userData.logicalIndex as number) === focusedLi
          ? FOCUS_FG_OPACITY
          : FOCUS_BG_OPACITY
        : mesh === hovered
          ? HOVER_OPACITY
          : IDLE_OPACITY;
      // The settled opacity is stored apart from the entrance multiplier, so the
      // entrance never feeds back into the lerp.
      const base = mesh.userData.baseOpacity as number;
      const o = reduceMotion ? want : base + (want - base) * OPACITY_LERP;
      const settled = Math.abs(o - want) < 0.004 ? want : o;
      mesh.userData.baseOpacity = settled;

      let enter = 1;
      if (!entranceComplete) {
        const t = (entranceElapsed - (mesh.userData.enterDelay as number)) / ENTER_FADE_DUR;
        if (t < 1) allEntered = false;
        enter = t <= 0 ? 0 : t >= 1 ? 1 : 1 - (1 - t) ** 3;
      }
      mat.opacity = settled * enter;
    }
    if (!entranceComplete && allEntered) entranceComplete = true;

    if (focused && title.style.display !== "none") {
      const ph = planeHeights[focusedLi] ?? tileWPx;
      title.style.top = `${viewH / 2 + (ph * camera.zoom) / 2 + 12}px`;
      title.style.width = `${tileWPx * camera.zoom}px`;
    }

    renderer.render(scene, camera);
    raf = requestAnimationFrame(tick);
  }

  function start() {
    if (running) return;
    running = true;
    lastT = performance.now();
    raf = requestAnimationFrame(tick);
  }

  function stop() {
    running = false;
    cancelAnimationFrame(raf);
  }

  /** Focus the centre piece with no flight: the camera starts there. */
  function focusInstantly() {
    const mesh = nearestMesh(0, 0);
    if (!mesh) return;
    preFocusTarget.set(0, 0, 0);
    enterFocus(mesh);
    camPos.copy(target);
    camera.zoom = targetZoom;
    camera.updateProjectionMatrix();
  }

  updateFrustum();
  buildGroups();
  if (startFocused) focusInstantly();

  surface.addEventListener("wheel", onWheel, { passive: false });
  surface.addEventListener("pointerdown", onPointerDown);
  surface.addEventListener("pointermove", onPointerMove);
  surface.addEventListener("pointerup", onPointerUp);
  surface.addEventListener("pointerleave", onPointerLeave);
  wrap.addEventListener("keydown", onKeyDown);
  wrap.addEventListener("keyup", onKeyUp);
  wrap.addEventListener("blur", onBlur);
  const resizeObserver = new ResizeObserver(onResize);
  resizeObserver.observe(wrap);
  const visibility = new IntersectionObserver(([entry]) => (entry?.isIntersecting ? start() : stop()));
  visibility.observe(wrap);

  const controls: FocusCanvasControls = {
    step(dx, dy) {
      const mesh = nearestMesh(...(focused ? ([dx, dy] as [number, number]) : ([0, 0] as [number, number])));
      if (mesh) enterFocus(mesh);
    },
    focusNearest() {
      const mesh = nearestMesh(0, 0);
      if (mesh) enterFocus(mesh);
    },
    exit() {
      if (focused) exitFocus();
    },
    engage() {
      wrap.focus({ preventScroll: true, focusVisible: false } as FocusOptions);
    },
  };

  const teardown = () => {
    stop();
    hideTitle();
    notifyFocus(false);

    surface.removeEventListener("wheel", onWheel);
    surface.removeEventListener("pointerdown", onPointerDown);
    surface.removeEventListener("pointermove", onPointerMove);
    surface.removeEventListener("pointerup", onPointerUp);
    surface.removeEventListener("pointerleave", onPointerLeave);
    wrap.removeEventListener("keydown", onKeyDown);
    wrap.removeEventListener("keyup", onKeyUp);
    wrap.removeEventListener("blur", onBlur);
    resizeObserver.disconnect();
    visibility.disconnect();

    clearGroups();
    for (const geom of sharedGeoms) geom.dispose();
    for (const tex of textureCache) tex?.dispose();
    baseMat.dispose();
    renderer.dispose();
    // Switching channels remounts the canvas; free the context now rather than
    // waiting for the browser's limit to evict the oldest one.
    renderer.forceContextLoss();
    scene.clear();
  };

  return { teardown, controls };
}
scramble.ts
/**
 * Tile layout for the focus canvas. The grid repeats on every side, so it is a
 * torus: a cell's right neighbour on the last column is the first column of the
 * next copy. Images are dealt out evenly (a balanced multiset) with no two
 * orthogonal neighbours, wrap included, showing the same image.
 */

/** Fisher-Yates shuffle (in-place, returns same array). Uses crypto when available. */
export function shuffle<T>(arr: T[]): T[] {
  for (let i = arr.length - 1; i > 0; i--) {
    const j = randomIntBelow(i + 1);
    [arr[i], arr[j]] = [arr[j]!, arr[i]!];
  }
  return arr;
}

/** Uniform integer in [0, hiExclusive). Prefer crypto for tile layout variety. */
function randomIntBelow(hiExclusive: number): number {
  if (hiExclusive <= 1) return 0;
  if (typeof crypto !== "undefined" && crypto.getRandomValues) {
    const hi = hiExclusive;
    const max = 0x1_0000_0000;
    const limit = Math.floor(max / hi) * hi;
    const buf = new Uint32Array(1);
    do {
      crypto.getRandomValues(buf);
    } while (buf[0]! >= limit);
    return buf[0]! % hi;
  }
  return Math.floor(Math.random() * hiExclusive);
}

/** Target multiplicity per logical image for an even split across `total` cells. */
function multisetRemaining(n: number, total: number): number[] {
  const base = Math.floor(total / n);
  const remainder = total % n;
  const rem = new Array<number>(n);
  for (let li = 0; li < n; li++) {
    rem[li] = base + (li < remainder ? 1 : 0);
  }
  return rem;
}

/** Orthogonal neighbors on a repeating tile (torus): edges wrap so seams never touch duplicates. */
function orthoNeighborsTorus(ci: number, cols: number, rows: number): number[] {
  const col = ci % cols;
  const row = Math.floor(ci / cols);
  return [
    row * cols + ((col + 1) % cols),
    row * cols + ((col - 1 + cols) % cols),
    ((row + 1) % rows) * cols + col,
    ((row - 1 + rows) % rows) * cols + col,
  ];
}

/**
 * Assign images to grid cells: multiset-balanced shuffle subject to no two
 * orthogonally adjacent cells (including across tile wrap) sharing the same image.
 */
export function buildScrambledLogicalIndices(
  cols: number,
  rows: number,
  n: number,
): number[] {
  const total = cols * rows;
  if (n <= 0 || total <= 0) return [];
  if (n === 1) return new Array<number>(total).fill(0);

  if (n === 2) {
    let c0 = 0;
    for (let r = 0; r < rows; r++) {
      for (let c = 0; c < cols; c++) {
        if ((c + r) % 2 === 0) c0++;
      }
    }
    const rem = multisetRemaining(2, total);
    const a = rem[0]!;
    const b = rem[1]!;
    if ((a === c0 && b === total - c0) || (a === total - c0 && b === c0)) {
      const grid: number[] = new Array(total);
      const vEven = a === c0 ? 0 : 1;
      const vOdd = 1 - vEven;
      let i = 0;
      for (let r = 0; r < rows; r++) {
        for (let c = 0; c < cols; c++) {
          grid[i++] = (c + r) % 2 === 0 ? vEven : vOdd;
        }
      }
      return grid;
    }
  }

  const GREEDY_ATTEMPTS = 400;
  for (let attempt = 0; attempt < GREEDY_ATTEMPTS; attempt++) {
    const remaining = multisetRemaining(n, total);
    const grid = new Array<number>(total).fill(-1);
    const order = shuffle(Array.from({ length: total }, (_, i) => i));
    let ok = true;
    for (const ci of order) {
      const banned = new Set<number>();
      for (const nb of orthoNeighborsTorus(ci, cols, rows)) {
        const v = grid[nb];
        if (v >= 0) banned.add(v);
      }
      const cands: number[] = [];
      for (let li = 0; li < n; li++) {
        if (remaining[li]! > 0 && !banned.has(li)) cands.push(li);
      }
      if (cands.length === 0) {
        ok = false;
        break;
      }
      const pick = cands[randomIntBelow(cands.length)]!;
      grid[ci] = pick;
      remaining[pick]!--;
    }
    if (ok) return grid;
  }

  const bt = backtrackNonAdjacentTile(cols, rows, n, total);
  if (bt) return bt;

  const pool: number[] = [];
  const rem0 = multisetRemaining(n, total);
  for (let li = 0; li < n; li++) {
    for (let k = 0; k < rem0[li]!; k++) pool.push(li);
  }
  shuffle(pool);
  return pool;
}

const BACKTRACK_NODE_LIMIT = 2_500_000;

function backtrackNonAdjacentTile(
  cols: number,
  rows: number,
  n: number,
  total: number,
): number[] | null {
  const remaining = multisetRemaining(n, total);
  const grid = new Array<number>(total).fill(-1);
  let nodes = 0;

  function dfs(ci: number): boolean {
    if (nodes++ > BACKTRACK_NODE_LIMIT) return false;
    if (ci === total) return true;

    const banned = new Set<number>();
    for (const nb of orthoNeighborsTorus(ci, cols, rows)) {
      const v = grid[nb];
      if (v >= 0) banned.add(v);
    }

    const cands: number[] = [];
    for (let li = 0; li < n; li++) {
      if (remaining[li]! > 0 && !banned.has(li)) cands.push(li);
    }
    shuffle(cands);

    for (const li of cands) {
      grid[ci] = li;
      remaining[li]!--;
      if (dfs(ci + 1)) return true;
      remaining[li]!++;
      grid[ci] = -1;
    }
    return false;
  }

  if (dfs(0)) return grid;
  return null;
}