// o2pTouch.js // ============================================================ // Touch/drag interaction handlers for o2p (object-to-path). // Provides drag-along-path and rotation handle control for // trig:touch faders and knobs. // ============================================================
import { computeBBoxCenterScreen, repositionRotationRing } from "../cues/animOverlays.js";
window._oscillaDragSessions = window._oscillaDragSessions || new Map();
// ============================================================ // TOUCH/DRAG MODE: Initialize drag handler for o2p elements // ============================================================ export function initO2PDragHandler(hitRecord, pathEl, cfg, updatePositionCallback) { if (!hitRecord || !hitRecord.hit || !pathEl) { console.warn("[hitLabel] initO2PDragHandler: missing required elements"); return; }
const hit = hitRecord.hit; const uid = cfg.uid;
// Store drag context const dragContext = { active: false, pathEl, cfg, updatePosition: updatePositionCallback, hitRecord };
// Convert screen coordinates to the path's LOCAL coordinate space. // getPointAtLength() returns points in the path's local space, // so pointer coords must be converted to the same space. // Using pathEl.getScreenCTM() (not svg.getScreenCTM()) accounts for // any ancestor transforms (scale, translate, matrix, rotate) on // parent groups like mixer1's matrix(1.77,0,0,1.77,521,-894). function screenToSVG(screenX, screenY) { const svg = pathEl.ownerSVGElement; if (!svg) return { x: screenX, y: screenY };
const pt = svg.createSVGPoint(); pt.x = screenX; pt.y = screenY;
const ctm = pathEl.getScreenCTM(); if (!ctm) return { x: screenX, y: screenY };
const inverse = ctm.inverse(); const svgPt = pt.matrixTransform(inverse); return { x: svgPt.x, y: svgPt.y }; }
// Find the closest point on the path to a given SVG coordinate // Returns normalized progress (0-1) function findClosestPointOnPath(svgX, svgY) { const totalLength = pathEl.getTotalLength(); if (totalLength === 0) return 0;
// Binary search with refinement for performance const COARSE_STEPS = 50; const FINE_STEPS = 20;
let bestT = 0; let bestDist = Infinity;
// Coarse search for (let i = 0; i <= COARSE_STEPS; i++) { const t = i / COARSE_STEPS; const len = t * totalLength; const pt = pathEl.getPointAtLength(len); const dist = Math.hypot(pt.x - svgX, pt.y - svgY); if (dist < bestDist) { bestDist = dist; bestT = t; } }
// Fine search around best coarse result const searchRadius = 1 / COARSE_STEPS; const startT = Math.max(0, bestT - searchRadius); const endT = Math.min(1, bestT + searchRadius);
for (let i = 0; i <= FINE_STEPS; i++) { const t = startT + (i / FINE_STEPS) * (endT - startT); const len = t * totalLength; const pt = pathEl.getPointAtLength(len); const dist = Math.hypot(pt.x - svgX, pt.y - svgY); if (dist < bestDist) { bestDist = dist; bestT = t; } }
return bestT; }
// Map raw t (0-1) to the configured start/end range function mapToRange(rawT) { const start = cfg.startPos ?? 0; const end = cfg.endPos ?? 1; // rawT represents position in range, map it to actual path position return start + rawT * (end - start); }
// Handle pointer move during drag function onPointerMove(e) { if (!dragContext.active) return;
e.preventDefault();
const clientX = e.touches ? e.touches[0].clientX : e.clientX; const clientY = e.touches ? e.touches[0].clientY : e.clientY;
const svgCoords = screenToSVG(clientX, clientY); const rawT = findClosestPointOnPath(svgCoords.x, svgCoords.y); const mappedT = mapToRange(rawT);
// Call the update callback with the new position if (dragContext.updatePosition) { dragContext.updatePosition(mappedT, rawT); } }
// Handle pointer up - end drag function onPointerUp(e) { if (!dragContext.active) return;
dragContext.active = false; hit.style.cursor = "grab";
// Remove global listeners document.removeEventListener("mousemove", onPointerMove); document.removeEventListener("mouseup", onPointerUp); document.removeEventListener("touchmove", onPointerMove); document.removeEventListener("touchend", onPointerUp);
// Dispatch drag end event hitRecord.groupEl?.dispatchEvent( new CustomEvent("oscilla-drag-end", { bubbles: true, detail: { uid, kind: "o2p" } }) );
console.log("[hitLabel] drag ended", uid); }
// Handle pointer down - start drag function onPointerDown(e) { e.preventDefault(); e.stopPropagation();
dragContext.active = true; hit.style.cursor = "grabbing";
// Add global listeners for move/up document.addEventListener("mousemove", onPointerMove, { passive: false }); document.addEventListener("mouseup", onPointerUp); document.addEventListener("touchmove", onPointerMove, { passive: false }); document.addEventListener("touchend", onPointerUp);
// Dispatch drag start event hitRecord.groupEl?.dispatchEvent( new CustomEvent("oscilla-drag-start", { bubbles: true, detail: { uid, kind: "o2p" } }) );
// Immediately update position to where user clicked onPointerMove(e);
console.log("[hitLabel] drag started", uid); }
// Set up visual indication that this is draggable hit.style.cursor = "grab";
// Attach listeners to the hit area hit.addEventListener("mousedown", onPointerDown); hit.addEventListener("touchstart", onPointerDown, { passive: false });
// Store the drag context for potential cleanup window._oscillaDragSessions.set(uid, dragContext);
console.log("[hitLabel] drag handler initialized for", uid);
return dragContext; }
// ============================================================ // Cleanup drag handler // ============================================================ export function destroyO2PDragHandler(uid) { const ctx = window._oscillaDragSessions.get(uid); if (ctx) { ctx.active = false; window._oscillaDragSessions.delete(uid); } }
// ============================================================ // ROTATION RING: auto-generated HTML/SVG rotation indicator // ============================================================ // Creates a ring overlay around the fader's hit label with a // draggable indicator dot. Returns { dot, hit, radius } matching // the shape expected by updateRotationIndicator() in o2p.js. // // Architecture: // - HTML div container (position:fixed, follows fader via updateHitCircle) // - Inline SVG inside container: ring outline + dot circle
// ============================================================
export function createRotationRing(hitRecord, hmode, rotrange) { if (!hitRecord || !hitRecord.groupEl) return null;
// Size ring relative to fader's actual screen size const faderBox = hitRecord.groupEl.getBoundingClientRect(); const faderR = Math.max(faderBox.width, faderBox.height) / 2;
const RADIUS = faderR + 14; // ring orbit: outside the fader + gap const DOT_R = 5; // indicator dot radius const PAD = DOT_R + 4; // padding around ring for dot overflow const SIZE = (RADIUS + PAD) * 2; const HALF = SIZE / 2; // Inner hole = fader area, so fader clicks pass through const INNER_HOLE = Math.max(14, faderR + 4); const svgNS = "http://www.w3.org/2000/svg";
// ── Container div (holds the inline SVG) ──────────────────
const container = document.createElement("div");
container.className = "oscilla-rotation-ring";
container.dataset.uid = hitRecord.uid;
Object.assign(container.style, {
position: "fixed",
width: ${SIZE}px,
height: ${SIZE}px,
pointerEvents: "none",
zIndex: "999998"
});
// ── Inline SVG ────────────────────────────────────────────
const svg = document.createElementNS(svgNS, "svg");
svg.setAttribute("width", SIZE);
svg.setAttribute("height", SIZE);
svg.setAttribute("viewBox", ${-HALF} ${-HALF} ${SIZE} ${SIZE});
svg.style.overflow = "visible";
// Ring outline const ring = document.createElementNS(svgNS, "circle"); ring.setAttribute("cx", 0); ring.setAttribute("cy", 0); ring.setAttribute("r", RADIUS); ring.setAttribute("fill", "none"); ring.setAttribute("stroke", "#ff9933"); ring.setAttribute("stroke-width", "1.5"); ring.setAttribute("opacity", "0.6"); if (hmode === "limited") { // Visual distinction for limited-range mode ring.setAttribute("stroke-dasharray", "4 2"); } svg.appendChild(ring);
// Arc range markers for limited mode if (hmode === "limited" && rotrange) { const range = rotrange || 270; // Draw start and end tick marks // Zero is at 7-o'clock (120deg standard), range goes clockwise for (const angleDeg of [0, range]) { const stdAngle = angleDeg + 120; const rad = (stdAngle * Math.PI) / 180; const inner = RADIUS - 5; const outer = RADIUS + 5; const tick = document.createElementNS(svgNS, "line"); tick.setAttribute("x1", Math.cos(rad) * inner); tick.setAttribute("y1", Math.sin(rad) * inner); tick.setAttribute("x2", Math.cos(rad) * outer); tick.setAttribute("y2", Math.sin(rad) * outer); tick.setAttribute("stroke", "#ff9933"); tick.setAttribute("stroke-width", "2"); tick.setAttribute("opacity", "0.8"); svg.appendChild(tick); } }
// Indicator dot — positioned by updateRotationIndicator() in o2p.js // Initial position: 7-o'clock (0 in our system = 120deg standard) const initRad = (120 * Math.PI) / 180; const dot = document.createElementNS(svgNS, "circle"); dot.setAttribute("cx", Math.cos(initRad) * RADIUS); dot.setAttribute("cy", Math.sin(initRad) * RADIUS); dot.setAttribute("r", DOT_R); dot.setAttribute("fill", "#ff9933"); dot.setAttribute("stroke", "#fff"); dot.setAttribute("stroke-width", "1"); svg.appendChild(dot);
container.appendChild(svg);
// ── Hit area (donut shape — catches pointer events on the ring zone only) ── // z-index ABOVE the fader hit (999999) so rotation ring is reachable. // clip-path cuts a circular hole in the center so clicks there fall // through naturally to the fader hit beneath — no synthetic events needed. const hit = document.createElement("div"); hit.className = "oscilla-rotation-ring-hit"; hit.dataset.uid = hitRecord.uid;
Object.assign(hit.style, {
position: "fixed",
width: ${SIZE}px,
height: ${SIZE}px,
marginLeft: -${HALF}px,
marginTop: -${HALF}px,
pointerEvents: "auto",
cursor: "grab",
zIndex: "1000000",
borderRadius: "50%",
background: "transparent",
// Donut clip: SVG path with outer rect + inner circle cutout (evenodd)
clipPath: path(evenodd, 'M 0 0 L ${SIZE} 0 L ${SIZE} ${SIZE} L 0 ${SIZE} Z M ${HALF} ${HALF - INNER_HOLE} A ${INNER_HOLE} ${INNER_HOLE} 0 1 0 ${HALF} ${HALF + INNER_HOLE} A ${INNER_HOLE} ${INNER_HOLE} 0 1 0 ${HALF} ${HALF - INNER_HOLE} Z')
});
document.body.appendChild(container); document.body.appendChild(hit);
// ── Ring data object ────────────────────────────────────── const ringData = { container, hit, dot, radius: RADIUS, uid: hitRecord.uid };
// Store on hit record for automatic repositioning in updateHitCircle hitRecord._rotationRing = ringData;
// Initial position repositionRotationRing(hitRecord, ringData);
return ringData; }
// Works in screen coordinates — both the fader center (from // getBoundingClientRect) and pointer events use screen/client space, // so no SVG coordinate conversion needed. Angle convention matches // controlXY: 0 = 7 o'clock (120deg standard), increasing clockwise. // ============================================================
export function initO2PRotationDragHandler( rotDragTarget, hitRecord, pathEl, cfg, onRotateCallback ) { if (!rotDragTarget) { console.warn("[hitLabel] initO2PRotationDragHandler: no drag target"); return null; }
const dragContext = { active: false, uid: cfg?.uid || hitRecord?.uid };
// Get fader's current screen center for angle computation function getFaderScreenCenter() { if (hitRecord?.groupEl) { return computeBBoxCenterScreen(hitRecord.groupEl); } return null; }
function onPointerMove(e) { if (!dragContext.active) return; e.preventDefault();
const clientX = e.touches ? e.touches[0].clientX : e.clientX; const clientY = e.touches ? e.touches[0].clientY : e.clientY;
const center = getFaderScreenCenter(); if (!center) return;
const dx = clientX - center.x; const dy = clientY - center.y;
// atan2 → standard math angle, then convert to 7-o'clock-zero // Same convention as controlXY lines 715-720 let angle = Math.atan2(dy, dx) * (180 / Math.PI); angle = ((angle - 120) + 360) % 360;
if (onRotateCallback) { onRotateCallback(angle); } }
function onPointerUp() { if (!dragContext.active) return; dragContext.active = false; rotDragTarget.style.cursor = "grab";
document.removeEventListener("mousemove", onPointerMove); document.removeEventListener("mouseup", onPointerUp); document.removeEventListener("touchmove", onPointerMove); document.removeEventListener("touchend", onPointerUp); }
function onPointerDown(e) { e.preventDefault(); e.stopPropagation();
dragContext.active = true; rotDragTarget.style.cursor = "grabbing";
document.addEventListener("mousemove", onPointerMove, { passive: false }); document.addEventListener("mouseup", onPointerUp); document.addEventListener("touchmove", onPointerMove, { passive: false }); document.addEventListener("touchend", onPointerUp);
// Immediately compute angle at click position onPointerMove(e); }
rotDragTarget.style.cursor = "grab"; rotDragTarget.addEventListener("mousedown", onPointerDown); rotDragTarget.addEventListener("touchstart", onPointerDown, { passive: false });
// Store for potential cleanup
window._oscillaDragSessions.set(
rot:${dragContext.uid}, dragContext
);
return dragContext; }
// ============================================================ // PAUSE-DRAG: Free drag when o2p animation is paused // ============================================================ // When an o2p animation is paused (via click-to-stop), this enables // unconstrained dragging of the source marker within the spatial // bounds. During drag, spatial OSC is emitted continuously. // On resume, findNearestTOnPath() locates the closest point on // the path so animation can restart from the new position. // // Unlike touch-mode drag (path-constrained), pause-drag is FREE — // the source can go anywhere within bounds, just like controlXY. // ============================================================
window._oscillaPauseDragSessions = window._oscillaPauseDragSessions || new Map();
/**
Enable free drag on a paused o2p element.
@param {SVGElement} el — the o2p source element (or its wrapper)
@param {Object} cfg — the o2p config object
@param {Object} callbacks — { onDrag(svgX, svgY), onDragEnd(svgX, svgY) } */ export function enableO2PPauseDrag(el, cfg, callbacks = {}) { const uid = cfg.uid; const wrapper = cfg._wrapper || el; const svg = el.ownerSVGElement || document.querySelector("svg"); if (!svg) return null;
// Find the hit label for this element (the clickable overlay) const hitRecord = window._oscillaHitLabels?.find(r => r.uid === uid); const dragTarget = hitRecord?.hit || wrapper;
// Resolve spatial bounds for clamping (dome/plan circle only). // If no spatial bounds set, drag is completely unconstrained — // detaching from the path is an intentional feature. let boundsRect = null; if (cfg._spatialBounds) { const b = cfg._spatialBounds; boundsRect = { cx: b.cx, cy: b.cy, radius: b.radius }; }
// Track current position (starts at wherever the animation stopped) const bbox = wrapper.getBBox(); const origCenter = wrapper._o2pOriginalCenter || { x: bbox.x + bbox.width / 2, y: bbox.y + bbox.height / 2 }; let curX = origCenter.x; let curY = origCenter.y;
// Try to get actual position from the wrapper transform const existingTransform = wrapper.getAttribute("transform") || ""; const translateMatch = existingTransform.match(/translate(\s*([-\d.]+)[,\s]+([-\d.]+)/); if (translateMatch) { curX = origCenter.x + parseFloat(translateMatch[1]); curY = origCenter.y + parseFloat(translateMatch[2]); }
const dragContext = { active: false, uid, curX, curY, wrapper, cfg, enabled: true, startScreenX: 0, startScreenY: 0, didMove: false };
function screenToSVG(screenX, screenY) { const pt = svg.createSVGPoint(); pt.x = screenX; pt.y = screenY;
// Use the wrapper's parent CTM — this is the coordinate space // that the wrapper's translate attribute operates in, and the // same space as _o2pOriginalCenter and _spatialBounds. const ctm = (wrapper.parentNode || svg).getScreenCTM(); if (!ctm) return { x: screenX, y: screenY }; return pt.matrixTransform(ctm.inverse()); }
function clampToBounds(x, y) { if (!boundsRect) return { x, y };
if (boundsRect.radius) { // Circular bounds — clamp to circle const dx = x - boundsRect.cx; const dy = y - boundsRect.cy; const dist = Math.hypot(dx, dy); if (dist > boundsRect.radius) { const scale = boundsRect.radius / dist; return { x: boundsRect.cx + dx * scale, y: boundsRect.cy + dy * scale }; } } return { x, y }; }
function applyFreePosition(x, y) { const clamped = clampToBounds(x, y); dragContext.curX = clamped.x; dragContext.curY = clamped.y;
// Compute offset from original center (matches o2p's captureOriginalCenter) const origCenter = wrapper._o2pOriginalCenter || { x: bbox.x + bbox.width / 2, y: bbox.y + bbox.height / 2 }; const offX = clamped.x - origCenter.x; const offY = clamped.y - origCenter.y;
wrapper.setAttribute("transform", translate(${offX}, ${offY}));
// Reposition hit labels if (window.repositionAllHitLabels) window.repositionAllHitLabels();
// Callback for spatial OSC emission if (callbacks.onDrag) { callbacks.onDrag(clamped.x, clamped.y); } }
// Movement threshold: only start actual drag after pointer moves 5px const DRAG_THRESHOLD = 5;
function onPointerMove(e) { if (!dragContext.active || !dragContext.enabled) return; e.preventDefault();
const clientX = e.touches ? e.touches[0].clientX : e.clientX; const clientY = e.touches ? e.touches[0].clientY : e.clientY;
// Check if we've moved enough to start dragging if (!dragContext.didMove) { const dx = clientX - dragContext.startScreenX; const dy = clientY - dragContext.startScreenY; if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return; dragContext.didMove = true; dragTarget.style.cursor = "grabbing"; }
const svgCoords = screenToSVG(clientX, clientY); applyFreePosition(svgCoords.x, svgCoords.y); }
function onPointerUp(e) { if (!dragContext.active) return; dragContext.active = false; dragTarget.style.cursor = "grab";
document.removeEventListener("mousemove", onPointerMove); document.removeEventListener("mouseup", onPointerUp); document.removeEventListener("touchmove", onPointerMove); document.removeEventListener("touchend", onPointerUp);
// Only fire drag-end callback if there was actual movement if (dragContext.didMove && callbacks.onDragEnd) { callbacks.onDragEnd(dragContext.curX, dragContext.curY); }
if (dragContext.didMove) { // Suppress the next click so the armed handler doesn't // immediately resume. The user can click again to resume. const suppress = (ev) => { ev.stopImmediatePropagation(); ev.preventDefault(); dragTarget.removeEventListener("click", suppress, true); }; dragTarget.addEventListener("click", suppress, true); setTimeout(() => dragTarget.removeEventListener("click", suppress, true), 400);
console.log([o2p:pauseDrag] drag ended ${uid} → (${dragContext.curX.toFixed(1)}, ${dragContext.curY.toFixed(1)}));
}
// If !didMove (just a tap), click passes through → triggers resume
}
function onPointerDown(e) { if (!dragContext.enabled) return; // Don't stopPropagation — let click handler still work for resume e.preventDefault();
const clientX = e.touches ? e.touches[0].clientX : e.clientX; const clientY = e.touches ? e.touches[0].clientY : e.clientY;
dragContext.active = true; dragContext.didMove = false; dragContext.startScreenX = clientX; dragContext.startScreenY = clientY;
document.addEventListener("mousemove", onPointerMove, { passive: false }); document.addEventListener("mouseup", onPointerUp); document.addEventListener("touchmove", onPointerMove, { passive: false }); document.addEventListener("touchend", onPointerUp); }
// Visual feedback — change cursor dragTarget.style.cursor = "grab"; dragTarget._pauseDragDown = onPointerDown; dragTarget.addEventListener("mousedown", onPointerDown); dragTarget.addEventListener("touchstart", onPointerDown, { passive: false });
// Store session window._oscillaPauseDragSessions.set(uid, dragContext);
// Update overlay if (cfg._overlay) { cfg._overlay.update("⏸ drag to reposition"); }
console.log([o2p:pauseDrag] enabled for ${uid});
return dragContext;
}
/**
Disable free drag on a paused o2p element.
Returns the last drag position { x, y } for nearest-t lookup. */ export function disableO2PPauseDrag(uid) { const ctx = window._oscillaPauseDragSessions.get(uid); if (!ctx) return null;
ctx.enabled = false; ctx.active = false;
// Remove listeners from drag target const hitRecord = window._oscillaHitLabels?.find(r => r.uid === uid); const dragTarget = hitRecord?.hit || ctx.wrapper;
if (dragTarget._pauseDragDown) { dragTarget.removeEventListener("mousedown", dragTarget._pauseDragDown); dragTarget.removeEventListener("touchstart", dragTarget._pauseDragDown); delete dragTarget._pauseDragDown; } dragTarget.style.cursor = "";
const lastPos = { x: ctx.curX, y: ctx.curY };
window._oscillaPauseDragSessions.delete(uid);
console.log([o2p:pauseDrag] disabled for ${uid}, last pos: (${lastPos.x.toFixed(1)}, ${lastPos.y.toFixed(1)}));
return lastPos;
}
/**
Find nearest t (0–1) on a path element to an arbitrary SVG point.
Exported wrapper around the binary search used by touch-mode drag. */ export function findNearestTOnPath(pathEl, svgX, svgY) { const totalLength = pathEl.getTotalLength(); if (totalLength === 0) return 0;
const COARSE_STEPS = 50; const FINE_STEPS = 20;
let bestT = 0; let bestDist = Infinity;
// Coarse search for (let i = 0; i <= COARSE_STEPS; i++) { const t = i / COARSE_STEPS; const pt = pathEl.getPointAtLength(t * totalLength); const dist = Math.hypot(pt.x - svgX, pt.y - svgY); if (dist < bestDist) { bestDist = dist; bestT = t; } }
// Fine search around best coarse result const searchRadius = 1 / COARSE_STEPS; const startT = Math.max(0, bestT - searchRadius); const endT = Math.min(1, bestT + searchRadius);
for (let i = 0; i <= FINE_STEPS; i++) { const t = startT + (i / FINE_STEPS) * (endT - startT); const pt = pathEl.getPointAtLength(t * totalLength); const dist = Math.hypot(pt.x - svgX, pt.y - svgY); if (dist < bestDist) { bestDist = dist; bestT = t; } }
return bestT; }