From f527a712be03e9a8a216a204384c9b8c14e4f7bd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 11:58:45 +0000 Subject: [PATCH] Implement neighbor-sum-avoidance polish + new computing-pi interactive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit neighbor-sum-avoidance (T2-T6): - Start button now reads just "Start" (no mode suffix). - Available numbers and shuffled preview arrangements render pre-start so the arena isn't empty while the user is still deciding. - Mode switch + count slider stay mounted once active; they're just disabled, so starting no longer causes a layout jump. - Guided-mode instructions expanded to call out light grey edges, red violation arcs, and the green valid-neighbor line explicitly. - Draggable numbers honor isActive for the draggable attribute and onDragStart, showing opacity-60 + cursor-not-allowed when locked. computing-pi (new interactive under Miscellany): - N parallel first-passage simulations: toss until heads > tails, record the fraction. Average × 4 converges to π. - Header row: runs slider | live average × 4 | Start/Reset. - Tile grid uses a mask-gradient marquee so long sequences scroll their tail while staying readable. - Click any tile once all runs complete to see the full toss sequence in a Dialog. - Safety cap of 1000 tosses per run (first-passage time has infinite expectation, so without the cap a pathological run could block). - Registered in interactives.ts + InteractiveRenderer + the parallel interactive-components map. Filled in three-bank-accounts in the latter while there, since it was missing. admin-data.json updated to mark T2-T6 done with remarks, remove the implemented Computing Pi idea, and add an implementation note. --- src/components/InteractiveRenderer.tsx | 3 +- src/components/interactives/ComputingPi.tsx | 246 ++++++++++++++++++ .../interactives/NeighborSumAvoidance.tsx | 143 +++++----- src/data/admin-data.json | 47 ++-- src/lib/interactive-components.ts | 2 + src/lib/interactives.ts | 18 ++ 6 files changed, 364 insertions(+), 95 deletions(-) create mode 100644 src/components/interactives/ComputingPi.tsx diff --git a/src/components/InteractiveRenderer.tsx b/src/components/InteractiveRenderer.tsx index 5e6711e..3e418ba 100644 --- a/src/components/InteractiveRenderer.tsx +++ b/src/components/InteractiveRenderer.tsx @@ -1,4 +1,4 @@ -import React, { Suspense, lazy, type ComponentType } from "react"; +import React, { type ComponentType,lazy, Suspense } from "react"; const componentMap: Record Promise<{ default: ComponentType }>> = { "binary-number-game": () => import("./interactives/BinaryNumberGame"), @@ -42,6 +42,7 @@ const componentMap: Record Promise<{ default: ComponentType } "eternal-domination-game": () => import("./interactives/EternalDominationGame"), "knights-and-knaves": () => import("./interactives/KnightsAndKnavesI"), "three-bank-accounts": () => import("./interactives/ThreeBankAccounts"), + "computing-pi": () => import("./interactives/ComputingPi"), }; const lazyCache = new Map>>(); diff --git a/src/components/interactives/ComputingPi.tsx b/src/components/interactives/ComputingPi.tsx new file mode 100644 index 0000000..fabeefc --- /dev/null +++ b/src/components/interactives/ComputingPi.tsx @@ -0,0 +1,246 @@ +import { Play, RotateCcw } from 'lucide-react'; +import React, { useEffect, useMemo, useState } from 'react'; + +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Slider } from '@/components/ui/slider'; + +type Toss = 'H' | 'T'; + +interface Run { + sequence: Toss[]; + stopped: boolean; + heads: number; + tails: number; + fraction: number | null; +} + +const MIN_RUNS = 4; +const MAX_RUNS = 100; +const DEFAULT_RUNS = 36; +const TICK_MS = 110; +const MAX_TOSSES_PER_RUN = 1000; // safety cap; first-passage time has infinite expectation + +function emptyRun(): Run { + return { sequence: [], stopped: false, heads: 0, tails: 0, fraction: null }; +} + +function stepRun(r: Run): Run { + if (r.stopped) return r; + const toss: Toss = Math.random() < 0.5 ? 'H' : 'T'; + const heads = r.heads + (toss === 'H' ? 1 : 0); + const tails = r.tails + (toss === 'T' ? 1 : 0); + const sequence = [...r.sequence, toss]; + if (heads > tails) { + return { sequence, stopped: true, heads, tails, fraction: heads / sequence.length }; + } + if (sequence.length >= MAX_TOSSES_PER_RUN) { + return { sequence, stopped: true, heads, tails, fraction: heads / sequence.length }; + } + return { sequence, stopped: false, heads, tails, fraction: null }; +} + +const ComputingPi: React.FC = () => { + const [n, setN] = useState(DEFAULT_RUNS); + const [runs, setRuns] = useState([]); + const [running, setRunning] = useState(false); + const [selectedIdx, setSelectedIdx] = useState(null); + + const started = runs.length > 0; + const allStopped = started && runs.every((r) => r.stopped); + + const avgTimes4 = useMemo(() => { + const stopped = runs.filter((r) => r.stopped && r.fraction !== null); + if (stopped.length === 0) return null; + const sum = stopped.reduce((acc, r) => acc + (r.fraction ?? 0), 0); + return (sum / stopped.length) * 4; + }, [runs]); + + // Simulate one toss per active run on every tick. + useEffect(() => { + if (!running) return; + const id = setInterval(() => { + setRuns((prev) => prev.map(stepRun)); + }, TICK_MS); + return () => clearInterval(id); + }, [running]); + + useEffect(() => { + if (running && allStopped) setRunning(false); + }, [running, allStopped]); + + const handleStart = () => { + setRuns(Array.from({ length: n }, emptyRun)); + setRunning(true); + setSelectedIdx(null); + }; + + const handleReset = () => { + setRunning(false); + setRuns([]); + setSelectedIdx(null); + }; + + const selectedRun = selectedIdx !== null ? runs[selectedIdx] : null; + + return ( +
+ + + Computing π from Coin Tosses + + Toss a fair coin until the number of heads first exceeds the number of tails, + and record the fraction of heads at that moment. Repeated, the average of those + fractions approaches π/4. + + + + {/* Header: slider | running avg × 4 | start/reset */} +
+
+ + setN(v[0])} + disabled={started} + /> +
+ +
+ + Average × 4 + + + {avgTimes4 !== null ? avgTimes4.toFixed(4) : '—'} + + + π ≈ 3.1416 + +
+ +
+ {!started ? ( + + ) : ( + + )} +
+
+ + {/* Grid of runs */} + {started ? ( +
+ {runs.map((run, idx) => ( + allStopped && setSelectedIdx(idx)} + /> + ))} +
+ ) : ( +
+ Pick a number of runs and press Start. +
+ )} + + {allStopped && ( +

+ All {runs.length} runs complete. Click any tile to see its full toss sequence. +

+ )} +
+
+ + { + if (!open) setSelectedIdx(null); + }} + > + + + Run {(selectedIdx ?? 0) + 1} + {selectedRun && ( + + {selectedRun.heads} heads,{' '} + {selectedRun.tails} tails in{' '} + {selectedRun.sequence.length}{' '} + toss{selectedRun.sequence.length === 1 ? '' : 'es'}. Recorded fraction:{' '} + + {selectedRun.heads}/{selectedRun.sequence.length} ={' '} + {(selectedRun.fraction ?? 0).toFixed(4)} + + . + + )} + + {selectedRun && ( +
+ {selectedRun.sequence.join('')} +
+ )} +
+
+
+ ); +}; + +interface RunTileProps { + index: number; + run: Run; + clickable: boolean; + onClick: () => void; +} + +const RunTile: React.FC = ({ index, run, clickable, onClick }) => { + const seq = run.sequence.join(''); + const footer = run.stopped + ? `${run.heads}/${run.sequence.length}` + : `${run.sequence.length}`; + return ( + + ); +}; + +export default ComputingPi; diff --git a/src/components/interactives/NeighborSumAvoidance.tsx b/src/components/interactives/NeighborSumAvoidance.tsx index 5e75c7a..3027c91 100644 --- a/src/components/interactives/NeighborSumAvoidance.tsx +++ b/src/components/interactives/NeighborSumAvoidance.tsx @@ -1,12 +1,13 @@ -import { useState, useEffect, useRef } from 'react'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import confetti from 'canvas-confetti'; +import { RotateCcw,Timer } from 'lucide-react'; +import { useEffect, useRef,useState } from 'react'; +import { toast } from 'sonner'; + import { Button } from '@/components/ui/button'; -import { Switch } from '@/components/ui/switch'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Label } from '@/components/ui/label'; import { Slider } from '@/components/ui/slider'; -import { Timer, RotateCcw } from 'lucide-react'; -import confetti from 'canvas-confetti'; -import { toast } from 'sonner'; +import { Switch } from '@/components/ui/switch'; interface NeighborSumAvoidanceProps { shareUrl?: string; @@ -41,13 +42,22 @@ const NeighborSumAvoidance = ({ shareUrl }: NeighborSumAvoidanceProps) => { }; }, [isActive, isComplete]); - // Keep the pre-start arena in sync with the Numbers slider. + // Keep the pre-start arena populated so users can preview the mode before starting. + // Default mode shows empty slots + draggable numbers (disabled). Guided mode shows + // a shuffled arrangement so edges/violations are visible right away. useEffect(() => { - if (!isActive) { + if (isActive) return; + if (mode === 'default') { setSlots(Array(numberCount).fill(null)); setAvailableNumbers(Array.from({ length: numberCount }, (_, i) => i + 1)); + } else { + const shuffled = Array.from({ length: numberCount }, (_, i) => i + 1).sort( + () => Math.random() - 0.5 + ); + setSlots(shuffled); + setAvailableNumbers([]); } - }, [numberCount, isActive]); + }, [numberCount, mode, isActive]); const isDivisible = (a: number, b: number) => { const sum = a + b; @@ -90,31 +100,20 @@ const NeighborSumAvoidance = ({ shareUrl }: NeighborSumAvoidanceProps) => { return `${mins}:${secs.toString().padStart(2, '0')}`; }; - const startGame = (selectedMode: 'default' | 'guided') => { - setMode(selectedMode); + const startGame = () => { setIsActive(true); setTime(0); setSwaps(0); setIsComplete(false); - - if (selectedMode === 'default') { - setSlots(Array(numberCount).fill(null)); - setAvailableNumbers(Array.from({ length: numberCount }, (_, i) => i + 1)); - } else { - // Guided mode: random arrangement - const shuffled = Array.from({ length: numberCount }, (_, i) => i + 1).sort(() => Math.random() - 0.5); - setSlots(shuffled); - setAvailableNumbers([]); - } + // Slots/availableNumbers are already primed by the pre-start useEffect. }; const restart = () => { setIsActive(false); setIsComplete(false); - setSlots(Array(numberCount).fill(null)); - setAvailableNumbers(Array.from({ length: numberCount }, (_, i) => i + 1)); setTime(0); setSwaps(0); + // The pre-start useEffect will re-populate slots for the current mode. }; const handleDragStart = (num: number, fromSlot: number | null) => { @@ -294,50 +293,51 @@ const NeighborSumAvoidance = ({ shareUrl }: NeighborSumAvoidanceProps) => { - {/* Config controls — shown before start */} - {!isActive && ( -
-
-
- - setMode(checked ? 'guided' : 'default')} - /> - -
- -
- - setNumberCount(value[0])} - className="w-24" - /> -
+ {/* Config controls — always visible, slider/toggle disabled once active */} +
+
+
+ + setMode(checked ? 'guided' : 'default')} + disabled={isActive} + /> +
-
- {mode === 'default' ? ( - <> -

Drag numbers from below into the circle slots.

-

Adjacent numbers that sum to a multiple of 3, 5, or 7 will show a red arc.

-

Click a filled slot to remove the number.

- - ) : ( - <> -

Numbers are connected by edges if their sum is NOT divisible by 3, 5, or 7.

-

Click two slots to swap their numbers and form a cycle on the circle's edge.

- - )} +
+ + setNumberCount(value[0])} + className="w-24" + disabled={isActive} + />
- )} + +
+ {mode === 'default' ? ( + <> +

Drag numbers from below into the circle slots.

+

Adjacent numbers that sum to a multiple of 3, 5, or 7 will show a red arc.

+

Click a filled slot to remove the number.

+ + ) : ( + <> +

Numbers are connected by light grey edges if their sum is NOT divisible by 3, 5, or 7.

+

Violations around the circle are marked by red arcs, and valid neighbors are connected by a green line.

+

Click two slots to swap their numbers and form a cycle on the circle's edge.

+ + )} +
+
{/* Timer + controls — shown after start */} {isActive && ( @@ -366,21 +366,24 @@ const NeighborSumAvoidance = ({ shareUrl }: NeighborSumAvoidanceProps) => { {/* Start button — shown before start */} {!isActive && (
-
)} - {/* Available numbers for default mode */} - {isActive && mode === 'default' && availableNumbers.length > 0 && ( + {/* Available numbers for default mode — always visible pre/post start, + but only draggable once active */} + {mode === 'default' && availableNumbers.length > 0 && (
{availableNumbers.map(num => (
handleDragStart(num, null)} - className="w-12 h-12 rounded-full border-2 border-primary bg-background flex items-center justify-center text-lg font-bold cursor-move hover:scale-110 transition-transform" + draggable={isActive} + onDragStart={isActive ? () => handleDragStart(num, null) : undefined} + className={`w-12 h-12 rounded-full border-2 border-primary bg-background flex items-center justify-center text-lg font-bold transition-transform ${ + isActive ? 'cursor-move hover:scale-110' : 'cursor-not-allowed opacity-60' + }`} > {num}
diff --git a/src/data/admin-data.json b/src/data/admin-data.json index fcfd1d3..b7a6ee9 100644 --- a/src/data/admin-data.json +++ b/src/data/admin-data.json @@ -1,21 +1,10 @@ { "statusOverrides": {}, "notes": { - "three-bank-accounts": "Implemented from idea. Three tabs: Play (sandbox), Explore (guided discovery with binary view), Solution (step-through algorithm). Uses KaTeX for math, canvas-confetti for celebrations." + "three-bank-accounts": "Implemented from idea. Three tabs: Play (sandbox), Explore (guided discovery with binary view), Solution (step-through algorithm). Uses KaTeX for math, canvas-confetti for celebrations.", + "computing-pi": "Implemented from idea. Header row: slider (N runs) | running avg × 4 | Start/Reset. Grid of N tiles, each running an independent first-passage simulation; tiles use a right-aligned mask-fade for the marquee effect on long sequences. Click any stopped tile after completion → Dialog showing the full toss sequence. Safety cap of 1000 tosses per run (first-passage time has infinite expectation)." }, - "ideas": [ - { - "title": "Computing Pi", - "description": "We propose a different way to use randomness to estimate pi. Toss a coin until the first time the observed proportion of heads exceeds - (that is, until the cumulative number of heads exceeds the cumulative number of tails) and record that fraction; for instance, if the tosses proceeded as T, H, T, H, H you would record the fraction . Now start afresh, again recording a fraction that exceeds . Repeat. As you perform more and more such trials, the\naverage of the fractions will approach pi/4. \n\nMake an interactive demonstrating this. The playground is rectangular as usual. On a small header-style div on top, divide the div into three parts. on left have a slider allowing the user to determine how many runs there are (say N). on the right have a start button. in the middle we keep track of the average.\n\nonce the user picks a number and clicks start, make a grid of N small rectangles and toss coins in each of them one by one (if the sequence exceeds the width of the small rectangle then create a billboard like animation effect --- the user should be ale to click any rectangle [once all simulations are done] and see the full sequence in a clean modal with an explicit close button.\n\nas the simulations are running in the center of the top div, track the average (times 4).\n\nwhen all simulations are done, change the start button to reset, and when the user clicks reset, reset to the starting state.", - "themes": [ - "Miscellany" - ], - "status": "idea", - "notes": "", - "id": "4ee4c9c5-7623-488a-ba36-db0c0bb18a8f", - "createdAt": "2026-04-21" - } - ], + "ideas": [], "todos": { "parity-bits-game": [ { @@ -353,32 +342,42 @@ { "id": "5ffacb36-9d7e-4e95-8632-deeb606c05a7", "text": "The start button labels should just read start", - "done": false, - "refId": "T2" + "done": true, + "refId": "T2", + "remark": "Removed mode-specific suffix; the Start button now reads just 'Start' regardless of mode.", + "doneDate": "2026-04-21" }, { "id": "81dc226f-df36-4894-a037-78b298889255", "text": "In default mode, show the numbers also, but they should not be draggable until the user hits start", - "done": false, - "refId": "T3" + "done": true, + "refId": "T3", + "remark": "Available numbers now render pre-start too. draggable and onDragStart are both gated on isActive; disabled state shows opacity-60 + cursor-not-allowed.", + "doneDate": "2026-04-21" }, { "id": "bd52f257-dae4-46cb-a28c-a1369c18f325", "text": "once the user hits start, don't hide the instructions (eg \"Drag numbers from below into the circle slots. Adjacent numbers that sum to a multiple of 3, 5, or 7 will show a red arc. Click a filled slot to remove the number.\") just disable the slider and toggle (but let all the UI elements be to avoid a jumping effect when start is clicked)", - "done": false, - "refId": "T4" + "done": true, + "refId": "T4", + "remark": "Lifted the config block out of the !isActive conditional; mode Switch and count Slider take disabled={isActive}. Instructions stay visible throughout the game, so starting no longer causes a layout jump.", + "doneDate": "2026-04-21" }, { "id": "342fcbce-da98-4332-8210-b19e7514eca1", "text": "in guided mode, show the edges and the numbers, but nothing should be swappable until the user hits start.", - "done": false, - "refId": "T5" + "done": true, + "refId": "T5", + "remark": "Pre-start useEffect now primes a shuffled arrangement for guided mode (instead of only doing so in startGame), so edges and numbers render immediately. The arena wrapper stays pointer-events-none until start, and handleDrop/handleSlotClick short-circuit on !isActive.", + "doneDate": "2026-04-21" }, { "id": "799be794-b904-4a08-9f04-46d1ae0329a9", "text": "guided mode help text, change \"Numbers are connected by edges if their sum is NOT divisible by 3, 5, or 7.\" to \"Numbers are connected by light grey edges if their sum is NOT divisible by 3, 5, or 7.\" add a line break and add \"Violations around the circle are marked by red arcs, and valid neighbors are connected by a green line.\"", - "done": false, - "refId": "T6" + "done": true, + "refId": "T6", + "remark": "Rewrote the guided-mode instructions as three paragraphs: light-grey edges rule, red arc / green line legend, and the click-to-swap instruction.", + "doneDate": "2026-04-21" } ] } diff --git a/src/lib/interactive-components.ts b/src/lib/interactive-components.ts index b1f31d6..a5313d8 100644 --- a/src/lib/interactive-components.ts +++ b/src/lib/interactive-components.ts @@ -43,4 +43,6 @@ export const componentMap: Record = { "rent-division-puzzle": () => import("@/components/interactives/RentDivisionPuzzle"), "eternal-domination-game": () => import("@/components/interactives/EternalDominationGame"), "knights-and-knaves": () => import("@/components/interactives/KnightsAndKnavesI"), + "three-bank-accounts": () => import("@/components/interactives/ThreeBankAccounts"), + "computing-pi": () => import("@/components/interactives/ComputingPi"), }; diff --git a/src/lib/interactives.ts b/src/lib/interactives.ts index 52a2dd8..4ec6803 100644 --- a/src/lib/interactives.ts +++ b/src/lib/interactives.ts @@ -706,6 +706,24 @@ export const allInteractives: Interactive[] = [ hasGreenScreen: false, status: "published" as const, }, + { + slug: "computing-pi", + title: "Computing π from Coin Tosses", + description: + "Toss a coin until heads first outnumber tails, record the fraction, and repeat. The average of those fractions approaches π/4 — watch it converge in parallel runs.", + tags: [ + "probability", + "simulation", + "pi", + "monte-carlo", + "random-walk", + "convergence", + ], + themes: ["Miscellany"], + dateAdded: "2026-04-21", + hasGreenScreen: false, + status: "published" as const, + }, ]; /** All published interactives. Use this for public-facing pages. */