This commit is contained in:
martijn.diersmann@pm.me
2026-09-26 18:50:28 +02:00
commit e9533f7ae7
4 changed files with 1090 additions and 0 deletions
+490
View File
@@ -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();
+93
View File
@@ -0,0 +1,93 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Lab — de kracht van een AI-agent</title>
<meta name="description" content="AI Lab: een zelfstandige demo met een live lerend neuraal netwerk, generative art en het verhaal van de AI die dit bouwde." />
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<header class="hero container">
<p class="hero-badge">100% in de browser · geen internet · geen installatie</p>
<h1 class="hero-title">AI <span>Lab</span></h1>
<p class="hero-sub">Alles op deze pagina is door een <strong>AI-agent</strong> geprogrammeerd — in één sessie, zonder voorbeeldcode. Bewonder, speel, en test zelf waartoe een AI in staat is.</p>
</header>
<main class="container">
<!-- Terminal -->
<section class="panel" id="terminal">
<div class="panel-head">
<span class="dot"></span>
<h2>Cline — het verhaal</h2>
<span class="tag">terminal</span>
</div>
<div class="term-body" id="termBody" aria-live="polite"></div>
<div class="term-foot">
<button class="btn ghost" id="termReplay" style="display:none">▶ Opnieuw afspelen</button>
</div>
</section>
<!-- Network -->
<section class="panel" id="network">
<div class="panel-head">
<span class="dot cyan"></span>
<h2>Neuraal netwerk dat écht leert</h2>
<span class="tag">JS · backpropagation</span>
</div>
<div class="nn-stats" id="nnStats">initialiseren…</div>
<div class="nn-grid">
<figure class="canvas-frame">
<figcaption>De beslisgrens wordt live scherper</figcaption>
<canvas id="nnCanvas" width="460" height="320"></canvas>
</figure>
<figure class="canvas-frame">
<figcaption>De fout (loss) daalt</figcaption>
<canvas id="lossCanvas" width="460" height="320"></canvas>
</figure>
</div>
<div class="nn-controls">
<div class="btn-row">
<button class="btn dataset active" data-set="XOR">XOR</button>
<button class="btn dataset" data-set="CIRKEL">Cirkel</button>
<button class="btn dataset" data-set="SPIRAAL">Spiraal</button>
</div>
<div class="btn-row">
<button class="btn accent" id="nnPause">⏸ Pauze</button>
<button class="btn" id="nnReset">↻ Herstart</button>
</div>
<label class="slider">
<span>Snelheid</span>
<input type="range" id="nnSpeed" min="1" max="300" value="40" />
</label>
</div>
</section>
<!-- Art -->
<section class="panel" id="art">
<div class="panel-head">
<span class="dot violet"></span>
<h2>Generative art</h2>
<span class="tag">canvas · flow field</span>
</div>
<div class="art-wrap">
<canvas id="artCanvas"></canvas>
</div>
<div class="btn-row">
<button class="btn accent" id="artNew">✦ Nieuw kunstwerk</button>
<button class="btn" id="artPause">⏸ Pauze</button>
<button class="btn" id="artPng">⬇ Opslaan als PNG</button>
</div>
<p class="hint">Beweeg met je muis over het kunstwerk — de deeltjes reageren.</p>
</section>
</main>
<footer class="footer container">
<p>AI Lab · gebouwd door een AI-agent (Cline). Elke pixel en elk cijfer hierboven is runtime gegenereerd.</p>
</footer>
<script src="app.js"></script>
</body>
</html>
+409
View File
@@ -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;
}
}
+98
View File
@@ -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);