From 73eeaac0cc6c47262462abc3601d7474fb7ff068 Mon Sep 17 00:00:00 2001 From: w1n5t0n Date: Thu, 26 Mar 2026 10:24:24 +0200 Subject: [PATCH] =?UTF-8?q?feat(playground):=20implement=20Phase=201=20con?= =?UTF-8?q?trol=20surface=20=E2=80=94=20compound=20axes,=20input=20pipelin?= =?UTF-8?q?e,=20enhanced=20joy-map?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new standalone ES modules + integration into a-app.js: - input-pipeline.js: 5-stage processing (deadzone → zoom → curve → smoothing → momentum-as-zoom), 3 anchor modes, zoom-at-zero freeze, per-axis overrides - control-surface.js: compound axes (Boldness/Memory/Precision) with interpolation tables, offset-based override resolution (trim-pot model), 6 built-in presets - control-surface-ui.js: floating bar axis sliders, gear icon settings drawer with Input/Training/Exploration/Output sections, log-scale sliders, override dots - joy-map-enhanced.js: zoom minimap with adaptive grid (4×4→32×32), vanishing trail with Catmull-Rom spline + tap-to-return, dual concentric noise rings, frozen overlay Integration fixes from fresh-eyes review: - getCurrentInputs()/setCurrentInputs() use cached pipeline coords (not raw) - CSS noise ring hidden when canvas version active (no doubling) - Input mode switch re-runs through pipeline - Control surface state persisted to localStorage Implements full Phase 1 of SPEC-controls.md plus bonus items from later phases (zoom-aware feedback, control presets with override resolution, input curve/deadzone/ smoothing/momentum all wired). --- CLAUDE.md | 31 +- playground/README.md | 50 +- playground/SPEC-controls.md | 38 +- playground/js/a-app.js | 149 ++++- playground/js/ui/control-surface-ui.js | 890 +++++++++++++++++++++++++ playground/js/ui/control-surface.js | 405 +++++++++++ playground/js/ui/input-pipeline.js | 611 +++++++++++++++++ playground/js/ui/joy-map-enhanced.js | 739 ++++++++++++++++++++ 8 files changed, 2864 insertions(+), 49 deletions(-) create mode 100644 playground/js/ui/control-surface-ui.js create mode 100644 playground/js/ui/control-surface.js create mode 100644 playground/js/ui/input-pipeline.js create mode 100644 playground/js/ui/joy-map-enhanced.js 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 = ` +
+ Control Surface + +
+
+
+ + + +
+ `; + + for (const section of SECTIONS) { + html += ` +
+
+ ${section.label} + +
+
+ `; + + 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 }; + } +}