Dome gallery

An infinite masonry wall that wraps on both axes, seen through a Gaussian dome: drag with inertia, wheel or arrow keys, motion blur at speed.

Show code
dome-gallery.tsx
"use client";

import {
  memo,
  useCallback,
  useEffect,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
  useSyncExternalStore,
  type ReactNode,
} from "react";
import { cn } from "@/lib/utils";
import { cubicBezier } from "@/lib/daniel-derro/motion";
import {
  dampingAlpha,
  SMOOTH_SCROLL_LERP,
  wheelDeltaPx,
} from "@/lib/daniel-derro/smooth-scroll";
import {
  layoutGalleryBarrel,
  poolCopies,
  projectDomeTile,
  wrapCoord,
  type BarrelLayoutItem,
  type BarrelTile,
  type BarrelWorld,
} from "./gallery-barrel";

const HOVER_HOLD_MS = 450;
const DRAG_CLICK_PX = 6;
/** Extra px around the viewport before a pooled tile is hidden. */
const CULL_MARGIN = 320;
/** px/frame below this stays sharp; above it maps into the blur range. */
const MOTION_BLUR_DEADZONE = 8;
const MOTION_BLUR_GAIN = 0.12;
const MOTION_BLUR_MAX = 6;
const KEY_STEP = 72;

function motionBlurPx(speed: number): number {
  if (speed < MOTION_BLUR_DEADZONE) return 0;
  return Math.min(MOTION_BLUR_MAX, (speed - MOTION_BLUR_DEADZONE) * MOTION_BLUR_GAIN);
}

function motionBlurFilter(blurPx: number): string {
  return blurPx < 0.35 ? "none" : `blur(${blurPx.toFixed(1)}px)`;
}

const REDUCED_MOTION = "(prefers-reduced-motion: reduce)";

function subscribeReducedMotion(onChange: () => void) {
  const mq = window.matchMedia(REDUCED_MOTION);
  mq.addEventListener("change", onChange);
  return () => mq.removeEventListener("change", onChange);
}

function useReducedMotion(): boolean {
  return useSyncExternalStore(
    subscribeReducedMotion,
    () => window.matchMedia(REDUCED_MOTION).matches,
    () => false,
  );
}

export type DomeGalleryTile = BarrelTile & {
  /** Accessible name of the tile, and the default hover caption. */
  label: string;
};

type TileNode = {
  el: HTMLDivElement;
  media: HTMLElement | null;
  item: BarrelLayoutItem<DomeGalleryTile>;
  cj: number;
  ck: number;
};

export type DomeGalleryProps<T extends DomeGalleryTile> = {
  tiles: T[];
  /** Tile contents. Fills a box of the tile's aspect ratio. */
  renderTile: (tile: T) => ReactNode;
  /** Caption shown at the stage center after the pointer rests on a tile. */
  renderMeta?: (tile: T) => ReactNode;
  /** Makes tiles buttons. Ignored when the press ended a drag. */
  onSelect?: (tile: T) => void;
  columns?: number;
  /** Column width in px; heights follow each tile's aspect. */
  tileWidth?: number;
  gap?: number;
  /** CSS perspective in px. Lower is a stronger fisheye. */
  perspective?: number;
  /** Crest height in px: translateZ at the stage center. */
  bulge?: number;
  /** Dome width as a fraction of the smaller stage dimension. */
  spread?: number;
  /** Rotation gain. 1 keeps tiles tangent to the dome surface. */
  curve?: number;
  /** Corner radius at the crest, in px. */
  round?: number;
  /** Per-frame velocity retention after a throw (0.82–0.985). */
  inertia?: number;
  /** Render a flat plane. Always flat under prefers-reduced-motion. */
  flat?: boolean;
  /**
   * "always" pans on any wheel over the stage (a full-page canvas).
   * "focus" pans by wheel only once the stage has focus, and lets vertical
   * touch swipes scroll the page until then, so an embedded canvas never
   * traps page scroll.
   */
  wheel?: "always" | "focus";
  className?: string;
  "aria-label"?: string;
};

/**
 * Fixed pool of tile nodes: every tile is mounted once per toroidal copy and
 * never unmounts while panning. The paint loop only moves existing nodes, so
 * media never remounts or re-decodes.
 */
const TilePool = memo(function TilePool({
  world,
  copiesX,
  copiesY,
  bind,
  renderTile,
  onHover,
  onLeave,
  onSelect,
  isDragClick,
}: {
  world: BarrelWorld<DomeGalleryTile>;
  copiesX: number;
  copiesY: number;
  bind: (
    key: string,
    item: BarrelLayoutItem<DomeGalleryTile>,
    cj: number,
    ck: number,
  ) => (el: HTMLDivElement | null) => void;
  renderTile: (tile: DomeGalleryTile) => ReactNode;
  onHover: (tileKey: string, pointerType: string) => void;
  onLeave: () => void;
  onSelect?: (tile: DomeGalleryTile) => void;
  isDragClick: () => boolean;
}) {
  const tiles: ReactNode[] = [];
  for (const item of world.items) {
    const content = renderTile(item.tile);
    for (let cj = 0; cj < copiesX; cj += 1) {
      for (let ck = 0; ck < copiesY; ck += 1) {
        const key = `${item.key}@${cj}:${ck}`;
        const primary = cj === 0 && ck === 0;
        const media = (
          <span
            data-dome-media=""
            className={cn(
              "relative block size-full overflow-hidden rounded-[inherit]",
              "transition-opacity duration-[900ms] motion-reduce:transition-none",
              "group-data-[dimmed=true]/tile:opacity-[0.18]",
            )}
            style={{ transitionTimingFunction: cubicBezier("heavy") }}
          >
            {content}
          </span>
        );
        const shared = {
          className:
            "block size-full rounded-[inherit] text-inherit outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
          onPointerEnter: (event: React.PointerEvent) => onHover(item.tile.key, event.pointerType),
          onPointerLeave: onLeave,
        };
        tiles.push(
          <div
            key={key}
            ref={bind(key, item, cj, ck)}
            className="group/tile absolute left-0 top-0 origin-center"
            style={{ width: item.w, height: item.h, visibility: "hidden" }}
          >
            {onSelect ? (
              <button
                type="button"
                {...shared}
                aria-label={item.tile.label}
                tabIndex={primary ? undefined : -1}
                aria-hidden={primary ? undefined : true}
                draggable={false}
                onClick={() => {
                  if (!isDragClick()) onSelect(item.tile);
                }}
              >
                {media}
              </button>
            ) : (
              <div
                {...shared}
                role={primary ? "img" : undefined}
                aria-label={primary ? item.tile.label : undefined}
                aria-hidden={primary ? undefined : true}
              >
                {media}
              </div>
            )}
          </div>,
        );
      }
    }
  }
  return <>{tiles}</>;
});

/**
 * An infinite, toroidally wrapped masonry wall seen through a Gaussian dome:
 * tiles near the center bulge toward the camera and roll onto the curve.
 * Drag (with inertia), wheel, or arrow keys pan; fast pans motion-blur.
 */
export function DomeGallery<T extends DomeGalleryTile>({
  tiles,
  renderTile,
  renderMeta,
  onSelect,
  columns = 8,
  tileWidth = 180,
  gap = 40,
  perspective = 2500,
  bulge = 690,
  spread = 1,
  curve = 0.23,
  round = 0,
  inertia = 0.9,
  flat = false,
  wheel = "always",
  className,
  "aria-label": ariaLabel = "Infinite gallery canvas. Drag, scroll, or use arrow keys to pan.",
}: DomeGalleryProps<T>) {
  const reduceMotion = useReducedMotion();
  const flatten = flat || reduceMotion;

  const world = useMemo(
    () => layoutGalleryBarrel<DomeGalleryTile>(tiles, columns, tileWidth, gap),
    [tiles, columns, tileWidth, gap],
  );
  const [copies, setCopies] = useState({ x: 1, y: 1 });

  const stageRef = useRef<HTMLDivElement>(null);
  const nodes = useRef(new Map<string, TileNode>());
  const pan = useRef({ x: 0, y: 0, vx: 0, vy: 0 });
  /** Wheel/keys steer this; the pan glides toward it each frame. */
  const wheelTarget = useRef({ x: 0, y: 0, active: false });
  const lastTick = useRef(0);
  const lastPan = useRef({ x: 0, y: 0 });
  const speedRef = useRef(0);
  const dragging = useRef(false);
  const dragMoved = useRef(false);
  const pointer = useRef({ id: -1, x: 0, y: 0 });
  /** Screen px per layout px, so a scaled-down stage still follows the hand 1:1. */
  const screenScale = useRef(1);
  const raf = useRef(0);
  const view = useRef({ w: 0, h: 0 });
  const optics = useMemo(() => ({ bulge, spread, curve, round }), [bulge, spread, curve, round]);
  const opticsRef = useRef(optics);
  const inertiaRef = useRef(inertia);
  const worldRef = useRef(world);
  const flattenRef = useRef(flatten);
  const wheelModeRef = useRef(wheel);
  useLayoutEffect(() => {
    opticsRef.current = optics;
    inertiaRef.current = inertia;
    worldRef.current = world;
    flattenRef.current = flatten;
    wheelModeRef.current = wheel;
  });

  // In "focus" mode the stage only owns wheel and vertical touch once engaged.
  const [engaged, setEngaged] = useState(false);
  const [hoveredKey, setHoveredKey] = useState<string | null>(null);
  const hoverTimer = useRef<number | null>(null);
  const hoveredTile = tiles.find((tile) => tile.key === hoveredKey) ?? null;
  // Keep the last caption mounted while it fades out.
  const [lastHovered, setLastHovered] = useState<T | null>(null);
  if (hoveredTile && hoveredTile !== lastHovered) setLastHovered(hoveredTile);
  const meta = hoveredTile ?? lastHovered;

  const clearHoverTimer = useCallback(() => {
    if (hoverTimer.current !== null) {
      window.clearTimeout(hoverTimer.current);
      hoverTimer.current = null;
    }
  }, []);

  const requestHover = useCallback(
    (key: string | null) => {
      if (dragging.current) return;
      clearHoverTimer();
      if (!key) {
        setHoveredKey(null);
        return;
      }
      hoverTimer.current = window.setTimeout(() => setHoveredKey(key), HOVER_HOLD_MS);
    },
    [clearHoverTimer],
  );

  // Touch has no hover: a tap would otherwise dim the wall under the finger.
  const onTileHover = useCallback(
    (tileKey: string, pointerType: string) => {
      if (pointerType !== "touch") requestHover(tileKey);
    },
    [requestHover],
  );
  const onTileLeave = useCallback(() => requestHover(null), [requestHover]);
  const isDragClick = useCallback(() => dragMoved.current, []);

  useEffect(() => clearHoverTimer, [clearHoverTimer]);

  // Hover dim writes straight to the pooled nodes: no React pass over tiles.
  useEffect(() => {
    nodes.current.forEach(({ el, item }) => {
      if (hoveredKey !== null && item.tile.key !== hoveredKey) el.dataset.dimmed = "true";
      else delete el.dataset.dimmed;
    });
  }, [hoveredKey]);

  const bindTile = useCallback(
    (key: string, item: BarrelLayoutItem<DomeGalleryTile>, cj: number, ck: number) =>
      (el: HTMLDivElement | null) => {
        if (el) {
          nodes.current.set(key, {
            el,
            media: el.querySelector("[data-dome-media]"),
            item,
            cj,
            ck,
          });
        } else {
          nodes.current.delete(key);
        }
      },
    [],
  );

  const measure = useCallback(() => {
    const node = stageRef.current;
    if (!node) return;
    view.current.w = node.clientWidth;
    view.current.h = node.clientHeight;
  }, []);

  const syncCopies = useCallback(() => {
    const next = poolCopies(worldRef.current, view.current.w, view.current.h);
    setCopies((prev) => (prev.x === next.x && prev.y === next.y ? prev : next));
  }, []);

  /** Position every pooled node for the current pan. Pure DOM writes. */
  const paint = useCallback(() => {
    const { w: vw, h: vh } = view.current;
    const flat = flattenRef.current;
    const opticsNow = opticsRef.current;
    const W = worldRef.current.width;
    const panX = pan.current.x;
    const panY = pan.current.y;
    const blurPx = flat ? 0 : motionBlurPx(speedRef.current);
    const filter = motionBlurFilter(blurPx);

    nodes.current.forEach(({ el, media, item, cj, ck }) => {
      const P = item.periodY;
      const sx0 = W > 0 ? wrapCoord(item.x - panX, W) : item.x - panX;
      const sy0 = P > 0 ? wrapCoord(item.y - panY, P) : item.y - panY;
      const sx = sx0 + (cj - 1) * W;
      const sy = sy0 + (ck - 1) * P;

      if (
        sx + item.w < -CULL_MARGIN ||
        sx > vw + CULL_MARGIN ||
        sy + item.h < -CULL_MARGIN ||
        sy > vh + CULL_MARGIN
      ) {
        if (el.style.visibility !== "hidden") {
          el.style.visibility = "hidden";
          if (media && media.style.filter !== "none") media.style.filter = "none";
        }
        return;
      }

      const t = projectDomeTile(sx, sy, item.w, item.h, vw, vh, opticsNow, flat);
      el.style.visibility = "visible";
      el.style.transform = `translate3d(${t.x}px, ${t.y}px, ${t.z}px) rotateX(${t.rotateX}deg) rotateY(${t.rotateY}deg)`;
      el.style.borderRadius = `${t.radius}px`;
      if (media && media.style.filter !== filter) media.style.filter = filter;
    });
  }, []);

  const wrapPan = useCallback(() => {
    // X wraps at the shared period; Y wraps per column inside paint, so panY
    // stays unbounded (pixel floats are exact far beyond any session's travel).
    const W = worldRef.current.width;
    if (W > 0) {
      const wrapped = wrapCoord(pan.current.x, W);
      // Shift the wheel target by the same period so the glide is unaffected.
      wheelTarget.current.x += wrapped - pan.current.x;
      pan.current.x = wrapped;
    }
  }, []);

  /** Steer from wheel / keys: damped glide, or direct under reduced motion. */
  const nudge = useCallback((dx: number, dy: number) => {
    pan.current.vx = 0;
    pan.current.vy = 0;
    if (flattenRef.current) {
      wheelTarget.current.active = false;
      pan.current.x += dx;
      pan.current.y += dy;
      return;
    }
    const target = wheelTarget.current;
    if (!target.active) {
      target.x = pan.current.x;
      target.y = pan.current.y;
      target.active = true;
    }
    target.x += dx;
    target.y += dy;
  }, []);

  const tickRef = useRef<(now: number) => void>(() => {});
  const tick = useCallback(
    (now: number) => {
      raf.current = 0;
      const dt = Math.min((now - lastTick.current) / 1000, 0.1);
      lastTick.current = now;

      if (!dragging.current) {
        const target = wheelTarget.current;
        if (target.active) {
          const alpha = dampingAlpha(SMOOTH_SCROLL_LERP, dt);
          const remainingX = target.x - pan.current.x;
          const remainingY = target.y - pan.current.y;
          if (Math.hypot(remainingX, remainingY) < 0.3) {
            pan.current.x = target.x;
            pan.current.y = target.y;
            target.active = false;
          } else {
            pan.current.x += remainingX * alpha;
            pan.current.y += remainingY * alpha;
          }
        } else {
          pan.current.x += pan.current.vx;
          pan.current.y += pan.current.vy;
          const damp = inertiaRef.current;
          pan.current.vx *= damp;
          pan.current.vy *= damp;
          if (Math.abs(pan.current.vx) < 0.04) pan.current.vx = 0;
          if (Math.abs(pan.current.vy) < 0.04) pan.current.vy = 0;
        }
      }

      // Measure before wrap: wrapping X by a world period would look like a
      // huge jump and spike the blur.
      const instant = Math.hypot(
        pan.current.x - lastPan.current.x,
        pan.current.y - lastPan.current.y,
      );
      wrapPan();
      lastPan.current.x = pan.current.x;
      lastPan.current.y = pan.current.y;

      speedRef.current = flattenRef.current ? 0 : speedRef.current * 0.72 + instant * 0.28;
      if (speedRef.current < 0.15) speedRef.current = 0;

      paint();

      const coasting =
        !dragging.current &&
        (pan.current.vx !== 0 || pan.current.vy !== 0 || wheelTarget.current.active);
      if (dragging.current || coasting || speedRef.current > 0) {
        raf.current = window.requestAnimationFrame(tickRef.current);
      }
    },
    [paint, wrapPan],
  );
  useLayoutEffect(() => {
    tickRef.current = tick;
  }, [tick]);

  const startLoop = useCallback(() => {
    if (raf.current) return;
    lastTick.current = performance.now();
    raf.current = window.requestAnimationFrame(tickRef.current);
  }, []);

  // Re-measure, size the pool, and repaint whenever layout inputs change.
  // Reveal only after the copy pool has settled, with the dome already
  // written: the veil then fades over a live 3D scene.
  useLayoutEffect(() => {
    measure();
    const next = poolCopies(worldRef.current, view.current.w, view.current.h);
    if (copies.x !== next.x || copies.y !== next.y) {
      setCopies(next);
      paint();
      return;
    }
    paint();
    const stage = stageRef.current;
    if (stage && view.current.w > 0 && view.current.h > 0) {
      stage.dataset.ready = "true";
    }
  }, [measure, paint, world, copies, optics, flatten]);

  useEffect(() => {
    const node = stageRef.current;
    if (!node) return;
    const observer = new ResizeObserver(() => {
      measure();
      syncCopies();
      paint();
    });
    observer.observe(node);

    const onWheel = (event: WheelEvent) => {
      if (event.ctrlKey) return;
      if (wheelModeRef.current === "focus" && !node.contains(document.activeElement)) return;
      event.preventDefault();
      const { dx, dy } = wheelDeltaPx(event);
      nudge(dx, dy);
      startLoop();
    };
    node.addEventListener("wheel", onWheel, { passive: false });
    return () => {
      observer.disconnect();
      node.removeEventListener("wheel", onWheel);
    };
  }, [measure, syncCopies, paint, startLoop, nudge]);

  useEffect(
    () => () => {
      if (raf.current) window.cancelAnimationFrame(raf.current);
    },
    [],
  );

  const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
    if (event.button !== 0) return;
    dragMoved.current = false;
    dragging.current = false;
    pointer.current = { id: event.pointerId, x: event.clientX, y: event.clientY };
    const box = event.currentTarget;
    screenScale.current = box.clientWidth > 0 ? box.getBoundingClientRect().width / box.clientWidth : 1;
    pan.current.vx = 0;
    pan.current.vy = 0;
    // The hand takes over from any wheel glide still in flight.
    wheelTarget.current.active = false;
  };

  const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
    if (event.pointerId !== pointer.current.id) return;
    const scale = screenScale.current || 1;
    const dx = (event.clientX - pointer.current.x) / scale;
    const dy = (event.clientY - pointer.current.y) / scale;
    if (!dragging.current) {
      if (Math.hypot(dx, dy) * scale <= DRAG_CLICK_PX) return;
      dragging.current = true;
      dragMoved.current = true;
      event.currentTarget.setPointerCapture(event.pointerId);
      event.currentTarget.dataset.dragging = "true";
      clearHoverTimer();
      setHoveredKey(null);
      startLoop();
    }
    pan.current.x -= dx;
    pan.current.y -= dy;
    pan.current.vx = -dx;
    pan.current.vy = -dy;
    pointer.current.x = event.clientX;
    pointer.current.y = event.clientY;
  };

  const endDrag = (event: React.PointerEvent<HTMLDivElement>) => {
    if (event.pointerId !== pointer.current.id) return;
    const wasDragging = dragging.current;
    dragging.current = false;
    pointer.current.id = -1;
    event.currentTarget.dataset.dragging = "false";
    if (wasDragging && event.currentTarget.hasPointerCapture(event.pointerId)) {
      event.currentTarget.releasePointerCapture(event.pointerId);
    }
    if (flattenRef.current) {
      pan.current.vx = 0;
      pan.current.vy = 0;
    }
    if (pan.current.vx !== 0 || pan.current.vy !== 0 || speedRef.current > 0) {
      startLoop();
    } else {
      wrapPan();
      lastPan.current.x = pan.current.x;
      lastPan.current.y = pan.current.y;
      paint();
    }
  };

  const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    if (event.key === "ArrowLeft") nudge(-KEY_STEP, 0);
    else if (event.key === "ArrowRight") nudge(KEY_STEP, 0);
    else if (event.key === "ArrowUp") nudge(0, -KEY_STEP);
    else if (event.key === "ArrowDown") nudge(0, KEY_STEP);
    else return;
    event.preventDefault();
    startLoop();
  };

  return (
    <div className={cn("relative size-full overflow-hidden bg-background", className)}>
      <div
        ref={stageRef}
        className={cn(
          "group/stage absolute inset-0 cursor-grab select-none overflow-hidden outline-none",
          "data-[dragging=true]:cursor-grabbing",
          "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
        )}
        style={{
          perspective: flatten ? undefined : `${perspective}px`,
          perspectiveOrigin: "50% 50%",
          // Until engaged, vertical swipes scroll the page past an embedded
          // canvas; horizontal drags still pan it.
          touchAction: wheel === "focus" && !engaged ? "pan-y pinch-zoom" : "none",
        }}
        tabIndex={0}
        role="group"
        aria-roledescription="canvas"
        aria-label={ariaLabel}
        data-dragging="false"
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onPointerUp={endDrag}
        onPointerCancel={endDrag}
        onKeyDown={onKeyDown}
        onFocus={() => setEngaged(true)}
        onBlur={(event) => {
          if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setEngaged(false);
        }}
        onMouseLeave={onTileLeave}
      >
        <div className="absolute inset-0" style={{ transformStyle: "preserve-3d" }}>
          <TilePool
            world={world}
            copiesX={copies.x}
            copiesY={copies.y}
            bind={bindTile}
            renderTile={renderTile as (tile: DomeGalleryTile) => ReactNode}
            onHover={onTileHover}
            onLeave={onTileLeave}
            onSelect={onSelect as ((tile: DomeGalleryTile) => void) | undefined}
            isDragClick={isDragClick}
          />
        </div>
        {/* Fade a veil, not the 3D scene: opacity on a preserve-3d ancestor
            flattens the dome until the fade finishes. */}
        <div
          aria-hidden
          className={cn(
            "absolute inset-0 z-[5] bg-background opacity-100",
            "transition-opacity duration-[800ms] motion-reduce:transition-none",
            "group-data-[ready=true]/stage:pointer-events-none group-data-[ready=true]/stage:opacity-0",
          )}
          style={{ transitionTimingFunction: cubicBezier("heavy") }}
        />
      </div>

      <div
        aria-hidden={!hoveredTile}
        className="pointer-events-none absolute inset-x-4 top-1/2 z-10 -translate-y-1/2 text-center text-sm text-foreground transition-opacity motion-reduce:transition-none"
        style={{
          opacity: hoveredTile ? 1 : 0,
          transitionDuration: "0.9s",
          transitionTimingFunction: cubicBezier("heavy"),
        }}
      >
        {meta ? (renderMeta ? renderMeta(meta) : meta.label) : null}
      </div>
    </div>
  );
}
gallery-barrel.ts
/** Anything with a stable key and an intrinsic aspect ratio (width / height). */
export type BarrelTile = {
  key: string;
  aspect: number;
};

export type BarrelLayoutItem<T extends BarrelTile = BarrelTile> = {
  key: string;
  tile: T;
  x: number;
  y: number;
  w: number;
  h: number;
  /** Vertical wrap period of this item's column (content + one gap). */
  periodY: number;
};

export type BarrelWorld<T extends BarrelTile = BarrelTile> = {
  items: BarrelLayoutItem<T>[];
  /** Horizontal wrap period — includes the seam gap after the last column. */
  width: number;
  /** Smallest column period; sizes the vertical copy pool. */
  minPeriodY: number;
};

export type DomeOptics = {
  /** Crest height in px — translateZ at the viewport center. */
  bulge: number;
  /** Dome width as a fraction of the smaller viewport dimension. */
  spread: number;
  /** Rotation gain — 1 keeps tiles tangent to the dome surface. */
  curve: number;
  /** Corner radius at the crest, in px. */
  round: number;
};

export type ProjectedTile = {
  x: number;
  y: number;
  z: number;
  rotateX: number;
  rotateY: number;
  radius: number;
};

export function wrapCoord(n: number, size: number): number {
  if (size <= 0) return 0;
  return ((n % size) + size) % size;
}

/**
 * Masonry pack built for seamless wrapping: the horizontal period is
 * `cols * (width + gap)` so the seam between copies is exactly one gap, and
 * each column wraps at its own period (`stacked height + one gap`) so the
 * vertical seam is also exactly one gap in every column. Per-column periods
 * differ slightly, which drifts the columns against each other and keeps the
 * repetition from reading as a block.
 */
export function layoutGalleryBarrel<T extends BarrelTile>(
  tiles: T[],
  columns: number,
  tileWidth: number,
  gap: number,
): BarrelWorld<T> {
  const cols = Math.max(1, Math.floor(columns));
  const width = Math.max(8, tileWidth);
  const gutter = Math.max(0, gap);
  const colH = Array.from({ length: cols }, () => 0);
  const colOf: number[] = [];
  const items: BarrelLayoutItem<T>[] = [];

  for (const tile of tiles) {
    const aspect = tile.aspect > 0 ? tile.aspect : 1;
    const col = colH.indexOf(Math.min(...colH));
    const h = width / aspect;
    colOf.push(col);
    items.push({
      key: tile.key,
      tile,
      x: col * (width + gutter),
      y: colH[col],
      w: width,
      h,
      periodY: 1,
    });
    // Trailing gutter doubles as the wrap-seam gap, making colH the period.
    colH[col] += h + gutter;
  }

  let minPeriodY = Infinity;
  items.forEach((item, i) => {
    item.periodY = Math.max(1, colH[colOf[i]]);
    if (item.periodY < minPeriodY) minPeriodY = item.periodY;
  });

  return {
    items,
    width: cols * (width + gutter),
    minPeriodY: Number.isFinite(minPeriodY) ? minPeriodY : 1,
  };
}

/**
 * How many toroidal copies are needed to cover the viewport in each axis.
 * Copy `c` renders at wrapped position + (c - 1) * period, so with sx0 in
 * [0, P) the copies span [-P, P * (copies - 1)). Vertical count is sized for
 * the shortest column period; longer columns just cull their spare copies.
 */
export function poolCopies(
  world: BarrelWorld,
  viewW: number,
  viewH: number,
): { x: number; y: number } {
  return {
    x: world.width > 0 ? Math.ceil(Math.max(1, viewW) / world.width) + 1 : 1,
    y:
      world.minPeriodY > 0
        ? Math.ceil(Math.max(1, viewH) / world.minPeriodY) + 1
        : 1,
  };
}

const DEG = 180 / Math.PI;
const MAX_TILT = 58;

function clampTilt(deg: number): number {
  return Math.max(-MAX_TILT, Math.min(MAX_TILT, deg));
}

/**
 * Place a tile tangent on a Gaussian dome bulging toward the camera at the
 * viewport center. z is the dome height at the tile's midpoint and the
 * rotations follow the analytic surface gradient, so tiles genuinely roll
 * onto the curved surface as they approach the crest (and the perspective
 * projection magnifies them there). Corner radius rises with dome height.
 */
export function projectDomeTile(
  sx: number,
  sy: number,
  tileW: number,
  tileH: number,
  viewW: number,
  viewH: number,
  optics: DomeOptics,
  flatten: boolean,
): ProjectedTile {
  if (flatten) {
    return { x: sx, y: sy, z: 0, rotateX: 0, rotateY: 0, radius: 0 };
  }

  const u = sx + tileW / 2 - viewW / 2;
  const v = sy + tileH / 2 - viewH / 2;
  const sigma = Math.max(1, Math.min(viewW, viewH) * optics.spread);
  const s2 = sigma * sigma;
  const g = Math.exp(-(u * u + v * v) / (2 * s2));
  // d(bulge * g)/du = -(bulge / s2) * u * g — the tangent slope of the dome.
  const slope = (optics.bulge / s2) * g;

  return {
    x: sx,
    y: sy,
    z: optics.bulge * g,
    // Tile below center: bottom edge falls away -> negative rotateX.
    rotateX: clampTilt(-Math.atan(slope * v) * DEG * optics.curve),
    // Tile right of center: right edge falls away -> positive rotateY.
    rotateY: clampTilt(Math.atan(slope * u) * DEG * optics.curve),
    radius: optics.round * g,
  };
}