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:
parent
014d90a36b
commit
f527a712be
6 changed files with 364 additions and 95 deletions
|
|
@ -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> }>> = {
|
||||
"binary-number-game": () => import("./interactives/BinaryNumberGame"),
|
||||
|
|
@ -42,6 +42,7 @@ const componentMap: Record<string, () => Promise<{ default: ComponentType<any> }
|
|||
"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<string, React.LazyExoticComponent<ComponentType<any>>>();
|
||||
|
|
|
|||
246
src/components/interactives/ComputingPi.tsx
Normal file
246
src/components/interactives/ComputingPi.tsx
Normal 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;
|
||||
|
|
@ -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,8 +293,7 @@ const NeighborSumAvoidance = ({ shareUrl }: NeighborSumAvoidanceProps) => {
|
|||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Config controls — shown before start */}
|
||||
{!isActive && (
|
||||
{/* Config controls — always visible, slider/toggle disabled once active */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-center gap-6">
|
||||
<div className="flex items-center gap-4">
|
||||
|
|
@ -304,6 +302,7 @@ const NeighborSumAvoidance = ({ shareUrl }: NeighborSumAvoidanceProps) => {
|
|||
id="mode-switch"
|
||||
checked={mode === 'guided'}
|
||||
onCheckedChange={(checked) => setMode(checked ? 'guided' : 'default')}
|
||||
disabled={isActive}
|
||||
/>
|
||||
<Label htmlFor="mode-switch">Guided Mode</Label>
|
||||
</div>
|
||||
|
|
@ -318,6 +317,7 @@ const NeighborSumAvoidance = ({ shareUrl }: NeighborSumAvoidanceProps) => {
|
|||
value={[numberCount]}
|
||||
onValueChange={(value) => setNumberCount(value[0])}
|
||||
className="w-24"
|
||||
disabled={isActive}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -331,13 +331,13 @@ const NeighborSumAvoidance = ({ shareUrl }: NeighborSumAvoidanceProps) => {
|
|||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>Numbers are connected by edges if their sum is NOT divisible by 3, 5, or 7.</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 */}
|
||||
{isActive && (
|
||||
|
|
@ -366,21 +366,24 @@ const NeighborSumAvoidance = ({ shareUrl }: NeighborSumAvoidanceProps) => {
|
|||
{/* Start button — shown before start */}
|
||||
{!isActive && (
|
||||
<div className="text-center">
|
||||
<Button onClick={() => startGame(mode)} size="lg">
|
||||
Start {mode === 'default' ? 'Default' : 'Guided'} Mode
|
||||
<Button onClick={startGame} size="lg">
|
||||
Start
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 && (
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
{availableNumbers.map(num => (
|
||||
<div
|
||||
key={num}
|
||||
draggable
|
||||
onDragStart={() => 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}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,4 +43,6 @@ export const componentMap: Record<string, LazyImport> = {
|
|||
"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"),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue