diff --git a/CLAUDE.md b/CLAUDE.md
index b641d4c..6226ba7 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -36,7 +36,7 @@ The `playground/` directory contains a browser-based interactive demo of the NIS
- **Serve statically**: `cd playground && python3 -m http.server`
- **Mobile-first**: designed for touch/foldable phone use
-Key files: `js/nisps/` (ML core port), `js/ui/` (visualizer, joystick, controls), `js/synth/` (C15 bridge, param map, arpeggiator), `js/app.js` (wiring).
+Key files: `js/nisps/` (ML core port), `js/ui/` (visualizer, joystick, controls, input pipeline, control surface), `js/synth/` (C15 bridge, param map, arpeggiator), `js/a-app.js` (immersive app wiring).
### URL Parameters
@@ -92,6 +92,35 @@ Presets (`js/synth/presets.js`) control which parameters the ML engine can modif
Presets use `curve` values to bias parameter distributions (< 0.5 = spend more time low, > 0.5 = bias high) without clamping extremes. Users can tweak any preset via the group drawer after loading.
+### Control Surface (Phase 1)
+
+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`:
+
+| 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. |
+
+**Compound Axes** — each controls 4-6 underlying parameters via interpolation tables:
+
+- **Boldness** (Caution ↔ Bold): input zoom, noise cap, noise growth, learning rate, weight decay, noise distribution
+- **Memory** (Amnesia ↔ Elephant): max examples, example decay, weight decay, noise decay, convergence threshold
+- **Precision** (Raw ↔ Precise): input curve, deadzone, smoothing, slew rate, momentum-zoom mode
+
+When a user manually overrides an individual param, the offset from the axis-derived value persists as the axis moves (like a trim pot on a mixing desk). Double-tap an axis to re-link all params.
+
+**Input Pipeline** — sits between physical joystick and MLP. Key feature: **zoom** narrows the effective input window around an anchor point (`effective = anchor + (raw - 0.5) * zoom_level`). Zoom-at-zero freezes input. Three anchor modes: auto (anchor follows current position when zoom changes), sticky (explicit anchor), center (always 0.5).
+
+**Control Presets**: Default, First Touch, Jazz Hands, Sculptor, Improviser, Microscope. These set compound axis positions — they don't include network weights or synth preset selection.
+
+**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).
+
## Build System
This is an Arduino project targeting Raspberry Pi Pico. Build and upload using Arduino IDE or arduino-cli with the earlephilhower/pico board package.
diff --git a/playground/README.md b/playground/README.md
index 993ef09..ee7e472 100644
--- a/playground/README.md
+++ b/playground/README.md
@@ -34,20 +34,56 @@ The 126 synth parameters (`js/synth/param-map.js`) cover all sonically meaningfu
## UI controls
-- **Output mode tabs** (side panel): switch between Visual and Synth modes.
-- **Expand / Collapse** button on the canvas: makes the visual surface nearly full-screen and compresses lower controls into a minimal view.
-- **Presets**: quick demo mappings (`Calm/Chaos`, `Rainbow`, `Vortex`) — visual mode only.
+- **Output mode tabs** (floating bar): switch between Visual and Synth modes.
+- **Expand / Collapse** chevron: expands bottom sheet with training controls, synth settings, advanced param sliders.
+- **Synth presets**: tiered presets (Beginner → Expert) control which of the 126 params the ML engine can modify.
- **Help** (`?`) overlay: in-app usage guide.
- **Follow mode**: double-click joystick in RL mode to toggle no-hold interaction.
- **Keyboard in Follow mode (RL)**: `2` = thumbs up, `1` = thumbs down.
-- **Gamepad in RL**: `RB` = thumbs up, `LB` = thumbs down.
+- **Gamepad in RL**: `RB` = thumbs up, `LB` = thumbs down, `A` = train, `X` = randomize, `B` = clear.
+
+## Control Surface
+
+The immersive app has a control surface system (Phase 1 of `SPEC-controls.md`) for tuning how exploration and learning feel.
+
+**Compound axes** on the floating bar (3 sliders: Bold, Mem, Prec):
+
+| Axis | Controls | Low end | High end |
+|------|----------|---------|----------|
+| **Boldness** | Input zoom, noise cap, noise growth, LR, weight decay | Cautious: small changes, heavy regularisation | Bold: full range, explosive exploration, fast learning |
+| **Memory** | Max examples, example decay, noise decay, convergence | Amnesia: only last few interactions matter | Elephant: every example sacred, stable mapping |
+| **Precision** | Input curve, deadzone, smoothing, slew rate, momentum | Raw: 1:1 with physical movement, twitchy | Precise: heavy shaping, deadzones, smooth |
+
+**Settings drawer** (gear icon, bottom-right): individual param overrides for Input, Training, Exploration, and Output sections. Manual overrides persist as offsets when compound axes move (trim-pot model). Double-tap an axis to re-link all params.
+
+**Control presets**: Default, First Touch, Jazz Hands, Sculptor, Improviser, Microscope.
+
+**Input pipeline**: joystick input is processed through deadzone → zoom → curve → smoothing → momentum-as-zoom before reaching the MLP. Zoom narrows the effective input window around an anchor. Zoom-at-zero freezes input.
+
+**Enhanced joy-map**: zoom minimap with adaptive grid (4×4 → 32×32), vanishing trail with tap-to-return, dual noise rings (zoom + noise level).
## Files
+### Original app (index.html)
- `index.html` - page structure.
- `css/style.css` - layout and visual styling.
- `js/app.js` - app wiring and interaction logic.
-- `js/ui/` - visualizer, joystick, controls, parameter display.
-- `js/nisps/` - JavaScript MLP + IML core.
-- `js/synth/` - C15 WASM bridge, parameter map, arpeggiator.
+
+### Immersive app (a-immersive.html)
+- `a-immersive.html` - fullscreen immersive UI.
+- `css/a-immersive.css` - immersive layout and styling.
+- `js/a-app.js` - immersive app wiring, state management, persistence.
+
+### Shared modules
+- `js/nisps/` - JavaScript MLP + IML core (also WASM variant).
+- `js/ui/visualizer.js` - flow-field particle system (Canvas2D).
+- `js/ui/joystick.js` - virtual joystick component.
+- `js/ui/gamepad.js` - gamepad input handling.
+- `js/ui/hand-tracker.js` - MediaPipe hand tracking (14 features).
+- `js/ui/input-pipeline.js` - input processing pipeline (zoom, deadzone, curve, smoothing, momentum).
+- `js/ui/control-surface.js` - compound axes, override resolution, control presets.
+- `js/ui/control-surface-ui.js` - settings drawer and floating bar axis sliders.
+- `js/ui/joy-map-enhanced.js` - zoom minimap, vanishing trail, dual noise rings.
+- `js/synth/` - C15 WASM bridge, parameter map, presets, arpeggiator.
- `c15/` - C15 engine WASM binary and parameter definitions.
+- `SPEC-controls.md` - comprehensive control surface spec (4 phases).
diff --git a/playground/SPEC-controls.md b/playground/SPEC-controls.md
index f6b3238..deededa 100644
--- a/playground/SPEC-controls.md
+++ b/playground/SPEC-controls.md
@@ -644,36 +644,34 @@ Control presets define a complete control surface state (all parameters from Par
## Part 10: Implementation Priority
-### Phase 1 — Core Zoom + Compound Axes
-1. Input zoom with anchor modes and minimap visualization
-2. Zoom-at-zero freeze behavior
-3. Vanishing trail with tap-to-return
-4. Compound axis sliders (Boldness, Memory, Precision) wired to underlying params
-5. Spread, LR, noise cap promoted to panel sliders
-6. Resolve UI location (Part 11.5) — prototype Option E (hybrid)
+### Phase 1 — Core Zoom + Compound Axes ✅ IMPLEMENTED
+1. ✅ Input zoom with anchor modes and minimap visualization — `js/ui/input-pipeline.js`, `js/ui/joy-map-enhanced.js`
+2. ✅ Zoom-at-zero freeze behavior — `InputPipeline.isFrozen()`, frozen overlay in joy-map
+3. ✅ Vanishing trail with tap-to-return — Catmull-Rom spline, ring buffer, 5s duration, zoom-width encoding
+4. ✅ Compound axis sliders (Boldness, Memory, Precision) wired to underlying params — `js/ui/control-surface.js`
+5. ✅ Spread, LR, noise cap promoted to panel sliders — settings drawer with all params from Parts 2-6
+6. ✅ Resolve UI location (Part 11.5) — Option E (hybrid): axes on floating bar, overrides in gear drawer
+7. ✅ (bonus) Input curve, deadzone, smoothing, momentum-zoom — all implemented in pipeline, exposed in drawer
+8. ✅ (bonus) Zoom-aware feedback scaling — thumbs-down noise scales by zoom level
+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
-11. Zoom-aware feedback scaling
### Phase 3 — Input Refinement + Exploration
-12. Input curve and deadzone controls
-13. Input smoothing (especially for hand tracking)
-14. Momentum-as-zoom toggle
-15. Pressure/hold-duration feedback
-16. Auto-Explore mode
-17. Input space heatmap on joy-map
+11. Pressure/hold-duration feedback
+12. Auto-Explore mode
+13. Input space heatmap on joy-map
### Phase 4 — Output, Persistence + Polish
-18. Output smoothing, slew rate, and freeze output gate
-19. Weight health indicator + gradient flow
-20. Control presets (save/load/built-ins) + persistence (localStorage + URL sharing)
-21. Session presets (control + synth bundled)
-22. Engine configuration panel
-23. Compound axis override resolution (Part 11.1) — prototype offset approach
+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
---
diff --git a/playground/js/a-app.js b/playground/js/a-app.js
index 7b4e2ca..ac0c228 100644
--- a/playground/js/a-app.js
+++ b/playground/js/a-app.js
@@ -6,6 +6,9 @@ import { FlowFieldVisualizer } from './ui/visualizer.js';
import { C15Bridge } from './synth/c15-bridge.js';
import { Arpeggiator } from './synth/arpeggiator.js';
import { MIDIInput } from './synth/midi-input.js';
+import { InputPipeline } from './ui/input-pipeline.js';
+import { initControlSurfaceUI } from './ui/control-surface-ui.js';
+import { JoyMapEnhanced } from './ui/joy-map-enhanced.js';
// ---- ShapeSeq (feature-flagged, enable with ?shapeseq=1) ----
const ENABLE_SHAPESEQ = new URLSearchParams(window.location.search).get('shapeseq') === '1';
@@ -107,6 +110,13 @@ let spreadLevel = 0.6;
let noiseLevel = 0.05;
const rlExplorationDecay = 0.97;
+// Control surface (Phase 1)
+let inputPipeline = null;
+let controlSurface = null;
+let joyMapEnhanced = null;
+let _lastFrameTime = 0;
+let _lastPipeX = 0.5, _lastPipeY = 0.5; // cached pipeline output for getCurrentInputs()
+
// Joystick state
let joyX = 0.5;
let joyY = 0.5;
@@ -798,6 +808,11 @@ async function init() {
window.addEventListener('resize', onResize);
onResize();
+ // ---- Control Surface (Phase 1) — init before loadState so restore works ----
+ inputPipeline = new InputPipeline();
+ const csUI = initControlSurfaceUI();
+ controlSurface = csUI.surface;
+
// Restore saved state (if any)
loadState();
@@ -814,12 +829,38 @@ async function init() {
if ($ps) $ps.value = activeSynthPresetId;
}
- // Initial inference
- iml.setInput(0, joyX);
- iml.setInput(1, joyY);
- iml.process();
- routeOutputs(iml.getOutputs());
- updateHeatmap(iml.getOutputs());
+ // Enhanced joy-map (replaces drawJoyMap)
+ joyMapEnhanced = new JoyMapEnhanced($joyMap, {
+ onTrailTap: (pos) => {
+ joyX = pos.x;
+ joyY = pos.y;
+ onJoystickMove();
+ },
+ getTrainingData: () => ({
+ features: iml.dataset.features,
+ labels: iml.dataset.labels,
+ }),
+ });
+
+ // Wire control surface changes to pipeline + RL params
+ document.addEventListener('controlsurface:change', (e) => {
+ const p = e.detail;
+ // Input pipeline
+ inputPipeline.setConfig({
+ zoom: p.zoom,
+ deadzone: p.deadzone,
+ inputCurve: p.inputCurve,
+ smoothing: p.smoothing,
+ momentumZoom: p.momentumZoom,
+ invertX: p.invertX,
+ invertY: p.invertY,
+ });
+ // Sync spread (used by moveWeights and randomise)
+ spreadLevel = p.spread;
+ });
+
+ // Initial inference — run through pipeline for consistency
+ if (inputMode === 'joystick') onJoystickMove();
updateStatus();
drawJoyMap();
drawLossPlot();
@@ -879,6 +920,22 @@ function updateHeatmap(outputs) {
// ---- Joy Map (merged joystick + minimap) ----
function drawJoyMap() {
+ // Use enhanced joy-map if available
+ if (joyMapEnhanced) {
+ joyMapEnhanced.draw({
+ joyX,
+ joyY,
+ // effectiveX/Y are set in onJoystickMove; for draw we show zoom window + cursor
+ zoomWindow: inputPipeline ? inputPipeline.getZoomWindow() : null,
+ zoomLevel: inputPipeline ? inputPipeline.getZoomLevel() : 1.0,
+ noiseLevel,
+ outputMode,
+ frozen: inputPipeline ? inputPipeline.isFrozen() : false,
+ });
+ return;
+ }
+
+ // Legacy fallback
const canvas = $joyMap;
const ctx = $joyMapCtx;
const w = canvas.width;
@@ -1065,8 +1122,24 @@ function updateFollowUI() {
function onJoystickMove() {
if (inputMode !== 'joystick') return;
- iml.setInput(0, joyX);
- iml.setInput(1, joyY);
+
+ // Process through input pipeline (zoom, deadzone, curve, smoothing, momentum)
+ const now = performance.now();
+ const dt = _lastFrameTime > 0 ? (now - _lastFrameTime) / 1000 : 1 / 60;
+ _lastFrameTime = now;
+
+ const pipeResult = inputPipeline
+ ? inputPipeline.process(joyX, joyY, dt)
+ : { x: joyX, y: joyY, frozen: false };
+
+ // Cache for getCurrentInputs() (avoids re-processing and mutating state)
+ _lastPipeX = pipeResult.x;
+ _lastPipeY = pipeResult.y;
+
+ if (pipeResult.frozen) return; // Input frozen (zoom at zero)
+
+ iml.setInput(0, pipeResult.x);
+ iml.setInput(1, pipeResult.y);
iml.process();
const outputs = iml.getOutputs();
@@ -1075,9 +1148,14 @@ function onJoystickMove() {
syncRawParamsFromOutputs(outputs);
- // Trail
+ // Trail (enhanced joy-map handles this now, legacy trail kept for compat)
joyTrail.push({ x: joyX, y: joyY, t: Date.now() });
if (joyTrail.length > 30) joyTrail.shift();
+
+ // Enhanced trail records in input-space coords
+ if (joyMapEnhanced) {
+ joyMapEnhanced.addTrailPoint(joyX, joyY, inputPipeline ? inputPipeline.getZoomLevel() : 1.0);
+ }
}
// ---- Output routing ----
@@ -1259,11 +1337,8 @@ async function _setInputModeInner(mode) {
document.getElementById('hand-status').classList.remove('tracking');
}
- iml.setInput(0, joyX);
- iml.setInput(1, joyY);
- iml.process();
- routeOutputs(iml.getOutputs());
- updateHeatmap(iml.getOutputs());
+ // Re-run through pipeline so MLP sees processed coords
+ onJoystickMove();
}
updateStatus();
@@ -1274,7 +1349,9 @@ function getCurrentInputs() {
if (inputMode === 'hands' && handTracker) {
return [...handTracker.features];
}
- return [joyX, joyY];
+ // Return pipeline-processed coords (what the MLP actually sees),
+ // not raw joyX/joyY, so examples are recorded in the correct input space
+ return [_lastPipeX, _lastPipeY];
}
function setCurrentInputs() {
@@ -1282,8 +1359,9 @@ function setCurrentInputs() {
const f = handTracker.features;
for (let i = 0; i < f.length; i++) iml.setInput(i, f[i]);
} else {
- iml.setInput(0, joyX);
- iml.setInput(1, joyY);
+ // Use cached pipeline output (matches what MLP sees during inference)
+ iml.setInput(0, _lastPipeX);
+ iml.setInput(1, _lastPipeY);
}
}
@@ -1391,6 +1469,11 @@ function setOutputMode(mode) {
}
function updateNoiseRing() {
+ // When enhanced joy-map is active, noise ring is drawn on canvas — hide CSS version
+ if (joyMapEnhanced) {
+ $noiseRing.className = 'noise-ring';
+ return;
+ }
if (noiseLevel > 0.15) {
$noiseRing.className = 'noise-ring active high';
} else if (noiseLevel > 0.01) {
@@ -1454,8 +1537,12 @@ function onThumbsUp() {
const outputs = [...iml.getOutputs()];
iml.addExample(inputs, outputs);
- noiseLevel *= rlExplorationDecay;
- noiseLevel = Math.max(noiseLevel, 0.005);
+ // Use control surface params if available, else legacy
+ const csParams = controlSurface ? controlSurface.getParams() : null;
+ const decay = csParams ? csParams.noiseDecay : rlExplorationDecay;
+ const floor = csParams ? csParams.noiseFloor : 0.005;
+ noiseLevel *= decay;
+ noiseLevel = Math.max(noiseLevel, floor);
flash('btn-thumbsup');
updateNoiseRing();
@@ -1463,8 +1550,18 @@ function onThumbsUp() {
}
function onThumbsDown() {
- const noiseCap = 0.3 * (1 - spreadLevel) + 0.05 * spreadLevel;
- noiseLevel = Math.min(noiseLevel * 1.5, noiseCap);
+ // 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);
+ const growth = csParams ? csParams.noiseGrowth : 1.5;
+
+ // Zoom-aware feedback: scale noise by zoom level
+ let effectiveGrowth = growth;
+ if (csParams && csParams.zoomAwareFeedback && inputPipeline) {
+ effectiveGrowth *= inputPipeline.getZoomLevel();
+ }
+
+ noiseLevel = Math.min(noiseLevel * effectiveGrowth, noiseCap);
iml.moveWeights(noiseLevel, spreadLevel);
@@ -2412,6 +2509,8 @@ function saveState() {
joyY,
groupOverrides,
synthPresetId: activeSynthPresetId,
+ controlSurface: controlSurface ? controlSurface.getState() : null,
+ inputPipeline: inputPipeline ? inputPipeline.getConfig() : null,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (e) {
@@ -2482,6 +2581,14 @@ function loadState() {
syncOutputToggles(outputMode);
}
+ // Restore control surface state
+ if (state.controlSurface && controlSurface) {
+ controlSurface.setState(state.controlSurface);
+ }
+ if (state.inputPipeline && inputPipeline) {
+ inputPipeline.setConfig(state.inputPipeline);
+ }
+
// Note: don't auto-restore inputMode='hands' — requires camera permission
console.log(`[NISPS] Restored ${state.features?.length || 0} joy examples, ${state.handFeatures?.length || 0} hand examples from storage`);
} catch (e) {
diff --git a/playground/js/ui/control-surface-ui.js b/playground/js/ui/control-surface-ui.js
new file mode 100644
index 0000000..1dd2fb8
--- /dev/null
+++ b/playground/js/ui/control-surface-ui.js
@@ -0,0 +1,890 @@
+// Control Surface UI — DOM for compound axes + settings drawer
+// Renders 3 compact sliders on the floating bar and a gear-icon settings drawer.
+//
+// Usage:
+// import { initControlSurfaceUI } from './ui/control-surface-ui.js';
+// const { surface, destroy } = initControlSurfaceUI();
+// // surface is the ControlSurface instance
+
+import { ControlSurface, CONTROL_PRESETS, PARAM_DEFAULTS, PARAM_RANGES, LOG_SCALE_PARAMS } from './control-surface.js';
+
+// ---- Parameter metadata for the drawer ----
+
+const SECTIONS = [
+ {
+ id: 'input',
+ label: 'Input',
+ params: [
+ { name: 'zoom', label: 'Zoom', log: true },
+ { name: 'deadzone', label: 'Deadzone' },
+ { name: 'inputCurve', label: 'Curve' },
+ { name: 'smoothing', label: 'Smoothing' },
+ { name: 'momentumZoom', label: 'Momentum', type: 'select', options: ['off', 'gentle', 'strong'] },
+ { name: 'invertX', label: 'Invert X', type: 'toggle' },
+ { name: 'invertY', label: 'Invert Y', type: 'toggle' },
+ ],
+ },
+ {
+ id: 'training',
+ label: 'Training',
+ params: [
+ { name: 'learningRate', label: 'Learning Rate', log: true },
+ { name: 'maxIterations', label: 'Max Iterations' },
+ { name: 'convergenceThreshold', label: 'Convergence', log: true },
+ { name: 'rlTrainIntensity', label: 'RL Intensity' },
+ { name: 'maxExamples', label: 'Max Examples' },
+ { name: 'exampleDecay', label: 'Example Decay' },
+ ],
+ },
+ {
+ id: 'exploration',
+ label: 'Exploration',
+ params: [
+ { name: 'spread', label: 'Spread' },
+ { name: 'noiseFloor', label: 'Noise Floor' },
+ { name: 'noiseCap', label: 'Noise Cap' },
+ { name: 'noiseGrowth', label: 'Noise Growth' },
+ { name: 'noiseDecay', label: 'Noise Decay' },
+ { name: 'weightDecay', label: 'Weight Decay' },
+ { name: 'noiseDistribution', label: 'Distribution', type: 'select', options: ['gaussian', 'cauchy'] },
+ { name: 'layerAwareNoise', label: 'Layer-Aware', type: 'toggle' },
+ { name: 'zoomAwareFeedback', label: 'Zoom-Aware FB', type: 'toggle' },
+ ],
+ },
+ {
+ id: 'output',
+ label: 'Output',
+ params: [
+ { name: 'outputSmoothing', label: 'Smoothing' },
+ { name: 'outputSlewRate', label: 'Slew Rate' },
+ { name: 'tame', label: 'Tame' },
+ { name: 'globalCurve', label: 'Global Curve' },
+ ],
+ },
+];
+
+// ---- Utility: log-scale slider helpers ----
+
+function toLogSlider(value, min, max) {
+ // Map a log-scale value to 0-1 slider position
+ const logMin = Math.log(min);
+ const logMax = Math.log(max);
+ return (Math.log(Math.max(value, min)) - logMin) / (logMax - logMin);
+}
+
+function fromLogSlider(sliderPos, min, max) {
+ const logMin = Math.log(min);
+ const logMax = Math.log(max);
+ return Math.exp(logMin + sliderPos * (logMax - logMin));
+}
+
+function formatValue(name, value) {
+ if (typeof value === 'boolean') return value ? 'On' : 'Off';
+ if (typeof value === 'string') return value;
+ if (typeof value !== 'number') return String(value);
+ // Convergence threshold: scientific notation
+ if (name === 'convergenceThreshold') return value.toExponential(1);
+ // Integers
+ if (name === 'maxExamples' || name === 'maxIterations') return Math.round(value).toString();
+ // Small decimals
+ if (Math.abs(value) < 0.01 && value !== 0) return value.toExponential(1);
+ if (Math.abs(value) < 1) return value.toFixed(3);
+ return value.toFixed(2);
+}
+
+// ---- Build DOM ----
+
+/**
+ * Initialize the control surface UI.
+ * Inserts compound axis sliders into the floating bar and creates the settings drawer.
+ * Returns { surface, destroy }.
+ */
+export function initControlSurfaceUI() {
+ const surface = new ControlSurface();
+
+ // Apply default preset
+ surface.applyPreset('default');
+
+ // --- Floating bar: compound axis sliders ---
+ const $floatingBar = document.getElementById('floating-bar');
+ const axisContainer = document.createElement('div');
+ axisContainer.className = 'cs-axes';
+ axisContainer.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ // Insert before the chevron button
+ const $chevron = document.getElementById('chevron-btn');
+ if ($chevron) {
+ $floatingBar.insertBefore(axisContainer, $chevron);
+ } else {
+ $floatingBar.appendChild(axisContainer);
+ }
+
+ // Wire axis sliders
+ const axisSliders = {};
+ for (const axisEl of axisContainer.querySelectorAll('.cs-axis')) {
+ const axisName = axisEl.dataset.axis;
+ const slider = axisEl.querySelector('.cs-axis-slider');
+ axisSliders[axisName] = slider;
+
+ slider.addEventListener('input', () => {
+ const val = parseFloat(slider.value);
+ if (axisName === 'boldness') surface.setBoldness(val);
+ else if (axisName === 'memory') surface.setMemory(val);
+ else if (axisName === 'precision') surface.setPrecision(val);
+ });
+
+ // Double-tap to clear all overrides for this axis
+ let lastTap = 0;
+ slider.addEventListener('pointerdown', () => {
+ const now = Date.now();
+ if (now - lastTap < 350) {
+ surface.clearAllOverrides();
+ syncDrawerValues();
+ }
+ lastTap = now;
+ });
+ }
+
+ // --- Gear button (settings drawer toggle) ---
+ const gearBtn = document.createElement('button');
+ gearBtn.className = 'cs-gear-btn';
+ gearBtn.title = 'Control surface settings';
+ gearBtn.innerHTML = ``;
+
+ // Place gear button near the help button (top-right area)
+ const $helpBtn = document.getElementById('help-btn');
+ if ($helpBtn && $helpBtn.parentNode) {
+ $helpBtn.parentNode.insertBefore(gearBtn, $helpBtn);
+ } else {
+ document.body.appendChild(gearBtn);
+ }
+
+ // --- Settings drawer ---
+ const overlay = document.createElement('div');
+ overlay.className = 'cs-drawer-overlay hidden';
+
+ const drawer = document.createElement('div');
+ drawer.className = 'cs-drawer';
+
+ // Build drawer content
+ drawer.innerHTML = buildDrawerHTML();
+ overlay.appendChild(drawer);
+ document.body.appendChild(overlay);
+
+ // --- Wire drawer interactions ---
+
+ let drawerOpen = false;
+
+ function openDrawer() {
+ drawerOpen = true;
+ overlay.classList.remove('hidden');
+ requestAnimationFrame(() => {
+ overlay.classList.add('open');
+ drawer.classList.add('open');
+ });
+ syncDrawerValues();
+ }
+
+ function closeDrawer() {
+ drawerOpen = false;
+ overlay.classList.remove('open');
+ drawer.classList.remove('open');
+ setTimeout(() => {
+ if (!drawerOpen) overlay.classList.add('hidden');
+ }, 220);
+ }
+
+ gearBtn.addEventListener('click', () => {
+ if (drawerOpen) closeDrawer();
+ else openDrawer();
+ });
+
+ // Close on overlay click (outside drawer)
+ overlay.addEventListener('click', (e) => {
+ if (e.target === overlay) closeDrawer();
+ });
+
+ // Close button inside drawer
+ const closeBtn = drawer.querySelector('.cs-drawer-close');
+ if (closeBtn) closeBtn.addEventListener('click', closeDrawer);
+
+ // Preset dropdown
+ const presetSelect = drawer.querySelector('#cs-preset-select');
+ if (presetSelect) {
+ presetSelect.addEventListener('change', () => {
+ const id = presetSelect.value;
+ if (id && CONTROL_PRESETS[id]) {
+ surface.applyPreset(id);
+ // Sync axis sliders
+ axisSliders.boldness.value = surface.getBoldness();
+ axisSliders.memory.value = surface.getMemory();
+ axisSliders.precision.value = surface.getPrecision();
+ syncDrawerValues();
+ }
+ });
+ }
+
+ // Reset button
+ const resetBtn = drawer.querySelector('#cs-reset-preset');
+ if (resetBtn) {
+ resetBtn.addEventListener('click', () => {
+ const id = presetSelect?.value || 'default';
+ surface.applyPreset(id);
+ axisSliders.boldness.value = surface.getBoldness();
+ axisSliders.memory.value = surface.getMemory();
+ axisSliders.precision.value = surface.getPrecision();
+ syncDrawerValues();
+ });
+ }
+
+ // Section expand/collapse
+ for (const header of drawer.querySelectorAll('.cs-section-header')) {
+ header.addEventListener('click', () => {
+ const section = header.parentElement;
+ section.classList.toggle('collapsed');
+ });
+ }
+
+ // Wire individual parameter controls
+ wireParamControls(drawer, surface, () => syncDrawerValues());
+
+ // --- Sync drawer values from surface state ---
+
+ function syncDrawerValues() {
+ const params = surface.getParams();
+ for (const section of SECTIONS) {
+ for (const p of section.params) {
+ const val = params[p.name];
+ const el = drawer.querySelector(`[data-param="${p.name}"]`);
+ if (!el) continue;
+
+ const range = PARAM_RANGES[p.name];
+
+ if (p.type === 'toggle') {
+ const checkbox = el.querySelector('input[type="checkbox"]');
+ if (checkbox) checkbox.checked = !!val;
+ } else if (p.type === 'select') {
+ const select = el.querySelector('select');
+ if (select) select.value = val;
+ } else if (range) {
+ const slider = el.querySelector('input[type="range"]');
+ const valueSpan = el.querySelector('.cs-param-value');
+ if (slider) {
+ if (p.log || LOG_SCALE_PARAMS.has(p.name)) {
+ slider.value = toLogSlider(val, range[0], range[1]);
+ } else {
+ slider.value = val;
+ }
+ }
+ if (valueSpan) valueSpan.textContent = formatValue(p.name, val);
+ }
+
+ // Show override indicator
+ const overrideIndicator = el.querySelector('.cs-override-dot');
+ if (overrideIndicator) {
+ overrideIndicator.classList.toggle('active', surface.hasOverride(p.name));
+ }
+ }
+ }
+ }
+
+ // Listen for external changes to the surface (e.g. from app code)
+ surface.onChange(() => {
+ if (drawerOpen) syncDrawerValues();
+ });
+
+ // --- Inject styles ---
+ injectStyles();
+
+ // --- Cleanup function ---
+ function destroy() {
+ axisContainer.remove();
+ gearBtn.remove();
+ overlay.remove();
+ const styleEl = document.getElementById('cs-styles');
+ if (styleEl) styleEl.remove();
+ }
+
+ return { surface, destroy };
+}
+
+
+// ---- Build drawer HTML ----
+
+function buildDrawerHTML() {
+ let html = `
+
+
+
+
+
+
+
+ `;
+
+ for (const section of SECTIONS) {
+ html += `
+
+
+
+ `;
+
+ for (const p of section.params) {
+ html += buildParamRow(p);
+ }
+
+ html += `
+
+
+ `;
+ }
+
+ html += `
`;
+ return html;
+}
+
+function buildParamRow(p) {
+ const range = PARAM_RANGES[p.name];
+
+ if (p.type === 'toggle') {
+ return `
+
+
+
+
+ `;
+ }
+
+ if (p.type === 'select') {
+ const options = p.options || [];
+ return `
+
+
+
+
+ `;
+ }
+
+ // Numeric slider
+ const isLog = p.log || LOG_SCALE_PARAMS.has(p.name);
+ const min = range ? range[0] : 0;
+ const max = range ? range[1] : 1;
+ const step = isLog ? 0.001 : (range ? (range[2] || 0.01) : 0.01);
+ const sliderMin = isLog ? 0 : min;
+ const sliderMax = isLog ? 1 : max;
+ const defaultVal = PARAM_DEFAULTS[p.name] ?? 0.5;
+ const sliderDefault = isLog ? toLogSlider(defaultVal, min, max) : defaultVal;
+
+ return `
+
+
+
+
+ ${formatValue(p.name, defaultVal)}
+
+
+ `;
+}
+
+
+// ---- Wire parameter controls ----
+
+function wireParamControls(drawer, surface, syncFn) {
+ for (const section of SECTIONS) {
+ for (const p of section.params) {
+ const el = drawer.querySelector(`[data-param="${p.name}"]`);
+ if (!el) continue;
+
+ const range = PARAM_RANGES[p.name];
+
+ if (p.type === 'toggle') {
+ const checkbox = el.querySelector('input[type="checkbox"]');
+ if (checkbox) {
+ checkbox.addEventListener('change', () => {
+ surface.setOverride(p.name, checkbox.checked);
+ syncFn();
+ });
+ }
+ } else if (p.type === 'select') {
+ const select = el.querySelector('select');
+ if (select) {
+ select.addEventListener('change', () => {
+ surface.setOverride(p.name, select.value);
+ syncFn();
+ });
+ }
+ } else if (range) {
+ const slider = el.querySelector('input[type="range"]');
+ const valueSpan = el.querySelector('.cs-param-value');
+ const isLog = p.log || LOG_SCALE_PARAMS.has(p.name);
+
+ if (slider) {
+ slider.addEventListener('input', () => {
+ let val;
+ if (isLog) {
+ val = fromLogSlider(parseFloat(slider.value), range[0], range[1]);
+ } else {
+ val = parseFloat(slider.value);
+ }
+ surface.setOverride(p.name, val);
+ if (valueSpan) valueSpan.textContent = formatValue(p.name, val);
+ // Update override dot
+ const dot = el.querySelector('.cs-override-dot');
+ if (dot) dot.classList.add('active');
+ });
+
+ // Double-tap slider to clear override for this param
+ let lastTap = 0;
+ slider.addEventListener('pointerdown', () => {
+ const now = Date.now();
+ if (now - lastTap < 350) {
+ surface.clearOverride(p.name);
+ syncFn();
+ }
+ lastTap = now;
+ });
+ }
+ }
+ }
+ }
+}
+
+
+// ---- CSS injection ----
+
+function injectStyles() {
+ if (document.getElementById('cs-styles')) return;
+
+ const style = document.createElement('style');
+ style.id = 'cs-styles';
+ style.textContent = `
+/* ---- Control Surface: Compound Axes (floating bar) ---- */
+.cs-axes {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin: 0 4px;
+}
+
+.cs-axis {
+ display: flex;
+ align-items: center;
+ gap: 3px;
+}
+
+.cs-axis-label {
+ font-size: 9px;
+ color: rgba(255, 255, 255, 0.45);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ white-space: nowrap;
+ cursor: default;
+ user-select: none;
+}
+
+.cs-axis-slider {
+ width: 48px;
+ height: 3px;
+ -webkit-appearance: none;
+ appearance: none;
+ background: rgba(255, 255, 255, 0.12);
+ border-radius: 2px;
+ outline: none;
+ cursor: pointer;
+}
+
+.cs-axis-slider::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: var(--accent, #ff6a00);
+ cursor: pointer;
+ border: none;
+}
+
+.cs-axis-slider::-moz-range-thumb {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: var(--accent, #ff6a00);
+ border: none;
+ cursor: pointer;
+}
+
+/* ---- Gear button ---- */
+.cs-gear-btn {
+ position: fixed;
+ bottom: 96px;
+ right: 50px;
+ z-index: 45;
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ border: 1px solid var(--glass-border, rgba(255, 255, 255, 0.08));
+ background: var(--glass-bg, rgba(13, 13, 13, 0.65));
+ backdrop-filter: blur(16px);
+ -webkit-backdrop-filter: blur(16px);
+ color: var(--text-dim, #888);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.15s;
+ padding: 0;
+}
+
+.cs-gear-btn:hover,
+.cs-gear-btn:active {
+ border-color: var(--accent, #ff6a00);
+ color: var(--accent, #ff6a00);
+}
+
+/* ---- Drawer overlay ---- */
+.cs-drawer-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 90;
+ background: rgba(0, 0, 0, 0);
+ transition: background 0.2s ease;
+ pointer-events: auto;
+}
+
+.cs-drawer-overlay.hidden {
+ display: none;
+}
+
+.cs-drawer-overlay.open {
+ background: rgba(0, 0, 0, 0.4);
+}
+
+/* ---- Drawer panel ---- */
+.cs-drawer {
+ position: fixed;
+ top: 0;
+ right: -320px;
+ width: 300px;
+ height: 100%;
+ z-index: 91;
+ background: rgba(0, 0, 0, 0.88);
+ backdrop-filter: blur(24px);
+ -webkit-backdrop-filter: blur(24px);
+ border-left: 1px solid rgba(255, 255, 255, 0.08);
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+ transition: right 0.2s ease;
+ font-family: 'JetBrains Mono', 'SF Mono', monospace;
+}
+
+.cs-drawer.open {
+ right: 0;
+}
+
+/* ---- Drawer header ---- */
+.cs-drawer-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 14px 16px 10px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+.cs-drawer-title {
+ font-size: 12px;
+ font-weight: 600;
+ color: rgba(255, 255, 255, 0.8);
+ text-transform: uppercase;
+ letter-spacing: 0.8px;
+}
+
+.cs-drawer-close {
+ width: 28px;
+ height: 28px;
+ border-radius: 4px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ background: rgba(255, 255, 255, 0.04);
+ color: rgba(255, 255, 255, 0.5);
+ font-size: 16px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.15s;
+ padding: 0;
+}
+
+.cs-drawer-close:hover {
+ background: rgba(255, 255, 255, 0.1);
+ color: white;
+}
+
+/* ---- Drawer body ---- */
+.cs-drawer-body {
+ padding: 0 0 24px;
+}
+
+/* ---- Preset row ---- */
+.cs-preset-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 16px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.04);
+}
+
+.cs-preset-label {
+ font-size: 10px;
+ color: rgba(255, 255, 255, 0.4);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ white-space: nowrap;
+}
+
+.cs-preset-select {
+ flex: 1;
+ height: 28px;
+ padding: 0 8px;
+ border-radius: 4px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ background: rgba(255, 255, 255, 0.04);
+ color: var(--text, #e0e0e0);
+ font-family: inherit;
+ font-size: 11px;
+ cursor: pointer;
+ appearance: none;
+ -webkit-appearance: none;
+}
+
+.cs-preset-select option {
+ background: #1a1a1a;
+ color: var(--text, #e0e0e0);
+}
+
+.cs-reset-btn {
+ height: 28px;
+ padding: 0 10px;
+ border-radius: 4px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ background: rgba(255, 255, 255, 0.04);
+ color: rgba(255, 255, 255, 0.5);
+ font-family: inherit;
+ font-size: 10px;
+ cursor: pointer;
+ transition: all 0.15s;
+ white-space: nowrap;
+}
+
+.cs-reset-btn:hover {
+ background: rgba(255, 255, 255, 0.1);
+ color: white;
+}
+
+/* ---- Sections ---- */
+.cs-section {
+ border-bottom: 1px solid rgba(255, 255, 255, 0.04);
+}
+
+.cs-section-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 16px;
+ cursor: pointer;
+ user-select: none;
+ transition: background 0.15s;
+}
+
+.cs-section-header:hover {
+ background: rgba(255, 255, 255, 0.03);
+}
+
+.cs-section-header span:first-child {
+ font-size: 10px;
+ font-weight: 500;
+ color: rgba(255, 255, 255, 0.5);
+ text-transform: uppercase;
+ letter-spacing: 1px;
+}
+
+.cs-section-chevron {
+ font-size: 8px;
+ color: rgba(255, 255, 255, 0.3);
+ transition: transform 0.2s;
+}
+
+.cs-section.collapsed .cs-section-chevron {
+ transform: rotate(-90deg);
+}
+
+.cs-section-body {
+ padding: 0 16px 8px;
+ overflow: hidden;
+ transition: max-height 0.2s ease, padding 0.2s ease;
+ max-height: 600px;
+}
+
+.cs-section.collapsed .cs-section-body {
+ max-height: 0;
+ padding-top: 0;
+ padding-bottom: 0;
+}
+
+/* ---- Parameter rows ---- */
+.cs-param-row {
+ margin-bottom: 8px;
+}
+
+.cs-param-label {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 10px;
+ color: rgba(255, 255, 255, 0.45);
+ margin-bottom: 3px;
+ cursor: default;
+}
+
+/* Override dot: shows when param has a manual offset */
+.cs-override-dot {
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ background: transparent;
+ flex-shrink: 0;
+ transition: background 0.15s;
+}
+
+.cs-override-dot.active {
+ background: var(--accent, #ff6a00);
+}
+
+/* ---- Slider row ---- */
+.cs-slider-row {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.cs-slider {
+ flex: 1;
+ height: 3px;
+ -webkit-appearance: none;
+ appearance: none;
+ background: rgba(255, 255, 255, 0.1);
+ border-radius: 2px;
+ outline: none;
+ cursor: pointer;
+}
+
+.cs-slider::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: var(--accent, #ff6a00);
+ cursor: pointer;
+ border: none;
+}
+
+.cs-slider::-moz-range-thumb {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: var(--accent, #ff6a00);
+ border: none;
+ cursor: pointer;
+}
+
+.cs-param-value {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 9px;
+ color: rgba(255, 255, 255, 0.5);
+ min-width: 36px;
+ text-align: right;
+ white-space: nowrap;
+}
+
+/* ---- Toggle (checkbox) ---- */
+.cs-toggle {
+ accent-color: var(--accent, #ff6a00);
+ cursor: pointer;
+ width: 14px;
+ height: 14px;
+}
+
+/* ---- Select ---- */
+.cs-select {
+ height: 24px;
+ padding: 0 8px;
+ border-radius: 4px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ background: rgba(255, 255, 255, 0.04);
+ color: var(--text, #e0e0e0);
+ font-family: inherit;
+ font-size: 10px;
+ cursor: pointer;
+ appearance: none;
+ -webkit-appearance: none;
+}
+
+.cs-select option {
+ background: #1a1a1a;
+ color: var(--text, #e0e0e0);
+}
+
+/* ---- Responsive: shrink axes on narrow screens ---- */
+@media (max-width: 600px) {
+ .cs-axes {
+ gap: 4px;
+ }
+ .cs-axis-slider {
+ width: 36px;
+ }
+ .cs-axis-label {
+ font-size: 8px;
+ }
+ .cs-drawer {
+ width: 280px;
+ right: -300px;
+ }
+}
+`;
+ document.head.appendChild(style);
+}
diff --git a/playground/js/ui/control-surface.js b/playground/js/ui/control-surface.js
new file mode 100644
index 0000000..84576a3
--- /dev/null
+++ b/playground/js/ui/control-surface.js
@@ -0,0 +1,405 @@
+// Control Surface — Compound Axes + Parameter Override System
+// Phase 1: Boldness, Memory, Precision axes with offset-based overrides.
+//
+// Each compound axis maps a single 0-1 slider to multiple underlying parameters.
+// Users can override individual params; the offset from axis-derived value persists
+// as the axis moves (trim-pot model). Double-tap an axis to re-link (clear offsets).
+
+// ---- Interpolation tables ----
+// Each row: [axisValue, { param: value, ... }]
+// Linear interpolation between adjacent rows.
+
+const BOLDNESS_TABLE = [
+ [0.0, { zoom: 0.1, noiseCap: 0.02, noiseGrowth: 1.1, learningRate: 0.1, weightDecay: 0.15, noiseDistribution: 'gaussian' }],
+ [0.5, { zoom: 0.5, noiseCap: 0.12, noiseGrowth: 1.5, learningRate: 1.0, weightDecay: 0.06, noiseDistribution: 'gaussian' }],
+ [1.0, { zoom: 1.0, noiseCap: 0.30, noiseGrowth: 2.5, learningRate: 3.0, weightDecay: 0.00, noiseDistribution: 'cauchy' }],
+];
+
+const MEMORY_TABLE = [
+ [0.0, { maxExamples: 5, exampleDecay: 0.3, memoryWeightDecay: 0.20, noiseDecay: 0.85, convergenceThreshold: 1e-3 }],
+ [0.5, { maxExamples: 50, exampleDecay: 0.7, memoryWeightDecay: 0.06, noiseDecay: 0.97, convergenceThreshold: 1e-5 }],
+ [1.0, { maxExamples: 500, exampleDecay: 1.0, memoryWeightDecay: 0.00, noiseDecay: 0.995, convergenceThreshold: 1e-8 }],
+];
+
+const PRECISION_TABLE = [
+ [0.0, { inputCurve: 1.0, deadzone: 0.0, smoothing: 0.0, slewRate: 1.0, momentumZoom: 'off' }],
+ [0.5, { inputCurve: 1.5, deadzone: 0.05, smoothing: 0.15, slewRate: 0.3, momentumZoom: 'gentle' }],
+ [1.0, { inputCurve: 3.0, deadzone: 0.15, smoothing: 0.40, slewRate: 0.1, momentumZoom: 'strong' }],
+];
+
+// ---- Control Presets ----
+
+const CONTROL_PRESETS = {
+ 'default': { boldness: 0.5, memory: 0.5, precision: 0.3 },
+ 'first-touch': { boldness: 0.2, memory: 0.7, precision: 0.6 },
+ 'jazz-hands': { boldness: 0.8, memory: 0.2, precision: 0.0 },
+ 'sculptor': { boldness: 0.3, memory: 0.9, precision: 0.8 },
+ 'improviser': { boldness: 0.6, memory: 0.3, precision: 0.2 },
+ 'microscope': { boldness: 0.1, memory: 1.0, precision: 1.0 },
+};
+
+// ---- Default values for all parameters ----
+// These are used when no axis or override controls a parameter.
+
+const PARAM_DEFAULTS = {
+ // Input pipeline
+ zoom: 1.0,
+ deadzone: 0.0,
+ inputCurve: 1.0,
+ smoothing: 0.0,
+ momentumZoom: 'off',
+ invertX: false,
+ invertY: false,
+
+ // Training
+ learningRate: 1.0,
+ maxIterations: 1000,
+ convergenceThreshold: 1e-5,
+ rlTrainIntensity: 1.0,
+ maxExamples: 50,
+ exampleDecay: 0.7,
+
+ // Noise / exploration
+ spread: 0.6,
+ noiseFloor: 0.005,
+ noiseCap: 0.12,
+ noiseGrowth: 1.5,
+ noiseDecay: 0.97,
+ weightDecay: 0.06,
+ noiseDistribution: 'gaussian',
+ layerAwareNoise: true,
+ zoomAwareFeedback: true,
+
+ // Output pipeline
+ outputSmoothing: 0.0,
+ outputSlewRate: 1.0,
+ tame: 1.0,
+ globalCurve: 1.0,
+};
+
+// Valid ranges for clamping (min, max, step). Discrete params use null.
+const PARAM_RANGES = {
+ zoom: [0.01, 1.0, 0.01],
+ deadzone: [0, 0.4, 0.01],
+ inputCurve: [0.2, 5.0, 0.1],
+ smoothing: [0, 1.0, 0.01],
+ momentumZoom: null, // discrete: 'off' | 'gentle' | 'strong'
+ invertX: null, // boolean
+ invertY: null, // boolean
+ learningRate: [0.01, 10.0, 0.01],
+ maxIterations: [10, 10000, 10],
+ convergenceThreshold: [1e-8, 1e-2, null], // log scale
+ rlTrainIntensity: [0.1, 5.0, 0.1],
+ maxExamples: [1, 500, 1],
+ exampleDecay: [0, 1.0, 0.01],
+ spread: [0, 1.0, 0.01],
+ noiseFloor: [0, 0.1, 0.001],
+ noiseCap: [0.01, 0.5, 0.01],
+ noiseGrowth: [1.0, 5.0, 0.1],
+ noiseDecay: [0.5, 1.0, 0.001],
+ weightDecay: [0, 0.5, 0.01],
+ noiseDistribution: null, // discrete: 'gaussian' | 'cauchy'
+ layerAwareNoise: null, // boolean
+ zoomAwareFeedback: null, // boolean
+ outputSmoothing: [0, 1.0, 0.01],
+ outputSlewRate: [0.01, 1.0, 0.01],
+ tame: [0, 1.0, 0.01],
+ globalCurve: [0.2, 5.0, 0.1],
+};
+
+// Which parameters are log-scale in the UI
+const LOG_SCALE_PARAMS = new Set(['zoom', 'learningRate', 'convergenceThreshold']);
+
+// ---- Helpers ----
+
+/**
+ * Linearly interpolate a value from a table.
+ * Table rows: [axisValue, { paramName: value, ... }]
+ * For discrete params (strings), snaps at 0.75 threshold toward the higher row.
+ */
+function interpolateTable(table, axisValue) {
+ const v = Math.max(0, Math.min(1, axisValue));
+ const result = {};
+
+ // Find the two bracketing rows
+ let lo = 0;
+ let hi = table.length - 1;
+ for (let i = 0; i < table.length - 1; i++) {
+ if (v >= table[i][0] && v <= table[i + 1][0]) {
+ lo = i;
+ hi = i + 1;
+ break;
+ }
+ }
+
+ const loVal = table[lo][0];
+ const hiVal = table[hi][0];
+ const t = hiVal === loVal ? 0 : (v - loVal) / (hiVal - loVal);
+
+ const loParams = table[lo][1];
+ const hiParams = table[hi][1];
+
+ for (const key of Object.keys(loParams)) {
+ const a = loParams[key];
+ const b = hiParams[key];
+ if (typeof a === 'number' && typeof b === 'number') {
+ result[key] = a + (b - a) * t;
+ } else {
+ // Discrete: snap at 75% toward the higher value
+ result[key] = t < 0.75 ? a : b;
+ }
+ }
+
+ return result;
+}
+
+/**
+ * Clamp a numeric value to its valid range.
+ */
+function clampParam(name, value) {
+ const range = PARAM_RANGES[name];
+ if (!range) return value; // discrete, no clamping
+ return Math.max(range[0], Math.min(range[1], value));
+}
+
+
+// ---- ControlSurface ----
+
+export class ControlSurface {
+ constructor() {
+ // Axis values (0-1)
+ this._axes = {
+ boldness: 0.5,
+ memory: 0.5,
+ precision: 0.3,
+ };
+
+ // Per-parameter offsets (trim-pot overrides)
+ // key = paramName, value = offset from axis-derived value
+ this._offsets = {};
+
+ // Change listeners
+ this._listeners = [];
+
+ // Compute initial derived params
+ this._derivedCache = null;
+ this._resolvedCache = null;
+ this._dirty = true;
+ }
+
+ // ---- Compound axes ----
+
+ setBoldness(value) {
+ this._axes.boldness = Math.max(0, Math.min(1, value));
+ this._dirty = true;
+ this._notify();
+ }
+
+ setMemory(value) {
+ this._axes.memory = Math.max(0, Math.min(1, value));
+ this._dirty = true;
+ this._notify();
+ }
+
+ setPrecision(value) {
+ this._axes.precision = Math.max(0, Math.min(1, value));
+ this._dirty = true;
+ this._notify();
+ }
+
+ getBoldness() { return this._axes.boldness; }
+ getMemory() { return this._axes.memory; }
+ getPrecision() { return this._axes.precision; }
+
+ // ---- Individual overrides (offset model) ----
+
+ /**
+ * Set an override for a specific parameter.
+ * Computes offset = manualValue - axisDerivedValue, so moving the
+ * axis later shifts the base while the offset persists.
+ */
+ setOverride(paramName, value) {
+ const derived = this._getDerived();
+ const base = derived[paramName] ?? PARAM_DEFAULTS[paramName];
+ if (typeof base === 'number' && typeof value === 'number') {
+ this._offsets[paramName] = value - base;
+ } else {
+ // For discrete params, store the literal value (not an offset)
+ this._offsets[paramName] = value;
+ }
+ this._dirty = true;
+ this._notify();
+ }
+
+ clearOverride(paramName) {
+ delete this._offsets[paramName];
+ this._dirty = true;
+ this._notify();
+ }
+
+ clearAllOverrides() {
+ this._offsets = {};
+ this._dirty = true;
+ this._notify();
+ }
+
+ hasOverride(paramName) {
+ return paramName in this._offsets;
+ }
+
+ // ---- Resolved parameter values ----
+
+ /**
+ * Returns a flat object of all resolved parameter values.
+ * axis-derived + offsets, clamped to valid ranges.
+ */
+ getParams() {
+ if (!this._dirty && this._resolvedCache) return this._resolvedCache;
+
+ const derived = this._getDerived();
+ const resolved = {};
+
+ for (const name of Object.keys(PARAM_DEFAULTS)) {
+ const base = derived[name] ?? PARAM_DEFAULTS[name];
+
+ if (name in this._offsets) {
+ const range = PARAM_RANGES[name];
+ if (range && typeof base === 'number') {
+ // Numeric with offset
+ resolved[name] = clampParam(name, base + this._offsets[name]);
+ } else {
+ // Discrete override: stored as literal value
+ resolved[name] = this._offsets[name];
+ }
+ } else {
+ resolved[name] = base;
+ }
+ }
+
+ this._resolvedCache = resolved;
+ this._dirty = false;
+ return resolved;
+ }
+
+ getParam(name) {
+ return this.getParams()[name];
+ }
+
+ // ---- Presets ----
+
+ applyPreset(presetId) {
+ const preset = CONTROL_PRESETS[presetId];
+ if (!preset) {
+ console.warn(`[ControlSurface] Unknown preset: ${presetId}`);
+ return;
+ }
+ this._axes.boldness = preset.boldness;
+ this._axes.memory = preset.memory;
+ this._axes.precision = preset.precision;
+ this._offsets = {};
+ this._dirty = true;
+ this._notify();
+ }
+
+ getPresetList() {
+ return Object.entries(CONTROL_PRESETS).map(([id, values]) => ({
+ id,
+ ...values,
+ }));
+ }
+
+ // ---- Events ----
+
+ onChange(callback) {
+ this._listeners.push(callback);
+ return () => {
+ const idx = this._listeners.indexOf(callback);
+ if (idx >= 0) this._listeners.splice(idx, 1);
+ };
+ }
+
+ // ---- Serialization ----
+
+ getState() {
+ return {
+ axes: { ...this._axes },
+ offsets: { ...this._offsets },
+ };
+ }
+
+ setState(state) {
+ if (state.axes) {
+ this._axes.boldness = state.axes.boldness ?? 0.5;
+ this._axes.memory = state.axes.memory ?? 0.5;
+ this._axes.precision = state.axes.precision ?? 0.3;
+ }
+ if (state.offsets) {
+ this._offsets = { ...state.offsets };
+ }
+ this._dirty = true;
+ this._notify();
+ }
+
+ // ---- Internal ----
+
+ /**
+ * Compute axis-derived values (before offsets).
+ * Merges all three axis interpolation tables plus defaults.
+ */
+ _getDerived() {
+ if (!this._dirty && this._derivedCache) return this._derivedCache;
+
+ const boldness = interpolateTable(BOLDNESS_TABLE, this._axes.boldness);
+ const memory = interpolateTable(MEMORY_TABLE, this._axes.memory);
+ const precision = interpolateTable(PRECISION_TABLE, this._axes.precision);
+
+ // Start from defaults, overlay axis-derived values.
+ // Memory table uses 'memoryWeightDecay' to avoid conflict with Boldness 'weightDecay'.
+ // Resolve: Boldness controls main weightDecay; Memory's memoryWeightDecay is additive context.
+ // For the resolved param, we take the max of the two weight decay influences.
+ const derived = { ...PARAM_DEFAULTS };
+
+ // Boldness params
+ derived.zoom = boldness.zoom;
+ derived.noiseCap = boldness.noiseCap;
+ derived.noiseGrowth = boldness.noiseGrowth;
+ derived.learningRate = boldness.learningRate;
+ derived.weightDecay = boldness.weightDecay;
+ derived.noiseDistribution = boldness.noiseDistribution;
+
+ // Memory params
+ derived.maxExamples = Math.round(memory.maxExamples);
+ derived.exampleDecay = memory.exampleDecay;
+ derived.noiseDecay = memory.noiseDecay;
+ derived.convergenceThreshold = memory.convergenceThreshold;
+ // Memory weight decay: blend with boldness weight decay (take the larger influence)
+ derived.weightDecay = Math.max(boldness.weightDecay, memory.memoryWeightDecay);
+
+ // Precision params
+ derived.inputCurve = precision.inputCurve;
+ derived.deadzone = precision.deadzone;
+ derived.smoothing = precision.smoothing;
+ derived.outputSlewRate = precision.slewRate;
+ derived.momentumZoom = precision.momentumZoom;
+
+ this._derivedCache = derived;
+ return derived;
+ }
+
+ _notify() {
+ const params = this.getParams();
+
+ // Dispatch DOM event for app-level wiring
+ document.dispatchEvent(new CustomEvent('controlsurface:change', {
+ detail: params,
+ }));
+
+ // Direct listeners
+ for (const fn of this._listeners) {
+ try { fn(params); } catch (e) { console.error('[ControlSurface] listener error:', e); }
+ }
+ }
+}
+
+// Re-export constants for UI module
+export { CONTROL_PRESETS, PARAM_DEFAULTS, PARAM_RANGES, LOG_SCALE_PARAMS };
diff --git a/playground/js/ui/input-pipeline.js b/playground/js/ui/input-pipeline.js
new file mode 100644
index 0000000..34fde31
--- /dev/null
+++ b/playground/js/ui/input-pipeline.js
@@ -0,0 +1,611 @@
+/**
+ * Input Pipeline — data processing layer between physical input and the MLP.
+ *
+ * Pipeline stages (in order):
+ * 1. Deadzone — suppress jitter near center, remap to full [0,1]
+ * 2. Zoom — narrow effective input window around an anchor point
+ * 3. Input Curve — centered power curve (exponent)
+ * 4. Smoothing — exponential moving average (frame-rate-independent)
+ * 5. Momentum-as-zoom — movement speed modulates effective zoom
+ *
+ * Pure math module — no DOM dependencies.
+ *
+ * @module input-pipeline
+ */
+
+// ---------------------------------------------------------------------------
+// Default constants
+// ---------------------------------------------------------------------------
+
+/** @type {number} Default zoom level (1.0 = full range, no zoom) */
+export const DEFAULT_ZOOM = 1.0;
+/** @type {number} Minimum zoom before input is frozen */
+export const ZOOM_MIN = 0.01;
+/** @type {number} Maximum zoom level */
+export const ZOOM_MAX = 1.0;
+/** @type {number} Default deadzone (0 = off) */
+export const DEFAULT_DEADZONE = 0;
+/** @type {number} Maximum allowed deadzone */
+export const DEADZONE_MAX = 0.4;
+/** @type {number} Default input curve exponent (1.0 = linear) */
+export const DEFAULT_INPUT_CURVE = 1.0;
+/** @type {number} Minimum input curve exponent */
+export const INPUT_CURVE_MIN = 0.2;
+/** @type {number} Maximum input curve exponent */
+export const INPUT_CURVE_MAX = 5.0;
+/** @type {number} Default smoothing factor (0 = no smoothing) */
+export const DEFAULT_SMOOTHING = 0;
+/** @type {number} Maximum smoothing factor */
+export const SMOOTHING_MAX = 0.95;
+/** @type {string} Default momentum-zoom mode */
+export const DEFAULT_MOMENTUM_ZOOM = 'off';
+/** @type {string} Default anchor mode */
+export const DEFAULT_ANCHOR_MODE = 'auto';
+/** @type {number} Default velocity estimation window in ms */
+export const DEFAULT_VELOCITY_WINDOW = 150;
+
+/** @type {number} Freeze threshold — zoom at or below this value freezes input */
+const FREEZE_THRESHOLD = ZOOM_MIN;
+
+// Momentum-zoom presets: { factor, minZoomMul, maxZoomMul }
+// factor controls how much velocity scales zoom (higher = more effect)
+const MOMENTUM_PRESETS = {
+ off: null,
+ gentle: { factor: 0.6, minZoomMul: 0.3, maxZoomMul: 1.0 },
+ strong: { factor: 1.5, minZoomMul: 0.15, maxZoomMul: 1.0 },
+};
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/** Clamp value to [lo, hi]. */
+function clamp(v, lo, hi) {
+ return v < lo ? lo : v > hi ? hi : v;
+}
+
+/** Sign-preserving centered power curve (spec 2.3). Input and output in [0,1]. */
+function centeredPowerCurve(input, exponent) {
+ if (exponent === 1) return input;
+ const offset = input - 0.5;
+ const sign = offset < 0 ? -1 : 1;
+ const shaped = sign * Math.pow(Math.abs(offset) * 2, exponent) / 2;
+ return shaped + 0.5;
+}
+
+/**
+ * Apply deadzone then remap live zone to [0,1].
+ * Deadzone is a fraction of half-travel from center (0.5).
+ * Returns value in [0,1].
+ */
+function applyDeadzone(input, deadzone) {
+ if (deadzone <= 0) return input;
+ const offset = input - 0.5; // [-0.5, 0.5]
+ const absOff = Math.abs(offset);
+ const halfDz = deadzone * 0.5; // deadzone fraction of half-travel (0.5)
+ if (absOff <= halfDz) return 0.5;
+ const sign = offset < 0 ? -1 : 1;
+ // Remap [halfDz, 0.5] -> [0, 0.5]
+ const remapped = (absOff - halfDz) / (0.5 - halfDz) * 0.5;
+ return 0.5 + sign * remapped;
+}
+
+/**
+ * Apply zoom around anchor for a single axis.
+ * effective = anchor + (raw - 0.5) * zoomLevel, clamped [0,1]
+ */
+function applyZoom(input, anchor, zoomLevel) {
+ return clamp(anchor + (input - 0.5) * zoomLevel, 0, 1);
+}
+
+/**
+ * Frame-rate-independent EMA.
+ * Converts a per-frame smoothing factor into a time-domain factor so
+ * the perceived smoothing is consistent regardless of frame rate.
+ *
+ * Reference frame rate is 60 fps (≈16.67 ms).
+ */
+function emaSmooth(prev, raw, smoothing, dt) {
+ if (smoothing <= 0) return raw;
+ // Convert from "per-frame at 60fps" to time-constant-based
+ const refDt = 1 / 60;
+ const effectiveDt = dt > 0 ? dt : refDt;
+ // alpha is the fraction of the new value to blend in per reference frame
+ // For frame-rate independence: alpha_eff = 1 - (1 - alpha)^(dt/refDt)
+ const alpha = 1 - smoothing; // per-reference-frame new-value weight
+ const alphaEff = 1 - Math.pow(1 - alpha, effectiveDt / refDt);
+ return prev + alphaEff * (raw - prev);
+}
+
+// ---------------------------------------------------------------------------
+// InputPipeline
+// ---------------------------------------------------------------------------
+
+/**
+ * Configurable input processing pipeline.
+ *
+ * Transforms raw 2D input (e.g. joystick X/Y in [0,1]) through deadzone,
+ * zoom, curve shaping, smoothing, and optional momentum-zoom, producing a
+ * processed {x, y, frozen} result suitable for feeding into the MLP.
+ */
+export class InputPipeline {
+ /**
+ * @param {object} [config] — initial configuration (all optional)
+ * @param {number} [config.zoom=1.0]
+ * @param {number} [config.zoomX] per-axis zoom override
+ * @param {number} [config.zoomY] per-axis zoom override
+ * @param {number} [config.deadzone=0]
+ * @param {number} [config.inputCurve=1.0]
+ * @param {number} [config.inputCurveX] per-axis curve override
+ * @param {number} [config.inputCurveY] per-axis curve override
+ * @param {number} [config.smoothing=0]
+ * @param {string} [config.momentumZoom='off'] 'off' | 'gentle' | 'strong'
+ * @param {number} [config.velocityWindow=150] velocity estimation window in ms
+ * @param {string} [config.anchorMode='auto'] 'auto' | 'sticky' | 'center'
+ * @param {number} [config.anchorX] explicit anchor X (for sticky mode)
+ * @param {number} [config.anchorY] explicit anchor Y (for sticky mode)
+ * @param {boolean} [config.invertX=false]
+ * @param {boolean} [config.invertY=false]
+ */
+ constructor(config = {}) {
+ // --- Configuration ---
+ this._zoom = DEFAULT_ZOOM;
+ this._zoomX = null; // null = use global zoom
+ this._zoomY = null;
+ this._deadzone = DEFAULT_DEADZONE;
+ this._inputCurve = DEFAULT_INPUT_CURVE;
+ this._inputCurveX = null;
+ this._inputCurveY = null;
+ this._smoothing = DEFAULT_SMOOTHING;
+ this._momentumZoom = DEFAULT_MOMENTUM_ZOOM;
+ this._velocityWindow = DEFAULT_VELOCITY_WINDOW;
+ this._anchorMode = DEFAULT_ANCHOR_MODE;
+ this._anchorX = 0.5;
+ this._anchorY = 0.5;
+ this._invertX = false;
+ this._invertY = false;
+
+ // --- Internal state ---
+ this._smoothedX = 0.5;
+ this._smoothedY = 0.5;
+ this._frozen = false;
+
+ // Velocity estimation ring buffer: { x, y, t }
+ this._velocityHistory = [];
+ this._currentVelocity = 0; // magnitude, [0,1]-space units per second
+ this._momentumZoomMultiplier = 1; // current momentum zoom factor
+
+ // Apply any initial config overrides
+ if (config && typeof config === 'object') {
+ this.setConfig(config);
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // Main processing
+ // -----------------------------------------------------------------------
+
+ /**
+ * Process raw input through the full pipeline.
+ *
+ * @param {number} rawX — raw input X in [0,1]
+ * @param {number} rawY — raw input Y in [0,1]
+ * @param {number} deltaTime — time since last call in seconds (e.g. 0.016)
+ * @returns {{ x: number, y: number, frozen: boolean }}
+ */
+ process(rawX, rawY, deltaTime) {
+ const dt = Math.max(0, deltaTime || 0);
+
+ // Resolve effective zoom per-axis
+ const baseZoomX = this._zoomX != null ? this._zoomX : this._zoom;
+ const baseZoomY = this._zoomY != null ? this._zoomY : this._zoom;
+
+ // Check freeze *before* momentum modulation
+ const frozenX = baseZoomX <= FREEZE_THRESHOLD;
+ const frozenY = baseZoomY <= FREEZE_THRESHOLD;
+ this._frozen = frozenX && frozenY;
+
+ if (this._frozen) {
+ // Both axes frozen — return last smoothed output, skip everything
+ return { x: this._smoothedX, y: this._smoothedY, frozen: true };
+ }
+
+ // --- 0. Invert ---
+ let x = this._invertX ? 1 - rawX : rawX;
+ let y = this._invertY ? 1 - rawY : rawY;
+
+ // --- 1. Deadzone ---
+ x = applyDeadzone(x, this._deadzone);
+ y = applyDeadzone(y, this._deadzone);
+
+ // --- 2. Zoom ---
+ const anchorX = this._resolveAnchorX();
+ const anchorY = this._resolveAnchorY();
+
+ // Apply momentum-zoom multiplier to base zoom
+ const effZoomX = frozenX ? FREEZE_THRESHOLD : clamp(baseZoomX * this._momentumZoomMultiplier, ZOOM_MIN, ZOOM_MAX);
+ const effZoomY = frozenY ? FREEZE_THRESHOLD : clamp(baseZoomY * this._momentumZoomMultiplier, ZOOM_MIN, ZOOM_MAX);
+
+ x = frozenX ? this._smoothedX : applyZoom(x, anchorX, effZoomX);
+ y = frozenY ? this._smoothedY : applyZoom(y, anchorY, effZoomY);
+
+ // --- 3. Input Curve ---
+ const curveX = this._inputCurveX != null ? this._inputCurveX : this._inputCurve;
+ const curveY = this._inputCurveY != null ? this._inputCurveY : this._inputCurve;
+ if (!frozenX) x = centeredPowerCurve(x, curveX);
+ if (!frozenY) y = centeredPowerCurve(y, curveY);
+
+ // --- 4. Smoothing ---
+ if (!frozenX) this._smoothedX = emaSmooth(this._smoothedX, x, this._smoothing, dt);
+ if (!frozenY) this._smoothedY = emaSmooth(this._smoothedY, y, this._smoothing, dt);
+
+ // --- 5. Momentum-as-zoom (update for *next* frame) ---
+ this._updateMomentum(rawX, rawY, dt);
+
+ return {
+ x: this._smoothedX,
+ y: this._smoothedY,
+ frozen: false,
+ };
+ }
+
+ // -----------------------------------------------------------------------
+ // Configuration setters
+ // -----------------------------------------------------------------------
+
+ /**
+ * Set global zoom level.
+ * In 'auto' anchor mode, updates the anchor to the current smoothed position
+ * when zoom changes.
+ * @param {number} level — 0.01 to 1.0
+ */
+ setZoom(level) {
+ const prev = this._zoom;
+ this._zoom = clamp(level, ZOOM_MIN, ZOOM_MAX);
+ if (this._anchorMode === 'auto' && prev !== this._zoom) {
+ this._anchorX = this._smoothedX;
+ this._anchorY = this._smoothedY;
+ }
+ }
+
+ /**
+ * Set per-axis zoom overrides. Pass null to clear an axis override.
+ * @param {number|null} x — zoom for X axis (0.01-1.0), or null
+ * @param {number|null} y — zoom for Y axis (0.01-1.0), or null
+ */
+ setZoomPerAxis(x, y) {
+ this._zoomX = x != null ? clamp(x, ZOOM_MIN, ZOOM_MAX) : null;
+ this._zoomY = y != null ? clamp(y, ZOOM_MIN, ZOOM_MAX) : null;
+ if (this._anchorMode === 'auto') {
+ this._anchorX = this._smoothedX;
+ this._anchorY = this._smoothedY;
+ }
+ }
+
+ /**
+ * Set explicit anchor point (used in sticky mode, also sets anchor for auto).
+ * @param {number} x — anchor X in [0,1]
+ * @param {number} y — anchor Y in [0,1]
+ */
+ setAnchor(x, y) {
+ this._anchorX = clamp(x, 0, 1);
+ this._anchorY = clamp(y, 0, 1);
+ }
+
+ /**
+ * Set anchor mode.
+ * @param {'auto'|'sticky'|'center'} mode
+ */
+ setAnchorMode(mode) {
+ if (mode !== 'auto' && mode !== 'sticky' && mode !== 'center') return;
+ this._anchorMode = mode;
+ if (mode === 'center') {
+ this._anchorX = 0.5;
+ this._anchorY = 0.5;
+ } else if (mode === 'auto') {
+ // Snap anchor to current position
+ this._anchorX = this._smoothedX;
+ this._anchorY = this._smoothedY;
+ }
+ }
+
+ /**
+ * Set deadzone as a fraction of half-travel.
+ * @param {number} pct — 0 to 0.4
+ */
+ setDeadzone(pct) {
+ this._deadzone = clamp(pct, 0, DEADZONE_MAX);
+ }
+
+ /**
+ * Set input curve exponent (applies to both axes unless per-axis is set).
+ * @param {number} exp — 0.2 to 5.0 (1.0 = linear)
+ */
+ setInputCurve(exp) {
+ this._inputCurve = clamp(exp, INPUT_CURVE_MIN, INPUT_CURVE_MAX);
+ }
+
+ /**
+ * Set per-axis input curve overrides. Pass null to clear.
+ * @param {number|null} expX — curve exponent for X, or null
+ * @param {number|null} expY — curve exponent for Y, or null
+ */
+ setInputCurvePerAxis(expX, expY) {
+ this._inputCurveX = expX != null ? clamp(expX, INPUT_CURVE_MIN, INPUT_CURVE_MAX) : null;
+ this._inputCurveY = expY != null ? clamp(expY, INPUT_CURVE_MIN, INPUT_CURVE_MAX) : null;
+ }
+
+ /**
+ * Set EMA smoothing factor.
+ * @param {number} factor — 0 (off) to 0.95
+ */
+ setSmoothing(factor) {
+ this._smoothing = clamp(factor, 0, SMOOTHING_MAX);
+ }
+
+ /**
+ * Set momentum-zoom mode.
+ * @param {'off'|'gentle'|'strong'} mode
+ */
+ setMomentumZoom(mode) {
+ if (!MOMENTUM_PRESETS.hasOwnProperty(mode)) return;
+ this._momentumZoom = mode;
+ if (mode === 'off') {
+ this._momentumZoomMultiplier = 1;
+ this._velocityHistory = [];
+ this._currentVelocity = 0;
+ }
+ }
+
+ /**
+ * Set axis inversion.
+ * @param {boolean} invertX
+ * @param {boolean} invertY
+ */
+ setInvert(invertX, invertY) {
+ this._invertX = !!invertX;
+ this._invertY = !!invertY;
+ }
+
+ /**
+ * Set velocity estimation window for momentum-zoom.
+ * @param {number} ms — window in milliseconds (50-500)
+ */
+ setVelocityWindow(ms) {
+ this._velocityWindow = clamp(ms, 50, 500);
+ }
+
+ // -----------------------------------------------------------------------
+ // State queries
+ // -----------------------------------------------------------------------
+
+ /**
+ * Get current effective zoom level (after momentum modulation).
+ * Returns the *minimum* of X and Y effective zoom if they differ.
+ * @returns {number}
+ */
+ getZoomLevel() {
+ const baseX = this._zoomX != null ? this._zoomX : this._zoom;
+ const baseY = this._zoomY != null ? this._zoomY : this._zoom;
+ const effX = clamp(baseX * this._momentumZoomMultiplier, ZOOM_MIN, ZOOM_MAX);
+ const effY = clamp(baseY * this._momentumZoomMultiplier, ZOOM_MIN, ZOOM_MAX);
+ return Math.min(effX, effY);
+ }
+
+ /**
+ * Get current anchor position.
+ * @returns {{ x: number, y: number }}
+ */
+ getAnchor() {
+ return { x: this._resolveAnchorX(), y: this._resolveAnchorY() };
+ }
+
+ /**
+ * Get the zoom window bounds in [0,1] space — useful for minimap rendering.
+ * Returns the rectangle of input space that the joystick currently covers.
+ * @returns {{ x1: number, y1: number, x2: number, y2: number }}
+ */
+ getZoomWindow() {
+ const anchorX = this._resolveAnchorX();
+ const anchorY = this._resolveAnchorY();
+ const baseZoomX = this._zoomX != null ? this._zoomX : this._zoom;
+ const baseZoomY = this._zoomY != null ? this._zoomY : this._zoom;
+ const effZoomX = clamp(baseZoomX * this._momentumZoomMultiplier, ZOOM_MIN, ZOOM_MAX);
+ const effZoomY = clamp(baseZoomY * this._momentumZoomMultiplier, ZOOM_MIN, ZOOM_MAX);
+ const halfX = effZoomX / 2;
+ const halfY = effZoomY / 2;
+ return {
+ x1: clamp(anchorX - halfX, 0, 1),
+ y1: clamp(anchorY - halfY, 0, 1),
+ x2: clamp(anchorX + halfX, 0, 1),
+ y2: clamp(anchorY + halfY, 0, 1),
+ };
+ }
+
+ /**
+ * Whether input is effectively frozen (zoom at or below minimum).
+ * @returns {boolean}
+ */
+ isFrozen() {
+ return this._frozen;
+ }
+
+ // -----------------------------------------------------------------------
+ // Serialization
+ // -----------------------------------------------------------------------
+
+ /**
+ * Export all settings as a plain object (no internal state).
+ * @returns {object}
+ */
+ getConfig() {
+ return {
+ zoom: this._zoom,
+ zoomX: this._zoomX,
+ zoomY: this._zoomY,
+ deadzone: this._deadzone,
+ inputCurve: this._inputCurve,
+ inputCurveX: this._inputCurveX,
+ inputCurveY: this._inputCurveY,
+ smoothing: this._smoothing,
+ momentumZoom: this._momentumZoom,
+ velocityWindow: this._velocityWindow,
+ anchorMode: this._anchorMode,
+ anchorX: this._anchorX,
+ anchorY: this._anchorY,
+ invertX: this._invertX,
+ invertY: this._invertY,
+ };
+ }
+
+ /**
+ * Restore configuration from a plain object. Unknown keys are ignored.
+ * Only updates settings that are present in the object.
+ * @param {object} config
+ */
+ setConfig(config) {
+ if (config.zoom != null) this.setZoom(config.zoom);
+ if (config.deadzone != null) this.setDeadzone(config.deadzone);
+ if (config.inputCurve != null) this.setInputCurve(config.inputCurve);
+ if (config.smoothing != null) this.setSmoothing(config.smoothing);
+ if (config.momentumZoom != null) this.setMomentumZoom(config.momentumZoom);
+ if (config.velocityWindow != null) this.setVelocityWindow(config.velocityWindow);
+ if (config.anchorMode != null) this.setAnchorMode(config.anchorMode);
+ if (config.invertX != null || config.invertY != null) {
+ this.setInvert(
+ config.invertX != null ? config.invertX : this._invertX,
+ config.invertY != null ? config.invertY : this._invertY,
+ );
+ }
+ // Per-axis overrides (allow explicit null to clear)
+ if ('zoomX' in config || 'zoomY' in config) {
+ this.setZoomPerAxis(
+ 'zoomX' in config ? config.zoomX : this._zoomX,
+ 'zoomY' in config ? config.zoomY : this._zoomY,
+ );
+ }
+ if ('inputCurveX' in config || 'inputCurveY' in config) {
+ this.setInputCurvePerAxis(
+ 'inputCurveX' in config ? config.inputCurveX : this._inputCurveX,
+ 'inputCurveY' in config ? config.inputCurveY : this._inputCurveY,
+ );
+ }
+ // Explicit anchor (set after anchorMode so sticky mode is already active)
+ if (config.anchorX != null && config.anchorY != null) {
+ this.setAnchor(config.anchorX, config.anchorY);
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // Reset
+ // -----------------------------------------------------------------------
+
+ /**
+ * Reset all settings to defaults and clear internal state.
+ */
+ reset() {
+ this._zoom = DEFAULT_ZOOM;
+ this._zoomX = null;
+ this._zoomY = null;
+ this._deadzone = DEFAULT_DEADZONE;
+ this._inputCurve = DEFAULT_INPUT_CURVE;
+ this._inputCurveX = null;
+ this._inputCurveY = null;
+ this._smoothing = DEFAULT_SMOOTHING;
+ this._momentumZoom = DEFAULT_MOMENTUM_ZOOM;
+ this._velocityWindow = DEFAULT_VELOCITY_WINDOW;
+ this._anchorMode = DEFAULT_ANCHOR_MODE;
+ this._anchorX = 0.5;
+ this._anchorY = 0.5;
+ this._invertX = false;
+ this._invertY = false;
+
+ this._smoothedX = 0.5;
+ this._smoothedY = 0.5;
+ this._frozen = false;
+ this._velocityHistory = [];
+ this._currentVelocity = 0;
+ this._momentumZoomMultiplier = 1;
+ }
+
+ // -----------------------------------------------------------------------
+ // Private helpers
+ // -----------------------------------------------------------------------
+
+ /** Resolve anchor X based on current mode. */
+ _resolveAnchorX() {
+ if (this._anchorMode === 'center') return 0.5;
+ return this._anchorX;
+ }
+
+ /** Resolve anchor Y based on current mode. */
+ _resolveAnchorY() {
+ if (this._anchorMode === 'center') return 0.5;
+ return this._anchorY;
+ }
+
+ /**
+ * Update velocity estimation and momentum-zoom multiplier.
+ * Called at the end of each process() with the *raw* (pre-pipeline) input
+ * so velocity reflects actual physical movement, not processed output.
+ */
+ _updateMomentum(rawX, rawY, dt) {
+ const preset = MOMENTUM_PRESETS[this._momentumZoom];
+ if (!preset) {
+ this._momentumZoomMultiplier = 1;
+ return;
+ }
+
+ const now = performance.now();
+
+ // Push current sample
+ this._velocityHistory.push({ x: rawX, y: rawY, t: now });
+
+ // Prune samples outside the velocity window
+ const windowStart = now - this._velocityWindow;
+ while (this._velocityHistory.length > 1 && this._velocityHistory[0].t < windowStart) {
+ this._velocityHistory.shift();
+ }
+
+ // Estimate velocity (distance in [0,1] space per second)
+ if (this._velocityHistory.length >= 2) {
+ const first = this._velocityHistory[0];
+ const last = this._velocityHistory[this._velocityHistory.length - 1];
+ const elapsed = (last.t - first.t) / 1000; // seconds
+ if (elapsed > 0.001) {
+ const dx = last.x - first.x;
+ const dy = last.y - first.y;
+ const dist = Math.sqrt(dx * dx + dy * dy);
+ this._currentVelocity = dist / elapsed;
+ }
+ }
+
+ // Map velocity to zoom multiplier.
+ // Slow movement (velocity ~ 0) → low multiplier (zoomed in, fine control)
+ // Fast movement (velocity high) → high multiplier (zoomed out, broad traversal)
+ //
+ // Velocity is in [0,1]-space units per second.
+ // Typical slow movement: 0.1–0.5 u/s
+ // Typical fast sweep: 2–6 u/s
+ //
+ // We use a sigmoid-like mapping so the transition feels smooth.
+ const v = this._currentVelocity;
+ const { factor, minZoomMul, maxZoomMul } = preset;
+
+ // Normalised speed: 0 at rest, approaches 1 at high velocity
+ // Using 1 - exp(-factor * v) which is smooth and bounded
+ const normalised = 1 - Math.exp(-factor * v);
+
+ // Interpolate between minZoomMul (slow) and maxZoomMul (fast)
+ const target = minZoomMul + normalised * (maxZoomMul - minZoomMul);
+
+ // Smooth the multiplier itself to avoid jitter
+ // Use a fast-attack, slow-release envelope so zoom-out is instant
+ // but zoom-in (slowing down) ramps gently
+ const attackRate = 12; // per second — fast response to speed increase
+ const releaseRate = 3; // per second — gentle return when slowing down
+ const rate = target > this._momentumZoomMultiplier ? attackRate : releaseRate;
+ const blend = 1 - Math.exp(-rate * dt);
+ this._momentumZoomMultiplier += blend * (target - this._momentumZoomMultiplier);
+ }
+}
diff --git a/playground/js/ui/joy-map-enhanced.js b/playground/js/ui/joy-map-enhanced.js
new file mode 100644
index 0000000..c1da6fb
--- /dev/null
+++ b/playground/js/ui/joy-map-enhanced.js
@@ -0,0 +1,739 @@
+// Enhanced Joy-Map Visualization
+// Zoom minimap (7.1), vanishing trail (7.2), noise ring (7.3)
+// Replaces/augments the basic drawJoyMap() in a-app.js
+
+// ---- Catmull-Rom spline helper ----
+
+function catmullRomPoint(p0, p1, p2, p3, t) {
+ // Centripetal Catmull-Rom (alpha=0.5 simplified to uniform for speed)
+ const t2 = t * t;
+ const t3 = t2 * t;
+ return {
+ x: 0.5 * (
+ (2 * p1.x) +
+ (-p0.x + p2.x) * t +
+ (2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2 +
+ (-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3
+ ),
+ y: 0.5 * (
+ (2 * p1.y) +
+ (-p0.y + p2.y) * t +
+ (2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2 +
+ (-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3
+ )
+ };
+}
+
+// ---- Ring buffer ----
+
+class RingBuffer {
+ constructor(capacity) {
+ this.capacity = capacity;
+ this.buffer = new Array(capacity);
+ this.head = 0; // next write index
+ this.count = 0;
+ }
+
+ push(item) {
+ this.buffer[this.head] = item;
+ this.head = (this.head + 1) % this.capacity;
+ if (this.count < this.capacity) this.count++;
+ }
+
+ // Iterate oldest to newest
+ forEach(fn) {
+ if (this.count === 0) return;
+ const start = (this.head - this.count + this.capacity) % this.capacity;
+ for (let i = 0; i < this.count; i++) {
+ const idx = (start + i) % this.capacity;
+ fn(this.buffer[idx], i, this.count);
+ }
+ }
+
+ // Get item by age-ordered index (0 = oldest)
+ get(i) {
+ if (i < 0 || i >= this.count) return null;
+ const start = (this.head - this.count + this.capacity) % this.capacity;
+ return this.buffer[(start + i) % this.capacity];
+ }
+
+ clear() {
+ this.head = 0;
+ this.count = 0;
+ }
+}
+
+// ---- Constants ----
+
+const TRAIL_COLORS = {
+ visual: '#ff6432',
+ synth: '#00d4aa'
+};
+
+const FROZEN_COLOR = 'rgba(100, 180, 255, 0.15)';
+const ZOOM_WINDOW_FILL = 'rgba(255, 200, 100, 0.06)';
+const ZOOM_WINDOW_BORDER = 'rgba(255, 200, 100, 0.6)';
+const DIM_OVERLAY = 'rgba(0, 0, 0, 0.35)';
+const GRID_MINOR = 'rgba(255, 255, 255, 0.08)';
+const GRID_MAJOR = 'rgba(255, 255, 255, 0.15)';
+const CURSOR_GLOW = 'rgba(255, 255, 255, 0.35)';
+
+const DEFAULT_TRAIL_DURATION = 5000; // ms
+const MAX_TRAIL_POINTS = 300;
+const TRAIL_MIN_WIDTH = 1.5;
+const TRAIL_MAX_WIDTH = 4;
+const CURSOR_RADIUS = 6;
+const TAP_HIT_RADIUS = 12;
+const SPLINE_SEGMENTS = 4; // subdivisions per trail segment
+
+// ---- Main class ----
+
+export class JoyMapEnhanced {
+ /**
+ * @param {HTMLCanvasElement} canvas - The joy-map canvas element
+ * @param {Object} options
+ * @param {Function} options.onTrailTap - Callback({x, y}) when a trail point is tapped
+ * @param {Function} options.getTrainingData - Returns {features, labels} for example dots
+ */
+ constructor(canvas, options = {}) {
+ this.canvas = canvas;
+ this.ctx = canvas.getContext('2d');
+ this.onTrailTap = options.onTrailTap || null;
+ this.getTrainingData = options.getTrainingData || null;
+
+ this.trail = new RingBuffer(MAX_TRAIL_POINTS);
+ this.trailDuration = DEFAULT_TRAIL_DURATION;
+ this.trailColor = TRAIL_COLORS.visual;
+
+ this.pinnedRegions = [];
+
+ // Flash state for tapped trail point
+ this._flashPoint = null;
+ this._flashTime = 0;
+
+ // Pulse phase for noise ring
+ this._pulsePhase = 0;
+
+ // HiDPI tracking
+ this._dpr = 1;
+ this._cssW = 0;
+ this._cssH = 0;
+
+ // Bind event handlers
+ this._onPointerDown = this._handlePointerDown.bind(this);
+ this._onTouchStart = this._handleTouchStart.bind(this);
+
+ canvas.addEventListener('pointerdown', this._onPointerDown);
+ canvas.addEventListener('touchstart', this._onTouchStart, { passive: false });
+ }
+
+ // ---- Public API ----
+
+ /**
+ * Main draw call. Invoke once per rAF frame.
+ */
+ draw(state) {
+ const {
+ joyX = 0.5,
+ joyY = 0.5,
+ effectiveX,
+ effectiveY,
+ zoomWindow = null,
+ zoomLevel = 1.0,
+ noiseLevel = 0,
+ outputMode = 'visual',
+ frozen = false
+ } = state;
+
+ this._ensureHiDPI();
+ const ctx = this.ctx;
+ const w = this._cssW;
+ const h = this._cssH;
+ const dpr = this._dpr;
+
+ ctx.save();
+ ctx.scale(dpr, dpr);
+
+ // Clip to circle
+ const cx = w / 2;
+ const cy = h / 2;
+ const r = w / 2 - 1;
+
+ ctx.beginPath();
+ ctx.arc(cx, cy, r, 0, Math.PI * 2);
+ ctx.clip();
+
+ // Background
+ ctx.fillStyle = 'rgba(13, 13, 13, 0.85)';
+ ctx.fillRect(0, 0, w, h);
+
+ // Layers (back to front):
+ // 1. Dim area outside zoom window
+ // 2. Grid (adapts to zoom)
+ // 3. Zoom window border
+ // 4. Pinned regions
+ // 5. Training examples
+ // 6. Trail
+ // 7. Noise ring (canvas-drawn)
+ // 8. Zoom ring (canvas-drawn)
+ // 9. Cursor
+ // 10. Frozen overlay
+
+ const zw = this._normalizeZoomWindow(zoomWindow, zoomLevel);
+
+ this._drawDimOverlay(ctx, w, h, zw);
+ this._drawGrid(ctx, w, h, zw, zoomLevel);
+ this._drawZoomWindowBorder(ctx, w, h, zw);
+ this._drawPinnedRegions(ctx, w, h);
+ this._drawTrainingExamples(ctx, w, h);
+ this._drawTrail(ctx, w, h, outputMode);
+ this._drawNoiseRings(ctx, cx, cy, r, zoomLevel, noiseLevel);
+ this._drawCursor(ctx, w, h, joyX, joyY, effectiveX, effectiveY, frozen);
+ this._drawFlash(ctx, w, h);
+
+ if (frozen) {
+ this._drawFrozenOverlay(ctx, w, h, cx, cy, r);
+ }
+
+ // Circle border (outermost)
+ ctx.beginPath();
+ ctx.arc(cx, cy, r, 0, Math.PI * 2);
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.12)';
+ ctx.lineWidth = 1.5;
+ ctx.stroke();
+
+ ctx.restore();
+
+ // Advance pulse
+ this._pulsePhase += 0.04;
+ }
+
+ /**
+ * Record a trail point. Call when joystick moves.
+ */
+ addTrailPoint(x, y, zoomLevel = 1.0) {
+ const now = performance.now();
+ // Deduplicate: skip if very close to last point and recent
+ if (this.trail.count > 0) {
+ const last = this.trail.get(this.trail.count - 1);
+ const dx = x - last.x;
+ const dy = y - last.y;
+ if (dx * dx + dy * dy < 0.0001 && now - last.t < 50) return;
+ }
+ this.trail.push({ x, y, z: zoomLevel, t: now });
+ }
+
+ /**
+ * Handle tap events. Returns {x, y} if a trail point was tapped, null otherwise.
+ */
+ handleTap(canvasX, canvasY) {
+ const now = performance.now();
+ const w = this._cssW;
+ const h = this._cssH;
+ const hitR = TAP_HIT_RADIUS;
+ let bestDist = hitR * hitR;
+ let bestPoint = null;
+
+ this.trail.forEach((pt, i, count) => {
+ const age = now - pt.t;
+ if (age > this.trailDuration) return;
+ const px = pt.x * w;
+ const py = (1 - pt.y) * h;
+ const dx = canvasX - px;
+ const dy = canvasY - py;
+ const d2 = dx * dx + dy * dy;
+ if (d2 < bestDist) {
+ bestDist = d2;
+ bestPoint = pt;
+ }
+ });
+
+ if (bestPoint) {
+ this._flashPoint = { x: bestPoint.x, y: bestPoint.y };
+ this._flashTime = now;
+ return { x: bestPoint.x, y: bestPoint.y };
+ }
+ return null;
+ }
+
+ setPinnedRegions(regions) {
+ this.pinnedRegions = regions || [];
+ }
+
+ setTrailDuration(seconds) {
+ this.trailDuration = seconds * 1000;
+ }
+
+ setTrailColor(color) {
+ this.trailColor = color;
+ }
+
+ destroy() {
+ this.canvas.removeEventListener('pointerdown', this._onPointerDown);
+ this.canvas.removeEventListener('touchstart', this._onTouchStart);
+ this.trail.clear();
+ this.pinnedRegions = [];
+ }
+
+ // ---- HiDPI ----
+
+ _ensureHiDPI() {
+ const dpr = window.devicePixelRatio || 1;
+ const rect = this.canvas.getBoundingClientRect();
+ const cssW = Math.round(rect.width) || this.canvas.clientWidth || 160;
+ const cssH = Math.round(rect.height) || this.canvas.clientHeight || 160;
+
+ if (this._dpr !== dpr || this._cssW !== cssW || this._cssH !== cssH) {
+ this._dpr = dpr;
+ this._cssW = cssW;
+ this._cssH = cssH;
+ this.canvas.width = cssW * dpr;
+ this.canvas.height = cssH * dpr;
+ }
+ }
+
+ // ---- Zoom window ----
+
+ _normalizeZoomWindow(zw, zoomLevel) {
+ if (zw && typeof zw.x1 === 'number') return zw;
+ // Synthesize from zoomLevel centered on 0.5
+ const half = zoomLevel / 2;
+ return {
+ x1: 0.5 - half,
+ y1: 0.5 - half,
+ x2: 0.5 + half,
+ y2: 0.5 + half
+ };
+ }
+
+ // ---- Drawing layers ----
+
+ _drawDimOverlay(ctx, w, h, zw) {
+ if (zw.x1 <= 0 && zw.y1 <= 0 && zw.x2 >= 1 && zw.y2 >= 1) return; // no zoom
+
+ // Draw dim overlay over the entire area, then clear the zoom window
+ ctx.save();
+ ctx.fillStyle = DIM_OVERLAY;
+
+ // Use even-odd rule: outer rect minus zoom rect
+ ctx.beginPath();
+ ctx.rect(0, 0, w, h);
+ // Zoom window rect (Y inverted)
+ const zx1 = zw.x1 * w;
+ const zy1 = (1 - zw.y2) * h;
+ const zx2 = zw.x2 * w;
+ const zy2 = (1 - zw.y1) * h;
+ const zw_ = zx2 - zx1;
+ const zh_ = zy2 - zy1;
+ // Draw inner rect counter-clockwise for even-odd
+ ctx.moveTo(zx1, zy1);
+ ctx.lineTo(zx1, zy1 + zh_);
+ ctx.lineTo(zx1 + zw_, zy1 + zh_);
+ ctx.lineTo(zx1 + zw_, zy1);
+ ctx.closePath();
+ ctx.fill('evenodd');
+ ctx.restore();
+ }
+
+ _drawGrid(ctx, w, h, zw, zoomLevel) {
+ // Determine grid density based on zoom
+ // zoom 1.0 -> 4x4, 0.5 -> 8x8, 0.25 -> 16x16, etc.
+ // Base divisions = 4, multiply by 1/zoomLevel
+ const baseDivisions = 4;
+ const zoomScale = Math.max(zw.x2 - zw.x1, zw.y2 - zw.y1);
+
+ // We draw multiple grid levels with fading
+ // Level 0: 4x4 (always)
+ // Level 1: 8x8 (fades in as zoom < 0.75)
+ // Level 2: 16x16 (fades in as zoom < 0.375)
+ // Level 3: 32x32 (fades in as zoom < 0.1875)
+ const levels = [
+ { divs: 4, fadeStart: 2.0, fadeFull: 1.0 },
+ { divs: 8, fadeStart: 0.75, fadeFull: 0.5 },
+ { divs: 16, fadeStart: 0.375, fadeFull: 0.25 },
+ { divs: 32, fadeStart: 0.1875, fadeFull: 0.1 }
+ ];
+
+ for (const level of levels) {
+ let alpha;
+ if (zoomScale >= level.fadeStart) {
+ alpha = 0;
+ } else if (zoomScale <= level.fadeFull) {
+ alpha = 1;
+ } else {
+ alpha = 1 - (zoomScale - level.fadeFull) / (level.fadeStart - level.fadeFull);
+ }
+ if (alpha < 0.01) continue;
+
+ // Is this a "major" grid (4x4)?
+ const isMajor = level.divs === 4;
+ const baseAlpha = isMajor ? 0.15 : 0.08;
+ const finalAlpha = baseAlpha * alpha;
+
+ ctx.strokeStyle = `rgba(255, 255, 255, ${finalAlpha})`;
+ ctx.lineWidth = isMajor ? 0.8 : 0.5;
+
+ // Draw grid lines within the zoom window region
+ // Lines at intervals of 1/divs in input space
+ const step = 1 / level.divs;
+
+ ctx.beginPath();
+
+ // Vertical lines
+ for (let i = 0; i <= level.divs; i++) {
+ const inputX = i * step;
+ // Only draw if visible in the full map
+ const canvasX = inputX * w;
+ ctx.moveTo(canvasX, 0);
+ ctx.lineTo(canvasX, h);
+ }
+
+ // Horizontal lines
+ for (let i = 0; i <= level.divs; i++) {
+ const inputY = i * step;
+ const canvasY = (1 - inputY) * h;
+ ctx.moveTo(0, canvasY);
+ ctx.lineTo(w, canvasY);
+ }
+
+ ctx.stroke();
+ }
+ }
+
+ _drawZoomWindowBorder(ctx, w, h, zw) {
+ if (zw.x1 <= 0 && zw.y1 <= 0 && zw.x2 >= 1 && zw.y2 >= 1) return;
+
+ const x1 = zw.x1 * w;
+ const y1 = (1 - zw.y2) * h;
+ const rw = (zw.x2 - zw.x1) * w;
+ const rh = (zw.y2 - zw.y1) * h;
+
+ // Fill
+ ctx.fillStyle = ZOOM_WINDOW_FILL;
+ ctx.fillRect(x1, y1, rw, rh);
+
+ // Border
+ ctx.strokeStyle = ZOOM_WINDOW_BORDER;
+ ctx.lineWidth = 1.5;
+ ctx.setLineDash([4, 3]);
+ ctx.strokeRect(x1, y1, rw, rh);
+ ctx.setLineDash([]);
+ }
+
+ _drawPinnedRegions(ctx, w, h) {
+ for (const region of this.pinnedRegions) {
+ const x1 = region.x1 * w;
+ const y1 = (1 - region.y2) * h;
+ const rw = (region.x2 - region.x1) * w;
+ const rh = (region.y2 - region.y1) * h;
+ const color = region.color || 'rgba(100, 200, 255, 0.15)';
+
+ ctx.fillStyle = color;
+ ctx.fillRect(x1, y1, rw, rh);
+
+ ctx.strokeStyle = color.replace(/[\d.]+\)$/, '0.5)');
+ ctx.lineWidth = 1;
+ ctx.strokeRect(x1, y1, rw, rh);
+ }
+ }
+
+ _drawTrainingExamples(ctx, w, h) {
+ if (!this.getTrainingData) return;
+ const data = this.getTrainingData();
+ if (!data) return;
+
+ const { features, labels } = data;
+ for (let i = 0; i < features.length; i++) {
+ const fx = features[i][0] * w;
+ const fy = (1 - features[i][1]) * h;
+ let hue = 0;
+ if (labels[i]) {
+ hue = (labels[i][3] || 0) * 360;
+ }
+ ctx.fillStyle = `hsla(${hue}, 80%, 60%, 0.85)`;
+ ctx.beginPath();
+ ctx.arc(fx, fy, 3, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ }
+
+ _drawTrail(ctx, w, h, outputMode) {
+ const now = performance.now();
+ const duration = this.trailDuration;
+ const baseColor = TRAIL_COLORS[outputMode] || this.trailColor;
+
+ // Collect alive points
+ const alive = [];
+ this.trail.forEach((pt) => {
+ const age = now - pt.t;
+ if (age <= duration) {
+ alive.push(pt);
+ }
+ });
+
+ if (alive.length < 2) return;
+
+ // Parse base color to RGB
+ const rgb = this._parseColor(baseColor);
+
+ // Draw spline segments using Catmull-Rom interpolation
+ // Pre-convert control points to canvas space
+ const canvasPts = alive.map(pt => ({
+ x: pt.x * w,
+ y: (1 - pt.y) * h,
+ z: pt.z,
+ t: pt.t
+ }));
+
+ for (let i = 0; i < canvasPts.length - 1; i++) {
+ const cp0 = canvasPts[Math.max(0, i - 1)];
+ const cp1 = canvasPts[i];
+ const cp2 = canvasPts[i + 1];
+ const cp3 = canvasPts[Math.min(canvasPts.length - 1, i + 2)];
+
+ let prev = catmullRomPoint(cp0, cp1, cp2, cp3, 0);
+
+ for (let s = 1; s <= SPLINE_SEGMENTS; s++) {
+ const t = s / SPLINE_SEGMENTS;
+ const curr = catmullRomPoint(cp0, cp1, cp2, cp3, t);
+
+ // Interpolate age and zoom along the segment
+ const segAge = now - (cp1.t + (cp2.t - cp1.t) * (t - 0.5 / SPLINE_SEGMENTS));
+ const segZoom = cp1.z + (cp2.z - cp1.z) * t;
+
+ const alpha = Math.max(0, 1 - segAge / duration);
+ const lineW = TRAIL_MIN_WIDTH + (TRAIL_MAX_WIDTH - TRAIL_MIN_WIDTH) * segZoom;
+
+ ctx.beginPath();
+ ctx.moveTo(prev.x, prev.y);
+ ctx.lineTo(curr.x, curr.y);
+ ctx.strokeStyle = `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha * 0.8})`;
+ ctx.lineWidth = lineW;
+ ctx.lineCap = 'round';
+ ctx.stroke();
+
+ prev = curr;
+ }
+ }
+
+ // Draw trail point dots (for tap targets) — only recent, visible ones
+ for (const pt of alive) {
+ const age = now - pt.t;
+ const alpha = Math.max(0, 1 - age / duration);
+ if (alpha < 0.1) continue;
+
+ const px = pt.x * w;
+ const py = (1 - pt.y) * h;
+ const dotR = 2 + alpha * 2;
+
+ ctx.beginPath();
+ ctx.arc(px, py, dotR, 0, Math.PI * 2);
+ ctx.fillStyle = `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha * 0.5})`;
+ ctx.fill();
+ }
+ }
+
+ _drawFlash(ctx, w, h) {
+ if (!this._flashPoint) return;
+ const now = performance.now();
+ const elapsed = now - this._flashTime;
+ const flashDuration = 300;
+ if (elapsed > flashDuration) {
+ this._flashPoint = null;
+ return;
+ }
+
+ const alpha = 1 - elapsed / flashDuration;
+ const radius = 6 + (elapsed / flashDuration) * 14;
+ const px = this._flashPoint.x * w;
+ const py = (1 - this._flashPoint.y) * h;
+
+ ctx.beginPath();
+ ctx.arc(px, py, radius, 0, Math.PI * 2);
+ ctx.fillStyle = `rgba(255, 255, 255, ${alpha * 0.6})`;
+ ctx.fill();
+ }
+
+ _drawNoiseRings(ctx, cx, cy, maxR, zoomLevel, noiseLevel) {
+ // Inner ring: zoom level (shrinks as zoom decreases)
+ const zoomR = maxR * Math.max(0.1, zoomLevel);
+ ctx.beginPath();
+ ctx.arc(cx, cy, zoomR, 0, Math.PI * 2);
+ ctx.strokeStyle = `rgba(255, 200, 100, ${0.15 + 0.15 * (1 - zoomLevel)})`;
+ ctx.lineWidth = 1.5;
+ ctx.stroke();
+
+ // Outer ring: noise magnitude
+ if (noiseLevel > 0.001) {
+ const noiseR = zoomR + 4 + noiseLevel * (maxR - zoomR - 4) * 3;
+ const clampedR = Math.min(noiseR, maxR - 1);
+
+ // Pulse when noise is high
+ const isHigh = noiseLevel > 0.1;
+ const pulse = isHigh ? Math.sin(this._pulsePhase) * 0.15 : 0;
+ const noiseAlpha = 0.2 + noiseLevel * 2 + pulse;
+
+ ctx.beginPath();
+ ctx.arc(cx, cy, clampedR, 0, Math.PI * 2);
+ ctx.strokeStyle = `rgba(255, 80, 80, ${Math.min(0.8, noiseAlpha)})`;
+ ctx.lineWidth = 2 + noiseLevel * 4;
+ ctx.stroke();
+
+ // Glow on high noise
+ if (isHigh) {
+ ctx.beginPath();
+ ctx.arc(cx, cy, clampedR, 0, Math.PI * 2);
+ ctx.strokeStyle = `rgba(255, 80, 80, ${Math.min(0.3, noiseAlpha * 0.3)})`;
+ ctx.lineWidth = 6 + noiseLevel * 8;
+ ctx.stroke();
+ }
+ }
+ }
+
+ _drawCursor(ctx, w, h, joyX, joyY, effectiveX, effectiveY, frozen) {
+ const px = joyX * w;
+ const py = (1 - joyY) * h;
+
+ // If we have effective coords, draw a ghost line from physical to effective
+ if (effectiveX !== undefined && effectiveY !== undefined) {
+ const ex = effectiveX * w;
+ const ey = (1 - effectiveY) * h;
+ const dx = ex - px;
+ const dy = ey - py;
+ if (dx * dx + dy * dy > 4) {
+ ctx.beginPath();
+ ctx.moveTo(px, py);
+ ctx.lineTo(ex, ey);
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)';
+ ctx.lineWidth = 0.5;
+ ctx.setLineDash([2, 2]);
+ ctx.stroke();
+ ctx.setLineDash([]);
+
+ // Effective position indicator (small ring)
+ ctx.beginPath();
+ ctx.arc(ex, ey, 3, 0, Math.PI * 2);
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
+ ctx.lineWidth = 1;
+ ctx.stroke();
+ }
+ }
+
+ // Crosshair (subtle)
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.06)';
+ ctx.lineWidth = 0.5;
+ ctx.beginPath();
+ ctx.moveTo(px, 0);
+ ctx.lineTo(px, h);
+ ctx.moveTo(0, py);
+ ctx.lineTo(w, py);
+ ctx.stroke();
+
+ // Glow
+ ctx.save();
+ ctx.shadowColor = frozen ? 'rgba(100, 180, 255, 0.7)' : CURSOR_GLOW;
+ ctx.shadowBlur = 12;
+
+ // Outer dot
+ ctx.beginPath();
+ ctx.arc(px, py, CURSOR_RADIUS, 0, Math.PI * 2);
+ ctx.fillStyle = frozen ? 'rgba(100, 180, 255, 0.7)' : 'rgba(255, 255, 255, 0.6)';
+ ctx.fill();
+
+ // Inner bright dot
+ ctx.shadowBlur = 0;
+ ctx.beginPath();
+ ctx.arc(px, py, 2.5, 0, Math.PI * 2);
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
+ ctx.fill();
+
+ ctx.restore();
+ }
+
+ _drawFrozenOverlay(ctx, w, h, cx, cy, r) {
+ // Subtle blue tint
+ ctx.fillStyle = FROZEN_COLOR;
+ ctx.beginPath();
+ ctx.arc(cx, cy, r, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Snowflake icon (simple asterisk-like)
+ ctx.save();
+ ctx.translate(cx, cy);
+ ctx.strokeStyle = 'rgba(150, 210, 255, 0.4)';
+ ctx.lineWidth = 1.5;
+ ctx.lineCap = 'round';
+
+ const armLen = 12;
+ const tickLen = 4;
+ for (let i = 0; i < 6; i++) {
+ const angle = (Math.PI / 3) * i;
+ ctx.save();
+ ctx.rotate(angle);
+
+ // Main arm
+ ctx.beginPath();
+ ctx.moveTo(0, 0);
+ ctx.lineTo(0, -armLen);
+ ctx.stroke();
+
+ // Ticks
+ ctx.beginPath();
+ ctx.moveTo(-tickLen * 0.5, -armLen * 0.55);
+ ctx.lineTo(0, -armLen * 0.7);
+ ctx.lineTo(tickLen * 0.5, -armLen * 0.55);
+ ctx.stroke();
+
+ ctx.restore();
+ }
+
+ ctx.restore();
+
+ // "FROZEN" text
+ ctx.fillStyle = 'rgba(150, 210, 255, 0.35)';
+ ctx.font = `bold ${Math.round(w * 0.065)}px system-ui, sans-serif`;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText('FROZEN', cx, cy + armLen + 14);
+ }
+
+ // ---- Event handling ----
+
+ _handlePointerDown(e) {
+ // Only respond to primary button (not right-click) and not if dragging
+ if (e.button !== 0) return;
+ this._tryTap(e.offsetX, e.offsetY);
+ }
+
+ _handleTouchStart(e) {
+ if (e.touches.length !== 1) return;
+ const rect = this.canvas.getBoundingClientRect();
+ const touch = e.touches[0];
+ const x = touch.clientX - rect.left;
+ const y = touch.clientY - rect.top;
+ this._tryTap(x, y);
+ }
+
+ _tryTap(cssX, cssY) {
+ const result = this.handleTap(cssX, cssY);
+ if (result && this.onTrailTap) {
+ this.onTrailTap(result);
+ }
+ }
+
+ // ---- Utilities ----
+
+ _parseColor(hex) {
+ // Parse #rrggbb or rgb(r,g,b)
+ if (hex.startsWith('#')) {
+ const bigint = parseInt(hex.slice(1), 16);
+ return {
+ r: (bigint >> 16) & 255,
+ g: (bigint >> 8) & 255,
+ b: bigint & 255
+ };
+ }
+ // Fallback
+ return { r: 255, g: 100, b: 50 };
+ }
+}