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).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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