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.
Loading preview…
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,
};
}