UNPKG

ngraph.svg

Version:

SVG-based graph visualization library with adaptive rendering

176 lines (160 loc) 5.71 kB
/** * Manages a DOM overlay container that sits on top of the SVG scene. * * The parent container mirrors the SVG transformGroup's transform * (translate + scale), so pan/zoom is a single CSS update. Individual * card elements are positioned in world coordinates with a contentScale * factor that matches NodeCollection's counter-scaling — ensuring DOM * cards scale identically to SVG node content. */ export default class DomOverlay { constructor(svgElement) { this._svg = svgElement; this._elements = new Map(); // nodeId -> { el, attached, stateVersion } this._restoreTimer = 0; this._onSvgWheel = this._handleSvgWheel.bind(this); this._svg.addEventListener('wheel', this._onSvgWheel); this._container = this._createContainer(); this._injectStyle(); } _createContainer() { const container = document.createElement('div'); // Zero-size anchor; children overflow visibly via translate. container.style.position = 'absolute'; container.style.top = '0'; container.style.left = '0'; container.style.width = '0'; container.style.height = '0'; container.style.transformOrigin = '0 0'; container.style.pointerEvents = 'none'; const parent = this._svg.parentNode; if (parent) { const cs = getComputedStyle(parent); if (cs.position === 'static') parent.style.position = 'relative'; parent.insertBefore(container, this._svg.nextSibling); } return container; } /** * Inject a one-off stylesheet rule that suppresses pointer events on all * children when the container has the suppressed class. The !important * overrides any inline pointer-events:auto set by the consumer. */ _injectStyle() { if (document.getElementById('dom-overlay-style')) return; const style = document.createElement('style'); style.id = 'dom-overlay-style'; style.textContent = '.dom-overlay--suppressed > * { pointer-events: none !important; }'; document.head.appendChild(style); } /** * When the user wheels on the SVG (zooming), suppress interactivity on * all DOM overlay children so cards that drift under the cursor can't * capture subsequent wheel ticks. Restore after 150ms of no wheel events. */ _handleSvgWheel() { this._container.classList.add('dom-overlay--suppressed'); clearTimeout(this._restoreTimer); this._restoreTimer = setTimeout(() => { this._container.classList.remove('dom-overlay--suppressed'); }, 150); } /** * Sync the overlay transform with the SVG scene transform. * Called once per frame. */ syncTransform(drawContext) { const t = drawContext.transform; this._container.style.transform = `translate(${t.x}px,${t.y}px) scale(${t.scale})`; } /** * Get or create a DOM element for the given node. * `createFn(data, ctx)` is only called on first encounter. */ ensureElement(nodeId, data, ctx, createFn) { let entry = this._elements.get(nodeId); if (!entry) { const el = createFn(data, ctx); el.style.position = 'absolute'; el.style.left = '0'; el.style.top = '0'; el.style.transformOrigin = '0 0'; el.style.pointerEvents = 'none'; entry = { el, attached: false, stateVersion: -1 }; this._elements.set(nodeId, entry); } return entry; } /** * Call the update callback if the node's state version has changed. */ updateState(nodeId, data, ctx, updateFn, stateVersion) { const entry = this._elements.get(nodeId); if (!entry || entry.stateVersion === stateVersion) return; entry.stateVersion = stateVersion; updateFn(data, ctx, entry.el); } /** * Set a DOM element's position in world coordinates with counter-scale. * contentScale matches NodeCollection's maxScale / zoom capping. * halfW/halfH offset the element so its center aligns with (worldX, worldY), * matching SVG node centering behavior. */ setPosition(nodeId, worldX, worldY, contentScale, halfW, halfH) { const entry = this._elements.get(nodeId); if (entry) { // halfW/halfH are in the element's local (unscaled) coordinates. // After scale(cs), local (halfW, halfH) occupies cs*halfW, cs*halfH // in the parent's space, so the translate offset must compensate for that. const ox = worldX - (halfW || 0) * contentScale; const oy = worldY - (halfH || 0) * contentScale; entry.el.style.transform = `translate(${ox}px,${oy}px) scale(${contentScale})`; } } /** * Attach the element to the overlay container (make visible). */ attach(nodeId) { const entry = this._elements.get(nodeId); if (entry && !entry.attached) { this._container.appendChild(entry.el); entry.attached = true; } } /** * Detach the element from the overlay container (hide, but keep cached). */ detach(nodeId) { const entry = this._elements.get(nodeId); if (entry && entry.attached) { this._container.removeChild(entry.el); entry.attached = false; } } /** * Remove a node's DOM element entirely (detach + delete from cache). */ remove(nodeId) { const entry = this._elements.get(nodeId); if (entry) { if (entry.attached && entry.el.parentNode) { entry.el.parentNode.removeChild(entry.el); } this._elements.delete(nodeId); } } dispose() { clearTimeout(this._restoreTimer); this._svg.removeEventListener('wheel', this._onSvgWheel); if (this._container) { if (this._container.parentNode) { this._container.parentNode.removeChild(this._container); } } this._elements.clear(); this._container = null; } }