chore: refactor graphStack to be simpler

This commit is contained in:
2026-05-05 18:45:54 +02:00
parent 8ad62cfc8e
commit ed11195327
8 changed files with 243 additions and 192 deletions
@@ -7,11 +7,16 @@
return group?.name || `Group#${groupId}`;
}
function exitToGroup(groupId?: number) {
while (graph.graphStack.length > 0 && graph.graphStack.at(-1)?.groupId !== groupId) {
function exitToGroup(targetId?: number) {
while (graph.currentGroupId !== (targetId ?? null)) {
graph.exitGroup();
}
}
// Intermediate groups: parent stack entries that are groups (not the root graph).
const intermediateGroups = $derived(
graph.parentStack.filter(e => e.id !== graph.id)
);
</script>
<div class="shadow" class:is-inside-group={graph.isInsideGroup}></div>
@@ -24,15 +29,23 @@
>
Root
</button>
{#each graph.graphStack as group (group.groupId)}
{#each intermediateGroups as entry (entry.id)}
<span class="i-[tabler--arrow-right]"></span>
<button
class="bg-layer-2 opacity-75 hover:opacity-100 cursor-pointer rounded-sm p-1 px-2"
onclick={() => exitToGroup(group.groupId)}
onclick={() => exitToGroup(entry.id)}
>
{getGroupName(group.groupId)}
{getGroupName(entry.id)}
</button>
{/each}
<span class="i-[tabler--arrow-right]"></span>
<button
class="bg-layer-2 opacity-100 cursor-pointer rounded-sm p-1 px-2"
>
{getGroupName(graph.currentGroupId!)}
</button>
</div>
{/if}
@@ -11,6 +11,8 @@ import type {
NodeInput,
NodeInstance,
NodeRegistry,
SerializedEdge,
SerializedNode,
Socket
} from '@nodarium/types';
import { fastHashString } from '@nodarium/utils';
@@ -25,8 +27,8 @@ import {
} from './helpers/nodeHelpers';
import { HistoryManager } from './history-manager';
const logger = createLogger('graph-manager');
logger.mute();
const log = createLogger('graph-manager');
log.mute();
const remoteRegistry = new RemoteNodeRegistry('');
@@ -42,24 +44,26 @@ export class GraphManager extends EventEmitter<{
loaded = false;
graph: Graph = $state({ id: 0, nodes: [], edges: [], groups: [] });
id = $state(0);
nodes = new SvelteMap<number, NodeInstance>();
nodeArray = $derived(Array.from(this.nodes.values()));
edges = $state<Edge[]>([]);
// Plain array — NOT $state. rootGraph items are plain-serialized (safe for structuredClone).
// savedNodes/savedEdges hold live reactive references so reactivity is preserved on exit.
graphStack: {
rootGraph: Graph;
savedNodes: Map<number, NodeInstance>;
savedEdges: Edge[];
outerGraph: Graph;
groupId: number;
nodeId: number;
// Snapshots of parent levels we navigated away from. Empty at root.
// Entry i has the saved state of depth i (0 = root graph, 1 = first group, …).
parentStack: {
id: number;
nodes: SerializedNode[];
edges: SerializedEdge[];
cameraPosition: [number, number, number];
}[] = [];
}[] = $state([]);
// ID of the currently active group, or null when at the root graph.
currentGroupId = $state<number | null>(null);
// Graph Data
id = $state(0);
nodes = new SvelteMap<number, NodeInstance>();
edges = $state<Edge[]>([]);
groups: GroupDefinition[] = $state([]);
nodeArray = $derived(Array.from(this.nodes.values()));
settingTypes: Record<string, NodeInput> = {};
settings = $state<Record<string, unknown>>();
@@ -76,24 +80,9 @@ export class GraphManager extends EventEmitter<{
history: HistoryManager = new HistoryManager();
public serializeFullGraph(): Graph {
if (this.graphStack.length === 0) return this.serialize();
let merged = this.serialize();
for (let i = this.graphStack.length - 1; i >= 0; i--) {
const { rootGraph, groupId } = this.graphStack[i];
merged = {
...rootGraph,
groups: rootGraph.groups.map(g =>
g.id === groupId ? { ...g, nodes: merged.nodes, edges: merged.edges } : g
)
};
}
return merged;
}
execute = throttle(() => {
if (this.loaded === false) return;
this.emit('result', this.serializeFullGraph());
this.emit('result', this.serialize());
}, 10);
constructor(public registry: NodeRegistry) {
@@ -101,32 +90,44 @@ export class GraphManager extends EventEmitter<{
}
serialize(): Graph {
const nodes = Array.from(this.nodes.values()).map((node) => serializeNode(node));
const edges = this.edges.map((edge) => serializeEdge(edge));
const groups = this.graph.groups?.map((group) => {
const groupNodes = group.nodes.map((node) => serializeNode(node));
const nodes =
(this.parentStack.length === 0 ? Array.from(this.nodes.values()) : this.parentStack[0].nodes)
.map(n => serializeNode(n));
const edges =
(this.parentStack.length === 0 ? Array.from(this.edges.values()) : this.parentStack[0].edges)
.map(e => serializeEdge(e));
const groups = this.groups?.map((group) => {
const isCurrentActive = this.currentGroupId === group.id;
const stackState = this.parentStack.find((s) => s.id === group.id);
const groupNodes =
(isCurrentActive ? [...this.nodes.values()] : stackState?.nodes ?? group.nodes).map(
n => serializeNode(n)
);
const groupEdges =
(isCurrentActive ? [...this.edges.values()] : stackState?.edges ?? group.edges).map(
e => serializeEdge(e)
);
return {
id: group.id,
name: group.name,
inputs: group.inputs,
outputs: group.outputs,
nodes: groupNodes,
edges: group.edges
edges: groupEdges
};
});
const serialized = {
id: this.graph.id,
settings: $state.snapshot(this.settings),
meta: $state.snapshot(this.graph.meta),
const serialized = $state.snapshot({
id: this.id,
settings: this.settings,
meta: this.graph.meta,
groups,
nodes,
edges
};
logger.log('serializing graph', serialized);
return clone($state.snapshot(serialized));
});
log.log('serializing graph', serialized);
return clone(serialized);
}
private lastSettingsHash = 0;
@@ -190,7 +191,7 @@ export class GraphManager extends EventEmitter<{
);
if (!bestInputEntry || bestOutputIdx === -1) {
logger.error('Could not find compatible sockets for drop');
log.error('Could not find compatible sockets for drop');
return;
}
@@ -296,21 +297,22 @@ export class GraphManager extends EventEmitter<{
return edges;
}
private _init(graph: Graph) {
const nodes = new SvelteMap(
graph.nodes.map((node) => {
const n = node as NodeInstance;
const registryType = this.registry.getNode(node.type);
n.state = registryType ? { type: registryType } : {};
const resolvedType = this.getNodeType(n);
if (resolvedType) n.state = { type: resolvedType };
return [node.id, n];
})
);
private _init(
graph: { nodes: SerializedNode[]; edges: SerializedEdge[] }
) {
this.nodes.clear();
for (const node of graph.nodes) {
const n = $state(node) as NodeInstance;
const registryType = this.registry.getNode(node.type);
n.state = registryType ? { type: registryType } : {};
const resolvedType = this.getNodeType(n);
if (resolvedType) n.state = { type: resolvedType };
this.nodes.set(n.id, n);
}
this.edges = graph.edges.map((edge) => {
const from = nodes.get(edge[0]);
const to = nodes.get(edge[2]);
const from = this.nodes.get(edge[0]);
const to = this.nodes.get(edge[2]);
if (!from || !to) {
throw new Error('Edge references non-existing node');
}
@@ -321,11 +323,6 @@ export class GraphManager extends EventEmitter<{
return [from, edge[1], to, edge[3]] as Edge;
});
this.nodes.clear();
for (const [id, node] of nodes) {
this.nodes.set(id, node);
}
this.execute();
}
@@ -334,11 +331,12 @@ export class GraphManager extends EventEmitter<{
this.loaded = false;
graph.groups ??= [];
this.groups = graph.groups;
this.graph = graph;
this.status = 'loading';
this.id = graph.id;
logger.info(
log.info(
'loading graph',
{ nodes: graph.nodes, edges: graph.edges, id: graph.id }
);
@@ -353,8 +351,7 @@ export class GraphManager extends EventEmitter<{
.filter(n => n && 'type' in n)
.map((n) => n.type)
)
)
.filter(n => !n.startsWith('__internal/'));
);
await this.registry.load(nodeIds);
@@ -374,20 +371,7 @@ export class GraphManager extends EventEmitter<{
});
}
logger.info('loaded node types', this.registry.getAllNodes());
for (const node of this.graph.nodes) {
const nodeType = this.registry.getNode(node.type);
if (!nodeType && !node.type.startsWith('__internal/')) {
logger.error(`Node type not found: ${node.type}`);
this.status = 'error';
return;
}
// Turn into runtime node
const n = node as NodeInstance;
n.state = {};
n.state.type = nodeType;
}
log.info('loaded node types', this.registry.getAllNodes());
// load settings
const settingTypes: Record<
@@ -418,18 +402,18 @@ export class GraphManager extends EventEmitter<{
}
}
this.parentStack = [];
this.currentGroupId = null;
this.settings = settingValues;
this.emit('settings', { types: settingTypes, values: settingValues });
this.history.reset();
this._init(this.graph);
this._init(graph);
this.save();
this.status = 'idle';
this.loaded = true;
logger.log(`Graph loaded in ${performance.now() - a}ms`);
log.log(`Graph loaded in ${performance.now() - a}ms`);
setTimeout(() => this.execute(), 100);
}
@@ -449,19 +433,15 @@ export class GraphManager extends EventEmitter<{
}
if (node.type === '__internal/group/input') {
const groupId = this.graphStack.at(-1)?.groupId;
const group = groupId !== undefined ? this.getGroup(groupId) : undefined;
const group = this.currentGroupId !== null ? this.getGroup(this.currentGroupId) : undefined;
if (!group) return node.state.type;
const groupInputs: NodeDefinition['inputs'] = Object.fromEntries(
Object.values(group?.inputs || {}).map((o, i) => {
return [
`in_${i}`,
{
...o,
external: true
}
];
return [`in_${i}`, {
...o,
external: true
}];
}) || []
);
return {
@@ -475,8 +455,7 @@ export class GraphManager extends EventEmitter<{
}
if (node.type === '__internal/group/output') {
const groupId = this.graphStack.at(-1)?.groupId;
const group = groupId !== undefined ? this.getGroup(groupId) : undefined;
const group = this.currentGroupId !== null ? this.getGroup(this.currentGroupId) : undefined;
if (!group) return node.state.type;
return {
id: '__internal/group/output' as NodeId,
@@ -499,7 +478,7 @@ export class GraphManager extends EventEmitter<{
const groupDefinition = this.getGroup(node.props?.groupId as number);
if (!groupDefinition) {
logger.error(`Group not found: ${node.props?.groupId}`);
log.error(`Group not found: ${node.props?.groupId}`);
return;
}
@@ -508,18 +487,24 @@ export class GraphManager extends EventEmitter<{
...groupDefinition?.inputs
};
// This is to make sure the the groupId is always first
delete defaultInputs['groupId'];
const inputs = {
'groupId': {
type: 'select',
label: '',
value: node.props?.groupId,
internal: true,
options: this.graph.groups.map((g) => ({
options: this.groups.map((g) => ({
value: g.id,
label: g.name || `Group#${g.id}`
}))
})).filter((g) => {
const activeIds = new SvelteSet([
...this.parentStack.filter(e => e.id !== this.id).map(e => e.id),
...(this.currentGroupId !== null ? [this.currentGroupId] : [])
]);
return !activeIds.has(g.value);
})
},
...defaultInputs
};
@@ -599,6 +584,7 @@ export class GraphManager extends EventEmitter<{
}
removeNode(node: NodeInstance, { restoreEdges = false } = {}) {
log.log('removing node', { id: node.id, type: node.type, restoreEdges });
const edgesToNode = this.edges.filter((edge) => edge[2].id === node.id);
const edgesFromNode = this.edges.filter((edge) => edge[0].id === node.id);
for (const edge of [...edgesToNode, ...edgesFromNode]) {
@@ -643,82 +629,69 @@ export class GraphManager extends EventEmitter<{
}
getGroup(id: number) {
return this.graph.groups.find(g => g.id === id);
return this.groups.find(g => g.id === id);
}
renameGroup(groupId: number, name: string) {
log.log('renaming group', { groupId, name });
const group = this.getGroup(groupId);
if (!group) return;
group.name = name;
this.save();
}
isInsideGroup = $state(false);
isInsideGroup = $derived(this.currentGroupId !== null);
enterGroup(nodeId: number, cameraPosition: [number, number, number]): boolean {
const groupNode = this.getNode(nodeId);
if (!groupNode || groupNode.type !== '__internal/group/instance') return false;
log.log('entering group', { nodeId, cameraPosition });
const groupId = groupNode.props?.groupId as number;
const group = this.getGroup(groupId);
if (!group) return false;
this.graphStack.push({
rootGraph: this.serialize(),
savedNodes: new SvelteMap(this.nodes),
savedEdges: [...this.edges],
outerGraph: this.graph,
groupId,
nodeId,
// Snapshot current level and push it onto the parent stack.
this.parentStack.push({
id: this.currentGroupId ?? this.id,
nodes: [...this.nodes.values()].map(n => serializeNode(n)),
edges: [...this.edges.values()].map(e => serializeEdge(e)),
cameraPosition
});
this.graph = { ...this.graph, nodes: group.nodes, edges: group.edges };
this._init(this.graph);
this.currentGroupId = groupId;
log.log('entered group', { groupId, depth: this.parentStack.length });
this.history.reset();
this.isInsideGroup = true;
this._init(group);
return true;
}
exitGroup(): { camera: [number, number, number]; nodeId: number } | false {
if (!this.graphStack.length) return false;
const { savedNodes, savedEdges, outerGraph, groupId, nodeId, cameraPosition } = this.graphStack
.pop()!;
const internalState = this.serialize();
exitGroup() {
log.log('exiting group', { depth: this.parentStack.length });
if (this.parentStack.length === 0) return;
// Clear stale DOM/mesh refs so the remounting components register fresh ones.
// The $effect guards in NodeHTML/Node only set these when undefined, so without
// this clear they'd keep pointing to the detached elements from before group entry.
for (const node of savedNodes.values()) {
node.state.ref = undefined;
node.state.mesh = undefined;
// Persist live edits back to the GroupDefinition.
const group = this.getGroup(this.currentGroupId!);
if (group) {
group.nodes = [...this.nodes.values()].map(n => serializeNode(n));
group.edges = [...this.edges.values()].map(e => serializeEdge(e));
}
// Restore live reactive nodes and edges so drag-reactivity is preserved
this.nodes.clear();
for (const [id, node] of savedNodes) {
this.nodes.set(id, node);
}
this.edges = savedEdges;
// Patch the group definition with the edited internal graph
this.graph = {
...outerGraph,
groups: (outerGraph.groups ?? []).map(g =>
g.id === groupId ? { ...g, nodes: internalState.nodes, edges: internalState.edges } : g
)
};
const parent = this.parentStack.pop()!;
this.currentGroupId = this.parentStack.length === 0 ? null : parent.id;
this._init(parent);
this.history.reset();
this.isInsideGroup = this.graphStack.length > 0;
this.execute();
this.save();
return { camera: cameraPosition, nodeId };
}
createNodeId() {
const ids = [
...this.nodes.keys(),
...this.graph.groups.map(g => g.id),
...this.graph.groups.flatMap(g => g.nodes.map(n => n.id))
...this.groups.map(g => g.id),
...this.groups.flatMap(g => g.nodes.map(n => n.id))
];
let id = 0;
@@ -775,12 +748,32 @@ export class GraphManager extends EventEmitter<{
const usedGroupIds = new SvelteSet<number>();
const queue: number[] = [];
for (const node of this.nodes.values()) {
// Seed from root-level nodes so outer groups aren't treated as unused when inside a group.
const rootNodes = this.parentStack.length > 0
? this.parentStack[0].nodes
: [...this.nodes.values()];
for (const node of rootNodes) {
if (node.type === '__internal/group/instance' && node.props?.groupId !== undefined) {
queue.push(node.props.groupId as number);
}
}
// Also seed from live nodes (may contain new group instances created inside a group).
if (this.currentGroupId !== null) {
for (const node of this.nodes.values()) {
if (node.type === '__internal/group/instance' && node.props?.groupId !== undefined) {
queue.push(node.props.groupId as number);
}
}
}
// Every group on the navigation path is used by definition.
for (const entry of this.parentStack) {
if (entry.id !== this.id) usedGroupIds.add(entry.id);
}
if (this.currentGroupId !== null) usedGroupIds.add(this.currentGroupId);
while (queue.length) {
const groupId = queue.pop()!;
if (usedGroupIds.has(groupId)) continue;
@@ -795,13 +788,13 @@ export class GraphManager extends EventEmitter<{
}
}
return this.graph.groups.filter(g => !usedGroupIds.has(g.id));
return this.groups.filter(g => !usedGroupIds.has(g.id));
}
removeUnusedGroups() {
const unused = this.getUnusedGroups();
const unusedIds = new SvelteSet(unused.map(g => g.id));
this.graph.groups = this.graph.groups.filter(g => !unusedIds.has(g.id));
this.groups = this.groups.filter(g => !unusedIds.has(g.id));
this.save();
return unused.length;
}
@@ -816,7 +809,7 @@ export class GraphManager extends EventEmitter<{
if (!nodes.length) return;
logger.log(`Grouping ${nodes.length} nodes`, { nodes });
log.log(`Grouping ${nodes.length} nodes`, { nodes });
const ids = new SvelteSet(nodes.map(n => n.id));
@@ -877,8 +870,8 @@ export class GraphManager extends EventEmitter<{
// Allocate all needed IDs up front so sequential calls never collide.
const usedIds = new SvelteSet<number>([
...this.nodes.keys(),
...this.graph.groups.map(g => g.id),
...this.graph.groups.flatMap(g => g.nodes.map(n => n.id))
...this.groups.map(g => g.id),
...this.groups.flatMap(g => g.nodes.map(n => n.id))
]);
const nextId = () => {
let id = 0;
@@ -925,7 +918,7 @@ export class GraphManager extends EventEmitter<{
};
// Push before createNode so createNodeId() inside sees the allocated IDs.
this.graph.groups.push(groupDefinition);
this.groups.push(groupDefinition);
const groupNode = this.createNode({
type: '__internal/group/instance',
@@ -972,7 +965,7 @@ export class GraphManager extends EventEmitter<{
}) {
const nodeType = this.registry.getNode(type);
if (!nodeType && !type.startsWith('__internal/')) {
logger.error(`Node type not found: ${type}`);
log.error(`Node type not found: ${type}`);
return;
}
@@ -984,6 +977,7 @@ export class GraphManager extends EventEmitter<{
props
});
log.log('creating node', { id: node.id, type, position, props });
this.nodes.set(node.id, node);
this.save();
@@ -1005,7 +999,7 @@ export class GraphManager extends EventEmitter<{
(e) => e[0].id === from.id && e[1] === fromSocket && e[3] === toSocket
);
if (existingEdge) {
logger.error('Edge already exists', existingEdge);
log.error('Edge already exists', existingEdge);
return;
}
@@ -1022,7 +1016,7 @@ export class GraphManager extends EventEmitter<{
}
if (!areSocketsCompatible(fromSocketType, toSocketType)) {
logger.error(
log.error(
`Socket types do not match: ${fromSocketType} !== ${toSocketType}`
);
return;
@@ -1037,6 +1031,7 @@ export class GraphManager extends EventEmitter<{
const edge = [from, fromSocket, to, toSocket] as Edge;
log.log('creating edge', { from: from.id, fromSocket, to: to.id, toSocket });
this.edges.push(edge);
from.state.children = from.state.children || [];
@@ -1053,6 +1048,7 @@ export class GraphManager extends EventEmitter<{
}
undo() {
log.log('undo');
const nextState = this.history.undo();
if (nextState) {
this._init(nextState);
@@ -1061,6 +1057,7 @@ export class GraphManager extends EventEmitter<{
}
redo() {
log.log('redo');
const nextState = this.history.redo();
if (nextState) {
this._init(nextState);
@@ -1088,9 +1085,9 @@ export class GraphManager extends EventEmitter<{
return;
}
const fullState = this.graphStack.length > 0 ? this.serializeFullGraph() : state;
const fullState = this.serialize();
this.emit('save', fullState);
logger.log('saving graphs', fullState);
log.log('saving graphs', fullState);
}
getParentsOfNode(node: NodeInstance) {
@@ -1098,7 +1095,7 @@ export class GraphManager extends EventEmitter<{
const stack = node.state?.parents?.slice(0);
while (stack?.length) {
if (parents.length > 1000000) {
logger.warn('Infinite loop detected');
log.warn('Infinite loop detected');
break;
}
const parent = stack.pop();
@@ -1215,6 +1212,12 @@ export class GraphManager extends EventEmitter<{
edge: Edge,
{ applyDeletion = true }: { applyDeletion?: boolean } = {}
) {
log.log('removing edge', {
from: edge[0].id,
fromSocket: edge[1],
to: edge[2].id,
toSocket: edge[3]
});
const id0 = edge[0].id;
const sid0 = edge[1];
const id2 = edge[2].id;
@@ -97,7 +97,7 @@ describe('enterGroupNode', () => {
const { manager, state } = createFixture();
state.activeNodeId = -1;
state.enterGroupNode();
expect(manager.graphStack.length).toBe(0);
expect(manager.parentStack.length).toBe(0);
});
it('does nothing when the active node is not a group instance', () => {
@@ -106,7 +106,7 @@ describe('enterGroupNode', () => {
assert.isDefined(node);
state.activeNodeId = node!.id;
state.enterGroupNode();
expect(manager.graphStack.length).toBe(0);
expect(manager.parentStack.length).toBe(0);
});
it('enters the group, pushes graphStack, and clears UI state', () => {
@@ -123,7 +123,7 @@ describe('enterGroupNode', () => {
state.enterGroupNode();
expect(manager.graphStack.length).toBe(1);
expect(manager.parentStack.length).toBe(1);
expect(state.activeNodeId).toBe(-1);
expect(state.selectedNodes.size).toBe(0);
expect(manager.isInsideGroup).toBe(true);
@@ -135,7 +135,7 @@ describe('exitGroupNode', () => {
const { manager, state } = createFixture();
const before = [...state.cameraPosition];
state.exitGroupNode();
expect(manager.graphStack.length).toBe(0);
expect(manager.parentStack.length).toBe(0);
expect(state.cameraPosition).toEqual(before);
});
@@ -1,6 +1,7 @@
<script lang="ts">
import { createKeyMap } from '$lib/helpers/createKeyMap';
import type { Graph, NodeInstance, NodeRegistry } from '@nodarium/types';
import { onMount } from 'svelte';
import { GraphManager } from '../graph-manager.svelte';
import { GraphState, setGraphManager, setGraphState } from '../graph-state.svelte';
import { setupKeymaps } from '../keymaps';
@@ -83,8 +84,8 @@
manager.on('save', (save) => onsave?.(save));
$effect(() => {
if (graph && (manager.status !== 'idle' || manager.graph.id !== graph.id)) {
onMount(() => {
if (graph) {
manager.load(graph);
}
});
@@ -42,8 +42,12 @@ export function serializeNode(node: SerializedNode | NodeInstance): SerializedNo
};
}
export function serializeEdge(edge: Edge): SerializedEdge {
return [edge[0].id, edge[1], edge[2].id, edge[3]];
export function serializeEdge(edge: SerializedEdge | Edge): SerializedEdge {
if (typeof edge[0] === 'number' && typeof edge[2] === 'number') {
return [edge[0], edge[1], edge[2], edge[3]];
}
const e = edge as Edge;
return [e[0].id, e[1], e[2].id, e[3]];
}
const nodeHeightCache: Record<string, number> = {};
+27 -1
View File
@@ -21,6 +21,26 @@ log.mute();
export function expandGroups(graph: Graph): Graph {
if (!graph.groups || graph.groups.length === 0) return graph;
function groupContainsSelf(groupId: number, visited = new Set<number>()): boolean {
if (visited.has(groupId)) return true;
visited.add(groupId);
const group = graph.groups!.find(g => g.id === groupId);
if (!group) return false;
for (const n of group.nodes) {
if (n.type === '__internal/group/instance') {
const nestedId = n.props?.groupId as number | undefined;
if (nestedId !== undefined && groupContainsSelf(nestedId, visited)) return true;
}
}
return false;
}
for (const group of graph.groups) {
if (groupContainsSelf(group.id)) {
throw new Error(`Circular group reference: group ${group.id} contains itself`);
}
}
const nodes = [...graph.nodes];
let edges = [...graph.edges];
@@ -57,10 +77,16 @@ export function expandGroups(graph: Graph): Graph {
const newEdges: Graph['edges'] = [];
// external_source → [inputBoundary →] internal_target
//
// External socket names are "input_N" where N equals the input boundary's
// output index. Match each external edge only to the internal edges that
// originate from that specific output slot — not a cartesian product of all.
if (inputBoundary) {
const fromInput = group.edges.filter(e => e[0] === inputBoundary.id);
for (const extEdge of incomingExternal) {
for (const intEdge of fromInput) {
const inputIndex = parseInt((extEdge[3] as string).replace('input_', ''), 10);
const matchingIntEdges = fromInput.filter(e => e[1] === inputIndex);
for (const intEdge of matchingIntEdges) {
const toId = idMap.get(intEdge[2]);
if (toId !== undefined) newEdges.push([extEdge[0], extEdge[1], toId, intEdge[3]]);
}
@@ -12,16 +12,16 @@
const { manager, node = $bindable() }: Props = $props();
const activeGroup = $derived.by(() => {
if (node?.type === '__internal/group/instance') {
return manager.getGroup(node.props?.groupId as number);
}
if (manager?.isInsideGroup) {
const activeGroupId = manager.graphStack?.at(-1)?.groupId;
const activeGroupId = manager.parentStack?.at(-1)?.id;
if (activeGroupId !== undefined) {
return manager.getGroup(activeGroupId);
}
}
if (node?.type === '__internal/group/instance') {
return manager.getGroup(node.props?.groupId as number);
}
});
const groupName = $derived(activeGroup?.name ?? '');
+20 -16
View File
@@ -95,7 +95,7 @@
randomSeed: { type: 'boolean', value: false }
});
$effect(() => {
if (graphSettings && graphSettingTypes) {
if (graphSettings && graphSettingTypes && manager?.loaded) {
manager?.setSettings($state.snapshot(graphSettings));
}
});
@@ -255,20 +255,22 @@
</Grid.Cell>
<Grid.Cell>
{#if pm.graph}
<GraphInterface
graph={pm.graph}
bind:this={graphInterface}
registry={nodeRegistry}
safePadding={{ right: sidebarOpen ? 321 : undefined }}
backgroundType={appSettings.value.nodeInterface.backgroundType}
snapToGrid={appSettings.value.nodeInterface.snapToGrid}
bind:activeNode
bind:showHelp={appSettings.value.nodeInterface.showHelp}
bind:settings={graphSettings}
bind:settingTypes={graphSettingTypes}
onsave={(g) => pm.saveGraph(g)}
onresult={(result) => handleUpdate(result as Graph)}
/>
{#key pm.graph.id}
<GraphInterface
graph={pm.graph}
bind:this={graphInterface}
registry={nodeRegistry}
safePadding={{ right: sidebarOpen ? 321 : undefined }}
backgroundType={appSettings.value.nodeInterface.backgroundType}
snapToGrid={appSettings.value.nodeInterface.snapToGrid}
bind:activeNode
bind:showHelp={appSettings.value.nodeInterface.showHelp}
bind:settings={graphSettings}
bind:settingTypes={graphSettingTypes}
onsave={(g) => pm.saveGraph(g)}
onresult={(result) => handleUpdate(result as Graph)}
/>
{/key}
{/if}
<Sidebar bind:open={sidebarOpen}>
<Panel id="general" title="General" icon="i-[tabler--settings]">
@@ -322,7 +324,9 @@
hidden={!appSettings.value.debug.advancedMode}
icon="i-[tabler--code]"
>
<GraphSource graph={manager?.serializeFullGraph()} />
{#if manager?.status === 'idle'}
<GraphSource graph={manager.serialize()} />
{/if}
</Panel>
<Panel
id="benchmark"