How I turned 5 abstract ML concepts into tactile, interactive playgrounds using React, D3.js, Three.js, and a lot of creative thinking.
The Problem
Every ML tutorial follows the same pattern:
- Show a math formula
- Explain the algorithm
- Run code on a dataset
- Look at static output metrics
Learners nod along but walk away without feeling the concepts. You can memorize the equation for linear regression, but do you understand how each data point pulls the line? Do you feel why one outlier can wreck your model?
I wanted to build something different — a place where you learn by touching, dragging, snapping, and building.
The Solution: Play & Learn ML
Play & Learn ML is a browser-based interactive playground where every ML concept is mapped to a physical metaphor:
| Concept | Metaphor | Why It Works |
|---|---|---|
| Linear Regression | Stretchy Rope | Everyone understands tension and balance |
| K-Means | Magnetic Clusters | Magnets pulling iron filings is visceral |
| Decision Trees | 20 Questions | Everyone knows this game |
| Ensemble | Jury Room | Wisdom of the crowd is intuitive |
| Neural Networks | Lego Blocks | Building with bricks is universal |
Architecture Deep Dive
Technology Choices
React 19 + Vite 8 — The latest React with Vite's instant HMR made iteration fast. Each module is a single component that owns its D3/Three.js canvas via refs. React handles state (levels, scores, messages), while D3 handles the rendering loop for performance.
D3.js 7 — I chose D3 over Canvas API because:
- SVG elements are DOM nodes, so CSS transitions work naturally
- D3's data join (
data().join()) is perfect for interactive datasets - Drag behavior is built-in via
d3.drag()
React Three Fiber — For the Neural Network module, I needed 3D. R3F gives me Three.js with React's component model. Each neuron is a <boxGeometry> with a custom useFrame hook for the glow pulse animation.
Zustand — Lightweight state management for persisting progress to localStorage. A single store with recordAttempt(), getStars(), and isUnlocked() methods.
The Level System
Each module has 5 progressive levels. The useLevelSystem hook encapsulates:
-
currentLevel(1–5) — which level is active -
completedLevels(Set) — which levels are done -
completeLevel()— marks current level as done, shows celebration -
goNext()— advances to the next level -
selectLevel(n)— jumps to a completed level for replay
Completion is auto-detected after each interaction via a checkLevel() function that evaluates current state against thresholds:
const checkLevel = useCallback((pts) => {
const { sse, r2 } = computeOLS(pts);
switch (ls.currentLevel) {
case 2: if (r2 > 0.85 && !ls.justCompleted) ls.completeLevel(); break;
case 3: if (sse < 200 && !ls.justCompleted) ls.completeLevel(); break;
// ...
}
}, [ls]);
Each Workbench Pattern
Every module follows the same structure:
// 1. Constants and helpers (D3 scales, color palettes, dataset generators)
const xScale = d3.scaleLinear().domain([-120, 120]).range([0, IW]);
// 2. Level definitions (title, objective, hint)
const LEVELS = [ /* ... */ ];
// 3. Component with state + D3 effect
export default function StretchyRope() {
const svgRef = useRef(null);
const ls = useLevelSystem(5);
useEffect(() => {
// Build D3 scene inside the SVG ref
const svg = d3.select(svgRef.current);
// ... render data points, axes, interactive elements
// ... attach drag/click behaviors
}, [dependencies]);
return (
<div className="flex gap-6">
<div><svg ref={svgRef} /></div> {/* D3 canvas */}
<div> {/* Sidebar */}
<LevelSystem /> {/* Level progression */}
<DefinitionGuide /> {/* What/How/Why/What */}
<div>Stats + Controls</div> {/* Live metrics */}
</div>
</div>
);
}
The D3 + React Bridge
The key challenge: React manages state, D3 manages the SVG. The bridge is the useEffect hook:
useEffect(() => {
const svg = d3.select(svgRef.current);
svg.selectAll("*").remove();
// ... build entire D3 scene ...
return () => svg.selectAll("*").remove(); // cleanup
}, [stateDependencies]);
This is intentionally destructive — every time dependencies change, the SVG is rebuilt from scratch. For scenes with <500 elements (our max is ~200 data points), this is fast enough (sub-16ms). The tradeoff is worth it for clean, bug-free code.
3D Neural Network with React Three Fiber
The Lego Blocks module uses Three.js for a rotatable 3D scene:
function NeuronBlock({ position, color }) {
const meshRef = useRef(null);
useFrame((state) => {
// Pulsing glow animation
const pulse = 0.85 + 0.15 * Math.sin(state.clock.elapsedTime * 2);
meshRef.current.material.emissiveIntensity = 0.2 + pulse * 0.8;
meshRef.current.scale.setScalar(0.9 + pulse * 0.1);
});
return (
<mesh ref={meshRef} position={position}>
<boxGeometry args={[0.3, 0.3, 0.3]} />
<meshStandardMaterial color="#6c63ff" emissive="#6c63ff" />
</mesh>
);
}
The useFrame hook runs on every animation frame, giving us the pulsing "alive" feel. Connections between neurons are rendered as quadratic Bezier curves with thickness proportional to weight.
Key Design Decisions
Why SVG over Canvas for 2D?
SVG means:
- CSS transitions — residuals animate smoothly when data changes
- Built-in hit detection — clicking a 1px line is hard, so I added a transparent 20px-wide hit area behind it
-
Accessibility —
<title>tags on data points for hover tooltips
The cost: SVG gets slow past ~1000 elements. Our modules stay well under that.
Why Destructive Re-renders?
Instead of incremental D3 updates (enter/update/exit), I destroy and rebuild the SVG on every state change. This is simpler, prevents stale DOM bugs, and at our scale (<500 elements) takes <5ms.
The DefinitionGuide Component
Instead of static text, each module has a collapsible guide with 4 sections:
| Section | Purpose |
|---|---|
| 📖 Definition | Formal explanation with key vocabulary |
| 🔧 How It Works | Step-by-step tied to the visual metaphor |
| 💡 Why It Matters | Real-world applications |
| 👆 What to Do | Guided experiments |
Only one section is open at a time to avoid overwhelming the learner.
Challenges & Solutions
1. D3 Drag + React State
Problem: D3's drag events fire faster than React's reconciliation. Setting state on every drag event causes lag.
Solution: Only update React state on drag.end. During drag, read/write directly to the D3 data array for instant feedback:
d3.drag()
.on("drag", (event, d) => {
d.x = xScale.invert(mouseX); // Direct mutation — instant
d.y = yScale.invert(mouseY);
// update DOM positions directly — no React
})
.on("end", () => {
setPoints(pts.map(p => ({...p}))); // React state — triggers effect
});
2. Three.js Bundle Size
Problem: Three.js adds ~400KB to the bundle.
Solution: It's loaded eagerly since the NN module is a core feature. For production, I'd use React.lazy() to code-split it.
3. Level Progression Detection
Problem: Level completion is checked inside useEffect, which can cause infinite loops if state changes trigger re-renders.
Solution: The completeLevel() function guards against double-completion:
const completeLevel = () => {
if (completedLevels.has(currentLevel)) return; // Guard
// ... proceed with completion
};
What I'd Do Differently
- Canvas-based rendering for the K-Means field lines (currently SVG generates 3000+ tiny line elements)
- Web Workers for heavy computation (like the ensemble decision surface, which grids 20,000+ cells)
- Unit tests for the level detection logic using Vitest
- Responsive design — the fixed 700×500 canvases don't work well on mobile
Try It Yourself
git clone https://github.com/harishkotra/play-learn-ml.git
cd play-learn-ml
npm install && npm run dev
Or just read the code — each module is a single self-contained file under src/workbenches/.
Try it here: https://play-learn-ml.vercel.app/
Code & more: https://www.dailybuild.xyz/
Top comments (0)