Implement neighbor-sum-avoidance polish + new computing-pi interactive

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.
This commit is contained in:
Claude 2026-04-21 11:58:45 +00:00
parent 014d90a36b
commit f527a712be
No known key found for this signature in database
6 changed files with 364 additions and 95 deletions

View file

@ -1,4 +1,4 @@
import React, { Suspense, lazy, type ComponentType } from "react"; import React, { type ComponentType,lazy, Suspense } from "react";
const componentMap: Record<string, () => Promise<{ default: ComponentType<any> }>> = { const componentMap: Record<string, () => Promise<{ default: ComponentType<any> }>> = {
"binary-number-game": () => import("./interactives/BinaryNumberGame"), "binary-number-game": () => import("./interactives/BinaryNumberGame"),
@ -42,6 +42,7 @@ const componentMap: Record<string, () => Promise<{ default: ComponentType<any> }
"eternal-domination-game": () => import("./interactives/EternalDominationGame"), "eternal-domination-game": () => import("./interactives/EternalDominationGame"),
"knights-and-knaves": () => import("./interactives/KnightsAndKnavesI"), "knights-and-knaves": () => import("./interactives/KnightsAndKnavesI"),
"three-bank-accounts": () => import("./interactives/ThreeBankAccounts"), "three-bank-accounts": () => import("./interactives/ThreeBankAccounts"),
"computing-pi": () => import("./interactives/ComputingPi"),
}; };
const lazyCache = new Map<string, React.LazyExoticComponent<ComponentType<any>>>(); const lazyCache = new Map<string, React.LazyExoticComponent<ComponentType<any>>>();

View file

@ -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<Run[]>([]);
const [running, setRunning] = useState(false);
const [selectedIdx, setSelectedIdx] = useState<number | null>(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 (
<div className="container mx-auto px-4 py-8 max-w-5xl">
<Card>
<CardHeader>
<CardTitle>Computing π from Coin Tosses</CardTitle>
<CardDescription>
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 <span className="font-mono">π/4</span>.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Header: slider | running avg × 4 | start/reset */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 items-center rounded-xl border bg-muted/40 p-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">
Runs: <span className="font-mono">{n}</span>
</label>
<Slider
min={MIN_RUNS}
max={MAX_RUNS}
step={1}
value={[n]}
onValueChange={(v) => setN(v[0])}
disabled={started}
/>
</div>
<div className="flex flex-col items-center">
<span className="text-xs uppercase tracking-wide text-muted-foreground">
Average × 4
</span>
<span className="font-mono text-3xl tabular-nums">
{avgTimes4 !== null ? avgTimes4.toFixed(4) : '—'}
</span>
<span className="text-xs text-muted-foreground">
π 3.1416
</span>
</div>
<div className="flex justify-center sm:justify-end">
{!started ? (
<Button size="lg" onClick={handleStart}>
<Play className="w-4 h-4 mr-2" />
Start
</Button>
) : (
<Button size="lg" variant="outline" onClick={handleReset}>
<RotateCcw className="w-4 h-4 mr-2" />
Reset
</Button>
)}
</div>
</div>
{/* Grid of runs */}
{started ? (
<div
className="grid gap-2"
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(110px, 1fr))' }}
>
{runs.map((run, idx) => (
<RunTile
key={idx}
index={idx}
run={run}
clickable={allStopped}
onClick={() => allStopped && setSelectedIdx(idx)}
/>
))}
</div>
) : (
<div className="text-center text-muted-foreground py-10">
Pick a number of runs and press <span className="font-medium">Start</span>.
</div>
)}
{allStopped && (
<p className="text-sm text-center text-muted-foreground">
All {runs.length} runs complete. Click any tile to see its full toss sequence.
</p>
)}
</CardContent>
</Card>
<Dialog
open={selectedIdx !== null}
onOpenChange={(open) => {
if (!open) setSelectedIdx(null);
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Run {(selectedIdx ?? 0) + 1}</DialogTitle>
{selectedRun && (
<DialogDescription>
<span className="font-mono">{selectedRun.heads}</span> heads,{' '}
<span className="font-mono">{selectedRun.tails}</span> tails in{' '}
<span className="font-mono">{selectedRun.sequence.length}</span>{' '}
toss{selectedRun.sequence.length === 1 ? '' : 'es'}. Recorded fraction:{' '}
<span className="font-mono font-semibold">
{selectedRun.heads}/{selectedRun.sequence.length} ={' '}
{(selectedRun.fraction ?? 0).toFixed(4)}
</span>
.
</DialogDescription>
)}
</DialogHeader>
{selectedRun && (
<div className="font-mono text-sm break-all bg-muted p-3 rounded-lg max-h-64 overflow-y-auto leading-6 tracking-wider">
{selectedRun.sequence.join('')}
</div>
)}
</DialogContent>
</Dialog>
</div>
);
};
interface RunTileProps {
index: number;
run: Run;
clickable: boolean;
onClick: () => void;
}
const RunTile: React.FC<RunTileProps> = ({ index, run, clickable, onClick }) => {
const seq = run.sequence.join('');
const footer = run.stopped
? `${run.heads}/${run.sequence.length}`
: `${run.sequence.length}`;
return (
<button
type="button"
onClick={onClick}
disabled={!clickable}
aria-label={`Run ${index + 1}${run.stopped ? `, fraction ${footer}` : ''}`}
className={`rounded-lg border px-2 py-1.5 flex flex-col gap-1 text-left transition-colors ${
run.stopped
? 'bg-green-50 dark:bg-green-950/30 border-green-500/60'
: 'bg-card border-border'
} ${clickable ? 'cursor-pointer hover:border-primary' : 'cursor-default'}`}
>
<div
className="overflow-hidden whitespace-nowrap text-right font-mono text-xs h-4 tracking-wider"
style={{
maskImage: 'linear-gradient(to right, transparent 0, black 25%)',
WebkitMaskImage: 'linear-gradient(to right, transparent 0, black 25%)',
}}
>
{seq || ' '}
</div>
<div className="font-mono text-[11px] text-muted-foreground tabular-nums">
{run.stopped ? footer : `${footer} toss${run.sequence.length === 1 ? '' : 'es'}`}
</div>
</button>
);
};
export default ComputingPi;

View file

@ -1,12 +1,13 @@
import { useState, useEffect, useRef } from 'react'; import confetti from 'canvas-confetti';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { RotateCcw,Timer } from 'lucide-react';
import { useEffect, useRef,useState } from 'react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; 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 { Label } from '@/components/ui/label';
import { Slider } from '@/components/ui/slider'; import { Slider } from '@/components/ui/slider';
import { Timer, RotateCcw } from 'lucide-react'; import { Switch } from '@/components/ui/switch';
import confetti from 'canvas-confetti';
import { toast } from 'sonner';
interface NeighborSumAvoidanceProps { interface NeighborSumAvoidanceProps {
shareUrl?: string; shareUrl?: string;
@ -41,13 +42,22 @@ const NeighborSumAvoidance = ({ shareUrl }: NeighborSumAvoidanceProps) => {
}; };
}, [isActive, isComplete]); }, [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(() => { useEffect(() => {
if (!isActive) { if (isActive) return;
if (mode === 'default') {
setSlots(Array(numberCount).fill(null)); setSlots(Array(numberCount).fill(null));
setAvailableNumbers(Array.from({ length: numberCount }, (_, i) => i + 1)); 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 isDivisible = (a: number, b: number) => {
const sum = a + b; const sum = a + b;
@ -90,31 +100,20 @@ const NeighborSumAvoidance = ({ shareUrl }: NeighborSumAvoidanceProps) => {
return `${mins}:${secs.toString().padStart(2, '0')}`; return `${mins}:${secs.toString().padStart(2, '0')}`;
}; };
const startGame = (selectedMode: 'default' | 'guided') => { const startGame = () => {
setMode(selectedMode);
setIsActive(true); setIsActive(true);
setTime(0); setTime(0);
setSwaps(0); setSwaps(0);
setIsComplete(false); setIsComplete(false);
// Slots/availableNumbers are already primed by the pre-start useEffect.
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([]);
}
}; };
const restart = () => { const restart = () => {
setIsActive(false); setIsActive(false);
setIsComplete(false); setIsComplete(false);
setSlots(Array(numberCount).fill(null));
setAvailableNumbers(Array.from({ length: numberCount }, (_, i) => i + 1));
setTime(0); setTime(0);
setSwaps(0); setSwaps(0);
// The pre-start useEffect will re-populate slots for the current mode.
}; };
const handleDragStart = (num: number, fromSlot: number | null) => { const handleDragStart = (num: number, fromSlot: number | null) => {
@ -294,50 +293,51 @@ const NeighborSumAvoidance = ({ shareUrl }: NeighborSumAvoidanceProps) => {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
{/* Config controls — shown before start */} {/* Config controls — always visible, slider/toggle disabled once active */}
{!isActive && ( <div className="space-y-4">
<div className="space-y-4"> <div className="flex items-center justify-center gap-6">
<div className="flex items-center justify-center gap-6"> <div className="flex items-center gap-4">
<div className="flex items-center gap-4"> <Label htmlFor="mode-switch">Default Mode</Label>
<Label htmlFor="mode-switch">Default Mode</Label> <Switch
<Switch id="mode-switch"
id="mode-switch" checked={mode === 'guided'}
checked={mode === 'guided'} onCheckedChange={(checked) => setMode(checked ? 'guided' : 'default')}
onCheckedChange={(checked) => setMode(checked ? 'guided' : 'default')} disabled={isActive}
/> />
<Label htmlFor="mode-switch">Guided Mode</Label> <Label htmlFor="mode-switch">Guided Mode</Label>
</div>
<div className="flex items-center gap-4 min-w-[200px]">
<Label htmlFor="number-count">Numbers: {numberCount}</Label>
<Slider
id="number-count"
min={5}
max={15}
step={1}
value={[numberCount]}
onValueChange={(value) => setNumberCount(value[0])}
className="w-24"
/>
</div>
</div> </div>
<div className="text-sm text-muted-foreground space-y-2 text-center"> <div className="flex items-center gap-4 min-w-[200px]">
{mode === 'default' ? ( <Label htmlFor="number-count">Numbers: {numberCount}</Label>
<> <Slider
<p>Drag numbers from below into the circle slots.</p> id="number-count"
<p>Adjacent numbers that sum to a multiple of 3, 5, or 7 will show a red arc.</p> min={5}
<p>Click a filled slot to remove the number.</p> max={15}
</> step={1}
) : ( value={[numberCount]}
<> onValueChange={(value) => setNumberCount(value[0])}
<p>Numbers are connected by edges if their sum is NOT divisible by 3, 5, or 7.</p> className="w-24"
<p>Click two slots to swap their numbers and form a cycle on the circle's edge.</p> disabled={isActive}
</> />
)}
</div> </div>
</div> </div>
)}
<div className="text-sm text-muted-foreground space-y-2 text-center">
{mode === 'default' ? (
<>
<p>Drag numbers from below into the circle slots.</p>
<p>Adjacent numbers that sum to a multiple of 3, 5, or 7 will show a red arc.</p>
<p>Click a filled slot to remove the number.</p>
</>
) : (
<>
<p>Numbers are connected by light grey edges if their sum is NOT divisible by 3, 5, or 7.</p>
<p>Violations around the circle are marked by red arcs, and valid neighbors are connected by a green line.</p>
<p>Click two slots to swap their numbers and form a cycle on the circle's edge.</p>
</>
)}
</div>
</div>
{/* Timer + controls — shown after start */} {/* Timer + controls — shown after start */}
{isActive && ( {isActive && (
@ -366,21 +366,24 @@ const NeighborSumAvoidance = ({ shareUrl }: NeighborSumAvoidanceProps) => {
{/* Start button — shown before start */} {/* Start button — shown before start */}
{!isActive && ( {!isActive && (
<div className="text-center"> <div className="text-center">
<Button onClick={() => startGame(mode)} size="lg"> <Button onClick={startGame} size="lg">
Start {mode === 'default' ? 'Default' : 'Guided'} Mode Start
</Button> </Button>
</div> </div>
)} )}
{/* Available numbers for default mode */} {/* Available numbers for default mode always visible pre/post start,
{isActive && mode === 'default' && availableNumbers.length > 0 && ( but only draggable once active */}
{mode === 'default' && availableNumbers.length > 0 && (
<div className="flex flex-wrap justify-center gap-4"> <div className="flex flex-wrap justify-center gap-4">
{availableNumbers.map(num => ( {availableNumbers.map(num => (
<div <div
key={num} key={num}
draggable draggable={isActive}
onDragStart={() => handleDragStart(num, null)} 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 cursor-move hover:scale-110 transition-transform" 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} {num}
</div> </div>

View file

@ -1,21 +1,10 @@
{ {
"statusOverrides": {}, "statusOverrides": {},
"notes": { "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": [ "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"
}
],
"todos": { "todos": {
"parity-bits-game": [ "parity-bits-game": [
{ {
@ -353,32 +342,42 @@
{ {
"id": "5ffacb36-9d7e-4e95-8632-deeb606c05a7", "id": "5ffacb36-9d7e-4e95-8632-deeb606c05a7",
"text": "The start button labels should just read start", "text": "The start button labels should just read start",
"done": false, "done": true,
"refId": "T2" "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", "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", "text": "In default mode, show the numbers also, but they should not be draggable until the user hits start",
"done": false, "done": true,
"refId": "T3" "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", "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)", "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, "done": true,
"refId": "T4" "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", "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.", "text": "in guided mode, show the edges and the numbers, but nothing should be swappable until the user hits start.",
"done": false, "done": true,
"refId": "T5" "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", "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.\"", "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, "done": true,
"refId": "T6" "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"
} }
] ]
} }

View file

@ -43,4 +43,6 @@ export const componentMap: Record<string, LazyImport> = {
"rent-division-puzzle": () => import("@/components/interactives/RentDivisionPuzzle"), "rent-division-puzzle": () => import("@/components/interactives/RentDivisionPuzzle"),
"eternal-domination-game": () => import("@/components/interactives/EternalDominationGame"), "eternal-domination-game": () => import("@/components/interactives/EternalDominationGame"),
"knights-and-knaves": () => import("@/components/interactives/KnightsAndKnavesI"), "knights-and-knaves": () => import("@/components/interactives/KnightsAndKnavesI"),
"three-bank-accounts": () => import("@/components/interactives/ThreeBankAccounts"),
"computing-pi": () => import("@/components/interactives/ComputingPi"),
}; };

View file

@ -706,6 +706,24 @@ export const allInteractives: Interactive[] = [
hasGreenScreen: false, hasGreenScreen: false,
status: "published" as const, 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. */ /** All published interactives. Use this for public-facing pages. */