Add album

This commit is contained in:
2026-06-22 22:28:16 +01:00
parent baca180ba7
commit 29336cc45e
14 changed files with 1471 additions and 0 deletions
+163
View File
@@ -0,0 +1,163 @@
import type { PieceWithIssuer } from '@/db/types';
// The pagination engine. Pure: no React, no three — everything the binder renders derives from the
// `Book` this produces, and the grid size can change responsively without touching the renderer.
export type GridSpec = { cols: number; rows: number };
export type CoinPocket = {
kind: 'coin';
piece: PieceWithIssuer;
// 0..count-1 for a piece with count instances. Nothing renders differently per instance; it only
// keeps React keys stable when one physical type fills several pockets.
instance: number;
};
// The first pocket of each country is a title tile (flag + name) instead of a coin — so every
// country's coins shift one pocket along.
export type LabelPocket = {
kind: 'label';
code: string;
name: string;
flagPath: string | null;
};
export type Pocket = CoinPocket | LabelPocket;
export type Leaf = {
index: number;
country: string; // issuer_code
// length === cols*rows, row-major. The SAME contents are seen from either side of the leaf
// (obverse from the front, reverse from the back). null === empty pocket.
pockets: (Pocket | null)[];
};
export type CountryMark = {
code: string;
name: string;
flagPath: string | null;
leafIndex: number; // first leaf of this country
};
export type Book = {
leaves: Leaf[];
countries: CountryMark[];
grid: GridSpec;
};
// Year used for ordering within a country. year_min is the issue (or first-issue) year; fall back
// to year_max. Pieces with neither sort last (see compareWithinCountry).
function yearKey(piece: PieceWithIssuer): number | null {
return piece.year_min ?? piece.year_max ?? null;
}
function faceValue(piece: PieceWithIssuer): number {
// Undated/valueless pieces (much exonumia) get +Infinity so they trail dated/valued ones.
return piece.currency_numeric_value ?? Number.POSITIVE_INFINITY;
}
// Within a country: by year ascending; pieces with no year last; ties broken by face value then id
// so the order is fully deterministic regardless of input order.
function compareWithinCountry(a: PieceWithIssuer, b: PieceWithIssuer): number {
const ya = yearKey(a);
const yb = yearKey(b);
if (ya === null && yb !== null) return 1;
if (yb === null && ya !== null) return -1;
if (ya !== null && yb !== null && ya !== yb) return ya - yb;
const fa = faceValue(a);
const fb = faceValue(b);
if (fa !== fb) return fa - fb;
return a.id - b.id;
}
// Country (issuer) order matches the rest of the app: alphabetical by issuer name. This mirrors the
// server's `ORDER BY i.name` in queries.issuerCounts / queryPieces. Ties broken by code.
function compareCountries(a: PieceWithIssuer, b: PieceWithIssuer): number {
const byName = a.issuer_name.localeCompare(b.issuer_name);
if (byName !== 0) return byName;
return a.issuer_code.localeCompare(b.issuer_code);
}
export function buildBook(pieces: PieceWithIssuer[], grid: GridSpec): Book {
const perLeaf = grid.cols * grid.rows;
// 1. Expand by count — one pocket entry per physical instance.
const entries: CoinPocket[] = [];
for (const piece of pieces) {
const n = Math.max(1, piece.count ?? 1);
for (let instance = 0; instance < n; instance++)
entries.push({ kind: 'coin', piece, instance });
}
// 2. Group by country.
const byCountry = new Map<string, CoinPocket[]>();
for (const entry of entries) {
const code = entry.piece.issuer_code;
const bucket = byCountry.get(code);
if (bucket) bucket.push(entry);
else byCountry.set(code, [entry]);
}
// Country order: alphabetical by name (every bucket has ≥1 entry; first piece names the country).
const groups = [...byCountry.values()].sort((a, b) => compareCountries(a[0].piece, b[0].piece));
const leaves: Leaf[] = [];
const countries: CountryMark[] = [];
for (const bucket of groups) {
const code = bucket[0].piece.issuer_code;
// 3. Sort within the country by year ascending (stable, deterministic tie-break).
bucket.sort((ea, eb) => compareWithinCountry(ea.piece, eb.piece));
const first = bucket[0].piece;
countries.push({
code,
name: first.issuer_name,
flagPath: first.issuer_flag_path,
leafIndex: leaves.length, // 4. each country starts a fresh leaf
});
// 4. The country's pockets: a title tile first, then its coins (so coins shift by one).
const cells: Pocket[] = [
{ kind: 'label', code, name: first.issuer_name, flagPath: first.issuer_flag_path },
...bucket,
];
// 5. Chunk into leaves of cols*rows pockets, row-major, padding the final leaf with nulls.
for (let offset = 0; offset < cells.length; offset += perLeaf) {
const slice = cells.slice(offset, offset + perLeaf);
const pockets: (Pocket | null)[] = new Array(perLeaf).fill(null);
for (let i = 0; i < slice.length; i++) pockets[i] = slice[i];
leaves.push({ index: leaves.length, country: code, pockets });
}
}
return { leaves, countries, grid };
}
// The country an (absolute) leaf belongs to — the last divider at or before it. null before the
// first country (e.g. while the book is still closed).
export function countryAtLeaf(countries: CountryMark[], leafIndex: number): CountryMark | null {
let found: CountryMark | null = null;
for (const c of countries) {
if (c.leafIndex <= leafIndex) found = c;
else break;
}
return found;
}
// Largest coin diameter (mm) in the collection. Drives pocket pitch so nothing clips its window;
// falls back to a sane default for an empty set. Banknotes are already excluded server-side.
export function maxDiameterMm(pieces: PieceWithIssuer[]): number {
let max = 0;
for (const p of pieces) if (p.diameter && p.diameter > max) max = p.diameter;
return max || 30;
}
// Thickest coin (mm) in the collection. Drives leaf-to-leaf spacing so leaves stack as tightly as
// possible without two back-to-back coins overlapping.
export function maxThicknessMm(pieces: PieceWithIssuer[]): number {
let max = 0;
for (const p of pieces) if (p.thickness && p.thickness > max) max = p.thickness;
return max || 2;
}
+139
View File
@@ -0,0 +1,139 @@
import type { GridSpec } from '@/album/book';
export type Vec3 = [number, number, number];
// Coordinate convention (used by every album component):
// Spine runs along world Y at x=0. Right stack at +X, left stack at X, separated by 2·spineHalfGap.
// +Z points up out of the table; sheets stack upward in +Z. Scene unit = 1 metre, matching <Coin>.
export type PanelSpec = {
index: number;
// Panel 0 carries the binding margin + column 0; the last panel carries the fore-edge margin.
width: number;
leftX: number; // leaf-local x, 0 at the spine
pocketLocalX: number; // panel-local (panel left edge = 0)
};
export type ShellMetrics = {
coverThickness: number;
ringR: number; // distance from spine centre to each ring leg
legTop: number;
spineWidth: number; // = ring height; this wide so the spine clears the rings when closing
coverW: number;
coverH: number;
xBL: number; // back cover's spine-side edge
};
export type BinderMetrics = {
grid: GridSpec;
pitch: number; // pocket pitch (square), from the largest coin + margin
bindingMarginX: number;
foreEdgeMargin: number;
topMargin: number;
leafWidth: number;
leafHeight: number;
leafGap: number; // sheet-to-sheet stacking distance
spineHalfGap: number; // half the gap between the two stacks; also the ring-arc radius
stackCap: number; // max sheets rendered/counted per stack
weldGap: number;
holeRadius: number;
holeYs: number[];
holeX: number; // leaf-local, in the binding margin
panels: PanelSpec[]; // length === grid.cols
pocketCentersY: number[]; // length === grid.rows, top row first
shell: ShellMetrics;
boundsCenter: Vec3;
boundsHalf: Vec3;
};
// Coins are never scaled to fill; the pitch just guarantees the biggest one clears its window, so a
// 1-cent stays visibly smaller than a 2-euro.
export function computeMetrics(
grid: GridSpec,
maxDiameterMm: number,
maxThicknessMm: number,
): BinderMetrics {
const pitch = Math.max(0.03, maxDiameterMm / 1000 + 0.013);
const bindingMarginX = 0.016;
const foreEdgeMargin = 0.012;
const topMargin = 0.014;
const leafHeight = grid.rows * pitch + 2 * topMargin;
const leafWidth = bindingMarginX + grid.cols * pitch + foreEdgeMargin;
// Clears two back-to-back thickest coins (each ±thickness/2 about its leaf centre plane), plus a
// hair so they never quite touch.
const leafGap = Math.max(0.0015, maxThicknessMm / 1000 + 0.0003);
const spineHalfGap = 0.006;
const stackCap = 7;
const weldGap = 0.006;
const panels: PanelSpec[] = [];
let leftX = 0;
for (let c = 0; c < grid.cols; c++) {
const isFirst = c === 0;
const isLast = c === grid.cols - 1;
const width = pitch + (isFirst ? bindingMarginX : 0) + (isLast ? foreEdgeMargin : 0);
const pocketLocalX = (isFirst ? bindingMarginX : 0) + pitch / 2;
panels.push({ index: c, width, leftX, pocketLocalX });
leftX += width;
}
const pocketCentersY: number[] = [];
for (let r = 0; r < grid.rows; r++) {
pocketCentersY.push(leafHeight / 2 - topMargin - (r + 0.5) * pitch);
}
// Static shell layout (mirrors hardware.tsx).
const holeX = bindingMarginX * 0.5;
const ringR = spineHalfGap + holeX;
const legTop = stackCap * leafGap;
const coverThickness = 0.004;
const innerGap = 0.004;
const pad = 0.009;
const spineWidth = legTop + ringR;
const coverH = leafHeight + 2 * pad;
const xBL = -(ringR + innerGap);
const xBR = spineHalfGap + leafWidth + pad;
const coverW = xBR - xBL;
const xLeft = xBL - spineWidth - coverW;
const shell: ShellMetrics = {
coverThickness,
ringR,
legTop,
spineWidth,
coverW,
coverH,
xBL,
};
return {
grid,
pitch,
bindingMarginX,
foreEdgeMargin,
topMargin,
leafWidth,
leafHeight,
leafGap,
spineHalfGap,
stackCap,
weldGap,
holeRadius: 0.0042,
holeYs: [leafHeight * 0.3, 0, -leafHeight * 0.3],
holeX,
panels,
pocketCentersY,
shell,
boundsCenter: [(xLeft + xBR) / 2, 0, spineWidth / 2],
boundsHalf: [(xBR - xLeft) / 2, coverH / 2, spineWidth / 2],
};
}
// Sheets within ±COIN_WINDOW of the flipping sheet mount live <Coin> meshes; the rest are bare
// pages. Covers the whole rendered stack so a coin is mounted (hidden behind frosted overlays) long
// before its leaf rises into view, avoiding pop-in.
export const COIN_WINDOW = 8;
// Peak relative fold angle (radians) at each column seam mid-flip. Accumulated across the chain it
// bows the leaf along the column lines as it lifts (≈ SEAM_FOLD · cols/2 at the fore-edge).
export const SEAM_FOLD = 0.28;
+145
View File
@@ -0,0 +1,145 @@
import * as THREE from 'three';
// three can't sample an SVG directly, so flags (and the serif labels) are rasterised onto a canvas
// and wrapped in a CanvasTexture. Everything is cached by key. Fully offline — no troika font fetch,
// no external image host (the static-server CSP would block both).
const SERIF = 'Georgia, "Times New Roman", serif';
const flagImageCache = new Map<string, Promise<HTMLImageElement>>();
function loadFlagImage(path: string): Promise<HTMLImageElement> {
let promise = flagImageCache.get(path);
if (!promise) {
promise = new Promise<HTMLImageElement>((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = reject;
img.src = `/flags/${path}`;
});
flagImageCache.set(path, promise);
}
return promise;
}
const labelTextureCache = new Map<string, THREE.CanvasTexture>();
function drawFlag(
ctx: CanvasRenderingContext2D,
img: HTMLImageElement,
x: number,
y: number,
w: number,
h: number,
) {
// Contain the flag within w×h, preserving aspect.
const ar = img.width / img.height || 1.5;
let dw = w;
let dh = w / ar;
if (dh > h) {
dh = h;
dw = h * ar;
}
ctx.drawImage(img, x + (w - dw) / 2, y + (h - dh) / 2, dw, dh);
}
// A square title tile for a country's first pocket: flag above, country name below, on a transparent
// ground (so the page shows through). Cached; the flag fills in async once it loads.
export function getLabelTexture(
code: string,
name: string,
flagPath: string | null,
): THREE.CanvasTexture {
const cached = labelTextureCache.get(code);
if (cached) return cached;
const dpr = 2;
const S = 220;
const canvas = document.createElement('canvas');
canvas.width = S * dpr;
canvas.height = S * dpr;
const ctx = canvas.getContext('2d');
if (!ctx) return new THREE.CanvasTexture(canvas); // 2d context unavailable (shouldn't happen)
ctx.scale(dpr, dpr);
const flagW = S * 0.5;
const flagH = flagW * 0.66;
const flagX = (S - flagW) / 2;
const flagY = S * 0.2;
const paint = (img?: HTMLImageElement) => {
ctx.clearRect(0, 0, S, S);
ctx.fillStyle = '#f4f3ee'; // opaque white card
ctx.fillRect(0, 0, S, S);
if (img) {
drawFlag(ctx, img, flagX, flagY, flagW, flagH);
ctx.strokeStyle = '#00000022';
ctx.lineWidth = 1;
ctx.strokeRect(flagX, flagY, flagW, flagH);
}
ctx.fillStyle = '#2a2620';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// Shrink the font until the name fits the tile width.
let size = 30;
do {
ctx.font = `small-caps ${size}px ${SERIF}`;
if (ctx.measureText(name).width <= S * 0.92) break;
size -= 2;
} while (size > 12);
ctx.fillText(name, S / 2, flagY + flagH + S * 0.18);
texture.needsUpdate = true;
};
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.anisotropy = 4;
paint();
if (flagPath)
loadFlagImage(flagPath)
.then(paint)
.catch(() => {});
labelTextureCache.set(code, texture);
return texture;
}
let titleTexture: THREE.CanvasTexture | null = null;
// Aspect ratio (W/H) of the cover title texture, so the mesh can match it.
export const COVER_TITLE_ASPECT = 512 / 220;
export function getCoverTitleTexture(): THREE.CanvasTexture {
if (titleTexture) return titleTexture;
const dpr = 2;
const W = 512;
const H = 220;
const canvas = document.createElement('canvas');
canvas.width = W * dpr;
canvas.height = H * dpr;
const ctx = canvas.getContext('2d');
if (!ctx) return new THREE.CanvasTexture(canvas); // 2d context unavailable (shouldn't happen)
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#c79a5e';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = `small-caps 150px ${SERIF}`;
// Small-caps + extra letter-spacing for a struck-title feel.
const text = 'Coins';
const tracking = 10;
const widths = [...text].map((ch) => ctx.measureText(ch).width + tracking);
const total = widths.reduce((a, b) => a + b, 0) - tracking;
let x = W / 2 - total / 2;
for (let i = 0; i < text.length; i++) {
ctx.fillText(text[i], x + (widths[i] - tracking) / 2, H / 2);
x += widths[i];
}
titleTexture = new THREE.CanvasTexture(canvas);
titleTexture.colorSpace = THREE.SRGBColorSpace;
titleTexture.anisotropy = 4;
return titleTexture;
}
+43
View File
@@ -0,0 +1,43 @@
import { useMemo } from 'react';
import * as THREE from 'three';
// Flat openings rather than bulged transmissive windows, so leaves stack as tightly as the thickest
// coin allows.
export function usePageMaterial(): THREE.MeshStandardMaterial {
return useMemo(
() =>
new THREE.MeshStandardMaterial({
color: 0xe9edf0,
roughness: 0.85,
metalness: 0,
side: THREE.DoubleSide,
}),
[],
);
}
// A leaf is hidden not by its own overlay but by the overlays of leaves resting on top, so the
// visible page reads clearly while buried pages fade out cumulatively. Exactly centred per leaf, so
// no two overlays intersect.
export const POCKET_BACKING_OPACITY = 0.75;
// Frosted-glass backing: transmission + roughness blur the buried leaves behind it; the visible
// coins sit in front of this plane so they stay crisp.
export function usePocketBackingMaterial(): THREE.MeshPhysicalMaterial {
return useMemo(
() =>
new THREE.MeshPhysicalMaterial({
color: 0xccd3d8,
metalness: 0,
roughness: 0.85,
transmission: 1,
thickness: 0.012,
ior: 1.2,
transparent: true,
opacity: POCKET_BACKING_OPACITY,
depthWrite: false,
side: THREE.DoubleSide,
}),
[],
);
}
+74
View File
@@ -0,0 +1,74 @@
import { SEAM_FOLD } from '@/album/constants';
// `position` is a float over the sheet sequence (front cover, then leaves, then back cover).
// floor(position) is the sheet currently flipping; frac(position) is its progress. At position 0
// the book is closed with the front cover on the right stack; the left stack grows as sheets flip.
export type SheetPose = {
x: number; // world x of the sheet's bound (hinge) edge
z: number; // world z (height) of that edge
angleY: number; // rotation about the spine axis (Y)
// 0 on the straight legs (so the page stays flat and can't clip the cover or neighbours), peaking
// over the arc.
bend: number;
};
// Path a flipping sheet's bound edge follows over the ring, parameterised by progress `f`. Up the
// right leg (x=+spineHalf) to the leg top, semicircle of radius spineHalf over the spine, then down
// the left leg (x=spineHalf). A hole punched `holeX` further out then traces the matching outer "D"
// (radius spineHalf+holeX) for all f, which is exactly the ring shape, so the ring threads every
// hole at every position. Arc-length parameterised so the edge moves at a steady pace.
export function flipPath(
startZ: number,
endZ: number,
f: number,
spineHalf: number,
legTop: number,
): { x: number; z: number; angleY: number; bend: number } {
const up = Math.max(0, legTop - startZ);
const arc = Math.PI * spineHalf;
const down = Math.max(0, legTop - endZ);
const total = up + arc + down;
const s = f * total;
if (s <= up) return { x: spineHalf, z: startZ + s, angleY: 0, bend: 0 };
if (s <= up + arc) {
const theta = (s - up) / spineHalf;
return {
x: spineHalf * Math.cos(theta),
z: legTop + spineHalf * Math.sin(theta),
angleY: -theta,
bend: Math.sin(theta),
};
}
return { x: -spineHalf, z: legTop - (s - up - arc), angleY: -Math.PI, bend: 0 };
}
// `t` = leaf gap (stack step), `spineHalf` = half the spine gap, `cap` = max sheets per stack.
export function sheetPose(
position: number,
index: number,
total: number,
t: number,
spineHalf: number,
cap: number,
): SheetPose {
const L = Math.floor(position);
const f = position - L;
// Continuous in `position` so nothing jumps across a flip.
const hR = Math.min(total - position, cap) * t;
const hL = Math.min(position, cap) * t;
if (index === L) {
return flipPath(hR, hL, f, spineHalf, cap * t);
}
if (index > L) {
return { x: spineHalf, z: hR - (index - position) * t, angleY: 0, bend: 0 };
}
return { x: -spineHalf, z: hL - (position - 1 - index) * t, angleY: -Math.PI, bend: 0 };
}
// Relative fold at column seam (1..cols-1), scaled by `bend` and growing toward the fore-edge.
export function seamAngle(bend: number, seam: number, cols: number): number {
if (cols <= 1) return 0;
return -SEAM_FOLD * bend * (seam / (cols - 1));
}
+129
View File
@@ -0,0 +1,129 @@
import { useFrame, useThree } from '@react-three/fiber';
import { useEffect, useRef, useState } from 'react';
// Wheel, touch-drag and keyboard all drive a single float `position` over leaves. Direct
// manipulation only: it rests exactly where you leave it, with no auto-snap and no flick momentum.
const WHEEL_SENS = 0.0035; // position units per wheel deltaY
const STIFFNESS = 9; // easing toward target, per second
const SETTLE_EPS = 0.0008;
export type BinderInput = {
positionRef: React.RefObject<number>;
center: number; // round(position); gates the render window
};
function clamp(v: number, min: number, max: number): number {
return Math.max(min, Math.min(max, v));
}
// `minPosition` < 0 reserves a span before the first leaf for the cover-opening actuation.
export function useBinderInput(
leafCount: number,
reducedMotion: boolean,
minPosition = 0,
initial = minPosition,
dragAxis: 'x' | 'y' = 'x',
): BinderInput {
const gl = useThree((s) => s.gl);
const maxPosition = leafCount;
const start = clamp(initial, minPosition, maxPosition);
const positionRef = useRef(start);
const targetRef = useRef(start);
const draggingRef = useRef(false);
const lastXRef = useRef(0);
const centerRef = useRef(start);
const [center, setCenter] = useState(start);
useEffect(() => {
const el = gl.domElement;
el.style.touchAction = 'none';
const onWheel = (e: WheelEvent) => {
e.preventDefault();
targetRef.current = clamp(
targetRef.current + e.deltaY * WHEEL_SENS,
minPosition,
maxPosition,
);
};
const coordOf = (e: PointerEvent) => (dragAxis === 'y' ? e.clientY : e.clientX);
const onPointerDown = (e: PointerEvent) => {
draggingRef.current = true;
lastXRef.current = coordOf(e);
el.setPointerCapture(e.pointerId);
};
const onPointerMove = (e: PointerEvent) => {
if (!draggingRef.current) return;
const c = coordOf(e);
const d = c - lastXRef.current;
lastXRef.current = c;
// Roughly a full turn per half-extent swipe.
const extent = dragAxis === 'y' ? el.clientHeight : el.clientWidth;
const sens = 1.8 / Math.max(320, extent);
positionRef.current = clamp(positionRef.current - d * sens, minPosition, maxPosition);
targetRef.current = positionRef.current;
};
const endDrag = (e: PointerEvent) => {
if (!draggingRef.current) return;
draggingRef.current = false;
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);
targetRef.current = positionRef.current;
};
el.addEventListener('wheel', onWheel, { passive: false });
el.addEventListener('pointerdown', onPointerDown);
el.addEventListener('pointermove', onPointerMove);
el.addEventListener('pointerup', endDrag);
el.addEventListener('pointercancel', endDrag);
return () => {
el.removeEventListener('wheel', onWheel);
el.removeEventListener('pointerdown', onPointerDown);
el.removeEventListener('pointermove', onPointerMove);
el.removeEventListener('pointerup', endDrag);
el.removeEventListener('pointercancel', endDrag);
};
}, [gl, minPosition, maxPosition, dragAxis]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
let handled = true;
// Arrow/Page keys turn one whole leaf.
if (e.key === 'ArrowRight' || e.key === 'PageDown')
targetRef.current = clamp(Math.round(targetRef.current) + 1, minPosition, maxPosition);
else if (e.key === 'ArrowLeft' || e.key === 'PageUp')
targetRef.current = clamp(Math.round(targetRef.current) - 1, minPosition, maxPosition);
else if (e.key === 'Home') targetRef.current = minPosition;
else if (e.key === 'End') targetRef.current = maxPosition;
else handled = false;
if (handled) e.preventDefault();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [minPosition, maxPosition]);
useFrame((_, delta) => {
if (reducedMotion) {
positionRef.current = targetRef.current;
} else if (!draggingRef.current) {
const k = Math.min(1, delta * STIFFNESS);
positionRef.current += (targetRef.current - positionRef.current) * k;
if (Math.abs(targetRef.current - positionRef.current) < SETTLE_EPS) {
positionRef.current = targetRef.current;
}
}
const c = clamp(Math.round(positionRef.current), minPosition, maxPosition);
if (c !== centerRef.current) {
centerRef.current = c;
setCenter(c);
}
});
return { positionRef, center };
}
+173
View File
@@ -0,0 +1,173 @@
import { PerspectiveCamera } from '@react-three/drei';
import { useThree } from '@react-three/fiber';
import { memo, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
import * as THREE from 'three';
import { type Book, countryAtLeaf } from '@/album/book';
import { type BinderMetrics, COIN_WINDOW, computeMetrics } from '@/album/constants';
import { usePageMaterial, usePocketBackingMaterial } from '@/album/plastic';
import { useBinderInput } from '@/album/use-binder-input';
import { BinderShell } from '@/components/album/hardware';
import { Leaf, type Materials } from '@/components/album/leaf';
import { buildLeafGeometry, disposeLeafGeometry } from '@/components/album/leaf-geometry';
import { AlbumLighting } from '@/components/album/lighting';
export type Orientation = 'portrait' | 'landscape';
const FOV = 38;
// View direction (target → camera). The camera is fixed; only the binder rotates.
const VIEW_DIR = new THREE.Vector3(0, -0.4, 0.92).normalize();
function CameraRig({ metrics, orientation }: { metrics: BinderMetrics; orientation: Orientation }) {
const camRef = useRef<THREE.PerspectiveCamera>(null);
const size = useThree((s) => s.size);
useLayoutEffect(() => {
const cam = camRef.current;
if (!cam) return;
const [cx, cy, cz] = metrics.boundsCenter;
const [hx, hy, hz] = metrics.boundsHalf;
const portrait = orientation === 'portrait';
// After the 90° Z rotation, canonical (x,y) → display (y, x).
const center = portrait ? new THREE.Vector3(cy, -cx, cz) : new THREE.Vector3(cx, cy, cz);
const halfX = portrait ? hy : hx;
const halfY = portrait ? hx : hy;
const aspect = size.width / Math.max(1, size.height);
const tanV = Math.tan((FOV * Math.PI) / 180 / 2);
// Tilted view: fold stack height (hz) into the vertical extent.
const distV = (halfY + hz) / tanV;
const distH = halfX / (tanV * aspect);
const dist = Math.max(distV, distH) * 1.18 + hz;
cam.position.copy(center).addScaledVector(VIEW_DIR, dist);
cam.lookAt(center);
cam.updateProjectionMatrix();
}, [size, metrics, orientation]);
return <PerspectiveCamera ref={camRef} makeDefault fov={FOV} near={0.005} far={6} />;
}
// Deep links are country-relative: ?country=<issuer code>&leaf=<n within country>. Returns an
// absolute leaf index, or null to start closed.
function leafFromUrl(book: Book): number | null {
if (typeof window === 'undefined') return null;
const params = new URLSearchParams(window.location.search);
const code = params.get('country');
if (!code) return null;
const idx = book.countries.findIndex((c) => c.code === code);
if (idx < 0) return null;
const mark = book.countries[idx];
// Clamp the relative leaf to this country's own span.
const lastLeaf = (book.countries[idx + 1]?.leafIndex ?? book.leaves.length) - 1;
const rel = Number(params.get('leaf') ?? 0);
const safeRel = Number.isFinite(rel) ? Math.max(0, Math.floor(rel)) : 0;
return Math.min(mark.leafIndex + safeRel, lastLeaf);
}
function syncUrl(book: Book, center: number): void {
if (typeof window === 'undefined') return;
const u = new URL(window.location.href);
const mark = center >= 0 ? countryAtLeaf(book.countries, center) : null;
if (mark) {
u.searchParams.set('country', mark.code);
u.searchParams.set('leaf', String(center - mark.leafIndex));
} else {
u.searchParams.delete('country');
u.searchParams.delete('leaf');
}
window.history.replaceState(null, '', u);
}
export const Binder = memo(function Binder({
book,
maxDiameterMm,
maxThicknessMm,
orientation,
reducedMotion,
onActiveCountry,
}: {
book: Book;
maxDiameterMm: number;
maxThicknessMm: number;
orientation: Orientation;
reducedMotion: boolean;
onActiveCountry: (name: string) => void;
}) {
const metrics = useMemo(
() => computeMetrics(book.grid, maxDiameterMm, maxThicknessMm),
[book.grid, maxDiameterMm, maxThicknessMm],
);
const geometry = useMemo(() => buildLeafGeometry(metrics), [metrics]);
useEffect(() => () => disposeLeafGeometry(geometry), [geometry]);
// One page + backing material shared by every leaf (see Leaf).
const page = usePageMaterial();
const backing = usePocketBackingMaterial();
const materials = useMemo<Materials>(() => ({ page, backing }), [page, backing]);
useEffect(
() => () => {
page.dispose();
backing.dispose();
},
[page, backing],
);
// Positions 0..leafCount flip leaves; [1, 0] actuates the cover opening. Book starts closed at
// 1 unless a deep link points into a country.
const totalSheets = book.leaves.length;
const initial = useMemo(() => leafFromUrl(book) ?? -1, [book]);
const { positionRef, center } = useBinderInput(
totalSheets,
reducedMotion,
-1,
initial,
orientation === 'portrait' ? 'y' : 'x',
);
useEffect(() => {
onActiveCountry(countryAtLeaf(book.countries, center)?.name ?? book.countries[0]?.name ?? '');
syncUrl(book, center);
}, [book, center, onActiveCountry]);
// Counter-spin to keep content upright against the binder's portrait roll.
const contentSpin = orientation === 'portrait' ? Math.PI / 2 : 0;
// Only leaves within stackCap of the flip point are drawn; deeper ones hit the height ceiling.
const renderWindow = metrics.stackCap;
const winStart = Math.max(0, center - renderWindow);
const winEnd = Math.min(totalSheets - 1, center + renderWindow);
const leaves: React.ReactNode[] = [];
for (let s = winStart; s <= winEnd; s++) {
const leaf = book.leaves[s];
leaves.push(
<Leaf
key={leaf.index}
leaf={leaf}
sheetIndex={s}
totalSheets={totalSheets}
metrics={metrics}
geometry={geometry}
materials={materials}
positionRef={positionRef}
mountCoins={Math.abs(s - center) <= COIN_WINDOW}
reducedMotion={reducedMotion}
contentSpin={contentSpin}
/>,
);
}
return (
<>
<color attach="background" args={['#0b0a07']} />
<CameraRig metrics={metrics} orientation={orientation} />
<AlbumLighting />
{/* Portrait rolls the binder 90° about Z; flip/geometry math stays in the canonical frame. */}
<group rotation={[0, 0, orientation === 'portrait' ? -Math.PI / 2 : 0]}>
{leaves}
<BinderShell metrics={metrics} positionRef={positionRef} contentSpin={contentSpin} />
</group>
</>
);
});
+148
View File
@@ -0,0 +1,148 @@
import { useFrame } from '@react-three/fiber';
import { useEffect, useMemo, useRef } from 'react';
import * as THREE from 'three';
import type { BinderMetrics } from '@/album/constants';
import { COVER_TITLE_ASPECT, getCoverTitleTexture } from '@/album/flag-texture';
// The binder shell — front cover, spine, back cover — and the rings. The back cover and rings are
// static; the front cover + spine ACTUATE: the book starts closed (front cover folded over the
// stack, spine standing) and opens as `position` rises from 1 to 0, swinging flat. The rings are
// mounted FULLY within the back cover (never the spine); the right stack rests on the back cover,
// the left (flipped) stack reaches left over the spine and front cover. Back cover width = leaf
// width + distance between the ring legs + inner gap + paddings; the front cover is the same width.
//
// Layout along x (z=0 is the table top; leaves stack up in +z, covers sit just below):
// front cover [xBLspineWidthW .. xBLspineWidth]
// spine [xBLspineWidth .. xBL]
// back cover [xBL .. xBL+W] (encloses both ring legs at ±ringR)
// where xBL = (ringR + innerGap).
const OUTER = 0x1a1611; // exterior / edges of a cover
const INNER = 0xbdb6a3; // inner face (toward the pages) — subtle warm, not saturated
const SPINE = 0x141009;
// Title plate on the OUTSIDE of the front cover (the face that ends up on top when the book is
// closed). The cover folds 180° about Y when closing, so the plate's own Ry(π) cancels that to read
// upright; `contentSpin` then keeps it upright when the whole binder is rolled for portrait.
function CoverTitle({ metrics, contentSpin }: { metrics: BinderMetrics; contentSpin: number }) {
const { coverW, coverThickness } = metrics.shell;
const material = useMemo(
() =>
new THREE.MeshBasicMaterial({
map: getCoverTitleTexture(),
transparent: true,
toneMapped: false,
}),
[],
);
const w = coverW * 0.62;
const h = w / COVER_TITLE_ASPECT;
return (
<group position={[-coverW / 2, 0, -coverThickness - 0.0004]} rotation={[0, Math.PI, 0]}>
<group rotation={[0, 0, contentSpin]}>
<mesh material={material}>
<planeGeometry args={[w, h]} />
</mesh>
</group>
</group>
);
}
export function BinderShell({
metrics,
positionRef,
contentSpin,
}: {
metrics: BinderMetrics;
positionRef: React.RefObject<number>;
contentSpin: number;
}) {
const { ringR, legTop, spineWidth, coverW, coverH, coverThickness, xBL } = metrics.shell;
const coverZ = -coverThickness / 2; // top face at z=0
// All shell geometry/materials in one bundle so a single effect can release them when the metrics
// change (grid resize) or the binder unmounts. Rings: legs at x = ±ringR up to legTop, joined by
// an upside-down semicircle — the exact hole path, so they stay threaded for every leaf position.
const tube = 0.0014;
const { coverGeo, spineGeo, coverMats, spineMat, ringMat, legGeo, arcGeo } = useMemo(() => {
const outer = new THREE.MeshStandardMaterial({
color: OUTER,
roughness: 0.55,
metalness: 0.12,
});
const inner = new THREE.MeshStandardMaterial({ color: INNER, roughness: 0.85, metalness: 0 });
const spine = new THREE.MeshStandardMaterial({ color: SPINE, roughness: 0.6, metalness: 0.12 });
const ring = new THREE.MeshStandardMaterial({ color: 0xcdc7bd, metalness: 1, roughness: 0.3 });
const leg = new THREE.CylinderGeometry(tube, tube, legTop, 8);
leg.rotateX(Math.PI / 2);
leg.translate(0, 0, legTop / 2);
const arc = new THREE.TorusGeometry(ringR, tube, 8, 32, Math.PI);
arc.rotateX(Math.PI / 2);
return {
coverGeo: new THREE.BoxGeometry(coverW, coverH, coverThickness),
spineGeo: new THREE.BoxGeometry(spineWidth, coverH, coverThickness),
// Box face order: +x, x, +y, y, +z (top/inner), z (bottom/outer).
coverMats: [outer, outer, outer, outer, inner, outer],
spineMat: spine,
ringMat: ring,
legGeo: leg,
arcGeo: arc,
};
}, [coverW, coverH, spineWidth, legTop, ringR, coverThickness]);
useEffect(
() => () => {
for (const r of [
coverGeo,
spineGeo,
coverMats[0],
coverMats[4],
spineMat,
ringMat,
legGeo,
arcGeo,
])
r.dispose();
},
[coverGeo, spineGeo, coverMats, spineMat, ringMat, legGeo, arcGeo],
);
// The spine + front cover fold open as `open` goes 0 (closed) → 1 (flat). The spine hinges up at
// the back cover's edge; the front cover folds a further equal angle, landing flat at open=1 and
// over the top of the stack at open=0.
const spineRef = useRef<THREE.Group>(null);
const frontRef = useRef<THREE.Group>(null);
useFrame(() => {
const open = Math.max(0, Math.min(1, positionRef.current + 1));
const a = (1 - open) * (Math.PI / 2);
if (spineRef.current) spineRef.current.rotation.y = a;
if (frontRef.current) frontRef.current.rotation.y = a;
});
return (
<group>
{/* Back cover — static, the binder rests on it. */}
<mesh geometry={coverGeo} material={coverMats} position={[xBL + coverW / 2, 0, coverZ]} />
{/* Spine, hinged at the back cover's spine edge (xBL); front cover hinged at the spine's end. */}
<group ref={spineRef} position={[xBL, 0, 0]}>
<mesh geometry={spineGeo} material={spineMat} position={[-spineWidth / 2, 0, coverZ]} />
<group ref={frontRef} position={[-spineWidth, 0, 0]}>
<mesh geometry={coverGeo} material={coverMats} position={[-coverW / 2, 0, coverZ]} />
<CoverTitle metrics={metrics} contentSpin={contentSpin} />
</group>
</group>
{metrics.holeYs.map((y) => (
<group key={y}>
<mesh geometry={legGeo} material={ringMat} position={[ringR, y, 0]} />
<mesh geometry={legGeo} material={ringMat} position={[-ringR, y, 0]} />
<mesh geometry={arcGeo} material={ringMat} position={[0, y, legTop]} />
</group>
))}
</group>
);
}
+57
View File
@@ -0,0 +1,57 @@
import * as THREE from 'three';
import type { BinderMetrics } from '@/album/constants';
// Geometry for one leaf, built once and shared by every leaf. Coordinates are leaf-local: x=0 at the
// spine, +x toward the fore-edge, y up, z = thickness about 0. The leaf is a single flat membrane
// with the pocket windows and binding holes cut out; coins seat in the windows, backed by the centre
// overlay added in leaf.tsx.
export type PanelGeometry = {
sheet: THREE.BufferGeometry;
};
export type LeafGeometrySet = {
panels: PanelGeometry[];
};
function squareHole(cx: number, cy: number, side: number): THREE.Path {
const h = side / 2;
const path = new THREE.Path();
path.moveTo(cx - h, cy - h);
path.lineTo(cx + h, cy - h);
path.lineTo(cx + h, cy + h);
path.lineTo(cx - h, cy + h);
path.closePath();
return path;
}
export function buildLeafGeometry(m: BinderMetrics): LeafGeometrySet {
const { pitch, weldGap, leafHeight } = m;
const windowSide = pitch - weldGap;
const panels: PanelGeometry[] = m.panels.map((panel, panelIndex) => {
const shape = new THREE.Shape();
shape.moveTo(0, -leafHeight / 2);
shape.lineTo(panel.width, -leafHeight / 2);
shape.lineTo(panel.width, leafHeight / 2);
shape.lineTo(0, leafHeight / 2);
shape.closePath();
shape.holes = m.pocketCentersY.map((cy) => squareHole(panel.pocketLocalX, cy, windowSide));
if (panelIndex === 0) {
for (const cy of m.holeYs) {
const path = new THREE.Path();
path.absarc(m.holeX, cy, m.holeRadius, 0, Math.PI * 2, false);
shape.holes.push(path);
}
}
return { sheet: new THREE.ShapeGeometry(shape) };
});
return { panels };
}
export function disposeLeafGeometry(set: LeafGeometrySet): void {
for (const p of set.panels) p.sheet.dispose();
}
+236
View File
@@ -0,0 +1,236 @@
import { useFrame } from '@react-three/fiber';
import { type RefObject, Suspense, useMemo, useRef } from 'react';
import * as THREE from 'three';
import type { CoinPocket, LabelPocket, Leaf as LeafModel } from '@/album/book';
import type { BinderMetrics } from '@/album/constants';
import { getLabelTexture } from '@/album/flag-texture';
import { seamAngle, sheetPose } from '@/album/turn';
import type { LeafGeometrySet } from '@/components/album/leaf-geometry';
import { Coin } from '@/components/piece/coin';
export type Materials = {
page: THREE.Material;
backing: THREE.Material;
};
// Obverse faces the leaf's local +Z, upright toward +Y. The reverse keeps the real die axis: <Coin>
// bakes it from the atlas, so the back is never "corrected" (plan §7.3).
function PocketCoin({
entry,
x,
y,
spin,
}: {
entry: CoinPocket;
x: number;
y: number;
spin: number;
}) {
return (
<group position={[x, y, 0]} rotation={[0, 0, spin]}>
<group rotation={[Math.PI / 2, 0, 0]}>
<Suspense fallback={null}>
<Coin coin={entry.piece} atlas={256} />
</Suspense>
</group>
</group>
);
}
// A country's title tile: a two-faced plane, flag + name on front, blank white back. Materials are
// session-cached (one shared back, one front per country code) since labels mount/unmount as leaves
// scroll, so per-mount materials would leak.
const LABEL_WHITE = 0xf4f3ee;
const labelBackMaterial = new THREE.MeshStandardMaterial({ color: LABEL_WHITE, roughness: 0.85 });
const labelFrontCache = new Map<string, THREE.MeshStandardMaterial>();
function labelFrontMaterial(entry: LabelPocket): THREE.MeshStandardMaterial {
let mat = labelFrontCache.get(entry.code);
if (!mat) {
mat = new THREE.MeshStandardMaterial({
map: getLabelTexture(entry.code, entry.name, entry.flagPath),
roughness: 0.85,
});
labelFrontCache.set(entry.code, mat);
}
return mat;
}
function PocketLabel({
entry,
x,
y,
size,
spin,
}: {
entry: LabelPocket;
x: number;
y: number;
size: number;
spin: number;
}) {
return (
<group position={[x, y, 0]} rotation={[0, 0, spin]}>
<mesh material={labelFrontMaterial(entry)}>
<planeGeometry args={[size, size]} />
</mesh>
<mesh material={labelBackMaterial} rotation={[0, Math.PI, 0]}>
<planeGeometry args={[size, size]} />
</mesh>
</group>
);
}
// Per-leaf layering, front (+Z) to back (Z): coin obverses · [flat sheet + centre backing] · coin
// reverses. The backing at the centre plane occludes this leaf's reverses and everything behind, but
// not its own obverses; so a leaf is hidden only by the backings of leaves resting on top of it.
function PanelContent({
panelIndex,
geometry,
materials,
metrics,
leaf,
mountCoins,
contentSpin,
}: {
panelIndex: number;
geometry: LeafGeometrySet;
materials: Materials;
metrics: BinderMetrics;
leaf: LeafModel;
mountCoins: boolean;
contentSpin: number;
}) {
const panelGeo = geometry.panels[panelIndex];
const cols = metrics.grid.cols;
const pocketLocalX = metrics.panels[panelIndex].pocketLocalX;
const pocketAreaH = metrics.grid.rows * metrics.pitch;
return (
<>
<mesh geometry={panelGeo.sheet} material={materials.page} />
{/* z offset keeps the overlay off the sheet plane to avoid coplanar z-fighting. */}
<mesh position={[pocketLocalX, 0, -0.0002]} material={materials.backing}>
<planeGeometry args={[metrics.pitch, pocketAreaH]} />
</mesh>
{mountCoins &&
metrics.pocketCentersY.map((cy, row) => {
const entry = leaf.pockets[row * cols + panelIndex];
if (!entry) return null;
if (entry.kind === 'label')
return (
<PocketLabel
key={`label-${entry.code}`}
entry={entry}
x={pocketLocalX}
y={cy}
size={metrics.pitch * 0.92}
spin={contentSpin}
/>
);
return (
<PocketCoin
key={`${entry.piece.id}-${entry.instance}`}
entry={entry}
x={pocketLocalX}
y={cy}
spin={contentSpin}
/>
);
})}
</>
);
}
// Piecewise-rigid leaf: `cols` flat panels chained by hinges at the column seams, so coins parented
// into a panel inherit its rigid transform and never warp.
export function Leaf({
leaf,
sheetIndex,
totalSheets,
metrics,
geometry,
materials,
positionRef,
mountCoins,
reducedMotion,
contentSpin,
}: {
leaf: LeafModel;
sheetIndex: number; // = leaf index; covers are static
totalSheets: number;
metrics: BinderMetrics;
geometry: LeafGeometrySet;
// Shared across leaves so the transmissive backing's shader compiles once, not per leaf.
materials: Materials;
positionRef: RefObject<number>;
mountCoins: boolean;
reducedMotion: boolean;
contentSpin: number;
}) {
const cols = metrics.grid.cols;
const rootRef = useRef<THREE.Group>(null);
const hingeRefs = useRef<(THREE.Group | null)[]>([]);
useFrame(() => {
const pose = sheetPose(
positionRef.current,
sheetIndex,
totalSheets,
metrics.leafGap,
metrics.spineHalfGap,
metrics.stackCap,
);
const root = rootRef.current;
if (root) {
root.position.set(pose.x, 0, pose.z);
root.rotation.y = pose.angleY;
}
for (let seam = 1; seam < cols; seam++) {
const hinge = hingeRefs.current[seam];
if (hinge) hinge.rotation.y = reducedMotion ? 0 : seamAngle(pose.bend, seam, cols);
}
});
const chain = useMemo(() => {
let node: React.ReactNode = null;
for (let i = cols - 1; i >= 0; i--) {
const content = (
<PanelContent
panelIndex={i}
geometry={geometry}
materials={materials}
metrics={metrics}
leaf={leaf}
mountCoins={mountCoins}
contentSpin={contentSpin}
/>
);
if (i === 0) {
node = (
<>
{content}
{node}
</>
);
} else {
const inner = node;
node = (
<group
ref={(g) => {
hingeRefs.current[i] = g;
}}
// Offset by the preceding panel's width; nesting accumulates the rest.
position={[metrics.panels[i - 1].width, 0, 0]}
>
{content}
{inner}
</group>
);
}
}
return node;
}, [cols, geometry, metrics, leaf, mountCoins, materials, contentSpin]);
return <group ref={rootRef}>{chain}</group>;
}
+11
View File
@@ -0,0 +1,11 @@
// No environment map: metal reads fine off the directional lights, matching the hail/pile scenes.
export function AlbumLighting() {
return (
<>
<ambientLight intensity={0.35} color="#f3e4c8" />
<directionalLight position={[0.15, 0.25, 0.4]} intensity={2.6} color="#ffe7c0" />
<directionalLight position={[-0.3, 0.1, 0.2]} intensity={0.8} color="#c9d8ff" />
<directionalLight position={[0, 0.4, -0.3]} intensity={0.6} color="#fff4e0" />
</>
);
}
+2
View File
@@ -4,6 +4,7 @@ import { RouterProvider } from 'react-aria-components';
import { createRoot } from 'react-dom/client';
import { Route, Switch, useLocation } from 'wouter';
import { Loading } from '@/components/loading';
import { Album } from '@/pages/album';
import { Home } from '@/pages/home';
import { Pile } from '@/pages/pile';
import { ShapeTest } from '@/pages/shape-test';
@@ -18,6 +19,7 @@ function App() {
<Suspense fallback={<Loading />}>
<Switch>
<Route path="/" component={Home} />
<Route path="/album" component={Album} />
<Route path="/showcase" component={Showcase} />
<Route path="/pile" component={Pile} />
<Route path="/shape-test" component={ShapeTest} />
+146
View File
@@ -0,0 +1,146 @@
import { Canvas } from '@react-three/fiber';
import { Suspense, use, useCallback, useEffect, useMemo, useState } from 'react';
import { buildBook, type GridSpec, maxDiameterMm, maxThicknessMm } from '@/album/book';
import { fetchPieces } from '@/api';
import { Binder, type Orientation } from '@/components/album/binder';
// Landscape spreads two stacks along the (wide) flip axis and turns right-to-left; portrait (taller
// than wide) stacks them vertically and turns bottom-to-top. The grid is just a prop on the binder
// model (plan §6) — changing it re-derives the book and renderer.
//
// The grid is fitted to the viewport rather than hardcoded: `cols` runs along the flip axis, which
// shows ~2 stacks, so it maps to the screen's LONG side ÷ 2; `rows` runs along the spine, mapping to
// the SHORT side. CELL is the rough on-screen size we want a pocket to occupy. Approximate by design.
const CELL_PX = 200;
function clampInt(n: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, Math.round(n)));
}
type Viewport = { grid: GridSpec; orientation: Orientation };
function readViewport(): Viewport {
if (typeof window === 'undefined')
return { grid: { cols: 4, rows: 5 }, orientation: 'landscape' };
const w = window.innerWidth;
const h = window.innerHeight;
const long = Math.max(w, h);
const short = Math.min(w, h);
return {
grid: { cols: clampInt(long / (2 * CELL_PX), 3, 5), rows: clampInt(short / CELL_PX, 3, 5) },
orientation: w < h ? 'portrait' : 'landscape',
};
}
function useViewport(): Viewport {
const [vp, setVp] = useState<Viewport>(readViewport);
useEffect(() => {
const onResize = () =>
setVp((prev) => {
const next = readViewport();
// Only re-render (and rebuild the book) when the grid or orientation actually changes — the
// rounding makes that happen at thresholds, not on every resize pixel.
return next.grid.cols === prev.grid.cols &&
next.grid.rows === prev.grid.rows &&
next.orientation === prev.orientation
? prev
: next;
});
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return vp;
}
function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = useState(
() =>
typeof window !== 'undefined' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches,
);
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
const onChange = () => setReduced(mq.matches);
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
}, []);
return reduced;
}
function hasWebGL(): boolean {
if (typeof document === 'undefined') return true;
try {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl2') ?? canvas.getContext('webgl');
// Release the probe context immediately — browsers cap live WebGL contexts.
gl?.getExtension('WEBGL_lose_context')?.loseContext();
return !!gl;
} catch {
return false;
}
}
function Overlay({ country }: { country: string }) {
return (
<>
<div className="absolute top-6 left-6 pointer-events-none select-none">
<p className="caps text-mini text-ink-dim">the album</p>
<h1 className="text-2xl text-ink mt-1">{country || '—'}</h1>
</div>
<div className="absolute bottom-5 left-1/2 -translate-x-1/2 caps text-mini text-ink-faint select-none whitespace-nowrap pointer-events-none">
scroll or drag to turn the pages
</div>
</>
);
}
export function Album() {
const { grid, orientation } = useViewport();
const reducedMotion = usePrefersReducedMotion();
const webgl = useMemo(hasWebGL, []);
// The album's only filter: banknotes excluded at the server (plan §4). Never filtered client-side.
const { pieces } = use(fetchPieces({ categories: ['coin', 'exonumia'] }));
const book = useMemo(() => buildBook(pieces, grid), [pieces, grid]);
const maxDiameter = useMemo(() => maxDiameterMm(pieces), [pieces]);
const maxThickness = useMemo(() => maxThicknessMm(pieces), [pieces]);
const [country, setCountry] = useState('');
const onActiveCountry = useCallback((name: string) => setCountry(name), []);
if (!webgl) {
return (
<div className="h-svh flex items-center justify-center bg-page text-ink-dim caps text-mini italic px-8 text-center">
the album needs a WebGL-capable browser to render the binder.
</div>
);
}
if (book.leaves.length === 0) {
return (
<div className="h-svh flex items-center justify-center bg-page text-ink-dim caps text-mini italic">
no pieces to bind yet
</div>
);
}
return (
<div className="h-svh bg-page text-ink relative overflow-hidden">
<Canvas dpr={[1, 2]} gl={{ antialias: true }}>
<Suspense fallback={null}>
<Binder
book={book}
maxDiameterMm={maxDiameter}
maxThicknessMm={maxThickness}
orientation={orientation}
reducedMotion={reducedMotion}
onActiveCountry={onActiveCountry}
/>
</Suspense>
</Canvas>
<Overlay country={country} />
</div>
);
}
+5
View File
@@ -26,6 +26,11 @@ export function Home() {
<header className="text-center mb-16">
<h1 className="text-6xl text-ink leading-none">Coins</h1>
<p className="caps text-mini text-ink-dim mt-4">a personal collection</p>
<nav className="caps text-mini mt-5 flex justify-center">
<Link href="/album" className="text-brass hover:text-ink transition-colors">
the album
</Link>
</nav>
</header>
{countries.length === 0 ? (