ui: NodeGraph improvements - Can insert node anywhere in a stack of nodes. - Ghost node showing where the node will land if dropped. - Snap to grid. - Edge pan while dragging a node. - Complete refactor of NodeGraph. - Ports are addressed using unique IDs. - Remove labels - just use styled nodes. This solves a whole host of inconsistencies around how lables are handled vs nodes. - Remove hotkeys - wrap NodeGraph in HotkeyContext instead. A few tweaks Change-Id: I8f421b29dbcbac2837bf4cd7fb7385954645532a
diff --git a/ui/src/assets/widgets/nodegraph.scss b/ui/src/assets/widgets/nodegraph.scss index 5418079..6ace75d 100644 --- a/ui/src/assets/widgets/nodegraph.scss +++ b/ui/src/assets/widgets/nodegraph.scss
@@ -14,88 +14,108 @@ @import "../theme"; -.pf-canvas { - isolation: isolate; // Don't allow z-index to escape container. - overflow: hidden; // Clip overflowing content. - position: relative; // Place absolute children relative to this container. - height: 450px; // Give the canvas some intrinsic height by default. +.pf-ng { + --bg-size: 12px; + --bg-pos-x: 0px; + --bg-pos-y: 0px; - touch-action: none; // Disable default touch actions for panning. - user-select: none; // Disable text selection during dragging. - - font-family: var(--pf-font-compact); // Consistent font. - cursor: grab; // Indicate panning by default. - - // Dot pattern background. - background-color: color-mix( - in srgb, - var(--pf-color-background) 90%, - var(--pf-color-border-secondary) 10% - ); + position: relative; + overflow: hidden; + width: 100%; + height: 100%; + touch-action: none; + user-select: none; + cursor: grab; background-image: radial-gradient( circle, color-mix(in srgb, var(--pf-color-border-secondary) 85%, black 15%) 1px, - transparent 0px + transparent 0 ); - background-size: 20px 20px; + background-size: var(--bg-size) var(--bg-size); + background-position: var(--bg-pos-x) var(--bg-pos-y); - &--fill-height { - height: 100%; // Fill parent height when modifier class applied. + &:active { + cursor: grabbing; } } -.pf-canvas-content { +.pf-ng__workspace { position: absolute; top: 0; left: 0; - width: 100%; - height: 100%; - pointer-events: none; - overflow: visible; - - svg { - isolation: isolate; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - pointer-events: none; - overflow: visible; - } + transform-origin: 0 0; + z-index: 1; } -.pf-nodegraph-controls { +.pf-ng__toolbar { position: absolute; - top: 16px; - right: 16px; - z-index: 3; - pointer-events: auto; + top: 6px; + right: 6px; display: flex; - gap: 8px; + gap: 4px; + pointer-events: auto; + z-index: 2; } -.pf-node { +.pf-ng__node { + display: flex; + flex-direction: column; + cursor: move; // The whole node and all children are draggable +} + +.pf-ng__card { position: relative; background: var(--pf-color-background-secondary); border: 2px solid var(--pf-color-border); - border-radius: 8px; - min-width: 180px; - cursor: move; - pointer-events: auto; - width: 100%; + min-width: 140px; + background: var(--pf-color-background-secondary); + border-radius: 6px; - // Hide elements with this class, show on node hover - .pf-show-on-hover { - visibility: hidden; + &:not(:only-child) { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; } - &:hover .pf-show-on-hover { - visibility: visible; + &--selected { + border-color: var(--pf-color-accent); + } + + .pf-ng__card-header { + border-radius: 4px 4px 0 0; + background: color-mix( + in srgb, + hsl(var(--pf-ng-hue), 60%, 50%) 50%, + var(--pf-color-background) + ); + padding: 6px 6px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 6px; + } + + .pf-ng__card-body { + padding: 8px; + min-width: max-content; } } -.pf-node--has-accent-bar::before { +.pf-ng__node { + .pf-ng__node { + .pf-ng__card { + border-top-left-radius: 0; + border-top-right-radius: 0; + border-top-width: 0; + + .pf-ng__card-header { + border-radius: 0; + } + } + } +} + +// Accent bar (left-side color strip) +.pf-ng__card--accent::before { content: ""; position: absolute; top: 0; @@ -105,129 +125,28 @@ background-color: var( --pf-color-border ); /* Fallback color when hue not set */ - border-top-left-radius: 6px; - border-bottom-left-radius: 6px; + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; } -/* Override with hue-based color when --pf-node-hue is set via inline styles */ -.pf-node[style*="--pf-node-hue"].pf-node--has-accent-bar::before { +.pf-ng__card[style*="--pf-ng-hue"].pf-ng__card--accent::before { background: color-mix( in srgb, - hsl(var(--pf-node-hue), 60%, 50%) 50%, + hsl(var(--pf-ng-hue), 60%, 50%) 50%, var(--pf-color-background) ); } -.pf-node-body { - padding-block: 12px; -} - -// Wrapper for dock chains - uses flexbox to make children same width -.pf-node-wrapper { - isolation: isolate; // Don't allow z-index to escape container. - position: absolute; - display: flex; - flex-direction: column; - pointer-events: auto; - box-shadow: 0 4px 12px var(--pf-color-box-shadow); - border-radius: 8px; - width: max-content; - transition: - left 0.1s ease-out, - top 0.1s ease-out; - - &--dragging { - z-index: 1; // Ensure dragging entire chain appears above other nodes. - - // Don't animate transitions while dragging to avoid laggy movement - transition: none; - - // TODO Might want to add some visual indication of dragging entire chain - // using box shadow, and maybe make slightly transparent. - // opacity: 0.8; - // box-shadow: 6px 6px 24px var(--pf-color-box-shadow); - } -} - -.pf-node.pf-selected { - border-color: var(--pf-color-accent); - box-shadow: 0 0 0 3px - color-mix(in srgb, var(--pf-color-accent) 50%, transparent); - z-index: 1; // Make sure box shadow appears above other nodes -} - -// Docked child: flush top edge with no rounded corners or border -.pf-node.pf-docked-child { - border-top-left-radius: 0; - border-top-right-radius: 0; - margin-top: -2px; // Overlap border with parent -} - -// Parent with docked child: flush bottom edge with no rounded corners -.pf-node.pf-has-docked-child { - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; -} - -// Dock target highlight with pulsing animation -.pf-node.pf-dock-target { - box-shadow: 0 4px 0 3px var(--pf-color-accent); - animation: pulse-dock 0.6s ease-in-out infinite; -} - -@keyframes pulse-dock { - 0%, - 100% { - box-shadow: 0 4px 0 3px var(--pf-color-accent); - } - 50% { - box-shadow: 0 6px 0 5px var(--pf-color-accent); - opacity: 0.95; - } -} - -.pf-node-header { - /* Default background when hue is not set */ - background: var(--pf-color-background); - padding: 6px 6px; - border-radius: 6px 6px 0 0; - display: flex; - border-bottom: 1px solid var(--pf-color-border); - justify-content: space-between; - align-items: center; - gap: 6px; -} - -/* Override with hue-based color when --pf-node-hue is set via inline styles */ -.pf-node[style*="--pf-node-hue"] .pf-node-header { - background: color-mix( - in srgb, - hsl(var(--pf-node-hue), 60%, 50%) 50%, - var(--pf-color-background) - ); -} - -.pf-node--has-accent-bar .pf-node-header { - padding-left: 22px; -} - -.pf-docked-child .pf-node-header { - border-top-left-radius: 0; - border-top-right-radius: 0; -} - -.pf-has-docked-child .pf-node-header { +// When the card has a docked node below it, flatten the accent bar's bottom-left corner. +.pf-ng__card--accent:not(:only-child)::before { border-bottom-left-radius: 0; } -.pf-docked-child::before { +// When the card is itself docked (nested node), flatten the accent bar's top-left corner. +.pf-ng__node .pf-ng__node .pf-ng__card--accent::before { border-top-left-radius: 0; } -.pf-has-docked-child::before { - border-bottom-left-radius: 0; -} - .pf-node-title { flex: 1; } @@ -236,45 +155,32 @@ align-self: center; } -.pf-node-context-menu { - position: absolute; - top: 6px; - right: 6px; -} - -.pf-node-content { - padding-inline: 12px; -} - -.pf-node--has-accent-bar .pf-node-content { +.pf-ng__card--accent .pf-ng__port, +.pf-ng__card--accent .pf-ng__card-body { padding-left: 22px; } -.pf-port-row { +.pf-ng__port { position: relative; margin: 8px 0; color: var(--pf-color-text-muted); font-size: 13px; padding: 4px 16px; - .pf-port { + .pf-ng__port-dot { transform: translateY(-50%); } } -.pf-node--has-accent-bar .pf-port-row { - padding-left: 22px; -} - -.pf-port-input { +.pf-ng__port--west { text-align: left; } -.pf-port-output { +.pf-ng__port--east { text-align: right; } -.pf-port { +.pf-ng__port-dot { width: 16px; height: 16px; border-radius: 50%; @@ -283,254 +189,71 @@ cursor: crosshair; position: absolute; top: 50%; - - &--with-context-menu { - cursor: pointer; - &::after { - // Render a little plus button to indicate context menu availability - @include material-icon("add"); - position: absolute; - font-size: 15px; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - } - } - - &.pf-connected { - background: var(--pf-color-accent); - border-color: var(--pf-color-accent); - color: var(--pf-color-text-on-accent); - } } .pf-output { background: var(--pf-color-border); - z-index: 2; // Make outputs appear above inputs when docked. } .pf-output:hover { - background: var(--pf-color-accent); - border-color: var(--pf-color-accent); - color: var(--pf-color-text-on-accent); + background: var(--pf-color-primary); + border-color: var(--pf-color-primary); + color: var(--pf-color-text-on-primary); } -.pf-port.pf-input { - left: -9px; +.pf-ng__port-dot.pf-input { + left: -8px; } -.pf-port.pf-port-top { +.pf-ng__port-dot.pf-port-north { top: -1px; + right: unset; left: 50%; transform: translate(-50%, -50%); } -.pf-port.pf-port-bottom { +.pf-ng__port-dot.pf-port-south { top: unset; bottom: -1px; + right: unset; left: 50%; transform: translate(-50%, 50%); } -.pf-port.pf-output { - right: -9px; +.pf-ng__port-dot.pf-output { + right: -8px; } -.pf-connection { - stroke: var(--pf-color-accent); - color: var(--pf-color-accent); - stroke-width: 2; - fill: none; - transition: - stroke-width 0.2s, - stroke 0.2s; - pointer-events: auto; - cursor: pointer; +// Connected input ports show a delete cursor on hover. +.pf-ng__port-dot.pf-input.pf-port--connected { + border-color: var(--pf-color-primary); + background: var(--pf-color-primary); } -.pf-connection-group:hover .pf-connection { +// Connected output ports are colored in primary blue. +.pf-ng__port-dot.pf-output.pf-port--connected { + border-color: var(--pf-color-primary); + background: var(--pf-color-primary); +} + +// Snap target highlight when a wire drag is close enough to connect. +.pf-ng__port-dot.pf-input.pf-wire-snap { + border-color: var(--pf-color-primary); + background: var(--pf-color-primary); +} + +// Connection wires turn red on hover to indicate they can be clicked to delete. +.pf-connection-group:hover .pf-connection-path { stroke: var(--pf-color-danger); - filter: drop-shadow( - 0 0 6px color-mix(in srgb, var(--pf-color-danger) 80%, transparent) - ); } -.pf-temp-connection { - stroke: var(--pf-color-text-muted); - stroke-width: 2; - fill: none; - stroke-dasharray: 5, 5; -} - -.pf-connecting { - cursor: crosshair; - - .pf-input:hover { - background: var(--pf-color-accent); - border-color: var(--pf-color-accent); - cursor: copy; - } - .pf-output:hover { - background: var(--pf-color-border); - border-color: var(--pf-color-border-secondary); - cursor: not-allowed; - } -} - -.pf-output.pf-active, -.pf-output.pf-active:hover { - background: var(--pf-color-accent); - border-color: var(--pf-color-accent); - color: var(--pf-color-text-on-accent); - cursor: crosshair; -} - -.pf-panning { - cursor: grabbing; -} - -.pf-selection-rect { - position: absolute; - border: 2px solid var(--pf-color-accent); - background: color-mix(in srgb, var(--pf-color-accent) 15%, transparent); - pointer-events: none; - z-index: 2; // Render selection rectangle above nodes. -} - -.pf-node.pf-invalid { - border-color: var(--pf-color-danger); +// Highlight input ports while the user is dragging a wire from an output port. +.pf-ng--wire-dragging .pf-ng__port-dot.pf-input { + border-color: var(--pf-color-primary); background: color-mix( in srgb, - var(--pf-color-danger) 8%, + var(--pf-color-primary) 20%, var(--pf-color-background-secondary) ); - - .pf-node-header { - background: color-mix( - in srgb, - var(--pf-color-danger) 15%, - var(--pf-color-background) - ); - border-bottom-color: var(--pf-color-danger); - } -} - -// Invalid nodes override hue-based styling -.pf-node.pf-invalid[style*="--pf-node-hue"] .pf-node-header { - background: color-mix( - in srgb, - var(--pf-color-danger) 15%, - var(--pf-color-background) - ); -} - -.pf-node.pf-invalid.pf-node--has-accent-bar::before { - background-color: var(--pf-color-danger); -} - -// Label styles -.pf-label { - position: absolute; - background: color-mix( - in srgb, - var(--pf-color-background-secondary) 85%, - var(--pf-color-accent) 15% - ); - border: 2px solid var(--pf-color-border); - cursor: move; - pointer-events: auto; - box-sizing: border-box; - - &.pf-dragging { - opacity: 0.7; - cursor: grabbing; - } - - &.pf-selected { - border-color: var(--pf-color-accent); - box-shadow: 0 0 0 3px - color-mix(in srgb, var(--pf-color-accent) 50%, transparent); - } - - .pf-label-resize-handle { - position: absolute; - right: -5px; - top: 50%; - transform: translateY(-50%); - width: 10px; - height: 20px; - background: var(--pf-color-accent); - border-radius: 3px; - cursor: ew-resize; - opacity: 0; - transition: opacity 0.2s; - - &:hover { - opacity: 1; - } - } - - &:hover .pf-label-resize-handle { - opacity: 0.6; - } - - &.pf-selected .pf-label-resize-handle { - opacity: 1; - } - - .pf-label-delete-button { - position: absolute; - top: -10px; - right: -10px; - width: 20px; - height: 20px; - display: flex; - align-items: center; - justify-content: center; - background: var(--pf-color-danger); - color: white; - border-radius: 3px; - cursor: pointer; - opacity: 0; - transition: opacity 0.2s; - - &:hover { - opacity: 1; - background: color-mix(in srgb, var(--pf-color-danger) 85%, black 15%); - } - - .pf-icon { - font-size: var(--pf-font-size-s); - } - } - - &:hover .pf-label-delete-button, - &.pf-selected .pf-label-delete-button { - opacity: 1; - } -} - -.pf-label-content { - width: 100%; - max-height: 400px; - overflow-y: auto; - box-sizing: border-box; - - > .pf-simple-label-text { - padding: 12px; - font-family: var(--pf-font-compact); - font-size: var(--pf-font-size-m); - line-height: 1.4; - color: var(--pf-color-text); - } - - > .pf-simple-label-button { - padding: 8px; - } - - > .pf-label-placeholder { - padding: 8px; - color: var(--pf-color-text-secondary); - font-style: italic; - } + cursor: crosshair; }
diff --git a/ui/src/base/dom_utils.ts b/ui/src/base/dom_utils.ts index f418b1f..f26c110 100644 --- a/ui/src/base/dom_utils.ts +++ b/ui/src/base/dom_utils.ts
@@ -137,3 +137,99 @@ }, }; } + +export interface DragEvent { + // Movement delta since the previous event. + delta: {readonly x: number; readonly y: number}; + // Absolute pointer position in client coordinates. + client: {readonly x: number; readonly y: number}; + // Pointer position at the very start of the drag in client coordinates. + startClient: {readonly x: number; readonly y: number}; +} + +// Waits for a drag gesture to begin (pointer moved beyond `deadzone` px). +// Returns an async iterable of DragEvents, or undefined if the pointer was +// released before the deadzone was crossed (i.e. it was a click). The first yielded +// event includes accumulated movement from the deadzone phase so callers never +// lose movement that occurred before the drag was confirmed. +export async function captureDrag(attrs: { + el: HTMLElement; + e: PointerEvent; + deadzone?: number; +}): Promise<AsyncIterable<DragEvent> | undefined> { + const {el, e, deadzone = 0} = attrs; + const pointerId = e.pointerId; + + el.setPointerCapture(pointerId); + + let resolveNext: ((e: PointerEvent | undefined) => void) | undefined; + + const onMove = (e: PointerEvent) => { + if (e.pointerId !== pointerId) return; + resolveNext?.(e); + resolveNext = undefined; + }; + const onDone = (e: PointerEvent) => { + if (e.pointerId !== pointerId) return; + resolveNext?.(undefined); + resolveNext = undefined; + }; + + el.addEventListener('pointermove', onMove); + el.addEventListener('pointerup', onDone); + el.addEventListener('pointercancel', onDone); + + const cleanup = () => { + el.removeEventListener('pointermove', onMove); + el.removeEventListener('pointerup', onDone); + el.removeEventListener('pointercancel', onDone); + }; + + const next = () => + new Promise<PointerEvent | undefined>((r) => { + resolveNext = r; + }); + + // Phase 1: wait for deadzone to be crossed, or pointerup (→ click). + // Accumulate movement so the first yield includes the full delta. + let accum = new Vector2D({x: 0, y: 0}); + const start = new Vector2D({x: e.clientX, y: e.clientY}); + let firstEvent: PointerEvent = e; + while (deadzone > 0) { + const ev = await next(); + if (ev === undefined) { + cleanup(); + return undefined; + } + firstEvent = ev; + accum = accum.add({x: ev.movementX, y: ev.movementY}); + if (start.sub({x: ev.clientX, y: ev.clientY}).magnitude >= deadzone) break; + } + + const startClient = {x: e.clientX, y: e.clientY}; + + // Phase 2: drag confirmed — stream events to the caller, leading with the + // accumulated deadzone movement as the first yield. + return (async function* () { + try { + if (deadzone > 0) { + yield { + delta: accum, + client: {x: firstEvent.clientX, y: firstEvent.clientY}, + startClient, + }; + } + while (true) { + const ev = await next(); + if (ev === undefined) return; + yield { + delta: {x: ev.movementX, y: ev.movementY}, + client: {x: ev.clientX, y: ev.clientY}, + startClient, + }; + } + } finally { + cleanup(); + } + })(); +}
diff --git a/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/graph/graph.ts b/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/graph/graph.ts index 625f475..a61c208 100644 --- a/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/graph/graph.ts +++ b/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/graph/graph.ts
@@ -44,16 +44,25 @@ MenuTitle, PopupMenu, } from '../../../../widgets/menu'; +import {Point2D} from '../../../../base/geom'; import { - Connection, - Label, - Node, + NodeGraphConnection, + NodeGraphDockedNode, + NodeGraphNode, NodeGraph, - NodeGraphApi, + NodeGraphAPI, NodeGraphAttrs, - NodePort, + NodeGraphPort, } from '../../../../widgets/nodegraph'; -import {createEditableTextLabels} from './text_label'; + +// Local label type (not part of NodeGraph API — rendered as plain nodes). +interface Label { + id: string; + x: number; + y: number; + width: number; + content?: m.Children; +} import {QueryNode, singleNodeOperation, NodeType} from '../../query_node'; import {NodeBox} from './node_box'; import {buildMenuItems} from './menu_utils'; @@ -85,6 +94,28 @@ }; // ======================================== +// PORT ID HELPERS +// ======================================== + +// Port IDs use '|' as separator (safe: UUIDs only contain hex chars and '-'). +const getOutputPortId = (nodeId: string) => `${nodeId}|out`; +const getTopPortId = (nodeId: string) => `${nodeId}|in-top`; +const getLeftPortId = (nodeId: string, i: number) => `${nodeId}|in-left-${i}`; + +function parseToPort( + toPort: string, +): {nodeId: string; isTop: boolean; leftIndex?: number} | undefined { + const sep = toPort.indexOf('|'); + if (sep === -1) return undefined; + const nodeId = toPort.slice(0, sep); + const suffix = toPort.slice(sep + 1); + if (suffix === 'in-top') return {nodeId, isTop: true}; + const m2 = suffix.match(/^in-left-(\d+)$/); + if (m2) return {nodeId, isTop: false, leftIndex: parseInt(m2[1])}; + return undefined; +} + +// ======================================== // TYPE GUARDS // ======================================== @@ -159,28 +190,31 @@ : Math.min(currentConnections + 1, max); } -function getInputLabels(node: QueryNode): NodePort[] { +function getInputPorts(node: QueryNode): NodeGraphPort[] { + const nodeId = node.nodeId; + // Single-input operation nodes always have a top port (even when disconnected) if (singleNodeOperation(node.type)) { - const labels: NodePort[] = []; - labels.push({content: 'Input', direction: 'top'}); + const ports: NodeGraphPort[] = []; + ports.push({id: getTopPortId(nodeId), label: 'Input', direction: 'north'}); // Check if node also has secondaryInputs (like AddColumnsNode or FilterDuring) if (node.secondaryInputs) { - // Show side ports using the node's custom port names const portNames = node.secondaryInputs.portNames; const currentConnections = node.secondaryInputs.connections.size ?? 0; const numPorts = calculateNumPorts( currentConnections, node.secondaryInputs.max, ); - for (let i = 0; i < numPorts; i++) { - const portName = getPortName(portNames, i); - labels.push({content: portName, direction: 'left'}); + ports.push({ + id: getLeftPortId(nodeId, i), + label: getPortName(portNames, i), + direction: 'west', + }); } } - return labels; + return ports; } // Multi-source nodes (IntervalIntersect, Join, Union) - no primaryInput @@ -191,13 +225,15 @@ currentConnections, node.secondaryInputs.max, ); - const labels: NodePort[] = []; - + const ports: NodeGraphPort[] = []; for (let i = 0; i < numPorts; i++) { - const portName = getPortName(portNames, i); - labels.push({content: portName, direction: 'left'}); + ports.push({ + id: getLeftPortId(nodeId, i), + label: getPortName(portNames, i), + direction: 'west', + }); } - return labels; + return ports; } // Source nodes have no inputs @@ -293,7 +329,7 @@ function ensureNodeLayouts( roots: QueryNode[], attrs: GraphAttrs, - nodeGraphApi: NodeGraphApi | null, + nodeGraphApi: NodeGraphAPI | null, ): void { if (!nodeGraphApi) return; @@ -306,10 +342,16 @@ if (!lastPlacement) { // First node - use API placement const canDockTop = shouldShowTopPort(qnode); - const nodeTemplate: Omit<Node, 'x' | 'y'> = { + const nodeTemplate: NodeGraphDockedNode = { id: qnode.nodeId, - inputs: getInputLabels(qnode), - outputs: [{content: 'Output', direction: 'bottom'}], + inputs: getInputPorts(qnode), + outputs: [ + { + id: getOutputPortId(qnode.nodeId), + label: 'Output', + direction: 'south', + }, + ], canDockBottom: true, canDockTop, hue: getNodeHue(qnode), @@ -342,7 +384,7 @@ function getNextDockedNode( qnode: QueryNode, attrs: GraphAttrs, -): Omit<Node, 'x' | 'y'> | undefined { +): NodeGraphDockedNode | undefined { if ( qnode.nextNodes.length === 1 && qnode.nextNodes[0] !== undefined && @@ -384,30 +426,39 @@ function createNodeConfig( qnode: QueryNode, attrs: GraphAttrs, -): Omit<Node, 'x' | 'y'> { +): NodeGraphDockedNode { const canDockTop = shouldShowTopPort(qnode); const addMenuItems = buildAddMenuItems(qnode, attrs.onAddOperationNode); - const outputs: NodePort[] = + const outputs: NodeGraphPort[] = addMenuItems.length > 0 ? [ { - content: 'Output', - direction: 'bottom', + id: getOutputPortId(qnode.nodeId), + label: 'Output', + direction: 'south', contextMenuItems: addMenuItems, }, ] : nodeRegistry.getAllowedChildrenFor(qnode.type).length > 0 - ? [{content: 'Output', direction: 'bottom'}] + ? [ + { + id: getOutputPortId(qnode.nodeId), + label: 'Output', + direction: 'south', + }, + ] : []; const isGroup = qnode.type === NodeType.kGroup; + const hasAllowedChildren = + nodeRegistry.getAllowedChildrenFor(qnode.type).length > 0; return { id: qnode.nodeId, - inputs: getInputLabels(qnode), + inputs: getInputPorts(qnode), outputs, - canDockBottom: !isGroup, + canDockBottom: !isGroup && hasAllowedChildren, canDockTop, - hue: isGroup ? undefined : getNodeHue(qnode), + hue: isGroup ? 0 : getNodeHue(qnode), accentBar: !isGroup, className: classNames(isGroup && 'pf-node--group'), contextMenuItems: buildNodeContextMenuItems(qnode, attrs), @@ -416,14 +467,13 @@ onAddOperationNode: attrs.onAddOperationNode, }), next: getNextDockedNode(qnode, attrs), - invalid: !qnode.validate(), }; } function renderChildNode( qnode: QueryNode, attrs: GraphAttrs, -): Omit<Node, 'x' | 'y'> { +): NodeGraphDockedNode { return createNodeConfig(qnode, attrs); } @@ -431,11 +481,10 @@ qnode: QueryNode, layout: Position, attrs: GraphAttrs, -): Node { +): NodeGraphNode { return { ...createNodeConfig(qnode, attrs), - x: layout.x, - y: layout.y, + pos: layout, }; } @@ -443,8 +492,8 @@ function renderNodes( rootNodes: QueryNode[], attrs: GraphAttrs, - nodeGraphApi: NodeGraphApi | null, -): Node[] { + nodeGraphApi: NodeGraphAPI | null, +): NodeGraphNode[] { const allNodes = getAllNodes(rootNodes, {traverseGroups: false}); const roots = getRootNodes(allNodes, attrs.nodeLayouts); @@ -459,35 +508,19 @@ } return renderNodeChain(qnode, layout, attrs); }) - .filter((n): n is Node => n !== null); + .filter((n): n is NodeGraphNode => n !== null); } // ======================================== // CONNECTION HANDLING // ======================================== -// Single-input nodes use port 0 for primaryInput, multi-source nodes don't have primaryInput -function hasPrimaryInputPort(node: QueryNode): boolean { - return singleNodeOperation(node.type); -} - -// Convert visual port to secondary input index (undefined means primary input) -function toSecondaryIndex( - node: QueryNode, - visualPort: number, -): number | undefined { - if (hasPrimaryInputPort(node)) { - return visualPort === 0 ? undefined : visualPort - 1; - } - return visualPort; -} - // Builds visual connections between nodes (skips docked chains since they use 'next' property) function buildConnections( rootNodes: QueryNode[], nodeLayouts: LayoutMap, -): Connection[] { - const connections: Connection[] = []; +): NodeGraphConnection[] { + const connections: NodeGraphConnection[] = []; const allNodes = getAllNodes(rootNodes, {traverseGroups: false}); for (const qnode of allNodes) { @@ -506,34 +539,24 @@ continue; } - // Check if this parent is connected to multiple ports on the child - // (e.g., Union node with same parent connected to Input 0 and Input 1) - const connectedPorts: number[] = []; + const fromPort = getOutputPortId(qnode.nodeId); // Check primary input if (child.primaryInput === qnode) { - connectedPorts.push(0); + connections.push({fromPort, toPort: getTopPortId(child.nodeId)}); } // Check secondary inputs if (child.secondaryInputs) { - const offset = hasPrimaryInputPort(child) ? 1 : 0; for (const [index, node] of child.secondaryInputs.connections) { if (node === qnode) { - connectedPorts.push(index + offset); + connections.push({ + fromPort, + toPort: getLeftPortId(child.nodeId, index), + }); } } } - - // Create a separate connection for each port - for (const toPort of connectedPorts) { - connections.push({ - fromNode: qnode.nodeId, - fromPort: 0, - toNode: child.nodeId, - toPort: toPort, - }); - } } } @@ -541,14 +564,19 @@ } // Handles creating a new connection between nodes (updates both forward and backward links) -function handleConnect(conn: Connection, rootNodes: QueryNode[]): void { - const fromNode = findQueryNode(conn.fromNode, rootNodes); - const toNode = findQueryNode(conn.toNode, rootNodes); +function handleConnect( + conn: NodeGraphConnection, + rootNodes: QueryNode[], +): void { + const parsed = parseToPort(conn.toPort); + if (!parsed) return; + + const fromNodeId = conn.fromPort.slice(0, conn.fromPort.lastIndexOf('|')); + const fromNode = findQueryNode(fromNodeId, rootNodes); + const toNode = findQueryNode(parsed.nodeId, rootNodes); if (!fromNode || !toNode) { - console.warn( - `Cannot create connection: node not found (from: ${conn.fromNode}, to: ${conn.toNode})`, - ); + console.warn(`Cannot create connection: node not found`); return; } @@ -557,13 +585,12 @@ } if (wouldCreateCycle(fromNode, toNode)) { - console.warn( - `Cannot create connection: would create a cycle (from: ${conn.fromNode}, to: ${conn.toNode})`, - ); + console.warn(`Cannot create connection: would create a cycle`); return; } - const secondaryIndex = toSecondaryIndex(toNode, conn.toPort); + // isTop → primary input (secondaryIndex = undefined), otherwise left port index + const secondaryIndex = parsed.isTop ? undefined : parsed.leftIndex; addConnection(fromNode, toNode, secondaryIndex); m.redraw(); @@ -571,7 +598,7 @@ // Handles removing a connection (cleans up both forward and backward links) function handleConnectionRemove( - conn: Connection, + conn: NodeGraphConnection, rootNodes: QueryNode[], onConnectionRemove: ( fromNode: QueryNode, @@ -579,34 +606,22 @@ isSecondaryInput: boolean, ) => void, ): void { - const fromNode = findQueryNode(conn.fromNode, rootNodes); - const toNode = findQueryNode(conn.toNode, rootNodes); + const parsed = parseToPort(conn.toPort); + if (!parsed) return; + + const fromNodeId = conn.fromPort.slice(0, conn.fromPort.lastIndexOf('|')); + const fromNode = findQueryNode(fromNodeId, rootNodes); + const toNode = findQueryNode(parsed.nodeId, rootNodes); if (!fromNode || !toNode) { - console.warn( - `Cannot remove connection: node not found (from: ${conn.fromNode}, to: ${conn.toNode})`, - ); + console.warn(`Cannot remove connection: node not found`); return; } - // Check BEFORE removal if this is a secondary input connection - let isSecondaryInput = false; - if (toNode.secondaryInputs?.connections) { - for (const node of toNode.secondaryInputs.connections.values()) { - if (node === fromNode) { - isSecondaryInput = true; - break; - } - } - } + const isSecondaryInput = !parsed.isTop; + const secondaryIndex = parsed.isTop ? undefined : parsed.leftIndex; - // Convert visual port to secondary index for removal - const secondaryIndex = toSecondaryIndex(toNode, conn.toPort); - - // Use the helper function to cleanly remove the connection removeConnection(fromNode, toNode, secondaryIndex); - - // Call the parent callback for any additional cleanup (e.g., state management) onConnectionRemove(fromNode, toNode, isSecondaryInput); } @@ -633,8 +648,7 @@ // ======================================== export class Graph implements m.ClassComponent<GraphAttrs> { - private nodeGraphApi: NodeGraphApi | null = null; - private hasPerformedInitialLayout: boolean = false; + private nodeGraphApi: NodeGraphAPI | null = null; private hasPerformedInitialRecenter: boolean = false; private recenterRequired: boolean = false; // True while a recenter is pending. The graph is hidden (visibility:hidden) @@ -804,7 +818,7 @@ title: 'Center Graph', onclick: () => { if (this.nodeGraphApi) { - this.nodeGraphApi.recenter(); + this.nodeGraphApi.autofit(); } }, }), @@ -824,7 +838,17 @@ view({attrs}: m.CVnode<GraphAttrs>) { const {rootNodes} = attrs; - const nodes = renderNodes(rootNodes, attrs, this.nodeGraphApi); + const queryNodes = renderNodes(rootNodes, attrs, this.nodeGraphApi); + const labelNodes: NodeGraphNode[] = this.labels.map((label) => ({ + id: label.id, + pos: {x: label.x, y: label.y}, + hue: 0, + content: label.content, + className: 'pf-ngd__label', + canDockTop: false, + canDockBottom: false, + })); + const nodes = [...queryNodes, ...labelNodes]; const connections = buildConnections(rootNodes, attrs.nodeLayouts); // Detect if loadGeneration has changed (indicates a load operation occurred) @@ -846,21 +870,7 @@ } } - // Perform auto-layout if nodeLayouts is empty and API is available if ( - !this.hasPerformedInitialLayout && - this.nodeGraphApi && - attrs.nodeLayouts.size === 0 && - nodes.length > 0 - ) { - this.hasPerformedInitialLayout = true; - this.hasPerformedInitialRecenter = true; - // Call autoLayout to arrange nodes hierarchically - // autoLayout will call onNodeMove for each node it repositions - this.nodeGraphApi.autoLayout(); - // Recenter will happen in the onReady callback after the next render - this.recenterRequired = true; - } else if ( !this.hasPerformedInitialRecenter && this.nodeGraphApi && nodes.length > 0 @@ -893,12 +903,12 @@ const ZOOM_STEP = 0.1; if (e.shiftKey && this.nodeGraphApi !== null) { if (e.code === 'KeyW') { - this.nodeGraphApi.zoomBy(ZOOM_STEP); + this.nodeGraphApi.zoom(ZOOM_STEP); e.preventDefault(); return; } if (e.code === 'KeyS') { - this.nodeGraphApi.zoomBy(-ZOOM_STEP); + this.nodeGraphApi.zoom(-ZOOM_STEP); e.preventDefault(); return; } @@ -914,7 +924,7 @@ }; const pan = panMap[e.code]; if (pan !== undefined && this.nodeGraphApi !== null) { - this.nodeGraphApi.panBy(pan[0], pan[1]); + this.nodeGraphApi.pan(pan[0], pan[1]); e.preventDefault(); } }, @@ -926,10 +936,7 @@ selectedNodeIds: attrs.selectedNodes, hideControls: true, fillHeight: true, - // Hide the graph while a recenter is pending to avoid a flash of - // un-centered content. - style: this.pendingRecenter ? {visibility: 'hidden'} : undefined, - onReady: (api: NodeGraphApi) => { + onReady: (api: NodeGraphAPI) => { this.nodeGraphApi = api; if (this.recenterRequired) { @@ -944,64 +951,57 @@ } this.recenterRequired = false; - api.recenter(); + api.autofit(); if (this.pendingRecenter) { this.pendingRecenter = false; m.redraw(); } } }, - multiselect: true, - onNodeSelect: (nodeId: string) => { - const qnode = findQueryNode(nodeId, rootNodes); - if (qnode) { - attrs.onNodeSelected(qnode); + onSelect: (nodeIds: string[]) => { + if (nodeIds.length === 0) { + attrs.onDeselect(); + } else { + const qnode = findQueryNode(nodeIds[0], rootNodes); + if (qnode) attrs.onNodeSelected(qnode); } }, - onNodeAddToSelection: (nodeId: string) => { + onSelectionAdd: (nodeId: string) => { const qnode = findQueryNode(nodeId, rootNodes); if (qnode) { attrs.onNodeAddToSelection(qnode); } }, - onNodeRemoveFromSelection: (nodeId: string) => { + onSelectionRemove: (nodeId: string) => { attrs.onNodeRemoveFromSelection(nodeId); }, onSelectionClear: () => { attrs.onDeselect(); }, - onNodeMove: (nodeId: string, x: number, y: number) => { - attrs.onNodeLayoutChange(nodeId, {x, y}); + onNodeMove: (nodeId: string, pos: Point2D) => { + // Labels are stored in component state, not in the layout map + const label = this.labels.find((l) => l.id === nodeId); + if (label) { + label.x = pos.x; + label.y = pos.y; + this.notifyLabelsChanged(attrs); + return; + } + attrs.onNodeLayoutChange(nodeId, pos); }, - onConnect: (conn: Connection) => { + onConnect: (conn: NodeGraphConnection) => { handleConnect(conn, rootNodes); }, - onConnectionRemove: (index: number) => { + onDisconnect: (index: number) => { handleConnectionRemove( connections[index], rootNodes, attrs.onConnectionRemove, ); }, - onNodeRemove: (nodeId: string) => { - const qnode = findQueryNode(nodeId, rootNodes); - if (qnode) { - attrs.onDeleteNode(qnode); - } - }, - onUndock: ( - _parentId: string, - nodeId: string, - x: number, - y: number, - ) => { - // Store the new position in the layout map so node becomes independent - attrs.onNodeLayoutChange(nodeId, {x, y}); - m.redraw(); - }, - onDock: (targetId: string, childNode: Omit<Node, 'x' | 'y'>) => { + onNodeDock: (nodeId: string, targetId: string) => { const parentNode = findQueryNode(targetId, rootNodes); - const childQueryNode = findQueryNode(childNode.id, rootNodes); + const childQueryNode = findQueryNode(nodeId, rootNodes); if (!parentNode || !childQueryNode) { console.warn('Cannot dock: parent or child node not found'); @@ -1046,41 +1046,10 @@ } // Dock the child - attrs.nodeLayouts.delete(childNode.id); + attrs.nodeLayouts.delete(nodeId); addConnection(parentNode, childQueryNode); m.redraw(); }, - contextMenuOnHover: true, - labels: createEditableTextLabels( - this.labels, - this.labelTexts, - this.editingLabels, - () => this.notifyLabelsChanged(attrs), - ), - onLabelMove: (labelId: string, x: number, y: number) => { - const label = this.labels.find((l) => l.id === labelId); - if (label) { - label.x = x; - label.y = y; - this.notifyLabelsChanged(attrs); - } - }, - onLabelResize: (labelId: string, width: number) => { - const label = this.labels.find((l) => l.id === labelId); - if (label) { - label.width = width; - this.notifyLabelsChanged(attrs); - } - }, - onLabelRemove: (labelId: string) => { - const labelIndex = this.labels.findIndex((l) => l.id === labelId); - if (labelIndex !== -1) { - this.labels.splice(labelIndex, 1); - } - this.labelTexts.delete(labelId); - this.notifyLabelsChanged(attrs); - m.redraw(); - }, } satisfies NodeGraphAttrs), this.renderControls(attrs), ],
diff --git a/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/graph/node_config.ts b/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/graph/node_config.ts index d76ea04..928ae8f 100644 --- a/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/graph/node_config.ts +++ b/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/graph/node_config.ts
@@ -12,7 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {Node, NodePort} from '../../../../widgets/nodegraph'; +import { + NodeGraphDockedNode, + NodeGraphPort, +} from '../../../../widgets/nodegraph'; import {QueryNode, NodeType, singleNodeOperation} from '../../query_node'; function getPortName( @@ -70,16 +73,19 @@ } } +// Port ID helpers for read-only preview nodes. +// The '|' separator is safe since node IDs are UUIDs (hex and '-' only). +export const getReadOnlyOutputPortId = (nodeId: string) => `${nodeId}|out`; +export const getReadOnlyTopPortId = (nodeId: string) => `${nodeId}|in-top`; +export const getReadOnlyLeftPortId = (nodeId: string, i: number) => + `${nodeId}|in-left-${i}`; + /** - * Set of "nodeId:portIndex" strings identifying ports that have connections. + * Set of port ID strings identifying ports that have connections. * Used to hide unused ports in read-only previews. */ export type ConnectedPorts = ReadonlySet<string>; -function portKey(nodeId: string, portIndex: number): string { - return `${nodeId}:${portIndex}`; -} - /** * Builds a read-only Node config for preview purposes (e.g. group node * inner graph). Only depends on QueryNode and nodegraph — no graph_utils @@ -90,45 +96,37 @@ innerSet: ReadonlySet<string>, connectedInputs?: ConnectedPorts, connectedOutputs?: ConnectedPorts, -): Omit<Node, 'x' | 'y'> { +): NodeGraphDockedNode { const isSingle = singleNodeOperation(qnode.type); - const inputs: NodePort[] = []; - let portIdx = 0; + const inputs: NodeGraphPort[] = []; if (isSingle) { - // Top port (primary input) — only show if connected or no filter provided. - if ( - connectedInputs === undefined || - connectedInputs.has(portKey(qnode.nodeId, portIdx)) - ) { - inputs.push({direction: 'top'}); + // North port (primary input) — only show if connected or no filter provided. + const portId = getReadOnlyTopPortId(qnode.nodeId); + if (connectedInputs === undefined || connectedInputs.has(portId)) { + inputs.push({id: portId, direction: 'north'}); } - portIdx++; } if (qnode.secondaryInputs) { const portNames = qnode.secondaryInputs.portNames; let secIdx = 0; for (const [,] of qnode.secondaryInputs.connections) { - if ( - connectedInputs === undefined || - connectedInputs.has(portKey(qnode.nodeId, portIdx)) - ) { + const portId = getReadOnlyLeftPortId(qnode.nodeId, secIdx); + if (connectedInputs === undefined || connectedInputs.has(portId)) { inputs.push({ - content: getPortName(portNames, secIdx), - direction: 'left', + id: portId, + label: getPortName(portNames, secIdx), + direction: 'west', }); } - portIdx++; secIdx++; } } - const outputs: NodePort[] = []; - if ( - connectedOutputs === undefined || - connectedOutputs.has(portKey(qnode.nodeId, 0)) - ) { - outputs.push({direction: 'bottom'}); + const outputs: NodeGraphPort[] = []; + const outPortId = getReadOnlyOutputPortId(qnode.nodeId); + if (connectedOutputs === undefined || connectedOutputs.has(outPortId)) { + outputs.push({id: outPortId, direction: 'south'}); } // Find docked child — the nodegraph widget can route connection lines @@ -137,7 +135,7 @@ // multiple children (even if only one is in innerSet), we skip docking to // avoid ambiguity — the outer graph's docking logic handles multi-child // cases differently via layout positions. - let next: Omit<Node, 'x' | 'y'> | undefined; + let next: NodeGraphDockedNode | undefined; if ( qnode.nextNodes.length === 1 && singleNodeOperation(qnode.nextNodes[0].type) && @@ -154,7 +152,7 @@ return { id: qnode.nodeId, - titleBar: undefined, + headerBar: undefined, inputs, outputs, canDockTop: isSingle,
diff --git a/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/graph/text_label.ts b/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/graph/text_label.ts index 14cf4f4..76126b5 100644 --- a/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/graph/text_label.ts +++ b/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/graph/text_label.ts
@@ -13,7 +13,15 @@ // limitations under the License. import m from 'mithril'; -import {Label} from '../../../../widgets/nodegraph'; + +// Local label type matching the Label interface used in graph.ts. +interface Label { + id: string; + x: number; + y: number; + width: number; + content?: m.Children; +} // Helper function to auto-resize textarea to fit content function autoResizeTextarea(textarea: HTMLTextAreaElement) {
diff --git a/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/nodes/group_node/inner_graph_preview.ts b/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/nodes/group_node/inner_graph_preview.ts index eb34780..8a5feda 100644 --- a/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/nodes/group_node/inner_graph_preview.ts +++ b/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/nodes/group_node/inner_graph_preview.ts
@@ -16,11 +16,16 @@ import {singleNodeOperation} from '../../../query_node'; import { NodeGraph, - Node as GraphNode, - Connection, - NodeGraphApi, + NodeGraphNode as GraphNode, + NodeGraphConnection, + NodeGraphAPI, } from '../../../../../widgets/nodegraph'; -import {buildReadOnlyNodeConfig} from '../../graph/node_config'; +import { + buildReadOnlyNodeConfig, + getReadOnlyOutputPortId, + getReadOnlyTopPortId, + getReadOnlyLeftPortId, +} from '../../graph/node_config'; import type {GroupNode} from '.'; interface InnerGraphPreviewAttrs { @@ -50,7 +55,7 @@ } const innerSet = new Set(groupNode.innerNodes.map((n) => n.nodeId)); - const connections: Connection[] = []; + const connections: NodeGraphConnection[] = []; // Collect docked node IDs (rendered via `next`, not as top-level). const dockedIds = new Set<string>(); @@ -75,13 +80,18 @@ inputNodeIds.set(conn.groupPort, inputId); const offset = singleNodeOperation(conn.innerTargetNode.type) ? 1 : 0; - const toPort = + const toPortIndex = conn.innerTargetPort !== undefined ? conn.innerTargetPort + offset : 0; + const toPortId = + toPortIndex === 0 + ? getReadOnlyTopPortId(conn.innerTargetNode.nodeId) + : getReadOnlyLeftPortId( + conn.innerTargetNode.nodeId, + toPortIndex - offset, + ); connections.push({ - fromNode: inputId, - fromPort: 0, - toNode: conn.innerTargetNode.nodeId, - toPort, + fromPort: getReadOnlyOutputPortId(inputId), + toPort: toPortId, }); } @@ -94,22 +104,17 @@ !dockedIds.has(n.nodeId) ) { connections.push({ - fromNode: n.primaryInput.nodeId, - fromPort: 0, - toNode: n.nodeId, - toPort: 0, + fromPort: getReadOnlyOutputPortId(n.primaryInput.nodeId), + toPort: getReadOnlyTopPortId(n.nodeId), }); } if (n.secondaryInputs) { - const offset = singleNodeOperation(n.type) ? 1 : 0; for (const [port, src] of n.secondaryInputs.connections) { if (src !== undefined && innerSet.has(src.nodeId)) { connections.push({ - fromNode: src.nodeId, - fromPort: 0, - toNode: n.nodeId, - toPort: port + offset, + fromPort: getReadOnlyOutputPortId(src.nodeId), + toPort: getReadOnlyLeftPortId(n.nodeId, port), }); } } @@ -120,8 +125,8 @@ const connectedInputs = new Set<string>(); const connectedOutputs = new Set<string>(); for (const c of connections) { - connectedInputs.add(`${c.toNode}:${c.toPort}`); - connectedOutputs.add(`${c.fromNode}:${c.fromPort}`); + connectedInputs.add(c.toPort); + connectedOutputs.add(c.fromPort); } // Docked nodes' primary input (port 0) is rendered via `next`, not as @@ -129,7 +134,7 @@ // filtered out — otherwise secondary input port indices shift and // connections target the wrong port. for (const dockedId of dockedIds) { - connectedInputs.add(`${dockedId}:0`); + connectedInputs.add(getReadOnlyTopPortId(dockedId)); } // Build nodes with only connected ports. @@ -145,8 +150,7 @@ connectedInputs, connectedOutputs, ), - x: i * nodeSpacingX, - y: 0, + pos: {x: i * nodeSpacingX, y: 0}, }); } @@ -161,10 +165,10 @@ if (inputId === undefined) continue; nodes.push({ id: inputId, - x: i * nodeSpacingX, - y: -80, - titleBar: {title: `Input ${conn.groupPort + 1}`}, - outputs: [{direction: 'bottom'}], + pos: {x: i * nodeSpacingX, y: -80}, + hue: 0, + headerBar: {title: `Input ${conn.groupPort + 1}`}, + outputs: [{id: getReadOnlyOutputPortId(inputId), direction: 'south'}], className: isConnected ? undefined : 'pf-node--disconnected', }); } @@ -182,10 +186,10 @@ connections, hideControls: true, fillHeight: true, - onReady: (api: NodeGraphApi) => { + onReady: (api: NodeGraphAPI) => { if (!this.recentered) { this.recentered = true; - requestAnimationFrame(() => api.recenter()); + requestAnimationFrame(() => api.autofit()); } }, }),
diff --git a/ui/src/plugins/dev.perfetto.WidgetsPage/demos/nodegraph_demo.scss b/ui/src/plugins/dev.perfetto.WidgetsPage/demos/nodegraph_demo.scss new file mode 100644 index 0000000..62a11bb --- /dev/null +++ b/ui/src/plugins/dev.perfetto.WidgetsPage/demos/nodegraph_demo.scss
@@ -0,0 +1,25 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +.pf-ng__card.pf-ngd__label { + // border-radius: 0; + + .pf-ng__card-body { + // padding: 0; + + textarea { + color: var(--pf-color-text); + } + } +}
diff --git a/ui/src/plugins/dev.perfetto.WidgetsPage/demos/nodegraph_demo.ts b/ui/src/plugins/dev.perfetto.WidgetsPage/demos/nodegraph_demo.ts index 86ab290..7e9f207 100644 --- a/ui/src/plugins/dev.perfetto.WidgetsPage/demos/nodegraph_demo.ts +++ b/ui/src/plugins/dev.perfetto.WidgetsPage/demos/nodegraph_demo.ts
@@ -12,25 +12,28 @@ // See the License for the specific language governing permissions and // limitations under the License. -import m from 'mithril'; import {produce} from 'immer'; -import {uuidv4} from '../../../base/uuid'; +import m from 'mithril'; +import {Point2D} from '../../../base/geom'; +import {shortUuid} from '../../../base/uuid'; import {Button, ButtonGroup, ButtonVariant} from '../../../widgets/button'; import {Checkbox} from '../../../widgets/checkbox'; import {MenuItem, PopupMenu} from '../../../widgets/menu'; import { - Connection, - Label, - Node, + NodeGraphConnection, + NodeGraphNode, NodeGraph, - NodeGraphApi, + NodeGraphAPI, NodeGraphAttrs, - NodePort, + NodeGraphPort, } from '../../../widgets/nodegraph'; import {Combobox} from '../../../widgets/combobox'; import {Select} from '../../../widgets/select'; import {TextInput} from '../../../widgets/text_input'; import {renderDocSection, renderWidgetShowcase} from '../widgets_page_utils'; +import {maybeUndefined} from '../../../base/utils'; +import {assertIsInstance} from '../../../base/assert'; +import {MithrilEvent} from '../../../base/mithril_utils'; const MAX_HISTORY_DEPTH = 500; @@ -39,7 +42,10 @@ readonly id: string; x: number; y: number; - nextId?: string; + next?: NodeData; + // Ordered list of source node IDs feeding into each manifest input slot. + // undefined means the slot is unconnected. + inputNodeIds?: (string | undefined)[]; } // Individual node type interfaces @@ -75,407 +81,447 @@ readonly unionType: 'UNION' | 'UNION ALL'; } -interface ResultNodeData extends BaseNodeData { - readonly type: 'result'; -} - -// Discriminated union of all node types +// Discriminated union of all pipeline node types type NodeData = | TableNodeData | SelectNodeData | FilterNodeData | SortNodeData | JoinNodeData - | UnionNodeData - | ResultNodeData; + | UnionNodeData; + +// Labels are a separate entity: free-floating annotations, no ports, no docking +interface LabelData { + readonly id: string; + x: number; + y: number; + text: string; +} // Store interface (only data that should be in undo/redo history) interface NodeGraphStore { - readonly nodes: Map<string, NodeData>; - readonly connections: Connection[]; - readonly labels: Label[]; - readonly invalidNodes: Set<string>; // Track which nodes are marked as invalid + readonly nodes: NodeData[]; // only root nodes; docked nodes are nested via .next + readonly labels: LabelData[]; } -// Node metadata configuration -interface NodeConfig { - readonly inputs?: ReadonlyArray<NodePort>; - readonly outputs?: ReadonlyArray<NodePort>; - readonly canDockTop?: boolean; - readonly canDockBottom?: boolean; - readonly hue: number; +// Single source of truth per node type: metadata, factory, and renderer. +// C is the specific NodeData subtype (e.g. TableNodeData). +interface NodeTypeManifest<C extends NodeData> { + readonly title: string; readonly icon: string; + readonly hue: number; + readonly inputs?: ReadonlyArray<{ + readonly id: string; + readonly label: string; + readonly direction?: NodeGraphPort['direction']; + }>; + create(id: string, x: number, y: number): C; + render(node: C, update: (updates: Partial<C>) => void): m.Children; } -const NODE_CONFIGS: Record<NodeData['type'], NodeConfig> = { +interface LabelContentAttrs { + readonly text: string; + readonly update: (text: string) => void; +} + +function LabelContent(): m.Component<LabelContentAttrs> { + let editing = false; + let lastPointerDownTime = 0; + let labelElement!: HTMLTextAreaElement; + + return { + view({attrs}: m.Vnode<LabelContentAttrs>) { + const {text, update} = attrs; + return m( + '', + { + // Use pointerdown-based double-click detection instead of ondblclick. + // captureDrag calls setPointerCapture on the first click, which can + // redirect the second click's events in some browsers, preventing + // the native dblclick event from firing on the original element. + onpointerdown: (e: PointerEvent) => { + if (editing) { + e.stopPropagation(); + return; + } + const now = Date.now(); + if (now - lastPointerDownTime < 400) { + editing = true; + e.stopPropagation(); + m.redraw(); + // Focus after redraw so the textarea is no longer readonly. + requestAnimationFrame(() => labelElement.focus()); + } + lastPointerDownTime = now; + }, + }, + m('textarea', { + readonly: !editing, + style: { + resize: editing ? 'both' : 'none', + width: '160px', + height: '60px', + border: 'none', + borderRadius: '4px', + background: editing ? 'var(--pf-color-background)' : 'transparent', + font: 'inherit', + cursor: editing ? 'text' : 'inherit', + pointerEvents: editing ? 'auto' : 'none', + outline: editing ? '2px solid var(--pf-color-primary)' : 'none', + padding: editing ? '4px' : '0', + }, + value: text, + onchange: (e: Event) => + update((e.target as HTMLTextAreaElement).value), + onkeydown: (e: MithrilEvent<KeyboardEvent>) => { + e.redraw = false; + + if (editing) { + e.stopPropagation(); + if (e.key === 'Escape') { + editing = false; + e.redraw = true; + } + } + }, + onpointerdown: (e: PointerEvent) => { + if (editing) e.stopPropagation(); + }, + onblur: (e: FocusEvent) => { + console.log('Label blur', e); + editing = false; + const ta = e.target as HTMLTextAreaElement; + ta.setSelectionRange(0, 0); + }, + }), + ); + }, + oncreate({dom}: m.VnodeDOM<LabelContentAttrs>) { + labelElement = assertIsInstance( + dom.querySelector('textarea'), + HTMLTextAreaElement, + ); + }, + }; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const NODE_CONFIGS: {[K in NodeData['type']]: NodeTypeManifest<any>} = { table: { - canDockBottom: true, - hue: 200, + title: 'Table', icon: 'table_chart', + hue: 200, + create: (id, x, y): TableNodeData => ({ + type: 'table', + id, + x, + y, + table: 'slice', + }), + render: (node: TableNodeData, update) => + m( + Select, + { + value: node.table, + onchange: (e: Event) => + update({table: (e.target as HTMLSelectElement).value}), + }, + [ + m('option', {value: 'slice'}, 'slice'), + m('option', {value: 'sched'}, 'sched'), + m('option', {value: 'thread'}, 'thread'), + m('option', {value: 'process'}, 'process'), + ], + ), }, + select: { - outputs: [{content: 'Output', direction: 'right'}], - canDockTop: true, - hue: 100, + title: 'Select', icon: 'checklist', + hue: 100, + inputs: [{label: 'Input', id: 'input', direction: 'north' as const}], + create: (id, x, y): SelectNodeData => ({ + type: 'select', + id, + x, + y, + columns: { + id: true, + name: true, + cpu: false, + duration: false, + timestamp: false, + }, + }), + render: (node: SelectNodeData, update) => + m( + '', + {style: {display: 'flex', flexDirection: 'column', gap: '4px'}}, + Object.entries(node.columns).map(([col, checked]) => + m(Checkbox, { + label: col, + checked, + onchange: () => + update({columns: {...node.columns, [col]: !checked}}), + }), + ), + ), }, + filter: { - canDockTop: true, - canDockBottom: true, - hue: 50, + title: 'Filter', icon: 'filter_alt', + hue: 50, + inputs: [{label: 'Input', id: 'input', direction: 'north' as const}], + create: (id, x, y): FilterNodeData => ({ + type: 'filter', + id, + x, + y, + filterExpression: '', + }), + render: (node: FilterNodeData, update) => + m(TextInput, { + placeholder: 'Filter expression...', + value: node.filterExpression, + oninput: (e: InputEvent) => + update({filterExpression: (e.target as HTMLInputElement).value}), + }), }, + sort: { - canDockTop: true, - canDockBottom: true, - hue: 150, + title: 'Sort', icon: 'sort', + hue: 150, + inputs: [{label: 'Input', id: 'input', direction: 'north' as const}], + create: (id, x, y): SortNodeData => ({ + type: 'sort', + id, + x, + y, + sortColumn: '', + sortOrder: 'ASC', + }), + render: (node: SortNodeData, update) => + m('', {style: {display: 'flex', flexDirection: 'column', gap: '4px'}}, [ + m(TextInput, { + placeholder: 'Sort column...', + value: node.sortColumn, + oninput: (e: InputEvent) => + update({sortColumn: (e.target as HTMLInputElement).value}), + }), + m( + Select, + { + value: node.sortOrder, + onchange: (e: Event) => + update({ + sortOrder: (e.target as HTMLSelectElement).value as + | 'ASC' + | 'DESC', + }), + }, + [ + m('option', {value: 'ASC'}, 'ASC'), + m('option', {value: 'DESC'}, 'DESC'), + ], + ), + ]), }, + join: { - inputs: [{content: 'Right', direction: 'left'}], - canDockTop: true, - canDockBottom: true, - hue: 300, + title: 'Join', icon: 'join', - }, - union: { + hue: 300, inputs: [ - {content: 'Input 1', direction: 'left'}, - {content: 'Input 2', direction: 'left'}, + {label: 'Left', id: 'left', direction: 'north' as const}, + {label: 'Right', id: 'right', direction: 'west' as const}, ], - canDockBottom: true, - hue: 240, - icon: 'merge', + create: (id, x, y): JoinNodeData => ({ + type: 'join', + id, + x, + y, + joinType: 'INNER', + joinOn: '', + }), + render: (node: JoinNodeData, update) => + m('', {style: {display: 'flex', flexDirection: 'column', gap: '4px'}}, [ + m( + Select, + { + value: node.joinType, + onchange: (e: Event) => + update({ + joinType: (e.target as HTMLSelectElement) + .value as JoinNodeData['joinType'], + }), + }, + [ + m('option', {value: 'INNER'}, 'INNER'), + m('option', {value: 'LEFT'}, 'LEFT'), + m('option', {value: 'RIGHT'}, 'RIGHT'), + m('option', {value: 'FULL'}, 'FULL'), + ], + ), + m(TextInput, { + placeholder: 'ON condition...', + value: node.joinOn, + oninput: (e: InputEvent) => + update({joinOn: (e.target as HTMLInputElement).value}), + }), + ]), }, - result: { - canDockTop: true, - hue: 0, - icon: 'output', + + union: { + title: 'Union', + icon: 'merge', + hue: 240, + inputs: [ + {label: 'Input 1', id: 'input-1', direction: 'north' as const}, + {label: 'Input 2', id: 'input-2', direction: 'west' as const}, + ], + create: (id, x, y): UnionNodeData => ({ + type: 'union', + id, + x, + y, + unionType: 'UNION ALL', + }), + render: (node: UnionNodeData, update) => + m( + Select, + { + value: node.unionType, + onchange: (e: Event) => + update({ + unionType: (e.target as HTMLSelectElement) + .value as UnionNodeData['unionType'], + }), + }, + [ + m('option', {value: 'UNION'}, 'UNION'), + m('option', {value: 'UNION ALL'}, 'UNION ALL'), + ], + ), }, }; -// Factory functions for creating node data -function createTableNode(id: string, x: number, y: number): TableNodeData { - return { - type: 'table', - id, - x, - y, - table: 'slice', - }; +// Assign stable per-node port IDs to a list of port templates. +// Input port i on node `nodeId` gets id `${nodeId}-in-${i}`. +function makeInputPorts( + nodeId: string, + templates: + | ReadonlyArray<{ + id: string; + label: string; + direction?: NodeGraphPort['direction']; + }> + | undefined, +): NodeGraphPort[] | undefined { + if (!templates) return undefined; + return templates.map((t) => ({ + direction: t.direction ?? 'west', + id: `${nodeId}-in-${t.id}`, + label: t.label, + })); } -function createSelectNode(id: string, x: number, y: number): SelectNodeData { - return { - type: 'select', - id, - x, - y, - columns: { - id: true, - name: true, - cpu: false, - duration: false, - timestamp: false, - }, - }; -} - -function createFilterNode(id: string, x: number, y: number): FilterNodeData { - return { - type: 'filter', - id, - x, - y, - filterExpression: '', - }; -} - -function createSortNode(id: string, x: number, y: number): SortNodeData { - return { - type: 'sort', - id, - x, - y, - sortColumn: '', - sortOrder: 'ASC', - }; -} - -function createJoinNode(id: string, x: number, y: number): JoinNodeData { - return { - type: 'join', - id, - x, - y, - joinType: 'INNER', - joinOn: '', - }; -} - -function createUnionNode(id: string, x: number, y: number): UnionNodeData { - return { - type: 'union', - id, - x, - y, - unionType: 'UNION ALL', - }; -} - -function createResultNode(id: string, x: number, y: number): ResultNodeData { - return { - type: 'result', - id, - x, - y, - }; -} - -// Pure render functions for each node type -const TABLE_SUGGESTIONS = [ - 'slice', - 'sched_slice', - 'thread_state', - 'thread', - 'process', - 'counter', - 'android_logs', - 'cpu_counter_track', - 'gpu_slice', - 'gpu_track', - 'gpu_counter_track', - 'heap_graph_object', - 'heap_profile_allocation', - 'perf_sample', - 'stack_profile_frame', - 'stack_profile_callsite', - 'ftrace_event', - 'flow', - 'args', - 'raw', - 'metadata', - 'trace_stats', -]; - -function renderTableNode( - node: TableNodeData, - updateNode: (updates: Partial<Omit<TableNodeData, 'type' | 'id'>>) => void, -): m.Children { - return m(Combobox, { - value: node.table, - suggestions: TABLE_SUGGESTIONS, - placeholder: 'Table name...', - icon: 'table_chart', - onChange: (value: string) => updateNode({table: value}), - }); -} - -function renderSelectNode( - node: SelectNodeData, - updateNode: (updates: Partial<Omit<SelectNodeData, 'type' | 'id'>>) => void, -): m.Children { - return m( - '', - {style: {display: 'flex', flexDirection: 'column', gap: '4px'}}, - Object.entries(node.columns).map(([col, checked]) => - m(Checkbox, { - label: col, - checked, - onchange: () => { - updateNode({ - columns: { - ...node.columns, - [col]: !checked, - }, - }); - }, - }), - ), - ); -} - -function renderFilterNode( - node: FilterNodeData, - updateNode: (updates: Partial<Omit<FilterNodeData, 'type' | 'id'>>) => void, -): m.Children { - return m(TextInput, { - placeholder: 'Filter expression...', - value: node.filterExpression, - oninput: (e: InputEvent) => { - const target = e.target as HTMLInputElement; - updateNode({filterExpression: target.value}); - }, - }); -} - -function renderSortNode( - node: SortNodeData, - updateNode: (updates: Partial<Omit<SortNodeData, 'type' | 'id'>>) => void, -): m.Children { - return m( - '', - {style: {display: 'flex', flexDirection: 'column', gap: '4px'}}, - [ - m(TextInput, { - placeholder: 'Sort column...', - value: node.sortColumn, - oninput: (e: InputEvent) => { - const target = e.target as HTMLInputElement; - updateNode({sortColumn: target.value}); - }, - }), - m( - Select, - { - value: node.sortOrder, - onchange: (e: Event) => { - updateNode({ - sortOrder: (e.target as HTMLSelectElement).value as - | 'ASC' - | 'DESC', - }); - }, - }, - [ - m('option', {value: 'ASC'}, 'ASC'), - m('option', {value: 'DESC'}, 'DESC'), - ], - ), - ], - ); -} - -function renderJoinNode( - node: JoinNodeData, - updateNode: (updates: Partial<Omit<JoinNodeData, 'type' | 'id'>>) => void, -): m.Children { - return m( - '', - {style: {display: 'flex', flexDirection: 'column', gap: '4px'}}, - [ - m( - Select, - { - value: node.joinType, - onchange: (e: Event) => { - updateNode({ - joinType: (e.target as HTMLSelectElement) - .value as JoinNodeData['joinType'], - }); - }, - }, - [ - m('option', {value: 'INNER'}, 'INNER'), - m('option', {value: 'LEFT'}, 'LEFT'), - m('option', {value: 'RIGHT'}, 'RIGHT'), - m('option', {value: 'FULL'}, 'FULL'), - ], - ), - m(TextInput, { - placeholder: 'ON condition...', - value: node.joinOn, - oninput: (e: InputEvent) => { - const target = e.target as HTMLInputElement; - updateNode({joinOn: target.value}); - }, - }), - ], - ); -} - -function renderUnionNode( - node: UnionNodeData, - updateNode: (updates: Partial<Omit<UnionNodeData, 'type' | 'id'>>) => void, -): m.Children { - return m( - Select, - { - value: node.unionType, - onchange: (e: Event) => { - updateNode({ - unionType: (e.target as HTMLSelectElement) - .value as UnionNodeData['unionType'], - }); - }, - }, - [ - m('option', {value: 'UNION'}, 'UNION'), - m('option', {value: 'UNION ALL'}, 'UNION ALL'), - ], - ); -} - -function renderResultNode(): m.Children { - return 'Result'; -} - -// Master renderer with type narrowing -function renderNodeContent( - node: NodeData, - updateNode: (updates: Partial<Omit<NodeData, 'id'>>) => void, -): m.Children { - switch (node.type) { - case 'table': - return renderTableNode(node, updateNode); - case 'select': - return renderSelectNode(node, updateNode); - case 'filter': - return renderFilterNode(node, updateNode); - case 'sort': - return renderSortNode(node, updateNode); - case 'join': - return renderJoinNode(node, updateNode); - case 'union': - return renderUnionNode(node, updateNode); - case 'result': - return renderResultNode(); - } +function createLabel(id: string, x: number, y: number): LabelData { + return {id, x, y, text: 'Label'}; } interface NodeGraphDemoAttrs { - readonly multiselect?: boolean; readonly titleBars?: boolean; readonly headerIcons?: boolean; readonly accentBars?: boolean; - readonly colors?: boolean; readonly contextMenus?: boolean; - readonly contextMenuOnHover?: boolean; } export function NodeGraphDemo(): m.Component<NodeGraphDemoAttrs> { - let graphApi: NodeGraphApi | undefined; + let graphApi!: NodeGraphAPI; + let nodeMenuSearch = ''; - // Initialize store with a single table node - const initialId = uuidv4(); + // Pipeline 1: slice ──► filter ──┐ + // ├──► join ──► sort ──► select + // sched ──────────────┘ + const sliceId = shortUuid(); + const schedId = shortUuid(); + const filterId = shortUuid(); + const joinId = shortUuid(); + const sortId = shortUuid(); + const selectId = shortUuid(); + const label1Id = shortUuid(); + + // Pipeline 2: thread ──┐ + // ├──► union ──► sort2 + // process ──┘ + const threadId = shortUuid(); + const processId = shortUuid(); + const unionId = shortUuid(); + const sort2Id = shortUuid(); + const label2Id = shortUuid(); + let store: NodeGraphStore = { - nodes: new Map([[initialId, createTableNode(initialId, 150, 100)]]), - connections: [], - labels: [ + nodes: [ + // Pipeline 1 — slice+filter are stacked (docked) { - id: uuidv4(), - x: 400, - y: 100, - width: 180, - content: m('.pf-simple-label-text', 'Simple text label'), + ...NODE_CONFIGS.table.create(sliceId, 50, 80), + next: { + ...NODE_CONFIGS.filter.create(filterId, 0, 0), + filterExpression: 'dur > 1000', + }, + }, + {...NODE_CONFIGS.table.create(schedId, 50, 300), table: 'sched'}, + { + ...NODE_CONFIGS.join.create(joinId, 570, 180), + joinType: 'INNER', + joinOn: 'utid', + inputNodeIds: [filterId, schedId], // left=filter, right=sched }, { - id: uuidv4(), - x: 400, - y: 200, - width: 180, - content: m( - '.pf-simple-label-button', - m(Button, { - label: 'Click me!', - onclick: () => { - console.log('Label button clicked!'); - }, - }), - ), + ...NODE_CONFIGS.sort.create(sortId, 830, 180), + sortColumn: 'dur', + sortOrder: 'DESC', + inputNodeIds: [joinId], + }, + { + ...NODE_CONFIGS.select.create(selectId, 1090, 180), + columns: { + id: true, + name: true, + dur: true, + cpu: false, + timestamp: false, + }, + inputNodeIds: [sortId], + }, + + // Pipeline 2 + {...NODE_CONFIGS.table.create(threadId, 50, 700), table: 'thread'}, + {...NODE_CONFIGS.table.create(processId, 50, 900), table: 'process'}, + { + ...NODE_CONFIGS.union.create(unionId, 310, 790), + unionType: 'UNION ALL', + inputNodeIds: [threadId, processId], + }, + { + ...NODE_CONFIGS.sort.create(sort2Id, 570, 790), + sortColumn: 'name', + sortOrder: 'ASC', + inputNodeIds: [unionId], }, ], - invalidNodes: new Set<string>(), + labels: [ + { + ...createLabel(label1Id, 50, 490), + text: 'Slice ⨝ Sched pipeline\nFilters short slices,\njoins on thread, sorts by duration.', + }, + { + ...createLabel(label2Id, 50, 1010), + text: 'Thread ∪ Process\nAll named entities,\nsorted alphabetically.', + }, + ], }; // History management @@ -485,35 +531,79 @@ // Selection state (separate from undo/redo history) const selectedNodeIds = new Set<string>(); - // Helper to find the parent node (node that has this node as nextId) - function findDockedParent( - nodes: Map<string, NodeData>, - nodeId: string, - ): NodeData | undefined { - for (const node of nodes.values()) { - if (node.nextId === nodeId) { - return node; + // Helper to find a node by id, searching recursively through linked chains + function findNodeById(nodes: NodeData[], id: string): NodeData | undefined { + for (const root of nodes) { + let cur: NodeData | undefined = root; + while (cur) { + if (cur.id === id) return cur; + cur = cur.next; } } return undefined; } - // Helper to find input nodes via connections - function findConnectedInputs( - nodes: Map<string, NodeData>, - connections: Connection[], - nodeId: string, - ): Map<number, NodeData> { - const inputs = new Map<number, NodeData>(); - for (const conn of connections) { - if (conn.toNode === nodeId) { - const inputNode = nodes.get(conn.fromNode); - if (inputNode) { - inputs.set(conn.toPort, inputNode); - } + // Helper to find the parent node (node that has this node as .next) + function findParent( + nodes: NodeData[], + targetId: string, + ): NodeData | undefined { + for (const root of nodes) { + let cur: NodeData | undefined = root; + while (cur) { + if (cur.next?.id === targetId) return cur; + cur = cur.next; } } - return inputs; + return undefined; + } + + // Flatten all nodes (root + docked chains) into a single array. + function flattenNodes(nodes: NodeData[]): NodeData[] { + const result: NodeData[] = []; + for (const root of nodes) { + let cur: NodeData | undefined = root; + while (cur) { + result.push(cur); + cur = cur.next; + } + } + return result; + } + + // Derive Connection objects from nodes' inputNodeIds. + function deriveConnections(nodes: NodeData[]): NodeGraphConnection[] { + const connections: NodeGraphConnection[] = []; + for (const node of flattenNodes(nodes)) { + if (!node.inputNodeIds) continue; + const manifest = NODE_CONFIGS[node.type]; + node.inputNodeIds.forEach((sourceId, slotIndex) => { + if (!sourceId) return; + const inputDef = manifest.inputs?.[slotIndex]; + if (!inputDef) return; + connections.push({ + fromPort: `${sourceId}-out`, + toPort: `${node.id}-in-${inputDef.id}`, + }); + }); + } + return connections; + } + + // Find which node and input slot a toPort string refers to. + function findPortSlot( + nodes: NodeData[], + toPort: string, + ): {node: NodeData; slotIndex: number} | undefined { + for (const node of flattenNodes(nodes)) { + const manifest = NODE_CONFIGS[node.type]; + const slotIndex = + manifest.inputs?.findIndex( + (input) => `${node.id}-in-${input.id}` === toPort, + ) ?? -1; + if (slotIndex !== -1) return {node, slotIndex}; + } + return undefined; } // Update store with history @@ -567,35 +657,59 @@ updates: Partial<Omit<NodeData, 'id'>>, ) => { updateStore((draft) => { - const node = draft.nodes.get(nodeId); + const node = findNodeById(draft.nodes, nodeId); if (node) { Object.assign(node, updates); } }); }; + const updateLabel = ( + labelId: string, + updates: Partial<Omit<LabelData, 'id'>>, + ) => { + updateStore((draft) => { + const label = draft.labels.find((l) => l.id === labelId); + if (label) Object.assign(label, updates); + }); + }; + const removeNode = (nodeId: string) => { updateStore((draft) => { - const nodeToDelete = draft.nodes.get(nodeId); + // Check if it's a label first + const labelIdx = draft.labels.findIndex((l) => l.id === nodeId); + if (labelIdx !== -1) { + draft.labels.splice(labelIdx, 1); + selectedNodeIds.delete(nodeId); + return; + } + + const nodeToDelete = findNodeById(draft.nodes, nodeId); if (!nodeToDelete) return; - // Dock any child node to its parent - for (const parent of draft.nodes.values()) { - if (parent.nextId === nodeId) { - parent.nextId = nodeToDelete.nextId; + // Clear any inputNodeIds referencing this node from other nodes. + for (const n of flattenNodes(draft.nodes)) { + if (!n.inputNodeIds) continue; + for (let i = 0; i < n.inputNodeIds.length; i++) { + if (n.inputNodeIds[i] === nodeId) n.inputNodeIds[i] = undefined; } } - // Remove any connections to/from this node - for (let i = draft.connections.length - 1; i >= 0; i--) { - const c = draft.connections[i]; - if (c.fromNode === nodeId || c.toNode === nodeId) { - draft.connections.splice(i, 1); + const parent = findParent(draft.nodes, nodeId); + if (parent) { + // Splice out from chain, promoting its child + parent.next = nodeToDelete.next; + } else { + // Root node: remove from array, optionally promoting child to root + const idx = draft.nodes.findIndex((n) => n.id === nodeId); + if (idx !== -1) { + if (nodeToDelete.next) { + draft.nodes.splice(idx, 1, nodeToDelete.next as NodeData); + } else { + draft.nodes.splice(idx, 1); + } } } - - // Finally remove the node - draft.nodes.delete(nodeId); }); // Clear from selection (outside of store update) @@ -604,554 +718,254 @@ console.log(`removeNode: ${nodeId}`); }; - const removeLabel = (labelId: string) => { - updateStore((draft) => { - const labelIndex = draft.labels.findIndex((l) => l.id === labelId); - if (labelIndex !== -1) { - draft.labels.splice(labelIndex, 1); - } - }); - - // Clear from selection (outside of store update) - selectedNodeIds.delete(labelId); - - console.log(`removeLabel: ${labelId}`); - }; - // Stress test function const runStressTest = () => { updateStore((draft) => { // Clear existing state - draft.nodes.clear(); - draft.connections.length = 0; - draft.invalidNodes.clear(); - - // Node factory options - const nodeFactories = [ - createTableNode, - createSelectNode, - createFilterNode, - createSortNode, - createJoinNode, - createUnionNode, - createResultNode, - ]; + draft.nodes.length = 0; + draft.labels.length = 0; // Create 100 random nodes + const nodeTypes = Object.keys(NODE_CONFIGS) as NodeData['type'][]; const nodeIds: string[] = []; for (let i = 0; i < 100; i++) { - const id = uuidv4(); - const factory = - nodeFactories[Math.floor(Math.random() * nodeFactories.length)]; + const id = shortUuid(); + const type = nodeTypes[Math.floor(Math.random() * nodeTypes.length)]; const x = Math.random() * 2000; const y = Math.random() * 2000; - const newNode = factory(id, x, y); - - draft.nodes.set(id, newNode); + draft.nodes.push(NODE_CONFIGS[type].create(id, x, y)); nodeIds.push(id); } - // Create some stacked (docked) nodes - // Aim for ~20 stacks (20% of nodes) - const usedInStacks = new Set<string>(); - let stacksCreated = 0; - for (let i = 0; i < 20; i++) { - // Find a parent node that can dock (has canDockBottom) - const availableParents = nodeIds.filter((id) => { - const node = draft.nodes.get(id)!; - const config = NODE_CONFIGS[node.type]; - return config.canDockBottom && !node.nextId && !usedInStacks.has(id); - }); + // Wire ~150 random connections via inputNodeIds. + for (let i = 0; i < 150; i++) { + const fromId = nodeIds[Math.floor(Math.random() * nodeIds.length)]; + const toId = nodeIds[Math.floor(Math.random() * nodeIds.length)]; + if (fromId === toId) continue; - if (availableParents.length === 0) break; + const toNode = findNodeById(draft.nodes, toId); + if (!toNode) continue; - const parentId = - availableParents[Math.floor(Math.random() * availableParents.length)]; - const parent = draft.nodes.get(parentId)!; + const numInputs = NODE_CONFIGS[toNode.type].inputs?.length ?? 0; + if (numInputs === 0) continue; - // Find a child node that can dock (has canDockTop) - const availableChildren = nodeIds.filter((id) => { - const node = draft.nodes.get(id)!; - const config = NODE_CONFIGS[node.type]; - return config.canDockTop && id !== parentId && !usedInStacks.has(id); - }); - - if (availableChildren.length === 0) continue; - - const childId = - availableChildren[ - Math.floor(Math.random() * availableChildren.length) - ]; - - // Stack the child under the parent - parent.nextId = childId; - usedInStacks.add(parentId); - usedInStacks.add(childId); - stacksCreated++; + const slotIndex = Math.floor(Math.random() * numInputs); + if (!toNode.inputNodeIds) toNode.inputNodeIds = []; + toNode.inputNodeIds[slotIndex] = fromId; } - // Create random connections between nodes - // Aim for ~150 connections (1.5 per node on average) - const numConnections = 150; - for (let i = 0; i < numConnections; i++) { - // Pick random nodes - const fromNodeId = nodeIds[Math.floor(Math.random() * nodeIds.length)]; - const toNodeId = nodeIds[Math.floor(Math.random() * nodeIds.length)]; - - if (fromNodeId === toNodeId) continue; - - const fromNode = draft.nodes.get(fromNodeId)!; - const toNode = draft.nodes.get(toNodeId)!; - - // Check if nodes have compatible ports - const fromConfig = NODE_CONFIGS[fromNode.type]; - const toConfig = NODE_CONFIGS[toNode.type]; - const numOutputs = fromConfig.outputs?.length ?? 0; - const numInputs = toConfig.inputs?.length ?? 0; - - if (numOutputs === 0 || numInputs === 0) continue; - - // Random output and input ports - const fromPort = Math.floor(Math.random() * numOutputs); - const toPort = Math.floor(Math.random() * numInputs); - - // Check if this connection already exists - const exists = draft.connections.some( - (c) => - c.fromNode === fromNodeId && - c.toNode === toNodeId && - c.fromPort === fromPort && - c.toPort === toPort, - ); - - if (!exists) { - draft.connections.push({ - fromNode: fromNodeId, - fromPort, - toNode: toNodeId, - toPort, - }); - } - } - - console.log( - `Stress test: Created ${draft.nodes.size} nodes, ${draft.connections.length} connections, and ${stacksCreated} stacks`, - ); + console.log(`Stress test: Created ${draft.nodes.length} nodes`); }); // Clear selection after stress test selectedNodeIds.clear(); }; - // Build SQL query from a node by traversing upwards - function buildSqlFromNode( - nodes: Map<string, NodeData>, - connections: Connection[], - nodeId: string, - visited: Set<string> = new Set(), - ): string { - // Cycle detection: if we've already visited this node in the current path, return empty - if (visited.has(nodeId)) { - console.warn(`Cycle detected at node ${nodeId}`); - return ''; - } - - const node = nodes.get(nodeId); - if (!node) return ''; - - // Add current node to visited set for this traversal path - const newVisited = new Set(visited); - newVisited.add(nodeId); - - // First check for docked parent - const dockedParent = findDockedParent(nodes, nodeId); - const connectedInputs = findConnectedInputs(nodes, connections, nodeId); - - switch (node.type) { - case 'table': { - return node.table || 'unknown_table'; - } - - case 'select': { - const selectedCols = Object.entries(node.columns) - .filter(([_, checked]) => checked) - .map(([col]) => col); - const colList = selectedCols.length > 0 ? selectedCols.join(', ') : '*'; - - const inputSql = dockedParent - ? buildSqlFromNode(nodes, connections, dockedParent.id, newVisited) - : connectedInputs.get(0) - ? buildSqlFromNode( - nodes, - connections, - connectedInputs.get(0)!.id, - newVisited, - ) - : ''; - - if (!inputSql) return `SELECT ${colList}`; - return `SELECT ${colList} FROM (${inputSql})`; - } - - case 'filter': { - const filterExpr = node.filterExpression || ''; - - const inputSql = dockedParent - ? buildSqlFromNode(nodes, connections, dockedParent.id, newVisited) - : connectedInputs.get(0) - ? buildSqlFromNode( - nodes, - connections, - connectedInputs.get(0)!.id, - newVisited, - ) - : ''; - - if (!inputSql) return ''; - if (!filterExpr) return inputSql; - return `SELECT * FROM (${inputSql}) WHERE ${filterExpr}`; - } - - case 'sort': { - const sortColumn = node.sortColumn || ''; - const sortOrder = node.sortOrder || 'ASC'; - - const inputSql = dockedParent - ? buildSqlFromNode(nodes, connections, dockedParent.id, newVisited) - : connectedInputs.get(0) - ? buildSqlFromNode( - nodes, - connections, - connectedInputs.get(0)!.id, - newVisited, - ) - : ''; - - if (!inputSql) return ''; - if (!sortColumn) return inputSql; - return `SELECT * FROM (${inputSql}) ORDER BY ${sortColumn} ${sortOrder}`; - } - - case 'join': { - const joinType = node.joinType || 'INNER'; - const joinOn = node.joinOn || 'true'; - - // Join needs two inputs: one docked and one from left connection - const leftInput = dockedParent - ? buildSqlFromNode(nodes, connections, dockedParent.id, newVisited) - : ''; - - const rightInput = connectedInputs.get(0) - ? buildSqlFromNode( - nodes, - connections, - connectedInputs.get(0)!.id, - newVisited, - ) - : ''; - - if (!leftInput || !rightInput) return leftInput || rightInput || ''; - return `SELECT * FROM (${leftInput}) ${joinType} JOIN (${rightInput}) ON ${joinOn}`; - } - - case 'union': { - const unionType = node.unionType || ''; - - const inputs: string[] = []; - - // Collect all inputs from left connections (no docked parent for union) - if (dockedParent) { - inputs.push( - buildSqlFromNode(nodes, connections, dockedParent.id, newVisited), - ); - } - for (const [_, inputNode] of connectedInputs) { - inputs.push( - buildSqlFromNode(nodes, connections, inputNode.id, newVisited), - ); - } - - const validInputs = inputs.filter((sql) => sql); - if (validInputs.length === 0) return ''; - if (validInputs.length === 1) return validInputs[0]; - return validInputs.map((sql) => `(${sql})`).join(` ${unionType} `); - } - - case 'result': { - const inputSql = dockedParent - ? buildSqlFromNode(nodes, connections, dockedParent.id, newVisited) - : connectedInputs.get(0) - ? buildSqlFromNode( - nodes, - connections, - connectedInputs.get(0)!.id, - newVisited, - ) - : ''; - return inputSql; - } - } - } - function renderNodeContextMenu(node: NodeData) { - const isInvalid = store.invalidNodes.has(node.id); return [ m(MenuItem, { - label: isInvalid ? 'Mark as Valid' : 'Mark as Invalid', - icon: isInvalid ? 'check_circle' : 'error', - onclick: () => { - updateStore((draft) => { - if (draft.invalidNodes.has(node.id)) { - draft.invalidNodes.delete(node.id); - } else { - draft.invalidNodes.add(node.id); - } - }); - console.log( - `Context Menu: Toggle invalid state for ${node.id}: ${!isInvalid}`, - ); - }, - }), - m(MenuItem, { label: 'Delete', icon: 'delete', onclick: () => { removeNode(node.id); - console.log(`Context Menu: onNodeRemove: ${node.id}`); }, }), ]; } - // Find root nodes (not referenced by any other node's nextId) - function getRootNodeIds(nodes: Map<string, NodeData>): string[] { - const referenced = new Set<string>(); - for (const node of nodes.values()) { - if (node.nextId) referenced.add(node.nextId); - } - return Array.from(nodes.keys()).filter((id) => !referenced.has(id)); - } - return { view: ({attrs}: m.Vnode<NodeGraphDemoAttrs>) => { - // Log the SQL queries for all result nodes - const queries = []; - for (const node of store.nodes.values()) { - if (node.type === 'result') { - const sql = buildSqlFromNode(store.nodes, store.connections, node.id); - queries.push(sql); - } - } - if (queries.length > 0) { - console.log('Generated SQL queries for result nodes:', queries); - } - + // Produces the "add downstream node" context menu for output ports. + // Only pipeline nodes (not table) can be appended downstream. function renderAddNodeMenu(toNode: string) { + const appendableTypes: NodeData['type'][] = [ + 'select', + 'filter', + 'sort', + 'join', + 'union', + ]; return [ - m(MenuItem, { - label: 'Select', - icon: 'checklist', - onclick: () => addNode(createSelectNode, toNode), - style: { - borderLeft: `4px solid hsl(${NODE_CONFIGS.select.hue}, 60%, 50%)`, - }, + ...appendableTypes.map((type) => { + const manifest = NODE_CONFIGS[type]; + return m(MenuItem, { + label: manifest.title, + icon: manifest.icon, + onclick: () => addNode(type, toNode), + style: {borderLeft: `4px solid hsl(${manifest.hue}, 60%, 50%)`}, + }); }), m(MenuItem, { - label: 'Filter', - icon: 'filter_alt', - onclick: () => addNode(createFilterNode, toNode), - style: { - borderLeft: `4px solid hsl(${NODE_CONFIGS.filter.hue}, 60%, 50%)`, - }, - }), - m(MenuItem, { - label: 'Sort', - icon: 'sort', - onclick: () => addNode(createSortNode, toNode), - style: { - borderLeft: `4px solid hsl(${NODE_CONFIGS.sort.hue}, 60%, 50%)`, - }, - }), - m(MenuItem, { - label: 'Join', - icon: 'join', - onclick: () => addNode(createJoinNode, toNode), - style: { - borderLeft: `4px solid hsl(${NODE_CONFIGS.join.hue}, 60%, 50%)`, - }, - }), - m(MenuItem, { - label: 'Union', - icon: 'merge', - onclick: () => addNode(createUnionNode, toNode), - style: { - borderLeft: `4px solid hsl(${NODE_CONFIGS.union.hue}, 60%, 50%)`, - }, - }), - m(MenuItem, { - label: 'Result', - icon: 'output', - onclick: () => addNode(createResultNode, toNode), - style: { - borderLeft: `4px solid hsl(${NODE_CONFIGS.result.hue}, 60%, 50%)`, - }, + label: 'Label', + icon: 'label', + onclick: () => addLabel(), }), ]; } - const addNode = ( - factory: (id: string, x: number, y: number) => NodeData, - toNodeId?: string, - ) => { - const id = uuidv4(); + const addNode = (type: NodeData['type'], toNodeId?: string) => { + const manifest = NODE_CONFIGS[type]; + const id = shortUuid(); let x: number; let y: number; - // Use API to find optimal placement if available - if (graphApi && !toNodeId) { - const tempNode = factory(id, 0, 0); - const config = NODE_CONFIGS[tempNode.type]; - const placement = graphApi.findPlacementForNode({ - id, - inputs: config.inputs, - outputs: config.outputs?.map((out) => { - return {...out, contextMenuItems: renderAddNodeMenu(tempNode.id)}; - }), - content: renderNodeContent(tempNode, () => {}), - canDockBottom: config.canDockBottom, - canDockTop: config.canDockTop, - accentBar: attrs.accentBars, - titleBar: attrs.titleBars - ? { - title: tempNode.type.toUpperCase(), - icon: attrs.headerIcons ? config.icon : undefined, - } - : undefined, - hue: attrs.colors ? config.hue : undefined, - contextMenuItems: attrs.contextMenus - ? renderNodeContextMenu(tempNode) - : undefined, - }); - x = placement.x; - y = placement.y; - } else { - // Fallback to random position - x = 100 + Math.random() * 200; - y = 50 + Math.random() * 200; - } + const tempNode = manifest.create(id, 0, 0); + const placement = graphApi.findPlacementForNode({ + id, + inputs: makeInputPorts(id, manifest.inputs), + outputs: [ + { + id: `${id}-out`, + label: 'Output', + direction: 'south', + contextMenuItems: renderAddNodeMenu(id), + }, + ], + content: manifest.render(tempNode, () => {}), + accentBar: attrs.accentBars, + headerBar: attrs.titleBars + ? { + title: manifest.title, + icon: attrs.headerIcons ? manifest.icon : undefined, + } + : undefined, + hue: manifest.hue, + contextMenuItems: attrs.contextMenus + ? renderNodeContextMenu(tempNode) + : undefined, + }); + x = placement.x; + y = placement.y; - const newNode = factory(id, x, y); - + const newNode = manifest.create(id, x, y); updateStore((draft) => { - draft.nodes.set(newNode.id, newNode); - if (toNodeId) { - const parentNode = draft.nodes.get(toNodeId); + const parentNode = findNodeById(draft.nodes, toNodeId); if (parentNode) { - newNode.nextId = parentNode.nextId; - parentNode.nextId = id; + // Insert newNode between parent and its current next + const existingNext = parentNode.next; + parentNode.next = newNode; + newNode.next = existingNext; + } else { + draft.nodes.push(newNode); } - // Find any connection connected to the bottom port of this node - const bottomConnectionIdx = draft.connections.findIndex( - (c) => c.fromNode === toNodeId && c.fromPort === 0, - ); - if (bottomConnectionIdx > -1) { - draft.connections[bottomConnectionIdx] = { - ...draft.connections[bottomConnectionIdx], - fromNode: id, - fromPort: 0, - }; + // Re-route any node whose first input was toNodeId so it now + // comes from the new node's output. + for (const n of flattenNodes(draft.nodes)) { + if (!n.inputNodeIds) continue; + for (let i = 0; i < n.inputNodeIds.length; i++) { + if (n.inputNodeIds[i] === toNodeId) { + n.inputNodeIds[i] = id; + } + } } + } else { + draft.nodes.push(newNode); } }); }; - // Render a model node and its chain - function renderNodeChain(nodeData: NodeData): Node { - const hasNext = nodeData.nextId !== undefined; - const nextModel = hasNext - ? store.nodes.get(nodeData.nextId!) - : undefined; + const addLabel = () => { + const id = shortUuid(); + const pos = graphApi.findPlacementForNode({ + id, + hue: 0, + canDockTop: false, + canDockBottom: false, + }); + updateStore((draft) => { + draft.labels.push(createLabel(id, pos.x, pos.y)); + }); + }; - const config = NODE_CONFIGS[nodeData.type]; - + // Shared helper: builds the common Node fields from a NodeData + manifest. + // originalManifest is the unsliced manifest used for canDockTop; it + // defaults to manifest when not supplied (i.e. for root nodes). + function buildNodeFields( + nodeData: NodeData, + manifest: NodeTypeManifest<NodeData>, + originalManifest?: NodeTypeManifest<NodeData>, + ) { + const orig = originalManifest ?? manifest; return { - id: nodeData.id, - x: nodeData.x, - y: nodeData.y, - inputs: config.inputs, - outputs: config.outputs?.map((out) => { - return {...out, contextMenuItems: renderAddNodeMenu(nodeData.id)}; - }), - content: renderNodeContent(nodeData, (updates) => + inputs: makeInputPorts(nodeData.id, manifest.inputs), + outputs: nodeData.next + ? [] + : [ + { + id: `${nodeData.id}-out`, + label: 'Output', + direction: 'south' as const, + contextMenuItems: renderAddNodeMenu(nodeData.id), + }, + ], + content: manifest.render(nodeData, (updates) => updateNode(nodeData.id, updates), ), - canDockBottom: config.canDockBottom, - canDockTop: config.canDockTop, - next: nextModel ? renderChildNode(nextModel) : undefined, accentBar: attrs.accentBars, - titleBar: attrs.titleBars + headerBar: attrs.titleBars ? { - title: nodeData.type.toUpperCase(), - icon: attrs.headerIcons ? config.icon : undefined, + title: manifest.title, + icon: attrs.headerIcons ? manifest.icon : undefined, } : undefined, - hue: attrs.colors ? config.hue : undefined, + hue: manifest.hue, contextMenuItems: attrs.contextMenus ? renderNodeContextMenu(nodeData) : undefined, - invalid: store.invalidNodes.has(nodeData.id), + canDockTop: (orig.inputs?.length ?? 0) > 0, + canDockBottom: true, }; } - // Render child node (keep all ports visible) - function renderChildNode(nodeData: NodeData): Omit<Node, 'x' | 'y'> { - const hasNext = nodeData.nextId !== undefined; - const nextModel = hasNext - ? store.nodes.get(nodeData.nextId!) - : undefined; - - const config = NODE_CONFIGS[nodeData.type]; - + // Render a model node and its chain + function renderNodeChain(nodeData: NodeData): NodeGraphNode { + const manifest = NODE_CONFIGS[nodeData.type]; return { id: nodeData.id, - inputs: config.inputs, - outputs: config.outputs?.map((out) => { - return {...out, contextMenuItems: renderAddNodeMenu(nodeData.id)}; + pos: {x: nodeData.x, y: nodeData.y}, + ...buildNodeFields(nodeData, manifest), + next: nodeData.next ? renderChildNode(nodeData.next) : undefined, + }; + } + + // Render child node: hide input[0] (implicitly fed by the parent above). + function renderChildNode(nodeData: NodeData): Omit<NodeGraphNode, 'pos'> { + const manifest = NODE_CONFIGS[nodeData.type]; + const manifestWithoutFirstInput = { + ...manifest, + inputs: manifest.inputs?.slice(1), + }; + return { + id: nodeData.id, + ...buildNodeFields(nodeData, manifestWithoutFirstInput, manifest), + next: nodeData.next ? renderChildNode(nodeData.next) : undefined, + }; + } + + function renderLabelNode(label: LabelData): NodeGraphNode { + return { + id: label.id, + pos: {x: label.x, y: label.y}, + hue: 0, + content: m(LabelContent, { + text: label.text, + update: (text) => updateLabel(label.id, {text}), }), - content: renderNodeContent(nodeData, (updates) => - updateNode(nodeData.id, updates), - ), - canDockBottom: config.canDockBottom, - canDockTop: config.canDockTop, - next: nextModel ? renderChildNode(nextModel) : undefined, - accentBar: attrs.accentBars, - titleBar: attrs.titleBars - ? { - title: nodeData.type.toUpperCase(), - icon: attrs.headerIcons ? config.icon : undefined, - } - : undefined, - hue: attrs.colors ? config.hue : undefined, - contextMenuItems: attrs.contextMenus - ? renderNodeContextMenu(nodeData) - : undefined, - invalid: store.invalidNodes.has(nodeData.id), + canDockTop: false, + canDockBottom: false, + className: 'pf-ngd__label', }; } // Render model state into NodeGraph nodes - function renderNodes(): Node[] { - const rootIds = getRootNodeIds(store.nodes); - return rootIds - .map((id) => { - const model = store.nodes.get(id); - if (!model) return null; - return renderNodeChain(model); - }) - .filter((n): n is Node => n !== null); + function renderNodes(): NodeGraphNode[] { + return [ + ...store.nodes.map(renderNodeChain), + ...store.labels.map(renderLabelNode), + ]; } const nodeGraphAttrs: NodeGraphAttrs = { @@ -1165,64 +979,67 @@ icon: 'add', variant: ButtonVariant.Filled, }), + onChange: (open) => { + if (!open) { + nodeMenuSearch = ''; + } + }, }, [ - m(MenuItem, { - label: 'Table', - icon: 'table_chart', - onclick: () => addNode(createTableNode), + m('input', { + type: 'text', + placeholder: 'Search...', + value: nodeMenuSearch, style: { - borderLeft: `4px solid hsl(${NODE_CONFIGS.table.hue}, 60%, 50%)`, + display: 'block', + width: '100%', + boxSizing: 'border-box', + padding: '6px 8px', + border: 'none', + borderBottom: '1px solid var(--pf-color-border)', + background: 'var(--pf-color-background)', + color: 'var(--pf-color-text)', + fontSize: '13px', + outline: 'none', + }, + oncreate: ({dom}: {dom: Element}) => { + (dom as HTMLInputElement).focus(); + }, + oninput: (e: InputEvent) => { + nodeMenuSearch = (e.target as HTMLInputElement).value; + m.redraw(); + }, + onkeydown: (e: KeyboardEvent) => { + e.stopPropagation(); }, }), - m(MenuItem, { - label: 'Select', - icon: 'checklist', - onclick: () => addNode(createSelectNode), - style: { - borderLeft: `4px solid hsl(${NODE_CONFIGS.select.hue}, 60%, 50%)`, - }, - }), - m(MenuItem, { - label: 'Filter', - icon: 'filter_alt', - onclick: () => addNode(createFilterNode), - style: { - borderLeft: `4px solid hsl(${NODE_CONFIGS.filter.hue}, 60%, 50%)`, - }, - }), - m(MenuItem, { - label: 'Sort', - icon: 'sort', - onclick: () => addNode(createSortNode), - style: { - borderLeft: `4px solid hsl(${NODE_CONFIGS.sort.hue}, 60%, 50%)`, - }, - }), - m(MenuItem, { - label: 'Join', - icon: 'join', - onclick: () => addNode(createJoinNode), - style: { - borderLeft: `4px solid hsl(${NODE_CONFIGS.join.hue}, 60%, 50%)`, - }, - }), - m(MenuItem, { - label: 'Union', - icon: 'merge', - onclick: () => addNode(createUnionNode), - style: { - borderLeft: `4px solid hsl(${NODE_CONFIGS.union.hue}, 60%, 50%)`, - }, - }), - m(MenuItem, { - label: 'Result', - icon: 'output', - onclick: () => addNode(createResultNode), - style: { - borderLeft: `4px solid hsl(${NODE_CONFIGS.result.hue}, 60%, 50%)`, - }, - }), + ...(Object.keys(NODE_CONFIGS) as NodeData['type'][]) + .filter((type) => { + const manifest = NODE_CONFIGS[type]; + return manifest.title + .toLowerCase() + .includes(nodeMenuSearch.toLowerCase()); + }) + .map((type) => { + const manifest = NODE_CONFIGS[type]; + return m(MenuItem, { + label: manifest.title, + icon: manifest.icon, + onclick: () => addNode(type), + style: { + borderLeft: `4px solid hsl(${manifest.hue}, 60%, 50%)`, + }, + }); + }), + ...('label'.includes(nodeMenuSearch.toLowerCase()) + ? [ + m(MenuItem, { + label: 'Label', + icon: 'label', + onclick: () => addLabel(), + }), + ] + : []), ], ), m( @@ -1257,129 +1074,150 @@ variant: ButtonVariant.Filled, onclick: () => { updateStore((draft) => { - draft.nodes.clear(); - draft.connections.length = 0; + draft.nodes.length = 0; + draft.labels.length = 0; }); selectedNodeIds.clear(); }, }), ), ], + style: {height: '500px'}, nodes: renderNodes(), - connections: store.connections, + connections: deriveConnections(store.nodes), selectedNodeIds: selectedNodeIds, - multiselect: attrs.multiselect, - contextMenuOnHover: attrs.contextMenuOnHover, - onReady: (api: NodeGraphApi) => { + onReady: (api: NodeGraphAPI) => { + console.log('onReady'); graphApi = api; }, - onNodeMove: (nodeId: string, x: number, y: number) => { - // Update position in store with history entry when node is dropped - updateNode(nodeId, {x, y}); - console.log(`onNodeMove: ${nodeId} to (${x}, ${y})`); - }, - onConnect: (conn: Connection) => { - console.log('onConnect:', conn); - updateStore((draft) => { - draft.connections.push(conn); - }); - }, - onConnectionRemove: (index: number) => { - console.log('onConnectionRemove:', index); - updateStore((draft) => { - draft.connections.splice(index, 1); - }); - }, - onNodeRemove: (nodeId: string) => { - removeNode(nodeId); - console.log(`onNodeRemove: ${nodeId}`); - }, - onNodeSelect: (nodeId: string) => { - selectedNodeIds.clear(); - selectedNodeIds.add(nodeId); - m.redraw(); - console.log(`onNodeSelect: ${nodeId}`); - }, - onNodeAddToSelection: (nodeId: string) => { - selectedNodeIds.add(nodeId); - m.redraw(); + onNodeMove: (nodeId: string, pos: Point2D) => { console.log( - `onNodeAddToSelection: ${nodeId} (total: ${selectedNodeIds.size})`, + `onNodeMove: ${nodeId} to (${pos.x.toFixed(1)}, ${pos.y.toFixed(1)})`, ); - }, - onNodeRemoveFromSelection: (nodeId: string) => { - selectedNodeIds.delete(nodeId); - m.redraw(); - console.log( - `onNodeRemoveFromSelection: ${nodeId} (total: ${selectedNodeIds.size})`, - ); - }, - onSelectionClear: () => { - selectedNodeIds.clear(); - m.redraw(); - console.log(`onSelectionClear`); - }, - onDock: (targetId: string, childNode: Omit<Node, 'x' | 'y'>) => { updateStore((draft) => { - const target = draft.nodes.get(targetId); - const child = draft.nodes.get(childNode.id); - - if (target && child) { - target.nextId = child.id; - console.log(`onDock: ${child.id} to ${targetId}`); + const label = draft.labels.find((l) => l.id === nodeId); + if (label) { + label.x = pos.x; + label.y = pos.y; + return; } - - // If a connection already exists between these nodes, remove it - for (let i = draft.connections.length - 1; i >= 0; i--) { - const conn = draft.connections[i]; - if ( - (conn.fromNode === targetId && conn.fromPort === 0) || - (conn.toNode === child?.id && conn.toPort === 0) - ) { - draft.connections.splice(i, 1); + const parent = findParent(draft.nodes, nodeId); + if (parent) { + // Undock: detach from parent chain, add as new root + const child = parent.next!; + parent.next = undefined; + child.x = pos.x; + child.y = pos.y; + draft.nodes.push(child as NodeData); + } else { + const node = findNodeById(draft.nodes, nodeId); + if (node) { + node.x = pos.x; + node.y = pos.y; } } }); }, - onUndock: (parentId: string, nodeId: string, x: number, y: number) => { + onConnect: (conn: NodeGraphConnection) => { + console.log(`onConnect: ${conn.fromPort} -> ${conn.toPort}`); updateStore((draft) => { - const parent = draft.nodes.get(parentId); - const child = draft.nodes.get(nodeId); - - if (parent && child) { - child.x = x; - child.y = y; - parent.nextId = undefined; - - console.log( - `onUndock: ${nodeId} from ${parentId} at (${x}, ${y})`, + const slot = findPortSlot(draft.nodes, conn.toPort); + if (!slot) return; + const sourceId = conn.fromPort.replace(/-out$/, ''); + if (!slot.node.inputNodeIds) slot.node.inputNodeIds = []; + slot.node.inputNodeIds[slot.slotIndex] = sourceId; + }); + }, + onDisconnect: (index: number) => { + console.log(`onDisconnect: index ${index}`); + updateStore((draft) => { + const conn = maybeUndefined(deriveConnections(draft.nodes)[index]); + if (conn === undefined) return; + const slot = findPortSlot(draft.nodes, conn.toPort); + if (!slot?.node.inputNodeIds) return; + slot.node.inputNodeIds[slot.slotIndex] = undefined; + }); + }, + onSelect: (nodeIds: string[]) => { + console.log(`onSelect: [${nodeIds.join(', ')}]`); + selectedNodeIds.clear(); + nodeIds.forEach((nodeId) => selectedNodeIds.add(nodeId)); + m.redraw(); + }, + onSelectionAdd: (nodeId: string) => { + console.log(`onSelectionAdd: ${nodeId}`); + selectedNodeIds.add(nodeId); + m.redraw(); + }, + onSelectionRemove: (nodeId: string) => { + console.log(`onSelectionRemove: ${nodeId}`); + selectedNodeIds.delete(nodeId); + m.redraw(); + }, + onSelectionClear: () => { + console.log('onSelectionClear'); + selectedNodeIds.clear(); + m.redraw(); + }, + onNodeDock: (nodeId, targetId) => { + console.log(`onDock: ${nodeId} -> ${targetId}`); + updateStore((draft) => { + const target = findNodeById(draft.nodes, targetId); + const child = findNodeById(draft.nodes, nodeId); + if (target && child) { + // Remove child from root array if it's there + const childRootIdx = draft.nodes.findIndex( + (n) => n.id === child.id, ); + if (childRootIdx !== -1) { + draft.nodes.splice(childRootIdx, 1); + } + + // If the child already exists somewhere else in the graph, remove it + const parent = findParent(draft.nodes, child.id); + if (parent) { + // Undock: detach from parent chain + parent.next = undefined; + } + + // If the target node already has children, insert the new child + // in between the target and its existing children + if (target.next) { + // Find the end of the chain of child nodes - this is where + // we'll attach the target's existing children + let nextChild = child; + while (nextChild.next) { + nextChild = nextChild.next; + } + nextChild.next = target.next; + } + target.next = child; + } + // Clear inputNodeIds that are no longer valid after docking: + // - child's slot 0 (implicitly fed by the docked parent) + // - any node whose input was targetId (target's output is now hidden) + if (child) { + if (child.inputNodeIds) child.inputNodeIds[0] = undefined; + for (const n of flattenNodes(draft.nodes)) { + if (!n.inputNodeIds) continue; + for (let i = 0; i < n.inputNodeIds.length; i++) { + if (n.inputNodeIds[i] === targetId) { + n.inputNodeIds[i] = undefined; + } + } + } } }); }, - labels: store.labels, - onLabelMove: (labelId: string, x: number, y: number) => { - updateStore((draft) => { - const label = draft.labels.find((l) => l.id === labelId); - if (label) { - label.x = x; - label.y = y; - console.log(`onLabelMove: ${labelId} to (${x}, ${y})`); - } - }); + onNodeRemove: (nodeIds: string[]) => { + for (const id of nodeIds) { + removeNode(id); + } }, - onLabelResize: (labelId: string, width: number) => { - updateStore((draft) => { - const label = draft.labels.find((l) => l.id === labelId); - if (label) { - label.width = width; - console.log(`onLabelResize: ${labelId} to width ${width}`); - } - }); - }, - onLabelRemove: (labelId: string) => { - removeLabel(labelId); - console.log(`onLabelRemove: ${labelId}`); + onViewportMove: ({offset, zoom}) => { + console.log( + `onViewportMove: (${offset.x.toFixed(1)}, ${offset.y.toFixed(1)}) zoom=${zoom.toFixed(2)}`, + ); }, }; @@ -1402,13 +1240,10 @@ noPadding: true, renderWidget: (opts) => m(NodeGraphDemo, opts), initialOpts: { - multiselect: true, accentBars: false, titleBars: true, headerIcons: true, - colors: true, contextMenus: true, - contextMenuOnHover: false, }, }),
diff --git a/ui/src/plugins/dev.perfetto.WidgetsPage/styles.scss b/ui/src/plugins/dev.perfetto.WidgetsPage/styles.scss index c039f49..5d74272 100644 --- a/ui/src/plugins/dev.perfetto.WidgetsPage/styles.scss +++ b/ui/src/plugins/dev.perfetto.WidgetsPage/styles.scss
@@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +@import "demos/nodegraph_demo"; + .pf-widgets-page { display: flex; height: 100%;
diff --git a/ui/src/widgets/nodegraph.ts b/ui/src/widgets/nodegraph.ts deleted file mode 100644 index f4099d9..0000000 --- a/ui/src/widgets/nodegraph.ts +++ /dev/null
@@ -1,2545 +0,0 @@ -// Copyright (C) 2025 The Android Open Source Project -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -/** - * A component for displaying and interacting with a node-based graph. - * - * Features: - * - Draggable, selectable, and removable nodes. - * - Pannable and zoomable canvas. - * - Connectable ports to create links between nodes. - * - Docking nodes to each other to form chains. - * - Customizable node content and appearance. - * - Auto-layout and fit-to-screen functionality. - * - * Minimal example: - * - * ```typescript - * const nodes: Node[] = [ - * {id: 'node1', x: 50, y: 50, outputs: [{direction: 'right'}]}, - * {id: 'node2', x: 250, y: 50, inputs: [{direction: 'left'}]}, - * ]; - * - * const connections: Connection[] = [ - * {fromNode: 'node1', fromPort: 0, toNode: 'node2', toPort: 0}, - * ]; - * - * m(NodeGraph, { - * nodes, - * connections, - * onConnect: (newConnection) => { - * // Handle new connection - * }, - * onNodeMove: (nodeId, x, y) => { - * // Handle node position change (called when node is dropped) - * }, - * }); - * ``` - */ -import m from 'mithril'; -import {Button, ButtonGroup, ButtonVariant} from './button'; -import {Icon} from './icon'; -import {PopupMenu} from './menu'; -import {classNames} from '../base/classnames'; -import {Icons} from '../base/semantic_icons'; -import {assertExists} from '../base/assert'; -import {shortUuid} from '../base/uuid'; - -// Default height estimate for labels (used for box selection calculations) -const DEFAULT_LABEL_MIN_HEIGHT = 30; - -// Typical height estimate for labels with content (used for autofit calculations) -// Labels can vary in height based on content, but this provides a reasonable -// estimate for bounding box calculations when actual DOM measurements aren't available -const TYPICAL_LABEL_HEIGHT = 100; - -interface Position { - x: number; - y: number; - transformedX?: number; - transformedY?: number; -} - -export interface Connection { - readonly fromNode: string; - readonly fromPort: number; - readonly toNode: string; - readonly toPort: number; -} - -export interface NodeTitleBar { - readonly title: m.Children; - readonly icon?: string; -} - -export interface NodePort { - readonly content?: m.Children; - readonly direction: 'top' | 'left' | 'right' | 'bottom'; - readonly contextMenuItems?: m.Children; -} - -export type DockedNode = Omit<Node, 'x' | 'y'>; - -export interface Node { - readonly id: string; - readonly x: number; - readonly y: number; - readonly hue?: number; // Color of the title / accent bar (0-360) - readonly accentBar?: boolean; // Optional strip of accent color on the left side (doesn't work well with titleBar) - readonly titleBar?: NodeTitleBar; // Optional title bar (doesn't work well with accentBar or docking) - readonly inputs?: ReadonlyArray<NodePort>; - readonly outputs?: ReadonlyArray<NodePort>; - readonly content?: m.Children; // Optional custom content to render in node body - readonly next?: DockedNode; // Next node in chain - readonly canDockTop?: boolean; - readonly canDockBottom?: boolean; - readonly contextMenuItems?: m.Children; - readonly invalid?: boolean; // Whether this node is in an invalid state - readonly className?: string; // Extra CSS class(es) on the .pf-node element -} - -export interface Label { - readonly id: string; - x: number; - y: number; - width: number; // Width of the label box (user can resize) - content?: m.Children; // Content to render inside the label (optional, defaults to empty) - selectable?: boolean; // Whether clicking the label selects it (default: false, only shift+click works) -} - -interface ConnectingState { - nodeId: string; - portIndex: number; - type: 'input' | 'output'; - portType: 'top' | 'bottom' | 'left' | 'right'; - x: number; - y: number; - transformedX: number; - transformedY: number; -} - -interface UndockCandidate { - nodeId: string; - parentId: string; - startX: number; - startY: number; - renderY: number; -} - -interface UndockedNode { - nodeId: string; - parentId: string; -} - -interface SelectionRect { - startX: number; - startY: number; - currentX: number; - currentY: number; -} - -interface CanvasState { - draggedNode: string | null; - dragOffset: Position; - connecting: ConnectingState | null; - mousePos: Position; - selectedNodes: ReadonlySet<string>; - panOffset: Position; - isPanning: boolean; - panStart: Position; - zoom: number; - dockTarget: string | null; // Node being targeted for docking - isDockZone: boolean; // Whether we're in valid dock position - undockCandidate: UndockCandidate | null; // Tracks potential undock before threshold - undockedNode: UndockedNode | null; // Node that was undocked (set when threshold exceeded) - hoveredPort: { - nodeId: string; - portIndex: number; - type: 'input' | 'output'; - } | null; - selectionRect: SelectionRect | null; // Box selection state - canvasMouseDownPos: Position; - tempNodePositions: Map<string, Position>; // Temporary positions during drag - tempLabelPositions: Map<string, Position>; // Temporary label positions during drag - tempLabelWidths: Map<string, number>; // Temporary label widths during resize - draggedLabel: string | null; // ID of label being dragged - labelDragStartPos: Position | null; // Position where label drag started - resizingLabel: string | null; // ID of label being resized - resizeStartWidth: number; // Width when resize started - resizeStartX: number; // Mouse X position when resize started -} - -export interface NodeGraphApi { - autoLayout: () => void; - recenter: () => void; - findPlacementForNode: (node: Omit<Node, 'x' | 'y'>) => Position; - panBy: (dx: number, dy: number) => void; - /** - * Zooms the canvas by the given delta factor. - * @param deltaZoom - The zoom delta (e.g., 0.1 for 10% zoom in, -0.1 for 10% zoom out) - * @param centerX - X coordinate to zoom around (in viewport space). Defaults to canvas center. - * @param centerY - Y coordinate to zoom around (in viewport space). Defaults to canvas center. - */ - zoomBy: (deltaZoom: number, centerX?: number, centerY?: number) => void; - /** - * Reset the canvas zoom level to the default (1.0) retaining the current - * center point. - */ - resetZoom: () => void; -} - -export interface NodeGraphAttrs { - readonly nodes: ReadonlyArray<Node>; - readonly connections: ReadonlyArray<Connection>; - readonly labels?: ReadonlyArray<Label>; - readonly onConnect?: (connection: Connection) => void; - readonly onNodeMove?: (nodeId: string, x: number, y: number) => void; - readonly onConnectionRemove?: (index: number) => void; - readonly onReady?: (api: NodeGraphApi) => void; - // Selection state and callbacks apply to both nodes and labels. - // selectedNodeIds should contain IDs of both selected nodes and labels. - readonly selectedNodeIds?: ReadonlySet<string>; - // Called when a node or label is selected (replacing current selection). - readonly onNodeSelect?: (nodeId: string) => void; - // Called when a node or label is added to the current selection (multiselect). - readonly onNodeAddToSelection?: (nodeId: string) => void; - // Called when a node or label is removed from the current selection. - readonly onNodeRemoveFromSelection?: (nodeId: string) => void; - readonly onSelectionClear?: () => void; - readonly onDock?: ( - parentId: string, - childNode: Omit<Node, 'x' | 'y'>, - ) => void; - readonly onUndock?: ( - parentId: string, - nodeId: string, - x: number, - y: number, - ) => void; - readonly onNodeRemove?: (nodeId: string) => void; - readonly onLabelMove?: (labelId: string, x: number, y: number) => void; - readonly onLabelResize?: (labelId: string, width: number) => void; - readonly onLabelRemove?: (labelId: string) => void; - readonly hideControls?: boolean; - readonly multiselect?: boolean; // Enable multi-node selection (default: true) - readonly contextMenuOnHover?: boolean; // Show context menu on hover (default: false) - readonly fillHeight?: boolean; - readonly toolbarItems?: m.Children; - readonly style?: Partial<CSSStyleDeclaration>; -} - -const UNDOCK_THRESHOLD = 5; // Pixels to drag before undocking - -function isPortConnected( - nodeId: string, - portType: 'input' | 'output', - portIndex: number, - connections: ReadonlyArray<Connection>, -): boolean { - return connections.some((conn) => { - if (portType === 'input') { - return conn.toNode === nodeId && conn.toPort === portIndex; - } else { - return conn.fromNode === nodeId && conn.fromPort === portIndex; - } - }); -} - -// Get the entire chain starting from a root node -function getChain(rootNode: Node): Array<Node | Omit<Node, 'x' | 'y'>> { - const chain: Array<Node | Omit<Node, 'x' | 'y'>> = [rootNode]; - let current = rootNode.next; - - while (current) { - chain.push(current); - current = current.next; - } - - return chain; -} - -function createCurve( - x1: number, - y1: number, - x2: number, - y2: number, - fromPortType?: 'top' | 'bottom' | 'left' | 'right', - toPortType?: 'top' | 'bottom' | 'left' | 'right', - shortenEnd = 0, -): string { - const dx = x2 - x1; - const dy = y2 - y1; - const distance = Math.sqrt(dx * dx + dy * dy); - - let cx1: number; - let cy1: number; - let cx2: number; - let cy2: number; - - if (shortenEnd > 0) { - if (toPortType === 'bottom') { - y2 += shortenEnd; - } else if (toPortType === 'top') { - y2 -= shortenEnd; - } else if (toPortType === 'left') { - x2 -= shortenEnd; - } else if (toPortType === 'right') { - x2 += shortenEnd; - } - } - - // For top/bottom ports, control points extend vertically - // For left/right ports, control points extend horizontally - if (fromPortType === 'bottom' || fromPortType === 'top') { - // First control point extends vertically - const verticalOffset = Math.max(Math.abs(dy) * 0.5, distance * 0.5); - cx1 = x1; - cy1 = fromPortType === 'bottom' ? y1 + verticalOffset : y1 - verticalOffset; - } else { - // First control point extends horizontally for left/right ports - const horizontalOffset = Math.max(Math.abs(dx) * 0.5, distance * 0.5); - cx1 = x1 + horizontalOffset; - cy1 = y1; // Keep Y constant for horizontal extension - } - - if (toPortType === 'bottom' || toPortType === 'top') { - // Second control point extends vertically - const verticalOffset = Math.max(Math.abs(dy) * 0.5, distance * 0.5); - cx2 = x2; - cy2 = toPortType === 'bottom' ? y2 + verticalOffset : y2 - verticalOffset; - } else { - // Second control point extends horizontally for left/right ports - const horizontalOffset = Math.max(Math.abs(dx) * 0.5, distance * 0.5); - cx2 = x2 - horizontalOffset; - cy2 = y2; // Keep Y constant for horizontal extension - } - - // if (shortenEnd > 0) { - // const tangentX = x2 - cx2; - // const tangentY = y2 - cy2; - // const tangentLength = Math.sqrt(tangentX * tangentX + tangentY * tangentY); - // if (tangentLength > shortenEnd) { - // const unitTangentX = tangentX / tangentLength; - // const unitTangentY = tangentY / tangentLength; - // x2 -= unitTangentX * shortenEnd; - // y2 -= unitTangentY * shortenEnd; - // } - // } - - return `M ${x1} ${y1} C ${cx1} ${cy1}, ${cx2} ${cy2}, ${x2} ${y2}`; -} - -export function NodeGraph(): m.Component<NodeGraphAttrs> { - const canvasState: CanvasState = { - draggedNode: null, - dragOffset: {x: 0, y: 0}, - connecting: null, - mousePos: {x: 0, y: 0}, - selectedNodes: new Set<string>(), - panOffset: {x: 0, y: 0}, - isPanning: false, - panStart: {x: 0, y: 0}, - zoom: 1.0, - dockTarget: null, - isDockZone: false, - undockCandidate: null, - undockedNode: null, - hoveredPort: null, - selectionRect: null, - canvasMouseDownPos: {x: 0, y: 0}, - tempNodePositions: new Map<string, Position>(), - tempLabelPositions: new Map<string, Position>(), - tempLabelWidths: new Map<string, number>(), - draggedLabel: null, - labelDragStartPos: null, - resizingLabel: null, - resizeStartWidth: 0, - resizeStartX: 0, - }; - - // Track drag state for batching updates - let dragStartPosition: {nodeId: string; x: number; y: number} | null = null; - let currentDragPosition: {x: number; y: number} | null = null; - - let latestVnode: m.Vnode<NodeGraphAttrs> | null = null; - let canvasElement: HTMLElement | null = null; - - // Unique instance ID for SVG marker references. Multiple NodeGraph instances - // (e.g. in different tabs) each create <marker id="..."> elements. Without - // unique IDs, url(#arrowhead) resolves to the first matching marker in - // document order, which may be inside a hidden tab (display:none). - const instanceId = shortUuid(); - - // Shared pan function used by both internal handlers and external API - const panBy = (dx: number, dy: number) => { - canvasState.panOffset.x += dx; - canvasState.panOffset.y += dy; - }; - - // Shared zoom function used by both internal handlers and external API - // Zooms around the center of the canvas (or specified point) - const zoomBy = (deltaZoom: number, centerX?: number, centerY?: number) => { - if (!canvasElement) return; - const canvas = canvasElement; - const canvasRect = canvas.getBoundingClientRect(); - - // Use center of canvas if no center point specified - const zoomX = centerX ?? canvasRect.width / 2; - const zoomY = centerY ?? canvasRect.height / 2; - - const newZoom = Math.max( - 0.1, - Math.min(5.0, canvasState.zoom * (1 + deltaZoom)), - ); - - // Calculate the point in canvas space (before zoom) - const canvasX = (zoomX - canvasState.panOffset.x) / canvasState.zoom; - const canvasY = (zoomY - canvasState.panOffset.y) / canvasState.zoom; - - // Update zoom - canvasState.zoom = newZoom; - - // Adjust pan to keep the same point under the zoom center - canvasState.panOffset = { - x: zoomX - canvasX * newZoom, - y: zoomY - canvasY * newZoom, - }; - }; - - // API functions that are exposed to parent components via onReady callback - // These are initialized in oncreate and can be used in subsequent lifecycle hooks - let autoLayoutApi: (() => void) | null = null; - let recenterApi: (() => void) | null = null; - let resetZoom: (() => void) | null = null; - let findPlacementForNodeApi: - | ((newNode: Omit<Node, 'x' | 'y'>) => Position) - | null = null; - - const handleMouseMove = (e: PointerEvent) => { - m.redraw(); - if (!latestVnode || !canvasElement) return; - const vnode = latestVnode; - const canvas = canvasElement; - const canvasRect = canvas.getBoundingClientRect(); - - // Store both screen and transformed coordinates - canvasState.mousePos = { - x: e.clientX, - y: e.clientY, - transformedX: - (e.clientX - canvasRect.left - canvasState.panOffset.x) / - canvasState.zoom, - transformedY: - (e.clientY - canvasRect.top - canvasState.panOffset.y) / - canvasState.zoom, - }; - - // Track hovered port (useful for connection snapping and visual feedback) - const portElement = (e.target as HTMLElement).closest('.pf-port.pf-input'); - if (portElement) { - const nodeElement = portElement.closest( - '[data-node]', - ) as HTMLElement | null; - const portId = - portElement.getAttribute('data-port') || - portElement.parentElement?.getAttribute('data-port'); - - if (nodeElement && portId) { - const nodeId = nodeElement.dataset.node!; - const [type, portIndexStr] = portId.split('-'); - if (type === 'input') { - const portIndex = parseInt(portIndexStr, 10); - canvasState.hoveredPort = {nodeId, portIndex, type: 'input'}; - } else { - canvasState.hoveredPort = null; - } - } else { - canvasState.hoveredPort = null; - } - } else { - canvasState.hoveredPort = null; - } - - if (canvasState.selectionRect) { - // Update selection rectangle - canvasState.selectionRect.currentX = - canvasState.mousePos.transformedX ?? 0; - canvasState.selectionRect.currentY = - canvasState.mousePos.transformedY ?? 0; - m.redraw(); - } else if (canvasState.draggedLabel !== null) { - // Handle label dragging - store temp position, don't call callback yet - const newX = - (canvasState.mousePos.transformedX ?? 0) - canvasState.dragOffset.x; - const newY = - (canvasState.mousePos.transformedY ?? 0) - canvasState.dragOffset.y; - - // Store temporary position during drag - canvasState.tempLabelPositions.set(canvasState.draggedLabel, { - x: newX, - y: newY, - }); - m.redraw(); - } else if (canvasState.resizingLabel !== null) { - // Handle label resizing - store temp width, don't call callback yet - const currentX = canvasState.mousePos.transformedX ?? 0; - const deltaX = currentX - canvasState.resizeStartX; - const newWidth = Math.max(100, canvasState.resizeStartWidth + deltaX); - - // Store temporary width during resize - canvasState.tempLabelWidths.set(canvasState.resizingLabel, newWidth); - m.redraw(); - } else if (canvasState.isPanning) { - // Pan the canvas - const dx = e.clientX - canvasState.panStart.x; - const dy = e.clientY - canvasState.panStart.y; - panBy(dx, dy); - canvasState.panStart = {x: e.clientX, y: e.clientY}; - m.redraw(); - } else if (canvasState.undockCandidate !== null) { - // Check if we've exceeded the undock threshold - const dx = e.clientX - canvasState.undockCandidate.startX; - const dy = e.clientY - canvasState.undockCandidate.startY; - const distance = Math.sqrt(dx * dx + dy * dy); - - if (distance > UNDOCK_THRESHOLD) { - // Exceeded threshold - call onUndock immediately so node becomes independent - const {onUndock} = vnode.attrs; - const tempX = - (canvasState.undockCandidate.startX - - canvasRect.left - - canvasState.panOffset.x) / - canvasState.zoom - - canvasState.dragOffset.x / canvasState.zoom; - const tempY = canvasState.undockCandidate.renderY; - - // Store temp position for this node - canvasState.tempNodePositions.set(canvasState.undockCandidate.nodeId, { - x: tempX, - y: tempY, - }); - - // Immediately call onUndock so the node becomes independent - if (onUndock) { - onUndock( - canvasState.undockCandidate.parentId, - canvasState.undockCandidate.nodeId, - tempX, - tempY, - ); - } - - // Mark as undocked so we track it as a regular drag now - canvasState.undockedNode = { - nodeId: canvasState.undockCandidate.nodeId, - parentId: canvasState.undockCandidate.parentId, - }; - - canvasState.undockCandidate = null; - m.redraw(); // Force update so nodes array regenerates - } - } else if (canvasState.draggedNode !== null) { - // Calculate new position relative to canvas container (accounting for pan and zoom) - const newX = - (e.clientX - canvasRect.left - canvasState.panOffset.x) / - canvasState.zoom - - canvasState.dragOffset.x / canvasState.zoom; - const newY = - (e.clientY - canvasRect.top - canvasState.panOffset.y) / - canvasState.zoom - - canvasState.dragOffset.y / canvasState.zoom; - - // Store current position internally - currentDragPosition = {x: newX, y: newY}; - canvasState.tempNodePositions.set(canvasState.draggedNode, { - x: newX, - y: newY, - }); - - // Check if we're in a dock zone (exclude the parent we just undocked from) - const {nodes} = vnode.attrs; - const draggedNode = nodes.find((n) => n.id === canvasState.draggedNode); - if (draggedNode) { - const dockInfo = findDockTarget(draggedNode, newX, newY, nodes); - canvasState.dockTarget = dockInfo.targetNodeId; - canvasState.isDockZone = dockInfo.isValidZone; - } - m.redraw(); - } - }; - - const handleMouseUp = () => { - if (!latestVnode) return; - const vnode = latestVnode; - - // Handle box selection completion - if (canvasState.selectionRect) { - const {nodes = [], labels = []} = vnode.attrs; - const rect = canvasState.selectionRect; - const minX = Math.min(rect.startX, rect.currentX); - const maxX = Math.max(rect.startX, rect.currentX); - const minY = Math.min(rect.startY, rect.currentY); - const maxY = Math.max(rect.startY, rect.currentY); - - // Helper to check if a node at given position overlaps with selection rectangle - const nodeOverlapsRect = ( - nodeX: number, - nodeY: number, - nodeId: string, - ): boolean => { - const dims = getNodeDimensions(nodeId); - const nodeRight = nodeX + dims.width; - const nodeBottom = nodeY + dims.height; - - return ( - nodeX < maxX && nodeRight > minX && nodeY < maxY && nodeBottom > minY - ); - }; - - // Helper to check if a label overlaps with selection rectangle - const labelOverlapsRect = (label: Label): boolean => { - const labelRight = label.x + label.width; - const labelBottom = label.y + DEFAULT_LABEL_MIN_HEIGHT; - - return ( - label.x < maxX && - labelRight > minX && - label.y < maxY && - labelBottom > minY - ); - }; - - // Find all nodes (including chained/docked nodes) that intersect with the selection rectangle - const selectedInRect: string[] = []; - nodes.forEach((node) => { - // Check root node - if (nodeOverlapsRect(node.x, node.y, node.id)) { - selectedInRect.push(node.id); - } - - // Check all chained nodes - const chain = getChain(node); - let currentY = node.y; - chain.slice(1).forEach((chainNode) => { - // For chained nodes, calculate their Y position - const previousNodeId = chain[chain.indexOf(chainNode) - 1].id; - currentY += getNodeDimensions(previousNodeId).height; - - if (nodeOverlapsRect(node.x, currentY, chainNode.id)) { - selectedInRect.push(chainNode.id); - } - }); - }); - - // Find all labels that intersect with the selection rectangle - labels.forEach((label) => { - if (labelOverlapsRect(label)) { - selectedInRect.push(label.id); - } - }); - - // Add all selected nodes and labels to selection - const {onNodeAddToSelection} = vnode.attrs; - selectedInRect.forEach((id) => { - if (!canvasState.selectedNodes.has(id)) { - if (onNodeAddToSelection !== undefined) { - onNodeAddToSelection(id); - } - } - }); - - canvasState.selectionRect = null; - m.redraw(); - return; - } - - // Handle docking if in dock zone - if ( - canvasState.draggedNode && - canvasState.isDockZone && - canvasState.dockTarget - ) { - const {nodes = [], onDock} = vnode.attrs; - const draggedNode = nodes.find((n) => n.id === canvasState.draggedNode); - if (onDock && draggedNode) { - // Create child node without x/y coordinates - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const {x, y, ...childNode} = draggedNode; - onDock(canvasState.dockTarget, childNode); - } - } - - // Check for collision and finalize drag (only for non-docked/undocked nodes) - if (canvasState.draggedNode !== null && !canvasState.isDockZone) { - const {nodes = [], onNodeMove} = vnode.attrs; - const draggedNode = nodes.find((n) => n.id === canvasState.draggedNode); - - // Only do overlap checking if NOT being docked - if (draggedNode) { - // Get actual node dimensions from DOM - const dims = getNodeDimensions(draggedNode.id); - - // Calculate total height of the dragged node's chain - const chain = getChain(draggedNode); - let chainHeight = 0; - chain.forEach((chainNode) => { - chainHeight += getNodeDimensions(chainNode.id).height; - }); - - // Check if node (and its entire chain) overlaps with any other nodes - if ( - currentDragPosition && - checkNodeOverlap( - currentDragPosition.x, - currentDragPosition.y, - draggedNode.id, - nodes, - dims.width, - chainHeight, - ) - ) { - // Find nearest non-overlapping position - const newPos = findNearestNonOverlappingPosition( - currentDragPosition.x, - currentDragPosition.y, - draggedNode.id, - nodes, - dims.width, - chainHeight, - ); - // Update to the non-overlapping position - currentDragPosition = newPos; - canvasState.tempNodePositions.set(draggedNode.id, newPos); - } - } - - // Call onNodeMove with final position if it changed - // For undocked nodes, this provides the final position after dragging - // For regular nodes, this is the only position update - if (onNodeMove !== undefined && currentDragPosition !== null) { - const startX = dragStartPosition?.x ?? 0; - const startY = dragStartPosition?.y ?? 0; - const moved = - Math.abs(currentDragPosition.x - startX) > 0.5 || - Math.abs(currentDragPosition.y - startY) > 0.5; - if (moved || canvasState.undockedNode !== null) { - onNodeMove( - canvasState.draggedNode, - currentDragPosition.x, - currentDragPosition.y, - ); - } - } - } - - // Handle label callbacks with final values - const {onLabelMove, onLabelResize} = vnode.attrs; - - if (canvasState.draggedLabel !== null) { - const finalPos = canvasState.tempLabelPositions.get( - canvasState.draggedLabel, - ); - if (finalPos && onLabelMove) { - onLabelMove(canvasState.draggedLabel, finalPos.x, finalPos.y); - } - } - - if (canvasState.resizingLabel !== null) { - const finalWidth = canvasState.tempLabelWidths.get( - canvasState.resizingLabel, - ); - if (finalWidth !== undefined && onLabelResize) { - onLabelResize(canvasState.resizingLabel, finalWidth); - } - } - - // Cleanup label state - canvasState.draggedLabel = null; - canvasState.labelDragStartPos = null; - canvasState.resizingLabel = null; - canvasState.tempLabelPositions.clear(); - canvasState.tempLabelWidths.clear(); - - canvasState.draggedNode = null; - dragStartPosition = null; - currentDragPosition = null; - canvasState.connecting = null; - canvasState.hoveredPort = null; - canvasState.isPanning = false; - canvasState.dockTarget = null; - canvasState.isDockZone = false; - canvasState.undockCandidate = null; - canvasState.undockedNode = null; - canvasState.tempNodePositions.clear(); - m.redraw(); - }; - - // Helper to determine port type based on port index - function getPortType( - nodeId: string, - portType: 'input' | 'output', - portIndex: number, - nodes: ReadonlyArray<Node>, - ): 'top' | 'bottom' | 'left' | 'right' { - // Search in main nodes array - let node: Node | Omit<Node, 'x' | 'y'> | undefined = nodes.find( - (n) => n.id === nodeId, - ); - - // If not found, search in the next chains of all nodes - if (!node) { - for (const rootNode of nodes) { - let current = rootNode.next; - while (current) { - if (current.id === nodeId) { - node = current; - break; - } - current = current.next; - } - if (node) break; - } - } - - if (!node) return portType === 'input' ? 'left' : 'right'; - - // Get the port from the node - const ports = portType === 'input' ? node.inputs : node.outputs; - if (!ports || portIndex >= ports.length) { - return portType === 'input' ? 'left' : 'right'; - } - - return ports[portIndex].direction; - } - - function renderConnections( - svg: SVGElement, - connections: ReadonlyArray<Connection>, - nodes: ReadonlyArray<Node>, - onConnectionRemove?: (index: number) => void, - ) { - const shortenLength = 16; - const arrowheadLength = 4; - - // Cache all port positions at once for performance - const portPositionCache = new Map<string, Position>(); - - // Query ports within this NodeGraph instance only (not globally). - // Using document.querySelectorAll would pick up ports from other - // NodeGraph instances (e.g. hidden tabs), causing incorrect positions. - const container = assertExists(canvasElement); - const allPorts = container.querySelectorAll('.pf-port[data-port]'); - allPorts.forEach((portElement) => { - const portId = portElement.getAttribute('data-port'); - if (!portId) return; - - const nodeElement = portElement.closest( - '[data-node]', - ) as HTMLElement | null; - if (!nodeElement) return; - - const nodeId = nodeElement.dataset.node; - if (!nodeId) return; - - const [portType, portIndexStr] = portId.split('-'); - const cacheKey = `${nodeId}-${portType}-${portIndexStr}`; - - // Calculate position - const chainContainer = nodeElement.closest( - '.pf-node-wrapper', - ) as HTMLElement | null; - - let nodeLeft: number; - let nodeTop: number; - - if (chainContainer) { - // Node is in a dock chain - use container's position - nodeLeft = parseFloat(chainContainer.style.left) || 0; - nodeTop = parseFloat(chainContainer.style.top) || 0; - - // Add offset of node within the chain - const chainRect = chainContainer.getBoundingClientRect(); - const nodeRect = nodeElement.getBoundingClientRect(); - const offsetY = (nodeRect.top - chainRect.top) / canvasState.zoom; - - nodeTop += offsetY; - } else { - // Standalone node - use its position directly - nodeLeft = parseFloat(nodeElement.style.left) || 0; - nodeTop = parseFloat(nodeElement.style.top) || 0; - } - - // Get port's position relative to the node - const portRect = portElement.getBoundingClientRect(); - const nodeRect = nodeElement.getBoundingClientRect(); - - // Calculate offset in screen space, then divide by zoom to get canvas content space - const portX = - (portRect.left - nodeRect.left + portRect.width / 2) / canvasState.zoom; - const portY = - (portRect.top - nodeRect.top + portRect.height / 2) / canvasState.zoom; - - portPositionCache.set(cacheKey, { - x: nodeLeft + portX, - y: nodeTop + portY, - }); - }); - - // Helper function to get port position from cache or fallback to direct lookup - const getPortPos = ( - nodeId: string, - portType: 'input' | 'output', - portIndex: number, - ): Position => { - const cacheKey = `${nodeId}-${portType}-${portIndex}`; - return ( - portPositionCache.get(cacheKey) || - getPortPosition(nodeId, portType, portIndex) - ); - }; - - // Build arrowhead markers using mithril - const arrowheadMarker = (id: string) => - m( - 'marker', - { - id, - viewBox: `0 0 ${arrowheadLength} 10`, - refX: '0', - refY: '5', - markerWidth: `${arrowheadLength}`, - markerHeight: '10', - orient: 'auto', - }, - m('polygon', { - points: `0 2.5, ${arrowheadLength} 5, 0 7.5`, - fill: 'context-stroke', - }), - ); - - // Build connection paths using mithril - // Each connection is rendered as two paths: a wider invisible hitbox and the visible line - const connectionPaths = connections - .map((conn, idx) => { - const from = getPortPos(conn.fromNode, 'output', conn.fromPort); - const to = getPortPos(conn.toNode, 'input', conn.toPort); - - // Validate that both ports exist (return {x: 0, y: 0} if not found) - const fromValid = from.x !== 0 || from.y !== 0; - const toValid = to.x !== 0 || to.y !== 0; - - if (!fromValid || !toValid) { - console.warn( - `Invalid connection: ${conn.fromNode}:${conn.fromPort} -> ${conn.toNode}:${conn.toPort}`, - !fromValid ? `(source port not found)` : `(target port not found)`, - ); - return null; - } - - const fromPortType = getPortType( - conn.fromNode, - 'output', - conn.fromPort, - nodes, - ); - const toPortType = getPortType( - conn.toNode, - 'input', - conn.toPort, - nodes, - ); - - const pathData = createCurve( - from.x, - from.y, - to.x, - to.y, - fromPortType, - toPortType, - shortenLength, - ); - - const handlePointerDown = (e: PointerEvent) => { - e.stopPropagation(); - e.preventDefault(); - }; - - const handleClick = (e: Event) => { - e.stopPropagation(); - if (onConnectionRemove !== undefined) { - onConnectionRemove(idx); - } - }; - - // Return a group with both the hitbox and visible path - return m('g', {key: `conn-${idx}`, class: 'pf-connection-group'}, [ - // Invisible wider hitbox path - m('path', { - d: pathData, - class: 'pf-connection-hitbox', - style: { - stroke: 'transparent', - strokeWidth: '20', - fill: 'none', - pointerEvents: 'stroke', - cursor: 'pointer', - }, - onpointerdown: handlePointerDown, - onclick: handleClick, - }), - // Visible connection path - m('path', { - 'd': pathData, - 'class': 'pf-connection', - 'marker-end': `url(#arrowhead-${instanceId})`, - 'style': { - pointerEvents: 'none', - }, - 'onpointerdown': handlePointerDown, - 'onclick': handleClick, - }), - ]); - }) - .filter((path) => path !== null); - - // Build temp connection if connecting - let tempConnectionPath = null; - if (canvasState.connecting) { - const fromX = canvasState.connecting.transformedX; - const fromY = canvasState.connecting.transformedY; - let toX = canvasState.mousePos.transformedX ?? 0; - let toY = canvasState.mousePos.transformedY ?? 0; - - const fromPortType = canvasState.connecting.portType; - let toPortType: 'top' | 'left' | 'right' | 'bottom' = - fromPortType === 'top' || fromPortType === 'bottom' ? 'top' : 'left'; - - if ( - canvasState.hoveredPort && - canvasState.connecting.type === 'output' && - canvasState.hoveredPort.type === 'input' - ) { - const {nodeId, portIndex, type} = canvasState.hoveredPort; - const hoverPos = getPortPos(nodeId, type, portIndex); - if (hoverPos.x !== 0 || hoverPos.y !== 0) { - toX = hoverPos.x; - toY = hoverPos.y; - toPortType = getPortType(nodeId, type, portIndex, nodes); - } - } - - tempConnectionPath = m('path', { - 'class': 'pf-temp-connection', - 'd': createCurve( - fromX, - fromY, - toX, - toY, - fromPortType, - toPortType, - shortenLength, - ), - 'marker-end': `url(#arrowhead-${instanceId})`, - }); - } - - // Render everything using mithril's render function. - // Use instance-unique marker ID to avoid conflicts when multiple - // NodeGraph instances exist in the document (e.g. tabs). - const markerId = `arrowhead-${instanceId}`; - m.render(svg, [ - m('defs', [arrowheadMarker(markerId)]), - m('g', connectionPaths), - tempConnectionPath, - ]); - } - - function getPortPosition( - nodeId: string, - portType: 'input' | 'output', - portIndex: number, - ): Position { - // For port index 0 (top/bottom), data-port is on .pf-port itself - // For port index 1+ (left/right), data-port is on .pf-port-row wrapper - const selector = - portIndex === 0 - ? `[data-node="${nodeId}"] .pf-port[data-port="${portType}-${portIndex}"]` - : `[data-node="${nodeId}"] [data-port="${portType}-${portIndex}"] .pf-port`; - - // Scope to this NodeGraph instance to avoid matching elements from other - // instances (e.g. hidden tabs with the same node IDs). - const scope = assertExists(canvasElement); - const portElement = scope.querySelector(selector); - - if (portElement) { - const nodeElement = portElement.closest('.pf-node') as HTMLElement | null; - if (nodeElement !== null) { - // Check if node is in a dock chain (flexbox positioning) - const chainContainer = nodeElement.closest( - '.pf-node-wrapper', - ) as HTMLElement | null; - - let nodeLeft: number; - let nodeTop: number; - - if (chainContainer) { - // Node is in a dock chain - use container's position - nodeLeft = parseFloat(chainContainer.style.left) || 0; - nodeTop = parseFloat(chainContainer.style.top) || 0; - - // Add offset of node within the chain - const chainRect = chainContainer.getBoundingClientRect(); - const nodeRect = nodeElement.getBoundingClientRect(); - const offsetY = (nodeRect.top - chainRect.top) / canvasState.zoom; - - nodeTop += offsetY; - } else { - // Standalone node - use its position directly - nodeLeft = parseFloat(nodeElement.style.left) || 0; - nodeTop = parseFloat(nodeElement.style.top) || 0; - } - - // Get port's position relative to the node - const portRect = portElement.getBoundingClientRect(); - const nodeRect = nodeElement.getBoundingClientRect(); - - // Calculate offset in screen space, then divide by zoom to get canvas content space - const portX = - (portRect.left - nodeRect.left + portRect.width / 2) / - canvasState.zoom; - const portY = - (portRect.top - nodeRect.top + portRect.height / 2) / - canvasState.zoom; - - return { - x: nodeLeft + portX, - y: nodeTop + portY, - }; - } - } - - return {x: 0, y: 0}; - } - - // Find if dragged node is in dock zone of any node - function findDockTarget( - draggedNode: Node, - draggedX: number, - draggedY: number, - nodes: ReadonlyArray<Node>, - ): {targetNodeId: string | null; isValidZone: boolean} { - const DOCK_DISTANCE = 30; - const HORIZONTAL_TOLERANCE = 100; - - // Check if dragged node can be docked at the top - if (!draggedNode.canDockTop) { - return {targetNodeId: null, isValidZone: false}; - } - - const draggedPos = {x: draggedX, y: draggedY}; - - for (const node of nodes) { - if (node.id === draggedNode.id) continue; - - // Find the last node in this chain - let lastInChain: Node | Omit<Node, 'x' | 'y'> = node; - while (lastInChain.next) { - lastInChain = lastInChain.next; - } - - // Check if last node in chain allows docking below it - if (!lastInChain.canDockBottom) { - continue; // Skip this node as a dock target - } - - const nodePos = {x: node.x, y: node.y}; - const lastDims = getNodeDimensions(lastInChain.id); - - // Calculate position of last node in chain - let chainHeight = 0; - let current: Node | Omit<Node, 'x' | 'y'> = node; - while (current !== lastInChain) { - chainHeight += getNodeDimensions(current.id).height; - current = current.next!; - } - - const nodeBottom = nodePos.y + chainHeight + lastDims.height; - - const verticalDist = draggedPos.y - nodeBottom; - const isBelow = verticalDist >= -10 && verticalDist <= DOCK_DISTANCE; - - const draggedDims = getNodeDimensions(draggedNode.id); - const nodeDims = getNodeDimensions(node.id); - const horizontalDist = Math.abs( - nodePos.x + nodeDims.width / 2 - (draggedPos.x + draggedDims.width / 2), - ); - const isAligned = horizontalDist <= HORIZONTAL_TOLERANCE; - - if (isBelow && isAligned) { - // Return the ID of the LAST node in the chain - return {targetNodeId: lastInChain.id, isValidZone: true}; - } - } - - return {targetNodeId: null, isValidZone: false}; - } - - function getNodeDimensions(nodeId: string): {width: number; height: number} { - const scope = assertExists(canvasElement); - const nodeElement = scope.querySelector(`[data-node="${nodeId}"]`); - if (nodeElement) { - const rect = nodeElement.getBoundingClientRect(); - // Divide by zoom to get canvas content space dimensions - return { - width: rect.width / canvasState.zoom, - height: rect.height / canvasState.zoom, - }; - } - // Fallback if DOM element not found - return {width: 180, height: 100}; - } - - function checkNodeOverlap( - x: number, - y: number, - nodeId: string, - nodes: ReadonlyArray<Node>, - nodeWidth: number, - nodeHeight: number, - ): boolean { - const padding = 10; - - for (const node of nodes) { - if (node.id === nodeId) continue; // Don't check against self - - // Get dimensions of the node we're checking against - const otherDims = getNodeDimensions(node.id); - - // Calculate total height of the other node's chain - const chain = getChain(node); - let otherChainHeight = 0; - chain.forEach((chainNode) => { - otherChainHeight += getNodeDimensions(chainNode.id).height; - }); - - const overlaps = !( - x + nodeWidth + padding < node.x || - x > node.x + otherDims.width + padding || - y + nodeHeight + padding < node.y || - y > node.y + otherChainHeight + padding - ); - - if (overlaps) return true; - } - return false; - } - - function findNearestNonOverlappingPosition( - startX: number, - startY: number, - nodeId: string, - nodes: ReadonlyArray<Node>, - nodeWidth: number, - nodeHeight: number, - ): Position { - // If no overlap at current position, return it - if ( - !checkNodeOverlap(startX, startY, nodeId, nodes, nodeWidth, nodeHeight) - ) { - return {x: startX, y: startY}; - } - - // Search in a spiral pattern for a non-overlapping position - const step = 20; // Step size for searching - const maxRadius = 500; // Maximum search radius - - for (let radius = step; radius <= maxRadius; radius += step) { - // Try positions in a circle around the original position - const numSteps = Math.ceil((2 * Math.PI * radius) / step); - - for (let i = 0; i < numSteps; i++) { - const angle = (2 * Math.PI * i) / numSteps; - const x = Math.round(startX + radius * Math.cos(angle)); - const y = Math.round(startY + radius * Math.sin(angle)); - - if (!checkNodeOverlap(x, y, nodeId, nodes, nodeWidth, nodeHeight)) { - return {x, y}; - } - } - } - - // Fallback: return original position if no free space found - return {x: startX, y: startY}; - } - - function getNodesBoundingBox( - nodes: ReadonlyArray<Node>, - includeChains: boolean, - ): {minX: number; minY: number; maxX: number; maxY: number} { - if (nodes.length === 0) { - return {minX: 0, minY: 0, maxX: 0, maxY: 0}; - } - - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; - - nodes.forEach((node) => { - const dims = getNodeDimensions(node.id); - minX = Math.min(minX, node.x); - minY = Math.min(minY, node.y); - maxX = Math.max(maxX, node.x + dims.width); - - if (includeChains) { - const chain = getChain(node); - let chainHeight = 0; - chain.forEach((chainNode) => { - const chainDims = getNodeDimensions(chainNode.id); - chainHeight += chainDims.height; - }); - maxY = Math.max(maxY, node.y + chainHeight); - } else { - maxY = Math.max(maxY, node.y + dims.height); - } - }); - - return {minX, minY, maxX, maxY}; - } - - // Helper to perform auto-layout - function autoLayoutGraph( - nodes: ReadonlyArray<Node>, - connections: ReadonlyArray<Connection>, - onNodeMove: ((nodeId: string, x: number, y: number) => void) | undefined, - ) { - // Build a map from any node ID (including nodes in chains) to its root node ID - const nodeIdToRootId = new Map<string, string>(); - nodes.forEach((node) => { - nodeIdToRootId.set(node.id, node.id); - const chain = getChain(node); - chain.slice(1).forEach((chainNode) => { - nodeIdToRootId.set(chainNode.id, node.id); - }); - }); - - // Find root nodes (nodes with no incoming connections) - // Count connections to any node in a chain as connections to the root - const incomingCounts = new Map<string, number>(); - nodes.forEach((node) => incomingCounts.set(node.id, 0)); - connections.forEach((conn) => { - const rootId = nodeIdToRootId.get(conn.toNode) ?? conn.toNode; - const currentCount = incomingCounts.get(rootId) ?? 0; - incomingCounts.set(rootId, currentCount + 1); - }); - - const rootNodes = nodes.filter((node) => incomingCounts.get(node.id) === 0); - const visited = new Set<string>(); - const layers: string[][] = []; - - // BFS to assign nodes to layers - const queue: Array<{id: string; layer: number}> = rootNodes.map((n) => ({ - id: n.id, - layer: 0, - })); - - while (queue.length > 0) { - const {id, layer} = queue.shift()!; - if (visited.has(id)) continue; - visited.add(id); - - if (layers[layer] === undefined) layers[layer] = []; - layers[layer].push(id); - - // Add connected nodes to next layer - // If connection goes to a node in a chain, add the root node - connections - .filter((conn) => { - // Check if this node or any node in its chain is the source - const node = nodes.find((n) => n.id === id); - if (!node) return false; - const chain = getChain(node); - return chain.some((chainNode) => chainNode.id === conn.fromNode); - }) - .forEach((conn) => { - const rootId = nodeIdToRootId.get(conn.toNode) ?? conn.toNode; - if (!visited.has(rootId)) { - queue.push({id: rootId, layer: layer + 1}); - } - }); - } - - // Position nodes using actual DOM dimensions - const layerSpacing = 50; // Horizontal spacing between layers - let currentX = 50; // Start position - - layers.forEach((layer) => { - // Find the widest node in this layer (considering entire chains) - let maxWidth = 0; - layer.forEach((nodeId) => { - const node = nodes.find((n) => n.id === nodeId); - if (node) { - // Check width of all nodes in the chain - const chain = getChain(node); - chain.forEach((chainNode) => { - const chainDims = getNodeDimensions(chainNode.id); - maxWidth = Math.max(maxWidth, chainDims.width); - }); - } - }); - - // Position each node in this layer - let currentY = 50; - layer.forEach((nodeId) => { - const node = nodes.find((n) => n.id === nodeId); - if (node && onNodeMove) { - onNodeMove(node.id, currentX, currentY); - - // Calculate height of entire chain - const chain = getChain(node); - let chainHeight = 0; - chain.forEach((chainNode) => { - const dims = getNodeDimensions(chainNode.id); - chainHeight += dims.height; - }); - - currentY += chainHeight + 30; - } - }); - - // Move to next layer - currentX += maxWidth + layerSpacing; - }); - - m.redraw(); - } - - function autofit( - nodes: ReadonlyArray<Node>, - labels: ReadonlyArray<Label>, - canvas: HTMLElement, - ) { - if (nodes.length === 0 && labels.length === 0) return; - - // Initialize bounding box - // If we have nodes, start with their bounding box - // If we only have labels, initialize with Infinity values that will be replaced - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; - - if (nodes.length > 0) { - const nodesBBox = getNodesBoundingBox(nodes, true); - minX = nodesBBox.minX; - minY = nodesBBox.minY; - maxX = nodesBBox.maxX; - maxY = nodesBBox.maxY; - } - - // Include labels in bounding box calculation - labels.forEach((label) => { - minX = Math.min(minX, label.x); - minY = Math.min(minY, label.y); - maxX = Math.max(maxX, label.x + label.width); - maxY = Math.max(maxY, label.y + TYPICAL_LABEL_HEIGHT); - }); - - // Calculate bounding box dimensions - const boundingWidth = maxX - minX; - const boundingHeight = maxY - minY; - - // Get canvas dimensions - const canvasRect = canvas.getBoundingClientRect(); - - // Calculate zoom to fit with buffer (10% padding) - const bufferFactor = 0.9; // Use 90% of viewport to leave 10% buffer - const zoomX = (canvasRect.width * bufferFactor) / boundingWidth; - const zoomY = (canvasRect.height * bufferFactor) / boundingHeight; - const newZoom = Math.max(0.1, Math.min(1.0, Math.min(zoomX, zoomY))); - - // Calculate the scaled bounding box dimensions - const scaledWidth = boundingWidth * newZoom; - const scaledHeight = boundingHeight * newZoom; - - // Calculate pan offset to center the bounding box with equal padding on all sides - const paddingX = (canvasRect.width - scaledWidth) / 2; - const paddingY = (canvasRect.height - scaledHeight) / 2; - - canvasState.zoom = newZoom; - canvasState.panOffset = { - x: paddingX - minX * newZoom, - y: paddingY - minY * newZoom, - }; - - m.redraw(); - } - - const handleWheel = (e: WheelEvent) => { - if (!canvasElement) return; - e.preventDefault(); - - // Zoom with Ctrl+wheel, pan without Ctrl - if (e.ctrlKey || e.metaKey) { - // Zoom around mouse position - const canvas = canvasElement; - const canvasRect = canvas.getBoundingClientRect(); - const mouseX = e.clientX - canvasRect.left; - const mouseY = e.clientY - canvasRect.top; - - // Calculate zoom delta (negative deltaY = zoom in) - const zoomDelta = -e.deltaY * 0.003; - const newZoom = Math.max( - 0.1, - Math.min(5.0, canvasState.zoom * (1 + zoomDelta)), - ); - - // Calculate the point in canvas space (before zoom) - const canvasX = (mouseX - canvasState.panOffset.x) / canvasState.zoom; - const canvasY = (mouseY - canvasState.panOffset.y) / canvasState.zoom; - - // Update zoom - canvasState.zoom = newZoom; - - // Adjust pan to keep the same point under the mouse - canvasState.panOffset = { - x: mouseX - canvasX * newZoom, - y: mouseY - canvasY * newZoom, - }; - } else { - // Pan the canvas based on wheel delta - panBy(-e.deltaX, -e.deltaY); - } - - m.redraw(); - }; - - // Helper function to render a single node - function renderNode( - node: Node | Omit<Node, 'x' | 'y'>, - vnode: m.Vnode<NodeGraphAttrs>, - options: { - isDockedChild: boolean; - hasDockedChild: boolean; - isDockTarget: boolean; - rootNode?: Node; - multiselect: boolean; - contextMenuOnHover: boolean; - }, - ): m.Vnode { - const { - id, - inputs = [], - outputs = [], - titleBar, - content, - hue, - accentBar, - contextMenuItems, - invalid, - className: nodeClassName, - } = node; - const { - isDockedChild, - hasDockedChild, - isDockTarget, - rootNode, - multiselect, - contextMenuOnHover, - } = options; - const {connections = [], onConnect, nodes = []} = vnode.attrs; - - // Separate ports by direction - const topInputs = inputs.filter((p) => p.direction === 'top'); - const leftInputs = inputs.filter((p) => p.direction === 'left'); - const bottomOutputs = outputs.filter((p) => p.direction === 'bottom'); - const rightOutputs = outputs.filter((p) => p.direction === 'right'); - - const classes = classNames( - canvasState.selectedNodes.has(id) && 'pf-selected', - isDockedChild && 'pf-docked-child', - hasDockedChild && 'pf-has-docked-child', - isDockTarget && 'pf-dock-target', - accentBar && 'pf-node--has-accent-bar', - invalid && 'pf-invalid', - nodeClassName, - ); - - // Helper to render a port - const renderPort = ( - port: NodePort, - portIndex: number, - portType: 'input' | 'output', - forceConnected?: boolean, - ) => { - const portId = `${portType}-${portIndex}`; - const cssClass = classNames( - portType === 'input' ? 'pf-input' : 'pf-output', - `pf-port-${port.direction}`, - (forceConnected || - isPortConnected(id, portType, portIndex, connections)) && - 'pf-connected', - canvasState.connecting && - canvasState.connecting.nodeId === id && - canvasState.connecting.portIndex === portIndex && - canvasState.connecting.type === portType && - 'pf-active', - port.contextMenuItems !== undefined && 'pf-port--with-context-menu', - ); - - const portElement = m('.pf-port', { - 'data-port': portId, - 'className': cssClass, - 'onpointerdown': (e: PointerEvent) => { - e.stopPropagation(); - if (portType === 'input') { - // Input port - check for existing connection - const existingConnIdx = connections.findIndex( - (conn) => conn.toNode === id && conn.toPort === portIndex, - ); - if (existingConnIdx !== -1) { - const existingConn = connections[existingConnIdx]; - const {onConnectionRemove} = vnode.attrs; - if (onConnectionRemove !== undefined) { - onConnectionRemove(existingConnIdx); - } - const outputPos = getPortPosition( - existingConn.fromNode, - 'output', - existingConn.fromPort, - ); - canvasState.connecting = { - nodeId: existingConn.fromNode, - portIndex: existingConn.fromPort, - type: 'output', - portType: getPortType( - existingConn.fromNode, - 'output', - existingConn.fromPort, - nodes, - ), - x: 0, - y: 0, - transformedX: outputPos.x, - transformedY: outputPos.y, - }; - m.redraw(); - } - } else { - // Output port - start connection - const portPos = getPortPosition(id, portType, portIndex); - canvasState.connecting = { - nodeId: id, - portIndex, - type: portType, - portType: port.direction, - x: 0, - y: 0, - transformedX: portPos.x, - transformedY: portPos.y, - }; - } - }, - 'onpointerup': (e: PointerEvent) => { - e.stopPropagation(); - if (portType === 'input') { - if ( - canvasState.connecting && - canvasState.connecting.type === 'output' - ) { - // Input port receiving connection - const existingConnIdx = connections.findIndex( - (conn) => conn.toNode === id && conn.toPort === portIndex, - ); - if (existingConnIdx !== -1) { - const {onConnectionRemove} = vnode.attrs; - if (onConnectionRemove !== undefined) { - onConnectionRemove(existingConnIdx); - } - } - const connection = { - fromNode: canvasState.connecting.nodeId, - fromPort: canvasState.connecting.portIndex, - toNode: id, - toPort: portIndex, - }; - if (onConnect !== undefined) { - onConnect(connection); - } - canvasState.connecting = null; - } - } else if (portType === 'output') { - // Clear connecting state if releasing on output port without completing connection - canvasState.connecting = null; - } - }, - }); - - // Wrap with PopupMenu if contextMenuItems exist - if (port.contextMenuItems !== undefined) { - return m(PopupMenu, {trigger: portElement}, port.contextMenuItems); - } - return portElement; - }; - - const style = hue !== undefined ? {'--pf-node-hue': `${hue}`} : undefined; - - return m( - '.pf-node', - { - 'key': id, - 'data-node': id, - 'class': classes, - 'style': { - ...style, - }, - 'onpointerdown': (e: PointerEvent) => { - if ((e.target as HTMLElement).closest('.pf-port')) { - return; - } - e.stopPropagation(); - - // Handle multi-selection with Shift or Cmd/Ctrl (only if multiselect is enabled) - if (multiselect && (e.shiftKey || e.metaKey || e.ctrlKey)) { - // Toggle selection - if (canvasState.selectedNodes.has(id)) { - const {onNodeRemoveFromSelection} = vnode.attrs; - if (onNodeRemoveFromSelection !== undefined) { - onNodeRemoveFromSelection(id); - } - } else { - const {onNodeAddToSelection} = vnode.attrs; - if (onNodeAddToSelection !== undefined) { - onNodeAddToSelection(id); - } - } - - // Focus the canvas element to ensure keyboard events (like Delete) are captured - if (canvasElement) { - canvasElement.focus(); - } - - return; - } - - const {onNodeSelect, selectedNodeIds} = vnode.attrs; - - // When multiple nodes are selected, disable all dragging. - // Only allow selection change and focus. - if (selectedNodeIds !== undefined && selectedNodeIds.size > 1) { - // If clicking an unselected node, select just that node - if (!selectedNodeIds.has(id) && onNodeSelect !== undefined) { - onNodeSelect(id); - } - // Focus the canvas for keyboard events (Delete, etc.) - if (canvasElement) { - canvasElement.focus(); - } - return; - } - - // Check if this is a chained node (not root) - if (isDockedChild && rootNode) { - // Don't undock immediately - wait for drag threshold - // Calculate current render position - let yOffset = rootNode.y; - const chainArr = getChain(rootNode); - for (const cn of chainArr) { - if (cn.id === id) break; - yOffset += getNodeDimensions(cn.id).height; - } - - // Find parent node in chain - let parentId = rootNode.id; - let curr = rootNode.next; - while (curr && curr.id !== id) { - parentId = curr.id; - curr = curr.next; - } - - // Store undock candidate - will undock if dragged beyond threshold - canvasState.undockCandidate = { - nodeId: id, - parentId: parentId, - startX: e.clientX, - startY: e.clientY, - renderY: yOffset, - }; - } - - canvasState.draggedNode = id; - - // Store initial drag position for batching - // Check if node has x,y properties (root nodes) vs docked children (no x,y) - if ('x' in node && 'y' in node) { - dragStartPosition = {nodeId: id, x: node.x, y: node.y}; - currentDragPosition = {x: node.x, y: node.y}; - } - - // Only change selection if the clicked node is not already selected - // This prevents unnecessary selection changes when starting to drag - if (!selectedNodeIds?.has(id) && onNodeSelect !== undefined) { - onNodeSelect(id); - } - - // Focus the canvas element to ensure keyboard events (like Delete) are captured - if (canvasElement) { - canvasElement.focus(); - } - - const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); - canvasState.dragOffset = { - x: e.clientX - rect.left, - y: e.clientY - rect.top, - }; - }, - }, - [ - // Render node title if it exists - titleBar !== undefined && - m('.pf-node-header', [ - titleBar.icon !== undefined && - m(Icon, {icon: titleBar.icon, className: 'pf-node-title-icon'}), - m('.pf-node-title', titleBar.title), - contextMenuItems !== undefined && - m( - PopupMenu, - { - trigger: m(Button, { - rounded: true, - icon: Icons.ContextMenuAlt, - className: contextMenuOnHover ? 'pf-show-on-hover' : '', - }), - }, - contextMenuItems, - ), - ]), - - // Context menu button for nodes without titlebar - titleBar === undefined && - contextMenuItems !== undefined && - m( - '.pf-node-context-menu', - {className: contextMenuOnHover ? 'pf-show-on-hover' : ''}, - m( - PopupMenu, - { - trigger: m(Button, { - rounded: true, - icon: Icons.ContextMenuAlt, - }), - }, - contextMenuItems, - ), - ), - - // Top input ports (if not docked child) - topInputs.map((port) => { - const portIndex = inputs.indexOf(port); - return renderPort(port, portIndex, 'input'); - }), - - m('.pf-node-body', [ - content !== undefined && - m( - '.pf-node-content', - { - onkeydown: (e: KeyboardEvent) => { - e.stopPropagation(); - }, - }, - content, - ), - - // Left input ports - leftInputs.map((port) => { - const portIndex = inputs.indexOf(port); - return m( - '.pf-port-row.pf-port-input', - { - 'data-port': `input-${portIndex}`, - }, - [renderPort(port, portIndex, 'input'), port.content], - ); - }), - - // Right output ports - rightOutputs.map((port) => { - const portIndex = outputs.indexOf(port); - return m( - '.pf-port-row.pf-port-output', - { - 'data-port': `output-${portIndex}`, - }, - [port.content, renderPort(port, portIndex, 'output')], - ); - }), - ]), - - // Bottom output ports (if no docked child below) - bottomOutputs.map((port) => { - const portIndex = outputs.indexOf(port); - return renderPort(port, portIndex, 'output'); - }), - ], - ); - } - - function renderLabel(label: Label, vnode: m.Vnode<NodeGraphAttrs>): m.Vnode { - const {id, x, y, width, content, selectable = false} = label; - const isDragging = canvasState.draggedLabel === id; - const isSelected = canvasState.selectedNodes.has(id); - - // Use temporary position/width during drag if available - const tempPos = canvasState.tempLabelPositions.get(id); - const tempWidth = canvasState.tempLabelWidths.get(id); - const renderX = tempPos?.x ?? x; - const renderY = tempPos?.y ?? y; - const renderWidth = tempWidth ?? width; - - return m( - '.pf-label', - { - 'key': `label-${id}`, - 'data-label': id, - 'className': classNames( - isDragging && 'pf-dragging', - isSelected && 'pf-selected', - ), - 'style': { - left: `${renderX}px`, - top: `${renderY}px`, - width: `${renderWidth}px`, - }, - 'onpointerdown': (e: PointerEvent) => { - const target = e.target as HTMLElement; - - // Check if clicking on the resize handle or delete button - if ( - target.closest('.pf-label-resize-handle') || - target.closest('.pf-label-delete-button') - ) { - e.stopPropagation(); - return; - } - - // Check if clicking on a textarea that is being edited (not readonly) - // Allow normal text selection behavior in edit mode - if (target instanceof HTMLTextAreaElement && !target.readOnly) { - // Don't start dragging, allow text selection - return; - } - - const {multiselect = true} = vnode.attrs; - - // Handle multi-selection with Shift or Cmd/Ctrl (only if multiselect is enabled) - if (multiselect && (e.shiftKey || e.metaKey || e.ctrlKey)) { - // Toggle selection - if (isSelected) { - const {onNodeRemoveFromSelection} = vnode.attrs; - if (onNodeRemoveFromSelection !== undefined) { - onNodeRemoveFromSelection(id); - } - } else { - const {onNodeAddToSelection} = vnode.attrs; - if (onNodeAddToSelection !== undefined) { - onNodeAddToSelection(id); - } - } - - // Focus the canvas element to ensure keyboard events (like Delete) are captured - if (canvasElement) { - canvasElement.focus(); - } - - e.stopPropagation(); - return; - } - - // Start dragging the label - canvasState.draggedLabel = id; - canvasState.dragOffset = { - x: (canvasState.mousePos.transformedX ?? 0) - x, - y: (canvasState.mousePos.transformedY ?? 0) - y, - }; - - // Select the label if selectable (replace current selection) - if (selectable) { - const {onNodeSelect} = vnode.attrs; - if (onNodeSelect !== undefined) { - onNodeSelect(id); - } - } - - // Focus the canvas element to ensure keyboard events (like Delete) are captured - if (canvasElement) { - canvasElement.focus(); - } - - e.stopPropagation(); - }, - }, - [ - // Render the content (or placeholder if not provided) - m( - '.pf-label-content', - content ?? m('.pf-label-placeholder', 'Empty label'), - ), - // Resize handle (always rendered) - m('.pf-label-resize-handle', { - onpointerdown: (e: PointerEvent) => { - // Start resizing - canvasState.resizingLabel = id; - canvasState.resizeStartWidth = width; - canvasState.resizeStartX = canvasState.mousePos.transformedX ?? 0; - e.stopPropagation(); - }, - }), - // Delete button (always rendered, visible on hover/selection) - m( - '.pf-label-delete-button', - { - onclick: (e: PointerEvent) => { - const {onLabelRemove} = vnode.attrs; - if (onLabelRemove !== undefined) { - onLabelRemove(id); - } - e.stopPropagation(); - }, - }, - m(Icon, {icon: 'close'}), - ), - ], - ); - } - - return { - oncreate: (vnode: m.VnodeDOM<NodeGraphAttrs>) => { - latestVnode = vnode; - canvasElement = vnode.dom as HTMLElement; - document.addEventListener('pointermove', handleMouseMove); - document.addEventListener('pointerup', handleMouseUp); - canvasElement.addEventListener('wheel', handleWheel, {passive: false}); - - const { - connections = [], - nodes = [], - onConnectionRemove, - onReady, - } = vnode.attrs; - - // Render connections after DOM is ready - const svg = vnode.dom.querySelector('svg'); - if (svg) { - renderConnections( - svg as SVGElement, - connections, - nodes, - onConnectionRemove, - ); - } - - // Create auto-layout function that uses actual DOM dimensions - autoLayoutApi = () => { - const {nodes = [], connections = [], onNodeMove} = vnode.attrs; - autoLayoutGraph(nodes, connections, onNodeMove); - }; - - // Create recenter function that brings all nodes into view - recenterApi = () => { - if (latestVnode === null || canvasElement === null) { - return; - } - const {nodes = [], labels = []} = latestVnode.attrs; - const canvas = canvasElement; - autofit(nodes, labels, canvas); - }; - - // Find a non-overlapping position for a new node - findPlacementForNodeApi = (newNode: Omit<Node, 'x' | 'y'>): Position => { - if (latestVnode === null || canvasElement === null) { - return {x: 0, y: 0}; - } - - const {nodes = []} = latestVnode.attrs; - const canvas = canvasElement; - - // Default starting position (center of viewport in canvas space) - const canvasRect = canvas.getBoundingClientRect(); - const centerX = - (canvasRect.width / 2 - canvasState.panOffset.x) / canvasState.zoom; - const centerY = - (canvasRect.height / 2 - canvasState.panOffset.y) / canvasState.zoom; - - // Create a temporary node with coordinates to render and measure - const tempNode: Node = { - ...newNode, - x: centerX, - y: centerY, - }; - - // Create temporary DOM element to measure size - const tempContainer = document.createElement('div'); - tempContainer.style.position = 'absolute'; - tempContainer.style.left = '-9999px'; - tempContainer.style.visibility = 'hidden'; - canvas.appendChild(tempContainer); - - // Render the node into the temporary container with animation disabled - m.render( - tempContainer, - m( - '.pf-node-wrapper', - m( - '.pf-node', - { - 'data-node': tempNode.id, - 'style': { - ...(tempNode.hue !== undefined - ? {'--pf-node-hue': `${tempNode.hue}`} - : {}), - }, - }, - [ - tempNode.titleBar && - m('.pf-node-header', [ - m('.pf-node-title', tempNode.titleBar.title), - ]), - m('.pf-node-body', [ - tempNode.content !== undefined && - m('.pf-node-content', tempNode.content), - tempNode.inputs - ?.filter((p) => p.direction === 'left') - .map((port) => - m('.pf-port-row.pf-port-input', [ - m('.pf-port'), - port.content, - ]), - ), - tempNode.outputs - ?.filter((p) => p.direction === 'right') - .map((port) => - m('.pf-port-row.pf-port-output', [ - port.content, - m('.pf-port'), - ]), - ), - ]), - ], - ), - ), - ); - - // Get dimensions from the rendered element - const dims = getNodeDimensions(tempNode.id); - - // Calculate chain height - const chain = getChain(tempNode); - let chainHeight = 0; - chain.forEach((chainNode) => { - const chainDims = getNodeDimensions(chainNode.id); - chainHeight += chainDims.height; - }); - - // Clean up temporary element - canvas.removeChild(tempContainer); - - // Find non-overlapping position starting from center - const finalPos = findNearestNonOverlappingPosition( - centerX - dims.width / 2, - centerY - dims.height / 2, - tempNode.id, - nodes, - dims.width, - chainHeight, - ); - - return finalPos; - }; - - // Reset zoom to 100% (1.0x) around canvas center - resetZoom = () => { - if (!canvasElement) return; - - const canvas = canvasElement; - const canvasRect = canvas.getBoundingClientRect(); - const centerX = canvasRect.width / 2; - const centerY = canvasRect.height / 2; - - // Calculate the point in canvas space (before zoom) - const canvasX = (centerX - canvasState.panOffset.x) / canvasState.zoom; - const canvasY = (centerY - canvasState.panOffset.y) / canvasState.zoom; - - // Reset zoom to 1.0 - canvasState.zoom = 1.0; - - // Adjust pan to keep the same point under the center - canvasState.panOffset = { - x: centerX - canvasX, - y: centerY - canvasY, - }; - - m.redraw(); - }; - - // Provide API to parent - if ( - onReady !== undefined && - autoLayoutApi !== null && - recenterApi !== null && - findPlacementForNodeApi !== null - ) { - onReady({ - autoLayout: autoLayoutApi, - recenter: recenterApi, - findPlacementForNode: findPlacementForNodeApi, - panBy, - zoomBy, - resetZoom, - }); - } - }, - - onupdate: (vnode: m.VnodeDOM<NodeGraphAttrs>) => { - latestVnode = vnode; - const { - connections = [], - nodes = [], - onConnectionRemove, - onReady, - } = vnode.attrs; - - // Re-render connections when component updates - const svg = vnode.dom.querySelector('svg'); - if (svg) { - renderConnections( - svg as SVGElement, - connections, - nodes, - onConnectionRemove, - ); - } - - // Call onReady after every render cycle so parent can perform - // post-render actions like recentering - if ( - onReady !== undefined && - autoLayoutApi !== null && - recenterApi !== null && - findPlacementForNodeApi !== null && - resetZoom !== null - ) { - onReady({ - autoLayout: autoLayoutApi, - recenter: recenterApi, - findPlacementForNode: findPlacementForNodeApi, - panBy, - zoomBy, - resetZoom, - }); - } - }, - - onremove: (vnode: m.VnodeDOM<NodeGraphAttrs>) => { - document.removeEventListener('pointermove', handleMouseMove); - document.removeEventListener('pointerup', handleMouseUp); - (vnode.dom as HTMLElement).removeEventListener('wheel', handleWheel); - }, - - view: (vnode: m.Vnode<NodeGraphAttrs>) => { - latestVnode = vnode; - const { - nodes, - selectedNodeIds = new Set<string>(), - multiselect = true, - contextMenuOnHover = false, - fillHeight, - } = vnode.attrs; - - // Sync internal state with prop - canvasState.selectedNodes = selectedNodeIds; - - const className = classNames( - fillHeight && 'pf-canvas--fill-height', - canvasState.connecting && 'pf-connecting', - canvasState.connecting && - `connecting-from-${canvasState.connecting.type}`, - canvasState.isPanning && 'pf-panning', - ); - - return m( - '.pf-canvas', - { - className, - tabindex: 0, // Make div focusable to capture keyboard events - oncontextmenu: (e: Event) => { - e.preventDefault(); // Disable default context menu - }, - onpointerdown: (e: PointerEvent) => { - const target = e.target as HTMLElement; - if ( - target.classList.contains('pf-canvas') || - target.tagName === 'svg' - ) { - // Start box selection with Shift (only if multiselect is enabled) - if (multiselect && e.shiftKey) { - const transformedX = canvasState.mousePos.transformedX ?? 0; - const transformedY = canvasState.mousePos.transformedY ?? 0; - canvasState.selectionRect = { - startX: transformedX, - startY: transformedY, - currentX: transformedX, - currentY: transformedY, - }; - return; - } - - // Start panning and store position to detect click vs drag - canvasState.isPanning = true; - canvasState.panStart = {x: e.clientX, y: e.clientY}; - canvasState.canvasMouseDownPos = {x: e.clientX, y: e.clientY}; - } - }, - onclick: (e: PointerEvent) => { - const target = e.target as HTMLElement; - // Clear selection on canvas click (only if mouse didn't move significantly) - if ( - target.classList.contains('pf-canvas') || - target.tagName === 'svg' - ) { - const dx = Math.abs(e.clientX - canvasState.canvasMouseDownPos.x); - const dy = Math.abs(e.clientY - canvasState.canvasMouseDownPos.y); - const threshold = 3; // Pixels of movement tolerance - - // Only clear if it was a click (not a drag) - if (dx <= threshold && dy <= threshold) { - const {onSelectionClear} = vnode.attrs; - if (onSelectionClear !== undefined) { - onSelectionClear(); - } - } - } - }, - onkeydown: (e: KeyboardEvent) => { - if (e.key === 'Escape') { - // Deselect all nodes and labels - const hasSelection = canvasState.selectedNodes.size > 0; - if (hasSelection) { - const {onSelectionClear} = vnode.attrs; - if (onSelectionClear !== undefined) { - onSelectionClear(); - } - } - } else if (e.key === 'Delete' || e.key === 'Backspace') { - const {onNodeRemove, onLabelRemove, labels = []} = vnode.attrs; - - if (canvasState.selectedNodes.size > 0) { - // Flatten all nodes including docked nodes (via 'next' property) - const allNodeIds = new Set<string>(); - const queue: Array<Node | DockedNode> = [...nodes]; - while (queue.length > 0) { - const node = queue.shift(); - if (node) { - allNodeIds.add(node.id); - // Traverse docked children via 'next' property - if (node.next) { - queue.push(node.next); - } - } - } - - const labelIds = new Set(labels.map((l) => l.id)); - - // Delete selected nodes and labels - canvasState.selectedNodes.forEach((id) => { - if (allNodeIds.has(id) && onNodeRemove !== undefined) { - onNodeRemove(id); - } else if (labelIds.has(id) && onLabelRemove !== undefined) { - onLabelRemove(id); - } - }); - } - } - }, - style: { - backgroundSize: (() => { - const minPixelSpacing = 10; - let gridSize = 20; - while (gridSize * canvasState.zoom < minPixelSpacing) { - gridSize *= 2; - } - const size = gridSize * canvasState.zoom; - return `${size}px ${size}px`; - })(), - backgroundPosition: (() => { - const minPixelSpacing = 10; - let gridSize = 20; - while (gridSize * canvasState.zoom < minPixelSpacing) { - gridSize *= 2; - } - const size = gridSize * canvasState.zoom; - // Subtract size/2 so the dot (centered in its tile) lands exactly - // on the world origin — stays fixed when gridSize doubles. - const x = canvasState.panOffset.x - size / 2; - const y = canvasState.panOffset.y - size / 2; - return `${x}px ${y}px`; - })(), - ...vnode.attrs.style, - }, - }, - [ - (vnode.attrs.toolbarItems !== undefined || - !vnode.attrs.hideControls) && - m('.pf-nodegraph-controls', [ - vnode.attrs.toolbarItems, - !vnode.attrs.hideControls && - m(Button, { - title: 'Auto layout', - icon: 'account_tree', - variant: ButtonVariant.Filled, - onclick: () => autoLayoutApi?.(), - }), - m( - ButtonGroup, - m(Button, { - title: 'Fit to screen', - icon: 'center_focus_strong', - variant: ButtonVariant.Filled, - onclick: () => recenterApi?.(), - }), - m(Button, { - title: 'Reset zoom to 100%', - icon: 'view_real_size', - variant: ButtonVariant.Filled, - onclick: () => resetZoom?.(), - }), - m(Button, { - title: 'Zoom in', - icon: 'zoom_in', - variant: ButtonVariant.Filled, - onclick: () => zoomBy(0.2), - }), - m(Button, { - title: 'Zoom out', - icon: 'zoom_out', - variant: ButtonVariant.Filled, - onclick: () => zoomBy(-0.2), - }), - ), - ]), - - // Container for nodes and SVG that gets transformed - m( - '.pf-canvas-content', - { - style: `transform: translate(${canvasState.panOffset.x}px, ${canvasState.panOffset.y}px) scale(${canvasState.zoom}); transform-origin: 0 0;`, - }, - [ - // SVG container for connections (rendered imperatively in oncreate/onupdate) - m('svg'), - - // Selection rectangle overlay - canvasState.selectionRect && - m('.pf-selection-rect', { - style: { - left: `${Math.min(canvasState.selectionRect.startX, canvasState.selectionRect.currentX)}px`, - top: `${Math.min(canvasState.selectionRect.startY, canvasState.selectionRect.currentY)}px`, - width: `${Math.abs(canvasState.selectionRect.currentX - canvasState.selectionRect.startX)}px`, - height: `${Math.abs(canvasState.selectionRect.currentY - canvasState.selectionRect.startY)}px`, - }, - }), - - // Render all nodes - wrap dock chains in flex container - nodes - .map((node: Node) => { - const {id} = node; - - // Check if this is the root of a dock chain - const chain = getChain(node); - const isChainRoot = chain.length > 1; - - // Check if we have a temp position for this node (during drag) - const tempPos = canvasState.tempNodePositions.get(id); - const renderPos = tempPos || {x: node.x, y: node.y}; - - // If this is a chain root, wrap all chain nodes in flex container - // Always wrap in a chain root container for consistency - - if (isChainRoot) { - return m( - '.pf-node-wrapper', - { - key: `chain-${id}`, - style: `left: ${renderPos.x}px; top: ${renderPos.y}px;`, - className: classNames( - canvasState.draggedNode === id && - 'pf-node-wrapper--dragging', - ), - }, - chain.map((chainNode) => { - const cIsDockedChild = 'x' in chainNode === false; - const cHasDockedChild = chainNode.next !== undefined; - const cIsDockTarget = - canvasState.dockTarget === chainNode.id && - canvasState.isDockZone; - - return renderNode(chainNode, vnode, { - isDockedChild: cIsDockedChild, - hasDockedChild: cHasDockedChild, - isDockTarget: cIsDockTarget, - rootNode: node, - multiselect, - contextMenuOnHover, - }); - }), - ); - } else { - // Render standalone node (not part of a chain) - const isDockTarget = - canvasState.dockTarget === id && canvasState.isDockZone; - - return m( - '.pf-node-wrapper', - { - key: `chain-${id}`, - style: `left: ${renderPos.x}px; top: ${renderPos.y}px;`, - className: classNames( - canvasState.draggedNode === id && - 'pf-node-wrapper--dragging', - ), - }, - renderNode(node, vnode, { - isDockedChild: false, - hasDockedChild: false, - isDockTarget, - rootNode: undefined, - multiselect, - contextMenuOnHover, - }), - ); - } - }) - .filter((vnode) => vnode !== null), - - // Render all labels - (vnode.attrs.labels ?? []).map((label: Label) => { - return renderLabel(label, vnode); - }), - ], - ), - ], - ); - }, - }; -}
diff --git a/ui/src/widgets/nodegraph/index.ts b/ui/src/widgets/nodegraph/index.ts new file mode 100644 index 0000000..9210a0c --- /dev/null +++ b/ui/src/widgets/nodegraph/index.ts
@@ -0,0 +1,16 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export * from './model'; +export * from './views/nodegraph';
diff --git a/ui/src/widgets/nodegraph/model.ts b/ui/src/widgets/nodegraph/model.ts new file mode 100644 index 0000000..ec0bd0f --- /dev/null +++ b/ui/src/widgets/nodegraph/model.ts
@@ -0,0 +1,178 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import m from 'mithril'; +import {NodeGraphViewport} from './views/nodegraph'; +import {Point2D} from '../../base/geom'; + +export interface NodeGraphConnection { + readonly fromPort: string; + readonly toPort: string; +} + +export type Direction = 'north' | 'south' | 'east' | 'west'; + +export interface NodeGraphPort { + // Unique identifier for this port. + readonly id: string; + + // Label shown next to the port dot. Only displayed for east/west ports. + readonly label?: m.Children; + + // Which edge of the node this port appears on. + readonly direction: Direction; + + // Items shown in a context menu when the port is right-clicked or tapped. + readonly contextMenuItems?: m.Children; +} + +// A node that is docked (stacked) below another node. Identical to +// NodeGraphNode but without a position, since its parent determines placement. +export type NodeGraphDockedNode = Omit<NodeGraphNode, 'pos'>; + +export interface NodeGraphNode { + // Unique identifier for this node. + readonly id: string; + + // Position in canvas coordinates (independent of the current pan/zoom). + readonly pos: Point2D; + + // Hue (0–360) used to tint the header and accent bar. The rendered color + // adapts to the current light/dark theme. + readonly hue: number; + + // Renders a colored strip on the left edge of the card. Mutually exclusive + // with headerBar — they don't look good together. + readonly accentBar?: boolean; + + // Renders a title bar at the top of the card with an optional icon. Mutually + // exclusive with accentBar — they don't look good together. + readonly headerBar?: { + readonly title: m.Children; + readonly icon?: string; + }; + + // Input ports shown on the node (used to receive connections). + readonly inputs?: ReadonlyArray<NodeGraphPort>; + + // Output ports shown on the node (used to originate connections). + readonly outputs?: ReadonlyArray<NodeGraphPort>; + + // Arbitrary content rendered in the card body. + readonly content?: m.Children; + + // Next node stacked below this one. When set, the two nodes are rendered as + // a single visual unit and move together. + readonly next?: NodeGraphDockedNode; + + // When true, this node can be dragged and docked below a node that has + // canDockBottom set. + readonly canDockTop?: boolean; + + // When true, another node with canDockTop can be docked below this one. + readonly canDockBottom?: boolean; + + // Items shown in a context menu on the node (e.g. via a triple-dot button). + readonly contextMenuItems?: m.Children; + + // Extra CSS class(es) applied to the outermost node element. + readonly className?: string; +} + +export interface NodeGraphAPI { + // Adjusts pan and zoom so all nodes fit within the visible viewport. + autofit(): void; + + // Pans the canvas by (dx, dy) in viewport pixels. Positive values pan right + // and down. + pan(dx: number, dy: number): void; + + // Zooms by adding deltaZoom to the current zoom level (e.g. +0.1 zooms in). + // centerX/centerY are the fixed point in viewport coordinates; defaults to + // the canvas center. + zoom(deltaZoom: number, centerX?: number, centerY?: number): void; + + // Resets zoom to 1.0, keeping the current viewport center fixed. + resetZoom(): void; + + // Finds a canvas position for a new node that doesn't overlap any existing + // node. Starts from the viewport center and spirals outward. The node + // description is used for context but the position is estimated since the + // node hasn't been rendered yet. + findPlacementForNode(node: Omit<NodeGraphNode, 'pos'>): Point2D; +} + +export interface NodeGraphAttrs { + readonly className?: string; + readonly style?: Partial<CSSStyleDeclaration>; + + // The nodes and connections to render. + readonly nodes: ReadonlyArray<NodeGraphNode>; + readonly connections: ReadonlyArray<NodeGraphConnection>; + + // IDs of currently selected nodes. The graph highlights these but does not + // own the selection state — the parent is responsible for updating it. + readonly selectedNodeIds?: ReadonlySet<string>; + + // Hide the built-in zoom/fit toolbar. + readonly hideControls?: boolean; + + // Make the graph fill the height of its container. + readonly fillHeight?: boolean; + + // Extra items rendered in the toolbar alongside the built-in controls. + readonly toolbarItems?: m.Children; + + // Called once after the component mounts, with an API object for + // programmatic control (pan, zoom, placement, etc.). + readonly onReady?: (api: NodeGraphAPI) => void; + + // Called when the user draws a wire between two ports. + readonly onConnect?: (connection: NodeGraphConnection) => void; + + // Called when the user removes a connection. The index refers to the + // position of the connection in the `connections` array. + readonly onDisconnect?: (index: number) => void; + + // Called when the user drops a node at a new position. The position is + // already snapped to the grid and adjusted to avoid overlaps. + readonly onNodeMove?: (nodeId: string, pos: Point2D) => void; + + // Called when the user docks one node below another. + readonly onNodeDock?: (nodeId: string, targetId: string) => void; + + // Called when the user triggers deletion of nodes (e.g. toolbar delete + // button). Receives the IDs of all nodes to remove. + readonly onNodeRemove?: (nodeIds: string[]) => void; + + // Called when the selection is replaced entirely (e.g. click or box-select). + readonly onSelect?: (nodeIds: string[]) => void; + + // Called when a single node is added to the existing selection (Ctrl+click). + readonly onSelectionAdd?: (nodeId: string) => void; + + // Called when a single node is removed from the selection (Ctrl+click on a + // selected node). + readonly onSelectionRemove?: (nodeId: string) => void; + + // Called when the selection is cleared (e.g. clicking the empty canvas). + readonly onSelectionClear?: () => void; + + // Called whenever the viewport changes due to pan or zoom. + readonly onViewportMove?: (viewport: NodeGraphViewport) => void; + + // Initial pan/zoom applied when the component first mounts. Defaults to + // offset (0, 0) and zoom 1.0. + readonly initialViewport?: NodeGraphViewport; +}
diff --git a/ui/src/widgets/nodegraph/svg.ts b/ui/src/widgets/nodegraph/svg.ts new file mode 100644 index 0000000..057777e --- /dev/null +++ b/ui/src/widgets/nodegraph/svg.ts
@@ -0,0 +1,151 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import m from 'mithril'; + +const ARROWHEAD_LENGTH = 4; +const SHORTEN_END = 16; + +export function arrowheadMarker(markerId: string): m.Vnode { + return m( + 'marker', + { + id: markerId, + viewBox: `0 0 ${ARROWHEAD_LENGTH} 10`, + refX: '0', + refY: '5', + markerWidth: `${ARROWHEAD_LENGTH}`, + markerHeight: '10', + orient: 'auto', + }, + m('polygon', { + points: `0 2.5, ${ARROWHEAD_LENGTH} 5, 0 7.5`, + fill: 'context-stroke', + }), + ); +} + +export type PortDirection = 'top' | 'bottom' | 'left' | 'right'; + +export function connectionPath( + from: {x: number; y: number}, + to: {x: number; y: number}, + markerId: string, + fromDir: PortDirection = 'right', + toDir: PortDirection = 'left', + extraAttrs?: Record<string, unknown>, + onRemove?: () => void, +): m.Vnode { + const d = createCurve( + from.x, + from.y, + to.x, + to.y, + fromDir, + toDir, + SHORTEN_END, + ); + + const visiblePath = m('path', { + d, + 'class': 'pf-connection-path', + 'stroke': 'var(--pf-color-primary)', + 'stroke-width': 2, + 'fill': 'none', + 'stroke-linecap': 'round', + 'marker-end': `url(#${markerId})`, + 'style': {pointerEvents: 'none'}, + ...extraAttrs, + }); + + if (onRemove === undefined) { + return visiblePath; + } + + // Wrap in a group with a wider invisible hitbox for easier clicking. + return m('g', {'class': 'pf-connection-group'}, + m('path', { + d, + 'class': 'pf-connection-hitbox', + 'style': { + stroke: 'transparent', + strokeWidth: 14, + fill: 'none', + pointerEvents: 'stroke', + cursor: 'pointer', + }, + onpointerdown: (e: PointerEvent) => e.stopPropagation(), + onclick: (e: MouseEvent) => { + e.stopPropagation(); + onRemove(); + }, + }), + visiblePath, + ); +} + +export function createCurve( + x1: number, + y1: number, + x2: number, + y2: number, + fromPortType?: 'top' | 'bottom' | 'left' | 'right', + toPortType?: 'top' | 'bottom' | 'left' | 'right', + shortenEnd = 0, +): string { + const dx = x2 - x1; + const dy = y2 - y1; + const distance = Math.sqrt(dx * dx + dy * dy); + + let cx1: number; + let cy1: number; + let cx2: number; + let cy2: number; + + if (shortenEnd > 0) { + if (toPortType === 'bottom') { + y2 += shortenEnd; + } else if (toPortType === 'top') { + y2 -= shortenEnd; + } else if (toPortType === 'left') { + x2 -= shortenEnd; + } else if (toPortType === 'right') { + x2 += shortenEnd; + } + } + + // For top/bottom ports, control points extend vertically. + // For left/right ports, control points extend horizontally. + if (fromPortType === 'bottom' || fromPortType === 'top') { + const verticalOffset = Math.max(Math.abs(dy) * 0.5, distance * 0.5); + cx1 = x1; + cy1 = fromPortType === 'bottom' ? y1 + verticalOffset : y1 - verticalOffset; + } else { + const horizontalOffset = Math.max(Math.abs(dx) * 0.5, distance * 0.5); + cx1 = x1 + horizontalOffset; + cy1 = y1; + } + + if (toPortType === 'bottom' || toPortType === 'top') { + const verticalOffset = Math.max(Math.abs(dy) * 0.5, distance * 0.5); + cx2 = x2; + cy2 = toPortType === 'bottom' ? y2 + verticalOffset : y2 - verticalOffset; + } else { + const horizontalOffset = Math.max(Math.abs(dx) * 0.5, distance * 0.5); + cx2 = x2 - horizontalOffset; + cy2 = y2; + } + + return `M ${x1} ${y1} C ${cx1} ${cy1}, ${cx2} ${cy2}, ${x2} ${y2}`; +}
diff --git a/ui/src/widgets/nodegraph/views/node.ts b/ui/src/widgets/nodegraph/views/node.ts new file mode 100644 index 0000000..2ee0cad --- /dev/null +++ b/ui/src/widgets/nodegraph/views/node.ts
@@ -0,0 +1,149 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import m from 'mithril'; +import {classNames} from '../../../base/classnames'; +import {Icon} from '../../icon'; + +/** + * - Node + * - Card + * - Header + * - Body + * - Port 1 + * - Port 2 + * - Port N + * - Node + * - Card + * - Header + * - Body + * - Port N + */ + +export interface NGNodeAttrs extends m.Attributes { + readonly id: string; + readonly position?: {readonly x: number; readonly y: number}; + // The next docked node in the chain (rendered below the body). + readonly nextNode?: m.Children; +} + +export const NGNode: m.Component<NGNodeAttrs> = { + view({attrs, children}: m.Vnode<NGNodeAttrs>): m.Children { + const {id, position, nextNode, ...htmlAttrs} = attrs; + + return m( + '.pf-ng__node', + { + ...htmlAttrs, + 'data-node-id': id, + 'style': { + ...(position + ? { + position: 'absolute', + left: `${position.x}px`, + top: `${position.y}px`, + } + : {}), + ...attrs.style, + }, + }, + children, + nextNode, + ); + }, +}; + +export interface NGCardAttrs extends m.Attributes { + readonly hue?: number; + readonly accent?: boolean; + readonly selected?: boolean; +} + +// Wraps arbitrary content inside a node body with standard padding. +export const NGCard: m.Component<NGCardAttrs> = { + view({attrs, children}: m.Vnode<NGCardAttrs>): m.Children { + const {accent, selected, className, ...htmlAttrs} = attrs; + return m( + '.pf-ng__card', + { + ...htmlAttrs, + style: { + '--pf-ng-hue': attrs.hue ?? 0, + }, + // Styles actually apply to the card + className: classNames( + accent && 'pf-ng__card--accent', + selected && 'pf-ng__card--selected', + className, + ), + }, + children, + ); + }, +}; + +export interface NGHeaderAttrs extends m.Attributes { + readonly title: m.Children; + readonly icon?: string; +} + +export const NGCardHeader: m.Component<NGHeaderAttrs> = { + view({attrs}: m.Vnode<NGHeaderAttrs>): m.Children { + const {title, icon, ...htmlAttrs} = attrs; + return m('.pf-ng__card-header', htmlAttrs, [ + icon !== undefined && m(Icon, {icon, className: 'pf-node-title-icon'}), + m('.pf-node-title', title), + ]); + }, +}; + +export const NGCardBody: m.Component<m.Attributes> = { + view({attrs, children}: m.Vnode<m.Attributes>): m.Children { + return m('.pf-ng__card-body', attrs, children); + }, +}; + +export interface NGPortAttrs extends m.Attributes { + // Identifier used as a Mithril key when rendering port lists. + readonly id: string; + readonly direction: 'north' | 'south' | 'east' | 'west'; + readonly portType: 'input' | 'output'; + readonly connected?: boolean; + // Label shown alongside the port dot (hidden on north/south ports). + readonly label?: m.Children; +} + +export const NGPort: m.Component<NGPortAttrs> = { + view({attrs}: m.Vnode<NGPortAttrs>) { + // Destructure our own attrs; pass the rest (e.g. onpointerdown) to the dot. + const {direction, connected, label, portType, ...dotAttrs} = attrs; + + const portDot = m('.pf-ng__port-dot', { + ...dotAttrs, + 'data-port-id': attrs.id, + 'className': classNames( + portType === 'input' ? 'pf-input' : 'pf-output', + `pf-port-${direction}`, + connected && 'pf-port--connected', + ), + }); + + // Left/right ports get a labelled row; top/bottom are bare dots. + if (direction === 'east' || direction === 'west') { + return m(`.pf-ng__port.pf-ng__port--${direction}`, [portDot, label]); + } else { + return portDot; + } + }, +};
diff --git a/ui/src/widgets/nodegraph/views/nodegraph.ts b/ui/src/widgets/nodegraph/views/nodegraph.ts new file mode 100644 index 0000000..866e546 --- /dev/null +++ b/ui/src/widgets/nodegraph/views/nodegraph.ts
@@ -0,0 +1,1238 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import m from 'mithril'; +import {assertIsInstance} from '../../../base/assert'; +import {captureDrag} from '../../../base/dom_utils'; +import {Point2D, Vector2D} from '../../../base/geom'; +import {MithrilEvent} from '../../../base/mithril_utils'; +import {shortUuid} from '../../../base/uuid'; +import {PopupMenu} from '../../../widgets/menu'; +import {PopupPosition} from '../../../widgets/popup'; +import { + NodeGraphAPI, + NodeGraphDockedNode, + NodeGraphNode, + NodeGraphAttrs, + NodeGraphPort, +} from '../model'; +import type {PortDirection} from '../svg'; +import {arrowheadMarker, connectionPath} from '../svg'; +import {NGCard, NGCardBody, NGCardHeader, NGNode, NGPort} from './node'; +import {NGToolbar} from './toolbar'; + +const WHEEL_ZOOM_SCALING_FACTOR = 0.006; +const MIN_ZOOM = 0.01; +const MAX_ZOOM = 3.0; +const GRID_SIZE = 24; +const EDGE_DRAG_THRESHOLD = 40; // viewport px from edge where panning begins +const EDGE_DRAG_SPEED = 5; // viewport px per frame at the very edge + +function snapToGrid(p: Point2D): Vector2D { + return new Vector2D({ + x: Math.round(p.x / GRID_SIZE) * GRID_SIZE, + y: Math.round(p.y / GRID_SIZE) * GRID_SIZE, + }); +} + +function portDirFromEl(el: Element): PortDirection { + if (el.classList.contains('pf-port-north')) return 'top'; + if (el.classList.contains('pf-port-south')) return 'bottom'; + if (el.classList.contains('pf-port-east')) return 'right'; + if (el.classList.contains('pf-input')) return 'left'; + return 'right'; +} + +function oppositeDir(dir: PortDirection): PortDirection { + switch (dir) { + case 'top': + return 'bottom'; + case 'bottom': + return 'top'; + case 'left': + return 'right'; + case 'right': + return 'left'; + } +} + +function dotBackground(zoom: number, offset: {x: number; y: number}) { + let gridSize = GRID_SIZE; + while (gridSize * zoom < 10) gridSize *= 2; + const size = gridSize * zoom; + return { + size, + posX: offset.x * zoom - size / 2, + posY: offset.y * zoom - size / 2, + }; +} + +export interface NodeGraphViewport { + readonly offset: Point2D; + readonly zoom: number; +} + +export class NodeGraph implements m.Component<NodeGraphAttrs> { + // Unique ID for this instance's SVG marker, to avoid conflicts when multiple + // NodeGraph instances exist in the document (e.g. different tabs). + private readonly markerId = `pf-ng-arrow-${shortUuid()}`; + + // The current viewport transform. Updated during zoom and pan operations, and + // applied to the canvas via CSS transform. + private viewport: NodeGraphViewport = {offset: {x: 0, y: 0}, zoom: 1.0}; + + // Cached references to important immutable DOM elements. Set in oncreate, + // used in event handlers. + private rootEl!: HTMLElement; + private workspaceEl!: HTMLElement; + private svgEl!: SVGSVGElement; + + // Non-null while the user is dragging a wire from an output port. + private wireDrag?: { + fromPortId: string; + toPoint: Point2D; + toDir: PortDirection; + }; + + // Port ID of the output port whose context menu is currently open. + private openPortMenuId?: string; + + // Index into attrs.connections of a connection that has been "picked up" by + // dragging from its input port. Hidden during drag; disconnected on drop to + // a new input or to empty space, and restored if the drag is cancelled. + private pickedUpConnIdx?: number; + + // Latest attrs, kept in sync so the API closures can read onViewportMove etc. + private latestAttrs!: NodeGraphAttrs; + + private getNodePosition(nodeEl: HTMLElement) { + const rect = nodeEl.getBoundingClientRect(); + const canvasRect = this.rootEl.getBoundingClientRect(); + return this.getWorkspacePosition({ + x: rect.left - canvasRect.left, + y: rect.top - canvasRect.top, + }); + } + + private getWorkspacePosition(pos: Point2D) { + const {offset, zoom} = this.viewport; + return new Vector2D({ + x: pos.x / zoom + offset.x, + y: pos.y / zoom + offset.y, + }); + } + + private client2Workspace(client: Point2D) { + const canvasRect = this.rootEl.getBoundingClientRect(); + return this.getWorkspacePosition({ + x: client.x - canvasRect.left, + y: client.y - canvasRect.top, + }); + } + + private createGhostNode(nodeEl: HTMLElement) { + const ghost = nodeEl.cloneNode(true) as HTMLElement; + ghost.removeAttribute('data-node-id'); // avoid confusion with the real node + ghost.style.position = 'absolute'; + ghost.style.zIndex = '-1'; // behind the node, but above the connections + ghost.style.pointerEvents = 'none'; + ghost.style.filter = 'brightness(0) opacity(0.5)'; + this.workspaceEl.appendChild(ghost); + + const workspaceEl = this.workspaceEl; // capture for closure + + return { + element: ghost, + moveTo(p: Point2D) { + ghost.style.left = `${p.x}px`; + ghost.style.top = `${p.y}px`; + }, + dockToNode(nodeEl: HTMLElement) { + const card = assertIsInstance( + nodeEl.querySelector('.pf-ng__card'), + HTMLElement, + ); + card.after(ghost); + ghost.style.removeProperty('left'); + ghost.style.removeProperty('top'); + ghost.style.removeProperty('position'); + }, + undock() { + ghost.style.position = 'absolute'; + workspaceEl.appendChild(ghost); + }, + [Symbol.dispose]() { + ghost.remove(); + }, + }; + } + + private moveNodeToWorkspace(nodeEl: HTMLElement) { + const previousStyle = nodeEl.getAttribute('style') ?? ''; + const previousParent = nodeEl.parentElement; + + this.workspaceEl.appendChild(nodeEl); + nodeEl.style.position = 'absolute'; + + return { + moveTo(p: Point2D) { + nodeEl.style.left = `${p.x}px`; + nodeEl.style.top = `${p.y}px`; + }, + [Symbol.dispose]: () => { + // Move the element back to its original parent and reset styles + previousParent?.appendChild(nodeEl); + nodeEl.setAttribute('style', previousStyle); + }, + }; + } + + view({attrs}: m.Vnode<NodeGraphAttrs>) { + const { + onViewportMove, + onNodeMove, + onNodeDock, + onSelect, + onSelectionAdd, + onSelectionRemove, + onSelectionClear, + onNodeRemove: onRemove, + onConnect, + nodes = [], + selectedNodeIds, + toolbarItems, + style, + className, + } = attrs; + + const createNodeDragHandler = ( + node: NodeGraphDockedNode, + parentId: string | undefined, + ) => { + return async (e: MithrilEvent<PointerEvent>) => { + if (e.button !== 0) return; // only left-click drags + + // Let interactive elements (inputs, buttons, selects, etc.) handle + // their own events without triggering a node drag. + const target = e.target as HTMLElement; + if (target.closest('input, button, select, textarea, a')) return; + + // Stop this pointer event from hitting the canvas + e.stopPropagation(); + + // Don't redraw just yet... + e.redraw = false; + + // Secure the node element + const nodeEl = assertIsInstance(e.currentTarget, HTMLElement); + + // Find the initial offset within the node in canvas space + const pMouse = {x: e.clientX, y: e.clientY}; + const pMouseWs = this.client2Workspace(pMouse); + + const nodeRect = nodeEl.getBoundingClientRect(); + const pNode = {x: nodeRect.left, y: nodeRect.top}; + const pNodeWs = this.client2Workspace(pNode); + + const grabPoint = pMouseWs.sub(pNodeWs); + + // Wait for this to turn into a proper drag + const drag = await captureDrag({el: this.rootEl, e, deadzone: 5}); + + if (drag) { + // Work out where in the workspace the node is right now + const startPosition = this.getNodePosition(nodeEl); + + using tempNode = this.moveNodeToWorkspace(nodeEl); + tempNode.moveTo(startPosition); + + using ghost = this.createGhostNode(nodeEl); + ghost.moveTo(startPosition); + + let pNode = startPosition; + let dockTarget: string | undefined = undefined; + + using edgePan = this.startEdgePanning((dx, dy) => { + pNode = pNode.add({x: dx, y: dy}); + tempNode.moveTo(pNode); + this.updateConnections(attrs); + }); + + for await (const mv of drag) { + // Find the initial offset within the node in canvas space + const pMouseWs = this.client2Workspace(mv.client); + pNode = pMouseWs.sub(grabPoint); + + edgePan.updatePointer(mv.client); + tempNode.moveTo(pNode); + + dockTarget = node.canDockTop + ? this.findDockTarget(nodeEl) + : undefined; + + if (dockTarget) { + const targetEl = this.getNodeElement(dockTarget); + ghost.dockToNode(targetEl); + } else { + ghost.undock(); + const snapped = snapToGrid(pNode); + ghost.moveTo( + this.findNonCollidingPosition(nodeEl, snapped, node.id), + ); + } + this.updateConnections(attrs); + } + + if (dockTarget) { + if (dockTarget !== parentId) { + onNodeDock?.(node.id, dockTarget); + } + } else { + const snapped = snapToGrid(pNode); + const placed = this.findNonCollidingPosition( + nodeEl, + snapped, + node.id, + ); + onNodeMove?.(node.id, placed); + } + + m.redraw(); + } else { + // Failed drag - treat as a click and select the node + if (e.ctrlKey || e.metaKey) { + if (selectedNodeIds?.has(node.id)) { + onSelectionRemove?.(node.id); + } else { + onSelectionAdd?.(node.id); + } + } else { + // No key held - just select this node and deselect everything else + onSelect?.([node.id]); + } + m.redraw(); + } + }; + }; + + const createOutputPortDragHandler = ( + node: NodeGraphDockedNode, + output: NodeGraphPort, + ) => { + return async (e: PointerEvent) => { + if (e.button !== 0) return; // only left-click drags + + e.stopPropagation(); + const drag = await captureDrag({ + el: e.currentTarget as HTMLElement, + e, + deadzone: 5, + }); + + if (!drag) { + // Click (no drag): open context menu if available + if (output.contextMenuItems != null) { + this.openPortMenuId = output.id; + m.redraw(); + } + return; + } + + let clientX = e.clientX; + let clientY = e.clientY; + + const toCanvasPoint = (): Point2D => { + const {zoom, offset} = this.viewport; + const rect = this.rootEl.getBoundingClientRect(); + return { + x: (clientX - rect.left) / zoom + offset.x, + y: (clientY - rect.top) / zoom + offset.y, + }; + }; + + const nodeDirToPortDir: Record<string, PortDirection> = { + north: 'top', + south: 'bottom', + east: 'right', + west: 'left', + }; + const freeToDir = oppositeDir( + nodeDirToPortDir[output.direction] ?? 'right', + ); + const makeWireDrag = (snap: typeof snapTarget) => ({ + fromPortId: output.id, + toPoint: snap ? this.portCenterToCanvas(snap.el) : toCanvasPoint(), + toDir: snap ? portDirFromEl(snap.el) : freeToDir, + }); + + this.wireDrag = makeWireDrag(undefined); + this.rootEl.classList.add('pf-ng--wire-dragging'); + this.updateConnections(attrs); + + let snapTarget: {el: HTMLElement; portId: string} | undefined; + + using edgePan = this.startEdgePanning(() => { + this.wireDrag = makeWireDrag(snapTarget); + this.updateConnections(attrs); + }); + + const processMove = (client: {x: number; y: number}) => { + clientX = client.x; + clientY = client.y; + edgePan.updatePointer(client); + const prevSnap = snapTarget; + snapTarget = this.findWireSnapTarget(node.id, clientX, clientY); + if (prevSnap?.el !== snapTarget?.el) { + prevSnap?.el.classList.remove('pf-wire-snap'); + snapTarget?.el.classList.add('pf-wire-snap'); + } + this.wireDrag = makeWireDrag(snapTarget); + this.updateConnections(attrs); + }; + + for await (const mv of drag) { + processMove(mv.client); + } + snapTarget?.el.classList.remove('pf-wire-snap'); + this.rootEl.classList.remove('pf-ng--wire-dragging'); + + const connectTo = (toPort: string) => { + onConnect?.({fromPort: output.id, toPort}); + }; + + if (snapTarget !== undefined) { + connectTo(snapTarget.portId); + } else { + // Fallback: check if the pointer is directly over an input port. + const target = document.elementFromPoint(clientX, clientY); + const inputPortEl = + target?.closest('.pf-ng__port-dot.pf-input') ?? null; + if (inputPortEl) { + const nodeEl = inputPortEl.closest( + '[data-node-id]', + ) as HTMLElement | null; + const toNodeId = nodeEl?.getAttribute('data-node-id'); + const portId = inputPortEl.getAttribute('data-port-id'); + if (portId && toNodeId && toNodeId !== node.id) { + connectTo(portId); + } + } + } + + this.wireDrag = undefined; + this.updateConnections(attrs); + m.redraw(); + }; + }; + + const createInputPortDragHandler = ( + node: NodeGraphDockedNode, + input: NodeGraphPort, + ) => { + return async (e: PointerEvent) => { + if (e.button !== 0) return; // only left-click drags + e.stopPropagation(); + + const connIdx = + attrs.connections?.findIndex((c) => c.toPort === input.id) ?? -1; + + const drag = await captureDrag({ + el: e.currentTarget as HTMLElement, + e, + deadzone: 5, + }); + + if (!drag) { + // Click: open context menu if available. + if (input.contextMenuItems != null) { + this.openPortMenuId = input.id; + m.redraw(); + } + return; + } + + // Only connected input ports can be picked up as a wire. + if (connIdx < 0) return; + + const existingConn = attrs.connections![connIdx]; + const fromNodeId = + this.findNodeIdForPort(existingConn.fromPort) ?? node.id; + + // Find the from-port element to determine its direction for the curve. + const fromPortEl = this.rootEl.querySelector( + `.pf-ng__port-dot[data-port-id="${existingConn.fromPort}"]`, + ) as HTMLElement | undefined; + const fromDir: PortDirection = fromPortEl + ? portDirFromEl(fromPortEl) + : 'right'; + const freeToDir = oppositeDir(fromDir); + + let clientX = e.clientX; + let clientY = e.clientY; + + const toCanvasPoint = (): Point2D => { + const {zoom, offset} = this.viewport; + const rect = this.rootEl.getBoundingClientRect(); + return { + x: (clientX - rect.left) / zoom + offset.x, + y: (clientY - rect.top) / zoom + offset.y, + }; + }; + + const makeWireDrag = (snap: typeof snapTarget) => ({ + fromPortId: existingConn.fromPort, + toPoint: snap ? this.portCenterToCanvas(snap.el) : toCanvasPoint(), + toDir: snap ? portDirFromEl(snap.el) : freeToDir, + }); + + this.pickedUpConnIdx = connIdx; + this.wireDrag = makeWireDrag(undefined); + this.rootEl.classList.add('pf-ng--wire-dragging'); + this.updateConnections(attrs); + + let snapTarget: {el: HTMLElement; portId: string} | undefined; + + using edgePan = this.startEdgePanning(() => { + this.wireDrag = makeWireDrag(snapTarget); + this.updateConnections(attrs); + }); + + const processMove = (client: {x: number; y: number}) => { + clientX = client.x; + clientY = client.y; + edgePan.updatePointer(client); + const prevSnap = snapTarget; + snapTarget = this.findWireSnapTarget(fromNodeId, clientX, clientY); + if (prevSnap?.el !== snapTarget?.el) { + prevSnap?.el.classList.remove('pf-wire-snap'); + snapTarget?.el.classList.add('pf-wire-snap'); + } + this.wireDrag = makeWireDrag(snapTarget); + this.updateConnections(attrs); + }; + + for await (const mv of drag) { + processMove(mv.client); + } + snapTarget?.el.classList.remove('pf-wire-snap'); + this.rootEl.classList.remove('pf-ng--wire-dragging'); + + const reconnect = (toPort: string) => { + attrs.onDisconnect?.(connIdx); + onConnect?.({fromPort: existingConn.fromPort, toPort}); + }; + + if (snapTarget !== undefined) { + reconnect(snapTarget.portId); + } else { + // Fallback: check if pointer is directly over an input port. + const target = document.elementFromPoint(clientX, clientY); + const inputPortEl = + target?.closest('.pf-ng__port-dot.pf-input') ?? null; + if (inputPortEl) { + const nodeEl = inputPortEl.closest( + '[data-node-id]', + ) as HTMLElement | null; + const toNodeId = nodeEl?.getAttribute('data-node-id'); + const portId = inputPortEl.getAttribute('data-port-id'); + if (portId && toNodeId && toNodeId !== fromNodeId) { + reconnect(portId); + } else { + // Dropped on invalid target — disconnect. + attrs.onDisconnect?.(connIdx); + } + } else { + // Dropped on empty canvas — disconnect. + attrs.onDisconnect?.(connIdx); + } + } + + this.pickedUpConnIdx = undefined; + this.wireDrag = undefined; + this.updateConnections(attrs); + m.redraw(); + }; + }; + + // parentId is set for docked nodes; absent for root nodes. + const renderNode = ( + node: NodeGraphDockedNode, + parentId?: string, + ): m.Children => { + const isDocked = parentId !== undefined; + const rootNode = !isDocked ? (node as NodeGraphNode) : undefined; + + return m( + NGNode, + { + key: rootNode ? node.id : undefined, + id: node.id, + position: rootNode?.pos, + // Recursively render the whole tree + nextNode: node.next && renderNode(node.next, node.id), + onpointerdown: createNodeDragHandler(node, parentId), + }, + m( + NGCard, + { + hue: node.hue, + accent: node.accentBar, + selected: selectedNodeIds?.has(node.id), + className: node.className, + }, + [ + node.headerBar && + m(NGCardHeader, { + title: node.headerBar.title, + icon: node.headerBar.icon, + }), + node.inputs?.map((input) => + m(NGPort, { + id: input.id, + direction: input.direction, + portType: 'input', + label: input.label, + connected: + attrs.connections?.some((c) => c.toPort === input.id) ?? + false, + onpointerdown: createInputPortDragHandler(node, input), + }), + ), + m(NGCardBody, node.content), + node.outputs?.map((output) => { + const portEl = m(NGPort, { + id: output.id, + direction: output.direction, + portType: 'output', + label: output.label, + connected: + attrs.connections?.some((c) => c.fromPort === output.id) ?? + false, + onpointerdown: createOutputPortDragHandler(node, output), + }); + if (output.contextMenuItems == null) return portEl; + return m( + PopupMenu, + { + trigger: portEl, + position: PopupPosition.Bottom, + isOpen: this.openPortMenuId === output.id, + onChange: (open) => { + if (!open) { + this.openPortMenuId = undefined; + } + }, + }, + output.contextMenuItems, + ); + }), + ], + ), + ); + }; + + return m( + '.pf-ng', + { + style, + className, + onwheel: (e: MithrilEvent<WheelEvent>) => { + e.preventDefault(); + if (e.ctrlKey || e.metaKey) { + const newZoom = + this.viewport.zoom * + Math.exp(-e.deltaY * WHEEL_ZOOM_SCALING_FACTOR); + this.zoomViewport(newZoom, {x: e.clientX, y: e.clientY}); + } else { + this.panViewport(e.deltaX, e.deltaY); + } + onViewportMove?.(this.viewport); + }, + oncontextmenu: (e: MouseEvent) => { + e.preventDefault(); + }, + onpointerdown: async (e: MithrilEvent<PointerEvent>) => { + e.redraw = false; + const nodegraph = this.rootEl; + const isBoxSelect = e.shiftKey; + const drag = await captureDrag({ + el: nodegraph, + e, + deadzone: 2, + }); + if (drag) { + if (isBoxSelect) { + const boxEl = document.createElement('div'); + boxEl.style.cssText = + 'position:absolute;pointer-events:none;' + + 'border:1px dashed var(--pf-color-primary);' + + 'background:color-mix(in srgb,var(--pf-color-primary) 10%,transparent);' + + 'z-index:10;'; + this.rootEl.appendChild(boxEl); + + const updateBox = (currentClient: {x: number; y: number}) => { + const ngRect = this.rootEl.getBoundingClientRect(); + const x1 = Math.min(e.clientX, currentClient.x) - ngRect.left; + const y1 = Math.min(e.clientY, currentClient.y) - ngRect.top; + const x2 = Math.max(e.clientX, currentClient.x) - ngRect.left; + const y2 = Math.max(e.clientY, currentClient.y) - ngRect.top; + Object.assign(boxEl.style, { + left: `${x1}px`, + top: `${y1}px`, + width: `${x2 - x1}px`, + height: `${y2 - y1}px`, + }); + }; + + let currentClient = {x: e.clientX, y: e.clientY}; + for await (const mv of drag) { + currentClient = {x: mv.client.x, y: mv.client.y}; + updateBox(currentClient); + } + + boxEl.remove(); + + const boxLeft = Math.min(e.clientX, currentClient.x); + const boxTop = Math.min(e.clientY, currentClient.y); + const boxRight = Math.max(e.clientX, currentClient.x); + const boxBottom = Math.max(e.clientY, currentClient.y); + const ids: string[] = []; + for (const nodeEl of this.rootEl.querySelectorAll( + '[data-node-id]', + )) { + const r = nodeEl.getBoundingClientRect(); + if ( + r.left < boxRight && + r.right > boxLeft && + r.top < boxBottom && + r.bottom > boxTop + ) { + ids.push(nodeEl.getAttribute('data-node-id')!); + } + } + if (ids.length > 0) { + onSelect?.(ids); + } + } else { + for await (const mv of drag) { + this.panViewport(-mv.delta.x, -mv.delta.y); + } + onViewportMove?.(this.viewport); + } + } else { + onSelectionClear?.(); + } + m.redraw(); + }, + }, + m( + '.pf-ng__workspace', + nodes.map((n) => renderNode(n)), + m('svg.pf-ng__connections', { + style: { + position: 'absolute', + left: '0', + top: '0', + overflow: 'visible', + pointerEvents: 'none', + }, + }), + ), + m(NGToolbar, { + zoom: this.viewport.zoom, + onZoom: (level) => { + this.zoomViewport(level); + onViewportMove?.(this.viewport); + }, + onFit: () => { + this.autofit(); + onViewportMove?.(this.viewport); + }, + hasSelection: (selectedNodeIds?.size ?? 0) > 0, + onDeleteSelected: () => + onRemove?.(selectedNodeIds ? [...selectedNodeIds] : []), + extraItems: toolbarItems, + }), + m('.pf-ng__trashcan'), + ); + } + + oncreate({dom, attrs}: m.VnodeDOM<NodeGraphAttrs>) { + this.rootEl = assertIsInstance(dom, HTMLElement); + this.workspaceEl = assertIsInstance( + dom.querySelector('.pf-ng__workspace'), + HTMLElement, + ); + this.svgEl = assertIsInstance( + dom.querySelector('.pf-ng__connections'), + SVGSVGElement, + ); + const {initialViewport = {offset: {x: 0, y: 0}, zoom: 1.0}} = attrs; + this.viewport = { + zoom: initialViewport.zoom, + offset: {...initialViewport.offset}, + }; + this.latestAttrs = attrs; + this.updateViewport(); + this.updateConnections(attrs); + attrs.onReady?.(this.buildAPI()); + } + + onupdate({attrs}: m.VnodeDOM<NodeGraphAttrs>) { + this.latestAttrs = attrs; + this.updateConnections(attrs); + } + + private getNodeElement(nodeId: string) { + return assertIsInstance( + this.rootEl.querySelector(`[data-node-id="${nodeId}"]`), + HTMLElement, + ); + } + + // Returns the ID of the root node whose chain bottom is close enough to + // snap-dock the dropped node onto. The dragged node must have canDockTop and + // the candidate must have canDockBottom. Position comparison is done in + // viewport space using the rendered wrapper's bounding rect. + // Returns the ID of a node whose bottom edge is close to the top of the + // dragged node element, with horizontally aligned centers. + private findDockTarget(draggedNodeEl: HTMLElement): string | undefined { + // Build the set of node IDs that allow docking below them. + const dockableIds = new Set<string>(); + const collect = (node: NodeGraphDockedNode) => { + if (node.canDockBottom) dockableIds.add(node.id); + if (node.next) collect(node.next); + }; + for (const node of this.latestAttrs.nodes ?? []) collect(node); + + const THRESHOLD = 30; // viewport px + const draggedRect = draggedNodeEl.getBoundingClientRect(); + const draggedCenterX = (draggedRect.left + draggedRect.right) / 2; + for (const el of this.rootEl.querySelectorAll('[data-node-id]')) { + const id = el.getAttribute('data-node-id')!; + if (!dockableIds.has(id)) continue; + // Use the node-content bottom so nested docked children don't inflate the rect. + const body = el.querySelector( + ':scope > .pf-ng__card', + ) as HTMLElement | null; + const rect = (body ?? (el as HTMLElement)).getBoundingClientRect(); + const candidateCenterX = (rect.left + rect.right) / 2; + if ( + Math.abs(draggedRect.top - rect.bottom) < THRESHOLD && + Math.abs(draggedCenterX - candidateCenterX) < THRESHOLD + ) { + return id; + } + } + return undefined; + } + + // Returns the input port element + connection target closest to (clientX, + // clientY), or undefined if nothing is within the snap threshold. + private findWireSnapTarget( + fromNodeId: string, + clientX: number, + clientY: number, + ): {el: HTMLElement; portId: string} | undefined { + const THRESHOLD = 40; // viewport px + let best: {el: HTMLElement; portId: string; dist: number} | undefined; + + for (const el of this.rootEl.querySelectorAll( + '.pf-ng__port-dot.pf-input', + )) { + if (el.closest('.pf-hidden')) continue; + const nodeEl = el.closest('[data-node-id]') as HTMLElement | null; + const toNodeId = nodeEl?.getAttribute('data-node-id'); + if (!toNodeId || toNodeId === fromNodeId) continue; + const portId = el.getAttribute('data-port-id'); + if (!portId) continue; + const rect = el.getBoundingClientRect(); + const dist = Math.hypot( + clientX - (rect.left + rect.width / 2), + clientY - (rect.top + rect.height / 2), + ); + if (dist < THRESHOLD && (!best || dist < best.dist)) { + best = {el: el as HTMLElement, portId, dist}; + } + } + + return best ? {el: best.el, portId: best.portId} : undefined; + } + + // Searches the full node tree (including docked children) to find which node + // owns the given port, returning that node's ID. + private findNodeIdForPort(portId: string): string | undefined { + const search = (node: NodeGraphDockedNode): string | undefined => { + if (node.outputs?.some((p) => p.id === portId)) return node.id; + if (node.inputs?.some((p) => p.id === portId)) return node.id; + if (node.next) return search(node.next); + return undefined; + }; + for (const n of this.latestAttrs.nodes ?? []) { + const found = search(n); + if (found) return found; + } + return undefined; + } + + // Converts an input port element's center to canvas coordinates. + private portCenterToCanvas(portEl: HTMLElement): Point2D { + const rect = portEl.getBoundingClientRect(); + const ngRect = this.rootEl.getBoundingClientRect(); + const {zoom, offset} = this.viewport; + return { + x: (rect.left + rect.width / 2 - ngRect.left) / zoom + offset.x, + y: (rect.top + rect.height / 2 - ngRect.top) / zoom + offset.y, + }; + } + + private updateViewport() { + const {offset, zoom} = this.viewport; + const canvas = this.rootEl; + this.workspaceEl.style.transform = `scale(${zoom}) translate(${-offset.x}px, ${-offset.y}px)`; + const {size, posX, posY} = dotBackground(zoom, offset); + canvas.style.setProperty('--bg-size', `${size}px`); + canvas.style.setProperty('--bg-pos-x', `${-posX}px`); + canvas.style.setProperty('--bg-pos-y', `${-posY}px`); + } + + private zoomViewport(zoom: number, center?: Point2D) { + const canvas = this.rootEl; + const rect = canvas.getBoundingClientRect(); + if (!center) { + center = {x: rect.left + rect.width / 2, y: rect.top + rect.height / 2}; + } + const {offset, zoom: currentZoom} = this.viewport; + const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom)); + const mouseX = center.x - rect.left; + const mouseY = center.y - rect.top; + const canvasX = mouseX / currentZoom + offset.x; + const canvasY = mouseY / currentZoom + offset.y; + this.viewport = { + zoom: newZoom, + offset: { + x: canvasX - mouseX / newZoom, + y: canvasY - mouseY / newZoom, + }, + }; + this.updateViewport(); + } + + private panViewport(dx: number, dy: number) { + const {offset, zoom} = this.viewport; + this.viewport = { + ...this.viewport, + offset: {x: offset.x + dx / zoom, y: offset.y + dy / zoom}, + }; + this.updateViewport(); + } + + // Starts a RAF loop that pans the viewport when the pointer is near an edge. + // `getClientPos` returns the current pointer position in client coordinates. + // `onPan` (optional) is called with canvas-space deltas so the caller can + // update any accumulated canvas-space state (e.g. node position). + // Returns a stop function; call it when the drag ends. + private startEdgePanning( + onPan?: (canvasDx: number, canvasDy: number) => void, + ) { + const edgeForce = (v: number, lo: number, hi: number): number => { + if (v < lo + EDGE_DRAG_THRESHOLD) + return ( + (-(lo + EDGE_DRAG_THRESHOLD - v) / EDGE_DRAG_THRESHOLD) * + EDGE_DRAG_SPEED + ); + if (v > hi - EDGE_DRAG_THRESHOLD) + return ( + ((v - (hi - EDGE_DRAG_THRESHOLD)) / EDGE_DRAG_THRESHOLD) * + EDGE_DRAG_SPEED + ); + return 0; + }; + + let rafId: number | undefined; + let latestPointerPos: Point2D | undefined; + + const frame = () => { + const {x: cx, y: cy} = latestPointerPos!; + const rect = this.rootEl.getBoundingClientRect(); + const vpDx = edgeForce(cx, rect.left, rect.right); + const vpDy = edgeForce(cy, rect.top, rect.bottom); + if (vpDx !== 0 || vpDy !== 0) { + const {zoom} = this.viewport; + this.panViewport(vpDx, vpDy); + onPan?.(vpDx / zoom, vpDy / zoom); + } + rafId = requestAnimationFrame(frame); + }; + + return { + updatePointer: (pos: Point2D) => { + latestPointerPos = pos; + if (rafId === undefined) rafId = requestAnimationFrame(frame); + }, + [Symbol.dispose]: () => { + if (rafId !== undefined) cancelAnimationFrame(rafId); + }, + }; + } + + private updateConnections(attrs: NodeGraphAttrs) { + const {connections = []} = attrs; + const ngRect = this.rootEl.getBoundingClientRect(); + const {zoom, offset} = this.viewport; + + const getPortInfo = (portId: string) => { + const candidates = this.rootEl.querySelectorAll( + `.pf-ng__port-dot[data-port-id="${portId}"]`, + ); + const el = Array.from(candidates).find( + (e) => e.closest('.pf-hidden') === null, + ) as HTMLElement | undefined; + if (!el) return undefined; + const rect = el.getBoundingClientRect(); + + const dir = portDirFromEl(el); + const cx = (rect.left + rect.width / 2 - ngRect.left) / zoom + offset.x; + const cy = (rect.top + rect.height / 2 - ngRect.top) / zoom + offset.y; + return {x: cx, y: cy, dir}; + }; + + const paths = connections.flatMap((conn, i) => { + // Hide a connection that's been picked up by the active wire drag. + if (i === this.pickedUpConnIdx) return []; + const fromCenter = getPortInfo(conn.fromPort); + const to = getPortInfo(conn.toPort); + if (!fromCenter || !to) return []; + // Offset the start point to the outer edge of the output port dot. + const portRadius = 8 / zoom; + const from = { + x: + fromCenter.dir === 'right' + ? fromCenter.x + portRadius + : fromCenter.dir === 'left' + ? fromCenter.x - portRadius + : fromCenter.x, + y: + fromCenter.dir === 'bottom' + ? fromCenter.y + portRadius + : fromCenter.dir === 'top' + ? fromCenter.y - portRadius + : fromCenter.y, + dir: fromCenter.dir, + }; + const onRemove = () => this.latestAttrs.onDisconnect?.(i); + return [ + connectionPath( + from, + to, + this.markerId, + from.dir, + to.dir, + undefined, + onRemove, + ), + ]; + }); + + if (this.wireDrag) { + const {fromPortId, toPoint, toDir} = this.wireDrag; + const from = getPortInfo(fromPortId); + if (from) { + paths.push( + connectionPath(from, toPoint, this.markerId, from.dir, toDir, { + 'stroke-dasharray': '6 3', + }), + ); + } + } + + m.render(this.svgEl, [m('defs', arrowheadMarker(this.markerId)), ...paths]); + } + + private buildAPI(): NodeGraphAPI { + return { + autofit: () => { + this.autofit(); + m.redraw(); + }, + pan: (dx, dy) => { + this.panViewport(dx, dy); + this.latestAttrs.onViewportMove?.(this.viewport); + }, + zoom: (deltaZoom, centerX, centerY) => { + const center = + centerX !== undefined && centerY !== undefined + ? {x: centerX, y: centerY} + : undefined; + this.zoomViewport(this.viewport.zoom + deltaZoom, center); + this.latestAttrs.onViewportMove?.(this.viewport); + }, + resetZoom: () => { + this.zoomViewport(1.0); + this.latestAttrs.onViewportMove?.(this.viewport); + }, + findPlacementForNode: (node) => { + return this.findPlacementPosition(node); + }, + }; + } + + // Spiral search: starting from `origin` (already snapped to grid), walks + // outward ring by ring and returns the first position where a box of size + // (nodeW × nodeH) doesn't overlap any obstacle. Falls back to origin. + private spiralSearch( + nodeW: number, + nodeH: number, + origin: Point2D, + obstacles: ReadonlyArray<{x: number; y: number; w: number; h: number}>, + ): Point2D { + const collides = (x: number, y: number): boolean => + obstacles.some( + (o) => + x < o.x + o.w && x + nodeW > o.x && y < o.y + o.h && y + nodeH > o.y, + ); + + const {x: ox, y: oy} = origin; + if (!collides(ox, oy)) return origin; + + for (let r = 1; r <= 50; r++) { + const d = r * GRID_SIZE; + for (let i = -r; i <= r; i++) { + const dx = i * GRID_SIZE; + if (!collides(ox + dx, oy - d)) return {x: ox + dx, y: oy - d}; + if (!collides(ox + dx, oy + d)) return {x: ox + dx, y: oy + d}; + } + for (let j = -r + 1; j <= r - 1; j++) { + const dy = j * GRID_SIZE; + if (!collides(ox - d, oy + dy)) return {x: ox - d, y: oy + dy}; + if (!collides(ox + d, oy + dy)) return {x: ox + d, y: oy + dy}; + } + } + + return origin; // fallback + } + + // Finds a placement position for a newly added node. Starts from the + // viewport center and spirals outward until a non-colliding spot is found. + // Uses an estimated node size since the node hasn't been rendered yet. + private findPlacementPosition(_node: Omit<NodeGraphNode, 'pos'>): Point2D { + const ESTIMATED_W = 200; + const ESTIMATED_H = 150; + + const obstacles: Array<{x: number; y: number; w: number; h: number}> = []; + for (const el of this.workspaceEl.querySelectorAll( + ':scope > [data-node-id]', + )) { + const nodeEl = el as HTMLElement; + obstacles.push({ + x: parseFloat(nodeEl.style.left) || 0, + y: parseFloat(nodeEl.style.top) || 0, + w: nodeEl.offsetWidth, + h: nodeEl.offsetHeight, + }); + } + + const {offset, zoom} = this.viewport; + const rect = this.rootEl.getBoundingClientRect(); + const origin = snapToGrid({ + x: offset.x + rect.width / (2 * zoom) - ESTIMATED_W / 2, + y: offset.y + rect.height / (2 * zoom) - ESTIMATED_H / 2, + }); + + return this.spiralSearch(ESTIMATED_W, ESTIMATED_H, origin, obstacles); + } + + // Finds a non-colliding canvas position for a dropped node using a spiral + // search outward from the desired drop position. Returns the first grid- + // aligned position that doesn't overlap any other root node. + private findNonCollidingPosition( + draggedNodeEl: HTMLElement, + desiredPos: Vector2D, + draggedNodeId: string, + ): Vector2D { + const nodeW = draggedNodeEl.offsetWidth; + const nodeH = draggedNodeEl.offsetHeight; + + const obstacles: Array<{x: number; y: number; w: number; h: number}> = []; + for (const el of this.workspaceEl.querySelectorAll( + ':scope > [data-node-id]', + )) { + if (el.getAttribute('data-node-id') === draggedNodeId) continue; + const nodeEl = el as HTMLElement; + obstacles.push({ + x: parseFloat(nodeEl.style.left) || 0, + y: parseFloat(nodeEl.style.top) || 0, + w: nodeEl.offsetWidth, + h: nodeEl.offsetHeight, + }); + } + + return new Vector2D(this.spiralSearch(nodeW, nodeH, desiredPos, obstacles)); + } + + private autofit() { + const PADDING = 40; // screen px of breathing room on each side + const nodeEls = Array.from( + this.workspaceEl.querySelectorAll(':scope > [data-node-id]'), + ) as HTMLElement[]; + + // If there are no nodes, do nothing. + if (nodeEls.length === 0) { + return; + } + + // Node left/top style is in canvas px; offsetWidth/Height are layout px + // (unaffected by the workspace CSS transform), so also canvas px. + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + for (const el of nodeEls) { + const x = parseFloat(el.style.left) || 0; + const y = parseFloat(el.style.top) || 0; + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x + el.offsetWidth); + maxY = Math.max(maxY, y + el.offsetHeight); + } + + const containerW = this.rootEl.offsetWidth; + const containerH = this.rootEl.offsetHeight; + const bbW = maxX - minX; + const bbH = maxY - minY; + + const zoom = Math.max( + MIN_ZOOM, + Math.min( + 1.0, + Math.min( + (containerW - 2 * PADDING) / bbW, + (containerH - 2 * PADDING) / bbH, + ), + ), + ); + + // Center: canvas midpoint should map to screen midpoint. + // screen = (canvas - offset) * zoom → offset = canvas - screen / zoom + this.viewport = { + zoom, + offset: { + x: (minX + maxX) / 2 - containerW / (2 * zoom), + y: (minY + maxY) / 2 - containerH / (2 * zoom), + }, + }; + this.updateViewport(); + } +}
diff --git a/ui/src/widgets/nodegraph/views/toolbar.ts b/ui/src/widgets/nodegraph/views/toolbar.ts new file mode 100644 index 0000000..a36490c --- /dev/null +++ b/ui/src/widgets/nodegraph/views/toolbar.ts
@@ -0,0 +1,85 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import m from 'mithril'; +import {Button, ButtonGroup, ButtonVariant} from '../../button'; + +export const ZOOM_LEVELS: readonly number[] = [ + 0.01, 0.02, 0.05, 0.1, 0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 3, +]; + +export interface ToolbarAttrs { + readonly zoom: number; + readonly onZoom: (level: number) => void; + readonly onFit: () => void; + readonly hasSelection: boolean; + readonly onDeleteSelected: () => void; + readonly extraItems?: m.Children; +} + +export const NGToolbar: m.Component<ToolbarAttrs> = { + view({attrs: {zoom, onZoom, onFit, hasSelection, onDeleteSelected, extraItems}}) { + const zoomIn = + ZOOM_LEVELS.find((l) => l > zoom + 0.01) ?? + ZOOM_LEVELS[ZOOM_LEVELS.length - 1]; + const zoomOut = + [...ZOOM_LEVELS].reverse().find((l) => l < zoom - 0.01) ?? ZOOM_LEVELS[0]; + + return m( + '.pf-ng__toolbar', + { + onpointerdown: (e: PointerEvent) => { + e.stopPropagation(); // Prevent toolbar clicks from starting a canvas drag + }, + }, + [ + extraItems, + hasSelection && + m(Button, { + title: 'Delete selected', + icon: 'delete', + variant: ButtonVariant.Filled, + onclick: onDeleteSelected, + }), + m( + ButtonGroup, + m(Button, { + title: 'Fit to screen', + icon: 'center_focus_strong', + variant: ButtonVariant.Filled, + onclick: onFit, + }), + m(Button, { + label: `${Math.round(zoom * 100)}%`, + title: 'Select zoom level', + variant: ButtonVariant.Filled, + onclick: () => onZoom(1), + }), + m(Button, { + title: 'Zoom in', + icon: 'zoom_in', + variant: ButtonVariant.Filled, + onclick: () => onZoom(zoomIn), + }), + m(Button, { + title: 'Zoom out', + icon: 'zoom_out', + variant: ButtonVariant.Filled, + onclick: () => onZoom(zoomOut), + }), + ), + ], + ); + }, +};