createGaltonBoard = () => {
const element = document.createElement("section");
element.className = "galton-lab";
element.setAttribute("aria-label", "Interactive Galton board");
element.innerHTML = [
'<div class="galton-controls">',
'<label class="galton-control"><span>Balls <output data-value="balls">1,000</output></span>',
'<input name="balls" type="range" min="100" max="5000" step="100" value="1000"></label>',
'<label class="galton-control"><span>Peg rows <output data-value="rows">16</output></span>',
'<input name="rows" type="range" min="4" max="30" step="1" value="16"></label>',
'<label class="galton-control"><span>Right-bounce probability <output data-value="probability">50%</output></span>',
'<input name="probability" type="range" min="0" max="100" step="5" value="50"></label>',
'</div>',
'<div class="galton-toolbar">',
'<div class="galton-actions">',
'<button type="button" class="galton-primary" data-action="run">Drop balls</button>',
'<button type="button" data-action="trace">Trace one ball</button>',
'<button type="button" data-action="finish" disabled>Finish run</button>',
'<button type="button" data-action="reset">Reset</button>',
'</div>',
'<label class="galton-speed">Speed <select name="speed">',
'<option value="0.5">0.5×</option><option value="1" selected>1×</option><option value="4">4×</option>',
'</select></label>',
'</div>',
'<p class="galton-status" role="status">Ready. Trace one ball or drop the sample.</p>',
'<div class="galton-board-wrap"><canvas class="galton-board" role="img" aria-label="Balls make independent left or right moves through a triangular array of pegs. The chart below counts their landing bins."></canvas></div>',
'<p class="galton-trace">Trace one ball to follow its left and right bounces.</p>',
'<div class="galton-chart-heading"><strong>Where the balls land</strong><span>Bin = number of right bounces</span></div>',
'<svg class="galton-histogram" role="group" aria-label="Landing distribution: focus or hover over a bin for its probability and observed count."></svg>',
'<div class="plot-legend galton-legend">',
'<span><span class="swatch galton-observed-swatch"></span>Observed share</span>',
'<label><input name="exact" type="checkbox" checked><span class="swatch-line galton-exact-swatch"></span>Exact binomial</label>',
'<label><input name="normal" type="checkbox" checked><span class="swatch-line galton-normal-swatch"></span>Normal approximation</label>',
'</div>',
'<p class="galton-bin-detail">Hover, tap, or focus a bin to inspect its values.</p>',
'<dl class="galton-stats">',
'<div><dt>Balls landed</dt><dd data-stat="landed">0 / 1,000</dd></div>',
'<div><dt>Mean bin</dt><dd data-stat="mean">—</dd><small data-stat="expected-mean"></small></div>',
'<div><dt>Standard deviation</dt><dd data-stat="sd">—</dd><small data-stat="expected-sd"></small></div>',
'</dl>',
'<p class="galton-note"></p>',
'<p class="galton-help">Changing balls, rows, or probability clears the sample. Speed only changes the animation.</p>',
].join("");
const find = (selector) => element.querySelector(selector);
const controls = {
balls: find('[name="balls"]'),
rows: find('[name="rows"]'),
probability: find('[name="probability"]'),
speed: find('[name="speed"]'),
exact: find('[name="exact"]'),
normal: find('[name="normal"]'),
};
const runButton = find('[data-action="run"]');
const traceButton = find('[data-action="trace"]');
const finishButton = find('[data-action="finish"]');
const status = find(".galton-status");
const traceLabel = find(".galton-trace");
const detail = find(".galton-bin-detail");
const canvas = find("canvas");
const ctx = canvas.getContext("2d");
const chart = find("svg");
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
const listeners = new AbortController();
const listen = (target, event, handler) => target.addEventListener(event, handler, { signal: listeners.signal });
const svgNS = "http://www.w3.org/2000/svg";
const addSvg = (tag, attributes, parent = chart) => {
const node = document.createElementNS(svgNS, tag);
Object.entries(attributes).forEach(([key, value]) => node.setAttribute(key, value));
parent.append(node);
return node;
};
const number = (value) => value.toLocaleString("en-US");
const percent = (value) => (100 * value).toFixed(1) + "%";
const colors = { ball: "#2196F3", exact: "#ff7f0e", normal: "#868e96", trace: "#1f4f9a" };
let rows, probability, target, counts, expected, normalBins, expectedMean, expectedSD;
let produced = 0, landed = 0, sum = 0, sumSquares = 0;
let active = [], traced = null, running = false, tracing = false;
let frame = null, previousTime = null, spawnCredit = 0, chartElapsed = 0;
let width = 640, boardHeight = 280, spacing = 30, left = 60, rowHeight = 13;
let bars = [], exactMarks = [], normalLine, meanLine, grid = [];
let selectedBin = null, destroyed = false;
const binX = (k) => left + k * spacing;
const pegX = (row, rights) => binX(rights) + (rows - row) * spacing / 2;
const pegY = (row) => 34 + row * rowHeight;
function calculateDistribution() {
expected = Array(rows + 1).fill(0);
// Repeated Bernoulli convolution is stable even at p = 0 or p = 1.
expected[0] = 1;
for (let trial = 0; trial < rows; trial++) {
for (let k = trial + 1; k >= 0; k--) {
expected[k] = (expected[k] || 0) * (1 - probability) +
(k > 0 ? expected[k - 1] * probability : 0);
}
}
expectedMean = rows * probability;
expectedSD = Math.sqrt(rows * probability * (1 - probability));
// Integrate over each unit-width bin (continuity correction).
normalBins = expected.map((_, k) => expectedSD === 0 ? 0 :
0.5 * (erf((k + 0.5 - expectedMean) / (expectedSD * Math.SQRT2)) -
erf((k - 0.5 - expectedMean) / (expectedSD * Math.SQRT2))));
}
function updateStatus(message) {
status.textContent = message;
runButton.textContent = running ? "Pause" : landed === target ? "Run again" :
active.length ? "Resume" : produced ? "Drop remaining" : "Drop balls";
traceButton.disabled = running || active.length > 0;
finishButton.disabled = landed === target;
}
function inspectBin(k) {
selectedBin = k;
detail.textContent = "Bin " + k + ": " + number(counts[k]) + " balls · observed " +
(landed ? percent(counts[k] / landed) : "—") + " · exact " + percent(expected[k]) +
(expectedSD ? " · normal ≈ " + percent(normalBins[k]) : "");
}
function updateChart() {
const shares = counts.map((count) => landed ? count / landed : 0);
const peak = Math.max(...expected, ...normalBins, ...shares);
const maximum = Math.min(1, Math.ceil(peak * 1.12 / 0.05) * 0.05);
const baseline = 190;
const y = (value) => baseline - value / maximum * 155;
grid.forEach(({ line, text }, i) => {
const value = maximum * i / 4;
line.setAttribute("y1", y(value));
line.setAttribute("y2", y(value));
text.setAttribute("y", y(value) + 4);
text.textContent = Math.round(100 * value) + "%";
});
bars.forEach(({ rect, hit, title }, k) => {
rect.setAttribute("y", y(shares[k]));
rect.setAttribute("height", baseline - y(shares[k]));
const description = "Bin " + k + ": " + counts[k] + " balls, observed " +
(landed ? percent(shares[k]) : "not yet available") + ", exact " + percent(expected[k]) +
(expectedSD ? ", normal approximation " + percent(normalBins[k]) : "");
hit.setAttribute("aria-label", description);
title.textContent = description;
exactMarks[k].setAttribute("y1", y(expected[k]));
exactMarks[k].setAttribute("y2", y(expected[k]));
exactMarks[k].style.display = controls.exact.checked ? "" : "none";
});
normalLine.setAttribute("d", normalBins.map((value, k) =>
(k === 0 ? "M" : "L") + binX(k) + "," + y(value)).join(" "));
normalLine.style.display = controls.normal.checked && expectedSD > 0 ? "" : "none";
meanLine.setAttribute("x1", binX(expectedMean));
meanLine.setAttribute("x2", binX(expectedMean));
find('[data-stat="landed"]').textContent = number(landed) + " / " + number(target);
const observedMean = landed ? sum / landed : 0;
find('[data-stat="mean"]').textContent = landed ? observedMean.toFixed(2) : "—";
// Empirical distribution SD: population denominator, rather than sample estimate.
find('[data-stat="sd"]').textContent = landed ?
Math.sqrt(Math.max(0, sumSquares / landed - observedMean * observedMean)).toFixed(2) : "—";
find('[data-stat="expected-mean"]').textContent = "Theoretical: " + expectedMean.toFixed(2);
find('[data-stat="expected-sd"]').textContent = "Theoretical: " + expectedSD.toFixed(2);
if (selectedBin !== null) inspectBin(selectedBin);
}
function buildChart() {
chart.replaceChildren();
chart.setAttribute("viewBox", "0 0 " + width + " 238");
grid = [];
for (let i = 0; i <= 4; i++) {
const line = addSvg("line", { x1: left - spacing / 2, x2: binX(rows) + spacing / 2, class: "galton-grid" });
const text = addSvg("text", { x: 38, "text-anchor": "end", class: "galton-axis" });
grid.push({ line, text });
}
addSvg("text", { x: 8, y: 16, class: "galton-axis" }).textContent = "Share";
meanLine = addSvg("line", { y1: 28, y2: 190, class: "galton-mean-line" });
bars = counts.map((_, k) => {
const rect = addSvg("rect", { x: binX(k) - spacing * 0.36, width: spacing * 0.72,
rx: Math.min(3, spacing * 0.15), fill: colors.ball, "fill-opacity": 0.8 });
return { rect };
});
exactMarks = expected.map((_, k) => addSvg("line", {
x1: binX(k) - spacing * 0.38, x2: binX(k) + spacing * 0.38,
stroke: colors.exact, "stroke-width": 2.5,
}));
normalLine = addSvg("path", { fill: "none", stroke: colors.normal,
"stroke-width": 2.5, "stroke-dasharray": "6 4", "stroke-linejoin": "round" });
bars.forEach((bar, k) => {
const hit = addSvg("rect", { x: binX(k) - spacing / 2, y: 28,
width: spacing, height: 166, fill: "transparent", tabindex: 0,
role: "img", class: "galton-bin" });
const title = addSvg("title", {}, hit);
// These nodes are replaced on resize/reset, so listeners leave with the nodes.
hit.addEventListener("pointerenter", () => inspectBin(k));
hit.addEventListener("focus", () => inspectBin(k));
hit.addEventListener("click", () => inspectBin(k));
hit.addEventListener("keydown", (event) => {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
event.preventDefault();
bars[Math.max(0, Math.min(rows, k + (event.key === "ArrowRight" ? 1 : -1)))].hit.focus();
});
Object.assign(bar, { hit, title });
const tickStep = Math.max(1, Math.ceil(24 / spacing));
if (k === 0 || k === rows || (k % tickStep === 0 && rows - k >= tickStep)) {
addSvg("text", { x: binX(k), y: 210, "text-anchor": "middle", class: "galton-axis" }).textContent = k;
}
});
addSvg("text", { x: (binX(0) + binX(rows)) / 2, y: 231,
"text-anchor": "middle", class: "galton-axis" }).textContent = "Right bounces (K)";
updateChart();
}
function drawBoard() {
ctx.clearRect(0, 0, width, boardHeight);
ctx.fillStyle = "#64748b";
ctx.font = "12px system-ui, sans-serif";
ctx.textAlign = "left";
ctx.fillText("← Left " + Math.round((1 - probability) * 100) + "%", 12, 18);
ctx.textAlign = "right";
ctx.fillText("Right " + Math.round(probability * 100) + "% →", width - 12, 18);
ctx.strokeStyle = "#dbe3ed";
ctx.lineWidth = 1;
for (let k = 0; k <= rows + 1; k++) {
const x = binX(k) - spacing / 2;
ctx.beginPath();
ctx.moveTo(x, pegY(rows) - 3);
ctx.lineTo(x, boardHeight - 4);
ctx.stroke();
}
if (traced) {
const lastStep = Math.min(rows, Math.floor(traced.progress));
ctx.strokeStyle = "#9fbad4";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(pegX(0, 0), pegY(0));
for (let i = 1; i <= lastStep; i++) ctx.lineTo(pegX(i, traced.path[i]), pegY(i));
ctx.stroke();
}
ctx.fillStyle = "#a8b7c8";
for (let row = 0; row < rows; row++) {
for (let k = 0; k <= row; k++) {
ctx.beginPath();
ctx.arc(pegX(row, k), pegY(row), Math.max(1.4, Math.min(2.5, spacing * 0.09)), 0, Math.PI * 2);
ctx.fill();
}
}
active.forEach((ball) => {
const row = Math.floor(ball.progress);
const t = ball.progress - row;
const eased = t * t * (3 - 2 * t);
const x1 = row < 0 ? pegX(0, 0) : pegX(row, ball.path[row]);
const x2 = pegX(row + 1, ball.path[row + 1]);
const x = x1 + (x2 - x1) * eased;
const y = pegY(row) + rowHeight * t - Math.sin(Math.PI * t) * 3;
ctx.fillStyle = ball === traced ? colors.trace : colors.ball;
ctx.beginPath();
ctx.arc(x, y - 3, Math.max(2.3, Math.min(4, spacing * 0.15)), 0, Math.PI * 2);
ctx.fill();
});
if (traced && traced.progress >= rows) {
ctx.fillStyle = colors.trace;
ctx.beginPath();
ctx.arc(binX(traced.path[rows]), pegY(rows) + 5, 4, 0, Math.PI * 2);
ctx.fill();
}
}
function updateTrace() {
if (!traced) {
traceLabel.textContent = "Trace one ball to follow its left and right bounces.";
return;
}
const steps = Math.max(0, Math.min(rows, Math.floor(traced.progress)));
const directions = traced.path.slice(1, steps + 1).map((k, i) => k > traced.path[i] ? "R" : "L");
traceLabel.textContent = (steps === rows ? "Highlighted ball: " : "Following one ball: ") +
(directions.length ? directions.join(" ") : "entering the board…") +
(steps === rows ? " → " + traced.path[rows] + " right bounces → bin " + traced.path[rows] : "");
}
function resize() {
if (destroyed) return;
width = Math.max(240, Math.round(canvas.getBoundingClientRect().width || 640));
left = 48 + (width - 64) / (rows + 1) / 2;
spacing = (width - 64) / (rows + 1);
boardHeight = Math.max(230, Math.min(360, 65 + rows * 13));
rowHeight = (boardHeight - 58) / rows;
const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(width * pixelRatio);
canvas.height = Math.round(boardHeight * pixelRatio);
canvas.style.height = boardHeight + "px";
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
buildChart();
drawBoard();
}
function stop() {
running = false;
if (frame !== null) cancelAnimationFrame(frame);
frame = null;
previousTime = null;
}
function reset() {
stop();
rows = Number(controls.rows.value);
probability = Number(controls.probability.value) / 100;
target = Number(controls.balls.value);
["balls", "rows", "probability"].forEach((name) => {
find('[data-value="' + name + '"]').textContent = number(Number(controls[name].value)) +
(name === "probability" ? "%" : "");
});
counts = Array(rows + 1).fill(0);
produced = landed = sum = sumSquares = spawnCredit = chartElapsed = 0;
active = [];
traced = null;
selectedBin = null;
tracing = false;
calculateDistribution();
controls.normal.disabled = expectedSD === 0;
find(".galton-note").textContent = expectedSD === 0 ?
"Every bounce goes the same way, so every ball lands in bin " + expectedMean + ". With zero spread, a normal approximation is not defined." :
Math.min(rows * probability, rows * (1 - probability)) < 5 ?
"The normal approximation is rough here: np or n(1 − p) is below 5. The exact binomial remains valid; add rows or move p closer to 50% to improve the approximation." :
"The dashed curve uses a normal distribution with mean np and variance np(1 − p), integrated over each bin. The vertical guide marks the theoretical mean.";
detail.textContent = "Hover, tap, or focus a bin to inspect its values.";
updateTrace();
resize();
updateStatus("Ready. Trace one ball or drop the sample." +
(reducedMotion.matches ? " Reduced motion is on; runs show their results immediately." : ""));
}
function makeBall() {
const path = [0];
for (let row = 0; row < rows; row++) {
path.push(path[row] + (Math.random() < probability ? 1 : 0));
}
return { path, progress: -1 };
}
function land(ball) {
const k = ball.path[rows];
counts[k]++;
landed++;
sum += k;
sumSquares += k * k;
ball.progress = rows;
}
function finish() {
stop();
active.forEach(land);
active = [];
while (produced < target) {
const ball = makeBall();
if (!traced) traced = ball;
produced++;
land(ball);
}
updateTrace();
drawBoard();
updateChart();
updateStatus("Complete. " + number(landed) + " balls landed. Run again to compare another sample.");
}
function tick(time) {
if (!running || destroyed) return;
const elapsed = previousTime === null ? 0 : Math.min(50, time - previousTime);
previousTime = time;
const speed = Number(controls.speed.value);
if (!tracing) {
// Spread a full sample over about 12 seconds at 1×, with bounded work per frame.
spawnCredit += elapsed / 1000 * target / 12 * speed;
while (spawnCredit >= 1 && produced < target) {
const ball = makeBall();
if (!traced) traced = ball;
active.push(ball);
produced++;
spawnCredit--;
}
}
const progress = elapsed / 1000 * (tracing ? 3 : rows / 2) * speed;
active.forEach((ball) => { ball.progress += progress; });
active = active.filter((ball) => {
if (ball.progress < rows) return true;
land(ball);
return false;
});
drawBoard();
chartElapsed += elapsed;
if (chartElapsed >= 100 || !active.length) {
updateChart();
updateTrace();
chartElapsed = 0;
}
if ((tracing || produced === target) && !active.length) {
stop();
updateStatus(landed === target ? "Complete. " + number(landed) + " balls landed. Run again to compare another sample." :
"Ball landed in bin " + traced.path[rows] + ". Drop the sample to build the distribution.");
return;
}
frame = requestAnimationFrame(tick);
}
listen(runButton, "click", () => {
if (running) {
stop();
updateStatus("Paused. " + number(landed) + " balls landed; " + active.length + " still on the board.");
return;
}
if (landed === target) reset();
if (!active.length) tracing = false;
if (reducedMotion.matches) { finish(); return; }
running = true;
updateStatus("Running. Watch the observed shares build toward the exact probabilities.");
frame = requestAnimationFrame(tick);
});
listen(traceButton, "click", () => {
if (landed === target) reset();
traced = makeBall();
produced++;
if (reducedMotion.matches) {
land(traced);
updateTrace();
drawBoard();
updateChart();
updateStatus("Ball landed in bin " + traced.path[rows] + ". Its path is shown on the board.");
return;
}
active.push(traced);
tracing = running = true;
updateStatus("Following one ball. Each peg is a new independent left/right choice.");
frame = requestAnimationFrame(tick);
});
listen(finishButton, "click", finish);
listen(find('[data-action="reset"]'), "click", reset);
["balls", "rows", "probability"].forEach((name) => listen(controls[name], "input", reset));
["exact", "normal"].forEach((name) => listen(controls[name], "change", updateChart));
listen(reducedMotion, "change", () => { if (reducedMotion.matches && running) finish(); });
reset();
const observer = new ResizeObserver(resize);
observer.observe(find(".galton-board-wrap"));
return {
element,
destroy: () => {
destroyed = true;
stop();
observer.disconnect();
listeners.abort();
},
};
}