Files
test/app.js
T
martijn.diersmann@pm.me e9533f7ae7 ok
2026-09-26 18:50:28 +02:00

491 lines
16 KiB
JavaScript

/*
* 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();