Skip to content

Node interaction callbacks (click, hover) #4

Description

@23min

Problem

dag-map renders SVG with data-node-id, data-id, data-edge-from, data-edge-to attributes on elements, which enables external event wiring via DOM queries:

svg.querySelectorAll('circle[data-id]').forEach(el => {
  el.addEventListener('click', () => handleClick(el.dataset.id))
})

This works but has significant drawbacks:

  1. Re-renders destroy listeners. Every call to renderSVG produces a new SVG string. When the host framework (Svelte, React) sets innerHTML, all manually-attached listeners are lost and must be re-bound.

  2. No hover lifecycle. Implementing hover-highlight requires tracking mouseenter/mouseleave on <g> groups, coordinating with dimmed nodes, and cleaning up on re-render. Every consumer reimplements this.

  3. Edge interaction is harder. Edge paths have data-edge-from/data-edge-to but edges are thin paths — hard to click without a transparent hit area.

  4. Framework coupling. Each consumer writes its own binding layer. The pattern is identical each time.

Context

The FlowTime Svelte UI is moving to a "workbench" paradigm where the DAG topology is a navigation surface — users click nodes to pin them to an inspection panel. This makes node click the single most important interaction primitive. Currently handled by manual DOM wiring in dag-map-view.svelte, but fragile across re-renders when metrics change per time bin.

The dag-map ROADMAP lists "Click/tap events on stations" and "Hover tooltips" as planned. This issue proposes a concrete design.

Proposed Design

Option A: Callback options on renderSVG

Add optional callbacks to render options. Problem: renderSVG returns a string and cannot attach listeners. Would require changing the return type.

Option B: Return a bindEvents helper (recommended)

Keep renderSVG returning a string. Add a separate function:

import { renderSVG, bindEvents } from 'dag-map'

const svgString = renderSVG(dag, layout, options)
container.innerHTML = svgString

// Call after mounting. Returns a cleanup function.
const unbind = bindEvents(container, {
  onNodeClick: (nodeId, event) => { ... },
  onNodeEnter: (nodeId, event) => { ... },
  onNodeLeave: (nodeId, event) => { ... },
  onEdgeClick: (fromId, toId, event) => { ... },
})

bindEvents:

  • Uses event delegation on the SVG root (single listener per event type, not per element)
  • Adds transparent hit areas on edges for easier clicking
  • Returns unbind() for cleanup on re-render
  • Can be tree-shaken — consumers who don't need interaction don't import it

Option C: Managed component via dagMap()

Extend dagMap() to accept a container and manage the full lifecycle with an update() method. More powerful but changes the library's character.

Recommendation

Option B is the best fit:

  • Keeps renderSVG as a pure string function (no DOM dependency)
  • Works with any framework's mounting strategy
  • Event delegation means O(1) listeners regardless of node count
  • unbind() makes cleanup explicit
  • Tree-shakeable

Edge Hit Areas

Each interactive edge path should have an invisible companion:

<path d="..." stroke="transparent" stroke-width="12" pointer-events="stroke"
      data-edge-from="A" data-edge-to="B"/>
<path d="..." stroke="#2B8A8E" stroke-width="3" pointer-events="none"
      data-edge-from="A" data-edge-to="B"/>

Render hit areas when any onEdge* callback is present (or always — no visual impact).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions