From 57aae3487046a0c9e1e76618aa3c47bcc1cacee8 Mon Sep 17 00:00:00 2001 From: monkey-w1n5t0n Date: Wed, 11 Feb 2026 13:17:17 +0100 Subject: [PATCH] feat: add web-based interactive playground for NISPS Faithful JS port of nisps-core MLP + IML engine with a touch-friendly UI for exploring neural parameter mapping visually. Two learning modes: example-based (set slider targets) and RL feedback (thumbs up/down with exploration noise). Flow field particle system controlled by 8 MLP outputs. --- CLAUDE.md | 11 + README.md | 10 + playground/css/style.css | 353 ++++++++++++++++++++++++++++++ playground/index.html | 73 ++++++ playground/js/app.js | 261 ++++++++++++++++++++++ playground/js/nisps/dataset.js | 47 ++++ playground/js/nisps/iml.js | 201 +++++++++++++++++ playground/js/nisps/layer.js | 132 +++++++++++ playground/js/nisps/mlp.js | 228 +++++++++++++++++++ playground/js/nisps/node.js | 153 +++++++++++++ playground/js/ui/controls.js | 78 +++++++ playground/js/ui/joystick.js | 129 +++++++++++ playground/js/ui/param-display.js | 109 +++++++++ playground/js/ui/visualizer.js | 146 ++++++++++++ 14 files changed, 1931 insertions(+) create mode 100644 playground/css/style.css create mode 100644 playground/index.html create mode 100644 playground/js/app.js create mode 100644 playground/js/nisps/dataset.js create mode 100644 playground/js/nisps/iml.js create mode 100644 playground/js/nisps/layer.js create mode 100644 playground/js/nisps/mlp.js create mode 100644 playground/js/nisps/node.js create mode 100644 playground/js/ui/controls.js create mode 100644 playground/js/ui/joystick.js create mode 100644 playground/js/ui/param-display.js create mode 100644 playground/js/ui/visualizer.js diff --git a/CLAUDE.md b/CLAUDE.md index cdf1171..c887d13 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,6 +24,17 @@ The `nisps-core/` directory contains a platform-agnostic C++20 extraction of the See `nisps-core/README.md` for complete documentation and examples. +## Web Playground + +The `playground/` directory contains a browser-based interactive demo of the NISPS ML engine. It's a faithful JavaScript port of nisps-core's MLP + IML, with no build step or dependencies. + +- **2 inputs** (virtual joystick X/Y) mapped through a `[3, 10, 10, 14, 8]` MLP to **8 outputs** controlling a Canvas2D flow-field particle system +- **Two learning modes**: Examples (set slider targets, add examples, train) and RL Feedback (thumbs up/down with exploration noise) +- **Serve statically**: `cd playground && python3 -m http.server` +- **Mobile-first**: designed for touch/foldable phone use + +Key files: `js/nisps/` (ML core port), `js/ui/` (visualizer, joystick, controls), `js/app.js` (wiring). + ## Build System This is an Arduino project targeting Raspberry Pi Pico. Build and upload using Arduino IDE or arduino-cli with the earlephilhower/pico board package. diff --git a/README.md b/README.md index f083064..20e47e7 100644 --- a/README.md +++ b/README.md @@ -2,4 +2,14 @@ https://musicallyembodiedml.github.io/memlnaut/approaches/nisps +## Web Playground +Try NISPS in your browser — no hardware required: + +```bash +cd playground +python3 -m http.server +# Open http://localhost:8000 +``` + +Train a neural network to map joystick positions to generative visuals through interactive machine learning. Two learning modes: direct example mapping and reinforcement learning with thumbs up/down feedback. diff --git a/playground/css/style.css b/playground/css/style.css new file mode 100644 index 0000000..fbfca12 --- /dev/null +++ b/playground/css/style.css @@ -0,0 +1,353 @@ +/* NISPS Playground - Dark theme, mobile-first */ + +:root { + --bg: #0d0d0d; + --bg-surface: #1a1a1a; + --bg-elevated: #252525; + --text: #ccc; + --text-dim: #666; + --accent: #00ff88; + --accent-dim: #00cc6a; + --danger: #ff3366; + --good: #00ff88; + --bad: #ff6644; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +html, body { + background: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, 'SF Pro', 'Segoe UI', system-ui, sans-serif; + font-size: 14px; + height: 100%; + overflow: hidden; + touch-action: manipulation; + -webkit-user-select: none; + user-select: none; +} + +/* --- Layout --- */ +.app { + display: grid; + grid-template-rows: auto 1fr auto auto auto; + height: 100vh; + height: 100dvh; + max-width: 600px; + margin: 0 auto; +} + +/* Header */ +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border-bottom: 1px solid #222; +} + +.header h1 { + font-size: 15px; + font-weight: 600; + letter-spacing: 0.5px; + color: var(--accent); +} + +.header button { + background: none; + border: 1px solid #333; + color: var(--text-dim); + width: 28px; + height: 28px; + border-radius: 50%; + cursor: pointer; + font-size: 14px; +} + +/* Visual canvas */ +.visual-container { + position: relative; + min-height: 0; +} + +.visual-container canvas { + width: 100%; + height: 100%; + display: block; +} + +/* Preset pills */ +.presets { + position: absolute; + top: 8px; + left: 8px; + display: flex; + gap: 6px; + z-index: 2; +} + +.preset-pill { + background: rgba(26, 26, 26, 0.8); + border: 1px solid #333; + color: var(--text-dim); + padding: 4px 10px; + border-radius: 12px; + font-size: 11px; + cursor: pointer; + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); +} + +.preset-pill:active { + background: rgba(0, 255, 136, 0.15); + border-color: var(--accent-dim); + color: var(--accent); +} + +/* Parameter display */ +.param-container { + padding: 6px 12px; + background: var(--bg-surface); + border-top: 1px solid #222; +} + +.param-row { + display: flex; + align-items: center; + gap: 8px; + height: 22px; +} + +.param-label { + width: 42px; + font-size: 10px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.5px; + flex-shrink: 0; +} + +.param-track { + flex: 1; + height: 8px; + background: #222; + border-radius: 4px; + overflow: hidden; + position: relative; +} + +.draggable .param-track { + cursor: ew-resize; + height: 14px; + border: 1px solid #333; +} + +.draggable .param-track:active { + border-color: var(--accent-dim); +} + +.param-fill { + height: 100%; + border-radius: 4px; + transition: width 0.05s ease-out; +} + +.param-value { + width: 32px; + font-size: 10px; + color: var(--text-dim); + text-align: right; + font-family: monospace; + flex-shrink: 0; +} + +/* Controls area */ +.controls-area { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 10px 12px; + background: var(--bg-surface); + border-top: 1px solid #222; +} + +#joystick-container { + flex-shrink: 0; +} + +#controls-container { + flex: 1; + min-width: 0; +} + +.controls-mode-toggle { + display: flex; + gap: 4px; + margin-bottom: 8px; +} + +.mode-btn { + flex: 1; + padding: 6px 0; + background: var(--bg-elevated); + border: 1px solid #333; + color: var(--text-dim); + font-size: 12px; + border-radius: 6px; + cursor: pointer; + transition: all 0.15s; +} + +.mode-btn.active { + background: rgba(0, 255, 136, 0.1); + border-color: var(--accent-dim); + color: var(--accent); +} + +.controls-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 8px; +} + +.btn { + padding: 8px 14px; + background: var(--bg-elevated); + border: 1px solid #333; + color: var(--text); + font-size: 12px; + border-radius: 8px; + cursor: pointer; + transition: all 0.1s; + white-space: nowrap; +} + +.btn:active { + transform: scale(0.96); +} + +.btn-primary { + background: rgba(0, 255, 136, 0.12); + border-color: var(--accent-dim); + color: var(--accent); +} + +.btn-primary:active { + background: rgba(0, 255, 136, 0.25); +} + +.btn-danger { + color: var(--danger); + border-color: #552233; +} + +.btn-danger:active { + background: rgba(255, 51, 102, 0.15); +} + +.btn-good { + background: rgba(0, 255, 136, 0.12); + border-color: var(--accent-dim); + color: var(--good); + font-size: 18px; + padding: 8px 20px; +} + +.btn-good:active { + background: rgba(0, 255, 136, 0.3); +} + +.btn-bad { + background: rgba(255, 102, 68, 0.1); + border-color: #553322; + color: var(--bad); + font-size: 18px; + padding: 8px 20px; +} + +.btn-bad:active { + background: rgba(255, 102, 68, 0.25); +} + +.flash { + animation: flash-anim 0.2s ease-out; +} + +@keyframes flash-anim { + 0% { box-shadow: 0 0 12px var(--accent); } + 100% { box-shadow: none; } +} + +.controls-status { + display: flex; + gap: 12px; + font-size: 11px; + color: var(--text-dim); + font-family: monospace; +} + +.hidden { display: none !important; } + +/* Help overlay */ +.help-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.85); + z-index: 100; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} + +.help-content { + background: var(--bg-surface); + border: 1px solid #333; + border-radius: 12px; + padding: 20px; + max-width: 360px; + max-height: 80vh; + overflow-y: auto; + font-size: 13px; + line-height: 1.5; +} + +.help-content h2 { + color: var(--accent); + font-size: 16px; + margin-bottom: 12px; +} + +.help-content h3 { + color: var(--text); + font-size: 13px; + margin-top: 14px; + margin-bottom: 4px; +} + +.help-content p, .help-content li { + color: var(--text-dim); + margin-bottom: 6px; +} + +.help-content ol { + padding-left: 18px; +} + +/* Responsive: wider screens (foldable inner, tablet) */ +@media (min-width: 500px) { + .app { + max-width: 800px; + } + + .controls-area { + padding: 12px 16px; + } + + .param-container { + padding: 8px 16px; + } +} diff --git a/playground/index.html b/playground/index.html new file mode 100644 index 0000000..e285c2a --- /dev/null +++ b/playground/index.html @@ -0,0 +1,73 @@ + + + + + + + + NISPS Playground + + + +
+ + +
+

NISPS Playground

+ +
+ + +
+ +
+ + + +
+
+ + +
+ + +
+
+
+
+ + + + +
+ + + + diff --git a/playground/js/app.js b/playground/js/app.js new file mode 100644 index 0000000..707239b --- /dev/null +++ b/playground/js/app.js @@ -0,0 +1,261 @@ +// NISPS Playground - Main application +// Wires IML engine to visual system with joystick input and dual learning modes + +import { IML } from './nisps/iml.js'; +import { FlowFieldVisualizer } from './ui/visualizer.js'; +import { VirtualJoystick } from './ui/joystick.js'; +import { Controls } from './ui/controls.js'; +import { ParamDisplay } from './ui/param-display.js'; + +const N_INPUTS = 2; +const N_OUTPUTS = 8; + +// --- State --- +let iml; +let visualizer; +let joystick; +let controls; +let paramDisplay; +let learningMode = 'examples'; // 'examples' | 'rl' +let noiseLevel = 0.05; +let rlExplorationDecay = 0.97; +let animating = true; + +// --- Init --- +function init() { + iml = new IML(N_INPUTS, N_OUTPUTS, [10, 10, 14], 1000, 1.0, 0.00001); + iml.setLogger(msg => console.log('[NISPS]', msg)); + + // Visualizer + const canvas = document.getElementById('visual-canvas'); + visualizer = new FlowFieldVisualizer(canvas); + + // Joystick + joystick = new VirtualJoystick(document.getElementById('joystick-container'), { + size: 160, + springBack: false, + onChange: onJoystickMove, + }); + + // Parameter display + paramDisplay = new ParamDisplay(document.getElementById('param-display'), N_OUTPUTS); + + // Controls + controls = new Controls(document.getElementById('controls-container'), { + onAddExample, + onTrain, + onRandomize, + onClear, + onThumbsUp, + onThumbsDown, + onModeChange, + }); + + // Resize handling + window.addEventListener('resize', () => { + visualizer.resize(); + visualizer.initParticles(); + }); + + // Help overlay + const helpBtn = document.getElementById('help-btn'); + const helpOverlay = document.getElementById('help-overlay'); + if (helpBtn && helpOverlay) { + helpBtn.addEventListener('click', () => helpOverlay.classList.toggle('hidden')); + helpOverlay.addEventListener('click', () => helpOverlay.classList.add('hidden')); + } + + // Run initial inference to populate outputs + iml.setInput(0, 0.5); + iml.setInput(1, 0.5); + iml.process(); + visualizer.setParams(iml.getOutputs()); + paramDisplay.update(iml.getOutputs()); + + // Start animation + animate(); + + // Load from localStorage if available + loadState(); +} + +// --- Animation loop --- +function animate() { + if (!animating) return; + visualizer.draw(); + requestAnimationFrame(animate); +} + +// --- Joystick handler --- +function onJoystickMove(x, y) { + iml.setInput(0, x); + iml.setInput(1, y); + iml.process(); + + const outputs = iml.getOutputs(); + visualizer.setParams(outputs); + + // Only update param display from network in inference (not when user is dragging) + if (learningMode !== 'examples' || paramDisplay.activeBar < 0) { + paramDisplay.update(outputs); + } +} + +// --- Examples mode callbacks --- +function onAddExample() { + // Use current joystick position as input, param bar values as desired output + const inputs = [joystick.x, joystick.y]; + const outputs = [...paramDisplay.values]; + iml.addExample(inputs, outputs); + controls.updateStatus(iml.exampleCount, iml.lastLoss, noiseLevel); + flash('btn-add'); +} + +function onTrain() { + const loss = iml.train(); + if (loss !== null) { + // After training, switch back to inference and update display + const outputs = iml.getOutputs(); + visualizer.setParams(outputs); + paramDisplay.update(outputs); + controls.updateStatus(iml.exampleCount, loss, noiseLevel); + flash('btn-train'); + } +} + +function onRandomize() { + iml.randomiseWeights(); + const outputs = iml.getOutputs(); + visualizer.setParams(outputs); + paramDisplay.update(outputs); + noiseLevel = 0.05; // reset noise + controls.updateStatus(iml.exampleCount, iml.lastLoss, noiseLevel); +} + +function onClear() { + iml.clearDataset(); + noiseLevel = 0.05; + controls.updateStatus(0, null, noiseLevel); + clearState(); +} + +// --- RL mode callbacks --- +function onThumbsUp() { + // Save current input->output mapping as a positive example + const inputs = [joystick.x, joystick.y]; + const outputs = [...iml.getOutputs()]; + iml.addExample(inputs, outputs); + + // Retrain incrementally + iml.train(); + + // Decay noise - more positive examples = less exploration + noiseLevel *= rlExplorationDecay; + noiseLevel = Math.max(noiseLevel, 0.005); + + controls.updateStatus(iml.exampleCount, iml.lastLoss, noiseLevel); + flash('btn-thumbsup'); +} + +function onThumbsDown() { + // Increase noise for more exploration + noiseLevel = Math.min(noiseLevel * 1.5, 0.3); + + // Perturb weights + iml.moveWeights(noiseLevel); + + const outputs = iml.getOutputs(); + visualizer.setParams(outputs); + paramDisplay.update(outputs); + controls.updateStatus(iml.exampleCount, iml.lastLoss, noiseLevel); + flash('btn-thumbsdown'); +} + +function onModeChange(mode) { + learningMode = mode; + if (mode === 'examples') { + paramDisplay.setDraggable(true); + } else { + paramDisplay.setDraggable(false); + } + controls.updateStatus(iml.exampleCount, iml.lastLoss, noiseLevel); +} + +// --- Presets --- +window.loadPreset = function(name) { + iml.clearDataset(); + + if (name === 'calm-to-chaotic') { + // Bottom-left: slow, smooth, cool; top-right: fast, turbulent, warm + iml.addExample([0.1, 0.9], [0.25, 0.3, 0.1, 0.55, 0.2, 0.3, 0.02, 0.05]); + iml.addExample([0.9, 0.1], [0.75, 0.7, 0.9, 0.05, 0.8, 0.7, 0.9, 0.95]); + iml.addExample([0.5, 0.5], [0.5, 0.5, 0.5, 0.3, 0.5, 0.5, 0.4, 0.5]); + } else if (name === 'rainbow-sweep') { + // Left to right sweeps through hues + iml.addExample([0.0, 0.5], [0.5, 0.5, 0.4, 0.0, 0.3, 0.4, 0.05, 0.3]); + iml.addExample([0.5, 0.5], [0.5, 0.5, 0.4, 0.5, 0.3, 0.4, 0.05, 0.3]); + iml.addExample([1.0, 0.5], [0.5, 0.5, 0.4, 1.0, 0.3, 0.4, 0.05, 0.3]); + } else if (name === 'vortex') { + // Center: tight spiral, edges: wide flow + iml.addExample([0.5, 0.5], [0.0, 0.8, 0.8, 0.6, 0.1, 0.15, 0.02, 1.0]); + iml.addExample([0.0, 0.0], [0.5, 0.2, 0.3, 0.8, 0.9, 0.6, 0.08, 0.1]); + iml.addExample([1.0, 1.0], [0.5, 0.2, 0.3, 0.2, 0.9, 0.6, 0.08, 0.1]); + iml.addExample([0.0, 1.0], [0.3, 0.4, 0.5, 0.4, 0.5, 0.4, 0.05, 0.5]); + iml.addExample([1.0, 0.0], [0.7, 0.4, 0.5, 0.0, 0.5, 0.4, 0.05, 0.5]); + } + + const loss = iml.train(); + const outputs = iml.getOutputs(); + visualizer.setParams(outputs); + paramDisplay.update(outputs); + controls.updateStatus(iml.exampleCount, loss, noiseLevel); +}; + +// --- Persistence --- +function saveState() { + try { + const state = { + features: iml.dataset.features, + labels: iml.dataset.labels, + }; + localStorage.setItem('nisps-playground', JSON.stringify(state)); + } catch (e) { /* ignore */ } +} + +function loadState() { + try { + const data = JSON.parse(localStorage.getItem('nisps-playground')); + if (data && data.features && data.features.length > 0) { + for (let i = 0; i < data.features.length; i++) { + iml.addExample(data.features[i], data.labels[i]); + } + iml.train(); + const outputs = iml.getOutputs(); + visualizer.setParams(outputs); + paramDisplay.update(outputs); + controls.updateStatus(iml.exampleCount, iml.lastLoss, noiseLevel); + } + } catch (e) { /* ignore */ } +} + +function clearState() { + try { localStorage.removeItem('nisps-playground'); } catch (e) { /* ignore */ } +} + +// Auto-save periodically +setInterval(saveState, 10000); + +// Visual feedback flash +function flash(id) { + const el = document.getElementById(id); + if (!el) return; + el.classList.add('flash'); + setTimeout(() => el.classList.remove('flash'), 200); +} + +// --- Start --- +document.addEventListener('DOMContentLoaded', () => { + init(); + // Start in examples mode with draggable params + paramDisplay.setDraggable(true); +}); diff --git a/playground/js/nisps/dataset.js b/playground/js/nisps/dataset.js new file mode 100644 index 0000000..93fc4a9 --- /dev/null +++ b/playground/js/nisps/dataset.js @@ -0,0 +1,47 @@ +// NISPS Dataset - faithful port of nisps-core/include/nisps/dataset.hpp +// Manages feature-label pairs for training + +export class Dataset { + constructor(maxExamples = 100) { + this.features = []; + this.labels = []; + this.maxExamples = maxExamples; + } + + add(feature, label) { + if (this.features.length > 0) { + if (feature.length !== this.features[0].length || label.length !== this.labels[0].length) { + return false; + } + } + if (this.features.length >= this.maxExamples) { + // FIFO: remove oldest + this.features.shift(); + this.labels.shift(); + } + this.features.push([...feature]); + this.labels.push([...label]); + return true; + } + + clear() { + this.features = []; + this.labels = []; + } + + getFeatures(withBias = true) { + return this.features.map(f => withBias ? [...f, 1.0] : [...f]); + } + + getLabels() { + return this.labels; + } + + get size() { + return this.features.length; + } + + isEmpty() { + return this.features.length === 0; + } +} diff --git a/playground/js/nisps/iml.js b/playground/js/nisps/iml.js new file mode 100644 index 0000000..470e278 --- /dev/null +++ b/playground/js/nisps/iml.js @@ -0,0 +1,201 @@ +// NISPS IML - faithful port of nisps-core/include/nisps/iml.hpp + iml_impl.hpp +// Interactive Machine Learning interface + +import { MLP } from './mlp.js'; +import { Dataset } from './dataset.js'; + +export class IML { + /** + * @param {number} nInputs + * @param {number} nOutputs + * @param {number[]} hiddenLayers + * @param {number} maxIterations + * @param {number} learningRate + * @param {number} convergenceThreshold + */ + constructor( + nInputs, + nOutputs, + hiddenLayers = [10, 10, 14], + maxIterations = 1000, + learningRate = 1.0, + convergenceThreshold = 0.00001 + ) { + this.nInputs = nInputs; + this.nOutputs = nOutputs; + this.maxIterations = maxIterations; + this.learningRate = learningRate; + this.convergenceThreshold = convergenceThreshold; + + // Build layer sizes: input+bias, hidden..., output + const BIAS = 1; + const layerSizes = [nInputs + BIAS, ...hiddenLayers, nOutputs]; + + // Activation functions: RELU for hidden, SIGMOID for output + const activationNames = [ + ...hiddenLayers.map(() => 'relu'), + 'sigmoid', + ]; + + this.dataset = new Dataset(100); + this.mlp = new MLP(layerSizes, activationNames); + + this.inputState = new Array(nInputs).fill(0.5); + this.outputState = new Array(nOutputs).fill(0); + this.mode = 'inference'; + this.performInference = true; + this.inputUpdated = true; + this.storedWeights = null; + this.weightsRandomised = false; + this.lastLoss = null; + this.logFn = null; + } + + setLogger(fn) { + this.logFn = fn; + } + + log(msg) { + if (this.logFn) this.logFn(msg); + } + + setInput(index, value) { + if (index >= this.nInputs) return; + this.inputState[index] = Math.max(0, Math.min(1, value)); + this.inputUpdated = true; + } + + setInputs(values) { + for (let i = 0; i < values.length && i < this.nInputs; i++) { + this.inputState[i] = Math.max(0, Math.min(1, values[i])); + } + this.inputUpdated = true; + } + + getOutputs() { + return this.outputState; + } + + setOutput(index, value) { + if (index >= this.nOutputs) return; + this.outputState[index] = Math.max(0, Math.min(1, value)); + } + + setOutputs(values) { + for (let i = 0; i < values.length && i < this.nOutputs; i++) { + this.outputState[i] = Math.max(0, Math.min(1, values[i])); + } + } + + process() { + if (!this.performInference || !this.inputUpdated) return; + + // Add bias term + const inputWithBias = [...this.inputState, 1.0]; + const { output } = this.mlp.getOutput(inputWithBias); + this.outputState = output; + this.inputUpdated = false; + } + + getMode() { + return this.mode; + } + + setMode(mode) { + if (mode === 'inference' && this.mode === 'training') { + this.train(); + } + this.mode = mode; + } + + // Two-step save example (hardware workflow) + saveExample() { + if (this.performInference) { + this.performInference = false; + this.log('Move to desired output position...'); + return; + } + + this.dataset.add(this.inputState, this.outputState); + this.performInference = true; + + // Run inference + const inputWithBias = [...this.inputState, 1.0]; + const { output } = this.mlp.getOutput(inputWithBias); + this.outputState = output; + + this.log('Example saved.'); + } + + // Direct programmatic example addition + addExample(inputs, outputs) { + const inVec = inputs.slice(0, this.nInputs); + while (inVec.length < this.nInputs) inVec.push(0); + const outVec = outputs.slice(0, this.nOutputs); + while (outVec.length < this.nOutputs) outVec.push(0); + this.dataset.add(inVec, outVec); + } + + clearDataset() { + this.dataset.clear(); + this.log('Dataset cleared.'); + } + + randomiseWeights() { + this.storedWeights = this.mlp.getWeights(); + this.mlp.drawWeights(); + this.weightsRandomised = true; + + // Run inference to show effect + const inputWithBias = [...this.inputState, 1.0]; + const { output } = this.mlp.getOutput(inputWithBias); + this.outputState = output; + + this.log('Weights randomised.'); + } + + // Add Gaussian noise to weights (for RL exploration) + moveWeights(speed) { + this.mlp.moveWeights(speed); + // Run inference to show effect + this.inputUpdated = true; + this.process(); + } + + train() { + // Restore weights if randomised + if (this.weightsRandomised && this.storedWeights) { + this.mlp.setWeights(this.storedWeights); + this.weightsRandomised = false; + } + + const features = this.dataset.getFeatures(true); // with bias + const labels = this.dataset.getLabels(); + + if (features.length === 0 || labels.length === 0) { + this.log('Empty dataset, skipping training.'); + return null; + } + + this.log('Training...'); + this.lastLoss = this.mlp.train( + features, + labels, + this.learningRate, + this.maxIterations, + this.convergenceThreshold + ); + + // Run inference after training + const inputWithBias = [...this.inputState, 1.0]; + const { output } = this.mlp.getOutput(inputWithBias); + this.outputState = output; + + this.log(`Training complete. Loss: ${this.lastLoss.toFixed(6)}`); + return this.lastLoss; + } + + get exampleCount() { + return this.dataset.size; + } +} diff --git a/playground/js/nisps/layer.js b/playground/js/nisps/layer.js new file mode 100644 index 0000000..4f9e165 --- /dev/null +++ b/playground/js/nisps/layer.js @@ -0,0 +1,132 @@ +// NISPS Layer - faithful port of nisps-core/include/nisps/layer.hpp +// Layer of neural network nodes with shared activation function + +import { Node } from './node.js'; + +// Activation functions matching C++ utils.hpp exactly +const RELU_SLOPE = 0.01; // kReLUSlope + +export const activations = { + relu: x => x > 0 ? x : RELU_SLOPE * x, + derivRelu: x => x > 0 ? 1 : RELU_SLOPE, + sigmoid: x => 1 / (1 + Math.exp(-x)), + derivSigmoid: x => { + const s = 1 / (1 + Math.exp(-x)); + return s * (1 - s); + }, + linear: x => x, + derivLinear: () => 1, + tanh: x => Math.tanh(x), + derivTanh: x => 1 - Math.pow(Math.tanh(x), 2), +}; + +// Map activation names to [fn, derivFn] pairs +const activationPairs = { + relu: [activations.relu, activations.derivRelu], + sigmoid: [activations.sigmoid, activations.derivSigmoid], + linear: [activations.linear, activations.derivLinear], + tanh: [activations.tanh, activations.derivTanh], +}; + +export class Layer { + constructor(numInputsPerNode, numNodes, activationName, useConstantInit = true, constantInit = 0.5) { + this.numInputsPerNode = numInputsPerNode; + this.numNodes = numNodes; + this.nodes = []; + + const pair = activationPairs[activationName]; + this.activationFn = pair[0]; + this.derivActivationFn = pair[1]; + + for (let i = 0; i < numNodes; i++) { + this.nodes.push(new Node(numInputsPerNode, useConstantInit, constantInit)); + } + } + + getOutputAfterActivation(input) { + const output = new Array(this.numNodes); + for (let i = 0; i < this.numNodes; i++) { + output[i] = this.nodes[i].getOutputAfterActivation(input, this.activationFn); + } + return output; + } + + initializeGradientAccumulators() { + for (const node of this.nodes) { + node.initializeGradientAccumulator(); + } + } + + clearGradientAccumulators() { + for (const node of this.nodes) { + node.clearGradientAccumulator(); + } + } + + // Backprop with accumulation or direct update + updateWeights(inputLayerActivation, derivError, learningRate, accumulate = false) { + const deltas = new Array(this.numInputsPerNode).fill(0); + + if (accumulate) { + // Accumulate gradients mode + for (let i = 0; i < this.nodes.length; i++) { + const dE_doj = derivError[i]; + const doj_dnetj = this.derivActivationFn(this.nodes[i].innerProd); + const errorSignal = dE_doj * doj_dnetj; + + this.nodes[i].accumulateGradients(inputLayerActivation, errorSignal); + + for (let j = 0; j < this.numInputsPerNode; j++) { + deltas[j] += errorSignal * this.nodes[i].weights[j]; + } + } + } else { + // Direct update mode + for (let i = 0; i < this.nodes.length; i++) { + const dE_doj = derivError[i]; + const doj_dnetj = this.derivActivationFn(this.nodes[i].innerProd); + + for (let j = 0; j < this.numInputsPerNode; j++) { + deltas[j] += dE_doj * doj_dnetj * this.nodes[i].weights[j]; + const dnetj_dwij = inputLayerActivation[j]; + this.nodes[i].updateWeight(j, -(dE_doj * doj_dnetj * dnetj_dwij), learningRate); + } + } + } + return deltas; + } + + applyAccumulatedGradients(learningRate, batchSizeInv) { + for (const node of this.nodes) { + node.applyAccumulatedGradients(learningRate, batchSizeInv); + } + } + + getGradSumSquared(batchSizeInv) { + let sumsq = 0; + for (const node of this.nodes) { + sumsq += node.getGradSumSquared(batchSizeInv); + } + return sumsq; + } + + scaleAccumulatedGradients(clipCoef) { + for (const node of this.nodes) { + node.scaleAccumulatedGradients(clipCoef); + } + } + + resetOptimizerState() { + for (const node of this.nodes) { + node.resetOptimizerState(); + } + } + + checkAndFixWeights() { + let had = false; + for (const node of this.nodes) { + had |= node.checkAndFixWeights(); + } + return had; + } +} diff --git a/playground/js/nisps/mlp.js b/playground/js/nisps/mlp.js new file mode 100644 index 0000000..361bbeb --- /dev/null +++ b/playground/js/nisps/mlp.js @@ -0,0 +1,228 @@ +// NISPS MLP - faithful port of nisps-core/include/nisps/mlp.hpp + mlp_impl.hpp +// Multi-layer perceptron with Train, TrainBatch, GetOutput, weight management + +import { Layer } from './layer.js'; + +// MSE loss function - port of loss.hpp +function mseLoss(expected, actual, lossDeriv, sampleSizeReciprocal) { + let accumLoss = 0; + const oneOverN = 1 / actual.length; + + for (let j = 0; j < actual.length; j++) { + const diff = expected[j] - actual[j]; + accumLoss += (diff * diff) * oneOverN; + lossDeriv[j] = -2 * oneOverN * diff * sampleSizeReciprocal; + } + accumLoss *= sampleSizeReciprocal; + return accumLoss; +} + +// Fisher-Yates shuffle +function shuffleArray(arr) { + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } +} + +export class MLP { + /** + * @param {number[]} layersNodes - e.g. [3, 10, 10, 14, 8] + * @param {string[]} layersActivations - e.g. ['relu', 'relu', 'relu', 'sigmoid'] + */ + constructor(layersNodes, layersActivations) { + this.layersNodes = layersNodes; + this.numInputs = layersNodes[0]; + this.numOutputs = layersNodes[layersNodes.length - 1]; + this.numHiddenLayers = layersNodes.length - 2; + this.layers = []; + this.progressCallback = null; + + for (let i = 0; i < layersNodes.length - 1; i++) { + this.layers.push( + new Layer(layersNodes[i], layersNodes[i + 1], layersActivations[i], false) + ); + } + } + + getOutput(input, forInference = true) { + if (input.length !== this.numInputs) return null; + + let tempIn = [...input]; + let tempOut; + const allActivations = []; + + for (let i = 0; i < this.layers.length; i++) { + if (i > 0) { + allActivations.push(tempIn); + tempIn = tempOut; + } + tempOut = this.layers[i].getOutputAfterActivation(tempIn); + } + + // Push last layer's input activation + allActivations.push(tempIn); + + return { output: tempOut, activations: allActivations }; + } + + // Per-sample SGD training (Train method from C++) + train(features, labels, learningRate, maxIterations = 1000, minError = 0.00001) { + const sampleSizeRecip = 1 / features.length; + let loss = 0; + + for (let iter = 0; iter < maxIterations; iter++) { + loss = 0; + + for (let s = 0; s < features.length; s++) { + const { output, activations } = this.getOutput(features[s], false); + const derivError = new Array(output.length); + + loss += mseLoss(labels[s], output, derivError, sampleSizeRecip); + + // Backprop with direct weight update + let tempDerivError = derivError; + for (let i = this.numHiddenLayers; i >= 0; i--) { + const deltas = this.layers[i].updateWeights(activations[i], tempDerivError, learningRate, false); + if (i > 0) tempDerivError = deltas; + } + } + + loss *= sampleSizeRecip; + + if (this.progressCallback && (iter & 0x1F) === 0) { + this.progressCallback(iter, loss); + } + + if (loss < minError) break; + } + + return loss; + } + + // Batch training with RMSProp (TrainBatch from C++) + trainBatch(features, labels, learningRate, maxIterations = 1000, batchSize = 8, minError = 0.00001) { + const nSamples = features.length; + const nBatches = Math.ceil(nSamples / batchSize); + let epochLoss = 0; + + for (let iter = 0; iter < maxIterations; iter++) { + epochLoss = 0; + + // Shuffle indices + const indices = Array.from({ length: nSamples }, (_, i) => i); + shuffleArray(indices); + + let sampleIdx = 0; + + for (let batch = 0; batch < nBatches; batch++) { + const currentBatchSize = Math.min(batchSize, nSamples - sampleIdx); + const batchSizeRecip = 1 / currentBatchSize; + + // Initialize gradient accumulators + for (const layer of this.layers) { + layer.initializeGradientAccumulators(); + } + + let batchLoss = 0; + + for (let i = 0; i < currentBatchSize; i++) { + const idx = indices[sampleIdx++]; + const { output, activations } = this.getOutput(features[idx], false); + const derivError = new Array(output.length); + + batchLoss += mseLoss(labels[idx], output, derivError, 1.0); + + // Backprop with accumulation + let tempDerivError = derivError; + for (let li = this.numHiddenLayers; li >= 0; li--) { + const deltas = this.layers[li].updateWeights(activations[li], tempDerivError, 0, true); + if (li > 0) tempDerivError = deltas; + } + } + + // Gradient clipping (norm > 5.0) + let gradSumSq = 0; + for (const layer of this.layers) { + gradSumSq += layer.getGradSumSquared(batchSizeRecip); + } + const gradNorm = Math.sqrt(gradSumSq); + + if (gradNorm > 5.0) { + const clipCoef = 5.0 / gradNorm; + for (const layer of this.layers) { + layer.scaleAccumulatedGradients(clipCoef); + } + } + + // Apply accumulated gradients + for (const layer of this.layers) { + layer.applyAccumulatedGradients(learningRate, batchSizeRecip); + } + + epochLoss += batchLoss / currentBatchSize; + } + + epochLoss /= nBatches; + + if (this.progressCallback) { + this.progressCallback(iter, epochLoss); + } + + if (epochLoss < minError) break; + } + + return epochLoss; + } + + getWeights() { + return this.layers.map(layer => + layer.nodes.map(node => ({ + weights: node.getWeightsCopy(), + bias: node.bias, + })) + ); + } + + setWeights(weights) { + for (let l = 0; l < this.layers.length; l++) { + for (let n = 0; n < this.layers[l].nodes.length; n++) { + this.layers[l].nodes[n].setWeights(weights[l][n].weights); + this.layers[l].nodes[n].bias = weights[l][n].bias; + } + } + } + + // DrawWeights - randomize all weights uniformly in [-1, 1] + drawWeights(scale = 1) { + for (const layer of this.layers) { + for (const node of layer.nodes) { + for (let j = 0; j < node.weights.length; j++) { + node.weights[j] = (Math.random() * 2 - 1) * scale; + } + } + } + } + + // MoveWeights - add Gaussian noise (port of gen_randn) + moveWeights(speed) { + for (const layer of this.layers) { + for (const node of layer.nodes) { + for (let j = 0; j < node.weights.length; j++) { + // gen_randn: sum of 3 uniform randoms * kN_times * stddev + mean + let accum = 0; + for (let n = 0; n < 3; n++) { + accum += Math.random() * 2 - 1; // gen_rand with range 2.0 + } + node.weights[j] = 3 * accum * speed + node.weights[j]; + } + } + } + } + + resetOptimizerState() { + for (const layer of this.layers) { + layer.resetOptimizerState(); + } + } +} diff --git a/playground/js/nisps/node.js b/playground/js/nisps/node.js new file mode 100644 index 0000000..80d7f6e --- /dev/null +++ b/playground/js/nisps/node.js @@ -0,0 +1,153 @@ +// NISPS Node - faithful port of nisps-core/include/nisps/node.hpp +// Single neural network node with weights, bias, and RMSProp optimizer + +const RMSPROP_DECAY = 0.9; +const RMSPROP_DECAY_INV = 0.1; +const RMSPROP_EPSILON = 1e-6; +const MAX_SQUARED_GRAD_AVG = 1e6; +const MAX_ADJUSTED_LR = 1.0; +const GRADIENT_CLIP_VALUE = 10.0; + +export class Node { + constructor(numInputs, useConstantInit = true, constantInit = 0.5) { + this.numInputs = numInputs; + this.bias = 0.0; + this.weights = new Float64Array(numInputs); + this.squaredGradientAvg = new Float64Array(numInputs); + this.biasSquaredGradientAvg = 0; + this.gradientAccumulator = new Float64Array(numInputs); + this.biasGradientAccumulator = 0; + this.innerProd = 0; + + if (useConstantInit) { + this.weights.fill(constantInit); + } else { + // gen_rand(2.0) produces values in [-1, 1] + for (let i = 0; i < numInputs; i++) { + this.weights[i] = Math.random() * 2 - 1; + } + } + } + + getInputInnerProdWithWeights(input) { + let res = 0; + for (let j = 0; j < input.length; j++) { + res += input[j] * this.weights[j]; + } + res += this.bias; + this.innerProd = res; + return this.innerProd; + } + + getOutputAfterActivation(input, activationFn) { + this.getInputInnerProdWithWeights(input); + return activationFn(this.innerProd); + } + + initializeGradientAccumulator() { + this.gradientAccumulator = new Float64Array(this.weights.length); + this.biasGradientAccumulator = 0; + } + + clearGradientAccumulator() { + this.gradientAccumulator.fill(0); + } + + accumulateGradients(input, errorSignal) { + for (let i = 0; i < this.weights.length; i++) { + this.gradientAccumulator[i] += input[i] * errorSignal; + } + this.biasGradientAccumulator += errorSignal; + } + + applyAccumulatedGradients(learningRate, batchSizeInv) { + for (let i = 0; i < this.weights.length; i++) { + let gradient = this.gradientAccumulator[i] * batchSizeInv; + + // Clamp gradient + gradient = Math.max(Math.min(gradient, GRADIENT_CLIP_VALUE), -GRADIENT_CLIP_VALUE); + + this.squaredGradientAvg[i] = + RMSPROP_DECAY * this.squaredGradientAvg[i] + + RMSPROP_DECAY_INV * gradient * gradient; + + // Clamp squared gradient average + this.squaredGradientAvg[i] = Math.min(this.squaredGradientAvg[i], MAX_SQUARED_GRAD_AVG); + + let adjustedLR = learningRate / (Math.sqrt(this.squaredGradientAvg[i]) + RMSPROP_EPSILON); + + // Clamp adjusted learning rate + adjustedLR = Math.min(adjustedLR, MAX_ADJUSTED_LR); + + this.weights[i] -= adjustedLR * gradient; + this.gradientAccumulator[i] = 0; + } + + // Bias update + let biasGradient = this.biasGradientAccumulator * batchSizeInv; + biasGradient = Math.max(Math.min(biasGradient, GRADIENT_CLIP_VALUE), -GRADIENT_CLIP_VALUE); + + this.biasSquaredGradientAvg = + RMSPROP_DECAY * this.biasSquaredGradientAvg + + RMSPROP_DECAY_INV * biasGradient * biasGradient; + + this.biasSquaredGradientAvg = Math.min(this.biasSquaredGradientAvg, MAX_SQUARED_GRAD_AVG); + + let biasAdjustedLR = learningRate / (Math.sqrt(this.biasSquaredGradientAvg) + RMSPROP_EPSILON); + biasAdjustedLR = Math.min(biasAdjustedLR, MAX_ADJUSTED_LR); + + this.bias -= biasAdjustedLR * biasGradient; + this.biasGradientAccumulator = 0; + } + + getGradSumSquared(batchSizeInv) { + let sumsq = 0; + for (let i = 0; i < this.gradientAccumulator.length; i++) { + const scaled = this.gradientAccumulator[i] * batchSizeInv; + sumsq += scaled * scaled; + } + return sumsq; + } + + scaleAccumulatedGradients(clipCoef) { + for (let i = 0; i < this.gradientAccumulator.length; i++) { + this.gradientAccumulator[i] *= clipCoef; + } + } + + updateWeight(weightId, increment, learningRate) { + this.weights[weightId] += learningRate * increment; + } + + resetOptimizerState() { + this.squaredGradientAvg.fill(0); + this.biasSquaredGradientAvg = 0; + } + + checkAndFixWeights() { + let hadCorruption = false; + for (let i = 0; i < this.weights.length; i++) { + if (!isFinite(this.weights[i])) { + this.weights[i] = 0; + this.squaredGradientAvg[i] = 0; + hadCorruption = true; + } + } + if (!isFinite(this.bias)) { + this.bias = 0; + this.biasSquaredGradientAvg = 0; + hadCorruption = true; + } + return hadCorruption; + } + + getWeightsCopy() { + return Array.from(this.weights); + } + + setWeights(weights) { + for (let i = 0; i < this.weights.length; i++) { + this.weights[i] = weights[i]; + } + } +} diff --git a/playground/js/ui/controls.js b/playground/js/ui/controls.js new file mode 100644 index 0000000..56dcae1 --- /dev/null +++ b/playground/js/ui/controls.js @@ -0,0 +1,78 @@ +// Control panel: mode toggle, buttons, status display + +export class Controls { + constructor(container, callbacks) { + this.callbacks = callbacks; + this.mode = 'examples'; // 'examples' or 'rl' + this.el = container; + this.build(); + } + + build() { + this.el.innerHTML = ` +
+ + +
+
+ + + + +
+ +
+ Examples: 0 + + +
+ `; + + // Mode toggle + this.el.querySelectorAll('.mode-btn').forEach(btn => { + btn.addEventListener('click', () => this.setMode(btn.dataset.mode)); + }); + + // Examples mode buttons + this.el.querySelector('#btn-add').addEventListener('click', () => this.callbacks.onAddExample?.()); + this.el.querySelector('#btn-train').addEventListener('click', () => this.callbacks.onTrain?.()); + this.el.querySelector('#btn-randomize').addEventListener('click', () => this.callbacks.onRandomize?.()); + this.el.querySelector('#btn-clear').addEventListener('click', () => this.callbacks.onClear?.()); + + // RL mode buttons + this.el.querySelector('#btn-thumbsup').addEventListener('click', () => this.callbacks.onThumbsUp?.()); + this.el.querySelector('#btn-thumbsdown').addEventListener('click', () => this.callbacks.onThumbsDown?.()); + this.el.querySelector('#btn-rl-randomize').addEventListener('click', () => this.callbacks.onRandomize?.()); + this.el.querySelector('#btn-rl-clear').addEventListener('click', () => this.callbacks.onClear?.()); + } + + setMode(mode) { + this.mode = mode; + this.el.querySelectorAll('.mode-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.mode === mode); + }); + this.el.querySelector('#controls-examples').classList.toggle('hidden', mode !== 'examples'); + this.el.querySelector('#controls-rl').classList.toggle('hidden', mode !== 'rl'); + this.el.querySelector('#status-noise').classList.toggle('hidden', mode !== 'rl'); + this.callbacks.onModeChange?.(mode); + } + + updateStatus(exampleCount, loss, noiseLevel) { + this.el.querySelector('#status-examples').textContent = `Examples: ${exampleCount}`; + if (loss !== null && loss !== undefined) { + this.el.querySelector('#status-loss').textContent = `Loss: ${loss.toFixed(5)}`; + } + if (noiseLevel !== undefined) { + this.el.querySelector('#status-noise').textContent = `Noise: ${noiseLevel.toFixed(3)}`; + } + } +} diff --git a/playground/js/ui/joystick.js b/playground/js/ui/joystick.js new file mode 100644 index 0000000..263778b --- /dev/null +++ b/playground/js/ui/joystick.js @@ -0,0 +1,129 @@ +// Virtual touch joystick (Canvas-based) +// Outputs normalized X, Y in [0, 1] + +export class VirtualJoystick { + constructor(container, options = {}) { + this.canvas = document.createElement('canvas'); + this.size = options.size || 180; + this.canvas.width = this.size; + this.canvas.height = this.size; + this.canvas.style.width = this.size + 'px'; + this.canvas.style.height = this.size + 'px'; + this.canvas.style.touchAction = 'none'; + container.appendChild(this.canvas); + this.ctx = this.canvas.getContext('2d'); + + this.x = 0.5; + this.y = 0.5; + this.touching = false; + this.springBack = options.springBack ?? false; + this.onChange = options.onChange || (() => {}); + + // Touch events + this.canvas.addEventListener('touchstart', this._onTouch.bind(this), { passive: false }); + this.canvas.addEventListener('touchmove', this._onTouch.bind(this), { passive: false }); + this.canvas.addEventListener('touchend', this._onRelease.bind(this)); + this.canvas.addEventListener('touchcancel', this._onRelease.bind(this)); + + // Mouse fallback + this.canvas.addEventListener('mousedown', (e) => { + this.touching = true; + this._updateFromEvent(e); + }); + window.addEventListener('mousemove', (e) => { + if (this.touching) this._updateFromEvent(e); + }); + window.addEventListener('mouseup', () => { + if (this.touching) this._onRelease(); + }); + + this.draw(); + } + + _onTouch(e) { + e.preventDefault(); + const touch = e.touches[0]; + this.touching = true; + const rect = this.canvas.getBoundingClientRect(); + this.x = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width)); + this.y = Math.max(0, Math.min(1, (touch.clientY - rect.top) / rect.height)); + this.onChange(this.x, this.y); + this.draw(); + } + + _updateFromEvent(e) { + const rect = this.canvas.getBoundingClientRect(); + this.x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + this.y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)); + this.onChange(this.x, this.y); + this.draw(); + } + + _onRelease() { + this.touching = false; + if (this.springBack) { + this.x = 0.5; + this.y = 0.5; + this.onChange(this.x, this.y); + } + this.draw(); + } + + draw() { + const { ctx, size: s } = this; + const r = s / 2; + ctx.clearRect(0, 0, s, s); + + // Background circle + ctx.beginPath(); + ctx.arc(r, r, r - 4, 0, Math.PI * 2); + ctx.strokeStyle = '#333'; + ctx.lineWidth = 1.5; + ctx.stroke(); + + // Crosshairs + ctx.strokeStyle = '#222'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(r, 6); ctx.lineTo(r, s - 6); + ctx.moveTo(6, r); ctx.lineTo(s - 6, r); + ctx.stroke(); + + // Grid + ctx.strokeStyle = '#1a1a1a'; + ctx.setLineDash([2, 4]); + ctx.beginPath(); + ctx.moveTo(s * 0.25, 6); ctx.lineTo(s * 0.25, s - 6); + ctx.moveTo(s * 0.75, 6); ctx.lineTo(s * 0.75, s - 6); + ctx.moveTo(6, s * 0.25); ctx.lineTo(s - 6, s * 0.25); + ctx.moveTo(6, s * 0.75); ctx.lineTo(s - 6, s * 0.75); + ctx.stroke(); + ctx.setLineDash([]); + + // Thumb + const tx = this.x * s; + const ty = this.y * s; + + // Glow + if (this.touching) { + const grad = ctx.createRadialGradient(tx, ty, 0, tx, ty, 24); + grad.addColorStop(0, 'rgba(0, 255, 136, 0.3)'); + grad.addColorStop(1, 'rgba(0, 255, 136, 0)'); + ctx.fillStyle = grad; + ctx.beginPath(); + ctx.arc(tx, ty, 24, 0, Math.PI * 2); + ctx.fill(); + } + + ctx.fillStyle = this.touching ? '#00ff88' : '#666'; + ctx.beginPath(); + ctx.arc(tx, ty, 14, 0, Math.PI * 2); + ctx.fill(); + + // Position text + ctx.fillStyle = '#555'; + ctx.font = '10px monospace'; + ctx.textAlign = 'right'; + ctx.fillText(`${this.x.toFixed(2)}, ${this.y.toFixed(2)}`, s - 8, s - 6); + } +} diff --git a/playground/js/ui/param-display.js b/playground/js/ui/param-display.js new file mode 100644 index 0000000..2a1e00d --- /dev/null +++ b/playground/js/ui/param-display.js @@ -0,0 +1,109 @@ +// Parameter bar display +// Shows 8 output parameters as horizontal bars, draggable in examples mode + +const PARAM_NAMES = ['Flow', 'Scale', 'Speed', 'Hue', 'Spread', 'Size', 'Trail', 'Turb']; +const PARAM_COLORS = ['#00ff88', '#00ccff', '#ff6600', '#ff00cc', '#ffcc00', '#88ff00', '#0088ff', '#ff3366']; + +export class ParamDisplay { + constructor(container, numParams = 8) { + this.container = container; + this.numParams = numParams; + this.values = new Array(numParams).fill(0.5); + this.draggable = false; + this.onChange = null; + this.activeBar = -1; + this.build(); + } + + build() { + this.container.innerHTML = ''; + this.bars = []; + + for (let i = 0; i < this.numParams; i++) { + const row = document.createElement('div'); + row.className = 'param-row'; + + const label = document.createElement('span'); + label.className = 'param-label'; + label.textContent = PARAM_NAMES[i] || `p${i}`; + + const track = document.createElement('div'); + track.className = 'param-track'; + track.dataset.index = i; + + const fill = document.createElement('div'); + fill.className = 'param-fill'; + fill.style.background = PARAM_COLORS[i] || '#888'; + fill.style.width = '50%'; + + const val = document.createElement('span'); + val.className = 'param-value'; + val.textContent = '0.50'; + + track.appendChild(fill); + row.appendChild(label); + row.appendChild(track); + row.appendChild(val); + this.container.appendChild(row); + + this.bars.push({ fill, val, track }); + } + + // Touch/mouse drag events on the container + const onStart = (e) => { + if (!this.draggable) return; + const target = e.target.closest('.param-track'); + if (!target) return; + e.preventDefault(); + this.activeBar = parseInt(target.dataset.index); + this._updateFromEvent(e); + }; + + const onMove = (e) => { + if (this.activeBar < 0 || !this.draggable) return; + e.preventDefault(); + this._updateFromEvent(e); + }; + + const onEnd = () => { + this.activeBar = -1; + }; + + this.container.addEventListener('mousedown', onStart); + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onEnd); + this.container.addEventListener('touchstart', onStart, { passive: false }); + this.container.addEventListener('touchmove', onMove, { passive: false }); + this.container.addEventListener('touchend', onEnd); + } + + _updateFromEvent(e) { + const i = this.activeBar; + if (i < 0) return; + const track = this.bars[i].track; + const rect = track.getBoundingClientRect(); + const clientX = e.touches ? e.touches[0].clientX : e.clientX; + const value = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + this.values[i] = value; + this._renderBar(i); + if (this.onChange) this.onChange(i, value, this.values); + } + + update(values) { + for (let i = 0; i < this.numParams && i < values.length; i++) { + this.values[i] = values[i]; + this._renderBar(i); + } + } + + _renderBar(i) { + const v = this.values[i]; + this.bars[i].fill.style.width = (v * 100) + '%'; + this.bars[i].val.textContent = v.toFixed(2); + } + + setDraggable(draggable) { + this.draggable = draggable; + this.container.classList.toggle('draggable', draggable); + } +} diff --git a/playground/js/ui/visualizer.js b/playground/js/ui/visualizer.js new file mode 100644 index 0000000..4139333 --- /dev/null +++ b/playground/js/ui/visualizer.js @@ -0,0 +1,146 @@ +// Flow field particle system with Canvas2D +// Controlled by 8 output parameters from the IML network + +// Simple value noise (no dependencies) +const PERM = new Uint8Array(512); +{ + const p = new Uint8Array(256); + for (let i = 0; i < 256; i++) p[i] = i; + for (let i = 255; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [p[i], p[j]] = [p[j], p[i]]; + } + for (let i = 0; i < 512; i++) PERM[i] = p[i & 255]; +} + +function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); } +function lerp(a, b, t) { return a + t * (b - a); } + +function grad(hash, x, y) { + const h = hash & 3; + const u = h < 2 ? x : y; + const v = h < 2 ? y : x; + return ((h & 1) ? -u : u) + ((h & 2) ? -v : v); +} + +function noise2D(x, y) { + const X = Math.floor(x) & 255; + const Y = Math.floor(y) & 255; + const xf = x - Math.floor(x); + const yf = y - Math.floor(y); + const u = fade(xf); + const v = fade(yf); + + const aa = PERM[PERM[X] + Y]; + const ab = PERM[PERM[X] + Y + 1]; + const ba = PERM[PERM[X + 1] + Y]; + const bb = PERM[PERM[X + 1] + Y + 1]; + + return lerp( + lerp(grad(aa, xf, yf), grad(ba, xf - 1, yf), u), + lerp(grad(ab, xf, yf - 1), grad(bb, xf - 1, yf - 1), u), + v + ); +} + +const TWO_PI = Math.PI * 2; + +export class FlowFieldVisualizer { + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.particles = []; + this.numParticles = 400; + this.time = 0; + + // Parameters (all 0-1 from IML, mapped to visual ranges) + this.params = { + angleOffset: 0, // p0: flow direction + scale: 0.005, // p1: pattern size + speed: 2, // p2: particle speed + hueBase: 180, // p3: base color + hueSpread: 60, // p4: color variation + particleSize: 3, // p5: dot radius + fadeRate: 0.05, // p6: trail length + turbulence: 1, // p7: chaos + }; + + this.resize(); + this.initParticles(); + } + + resize() { + const rect = this.canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + this.canvas.width = rect.width * dpr; + this.canvas.height = rect.height * dpr; + this.ctx.scale(dpr, dpr); + this.width = rect.width; + this.height = rect.height; + } + + initParticles() { + this.particles = []; + for (let i = 0; i < this.numParticles; i++) { + this.particles.push({ + x: Math.random() * this.width, + y: Math.random() * this.height, + id: i, + }); + } + // Clear canvas to black + this.ctx.fillStyle = '#0d0d0d'; + this.ctx.fillRect(0, 0, this.width, this.height); + } + + // Set parameters from IML output (all values 0-1) + setParams(outputs) { + if (!outputs || outputs.length < 8) return; + this.params.angleOffset = outputs[0] * TWO_PI; + this.params.scale = 0.001 + outputs[1] * 0.009; + this.params.speed = 0.5 + outputs[2] * 4.5; + this.params.hueBase = outputs[3] * 360; + this.params.hueSpread = outputs[4] * 120; + this.params.particleSize = 1 + outputs[5] * 5; + this.params.fadeRate = 0.01 + outputs[6] * 0.14; + this.params.turbulence = outputs[7] * 2; + } + + draw() { + const { ctx, width, height, params } = this; + this.time += 0.003; + + // Fade existing content (creates trails) + ctx.fillStyle = `rgba(13, 13, 13, ${params.fadeRate})`; + ctx.fillRect(0, 0, width, height); + + for (const p of this.particles) { + // Sample flow field + const nx = p.x * params.scale; + const ny = p.y * params.scale; + const angle = noise2D(nx + this.time, ny) * TWO_PI + params.angleOffset; + const curl = noise2D(nx + 100, ny + 100 + this.time * 0.5) * params.turbulence; + + // Move particle + const vx = Math.cos(angle + curl) * params.speed; + const vy = Math.sin(angle + curl) * params.speed; + p.x += vx; + p.y += vy; + + // Wrap around edges + if (p.x < 0) p.x += width; + if (p.x > width) p.x -= width; + if (p.y < 0) p.y += height; + if (p.y > height) p.y -= height; + + // Color based on particle id + hue params + const hue = (params.hueBase + (p.id / this.numParticles) * params.hueSpread) % 360; + const lightness = 50 + Math.sin(p.id * 0.1 + this.time) * 15; + + ctx.fillStyle = `hsl(${hue}, 75%, ${lightness}%)`; + ctx.beginPath(); + ctx.arc(p.x, p.y, params.particleSize, 0, TWO_PI); + ctx.fill(); + } + } +}