memlnaut-nisps/playground/js/ui/pressure-feedback.js
w1n5t0n 1f21494dee feat(playground): implement Phases 2-4 of control surface spec
Phase 2 — Pinning + History:
- snapshot-stack.js: ring buffer (20 max) with auto-snapshot on
  train/randomize/thumbs-down, multi-level undo, tagged entries
- ab-compare.js: A/B weight state comparison with capture/toggle/accept/revert
- region-pin.js: pin rectangular input-space regions (Approach A: example
  pinning), pinned examples always included in training
- param-pin.js: per-output pin flags, pin mask skips pinned nodes in moveWeights
- phase2-ui.js: undo button with history popup, A/B toggle, long-press region
  pin, double-tap param pin
- Modified mlp.js/iml.js/nisps-wasm.js to accept outputPinMask in moveWeights

Phase 3 — Input Refinement + Exploration:
- pressure-feedback.js: touch force + hold duration → intensity multiplier
- auto-explore.js: automated thumbs-down at configurable interval, zoom-scaled
- input-heatmap.js: 16×16 MLP sampling, 3 color modes (luminance/variance/
  divergence), zoom-aware resampling, offscreen canvas rendering
- phase3-ui.js: auto-explore toggle with progress ring, heatmap eye icon,
  pressure indicators, settings drawer section
- joy-map-enhanced.js: added setHeatmap() for background layer rendering

Phase 4 — Output Pipeline + Visualization + Polish:
- output-pipeline.js: global curve → smoothing → slew rate → freeze gate
- weight-health.js: weight magnitude histogram, dead/saturating/healthy status
- gradient-flow.js: per-layer weight-delta analysis, vanishing/exploding detection
- session-presets.js: save/load full state, URL sharing via compact params
- phase4-ui.js: freeze button, network health panel, session preset UI

All phases merged into a-app.js with proper integration: auto-snapshots,
pressure-modulated RL, heatmap triggers, output pipeline in routeOutputs,
gradient capture around training, persistence for all new state.
2026-03-26 10:48:12 +02:00

194 lines
5.5 KiB
JavaScript

/**
* Pressure / Hold-Duration Feedback
*
* Combines touch pressure (Force Touch, stylus) and hold duration into a
* single intensity multiplier (0-1). Applied to noise growth on thumbs-down
* and train intensity on thumbs-up.
*
* When disabled, getIntensity() returns 1.0 (passthrough).
*
* @module pressure-feedback
*/
export class PressureFeedback {
/**
* @param {object} [options]
* @param {boolean} [options.pressureEnabled=false] - use event.pressure
* @param {boolean} [options.holdEnabled=true] - ramp intensity by hold duration
* @param {number} [options.holdCurve=2] - seconds to reach max intensity
* @param {number} [options.minIntensity=0.15] - floor when active (quick tap)
*/
constructor(options = {}) {
this._pressureEnabled = options.pressureEnabled ?? false;
this._holdEnabled = options.holdEnabled ?? true;
this._holdCurve = Math.max(0.1, options.holdCurve ?? 2);
this._minIntensity = Math.max(0, Math.min(1, options.minIntensity ?? 0.15));
// Runtime state
this._active = false; // pointer is currently down
this._startTime = 0; // timestamp of pointerdown (ms)
this._currentPressure = 0; // latest event.pressure (0-1)
this._holdIntensity = 0; // computed hold intensity (0-1)
}
// ---- Pointer event handlers ----
/**
* Call on pointerdown. Starts tracking hold duration and pressure.
* @param {PointerEvent} event
*/
onPointerDown(event) {
this._active = true;
this._startTime = performance.now();
this._currentPressure = this._extractPressure(event);
this._holdIntensity = 0;
}
/**
* Call on pointermove while pointer is down.
* Updates pressure reading.
* @param {PointerEvent} event
*/
onPointerMove(event) {
if (!this._active) return;
this._currentPressure = this._extractPressure(event);
this._updateHold();
}
/**
* Call on pointerup / pointercancel.
*/
onPointerUp(_event) {
this.reset();
}
// ---- Queries ----
/**
* Get current intensity multiplier.
*
* - If both pressure and hold are disabled, returns 1.0 (passthrough).
* - If active and at least one source is enabled, returns max(pressure, holdIntensity).
* - If not active (no pointer down), returns the minIntensity floor.
*
* @returns {number} 0-1
*/
getIntensity() {
if (!this._pressureEnabled && !this._holdEnabled) return 1.0;
if (!this._active) return this._minIntensity;
this._updateHold();
let intensity = 0;
if (this._pressureEnabled) {
intensity = Math.max(intensity, this._currentPressure);
}
if (this._holdEnabled) {
intensity = Math.max(intensity, this._holdIntensity);
}
// Ensure at least minIntensity when active
return Math.max(this._minIntensity, intensity);
}
/**
* Whether a pointer is currently held down.
* @returns {boolean}
*/
get active() {
return this._active;
}
/**
* Whether any source is enabled.
* @returns {boolean}
*/
get enabled() {
return this._pressureEnabled || this._holdEnabled;
}
// ---- Configuration ----
/**
* Toggle pressure and hold-duration sources independently.
* @param {boolean} pressure
* @param {boolean} holdDuration
*/
setEnabled(pressure, holdDuration) {
this._pressureEnabled = !!pressure;
this._holdEnabled = !!holdDuration;
}
/**
* Set the hold-duration ramp time.
* @param {number} seconds - time to reach max intensity (0.1-10)
*/
setHoldCurve(seconds) {
this._holdCurve = Math.max(0.1, Math.min(10, seconds));
}
/** @returns {number} */
getHoldCurve() { return this._holdCurve; }
/** @returns {boolean} */
getPressureEnabled() { return this._pressureEnabled; }
/** @returns {boolean} */
getHoldEnabled() { return this._holdEnabled; }
/**
* Reset state (called on pointer up).
*/
reset() {
this._active = false;
this._startTime = 0;
this._currentPressure = 0;
this._holdIntensity = 0;
}
// ---- Serialization ----
getConfig() {
return {
pressureEnabled: this._pressureEnabled,
holdEnabled: this._holdEnabled,
holdCurve: this._holdCurve,
minIntensity: this._minIntensity,
};
}
setConfig(config) {
if (config.pressureEnabled != null) this._pressureEnabled = !!config.pressureEnabled;
if (config.holdEnabled != null) this._holdEnabled = !!config.holdEnabled;
if (config.holdCurve != null) this.setHoldCurve(config.holdCurve);
if (config.minIntensity != null) this._minIntensity = Math.max(0, Math.min(1, config.minIntensity));
}
// ---- Internal ----
/**
* Extract pressure from a pointer event.
* Falls back to 1.0 on devices without pressure support.
* A pressure of 0 on non-touch events means "no pressure data" — treat as 1.0.
*/
_extractPressure(event) {
if (!event) return 1.0;
const p = event.pressure;
// Most mouse/touch events report 0.5 for button-down without pressure sensing.
// A true 0 means "no pressure data" (mouse hover, etc.) — fall back to 1.0.
if (typeof p !== 'number' || p === 0) return 1.0;
return Math.max(0, Math.min(1, p));
}
/**
* Update hold intensity based on elapsed time since pointer down.
* Uses an ease-in curve (quadratic).
*/
_updateHold() {
if (!this._active || !this._holdEnabled) return;
const elapsed = (performance.now() - this._startTime) / 1000; // seconds
const t = Math.min(1, elapsed / this._holdCurve);
// Ease-in (quadratic) — slow start, fast finish
this._holdIntensity = t * t;
}
}