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