feat(playground): implement Phase 1 control surface — compound axes, input pipeline, enhanced joy-map

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).
This commit is contained in:
w1n5t0n 2026-03-26 10:24:24 +02:00
parent 9dfa1429d0
commit 73eeaac0cc
8 changed files with 2864 additions and 49 deletions

View file

@ -36,7 +36,7 @@ The `playground/` directory contains a browser-based interactive demo of the NIS
- **Serve statically**: `cd playground && python3 -m http.server` - **Serve statically**: `cd playground && python3 -m http.server`
- **Mobile-first**: designed for touch/foldable phone use - **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 ### 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. 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 ## 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. This is an Arduino project targeting Raspberry Pi Pico. Build and upload using Arduino IDE or arduino-cli with the earlephilhower/pico board package.

View file

@ -34,20 +34,56 @@ The 126 synth parameters (`js/synth/param-map.js`) cover all sonically meaningfu
## UI controls ## UI controls
- **Output mode tabs** (side panel): switch between Visual and Synth modes. - **Output mode tabs** (floating bar): 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. - **Expand / Collapse** chevron: expands bottom sheet with training controls, synth settings, advanced param sliders.
- **Presets**: quick demo mappings (`Calm/Chaos`, `Rainbow`, `Vortex`) — visual mode only. - **Synth presets**: tiered presets (Beginner → Expert) control which of the 126 params the ML engine can modify.
- **Help** (`?`) overlay: in-app usage guide. - **Help** (`?`) overlay: in-app usage guide.
- **Follow mode**: double-click joystick in RL mode to toggle no-hold interaction. - **Follow mode**: double-click joystick in RL mode to toggle no-hold interaction.
- **Keyboard in Follow mode (RL)**: `2` = thumbs up, `1` = thumbs down. - **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 ## Files
### Original app (index.html)
- `index.html` - page structure. - `index.html` - page structure.
- `css/style.css` - layout and visual styling. - `css/style.css` - layout and visual styling.
- `js/app.js` - app wiring and interaction logic. - `js/app.js` - app wiring and interaction logic.
- `js/ui/` - visualizer, joystick, controls, parameter display.
- `js/nisps/` - JavaScript MLP + IML core. ### Immersive app (a-immersive.html)
- `js/synth/` - C15 WASM bridge, parameter map, arpeggiator. - `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. - `c15/` - C15 engine WASM binary and parameter definitions.
- `SPEC-controls.md` - comprehensive control surface spec (4 phases).

View file

@ -644,36 +644,34 @@ Control presets define a complete control surface state (all parameters from Par
## Part 10: Implementation Priority ## Part 10: Implementation Priority
### Phase 1 — Core Zoom + Compound Axes ### Phase 1 — Core Zoom + Compound Axes ✅ IMPLEMENTED
1. Input zoom with anchor modes and minimap visualization 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 2. ✅ Zoom-at-zero freeze behavior — `InputPipeline.isFrozen()`, frozen overlay in joy-map
3. Vanishing trail with tap-to-return 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 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 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) — prototype Option E (hybrid) 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 ### Phase 2 — Pinning + History
7. Parameter pinning (per-output, in synth visualizer drawer) 7. Parameter pinning (per-output, in synth visualizer drawer)
8. Region pinning (on joy-map, Approach A: example pinning) 8. Region pinning (on joy-map, Approach A: example pinning)
9. Snapshot stack with undo button 9. Snapshot stack with undo button
10. A/B Compare toggle 10. A/B Compare toggle
11. Zoom-aware feedback scaling
### Phase 3 — Input Refinement + Exploration ### Phase 3 — Input Refinement + Exploration
12. Input curve and deadzone controls 11. Pressure/hold-duration feedback
13. Input smoothing (especially for hand tracking) 12. Auto-Explore mode
14. Momentum-as-zoom toggle 13. Input space heatmap on joy-map
15. Pressure/hold-duration feedback
16. Auto-Explore mode
17. Input space heatmap on joy-map
### Phase 4 — Output, Persistence + Polish ### Phase 4 — Output, Persistence + Polish
18. Output smoothing, slew rate, and freeze output gate 14. Output smoothing, slew rate, and freeze output gate — (sliders exist in drawer but not yet wired to output pipeline)
19. Weight health indicator + gradient flow 15. Weight health indicator + gradient flow
20. Control presets (save/load/built-ins) + persistence (localStorage + URL sharing) 16. Session presets (control + synth bundled)
21. Session presets (control + synth bundled) 17. Engine configuration panel
22. Engine configuration panel
23. Compound axis override resolution (Part 11.1) — prototype offset approach
--- ---

View file

@ -6,6 +6,9 @@ import { FlowFieldVisualizer } from './ui/visualizer.js';
import { C15Bridge } from './synth/c15-bridge.js'; import { C15Bridge } from './synth/c15-bridge.js';
import { Arpeggiator } from './synth/arpeggiator.js'; import { Arpeggiator } from './synth/arpeggiator.js';
import { MIDIInput } from './synth/midi-input.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) ---- // ---- ShapeSeq (feature-flagged, enable with ?shapeseq=1) ----
const ENABLE_SHAPESEQ = new URLSearchParams(window.location.search).get('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; let noiseLevel = 0.05;
const rlExplorationDecay = 0.97; 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 // Joystick state
let joyX = 0.5; let joyX = 0.5;
let joyY = 0.5; let joyY = 0.5;
@ -798,6 +808,11 @@ async function init() {
window.addEventListener('resize', onResize); window.addEventListener('resize', onResize);
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) // Restore saved state (if any)
loadState(); loadState();
@ -814,12 +829,38 @@ async function init() {
if ($ps) $ps.value = activeSynthPresetId; if ($ps) $ps.value = activeSynthPresetId;
} }
// Initial inference // Enhanced joy-map (replaces drawJoyMap)
iml.setInput(0, joyX); joyMapEnhanced = new JoyMapEnhanced($joyMap, {
iml.setInput(1, joyY); onTrailTap: (pos) => {
iml.process(); joyX = pos.x;
routeOutputs(iml.getOutputs()); joyY = pos.y;
updateHeatmap(iml.getOutputs()); 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(); updateStatus();
drawJoyMap(); drawJoyMap();
drawLossPlot(); drawLossPlot();
@ -879,6 +920,22 @@ function updateHeatmap(outputs) {
// ---- Joy Map (merged joystick + minimap) ---- // ---- Joy Map (merged joystick + minimap) ----
function drawJoyMap() { 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 canvas = $joyMap;
const ctx = $joyMapCtx; const ctx = $joyMapCtx;
const w = canvas.width; const w = canvas.width;
@ -1065,8 +1122,24 @@ function updateFollowUI() {
function onJoystickMove() { function onJoystickMove() {
if (inputMode !== 'joystick') return; 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(); iml.process();
const outputs = iml.getOutputs(); const outputs = iml.getOutputs();
@ -1075,9 +1148,14 @@ function onJoystickMove() {
syncRawParamsFromOutputs(outputs); syncRawParamsFromOutputs(outputs);
// Trail // Trail (enhanced joy-map handles this now, legacy trail kept for compat)
joyTrail.push({ x: joyX, y: joyY, t: Date.now() }); joyTrail.push({ x: joyX, y: joyY, t: Date.now() });
if (joyTrail.length > 30) joyTrail.shift(); 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 ---- // ---- Output routing ----
@ -1259,11 +1337,8 @@ async function _setInputModeInner(mode) {
document.getElementById('hand-status').classList.remove('tracking'); document.getElementById('hand-status').classList.remove('tracking');
} }
iml.setInput(0, joyX); // Re-run through pipeline so MLP sees processed coords
iml.setInput(1, joyY); onJoystickMove();
iml.process();
routeOutputs(iml.getOutputs());
updateHeatmap(iml.getOutputs());
} }
updateStatus(); updateStatus();
@ -1274,7 +1349,9 @@ function getCurrentInputs() {
if (inputMode === 'hands' && handTracker) { if (inputMode === 'hands' && handTracker) {
return [...handTracker.features]; 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() { function setCurrentInputs() {
@ -1282,8 +1359,9 @@ function setCurrentInputs() {
const f = handTracker.features; const f = handTracker.features;
for (let i = 0; i < f.length; i++) iml.setInput(i, f[i]); for (let i = 0; i < f.length; i++) iml.setInput(i, f[i]);
} else { } else {
iml.setInput(0, joyX); // Use cached pipeline output (matches what MLP sees during inference)
iml.setInput(1, joyY); iml.setInput(0, _lastPipeX);
iml.setInput(1, _lastPipeY);
} }
} }
@ -1391,6 +1469,11 @@ function setOutputMode(mode) {
} }
function updateNoiseRing() { 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) { if (noiseLevel > 0.15) {
$noiseRing.className = 'noise-ring active high'; $noiseRing.className = 'noise-ring active high';
} else if (noiseLevel > 0.01) { } else if (noiseLevel > 0.01) {
@ -1454,8 +1537,12 @@ function onThumbsUp() {
const outputs = [...iml.getOutputs()]; const outputs = [...iml.getOutputs()];
iml.addExample(inputs, outputs); iml.addExample(inputs, outputs);
noiseLevel *= rlExplorationDecay; // Use control surface params if available, else legacy
noiseLevel = Math.max(noiseLevel, 0.005); 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'); flash('btn-thumbsup');
updateNoiseRing(); updateNoiseRing();
@ -1463,8 +1550,18 @@ function onThumbsUp() {
} }
function onThumbsDown() { function onThumbsDown() {
const noiseCap = 0.3 * (1 - spreadLevel) + 0.05 * spreadLevel; // Use control surface params if available, else legacy
noiseLevel = Math.min(noiseLevel * 1.5, noiseCap); 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); iml.moveWeights(noiseLevel, spreadLevel);
@ -2412,6 +2509,8 @@ function saveState() {
joyY, joyY,
groupOverrides, groupOverrides,
synthPresetId: activeSynthPresetId, synthPresetId: activeSynthPresetId,
controlSurface: controlSurface ? controlSurface.getState() : null,
inputPipeline: inputPipeline ? inputPipeline.getConfig() : null,
}; };
localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (e) { } catch (e) {
@ -2482,6 +2581,14 @@ function loadState() {
syncOutputToggles(outputMode); 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 // 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`); console.log(`[NISPS] Restored ${state.features?.length || 0} joy examples, ${state.handFeatures?.length || 0} hand examples from storage`);
} catch (e) { } catch (e) {

View file

@ -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 = `
<div class="cs-axis" data-axis="boldness">
<label class="cs-axis-label">Bold</label>
<input type="range" class="cs-axis-slider" min="0" max="1" step="0.01" value="0.5">
</div>
<div class="cs-axis" data-axis="memory">
<label class="cs-axis-label">Mem</label>
<input type="range" class="cs-axis-slider" min="0" max="1" step="0.01" value="0.5">
</div>
<div class="cs-axis" data-axis="precision">
<label class="cs-axis-label">Prec</label>
<input type="range" class="cs-axis-slider" min="0" max="1" step="0.01" value="0.3">
</div>
`;
// 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 = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="8" cy="8" r="2.5"/>
<path d="M13.3 9.5a1.2 1.2 0 00.24 1.32l.04.04a1.45 1.45 0 11-2.05 2.05l-.04-.04a1.2 1.2 0 00-1.32-.24 1.2 1.2 0 00-.73 1.1v.12a1.45 1.45 0 11-2.9 0v-.06a1.2 1.2 0 00-.78-1.1 1.2 1.2 0 00-1.32.24l-.04.04a1.45 1.45 0 11-2.05-2.05l.04-.04a1.2 1.2 0 00.24-1.32 1.2 1.2 0 00-1.1-.73h-.12a1.45 1.45 0 110-2.9h.06a1.2 1.2 0 001.1-.78 1.2 1.2 0 00-.24-1.32l-.04-.04A1.45 1.45 0 114.38 1.68l.04.04a1.2 1.2 0 001.32.24h.06a1.2 1.2 0 00.73-1.1V.74a1.45 1.45 0 112.9 0v.06a1.2 1.2 0 00.73 1.1 1.2 1.2 0 001.32-.24l.04-.04a1.45 1.45 0 112.05 2.05l-.04.04a1.2 1.2 0 00-.24 1.32v.06a1.2 1.2 0 001.1.73h.12a1.45 1.45 0 110 2.9h-.06a1.2 1.2 0 00-1.1.73z"/>
</svg>`;
// 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 = `
<div class="cs-drawer-header">
<span class="cs-drawer-title">Control Surface</span>
<button class="cs-drawer-close">&times;</button>
</div>
<div class="cs-drawer-body">
<div class="cs-preset-row">
<label class="cs-preset-label">Preset</label>
<select id="cs-preset-select" class="cs-preset-select">
${Object.keys(CONTROL_PRESETS).map(id =>
`<option value="${id}">${id.replace(/-/g, ' ')}</option>`
).join('')}
</select>
<button id="cs-reset-preset" class="cs-reset-btn" title="Reset to preset">Reset</button>
</div>
`;
for (const section of SECTIONS) {
html += `
<div class="cs-section" data-section="${section.id}">
<div class="cs-section-header">
<span>${section.label}</span>
<span class="cs-section-chevron">&#9660;</span>
</div>
<div class="cs-section-body">
`;
for (const p of section.params) {
html += buildParamRow(p);
}
html += `
</div>
</div>
`;
}
html += `</div>`;
return html;
}
function buildParamRow(p) {
const range = PARAM_RANGES[p.name];
if (p.type === 'toggle') {
return `
<div class="cs-param-row" data-param="${p.name}">
<label class="cs-param-label">
<span class="cs-override-dot"></span>
${p.label}
</label>
<input type="checkbox" class="cs-toggle" ${PARAM_DEFAULTS[p.name] ? 'checked' : ''}>
</div>
`;
}
if (p.type === 'select') {
const options = p.options || [];
return `
<div class="cs-param-row" data-param="${p.name}">
<label class="cs-param-label">
<span class="cs-override-dot"></span>
${p.label}
</label>
<select class="cs-select">
${options.map(o => `<option value="${o}">${o}</option>`).join('')}
</select>
</div>
`;
}
// 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 `
<div class="cs-param-row" data-param="${p.name}">
<label class="cs-param-label">
<span class="cs-override-dot"></span>
${p.label}
</label>
<div class="cs-slider-row">
<input type="range" class="cs-slider" min="${sliderMin}" max="${sliderMax}" step="${step}" value="${sliderDefault}">
<span class="cs-param-value">${formatValue(p.name, defaultVal)}</span>
</div>
</div>
`;
}
// ---- 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);
}

View file

@ -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 };

View file

@ -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.10.5 u/s
// Typical fast sweep: 26 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);
}
}

View file

@ -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 };
}
}