NodeGraph animations and toolbar - Animate nodes when they move automatically - Add resetZoom API (reset to 100% zoom) - Tweak built-in toolbar
diff --git a/ui/src/assets/widgets/nodegraph.scss b/ui/src/assets/widgets/nodegraph.scss index 97ec54b..555f059 100644 --- a/ui/src/assets/widgets/nodegraph.scss +++ b/ui/src/assets/widgets/nodegraph.scss
@@ -93,29 +93,104 @@ &:hover .pf-show-on-hover { visibility: visible; } -} -.pf-node--has-accent-bar::before { - content: ""; - position: absolute; - top: 0; - left: 0; - width: 10px; - height: 100%; - background-color: var( - --pf-color-border - ); /* Fallback color when hue not set */ - border-top-left-radius: 6px; - border-bottom-left-radius: 6px; -} + &__dock-indicator-bottom { + position: absolute; + bottom: -8px; + left: 50%; + width: calc(var(--tab-width) - 6px); + height: 8px; + transform: translateX(-50%); + background-color: var(--pf-color-background); + border-bottom-right-radius: 5px; + border-bottom-left-radius: 5px; + box-shadow: 0 2px 6px var(--pf-color-box-shadow); + clip-path: inset(0 -10px -10px -10px); + } -/* 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 { - background: color-mix( - in srgb, - hsl(var(--pf-node-hue), 60%, 50%) 50%, - var(--pf-color-background) - ); + &.pf-selected { + .pf-node__dock-indicator-bottom { + outline: 3px solid + color-mix( + in srgb, + var(--pf-color-accent) 50%, + var(--pf-color-background) + ); + } + } + + &__dock-indicator-top { + position: absolute; + top: 0; + left: 50%; + width: var(--tab-width); + height: 8px; + transform: translateX(-50%); + background-color: var(--pf-color-border); + border-bottom-right-radius: 8px; + border-bottom-left-radius: 8px; + } + + &--has-docked-child { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + + &.pf-node--has-accent-bar:before { + border-bottom-left-radius: 0; + } + + .pf-node__dock-indicator-bottom { + box-shadow: none; + } + } + + &--is-docked-child { + border-top: solid 3px var(--pf-color-border); + border-top-left-radius: 0; + border-top-right-radius: 0; + + &.pf-node--has-accent-bar:before { + border-top-left-radius: 0; + } + + &.pf-selected { + border-top: none; + margin-top: 3px; + } + } + + &--has-accent-bar::before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 10px; + height: 100%; + background-color: var( + --pf-color-border + ); /* Fallback color when hue not set */ + border-top-left-radius: 8px; + border-bottom-left-radius: 8px; + } + + &.pf-selected { + .pf-node__dock-indicator-top { + background-color: color-mix( + in srgb, + var(--pf-color-accent) 50%, + var(--pf-color-background) + ); + } + } + + /* Override with hue-based color when --pf-node-hue is set via inline styles */ + &[style*="--pf-node-hue"].pf-node--has-accent-bar::before { + background: color-mix( + in srgb, + hsl(var(--pf-node-hue, gray), 50%, 50%) 80%, + var(--pf-color-background) + ); + } } .pf-node-body { @@ -132,15 +207,52 @@ 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; + + // Pop-in animation when node first appears + animation: pop-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); &--dragging { z-index: 1; // Ensure dragging entire chain appears above other nodes. + 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); } + + &--no-animation { + animation: none; // Disable animation for temporary/measurement nodes + } +} + +@keyframes pop-in { + 0% { + opacity: 0; + transform: scale(0.8); + } + 100% { + opacity: 1; + transform: scale(1); + } +} + +@keyframes pop-out { + 0% { + opacity: 1; + transform: scale(1); + } + 100% { + opacity: 0; + transform: scale(0.8); + } +} + +.pf-node-wrapper--removing { + animation: pop-out 0.1s cubic-bezier(0.4, 0, 0.68, 0.06) forwards; } .pf-node.pf-selected {
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 75180bc..2ab4529 100644 --- a/ui/src/plugins/dev.perfetto.WidgetsPage/demos/nodegraph_demo.ts +++ b/ui/src/plugins/dev.perfetto.WidgetsPage/demos/nodegraph_demo.ts
@@ -15,7 +15,7 @@ import m from 'mithril'; import {produce} from 'immer'; import {uuidv4} from '../../../base/uuid'; -import {Button, ButtonVariant} from '../../../widgets/button'; +import {Button, ButtonGroup, ButtonVariant} from '../../../widgets/button'; import {Checkbox} from '../../../widgets/checkbox'; import {MenuItem, PopupMenu} from '../../../widgets/menu'; import { @@ -31,6 +31,8 @@ import {TextInput} from '../../../widgets/text_input'; import {renderDocSection, renderWidgetShowcase} from '../widgets_page_utils'; +const MAX_HISTORY_DEPTH = 500; + // Base node data interface interface BaseNodeData { readonly id: string; @@ -106,41 +108,30 @@ const NODE_CONFIGS: Record<NodeData['type'], NodeConfig> = { table: { - outputs: [{content: 'Output', direction: 'bottom'}], canDockBottom: true, hue: 200, icon: 'table_chart', }, select: { - inputs: [{content: 'Input', direction: 'top'}], - outputs: [{content: 'Output', direction: 'bottom'}], + outputs: [{content: 'Output', direction: 'right'}], canDockTop: true, - canDockBottom: true, hue: 100, icon: 'checklist', }, filter: { - inputs: [{content: 'Input', direction: 'top'}], - outputs: [{content: 'Output', direction: 'bottom'}], canDockTop: true, canDockBottom: true, hue: 50, icon: 'filter_alt', }, sort: { - inputs: [{content: 'Input', direction: 'top'}], - outputs: [{content: 'Output', direction: 'bottom'}], canDockTop: true, canDockBottom: true, hue: 150, icon: 'sort', }, join: { - inputs: [ - {content: 'Left', direction: 'top'}, - {content: 'Right', direction: 'left'}, - ], - outputs: [{content: 'Output', direction: 'bottom'}], + inputs: [{content: 'Right', direction: 'left'}], canDockTop: true, canDockBottom: true, hue: 300, @@ -148,17 +139,14 @@ }, union: { inputs: [ - {content: 'Input 1', direction: 'top'}, + {content: 'Input 1', direction: 'left'}, {content: 'Input 2', direction: 'left'}, ], - outputs: [{content: 'Output', direction: 'bottom'}], - canDockTop: true, canDockBottom: true, hue: 240, icon: 'merge', }, result: { - inputs: [{content: 'Input', direction: 'top'}], canDockTop: true, hue: 0, icon: 'output', @@ -525,8 +513,8 @@ history.push(store); historyIndex = history.length - 1; - // Limit history to prevent memory issues (keep last 50 states) - if (history.length > 50) { + // Limit history to prevent memory issues + if (history.length > MAX_HISTORY_DEPTH) { history.shift(); historyIndex--; } @@ -741,10 +729,21 @@ 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); @@ -761,9 +760,14 @@ const colList = selectedCols.length > 0 ? selectedCols.join(', ') : '*'; const inputSql = dockedParent - ? buildSqlFromNode(nodes, connections, dockedParent.id) + ? buildSqlFromNode(nodes, connections, dockedParent.id, newVisited) : connectedInputs.get(0) - ? buildSqlFromNode(nodes, connections, connectedInputs.get(0)!.id) + ? buildSqlFromNode( + nodes, + connections, + connectedInputs.get(0)!.id, + newVisited, + ) : ''; if (!inputSql) return `SELECT ${colList}`; @@ -774,9 +778,14 @@ const filterExpr = node.filterExpression || ''; const inputSql = dockedParent - ? buildSqlFromNode(nodes, connections, dockedParent.id) + ? buildSqlFromNode(nodes, connections, dockedParent.id, newVisited) : connectedInputs.get(0) - ? buildSqlFromNode(nodes, connections, connectedInputs.get(0)!.id) + ? buildSqlFromNode( + nodes, + connections, + connectedInputs.get(0)!.id, + newVisited, + ) : ''; if (!inputSql) return ''; @@ -789,9 +798,14 @@ const sortOrder = node.sortOrder || 'ASC'; const inputSql = dockedParent - ? buildSqlFromNode(nodes, connections, dockedParent.id) + ? buildSqlFromNode(nodes, connections, dockedParent.id, newVisited) : connectedInputs.get(0) - ? buildSqlFromNode(nodes, connections, connectedInputs.get(0)!.id) + ? buildSqlFromNode( + nodes, + connections, + connectedInputs.get(0)!.id, + newVisited, + ) : ''; if (!inputSql) return ''; @@ -803,15 +817,18 @@ const joinType = node.joinType || 'INNER'; const joinOn = node.joinOn || 'true'; - // Join needs two inputs: one docked (or from top connection) and one from left connection + // Join needs two inputs: one docked and one from left connection const leftInput = dockedParent - ? buildSqlFromNode(nodes, connections, dockedParent.id) - : connectedInputs.get(0) - ? buildSqlFromNode(nodes, connections, connectedInputs.get(0)!.id) - : ''; + ? buildSqlFromNode(nodes, connections, dockedParent.id, newVisited) + : ''; - const rightInput = connectedInputs.get(1) - ? buildSqlFromNode(nodes, connections, connectedInputs.get(1)!.id) + const rightInput = connectedInputs.get(0) + ? buildSqlFromNode( + nodes, + connections, + connectedInputs.get(0)!.id, + newVisited, + ) : ''; if (!leftInput || !rightInput) return leftInput || rightInput || ''; @@ -823,12 +840,16 @@ const inputs: string[] = []; - // Collect all inputs (docked + connections) + // Collect all inputs from left connections (no docked parent for union) if (dockedParent) { - inputs.push(buildSqlFromNode(nodes, connections, dockedParent.id)); + inputs.push( + buildSqlFromNode(nodes, connections, dockedParent.id, newVisited), + ); } for (const [_, inputNode] of connectedInputs) { - inputs.push(buildSqlFromNode(nodes, connections, inputNode.id)); + inputs.push( + buildSqlFromNode(nodes, connections, inputNode.id, newVisited), + ); } const validInputs = inputs.filter((sql) => sql); @@ -839,9 +860,14 @@ case 'result': { const inputSql = dockedParent - ? buildSqlFromNode(nodes, connections, dockedParent.id) + ? buildSqlFromNode(nodes, connections, dockedParent.id, newVisited) : connectedInputs.get(0) - ? buildSqlFromNode(nodes, connections, connectedInputs.get(0)!.id) + ? buildSqlFromNode( + nodes, + connections, + connectedInputs.get(0)!.id, + newVisited, + ) : ''; return inputSql; } @@ -1102,23 +1128,12 @@ const nodeGraphAttrs: NodeGraphAttrs = { toolbarItems: [ - m(Button, { - label: 'Undo', - icon: 'undo', - disabled: !canUndo(), - onclick: undo, - }), - m(Button, { - label: 'Redo', - icon: 'redo', - disabled: !canRedo(), - onclick: redo, - }), m( PopupMenu, { trigger: m(Button, { label: 'Add Node', + title: 'Add a new node to the graph', icon: 'add', variant: ButtonVariant.Filled, }), @@ -1182,13 +1197,45 @@ }), ], ), - m(Button, { - label: 'Stress Test', - icon: 'science', - variant: ButtonVariant.Filled, - title: 'Generate a large random graph for performance testing', - onclick: () => runStressTest(), - }), + m( + ButtonGroup, + m(Button, { + icon: 'undo', + title: 'Undo', + disabled: !canUndo(), + variant: ButtonVariant.Filled, + onclick: undo, + }), + m(Button, { + icon: 'redo', + title: 'Redo', + disabled: !canRedo(), + variant: ButtonVariant.Filled, + onclick: redo, + }), + ), + m( + ButtonGroup, + m(Button, { + title: + 'Add a large number of random nodes and connections for performance testing', + icon: 'science', + variant: ButtonVariant.Filled, + onclick: () => runStressTest(), + }), + m(Button, { + title: 'Remove all nodes from the graph', + icon: 'delete', + variant: ButtonVariant.Filled, + onclick: () => { + updateStore((draft) => { + draft.nodes.clear(); + draft.connections.length = 0; + }); + selectedNodeIds.clear(); + }, + }), + ), ], nodes: renderNodes(), connections: store.connections,
diff --git a/ui/src/widgets/nodegraph.ts b/ui/src/widgets/nodegraph.ts index 72abedb..33fec12 100644 --- a/ui/src/widgets/nodegraph.ts +++ b/ui/src/widgets/nodegraph.ts
@@ -48,7 +48,7 @@ * ``` */ import m from 'mithril'; -import {Button, ButtonVariant} from './button'; +import {Button, ButtonGroup, ButtonVariant} from './button'; import {Icon} from './icon'; import {PopupMenu} from './menu'; import {classNames} from '../base/classnames'; @@ -191,6 +191,11 @@ * @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 { @@ -415,6 +420,7 @@ // 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; @@ -2087,45 +2093,48 @@ tempContainer.style.visibility = 'hidden'; canvas.appendChild(tempContainer); - // Render the node into the temporary container + // Render the node into the temporary container with animation disabled m.render( tempContainer, m( - '.pf-node', - { - 'data-node': tempNode.id, - 'style': { - ...(tempNode.hue !== undefined - ? {'--pf-node-hue': `${tempNode.hue}`} - : {}), + '.pf-node-wrapper.pf-node-wrapper--no-animation', + 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), + [ + 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'), + ]), + ), ]), - 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'), - ]), - ), - ]), - ], + ], + ), ), ); @@ -2156,6 +2165,31 @@ 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 && @@ -2169,6 +2203,7 @@ findPlacementForNode: findPlacementForNodeApi, panBy, zoomBy, + resetZoom, }); } }, @@ -2199,7 +2234,8 @@ onReady !== undefined && autoLayoutApi !== null && recenterApi !== null && - findPlacementForNodeApi !== null + findPlacementForNodeApi !== null && + resetZoom !== null ) { onReady({ autoLayout: autoLayoutApi, @@ -2207,6 +2243,7 @@ findPlacementForNode: findPlacementForNodeApi, panBy, zoomBy, + resetZoom, }); } }, @@ -2222,7 +2259,6 @@ const { nodes, selectedNodeIds = new Set<string>(), - hideControls = false, multiselect = true, contextMenuOnHover = false, fillHeight, @@ -2340,37 +2376,44 @@ }, }, [ - // Control buttons (can be hidden via hideControls prop) - !hideControls && + (vnode.attrs.toolbarItems !== undefined || + !vnode.attrs.hideControls) && m('.pf-nodegraph-controls', [ vnode.attrs.toolbarItems, - m(Button, { - label: 'Auto Layout', - icon: 'account_tree', - variant: ButtonVariant.Filled, - onclick: () => { - const { - nodes = [], - connections = [], - onNodeMove, - } = vnode.attrs; - autoLayoutGraph(nodes, connections, onNodeMove); - }, - }), - m(Button, { - label: 'Fit to Screen', - icon: 'center_focus_strong', - variant: ButtonVariant.Filled, - onclick: (e: PointerEvent) => { - const {nodes = [], labels = []} = vnode.attrs; - const canvas = (e.currentTarget as HTMLElement).closest( - '.pf-canvas', - ); - if (canvas) { - autofit(nodes, labels, canvas as HTMLElement); - } - }, - }), + !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 @@ -2420,6 +2463,14 @@ canvasState.draggedNode === id && 'pf-node-wrapper--dragging', ), + onbeforeremove: (vnode: m.VnodeDOM) => { + return new Promise((resolve) => { + vnode.dom.classList.add( + 'pf-node-wrapper--removing', + ); + setTimeout(resolve, 200); // Match animation duration + }); + }, }, chain.map((chainNode) => { const cIsDockedChild = 'x' in chainNode === false; @@ -2452,6 +2503,14 @@ canvasState.draggedNode === id && 'pf-node-wrapper--dragging', ), + onbeforeremove: (vnode: m.VnodeDOM) => { + return new Promise((resolve) => { + vnode.dom.classList.add( + 'pf-node-wrapper--removing', + ); + setTimeout(resolve, 200); // Match animation duration + }); + }, }, renderNode(node, vnode, { isDockedChild: false,