Rocket Flow Pro

Rocket is currently in beta.

A Datastar powered version of React Flow, in which the server owns the graph state while Rocket provides a canvas for manipulating nodes and edges.

Demo

Pan and zoom on the graph. When you move a node, the optimistic position is shown with a faded opacity while the server remains the source of truth. When the backend broadcasts the snapped coordinates, the pending highlight clears. The blue node demonstrates supplying custom SVG content directly inside the light DOM, and the edges trace automatically between node IDs. Click an edge to select it, then press Delete or Backspace to request removal.

Explanation #

Each client subscribes to an updates endpoint from which the server streams the authoritative graph. This is a heavy integration example, so the component is intentionally more imperative than the simpler demos: it has to own a live canvas surface, drag state, and server reconciliation.

This keeps the flow editor optimistic without turning it into a client-owned graph like React Flow. Rocket simply provides the interactive layer while Go continues to own the topology, making it easy to broadcast changes across tabs and keep the visuals honest.

Rocket Component #

   1import { rocket } from 'datastar'
   2
   3const SVG_NS = 'http://www.w3.org/2000/svg'
   4const XLINK_NS = 'http://www.w3.org/1999/xlink'
   5const XML_NS = 'http://www.w3.org/XML/1998/namespace'
   6const SVG_ATTR_CASE_MAP = {
   7  viewbox: 'viewBox',
   8  preserveaspectratio: 'preserveAspectRatio',
   9  patternunits: 'patternUnits',
  10  patterntransform: 'patternTransform',
  11  markerunits: 'markerUnits',
  12  markerwidth: 'markerWidth',
  13  markerheight: 'markerHeight',
  14  gradientunits: 'gradientUnits',
  15  gradienttransform: 'gradientTransform',
  16  clippathunits: 'clipPathUnits',
  17  filterunits: 'filterUnits',
  18  maskunits: 'maskUnits',
  19  maskcontentunits: 'maskContentUnits',
  20  spreadmethod: 'spreadMethod',
  21  unicodebidi: 'unicodeBidi',
  22}
  23const DEFAULT_NODE_WIDTH = 120
  24const DEFAULT_NODE_HEIGHT = 48
  25
  26const clampZoom = (value) => {
  27  const next = Number(value)
  28  if (!Number.isFinite(next) || next <= 0) return 1
  29  return Math.min(16, Math.max(0.05, next))
  30}
  31
  32const normalizeViewport = (value) => [
  33  value?.[0] ?? 0,
  34  value?.[1] ?? 0,
  35  clampZoom(value?.[2] ?? 1),
  36]
  37
  38const bezierPathPoints = (sx, sy, tx, ty) => {
  39  const dx = tx - sx
  40  const dy = ty - sy
  41  if (Math.abs(dx) >= Math.abs(dy)) {
  42    const midX = sx + dx * 0.5
  43    return { c1x: midX, c1y: sy, c2x: midX, c2y: ty }
  44  }
  45  const midY = sy + dy * 0.5
  46  return { c1x: sx, c1y: midY, c2x: tx, c2y: midY }
  47}
  48
  49const makeEdgeKey = (sourceId, targetId, id) =>
  50  id ? `id:${id}` : `${sourceId ?? ''}->${targetId ?? ''}`
  51
  52const hideFlowChild = (node) => {
  53  if (!(node instanceof HTMLElement)) return
  54  const tag = node.tagName.toLowerCase()
  55  if (tag === 'rocket-flow-node' || tag === 'rocket-flow-edge') {
  56    node.style.display = 'none'
  57  }
  58}
  59
  60const cloneNodeIntoSvg = (node) => {
  61  if (!node) return null
  62  if (node.nodeType === Node.TEXT_NODE) {
  63    const text = node.textContent ?? ''
  64    return text.trim() ? document.createTextNode(text) : null
  65  }
  66  if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
  67    const fragment = document.createDocumentFragment()
  68    node.childNodes.forEach((child) => {
  69      const clone = cloneNodeIntoSvg(child)
  70      if (!clone) return
  71      if (clone.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
  72        fragment.append(...Array.from(clone.childNodes))
  73      } else {
  74        fragment.append(clone)
  75      }
  76    })
  77    return fragment
  78  }
  79  if (node.nodeType !== Node.ELEMENT_NODE) return null
  80  if (node.tagName === 'SLOT') return null
  81  const tag = node.tagName.toUpperCase()
  82  if (tag === 'SCRIPT' || tag === 'STYLE') return null
  83  if (
  84    node.hasAttribute('data-ref') &&
  85    node.getAttribute('data-ref') === 'defaultContent'
  86  ) {
  87    return null
  88  }
  89  if (node.tagName === 'TEMPLATE') {
  90    const fragment = document.createDocumentFragment()
  91    node.content.childNodes.forEach((child) => {
  92      const clone = cloneNodeIntoSvg(child)
  93      if (!clone) return
  94      if (clone.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
  95        fragment.append(...Array.from(clone.childNodes))
  96      } else {
  97        fragment.append(clone)
  98      }
  99    })
 100    return fragment
 101  }
 102  const clone = document.createElementNS(SVG_NS, node.localName.toLowerCase())
 103  for (const attr of Array.from(node.attributes)) {
 104    if (attr.namespaceURI)
 105      clone.setAttributeNS(attr.namespaceURI, attr.name, attr.value)
 106    else if (attr.prefix === 'xlink')
 107      clone.setAttributeNS(XLINK_NS, attr.name, attr.value)
 108    else if (attr.prefix === 'xml')
 109      clone.setAttributeNS(XML_NS, attr.name, attr.value)
 110    else
 111      clone.setAttribute(
 112        SVG_ATTR_CASE_MAP[attr.name.toLowerCase()] ?? attr.name,
 113        attr.value,
 114      )
 115  }
 116  node.childNodes.forEach((child) => {
 117    const clonedChild = cloneNodeIntoSvg(child)
 118    if (!clonedChild) return
 119    if (clonedChild.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
 120      clone.append(...Array.from(clonedChild.childNodes))
 121    } else {
 122      clone.append(clonedChild)
 123    }
 124  })
 125  return clone
 126}
 127
 128rocket('flow-container', {
 129  props: ({ array, number, date }) => ({
 130    viewport: array(number, number, number).default([0, 0, 1]),
 131    grid: number.min(1).default(32),
 132    serverUpdateTime: date.default(() => new Date()),
 133  }),
 134  onFirstRender: ({ cleanup, host, observeProps, props, refs }) => {
 135    host.style.display = 'block'
 136    host.style.position = 'relative'
 137    if (!host.hasAttribute('tabindex')) host.setAttribute('tabindex', '0')
 138
 139    let svg
 140    let background
 141    let gridPattern
 142    let gridPath
 143    let graph
 144    let frame = 0
 145    let graphFrame = 0
 146    let edgeFrameId = 0
 147    let edgeFramePending = false
 148    let lastSignature = ''
 149    let selectedEdgeKey = null
 150    let lastServerUpdateTime = Number.NaN
 151    let viewport = normalizeViewport(props.viewport)
 152    let grid = props.grid
 153
 154    const metrics = {
 155      screenWidth: 1,
 156      screenHeight: 1,
 157      worldWidth: 1,
 158      worldHeight: 1,
 159      zoom: 1,
 160      minX: 0,
 161      minY: 0,
 162    }
 163    const nodes = new Map()
 164    const nodesById = new Map()
 165    const edges = new Map()
 166    const edgesByKey = new Map()
 167    const activeNodeDrags = new Map()
 168    const edgeSelectionBoundGroups = new WeakSet()
 169    const dragEnabledGroups = new WeakSet()
 170
 171    const ensureRefs = () => {
 172      svg = refs.svg ?? null
 173      background = refs.background ?? null
 174      gridPattern = refs.gridPattern ?? null
 175      gridPath = refs.gridPath ?? null
 176      graph = refs.graph ?? null
 177      return (
 178        svg instanceof SVGSVGElement &&
 179        background instanceof SVGRectElement &&
 180        gridPattern instanceof SVGPatternElement &&
 181        gridPath instanceof SVGPathElement &&
 182        graph instanceof SVGGElement
 183      )
 184    }
 185
 186    const ensureLayer = (name) => {
 187      let layer = graph?.querySelector(`g[data-layer="${name}"]`)
 188      if (layer instanceof SVGGElement) return layer
 189      layer = document.createElementNS(SVG_NS, 'g')
 190      layer.dataset.layer = name
 191      graph?.append(layer)
 192      return layer
 193    }
 194
 195    let edgesLayer
 196    let nodesLayer
 197
 198    const syncNodeDataset = (entry) => {
 199      if (!entry?.group) return
 200      if (entry.id) entry.group.dataset.nodeId = entry.id
 201      else delete entry.group.dataset.nodeId
 202    }
 203
 204    const updateNodeId = (entry, nextId) => {
 205      const id = (nextId ?? '').trim()
 206      if (entry.id && nodesById.get(entry.id) === entry)
 207        nodesById.delete(entry.id)
 208      entry.id = id
 209      if (id) nodesById.set(id, entry)
 210      syncNodeDataset(entry)
 211    }
 212
 213    const emitNodeEvent = (entry, type, extra = {}) => {
 214      if (!entry?.el) return
 215      entry.el.dispatchEvent(
 216        new CustomEvent(type, {
 217          detail: {
 218            instanceId: entry.el.rocketInstanceId,
 219            node: entry.el,
 220            x: entry.x,
 221            y: entry.y,
 222            ...extra,
 223          },
 224          bubbles: true,
 225          composed: true,
 226        }),
 227      )
 228    }
 229
 230    const scheduleEdgeRender = () => {
 231      if (!edges.size || edgeFramePending) return
 232      edgeFramePending = true
 233      edgeFrameId = requestAnimationFrame(() => {
 234        edgeFramePending = false
 235        edgeFrameId = 0
 236        for (const entry of edges.values()) {
 237          const source = entry.sourceId ? nodesById.get(entry.sourceId) : null
 238          const target = entry.targetId ? nodesById.get(entry.targetId) : null
 239          if (!source || !target) {
 240            entry.group?.setAttribute('display', 'none')
 241            continue
 242          }
 243          entry.group?.removeAttribute('display')
 244          const { c1x, c1y, c2x, c2y } = bezierPathPoints(
 245            source.x,
 246            source.y,
 247            target.x,
 248            target.y,
 249          )
 250          entry.path?.setAttribute(
 251            'd',
 252            `M ${source.x} ${source.y} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${target.x} ${target.y}`,
 253          )
 254          entry.path?.classList.toggle('is-animated', !!entry.animated)
 255          if (!entry.labelEl) continue
 256          if (!entry.label) {
 257            entry.labelEl.textContent = ''
 258            entry.labelEl.setAttribute('display', 'none')
 259            continue
 260          }
 261          entry.labelEl.textContent = entry.label
 262          entry.labelEl.removeAttribute('display')
 263          entry.labelEl.setAttribute('x', String((source.x + target.x) / 2))
 264          entry.labelEl.setAttribute('y', String((source.y + target.y) / 2))
 265        }
 266      })
 267    }
 268
 269    const applyEdgeSelectionClasses = () => {
 270      edgesLayer?.querySelectorAll('.flow-edge').forEach((group) => {
 271        const selected = group.dataset.edgeKey === selectedEdgeKey
 272        group.classList.toggle('is-selected', selected)
 273        if (
 274          !selected &&
 275          group.dataset.edgeKey !== host.getAttribute('pending-edge')
 276        ) {
 277          group.classList.remove('is-pending')
 278        }
 279      })
 280    }
 281
 282    const clearSelectedEdge = () => {
 283      if (!selectedEdgeKey) return
 284      selectedEdgeKey = null
 285      applyEdgeSelectionClasses()
 286      host.dispatchEvent(
 287        new CustomEvent('flow-edge-select', {
 288          detail: null,
 289          bubbles: true,
 290          composed: true,
 291        }),
 292      )
 293    }
 294
 295    const registerEdgeEntry = (entry, previousKey = entry.key) => {
 296      entry.key = makeEdgeKey(entry.sourceId, entry.targetId, entry.id)
 297      if (
 298        previousKey &&
 299        previousKey !== entry.key &&
 300        edgesByKey.get(previousKey) === entry
 301      ) {
 302        edgesByKey.delete(previousKey)
 303      }
 304      if (entry.key) edgesByKey.set(entry.key, entry)
 305      if (entry.group) {
 306        if (entry.sourceId) entry.group.dataset.sourceId = entry.sourceId
 307        else delete entry.group.dataset.sourceId
 308        if (entry.targetId) entry.group.dataset.targetId = entry.targetId
 309        else delete entry.group.dataset.targetId
 310        if (entry.id) entry.group.dataset.edgeId = entry.id
 311        else delete entry.group.dataset.edgeId
 312        if (entry.key) entry.group.dataset.edgeKey = entry.key
 313        else delete entry.group.dataset.edgeKey
 314      }
 315      if (entry.group && !edgeSelectionBoundGroups.has(entry.group)) {
 316        edgeSelectionBoundGroups.add(entry.group)
 317        entry.group.addEventListener('pointerdown', (evt) => {
 318          evt.stopPropagation()
 319          evt.preventDefault()
 320          if (!entry.key) registerEdgeEntry(entry)
 321          if (!entry.key) return
 322          selectedEdgeKey = entry.key
 323          applyEdgeSelectionClasses()
 324          host.dispatchEvent(
 325            new CustomEvent('flow-edge-select', {
 326              detail: {
 327                id: entry.id ?? '',
 328                sourceId: entry.sourceId ?? '',
 329                targetId: entry.targetId ?? '',
 330              },
 331              bubbles: true,
 332              composed: true,
 333            }),
 334          )
 335          try {
 336            host.focus({ preventScroll: true })
 337          } catch {}
 338        })
 339      }
 340      applyEdgeSelectionClasses()
 341    }
 342
 343    const ensureEdgeGraphics = (entry) => {
 344      if (entry.group) return
 345      const group = document.createElementNS(SVG_NS, 'g')
 346      const path = document.createElementNS(SVG_NS, 'path')
 347      const label = document.createElementNS(SVG_NS, 'text')
 348      group.classList.add('flow-edge')
 349      group.style.pointerEvents = 'auto'
 350      group.style.cursor = 'pointer'
 351      path.setAttribute('pointer-events', 'stroke')
 352      group.append(path, label)
 353      edgesLayer?.append(group)
 354      entry.group = group
 355      entry.path = path
 356      entry.labelEl = label
 357      registerEdgeEntry(entry)
 358    }
 359
 360    const positionNode = (entry) => {
 361      if (!entry.group) return
 362      entry.group.setAttribute(
 363        'transform',
 364        `translate(${entry.x - entry.width / 2} ${entry.y - entry.height / 2})`,
 365      )
 366      scheduleEdgeRender()
 367    }
 368
 369    const applyNodeContent = (entry, content) => {
 370      if (!entry.group) return
 371      if (!content) {
 372        entry.group.replaceChildren()
 373        return
 374      }
 375      const clone = content.cloneNode(true)
 376      if (clone.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
 377        entry.group.replaceChildren(...Array.from(clone.childNodes))
 378      } else {
 379        entry.group.replaceChildren(clone)
 380      }
 381    }
 382
 383    const enableNodeDrag = (entry) => {
 384      if (!entry.group || dragEnabledGroups.has(entry.group) || !svg) return
 385      dragEnabledGroups.add(entry.group)
 386      const root = svg.ownerDocument?.defaultView ?? window
 387      entry.group.addEventListener('pointerdown', (evt) => {
 388        if (evt.button !== 0) return
 389        console.log('[rocket/flow] node:pointerdown', {
 390          pointerId: evt.pointerId,
 391          id: entry.id,
 392          target: evt.target instanceof Element ? evt.target.tagName : null,
 393        })
 394        evt.preventDefault()
 395        evt.stopPropagation()
 396        const pointerId = evt.pointerId
 397        const drag = {
 398          pointerId,
 399          startX: entry.x,
 400          startY: entry.y,
 401          startClientX: evt.clientX,
 402          startClientY: evt.clientY,
 403        }
 404        activeNodeDrags.set(pointerId, drag)
 405        try {
 406          entry.group.setPointerCapture(pointerId)
 407        } catch {}
 408        entry.group.classList.add('is-dragging')
 409        entry.group.style.cursor = 'grabbing'
 410        emitNodeEvent(entry, 'flow-node-drag-start', {
 411          pointerId,
 412          origin: { x: drag.startX, y: drag.startY },
 413        })
 414
 415        const move = (event) => {
 416          if (event.pointerId !== pointerId) return
 417          entry.x =
 418            drag.startX +
 419            (event.clientX - drag.startClientX) *
 420              (metrics.worldWidth / metrics.screenWidth)
 421          entry.y =
 422            drag.startY +
 423            (event.clientY - drag.startClientY) *
 424              (metrics.worldHeight / metrics.screenHeight)
 425          positionNode(entry)
 426          emitNodeEvent(entry, 'flow-node-drag', {
 427            pointerId,
 428            origin: { x: drag.startX, y: drag.startY },
 429          })
 430        }
 431
 432        const finish = (event, type) => {
 433          if (event.pointerId !== pointerId) return
 434          activeNodeDrags.delete(pointerId)
 435          try {
 436            entry.group.releasePointerCapture(pointerId)
 437          } catch {}
 438          entry.group.classList.remove('is-dragging')
 439          entry.group.style.cursor = 'grab'
 440          root.removeEventListener('pointermove', move, true)
 441          root.removeEventListener('pointerup', end, true)
 442          root.removeEventListener('pointercancel', cancel, true)
 443          if (type === 'cancel') {
 444            emitNodeEvent(entry, 'flow-node-drag-cancel', {
 445              pointerId,
 446              origin: { x: drag.startX, y: drag.startY },
 447            })
 448            return
 449          }
 450          const detail = {
 451            pointerId,
 452            origin: { x: drag.startX, y: drag.startY },
 453            source: 'drag',
 454            x: entry.x,
 455            y: entry.y,
 456          }
 457          emitNodeEvent(entry, 'flow-node-drag-end', detail)
 458          try {
 459            entry.el.setAttribute('x', String(entry.x))
 460            entry.el.setAttribute('y', String(entry.y))
 461          } catch {}
 462          emitNodeEvent(entry, 'flow-node-update', detail)
 463        }
 464
 465        const end = (event) => finish(event, 'end')
 466        const cancel = (event) => finish(event, 'cancel')
 467        root.addEventListener('pointermove', move, true)
 468        root.addEventListener('pointerup', end, true)
 469        root.addEventListener('pointercancel', cancel, true)
 470      })
 471    }
 472
 473    const ensureNodeGroup = (entry) => {
 474      if (entry.group) return
 475      entry.group = document.createElementNS(SVG_NS, 'g')
 476      entry.group.classList.add('flow-node')
 477      entry.group.setAttribute('tabindex', '-1')
 478      entry.group.style.cursor = 'grab'
 479      nodesLayer?.append(entry.group)
 480      syncNodeDataset(entry)
 481      enableNodeDrag(entry)
 482    }
 483
 484    const renderNodes = () => {
 485      if (!nodes.size) return
 486      for (const entry of nodes.values()) {
 487        ensureNodeGroup(entry)
 488        positionNode(entry)
 489      }
 490      scheduleEdgeRender()
 491    }
 492
 493    const scheduleNodeRender = () => {
 494      if (graphFrame) return
 495      graphFrame = requestAnimationFrame(() => {
 496        graphFrame = 0
 497        renderNodes()
 498      })
 499    }
 500
 501    const clearPendingEdge = () => {
 502      host.removeAttribute('pending-edge')
 503      edgesLayer?.querySelectorAll('.flow-edge.is-pending').forEach((group) => {
 504        group.classList.remove('is-pending')
 505      })
 506    }
 507
 508    const clearPendingState = () => {
 509      host.removeAttribute('pending')
 510      host.removeAttribute('data-pending-node')
 511      graph
 512        ?.querySelectorAll('.flow-node.is-pending, .flow-edge.is-pending')
 513        .forEach((item) => {
 514          item.classList.remove('is-pending')
 515        })
 516      clearPendingEdge()
 517    }
 518
 519    const setPendingState = (nodeId) => {
 520      if (!nodeId) {
 521        clearPendingState()
 522        return
 523      }
 524      host.setAttribute('pending', 'true')
 525      host.setAttribute('data-pending-node', nodeId)
 526      nodesLayer?.querySelectorAll('.flow-node').forEach((group) => {
 527        group.classList.toggle('is-pending', group.dataset.nodeId === nodeId)
 528      })
 529      edgesLayer?.querySelectorAll('.flow-edge').forEach((group) => {
 530        const connected =
 531          group.dataset.sourceId === nodeId || group.dataset.targetId === nodeId
 532        group.classList.toggle('is-pending', connected)
 533      })
 534    }
 535
 536    const setPendingEdge = (edgeKey) => {
 537      if (!edgeKey) {
 538        clearPendingEdge()
 539        return
 540      }
 541      host.setAttribute('pending-edge', edgeKey)
 542      edgesLayer?.querySelectorAll('.flow-edge').forEach((group) => {
 543        group.classList.toggle('is-pending', group.dataset.edgeKey === edgeKey)
 544      })
 545    }
 546
 547    const schedule = () => {
 548      if (frame || !svg || !background || !gridPattern || !gridPath) return
 549      frame = requestAnimationFrame(() => {
 550        frame = 0
 551        const [cx, cy, zoom] = viewport
 552        const rect = svg.getBoundingClientRect()
 553        metrics.screenWidth = Math.max(1, rect.width)
 554        metrics.screenHeight = Math.max(1, rect.height)
 555        metrics.zoom = zoom
 556        metrics.worldWidth = metrics.screenWidth / zoom
 557        metrics.worldHeight = metrics.screenHeight / zoom
 558        metrics.minX = cx - metrics.worldWidth / 2
 559        metrics.minY = cy - metrics.worldHeight / 2
 560        svg.setAttribute(
 561          'viewBox',
 562          `${metrics.minX} ${metrics.minY} ${metrics.worldWidth} ${metrics.worldHeight}`,
 563        )
 564        background.setAttribute('x', String(metrics.minX))
 565        background.setAttribute('y', String(metrics.minY))
 566        background.setAttribute('width', String(metrics.worldWidth))
 567        background.setAttribute('height', String(metrics.worldHeight))
 568        const spacing = Math.max(4, grid)
 569        gridPattern.setAttribute('width', String(spacing))
 570        gridPattern.setAttribute('height', String(spacing))
 571        gridPath.setAttribute('d', `M ${spacing} 0 L 0 0 0 ${spacing}`)
 572        gridPattern.removeAttribute('patternTransform')
 573        const signature = `${metrics.minX}|${metrics.minY}|${metrics.zoom}|${metrics.screenWidth}|${metrics.screenHeight}`
 574        if (signature !== lastSignature) {
 575          lastSignature = signature
 576          console.log('[rocket/flow] viewport:change', {
 577            viewport: [...viewport],
 578          })
 579          host.dispatchEvent(
 580            new CustomEvent('viewport-change', {
 581              detail: {
 582                viewport: [...viewport],
 583                screen: {
 584                  width: metrics.screenWidth,
 585                  height: metrics.screenHeight,
 586                },
 587                world: {
 588                  minX: metrics.minX,
 589                  minY: metrics.minY,
 590                  width: metrics.worldWidth,
 591                  height: metrics.worldHeight,
 592                },
 593              },
 594              bubbles: true,
 595              composed: true,
 596            }),
 597          )
 598        }
 599        renderNodes()
 600      })
 601    }
 602
 603    const pointerDown = (evt) => {
 604      if (evt.button !== 0 || (evt.target !== svg && evt.target !== background))
 605        return
 606      console.log('[rocket/flow] viewport:pointerdown', {
 607        pointerId: evt.pointerId,
 608        target: evt.target instanceof Element ? evt.target.tagName : null,
 609      })
 610      clearSelectedEdge()
 611      evt.preventDefault()
 612      const origin = [...viewport]
 613      svg.classList.add('is-dragging')
 614      try {
 615        svg.setPointerCapture(evt.pointerId)
 616      } catch {}
 617      const move = (event) => {
 618        if (event.pointerId !== evt.pointerId) return
 619        const scale = 1 / origin[2]
 620        viewport = [
 621          origin[0] - (event.clientX - evt.clientX) * scale,
 622          origin[1] - (event.clientY - evt.clientY) * scale,
 623          origin[2],
 624        ]
 625        schedule()
 626      }
 627      const end = (event) => {
 628        if (event.pointerId !== evt.pointerId) return
 629        svg.classList.remove('is-dragging')
 630        try {
 631          svg.releasePointerCapture(evt.pointerId)
 632        } catch {}
 633        svg.removeEventListener('pointermove', move, true)
 634        svg.removeEventListener('pointerup', end, true)
 635        svg.removeEventListener('pointercancel', end, true)
 636      }
 637      svg.addEventListener('pointermove', move, true)
 638      svg.addEventListener('pointerup', end, true)
 639      svg.addEventListener('pointercancel', end, true)
 640    }
 641
 642    const handleWheel = (evt) => {
 643      evt.preventDefault()
 644      if (!svg) return
 645      const rect = svg.getBoundingClientRect()
 646      const zoom = metrics.zoom
 647      const nextZoom = clampZoom(zoom * 1.2 ** (-evt.deltaY / 120))
 648      if (nextZoom === zoom) return
 649      const px = evt.clientX - rect.left
 650      const py = evt.clientY - rect.top
 651      const focusX = metrics.minX + (px / rect.width) * metrics.worldWidth
 652      const focusY = metrics.minY + (py / rect.height) * metrics.worldHeight
 653      const nextWorldWidth = metrics.screenWidth / nextZoom
 654      const nextWorldHeight = metrics.screenHeight / nextZoom
 655      const minX = focusX - (px / rect.width) * nextWorldWidth
 656      const minY = focusY - (py / rect.height) * nextWorldHeight
 657      viewport = [
 658        minX + nextWorldWidth / 2,
 659        minY + nextWorldHeight / 2,
 660        nextZoom,
 661      ]
 662      schedule()
 663    }
 664
 665    const handleKeydown = (evt) => {
 666      if (!selectedEdgeKey || (evt.key !== 'Backspace' && evt.key !== 'Delete'))
 667        return
 668      const entry = edgesByKey.get(selectedEdgeKey)
 669      if (!entry) {
 670        clearSelectedEdge()
 671        return
 672      }
 673      evt.preventDefault()
 674      setPendingEdge(entry.key)
 675      host.dispatchEvent(
 676        new CustomEvent('flow-edge-delete-request', {
 677          detail: {
 678            id: entry.id ?? '',
 679            sourceId: entry.sourceId ?? '',
 680            targetId: entry.targetId ?? '',
 681          },
 682          bubbles: true,
 683          composed: true,
 684        }),
 685      )
 686    }
 687
 688    const init = () => {
 689      if (!ensureRefs()) return
 690      const patternId = `flow-grid-${/** @type {HTMLElement & { rocketInstanceId?: string }} */ (host).rocketInstanceId ?? ''}`
 691      gridPattern.id = patternId
 692      background.setAttribute('fill', `url(#${patternId})`)
 693      edgesLayer = ensureLayer('edges')
 694      nodesLayer = ensureLayer('nodes')
 695      schedule()
 696      svg.addEventListener('pointerdown', pointerDown)
 697      svg.addEventListener('wheel', handleWheel, { passive: false })
 698      const resize = new ResizeObserver(schedule)
 699      resize.observe(svg)
 700      cleanup(() => resize.disconnect())
 701    }
 702
 703    const childObserver = new MutationObserver((mutations) => {
 704      for (const mutation of mutations) {
 705        for (const node of mutation.addedNodes) hideFlowChild(node)
 706      }
 707    })
 708
 709    const onNodeRegister = (evt) => {
 710      const target = evt.target
 711      if (!(target instanceof HTMLElement)) return
 712      hideFlowChild(target)
 713      const detail =
 714        /** @type {CustomEvent<Record<string, any>>} */ (evt).detail ?? {}
 715      const entry = nodes.get(target) ?? {
 716        el: target,
 717        group: null,
 718        x: 0,
 719        y: 0,
 720        width: DEFAULT_NODE_WIDTH,
 721        height: DEFAULT_NODE_HEIGHT,
 722      }
 723      entry.x = detail.x
 724      entry.y = detail.y
 725      entry.width = detail.width
 726      entry.height = detail.height
 727      updateNodeId(entry, detail.id ?? target.getAttribute('id'))
 728      nodes.set(target, entry)
 729      ensureNodeGroup(entry)
 730      if (detail.content) applyNodeContent(entry, detail.content)
 731      scheduleNodeRender()
 732      positionNode(entry)
 733    }
 734
 735    const onNodeUpdate = (evt) => {
 736      const target = evt.target
 737      if (!(target instanceof HTMLElement)) return
 738      const entry = nodes.get(target)
 739      if (!entry) return
 740      const detail =
 741        /** @type {CustomEvent<Record<string, any>>} */ (evt).detail ?? {}
 742      if ('x' in detail) entry.x = detail.x
 743      if ('y' in detail) entry.y = detail.y
 744      if ('width' in detail) entry.width = detail.width
 745      if ('height' in detail) entry.height = detail.height
 746      if ('id' in detail)
 747        updateNodeId(entry, detail.id ?? target.getAttribute('id'))
 748      if (detail.content) applyNodeContent(entry, detail.content)
 749      scheduleNodeRender()
 750      positionNode(entry)
 751    }
 752
 753    const onNodeRemove = (evt) => {
 754      const target = evt.target
 755      if (!(target instanceof HTMLElement)) return
 756      const entry = nodes.get(target)
 757      entry?.group?.remove()
 758      nodes.delete(target)
 759      if (entry?.id && nodesById.get(entry.id) === entry)
 760        nodesById.delete(entry.id)
 761      scheduleEdgeRender()
 762    }
 763
 764    const onEdgeRegister = (evt) => {
 765      const target = evt.target
 766      if (!(target instanceof HTMLElement)) return
 767      hideFlowChild(target)
 768      const detail =
 769        /** @type {CustomEvent<Record<string, any>>} */ (evt).detail ?? {}
 770      const entry = edges.get(target) ?? {
 771        el: target,
 772        group: null,
 773        path: null,
 774        labelEl: null,
 775        sourceId: '',
 776        targetId: '',
 777        label: '',
 778        animated: false,
 779        id: '',
 780      }
 781      entry.sourceId = (
 782        detail.source ??
 783        detail.sourceId ??
 784        target.getAttribute('source') ??
 785        ''
 786      ).trim()
 787      entry.targetId = (
 788        detail.target ??
 789        detail.targetId ??
 790        target.getAttribute('target') ??
 791        ''
 792      ).trim()
 793      entry.label = detail.label ?? target.getAttribute('label') ?? ''
 794      entry.animated = Boolean(
 795        detail.animated ?? target.hasAttribute('animated'),
 796      )
 797      entry.id = (detail.id ?? target.getAttribute('id') ?? '').trim()
 798      edges.set(target, entry)
 799      ensureEdgeGraphics(entry)
 800      registerEdgeEntry(entry)
 801      scheduleEdgeRender()
 802    }
 803
 804    const onEdgeUpdate = (evt) => {
 805      const target = evt.target
 806      if (!(target instanceof HTMLElement)) return
 807      const entry = edges.get(target)
 808      if (!entry) return
 809      const detail =
 810        /** @type {CustomEvent<Record<string, any>>} */ (evt).detail ?? {}
 811      entry.sourceId = (
 812        detail.source ??
 813        detail.sourceId ??
 814        target.getAttribute('source') ??
 815        ''
 816      ).trim()
 817      entry.targetId = (
 818        detail.target ??
 819        detail.targetId ??
 820        target.getAttribute('target') ??
 821        ''
 822      ).trim()
 823      entry.label = detail.label ?? target.getAttribute('label') ?? ''
 824      entry.animated = Boolean(
 825        detail.animated ?? target.hasAttribute('animated'),
 826      )
 827      entry.id = (detail.id ?? target.getAttribute('id') ?? '').trim()
 828      registerEdgeEntry(entry)
 829      scheduleEdgeRender()
 830    }
 831
 832    const onEdgeRemove = (evt) => {
 833      const target = evt.target
 834      if (!(target instanceof HTMLElement)) return
 835      const entry = edges.get(target)
 836      if (entry?.key && edgesByKey.get(entry.key) === entry)
 837        edgesByKey.delete(entry.key)
 838      entry?.group?.remove()
 839      edges.delete(target)
 840      if (entry?.key === selectedEdgeKey) clearSelectedEdge()
 841      scheduleEdgeRender()
 842    }
 843
 844    host.addEventListener('flow-node-register', onNodeRegister)
 845    host.addEventListener('flow-node-update', onNodeUpdate)
 846    host.addEventListener('flow-node-remove', onNodeRemove)
 847    host.addEventListener('flow-edge-register', onEdgeRegister)
 848    host.addEventListener('flow-edge-update', onEdgeUpdate)
 849    host.addEventListener('flow-edge-remove', onEdgeRemove)
 850    host.addEventListener('flow-node-drag-start', (evt) => {
 851      const detail = /** @type {CustomEvent<{ node?: { id?: string } }>} */ (
 852        evt
 853      ).detail
 854      setPendingState(detail?.node?.id ?? '')
 855    })
 856    host.addEventListener('flow-node-drag-end', (evt) => {
 857      const detail = /** @type {CustomEvent<{ node?: { id?: string } }>} */ (
 858        evt
 859      ).detail
 860      setPendingState(detail?.node?.id ?? '')
 861    })
 862    host.addEventListener('flow-node-drag-cancel', clearPendingState)
 863    host.addEventListener('keydown', handleKeydown)
 864    childObserver.observe(host, { childList: true })
 865    queueMicrotask(() => {
 866      Array.from(host.children).forEach(hideFlowChild)
 867    })
 868    init()
 869
 870    observeProps(() => {
 871      viewport = normalizeViewport(props.viewport)
 872      schedule()
 873    }, 'viewport')
 874    observeProps(() => {
 875      grid = props.grid
 876      schedule()
 877    }, 'grid')
 878    observeProps(() => {
 879      const next = props.serverUpdateTime.getTime()
 880      if (next === lastServerUpdateTime) return
 881      lastServerUpdateTime = next
 882      clearPendingState()
 883      clearSelectedEdge()
 884    }, 'serverUpdateTime')
 885
 886    cleanup(() => {
 887      childObserver.disconnect()
 888      host.removeEventListener('flow-node-register', onNodeRegister)
 889      host.removeEventListener('flow-node-update', onNodeUpdate)
 890      host.removeEventListener('flow-node-remove', onNodeRemove)
 891      host.removeEventListener('flow-edge-register', onEdgeRegister)
 892      host.removeEventListener('flow-edge-update', onEdgeUpdate)
 893      host.removeEventListener('flow-edge-remove', onEdgeRemove)
 894      host.removeEventListener('keydown', handleKeydown)
 895      if (svg) {
 896        svg.removeEventListener('pointerdown', pointerDown)
 897        svg.removeEventListener('wheel', handleWheel)
 898      }
 899      if (frame) cancelAnimationFrame(frame)
 900      if (graphFrame) cancelAnimationFrame(graphFrame)
 901      if (edgeFrameId) cancelAnimationFrame(edgeFrameId)
 902    })
 903  },
 904  render: ({ html }) => html`
 905    <style>
 906      :host {
 907        display: block;
 908        position: relative;
 909      }
 910
 911      .flow-root {
 912        position: relative;
 913        width: 100%;
 914        height: 100%;
 915        overflow: hidden;
 916      }
 917
 918      svg.flow-svg {
 919        display: block;
 920        width: 100%;
 921        height: 100%;
 922        cursor: grab;
 923        background: #eef2ff;
 924        touch-action: none;
 925        user-select: none;
 926      }
 927
 928      svg.flow-svg.is-dragging {
 929        cursor: grabbing;
 930      }
 931
 932      .flow-edge path {
 933        stroke: #64748b;
 934        stroke-width: 1.5;
 935        fill: none;
 936        stroke-linecap: round;
 937        stroke-linejoin: round;
 938      }
 939
 940      .flow-edge path.is-animated {
 941        stroke-dasharray: 6 4;
 942        animation: flow-edge-dash 1.2s linear infinite;
 943      }
 944
 945      .flow-edge text {
 946        fill: #475569;
 947        font-size: 12px;
 948        font-weight: 500;
 949        text-anchor: middle;
 950        dominant-baseline: middle;
 951      }
 952
 953      .flow-edge.is-selected path {
 954        stroke: #2563eb;
 955        stroke-width: 2;
 956      }
 957
 958      .flow-edge.is-selected text {
 959        fill: #2563eb;
 960        font-weight: 600;
 961      }
 962
 963      .flow-node.is-pending,
 964      .flow-edge.is-pending {
 965        opacity: 0.55;
 966      }
 967
 968      @keyframes flow-edge-dash {
 969        from {
 970          stroke-dashoffset: 0;
 971        }
 972        to {
 973          stroke-dashoffset: -10;
 974        }
 975      }
 976    </style>
 977    <div class="flow-root">
 978      <svg class="flow-svg" data-ref:svg role="presentation" tabindex="0">
 979        <defs>
 980          <pattern data-ref:grid-pattern patternUnits="userSpaceOnUse">
 981            <path
 982              data-ref:grid-path
 983              fill="none"
 984              stroke="rgba(100,116,139,0.38)"
 985              stroke-width="0.75"
 986            ></path>
 987          </pattern>
 988        </defs>
 989        <rect data-ref:background></rect>
 990        <g data-ref:graph></g>
 991      </svg>
 992    </div>
 993    <slot hidden></slot>
 994  `,
 995})
 996
 997rocket('flow-node', {
 998  props: ({ number, string }) => ({
 999    x: number,
1000    y: number,
1001    label: string,
1002    width: number.min(1).default(DEFAULT_NODE_WIDTH),
1003    height: number.min(1).default(DEFAULT_NODE_HEIGHT),
1004  }),
1005  setup: ({ cleanup, effect, host, props }) => {
1006    host.style.display = 'none'
1007
1008    const collectCustomContent = () => {
1009      const fragment = document.createDocumentFragment()
1010      host.childNodes.forEach((child) => {
1011        if (
1012          child.nodeType === Node.ELEMENT_NODE &&
1013          child.nodeName.toUpperCase() === 'TEMPLATE'
1014        )
1015          return
1016        const clone = cloneNodeIntoSvg(child)
1017        if (!clone) return
1018        if (clone.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
1019          fragment.append(...Array.from(clone.childNodes))
1020        } else {
1021          fragment.append(clone)
1022        }
1023      })
1024      return fragment.childNodes.length ? fragment : null
1025    }
1026
1027    const createDefaultContent = () => {
1028      const fragment = document.createDocumentFragment()
1029      const svg = document.createElementNS(SVG_NS, 'svg')
1030      const rect = document.createElementNS(SVG_NS, 'rect')
1031      const text = document.createElementNS(SVG_NS, 'text')
1032      svg.setAttribute('width', String(props.width))
1033      svg.setAttribute('height', String(props.height))
1034      svg.setAttribute('viewBox', `0 0 ${props.width} ${props.height}`)
1035      svg.setAttribute('data-default-node', '')
1036      rect.setAttribute('rx', '12')
1037      rect.setAttribute('ry', '12')
1038      rect.setAttribute('width', String(props.width))
1039      rect.setAttribute('height', String(props.height))
1040      rect.setAttribute('fill', '#ffffff')
1041      rect.setAttribute('stroke', '#94a3b8')
1042      rect.setAttribute('stroke-width', '1.5')
1043      text.setAttribute('x', String(props.width / 2))
1044      text.setAttribute('y', String(props.height / 2))
1045      text.setAttribute('text-anchor', 'middle')
1046      text.setAttribute('dominant-baseline', 'middle')
1047      text.setAttribute(
1048        'font-family',
1049        'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont',
1050      )
1051      text.setAttribute('font-weight', '600')
1052      text.setAttribute('font-size', '13')
1053      text.setAttribute('fill', '#0f172a')
1054      text.textContent = props.label
1055      svg.append(rect, text)
1056      fragment.append(svg)
1057      return fragment
1058    }
1059
1060    const snapshot = () => ({
1061      id: host.getAttribute('id') ?? '',
1062      x: props.x,
1063      y: props.y,
1064      label: props.label,
1065      width: props.width,
1066      height: props.height,
1067      content: collectCustomContent() ?? createDefaultContent(),
1068    })
1069
1070    const emit = (name) => {
1071      host.dispatchEvent(
1072        new CustomEvent(name, {
1073          detail: snapshot(),
1074          bubbles: true,
1075          composed: true,
1076        }),
1077      )
1078    }
1079
1080    const observer = new MutationObserver(() =>
1081      queueMicrotask(() => emit('flow-node-update')),
1082    )
1083    observer.observe(host, {
1084      childList: true,
1085      subtree: true,
1086      characterData: true,
1087      attributes: true,
1088    })
1089
1090    queueMicrotask(() => emit('flow-node-register'))
1091    cleanup(() => {
1092      observer.disconnect()
1093      emit('flow-node-remove')
1094    })
1095    effect(() => {
1096      props.x
1097      props.y
1098      props.label
1099      props.width
1100      props.height
1101      emit('flow-node-update')
1102    })
1103  },
1104})
1105
1106rocket('flow-edge', {
1107  props: ({ string, bool }) => ({
1108    source: string,
1109    target: string,
1110    label: string,
1111    animated: bool,
1112  }),
1113  setup: ({ cleanup, effect, host, props }) => {
1114    host.style.display = 'none'
1115
1116    const snapshot = () => ({
1117      id: host.getAttribute('id') ?? '',
1118      source: host.getAttribute('source') ?? props.source,
1119      target: host.getAttribute('target') ?? props.target,
1120      label: host.getAttribute('label') ?? props.label,
1121      animated: props.animated || host.hasAttribute('animated'),
1122    })
1123
1124    const emit = (name) => {
1125      host.dispatchEvent(
1126        new CustomEvent(name, {
1127          detail: snapshot(),
1128          bubbles: true,
1129          composed: true,
1130        }),
1131      )
1132    }
1133
1134    const observer = new MutationObserver(() =>
1135      queueMicrotask(() => emit('flow-edge-update')),
1136    )
1137    observer.observe(host, { attributes: true })
1138
1139    queueMicrotask(() => emit('flow-edge-register'))
1140    cleanup(() => {
1141      observer.disconnect()
1142      emit('flow-edge-remove')
1143    })
1144    effect(() => {
1145      props.source
1146      props.target
1147      props.label
1148      props.animated
1149      emit('flow-edge-update')
1150    })
1151  },
1152})