From 69e4b8074c09a67f1a983149cb3b93d90fc77b3a Mon Sep 17 00:00:00 2001 From: sujay <166272628+sujugithub@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:37:52 +1100 Subject: [PATCH] Add files via upload --- animations.css | 55 +++++ app.js | 222 ++++++++++++++++++ canvas.js | 105 +++++++++ components.css | 562 +++++++++++++++++++++++++++++++++++++++++++++ layout.css | 197 ++++++++++++++++ model.js | 248 ++++++++++++++++++++ networkRenderer.js | 268 +++++++++++++++++++++ preprocessor.js | 71 ++++++ reset.css | 33 +++ resultsUI.js | 153 ++++++++++++ theme.js | 48 ++++ tokens.css | 130 +++++++++++ 12 files changed, 2092 insertions(+) create mode 100644 animations.css create mode 100644 app.js create mode 100644 canvas.js create mode 100644 components.css create mode 100644 layout.css create mode 100644 model.js create mode 100644 networkRenderer.js create mode 100644 preprocessor.js create mode 100644 reset.css create mode 100644 resultsUI.js create mode 100644 theme.js create mode 100644 tokens.css 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); +}