From e9533f7ae70c9e9ec7632ab34a2a22986033c57c Mon Sep 17 00:00:00 2001 From: "martijn.diersmann@pm.me" Date: Sat, 26 Sep 2026 18:50:28 +0200 Subject: [PATCH] ok --- app.js | 490 +++++++++++++++++++++++++++++++++++++++++++++++++++++ index.html | 93 ++++++++++ styles.css | 409 ++++++++++++++++++++++++++++++++++++++++++++ test-nn.js | 98 +++++++++++ 4 files changed, 1090 insertions(+) create mode 100644 app.js create mode 100644 index.html create mode 100644 styles.css create mode 100644 test-nn.js diff --git a/app.js b/app.js new file mode 100644 index 0000000..ba6c001 --- /dev/null +++ b/app.js @@ -0,0 +1,490 @@ +/* + * AI Lab — self-contained demo. + * Alles op deze pagina is in een sessie door een AI-agent (Cline) geschreven. + * Geen libraries, geen internet. Alleen HTML + CSS + JS die aan elkaar + * getypt zijn tijdens het bouwen van deze demo. + */ +'use strict'; + +/* ========================================================= + * A. Cline-terminal: het verhaal achter deze demo + * ========================================================= */ + +const SCRIPT = [ + { t: 'prompt', tx: 'Jij: Bouw een self-contained demo-pagina die laat zien hoe krachtig een AI coding agent is. Alles draait in de browser: geen backend, geen API-keys, geen installaties.' }, + { t: 'plan', tx: 'Cline analyseert de vraag en stelt een plan voor — "AI Lab" in 3 blokken.' }, + { t: 'plan', tx: ' 1. Een neuraal netwerk dat écht leert (JS, backpropagation, geen libraries)' }, + { t: 'plan', tx: ' 2. Generative art op canvas die reageert op je muis' }, + { t: 'plan', tx: ' 3. Een terminal die vertelt hoe dit allemaal is ontstaan' }, + { t: 'tool', tx: 'Scannen van de map… 0 bestanden (schone start)' }, + { t: 'tool', tx: 'index.html schrijven … klaar' }, + { t: 'tool', tx: 'styles.css schrijven … klaar' }, + { t: 'tool', tx: 'app.js schrijven … klaar (NeuralNet: forward + backprop in ±90 regels)' }, + { t: 'tool', tx: 'node --check app.js → geen fouten' }, + { t: 'tool', tx: 'python3 -m http.server 8080 → HTTP 200' }, + { t: 'ok', tx: 'Klaar. Dit alles is door een AI-agent in één sessie geschreven — en draait nu op je scherm.' } +]; + +const termBody = document.getElementById('termBody'); +const replayBtn = document.getElementById('termReplay'); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +function pushLine(cls, text) { + const div = document.createElement('div'); + div.className = cls; + div.textContent = text; + termBody.appendChild(div); + termBody.scrollTop = termBody.scrollHeight; + return div; +} + +async function typeInto(el, text, cps) { + for (let i = 0; i < text.length; i++) { + el.textContent = text.slice(0, i + 1); + termBody.scrollTop = termBody.scrollHeight; + await sleep(cps); + } +} + +async function runScript() { + replayBtn.style.display = 'none'; + termBody.innerHTML = ''; + for (const line of SCRIPT) { + if (line.t === 'prompt' || line.t === 'plan') { + const spacer = document.createElement('div'); + spacer.className = 'term-spacer'; + termBody.appendChild(spacer); + } + const el = pushLine(line.t, ''); + const cps = line.t === 'tool' ? 3 : line.t === 'ok' ? 9 : 11; + const txt = line.t === 'tool' ? '$ ' + line.tx : line.tx; + await typeInto(el, txt, cps); + await sleep(line.t === 'ok' ? 300 : 170); + } + replayBtn.style.display = 'inline-flex'; +} + +function bootTerminal() { + replayBtn.addEventListener('click', () => runScript()); + setTimeout(runScript, 600); +} + +/* ========================================================= + * B. Neuraal netwerk dat écht leert + * ========================================================= */ + +const nnCanvas = document.getElementById('nnCanvas'); +const nnCtx = nnCanvas.getContext('2d'); +const lossCanvas = document.getElementById('lossCanvas'); +const lossCtx = lossCanvas.getContext('2d'); +const nnStats = document.getElementById('nnStats'); +const nnPauseBtn = document.getElementById('nnPause'); +const nnResetBtn = document.getElementById('nnReset'); +const nnSpeed = document.getElementById('nnSpeed'); + +const tanh = Math.tanh; +const sig = (v) => 1 / (1 + Math.exp(-v)); +function randn() { + let u = 0, v = 0; + while (!u) u = Math.random(); + while (!v) v = Math.random(); + return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v); +} + +class NeuralNet { + constructor(shape) { + this.shape = shape; + this.W = []; this.b = []; + this.m1W = []; this.m2W = []; this.m1b = []; this.m2b = []; + this.t = 0; + for (let i = 0; i < shape.length - 1; i++) { + const s = Math.sqrt(2 / shape[i]); + const W = Array.from({ length: shape[i + 1] }, () => + Array.from({ length: shape[i] }, () => randn() * s)); + this.W.push(W); + this.b.push(new Array(shape[i + 1]).fill(0)); + this.m1W.push(W.map((r) => r.map(() => 0))); + this.m2W.push(W.map((r) => r.map(() => 0))); + this.m1b.push(new Array(shape[i + 1]).fill(0)); + this.m2b.push(new Array(shape[i + 1]).fill(0)); + } + } + forward(x) { + this.a = [x]; + for (let l = 0; l < this.W.length; l++) { + const n = this.W[l].length; + const z = new Array(n).fill(0); + const prev = this.a[l]; + for (let j = 0; j < n; j++) { + let s = this.b[l][j]; + const row = this.W[l][j]; + for (let k = 0; k < prev.length; k++) s += row[k] * prev[k]; + z[j] = s; + } + const last = l === this.W.length - 1; + this.a.push(last ? z.map(sig) : z.map(tanh)); + } + return this.a[this.a.length - 1]; + } + trainSample(x, y, lr) { + this.t += 1; + const b1 = 0.9, b2 = 0.999, eps = 1e-8; + const bc1 = 1 - Math.pow(b1, this.t); + const bc2 = 1 - Math.pow(b2, this.t); + const out = this.forward(x)[0]; + let d = [out - y]; // gradient dL/dz van de output (sigmoïde + cross-entropy) + for (let l = this.W.length - 1; l >= 0; l--) { + const aPrev = this.a[l]; + const Wl = this.W[l]; + for (let j = 0; j < Wl.length; j++) { + const gb = d[j]; + this.m1b[l][j] = b1 * this.m1b[l][j] + (1 - b1) * gb; + this.m2b[l][j] = b2 * this.m2b[l][j] + (1 - b2) * gb * gb; + this.b[l][j] -= lr * (this.m1b[l][j] / bc1) / (Math.sqrt(this.m2b[l][j] / bc2) + eps); + for (let k = 0; k < aPrev.length; k++) { + const gw = gb * aPrev[k]; + this.m1W[l][j][k] = b1 * this.m1W[l][j][k] + (1 - b1) * gw; + this.m2W[l][j][k] = b2 * this.m2W[l][j][k] + (1 - b2) * gw * gw; + Wl[j][k] -= lr * (this.m1W[l][j][k] / bc1) / (Math.sqrt(this.m2W[l][j][k] / bc2) + eps); + } + } + if (l > 0) { + const dNext = new Array(this.a[l].length).fill(0); + for (let k = 0; k < this.a[l].length; k++) { + let acc = 0; + for (let j = 0; j < Wl.length; j++) acc += Wl[j][k] * d[j]; + const a = this.a[l][k]; + dNext[k] = acc * (1 - a * a); // tanh' = 1 - a² + } + d = dNext; + } + } + return -(y * Math.log(out + 1e-9) + (1 - y) * Math.log(1 - out + 1e-9)); + } + predict(x) { + return this.forward(x)[0]; + } +} + +function makeDataset(name) { + const N = name === 'SPIRAAL' ? 420 : 320; + const pts = []; + if (name === 'XOR') { + for (let i = 0; i < N; i++) { + const x = Math.random() * 2 - 1; + const y = Math.random() * 2 - 1; + pts.push({ x: [x, y], y: (x * y < 0) ? 1 : 0 }); + } + } else if (name === 'CIRKEL') { + for (let i = 0; i < N; i++) { + const x = Math.random() * 2 - 1; + const y = Math.random() * 2 - 1; + pts.push({ x: [x, y], y: (x * x + y * y < 0.64) ? 1 : 0 }); + } + } else { + for (let i = 0; i < N; i++) { + const arm = i < N / 2 ? 0 : 1; + const frac = (i % (N / 2)) / (N / 2); + const t = frac * Math.PI * 3.6; + const r = 0.95 * frac; + const a = t + arm * Math.PI; + pts.push({ + x: [r * Math.cos(a) + (Math.random() - 0.5) * 0.04, + r * Math.sin(a) + (Math.random() - 0.5) * 0.04], + y: arm + }); + } + } + return pts; +} + +let nn = null, samples = [], indices = [], epochLoss = 0, epochs = 0, history = []; +let nnRunning = true, datasetName = 'XOR'; + +function resetNetwork() { + const h = datasetName === 'SPIRAAL' ? 30 : (datasetName === 'CIRKEL' ? 14 : 10); + nn = new NeuralNet([2, h, 1]); + samples = makeDataset(datasetName); + indices = samples.map((_, i) => i); + epochLoss = 0; epochs = 0; history = []; +} + +function shuffle(arr) { + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + const t = arr[i]; arr[i] = arr[j]; arr[j] = t; + } +} + +function evalAccuracy() { + const t = makeDataset(datasetName); + let correct = 0; + const n = Math.min(200, t.length); + for (let i = 0; i < n; i++) { + if ((nn.predict(t[i].x) > 0.5 ? 1 : 0) === t[i].y) correct++; + } + return (correct / n) * 100; +} + +const bThumb = document.createElement('canvas'); +bThumb.width = 200; bThumb.height = 150; +const bCtx = bThumb.getContext('2d'); +const C0 = [15, 23, 42]; +const C1 = [251, 191, 36]; +const C2 = [34, 211, 238]; + +function paintBoundary() { + const img = bCtx.createImageData(200, 150); + const data = img.data; + for (let py = 0; py < 150; py++) { + const y = ((149 - py) / 149) * 2 - 1; + for (let px = 0; px < 200; px++) { + const x = (px / 199) * 2 - 1; + const p = nn.predict([x, y]); + let r = C0[0] + (C1[0] - C0[0]) * p; + let g = C0[1] + (C1[1] - C0[1]) * p; + let b = C0[2] + (C1[2] - C0[2]) * p; + const near = Math.abs(p - 0.5); + if (near < 0.07) { + const w = 1 - near / 0.07; + r += (C2[0] - r) * w; g += (C2[1] - g) * w; b += (C2[2] - b) * w; + } + const i = (py * 200 + px) * 4; + data[i] = r; data[i + 1] = g; data[i + 2] = b; data[i + 3] = 255; + } + } + bCtx.putImageData(img, 0, 0); +} + +function paintPoints() { + for (const s of samples) { + const sx = ((s.x[0] + 1) / 2) * nnCanvas.width; + const sy = (1 - (s.x[1] + 1) / 2) * nnCanvas.height; + nnCtx.beginPath(); + nnCtx.arc(sx, sy, 3.2, 0, Math.PI * 2); + nnCtx.fillStyle = s.y === 1 ? '#fbbf24' : '#7dd3fc'; + nnCtx.fill(); + nnCtx.strokeStyle = 'rgba(255,255,255,0.35)'; + nnCtx.lineWidth = 0.8; + nnCtx.stroke(); + } +} + +function paintLoss() { + const w = lossCanvas.width, h = lossCanvas.height; + lossCtx.fillStyle = '#05070d'; + lossCtx.fillRect(0, 0, w, h); + if (history.length < 2) return; + const top = Math.max(...history.slice(0, 40), 1); + const bottom = Math.max(0, Math.min(...history) * 0.85); + const range = (top - bottom) || 1; + const pad = 12; + lossCtx.strokeStyle = '#34d399'; + lossCtx.lineWidth = 2; + lossCtx.beginPath(); + for (let i = 0; i < history.length; i++) { + const px = pad + (i / (history.length - 1)) * (w - 2 * pad); + const py = pad + (1 - (history[i] - bottom) / range) * (h - 2 * pad); + if (i === 0) lossCtx.moveTo(px, py); else lossCtx.lineTo(px, py); + } + lossCtx.stroke(); + lossCtx.fillStyle = '#8ea3c0'; + lossCtx.font = '12px Menlo, Consolas, monospace'; + lossCtx.fillText('loss ' + epochLoss.toFixed(4), 14, h - 10); +} + +function updateStats() { + nnStats.textContent = + 'epoch ' + epochs.toLocaleString('nl-NL') + + ' · loss ' + epochLoss.toFixed(4) + + ' · accuratie ' + evalAccuracy().toFixed(1) + '% · ' + + (nnRunning ? 'trainen…' : 'gepauzeerd'); +} + +function nnLoop() { + if (nnRunning && nn) { + const e = Math.max(1, Math.floor(Number(nnSpeed.value))); + for (let ep = 0; ep < e; ep++) { + shuffle(indices); + let sum = 0; + for (const i of indices) sum += nn.trainSample(samples[i].x, samples[i].y, 0.05); + epochLoss = sum / indices.length; + epochs++; + } + history.push(epochLoss); + if (history.length > 420) history.shift(); + } + paintBoundary(); + nnCtx.clearRect(0, 0, nnCanvas.width, nnCanvas.height); + nnCtx.drawImage(bThumb, 0, 0, nnCanvas.width, nnCanvas.height); + paintPoints(); + paintLoss(); + updateStats(); + requestAnimationFrame(nnLoop); +} + +function bootNetwork() { + resetNetwork(); + nnSpeed.value = 6; + document.querySelectorAll('.dataset').forEach((b) => { + b.addEventListener('click', () => { + document.querySelectorAll('.dataset').forEach((x) => x.classList.remove('active')); + b.classList.add('active'); + datasetName = b.dataset.set; + resetNetwork(); + }); + }); + nnPauseBtn.addEventListener('click', () => { + nnRunning = !nnRunning; + nnPauseBtn.textContent = nnRunning ? '⏸ Pauze' : '▶ Doorgaan'; + }); + nnResetBtn.addEventListener('click', () => resetNetwork()); + nnLoop(); +} + +/* ========================================================= + * C. Generative art — flow field dat reageert op je muis + * ========================================================= */ + +const artCanvas = document.getElementById('artCanvas'); +const artCtx = artCanvas.getContext('2d'); +const artNewBtn = document.getElementById('artNew'); +const artPauseBtn = document.getElementById('artPause'); +const artPngBtn = document.getElementById('artPng'); + +const PALETTES = [ + ['#0b1020', '#22d3ee', '#a78bfa'], + ['#0b1020', '#fbbf24', '#f43f5e'], + ['#0b1020', '#34d399', '#22d3ee'], + ['#120b20', '#e879f9', '#60a5fa'], + ['#0b1620', '#f59e0b', '#ef4444'] +]; + +let particles = [], artRunning = true, artT = 0; +let artW = 0, artH = 0, mx = -9999, my = -9999; +let palIdx = 0, fieldSeed = 12.3; + +function hexToRgb(h) { + const n = parseInt(h.slice(1), 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +} + +function sizeArtCanvas() { + const wrap = artCanvas.parentElement; + artW = wrap.clientWidth || 800; + artH = Math.round(artW * 0.5); + artCanvas.width = artW; + artCanvas.height = artH; +} + +function fieldAngle(nx, ny) { + const s = fieldSeed; + const t = artT * 0.06; + return Math.sin(nx * 2.4 + s) * Math.cos(ny * 3.1 - s * 0.3 + t) + + Math.sin(nx * 1.2 + ny * 1.7 - s + t) * 0.9 + + Math.cos(nx * 0.6 - ny * 2.2 + s * 0.7 + t) * 0.5; +} + +function initArt() { + fieldSeed = Math.random() * 1000; + palIdx = Math.floor(Math.random() * PALETTES.length); + sizeArtCanvas(); + const n = Math.max(160, Math.min(760, Math.round((artW * artH) / 1300))); + particles = []; + for (let i = 0; i < n; i++) { + particles.push({ x: Math.random() * artW, y: Math.random() * artH }); + } + artCtx.clearRect(0, 0, artW, artH); +} + +function drawParticles(ctx) { + const c2 = hexToRgb(PALETTES[palIdx][1]); + const c3 = hexToRgb(PALETTES[palIdx][2]); + for (const p of particles) { + const ang = fieldAngle(p.x / artW, p.y / artH) * Math.PI * 0.9; + const vx = Math.cos(ang), vy = Math.sin(ang); + const oldX = p.x, oldY = p.y; + let pushX = 0, pushY = 0; + if (mx > -1000) { + const dx = p.x - mx, dy = p.y - my; + const dist = Math.hypot(dx, dy); + if (dist < 120 && dist > 0.001) { + const f = (120 - dist) * 0.06; + pushX = (dx / dist) * f; + pushY = (dy / dist) * f; + } + } + p.x += vx * 2.2 + pushX; + p.y += vy * 2.2 + pushY; + if (p.x < 0) p.x += artW; else if (p.x > artW) p.x -= artW; + if (p.y < 0) p.y += artH; else if (p.y > artH) p.y -= artH; + const mix = 0.5 + 0.5 * Math.sin(ang * 3 + p.x * 0.01); + const r = c2[0] + (c3[0] - c2[0]) * mix; + const g = c2[1] + (c3[1] - c2[1]) * mix; + const b = c2[2] + (c3[2] - c2[2]) * mix; + ctx.strokeStyle = 'rgba(' + (r | 0) + ',' + (g | 0) + ',' + (b | 0) + ',0.85)'; + ctx.lineWidth = 1.4; + ctx.beginPath(); + ctx.moveTo(oldX, oldY); + ctx.lineTo(p.x, p.y); + ctx.stroke(); + } +} + +function artFrame() { + if (artRunning) { + artT++; + artCtx.fillStyle = 'rgba(7,11,22,0.10)'; + artCtx.fillRect(0, 0, artW, artH); + artCtx.globalCompositeOperation = 'lighter'; + drawParticles(artCtx); + artCtx.globalCompositeOperation = 'source-over'; + } + requestAnimationFrame(artFrame); +} + +function exportPng() { + const c = document.createElement('canvas'); + c.width = artW; c.height = artH; + const cx = c.getContext('2d'); + cx.fillStyle = '#070b16'; + cx.fillRect(0, 0, artW, artH); + cx.globalCompositeOperation = 'lighter'; + drawParticles(cx); + const a = document.createElement('a'); + a.download = 'ai-lab-art.png'; + a.href = c.toDataURL('image/png'); + a.click(); +} + +function bootArt() { + initArt(); + artNewBtn.addEventListener('click', () => initArt()); + artPauseBtn.addEventListener('click', () => { + artRunning = !artRunning; + artPauseBtn.textContent = artRunning ? '⏸ Pauze' : '▶ Afspelen'; + }); + artPngBtn.addEventListener('click', exportPng); + artCanvas.addEventListener('mousemove', (e) => { + const r = artCanvas.getBoundingClientRect(); + mx = e.clientX - r.left; + my = e.clientY - r.top; + }); + artCanvas.addEventListener('mouseleave', () => { mx = -9999; my = -9999; }); + window.addEventListener('resize', () => sizeArtCanvas()); + artFrame(); +} + +/* ========================================================= + * Boot — start de drie demo-blokken + * ========================================================= */ + +bootTerminal(); +bootNetwork(); +bootArt(); + + + + + diff --git a/index.html b/index.html new file mode 100644 index 0000000..dfb96ad --- /dev/null +++ b/index.html @@ -0,0 +1,93 @@ + + + + + + AI Lab — de kracht van een AI-agent + + + + +
+

100% in de browser · geen internet · geen installatie

+

AI Lab

+

Alles op deze pagina is door een AI-agent geprogrammeerd — in één sessie, zonder voorbeeldcode. Bewonder, speel, en test zelf waartoe een AI in staat is.

+
+ +
+ + +
+
+ +

Cline — het verhaal

+ terminal +
+
+
+ +
+
+ + +
+
+ +

Neuraal netwerk dat écht leert

+ JS · backpropagation +
+
initialiseren…
+
+
+
De beslisgrens wordt live scherper
+ +
+
+
De fout (loss) daalt
+ +
+
+
+
+ + + +
+
+ + +
+ +
+
+ + +
+
+ +

Generative art

+ canvas · flow field +
+
+ +
+
+ + + +
+

Beweeg met je muis over het kunstwerk — de deeltjes reageren.

+
+ +
+ + + + + + diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..1217550 --- /dev/null +++ b/styles.css @@ -0,0 +1,409 @@ +/* ===== AI Lab — dark thema ===== */ +:root { + --bg: #070b16; + --panel: #0e1526; + --panel2: #131b30; + --line: #223049; + --text: #e6ecf7; + --muted: #8ea3c0; + --cyan: #22d3ee; + --violet: #a78bfa; + --amber: #fbbf24; + --green: #34d399; + --red: #f43f5e; + --mono: "Cascadia Code", "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; +} + +body { + font-family: "Segoe UI", system-ui, -apple-system, Roboto, Arial, sans-serif; + color: var(--text); + background: var(--bg); + line-height: 1.6; + overflow-x: hidden; +} + +body::before { + content: ""; + position: fixed; + inset: 0; + z-index: -2; + background: + radial-gradient(60% 40% at 15% 0%, rgba(34, 211, 238, 0.10), transparent 60%), + radial-gradient(50% 40% at 90% 10%, rgba(167, 139, 250, 0.12), transparent 60%), + var(--bg); +} + +a { + color: var(--cyan); + text-decoration: none; +} + +.container { + max-width: 1060px; + margin: 0 auto; + padding: 0 20px; +} + +/* ===== Hero ===== */ +.hero { + text-align: center; + padding: 64px 20px 24px; +} + +.hero-badge { + display: inline-block; + font-size: 0.8rem; + color: var(--muted); + border: 1px solid var(--line); + border-radius: 999px; + padding: 6px 14px; + background: rgba(19, 27, 48, 0.6); + margin-bottom: 22px; +} + +.hero-title { + margin: 0; + font-size: clamp(3rem, 10vw, 5.5rem); + font-weight: 800; + letter-spacing: -0.02em; + background: linear-gradient(90deg, #22d3ee, #a78bfa 55%, #fbbf24); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +.hero-sub { + max-width: 640px; + margin: 18px auto 0; + color: var(--muted); + font-size: 1.08rem; +} + +.hero-sub strong { + color: var(--text); +} + +/* ===== Panels ===== */ +.panel { + background: linear-gradient(180deg, var(--panel), rgba(14, 21, 38, 0.5)); + border: 1px solid var(--line); + border-radius: 16px; + padding: 22px; + margin: 26px 0; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.35); +} + +.panel-head { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 16px; +} + +.panel-head h2 { + margin: 0; + font-size: 1.15rem; + font-weight: 700; +} + +.tag { + margin-left: auto; + font-family: var(--mono); + font-size: 0.72rem; + color: var(--muted); + background: var(--panel2); + border: 1px solid var(--line); + border-radius: 6px; + padding: 3px 8px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--amber); + flex-shrink: 0; +} + +.dot.cyan { + background: var(--cyan); +} + +.dot.violet { + background: var(--violet); +} + +/* ===== Terminal ===== */ +.term-body { + background: #05070d; + border: 1px solid var(--line); + border-radius: 10px; + padding: 14px 16px; + font-family: var(--mono); + font-size: 0.86rem; + line-height: 1.65; + height: 320px; + overflow-y: auto; +} + +.term-spacer { + height: 10px; +} + +.term-prompt { + color: #b9cdf5; +} + +.term-plan { + color: #c9bcf8; +} + +.term-tool { + color: var(--muted); +} + +.term-ok { + color: var(--green); + font-weight: 700; +} + +.term-body::after { + content: "▍"; + color: var(--cyan); + animation: blink 1s steps(2) infinite; + position: relative; + top: 1px; +} + +@keyframes blink { + 0%, 49% { opacity: 1; } + 50%, 100% { opacity: 0; } +} + +.term-foot { + margin-top: 10px; + text-align: right; +} + +/* ===== Knoppen ===== */ +.btn { + font-family: inherit; + font-size: 0.92rem; + font-weight: 600; + color: var(--text); + background: var(--panel2); + border: 1px solid var(--line); + border-radius: 9px; + padding: 9px 16px; + cursor: pointer; + transition: transform 0.1s ease, border-color 0.15s ease, color 0.15s ease, background 0.15s ease; +} + +.btn:hover { + border-color: var(--cyan); + color: #fff; + transform: translateY(-1px); +} + +.btn.accent { + background: linear-gradient(90deg, #164e63, #155e75); + border-color: rgba(34, 211, 238, 0.4); + color: #a5f3fc; +} + +.btn.accent:hover { + background: linear-gradient(90deg, #0e7490, #155e75); + color: #fff; +} + +.btn.ghost { + background: transparent; + color: var(--muted); +} + +.btn.active { + background: var(--cyan); + color: #062630; + border-color: var(--cyan); +} + +.btn-row { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.hint { + color: var(--muted); + font-size: 0.85rem; + margin: 12px 0 0; +} + +/* ===== Netwerk ===== */ +.nn-stats { + font-family: var(--mono); + font-size: 0.86rem; + color: var(--green); + background: var(--panel2); + border: 1px solid var(--line); + border-radius: 8px; + padding: 8px 12px; + margin-bottom: 14px; + overflow-x: auto; + white-space: nowrap; +} + +.nn-grid { + display: grid; + grid-template-columns: 1.15fr 1fr; + gap: 14px; +} + +.canvas-frame { + margin: 0; + background: var(--panel2); + border: 1px solid var(--line); + border-radius: 10px; + padding: 10px; + min-width: 0; +} + +.canvas-frame figcaption { + font-size: 0.78rem; + color: var(--muted); + margin-bottom: 8px; +} + +.canvas-frame canvas { + display: block; + width: 100%; + height: auto; + border-radius: 6px; + background: #05070d; +} + +.nn-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 16px; + margin-top: 16px; +} + +.slider { + display: flex; + align-items: center; + gap: 12px; + color: var(--muted); + font-size: 0.85rem; +} + +.slider input[type="range"] { + -webkit-appearance: none; + appearance: none; + width: 150px; + height: 6px; + border-radius: 999px; + background: var(--line); + outline: none; +} + +.slider input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--cyan); + border: 2px solid #062630; + cursor: pointer; +} + +.slider input[type="range"]::-moz-range-thumb { + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--cyan); + border: 2px solid #062630; + cursor: pointer; +} + +/* ===== Art ===== */ +.art-wrap { + background: var(--panel2); + border: 1px solid var(--line); + border-radius: 12px; + padding: 10px; +} + +.art-wrap canvas { + display: block; + width: 100%; + border-radius: 8px; + background: #05070d; +} + +/* ===== Footer ===== */ +.footer { + text-align: center; + color: var(--muted); + font-size: 0.85rem; + padding: 34px 20px 44px; +} + +.footer p { + margin: 0; +} + +.footer::before { + content: ""; + display: block; + width: 60px; + height: 3px; + border-radius: 2px; + background: linear-gradient(90deg, var(--cyan), var(--violet)); + margin: 0 auto 18px; +} + +/* ===== Responsive ===== */ +@media (max-width: 820px) { + .nn-grid { + grid-template-columns: 1fr; + } + + .panel { + padding: 16px; + } + + .term-body { + height: 280px; + } + + .nn-controls { + flex-direction: column; + align-items: flex-start; + } +} + +@media (max-width: 520px) { + .hero { + padding-top: 40px; + } + + .tag { + display: none; + } +} + diff --git a/test-nn.js b/test-nn.js new file mode 100644 index 0000000..201994b --- /dev/null +++ b/test-nn.js @@ -0,0 +1,98 @@ +'use strict'; +// Vluchtige Node-test: laadt app.js met een minimale DOM-stub en +// controleert of het neuraal netwerk echt leert (XOR / cirkel / spiraal). +const fs = require('fs'); +const path = require('path'); +const src = fs.readFileSync(path.join(__dirname, 'app.js'), 'utf8'); + +function makeCtx() { + const data = new Uint8ClampedArray(200 * 150 * 4); + return { + createImageData: () => ({ width: 200, height: 150, data }), + putImageData: () => {}, clearRect: () => {}, drawImage: () => {}, + fillRect: () => {}, fillText: () => {}, beginPath: () => {}, arc: () => {}, + fill: () => {}, stroke: () => {}, moveTo: () => {}, lineTo: () => {}, strokeRect: () => {} + }; +} +function makeEl() { + return { + width: 460, height: 320, style: {}, textContent: '', innerHTML: '', + scrollTop: 0, scrollHeight: 0, + classList: { add() {}, remove() {}, toggle() { return false; } }, + appendChild() {}, addEventListener() {}, setAttribute() {}, click() {}, + getContext: makeCtx, parentElement: { clientWidth: 800 }, dataset: {}, value: '6', + toDataURL: () => 'data:image/png;base64,xx' + }; +} +const documentStub = { + getElementById: () => makeEl(), + querySelectorAll: () => [], + createElement: () => makeEl() +}; +const rafNoop = () => {}; + +const code = src + '\n;globalThis.__NN = NeuralNet; globalThis.__makeDataset = makeDataset;'; +new Function('document', 'requestAnimationFrame', 'window', 'setTimeout', code)( + documentStub, rafNoop, { addEventListener() {} }, setTimeout +); + +const NeuralNet = globalThis.__NN; +const makeDataset = globalThis.__makeDataset; + +function trainOn(name, epochs, lr, hidden) { + const net = new NeuralNet([2, hidden, 1]); + const samples = makeDataset(name); + const idx = samples.map((_, i) => i); + let last = null; + for (let e = 0; e < epochs; e++) { + for (let i = idx.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + const t = idx[i]; idx[i] = idx[j]; idx[j] = t; + } + let sum = 0; + for (const i of idx) sum += net.trainSample(samples[i].x, samples[i].y, lr); + last = sum / idx.length; + } + const test = makeDataset(name); + const n = Math.min(200, test.length); + let correct = 0; + for (let i = 0; i < n; i++) { + if ((net.predict(test[i].x) > 0.5 ? 1 : 0) === test[i].y) correct++; + } + return { acc: (correct / n) * 100, loss: last }; +} + +// Finale controle — zelfde instelling als app.js (Adam, lr 0.05) +function trial(name, hidden, epochs) { + const res = []; + for (let t = 0; t < 6; t++) res.push(trainOn(name, epochs, 0.05, hidden)); + const worst = Math.min(...res.map((a) => a.acc)); + return { worst, raw: res.map((a) => a.acc.toFixed(1) + '%').join(' ') }; +} + +const xor0 = trainOn('XOR', 0, 0.05, 10); +const xor = trial('XOR', 10, 250); +const cir = trial('CIRKEL', 14, 400); +const spi = trial('SPIRAAL', 30, 1400); + +console.log('XOR 0ep :', xor0.acc.toFixed(1) + '%'); +console.log('XOR 250ep :', xor.raw, ' (slechtste ' + xor.worst.toFixed(1) + '%)'); +console.log('CIRKEL 400ep :', cir.raw, ' (slechtste ' + cir.worst.toFixed(1) + '%)'); +console.log('SPIRAAL 1400ep :', spi.raw, ' (slechtste ' + spi.worst.toFixed(1) + '%)'); + +let fail = 0; +if (xor0.acc < 35 || xor0.acc > 70) { console.error('FAIL: XOR ongetraind moet ~50% zijn'); fail = 1; } +if (xor.worst < 85) { console.error('FAIL: XOR convergerde niet'); fail = 1; } +if (cir.worst < 90) { console.error('FAIL: Cirkel convergerde niet'); fail = 1; } +if (spi.worst < 85) { console.error('FAIL: Spiraal convergerde niet'); fail = 1; } + +if (fail) { + console.error('VALIDATIE MISLUKT'); + process.exit(1); +} +console.log('PASS: alle drie datasets leren betrouwbaar met Adam (lr 0.05).'); +process.exit(0); + +if (fail) process.exit(1); +console.log('PASS: app.js laadt schoon én het netwerk leert alle drie datasets.'); +process.exit(0);