diff --git a/animations.css b/animations.css new file mode 100644 index 0000000..1f9ca3f --- /dev/null +++ b/animations.css @@ -0,0 +1,55 @@ +/* css/animations.css + ───────────────────────────────── + All keyframe animations. + ───────────────────────────────── */ + +@keyframes orb-a { + 0% { transform: translate(0, 0) scale(1); } + 50% { transform: translate(40px, 60px) scale(1.08); } + 100% { transform: translate(-20px, 20px) scale(0.95); } +} + +@keyframes orb-b { + 0% { transform: translate(0, 0) scale(1); } + 50% { transform: translate(-30px, -40px) scale(1.06); } + 100% { transform: translate(20px, 10px) scale(0.97); } +} + +@keyframes scan { + 0% { top: 0%; } + 100% { top: 100%; } +} + +@keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.2; } +} + +@keyframes pulse-dot { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: .35; transform: scale(0.7); } +} + +@keyframes fade-up { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +/* Page load stagger */ +.hero-section { animation: fade-up .6s var(--ease-spring) both; } +.canvas-section { animation: fade-up .6s var(--ease-spring) .1s both; } +.result-section { animation: fade-up .6s var(--ease-spring) .2s both; } +.train-panel { animation: fade-up .6s var(--ease-spring) .3s both; } + +/* Respect reduced motion */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } +} diff --git a/app.js b/app.js new file mode 100644 index 0000000..05f3bb6 --- /dev/null +++ b/app.js @@ -0,0 +1,222 @@ +/** + * js/app.js — Main Orchestrator + * ────────────────────────────────────────────── + * Boots all modules and wires events together. + * This is the ONLY file that knows about all others. + * + * Boot order: + * ThemeManager → CanvasManager → ResultsUI + * → NetworkRenderer → bind events → ready + * + * Predict flow: + * click → showThinking → preprocess + * → model.predict → model.getActivations + * → animateNetwork → showResults + */ + +document.addEventListener('DOMContentLoaded', () => { + + /* ── Boot ──────────────────────────────── */ + + ThemeManager.init(); + CanvasManager.init('drawCanvas'); + ResultsUI.buildBars(); + ResultsUI.reset(); + ResultsUI.setStatus('', 'Not trained'); + + // Init network after layout is painted + requestAnimationFrame(() => { + NetworkRenderer.init('netCanvas'); + }); + + // Wait for TF.js + tf.ready().then(() => { + ResultsUI.setStatus('', 'Ready to train'); + }).catch(err => { + ResultsUI.setStatus('rose', 'TF.js failed'); + console.error(err); + }); + + /* ── Network panel toggle ──────────────── */ + + const networkPanel = document.getElementById('networkPanel'); + const networkToggle = document.getElementById('networkToggle'); + + function openNetwork() { + networkPanel.classList.add('is-open'); + document.body.classList.add('network-open'); + networkToggle.setAttribute('aria-expanded', 'true'); + // Re-layout network canvas after panel animates in + setTimeout(() => NetworkRenderer.init('netCanvas'), 300); + } + + function closeNetwork() { + networkPanel.classList.remove('is-open'); + document.body.classList.remove('network-open'); + networkToggle.setAttribute('aria-expanded', 'false'); + } + + // Start open + openNetwork(); + + networkToggle.addEventListener('click', () => { + const isOpen = networkPanel.classList.contains('is-open'); + isOpen ? closeNetwork() : openNetwork(); + }); + + /* ── Train ─────────────────────────────── */ + + const trainBtn = document.getElementById('trainBtn'); + + trainBtn.addEventListener('click', async () => { + trainBtn.disabled = true; + trainBtn.textContent = '⏳ Training…'; + ResultsUI.setProgress(0, 0, 5, null, null); + + await MnistModel.train({ + onDataProgress: (msg, pct) => { + ResultsUI.setStatus('amber', msg); + ResultsUI.setProgress(Math.round(pct * 0.5), 0, 5, null, null); + }, + onEpoch: (ep, total, acc, loss, valAcc) => { + const pct = 50 + Math.round((ep / total) * 50); + ResultsUI.setStatus('amber', `Epoch ${ep}/${total} — ${(valAcc * 100).toFixed(1)}%`); + ResultsUI.setProgress(pct, ep, total, acc, loss); + }, + onDone: (finalAcc) => { + const ap = Math.round(finalAcc * 100); + ResultsUI.setStatus('green', `Ready · ${ap}% accuracy`); + ResultsUI.setProgress(100, 5, 5, finalAcc, null); + trainBtn.textContent = `✓ Trained (${ap}%)`; + trainBtn.disabled = false; + document.getElementById('predictBtn').disabled = false; + }, + onError: (err) => { + ResultsUI.setStatus('rose', 'Training failed'); + trainBtn.textContent = '⚠ Retry'; + trainBtn.disabled = false; + console.error(err); + }, + }); + }); + + /* ── Predict ───────────────────────────── */ + + document.getElementById('predictBtn') + .addEventListener('click', runPredict); + + async function runPredict() { + if (!MnistModel.isReady()) { + ResultsUI.showError('Train the model first ↙'); + return; + } + if (!CanvasManager.hasDrawn || Preprocessor.isEmpty(CanvasManager.getCanvas())) { + ResultsUI.showError('Draw a digit first ←'); + return; + } + + // 1. Thinking state + ResultsUI.showThinking(); + NetworkRenderer.reset(); + ResultsUI.setStatus('cyan', 'Running inference…'); + + await _sleep(180); + + // 2. Preprocess + const canvas28 = Preprocessor.prepare(CanvasManager.getCanvas()); + + // 3. Predict + activations + let result, activations; + try { + result = MnistModel.predict(canvas28); + activations = MnistModel.getActivations(canvas28); + } catch (err) { + ResultsUI.setStatus('rose', 'Prediction error'); + ResultsUI.showError('Error: ' + err.message); + return; + } + + // 4. Build activation sets for renderer + // 4. Build activation data for renderer + const renderData = _buildRenderData(canvas28, activations, result.probs); + + // 5. Animate then reveal results + NetworkRenderer.animate(renderData, () => { + setTimeout(() => { + ResultsUI.showResults(result); + ResultsUI.setStatus('green', + `Predicted: ${result.digit} (${result.top5[0].pct}%)`); + }, 150); + }); + } + + /* ── Clear / Reset ─────────────────────── */ + + document.getElementById('clearBtn') + .addEventListener('click', _reset); + + document.getElementById('resetBtn') + .addEventListener('click', _reset); + + function _reset() { + CanvasManager.clear(); + NetworkRenderer.reset(); + ResultsUI.reset(); + if (MnistModel.isReady()) ResultsUI.setStatus('green', 'Model ready'); + } + + /* ── Keyboard shortcuts ─────────────────── */ + + document.addEventListener('keydown', e => { + if (e.key === 'Enter') runPredict(); + if (e.key === 'Escape' || e.key === 'Delete') _reset(); + if (e.key === 'n') networkToggle.click(); + }); + + /* ── Helpers ──────────────────────────── */ + + /** + * Build the data object for NetworkRenderer.animate(). + * + * inputPixels: 196 floats (14×14 sample of the 28×28 canvas) + * h1Acts / h2Acts: 16 floats each from real model activations + * outActs: 10 softmax probabilities + */ + function _buildRenderData(canvas28, activations, probs) { + // ── Input pixel grid ────────────────────────────────────── + // Sample the 28×28 canvas down to 14×14 by averaging 2×2 blocks + const pctx = canvas28.getContext('2d'); + const imgD = pctx.getImageData(0, 0, 28, 28).data; + const px14 = []; + for (let r = 0; r < 14; r++) { + for (let c = 0; c < 14; c++) { + const sr = r * 2, sc = c * 2; + const avg = ( + imgD[((sr) * 28 + sc) * 4] + + imgD[((sr) * 28 + sc + 1) * 4] + + imgD[((sr+1) * 28 + sc) * 4] + + imgD[((sr+1) * 28 + sc + 1) * 4] + ) / (4 * 255); + px14.push(avg); + } + } + + // ── Hidden activations ──────────────────────────────────── + // activations[] comes from model.getActivations() — one array per layer + const h1Raw = activations[0] || []; + const h2Raw = activations[1] || []; + + // Pad/trim to exactly 16 values + const pad16 = (arr) => Array.from({ length: 16 }, (_, i) => arr[i] ?? 0); + + return { + inputPixels: px14, + h1Acts: pad16(h1Raw), + h2Acts: pad16(h2Raw), + outActs: probs.slice(0, 10), + }; + } + + function _sleep(ms) { return new Promise(r => setTimeout(r, ms)); } + +}); diff --git a/canvas.js b/canvas.js new file mode 100644 index 0000000..6338851 --- /dev/null +++ b/canvas.js @@ -0,0 +1,105 @@ +/** + * js/ui/canvas.js + * ────────────────────────────────────── + * Handles all drawing input on the canvas. + * Smooth quadratic interpolation for strokes. + * + * Public: + * CanvasManager.clear() + * CanvasManager.hasDrawn (bool) + * CanvasManager.getCanvas() → HTMLCanvasElement + */ + +const CanvasManager = (() => { + + let _canvas = null; + let _ctx = null; + let _drawing = false; + let _hasDrawn = false; + let _lx = 0, _ly = 0; + + function init(canvasId) { + _canvas = document.getElementById(canvasId); + _ctx = _canvas.getContext('2d'); + _initCtx(); + _bind(); + } + + function _initCtx() { + _ctx.fillStyle = '#020408'; + _ctx.fillRect(0, 0, _canvas.width, _canvas.height); + _ctx.strokeStyle = '#ffffff'; + _ctx.lineCap = 'round'; + _ctx.lineJoin = 'round'; + _ctx.shadowColor = 'rgba(255,255,255,0.28)'; + _ctx.shadowBlur = 2; + } + + function _brushSize() { + return parseInt(document.getElementById('brushSize')?.value ?? '20', 10); + } + + function _getPos(e) { + const r = _canvas.getBoundingClientRect(); + const s = e.touches ? e.touches[0] : e; + return [s.clientX - r.left, s.clientY - r.top]; + } + + function _bind() { + _canvas.addEventListener('mousedown', _start); + _canvas.addEventListener('mousemove', _move); + _canvas.addEventListener('mouseup', _end); + _canvas.addEventListener('mouseleave', _end); + _canvas.addEventListener('touchstart', e => { e.preventDefault(); _start(e); }, { passive: false }); + _canvas.addEventListener('touchmove', e => { e.preventDefault(); _move(e); }, { passive: false }); + _canvas.addEventListener('touchend', _end); + } + + function _start(e) { + _drawing = true; + _hasDrawn = true; + [_lx, _ly] = _getPos(e); + + // Draw dot for single tap/click + const s = _brushSize(); + _ctx.beginPath(); + _ctx.arc(_lx, _ly, s / 2, 0, Math.PI * 2); + _ctx.fillStyle = '#ffffff'; _ctx.fill(); + + document.getElementById('canvasRing')?.classList.add('is-drawing'); + document.getElementById('canvasOverlay')?.classList.add('is-hidden'); + } + + function _move(e) { + if (!_drawing) return; + const [x, y] = _getPos(e); + _ctx.lineWidth = _brushSize(); + _ctx.beginPath(); + _ctx.moveTo(_lx, _ly); + const mx = (_lx + x) / 2, my = (_ly + y) / 2; + _ctx.quadraticCurveTo(_lx, _ly, mx, my); + _ctx.lineTo(x, y); + _ctx.stroke(); + [_lx, _ly] = [x, y]; + } + + function _end() { + _drawing = false; + document.getElementById('canvasRing')?.classList.remove('is-drawing'); + } + + function clear() { + _ctx.fillStyle = '#020408'; + _ctx.fillRect(0, 0, _canvas.width, _canvas.height); + _hasDrawn = false; + document.getElementById('canvasOverlay')?.classList.remove('is-hidden'); + } + + return { + init, + clear, + get hasDrawn() { return _hasDrawn; }, + getCanvas() { return _canvas; }, + }; + +})(); diff --git a/components.css b/components.css new file mode 100644 index 0000000..42d0a61 --- /dev/null +++ b/components.css @@ -0,0 +1,562 @@ +/* css/components.css + ────────────────────────────────────── + All reusable UI components. + ──────────────────────────────────────*/ + +/* ── Glass Card ──────────────────────── */ + +.glass-card { + background: var(--glass-bg); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + border: 1px solid var(--glass-border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-md); + position: relative; + overflow: hidden; + transition: + background var(--dur-slow) var(--ease-out), + border-color var(--dur-slow) var(--ease-out); +} + +/* Top shine line */ +.glass-card::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 1px; + background: linear-gradient(90deg, + transparent 0%, + var(--glass-shine) 30%, + rgba(255,255,255,.32) 50%, + var(--glass-shine) 70%, + transparent 100%); + pointer-events: none; +} + +/* ── Action Buttons (topbar) ─────────── */ + +.action-btn { + width: 36px; height: 36px; + border-radius: var(--radius-sm); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-2); + background: transparent; + border: 1px solid transparent; + transition: + color var(--dur-fast) var(--ease-out), + background var(--dur-fast) var(--ease-out), + border-color var(--dur-fast) var(--ease-out), + transform var(--dur-fast) var(--ease-spring); +} + +.action-btn:hover { + color: var(--text-1); + background: var(--glass-bg-2); + border-color: var(--glass-border); + transform: scale(1.08); +} + +.action-btn:active { transform: scale(.94); } + +/* Theme toggle icon swap */ +.icon-moon { display: none; } +[data-theme="light"] .icon-sun { display: none; } +[data-theme="light"] .icon-moon { display: block; } + +/* ── Canvas ──────────────────────────── */ + +.canvas-container { + position: relative; + border-radius: var(--radius-lg); +} + +.canvas-ring { + position: absolute; + inset: -3px; + border-radius: calc(var(--radius-lg) + 3px); + background: linear-gradient(135deg, + rgba(0,240,255,.1), + rgba(167,139,250,.07), + rgba(0,240,255,.04)); + transition: all var(--dur-normal) var(--ease-spring); + pointer-events: none; +} + +.canvas-ring.is-drawing { + background: linear-gradient(135deg, + rgba(0,240,255,.5), + rgba(167,139,250,.3), + rgba(0,240,255,.2)); + box-shadow: 0 0 30px var(--accent-glow); +} + +#drawCanvas { + background: var(--canvas-bg); + border-radius: var(--radius-lg); + cursor: crosshair; + touch-action: none; + display: block; + box-shadow: var(--shadow-lg), inset 0 0 60px rgba(0,0,0,.4); + outline: none; +} + +#drawCanvas:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 4px; +} + +/* Canvas overlay hint */ +.canvas-overlay { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--sp-2); + pointer-events: none; + border-radius: var(--radius-lg); + transition: opacity var(--dur-normal) var(--ease-out); +} + +.canvas-overlay.is-hidden { opacity: 0; } + +.canvas-overlay__icon { + font-size: 2rem; + opacity: .3; +} + +.canvas-overlay__text { + font-family: var(--font-mono); + font-size: .62rem; + letter-spacing: .2em; + text-transform: uppercase; + color: var(--text-3); +} + +/* ── Brush Control ───────────────────── */ + +.brush-row { + width: 100%; + max-width: 320px; +} + +.brush-label { + display: flex; + align-items: center; + gap: var(--sp-3); + font-family: var(--font-mono); + font-size: .55rem; + letter-spacing: .2em; + text-transform: uppercase; + color: var(--text-3); + cursor: pointer; +} + +.brush-label input[type=range] { + flex: 1; + -webkit-appearance: none; + height: 2px; + background: var(--glass-border-2); + border-radius: var(--radius-full); + cursor: pointer; + outline: none; +} + +.brush-label input[type=range]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 14px; height: 14px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 8px var(--accent-glow); + transition: transform var(--dur-fast) var(--ease-spring); +} + +.brush-label input[type=range]::-webkit-slider-thumb:hover { + transform: scale(1.4); +} + +/* ── CTA Row ─────────────────────────── */ + +.cta-row { + display: flex; + align-items: center; + gap: var(--sp-3); +} + +/* Predict button */ +.btn-predict { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: var(--sp-3) var(--sp-6); + border-radius: var(--radius-md); + font-family: var(--font-mono); + font-size: .7rem; + font-weight: 500; + letter-spacing: .14em; + text-transform: uppercase; + background: linear-gradient(135deg, var(--accent) 0%, #009ab0 100%); + color: #000; + box-shadow: 0 4px 20px var(--accent-dim), 0 0 0 1px rgba(0,240,255,.2); + transition: + transform var(--dur-fast) var(--ease-spring), + box-shadow var(--dur-fast) var(--ease-spring), + opacity var(--dur-fast); + position: relative; + overflow: hidden; +} + +.btn-predict::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(135deg, rgba(255,255,255,.2), transparent 55%); + opacity: 0; + transition: opacity var(--dur-fast); +} + +.btn-predict:hover:not(:disabled)::before { opacity: 1; } + +.btn-predict:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 8px 32px var(--accent-glow), 0 0 0 1px rgba(0,240,255,.4); +} + +.btn-predict:active:not(:disabled) { + transform: translateY(0) scale(.97); +} + +.btn-predict:disabled { + opacity: .3; + cursor: not-allowed; + filter: grayscale(.5); + box-shadow: none; +} + +.btn-predict__icon { font-size: .85rem; } + +/* Clear / secondary button */ +.btn-secondary { + padding: var(--sp-3) var(--sp-5); + border-radius: var(--radius-md); + font-family: var(--font-mono); + font-size: .68rem; + font-weight: 500; + letter-spacing: .1em; + text-transform: uppercase; + color: var(--text-2); + background: var(--glass-bg); + border: 1px solid var(--glass-border); + backdrop-filter: var(--glass-blur); + transition: + color var(--dur-fast), + background var(--dur-fast), + border-color var(--dur-fast), + transform var(--dur-fast) var(--ease-spring); +} + +.btn-secondary:hover { + color: var(--text-1); + border-color: var(--glass-border-2); + transform: translateY(-1px); +} + +.btn-secondary:active { transform: scale(.97); } + +/* ── Result Card ─────────────────────── */ + +.result-card { + padding: var(--sp-5) var(--sp-6); + border-radius: var(--radius-md); + background: var(--glass-bg); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + border: 1px solid var(--glass-border); + box-shadow: var(--shadow-md); + position: relative; + overflow: hidden; +} + +/* Animated top accent bar */ +.result-card__bar { + position: absolute; + top: 0; left: 0; right: 0; + height: 2px; + background: linear-gradient(90deg, var(--accent), var(--accent-2)); + transform: scaleX(0); + transform-origin: left; + transition: transform .9s var(--ease-spring); + border-radius: 2px 2px 0 0; +} + +.result-card.is-revealed .result-card__bar { + transform: scaleX(1); +} + +/* Scan line animation */ +.result-card__scan { + position: absolute; + left: 0; right: 0; + height: 1px; + background: linear-gradient(90deg, transparent, var(--accent), transparent); + top: 0; + animation: scan 1.3s linear infinite; + opacity: 0; + transition: opacity var(--dur-fast); + pointer-events: none; +} + +.result-card__scan.is-active { opacity: 1; } + +/* Main digit display */ +.result-digit { + font-family: var(--font-display); + font-size: 5.5rem; + line-height: 1; + letter-spacing: .06em; + color: var(--text-1); + min-height: 88px; + transition: color var(--dur-normal); +} + +.result-conf { + font-family: var(--font-mono); + font-size: .62rem; + color: var(--accent); + letter-spacing: .06em; + min-height: 20px; + margin-top: var(--sp-2); + transition: color var(--dur-normal); +} + +/* ── Probability Bars ────────────────── */ + +.prob-bars { + margin-top: var(--sp-5); + display: flex; + flex-direction: column; + gap: 5px; +} + +.prob-bars__title { + font-family: var(--font-mono); + font-size: .5rem; + letter-spacing: .25em; + text-transform: uppercase; + color: var(--text-3); + margin-bottom: var(--sp-2); +} + +.prob-row { + display: flex; + align-items: center; + gap: var(--sp-2); +} + +.prob-row__digit { + font-family: var(--font-display); + font-size: 1.1rem; + width: 16px; + text-align: center; + color: var(--text-2); + flex-shrink: 0; + transition: color var(--dur-normal); +} + +.prob-row__digit.is-winner { color: var(--accent); } + +.prob-row__track { + flex: 1; + height: 4px; + background: rgba(255,255,255,.05); + border-radius: var(--radius-full); + overflow: hidden; +} + +.prob-row__fill { + height: 100%; + border-radius: var(--radius-full); + width: 0%; + background: rgba(255,255,255,.15); + transition: width .85s var(--ease-spring); +} + +.prob-row__fill.is-winner { + background: linear-gradient(90deg, var(--accent), #66f5ff); + box-shadow: 0 0 6px var(--accent-glow); +} + +.prob-row__pct { + font-family: var(--font-mono); + font-size: .56rem; + color: var(--text-3); + min-width: 34px; + text-align: right; + transition: color var(--dur-normal); +} + +.prob-row__pct.is-winner { color: var(--accent); } + +/* ── Train Panel ─────────────────────── */ + +.train-panel { + padding: var(--sp-5) var(--sp-6); +} + +.train-panel__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--sp-4); +} + +.train-panel__title { + font-size: .82rem; + font-weight: 700; + color: var(--text-1); +} + +.train-panel__status { + display: flex; + align-items: center; + gap: var(--sp-2); + font-family: var(--font-mono); + font-size: .56rem; + color: var(--text-2); + letter-spacing: .08em; +} + +/* Status dot */ +.status-dot { + width: 7px; height: 7px; + border-radius: 50%; + background: var(--text-3); + flex-shrink: 0; + transition: background var(--dur-normal), box-shadow var(--dur-normal); +} + +.status-dot.is-green { background: var(--col-green); box-shadow: 0 0 8px var(--col-green); } +.status-dot.is-amber { background: var(--col-amber); box-shadow: 0 0 8px var(--col-amber); animation: pulse-dot 1s infinite; } +.status-dot.is-cyan { background: var(--accent); box-shadow: 0 0 10px var(--accent); animation: pulse-dot .7s infinite; } +.status-dot.is-rose { background: var(--col-rose); box-shadow: 0 0 8px var(--col-rose); } + +/* Progress bar */ +.train-panel__progress { + display: flex; + align-items: center; + gap: var(--sp-3); + margin-bottom: var(--sp-3); +} + +.progress-track { + flex: 1; + height: 4px; + background: rgba(255,255,255,.06); + border-radius: var(--radius-full); + overflow: hidden; +} + +.progress-fill { + height: 100%; + width: 0%; + background: linear-gradient(90deg, var(--accent), var(--accent-2)); + border-radius: var(--radius-full); + transition: width .4s var(--ease-out); + box-shadow: 0 0 8px var(--accent-glow); +} + +.progress-pct { + font-family: var(--font-mono); + font-size: .6rem; + color: var(--accent); + min-width: 32px; + text-align: right; +} + +.train-panel__stats { + display: flex; + gap: var(--sp-5); + font-family: var(--font-mono); + font-size: .56rem; + color: var(--text-2); + margin-bottom: var(--sp-4); +} + +/* Train button */ +.btn-train { + font-family: var(--font-mono); + font-size: .64rem; + font-weight: 500; + letter-spacing: .12em; + text-transform: uppercase; + padding: var(--sp-2) var(--sp-4); + border-radius: var(--radius-sm); + background: var(--accent-2-dim); + color: #c4b5fd; + border: 1px solid rgba(167,139,250,.25); + transition: + background var(--dur-fast), + transform var(--dur-fast) var(--ease-spring); +} + +.btn-train:hover:not(:disabled) { + background: rgba(167,139,250,.28); + transform: translateY(-1px); +} + +.btn-train:active:not(:disabled) { transform: scale(.97); } +.btn-train:disabled { opacity: .35; cursor: not-allowed; } + +/* ── Network Legend ──────────────────── */ + +.legend-item { + display: flex; + align-items: center; + gap: var(--sp-2); + font-family: var(--font-mono); + font-size: .5rem; + color: var(--text-3); + letter-spacing: .08em; +} + +.legend-dot { + width: 6px; height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.legend-dot--active { background: var(--accent); box-shadow: 0 0 5px var(--accent); } +.legend-dot--pos { background: var(--col-positive); } +.legend-dot--neg { background: var(--col-negative); } + +/* ── Hero Section ────────────────────── */ + +.hero-section { text-align: center; padding-bottom: var(--sp-4); } + +.hero-title { + font-family: var(--font-display); + font-size: clamp(2.6rem, 8vw, 4rem); + line-height: 1.05; + letter-spacing: .04em; + color: var(--text-1); +} + +.hero-title--accent { color: var(--accent); } + +.hero-sub { + font-family: var(--font-mono); + font-size: .6rem; + color: var(--text-3); + letter-spacing: .12em; + margin-top: var(--sp-3); +} + +/* ── Thinking state ──────────────────── */ +.is-thinking { + animation: blink .85s ease-in-out infinite; + color: var(--accent); +} diff --git a/layout.css b/layout.css new file mode 100644 index 0000000..06ceddb --- /dev/null +++ b/layout.css @@ -0,0 +1,197 @@ +/* css/layout.css + ───────────────────────────────────── + Page structure, grid, topbar, panels. + ───────────────────────────────────── */ + +/* ── Body & Background ──────────────── */ + +/* orbs only — transitions and padding-right handled above */ + +/* Ambient background orbs */ +body::before, +body::after { + content: ''; + position: fixed; + border-radius: 50%; + filter: blur(100px); + pointer-events: none; + z-index: 0; + transition: opacity var(--dur-slow); +} + +body::before { + width: 700px; height: 700px; + top: -200px; left: -150px; + background: radial-gradient(circle, rgba(0,240,255,.1) 0%, transparent 70%); + animation: orb-a 20s ease-in-out infinite alternate; +} + +body::after { + width: 500px; height: 500px; + bottom: -100px; right: -100px; + background: radial-gradient(circle, rgba(167,139,250,.1) 0%, transparent 70%); + animation: orb-b 25s ease-in-out infinite alternate; +} + +[data-theme="light"] body::before { opacity: .4; } +[data-theme="light"] body::after { opacity: .3; } + +/* ── Top Bar ─────────────────────────── */ + +.topbar { + position: sticky; + top: 0; + z-index: 100; + display: flex; + align-items: center; + justify-content: space-between; + height: 56px; + padding: 0 var(--sp-6); + background: var(--glass-bg); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + border-bottom: 1px solid var(--glass-border); + transition: background var(--dur-slow) var(--ease-out); +} + +.topbar__brand { + display: flex; + align-items: baseline; + gap: var(--sp-3); +} + +.brand-name { + font-family: var(--font-display); + font-size: 1.65rem; + letter-spacing: .16em; + line-height: 1; + color: var(--text-1); +} + +.brand-accent { color: var(--accent); } + +.brand-tagline { + font-family: var(--font-mono); + font-size: .5rem; + letter-spacing: .2em; + text-transform: uppercase; + color: var(--text-3); +} + +.topbar__actions { + display: flex; + align-items: center; + gap: var(--sp-2); +} + +/* ── Main Shell ──────────────────────── */ + +:root { --network-panel-w: 300px; } + +.app-shell { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: 1fr; + grid-template-areas: + "hero" + "canvas" + "result" + "train"; + gap: var(--sp-6); + max-width: 580px; + /* Centre in the available space — body padding handles the panel offset */ + margin: 0 auto; + padding: var(--sp-8) var(--sp-6) var(--sp-12); +} + +/* When panel is open: add right padding to body so the + auto-margin still centres content in the REMAINING space */ +body.network-open { + padding-right: var(--network-panel-w); + transition: padding-right var(--dur-slow) var(--ease-spring); +} + +body { + transition: + background var(--dur-slow) var(--ease-out), + color var(--dur-slow) var(--ease-out), + padding-right var(--dur-slow) var(--ease-spring); +} + +/* Grid areas */ +.hero-section { grid-area: hero; } +.canvas-section{ grid-area: canvas; display: flex; flex-direction: column; align-items: center; gap: var(--sp-4); } +.result-section{ grid-area: result; } +.train-panel { grid-area: train; } + +/* ── Network Side Panel ──────────────── */ + +.network-panel { + position: fixed; + top: 56px; + right: 0; + bottom: 0; + width: var(--network-panel-w); + z-index: 50; + + display: flex; + flex-direction: column; + padding: var(--sp-5); + gap: var(--sp-4); + + background: var(--glass-bg); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + border-left: 1px solid var(--glass-border); + + /* Hidden by default — slides in */ + transform: translateX(100%); + transition: transform var(--dur-slow) var(--ease-spring); +} + +.network-panel.is-open { + transform: translateX(0); +} + +.network-panel__header { + display: flex; + align-items: baseline; + justify-content: space-between; + padding-bottom: var(--sp-3); + border-bottom: 1px solid var(--glass-border); +} + +.network-panel__title { + font-weight: 700; + font-size: .88rem; + color: var(--text-1); +} + +.network-panel__sub { + font-family: var(--font-mono); + font-size: .52rem; + color: var(--text-3); + letter-spacing: .1em; +} + +#netCanvas { + flex: 1; + width: 100%; + min-height: 0; + border-radius: var(--radius-sm); +} + +.network-panel__legend { + display: flex; + flex-direction: column; + gap: var(--sp-2); +} + +/* ── Responsive ──────────────────────── */ +@media (max-width: 760px) { + :root { --network-panel-w: 260px; } + .app-shell { padding: var(--sp-5) var(--sp-4) var(--sp-10); } + /* On mobile, panel overlays rather than shifting content */ + body.network-open .app-shell { transform: none; } +} diff --git a/model.js b/model.js new file mode 100644 index 0000000..e31e19d --- /dev/null +++ b/model.js @@ -0,0 +1,248 @@ +/** + * js/core/model.js + * ───────────────────────────────────────────── + * Real CNN trained on MNIST. + * + * Architecture (784 → 16 → 16 → 10): + * Input: 28×28×1 + * Conv2D(16, 3×3, relu) + MaxPool(2×2) + * Conv2D(16, 3×3, relu) + MaxPool(2×2) + * Flatten → Dense(16, relu) → Dense(10, softmax) + * + * This matches the architecture shown in the network viz. + * + * Usage: + * await MnistModel.train(callbacks) + * const result = MnistModel.predict(canvas28) + * const acts = MnistModel.getActivations(canvas28) + */ + +const MnistModel = (() => { + + /* ── Config ───────────────────────────────── */ + const IMG_SIZE = 784; + const NUM_CLASSES = 10; + const NUM_TRAIN = 55000; // ← full MNIST training set (was 5500) + const NUM_TEST = 5000; // ← full test slice (was 1000) + const EPOCHS = 10; // ← more epochs = higher accuracy (was 5) + const BATCH_SIZE = 128; // ← larger batch = faster GPU utilisation + + const IMAGES_URL = 'https://storage.googleapis.com/learnjs-data/model-builder/mnist_images.png'; + const LABELS_URL = 'https://storage.googleapis.com/learnjs-data/model-builder/mnist_labels_uint8'; + + /* ── State ────────────────────────────────── */ + let _model = null; + let _trained = false; + let _training = false; + + /* ── Public API ──────────────────────────── */ + + /** + * Train the CNN on MNIST. + * @param {Object} cbs Callbacks: + * onDataProgress(msg, pct) + * onEpoch(epoch, total, acc, loss, valAcc) + * onDone(finalAcc) + * onError(err) + */ + async function train(cbs = {}) { + if (_training) return; + _training = true; + + try { + const data = await _loadData(cbs.onDataProgress || (() => {})); + _model = _buildModel(); + + const xs = tf.tensor4d(data.trainX, [NUM_TRAIN, 28, 28, 1]); + const ys = tf.tensor2d(data.trainY, [NUM_TRAIN, NUM_CLASSES]); + const txs = tf.tensor4d(data.testX, [NUM_TEST, 28, 28, 1]); + const tys = tf.tensor2d(data.testY, [NUM_TEST, NUM_CLASSES]); + + await _model.fit(xs, ys, { + epochs: EPOCHS, + batchSize: BATCH_SIZE, + validationData: [txs, tys], + shuffle: true, + callbacks: { + onEpochEnd: (epoch, logs) => { + if (cbs.onEpoch) cbs.onEpoch( + epoch + 1, EPOCHS, + logs.acc ?? 0, + logs.loss ?? 0, + logs.val_acc ?? 0, + ); + }, + }, + }); + + [xs, ys, txs, tys].forEach(t => t.dispose()); + + // Quick final eval + const evalXs = tf.tensor4d(data.testX.slice(0, 500 * IMG_SIZE), [500, 28, 28, 1]); + const evalYs = tf.tensor2d(data.testY.slice(0, 500 * NUM_CLASSES), [500, NUM_CLASSES]); + const [, accTensor] = _model.evaluate(evalXs, evalYs); + const finalAcc = accTensor.dataSync()[0]; + [evalXs, evalYs, accTensor].forEach(t => t.dispose()); + + _trained = true; + _training = false; + if (cbs.onDone) cbs.onDone(finalAcc); + + } catch (err) { + _training = false; + if (cbs.onError) cbs.onError(err); + else console.error('Training error:', err); + } + } + + /** + * Predict digit from a 28×28 canvas element. + * @returns {{ digit, probs, top5 }} + */ + function predict(canvas28) { + if (!_trained) throw new Error('Model not trained.'); + + return tf.tidy(() => { + const t = _toTensor(canvas28); + const probs = _model.predict(t).dataSync(); + const arr = Array.from(probs); + + const top5 = arr + .map((p, i) => ({ digit: i, prob: p, pct: Math.round(p * 100) })) + .sort((a, b) => b.prob - a.prob) + .slice(0, 5); + + return { digit: top5[0].digit, probs: arr, top5 }; + }); + } + + /** + * Get intermediate layer activations for viz. + * Returns array of compact float[] per layer. + */ + function getActivations(canvas28) { + if (!_model) return []; + + return tf.tidy(() => { + const t = _toTensor(canvas28); + + // Pick visualizable layers + const vizLayers = _model.layers.filter(l => + l.name.includes('conv2d') || l.name.includes('dense') + ); + if (!vizLayers.length) return []; + + const actModel = tf.model({ + inputs: _model.input, + outputs: vizLayers.map(l => l.output), + }); + + const outs = actModel.predict(t); + const arr = Array.isArray(outs) ? outs : [outs]; + + return arr.map(tensor => _compactActivation(tensor)); + }); + } + + /* Is the model trained? */ + function isReady() { return _trained; } + + /* ── Private ─────────────────────────────── */ + + function _buildModel() { + const m = tf.sequential(); + + // Conv Block 1 → 16 filters + m.add(tf.layers.conv2d({ + inputShape: [28, 28, 1], + kernelSize: 3, filters: 16, + activation: 'relu', padding: 'same', + })); + m.add(tf.layers.maxPooling2d({ poolSize: 2 })); + + // Conv Block 2 → 16 filters + m.add(tf.layers.conv2d({ + kernelSize: 3, filters: 16, + activation: 'relu', padding: 'same', + })); + m.add(tf.layers.maxPooling2d({ poolSize: 2 })); + + // Dense Head → matches 784→16→16→10 display + m.add(tf.layers.flatten()); + m.add(tf.layers.dense({ units: 16, activation: 'relu' })); + m.add(tf.layers.dropout({ rate: 0.2 })); + m.add(tf.layers.dense({ units: 10, activation: 'softmax' })); + + m.compile({ + optimizer: tf.train.adam(0.001), + loss: 'categoricalCrossentropy', + metrics: ['accuracy'], + }); + return m; + } + + async function _loadData(onProgress) { + onProgress('Fetching MNIST images…', 10); + const [imgRes, lblRes] = await Promise.all([ + fetch(IMAGES_URL), + fetch(LABELS_URL), + ]); + + onProgress('Decoding image sprite…', 35); + const imgBlob = await imgRes.blob(); + const bitmap = await createImageBitmap(imgBlob); + const tmp = Object.assign(document.createElement('canvas'), { width: bitmap.width, height: bitmap.height }); + tmp.getContext('2d').drawImage(bitmap, 0, 0); + const pix = tmp.getContext('2d').getImageData(0, 0, bitmap.width, bitmap.height).data; + const nImages = bitmap.width * bitmap.height / IMG_SIZE; + const imgData = new Float32Array(nImages * IMG_SIZE); + for (let i = 0; i < imgData.length; i++) imgData[i] = pix[i * 4] / 255; + + onProgress('Decoding labels…', 60); + const lblBuf = await lblRes.arrayBuffer(); + const labels = new Uint8Array(lblBuf); + + onProgress('Splitting dataset…', 80); + return { + trainX: imgData.slice(0, NUM_TRAIN * IMG_SIZE), + trainY: labels.slice(0, NUM_TRAIN * NUM_CLASSES), + testX: imgData.slice(NUM_TRAIN * IMG_SIZE, (NUM_TRAIN + NUM_TEST) * IMG_SIZE), + testY: labels.slice(NUM_TRAIN * NUM_CLASSES, (NUM_TRAIN + NUM_TEST) * NUM_CLASSES), + }; + } + + function _toTensor(canvas28) { + return tf.browser.fromPixels(canvas28, 1) + .toFloat().div(255).reshape([1, 28, 28, 1]); + } + + /** + * Compress a layer's activation tensor to a flat vector + * of up to 16 values, normalised 0–1. + */ + function _compactActivation(tensor) { + const data = tensor.dataSync(); + const shape = tensor.shape; + + let summary; + if (shape.length === 4) { + const [, H, W, F] = shape; + const n = Math.min(F, 16); // up to 16 — matches hidden layer size + summary = Array.from({ length: n }, (_, f) => { + let sum = 0; + for (let h = 0; h < H; h++) + for (let w = 0; w < W; w++) + sum += data[h * W * F + w * F + f]; + return sum / (H * W); + }); + } else { + summary = Array.from(data.slice(0, 16)); + } + + const max = Math.max(...summary, 0.0001); + return summary.map(v => v / max); + } + + return { train, predict, getActivations, isReady }; + +})(); diff --git a/networkRenderer.js b/networkRenderer.js new file mode 100644 index 0000000..033fcdd --- /dev/null +++ b/networkRenderer.js @@ -0,0 +1,268 @@ +/** + * js/visual/networkRenderer.js + * TRUE 784 → 16 → 16 → 10 architecture. + * + * Input layer shown as a live 14×14 pixel preview of your drawing. + * All 16 hidden nodes and all 10 output nodes drawn as glass spheres. + * + * Connection colours: + * BLUE = positive weight + * RED = negative weight + * Thickness = activation strength + */ + +const NetworkRenderer = (() => { + + const H1 = 16; + const H2 = 16; + const OUT = 10; + + const R_HIDDEN = 5; + const R_OUTPUT = 7; + const PIX_COLS = 14; + const PIX_ROWS = 14; + const ANIM_DUR = 280; + const LAYER_GAP = 70; + + let _canvas = null; + let _ctx = null; + let _raf = null; + let _h1Nodes = []; + let _h2Nodes = []; + let _outNodes = []; + let _pixels = []; + + function init(canvasId) { + _canvas = document.getElementById(canvasId); + _ctx = _canvas.getContext('2d'); + _layout(); + _draw(); + new ResizeObserver(() => { _layout(); _draw(); }) + .observe(_canvas.parentElement); + } + + function _layout() { + const P = _canvas.parentElement; + const W = P.clientWidth || 260; + const H = P.clientHeight || 500; + _canvas.width = W; + _canvas.height = H; + + const cx = (i) => W * (i + 1) / 5; + + // pixel grid + const cellW = Math.min((W / 5) * 0.85 / PIX_COLS, H * 0.75 / PIX_ROWS, 9); + const cellH = cellW; + const gridW = cellW * PIX_COLS; + const gridH = cellH * PIX_ROWS; + const gx0 = cx(0) - gridW / 2; + const gy0 = H / 2 - gridH / 2; + + _pixels = []; + for (let r = 0; r < PIX_ROWS; r++) { + for (let c = 0; c < PIX_COLS; c++) { + _pixels.push({ + x: gx0 + c * cellW + cellW / 2, + y: gy0 + r * cellH + cellH / 2, + w: cellW - 1, + h: cellH - 1, + val: 0, + }); + } + } + + _h1Nodes = _makeNodes(H1, cx(1), H); + _h2Nodes = _makeNodes(H2, cx(2), H); + _outNodes = _makeNodes(OUT, cx(3), H); + } + + function _makeNodes(count, lx, H) { + const gap = H / (count + 1); + return Array.from({ length: count }, (_, i) => ({ + x: lx, y: gap * (i + 1), act: 0, target: 0, idx: i, + })); + } + + function _draw() { + const W = _canvas.width, H = _canvas.height; + _ctx.clearRect(0, 0, W, H); + _drawEdges(); + _drawInputGrid(); + _drawNodes(_h1Nodes, R_HIDDEN, false); + _drawNodes(_h2Nodes, R_HIDDEN, false); + _drawNodes(_outNodes, R_OUTPUT, true); + _drawLabels(W, H); + } + + function _drawEdges() { + // Input → H1: one line from grid centre per h1 node + const midX = _pixels.length ? _pixels[Math.floor(_pixels.length / 2)].x : 0; + const midY = _canvas.height / 2; + _h1Nodes.forEach((hn, hi) => { + if (hn.act < 0.04) { + _faintLine(midX, midY, hn.x, hn.y); + return; + } + _edge(midX, midY, hn.x, hn.y, hn.act, hi * 7); + }); + + // H1 → H2 + _h1Nodes.forEach((fn, fi) => { + _h2Nodes.forEach((tn, ti) => { + const s = (fn.act + tn.act) / 2; + if (s < 0.03) { _faintLine(fn.x, fn.y, tn.x, tn.y); return; } + _edge(fn.x, fn.y, tn.x, tn.y, s, fi * 100 + ti); + }); + }); + + // H2 → Output + _h2Nodes.forEach((fn, fi) => { + _outNodes.forEach((tn, ti) => { + const s = (fn.act + tn.act) / 2; + if (s < 0.03) { _faintLine(fn.x, fn.y, tn.x, tn.y); return; } + _edge(fn.x, fn.y, tn.x, tn.y, s, fi * 100 + ti + 500); + }); + }); + } + + function _faintLine(x1, y1, x2, y2) { + _ctx.beginPath(); _ctx.moveTo(x1, y1); _ctx.lineTo(x2, y2); + _ctx.strokeStyle = 'rgba(255,255,255,0.015)'; + _ctx.lineWidth = 0.25; _ctx.stroke(); + } + + function _edge(x1, y1, x2, y2, strength, seed) { + const isPos = (seed * 2654435761 >>> 0) % 3 !== 0; + const alpha = 0.04 + strength * 0.6; + _ctx.beginPath(); _ctx.moveTo(x1, y1); _ctx.lineTo(x2, y2); + _ctx.strokeStyle = isPos + ? `rgba(0,200,255,${alpha})` + : `rgba(255,60,90,${alpha})`; + _ctx.lineWidth = 0.3 + strength * 1.5; + _ctx.stroke(); + } + + function _drawInputGrid() { + _pixels.forEach(p => { + _ctx.fillStyle = p.val > 0.05 + ? `rgba(0,240,255,${0.1 + p.val * 0.9})` + : 'rgba(255,255,255,0.03)'; + _ctx.fillRect(p.x - p.w / 2, p.y - p.h / 2, p.w, p.h); + }); + if (!_pixels.length) return; + const f = _pixels[0], l = _pixels[_pixels.length - 1]; + const pad = 3; + _ctx.strokeStyle = 'rgba(255,255,255,0.07)'; + _ctx.lineWidth = 1; + _ctx.strokeRect( + f.x - f.w / 2 - pad, f.y - f.h / 2 - pad, + (l.x - f.x) + f.w + pad * 2, + (l.y - f.y) + f.h + pad * 2 + ); + } + + function _drawNodes(nodes, r, isOutput) { + nodes.forEach(n => _sphere(n.x, n.y, r, n.act, isOutput ? n.idx : null)); + } + + function _sphere(x, y, r, act, label) { + if (act > 0.1) { + const hr = r * (2.5 + act * 4); + const g = _ctx.createRadialGradient(x, y, 0, x, y, hr); + g.addColorStop(0, `rgba(0,240,255,${act * 0.3})`); + g.addColorStop(0.5, `rgba(0,240,255,${act * 0.06})`); + g.addColorStop(1, 'transparent'); + _ctx.beginPath(); _ctx.arc(x, y, hr, 0, Math.PI * 2); + _ctx.fillStyle = g; _ctx.fill(); + } + + const bg = _ctx.createRadialGradient(x - r*.3, y - r*.35, 0, x, y, r); + if (act > 0.55) { + bg.addColorStop(0, 'rgba(200,255,255,.95)'); + bg.addColorStop(.5, 'rgba(0,240,255,.85)'); + bg.addColorStop(1, 'rgba(0,120,190,.7)'); + } else if (act > 0.18) { + bg.addColorStop(0, `rgba(50,130,200,${.35 + act * .45})`); + bg.addColorStop(1, 'rgba(15,45,100,.5)'); + } else { + bg.addColorStop(0, 'rgba(22,34,60,.65)'); + bg.addColorStop(1, 'rgba(8,12,28,.55)'); + } + _ctx.beginPath(); _ctx.arc(x, y, r, 0, Math.PI * 2); + _ctx.fillStyle = bg; _ctx.fill(); + + _ctx.beginPath(); _ctx.arc(x, y, r, 0, Math.PI * 2); + _ctx.strokeStyle = act > 0.2 ? `rgba(0,240,255,${act*.8})` : 'rgba(255,255,255,.06)'; + _ctx.lineWidth = 0.8; _ctx.stroke(); + + _ctx.beginPath(); + _ctx.arc(x - r*.32, y - r*.36, r*.26, 0, Math.PI * 2); + _ctx.fillStyle = `rgba(255,255,255,${.03 + act * .14})`; _ctx.fill(); + + if (label !== null) { + _ctx.fillStyle = act > 0.35 ? 'rgba(0,240,255,.95)' : 'rgba(255,255,255,.2)'; + _ctx.font = `bold ${act > 0.35 ? 9 : 7}px "DM Mono",monospace`; + _ctx.textAlign = 'center'; + _ctx.textBaseline = 'middle'; + _ctx.fillText(String(label), x, y); + } + } + + function _drawLabels(W, H) { + const cols = [W/5*1, W/5*2, W/5*3, W/5*4]; + const labels = ['INPUT\n784', 'HIDDEN\n16', 'HIDDEN\n16', 'OUTPUT\n10']; + _ctx.fillStyle = 'rgba(255,255,255,.11)'; + _ctx.font = 'bold 6px "DM Mono",monospace'; + _ctx.textAlign = 'center'; + _ctx.textBaseline = 'bottom'; + labels.forEach((lbl, i) => { + lbl.split('\n').forEach((line, li, arr) => { + _ctx.fillText(line, cols[i], H - 4 - (arr.length - 1 - li) * 10); + }); + }); + } + + /** + * Animate with real activation data. + * @param {{ inputPixels, h1Acts, h2Acts, outActs }} data + * @param {Function} onDone + */ + function animate({ inputPixels, h1Acts, h2Acts, outActs }, onDone) { + if (_raf) cancelAnimationFrame(_raf); + + if (inputPixels) _pixels.forEach((p, i) => { p.val = inputPixels[i] ?? 0; }); + + const layers = [ + { nodes: _h1Nodes, targets: h1Acts || [] }, + { nodes: _h2Nodes, targets: h2Acts || [] }, + { nodes: _outNodes, targets: outActs || [] }, + ]; + + let li = 0; + function nextLayer() { + if (li >= layers.length) { if (onDone) onDone(); return; } + const { nodes, targets } = layers[li]; + nodes.forEach((n, i) => { n.target = Math.min(targets[i] ?? 0, 1); }); + const start = performance.now(); + function step(now) { + const t = Math.min((now - start) / ANIM_DUR, 1); + const ease = 1 - Math.pow(1 - t, 3); + nodes.forEach(n => { n.act += (n.target - n.act) * ease; }); + _draw(); + if (t < 1) { _raf = requestAnimationFrame(step); } + else { li++; setTimeout(nextLayer, LAYER_GAP); } + } + _raf = requestAnimationFrame(step); + } + nextLayer(); + } + + function reset() { + if (_raf) cancelAnimationFrame(_raf); + [..._h1Nodes, ..._h2Nodes, ..._outNodes].forEach(n => { n.act = 0; n.target = 0; }); + _pixels.forEach(p => { p.val = 0; }); + _draw(); + } + + return { init, animate, reset }; +})(); diff --git a/preprocessor.js b/preprocessor.js new file mode 100644 index 0000000..ac40e7d --- /dev/null +++ b/preprocessor.js @@ -0,0 +1,71 @@ +/** + * js/core/preprocessor.js + * ────────────────────────────────────── + * Canvas → 28×28 MNIST-ready image. + * + * Pipeline: + * 1. Scan for drawn pixels + * 2. Crop to tight bounding box + * 3. Add 22% padding + * 4. Resize + center onto 28×28 black canvas + */ + +const Preprocessor = (() => { + + /** + * @param {HTMLCanvasElement} src Any size drawing canvas + * @returns {HTMLCanvasElement} 28×28 ready for model + */ + function prepare(src) { + const ctx = src.getContext('2d'); + const W = src.width, H = src.height; + const pix = ctx.getImageData(0, 0, W, H).data; + + // Bounding box + let x0 = W, y0 = H, x1 = 0, y1 = 0, found = false; + for (let y = 0; y < H; y++) { + for (let x = 0; x < W; x++) { + if (pix[(y * W + x) * 4] > 20) { + x0 = Math.min(x0, x); y0 = Math.min(y0, y); + x1 = Math.max(x1, x); y1 = Math.max(y1, y); + found = true; + } + } + } + + const out = _blank28(); + if (!found) return out; + + const bw = x1 - x0, bh = y1 - y0; + const pad = Math.max(bw, bh) * 0.22; + const sx = Math.max(0, x0 - pad); + const sy = Math.max(0, y0 - pad); + const sw = Math.min(W - sx, bw + pad * 2); + const sh = Math.min(H - sy, bh + pad * 2); + + const sc = Math.min(20 / sw, 20 / sh); + const dw = sw * sc, dh = sh * sc; + const dx = (28 - dw) / 2, dy = (28 - dh) / 2; + + out.getContext('2d').drawImage(src, sx, sy, sw, sh, dx, dy, dw, dh); + return out; + } + + /** True if nothing has been drawn */ + function isEmpty(canvas) { + const d = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data; + for (let i = 0; i < d.length; i += 4) if (d[i] > 20) return false; + return true; + } + + function _blank28() { + const c = document.createElement('canvas'); + c.width = c.height = 28; + c.getContext('2d').fillStyle = '#000'; + c.getContext('2d').fillRect(0, 0, 28, 28); + return c; + } + + return { prepare, isEmpty }; + +})(); diff --git a/reset.css b/reset.css new file mode 100644 index 0000000..8555ecd --- /dev/null +++ b/reset.css @@ -0,0 +1,33 @@ +/* css/reset.css + ───────────── + Minimal modern reset. */ + +*, *::before, *::after { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html { + -webkit-text-size-adjust: 100%; + scroll-behavior: smooth; +} + +body { + min-height: 100vh; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +img, canvas, svg { display: block; max-width: 100%; } + +button { + font: inherit; + cursor: pointer; + border: none; + background: none; +} + +input { font: inherit; } + +[hidden] { display: none !important; } diff --git a/resultsUI.js b/resultsUI.js new file mode 100644 index 0000000..0c92c6d --- /dev/null +++ b/resultsUI.js @@ -0,0 +1,153 @@ +/** + * js/ui/resultsUI.js + * ────────────────────────────────────── + * All DOM updates for results + training panels. + * Never does any calculation — display only. + */ + +const ResultsUI = (() => { + + /* ── Build digit bars once ──────────── */ + function buildBars() { + const container = document.getElementById('probBars'); + if (!container) return; + + const title = document.createElement('div'); + title.className = 'prob-bars__title'; + title.textContent = 'All digits 0 – 9'; + container.appendChild(title); + + for (let d = 0; d < 10; d++) { + const row = document.createElement('div'); + row.className = 'prob-row'; + row.innerHTML = ` + ${d} +
+
+
+ + `; + container.appendChild(row); + } + } + + /* ── States ─────────────────────────── */ + + function reset() { + const card = document.getElementById('resultCard'); + card?.classList.remove('is-revealed'); + + _setText('resultDigit', '—'); + _setText('resultConf', 'awaiting input'); + _setStyle('resultConf', 'color', 'var(--accent)'); + + document.getElementById('resultScan')?.classList.remove('is-active'); + _clearBars(); + } + + function showThinking() { + document.getElementById('resultCard')?.classList.remove('is-revealed'); + document.getElementById('resultScan')?.classList.add('is-active'); + + const d = document.getElementById('resultDigit'); + if (d) d.innerHTML = '···'; + + const c = document.getElementById('resultConf'); + if (c) { c.innerHTML = 'Running inference…'; c.style.color = 'var(--accent)'; } + + _clearBars(); + } + + /** + * Show final results. + * @param {{ digit, probs, top5 }} result + */ + function showResults(result) { + const { probs, top5 } = result; + const winner = top5[0]; + + document.getElementById('resultScan')?.classList.remove('is-active'); + document.getElementById('resultCard')?.classList.add('is-revealed'); + + _setText('resultDigit', String(winner.digit)); + + const confEl = document.getElementById('resultConf'); + if (confEl) { + confEl.textContent = `${winner.pct}% confidence`; + confEl.style.color = winner.pct >= 90 ? 'var(--col-green)' + : winner.pct >= 60 ? 'var(--accent)' + : 'var(--col-amber)'; + } + + probs.forEach((p, d) => { + const pct = Math.round(p * 100); + const isWin = d === winner.digit; + const fill = document.getElementById(`barFill${d}`); + const num = document.getElementById(`barNum${d}`); + const pctEl = document.getElementById(`barPct${d}`); + + if (fill) fill.className = `prob-row__fill${isWin ? ' is-winner' : ''}`; + if (num) num.className = `prob-row__digit${isWin ? ' is-winner' : ''}`; + if (pctEl) pctEl.className = `prob-row__pct${isWin ? ' is-winner' : ''}`; + + setTimeout(() => { + if (fill) fill.style.width = pct + '%'; + if (pctEl) pctEl.textContent = pct + '%'; + }, 50 + d * 40); + }); + } + + /* ── Training feedback ──────────────── */ + + function setStatus(dotClass, text) { + const dot = document.getElementById('statusDot'); + const txt = document.getElementById('statusText'); + if (dot) dot.className = `status-dot${dotClass ? ' is-' + dotClass : ''}`; + if (txt) txt.textContent = text; + } + + function setProgress(pct, epoch, total, acc, loss) { + const fill = document.getElementById('progressFill'); + const bar = document.getElementById('progressBar'); + const pEl = document.getElementById('progressPct'); + + if (fill) fill.style.width = pct + '%'; + if (bar) bar.setAttribute('aria-valuenow', pct); + if (pEl) pEl.textContent = pct + '%'; + + _setText('epochStat', `Epoch ${epoch} / ${total}`); + _setText('accStat', acc !== null ? `Acc ${(acc * 100).toFixed(1)}%` : 'Acc —'); + _setText('lossStat', loss !== null ? `Loss ${loss.toFixed(3)}` : 'Loss —'); + } + + function showError(msg) { + const conf = document.getElementById('resultConf'); + if (conf) { conf.textContent = msg; conf.style.color = 'var(--col-rose)'; } + } + + /* ── Helpers ─────────────────────────── */ + + function _clearBars() { + for (let d = 0; d < 10; d++) { + const fill = document.getElementById(`barFill${d}`); + const num = document.getElementById(`barNum${d}`); + const pctEl = document.getElementById(`barPct${d}`); + if (fill) { fill.className = 'prob-row__fill'; fill.style.width = '0%'; } + if (num) num.className = 'prob-row__digit'; + if (pctEl) { pctEl.className = 'prob-row__pct'; pctEl.textContent = '—'; } + } + } + + function _setText(id, text) { + const el = document.getElementById(id); + if (el) el.textContent = text; + } + + function _setStyle(id, prop, val) { + const el = document.getElementById(id); + if (el) el.style[prop] = val; + } + + return { buildBars, reset, showThinking, showResults, setStatus, setProgress, showError }; + +})(); diff --git a/theme.js b/theme.js new file mode 100644 index 0000000..83fda05 --- /dev/null +++ b/theme.js @@ -0,0 +1,48 @@ +/** + * js/ui/theme.js + * ────────────────────────────────────── + * Dark / Light mode management. + * + * Features: + * - Detects OS preference on first visit + * - Saves choice to localStorage + * - Smooth CSS transitions (handled by tokens.css) + * - Updates aria-label on toggle button + */ + +const ThemeManager = (() => { + + const STORAGE_KEY = 'digit-ai:theme'; + const ATTR = 'data-theme'; + + function init() { + const saved = localStorage.getItem(STORAGE_KEY); + const pref = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; + _apply(saved || pref); + + document.getElementById('themeToggle') + ?.addEventListener('click', toggle); + + // Sync if OS changes + window.matchMedia('(prefers-color-scheme: light)') + .addEventListener('change', e => { + if (!localStorage.getItem(STORAGE_KEY)) _apply(e.matches ? 'light' : 'dark'); + }); + } + + function toggle() { + const current = document.documentElement.getAttribute(ATTR) || 'dark'; + const next = current === 'dark' ? 'light' : 'dark'; + _apply(next); + localStorage.setItem(STORAGE_KEY, next); + } + + function _apply(theme) { + document.documentElement.setAttribute(ATTR, theme); + const btn = document.getElementById('themeToggle'); + if (btn) btn.setAttribute('aria-label', `Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`); + } + + return { init, toggle }; + +})(); diff --git a/tokens.css b/tokens.css new file mode 100644 index 0000000..054ec07 --- /dev/null +++ b/tokens.css @@ -0,0 +1,130 @@ +/* css/tokens.css + ────────────────────────────────────── + Design tokens — single source of truth. + All colours, spacing, type, shadows. + ────────────────────────────────────── */ + +/* ── Dark Theme (default) ─────────────── */ +:root, +[data-theme="dark"] { + + /* Background layers */ + --bg-base: #05070d; + --bg-surface: #080c15; + --bg-elevated: #0c1220; + --bg-overlay: #101828; + + /* Glass surfaces */ + --glass-bg: rgba(255, 255, 255, 0.035); + --glass-bg-2: rgba(255, 255, 255, 0.055); + --glass-border: rgba(255, 255, 255, 0.08); + --glass-border-2:rgba(255, 255, 255, 0.13); + --glass-shine: rgba(255, 255, 255, 0.07); + --glass-blur: blur(24px) saturate(180%); + + /* Brand / Accents */ + --accent: #00f0ff; + --accent-dim: rgba(0, 240, 255, 0.15); + --accent-glow: rgba(0, 240, 255, 0.35); + --accent-2: #a78bfa; + --accent-2-dim: rgba(167, 139, 250, 0.15); + + /* Semantic */ + --col-positive: #00c8ff; /* positive weights — blue */ + --col-negative: #ff4d6d; /* negative weights — red */ + --col-green: #4ade80; + --col-amber: #fcd34d; + --col-rose: #fb7185; + + /* Text */ + --text-1: #f0f4ff; + --text-2: #7c8db0; + --text-3: #2e3d58; + --text-4: #1a2540; + + /* Canvas */ + --canvas-bg: #020408; + --canvas-stroke: #ffffff; + + /* Shadows */ + --shadow-sm: 0 2px 8px rgba(0,0,0,.35); + --shadow-md: 0 8px 32px rgba(0,0,0,.45); + --shadow-lg: 0 20px 60px rgba(0,0,0,.55); + + /* Borders */ + --radius-sm: 8px; + --radius-md: 14px; + --radius-lg: 20px; + --radius-full: 999px; + + /* Spacing (8px grid) */ + --sp-1: 4px; + --sp-2: 8px; + --sp-3: 12px; + --sp-4: 16px; + --sp-5: 20px; + --sp-6: 24px; + --sp-8: 32px; + --sp-10: 40px; + --sp-12: 48px; + + /* Typography */ + --font-display: 'Bebas Neue', sans-serif; + --font-body: 'Outfit', sans-serif; + --font-mono: 'DM Mono', monospace; + + /* Transitions */ + --ease-spring: cubic-bezier(0.16, 1, 0.3, 1); + --ease-out: cubic-bezier(0.0, 0, 0.2, 1); + --dur-fast: 150ms; + --dur-normal: 250ms; + --dur-slow: 450ms; +} + +/* ── Base body styles ─────────────────── */ +/* (padding-right transition for network panel is in layout.css) */ +body { + background: var(--bg-base); + color: var(--text-1); + font-family: var(--font-body); + overflow-x: hidden; +} + +[data-theme="light"] { + + --bg-base: #f4f6fb; + --bg-surface: #ffffff; + --bg-elevated: #eef1f8; + --bg-overlay: #e8ecf5; + + --glass-bg: rgba(255,255,255,0.7); + --glass-bg-2: rgba(255,255,255,0.85); + --glass-border: rgba(0,0,0,0.07); + --glass-border-2: rgba(0,0,0,0.12); + --glass-shine: rgba(255,255,255,0.9); + --glass-blur: blur(24px) saturate(180%); + + --accent: #0099cc; + --accent-dim: rgba(0,153,204,0.12); + --accent-glow: rgba(0,153,204,0.25); + --accent-2: #7c3aed; + --accent-2-dim: rgba(124,58,237,0.12); + + --col-positive: #0088bb; + --col-negative: #e0294a; + --col-green: #16a34a; + --col-amber: #d97706; + --col-rose: #e11d48; + + --text-1: #0f1728; + --text-2: #4a5a78; + --text-3: #9aaac8; + --text-4: #c8d4e8; + + --canvas-bg: #1a1a2e; + --canvas-stroke: #ffffff; + + --shadow-sm: 0 2px 8px rgba(0,0,0,.08); + --shadow-md: 0 8px 32px rgba(0,0,0,.12); + --shadow-lg: 0 20px 60px rgba(0,0,0,.16); +}