Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions animations.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
222 changes: 222 additions & 0 deletions app.js
Original file line number Diff line number Diff line change
@@ -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)); }

});
105 changes: 105 additions & 0 deletions canvas.js
Original file line number Diff line number Diff line change
@@ -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; },
};

})();
Loading