o2p.js — Developer Reference

Internal architecture and maintenance guide for public/js/cues/o2p.js.


FILE STRUCTURE

The file is ~1750 lines organised into numbered sections:

Section Lines Purpose ------- ------ ----------------------------------------------- Imports 1-50 Module imports Helpers 51-100 Rotation indicator/handle update helpers 1 ~105 ensureO2PWrapper — wraps element for transforms 2 ~155 captureOriginalCenter — stores bbox center once 3 ~189 normalizeEase — ease value to iterator 4 ~231 VirtualPath — multi-path sampler + closed detection 5 ~292 makeTMapper — startPos/endPos range mapper 5b ~303 normalizeMode — fwd/rev/alt alias expansion 5c ~312 HOT_PARAMS + diffAndMergeParams — live console 6 ~354 applyTransform — positions object on path point 7 ~459 emitO2POsc — spatial OSC output 8 ~532 createPauseDragEntry — armed pause/drag/resume 9 ~648 startContinuousO2P — forward/reverse animation 10 ~784 startAlternateO2P — ping-pong animation 11 ~966 startO2PForElement — mode dispatch + cleanup 12 ~1058 positionO2PInitial — pre-position at startPos 13 ~1099 handleO2PCue — main entry point (cue dispatcher)


KEY DATA STRUCTURES

cfg (animation config)

Created once per element in handleO2PCue, stored as el._oscillaCfg. Contains both DSL params and runtime state (prefixed with _).

DSL params Runtime state (do not persist) --------------- ---------------------------------- path, mode, dur _armState: registered/armed/running/paused loop, ease _anim: pauseDragEntry (pause/play/resume/stop) startPos, endPos _start: rawStart function rotate, rotspeed _currentT: last globalT (0-1) from update osc, oscAddr _alternateDir: +1 or -1 (alternate mode) init, trig, uid _dragResumeT: one-shot resume position drag _dragResumeDir: direction at time of drag spatial, bounds _needsRestart: live console params changed format, group _wrapper: SVG group wrapping the element handle, hmode _oscillaCfg: self-reference on el launcher _spatialBounds: resolved bounds object rotrange _overlay: OSC debug overlay

startPos / endPos — IMMUTABLE AT RUNTIME

These define the path range from the DSL and must never be mutated after initial parse. All resume/restart logic uses the one-shot _dragResumeT mechanism instead. The animation closures capture startPos/endPos via destructuring at creation time.

_dragResumeT — one-shot resume position

Set by:

Consumed by:

After consumption, animation uses original startPos/endPos for all subsequent cycles. This is the core mechanism for "start here once, then use full range".


ANIMATION MODES

Continuous (forward / reverse)

Single anime() instance drives driver.u from 0 to 1. The update callback maps phase to globalT:

forward: globalT = (effectiveStart + phase) % 1 [wrapping] globalT = effectiveTMap(phase) [ranged] reverse: globalT = (effectiveStart - phase + 1) % 1 [wrapping] globalT = effectiveTMap(1 - phase) [ranged]

The wrapping branch is used when startPos != 0 and endPos == 1 (closed orbit with custom entry point). The tMap branch is used when endPos is explicitly set (partial range).

dragStart is a mutable local variable — first cycle uses the drag position, loopComplete sets it to null, subsequent cycles use original startPos.

Alternate (ping-pong)

Async loop calls halfCycle(+1) then halfCycle(-1) in a while loop. Each halfCycle creates a fresh anime() instance.

globalT calculation branches on closed vs open path:

closed + drag: (dragT + directionSign * phase) % 1 closed: (startPos + directionSign * phase) % 1 open + drag: lerp(dragT, target, phase) open forward: tMap(phase) open reverse: endPos + startPos - tMap(phase)

_dragResumeT is consumed once in the first halfCycle that runs. The _dragResumeDir field ensures resume starts in the correct direction (forward or reverse) — not always forward.

Duration scaling: drag resume on open paths scales cycleDur proportionally to the fraction of path being covered. Closed orbits always use full duration.

VirtualPath.closed detection

Checks SVG path d attribute for trailing Z/z command, or falls back to comparing start/end point distance (< 1px = closed).


ZOMBIE PREVENTION

startO2PForElement kills the previous animation before creating a new one:

1. Looks up runningAnimations.get(uid) 2. Calls entry.stop() — this sets stopped=true on alternate's async loop 3. Pauses el._o2pAnim (the raw anime instance)

The stopped flag is critical for alternate mode: without it, the old async loop's halfCycle promise may resolve after the new animation starts, creating a zombie half-cycle that interferes.

The alternate mode wraps the base pauseDragEntry.stop to inject stopped = true:

pauseDragEntry.stop = () => { stopped = true; origStop(); };


LIVE CONSOLE HOT-RELOAD

When handleO2PCue is called for an already-registered armed element:

1. diffAndMergeParams(cfg, args) compares and merges in one pass 2. If no params changed → return (playhead re-trigger, no-op) 3. If params changed: a. Set _needsRestart = true b. If running: capture _currentT, setTimeout(0) to restart c. If paused: flag stays, consumed on next click-resume

The setTimeout(0) is essential — synchronous restart from inside the cue dispatcher stack caused stuck states. Breaking to the next tick lets the call stack unwind first.

HOT_PARAMS table

Data-driven comparison and merge. Each entry defines:

get(cfg) → current value (for comparison) set(cfg, val) → merge new value into cfg norm(val) → normalize DSL value for comparison

Handles all alias/type mismatches: "rev" vs "reverse", 3 vs "3", "start" vs "startPos".


PAUSE-DRAG (drag:1)

Entry: createPauseDragEntry()

Returns { pause, play, resume, stop } stored as cfg._anim and in window.runningAnimations.

The pause() method only enables free drag if cfg.drag === true. Without the flag, pause just freezes the anime instance in place.

Resume flow

1. resumeFromDrag() called by play/resume 2. disableO2PPauseDrag(uid) → returns last {x, y} 3. findNearestTOnPath(pathEl, x, y) → nearestT 4. cfg._dragResumeT = nearestT (one-shot) 5. cfg._dragResumeDir = cfg._alternateDir (save direction) 6. cfg._resumeT = nearestT (for arm_sync network broadcast) 7. startO2PForElement(el, cfg) — kills old, creates new

Range preservation

cfg.startPos and cfg.endPos are NEVER mutated by resume. The one-shot _dragResumeT is consumed by the animation closure and automatically cleared after the first cycle.


MULTI-CLIENT SYNC (arm_sync)

Send (animShared.js click handler)

armed → running: sendArmSync(uid, "running") running → paused: sendArmSync(uid, "paused") paused → running: sendArmSync(uid, "running", { resumeT })

Relay (server.js)

JSON.stringify(data) passthrough to all other clients.

Receive (animShared.js listener)

window.addEventListener("oscilla:arm-sync", ...) → applyRemoteArmSync(uid, state, extra)

Remote resume with resumeT sets cfg._dragResumeT on the receiving client, so the animation restarts from the same path position.

Limitations


OBSERVER INTERACTION (oscillaObserver.js)

The visibility observer is path-aware for o2p animations:


FILES

public/js/cues/o2p.js This file public/js/cues/animShared.js Armed lifecycle + arm_sync public/js/cues/animOverlays.js Hit labels + click dispatch public/js/control/o2pTouch.js Drag handlers + findNearestTOnPath public/js/control/o2pLauncher.js Launcher bar for touch groups public/js/control/o2pPresets.js Preset save/recall/tween public/js/control/o2pPresetUI.js Preset manager panel public/js/control/spatialMap.js Coordinate mapping for spatial OSC public/js/control/rotationMath.js Rotation handle math public/js/system/oscillaObserver.js Visibility observer (path-aware) public/js/system/socket.js WebSocket message routing