New
Chip PileDraggable
A ratings breakdown whose five bars are a tally of stars that have actually been cast, one falling ball at a time. The bars wobble, and the wobble shrinks as the sample grows — so switching from every review to the last few hundred makes the same product go visibly noisy.
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCanvasScene, useReducedMotion } from '@/hooks/use-canvas-scene';
import './galton-histogram.css';
/**
* Galton histogram — the ratings breakdown on a product page, where the five bars are the
* tally of balls that have actually fallen through a four-row Galton board.
*
* The solve is the sampling law, not the fall. A ball takes ROWS independent Bernoulli(P)
* decisions, so the bin it ends in is k ~ Binomial(ROWS, P) with mass C(n,k)·p^k·q^(n−k), and
* the star is k + 1. Nothing here draws that shape: the bars are counts, incremented one at a
* time as a ball is absorbed.
*
* What it is not: five widths tweened to five target percentages. Two consequences a tween
* cannot have. The bars wobble, and the wobble shrinks as 1/sqrt(N). And the toggle changes
* nothing but N: the last WINDOW trials and every trial ever are one process read at two
* sample sizes, so the short window jitters by sqrt(N/WINDOW) times as much as the long one.
* A few thousand trials in, that is a factor of four, visible without being told.
*
* The board is not drawn. It is still there — the fall is what hands each trial to the tally —
* but the card shows only the average, the count and the five bars.
*/
const STEP = 1 / 120; // Ballistic, not stiff. Nothing here is stiff.
const MAX_STEPS = 12; // 0.1s of catch-up, which is a fifth of one fall.
const ROWS = 4; // Four decisions is five outcomes is five stars. That is the whole reason.
const BINS = ROWS + 1;
const P = 0.79; // Bernoulli bias. Puts the mean at 4.16 stars, which is what a real page shows.
const WINDOW = 240; // The short reading. Small enough to visibly jitter, big enough to read.
const SPAWN = 0.1; // s between arrivals, so about ten reviews a second at rest.
const RUSH = 8; // Press-and-hold multiplier. Buys a factor of ~3 on the interval in ten seconds.
const MAX_BALLS = 96;
const GRAV = 30; // In row-heights per second squared, so the fall scales with the board.
const PUBLISH = 0.25; // s between handing numbers to React. The bars are written every frame.
const SEED_N = 180; // Trials the scene starts from, so the first frame is not an empty chart.
const CALM = 6000; // Reduced motion: the sample size at which the answer stops moving.
// The mass the tally is an estimate of. Computed, not tabulated, so changing ROWS or P cannot
// leave a hand-written table behind to disagree with the balls.
function pmf(): number[] {
const out: number[] = [];
for (let k = 0; k <= ROWS; k += 1) {
let c = 1;
for (let i = 0; i < k; i += 1) {
c = (c * (ROWS - i)) / (i + 1);
}
out.push(c * Math.pow(P, k) * Math.pow(1 - P, ROWS - k));
}
return out;
}
const PMF = pmf();
// A deterministic tally for the first render. The scene replaces it within a frame with real
// trials, but server and client have to agree on the markup before that, so this cannot be
// sampled — it is the expectation, rounded.
function expect(n: number): number[] {
return PMF.map((p) => Math.round(p * n));
}
const SEED = expect(SEED_N);
interface Reading {
readonly counts: readonly number[];
readonly n: number;
}
const total = (counts: readonly number[]): number => counts.reduce((a, b) => a + b, 0);
const SEED_READING: Reading = { counts: SEED, n: total(SEED) };
// One ball. Between two pegs it is in free flight, so x is uniform in time and y is not.
interface Ball {
k: number; // decisions taken to the right, which is the column
row: number; // 0..ROWS; at ROWS it is in a bin, falling onto the pile
x: number;
x0: number;
x1: number;
y: number;
vy: number;
t: number; // s into this hop
span: number; // s this hop takes, from the fall time for one row height
goal: number; // y this hop ends at
}
// Where the board is, in card pixels. The floor is not a constant: it is measured off the top
// of the DOM block that holds the numbers, so the stylesheet owns that number and this file
// cannot disagree with it.
interface Board {
readonly cx: number;
readonly top: number;
readonly rowH: number;
readonly pitch: number; // horizontal spacing between adjacent columns, so a hop is pitch/2
readonly mouth: number; // y where the last peg row hands the ball to a bin
readonly floor: number;
readonly g: number; // px/s², from GRAV row-heights, so the fall time is width-independent
}
interface GaltonState {
readonly board: Board;
readonly balls: Ball[];
readonly all: number[]; // every trial since setup
readonly win: number[]; // the last WINDOW of them
readonly ring: Int8Array;
allN: number;
ringAt: number;
ringN: number;
since: number; // s of arrival budget not yet spent
rush: number;
carry: number;
clock: number;
pub: number;
short: boolean;
snap: boolean;
postedN: number;
}
// One trial is ROWS coin flips, which is the definition and not a shortcut: the ball that
// falls takes the same ROWS decisions, one per peg row.
function trial(): number {
let k = 0;
for (let i = 0; i < ROWS; i += 1) {
if (Math.random() < P) {
k += 1;
}
}
return k;
}
// The column a ball occupies. At row r there are r + 1 of them, half a pitch apart from the
// row above, which is why one decision moves the ball pitch/2 and not pitch.
const columnX = (board: Board, k: number, row: number): number =>
board.cx + (k - row / 2) * board.pitch;
const reading = (state: GaltonState): number[] => (state.short ? state.win : state.all);
// Where a ball comes to rest in a bin: the more trials that bin already holds, the shallower
// its last drop. Same counts as the bar in the row, scaled to the largest bin — one tally.
function pileTop(state: GaltonState, k: number): number {
const counts = reading(state);
let max = 1;
for (let i = 0; i < BINS; i += 1) {
if (counts[i] > max) {
max = counts[i];
}
}
const depth = state.board.floor - state.board.mouth - 3;
return state.board.floor - (counts[k] / max) * depth;
}
function record(state: GaltonState, k: number): void {
state.all[k] += 1;
state.allN += 1;
const at = state.ringAt;
if (state.ringN === WINDOW) {
state.win[state.ring[at]] -= 1; // the trial leaving the window, not a decay factor
} else {
state.ringN += 1;
}
state.ring[at] = k;
state.win[k] += 1;
state.ringAt = (at + 1) % WINDOW;
}
function build(width: number, height: number, bodyTop: number, short: boolean, snap: boolean): GaltonState {
const top = 14;
const floor = Math.max(top + 92, (bodyTop > 0 ? bodyTop : height * 0.42) - 12);
const room = floor - top;
const binDepth = Math.min(34, Math.max(20, room * 0.24));
const rowH = (room - binDepth) / ROWS;
const pitch = Math.min(38, Math.max(16, (width - 34) / BINS));
const board: Board = {
cx: width / 2,
top,
rowH,
pitch,
mouth: top + ROWS * rowH,
floor,
g: GRAV * rowH,
};
const state: GaltonState = {
board,
balls: [],
all: snap ? expect(CALM) : [0, 0, 0, 0, 0],
win: snap ? expect(WINDOW) : [0, 0, 0, 0, 0],
ring: new Int8Array(WINDOW),
allN: 0,
ringAt: 0,
ringN: snap ? WINDOW : 0,
since: 0,
rush: 1,
carry: 0,
clock: 0,
pub: PUBLISH, // publish on the first frame, whatever the seed turned out to be
short,
snap,
postedN: -1,
};
if (snap) {
state.allN = total(state.all);
return state;
}
for (let i = 0; i < SEED_N; i += 1) {
record(state, trial());
}
return state;
}
// Start the next leg from wherever the ball has just arrived. The decision is taken here, once
// per peg row, and it is the only random number in the fall: the arc itself is not sampled.
function hop(state: GaltonState, ball: Ball): void {
const b = state.board;
ball.x0 = ball.x;
ball.y = ball.goal; // snap to the row, so four hops cannot accumulate integration error
ball.t = 0;
if (ball.row < ROWS) {
if (Math.random() < P) {
ball.k += 1;
}
ball.row += 1;
ball.x1 = columnX(b, ball.k, ball.row);
ball.goal = b.top + ball.row * b.rowH;
} else {
ball.row = BINS; // past the last peg row: straight down onto the pile it will join
ball.x1 = columnX(b, ball.k, ROWS);
ball.goal = pileTop(state, ball.k);
}
// Time to fall the gap from the speed it already has. This is what makes the lower rows
// quick: vy is carried across pegs, never reset.
ball.span = (Math.sqrt(ball.vy * ball.vy + 2 * b.g * (ball.goal - ball.y)) - ball.vy) / b.g;
}
function spawn(state: GaltonState): void {
const b = state.board;
const y = b.top - b.rowH;
state.balls.push({
k: 0,
row: 0,
x: b.cx,
x0: b.cx,
x1: b.cx,
y,
vy: 0,
t: 0,
span: Math.sqrt((2 * b.rowH) / b.g),
goal: b.top,
});
}
// Returns true when the ball has been absorbed, which is the moment the tally changes.
function advance(state: GaltonState, ball: Ball, dt: number): boolean {
const b = state.board;
ball.t += dt;
ball.vy += b.g * dt;
ball.y += ball.vy * dt;
const f = ball.span > 0 ? Math.min(1, ball.t / ball.span) : 1;
ball.x = ball.x0 + (ball.x1 - ball.x0) * f; // uniform in time: free flight has no ax
if (ball.y < ball.goal) {
return false;
}
if (ball.row === BINS) {
record(state, ball.k);
return true;
}
hop(state, ball);
return false;
}
function step(state: GaltonState, dt: number): void {
if (state.snap) {
return;
}
state.since += dt * state.rush;
while (state.since >= SPAWN && state.balls.length < MAX_BALLS) {
state.since -= SPAWN;
spawn(state);
}
if (state.since > SPAWN) {
state.since = SPAWN; // held at the cap: drop the backlog rather than firing it later
}
for (let i = state.balls.length - 1; i >= 0; i -= 1) {
if (advance(state, state.balls[i], dt)) {
state.balls.splice(i, 1);
}
}
}
const mean = (counts: readonly number[], n: number): number => {
if (n <= 0) {
return 0;
}
let sum = 0;
for (let k = 0; k < BINS; k += 1) {
sum += k * counts[k];
}
return sum / n;
};
// Rows read downward from five stars; bins count upward from k = 0. Row i is bin ROWS − i, and
// the refs stay indexed by bin so the writer below never has to think about it.
const ROW_BINS = [4, 3, 2, 1, 0];
// Per-frame, and the only per-frame write into the DOM. A scaleX on a positioned child does
// not lay the row out again, and the threshold keeps a settled bar from being touched at all.
function writeBars(nodes: Array<HTMLDivElement | null>, painted: number[], counts: readonly number[]): void {
let max = 1;
for (let i = 0; i < BINS; i += 1) {
if (counts[i] > max) {
max = counts[i];
}
}
for (let i = 0; i < BINS; i += 1) {
const v = counts[i] / max;
if (Math.abs(v - painted[i]) < 0.002) {
continue;
}
painted[i] = v;
const node = nodes[i];
if (node) {
node.style.transform = 'scaleX(' + v.toFixed(4) + ')';
}
}
}
// Grouped by hand rather than by toLocaleString, which can disagree between the server render
// and the browser and take the whole tree down with a hydration mismatch.
const group = (n: number): string => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
/** `compact` is the 298x240 catalogue card: the same board, the same five rows and the same
* press-and-hold, with the hint dropped and the numbers tightened. The board's geometry is
* measured off the first line of copy and clamped to a fixed 92px of fall, so it is the same
* board at either size — see `galton-histogram.css`. */
export type GaltonHistogramProps = { compact?: boolean };
export function GaltonHistogram({ compact = false }: GaltonHistogramProps) {
const reduced = useReducedMotion();
const [short, setShort] = useState(false);
const [shown, setShown] = useState<Reading>(SEED_READING);
const headRef = useRef<HTMLParagraphElement | null>(null);
const fillRefs = useRef<Array<HTMLDivElement | null>>([]);
const paintedRef = useRef<number[]>([0, 0, 0, 0, 0]);
const shortRef = useRef(false);
const { stageRef, canvasRef, requestRender } = useCanvasScene<GaltonState>({
// The geometry comes off the first line of copy, not off the block that holds it: the block
// starts at the top of the card, so the stylesheet and not this file decides where it ends.
setup: ({ width, height }) =>
build(width, height, headRef.current ? headRef.current.offsetTop : 0, shortRef.current, reduced),
draw: ({ context, width, height, state, pointer }) => {
if (state.short !== shortRef.current) {
state.short = shortRef.current;
state.postedN = -1; // the reading changed without a trial arriving, so force one publish
}
state.rush = pointer.down ? RUSH : 1;
if (!state.snap) {
const now = performance.now();
const dt = state.clock ? Math.min(0.05, (now - state.clock) / 1000) : STEP;
state.clock = now;
state.carry += dt;
let taken = 0;
while (state.carry >= STEP && taken < MAX_STEPS) {
step(state, STEP);
state.carry -= STEP;
taken += 1;
}
if (state.carry > STEP * MAX_STEPS) {
state.carry = 0; // back from a background tab: do not pour the missing minute in
}
state.pub += dt;
}
const counts = reading(state);
writeBars(fillRefs.current, paintedRef.current, counts);
if (state.pub >= PUBLISH && state.allN !== state.postedN) {
// Four times a second, not sixty. The bars are the fast channel; the numbers beside
// them only have to be readable, and a re-render per trial would be neither.
state.pub = 0;
state.postedN = state.allN;
setShown({ counts: counts.slice(), n: state.short ? state.ringN : state.allN });
}
// Nothing is drawn: the fall runs so the tally is real, and the rows are the only output.
context.clearRect(0, 0, width, height);
},
});
const pick = useCallback((next: boolean) => {
setShort(next);
}, []);
// The loop reads the window through a ref, so the press has to poke the renderer too — under
// reduced motion there is no loop running to notice the change.
useEffect(() => {
shortRef.current = short;
requestRender();
}, [short, reduced, requestRender]);
const avg = mean(shown.counts, shown.n) + 1;
return (
<div className="galton-histogram-stage" data-compact={compact ? 'true' : undefined}>
<div className="galton-histogram-card">
<div className="galton-histogram-well" ref={stageRef} aria-hidden="true">
<canvas ref={canvasRef} />
</div>
<div className="galton-histogram-body">
<p className="galton-histogram-label" ref={headRef}>
Customer ratings
</p>
<div className="galton-histogram-headline">
<span className="galton-histogram-mean">{avg.toFixed(2)}</span>
<span className="galton-histogram-outof">out of 5</span>
<span className="galton-histogram-total">{group(shown.n)} ratings</span>
</div>
<ul className="galton-histogram-rows">
{ROW_BINS.map((bin) => {
const share = shown.n > 0 ? shown.counts[bin] / shown.n : 0;
return (
<li className="galton-histogram-row" key={bin}>
<span className="galton-histogram-star">{bin + 1}★</span>
<div className="galton-histogram-track" aria-hidden="true">
<div
className="galton-histogram-fill"
ref={(el) => {
fillRefs.current[bin] = el;
}}
/>
</div>
<span className="galton-histogram-share">{(share * 100).toFixed(1)}%</span>
</li>
);
})}
</ul>
</div>
<div className="galton-histogram-foot">
<button
type="button"
className="galton-histogram-button"
aria-pressed={!short}
/* Still pressable in a card, but out of the tab order: the card frame is
aria-hidden, and a focusable node inside one is a trap with no label. */
tabIndex={compact ? -1 : undefined}
onClick={() => pick(false)}
>
All time
</button>
<button
type="button"
className="galton-histogram-button"
aria-pressed={short}
tabIndex={compact ? -1 : undefined}
onClick={() => pick(true)}
>
Recent
</button>
</div>
<p className="galton-histogram-hint">Hold to load more</p>
</div>
</div>
);
}
export default GaltonHistogram;.galton-histogram-stage {
position: relative;
display: grid;
place-content: center;
width: 100%;
min-height: 19.5rem;
padding: 2.25rem 1.5rem;
overflow: hidden;
border-radius: 0.75rem;
background: radial-gradient(120% 110% at 50% 0%, #0c1620 0%, #070b12 60%, #05070c 100%);
color: #eaf3ff;
}
.galton-histogram-card {
position: relative;
display: flex;
width: min(24rem, 100%);
min-height: 15rem;
flex-direction: column;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.09);
border-radius: 1rem;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.015));
isolation: isolate;
}
/* The press surface, behind the numbers and covering the whole card: the canvas paints nothing,
but it is what takes a press-and-hold, and the tsx measures the stack below against it. */
.galton-histogram-well {
position: absolute;
inset: 0;
}
.galton-histogram-well canvas {
display: block;
width: 100%;
height: 100%;
}
/* The numbers, which are now the whole card. The tsx reads this element's first line for its
own geometry, so this padding stays the one place that offset is written. */
.galton-histogram-body {
position: relative;
z-index: 1;
display: flex;
flex: 1;
flex-direction: column;
padding: 1.25rem 1.25rem 0.875rem;
pointer-events: none;
}
.galton-histogram-label {
margin: 0;
font: 500 0.6875rem/1 ui-monospace, "SFMono-Regular", Menlo, monospace;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgba(234, 243, 255, 0.5);
}
.galton-histogram-headline {
display: flex;
align-items: baseline;
gap: 0.4375rem;
margin: 0.4375rem 0 0;
}
.galton-histogram-mean {
font-size: 1.75rem;
font-weight: 500;
line-height: 1;
letter-spacing: -0.03em;
font-variant-numeric: tabular-nums;
}
.galton-histogram-outof {
font-size: 0.8125rem;
color: rgba(234, 243, 255, 0.42);
}
.galton-histogram-total {
margin-left: auto;
font: 500 0.6875rem/1 ui-monospace, "SFMono-Regular", Menlo, monospace;
color: rgba(234, 243, 255, 0.42);
font-variant-numeric: tabular-nums;
}
/* Five rows because a ball takes four decisions, so it can land in five places, and k + 1 is
the star. They own the height the card used to spend above them. */
.galton-histogram-rows {
display: flex;
margin: 1rem 0 0;
padding: 0;
flex-direction: column;
gap: 0.5rem;
list-style: none;
}
.galton-histogram-row {
display: flex;
align-items: center;
gap: 0.5rem;
font: 500 0.6875rem/1 ui-monospace, "SFMono-Regular", Menlo, monospace;
font-variant-numeric: tabular-nums;
}
.galton-histogram-star {
width: 1.625rem;
color: rgba(234, 243, 255, 0.55);
letter-spacing: 0.04em;
}
.galton-histogram-track {
position: relative;
flex: 1;
height: 0.875rem;
overflow: hidden;
border-radius: 0.4375rem;
background: rgba(255, 255, 255, 0.055);
}
/* Written by the loop as a scaleX, not as a width, so a bar that is still settling does not
put the row through layout sixty times a second. */
.galton-histogram-fill {
position: absolute;
inset: 0;
transform: scaleX(0);
transform-origin: left center;
border-radius: inherit;
background: linear-gradient(90deg, rgba(158, 205, 255, 0.5), rgba(158, 205, 255, 0.85));
}
.galton-histogram-share {
width: 2.375rem;
text-align: right;
color: rgba(234, 243, 255, 0.72);
}
/*
* The window toggle. A sibling of the body rather than a child of it: the body is transparent
* to the pointer so the surface underneath can take a press-and-hold, and a button inside it
* would be tabbable but not clickable.
*/
.galton-histogram-foot {
position: absolute;
bottom: 1rem;
left: 1.25rem;
z-index: 2;
display: flex;
gap: 0.375rem;
}
.galton-histogram-button {
padding: 0.375rem 0.6875rem;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 0.4375rem;
background: rgba(255, 255, 255, 0.03);
color: rgba(234, 243, 255, 0.6);
font: 500 0.6875rem/1 ui-monospace, "SFMono-Regular", Menlo, monospace;
letter-spacing: 0.06em;
text-transform: uppercase;
cursor: pointer;
transition: color 160ms ease, border-color 160ms ease;
}
.galton-histogram-button[aria-pressed='true'] {
border-color: rgba(158, 205, 255, 0.45);
background: linear-gradient(180deg, rgba(158, 205, 255, 0.16), rgba(8, 14, 22, 0.6));
color: #eaf3ff;
}
.galton-histogram-button:hover {
border-color: rgba(158, 205, 255, 0.55);
color: #eaf3ff;
}
.galton-histogram-button:focus-visible {
outline: 2px solid rgba(158, 205, 255, 0.75);
outline-offset: 2px;
}
.galton-histogram-hint {
position: absolute;
right: 0.9375rem;
bottom: 1.25rem;
z-index: 1;
margin: 0;
font: 500 0.6875rem/1 ui-monospace, "SFMono-Regular", Menlo, monospace;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(234, 243, 255, 0.28);
pointer-events: none;
}
/* Narrow: pull the side padding in so the bars keep as much length as the card can give them,
and bring the toggle in with it. */
@media (max-width: 24rem) {
.galton-histogram-body {
padding: 1.25rem 1rem 0.875rem;
}
.galton-histogram-foot {
left: 1rem;
}
}
/*
* With the loop stopped the tally is seeded with a large sample drawn in setup — so the bars and
* the mean are the answer the running scene converges to. What is gone is the convergence.
*/
@media (prefers-reduced-motion: reduce) {
.galton-histogram-button {
transition: none;
}
}
/*
* The card variant: the 298x240 catalogue frame, at that real size and never scaled.
* The board is untouched, and not by luck: `floor` is `max(top + 92, bodyTop − 12)`, so the
* fall is a fixed 92px whatever the card measures, and the pitch is already at its 38px clamp
* at both widths. The same four decisions land in the same five bins. Nothing is drawn on the
* canvas either way — the rows are the whole output — so all of this is spacing.
*
* No `touch-action` rule here, unlike the other cards: this surface never claimed the
* gesture, so a vertical drag over it has always scrolled the page.
*/
.galton-histogram-stage[data-compact='true'] {
min-height: 0;
height: 100%;
/* `place-content: center` leaves the single row auto-sized, and a row sized from its
content is what the card's `height: 100%` would then resolve against — which would
put the absolutely-placed toggle back over the bottom row. Stretched, the row is the
frame and the card is the row. */
place-content: stretch;
padding: 0.625rem;
/* The card frame rounds and clips already. */
border-radius: 0;
}
.galton-histogram-stage[data-compact='true'] .galton-histogram-card {
width: 100%;
height: 100%;
min-height: 0;
}
/* 14px of head room rather than 20. `bodyTop` is read from this padding and floors at 106px,
which it clears at both values, so the board does not move. */
.galton-histogram-stage[data-compact='true'] .galton-histogram-body {
padding: 0.875rem 0.875rem 0.625rem;
}
.galton-histogram-stage[data-compact='true'] .galton-histogram-mean {
font-size: 1.5rem;
}
/* 12px above the rows and 6px between them: five rows, the headline and the toggle clear
220px with room over, where the full card's 16 and 8 would not. */
.galton-histogram-stage[data-compact='true'] .galton-histogram-rows {
margin-top: 0.75rem;
gap: 0.375rem;
}
.galton-histogram-stage[data-compact='true'] .galton-histogram-foot {
bottom: 0.625rem;
left: 0.875rem;
}
.galton-histogram-stage[data-compact='true'] .galton-histogram-button {
padding: 0.3125rem 0.5625rem;
font-size: 0.625rem;
}
/* The toggle needs the width back, and the card's own title carries the affordance. */
.galton-histogram-stage[data-compact='true'] .galton-histogram-hint {
display: none;
}"use client"
import { useCallback, useEffect, useRef, useState } from "react"
/**
* The canvas preamble every 2D scene needs, in one place: a DPR-scaled backing
* store, a rebuild on resize, a loop that stops when the stage scrolls out of
* view, pointer tracking with per-frame deltas, and teardown.
*
* A scene supplies two functions. `setup` builds whatever mutable state the
* animation owns and is re-run whenever the stage changes size, so the state can
* be sized to the stage without ever being resized in place. `draw` paints one
* frame from that state — it is called with the transform already scaled to
* device pixels, so every coordinate in it is a CSS pixel.
*/
export type ScenePointer = {
x: number
y: number
/** Position at the previous painted frame, so `x - lastX` is a frame delta. */
lastX: number
lastY: number
down: boolean
inside: boolean
}
export type SceneSetupContext = {
context: CanvasRenderingContext2D
width: number
height: number
dpr: number
}
export type SceneDrawContext<State> = SceneSetupContext & {
state: State
pointer: ScenePointer
/** Painted frames since the last rebuild. Useful for every-Nth-frame work. */
frame: number
}
export type CanvasSceneOptions<State> = {
setup: (context: SceneSetupContext) => State
draw: (context: SceneDrawContext<State>) => void
}
export type CanvasScene = {
/** The sizing element. Owns the pointer listeners and is what is observed. */
stageRef: (node: HTMLDivElement | null) => void
canvasRef: (node: HTMLCanvasElement | null) => void
/** Paint one frame now. The escape hatch for a paused or reduced-motion loop. */
requestRender: () => void
}
/** Live `prefers-reduced-motion`. False during SSR and the first paint. */
export function useReducedMotion() {
const [reduced, setReduced] = useState(false)
useEffect(() => {
const query = window.matchMedia("(prefers-reduced-motion: reduce)")
setReduced(query.matches)
const onChange = () => setReduced(query.matches)
query.addEventListener("change", onChange)
return () => query.removeEventListener("change", onChange)
}, [])
return reduced
}
export function useCanvasScene<State>(options: CanvasSceneOptions<State>): CanvasScene {
const reduced = useReducedMotion()
/*
* `draw` is usually an inline closure, so it is a new function on every
* render. Reading it through a ref keeps the loop from being torn down and
* the scene from being rebuilt each time the component re-renders.
*/
const optionsRef = useRef(options)
optionsRef.current = options
const stage = useRef<HTMLDivElement | null>(null)
const canvas = useRef<HTMLCanvasElement | null>(null)
/*
* Plain ref assignment, with no state behind it. React attaches refs during
* the commit phase, before passive effects run, so the effect below already
* sees both nodes on the first mount — which is why these used to bump a
* `mounted` counter for nothing: the two `setMounted` calls batched into one
* re-render, the counter went 0 → 2, and the effect's dependency on it tore
* the live scene down and rebuilt it. Every scene was constructed, measured
* and warmed twice on every mount, four times under StrictMode in dev.
*
* The requirement this trades for that: a consumer must render the stage and
* the canvas unconditionally, in the same commit as the component itself. All
* thirteen do. Gating the canvas behind a flag would leave the effect bailing
* on the null guard with nothing to re-run it.
*/
const stageRef = useCallback((node: HTMLDivElement | null) => {
stage.current = node
}, [])
const canvasRef = useCallback((node: HTMLCanvasElement | null) => {
canvas.current = node
}, [])
/** Set once the scene is live, so `requestRender` before that is a no-op. */
const render = useRef<(() => void) | null>(null)
const requestRender = useCallback(() => render.current?.(), [])
useEffect(() => {
const stageNode = stage.current
const canvasNode = canvas.current
if (!stageNode || !canvasNode) return
const context = canvasNode.getContext("2d")
if (!context) return
const pointer: ScenePointer = {
x: 0,
y: 0,
lastX: 0,
lastY: 0,
down: false,
inside: false,
}
let state: State | null = null
let width = 0
let height = 0
let dpr = 1
let frame = 0
let loop = 0
let pending = 0
let visible = true
/** Rebuild the backing store and the scene state for the current size. */
const measure = () => {
// `offsetWidth`/`offsetHeight`, not `getBoundingClientRect()`: the rect is
// post-transform, so a scene sitting inside a scaled ancestor measured its
// own frame at the scaled size, sized the backing store to that, and then
// had CSS scale the result a second time — the scene ran at a fraction of
// the box it was drawn into. The catalogue's scaled-poster branch is the
// one place that happens, and it is reachable again the moment an
// animation is registered without a card composition. These two properties
// are the untransformed layout box; both are integers, which is what the
// rounding below already reduced the rect to.
const nextWidth = Math.max(1, stageNode.offsetWidth)
const nextHeight = Math.max(1, stageNode.offsetHeight)
const nextDpr = Math.min(2, window.devicePixelRatio || 1)
if (nextWidth === width && nextHeight === height && nextDpr === dpr && state) return
width = nextWidth
height = nextHeight
dpr = nextDpr
canvasNode.width = Math.round(width * dpr)
canvasNode.height = Math.round(height * dpr)
canvasNode.style.width = `${width}px`
canvasNode.style.height = `${height}px`
frame = 0
state = optionsRef.current.setup({ context, width, height, dpr })
}
const paint = () => {
if (!state) return
// Re-applied every frame: a scene is free to install its own transform
// for a cell or a sprite, and most do.
context.setTransform(dpr, 0, 0, dpr, 0, 0)
optionsRef.current.draw({ context, width, height, dpr, state, pointer, frame })
pointer.lastX = pointer.x
pointer.lastY = pointer.y
frame += 1
}
/** One frame on the next tick, coalescing however many were asked for. */
const paintOnce = () => {
if (pending) return
pending = requestAnimationFrame(() => {
pending = 0
measure()
paint()
})
}
render.current = paintOnce
const tick = () => {
loop = requestAnimationFrame(tick)
if (visible) paint()
}
const start = () => {
if (loop || reduced) return
loop = requestAnimationFrame(tick)
}
const stop = () => {
if (!loop) return
cancelAnimationFrame(loop)
loop = 0
}
const at = (event: PointerEvent) => {
const rect = stageNode.getBoundingClientRect()
// The rect is the right thing to subtract here — `clientX` is viewport
// space and so is the rect — but the difference comes back in *rendered*
// pixels, and a scene reads `pointer` in the scene pixels `measure()` set
// up from the untransformed box. Under a CSS scale those two disagree, so
// divide the transform back out. `rect.width / offsetWidth` is the scale
// actually in force, whatever produced it, and it is exactly 1 when there
// is none.
const scale = stageNode.offsetWidth > 0 ? rect.width / stageNode.offsetWidth : 1
pointer.x = (event.clientX - rect.left) / (scale || 1)
pointer.y = (event.clientY - rect.top) / (scale || 1)
// A frozen loop still owes the user feedback for a drag.
if (reduced) paintOnce()
}
const onEnter = (event: PointerEvent) => {
pointer.inside = true
at(event)
pointer.lastX = pointer.x
pointer.lastY = pointer.y
}
const onMove = (event: PointerEvent) => {
pointer.inside = true
at(event)
}
const onDown = (event: PointerEvent) => {
pointer.down = true
at(event)
// Capture keeps a drag alive past the edge of the stage, which is where
// a hard throw naturally ends up.
stageNode.setPointerCapture(event.pointerId)
}
const onUp = (event: PointerEvent) => {
pointer.down = false
at(event)
if (stageNode.hasPointerCapture(event.pointerId)) {
stageNode.releasePointerCapture(event.pointerId)
}
}
const onLeave = () => {
pointer.inside = false
pointer.down = false
if (reduced) paintOnce()
}
stageNode.addEventListener("pointerenter", onEnter)
stageNode.addEventListener("pointermove", onMove)
stageNode.addEventListener("pointerdown", onDown)
stageNode.addEventListener("pointerup", onUp)
stageNode.addEventListener("pointercancel", onUp)
stageNode.addEventListener("pointerleave", onLeave)
const resizes = new ResizeObserver(() => paintOnce())
resizes.observe(stageNode)
/*
* An animation nobody can see is heat. The observer both pauses the loop
* and, on the way back in, repaints immediately rather than waiting a frame.
*/
const views = new IntersectionObserver(
(entries) => {
visible = entries.some((entry) => entry.isIntersecting)
if (visible) {
start()
paintOnce()
} else {
stop()
}
},
{ rootMargin: "120px" },
)
views.observe(stageNode)
measure()
paint()
start()
return () => {
render.current = null
stop()
if (pending) cancelAnimationFrame(pending)
resizes.disconnect()
views.disconnect()
stageNode.removeEventListener("pointerenter", onEnter)
stageNode.removeEventListener("pointermove", onMove)
stageNode.removeEventListener("pointerdown", onDown)
stageNode.removeEventListener("pointerup", onUp)
stageNode.removeEventListener("pointercancel", onUp)
stageNode.removeEventListener("pointerleave", onLeave)
}
}, [reduced])
return { stageRef, canvasRef, requestRender }
}