memlnaut-nisps/playground/js/shapeseq/step-viz.js
w1n5t0n c8015da35a feat(shapeseq): implement Layer 1 modules
Four modules that build on the Layer 0 foundation:

- primitive.js: base class with category system (generator/processor/
  timing/converter), param schema validation, boundary enforcement
  (clamp/wrap/scaled), symbolic process() interface, state management
  for freeze, and applyBoundary() utility for delta control
- clock.js: AudioContext lookahead scheduling (25ms interval, 100ms
  window) replacing setTimeout arpeggiator. Reads pattern descriptions
  for per-step swing (timeOffset) and ratchet (subdivisions). Emits
  seq.noteOn/noteOff/step/loopStart via event bus.
- projection.js: composable transform chain with 5 transforms
  (VelocityCurve, GateThreshold, RangeMap, OctaveFolder, StutterMap)
  and 3 presets (expressive, percussive, fullRange). Pitch quantization
  deliberately excluded (handled by Interval Lock primitive).
- step-viz.js: Canvas2D circular step visualizer with even angular
  spacing for any step count. Pitch→radius, velocity→node size,
  accent→color. 60fps-friendly with pre-allocated coordinate buffers.
  Tap interaction for step toggling.
2026-03-24 02:02:01 +02:00

328 lines
9.7 KiB
JavaScript

/**
* ShapeSeq Circular Step Visualizer
*
* Renders steps arranged in a circle (heptagon, tridecagon, etc.)
* with pitch mapped to radial distance, velocity to node size,
* and accent to color brightness.
*
* Designed for 60fps rendering — no allocations in the render loop.
* Port-ready: explicit state, no closures in hot paths.
*
* @module shapeseq/step-viz
*/
import { SEQ } from './event-bus.js';
// ── Constants (pre-allocated, shared across instances) ──────────────
const TWO_PI = Math.PI * 2;
const HALF_PI = Math.PI * 0.5;
// Color constants
const COLOR_INACTIVE = 'rgba(255, 255, 255, 0.15)';
const COLOR_ACTIVE = '#00ccff';
const COLOR_CURRENT = '#ff6a00';
const COLOR_ACCENT = '#ffcc00';
const COLOR_BG = '#0d0d0d';
// Glow colors (pre-computed rgba strings)
const GLOW_ACTIVE = 'rgba(0, 204, 255, 0.3)';
const GLOW_CURRENT = 'rgba(255, 106, 0, 0.4)';
const GLOW_ACCENT = 'rgba(255, 204, 0, 0.35)';
// Layout
const PADDING_RATIO = 0.08; // canvas padding as fraction of min dimension
const OUTER_RADIUS_RATIO = 0.90; // outer ring at 90% of available radius
const INNER_RADIUS_RATIO = 0.30; // inner ring at 30% of available radius
// Node sizing
const NODE_MIN_RADIUS = 4;
const NODE_MAX_RADIUS = 18;
const NODE_OUTLINE_WIDTH = 1.5;
// Current-step indicator
const INDICATOR_EXTRA_RADIUS = 8;
const INDICATOR_LINE_WIDTH = 2;
// Center dot
const CENTER_DOT_RADIUS = 3;
// ── StepVisualizer ──────────────────────────────────────────────────
export class StepVisualizer {
/**
* @param {{ canvas: HTMLCanvasElement, eventBus: import('./event-bus.js').EventBus }} opts
*/
constructor({ canvas, eventBus }) {
this._canvas = canvas;
this._ctx = canvas.getContext('2d');
this._bus = eventBus;
// State
this._pattern = null; // current pattern description
this._currentStep = -1; // playback position (-1 = none)
this._width = 0;
this._height = 0;
this._cx = 0; // center x
this._cy = 0; // center y
this._maxRadius = 0; // max ring radius in pixels
// Pre-allocated arrays to avoid per-frame allocation.
// Sized lazily when pattern is set.
this._nodeX = null; // Float64Array — screen x per step
this._nodeY = null; // Float64Array — screen y per step
this._nodeR = null; // Float64Array — rendered radius per step
// Interaction
this._tapCallback = null;
this._onPointerDown = this._handlePointerDown.bind(this);
// Event bus subscription
this._onStep = this._handleStep.bind(this);
this._bus.on(SEQ.STEP, this._onStep);
// Canvas interaction
this._canvas.addEventListener('pointerdown', this._onPointerDown);
// Initial sizing
this.resize(canvas.getBoundingClientRect().width, canvas.getBoundingClientRect().height);
}
// ── Public API ──────────────────────────────────────────────────
/**
* Update the displayed pattern.
* @param {{ steps: Array, stepCount: number, metadata: Object }} patternDesc
*/
setPattern(patternDesc) {
this._pattern = patternDesc;
const count = patternDesc ? patternDesc.stepCount : 0;
// (Re)allocate coordinate buffers only when step count changes
if (!this._nodeX || this._nodeX.length !== count) {
this._nodeX = new Float64Array(count);
this._nodeY = new Float64Array(count);
this._nodeR = new Float64Array(count);
}
this._computeLayout();
}
/**
* Update the playback position.
* @param {number} index — step index (0-based), or -1 for none
*/
setCurrentStep(index) {
this._currentStep = index;
}
/**
* Draw one frame. Call from requestAnimationFrame.
*/
render() {
const ctx = this._ctx;
const w = this._width;
const h = this._height;
// Clear
ctx.fillStyle = COLOR_BG;
ctx.fillRect(0, 0, w, h);
if (!this._pattern || this._pattern.stepCount === 0) return;
const steps = this._pattern.steps;
const count = this._pattern.stepCount;
const cx = this._cx;
const cy = this._cy;
// Draw connecting ring (subtle guide circle at midpoint radius)
const midRadius = this._maxRadius * ((OUTER_RADIUS_RATIO + INNER_RADIUS_RATIO) * 0.5);
ctx.beginPath();
ctx.arc(cx, cy, midRadius, 0, TWO_PI);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.06)';
ctx.lineWidth = 1;
ctx.stroke();
// Center dot
ctx.beginPath();
ctx.arc(cx, cy, CENTER_DOT_RADIUS, 0, TWO_PI);
ctx.fillStyle = 'rgba(255, 255, 255, 0.2)';
ctx.fill();
// Draw connector line from center to current step
if (this._currentStep >= 0 && this._currentStep < count) {
const si = this._currentStep;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(this._nodeX[si], this._nodeY[si]);
ctx.strokeStyle = 'rgba(255, 106, 0, 0.2)';
ctx.lineWidth = INDICATOR_LINE_WIDTH;
ctx.stroke();
}
// Draw step nodes
for (let i = 0; i < count; i++) {
const step = steps[i];
const nx = this._nodeX[i];
const ny = this._nodeY[i];
const nr = this._nodeR[i];
const isCurrent = i === this._currentStep;
if (isCurrent) {
// Outer glow for current step
ctx.beginPath();
ctx.arc(nx, ny, nr + INDICATOR_EXTRA_RADIUS, 0, TWO_PI);
ctx.fillStyle = GLOW_CURRENT;
ctx.fill();
}
if (step.trigger) {
// Glow behind active nodes
if (!isCurrent) {
const glowColor = step.accent ? GLOW_ACCENT : GLOW_ACTIVE;
ctx.beginPath();
ctx.arc(nx, ny, nr + 4, 0, TWO_PI);
ctx.fillStyle = glowColor;
ctx.fill();
}
// Filled node
ctx.beginPath();
ctx.arc(nx, ny, nr, 0, TWO_PI);
if (isCurrent) {
ctx.fillStyle = COLOR_CURRENT;
} else if (step.accent) {
ctx.fillStyle = COLOR_ACCENT;
} else {
ctx.fillStyle = COLOR_ACTIVE;
}
ctx.fill();
} else {
// Dim outline only for untriggered steps
ctx.beginPath();
ctx.arc(nx, ny, nr, 0, TWO_PI);
ctx.strokeStyle = isCurrent ? COLOR_CURRENT : COLOR_INACTIVE;
ctx.lineWidth = NODE_OUTLINE_WIDTH;
ctx.stroke();
}
}
}
/**
* Handle canvas resize.
* @param {number} width — CSS pixels
* @param {number} height — CSS pixels
*/
resize(width, height) {
const dpr = window.devicePixelRatio || 1;
this._canvas.width = width * dpr;
this._canvas.height = height * dpr;
this._ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this._width = width;
this._height = height;
this._cx = width * 0.5;
this._cy = height * 0.5;
const minDim = Math.min(width, height);
this._maxRadius = (minDim * 0.5) * (1 - PADDING_RATIO * 2);
this._computeLayout();
}
/**
* Register a tap callback.
* @param {function(number): void} callback — receives step index
*/
onStepTap(callback) {
this._tapCallback = callback;
}
/**
* Unsubscribe from event bus and remove DOM listeners.
*/
destroy() {
this._bus.off(SEQ.STEP, this._onStep);
this._canvas.removeEventListener('pointerdown', this._onPointerDown);
this._tapCallback = null;
this._pattern = null;
}
// ── Private ─────────────────────────────────────────────────────
/**
* Re-compute node positions from current pattern + canvas size.
* Called when pattern or size changes — NOT per frame.
*/
_computeLayout() {
if (!this._pattern || !this._nodeX) return;
const steps = this._pattern.steps;
const count = this._pattern.stepCount;
const cx = this._cx;
const cy = this._cy;
const maxR = this._maxRadius;
const outerR = maxR * OUTER_RADIUS_RATIO;
const innerR = maxR * INNER_RADIUS_RATIO;
const radiusRange = outerR - innerR;
// Angular step: start at top (-PI/2), go clockwise
const angleStep = TWO_PI / count;
for (let i = 0; i < count; i++) {
const step = steps[i];
const angle = -HALF_PI + angleStep * i;
// Pitch -> radial distance: low pitch = outer, high pitch = inner
const pitchNorm = step.pitch; // 0 = low (outer), 1 = high (inner)
const r = outerR - pitchNorm * radiusRange;
this._nodeX[i] = cx + Math.cos(angle) * r;
this._nodeY[i] = cy + Math.sin(angle) * r;
// Velocity -> node size
this._nodeR[i] = NODE_MIN_RADIUS + step.velocity * (NODE_MAX_RADIUS - NODE_MIN_RADIUS);
}
}
/**
* Handle seq.step events from the event bus.
*/
_handleStep(data) {
if (typeof data.stepIndex === 'number') {
this._currentStep = data.stepIndex;
}
}
/**
* Handle pointer down on the canvas for tap interaction.
*/
_handlePointerDown(e) {
if (!this._tapCallback || !this._pattern) return;
const rect = this._canvas.getBoundingClientRect();
const px = e.clientX - rect.left;
const py = e.clientY - rect.top;
const count = this._pattern.stepCount;
// Find closest step within hit radius
let bestIdx = -1;
let bestDistSq = Infinity;
for (let i = 0; i < count; i++) {
const dx = px - this._nodeX[i];
const dy = py - this._nodeY[i];
const distSq = dx * dx + dy * dy;
// Hit area is the node radius + some tolerance
const hitR = this._nodeR[i] + 12;
if (distSq < hitR * hitR && distSq < bestDistSq) {
bestDistSq = distSq;
bestIdx = i;
}
}
if (bestIdx >= 0) {
this._tapCallback(bestIdx);
}
}
}