diff --git a/src/App.tsx b/src/App.tsx index 3135e88..373a6b8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -43,6 +43,7 @@ const Dice = lazy(() => import("./pages/Dice")); const Maze = lazy(() => import("./pages/Maze")); const Fourier = lazy(() => import("./pages/Fourier")); const Morse = lazy(() => import("./pages/Morse")); +const Galton = lazy(() => import("./pages/Galton")); const NotFound = lazy(() => import("./pages/NotFound")); const App = () => ( @@ -480,6 +481,19 @@ const App = () => ( } /> + + + + + } + /> {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} (rows + 1).fill(0), + dropped: 0, + }; +} + +export function spawnBall(s: GaltonState): void { + s.balls.push({ + row: 0, + rights: 0, + progress: 0, + jitter: (Math.random() - 0.5) * 0.3, + }); +} + +/** + * Advance all balls by dt (in row-transits). Balls that clear the last + * row land in bins. Mutates state. + */ +export function stepBalls(s: GaltonState, dt: number): void { + const landed: number[] = []; + for (const b of s.balls) { + b.progress += dt; + while (b.progress >= 1) { + b.progress -= 1; + // Bounce at the peg: decide direction for the row just passed + if (Math.random() < s.p) b.rights++; + b.row++; + b.jitter = (Math.random() - 0.5) * 0.3; + if (b.row >= s.rows) { + landed.push(b.rights); + b.row = -1; // mark for removal + break; + } + } + } + for (const r of landed) { + s.bins[r]++; + s.dropped++; + } + s.balls = s.balls.filter((b) => b.row >= 0); +} + +/** Binomial pmf B(rows, p) at k. */ +export function binomialPmf(rows: number, p: number, k: number): number { + // log-space to stay stable for larger rows + let logC = 0; + for (let i = 0; i < k; i++) { + logC += Math.log(rows - i) - Math.log(i + 1); + } + return Math.exp(logC + k * Math.log(p) + (rows - k) * Math.log(1 - p)); +} + +/** + * Ball x-position in "bin widths" left of center, for rendering. + * While descending, position interpolates between current displacement + * and the pegs it might hit; we simply center on rights - row*p drift. + */ +export function ballX(b: Ball, s: GaltonState): number { + // displacement from center in half-bin units: rights - row/2 + const cur = b.rights - b.row / 2; + return cur + b.jitter; +} diff --git a/src/pages/Galton.tsx b/src/pages/Galton.tsx new file mode 100644 index 0000000..d77c172 --- /dev/null +++ b/src/pages/Galton.tsx @@ -0,0 +1,214 @@ +import { useEffect, useRef, useState, useCallback } from "react"; +import { Link } from "react-router-dom"; +import { + GaltonState, + newGalton, + spawnBall, + stepBalls, + binomialPmf, + ballX, +} from "../features/galton/board"; + +const ROW_OPTIONS = [8, 12, 16]; + +export default function Galton() { + const canvasRef = useRef(null); + const stateRef = useRef(newGalton(12, 0.5)); + const runningRef = useRef(true); + const rateRef = useRef(3); // balls per 10 frames + + const [rows, setRows] = useState(12); + const [p, setP] = useState(0.5); + const [running, setRunning] = useState(true); + const [dropped, setDropped] = useState(0); + + const reset = useCallback((r: number, prob: number) => { + stateRef.current = newGalton(r, prob); + setDropped(0); + }, []); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const resize = () => { + const rect = canvas.parentElement!.getBoundingClientRect(); + canvas.width = rect.width; + canvas.height = rect.height; + }; + resize(); + window.addEventListener("resize", resize); + + let frame = 0; + let raf = 0; + const loop = () => { + raf = requestAnimationFrame(loop); + frame++; + const s = stateRef.current; + const { width: W, height: H } = canvas; + + if (runningRef.current) { + if (frame % 10 < rateRef.current && s.balls.length < 120) spawnBall(s); + stepBalls(s, 0.12); + if (frame % 10 === 0) setDropped(s.dropped); + } + + // Layout: pegs occupy top 55%, bins the rest + const pegTop = 40; + const pegBottom = H * 0.55; + const rowH = (pegBottom - pegTop) / s.rows; + const binW = Math.min(36, (W - 60) / (s.rows + 1)); + const cx = W / 2; + + ctx.fillStyle = "#000"; + ctx.fillRect(0, 0, W, H); + + // Pegs + ctx.fillStyle = "rgba(0,255,65,0.4)"; + for (let r = 0; r < s.rows; r++) { + const y = pegTop + (r + 1) * rowH; + for (let k = 0; k <= r; k++) { + const x = cx + (k - r / 2) * binW; + ctx.beginPath(); + ctx.arc(x, y, 2.5, 0, Math.PI * 2); + ctx.fill(); + } + } + + // Balls + ctx.fillStyle = "#00ff41"; + for (const b of s.balls) { + const y = pegTop + (b.row + b.progress) * rowH; + const x = cx + ballX(b, s) * binW; + ctx.beginPath(); + ctx.arc(x, y, 3.5, 0, Math.PI * 2); + ctx.fill(); + } + + // Bins + const binTop = pegBottom + 20; + const binMax = Math.max(1, ...s.bins); + const binAreaH = H - binTop - 26; + s.bins.forEach((count, k) => { + const x = cx + (k - s.rows / 2) * binW; + const h = (count / binMax) * binAreaH; + ctx.fillStyle = "rgba(0,255,65,0.5)"; + ctx.fillRect(x - binW / 2 + 2, H - 26 - h, binW - 4, h); + }); + + // Binomial expectation overlay: expected count = dropped × pmf, + // drawn on the same scale as the bars + if (s.dropped > 20) { + ctx.strokeStyle = "#ffe066"; + ctx.lineWidth = 1.5; + ctx.beginPath(); + s.bins.forEach((_, k) => { + const x = cx + (k - s.rows / 2) * binW; + const expected = s.dropped * binomialPmf(s.rows, s.p, k); + const h = Math.min(binAreaH, (expected / binMax) * binAreaH); + const y = H - 26 - h; + if (k === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.stroke(); + } + + // Bin divider baseline + ctx.strokeStyle = "rgba(0,255,65,0.25)"; + ctx.beginPath(); + ctx.moveTo(30, H - 25.5); + ctx.lineTo(W - 30, H - 25.5); + ctx.stroke(); + }; + raf = requestAnimationFrame(loop); + + return () => { + cancelAnimationFrame(raf); + window.removeEventListener("resize", resize); + }; + }, []); + + return ( +
+ {/* Header */} +
+
+ + ← home + + | + galton board +
+
+ {dropped.toLocaleString()} balls landed +
+
+ + {/* Controls */} +
+
+ rows + {ROW_OPTIONS.map((r) => ( + + ))} +
+ +
+ bias p(right) + { + const v = Number(e.target.value); + setP(v); + reset(rows, v); + }} + className="w-24 accent-primary" + /> + {p.toFixed(2)} +
+ +
+ + +
+
+ + {/* Canvas */} +
+ +
+ each peg deflects right with probability p — bins fill toward the binomial (yellow) +
+
+
+ ); +}