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
+
Train a neural network to map joystick positions to visual parameters using interactive machine learning.
+
+
Examples Mode
+
+ - Move the joystick to a position
+ - Drag the parameter bars to set desired visual output
+ - Press Add Example to save this mapping
+ - Repeat for different joystick positions
+ - Press Train — the network learns to interpolate between your examples
+ - Move the joystick — visuals respond through the learned mapping
+
+
+
RL Feedback Mode
+
+ - Move the joystick around — the network produces different visual outputs
+ - See something you like? Press + (thumbs up)
+ - Don't like it? Press − (thumbs down) to explore more
+ - The network learns from your preferences over time
+
+
+
Tips
+
Try the preset buttons above the visuals for instant demos. Your training data auto-saves to your browser.
+
Tap anywhere outside to close
+
+
+
+
+
+
+
+
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
+
+ Noise: 0.05
+
+ `;
+
+ // 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();
+ }
+ }
+}