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:
parent
45193a2c01
commit
57aae34870
14 changed files with 1931 additions and 0 deletions
11
CLAUDE.md
11
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.
|
||||
|
|
|
|||
10
README.md
10
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.
|
||||
|
|
|
|||
353
playground/css/style.css
Normal file
353
playground/css/style.css
Normal 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
73
playground/index.html
Normal 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> — the network learns to interpolate between your examples</li>
|
||||
<li>Move the joystick — visuals respond through the learned mapping</li>
|
||||
</ol>
|
||||
|
||||
<h3>RL Feedback Mode</h3>
|
||||
<ol>
|
||||
<li>Move the joystick around — 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>−</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
261
playground/js/app.js
Normal 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);
|
||||
});
|
||||
47
playground/js/nisps/dataset.js
Normal file
47
playground/js/nisps/dataset.js
Normal 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
201
playground/js/nisps/iml.js
Normal 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;
|
||||
}
|
||||
}
|
||||
132
playground/js/nisps/layer.js
Normal file
132
playground/js/nisps/layer.js
Normal 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
228
playground/js/nisps/mlp.js
Normal 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
153
playground/js/nisps/node.js
Normal 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
78
playground/js/ui/controls.js
vendored
Normal 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">−</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)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
129
playground/js/ui/joystick.js
Normal file
129
playground/js/ui/joystick.js
Normal 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);
|
||||
}
|
||||
}
|
||||
109
playground/js/ui/param-display.js
Normal file
109
playground/js/ui/param-display.js
Normal 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);
|
||||
}
|
||||
}
|
||||
146
playground/js/ui/visualizer.js
Normal file
146
playground/js/ui/visualizer.js
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue