feat(playground): implement Phases 2-4 of control surface spec
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.
This commit is contained in:
parent
73eeaac0cc
commit
1f21494dee
21 changed files with 4617 additions and 37 deletions
30
CLAUDE.md
30
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
153
playground/js/ui/ab-compare.js
Normal file
153
playground/js/ui/ab-compare.js
Normal file
|
|
@ -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 }));
|
||||
}
|
||||
}
|
||||
160
playground/js/ui/auto-explore.js
Normal file
160
playground/js/ui/auto-explore.js
Normal file
|
|
@ -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;
|
||||
}
|
||||
261
playground/js/ui/gradient-flow.js
Normal file
261
playground/js/ui/gradient-flow.js
Normal file
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
382
playground/js/ui/input-heatmap.js
Normal file
382
playground/js/ui/input-heatmap.js
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 || [];
|
||||
}
|
||||
|
|
|
|||
312
playground/js/ui/output-pipeline.js
Normal file
312
playground/js/ui/output-pipeline.js
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
151
playground/js/ui/param-pin.js
Normal file
151
playground/js/ui/param-pin.js
Normal file
|
|
@ -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 }));
|
||||
}
|
||||
}
|
||||
626
playground/js/ui/phase2-ui.js
Normal file
626
playground/js/ui/phase2-ui.js
Normal file
|
|
@ -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<number> (flat weights)
|
||||
setWeights, // (weights: Array<number>) => 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 = `
|
||||
<span class="rl-icon phase2-undo-icon">↩</span>
|
||||
<span class="phase2-undo-badge" id="phase2-undo-badge">0</span>
|
||||
`;
|
||||
|
||||
// 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 = `
|
||||
<span class="phase2-snap-tag">${escapeHtml(snap.tag)}</span>
|
||||
<span class="phase2-snap-age">${age}</span>
|
||||
`;
|
||||
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 = `
|
||||
<button class="phase2-ab-btn phase2-ab-capture" id="phase2-ab-capture" title="Capture A (reference)">A</button>
|
||||
<button class="phase2-ab-btn phase2-ab-toggle hidden" id="phase2-ab-toggle" title="Toggle A/B">A/B</button>
|
||||
<button class="phase2-ab-btn phase2-ab-accept hidden" id="phase2-ab-accept" title="Accept B (keep current)">Accept B</button>
|
||||
<button class="phase2-ab-btn phase2-ab-revert hidden" id="phase2-ab-revert" title="Revert to A">Revert A</button>
|
||||
`;
|
||||
|
||||
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, '<').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);
|
||||
}
|
||||
588
playground/js/ui/phase3-ui.js
Normal file
588
playground/js/ui/phase3-ui.js
Normal file
|
|
@ -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 = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M1 8s2.5-5 7-5 7 5 7 5-2.5 5-7 5-7-5-7-5z"/>
|
||||
<circle cx="8" cy="8" r="2"/>
|
||||
</svg>`;
|
||||
|
||||
// ---- Wander icon SVG ----
|
||||
const WANDER_SVG = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="ae-icon">
|
||||
<path d="M3 12c2-3 4 1 5-2s3-5 5-3"/>
|
||||
<circle cx="3" cy="12" r="1.5" fill="currentColor"/>
|
||||
</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}
|
||||
<span style="font-size:9px;line-height:1">Auto</span>
|
||||
<svg class="ae-progress-ring" viewBox="0 0 62 62">
|
||||
<circle cx="31" cy="31" r="28"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
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 = '<div class="pressure-indicator-fill"></div>';
|
||||
$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 = `
|
||||
<div class="cs-section-header">
|
||||
<span>Exploration & Viz</span>
|
||||
<span class="cs-section-chevron">▼</span>
|
||||
</div>
|
||||
<div class="cs-section-body">
|
||||
<div class="cs-param-row" data-param="autoExploreInterval">
|
||||
<label class="cs-param-label">Auto Interval</label>
|
||||
<div class="cs-slider-row">
|
||||
<input type="range" class="cs-slider" min="0.5" max="10" step="0.5" value="2">
|
||||
<span class="cs-param-value">2.0s</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cs-param-row" data-param="autoExploreIntensity">
|
||||
<label class="cs-param-label">Auto Intensity</label>
|
||||
<div class="cs-slider-row">
|
||||
<input type="range" class="cs-slider" min="0.1" max="1" step="0.05" value="0.5">
|
||||
<span class="cs-param-value">0.50</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cs-param-row" data-param="pressureHold">
|
||||
<label class="cs-param-label">Hold Feedback</label>
|
||||
<input type="checkbox" class="cs-toggle" checked>
|
||||
</div>
|
||||
<div class="cs-param-row" data-param="pressureFeedback">
|
||||
<label class="cs-param-label">Pressure FB</label>
|
||||
<input type="checkbox" class="cs-toggle">
|
||||
</div>
|
||||
<div class="cs-param-row" data-param="holdCurve">
|
||||
<label class="cs-param-label">Hold Curve</label>
|
||||
<div class="cs-slider-row">
|
||||
<input type="range" class="cs-slider" min="0.5" max="5" step="0.1" value="2">
|
||||
<span class="cs-param-value">2.0s</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cs-param-row" data-param="heatmapResolution">
|
||||
<label class="cs-param-label">Heatmap Res</label>
|
||||
<div class="cs-slider-row">
|
||||
<input type="range" class="cs-slider" min="4" max="32" step="4" value="16">
|
||||
<span class="cs-param-value">16</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cs-param-row" data-param="heatmapOpacity">
|
||||
<label class="cs-param-label">Heatmap Opacity</label>
|
||||
<div class="cs-slider-row">
|
||||
<input type="range" class="cs-slider" min="0.1" max="1" step="0.05" value="0.55">
|
||||
<span class="cs-param-value">0.55</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
$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,
|
||||
};
|
||||
}
|
||||
583
playground/js/ui/phase4-ui.js
Normal file
583
playground/js/ui/phase4-ui.js
Normal file
|
|
@ -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 = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="7" y1="1" x2="7" y2="13"/>
|
||||
<line x1="1" y1="7" x2="13" y2="7"/>
|
||||
<line x1="3" y1="3" x2="11" y2="11"/>
|
||||
<line x1="11" y1="3" x2="3" y2="11"/>
|
||||
<line x1="7" y1="1" x2="5.5" y2="2.5"/>
|
||||
<line x1="7" y1="1" x2="8.5" y2="2.5"/>
|
||||
<line x1="7" y1="13" x2="5.5" y2="11.5"/>
|
||||
<line x1="7" y1="13" x2="8.5" y2="11.5"/>
|
||||
</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 = `
|
||||
<div class="cs-section-header">
|
||||
<span>Network Health</span>
|
||||
<span class="cs-section-chevron">▼</span>
|
||||
</div>
|
||||
<div class="cs-section-body">
|
||||
<div class="p4-health-row">
|
||||
<div class="p4-health-label">Weight Health</div>
|
||||
<canvas class="p4-health-canvas" width="120" height="32"></canvas>
|
||||
</div>
|
||||
<div class="p4-health-row">
|
||||
<div class="p4-health-label">Gradient Flow</div>
|
||||
<canvas class="p4-grad-canvas" width="120" height="48"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 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 = `
|
||||
<div class="p4-session-header">Session Presets</div>
|
||||
<div class="p4-session-row">
|
||||
<select class="p4-session-select" id="p4-session-select">
|
||||
<option value="">-- load session --</option>
|
||||
${presets.map(p => `<option value="${p.name}">${p.name}</option>`).join('')}
|
||||
</select>
|
||||
<button class="p4-session-btn p4-session-load" id="p4-session-load" title="Load selected session">Load</button>
|
||||
<button class="p4-session-btn p4-session-del" id="p4-session-delete" title="Delete selected session">×</button>
|
||||
</div>
|
||||
<div class="p4-session-row">
|
||||
<input type="text" class="p4-session-name" id="p4-session-name" placeholder="Session name..." maxlength="40">
|
||||
<button class="p4-session-btn p4-session-save" id="p4-session-save" title="Save current session">Save</button>
|
||||
<button class="p4-session-btn p4-session-share" id="p4-session-share" title="Copy URL to clipboard">URL</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 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 = `<option value="">-- load session --</option>` +
|
||||
list.map(p => `<option value="${p.name}">${p.name}</option>`).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);
|
||||
}
|
||||
194
playground/js/ui/pressure-feedback.js
Normal file
194
playground/js/ui/pressure-feedback.js
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
205
playground/js/ui/region-pin.js
Normal file
205
playground/js/ui/region-pin.js
Normal file
|
|
@ -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<Array<number>>, labels: Array<Array<number>> }} 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<Array<number>>, labels: Array<Array<number>> }}
|
||||
*/
|
||||
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<number>} 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 }));
|
||||
}
|
||||
}
|
||||
243
playground/js/ui/session-presets.js
Normal file
243
playground/js/ui/session-presets.js
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
169
playground/js/ui/snapshot-stack.js
Normal file
169
playground/js/ui/snapshot-stack.js
Normal file
|
|
@ -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<number> 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 }));
|
||||
}
|
||||
}
|
||||
218
playground/js/ui/weight-health.js
Normal file
218
playground/js/ui/weight-health.js
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue