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:
- Transform accumulation (rotated/scaled parents)
- Z-index/stacking issues within SVG
- Pointer event bubbling to scroll handlers
- Inconsistent touch target sizes
The HTML overlay system solves these problems by creating invisible HTML <div> elements that:
- Are positioned over the SVG element using screen coordinates
- Capture pointer events reliably
- Dispatch actions to the underlying SVG element
- 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:
- SVG element remains visible (provides visuals)
- HTML overlay captures clicks
- Click triggers the cue action
- Visual feedback via
filter: brightness(0.85)on press
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:
- Captures pointer events to prevent score scrolling
- Drags the SVG element via
transform: translate() - Position persists to localStorage by uid
- Restores position on page reload
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:
- Short tap → triggers click action (toggle)
- Drag gesture → moves the element
- Uses 5px threshold to distinguish click vs drag
- Position persists to localStorage
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:
- Window resize
- After drag completes
- Score zoom/scale changes
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
- Check if SVG element has valid
getBBox()(not zero-sized) - Verify container element is not SVG (must be HTML)
- Check z-index conflicts with other elements
Clicks Not Working
- Verify overlay is positioned correctly (add debug background)
- Check for higher z-index elements blocking
- Ensure pointer-events not disabled on overlay
Drag Interferes with Scroll
- Verify
touch-action: noneis set on overlay - Check event propagation is stopped
- Ensure overlay bounds match element bounds
Position Drifts After Zoom
- Reposition overlays after zoom completes
- Verify CTM is recalculated (not cached stale)
Version History
- v1.2 (2024-03) — Visibility coordination with parent panels
- v1.1 (2024-02) — Position persistence, click-through for nested elements
- v1.0 (2024-01) — Initial overlay system (click:1, drag-html:1, drag-click:1)
Tip: use ← → or ↑ ↓ to navigate the docs