Internal architecture and maintenance guide for public/js/cues/o2p.js.
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)
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
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.
Set by:
resumeFromDrag() — nearest t on path after free drag_currentT before restartarm_sync — resumeT from authority clientConsumed by:
startContinuousO2P — captured as local dragStart, cleared on loopCompletestartAlternateO2P.halfCycle — consumed on first call, deleted immediatelyAfter consumption, animation uses original startPos/endPos for all subsequent cycles. This is the core mechanism for "start here once, then use full range".
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.
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.
Checks SVG path d attribute for trailing Z/z command, or falls
back to comparing start/end point distance (< 1px = closed).
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(); };
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.
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".
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.
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
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.
armed → running: sendArmSync(uid, "running") running → paused: sendArmSync(uid, "paused") paused → running: sendArmSync(uid, "running", { resumeT })
JSON.stringify(data) passthrough to all other clients.
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.
The visibility observer is path-aware for o2p animations:
_ghostState === "paused"
(user-paused via armed click)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