// 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();

/**

/**

/**