Add album
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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 [xBL−spineWidth−W .. xBL−spineWidth]
|
||||
// spine [xBL−spineWidth .. 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user