HTML Overlay System

This document describes Oscilla's HTML overlay system, which creates transparent HTML elements positioned over SVG score elements to handle touch/click interactions that would otherwise be blocked by SVG stacking order or transform issues.


Overview

SVG elements can be difficult to interact with on touch devices due to:

The HTML overlay system solves these problems by creating invisible HTML <div> elements that:

  1. Are positioned over the SVG element using screen coordinates
  2. Capture pointer events reliably
  3. Dispatch actions to the underlying SVG element
  4. Can be hidden/shown with their parent panels

Overlay Types

1. Click Overlay (click:1)

File: cues/button.js → buildClickOverlaysIn()

Creates an invisible click target over any SVG element.

<g id="ui(uid:rot_triangle, action:toggle, click:1)">
  <rect x="0" y="0" width="45" height="24" fill="#d4af37"/>
  <text x="22.5" y="16">TRI</text>
</g>

Behavior:

2. Drag Overlay (drag-html:1)

File: cues/button.js → buildDragOverlaysIn()

Creates a draggable overlay for moving SVG elements without triggering score scroll.

<g id="drag(1, uid:inputLevels, drag-html:1)">
  <!-- Panel contents -->
</g>

Behavior:

3. Drag-Click Overlay (drag-click:1)

File: cues/button.js → buildDragClickOverlaysIn()

Combines drag and click on the same element (typically toggle icons).

<g id="ui(uid:rot_triangle, action:toggle, drag-click:1)">
  <path d="M 20,-18 L 38,18 L 2,18 Z" fill="#d4af37"/>
</g>

Behavior:


Architecture

Overlay Creation Flow

1. Score SVG loads
2. preProcessDrag.js finds drag() elements, sets data-drag-html attribute
3. button.js buildXxxOverlaysIn() called during score init
4. For each matching element:
   a. Create HTML div with position:absolute
   b. Calculate screen position from SVG CTM
   c. Attach pointer event handlers
   d. Store reference to SVG element
5. Window resize → reposition overlays

DOM Structure

#singlePage-content (or #scoreInner)
├── <svg> (score)
│   └── <g id="drag(1, uid:panel1, drag-html:1)">
│       └── ... SVG contents ...
│
├── .oscilla-drag-overlay (HTML, z-index: 100001)
├── .oscilla-click-overlay (HTML, z-index: 100015)
└── .oscilla-drag-click-overlay (HTML, z-index: 100010)

Z-Index Hierarchy

Overlay Type Z-Index Purpose
drag-overlay 100001 Lowest - blocks score scroll
drag-click-overlay 100010 Middle - drag + click
click-overlay 100015 Highest - click takes priority

Position Calculation

Overlays use SVG's getScreenCTM() to convert SVG coordinates to screen coordinates:

const place = () => {
    const bbox = el.getBBox();
    const ctm = el.getScreenCTM();
    if (!ctm) return;

    const container = containerEl.getBoundingClientRect();

    // SVG element corners in screen space
    const screenX = ctm.e + bbox.x * ctm.a;
    const screenY = ctm.f + bbox.y * ctm.d;
    const width = bbox.width * ctm.a;
    const height = bbox.height * ctm.d;

    // Convert to container-relative coordinates
    overlay.style.left = `${screenX - container.left}px`;
    overlay.style.top = `${screenY - container.top}px`;
    overlay.style.width = `${Math.abs(width)}px`;
    overlay.style.height = `${Math.abs(height)}px`;
};

Position Updates

Overlays reposition on:


Position Persistence

Storage Key

const DRAG_POSITIONS_KEY = "oscilla.dragPositions";

Storage Structure

{
  "projectName": {
    "uid1": { x: 100, y: 50 },
    "uid2": { x: 200, y: 100 },
    "toggle_uid3": { x: 300, y: 150 }  // For drag-click icons
  }
}

Restore on Load

const savedPos = getSavedDragPosition(uid);
if (savedPos) {
    el.setAttribute("transform", `translate(${savedPos.x}, ${savedPos.y})`);
}

Event Handling

Preventing Score Scroll

Drag overlays prevent events from reaching the score scroll handler:

overlay.addEventListener('touchstart', (e) => {
    e.preventDefault();
    e.stopPropagation();
}, { passive: false, capture: true });

overlay.addEventListener('touchmove', (e) => {
    e.preventDefault();
    e.stopPropagation();
}, { passive: false, capture: true });

Click-Through for Nested Elements

Drag overlays check for clickable elements beneath them:

const getClickableElementAt = (x, y) => {
    const originalDisplay = overlay.style.display;
    overlay.style.display = 'none';
    const elementUnderneath = document.elementFromPoint(x, y);
    overlay.style.display = originalDisplay;

    if (elementUnderneath?.classList.contains('oscilla-click-overlay')) {
        return elementUnderneath;
    }
    return null;
};

Drag vs Click Threshold

For drag-click:1, a 5px movement threshold distinguishes clicks from drags:

const DRAG_THRESHOLD = 5;

const onPointerMove = (e) => {
    const dx = e.clientX - startX;
    const dy = e.clientY - startY;

    if (!isDragging && (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD)) {
        isDragging = true;
    }

    if (isDragging) {
        // Apply drag transform
    }
};

const onPointerUp = (e) => {
    if (!isDragging) {
        // Was a click - trigger action
        handleCueTrigger(ast, false, true, el);
    }
};

Visibility Coordination

When a parent panel is hidden via ui(action:toggle), associated overlays must also be hidden.

File: cues/animShared.js → setElementVisibility()

// Find and hide/show all HTML overlays for elements inside this panel
const overlayClasses = [
    '.oscilla-drag-overlay',
    '.oscilla-drag-click-overlay',
    '.oscilla-click-overlay'
];

for (const cls of overlayClasses) {
    const overlays = document.querySelectorAll(cls);
    for (const overlay of overlays) {
        const svgEl = overlay._svgElement;
        if (svgEl && parentElement.contains(svgEl)) {
            overlay.style.display = visible ? "" : "none";
        }
    }
}

Cleanup Functions

// Remove all overlays of each type
destroyAllClickOverlays();
destroyAllDragOverlays();
destroyAllDragClickOverlays();

// Reposition all overlays (after drag, resize, etc.)
repositionAllClickOverlays();
repositionAllDragOverlays();
repositionAllDragClickOverlays();

Debugging

Visualize Overlays

Add temporary background color to see overlay positions:

overlay.style.background = 'rgba(255, 0, 0, 0.3)';

Console Inspection

// List all drag overlays
document.querySelectorAll('.oscilla-drag-overlay').forEach(o => {
    console.log(o._uid, o._svgElement);
});

// Check overlay count
document.querySelectorAll('.oscilla-drag-overlay').length
document.querySelectorAll('.oscilla-click-overlay').length

Common Issues

Overlay Not Appearing

  1. Check if SVG element has valid getBBox() (not zero-sized)
  2. Verify container element is not SVG (must be HTML)
  3. Check z-index conflicts with other elements

Clicks Not Working

  1. Verify overlay is positioned correctly (add debug background)
  2. Check for higher z-index elements blocking
  3. Ensure pointer-events not disabled on overlay

Drag Interferes with Scroll

  1. Verify touch-action: none is set on overlay
  2. Check event propagation is stopped
  3. Ensure overlay bounds match element bounds

Position Drifts After Zoom

  1. Reposition overlays after zoom completes
  2. Verify CTM is recalculated (not cached stale)

Version History

Tip: use ← → or ↑ ↓ to navigate the docs