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.
This commit is contained in:
monkey-w1n5t0n 2026-02-11 13:17:17 +01:00
parent 45193a2c01
commit 57aae34870
14 changed files with 1931 additions and 0 deletions

View file

@ -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. 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 ## 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. This is an Arduino project targeting Raspberry Pi Pico. Build and upload using Arduino IDE or arduino-cli with the earlephilhower/pico board package.

View file

@ -2,4 +2,14 @@
https://musicallyembodiedml.github.io/memlnaut/approaches/nisps 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.

353
playground/css/style.css Normal file
View file

@ -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;
}
}

73
playground/index.html Normal file
View file

@ -0,0 +1,73 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<title>NISPS Playground</title>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<div class="app">
<!-- Header -->
<div class="header">
<h1>NISPS Playground</h1>
<button id="help-btn" title="Help">?</button>
</div>
<!-- Visual output -->
<div class="visual-container">
<canvas id="visual-canvas"></canvas>
<div class="presets">
<button class="preset-pill" onclick="loadPreset('calm-to-chaotic')">Calm/Chaos</button>
<button class="preset-pill" onclick="loadPreset('rainbow-sweep')">Rainbow</button>
<button class="preset-pill" onclick="loadPreset('vortex')">Vortex</button>
</div>
</div>
<!-- Parameter bars -->
<div class="param-container" id="param-display"></div>
<!-- Joystick + Controls -->
<div class="controls-area">
<div id="joystick-container"></div>
<div id="controls-container"></div>
</div>
<!-- Help overlay -->
<div class="help-overlay hidden" id="help-overlay">
<div class="help-content" onclick="event.stopPropagation()">
<h2>NISPS Playground</h2>
<p>Train a neural network to map joystick positions to visual parameters using interactive machine learning.</p>
<h3>Examples Mode</h3>
<ol>
<li>Move the joystick to a position</li>
<li>Drag the parameter bars to set desired visual output</li>
<li>Press <strong>Add Example</strong> to save this mapping</li>
<li>Repeat for different joystick positions</li>
<li>Press <strong>Train</strong> &mdash; the network learns to interpolate between your examples</li>
<li>Move the joystick &mdash; visuals respond through the learned mapping</li>
</ol>
<h3>RL Feedback Mode</h3>
<ol>
<li>Move the joystick around &mdash; the network produces different visual outputs</li>
<li>See something you like? Press <strong>+</strong> (thumbs up)</li>
<li>Don't like it? Press <strong>&minus;</strong> (thumbs down) to explore more</li>
<li>The network learns from your preferences over time</li>
</ol>
<h3>Tips</h3>
<p>Try the preset buttons above the visuals for instant demos. Your training data auto-saves to your browser.</p>
<p style="margin-top: 16px; text-align: center; color: #444;">Tap anywhere outside to close</p>
</div>
</div>
</div>
<script type="module" src="js/app.js"></script>
</body>
</html>

261
playground/js/app.js Normal file
View file

@ -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);
});

View file

@ -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;
}
}

201
playground/js/nisps/iml.js Normal file
View file

@ -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;
}
}

View file

@ -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;
}
}

228
playground/js/nisps/mlp.js Normal file
View file

@ -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();
}
}
}

153
playground/js/nisps/node.js Normal file
View file

@ -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<T>(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];
}
}
}

78
playground/js/ui/controls.js vendored Normal file
View file

@ -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 = `
<div class="controls-mode-toggle">
<button class="mode-btn active" data-mode="examples">Examples</button>
<button class="mode-btn" data-mode="rl">RL Feedback</button>
</div>
<div class="controls-actions" id="controls-examples">
<button class="btn btn-primary" id="btn-add">Add Example</button>
<button class="btn" id="btn-train">Train</button>
<button class="btn" id="btn-randomize">Randomize</button>
<button class="btn btn-danger" id="btn-clear">Clear</button>
</div>
<div class="controls-actions hidden" id="controls-rl">
<button class="btn btn-good" id="btn-thumbsup">
<span style="font-size:1.4em">+</span>
</button>
<button class="btn btn-bad" id="btn-thumbsdown">
<span style="font-size:1.4em">&minus;</span>
</button>
<button class="btn" id="btn-rl-randomize">Randomize</button>
<button class="btn btn-danger" id="btn-rl-clear">Clear</button>
</div>
<div class="controls-status">
<span id="status-examples">Examples: 0</span>
<span id="status-loss"></span>
<span id="status-noise" class="hidden">Noise: 0.05</span>
</div>
`;
// 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)}`;
}
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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();
}
}
}