From 1f21494dee1cfeec9c4cebdd5ca0086b4f39ca98 Mon Sep 17 00:00:00 2001 From: w1n5t0n Date: Thu, 26 Mar 2026 10:48:12 +0200 Subject: [PATCH] feat(playground): implement Phases 2-4 of control surface spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 — Pinning + History: - snapshot-stack.js: ring buffer (20 max) with auto-snapshot on train/randomize/thumbs-down, multi-level undo, tagged entries - ab-compare.js: A/B weight state comparison with capture/toggle/accept/revert - region-pin.js: pin rectangular input-space regions (Approach A: example pinning), pinned examples always included in training - param-pin.js: per-output pin flags, pin mask skips pinned nodes in moveWeights - phase2-ui.js: undo button with history popup, A/B toggle, long-press region pin, double-tap param pin - Modified mlp.js/iml.js/nisps-wasm.js to accept outputPinMask in moveWeights Phase 3 — Input Refinement + Exploration: - pressure-feedback.js: touch force + hold duration → intensity multiplier - auto-explore.js: automated thumbs-down at configurable interval, zoom-scaled - input-heatmap.js: 16×16 MLP sampling, 3 color modes (luminance/variance/ divergence), zoom-aware resampling, offscreen canvas rendering - phase3-ui.js: auto-explore toggle with progress ring, heatmap eye icon, pressure indicators, settings drawer section - joy-map-enhanced.js: added setHeatmap() for background layer rendering Phase 4 — Output Pipeline + Visualization + Polish: - output-pipeline.js: global curve → smoothing → slew rate → freeze gate - weight-health.js: weight magnitude histogram, dead/saturating/healthy status - gradient-flow.js: per-layer weight-delta analysis, vanishing/exploding detection - session-presets.js: save/load full state, URL sharing via compact params - phase4-ui.js: freeze button, network health panel, session preset UI All phases merged into a-app.js with proper integration: auto-snapshots, pressure-modulated RL, heatmap triggers, output pipeline in routeOutputs, gradient capture around training, persistence for all new state. --- CLAUDE.md | 30 +- playground/SPEC-controls.md | 32 +- playground/js/a-app.js | 271 ++++++++++- playground/js/nisps/iml.js | 5 +- playground/js/nisps/mlp.js | 13 +- playground/js/nisps/nisps-wasm.js | 41 +- playground/js/ui/ab-compare.js | 153 +++++++ playground/js/ui/auto-explore.js | 160 +++++++ playground/js/ui/gradient-flow.js | 261 +++++++++++ playground/js/ui/input-heatmap.js | 382 ++++++++++++++++ playground/js/ui/joy-map-enhanced.js | 17 + playground/js/ui/output-pipeline.js | 312 +++++++++++++ playground/js/ui/param-pin.js | 151 +++++++ playground/js/ui/phase2-ui.js | 626 ++++++++++++++++++++++++++ playground/js/ui/phase3-ui.js | 588 ++++++++++++++++++++++++ playground/js/ui/phase4-ui.js | 583 ++++++++++++++++++++++++ playground/js/ui/pressure-feedback.js | 194 ++++++++ playground/js/ui/region-pin.js | 205 +++++++++ playground/js/ui/session-presets.js | 243 ++++++++++ playground/js/ui/snapshot-stack.js | 169 +++++++ playground/js/ui/weight-health.js | 218 +++++++++ 21 files changed, 4617 insertions(+), 37 deletions(-) create mode 100644 playground/js/ui/ab-compare.js create mode 100644 playground/js/ui/auto-explore.js create mode 100644 playground/js/ui/gradient-flow.js create mode 100644 playground/js/ui/input-heatmap.js create mode 100644 playground/js/ui/output-pipeline.js create mode 100644 playground/js/ui/param-pin.js create mode 100644 playground/js/ui/phase2-ui.js create mode 100644 playground/js/ui/phase3-ui.js create mode 100644 playground/js/ui/phase4-ui.js create mode 100644 playground/js/ui/pressure-feedback.js create mode 100644 playground/js/ui/region-pin.js create mode 100644 playground/js/ui/session-presets.js create mode 100644 playground/js/ui/snapshot-stack.js create mode 100644 playground/js/ui/weight-health.js diff --git a/CLAUDE.md b/CLAUDE.md index 6226ba7..d9fa59d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,14 +96,28 @@ Presets use `curve` values to bias parameter distributions (< 0.5 = spend more t The immersive app (`a-immersive.html`) has a control surface system for tuning how exploration and learning feel. Full spec: `playground/SPEC-controls.md`. -**Architecture** — three standalone ES modules wired into `a-app.js`: +**Architecture** — modular ES modules organized by phase, wired into `a-app.js`: -| Module | Purpose | -|--------|---------| -| `js/ui/input-pipeline.js` | Processes raw joystick input through deadzone → zoom → curve → smoothing → momentum-as-zoom. Pure math, no DOM. | -| `js/ui/control-surface.js` | Compound axes (Boldness, Memory, Precision) that map single sliders to multiple underlying params. Offset-based override resolution (trim-pot model). 6 built-in control presets. | -| `js/ui/control-surface-ui.js` | DOM layer: 3 axis sliders on floating bar, gear icon settings drawer with per-param overrides. Injects its own CSS. | -| `js/ui/joy-map-enhanced.js` | Enhanced joy-map canvas: zoom minimap with adaptive grid, vanishing trail with Catmull-Rom spline and tap-to-return, dual concentric noise rings (zoom + noise), frozen state overlay. | +| Module | Phase | Purpose | +|--------|-------|---------| +| `js/ui/input-pipeline.js` | 1 | Processes raw joystick input through deadzone → zoom → curve → smoothing → momentum-as-zoom. Pure math, no DOM. | +| `js/ui/control-surface.js` | 1 | Compound axes (Boldness, Memory, Precision) that map single sliders to multiple underlying params. Offset-based override resolution (trim-pot model). 6 built-in control presets. | +| `js/ui/control-surface-ui.js` | 1 | DOM layer: 3 axis sliders on floating bar, gear icon settings drawer with per-param overrides. Injects its own CSS. | +| `js/ui/joy-map-enhanced.js` | 1 | Enhanced joy-map canvas: zoom minimap with adaptive grid, vanishing trail with Catmull-Rom spline and tap-to-return, dual concentric noise rings, frozen state overlay. | +| `js/ui/snapshot-stack.js` | 2 | Ring buffer (20 max) of weight snapshots. Auto-snapshot on train/randomize/thumbs-down. Multi-level undo. | +| `js/ui/ab-compare.js` | 2 | Rapid A/B weight state comparison. Capture, toggle, accept or revert. | +| `js/ui/region-pin.js` | 2 | Pins rectangular input-space regions (Approach A: example pinning). Pinned examples always included in training. | +| `js/ui/param-pin.js` | 2 | Per-output pin flags. Pin mask passed to `moveWeights()` to skip pinned output nodes. | +| `js/ui/phase2-ui.js` | 2 | DOM: undo button with history popup, A/B toggle, region pin via long-press, param pin via double-tap. | +| `js/ui/pressure-feedback.js` | 3 | Touch force + hold duration → intensity multiplier for noise growth/decay. | +| `js/ui/auto-explore.js` | 3 | Automated thumbs-down at configurable interval. Zoom-scaled intensity. | +| `js/ui/input-heatmap.js` | 3 | 2D color field sampling MLP across input space. 3 color modes, zoom-aware resampling. | +| `js/ui/phase3-ui.js` | 3 | DOM: auto-explore toggle with progress ring, heatmap eye icon, pressure indicators. | +| `js/ui/output-pipeline.js` | 4 | Global curve → smoothing → slew rate → freeze gate on MLP outputs before synth/visual routing. | +| `js/ui/weight-health.js` | 4 | Weight magnitude histogram, dead/saturating/healthy status detection, ambient visualization. | +| `js/ui/gradient-flow.js` | 4 | Per-layer weight-delta analysis after training. Vanishing/exploding/converged detection. | +| `js/ui/session-presets.js` | 4 | Save/load full session state. URL sharing via compact params. | +| `js/ui/phase4-ui.js` | 4 | DOM: freeze button, network health panel, session preset UI, output pipeline slider wiring. | **Compound Axes** — each controls 4-6 underlying parameters via interpolation tables: @@ -119,7 +133,7 @@ When a user manually overrides an individual param, the offset from the axis-der **Integration** — the control surface dispatches `controlsurface:change` CustomEvents. `a-app.js` listens and updates the input pipeline config, spread level, and RL parameters (noise cap, growth, decay, floor, zoom-aware feedback scaling). Pipeline-processed coordinates are cached (`_lastPipeX/Y`) so `getCurrentInputs()` and `setCurrentInputs()` use the same values the MLP sees. State is persisted to localStorage alongside existing app state. -**Remaining spec phases** (not yet implemented): Phase 2 (pinning + history + A/B compare), Phase 3 (momentum-zoom, pressure feedback, auto-explore, heatmap), Phase 4 (output pipeline, weight health, gradient flow, engine config, session presets). +**Remaining**: Engine configuration panel (Part 8 of spec) — network architecture, loss function, optimizer selection. ## Build System diff --git a/playground/SPEC-controls.md b/playground/SPEC-controls.md index deededa..057b18d 100644 --- a/playground/SPEC-controls.md +++ b/playground/SPEC-controls.md @@ -656,22 +656,26 @@ Control presets define a complete control surface state (all parameters from Par 9. ✅ (bonus) Control presets with offset-based override resolution — 6 built-in presets, trim-pot model 10. ✅ (bonus) Dual concentric noise rings (zoom + noise) replacing CSS-only ring -### Phase 2 — Pinning + History -7. Parameter pinning (per-output, in synth visualizer drawer) -8. Region pinning (on joy-map, Approach A: example pinning) -9. Snapshot stack with undo button -10. A/B Compare toggle +### Phase 2 — Pinning + History ✅ IMPLEMENTED +7. ✅ Parameter pinning — per-output pin flags, pin mask passed to `moveWeights()`, double-tap to toggle in synth visualizer +8. ✅ Region pinning (Approach A: example pinning) — long-press joy-map pins current zoom window, pinned examples always included in training with high weight +9. ✅ Snapshot stack with undo — ring buffer (20 max), auto-snapshot on train/randomize/thumbs-down, long-press for history popup +10. ✅ A/B Compare toggle — capture A, toggle between states, accept B or revert to A +11. ✅ Modified `mlp.js` moveWeights to accept optional `outputPinMask` for pinned output nodes -### Phase 3 — Input Refinement + Exploration -11. Pressure/hold-duration feedback -12. Auto-Explore mode -13. Input space heatmap on joy-map +### Phase 3 — Input Refinement + Exploration ✅ IMPLEMENTED +12. ✅ Pressure/hold-duration feedback — touch force + hold duration modulate noise growth/decay strength +13. ✅ Auto-Explore mode — automated thumbs-down at configurable interval, zoom-scaled intensity, emerald toggle button with progress ring +14. ✅ Input space heatmap — 16×16 grid inference sampling, 3 color modes (luminance/variance/divergence), zoom-aware resampling, throttled updates -### Phase 4 — Output, Persistence + Polish -14. Output smoothing, slew rate, and freeze output gate — (sliders exist in drawer but not yet wired to output pipeline) -15. Weight health indicator + gradient flow -16. Session presets (control + synth bundled) -17. Engine configuration panel +### Phase 4 — Output, Persistence + Polish ✅ IMPLEMENTED +15. ✅ Output pipeline — global curve → smoothing → slew rate → freeze gate, wired into `routeOutputs()` +16. ✅ Weight health indicator — weight magnitude histogram, dead/saturating/healthy status, ambient glow visualization +17. ✅ Gradient flow indicator — per-layer weight-delta analysis, vanishing/exploding/converged detection, bar visualization +18. ✅ Session presets — save/load full state (control surface + synth preset + pipelines), URL sharing via compact params + +### Remaining +- Engine configuration panel (Part 8) — network architecture, loss function, optimizer selection --- diff --git a/playground/js/a-app.js b/playground/js/a-app.js index ac0c228..c02ddc6 100644 --- a/playground/js/a-app.js +++ b/playground/js/a-app.js @@ -10,6 +10,20 @@ import { InputPipeline } from './ui/input-pipeline.js'; import { initControlSurfaceUI } from './ui/control-surface-ui.js'; import { JoyMapEnhanced } from './ui/joy-map-enhanced.js'; +// Phase 2: Pinning + History +import { SnapshotStack } from './ui/snapshot-stack.js'; +import { ABCompare } from './ui/ab-compare.js'; +import { RegionPinManager } from './ui/region-pin.js'; +import { ParamPinManager } from './ui/param-pin.js'; +import { initPhase2UI } from './ui/phase2-ui.js'; + +// Phase 3: Pressure, Auto-Explore, Heatmap +import { initPhase3UI } from './ui/phase3-ui.js'; + +// Phase 4: Output Pipeline + Visualization + Polish +import { OutputPipeline } from './ui/output-pipeline.js'; +import { initPhase4UI } from './ui/phase4-ui.js'; + // ---- ShapeSeq (feature-flagged, enable with ?shapeseq=1) ---- const ENABLE_SHAPESEQ = new URLSearchParams(window.location.search).get('shapeseq') === '1'; let _shapeSeqImports = null; @@ -117,6 +131,20 @@ let joyMapEnhanced = null; let _lastFrameTime = 0; let _lastPipeX = 0.5, _lastPipeY = 0.5; // cached pipeline output for getCurrentInputs() +// Phase 2: Pinning + History +let snapshotStack = null; +let abCompare = null; +let regionPins = null; +let paramPins = null; +let phase2UI = null; + +// Phase 3: Pressure, Auto-Explore, Heatmap +let phase3 = null; + +// Phase 4: Output Pipeline + Visualization + Polish +let outputPipeline = null; +let phase4 = null; + // Joystick state let joyX = 0.5; let joyY = 0.5; @@ -490,14 +518,28 @@ class SynthVisualizer { const barH = val * maxBarHeight; const barY = topPad + usableHeight - barH; - // Bar with slight transparency - ctx.fillStyle = sec.color + 'cc'; + // Phase 2: Dim pinned param bars + const isPinned = paramPins && paramPins.isPinned(i); + + // Bar with slight transparency (dimmer if pinned) + ctx.fillStyle = sec.color + (isPinned ? '66' : 'cc'); ctx.fillRect(x, barY, Math.max(barWidth - 0.5, 1), barH); // Bright top edge - ctx.fillStyle = sec.color; + ctx.fillStyle = isPinned ? (sec.color + '88') : sec.color; ctx.fillRect(x, barY, Math.max(barWidth - 0.5, 1), Math.min(2 * dpr, barH)); + // Phase 2: Draw small lock indicator on pinned params + if (isPinned) { + const dotR = Math.max(2 * dpr, barWidth * 0.2); + const dotX = x + barWidth / 2; + const dotY = topPad + usableHeight + 6 * dpr; + ctx.fillStyle = 'rgba(255, 180, 0, 0.8)'; + ctx.beginPath(); + ctx.arc(dotX, dotY, dotR, 0, Math.PI * 2); + ctx.fill(); + } + x += barWidth; } @@ -813,6 +855,12 @@ async function init() { const csUI = initControlSurfaceUI(); controlSurface = csUI.surface; + // ---- Phase 2: Data models — init before loadState so restore works ---- + snapshotStack = new SnapshotStack(20); + abCompare = new ABCompare(); + regionPins = new RegionPinManager(); + paramPins = new ParamPinManager(N_OUTPUTS); + // Restore saved state (if any) loadState(); @@ -842,9 +890,115 @@ async function init() { }), }); + // ---- Phase 2: UI (data models already created above, before loadState) ---- + phase2UI = initPhase2UI({ + snapshotStack, + abCompare, + regionPins, + paramPins, + getWeights: () => iml._getFlatWeights(), + setWeights: (w) => { iml._setFlatWeights(w); }, + getNoiseLevel: () => noiseLevel, + setNoiseLevel: (n) => { noiseLevel = n; updateNoiseRing(); }, + getZoomLevel: () => inputPipeline ? inputPipeline.getZoomLevel() : 1.0, + getZoomWindow: () => inputPipeline ? inputPipeline.getZoomWindow() : null, + getTrainingData: () => ({ + features: iml.dataset.features, + labels: iml.dataset.labels, + }), + runInference: () => { + iml.inputUpdated = true; + iml.process(); + const outputs = iml.getOutputs(); + routeOutputs(outputs); + updateHeatmap(outputs); + syncRawParamsFromOutputs(outputs); + updateStatus(); + }, + joyMapCanvas: $joyMap, + synthVisualizer, + }); + + // Sync region pin overlays to the enhanced joy-map + document.addEventListener('regionpin:sync', (e) => { + if (joyMapEnhanced) { + joyMapEnhanced.setPinnedRegions(e.detail.regions); + } + }); + + // ---- Phase 3: Pressure, Auto-Explore, Input Heatmap ---- + phase3 = initPhase3UI({ + onAutoExplore: ({ intensity }) => { + const csParams = controlSurface ? controlSurface.getParams() : null; + const noiseCap = csParams ? csParams.noiseCap : (0.3 * (1 - spreadLevel) + 0.05 * spreadLevel); + const growth = csParams ? csParams.noiseGrowth : 1.5; + let effectiveGrowth = growth * intensity; + if (csParams && csParams.zoomAwareFeedback && inputPipeline) { + effectiveGrowth *= inputPipeline.getZoomLevel(); + } + noiseLevel = Math.min(noiseLevel * effectiveGrowth, noiseCap); + const pinMask = paramPins ? paramPins.getPinMask() : null; + iml.moveWeights(noiseLevel, spreadLevel, pinMask); + const outputs = iml.getOutputs(); + routeOutputs(outputs); + updateHeatmap(outputs); + syncRawParamsFromOutputs(outputs); + updateStatus(); + updateNoiseRing(); + if (phase3) phase3.updateHeatmap(); + }, + getZoomLevel: () => inputPipeline ? inputPipeline.getZoomLevel() : 1.0, + inferFn: (inputs) => { + if (inputMode === 'hands') return []; + const savedInput = [...iml.inputState]; + const savedUpdated = iml.inputUpdated; + iml.setInput(0, inputs[0]); + iml.setInput(1, inputs[1]); + iml.inputUpdated = true; + iml.process(); + const result = [...iml.getOutputs()]; + for (let i = 0; i < savedInput.length; i++) iml.inputState[i] = savedInput[i]; + iml.inputUpdated = savedUpdated; + return result; + }, + getZoomWindow: () => inputPipeline ? inputPipeline.getZoomWindow() : null, + }); + + // Wire heatmap into the enhanced joy-map + if (joyMapEnhanced && phase3) { + joyMapEnhanced.setHeatmap(phase3.heatmap); + } + + // ---- Phase 4: Output Pipeline + Visualization + Polish ---- + outputPipeline = new OutputPipeline(N_OUTPUTS); + phase4 = initPhase4UI({ + outputPipeline, + iml, + controlSurface, + getGroupOverrides: () => groupOverrides, + getActiveSynthPresetId: () => activeSynthPresetId, + inputPipeline, + onSessionLoad: (preset) => { + if (preset.controlSurface && controlSurface) { + controlSurface.setState(preset.controlSurface); + } + if (preset.inputPipeline && inputPipeline) { + inputPipeline.setConfig(preset.inputPipeline); + } + if (preset.outputPipeline && outputPipeline) { + outputPipeline.setConfig(preset.outputPipeline); + } + if (preset.synthPresetId) { + applyPreset(preset.synthPresetId); + } + if (inputMode === 'joystick') onJoystickMove(); + }, + }); + // Wire control surface changes to pipeline + RL params document.addEventListener('controlsurface:change', (e) => { const p = e.detail; + if (p._phase3) return; // Phase 3 drawer events handled internally // Input pipeline inputPipeline.setConfig({ zoom: p.zoom, @@ -857,6 +1011,12 @@ async function init() { }); // Sync spread (used by moveWeights and randomise) spreadLevel = p.spread; + // Phase 4: wire output pipeline params + if (outputPipeline) { + if (p.outputSmoothing !== undefined) outputPipeline.setSmoothing(p.outputSmoothing); + if (p.outputSlewRate !== undefined) outputPipeline.setSlewRate(p.outputSlewRate); + if (p.globalCurve !== undefined) outputPipeline.setGlobalCurve(p.globalCurve); + } }); // Initial inference — run through pipeline for consistency @@ -1164,15 +1324,25 @@ function onJoystickMove() { const _lastSentParams = new Float32Array(N_OUTPUTS); const PARAM_DEAD_ZONE = 0.002; // ~0.2% change threshold let _lastParamSendTime = 0; +let _lastRouteTime = 0; const PARAM_SEND_INTERVAL = 50; // max ~20fps for synth param updates function routeOutputs(outputs) { + // Phase 4: Output Pipeline (curve, smoothing, slew, freeze) + let pipelined = outputs; + if (outputPipeline) { + const now = performance.now(); + const dt = _lastRouteTime > 0 ? (now - _lastRouteTime) / 1000 : 1 / 60; + _lastRouteTime = now; + pipelined = outputPipeline.process(outputs, dt); + } + let overridden = null; if (outputMode === 'synth') { - overridden = new Array(outputs.length); - for (let i = 0; i < outputs.length; i++) { - overridden[i] = applyGroupOverrides(outputs[i], i); + overridden = new Array(pipelined.length); + for (let i = 0; i < pipelined.length; i++) { + overridden[i] = applyGroupOverrides(pipelined[i], i); } // Visualizer always gets every frame (it's local, no buffer) synthVisualizer.setParams(overridden); @@ -1192,13 +1362,13 @@ function routeOutputs(outputs) { } } } else { - visualizer.setParams(outputs.slice(0, N_VISUAL_OUTPUTS)); + visualizer.setParams(Array.from(pipelined).slice(0, N_VISUAL_OUTPUTS)); } // OSC output — sends in both modes (has its own throttle + dead-zone). // In synth mode sends post-override values; in visual mode sends raw outputs. if (oscOutput) { - oscOutput.sendParams(overridden || outputs); + oscOutput.sendParams(overridden || Array.from(pipelined)); } } @@ -1495,11 +1665,15 @@ function onAddExample() { function onTrain() { if (iml.isTraining) return; + // Phase 2: auto-snapshot before training + if (phase2UI) phase2UI.pushSnapshot('before train'); flash('btn-train'); trainModelAsync(); } function onRandomize() { + // Phase 2: auto-snapshot before randomize + if (phase2UI) phase2UI.pushSnapshot('before randomize'); iml.randomiseWeights(spreadLevel); setCurrentInputs(); iml.process(); @@ -1509,6 +1683,7 @@ function onRandomize() { syncRawParamsFromOutputs(outputs); noiseLevel = 0.05; updateStatus(); + if (phase3) phase3.updateHeatmap(); } function onClearExamples() { @@ -1541,15 +1716,22 @@ function onThumbsUp() { const csParams = controlSurface ? controlSurface.getParams() : null; const decay = csParams ? csParams.noiseDecay : rlExplorationDecay; const floor = csParams ? csParams.noiseFloor : 0.005; - noiseLevel *= decay; + // Phase 3: pressure/hold modulates decay strength + const p3Intensity = phase3 ? phase3.pressure.getIntensity() : 1.0; + const modulatedDecay = 1 - (1 - decay) * p3Intensity; + noiseLevel *= modulatedDecay; noiseLevel = Math.max(noiseLevel, floor); flash('btn-thumbsup'); updateNoiseRing(); trainModelAsync(); + if (phase3) phase3.updateHeatmap(); } function onThumbsDown() { + // Phase 2: auto-snapshot before thumbs-down + if (phase2UI) phase2UI.pushSnapshot('before thumbs-down'); + // Use control surface params if available, else legacy const csParams = controlSurface ? controlSurface.getParams() : null; const noiseCap = csParams ? csParams.noiseCap : (0.3 * (1 - spreadLevel) + 0.05 * spreadLevel); @@ -1560,10 +1742,15 @@ function onThumbsDown() { if (csParams && csParams.zoomAwareFeedback && inputPipeline) { effectiveGrowth *= inputPipeline.getZoomLevel(); } + // Phase 3: pressure/hold modulates noise growth + const p3DownIntensity = phase3 ? phase3.pressure.getIntensity() : 1.0; + effectiveGrowth *= p3DownIntensity; noiseLevel = Math.min(noiseLevel * effectiveGrowth, noiseCap); - iml.moveWeights(noiseLevel, spreadLevel); + // Phase 2: pass param pin mask to moveWeights (pinned outputs are not perturbed) + const pinMask = paramPins ? paramPins.getPinMask() : null; + iml.moveWeights(noiseLevel, spreadLevel, pinMask); const outputs = iml.getOutputs(); routeOutputs(outputs); @@ -1572,6 +1759,7 @@ function onThumbsDown() { updateStatus(); updateNoiseRing(); flash('btn-thumbsdown'); + if (phase3) phase3.updateHeatmap(); } // ---- Training ---- @@ -1581,8 +1769,39 @@ function trainModel() { } // Async — used for interactive training (thumbs-up, train button) +// Phase 2: includes pinned region examples and freezes pinned param labels. function trainModelAsync(onDone) { + // Phase 2: Inject pinned region examples into the dataset before training. + // These anchor behavior in pinned regions and can't be evicted by FIFO. + if (regionPins && regionPins.count > 0) { + const pinned = regionPins.getPinnedExamples(); + for (let i = 0; i < pinned.features.length; i++) { + iml.addExample(pinned.features[i], pinned.labels[i]); + } + } + + // Phase 2: If any output params are pinned, freeze their labels to current + // inferred values so the network maintains its mapping for those outputs. + if (paramPins && paramPins.pinnedCount > 0) { + const mask = paramPins.getPinMask(); + const currentOutputs = iml.getOutputs(); + const labels = iml.dataset.labels; + for (let i = 0; i < labels.length; i++) { + for (let j = 0; j < labels[i].length; j++) { + if (mask[j]) { + labels[i][j] = currentOutputs[j]; + } + } + } + } + + // Phase 4: Capture weights before training for gradient flow analysis + if (phase4) phase4.captureBeforeTrain(); + iml.trainAsync(({ loss, outputs }) => { + // Phase 4: Capture weights after training for gradient flow analysis + if (phase4) phase4.captureAfterTrain(); + routeOutputs(outputs); updateHeatmap(outputs); syncRawParamsFromOutputs(outputs); @@ -2468,6 +2687,11 @@ function wireHelp() { // ---- Animation ---- function animate() { + // Phase 3: tick auto-explore timer and pressure indicators + const _animNow = performance.now(); + const _animDt = _lastFrameTime > 0 ? (_animNow - _lastFrameTime) / 1000 : 1 / 60; + if (phase3) phase3.tick(_animDt); + if (gamepad) gamepad.poll(); if (outputMode === 'synth') { synthVisualizer.draw(); @@ -2511,6 +2735,14 @@ function saveState() { synthPresetId: activeSynthPresetId, controlSurface: controlSurface ? controlSurface.getState() : null, inputPipeline: inputPipeline ? inputPipeline.getConfig() : null, + // Phase 2: Pinning + History + snapshotStack: snapshotStack ? snapshotStack.getState() : null, + regionPins: regionPins ? regionPins.getState() : null, + paramPins: paramPins ? paramPins.getState() : null, + // Phase 3 + phase3: phase3 ? phase3.getConfig() : null, + // Phase 4 + outputPipeline: outputPipeline ? outputPipeline.getConfig() : null, }; localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch (e) { @@ -2589,6 +2821,25 @@ function loadState() { inputPipeline.setConfig(state.inputPipeline); } + // Phase 2: Restore pinning + history state + if (state.snapshotStack && snapshotStack) { + snapshotStack.setState(state.snapshotStack); + } + if (state.regionPins && regionPins) { + regionPins.setState(state.regionPins); + } + if (state.paramPins && paramPins) { + paramPins.setState(state.paramPins); + } + // Phase 3: restore config + if (state.phase3 && phase3) { + phase3.setConfig(state.phase3); + } + // Phase 4: restore output pipeline + if (state.outputPipeline && outputPipeline) { + outputPipeline.setConfig(state.outputPipeline); + } + // Note: don't auto-restore inputMode='hands' — requires camera permission console.log(`[NISPS] Restored ${state.features?.length || 0} joy examples, ${state.handFeatures?.length || 0} hand examples from storage`); } catch (e) { diff --git a/playground/js/nisps/iml.js b/playground/js/nisps/iml.js index e9c962c..eea2005 100644 --- a/playground/js/nisps/iml.js +++ b/playground/js/nisps/iml.js @@ -159,8 +159,9 @@ export class IML { // Add Gaussian noise to weights (for RL exploration) // spread: 0 = flat noise, 1 = Xavier-scaled per layer - moveWeights(speed, spread = 0) { - this.mlp.moveWeights(speed, spread); + // outputPinMask: optional Uint8Array[nOutputs], 1 = skip that output node + moveWeights(speed, spread = 0, outputPinMask = null) { + this.mlp.moveWeights(speed, spread, outputPinMask); // Run inference to show effect this.inputUpdated = true; this.process(); diff --git a/playground/js/nisps/mlp.js b/playground/js/nisps/mlp.js index 1f04172..d836a5b 100644 --- a/playground/js/nisps/mlp.js +++ b/playground/js/nisps/mlp.js @@ -228,13 +228,22 @@ export class MLP { // preventing unbounded magnitude drift from repeated thumbs-down. At spread=0 there is // no decay (original behavior). At spread=1 each call decays weights by ~10%, creating // a natural equilibrium where exploration can't permanently saturate sigmoid. - moveWeights(speed, spread = 0) { + // + // outputPinMask: optional Uint8Array[numOutputs]. If provided, output-layer nodes where + // mask[i] === 1 are skipped (their weights are not perturbed). Only affects the last layer. + moveWeights(speed, spread = 0, outputPinMask = null) { const decay = 1 - 0.1 * spread; // spread=0 → 1.0 (no decay), spread=1 → 0.9 for (let l = 0; l < this.layers.length; l++) { const fanIn = this.layersNodes[l]; const xavierScale = 1 / Math.sqrt(fanIn); const layerScale = 1 * (1 - spread) + xavierScale * spread; - for (const node of this.layers[l].nodes) { + const isOutputLayer = l === this.layers.length - 1; + const nodes = this.layers[l].nodes; + for (let ni = 0; ni < nodes.length; ni++) { + // Skip pinned output nodes in the final layer + if (isOutputLayer && outputPinMask && outputPinMask[ni]) continue; + + const node = nodes[ni]; for (let j = 0; j < node.weights.length; j++) { // Decay toward zero to prevent magnitude drift node.weights[j] *= decay; diff --git a/playground/js/nisps/nisps-wasm.js b/playground/js/nisps/nisps-wasm.js index 21101e6..55e8663 100644 --- a/playground/js/nisps/nisps-wasm.js +++ b/playground/js/nisps/nisps-wasm.js @@ -287,8 +287,47 @@ export class WasmIML { this.log('Weights randomised.'); } - moveWeights(speed, spread = 0) { + // outputPinMask: optional Uint8Array[nOutputs], 1 = skip that output node. + // Since WASM moveWeights doesn't support pin masks, we save pinned nodes' + // weights before the call and restore them after. + moveWeights(speed, spread = 0, outputPinMask = null) { + let savedSlices = null; + + if (outputPinMask && outputPinMask.some(v => v)) { + // Compute the flat-array offset of the last layer's nodes. + // Flat format: for each layer, for each node: [w0..wN, bias] + const allWeights = this._getFlatWeights(); + const lastLayerInputSize = this.layerSizes[this.layerSizes.length - 2]; + const numOutputNodes = this.layerSizes[this.layerSizes.length - 1]; + const weightsPerOutputNode = lastLayerInputSize + 1; // weights + bias + + // Offset of the last layer in the flat array + const lastLayerOffset = this._weightCount - (numOutputNodes * weightsPerOutputNode); + + // Save pinned nodes' weight slices + savedSlices = []; + for (let i = 0; i < numOutputNodes; i++) { + if (outputPinMask[i]) { + const start = lastLayerOffset + i * weightsPerOutputNode; + const end = start + weightsPerOutputNode; + savedSlices.push({ start, end, data: allWeights.slice(start, end) }); + } + } + } + this._w.moveWeightsSpread(this._mlp, speed, spread); + + // Restore pinned nodes' weights + if (savedSlices && savedSlices.length > 0) { + const allWeights = this._getFlatWeights(); + for (const slice of savedSlices) { + for (let j = 0; j < slice.data.length; j++) { + allWeights[slice.start + j] = slice.data[j]; + } + } + this._setFlatWeights(allWeights); + } + this.inputUpdated = true; this.process(); } diff --git a/playground/js/ui/ab-compare.js b/playground/js/ui/ab-compare.js new file mode 100644 index 0000000..277acbd --- /dev/null +++ b/playground/js/ui/ab-compare.js @@ -0,0 +1,153 @@ +// A/B Compare — Rapid toggle between two weight states +// +// Workflow: +// 1. User presses "A" to capture current state as the reference +// 2. User continues exploring (this becomes the live "B" state) +// 3. Toggle switches instantly between A and B +// 4. "Accept B" discards A and exits A/B mode +// 5. "Revert to A" restores the A snapshot and discards B +// +// The state object stored for each side includes weights, noiseLevel, and +// an optional controlState (for any control surface params worth preserving). +// +// Usage: +// import { ABCompare } from './ab-compare.js'; +// const ab = new ABCompare(); +// ab.captureA({ weights, noiseLevel, controlState }); +// // ... user explores ... +// const stateToRestore = ab.toggle(); // switches to A, returns A's state +// const stateToRestore2 = ab.toggle(); // switches back to B, returns B's state + +export class ABCompare { + constructor() { + this._a = null; // { weights, noiseLevel, controlState } + this._b = null; // { weights, noiseLevel, controlState } + this._current = null; // 'a' | 'b' | null (null = inactive) + } + + // ---- Public API ---- + + /** + * Capture the current state as side "A" and enter A/B mode. + * The live state after this call becomes "B". + * @param {{ weights: Array, noiseLevel: number, controlState?: any }} state + */ + captureA(state) { + this._a = { + weights: Array.isArray(state.weights) + ? [...state.weights] + : Array.from(state.weights), + noiseLevel: state.noiseLevel ?? 0, + controlState: state.controlState ?? null, + }; + this._b = null; // B is "live" — captured on first toggle away from B + this._current = 'b'; // We're currently hearing the B (live) side + this._dispatch('ab:activate', { side: 'b' }); + } + + /** + * Whether A/B mode is currently active. + * @returns {boolean} + */ + get active() { + return this._current !== null; + } + + /** + * Which side is currently active: 'a', 'b', or null if inactive. + * @returns {'a'|'b'|null} + */ + get current() { + return this._current; + } + + /** + * Toggle between A and B. Returns the state for whichever side we're + * switching TO, so the caller can restore weights/noise from it. + * + * The caller must pass the current live state so we can snapshot whichever + * side we're leaving. + * + * @param {{ weights: Array, noiseLevel: number, controlState?: any }} currentLiveState + * @returns {{ weights: Array, noiseLevel: number, controlState?: any }|null} + */ + toggle(currentLiveState) { + if (!this.active) return null; + + if (this._current === 'b') { + // Switching B → A: save current live state as B, return A + this._b = { + weights: Array.isArray(currentLiveState.weights) + ? [...currentLiveState.weights] + : Array.from(currentLiveState.weights), + noiseLevel: currentLiveState.noiseLevel ?? 0, + controlState: currentLiveState.controlState ?? null, + }; + this._current = 'a'; + this._dispatch('ab:toggle', { side: 'a' }); + return this._cloneState(this._a); + } else { + // Switching A → B: save current live state as A, return B + this._a = { + weights: Array.isArray(currentLiveState.weights) + ? [...currentLiveState.weights] + : Array.from(currentLiveState.weights), + noiseLevel: currentLiveState.noiseLevel ?? 0, + controlState: currentLiveState.controlState ?? null, + }; + this._current = 'b'; + this._dispatch('ab:toggle', { side: 'b' }); + return this._cloneState(this._b); + } + } + + /** + * Accept B: discard A, exit A/B mode. + * The current live state (B) is kept as-is — nothing to restore. + */ + acceptB() { + this._a = null; + this._b = null; + this._current = null; + this._dispatch('ab:deactivate', { accepted: 'b' }); + } + + /** + * Revert to A: restore A state and discard B, exit A/B mode. + * @returns {{ weights: Array, noiseLevel: number, controlState?: any }|null} + */ + revertToA() { + if (!this._a) return null; + const state = this._cloneState(this._a); + this._a = null; + this._b = null; + this._current = null; + this._dispatch('ab:deactivate', { accepted: 'a' }); + return state; + } + + /** + * Cancel A/B mode without choosing. Stays on current live state. + */ + cancel() { + this._a = null; + this._b = null; + this._current = null; + this._dispatch('ab:deactivate', { accepted: null }); + } + + // ---- Internal ---- + + _cloneState(s) { + if (!s) return null; + return { + weights: [...s.weights], + noiseLevel: s.noiseLevel, + controlState: s.controlState, + }; + } + + _dispatch(type, detail) { + document.dispatchEvent(new CustomEvent(type, { detail })); + } +} diff --git a/playground/js/ui/auto-explore.js b/playground/js/ui/auto-explore.js new file mode 100644 index 0000000..07b9f35 --- /dev/null +++ b/playground/js/ui/auto-explore.js @@ -0,0 +1,160 @@ +/** + * Auto-Explore — automated thumbs-down at regular intervals. + * + * "Wander" mode for weight space: the system drifts continuously while + * the user only gives thumbs-up when it lands on something good. + * + * Respects noise cap, spread, zoom-aware feedback — same rules as manual + * thumbs-down. Intensity scales with zoom: zoomed in = gentler steps, + * zoomed out = bigger leaps. + * + * @module auto-explore + */ + +// ---- Constants ---- +const MIN_INTERVAL = 0.5; // seconds +const MAX_INTERVAL = 10; // seconds +const DEFAULT_INTERVAL = 2; // seconds +const MIN_INTENSITY = 0.1; +const MAX_INTENSITY = 1.0; +const DEFAULT_INTENSITY = 0.5; + +export class AutoExplore { + /** + * @param {object} [options] + * @param {number} [options.interval=2] - seconds between auto-steps + * @param {number} [options.intensity=0.5] - 0.1-1.0, scales noise per step + */ + constructor(options = {}) { + this._interval = clamp(options.interval ?? DEFAULT_INTERVAL, MIN_INTERVAL, MAX_INTERVAL); + this._intensity = clamp(options.intensity ?? DEFAULT_INTENSITY, MIN_INTENSITY, MAX_INTENSITY); + this._active = false; + this._elapsed = 0; // seconds since last auto-step + this._callback = null; + } + + // ---- Control ---- + + /** Start auto-exploration. */ + start() { + this._active = true; + this._elapsed = 0; + } + + /** Stop auto-exploration. */ + stop() { + this._active = false; + this._elapsed = 0; + } + + /** Toggle on/off. */ + toggle() { + if (this._active) this.stop(); + else this.start(); + } + + /** Whether auto-explore is currently running. */ + get active() { + return this._active; + } + + // ---- Configuration ---- + + /** + * Set interval between auto-steps. + * @param {number} seconds - 0.5 to 10 + */ + setInterval(seconds) { + this._interval = clamp(seconds, MIN_INTERVAL, MAX_INTERVAL); + } + + /** @returns {number} */ + getInterval() { return this._interval; } + + /** + * Set exploration intensity. + * @param {number} intensity - 0.1 to 1.0 + */ + setIntensity(intensity) { + this._intensity = clamp(intensity, MIN_INTENSITY, MAX_INTENSITY); + } + + /** @returns {number} */ + getIntensity() { return this._intensity; } + + // ---- Tick (call every frame) ---- + + /** + * Advance the auto-explore timer. When the interval elapses, fires + * the registered callback with { intensity, zoomLevel }. + * + * @param {number} deltaTime - seconds since last frame + * @param {number} [zoomLevel=1.0] - current zoom level (0-1) + */ + tick(deltaTime, zoomLevel = 1.0) { + if (!this._active) return; + if (deltaTime <= 0) return; + + this._elapsed += deltaTime; + + if (this._elapsed >= this._interval) { + this._elapsed -= this._interval; + // Prevent runaway accumulation if tab was backgrounded + if (this._elapsed > this._interval) this._elapsed = 0; + + this._fireExplore(zoomLevel); + } + } + + // ---- Callback ---- + + /** + * Register the explore callback. Called when auto-step fires. + * @param {function} callback - receives { intensity } + */ + onExplore(callback) { + this._callback = callback; + } + + // ---- Visual state ---- + + /** + * Progress toward next auto-step (0-1). + * Useful for a countdown/progress ring UI. + * @returns {number} + */ + getProgress() { + if (!this._active) return 0; + return Math.min(1, this._elapsed / this._interval); + } + + // ---- Serialization ---- + + getConfig() { + return { + interval: this._interval, + intensity: this._intensity, + active: this._active, + }; + } + + setConfig(config) { + if (config.interval != null) this.setInterval(config.interval); + if (config.intensity != null) this.setIntensity(config.intensity); + // Note: we don't auto-start from config — caller decides + } + + // ---- Internal ---- + + _fireExplore(zoomLevel) { + if (!this._callback) return; + // Scale intensity by zoom: zoomed in = gentler, zoomed out = bigger leaps + const scaledIntensity = this._intensity * Math.max(0.05, zoomLevel); + this._callback({ intensity: scaledIntensity }); + } +} + +// ---- Utility ---- +function clamp(v, lo, hi) { + return v < lo ? lo : v > hi ? hi : v; +} diff --git a/playground/js/ui/gradient-flow.js b/playground/js/ui/gradient-flow.js new file mode 100644 index 0000000..be7dfd2 --- /dev/null +++ b/playground/js/ui/gradient-flow.js @@ -0,0 +1,261 @@ +/** + * Gradient Flow Indicator — per-layer gradient magnitude visualization. + * + * Uses the weight-delta approach: snapshot weights before training, snapshot after, + * compute per-layer L2 norm of the delta. No WASM changes needed. + * + * @module gradient-flow + */ + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Ratio threshold: if next layer's gradient < VANISHING_RATIO * previous, it's vanishing */ +const VANISHING_RATIO = 0.5; + +/** Ratio threshold: if next layer's gradient > EXPLODING_RATIO * previous, it's exploding */ +const EXPLODING_RATIO = 2.0; + +/** Absolute threshold: if all gradient norms below this, network has converged */ +const CONVERGED_THRESHOLD = 1e-6; + +// --------------------------------------------------------------------------- +// Colors +// --------------------------------------------------------------------------- + +const COLOR_HEALTHY = { r: 100, g: 200, b: 120 }; +const COLOR_WARNING = { r: 230, g: 200, b: 60 }; +const COLOR_DANGER = { r: 240, g: 80, b: 60 }; +const COLOR_CONVERGED = { r: 100, g: 140, b: 200 }; + +// --------------------------------------------------------------------------- +// GradientFlowIndicator +// --------------------------------------------------------------------------- + +export class GradientFlowIndicator { + /** + * @param {number[]} layerSizes — e.g. [3, 32, 48, 64, 126] + */ + constructor(layerSizes) { + this._layerSizes = layerSizes; + this._numLayers = layerSizes.length - 1; // number of weight matrices + + // Compute per-layer weight counts: layer i has layerSizes[i] * layerSizes[i+1] weights + layerSizes[i+1] biases + this._layerWeightCounts = []; + this._layerOffsets = []; + let offset = 0; + for (let i = 0; i < this._numLayers; i++) { + const count = layerSizes[i] * layerSizes[i + 1] + layerSizes[i + 1]; + this._layerWeightCounts.push(count); + this._layerOffsets.push(offset); + offset += count; + } + this._totalWeights = offset; + + // Build layer labels + this._layerLabels = []; + for (let i = 0; i < this._numLayers; i++) { + if (i === this._numLayers - 1) { + this._layerLabels.push('Out'); + } else { + this._layerLabels.push(`L${i + 1}`); + } + } + + // Weight snapshots + this._beforeWeights = null; + this._afterWeights = null; + this._flow = null; + } + + /** + * Capture weight snapshot before training. + * @param {number[]|Float32Array} weightsArray — flat array of all weights + */ + captureBeforeTrain(weightsArray) { + if (!weightsArray) return; + this._beforeWeights = weightsArray instanceof Float32Array + ? new Float32Array(weightsArray) + : new Float32Array(weightsArray); + } + + /** + * Capture weight snapshot after training and compute gradient flow. + * @param {number[]|Float32Array} weightsArray — flat array of all weights + */ + captureAfterTrain(weightsArray) { + if (!weightsArray || !this._beforeWeights) return; + + this._afterWeights = weightsArray instanceof Float32Array + ? weightsArray + : new Float32Array(weightsArray); + + this._computeFlow(); + } + + /** + * Get per-layer gradient flow info. + * @returns {object|null} + */ + getFlow() { + return this._flow; + } + + /** + * Draw per-layer gradient flow bars. + * + * @param {CanvasRenderingContext2D} ctx + * @param {number} x + * @param {number} y + * @param {number} width + * @param {number} height + */ + draw(ctx, x, y, width, height) { + if (!this._flow) { + ctx.fillStyle = 'rgba(60, 60, 60, 0.4)'; + ctx.fillRect(x, y, width, height); + ctx.font = '8px monospace'; + ctx.fillStyle = 'rgba(120, 120, 120, 0.6)'; + ctx.textBaseline = 'middle'; + ctx.textAlign = 'center'; + ctx.fillText('no grad data', x + width / 2, y + height / 2); + return; + } + + const { layers, status } = this._flow; + const n = layers.length; + if (n === 0) return; + + // Background + ctx.fillStyle = 'rgba(20, 20, 20, 0.6)'; + ctx.fillRect(x, y, width, height); + + const labelHeight = 12; + const barAreaHeight = height - labelHeight - 2; + const barWidth = Math.floor((width - 4) / n); + const startX = x + 2 + (width - 4 - barWidth * n) / 2; + + for (let i = 0; i < n; i++) { + const layer = layers[i]; + const barH = Math.max(1, layer.relativeFlow * barAreaHeight); + const bx = startX + i * barWidth; + const by = y + barAreaHeight - barH + 1; + + // Color based on per-layer health + let color; + if (status === 'converged') { + color = COLOR_CONVERGED; + } else if (layer.relativeFlow < 0.15 && i > 0) { + color = COLOR_DANGER; // vanishing at this layer + } else if (layer.relativeFlow > 0.85 && i === n - 1 && n > 1) { + color = COLOR_WARNING; // potential explosion + } else { + color = COLOR_HEALTHY; + } + + ctx.fillStyle = `rgba(${color.r}, ${color.g}, ${color.b}, 0.75)`; + ctx.fillRect(bx + 1, by, barWidth - 2, barH); + + // Layer label + ctx.font = '7px monospace'; + ctx.fillStyle = `rgba(${color.r}, ${color.g}, ${color.b}, 0.7)`; + ctx.textBaseline = 'top'; + ctx.textAlign = 'center'; + ctx.fillText(this._layerLabels[i], bx + barWidth / 2, y + barAreaHeight + 2); + } + + // Status label at top + ctx.font = '8px monospace'; + ctx.textBaseline = 'top'; + ctx.textAlign = 'left'; + + let statusColor; + if (status === 'healthy') statusColor = COLOR_HEALTHY; + else if (status === 'vanishing') statusColor = COLOR_DANGER; + else if (status === 'exploding') statusColor = COLOR_WARNING; + else statusColor = COLOR_CONVERGED; + + ctx.fillStyle = `rgba(${statusColor.r}, ${statusColor.g}, ${statusColor.b}, 0.8)`; + ctx.fillText(`G: ${status}`, x + 2, y + 1); + } + + // ----------------------------------------------------------------------- + // Internal + // ----------------------------------------------------------------------- + + _computeFlow() { + const before = this._beforeWeights; + const after = this._afterWeights; + + if (!before || !after || before.length !== after.length) { + this._flow = null; + return; + } + + const layers = []; + const norms = []; + + for (let li = 0; li < this._numLayers; li++) { + const offset = this._layerOffsets[li]; + const count = this._layerWeightCounts[li]; + + // Compute L2 norm of weight delta for this layer + let sumSq = 0; + for (let j = 0; j < count; j++) { + const idx = offset + j; + if (idx < before.length && idx < after.length) { + const delta = after[idx] - before[idx]; + sumSq += delta * delta; + } + } + const norm = Math.sqrt(sumSq); + norms.push(norm); + + layers.push({ + name: `${this._layerLabels[li]} (${this._layerSizes[li]}\u2192${this._layerSizes[li + 1]})`, + gradientNorm: norm, + relativeFlow: 0, // computed below + }); + } + + // Compute relative flow (normalize to max) + const maxNorm = Math.max(...norms, 1e-12); + for (let i = 0; i < layers.length; i++) { + layers[i].relativeFlow = norms[i] / maxNorm; + } + + // Detect status + let status = 'healthy'; + + // Check convergence: all norms very small + if (norms.every(n => n < CONVERGED_THRESHOLD)) { + status = 'converged'; + } else if (norms.length >= 2) { + // Check vanishing: each successive layer < VANISHING_RATIO of previous + let vanishing = true; + let exploding = true; + + for (let i = 1; i < norms.length; i++) { + const prev = norms[i - 1]; + const curr = norms[i]; + + if (prev <= CONVERGED_THRESHOLD) { + // Can't assess ratio with near-zero denominator + vanishing = false; + exploding = false; + break; + } + + const ratio = curr / prev; + if (ratio >= VANISHING_RATIO) vanishing = false; + if (ratio <= EXPLODING_RATIO) exploding = false; + } + + if (vanishing) status = 'vanishing'; + else if (exploding) status = 'exploding'; + } + + this._flow = { layers, status }; + } +} diff --git a/playground/js/ui/input-heatmap.js b/playground/js/ui/input-heatmap.js new file mode 100644 index 0000000..4f2a06c --- /dev/null +++ b/playground/js/ui/input-heatmap.js @@ -0,0 +1,382 @@ +/** + * Input Space Heatmap — 2D color field on the joy-map showing what the + * network produces across the entire input space. + * + * Samples the MLP at a grid of input points, reduces the output vector + * to a color, and renders as a background layer on the joy-map canvas. + * + * Three color modes: + * - luminance: mean output -> brightness (shows "loud" vs "quiet" regions) + * - variance: output variance -> saturation (shows "interesting" vs "flat") + * - divergence: difference from center point output (how each region diverges) + * + * Performance: 16x16 = 256 inferences at ~20us each = ~5ms. + * Throttled to max 5 updates/sec by default. + * + * @module input-heatmap + */ + +// ---- Constants ---- +const DEFAULT_RESOLUTION = 16; +const MIN_RESOLUTION = 4; +const MAX_RESOLUTION = 32; +const DEFAULT_THROTTLE = 200; // ms +const COLOR_MODES = ['luminance', 'variance', 'divergence']; + +// ---- Color palette ---- +// Dark-to-warm gradient: dark blue/purple -> amber -> white +// Pre-computed as HSL stops for fast interpolation +const PALETTE = [ + { h: 260, s: 60, l: 8 }, // 0.0 — very dark purple + { h: 250, s: 65, l: 18 }, // 0.2 — deep blue-purple + { h: 220, s: 55, l: 30 }, // 0.4 — medium blue + { h: 35, s: 80, l: 45 }, // 0.6 — warm amber + { h: 38, s: 90, l: 60 }, // 0.8 — bright amber + { h: 42, s: 95, l: 85 }, // 1.0 — near-white warm +]; + +function samplePalette(t) { + const clamped = Math.max(0, Math.min(1, t)); + const idx = clamped * (PALETTE.length - 1); + const lo = Math.floor(idx); + const hi = Math.min(lo + 1, PALETTE.length - 1); + const frac = idx - lo; + + const a = PALETTE[lo]; + const b = PALETTE[hi]; + const h = a.h + (b.h - a.h) * frac; + const s = a.s + (b.s - a.s) * frac; + const l = a.l + (b.l - a.l) * frac; + return { h, s, l }; +} + +function hslToRGB(h, s, l) { + s /= 100; + l /= 100; + const c = (1 - Math.abs(2 * l - 1)) * s; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = l - c / 2; + let r, g, b; + if (h < 60) { r = c; g = x; b = 0; } + else if (h < 120) { r = x; g = c; b = 0; } + else if (h < 180) { r = 0; g = c; b = x; } + else if (h < 240) { r = 0; g = x; b = c; } + else if (h < 300) { r = x; g = 0; b = c; } + else { r = c; g = 0; b = x; } + return { + r: Math.round((r + m) * 255), + g: Math.round((g + m) * 255), + b: Math.round((b + m) * 255), + }; +} + +export class InputHeatmap { + /** + * @param {object} [options] + * @param {number} [options.resolution=16] - grid points per axis + * @param {string} [options.colorMode='luminance'] - 'luminance'|'variance'|'divergence' + * @param {number} [options.throttle=200] - min ms between recomputes + * @param {number} [options.opacity=0.55] - heatmap alpha + */ + constructor(options = {}) { + this._resolution = clamp(options.resolution ?? DEFAULT_RESOLUTION, MIN_RESOLUTION, MAX_RESOLUTION); + this._colorMode = COLOR_MODES.includes(options.colorMode) ? options.colorMode : 'luminance'; + this._throttle = Math.max(50, options.throttle ?? DEFAULT_THROTTLE); + this._opacity = Math.max(0, Math.min(1, options.opacity ?? 0.55)); + this._enabled = false; + this._lastUpdate = 0; + + // Cached heatmap data: Float32Array of reduced values (resolution x resolution) + this._grid = null; // raw reduced values per cell + this._gridWindow = null; // zoom window used when computing this grid + this._imageData = null; // cached ImageData for rendering + this._offscreen = null; // offscreen canvas for compositing + this._offscreenCtx = null; + + // Center-point output cache (for divergence mode) + this._centerOutput = null; + } + + // ---- Public API ---- + + /** + * Recompute the heatmap. Call on weight changes (train, randomize, moveWeights). + * + * @param {function} inferFn - (inputArray: number[]) => number[] + * Runs inference for a given 2D input. Must NOT corrupt the main inference state. + * @param {object} [options] + * @param {object} [options.zoomWindow] - { x1, y1, x2, y2 } in [0,1] space + * @param {number} [options.resolution] - override resolution for this update + */ + update(inferFn, options = {}) { + if (!this._enabled) return; + + const now = performance.now(); + if (now - this._lastUpdate < this._throttle) return; + this._lastUpdate = now; + + const res = clamp(options.resolution ?? this._resolution, MIN_RESOLUTION, MAX_RESOLUTION); + const zw = options.zoomWindow || { x1: 0, y1: 0, x2: 1, y2: 1 }; + + // Sample grid + const grid = new Float32Array(res * res); + const outputs = []; + + // Pre-compute center output for divergence mode + if (this._colorMode === 'divergence') { + const cx = (zw.x1 + zw.x2) / 2; + const cy = (zw.y1 + zw.y2) / 2; + this._centerOutput = inferFn([cx, cy]); + } + + // Collect all outputs for normalization + for (let gy = 0; gy < res; gy++) { + for (let gx = 0; gx < res; gx++) { + // Map grid cell to input space + const inputX = zw.x1 + (gx + 0.5) / res * (zw.x2 - zw.x1); + const inputY = zw.y1 + (gy + 0.5) / res * (zw.y2 - zw.y1); + const out = inferFn([inputX, inputY]); + outputs.push(out); + } + } + + // Reduce outputs to scalar values based on color mode + let minVal = Infinity; + let maxVal = -Infinity; + + for (let i = 0; i < outputs.length; i++) { + const val = this._reduceOutput(outputs[i]); + grid[i] = val; + if (val < minVal) minVal = val; + if (val > maxVal) maxVal = val; + } + + // Normalize to [0,1] + const range = maxVal - minVal; + if (range > 1e-8) { + for (let i = 0; i < grid.length; i++) { + grid[i] = (grid[i] - minVal) / range; + } + } else { + grid.fill(0.5); + } + + this._grid = grid; + this._gridWindow = { ...zw }; + this._gridRes = res; + + // Build ImageData + this._buildImageData(res); + } + + /** + * Draw the heatmap onto a canvas context as a background layer. + * Should be called before other joy-map layers. + * + * @param {CanvasRenderingContext2D} ctx + * @param {number} canvasWidth - CSS pixel width + * @param {number} canvasHeight - CSS pixel height + * @param {object} [zoomWindow] - current zoom window { x1, y1, x2, y2 } + */ + draw(ctx, canvasWidth, canvasHeight, zoomWindow) { + if (!this._enabled || !this._imageData) return; + + const res = this._gridRes; + if (!this._offscreen || this._offscreen.width !== res || this._offscreen.height !== res) { + this._offscreen = new OffscreenCanvas(res, res); + this._offscreenCtx = this._offscreen.getContext('2d'); + } + + this._offscreenCtx.putImageData(this._imageData, 0, 0); + + // Determine draw rect: if the heatmap was computed for a zoom window, + // draw it into that region of the canvas + const gw = this._gridWindow || { x1: 0, y1: 0, x2: 1, y2: 1 }; + + // Canvas Y is inverted (y=0 is top, but our y1 is bottom of input space) + const dx = gw.x1 * canvasWidth; + const dy = (1 - gw.y2) * canvasHeight; + const dw = (gw.x2 - gw.x1) * canvasWidth; + const dh = (gw.y2 - gw.y1) * canvasHeight; + + ctx.save(); + ctx.globalAlpha = this._opacity; + // Use bilinear interpolation for smooth gradients + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'low'; + ctx.drawImage(this._offscreen, dx, dy, dw, dh); + ctx.restore(); + } + + // ---- Color mode ---- + + /** + * Set the color reduction mode. + * @param {string} mode - 'luminance' | 'variance' | 'divergence' + */ + setColorMode(mode) { + if (!COLOR_MODES.includes(mode)) return; + this._colorMode = mode; + // Invalidate cache so next update recomputes + this._grid = null; + this._imageData = null; + } + + /** @returns {string} */ + getColorMode() { return this._colorMode; } + + /** + * Cycle to next color mode. + * @returns {string} the new mode + */ + cycleColorMode() { + const idx = COLOR_MODES.indexOf(this._colorMode); + const next = COLOR_MODES[(idx + 1) % COLOR_MODES.length]; + this.setColorMode(next); + return next; + } + + // ---- Toggle ---- + + /** @param {boolean} enabled */ + setEnabled(enabled) { + this._enabled = !!enabled; + if (!this._enabled) { + this._grid = null; + this._imageData = null; + } + } + + /** @returns {boolean} */ + get enabled() { return this._enabled; } + + // ---- Configuration ---- + + /** @param {number} ms - minimum time between recomputes */ + setThrottle(ms) { + this._throttle = Math.max(50, ms); + } + + /** @param {number} res - grid points per axis (4-32) */ + setResolution(res) { + this._resolution = clamp(res, MIN_RESOLUTION, MAX_RESOLUTION); + } + + /** @returns {number} */ + getResolution() { return this._resolution; } + + /** @param {number} alpha - 0-1 */ + setOpacity(alpha) { + this._opacity = Math.max(0, Math.min(1, alpha)); + } + + /** + * Force a recompute on next update() call (clears throttle timer). + */ + invalidate() { + this._lastUpdate = 0; + } + + // ---- Serialization ---- + + getConfig() { + return { + enabled: this._enabled, + resolution: this._resolution, + colorMode: this._colorMode, + throttle: this._throttle, + opacity: this._opacity, + }; + } + + setConfig(config) { + if (config.enabled != null) this.setEnabled(config.enabled); + if (config.resolution != null) this.setResolution(config.resolution); + if (config.colorMode != null) this.setColorMode(config.colorMode); + if (config.throttle != null) this.setThrottle(config.throttle); + if (config.opacity != null) this.setOpacity(config.opacity); + } + + // ---- Internal ---- + + /** + * Reduce an output vector to a single scalar based on color mode. + */ + _reduceOutput(output) { + switch (this._colorMode) { + case 'luminance': + return this._meanOutput(output); + case 'variance': + return this._varianceOutput(output); + case 'divergence': + return this._divergenceOutput(output); + default: + return this._meanOutput(output); + } + } + + /** Mean of all outputs. */ + _meanOutput(output) { + let sum = 0; + for (let i = 0; i < output.length; i++) sum += output[i]; + return sum / output.length; + } + + /** Variance of outputs (how "interesting" / spread out the values are). */ + _varianceOutput(output) { + const mean = this._meanOutput(output); + let sumSq = 0; + for (let i = 0; i < output.length; i++) { + const d = output[i] - mean; + sumSq += d * d; + } + return sumSq / output.length; + } + + /** Euclidean distance from center-point output (normalized by dimension). */ + _divergenceOutput(output) { + if (!this._centerOutput || this._centerOutput.length !== output.length) { + return this._meanOutput(output); + } + let sumSq = 0; + for (let i = 0; i < output.length; i++) { + const d = output[i] - this._centerOutput[i]; + sumSq += d * d; + } + // Normalize: max possible distance for [0,1] outputs = sqrt(N) + return Math.sqrt(sumSq / output.length); + } + + /** + * Build an ImageData from the normalized grid values. + */ + _buildImageData(res) { + if (!this._grid) return; + + this._imageData = new ImageData(res, res); + const data = this._imageData.data; + + for (let gy = 0; gy < res; gy++) { + for (let gx = 0; gx < res; gx++) { + // Grid is stored bottom-to-top (y=0 is bottom of input space) + // ImageData is top-to-bottom, so flip Y + const gridIdx = (res - 1 - gy) * res + gx; + const val = this._grid[gridIdx]; + + const { h, s, l } = samplePalette(val); + const { r, g, b } = hslToRGB(h, s, l); + + const pixIdx = (gy * res + gx) * 4; + data[pixIdx + 0] = r; + data[pixIdx + 1] = g; + data[pixIdx + 2] = b; + data[pixIdx + 3] = 255; + } + } + } +} + +// ---- Utility ---- +function clamp(v, lo, hi) { + return v < lo ? lo : v > hi ? hi : v; +} diff --git a/playground/js/ui/joy-map-enhanced.js b/playground/js/ui/joy-map-enhanced.js index c1da6fb..e0a9ff9 100644 --- a/playground/js/ui/joy-map-enhanced.js +++ b/playground/js/ui/joy-map-enhanced.js @@ -107,6 +107,9 @@ export class JoyMapEnhanced { this.pinnedRegions = []; + // Optional heatmap layer (InputHeatmap instance) + this._heatmap = null; + // Flash state for tapped trail point this._flashPoint = null; this._flashTime = 0; @@ -168,6 +171,7 @@ export class JoyMapEnhanced { ctx.fillRect(0, 0, w, h); // Layers (back to front): + // 0. Input heatmap (background) // 1. Dim area outside zoom window // 2. Grid (adapts to zoom) // 3. Zoom window border @@ -181,6 +185,11 @@ export class JoyMapEnhanced { const zw = this._normalizeZoomWindow(zoomWindow, zoomLevel); + // Heatmap background layer + if (this._heatmap && this._heatmap.enabled) { + this._heatmap.draw(ctx, w, h, zw); + } + this._drawDimOverlay(ctx, w, h, zw); this._drawGrid(ctx, w, h, zw, zoomLevel); this._drawZoomWindowBorder(ctx, w, h, zw); @@ -256,6 +265,14 @@ export class JoyMapEnhanced { return null; } + /** + * Set a heatmap instance to draw as the background layer. + * @param {InputHeatmap|null} heatmap + */ + setHeatmap(heatmap) { + this._heatmap = heatmap || null; + } + setPinnedRegions(regions) { this.pinnedRegions = regions || []; } diff --git a/playground/js/ui/output-pipeline.js b/playground/js/ui/output-pipeline.js new file mode 100644 index 0000000..14a44fc --- /dev/null +++ b/playground/js/ui/output-pipeline.js @@ -0,0 +1,312 @@ +/** + * Output Pipeline — processes MLP outputs before they reach the synth/visualizer. + * + * Pipeline stages (in order): + * 1. Global Curve — power curve applied to all outputs + * 2. Output Smoothing — per-output EMA (frame-rate-independent) + * 3. Slew Rate Limiting — max change per second per output + * 4. Freeze Gate — when frozen, outputs don't update + * + * Pure math module — no DOM dependencies. + * + * @module output-pipeline + */ + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const DEFAULT_GLOBAL_CURVE = 1.0; +const GLOBAL_CURVE_MIN = 0.2; +const GLOBAL_CURVE_MAX = 5.0; + +const DEFAULT_SMOOTHING = 0; +const SMOOTHING_MAX = 0.95; + +const DEFAULT_SLEW_RATE = Infinity; // unlimited +const SLEW_RATE_MIN = 0.005; + +const REFERENCE_DT = 1 / 60; // 60fps reference for frame-rate-independent EMA + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function clamp(v, lo, hi) { + return v < lo ? lo : v > hi ? hi : v; +} + +/** + * Frame-rate-independent EMA. + * Same approach as input pipeline: converts per-frame smoothing factor into + * a time-domain factor so perceived smoothing is consistent regardless of frame rate. + */ +function emaSmooth(prev, raw, smoothing, dt) { + if (smoothing <= 0) return raw; + const effectiveDt = dt > 0 ? dt : REFERENCE_DT; + const alpha = 1 - smoothing; + const alphaEff = 1 - Math.pow(1 - alpha, effectiveDt / REFERENCE_DT); + return prev + alphaEff * (raw - prev); +} + +// --------------------------------------------------------------------------- +// OutputPipeline +// --------------------------------------------------------------------------- + +export class OutputPipeline { + /** + * @param {number} [numOutputs=126] — number of MLP outputs + */ + constructor(numOutputs = 126) { + this._numOutputs = numOutputs; + + // Configuration + this._globalCurve = DEFAULT_GLOBAL_CURVE; + this._smoothing = DEFAULT_SMOOTHING; + this._slewRate = DEFAULT_SLEW_RATE; + this._frozen = false; + + // Per-output state + this._smoothed = new Float32Array(numOutputs); + this._lastOutput = new Float32Array(numOutputs); + this._initialized = false; + + // Per-output freeze mask (for parameter pinning) + this._outputFrozen = new Uint8Array(numOutputs); // 0 = not frozen, 1 = frozen + + // Working buffer for processed output (avoid allocation per frame) + this._processed = new Float32Array(numOutputs); + + // Store raw outputs separately (for heatmap preview while globally frozen) + this._lastRawAfterCurve = new Float32Array(numOutputs); + } + + // ----------------------------------------------------------------------- + // Main processing + // ----------------------------------------------------------------------- + + /** + * Process raw MLP outputs through the pipeline. + * + * @param {number[]|Float32Array} rawOutputs — raw MLP output values in [0,1] + * @param {number} deltaTime — time since last call in seconds (e.g. 0.016) + * @returns {Float32Array} — processed outputs + */ + process(rawOutputs, deltaTime) { + const dt = Math.max(0, deltaTime || 0); + const n = Math.min(rawOutputs.length, this._numOutputs); + + // On first call, initialize smoothed state to raw values + if (!this._initialized) { + for (let i = 0; i < n; i++) { + this._smoothed[i] = rawOutputs[i]; + this._lastOutput[i] = rawOutputs[i]; + } + this._initialized = true; + } + + // Stage 1: Global Curve + for (let i = 0; i < n; i++) { + const raw = clamp(rawOutputs[i], 0, 1); + this._lastRawAfterCurve[i] = this._globalCurve === 1.0 + ? raw + : Math.pow(raw, this._globalCurve); + } + + // If globally frozen, return the last non-frozen output + // (raw-after-curve is still updated for preview access) + if (this._frozen) { + this._processed.set(this._lastOutput); + return this._processed; + } + + for (let i = 0; i < n; i++) { + // Per-output freeze: skip this output if individually pinned + if (this._outputFrozen[i]) { + this._processed[i] = this._lastOutput[i]; + continue; + } + + let value = this._lastRawAfterCurve[i]; + + // Stage 2: Output Smoothing (EMA) + value = emaSmooth(this._smoothed[i], value, this._smoothing, dt); + this._smoothed[i] = value; + + // Stage 3: Slew Rate Limiting + if (this._slewRate !== Infinity && isFinite(this._slewRate)) { + const maxDelta = this._slewRate * dt; + if (maxDelta > 0) { + const delta = value - this._lastOutput[i]; + if (Math.abs(delta) > maxDelta) { + value = this._lastOutput[i] + Math.sign(delta) * maxDelta; + } + } + } + + value = clamp(value, 0, 1); + this._processed[i] = value; + this._lastOutput[i] = value; + } + + return this._processed; + } + + // ----------------------------------------------------------------------- + // Configuration + // ----------------------------------------------------------------------- + + /** + * Set global curve exponent. + * <1 pushes outputs toward extremes (0 and 1), >1 pushes toward center. + * @param {number} exponent — 0.2 to 5.0, default 1.0 (linear) + */ + setGlobalCurve(exponent) { + this._globalCurve = clamp(exponent, GLOBAL_CURVE_MIN, GLOBAL_CURVE_MAX); + } + + /** @returns {number} current global curve exponent */ + getGlobalCurve() { + return this._globalCurve; + } + + /** + * Set output smoothing factor (EMA). + * @param {number} factor — 0 (off) to 0.95 + */ + setSmoothing(factor) { + this._smoothing = clamp(factor, 0, SMOOTHING_MAX); + } + + /** @returns {number} current smoothing factor */ + getSmoothing() { + return this._smoothing; + } + + /** + * Set slew rate limit (max change per second per output). + * @param {number} maxChangePerSec — 0.005 to Infinity, default Infinity (unlimited) + */ + setSlewRate(maxChangePerSec) { + if (maxChangePerSec >= 1.0 || !isFinite(maxChangePerSec)) { + this._slewRate = Infinity; + } else { + this._slewRate = Math.max(SLEW_RATE_MIN, maxChangePerSec); + } + } + + /** @returns {number} current slew rate */ + getSlewRate() { + return this._slewRate; + } + + /** + * Set global freeze state. + * When frozen, process() returns the last non-frozen output. + * @param {boolean} frozen + */ + setFrozen(frozen) { + this._frozen = !!frozen; + } + + // ----------------------------------------------------------------------- + // State queries + // ----------------------------------------------------------------------- + + /** @returns {boolean} whether output is globally frozen */ + isFrozen() { + return this._frozen; + } + + /** @returns {Float32Array} last processed (post-pipeline) output */ + getLastOutput() { + return new Float32Array(this._lastOutput); + } + + /** + * Get the last raw-after-curve output (useful for heatmap preview while frozen). + * @returns {Float32Array} + */ + getRawPreview() { + return new Float32Array(this._lastRawAfterCurve); + } + + // ----------------------------------------------------------------------- + // Per-output freeze (parameter pinning) + // ----------------------------------------------------------------------- + + /** + * Freeze a specific output index (its value will not update). + * @param {number} index + */ + freezeOutput(index) { + if (index >= 0 && index < this._numOutputs) { + this._outputFrozen[index] = 1; + } + } + + /** + * Unfreeze a specific output index. + * @param {number} index + */ + unfreezeOutput(index) { + if (index >= 0 && index < this._numOutputs) { + this._outputFrozen[index] = 0; + } + } + + /** + * Check if a specific output is frozen. + * @param {number} index + * @returns {boolean} + */ + isOutputFrozen(index) { + return index >= 0 && index < this._numOutputs && this._outputFrozen[index] === 1; + } + + // ----------------------------------------------------------------------- + // Serialization + // ----------------------------------------------------------------------- + + /** Export configuration as a plain object (no internal state). */ + getConfig() { + return { + globalCurve: this._globalCurve, + smoothing: this._smoothing, + slewRate: this._slewRate === Infinity ? 1.0 : this._slewRate, + frozen: this._frozen, + frozenOutputs: Array.from(this._outputFrozen), + }; + } + + /** Restore configuration from a plain object. */ + setConfig(config) { + if (config.globalCurve != null) this.setGlobalCurve(config.globalCurve); + if (config.smoothing != null) this.setSmoothing(config.smoothing); + if (config.slewRate != null) this.setSlewRate(config.slewRate); + if (config.frozen != null) this.setFrozen(config.frozen); + if (Array.isArray(config.frozenOutputs)) { + for (let i = 0; i < config.frozenOutputs.length && i < this._numOutputs; i++) { + this._outputFrozen[i] = config.frozenOutputs[i] ? 1 : 0; + } + } + } + + // ----------------------------------------------------------------------- + // Reset + // ----------------------------------------------------------------------- + + /** Reset all settings to defaults and clear internal state. */ + reset() { + this._globalCurve = DEFAULT_GLOBAL_CURVE; + this._smoothing = DEFAULT_SMOOTHING; + this._slewRate = DEFAULT_SLEW_RATE; + this._frozen = false; + this._smoothed.fill(0); + this._lastOutput.fill(0); + this._lastRawAfterCurve.fill(0); + this._outputFrozen.fill(0); + this._processed.fill(0); + this._initialized = false; + } +} diff --git a/playground/js/ui/param-pin.js b/playground/js/ui/param-pin.js new file mode 100644 index 0000000..3210c63 --- /dev/null +++ b/playground/js/ui/param-pin.js @@ -0,0 +1,151 @@ +// Parameter Pin Manager — Per-output pin flags +// +// Pinned parameters keep their current mapping during moveWeights and training. +// During moveWeights, weights in the final layer connecting to pinned output nodes +// are skipped. During training, pinned outputs have their labels frozen to current +// inferred values so the network maintains the learned mapping. +// +// For the WASM path (which doesn't support pin masks natively), the approach is: +// 1. Before moveWeights: snapshot weights for pinned output nodes' final-layer connections +// 2. After moveWeights: restore those weights +// This is handled by the app integration, not inside this module. +// +// Usage: +// import { ParamPinManager } from './param-pin.js'; +// const paramPins = new ParamPinManager(126); +// paramPins.pin(42); // pin output #42 +// paramPins.toggle(42); // unpin +// const mask = paramPins.getPinMask(); // Uint8Array[126], 1 = pinned + +export class ParamPinManager { + /** + * @param {number} numOutputs Total number of output parameters + */ + constructor(numOutputs) { + this._numOutputs = numOutputs; + this._pinned = new Uint8Array(numOutputs); // 0 = unpinned, 1 = pinned + } + + // ---- Public API ---- + + /** + * Pin a specific output index. + * @param {number} outputIndex + */ + pin(outputIndex) { + if (outputIndex < 0 || outputIndex >= this._numOutputs) return; + if (this._pinned[outputIndex] === 1) return; // already pinned + this._pinned[outputIndex] = 1; + this._dispatch('parampin:change', { + index: outputIndex, + pinned: true, + totalPinned: this.pinnedCount, + }); + } + + /** + * Unpin a specific output index. + * @param {number} outputIndex + */ + unpin(outputIndex) { + if (outputIndex < 0 || outputIndex >= this._numOutputs) return; + if (this._pinned[outputIndex] === 0) return; // already unpinned + this._pinned[outputIndex] = 0; + this._dispatch('parampin:change', { + index: outputIndex, + pinned: false, + totalPinned: this.pinnedCount, + }); + } + + /** + * Toggle pin state for a specific output index. + * @param {number} outputIndex + * @returns {boolean} New pin state (true = pinned). + */ + toggle(outputIndex) { + if (outputIndex < 0 || outputIndex >= this._numOutputs) return false; + if (this._pinned[outputIndex]) { + this.unpin(outputIndex); + return false; + } else { + this.pin(outputIndex); + return true; + } + } + + /** + * Check if a specific output is pinned. + * @param {number} outputIndex + * @returns {boolean} + */ + isPinned(outputIndex) { + if (outputIndex < 0 || outputIndex >= this._numOutputs) return false; + return this._pinned[outputIndex] === 1; + } + + /** + * Get the full pin mask. + * @returns {Uint8Array} Length = numOutputs. 1 = pinned, 0 = unpinned. + */ + getPinMask() { + return this._pinned; + } + + /** + * Get array of pinned output indices. + * @returns {number[]} + */ + getPinnedIndices() { + const indices = []; + for (let i = 0; i < this._numOutputs; i++) { + if (this._pinned[i]) indices.push(i); + } + return indices; + } + + /** + * Number of currently pinned outputs. + * @returns {number} + */ + get pinnedCount() { + let count = 0; + for (let i = 0; i < this._numOutputs; i++) { + if (this._pinned[i]) count++; + } + return count; + } + + /** + * Unpin all outputs. + */ + clearAll() { + this._pinned.fill(0); + this._dispatch('parampin:clearall', { totalPinned: 0 }); + } + + // ---- Serialization ---- + + getState() { + return { + numOutputs: this._numOutputs, + pinned: Array.from(this._pinned), + }; + } + + setState(saved) { + if (!saved) return; + if (Array.isArray(saved.pinned)) { + const len = Math.min(saved.pinned.length, this._numOutputs); + for (let i = 0; i < len; i++) { + this._pinned[i] = saved.pinned[i] ? 1 : 0; + } + } + } + + // ---- Internal ---- + + _dispatch(type, detail) { + document.dispatchEvent(new CustomEvent(type, { detail })); + } +} diff --git a/playground/js/ui/phase2-ui.js b/playground/js/ui/phase2-ui.js new file mode 100644 index 0000000..e1b41f3 --- /dev/null +++ b/playground/js/ui/phase2-ui.js @@ -0,0 +1,626 @@ +// Phase 2 UI — Undo button, A/B Compare toggle, Region Pin, Parameter Pin icons +// +// Adds Phase 2 controls to the immersive app: +// - Undo button (near RL thumbs) with badge showing snapshot depth + long-press list +// - A/B toggle button pair near RL buttons +// - Region pin: long-press on joy-map (>500ms) pins the current zoom window +// - Parameter pin: pin icon overlay on synth visualizer bars +// +// All state management is delegated to the data modules (SnapshotStack, ABCompare, +// RegionPinManager, ParamPinManager). This module only builds DOM and dispatches events. +// +// Usage: +// import { initPhase2UI } from './phase2-ui.js'; +// const phase2 = initPhase2UI({ +// snapshotStack, abCompare, regionPins, paramPins, +// getWeights, setWeights, getNoiseLevel, setNoiseLevel, +// getZoomWindow, onRegionPin, onParamPinToggle, +// }); + +export function initPhase2UI(options) { + const { + snapshotStack, + abCompare, + regionPins, + paramPins, + // Callbacks the app provides so we can read/write state + getWeights, // () => Array (flat weights) + setWeights, // (weights: Array) => void + getNoiseLevel, // () => number + setNoiseLevel, // (level: number) => void + getZoomLevel, // () => number + getZoomWindow, // () => { x1, y1, x2, y2 } | null + getTrainingData, // () => { features, labels } + runInference, // () => void (re-run inference after weight change) + joyMapCanvas, // the joy-map canvas element (for long-press detection) + synthVisualizer, // SynthVisualizer instance (for param pin overlay) + } = options; + + // ---- Inject styles ---- + injectPhase2Styles(); + + // ==== 1. UNDO BUTTON ==== + + const $rlButtons = document.getElementById('rl-buttons'); + + const $undoBtn = document.createElement('button'); + $undoBtn.className = 'rl-btn phase2-undo-btn'; + $undoBtn.title = 'Undo (long-press for history)'; + $undoBtn.innerHTML = ` + + 0 + `; + + // Insert undo button at the start of the RL buttons container + if ($rlButtons) { + $rlButtons.insertBefore($undoBtn, $rlButtons.firstChild); + } + + const $undoBadge = $undoBtn.querySelector('#phase2-undo-badge'); + + function updateUndoBadge() { + const depth = snapshotStack.depth; + $undoBadge.textContent = depth; + $undoBadge.classList.toggle('hidden', depth === 0); + $undoBtn.disabled = depth === 0; + } + updateUndoBadge(); + + // Tap: single undo + $undoBtn.addEventListener('click', () => { + const state = snapshotStack.pop(); + if (state) { + setWeights(state.weights); + setNoiseLevel(state.noiseLevel); + runInference(); + } + updateUndoBadge(); + }); + + // Long-press: show snapshot list popup + let _undoLongPressTimer = null; + let _undoLongPressActive = false; + + $undoBtn.addEventListener('pointerdown', (e) => { + _undoLongPressActive = false; + _undoLongPressTimer = setTimeout(() => { + _undoLongPressActive = true; + showSnapshotListPopup(); + }, 500); + }); + + $undoBtn.addEventListener('pointerup', () => { + clearTimeout(_undoLongPressTimer); + }); + + $undoBtn.addEventListener('pointerleave', () => { + clearTimeout(_undoLongPressTimer); + }); + + // Prevent click from firing after long-press + $undoBtn.addEventListener('click', (e) => { + if (_undoLongPressActive) { + e.stopImmediatePropagation(); + _undoLongPressActive = false; + } + }, true); + + // Snapshot list popup + let $snapshotPopup = null; + + function showSnapshotListPopup() { + removeSnapshotPopup(); + + const snapshots = snapshotStack.list(); + if (snapshots.length === 0) return; + + $snapshotPopup = document.createElement('div'); + $snapshotPopup.className = 'phase2-snapshot-popup'; + + const header = document.createElement('div'); + header.className = 'phase2-snapshot-popup-header'; + header.textContent = 'Snapshot History'; + $snapshotPopup.appendChild(header); + + const list = document.createElement('div'); + list.className = 'phase2-snapshot-list'; + + // Show newest first + for (let i = snapshots.length - 1; i >= 0; i--) { + const snap = snapshots[i]; + const item = document.createElement('button'); + item.className = 'phase2-snapshot-item'; + const age = formatAge(snap.timestamp); + item.innerHTML = ` + ${escapeHtml(snap.tag)} + ${age} + `; + item.addEventListener('click', () => { + const state = snapshotStack.jumpTo(snap.index); + if (state) { + setWeights(state.weights); + setNoiseLevel(state.noiseLevel); + runInference(); + } + updateUndoBadge(); + removeSnapshotPopup(); + }); + list.appendChild(item); + } + + $snapshotPopup.appendChild(list); + document.body.appendChild($snapshotPopup); + + // Position near the undo button + const rect = $undoBtn.getBoundingClientRect(); + $snapshotPopup.style.left = `${rect.left}px`; + $snapshotPopup.style.bottom = `${window.innerHeight - rect.top + 8}px`; + + // Close on click outside + setTimeout(() => { + document.addEventListener('pointerdown', handlePopupOutsideClick); + }, 50); + } + + function handlePopupOutsideClick(e) { + if ($snapshotPopup && !$snapshotPopup.contains(e.target)) { + removeSnapshotPopup(); + } + } + + function removeSnapshotPopup() { + if ($snapshotPopup) { + $snapshotPopup.remove(); + $snapshotPopup = null; + document.removeEventListener('pointerdown', handlePopupOutsideClick); + } + } + + // Listen for snapshot events to update badge + document.addEventListener('snapshot:push', updateUndoBadge); + document.addEventListener('snapshot:pop', updateUndoBadge); + document.addEventListener('snapshot:jump', updateUndoBadge); + document.addEventListener('snapshot:clear', updateUndoBadge); + + // ==== 2. A/B COMPARE TOGGLE ==== + + const $abContainer = document.createElement('div'); + $abContainer.className = 'phase2-ab-container'; + $abContainer.innerHTML = ` + + + + + `; + + if ($rlButtons) { + $rlButtons.appendChild($abContainer); + } + + const $abCapture = $abContainer.querySelector('#phase2-ab-capture'); + const $abToggle = $abContainer.querySelector('#phase2-ab-toggle'); + const $abAccept = $abContainer.querySelector('#phase2-ab-accept'); + const $abRevert = $abContainer.querySelector('#phase2-ab-revert'); + + function updateABUI() { + const active = abCompare.active; + const side = abCompare.current; + + $abCapture.classList.toggle('hidden', active); + $abToggle.classList.toggle('hidden', !active); + $abAccept.classList.toggle('hidden', !active); + $abRevert.classList.toggle('hidden', !active); + + if (active) { + $abToggle.textContent = side === 'a' ? 'A' : 'B'; + $abToggle.classList.toggle('phase2-ab-side-a', side === 'a'); + $abToggle.classList.toggle('phase2-ab-side-b', side === 'b'); + } + } + + $abCapture.addEventListener('click', () => { + abCompare.captureA({ + weights: getWeights(), + noiseLevel: getNoiseLevel(), + }); + updateABUI(); + }); + + $abToggle.addEventListener('click', () => { + const state = abCompare.toggle({ + weights: getWeights(), + noiseLevel: getNoiseLevel(), + }); + if (state) { + setWeights(state.weights); + setNoiseLevel(state.noiseLevel); + runInference(); + } + updateABUI(); + }); + + $abAccept.addEventListener('click', () => { + abCompare.acceptB(); + updateABUI(); + }); + + $abRevert.addEventListener('click', () => { + const state = abCompare.revertToA(); + if (state) { + setWeights(state.weights); + setNoiseLevel(state.noiseLevel); + runInference(); + } + updateABUI(); + }); + + // Listen for AB events + document.addEventListener('ab:activate', updateABUI); + document.addEventListener('ab:toggle', updateABUI); + document.addEventListener('ab:deactivate', updateABUI); + + // ==== 3. REGION PIN (long-press on joy-map) ==== + + let _regionLongPressTimer = null; + + if (joyMapCanvas) { + joyMapCanvas.addEventListener('pointerdown', (e) => { + _regionLongPressTimer = setTimeout(() => { + // Pin the current zoom window as a region + const zoomWindow = getZoomWindow ? getZoomWindow() : null; + let region; + if (zoomWindow) { + region = { + x1: zoomWindow.x1, + y1: zoomWindow.y1, + x2: zoomWindow.x2, + y2: zoomWindow.y2, + }; + } else { + // No zoom — pin the full space (not very useful, but valid) + region = { x1: 0, y1: 0, x2: 1, y2: 1 }; + } + + // Snapshot before pinning + snapshotStack.push('before region pin', { + weights: getWeights(), + noiseLevel: getNoiseLevel(), + zoomLevel: getZoomLevel ? getZoomLevel() : 1.0, + }); + updateUndoBadge(); + + const data = getTrainingData ? getTrainingData() : { features: [], labels: [] }; + const pinId = regionPins.pin(region, data); + + if (pinId >= 0) { + // Flash feedback + joyMapCanvas.classList.add('phase2-pin-flash'); + setTimeout(() => joyMapCanvas.classList.remove('phase2-pin-flash'), 300); + + // Update joy-map overlay + syncJoyMapPins(); + } + }, 500); + }); + + joyMapCanvas.addEventListener('pointerup', () => { + clearTimeout(_regionLongPressTimer); + }); + + joyMapCanvas.addEventListener('pointerleave', () => { + clearTimeout(_regionLongPressTimer); + }); + + // Prevent context menu on long-press + joyMapCanvas.addEventListener('contextmenu', (e) => e.preventDefault()); + } + + // Keep joy-map overlay in sync when pins change + function syncJoyMapPins() { + document.dispatchEvent(new CustomEvent('regionpin:sync', { + detail: { regions: regionPins.getRegions() }, + })); + } + + document.addEventListener('regionpin:add', syncJoyMapPins); + document.addEventListener('regionpin:remove', syncJoyMapPins); + + // ==== 4. PARAMETER PIN (overlay on synth visualizer) ==== + + // Param pin state is tracked by paramPins (ParamPinManager). + // We add a click handler to the synth visualizer canvas to toggle pins. + // The SynthVisualizer's draw loop can check paramPins.isPinned(i) for rendering. + + if (synthVisualizer && synthVisualizer.canvas) { + // Double-tap on a param bar to toggle pin + let _lastParamTapTime = 0; + let _lastParamTapIndex = -1; + + synthVisualizer.canvas.addEventListener('pointerdown', (e) => { + const idx = synthVisualizer.hitTest(e.clientX, e.clientY); + if (idx < 0) return; + + const now = Date.now(); + if (now - _lastParamTapTime < 400 && idx === _lastParamTapIndex) { + // Double-tap: toggle pin + const newState = paramPins.toggle(idx); + // Visual feedback is handled by the draw loop checking paramPins + _lastParamTapTime = 0; + _lastParamTapIndex = -1; + } else { + _lastParamTapTime = now; + _lastParamTapIndex = idx; + } + }); + } + + // ==== PUBLIC API ==== + + /** + * Push a snapshot (called by app before destructive operations). + * @param {string} tag + */ + function pushSnapshot(tag) { + snapshotStack.push(tag, { + weights: getWeights(), + noiseLevel: getNoiseLevel(), + zoomLevel: getZoomLevel ? getZoomLevel() : 1.0, + }); + updateUndoBadge(); + } + + /** + * Cleanup all Phase 2 UI elements. + */ + function destroy() { + $undoBtn.remove(); + $abContainer.remove(); + removeSnapshotPopup(); + document.removeEventListener('snapshot:push', updateUndoBadge); + document.removeEventListener('snapshot:pop', updateUndoBadge); + document.removeEventListener('snapshot:jump', updateUndoBadge); + document.removeEventListener('snapshot:clear', updateUndoBadge); + document.removeEventListener('ab:activate', updateABUI); + document.removeEventListener('ab:toggle', updateABUI); + document.removeEventListener('ab:deactivate', updateABUI); + document.removeEventListener('regionpin:add', syncJoyMapPins); + document.removeEventListener('regionpin:remove', syncJoyMapPins); + const styleEl = document.getElementById('phase2-styles'); + if (styleEl) styleEl.remove(); + } + + return { + pushSnapshot, + updateUndoBadge, + syncJoyMapPins, + destroy, + }; +} + +// ---- Helpers ---- + +function formatAge(timestamp) { + const seconds = Math.floor((Date.now() - timestamp) / 1000); + if (seconds < 5) return 'just now'; + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + return `${Math.floor(minutes / 60)}h ago`; +} + +function escapeHtml(str) { + return str.replace(/&/g, '&').replace(//g, '>'); +} + +// ---- CSS ---- + +function injectPhase2Styles() { + if (document.getElementById('phase2-styles')) return; + + const style = document.createElement('style'); + style.id = 'phase2-styles'; + style.textContent = ` +/* ==== Phase 2: Undo Button ==== */ +.phase2-undo-btn { + position: relative; +} + +.phase2-undo-icon { + font-size: 18px; + line-height: 1; +} + +.phase2-undo-badge { + position: absolute; + top: -4px; + right: -4px; + min-width: 16px; + height: 16px; + border-radius: 8px; + background: var(--accent, #ff6a00); + color: #000; + font-size: 9px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + padding: 0 3px; + pointer-events: none; + transition: opacity 0.15s; +} + +.phase2-undo-badge.hidden { + opacity: 0; +} + +.phase2-undo-btn:disabled { + opacity: 0.3; + pointer-events: none; +} + +/* ==== Phase 2: Snapshot Popup ==== */ +.phase2-snapshot-popup { + position: fixed; + z-index: 100; + width: 240px; + max-height: 300px; + background: rgba(0, 0, 0, 0.92); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + overflow: hidden; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + font-family: 'JetBrains Mono', 'SF Mono', monospace; +} + +.phase2-snapshot-popup-header { + padding: 10px 12px 8px; + font-size: 10px; + font-weight: 600; + color: rgba(255, 255, 255, 0.6); + text-transform: uppercase; + letter-spacing: 0.8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.phase2-snapshot-list { + overflow-y: auto; + max-height: 250px; + -webkit-overflow-scrolling: touch; +} + +.phase2-snapshot-item { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 8px 12px; + border: none; + background: transparent; + color: rgba(255, 255, 255, 0.8); + font-family: inherit; + font-size: 11px; + cursor: pointer; + transition: background 0.1s; + text-align: left; +} + +.phase2-snapshot-item:hover { + background: rgba(255, 255, 255, 0.06); +} + +.phase2-snap-tag { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-right: 8px; +} + +.phase2-snap-age { + font-size: 9px; + color: rgba(255, 255, 255, 0.35); + white-space: nowrap; +} + +/* ==== Phase 2: A/B Compare ==== */ +.phase2-ab-container { + display: flex; + gap: 4px; + align-items: center; + margin-left: 6px; +} + +.phase2-ab-btn { + height: 36px; + min-width: 36px; + border-radius: 18px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: var(--glass-bg, rgba(13, 13, 13, 0.65)); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + color: rgba(255, 255, 255, 0.7); + font-family: 'JetBrains Mono', 'SF Mono', monospace; + font-size: 11px; + font-weight: 600; + cursor: pointer; + padding: 0 10px; + transition: all 0.15s; + white-space: nowrap; +} + +.phase2-ab-btn:hover { + border-color: rgba(255, 255, 255, 0.25); + color: white; +} + +.phase2-ab-btn.hidden { + display: none; +} + +/* A/B side indicators */ +.phase2-ab-side-a { + border-color: #4488ff; + color: #4488ff; + background: rgba(68, 136, 255, 0.12); +} + +.phase2-ab-side-b { + border-color: #ff8844; + color: #ff8844; + background: rgba(255, 136, 68, 0.12); +} + +.phase2-ab-capture { + border-color: rgba(68, 136, 255, 0.4); + color: rgba(68, 136, 255, 0.8); +} + +.phase2-ab-accept { + border-color: rgba(139, 195, 74, 0.4); + color: rgba(139, 195, 74, 0.9); + font-size: 10px; +} + +.phase2-ab-revert { + border-color: rgba(255, 152, 0, 0.4); + color: rgba(255, 152, 0, 0.9); + font-size: 10px; +} + +/* ==== Phase 2: Region Pin Flash ==== */ +.phase2-pin-flash { + animation: phase2-pin-flash-anim 0.3s ease-out; +} + +@keyframes phase2-pin-flash-anim { + 0% { box-shadow: 0 0 0 0 rgba(0, 188, 212, 0.5); } + 100% { box-shadow: 0 0 0 12px rgba(0, 188, 212, 0); } +} + +/* ==== Phase 2: Param Pin overlay (rendered on synth visualizer) ==== */ +/* Lock icon styling — drawn by SynthVisualizer, these are for any DOM overlays */ +.phase2-param-pinned-bar { + opacity: 0.5; +} + +/* ==== Responsive ==== */ +@media (max-width: 600px) { + .phase2-ab-container { + gap: 3px; + margin-left: 4px; + } + .phase2-ab-btn { + height: 32px; + min-width: 32px; + font-size: 10px; + padding: 0 8px; + } + .phase2-snapshot-popup { + width: 200px; + } +} +`; + document.head.appendChild(style); +} diff --git a/playground/js/ui/phase3-ui.js b/playground/js/ui/phase3-ui.js new file mode 100644 index 0000000..1dcebf5 --- /dev/null +++ b/playground/js/ui/phase3-ui.js @@ -0,0 +1,588 @@ +/** + * Phase 3 UI Integration — Auto-Explore button, Heatmap toggle, + * Pressure indicator, and settings drawer additions. + * + * Wires PressureFeedback, AutoExplore, and InputHeatmap into the + * immersive app's DOM and event flow. + * + * Usage: + * import { initPhase3UI } from './ui/phase3-ui.js'; + * const p3 = initPhase3UI({ + * onAutoExplore: ({ intensity }) => { ... }, + * getZoomLevel: () => pipeline.getZoomLevel(), + * }); + * // In animation loop: + * p3.tick(deltaTime); + * + * @module phase3-ui + */ + +import { PressureFeedback } from './pressure-feedback.js'; +import { AutoExplore } from './auto-explore.js'; +import { InputHeatmap } from './input-heatmap.js'; + +// ---- Style injection ---- + +const PHASE3_STYLES = ` +/* ---- Auto-Explore button ---- */ +.auto-explore-btn { + width: 56px; + height: 56px; + border-radius: 50%; + border: 2px solid rgba(52, 211, 153, 0.4); + background: rgba(13, 13, 13, 0.65); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + color: rgba(52, 211, 153, 0.7); + font-family: inherit; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.3px; + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + transition: all 0.2s ease; + position: relative; + overflow: visible; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; +} + +.auto-explore-btn:active { + transform: scale(0.9); +} + +.auto-explore-btn.active { + border-color: rgba(52, 211, 153, 0.7); + background: rgba(52, 211, 153, 0.15); + color: rgba(52, 211, 153, 1); + box-shadow: 0 0 16px rgba(52, 211, 153, 0.3); +} + +.auto-explore-btn.active .ae-icon { + animation: ae-wander 2s ease-in-out infinite; +} + +@keyframes ae-wander { + 0%, 100% { transform: translate(0, 0); } + 25% { transform: translate(2px, -1px); } + 50% { transform: translate(-1px, 2px); } + 75% { transform: translate(-2px, -1px); } +} + +/* Progress ring (SVG overlay) */ +.ae-progress-ring { + position: absolute; + inset: -3px; + width: calc(100% + 6px); + height: calc(100% + 6px); + pointer-events: none; + transform: rotate(-90deg); +} + +.ae-progress-ring circle { + fill: none; + stroke: rgba(52, 211, 153, 0.6); + stroke-width: 2; + stroke-dasharray: 175; + stroke-dashoffset: 175; + stroke-linecap: round; + transition: stroke-dashoffset 0.1s linear; +} + +/* ---- Heatmap toggle ---- */ +.heatmap-toggle { + position: absolute; + top: 4px; + right: 4px; + width: 24px; + height: 24px; + border-radius: 50%; + border: 1px solid rgba(255, 255, 255, 0.15); + background: rgba(13, 13, 13, 0.5); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + color: rgba(255, 255, 255, 0.4); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + z-index: 5; + transition: all 0.15s ease; + padding: 0; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; +} + +.heatmap-toggle:active { + transform: scale(0.85); +} + +.heatmap-toggle.active { + border-color: rgba(255, 200, 100, 0.5); + background: rgba(255, 200, 100, 0.15); + color: rgba(255, 200, 100, 0.9); +} + +.heatmap-toggle svg { + width: 14px; + height: 14px; +} + +/* Color mode label (appears briefly on mode switch) */ +.heatmap-mode-label { + position: absolute; + top: 30px; + right: 0; + background: rgba(13, 13, 13, 0.8); + color: rgba(255, 200, 100, 0.8); + font-size: 9px; + padding: 2px 6px; + border-radius: 4px; + white-space: nowrap; + pointer-events: none; + opacity: 0; + transition: opacity 0.2s ease; + z-index: 5; +} + +.heatmap-mode-label.visible { + opacity: 1; +} + +/* ---- Pressure indicator ---- */ +.pressure-indicator { + position: absolute; + bottom: -10px; + left: 50%; + transform: translateX(-50%); + width: 40px; + height: 4px; + border-radius: 2px; + background: rgba(255, 255, 255, 0.08); + overflow: hidden; + pointer-events: none; + opacity: 0; + transition: opacity 0.2s ease; +} + +.pressure-indicator.visible { + opacity: 1; +} + +.pressure-indicator-fill { + height: 100%; + width: 0%; + border-radius: 2px; + background: linear-gradient(90deg, rgba(255, 200, 100, 0.6), rgba(255, 106, 0, 0.8)); + transition: width 0.1s ease; +} + +/* ---- Settings drawer additions ---- */ +.cs-section[data-section="phase3"] .cs-section-header { + color: rgba(52, 211, 153, 0.8); +} +`; + +// ---- Eye icon SVG ---- +const EYE_SVG = ` + + +`; + +// ---- Wander icon SVG ---- +const WANDER_SVG = ` + + +`; + +/** + * Initialize Phase 3 UI. + * + * @param {object} options + * @param {function} options.onAutoExplore - callback({ intensity }) on auto-step + * @param {function} options.getZoomLevel - () => number (current zoom 0-1) + * @param {function} [options.inferFn] - (inputs) => outputs for heatmap + * @param {function} [options.getZoomWindow] - () => { x1, y1, x2, y2 } + * @returns {{ pressure, autoExplore, heatmap, tick, updateHeatmap, destroy, getConfig, setConfig }} + */ +export function initPhase3UI(options = {}) { + const { onAutoExplore, getZoomLevel, inferFn, getZoomWindow } = options; + + // ---- Instances ---- + const pressure = new PressureFeedback({ holdEnabled: true, pressureEnabled: false }); + const autoExplore = new AutoExplore(); + const heatmap = new InputHeatmap(); + + // Wire auto-explore callback + autoExplore.onExplore((data) => { + if (onAutoExplore) onAutoExplore(data); + }); + + // ---- Inject styles ---- + if (!document.getElementById('phase3-styles')) { + const style = document.createElement('style'); + style.id = 'phase3-styles'; + style.textContent = PHASE3_STYLES; + document.head.appendChild(style); + } + + // ---- Auto-Explore button ---- + const $rlButtons = document.getElementById('rl-buttons'); + const $autoBtn = document.createElement('button'); + $autoBtn.className = 'auto-explore-btn'; + $autoBtn.title = 'Auto-Explore: automated wandering'; + $autoBtn.innerHTML = ` + ${WANDER_SVG} + Auto + + + + `; + + if ($rlButtons) { + $rlButtons.appendChild($autoBtn); + } + + const $progressCircle = $autoBtn.querySelector('.ae-progress-ring circle'); + const circumference = 2 * Math.PI * 28; // r=28 + if ($progressCircle) { + $progressCircle.style.strokeDasharray = circumference; + $progressCircle.style.strokeDashoffset = circumference; + } + + $autoBtn.addEventListener('click', () => { + autoExplore.toggle(); + $autoBtn.classList.toggle('active', autoExplore.active); + }); + + // ---- Heatmap toggle (on joy-map) ---- + const $joystickContainer = document.querySelector('.joystick-container'); + let $heatmapToggle = null; + let $hmModeLabel = null; + let _hmLabelTimeout = null; + + if ($joystickContainer) { + // Ensure position context + if (getComputedStyle($joystickContainer).position === 'static') { + $joystickContainer.style.position = 'relative'; + } + + $heatmapToggle = document.createElement('button'); + $heatmapToggle.className = 'heatmap-toggle'; + $heatmapToggle.title = 'Toggle input heatmap'; + $heatmapToggle.innerHTML = EYE_SVG; + $joystickContainer.appendChild($heatmapToggle); + + $hmModeLabel = document.createElement('div'); + $hmModeLabel.className = 'heatmap-mode-label'; + $joystickContainer.appendChild($hmModeLabel); + + // Tap to toggle, long-press to cycle color mode + let _pressTimer = null; + let _didLongPress = false; + + $heatmapToggle.addEventListener('pointerdown', (e) => { + e.stopPropagation(); + _didLongPress = false; + _pressTimer = setTimeout(() => { + _didLongPress = true; + if (heatmap.enabled) { + const mode = heatmap.cycleColorMode(); + showModeLabel(mode); + // Trigger recompute + if (inferFn) { + heatmap.invalidate(); + const zw = getZoomWindow ? getZoomWindow() : undefined; + heatmap.update(inferFn, { zoomWindow: zw }); + } + } + }, 500); + }); + + $heatmapToggle.addEventListener('pointerup', (e) => { + e.stopPropagation(); + clearTimeout(_pressTimer); + if (!_didLongPress) { + heatmap.setEnabled(!heatmap.enabled); + $heatmapToggle.classList.toggle('active', heatmap.enabled); + if (heatmap.enabled && inferFn) { + heatmap.invalidate(); + const zw = getZoomWindow ? getZoomWindow() : undefined; + heatmap.update(inferFn, { zoomWindow: zw }); + } + } + }); + + $heatmapToggle.addEventListener('pointercancel', () => { + clearTimeout(_pressTimer); + }); + + // Prevent joy-map from capturing these events + $heatmapToggle.addEventListener('touchstart', (e) => e.stopPropagation(), { passive: true }); + } + + function showModeLabel(mode) { + if (!$hmModeLabel) return; + $hmModeLabel.textContent = mode; + $hmModeLabel.classList.add('visible'); + clearTimeout(_hmLabelTimeout); + _hmLabelTimeout = setTimeout(() => { + $hmModeLabel.classList.remove('visible'); + }, 1500); + } + + // ---- Pressure indicators (on RL buttons) ---- + const $thumbsUp = document.getElementById('btn-thumbsup'); + const $thumbsDown = document.getElementById('btn-thumbsdown'); + const pressureIndicators = []; + + for (const $btn of [$thumbsUp, $thumbsDown]) { + if (!$btn) continue; + // Ensure position context + if (getComputedStyle($btn).position === 'static') { + $btn.style.position = 'relative'; + } + + const $indicator = document.createElement('div'); + $indicator.className = 'pressure-indicator'; + $indicator.innerHTML = '
'; + $btn.appendChild($indicator); + pressureIndicators.push({ el: $indicator, fill: $indicator.querySelector('.pressure-indicator-fill'), btn: $btn }); + + // Wire pointer events for pressure tracking + $btn.addEventListener('pointerdown', (e) => pressure.onPointerDown(e)); + $btn.addEventListener('pointermove', (e) => pressure.onPointerMove(e)); + $btn.addEventListener('pointerup', (e) => { + pressure.onPointerUp(e); + $indicator.classList.remove('visible'); + }); + $btn.addEventListener('pointercancel', (e) => { + pressure.onPointerUp(e); + $indicator.classList.remove('visible'); + }); + } + + // ---- Settings drawer additions ---- + addDrawerSection(); + + // ---- Frame tick ---- + + /** + * Call once per animation frame. + * @param {number} deltaTime - seconds since last frame + */ + function tick(deltaTime) { + const zoomLevel = getZoomLevel ? getZoomLevel() : 1.0; + + // Auto-explore + autoExplore.tick(deltaTime, zoomLevel); + + // Update progress ring + if ($progressCircle && autoExplore.active) { + const progress = autoExplore.getProgress(); + const offset = circumference * (1 - progress); + $progressCircle.style.strokeDashoffset = offset; + } else if ($progressCircle) { + $progressCircle.style.strokeDashoffset = circumference; + } + + // Update pressure indicators + if (pressure.active && pressure.enabled) { + const intensity = pressure.getIntensity(); + for (const { el, fill } of pressureIndicators) { + el.classList.add('visible'); + fill.style.width = `${intensity * 100}%`; + } + } + } + + /** + * Trigger a heatmap update. Call after weight changes (train, randomize, moveWeights). + */ + function updateHeatmap() { + if (!heatmap.enabled || !inferFn) return; + const zw = getZoomWindow ? getZoomWindow() : undefined; + heatmap.update(inferFn, { zoomWindow: zw }); + } + + // ---- Settings drawer: Phase 3 section ---- + + function addDrawerSection() { + // Find the drawer body + const $drawerBody = document.querySelector('.cs-drawer-body'); + if (!$drawerBody) return; + + const section = document.createElement('div'); + section.className = 'cs-section'; + section.dataset.section = 'phase3'; + section.innerHTML = ` +
+ Exploration & Viz + +
+
+
+ +
+ + 2.0s +
+
+
+ +
+ + 0.50 +
+
+
+ + +
+
+ + +
+
+ +
+ + 2.0s +
+
+
+ +
+ + 16 +
+
+
+ +
+ + 0.55 +
+
+
+ `; + + $drawerBody.appendChild(section); + + // Wire collapse + const header = section.querySelector('.cs-section-header'); + if (header) { + header.addEventListener('click', () => section.classList.toggle('collapsed')); + } + + // Wire sliders + wireSlider(section, 'autoExploreInterval', (val) => { + autoExplore.setInterval(val); + return `${val.toFixed(1)}s`; + }); + + wireSlider(section, 'autoExploreIntensity', (val) => { + autoExplore.setIntensity(val); + return val.toFixed(2); + }); + + wireSlider(section, 'holdCurve', (val) => { + pressure.setHoldCurve(val); + return `${val.toFixed(1)}s`; + }); + + wireSlider(section, 'heatmapResolution', (val) => { + heatmap.setResolution(val); + return String(Math.round(val)); + }); + + wireSlider(section, 'heatmapOpacity', (val) => { + heatmap.setOpacity(val); + return val.toFixed(2); + }); + + // Wire toggles + wireToggle(section, 'pressureHold', (checked) => { + pressure.setEnabled(pressure.getPressureEnabled(), checked); + }); + + wireToggle(section, 'pressureFeedback', (checked) => { + pressure.setEnabled(checked, pressure.getHoldEnabled()); + }); + } + + function wireSlider(container, paramName, onChange) { + const row = container.querySelector(`[data-param="${paramName}"]`); + if (!row) return; + const slider = row.querySelector('input[type="range"]'); + const valueSpan = row.querySelector('.cs-param-value'); + if (!slider) return; + slider.addEventListener('input', () => { + const val = parseFloat(slider.value); + const display = onChange(val); + if (valueSpan && display) valueSpan.textContent = display; + // Dispatch controlsurface:change for consistency + document.dispatchEvent(new CustomEvent('controlsurface:change', { + detail: { _phase3: true, [paramName]: val }, + })); + }); + } + + function wireToggle(container, paramName, onChange) { + const row = container.querySelector(`[data-param="${paramName}"]`); + if (!row) return; + const checkbox = row.querySelector('input[type="checkbox"]'); + if (!checkbox) return; + checkbox.addEventListener('change', () => { + onChange(checkbox.checked); + }); + } + + // ---- Cleanup ---- + + function destroy() { + $autoBtn.remove(); + if ($heatmapToggle) $heatmapToggle.remove(); + if ($hmModeLabel) $hmModeLabel.remove(); + for (const { el } of pressureIndicators) el.remove(); + const $styles = document.getElementById('phase3-styles'); + if ($styles) $styles.remove(); + const $section = document.querySelector('.cs-section[data-section="phase3"]'); + if ($section) $section.remove(); + clearTimeout(_hmLabelTimeout); + } + + // ---- Serialization ---- + + function getConfig() { + return { + pressure: pressure.getConfig(), + autoExplore: autoExplore.getConfig(), + heatmap: heatmap.getConfig(), + }; + } + + function setConfig(config) { + if (config.pressure) pressure.setConfig(config.pressure); + if (config.autoExplore) autoExplore.setConfig(config.autoExplore); + if (config.heatmap) heatmap.setConfig(config.heatmap); + // Sync UI + $autoBtn.classList.toggle('active', autoExplore.active); + if ($heatmapToggle) $heatmapToggle.classList.toggle('active', heatmap.enabled); + } + + return { + pressure, + autoExplore, + heatmap, + tick, + updateHeatmap, + destroy, + getConfig, + setConfig, + }; +} diff --git a/playground/js/ui/phase4-ui.js b/playground/js/ui/phase4-ui.js new file mode 100644 index 0000000..e869df3 --- /dev/null +++ b/playground/js/ui/phase4-ui.js @@ -0,0 +1,583 @@ +/** + * Phase 4 UI — Output Pipeline + Visualization + Polish + * + * Integrates: + * - Output freeze button on floating bar + * - Weight health + gradient flow panel in settings drawer + * - Session preset UI in settings drawer + * - Wires output pipeline sliders to actual OutputPipeline instance + * + * Usage: + * import { initPhase4UI } from './ui/phase4-ui.js'; + * const phase4 = initPhase4UI({ outputPipeline, iml, controlSurface, ... }); + * + * @module phase4-ui + */ + +import { WeightHealthIndicator } from './weight-health.js'; +import { GradientFlowIndicator } from './gradient-flow.js'; +import { SessionPresetManager } from './session-presets.js'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const HEALTH_UPDATE_INTERVAL = 500; // ms between weight health updates + +// Snowflake SVG icon (for output freeze button) +const SNOWFLAKE_SVG = ` + + + + + + + + +`; + +// --------------------------------------------------------------------------- +// Main initialization +// --------------------------------------------------------------------------- + +/** + * Initialize Phase 4 UI components. + * + * @param {object} opts + * @param {import('./output-pipeline.js').OutputPipeline} opts.outputPipeline + * @param {object} opts.iml — WasmIML or IML instance + * @param {import('./control-surface.js').ControlSurface} opts.controlSurface + * @param {function} opts.getGroupOverrides — returns current groupOverrides array + * @param {function} opts.getActiveSynthPresetId — returns active synth preset id + * @param {import('./input-pipeline.js').InputPipeline} opts.inputPipeline + * @param {function} opts.onSessionLoad — callback(preset) when a session is loaded + * @returns {object} { weightHealth, gradientFlow, sessionManager, destroy } + */ +export function initPhase4UI(opts) { + const { + outputPipeline, + iml, + controlSurface, + getGroupOverrides, + getActiveSynthPresetId, + inputPipeline, + onSessionLoad, + } = opts; + + // ---- Weight Health ---- + const weightHealth = new WeightHealthIndicator(); + + // ---- Gradient Flow ---- + // layerSizes from the IML instance + const layerSizes = iml.layerSizes || [3, 32, 48, 64, 126]; + const gradientFlow = new GradientFlowIndicator(layerSizes); + + // ---- Session Presets ---- + const sessionManager = new SessionPresetManager(); + + // ---- 1. Output Freeze Button ---- + const freezeBtn = createFreezeButton(outputPipeline); + + // ---- 2. Network Health Panel (in settings drawer) ---- + const healthPanel = createHealthPanel(weightHealth, gradientFlow); + + // ---- 3. Session Preset UI (in settings drawer) ---- + const sessionUI = createSessionUI(sessionManager, { + controlSurface, + outputPipeline, + inputPipeline, + getGroupOverrides, + getActiveSynthPresetId, + onSessionLoad, + }); + + // ---- 4. Wire output pipeline sliders ---- + wireOutputPipelineSliders(outputPipeline, controlSurface); + + // ---- Periodic weight health updates ---- + let healthInterval = setInterval(() => { + updateWeightHealth(iml, weightHealth, healthPanel.canvas, healthPanel.ctx, gradientFlow, healthPanel.gradCanvas, healthPanel.gradCtx); + }, HEALTH_UPDATE_INTERVAL); + + // ---- Inject styles ---- + injectPhase4Styles(); + + // ---- Cleanup ---- + function destroy() { + clearInterval(healthInterval); + freezeBtn.remove(); + healthPanel.container.remove(); + sessionUI.container.remove(); + const styleEl = document.getElementById('phase4-styles'); + if (styleEl) styleEl.remove(); + } + + return { + weightHealth, + gradientFlow, + sessionManager, + freezeBtn, + /** + * Call before iml.train() / iml.trainAsync() to capture weight snapshot. + */ + captureBeforeTrain() { + const weights = getFlatWeights(iml); + if (weights) gradientFlow.captureBeforeTrain(weights); + }, + /** + * Call after training completes to capture weight snapshot and update gradient flow. + */ + captureAfterTrain() { + const weights = getFlatWeights(iml); + if (weights) { + gradientFlow.captureAfterTrain(weights); + // Immediately refresh the health panel + updateWeightHealth(iml, weightHealth, healthPanel.canvas, healthPanel.ctx, gradientFlow, healthPanel.gradCanvas, healthPanel.gradCtx); + } + }, + destroy, + }; +} + +// --------------------------------------------------------------------------- +// Freeze Button +// --------------------------------------------------------------------------- + +function createFreezeButton(outputPipeline) { + const $floatingBar = document.getElementById('floating-bar'); + if (!$floatingBar) { + console.warn('[Phase4] floating-bar not found'); + return document.createElement('button'); + } + + const btn = document.createElement('button'); + btn.className = 'float-btn p4-freeze-btn'; + btn.id = 'btn-freeze-output'; + btn.title = 'Freeze output (synth/visual stays locked)'; + btn.innerHTML = SNOWFLAKE_SVG; + + btn.addEventListener('click', () => { + const frozen = !outputPipeline.isFrozen(); + outputPipeline.setFrozen(frozen); + btn.classList.toggle('active', frozen); + btn.title = frozen ? 'Unfreeze output' : 'Freeze output'; + }); + + // Insert before the chevron + const $chevron = document.getElementById('chevron-btn'); + if ($chevron) { + $floatingBar.insertBefore(btn, $chevron); + } else { + $floatingBar.appendChild(btn); + } + + return btn; +} + +// --------------------------------------------------------------------------- +// Network Health Panel +// --------------------------------------------------------------------------- + +function createHealthPanel(weightHealth, gradientFlow) { + // Find the settings drawer body + const drawerBody = document.querySelector('.cs-drawer-body'); + if (!drawerBody) { + // Drawer not yet created; defer by creating a detached element + const container = document.createElement('div'); + return { + container, + canvas: document.createElement('canvas'), + ctx: null, + gradCanvas: document.createElement('canvas'), + gradCtx: null, + }; + } + + const container = document.createElement('div'); + container.className = 'cs-section p4-health-section'; + container.innerHTML = ` +
+ Network Health + +
+
+
+
Weight Health
+ +
+
+
Gradient Flow
+ +
+
+ `; + + // Collapse toggle + const header = container.querySelector('.cs-section-header'); + header.addEventListener('click', () => { + container.classList.toggle('collapsed'); + }); + + // Append to drawer body (at the bottom) + drawerBody.appendChild(container); + + const canvas = container.querySelector('.p4-health-canvas'); + const gradCanvas = container.querySelector('.p4-grad-canvas'); + + return { + container, + canvas, + ctx: canvas.getContext('2d'), + gradCanvas, + gradCtx: gradCanvas.getContext('2d'), + }; +} + +// --------------------------------------------------------------------------- +// Session Preset UI +// --------------------------------------------------------------------------- + +function createSessionUI(sessionManager, deps) { + const drawerBody = document.querySelector('.cs-drawer-body'); + if (!drawerBody) { + return { container: document.createElement('div') }; + } + + const container = document.createElement('div'); + container.className = 'p4-session-section'; + + const presets = sessionManager.list(); + + container.innerHTML = ` +
Session Presets
+
+ + + +
+
+ + + +
+ `; + + // Insert at the top of the drawer body (before the preset row) + const firstChild = drawerBody.firstChild; + drawerBody.insertBefore(container, firstChild); + + // Wire interactions + const $select = container.querySelector('#p4-session-select'); + const $loadBtn = container.querySelector('#p4-session-load'); + const $deleteBtn = container.querySelector('#p4-session-delete'); + const $nameInput = container.querySelector('#p4-session-name'); + const $saveBtn = container.querySelector('#p4-session-save'); + const $shareBtn = container.querySelector('#p4-session-share'); + + function refreshDropdown() { + const list = sessionManager.list(); + $select.innerHTML = `` + + list.map(p => ``).join(''); + } + + function getCurrentState() { + return { + controlSurface: deps.controlSurface ? deps.controlSurface.getState() : null, + synthPresetId: deps.getActiveSynthPresetId ? deps.getActiveSynthPresetId() : null, + groupOverrides: deps.getGroupOverrides ? deps.getGroupOverrides() : null, + inputPipeline: deps.inputPipeline ? deps.inputPipeline.getConfig() : null, + outputPipeline: deps.outputPipeline ? deps.outputPipeline.getConfig() : null, + }; + } + + $saveBtn.addEventListener('click', () => { + const name = $nameInput.value.trim(); + if (!name) return; + sessionManager.save(name, getCurrentState()); + $nameInput.value = ''; + refreshDropdown(); + $select.value = name; + }); + + $loadBtn.addEventListener('click', () => { + const name = $select.value; + if (!name) return; + const preset = sessionManager.load(name); + if (preset && deps.onSessionLoad) { + deps.onSessionLoad(preset); + } + }); + + $deleteBtn.addEventListener('click', () => { + const name = $select.value; + if (!name) return; + sessionManager.delete(name); + refreshDropdown(); + }); + + $shareBtn.addEventListener('click', () => { + const state = getCurrentState(); + const preset = sessionManager.capture('share', state); + const params = sessionManager.toURL(preset); + const url = new URL(window.location.href); + // Clear existing session params + for (const key of ['sp', 'cs', 'op', 'co']) { + url.searchParams.delete(key); + } + // Append session params + const sessionParams = new URLSearchParams(params); + for (const [k, v] of sessionParams) { + url.searchParams.set(k, v); + } + navigator.clipboard.writeText(url.toString()).then(() => { + $shareBtn.textContent = 'Copied!'; + setTimeout(() => { $shareBtn.textContent = 'URL'; }, 1500); + }).catch(() => { + $shareBtn.textContent = 'Error'; + setTimeout(() => { $shareBtn.textContent = 'URL'; }, 1500); + }); + }); + + return { container, refreshDropdown }; +} + +// --------------------------------------------------------------------------- +// Wire Output Pipeline Sliders +// --------------------------------------------------------------------------- + +/** + * Listen for controlsurface:change events and pipe output-related params + * into the OutputPipeline instance. + */ +function wireOutputPipelineSliders(outputPipeline, controlSurface) { + document.addEventListener('controlsurface:change', (e) => { + const p = e.detail; + + if (p.outputSmoothing != null) { + outputPipeline.setSmoothing(p.outputSmoothing); + } + if (p.outputSlewRate != null) { + outputPipeline.setSlewRate(p.outputSlewRate); + } + if (p.globalCurve != null) { + outputPipeline.setGlobalCurve(p.globalCurve); + } + // Note: 'tame' is handled at groupOverrides init time (baked into min/max), + // not as a runtime pipeline stage. + }); +} + +// --------------------------------------------------------------------------- +// Weight Health Update +// --------------------------------------------------------------------------- + +function getFlatWeights(iml) { + // WasmIML has _getFlatWeights() + if (typeof iml._getFlatWeights === 'function') { + return iml._getFlatWeights(); + } + // JS IML: flatten from mlp.getWeights() structure + if (iml.mlp && typeof iml.mlp.getWeights === 'function') { + const structured = iml.mlp.getWeights(); + const flat = []; + for (const layer of structured) { + for (const node of layer) { + flat.push(...node.weights, node.bias); + } + } + return new Float32Array(flat); + } + return null; +} + +function updateWeightHealth(iml, weightHealth, canvas, ctx, gradientFlow, gradCanvas, gradCtx) { + const weights = getFlatWeights(iml); + if (weights) { + weightHealth.update(weights); + } + + if (ctx && canvas) { + ctx.clearRect(0, 0, canvas.width, canvas.height); + weightHealth.draw(ctx, 0, 0, canvas.width, canvas.height); + } + + if (gradCtx && gradCanvas) { + gradCtx.clearRect(0, 0, gradCanvas.width, gradCanvas.height); + gradientFlow.draw(gradCtx, 0, 0, gradCanvas.width, gradCanvas.height); + } +} + +// --------------------------------------------------------------------------- +// CSS injection +// --------------------------------------------------------------------------- + +function injectPhase4Styles() { + if (document.getElementById('phase4-styles')) return; + + const style = document.createElement('style'); + style.id = 'phase4-styles'; + style.textContent = ` +/* ---- Phase 4: Freeze Button ---- */ +.p4-freeze-btn { + display: flex; + align-items: center; + justify-content: center; + padding: 0 6px; + min-width: 28px; + color: rgba(255, 255, 255, 0.5); + transition: color 0.15s, background 0.15s; +} + +.p4-freeze-btn.active { + color: #5bcefa; + background: rgba(91, 206, 250, 0.12); + border-color: rgba(91, 206, 250, 0.3); +} + +.p4-freeze-btn:hover { + color: #5bcefa; +} + +/* ---- Phase 4: Network Health Section ---- */ +.p4-health-section { + border-top: 1px solid rgba(255, 255, 255, 0.06); + margin-top: 4px; +} + +.p4-health-row { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; +} + +.p4-health-label { + font-size: 10px; + color: rgba(255, 255, 255, 0.45); + min-width: 70px; + flex-shrink: 0; +} + +.p4-health-canvas { + border-radius: 3px; + flex: 1; + max-width: 140px; + height: 32px; +} + +.p4-grad-canvas { + border-radius: 3px; + flex: 1; + max-width: 140px; + height: 48px; +} + +/* ---- Phase 4: Session Presets ---- */ +.p4-session-section { + padding: 10px 16px 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.p4-session-header { + font-size: 10px; + font-weight: 600; + color: rgba(255, 255, 255, 0.6); + text-transform: uppercase; + letter-spacing: 0.7px; + margin-bottom: 6px; +} + +.p4-session-row { + display: flex; + align-items: center; + gap: 4px; + margin-bottom: 4px; +} + +.p4-session-select { + flex: 1; + min-width: 0; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + color: rgba(255, 255, 255, 0.8); + font-size: 11px; + padding: 4px 6px; + font-family: inherit; +} + +.p4-session-name { + flex: 1; + min-width: 0; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + color: rgba(255, 255, 255, 0.8); + font-size: 11px; + padding: 4px 6px; + font-family: inherit; + outline: none; +} + +.p4-session-name::placeholder { + color: rgba(255, 255, 255, 0.25); +} + +.p4-session-name:focus { + border-color: var(--accent, #ff6a00); +} + +.p4-session-btn { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + color: rgba(255, 255, 255, 0.6); + font-size: 10px; + padding: 4px 8px; + cursor: pointer; + font-family: inherit; + white-space: nowrap; + transition: all 0.12s; +} + +.p4-session-btn:hover { + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.9); +} + +.p4-session-save { + color: var(--accent, #ff6a00); + border-color: rgba(255, 106, 0, 0.2); +} + +.p4-session-save:hover { + background: rgba(255, 106, 0, 0.12); +} + +.p4-session-del { + color: rgba(255, 80, 80, 0.7); + font-size: 14px; + padding: 2px 6px; + line-height: 1; +} + +.p4-session-del:hover { + color: rgba(255, 80, 80, 1); + background: rgba(255, 80, 80, 0.1); +} + +.p4-session-share { + color: rgba(150, 180, 255, 0.7); + border-color: rgba(150, 180, 255, 0.15); +} + +.p4-session-share:hover { + background: rgba(150, 180, 255, 0.1); + color: rgba(150, 180, 255, 1); +} +`; + document.head.appendChild(style); +} diff --git a/playground/js/ui/pressure-feedback.js b/playground/js/ui/pressure-feedback.js new file mode 100644 index 0000000..c9b6978 --- /dev/null +++ b/playground/js/ui/pressure-feedback.js @@ -0,0 +1,194 @@ +/** + * Pressure / Hold-Duration Feedback + * + * Combines touch pressure (Force Touch, stylus) and hold duration into a + * single intensity multiplier (0-1). Applied to noise growth on thumbs-down + * and train intensity on thumbs-up. + * + * When disabled, getIntensity() returns 1.0 (passthrough). + * + * @module pressure-feedback + */ + +export class PressureFeedback { + /** + * @param {object} [options] + * @param {boolean} [options.pressureEnabled=false] - use event.pressure + * @param {boolean} [options.holdEnabled=true] - ramp intensity by hold duration + * @param {number} [options.holdCurve=2] - seconds to reach max intensity + * @param {number} [options.minIntensity=0.15] - floor when active (quick tap) + */ + constructor(options = {}) { + this._pressureEnabled = options.pressureEnabled ?? false; + this._holdEnabled = options.holdEnabled ?? true; + this._holdCurve = Math.max(0.1, options.holdCurve ?? 2); + this._minIntensity = Math.max(0, Math.min(1, options.minIntensity ?? 0.15)); + + // Runtime state + this._active = false; // pointer is currently down + this._startTime = 0; // timestamp of pointerdown (ms) + this._currentPressure = 0; // latest event.pressure (0-1) + this._holdIntensity = 0; // computed hold intensity (0-1) + } + + // ---- Pointer event handlers ---- + + /** + * Call on pointerdown. Starts tracking hold duration and pressure. + * @param {PointerEvent} event + */ + onPointerDown(event) { + this._active = true; + this._startTime = performance.now(); + this._currentPressure = this._extractPressure(event); + this._holdIntensity = 0; + } + + /** + * Call on pointermove while pointer is down. + * Updates pressure reading. + * @param {PointerEvent} event + */ + onPointerMove(event) { + if (!this._active) return; + this._currentPressure = this._extractPressure(event); + this._updateHold(); + } + + /** + * Call on pointerup / pointercancel. + */ + onPointerUp(_event) { + this.reset(); + } + + // ---- Queries ---- + + /** + * Get current intensity multiplier. + * + * - If both pressure and hold are disabled, returns 1.0 (passthrough). + * - If active and at least one source is enabled, returns max(pressure, holdIntensity). + * - If not active (no pointer down), returns the minIntensity floor. + * + * @returns {number} 0-1 + */ + getIntensity() { + if (!this._pressureEnabled && !this._holdEnabled) return 1.0; + if (!this._active) return this._minIntensity; + + this._updateHold(); + + let intensity = 0; + + if (this._pressureEnabled) { + intensity = Math.max(intensity, this._currentPressure); + } + + if (this._holdEnabled) { + intensity = Math.max(intensity, this._holdIntensity); + } + + // Ensure at least minIntensity when active + return Math.max(this._minIntensity, intensity); + } + + /** + * Whether a pointer is currently held down. + * @returns {boolean} + */ + get active() { + return this._active; + } + + /** + * Whether any source is enabled. + * @returns {boolean} + */ + get enabled() { + return this._pressureEnabled || this._holdEnabled; + } + + // ---- Configuration ---- + + /** + * Toggle pressure and hold-duration sources independently. + * @param {boolean} pressure + * @param {boolean} holdDuration + */ + setEnabled(pressure, holdDuration) { + this._pressureEnabled = !!pressure; + this._holdEnabled = !!holdDuration; + } + + /** + * Set the hold-duration ramp time. + * @param {number} seconds - time to reach max intensity (0.1-10) + */ + setHoldCurve(seconds) { + this._holdCurve = Math.max(0.1, Math.min(10, seconds)); + } + + /** @returns {number} */ + getHoldCurve() { return this._holdCurve; } + /** @returns {boolean} */ + getPressureEnabled() { return this._pressureEnabled; } + /** @returns {boolean} */ + getHoldEnabled() { return this._holdEnabled; } + + /** + * Reset state (called on pointer up). + */ + reset() { + this._active = false; + this._startTime = 0; + this._currentPressure = 0; + this._holdIntensity = 0; + } + + // ---- Serialization ---- + + getConfig() { + return { + pressureEnabled: this._pressureEnabled, + holdEnabled: this._holdEnabled, + holdCurve: this._holdCurve, + minIntensity: this._minIntensity, + }; + } + + setConfig(config) { + if (config.pressureEnabled != null) this._pressureEnabled = !!config.pressureEnabled; + if (config.holdEnabled != null) this._holdEnabled = !!config.holdEnabled; + if (config.holdCurve != null) this.setHoldCurve(config.holdCurve); + if (config.minIntensity != null) this._minIntensity = Math.max(0, Math.min(1, config.minIntensity)); + } + + // ---- Internal ---- + + /** + * Extract pressure from a pointer event. + * Falls back to 1.0 on devices without pressure support. + * A pressure of 0 on non-touch events means "no pressure data" — treat as 1.0. + */ + _extractPressure(event) { + if (!event) return 1.0; + const p = event.pressure; + // Most mouse/touch events report 0.5 for button-down without pressure sensing. + // A true 0 means "no pressure data" (mouse hover, etc.) — fall back to 1.0. + if (typeof p !== 'number' || p === 0) return 1.0; + return Math.max(0, Math.min(1, p)); + } + + /** + * Update hold intensity based on elapsed time since pointer down. + * Uses an ease-in curve (quadratic). + */ + _updateHold() { + if (!this._active || !this._holdEnabled) return; + const elapsed = (performance.now() - this._startTime) / 1000; // seconds + const t = Math.min(1, elapsed / this._holdCurve); + // Ease-in (quadratic) — slow start, fast finish + this._holdIntensity = t * t; + } +} diff --git a/playground/js/ui/region-pin.js b/playground/js/ui/region-pin.js new file mode 100644 index 0000000..b57d164 --- /dev/null +++ b/playground/js/ui/region-pin.js @@ -0,0 +1,205 @@ +// Region Pin Manager — Pin rectangular input-space regions (Approach A: Example Pinning) +// +// Pinned regions capture training examples whose inputs fall within a rectangle +// in the 2D input space. These pinned examples: +// - Are always included in training with high weight +// - Cannot be evicted by FIFO example rotation +// - Anchor behavior in that region while the user explores elsewhere +// +// Up to 5 pinned regions, each with a distinct color from the palette. +// +// Usage: +// import { RegionPinManager } from './region-pin.js'; +// const regionPins = new RegionPinManager(); +// const id = regionPins.pin({ x1: 0.2, y1: 0.3, x2: 0.5, y2: 0.7 }, examples); +// const pinned = regionPins.getPinnedExamples(); // { features: [...], labels: [...] } + +const MAX_PINS = 5; + +// Distinct colors for pinned region overlays (used for joy-map rendering) +const PIN_PALETTE = [ + 'rgba(0, 188, 212, 0.15)', // teal + 'rgba(156, 39, 176, 0.15)', // purple + 'rgba(255, 193, 7, 0.15)', // amber + 'rgba(233, 30, 99, 0.15)', // rose + 'rgba(139, 195, 74, 0.15)', // lime +]; + +let _nextId = 1; + +export class RegionPinManager { + constructor() { + // Array of { id, region: {x1,y1,x2,y2}, color, examples: { features: [[]], labels: [[]] } } + this._pins = []; + } + + // ---- Public API ---- + + /** + * Pin a region. Captures examples whose inputs fall within the region. + * @param {{ x1: number, y1: number, x2: number, y2: number }} region + * Normalized [0,1] coordinates. x1 < x2, y1 < y2. + * @param {{ features: Array>, labels: Array> }} examples + * The current full training dataset to filter from. + * @returns {number} Pin ID, or -1 if at capacity. + */ + pin(region, examples) { + if (this._pins.length >= MAX_PINS) { + console.warn('[RegionPin] Max pins reached (' + MAX_PINS + ')'); + return -1; + } + + // Normalize region bounds + const r = { + x1: Math.min(region.x1, region.x2), + y1: Math.min(region.y1, region.y2), + x2: Math.max(region.x1, region.x2), + y2: Math.max(region.y1, region.y2), + }; + + // Filter examples whose inputs fall within the region + const pinnedFeatures = []; + const pinnedLabels = []; + if (examples && examples.features) { + for (let i = 0; i < examples.features.length; i++) { + const f = examples.features[i]; + // Inputs are the first 2 elements (joystick X, Y) — without bias + const x = f[0]; + const y = f.length > 1 ? f[1] : 0.5; + if (x >= r.x1 && x <= r.x2 && y >= r.y1 && y <= r.y2) { + pinnedFeatures.push([...f]); + pinnedLabels.push([...(examples.labels[i] || [])]); + } + } + } + + const id = _nextId++; + const colorIndex = this._pins.length % PIN_PALETTE.length; + + this._pins.push({ + id, + region: r, + color: PIN_PALETTE[colorIndex], + examples: { + features: pinnedFeatures, + labels: pinnedLabels, + }, + }); + + this._dispatch('regionpin:add', { + id, + region: r, + exampleCount: pinnedFeatures.length, + total: this._pins.length, + }); + + return id; + } + + /** + * Remove a pinned region by ID. + * @param {number} pinId + * @returns {boolean} True if removed. + */ + unpin(pinId) { + const idx = this._pins.findIndex(p => p.id === pinId); + if (idx < 0) return false; + this._pins.splice(idx, 1); + this._dispatch('regionpin:remove', { id: pinId, total: this._pins.length }); + return true; + } + + /** + * Get all pinned regions for joy-map overlay rendering. + * @returns {Array<{ id: number, region: {x1,y1,x2,y2}, color: string, exampleCount: number }>} + */ + getRegions() { + return this._pins.map(p => ({ + id: p.id, + region: { ...p.region }, + color: p.color, + exampleCount: p.examples.features.length, + })); + } + + /** + * Get all pinned examples merged together (for training). + * These should always be included in the training set alongside regular examples. + * @returns {{ features: Array>, labels: Array> }} + */ + getPinnedExamples() { + const features = []; + const labels = []; + for (const pin of this._pins) { + for (let i = 0; i < pin.examples.features.length; i++) { + features.push(pin.examples.features[i]); + labels.push(pin.examples.labels[i]); + } + } + return { features, labels }; + } + + /** + * Check if a 2D input point falls within any pinned region. + * @param {Array} inputs [x, y, ...] + * @returns {boolean} + */ + isInPinnedRegion(inputs) { + const x = inputs[0]; + const y = inputs.length > 1 ? inputs[1] : 0.5; + for (const pin of this._pins) { + const r = pin.region; + if (x >= r.x1 && x <= r.x2 && y >= r.y1 && y <= r.y2) { + return true; + } + } + return false; + } + + /** + * Current number of pinned regions. + * @returns {number} + */ + get count() { + return this._pins.length; + } + + // ---- Serialization ---- + + getState() { + return { + pins: this._pins.map(p => ({ + id: p.id, + region: { ...p.region }, + color: p.color, + examples: { + features: p.examples.features.map(f => [...f]), + labels: p.examples.labels.map(l => [...l]), + }, + })), + }; + } + + setState(saved) { + if (!saved || !Array.isArray(saved.pins)) return; + this._pins = saved.pins.map(p => ({ + id: p.id || _nextId++, + region: p.region || { x1: 0, y1: 0, x2: 1, y2: 1 }, + color: p.color || PIN_PALETTE[0], + examples: { + features: (p.examples?.features || []).map(f => [...f]), + labels: (p.examples?.labels || []).map(l => [...l]), + }, + })); + // Advance ID counter past any restored IDs + for (const p of this._pins) { + if (p.id >= _nextId) _nextId = p.id + 1; + } + } + + // ---- Internal ---- + + _dispatch(type, detail) { + document.dispatchEvent(new CustomEvent(type, { detail })); + } +} diff --git a/playground/js/ui/session-presets.js b/playground/js/ui/session-presets.js new file mode 100644 index 0000000..99e005f --- /dev/null +++ b/playground/js/ui/session-presets.js @@ -0,0 +1,243 @@ +/** + * Session Preset Manager — bundles control surface state + synth preset into + * a single loadable configuration. + * + * Presets are stored in localStorage under a dedicated key, separate from the + * main app state. + * + * @module session-presets + */ + +const STORAGE_KEY = 'nisps-session-presets'; + +// --------------------------------------------------------------------------- +// URL encoding helpers +// --------------------------------------------------------------------------- + +/** + * Encode a compact session state into URL search params. + * Only encodes the most important state for sharing. + */ +function encodeToURL(preset) { + const params = new URLSearchParams(); + + // Synth preset + if (preset.synthPresetId) { + params.set('sp', preset.synthPresetId); + } + + // Compound axes (3 values, comma-separated) + if (preset.controlSurface?.axes) { + const a = preset.controlSurface.axes; + params.set('cs', [ + (a.boldness ?? 0.5).toFixed(2), + (a.memory ?? 0.5).toFixed(2), + (a.precision ?? 0.3).toFixed(2), + ].join(',')); + } + + // Output pipeline (only non-default values) + if (preset.outputPipeline) { + const op = preset.outputPipeline; + const parts = []; + if (op.globalCurve != null && op.globalCurve !== 1.0) parts.push(`gc=${op.globalCurve.toFixed(2)}`); + if (op.smoothing != null && op.smoothing !== 0) parts.push(`sm=${op.smoothing.toFixed(2)}`); + if (op.slewRate != null && op.slewRate !== 1.0) parts.push(`sl=${op.slewRate.toFixed(3)}`); + if (parts.length > 0) params.set('op', parts.join(',')); + } + + // Key control surface overrides (compact: name=value pairs) + if (preset.controlSurface?.offsets) { + const offsets = preset.controlSurface.offsets; + const keys = Object.keys(offsets); + if (keys.length > 0 && keys.length <= 10) { + // Only encode up to 10 overrides to keep URL reasonable + const pairs = keys.slice(0, 10).map(k => { + const v = offsets[k]; + return `${k}:${typeof v === 'number' ? v.toFixed(3) : v}`; + }); + params.set('co', pairs.join(',')); + } + } + + return params.toString(); +} + +/** + * Decode session state from URL search params. + * Returns a partial preset object. + */ +function decodeFromURL(urlParams) { + const preset = {}; + + // Synth preset + const sp = urlParams.get('sp'); + if (sp) preset.synthPresetId = sp; + + // Compound axes + const cs = urlParams.get('cs'); + if (cs) { + const [b, m, p] = cs.split(',').map(Number); + preset.controlSurface = { + axes: { + boldness: isNaN(b) ? 0.5 : b, + memory: isNaN(m) ? 0.5 : m, + precision: isNaN(p) ? 0.3 : p, + }, + offsets: {}, + }; + } + + // Control surface overrides + const co = urlParams.get('co'); + if (co && preset.controlSurface) { + for (const pair of co.split(',')) { + const colonIdx = pair.indexOf(':'); + if (colonIdx > 0) { + const key = pair.substring(0, colonIdx); + const valStr = pair.substring(colonIdx + 1); + const num = Number(valStr); + preset.controlSurface.offsets[key] = isNaN(num) ? valStr : num; + } + } + } + + // Output pipeline + const op = urlParams.get('op'); + if (op) { + const pipeline = {}; + for (const part of op.split(',')) { + const [k, v] = part.split('='); + const num = Number(v); + if (!isNaN(num)) { + if (k === 'gc') pipeline.globalCurve = num; + else if (k === 'sm') pipeline.smoothing = num; + else if (k === 'sl') pipeline.slewRate = num; + } + } + preset.outputPipeline = pipeline; + } + + return preset; +} + +// --------------------------------------------------------------------------- +// SessionPresetManager +// --------------------------------------------------------------------------- + +export class SessionPresetManager { + constructor() { + this._presets = this._loadFromStorage(); + } + + /** + * Capture a snapshot of the current full session state. + * + * @param {string} name — user-facing name for this preset + * @param {object} state — current state from various subsystems + * @param {object} state.controlSurface — result of ControlSurface.getState() + * @param {string} state.synthPresetId — active synth preset ID + * @param {Array} state.groupOverrides — current group overrides + * @param {object} state.inputPipeline — result of InputPipeline.getConfig() + * @param {object} state.outputPipeline — result of OutputPipeline.getConfig() + * @returns {object} the captured preset + */ + capture(name, state) { + return { + name, + controlSurface: state.controlSurface || null, + synthPresetId: state.synthPresetId || null, + groupOverrides: state.groupOverrides || null, + inputPipeline: state.inputPipeline || null, + outputPipeline: state.outputPipeline || null, + timestamp: Date.now(), + }; + } + + /** + * Save current state as a named session preset to localStorage. + * + * @param {string} name + * @param {object} state — same as capture() state param + */ + save(name, state) { + const preset = this.capture(name, state); + this._presets[name] = preset; + this._saveToStorage(); + return preset; + } + + /** + * Load a named session preset. + * @param {string} name + * @returns {object|null} + */ + load(name) { + return this._presets[name] || null; + } + + /** + * List all saved session presets. + * @returns {Array<{name: string, timestamp: number}>} + */ + list() { + return Object.values(this._presets) + .map(p => ({ name: p.name, timestamp: p.timestamp })) + .sort((a, b) => b.timestamp - a.timestamp); + } + + /** + * Delete a named session preset. + * @param {string} name + * @returns {boolean} true if deleted + */ + delete(name) { + if (name in this._presets) { + delete this._presets[name]; + this._saveToStorage(); + return true; + } + return false; + } + + /** + * Encode a preset into URL search params for sharing. + * @param {object} preset + * @returns {string} URL search string (without leading ?) + */ + toURL(preset) { + return encodeToURL(preset); + } + + /** + * Decode a preset from URL search params. + * @param {URLSearchParams} urlParams + * @returns {object} partial preset + */ + fromURL(urlParams) { + return decodeFromURL(urlParams); + } + + // ----------------------------------------------------------------------- + // Internal + // ----------------------------------------------------------------------- + + _loadFromStorage() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return {}; + return JSON.parse(raw); + } catch (e) { + console.warn('[SessionPresets] Failed to load:', e); + return {}; + } + } + + _saveToStorage() { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(this._presets)); + } catch (e) { + console.warn('[SessionPresets] Failed to save:', e); + } + } +} diff --git a/playground/js/ui/snapshot-stack.js b/playground/js/ui/snapshot-stack.js new file mode 100644 index 0000000..492588f --- /dev/null +++ b/playground/js/ui/snapshot-stack.js @@ -0,0 +1,169 @@ +// Snapshot Stack — Multi-level undo for weight states +// +// A ring buffer of weight snapshots tagged with context (e.g. "before thumbs-down", +// "before train"). Auto-snapshot triggers in the app push snapshots before destructive +// operations. Users can undo (pop), peek, list, or jump to any snapshot. +// +// Each snapshot stores: +// - weights: the full flat weight array (Array or Float32Array) +// - noiseLevel: the RL noise level at time of snapshot +// - zoomLevel: the input pipeline zoom level (optional) +// - tag: human-readable label for UI display +// - timestamp: Date.now() when captured +// +// Usage: +// import { SnapshotStack } from './snapshot-stack.js'; +// const stack = new SnapshotStack(20); +// stack.push('before thumbs-down', { weights, noiseLevel, zoomLevel }); +// const prev = stack.pop(); // undo + +export class SnapshotStack { + /** + * @param {number} maxSnapshots Ring buffer capacity. Oldest evicted when full. + */ + constructor(maxSnapshots = 20) { + this._max = maxSnapshots; + this._stack = []; // Array of { tag, timestamp, state: { weights, noiseLevel, zoomLevel } } + } + + // ---- Core operations ---- + + /** + * Push a snapshot onto the stack. + * @param {string} tag Human-readable label (e.g. "before thumbs-down") + * @param {{ weights: Array|Float32Array, noiseLevel: number, zoomLevel?: number }} state + */ + push(tag, state) { + const snapshot = { + tag, + timestamp: Date.now(), + state: { + weights: Array.isArray(state.weights) + ? [...state.weights] + : Array.from(state.weights), + noiseLevel: state.noiseLevel ?? 0, + zoomLevel: state.zoomLevel ?? 1.0, + }, + }; + + this._stack.push(snapshot); + + // Ring buffer eviction: drop oldest if over capacity + if (this._stack.length > this._max) { + this._stack.shift(); + } + + this._dispatch('snapshot:push', { tag, depth: this.depth }); + } + + /** + * Pop the most recent snapshot (undo). + * @returns {{ weights: Array, noiseLevel: number, zoomLevel: number }|null} + */ + pop() { + if (this._stack.length === 0) return null; + const snapshot = this._stack.pop(); + this._dispatch('snapshot:pop', { tag: snapshot.tag, depth: this.depth }); + return snapshot.state; + } + + /** + * Peek at the most recent snapshot without removing it. + * @returns {{ weights: Array, noiseLevel: number, zoomLevel: number }|null} + */ + peek() { + if (this._stack.length === 0) return null; + return this._stack[this._stack.length - 1].state; + } + + /** + * List all snapshots (newest last) for UI display. + * @returns {Array<{ tag: string, timestamp: number, index: number }>} + */ + list() { + return this._stack.map((snap, i) => ({ + tag: snap.tag, + timestamp: snap.timestamp, + index: i, + })); + } + + /** + * Jump to a specific snapshot by index. Removes everything above it. + * @param {number} index + * @returns {{ weights: Array, noiseLevel: number, zoomLevel: number }|null} + */ + jumpTo(index) { + if (index < 0 || index >= this._stack.length) return null; + const snapshot = this._stack[index]; + // Truncate: keep entries 0..index (inclusive), remove the rest + this._stack = this._stack.slice(0, index); + this._dispatch('snapshot:jump', { tag: snapshot.tag, depth: this.depth }); + return snapshot.state; + } + + /** + * Current stack depth. + * @returns {number} + */ + get depth() { + return this._stack.length; + } + + /** + * Clear all snapshots. + */ + clear() { + this._stack = []; + this._dispatch('snapshot:clear', { depth: 0 }); + } + + // ---- Serialization ---- + + /** + * Serialize for localStorage persistence. + */ + getState() { + return { + max: this._max, + snapshots: this._stack.map(s => ({ + tag: s.tag, + timestamp: s.timestamp, + state: { + weights: s.state.weights, + noiseLevel: s.state.noiseLevel, + zoomLevel: s.state.zoomLevel, + }, + })), + }; + } + + /** + * Restore from serialized state. + */ + setState(saved) { + if (!saved) return; + if (typeof saved.max === 'number') this._max = saved.max; + if (Array.isArray(saved.snapshots)) { + this._stack = saved.snapshots.map(s => ({ + tag: s.tag || 'restored', + timestamp: s.timestamp || Date.now(), + state: { + weights: s.state?.weights || [], + noiseLevel: s.state?.noiseLevel ?? 0, + zoomLevel: s.state?.zoomLevel ?? 1.0, + }, + })); + // Trim to capacity + while (this._stack.length > this._max) { + this._stack.shift(); + } + } + } + + // ---- Internal ---- + + _dispatch(type, detail) { + document.dispatchEvent(new CustomEvent(type, { detail })); + } +} diff --git a/playground/js/ui/weight-health.js b/playground/js/ui/weight-health.js new file mode 100644 index 0000000..0426555 --- /dev/null +++ b/playground/js/ui/weight-health.js @@ -0,0 +1,218 @@ +/** + * Weight Health Indicator — ambient indicator showing network weight statistics. + * + * Analyzes a flat weight array and produces health status + compact visualization. + * Call update() periodically (not every frame) with the current flat weights. + * + * @module weight-health + */ + +// --------------------------------------------------------------------------- +// Thresholds +// --------------------------------------------------------------------------- + +/** Weight magnitude below this is considered "dead" */ +const DEAD_THRESHOLD = 0.01; + +/** Weight magnitude above this drives sigmoid >99% saturated */ +const SATURATING_THRESHOLD = 3.0; + +/** If more than this fraction of weights are dead, status = 'dead' */ +const DEAD_FRACTION_LIMIT = 0.30; + +/** If more than this fraction of weights are saturating, status = 'saturating' */ +const SATURATED_FRACTION_LIMIT = 0.40; + +/** Number of histogram bins for weight magnitude distribution */ +const HISTOGRAM_BINS = 10; + +/** Histogram bin range: [0, MAX_HISTOGRAM_MAG] */ +const MAX_HISTOGRAM_MAG = 5.0; + +// --------------------------------------------------------------------------- +// Colors +// --------------------------------------------------------------------------- + +const COLOR_HEALTHY = { r: 100, g: 200, b: 120 }; // calm green +const COLOR_SATURATING = { r: 255, g: 120, b: 40 }; // hot orange +const COLOR_DEAD = { r: 120, g: 120, b: 120 }; // dim gray + +// --------------------------------------------------------------------------- +// WeightHealthIndicator +// --------------------------------------------------------------------------- + +export class WeightHealthIndicator { + constructor() { + this._status = null; + this._pulsePhase = 0; + } + + /** + * Update from current weights. Call periodically (e.g. every 500ms). + * + * @param {number[]|Float32Array} weightsArray — flat array of all network weights + */ + update(weightsArray) { + if (!weightsArray || weightsArray.length === 0) { + this._status = null; + return; + } + + const n = weightsArray.length; + let sumMag = 0; + let maxMag = 0; + let deadCount = 0; + let saturatedCount = 0; + + const histogram = new Array(HISTOGRAM_BINS).fill(0); + const binWidth = MAX_HISTOGRAM_MAG / HISTOGRAM_BINS; + + for (let i = 0; i < n; i++) { + const mag = Math.abs(weightsArray[i]); + sumMag += mag; + if (mag > maxMag) maxMag = mag; + + if (mag < DEAD_THRESHOLD) deadCount++; + if (mag > SATURATING_THRESHOLD) saturatedCount++; + + const bin = Math.min(Math.floor(mag / binWidth), HISTOGRAM_BINS - 1); + histogram[bin]++; + } + + const meanMagnitude = sumMag / n; + const deadFraction = deadCount / n; + const saturatedFraction = saturatedCount / n; + + // Normalize histogram to fractions + for (let i = 0; i < HISTOGRAM_BINS; i++) { + histogram[i] /= n; + } + + let health; + if (deadFraction > DEAD_FRACTION_LIMIT) { + health = 'dead'; + } else if (saturatedFraction > SATURATED_FRACTION_LIMIT) { + health = 'saturating'; + } else { + health = 'healthy'; + } + + this._status = { + health, + meanMagnitude, + maxMagnitude: maxMag, + deadFraction, + saturatedFraction, + histogram, + }; + } + + /** + * Get the current health status. + * @returns {object|null} Status object or null if never updated. + */ + getStatus() { + return this._status; + } + + /** + * Draw a compact visual indicator (ambient glow bar). + * + * @param {CanvasRenderingContext2D} ctx + * @param {number} x — left edge + * @param {number} y — top edge + * @param {number} width — total width + * @param {number} height — total height + */ + draw(ctx, x, y, width, height) { + if (!this._status) { + // No data — draw a dim placeholder + ctx.fillStyle = 'rgba(60, 60, 60, 0.4)'; + ctx.fillRect(x, y, width, height); + return; + } + + const { health, deadFraction, saturatedFraction, histogram } = this._status; + + // Advance pulse phase + this._pulsePhase += 0.04; + + // Determine base color + glow intensity + let color; + let glowAlpha; + let pulseAmount = 0; + + if (health === 'healthy') { + color = COLOR_HEALTHY; + glowAlpha = 0.6 + 0.15 * Math.sin(this._pulsePhase * 0.3); // gentle breathing + pulseAmount = 0; + } else if (health === 'saturating') { + color = COLOR_SATURATING; + glowAlpha = 0.7; + pulseAmount = 0.25 * (0.5 + 0.5 * Math.sin(this._pulsePhase * 1.5)); // pulsing + } else { + // dead + color = COLOR_DEAD; + glowAlpha = 0.3; + pulseAmount = 0; + } + + const alpha = glowAlpha + pulseAmount; + + // Background + ctx.fillStyle = 'rgba(20, 20, 20, 0.6)'; + ctx.fillRect(x, y, width, height); + + // Draw mini histogram bars + const barWidth = width / HISTOGRAM_BINS; + const maxBinVal = Math.max(...histogram, 0.01); + + for (let i = 0; i < HISTOGRAM_BINS; i++) { + const barH = (histogram[i] / maxBinVal) * height; + const bx = x + i * barWidth; + const by = y + height - barH; + + // Color gradient: first bins (low magnitude) lean toward dead color, + // last bins (high magnitude) lean toward saturating color + const t = i / (HISTOGRAM_BINS - 1); + let r, g, b; + if (t < 0.3) { + // Low magnitude — blend dead/healthy + const lt = t / 0.3; + r = COLOR_DEAD.r + (color.r - COLOR_DEAD.r) * lt; + g = COLOR_DEAD.g + (color.g - COLOR_DEAD.g) * lt; + b = COLOR_DEAD.b + (color.b - COLOR_DEAD.b) * lt; + } else if (t > 0.7) { + // High magnitude — blend toward saturating + const lt = (t - 0.7) / 0.3; + r = color.r + (COLOR_SATURATING.r - color.r) * lt; + g = color.g + (COLOR_SATURATING.g - color.g) * lt; + b = color.b + (COLOR_SATURATING.b - color.b) * lt; + } else { + r = color.r; + g = color.g; + b = color.b; + } + + ctx.fillStyle = `rgba(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)}, ${alpha.toFixed(2)})`; + ctx.fillRect(bx, by, barWidth - 1, barH); + } + + // Top glow line + ctx.fillStyle = `rgba(${color.r}, ${color.g}, ${color.b}, ${(alpha * 0.8).toFixed(2)})`; + ctx.fillRect(x, y, width, 1); + + // Status label + ctx.font = '8px monospace'; + ctx.fillStyle = `rgba(${color.r}, ${color.g}, ${color.b}, ${(alpha * 0.9).toFixed(2)})`; + ctx.textBaseline = 'top'; + ctx.textAlign = 'left'; + + const label = health === 'healthy' + ? `W: ok` + : health === 'saturating' + ? `W: sat ${(saturatedFraction * 100).toFixed(0)}%` + : `W: dead ${(deadFraction * 100).toFixed(0)}%`; + ctx.fillText(label, x + 2, y + 1); + } +}