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