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:
parent
9dfa1429d0
commit
73eeaac0cc
8 changed files with 2864 additions and 49 deletions
31
CLAUDE.md
31
CLAUDE.md
|
|
@ -36,7 +36,7 @@ The `playground/` directory contains a browser-based interactive demo of the NIS
|
|||
- **Serve statically**: `cd playground && python3 -m http.server`
|
||||
- **Mobile-first**: designed for touch/foldable phone use
|
||||
|
||||
Key files: `js/nisps/` (ML core port), `js/ui/` (visualizer, joystick, controls), `js/synth/` (C15 bridge, param map, arpeggiator), `js/app.js` (wiring).
|
||||
Key files: `js/nisps/` (ML core port), `js/ui/` (visualizer, joystick, controls, input pipeline, control surface), `js/synth/` (C15 bridge, param map, arpeggiator), `js/a-app.js` (immersive app wiring).
|
||||
|
||||
### URL Parameters
|
||||
|
||||
|
|
@ -92,6 +92,35 @@ Presets (`js/synth/presets.js`) control which parameters the ML engine can modif
|
|||
|
||||
Presets use `curve` values to bias parameter distributions (< 0.5 = spend more time low, > 0.5 = bias high) without clamping extremes. Users can tweak any preset via the group drawer after loading.
|
||||
|
||||
### Control Surface (Phase 1)
|
||||
|
||||
The immersive app (`a-immersive.html`) has a control surface system for tuning how exploration and learning feel. Full spec: `playground/SPEC-controls.md`.
|
||||
|
||||
**Architecture** — three standalone ES modules wired into `a-app.js`:
|
||||
|
||||
| Module | Purpose |
|
||||
|--------|---------|
|
||||
| `js/ui/input-pipeline.js` | Processes raw joystick input through deadzone → zoom → curve → smoothing → momentum-as-zoom. Pure math, no DOM. |
|
||||
| `js/ui/control-surface.js` | Compound axes (Boldness, Memory, Precision) that map single sliders to multiple underlying params. Offset-based override resolution (trim-pot model). 6 built-in control presets. |
|
||||
| `js/ui/control-surface-ui.js` | DOM layer: 3 axis sliders on floating bar, gear icon settings drawer with per-param overrides. Injects its own CSS. |
|
||||
| `js/ui/joy-map-enhanced.js` | Enhanced joy-map canvas: zoom minimap with adaptive grid, vanishing trail with Catmull-Rom spline and tap-to-return, dual concentric noise rings (zoom + noise), frozen state overlay. |
|
||||
|
||||
**Compound Axes** — each controls 4-6 underlying parameters via interpolation tables:
|
||||
|
||||
- **Boldness** (Caution ↔ Bold): input zoom, noise cap, noise growth, learning rate, weight decay, noise distribution
|
||||
- **Memory** (Amnesia ↔ Elephant): max examples, example decay, weight decay, noise decay, convergence threshold
|
||||
- **Precision** (Raw ↔ Precise): input curve, deadzone, smoothing, slew rate, momentum-zoom mode
|
||||
|
||||
When a user manually overrides an individual param, the offset from the axis-derived value persists as the axis moves (like a trim pot on a mixing desk). Double-tap an axis to re-link all params.
|
||||
|
||||
**Input Pipeline** — sits between physical joystick and MLP. Key feature: **zoom** narrows the effective input window around an anchor point (`effective = anchor + (raw - 0.5) * zoom_level`). Zoom-at-zero freezes input. Three anchor modes: auto (anchor follows current position when zoom changes), sticky (explicit anchor), center (always 0.5).
|
||||
|
||||
**Control Presets**: Default, First Touch, Jazz Hands, Sculptor, Improviser, Microscope. These set compound axis positions — they don't include network weights or synth preset selection.
|
||||
|
||||
**Integration** — the control surface dispatches `controlsurface:change` CustomEvents. `a-app.js` listens and updates the input pipeline config, spread level, and RL parameters (noise cap, growth, decay, floor, zoom-aware feedback scaling). Pipeline-processed coordinates are cached (`_lastPipeX/Y`) so `getCurrentInputs()` and `setCurrentInputs()` use the same values the MLP sees. State is persisted to localStorage alongside existing app state.
|
||||
|
||||
**Remaining spec phases** (not yet implemented): Phase 2 (pinning + history + A/B compare), Phase 3 (momentum-zoom, pressure feedback, auto-explore, heatmap), Phase 4 (output pipeline, weight health, gradient flow, engine config, session presets).
|
||||
|
||||
## Build System
|
||||
|
||||
This is an Arduino project targeting Raspberry Pi Pico. Build and upload using Arduino IDE or arduino-cli with the earlephilhower/pico board package.
|
||||
|
|
|
|||
|
|
@ -34,20 +34,56 @@ The 126 synth parameters (`js/synth/param-map.js`) cover all sonically meaningfu
|
|||
|
||||
## UI controls
|
||||
|
||||
- **Output mode tabs** (side panel): switch between Visual and Synth modes.
|
||||
- **Expand / Collapse** button on the canvas: makes the visual surface nearly full-screen and compresses lower controls into a minimal view.
|
||||
- **Presets**: quick demo mappings (`Calm/Chaos`, `Rainbow`, `Vortex`) — visual mode only.
|
||||
- **Output mode tabs** (floating bar): switch between Visual and Synth modes.
|
||||
- **Expand / Collapse** chevron: expands bottom sheet with training controls, synth settings, advanced param sliders.
|
||||
- **Synth presets**: tiered presets (Beginner → Expert) control which of the 126 params the ML engine can modify.
|
||||
- **Help** (`?`) overlay: in-app usage guide.
|
||||
- **Follow mode**: double-click joystick in RL mode to toggle no-hold interaction.
|
||||
- **Keyboard in Follow mode (RL)**: `2` = thumbs up, `1` = thumbs down.
|
||||
- **Gamepad in RL**: `RB` = thumbs up, `LB` = thumbs down.
|
||||
- **Gamepad in RL**: `RB` = thumbs up, `LB` = thumbs down, `A` = train, `X` = randomize, `B` = clear.
|
||||
|
||||
## Control Surface
|
||||
|
||||
The immersive app has a control surface system (Phase 1 of `SPEC-controls.md`) for tuning how exploration and learning feel.
|
||||
|
||||
**Compound axes** on the floating bar (3 sliders: Bold, Mem, Prec):
|
||||
|
||||
| Axis | Controls | Low end | High end |
|
||||
|------|----------|---------|----------|
|
||||
| **Boldness** | Input zoom, noise cap, noise growth, LR, weight decay | Cautious: small changes, heavy regularisation | Bold: full range, explosive exploration, fast learning |
|
||||
| **Memory** | Max examples, example decay, noise decay, convergence | Amnesia: only last few interactions matter | Elephant: every example sacred, stable mapping |
|
||||
| **Precision** | Input curve, deadzone, smoothing, slew rate, momentum | Raw: 1:1 with physical movement, twitchy | Precise: heavy shaping, deadzones, smooth |
|
||||
|
||||
**Settings drawer** (gear icon, bottom-right): individual param overrides for Input, Training, Exploration, and Output sections. Manual overrides persist as offsets when compound axes move (trim-pot model). Double-tap an axis to re-link all params.
|
||||
|
||||
**Control presets**: Default, First Touch, Jazz Hands, Sculptor, Improviser, Microscope.
|
||||
|
||||
**Input pipeline**: joystick input is processed through deadzone → zoom → curve → smoothing → momentum-as-zoom before reaching the MLP. Zoom narrows the effective input window around an anchor. Zoom-at-zero freezes input.
|
||||
|
||||
**Enhanced joy-map**: zoom minimap with adaptive grid (4×4 → 32×32), vanishing trail with tap-to-return, dual noise rings (zoom + noise level).
|
||||
|
||||
## Files
|
||||
|
||||
### Original app (index.html)
|
||||
- `index.html` - page structure.
|
||||
- `css/style.css` - layout and visual styling.
|
||||
- `js/app.js` - app wiring and interaction logic.
|
||||
- `js/ui/` - visualizer, joystick, controls, parameter display.
|
||||
- `js/nisps/` - JavaScript MLP + IML core.
|
||||
- `js/synth/` - C15 WASM bridge, parameter map, arpeggiator.
|
||||
|
||||
### Immersive app (a-immersive.html)
|
||||
- `a-immersive.html` - fullscreen immersive UI.
|
||||
- `css/a-immersive.css` - immersive layout and styling.
|
||||
- `js/a-app.js` - immersive app wiring, state management, persistence.
|
||||
|
||||
### Shared modules
|
||||
- `js/nisps/` - JavaScript MLP + IML core (also WASM variant).
|
||||
- `js/ui/visualizer.js` - flow-field particle system (Canvas2D).
|
||||
- `js/ui/joystick.js` - virtual joystick component.
|
||||
- `js/ui/gamepad.js` - gamepad input handling.
|
||||
- `js/ui/hand-tracker.js` - MediaPipe hand tracking (14 features).
|
||||
- `js/ui/input-pipeline.js` - input processing pipeline (zoom, deadzone, curve, smoothing, momentum).
|
||||
- `js/ui/control-surface.js` - compound axes, override resolution, control presets.
|
||||
- `js/ui/control-surface-ui.js` - settings drawer and floating bar axis sliders.
|
||||
- `js/ui/joy-map-enhanced.js` - zoom minimap, vanishing trail, dual noise rings.
|
||||
- `js/synth/` - C15 WASM bridge, parameter map, presets, arpeggiator.
|
||||
- `c15/` - C15 engine WASM binary and parameter definitions.
|
||||
- `SPEC-controls.md` - comprehensive control surface spec (4 phases).
|
||||
|
|
|
|||
|
|
@ -644,36 +644,34 @@ Control presets define a complete control surface state (all parameters from Par
|
|||
|
||||
## Part 10: Implementation Priority
|
||||
|
||||
### Phase 1 — Core Zoom + Compound Axes
|
||||
1. Input zoom with anchor modes and minimap visualization
|
||||
2. Zoom-at-zero freeze behavior
|
||||
3. Vanishing trail with tap-to-return
|
||||
4. Compound axis sliders (Boldness, Memory, Precision) wired to underlying params
|
||||
5. Spread, LR, noise cap promoted to panel sliders
|
||||
6. Resolve UI location (Part 11.5) — prototype Option E (hybrid)
|
||||
### Phase 1 — Core Zoom + Compound Axes ✅ IMPLEMENTED
|
||||
1. ✅ Input zoom with anchor modes and minimap visualization — `js/ui/input-pipeline.js`, `js/ui/joy-map-enhanced.js`
|
||||
2. ✅ Zoom-at-zero freeze behavior — `InputPipeline.isFrozen()`, frozen overlay in joy-map
|
||||
3. ✅ Vanishing trail with tap-to-return — Catmull-Rom spline, ring buffer, 5s duration, zoom-width encoding
|
||||
4. ✅ Compound axis sliders (Boldness, Memory, Precision) wired to underlying params — `js/ui/control-surface.js`
|
||||
5. ✅ Spread, LR, noise cap promoted to panel sliders — settings drawer with all params from Parts 2-6
|
||||
6. ✅ Resolve UI location (Part 11.5) — Option E (hybrid): axes on floating bar, overrides in gear drawer
|
||||
7. ✅ (bonus) Input curve, deadzone, smoothing, momentum-zoom — all implemented in pipeline, exposed in drawer
|
||||
8. ✅ (bonus) Zoom-aware feedback scaling — thumbs-down noise scales by zoom level
|
||||
9. ✅ (bonus) Control presets with offset-based override resolution — 6 built-in presets, trim-pot model
|
||||
10. ✅ (bonus) Dual concentric noise rings (zoom + noise) replacing CSS-only ring
|
||||
|
||||
### Phase 2 — Pinning + History
|
||||
7. Parameter pinning (per-output, in synth visualizer drawer)
|
||||
8. Region pinning (on joy-map, Approach A: example pinning)
|
||||
9. Snapshot stack with undo button
|
||||
10. A/B Compare toggle
|
||||
11. Zoom-aware feedback scaling
|
||||
|
||||
### Phase 3 — Input Refinement + Exploration
|
||||
12. Input curve and deadzone controls
|
||||
13. Input smoothing (especially for hand tracking)
|
||||
14. Momentum-as-zoom toggle
|
||||
15. Pressure/hold-duration feedback
|
||||
16. Auto-Explore mode
|
||||
17. Input space heatmap on joy-map
|
||||
11. Pressure/hold-duration feedback
|
||||
12. Auto-Explore mode
|
||||
13. Input space heatmap on joy-map
|
||||
|
||||
### Phase 4 — Output, Persistence + Polish
|
||||
18. Output smoothing, slew rate, and freeze output gate
|
||||
19. Weight health indicator + gradient flow
|
||||
20. Control presets (save/load/built-ins) + persistence (localStorage + URL sharing)
|
||||
21. Session presets (control + synth bundled)
|
||||
22. Engine configuration panel
|
||||
23. Compound axis override resolution (Part 11.1) — prototype offset approach
|
||||
14. Output smoothing, slew rate, and freeze output gate — (sliders exist in drawer but not yet wired to output pipeline)
|
||||
15. Weight health indicator + gradient flow
|
||||
16. Session presets (control + synth bundled)
|
||||
17. Engine configuration panel
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ import { FlowFieldVisualizer } from './ui/visualizer.js';
|
|||
import { C15Bridge } from './synth/c15-bridge.js';
|
||||
import { Arpeggiator } from './synth/arpeggiator.js';
|
||||
import { MIDIInput } from './synth/midi-input.js';
|
||||
import { InputPipeline } from './ui/input-pipeline.js';
|
||||
import { initControlSurfaceUI } from './ui/control-surface-ui.js';
|
||||
import { JoyMapEnhanced } from './ui/joy-map-enhanced.js';
|
||||
|
||||
// ---- ShapeSeq (feature-flagged, enable with ?shapeseq=1) ----
|
||||
const ENABLE_SHAPESEQ = new URLSearchParams(window.location.search).get('shapeseq') === '1';
|
||||
|
|
@ -107,6 +110,13 @@ let spreadLevel = 0.6;
|
|||
let noiseLevel = 0.05;
|
||||
const rlExplorationDecay = 0.97;
|
||||
|
||||
// Control surface (Phase 1)
|
||||
let inputPipeline = null;
|
||||
let controlSurface = null;
|
||||
let joyMapEnhanced = null;
|
||||
let _lastFrameTime = 0;
|
||||
let _lastPipeX = 0.5, _lastPipeY = 0.5; // cached pipeline output for getCurrentInputs()
|
||||
|
||||
// Joystick state
|
||||
let joyX = 0.5;
|
||||
let joyY = 0.5;
|
||||
|
|
@ -798,6 +808,11 @@ async function init() {
|
|||
window.addEventListener('resize', onResize);
|
||||
onResize();
|
||||
|
||||
// ---- Control Surface (Phase 1) — init before loadState so restore works ----
|
||||
inputPipeline = new InputPipeline();
|
||||
const csUI = initControlSurfaceUI();
|
||||
controlSurface = csUI.surface;
|
||||
|
||||
// Restore saved state (if any)
|
||||
loadState();
|
||||
|
||||
|
|
@ -814,12 +829,38 @@ async function init() {
|
|||
if ($ps) $ps.value = activeSynthPresetId;
|
||||
}
|
||||
|
||||
// Initial inference
|
||||
iml.setInput(0, joyX);
|
||||
iml.setInput(1, joyY);
|
||||
iml.process();
|
||||
routeOutputs(iml.getOutputs());
|
||||
updateHeatmap(iml.getOutputs());
|
||||
// Enhanced joy-map (replaces drawJoyMap)
|
||||
joyMapEnhanced = new JoyMapEnhanced($joyMap, {
|
||||
onTrailTap: (pos) => {
|
||||
joyX = pos.x;
|
||||
joyY = pos.y;
|
||||
onJoystickMove();
|
||||
},
|
||||
getTrainingData: () => ({
|
||||
features: iml.dataset.features,
|
||||
labels: iml.dataset.labels,
|
||||
}),
|
||||
});
|
||||
|
||||
// Wire control surface changes to pipeline + RL params
|
||||
document.addEventListener('controlsurface:change', (e) => {
|
||||
const p = e.detail;
|
||||
// Input pipeline
|
||||
inputPipeline.setConfig({
|
||||
zoom: p.zoom,
|
||||
deadzone: p.deadzone,
|
||||
inputCurve: p.inputCurve,
|
||||
smoothing: p.smoothing,
|
||||
momentumZoom: p.momentumZoom,
|
||||
invertX: p.invertX,
|
||||
invertY: p.invertY,
|
||||
});
|
||||
// Sync spread (used by moveWeights and randomise)
|
||||
spreadLevel = p.spread;
|
||||
});
|
||||
|
||||
// Initial inference — run through pipeline for consistency
|
||||
if (inputMode === 'joystick') onJoystickMove();
|
||||
updateStatus();
|
||||
drawJoyMap();
|
||||
drawLossPlot();
|
||||
|
|
@ -879,6 +920,22 @@ function updateHeatmap(outputs) {
|
|||
|
||||
// ---- Joy Map (merged joystick + minimap) ----
|
||||
function drawJoyMap() {
|
||||
// Use enhanced joy-map if available
|
||||
if (joyMapEnhanced) {
|
||||
joyMapEnhanced.draw({
|
||||
joyX,
|
||||
joyY,
|
||||
// effectiveX/Y are set in onJoystickMove; for draw we show zoom window + cursor
|
||||
zoomWindow: inputPipeline ? inputPipeline.getZoomWindow() : null,
|
||||
zoomLevel: inputPipeline ? inputPipeline.getZoomLevel() : 1.0,
|
||||
noiseLevel,
|
||||
outputMode,
|
||||
frozen: inputPipeline ? inputPipeline.isFrozen() : false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Legacy fallback
|
||||
const canvas = $joyMap;
|
||||
const ctx = $joyMapCtx;
|
||||
const w = canvas.width;
|
||||
|
|
@ -1065,8 +1122,24 @@ function updateFollowUI() {
|
|||
|
||||
function onJoystickMove() {
|
||||
if (inputMode !== 'joystick') return;
|
||||
iml.setInput(0, joyX);
|
||||
iml.setInput(1, joyY);
|
||||
|
||||
// Process through input pipeline (zoom, deadzone, curve, smoothing, momentum)
|
||||
const now = performance.now();
|
||||
const dt = _lastFrameTime > 0 ? (now - _lastFrameTime) / 1000 : 1 / 60;
|
||||
_lastFrameTime = now;
|
||||
|
||||
const pipeResult = inputPipeline
|
||||
? inputPipeline.process(joyX, joyY, dt)
|
||||
: { x: joyX, y: joyY, frozen: false };
|
||||
|
||||
// Cache for getCurrentInputs() (avoids re-processing and mutating state)
|
||||
_lastPipeX = pipeResult.x;
|
||||
_lastPipeY = pipeResult.y;
|
||||
|
||||
if (pipeResult.frozen) return; // Input frozen (zoom at zero)
|
||||
|
||||
iml.setInput(0, pipeResult.x);
|
||||
iml.setInput(1, pipeResult.y);
|
||||
iml.process();
|
||||
|
||||
const outputs = iml.getOutputs();
|
||||
|
|
@ -1075,9 +1148,14 @@ function onJoystickMove() {
|
|||
|
||||
syncRawParamsFromOutputs(outputs);
|
||||
|
||||
// Trail
|
||||
// Trail (enhanced joy-map handles this now, legacy trail kept for compat)
|
||||
joyTrail.push({ x: joyX, y: joyY, t: Date.now() });
|
||||
if (joyTrail.length > 30) joyTrail.shift();
|
||||
|
||||
// Enhanced trail records in input-space coords
|
||||
if (joyMapEnhanced) {
|
||||
joyMapEnhanced.addTrailPoint(joyX, joyY, inputPipeline ? inputPipeline.getZoomLevel() : 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Output routing ----
|
||||
|
|
@ -1259,11 +1337,8 @@ async function _setInputModeInner(mode) {
|
|||
document.getElementById('hand-status').classList.remove('tracking');
|
||||
}
|
||||
|
||||
iml.setInput(0, joyX);
|
||||
iml.setInput(1, joyY);
|
||||
iml.process();
|
||||
routeOutputs(iml.getOutputs());
|
||||
updateHeatmap(iml.getOutputs());
|
||||
// Re-run through pipeline so MLP sees processed coords
|
||||
onJoystickMove();
|
||||
}
|
||||
|
||||
updateStatus();
|
||||
|
|
@ -1274,7 +1349,9 @@ function getCurrentInputs() {
|
|||
if (inputMode === 'hands' && handTracker) {
|
||||
return [...handTracker.features];
|
||||
}
|
||||
return [joyX, joyY];
|
||||
// Return pipeline-processed coords (what the MLP actually sees),
|
||||
// not raw joyX/joyY, so examples are recorded in the correct input space
|
||||
return [_lastPipeX, _lastPipeY];
|
||||
}
|
||||
|
||||
function setCurrentInputs() {
|
||||
|
|
@ -1282,8 +1359,9 @@ function setCurrentInputs() {
|
|||
const f = handTracker.features;
|
||||
for (let i = 0; i < f.length; i++) iml.setInput(i, f[i]);
|
||||
} else {
|
||||
iml.setInput(0, joyX);
|
||||
iml.setInput(1, joyY);
|
||||
// Use cached pipeline output (matches what MLP sees during inference)
|
||||
iml.setInput(0, _lastPipeX);
|
||||
iml.setInput(1, _lastPipeY);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1391,6 +1469,11 @@ function setOutputMode(mode) {
|
|||
}
|
||||
|
||||
function updateNoiseRing() {
|
||||
// When enhanced joy-map is active, noise ring is drawn on canvas — hide CSS version
|
||||
if (joyMapEnhanced) {
|
||||
$noiseRing.className = 'noise-ring';
|
||||
return;
|
||||
}
|
||||
if (noiseLevel > 0.15) {
|
||||
$noiseRing.className = 'noise-ring active high';
|
||||
} else if (noiseLevel > 0.01) {
|
||||
|
|
@ -1454,8 +1537,12 @@ function onThumbsUp() {
|
|||
const outputs = [...iml.getOutputs()];
|
||||
iml.addExample(inputs, outputs);
|
||||
|
||||
noiseLevel *= rlExplorationDecay;
|
||||
noiseLevel = Math.max(noiseLevel, 0.005);
|
||||
// Use control surface params if available, else legacy
|
||||
const csParams = controlSurface ? controlSurface.getParams() : null;
|
||||
const decay = csParams ? csParams.noiseDecay : rlExplorationDecay;
|
||||
const floor = csParams ? csParams.noiseFloor : 0.005;
|
||||
noiseLevel *= decay;
|
||||
noiseLevel = Math.max(noiseLevel, floor);
|
||||
|
||||
flash('btn-thumbsup');
|
||||
updateNoiseRing();
|
||||
|
|
@ -1463,8 +1550,18 @@ function onThumbsUp() {
|
|||
}
|
||||
|
||||
function onThumbsDown() {
|
||||
const noiseCap = 0.3 * (1 - spreadLevel) + 0.05 * spreadLevel;
|
||||
noiseLevel = Math.min(noiseLevel * 1.5, noiseCap);
|
||||
// Use control surface params if available, else legacy
|
||||
const csParams = controlSurface ? controlSurface.getParams() : null;
|
||||
const noiseCap = csParams ? csParams.noiseCap : (0.3 * (1 - spreadLevel) + 0.05 * spreadLevel);
|
||||
const growth = csParams ? csParams.noiseGrowth : 1.5;
|
||||
|
||||
// Zoom-aware feedback: scale noise by zoom level
|
||||
let effectiveGrowth = growth;
|
||||
if (csParams && csParams.zoomAwareFeedback && inputPipeline) {
|
||||
effectiveGrowth *= inputPipeline.getZoomLevel();
|
||||
}
|
||||
|
||||
noiseLevel = Math.min(noiseLevel * effectiveGrowth, noiseCap);
|
||||
|
||||
iml.moveWeights(noiseLevel, spreadLevel);
|
||||
|
||||
|
|
@ -2412,6 +2509,8 @@ function saveState() {
|
|||
joyY,
|
||||
groupOverrides,
|
||||
synthPresetId: activeSynthPresetId,
|
||||
controlSurface: controlSurface ? controlSurface.getState() : null,
|
||||
inputPipeline: inputPipeline ? inputPipeline.getConfig() : null,
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch (e) {
|
||||
|
|
@ -2482,6 +2581,14 @@ function loadState() {
|
|||
syncOutputToggles(outputMode);
|
||||
}
|
||||
|
||||
// Restore control surface state
|
||||
if (state.controlSurface && controlSurface) {
|
||||
controlSurface.setState(state.controlSurface);
|
||||
}
|
||||
if (state.inputPipeline && inputPipeline) {
|
||||
inputPipeline.setConfig(state.inputPipeline);
|
||||
}
|
||||
|
||||
// Note: don't auto-restore inputMode='hands' — requires camera permission
|
||||
console.log(`[NISPS] Restored ${state.features?.length || 0} joy examples, ${state.handFeatures?.length || 0} hand examples from storage`);
|
||||
} catch (e) {
|
||||
|
|
|
|||
890
playground/js/ui/control-surface-ui.js
Normal file
890
playground/js/ui/control-surface-ui.js
Normal 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">×</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">▼</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);
|
||||
}
|
||||
405
playground/js/ui/control-surface.js
Normal file
405
playground/js/ui/control-surface.js
Normal 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 };
|
||||
611
playground/js/ui/input-pipeline.js
Normal file
611
playground/js/ui/input-pipeline.js
Normal 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.1–0.5 u/s
|
||||
// Typical fast sweep: 2–6 u/s
|
||||
//
|
||||
// We use a sigmoid-like mapping so the transition feels smooth.
|
||||
const v = this._currentVelocity;
|
||||
const { factor, minZoomMul, maxZoomMul } = preset;
|
||||
|
||||
// Normalised speed: 0 at rest, approaches 1 at high velocity
|
||||
// Using 1 - exp(-factor * v) which is smooth and bounded
|
||||
const normalised = 1 - Math.exp(-factor * v);
|
||||
|
||||
// Interpolate between minZoomMul (slow) and maxZoomMul (fast)
|
||||
const target = minZoomMul + normalised * (maxZoomMul - minZoomMul);
|
||||
|
||||
// Smooth the multiplier itself to avoid jitter
|
||||
// Use a fast-attack, slow-release envelope so zoom-out is instant
|
||||
// but zoom-in (slowing down) ramps gently
|
||||
const attackRate = 12; // per second — fast response to speed increase
|
||||
const releaseRate = 3; // per second — gentle return when slowing down
|
||||
const rate = target > this._momentumZoomMultiplier ? attackRate : releaseRate;
|
||||
const blend = 1 - Math.exp(-rate * dt);
|
||||
this._momentumZoomMultiplier += blend * (target - this._momentumZoomMultiplier);
|
||||
}
|
||||
}
|
||||
739
playground/js/ui/joy-map-enhanced.js
Normal file
739
playground/js/ui/joy-map-enhanced.js
Normal 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 };
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue