From 7cd5531730d2301d1a6df1d7064a48aed53c84ef Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 1 May 2026 05:24:59 -0700 Subject: [PATCH] Optimize the node graph panel while panning --- frontend/src/components/views/Graph.svelte | 140 +++++++++++---------- frontend/src/stores/node-graph.ts | 75 ++++++----- 2 files changed, 116 insertions(+), 99 deletions(-) diff --git a/frontend/src/components/views/Graph.svelte b/frontend/src/components/views/Graph.svelte index 5a410106..c34d06a0 100644 --- a/frontend/src/components/views/Graph.svelte +++ b/frontend/src/components/views/Graph.svelte @@ -20,13 +20,17 @@ const editor = getContext("editor"); const nodeGraph = getContext("nodeGraph"); + const nodeGraphTransform = nodeGraph.transformStore; + const nodeGraphImportsExports = nodeGraph.importsExportsStore; + const visibleNodes = nodeGraph.visibleNodesStore; + const nodeGraphWires = nodeGraph.wiresStore; const documentState = getContext("document"); const subscriptions = getContext("subscriptions"); let graph: HTMLDivElement | undefined; - $: gridSpacing = calculateGridSpacing($nodeGraph.transform.scale); - $: gridDotRadius = 1 + Math.floor($nodeGraph.transform.scale - 0.5 + 0.001) / 2; + $: gridSpacing = calculateGridSpacing($nodeGraphTransform.scale); + $: gridDotRadius = 1 + Math.floor($nodeGraphTransform.scale - 0.5 + 0.001) / 2; // Close the context menu when the graph view overlay is closed $: if (!$documentState.graphViewOverlayOpen) closeContextMenu(); @@ -215,23 +219,22 @@ } -
+
+
{#if $nodeGraph.contextMenuInformation} +
{$nodeGraph.error.error} @@ -295,7 +298,7 @@ {#if $nodeGraph.clickTargets} -
+
{#each $nodeGraph.clickTargets.nodeClickTargets as pathString} @@ -318,9 +321,9 @@ {/if} -
+
- {#each $nodeGraph.wires.values() as map} + {#each $nodeGraphWires.values() as map} {#each map.values() as { pathString, dataType, thick, dashed }} {#if thick} -
- {#if $nodeGraph.updateImportsExports} - {#each $nodeGraph.updateImportsExports.imports as frontendOutput, index} +
+ {#if $nodeGraphImportsExports} + {#each $nodeGraphImportsExports.imports as frontendOutput, index} {#if frontendOutput} {#if frontendOutput.connectedTo.length > 0} @@ -365,10 +368,10 @@ on:pointerenter={() => (hoveringImportIndex = index)} on:pointerleave={() => (hoveringImportIndex = undefined)} class="edit-import-export import" - class:separator-bottom={index === 0 && $nodeGraph.updateImportsExports.addImportExport} - class:separator-top={index === 1 && $nodeGraph.updateImportsExports.addImportExport} - style:--offset-left={($nodeGraph.updateImportsExports.importPosition[0] - 8) / 24} - style:--offset-top={($nodeGraph.updateImportsExports.importPosition[1] - 8) / 24 + index} + class:separator-bottom={index === 0 && $nodeGraphImportsExports.addImportExport} + class:separator-top={index === 1 && $nodeGraphImportsExports.addImportExport} + style:--offset-left={($nodeGraphImportsExports.importPosition[0] - 8) / 24} + style:--offset-top={($nodeGraphImportsExports.importPosition[1] - 8) / 24 + index} > {#if editingNameImportIndex === index}
+
editor.addPrimaryImport()} />
{/if} {/each} - {#each $nodeGraph.updateImportsExports.exports as frontendInput, index} + {#each $nodeGraphImportsExports.exports as frontendInput, index} {#if frontendInput} {#if frontendInput.connectedTo !== "Connected to nothing."} @@ -437,12 +436,12 @@ on:pointerenter={() => (hoveringExportIndex = index)} on:pointerleave={() => (hoveringExportIndex = undefined)} class="edit-import-export export" - class:separator-bottom={index === 0 && $nodeGraph.updateImportsExports.addImportExport} - class:separator-top={index === 1 && $nodeGraph.updateImportsExports.addImportExport} - style:--offset-left={($nodeGraph.updateImportsExports.exportPosition[0] - 8) / 24} - style:--offset-top={($nodeGraph.updateImportsExports.exportPosition[1] - 8) / 24 + index} + class:separator-bottom={index === 0 && $nodeGraphImportsExports.addImportExport} + class:separator-top={index === 1 && $nodeGraphImportsExports.addImportExport} + style:--offset-left={($nodeGraphImportsExports.exportPosition[0] - 8) / 24} + style:--offset-top={($nodeGraphImportsExports.exportPosition[1] - 8) / 24 + index} > - {#if (hoveringExportIndex === index || editingNameExportIndex === index) && $nodeGraph.updateImportsExports.addImportExport} + {#if (hoveringExportIndex === index || editingNameExportIndex === index) && $nodeGraphImportsExports.addImportExport} {#if index > 0}
{/if} @@ -473,28 +472,24 @@ {/if}
{:else} -
+
editor.addPrimaryExport()} />
{/if} {/each} - {#if $nodeGraph.updateImportsExports.addImportExport} + {#if $nodeGraphImportsExports.addImportExport}
editor.addSecondaryImport()} />
editor.addSecondaryExport()} />
@@ -502,16 +497,16 @@ {#if $nodeGraph.reorderImportIndex !== undefined} {@const position = { - x: Number($nodeGraph.updateImportsExports.importPosition[0]), - y: Number($nodeGraph.updateImportsExports.importPosition[1]) + Number($nodeGraph.reorderImportIndex) * 24, + x: Number($nodeGraphImportsExports.importPosition[0]), + y: Number($nodeGraphImportsExports.importPosition[1]) + Number($nodeGraph.reorderImportIndex) * 24, }}
{/if} {#if $nodeGraph.reorderExportIndex !== undefined} {@const position = { - x: Number($nodeGraph.updateImportsExports.exportPosition[0]), - y: Number($nodeGraph.updateImportsExports.exportPosition[1]) + Number($nodeGraph.reorderExportIndex) * 24, + x: Number($nodeGraphImportsExports.exportPosition[0]), + y: Number($nodeGraphImportsExports.exportPosition[1]) + Number($nodeGraph.reorderExportIndex) * 24, }}
{/if} @@ -519,11 +514,11 @@
-
+
{#each Array.from($nodeGraph.nodes) - .filter(([nodeId, node]) => node.isLayer && $nodeGraph.visibleNodes.has(nodeId)) - .map(([_, node], nodeIndex) => ({ node, nodeIndex })) as { node, nodeIndex } (nodeIndex)} + .filter(([nodeId, node]) => node.isLayer && $visibleNodes.has(nodeId)) + .map(([_, node]) => node) as node (node.id)} {@const clipPathId = String(Math.random()).substring(2)} {@const stackDataInput = node.exposedInputs[0]} {@const layerAreaWidth = $nodeGraph.layerWidths.get(node.id) || 8} @@ -687,7 +682,7 @@
- {#each $nodeGraph.wires.values() as map} + {#each $nodeGraphWires.values() as map} {#each map.values() as { pathString, dataType, thick, dashed }} {#if !thick} {#each Array.from($nodeGraph.nodes) - .filter(([nodeId, node]) => !node.isLayer && $nodeGraph.visibleNodes.has(nodeId)) - .map(([_, node], nodeIndex) => ({ node, nodeIndex })) as { node, nodeIndex } (nodeIndex)} + .filter(([nodeId, node]) => !node.isLayer && $visibleNodes.has(nodeId)) + .map(([_, node]) => node) as node (node.id)} {@const exposedInputsOutputs = zipWithUndefined(node.exposedInputs, node.exposedOutputs)} {@const clipPathId = String(Math.random()).substring(2)} {@const description = node.reference ? $nodeGraph.nodeDescriptions.get(node.reference) : undefined} @@ -870,18 +865,25 @@ flex-direction: row; flex-grow: 1; - // We're displaying the dotted grid in a pseudo-element because `image-rendering` is an inherited property and we don't want it to apply to child elements - &::before { - content: ""; + .grid-background { position: absolute; width: 100%; height: 100%; - background-size: var(--grid-spacing) var(--grid-spacing); - background-position: calc(var(--grid-offset-x) - var(--grid-dot-radius)) calc(var(--grid-offset-y) - var(--grid-dot-radius)); - background-image: radial-gradient(circle at var(--grid-dot-radius) var(--grid-dot-radius), var(--color-3-darkgray) var(--grid-dot-radius), transparent 0); - background-repeat: repeat; - image-rendering: pixelated; - mix-blend-mode: screen; + pointer-events: none; + + // We're displaying the dotted grid in a pseudo-element because `image-rendering` is an inherited property and we don't want it to apply to child elements + &::before { + content: ""; + position: absolute; + width: 100%; + height: 100%; + background-size: var(--grid-spacing) var(--grid-spacing); + background-position: calc(var(--grid-offset-x) - var(--grid-dot-radius)) calc(var(--grid-offset-y) - var(--grid-dot-radius)); + background-image: radial-gradient(circle at var(--grid-dot-radius) var(--grid-dot-radius), var(--color-3-darkgray) var(--grid-dot-radius), transparent 0); + background-repeat: repeat; + image-rendering: pixelated; + mix-blend-mode: screen; + } } > img { diff --git a/frontend/src/stores/node-graph.ts b/frontend/src/stores/node-graph.ts index 4a914847..24d7c7e7 100644 --- a/frontend/src/stores/node-graph.ts +++ b/frontend/src/stores/node-graph.ts @@ -6,6 +6,8 @@ import type { NodeGraphErrorDiagnostic, BoxSelection, FrontendClickTargets, Cont export type NodeGraphStore = ReturnType; +export type NodeGraphTransform = { scale: number; x: number; y: number }; + type NodeGraphStoreState = { box: BoxSelection | undefined; clickTargets: FrontendClickTargets | undefined; @@ -14,17 +16,12 @@ type NodeGraphStoreState = { layerWidths: Map; chainWidths: Map; hasLeftInputWire: Map; - updateImportsExports: MessageBody<"UpdateImportsExports"> | undefined; nodes: Map; - visibleNodes: Set; - /// The index is the exposed input index. The exports have a first key value of u32::MAX. - wires: Map>; wirePathInProgress: WirePath | undefined; nodeDescriptions: Map; nodeTypes: FrontendNodeType[]; thumbnails: Map; selected: bigint[]; - transform: { scale: number; x: number; y: number }; inSelectedNetwork: boolean; reorderImportIndex: number | undefined; reorderExportIndex: number | undefined; @@ -37,16 +34,12 @@ const initialState: NodeGraphStoreState = { layerWidths: new Map(), chainWidths: new Map(), hasLeftInputWire: new Map(), - updateImportsExports: undefined, nodes: new Map(), - visibleNodes: new Set(), - wires: new Map(), wirePathInProgress: undefined, nodeDescriptions: new Map(), nodeTypes: [], thumbnails: new Map(), selected: [], - transform: { scale: 1, x: 0, y: 0 }, inSelectedNetwork: true, reorderImportIndex: undefined, reorderExportIndex: undefined, @@ -59,6 +52,22 @@ const store: Writable = import.meta.hot?.data?.store || wri if (import.meta.hot) import.meta.hot.data.store = store; const { subscribe, update } = store; +// Separate transform store so pan/zoom updates don't trigger re-rendering the entire node graph +const transformStore: Writable = import.meta.hot?.data?.transformStore || writable({ scale: 1, x: 0, y: 0 }); +if (import.meta.hot) import.meta.hot.data.transformStore = transformStore; + +// Separate imports/exports store so viewport-anchored position updates don't trigger node re-renders +const importsExportsStore: Writable | undefined> = import.meta.hot?.data?.importsExportsStore || writable(undefined); +if (import.meta.hot) import.meta.hot.data.importsExportsStore = importsExportsStore; + +// Separate visible nodes store so viewport culling changes don't trigger full node re-renders +const visibleNodesStore: Writable> = import.meta.hot?.data?.visibleNodesStore || writable(new Set()); +if (import.meta.hot) import.meta.hot.data.visibleNodesStore = visibleNodesStore; + +// Separate wires store so wire path updates (e.g. export connector movement during pan) don't trigger node re-renders +const wiresStore: Writable>> = import.meta.hot?.data?.wiresStore || writable(new Map()); +if (import.meta.hot) import.meta.hot.data.wiresStore = wiresStore; + export function createNodeGraphStore(subscriptions: SubscriptionsRouter) { destroyNodeGraphStore(); @@ -108,10 +117,7 @@ export function createNodeGraphStore(subscriptions: SubscriptionsRouter) { }); subscriptions.subscribeFrontendMessage("UpdateImportsExports", (data) => { - update((state) => { - state.updateImportsExports = data; - return state; - }); + importsExportsStore.set(data); }); subscriptions.subscribeFrontendMessage("UpdateInSelectedNetwork", (data) => { @@ -148,20 +154,35 @@ export function createNodeGraphStore(subscriptions: SubscriptionsRouter) { }); subscriptions.subscribeFrontendMessage("UpdateVisibleNodes", (data) => { - update((state) => { - state.visibleNodes = new Set(data.nodes); - return state; + const newNodes = new Set(data.nodes); + + // Short-circuit when the visible set hasn't changed to avoid unnecessary re-renders + let changed = false; + const unsubscribe = visibleNodesStore.subscribe((current) => { + if (current.size !== newNodes.size) { + changed = true; + } else { + newNodes.forEach((node) => { + if (!current.has(node)) changed = true; + }); + } }); + unsubscribe(); + + if (!changed) return; + + visibleNodesStore.set(newNodes); }); subscriptions.subscribeFrontendMessage("UpdateNodeGraphWires", (data) => { - update((state) => { + if (data.wires.length === 0) return; + + wiresStore.update((wires) => { data.wires.forEach((wireUpdate) => { - let inputMap = state.wires.get(wireUpdate.id); - // If it doesn't exist, create it and set it in the outer map + let inputMap = wires.get(wireUpdate.id); if (!inputMap) { inputMap = new Map(); - state.wires.set(wireUpdate.id, inputMap); + wires.set(wireUpdate.id, inputMap); } if (wireUpdate.wirePathUpdate !== undefined) { inputMap.set(wireUpdate.inputIndex, wireUpdate.wirePathUpdate); @@ -169,15 +190,12 @@ export function createNodeGraphStore(subscriptions: SubscriptionsRouter) { inputMap.delete(wireUpdate.inputIndex); } }); - return state; + return wires; }); }); subscriptions.subscribeFrontendMessage("ClearAllNodeGraphWires", () => { - update((state) => { - state.wires.clear(); - return state; - }); + wiresStore.set(new Map()); }); subscriptions.subscribeFrontendMessage("UpdateNodeGraphSelection", (data) => { @@ -188,10 +206,7 @@ export function createNodeGraphStore(subscriptions: SubscriptionsRouter) { }); subscriptions.subscribeFrontendMessage("UpdateNodeGraphTransform", (data) => { - update((state) => { - state.transform = { scale: data.scale, x: data.translation[0], y: data.translation[1] }; - return state; - }); + transformStore.set({ scale: data.scale, x: data.translation[0], y: data.translation[1] }); }); subscriptions.subscribeFrontendMessage("UpdateNodeThumbnail", (data) => { @@ -208,7 +223,7 @@ export function createNodeGraphStore(subscriptions: SubscriptionsRouter) { }); }); - return { subscribe }; + return { subscribe, transformStore, importsExportsStore, visibleNodesStore, wiresStore }; } export function destroyNodeGraphStore() {