Merge pull request 'feat/drop-node-on-connection' (#18) from feat/drop-node-on-connection into main
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 2m2s

Reviewed-on: #18
This commit was merged in pull request #18.
This commit is contained in:
2026-01-20 17:47:04 +01:00
13 changed files with 722 additions and 417 deletions

View File

@@ -1,19 +1,44 @@
<script lang="ts"> <script lang="ts">
import { MeshLineGeometry, MeshLineMaterial } from '@threlte/extras'; import { MeshLineGeometry, MeshLineMaterial } from "@threlte/extras";
import { points, lines } from './store.js'; import { points, lines, rects } from "./store.js";
import { T } from '@threlte/core'; import { T } from "@threlte/core";
import { Color } from "three";
</script> </script>
{#each $points as point} {#each $points as point}
<T.Mesh position.x={point.x} position.y={point.y} position.z={point.z} rotation.x={-Math.PI / 2}> <T.Mesh
position.x={point.x}
position.y={point.y}
position.z={point.z}
rotation.x={-Math.PI / 2}
>
<T.CircleGeometry args={[0.2, 32]} /> <T.CircleGeometry args={[0.2, 32]} />
<T.MeshBasicMaterial color="red" /> <T.MeshBasicMaterial color="red" />
</T.Mesh> </T.Mesh>
{/each} {/each}
{#each $lines as line} {#each $rects as rect, i}
<T.Mesh> <T.Mesh
<MeshLineGeometry points={line} /> position.x={(rect.minX + rect.maxX) / 2}
<MeshLineMaterial color="red" linewidth={1} attenuate={false} /> position.y={0}
position.z={(rect.minY + rect.maxY) / 2}
rotation.x={-Math.PI / 2}
>
<T.PlaneGeometry args={[rect.maxX - rect.minX, rect.maxY - rect.minY]} />
<T.MeshBasicMaterial
color={new Color().setHSL((i * 1.77) % 1, 1, 0.5)}
opacity={0.9}
/>
</T.Mesh>
{/each}
{#each $lines as line}
<T.Mesh position.y={1}>
<MeshLineGeometry points={line.points} />
<MeshLineMaterial
color={line.color || "red"}
linewidth={1}
attenuate={false}
/>
</T.Mesh> </T.Mesh>
{/each} {/each}

View File

@@ -1,5 +1,7 @@
import { Vector3 } from "three/src/math/Vector3.js"; import type { Box } from '@nodarium/types';
import { lines, points } from "./store"; import { Vector3 } from 'three/src/math/Vector3.js';
import Component from './Debug.svelte';
import { lines, points, rects } from './store';
export function debugPosition(x: number, y: number) { export function debugPosition(x: number, y: number) {
points.update((p) => { points.update((p) => {
@@ -8,18 +10,28 @@ export function debugPosition(x: number, y: number) {
}); });
} }
export function debugRect(rect: Box) {
console.log(rect);
rects.update((r) => {
r.push(rect);
return r;
});
}
export function clear() { export function clear() {
points.set([]); points.set([]);
lines.set([]); lines.set([]);
rects.set([]);
} }
export function debugLine(line: Vector3[]) { export function debugLine(points: Vector3[], color?: Color) {
lines.update((l) => { lines.update((l) => {
l.push(line); l.push({ points, color });
return l; return l;
}); });
} }
import Component from "./Debug.svelte"; export default Component;
export function clearLines() {
export default Component lines.set([]);
}

View File

@@ -1,6 +1,8 @@
import { writable } from "svelte/store"; import type { Box } from '@nodarium/types';
import { Vector3 } from "three/src/math/Vector3.js"; import { writable } from 'svelte/store';
import type { Color } from 'three';
import { Vector3 } from 'three/src/math/Vector3.js';
export const points = writable<Vector3[]>([]); export const points = writable<Vector3[]>([]);
export const rects = writable<Box[]>([]);
export const lines = writable<Vector3[][]>([]); export const lines = writable<{ points: Vector3[]; color?: Color }[]>([]);

View File

@@ -31,6 +31,10 @@
import { CubicBezierCurve } from "three/src/extras/curves/CubicBezierCurve.js"; import { CubicBezierCurve } from "three/src/extras/curves/CubicBezierCurve.js";
import { Vector2 } from "three/src/math/Vector2.js"; import { Vector2 } from "three/src/math/Vector2.js";
import { appSettings } from "$lib/settings/app-settings.svelte"; import { appSettings } from "$lib/settings/app-settings.svelte";
import { getGraphState } from "../graph-state.svelte";
import { onDestroy } from "svelte";
const graphState = getGraphState();
type Props = { type Props = {
x1: number; x1: number;
@@ -38,20 +42,21 @@
x2: number; x2: number;
y2: number; y2: number;
z: number; z: number;
id?: string;
}; };
const { x1, y1, x2, y2, z }: Props = $props(); const { x1, y1, x2, y2, z, id }: Props = $props();
const thickness = $derived(Math.max(0.001, 0.00082 * Math.exp(0.055 * z))); const thickness = $derived(Math.max(0.001, 0.00082 * Math.exp(0.055 * z)));
let points = $state<Vector3[]>([]); let points = $state<Vector3[]>([]);
let lastId: string | null = null; let lastId: string | null = null;
const curveId = $derived(`${x1}-${y1}-${x2}-${y2}`);
function update() { function update() {
const new_x = x2 - x1; const new_x = x2 - x1;
const new_y = y2 - y1; const new_y = y2 - y1;
const curveId = `${x1}-${y1}-${x2}-${y2}`;
if (lastId === curveId) { if (lastId === curveId) {
return; return;
} }
@@ -72,6 +77,15 @@
.getPoints(samples) .getPoints(samples)
.map((p) => new Vector3(p.x, 0, p.y)) .map((p) => new Vector3(p.x, 0, p.y))
.flat(); .flat();
if (id) {
graphState.setEdgeGeometry(
id,
x1,
y1,
$state.snapshot(points) as unknown as Vector3[],
);
}
} }
$effect(() => { $effect(() => {
@@ -79,6 +93,10 @@
update(); update();
} }
}); });
onDestroy(() => {
if (id) graphState.removeEdgeGeometry(id);
});
</script> </script>
<T.Mesh <T.Mesh
@@ -101,6 +119,18 @@
<T.CircleGeometry args={[0.5, 16]} /> <T.CircleGeometry args={[0.5, 16]} />
</T.Mesh> </T.Mesh>
{#if graphState.hoveredEdgeId === id}
<T.Mesh position.x={x1} position.z={y1} position.y={0.1}>
<MeshLineGeometry {points} />
<MeshLineMaterial
width={thickness * 5}
color={lineColor}
opacity={0.5}
transparent
/>
</T.Mesh>
{/if}
<T.Mesh position.x={x1} position.z={y1} position.y={0.1}> <T.Mesh position.x={x1} position.z={y1} position.y={0.1}>
<MeshLineGeometry {points} /> <MeshLineGeometry {points} />
<MeshLineMaterial width={thickness} color={lineColor} /> <MeshLineMaterial width={thickness} color={lineColor} />

View File

@@ -1,31 +1,30 @@
import throttle from '$lib/helpers/throttle';
import type { import type {
Edge, Edge,
Graph, Graph,
NodeInstance,
NodeDefinition, NodeDefinition,
NodeInput,
NodeRegistry,
NodeId, NodeId,
Socket, NodeInput,
} from "@nodarium/types"; NodeInstance,
import { fastHashString } from "@nodarium/utils"; NodeRegistry,
import { SvelteMap } from "svelte/reactivity"; Socket
import EventEmitter from "./helpers/EventEmitter"; } from '@nodarium/types';
import { createLogger } from "@nodarium/utils"; import { fastHashString } from '@nodarium/utils';
import throttle from "$lib/helpers/throttle"; import { createLogger } from '@nodarium/utils';
import { HistoryManager } from "./history-manager"; import { SvelteMap } from 'svelte/reactivity';
import EventEmitter from './helpers/EventEmitter';
import { HistoryManager } from './history-manager';
const logger = createLogger("graph-manager"); const logger = createLogger('graph-manager');
logger.mute(); logger.mute();
const clone = const clone = 'structuredClone' in self
"structuredClone" in self
? self.structuredClone ? self.structuredClone
: (args: any) => JSON.parse(JSON.stringify(args)); : (args: any) => JSON.parse(JSON.stringify(args));
function areSocketsCompatible( function areSocketsCompatible(
output: string | undefined, output: string | undefined,
inputs: string | (string | undefined)[] | undefined, inputs: string | (string | undefined)[] | undefined
) { ) {
if (Array.isArray(inputs) && output) { if (Array.isArray(inputs) && output) {
return inputs.includes(output); return inputs.includes(output);
@@ -34,24 +33,23 @@ function areSocketsCompatible(
} }
function areEdgesEqual(firstEdge: Edge, secondEdge: Edge) { function areEdgesEqual(firstEdge: Edge, secondEdge: Edge) {
if (firstEdge[0].id !== secondEdge[0].id) { if (firstEdge[0].id !== secondEdge[0].id) {
return false; return false;
} }
if (firstEdge[1] !== secondEdge[1]) { if (firstEdge[1] !== secondEdge[1]) {
return false return false;
} }
if (firstEdge[2].id !== secondEdge[2].id) { if (firstEdge[2].id !== secondEdge[2].id) {
return false return false;
} }
if (firstEdge[3] !== secondEdge[3]) { if (firstEdge[3] !== secondEdge[3]) {
return false return false;
} }
return true return true;
} }
export class GraphManager extends EventEmitter<{ export class GraphManager extends EventEmitter<{
@@ -62,7 +60,7 @@ export class GraphManager extends EventEmitter<{
values: Record<string, unknown>; values: Record<string, unknown>;
}; };
}> { }> {
status = $state<"loading" | "idle" | "error">(); status = $state<'loading' | 'idle' | 'error'>();
loaded = false; loaded = false;
graph: Graph = { id: 0, nodes: [], edges: [] }; graph: Graph = { id: 0, nodes: [], edges: [] };
@@ -88,7 +86,7 @@ export class GraphManager extends EventEmitter<{
history: HistoryManager = new HistoryManager(); history: HistoryManager = new HistoryManager();
execute = throttle(() => { execute = throttle(() => {
if (this.loaded === false) return; if (this.loaded === false) return;
this.emit("result", this.serialize()); this.emit('result', this.serialize());
}, 10); }, 10);
constructor(public registry: NodeRegistry) { constructor(public registry: NodeRegistry) {
@@ -100,21 +98,21 @@ export class GraphManager extends EventEmitter<{
id: node.id, id: node.id,
position: [...node.position], position: [...node.position],
type: node.type, type: node.type,
props: node.props, props: node.props
})) as NodeInstance[]; })) as NodeInstance[];
const edges = this.edges.map((edge) => [ const edges = this.edges.map((edge) => [
edge[0].id, edge[0].id,
edge[1], edge[1],
edge[2].id, edge[2].id,
edge[3], edge[3]
]) as Graph["edges"]; ]) as Graph['edges'];
const serialized = { const serialized = {
id: this.graph.id, id: this.graph.id,
settings: $state.snapshot(this.settings), settings: $state.snapshot(this.settings),
nodes, nodes,
edges, edges
}; };
logger.log("serializing graph", serialized); logger.log('serializing graph', serialized);
return clone($state.snapshot(serialized)); return clone($state.snapshot(serialized));
} }
@@ -148,6 +146,95 @@ export class GraphManager extends EventEmitter<{
return [...nodes.values()]; return [...nodes.values()];
} }
getEdgeId(e: Edge) {
return `${e[0].id}-${e[1]}-${e[2].id}-${e[3]}`;
}
getEdgeById(id: string): Edge | undefined {
return this.edges.find((e) => this.getEdgeId(e) === id);
}
dropNodeOnEdge(nodeId: number, edge: Edge) {
const draggedNode = this.getNode(nodeId);
if (!draggedNode || !draggedNode.state?.type) return;
const [fromNode, fromSocketIdx, toNode, toSocketKey] = edge;
const draggedInputs = Object.entries(draggedNode.state.type.inputs ?? {});
const draggedOutputs = draggedNode.state.type.outputs ?? [];
const edgeOutputSocketType = fromNode.state?.type?.outputs?.[fromSocketIdx];
const targetInput = toNode.state?.type?.inputs?.[toSocketKey];
const targetAcceptedTypes = [targetInput?.type, ...(targetInput?.accepts || [])];
const bestInputEntry = draggedInputs.find(([_, input]) => {
const accepted = [input.type, ...(input.accepts || [])];
return areSocketsCompatible(edgeOutputSocketType, accepted);
});
const bestOutputIdx = draggedOutputs.findIndex(outputType => areSocketsCompatible(outputType, targetAcceptedTypes));
if (!bestInputEntry || bestOutputIdx === -1) {
logger.error('Could not find compatible sockets for drop');
return;
}
this.startUndoGroup();
this.removeEdge(edge, { applyDeletion: false });
this.createEdge(fromNode, fromSocketIdx, draggedNode, bestInputEntry[0], {
applyUpdate: false
});
this.createEdge(draggedNode, bestOutputIdx, toNode, toSocketKey, {
applyUpdate: false
});
this.saveUndoGroup();
this.execute();
}
getPossibleDropOnEdges(nodeId: number): Edge[] {
const draggedNode = this.getNode(nodeId);
if (!draggedNode || !draggedNode.state?.type) return [];
const draggedInputs = Object.values(draggedNode.state.type.inputs ?? {});
const draggedOutputs = draggedNode.state.type.outputs ?? [];
// Optimization: Pre-calculate parents to avoid cycles
const parentIds = new Set(this.getParentsOfNode(draggedNode).map(n => n.id));
return this.edges.filter((edge) => {
const [fromNode, fromSocketIdx, toNode, toSocketKey] = edge;
// 1. Prevent cycles: If the target node is already a parent, we can't drop here
if (parentIds.has(toNode.id)) return false;
// 2. Prevent self-dropping: Don't drop on edges already connected to this node
if (fromNode.id === nodeId || toNode.id === nodeId) return false;
// 3. Check if edge.source can plug into ANY draggedNode.input
const edgeOutputSocketType = fromNode.state?.type?.outputs?.[fromSocketIdx];
const canPlugIntoDragged = draggedInputs.some(input => {
const acceptedTypes = [input.type, ...(input.accepts || [])];
return areSocketsCompatible(edgeOutputSocketType, acceptedTypes);
});
if (!canPlugIntoDragged) return false;
// 4. Check if ANY draggedNode.output can plug into edge.target
const targetInput = toNode.state?.type?.inputs?.[toSocketKey];
const targetAcceptedTypes = [targetInput?.type, ...(targetInput?.accepts || [])];
const draggedCanPlugIntoTarget = draggedOutputs.some(outputType =>
areSocketsCompatible(outputType, targetAcceptedTypes)
);
return draggedCanPlugIntoTarget;
});
}
getEdgesBetweenNodes(nodes: NodeInstance[]): [number, number, number, string][] { getEdgesBetweenNodes(nodes: NodeInstance[]): [number, number, number, string][] {
const edges = []; const edges = [];
for (const node of nodes) { for (const node of nodes) {
@@ -155,14 +242,14 @@ export class GraphManager extends EventEmitter<{
for (const child of children) { for (const child of children) {
if (nodes.includes(child)) { if (nodes.includes(child)) {
const edge = this.edges.find( const edge = this.edges.find(
(e) => e[0].id === node.id && e[2].id === child.id, (e) => e[0].id === node.id && e[2].id === child.id
); );
if (edge) { if (edge) {
edges.push([edge[0].id, edge[1], edge[2].id, edge[3]] as [ edges.push([edge[0].id, edge[1], edge[2].id, edge[3]] as [
number, number,
number, number,
number, number,
string, string
]); ]);
} }
} }
@@ -179,18 +266,18 @@ export class GraphManager extends EventEmitter<{
const n = node as NodeInstance; const n = node as NodeInstance;
if (nodeType) { if (nodeType) {
n.state = { n.state = {
type: nodeType, type: nodeType
}; };
} }
return [node.id, n]; return [node.id, n];
}), })
); );
const edges = graph.edges.map((edge) => { const edges = graph.edges.map((edge) => {
const from = nodes.get(edge[0]); const from = nodes.get(edge[0]);
const to = nodes.get(edge[2]); const to = 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');
} }
from.state.children = from.state.children || []; from.state.children = from.state.children || [];
from.state.children.push(to); from.state.children.push(to);
@@ -214,21 +301,21 @@ export class GraphManager extends EventEmitter<{
this.loaded = false; this.loaded = false;
this.graph = graph; this.graph = graph;
this.status = "loading"; this.status = 'loading';
this.id = graph.id; this.id = graph.id;
logger.info("loading graph", $state.snapshot(graph)); logger.info('loading graph', $state.snapshot(graph));
const nodeIds = Array.from(new Set([...graph.nodes.map((n) => n.type)])); const nodeIds = Array.from(new Set([...graph.nodes.map((n) => n.type)]));
await this.registry.load(nodeIds); await this.registry.load(nodeIds);
logger.info("loaded node types", this.registry.getAllNodes()); logger.info('loaded node types', this.registry.getAllNodes());
for (const node of this.graph.nodes) { for (const node of this.graph.nodes) {
const nodeType = this.registry.getNode(node.type); const nodeType = this.registry.getNode(node.type);
if (!nodeType) { if (!nodeType) {
logger.error(`Node type not found: ${node.type}`); logger.error(`Node type not found: ${node.type}`);
this.status = "error"; this.status = 'error';
return; return;
} }
// Turn into runtime node // Turn into runtime node
@@ -253,11 +340,11 @@ export class GraphManager extends EventEmitter<{
settingTypes[settingId] = { settingTypes[settingId] = {
__node_type: type.id, __node_type: type.id,
__node_input: key, __node_input: key,
...type.inputs[key], ...type.inputs[key]
}; };
if ( if (
settingValues[settingId] === undefined && settingValues[settingId] === undefined
"value" in type.inputs[key] && 'value' in type.inputs[key]
) { ) {
settingValues[settingId] = type.inputs[key].value; settingValues[settingId] = type.inputs[key].value;
} }
@@ -267,14 +354,14 @@ export class GraphManager extends EventEmitter<{
} }
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(this.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`); logger.log(`Graph loaded in ${performance.now() - a}ms`);
@@ -307,9 +394,9 @@ export class GraphManager extends EventEmitter<{
if (settingId) { if (settingId) {
settingTypes[settingId] = nodeType.inputs[key]; settingTypes[settingId] = nodeType.inputs[key];
if ( if (
settingValues && settingValues
settingValues?.[settingId] === undefined && && settingValues?.[settingId] === undefined
"value" in nodeType.inputs[key] && 'value' in nodeType.inputs[key]
) { ) {
settingValues[settingId] = nodeType.inputs[key].value; settingValues[settingId] = nodeType.inputs[key].value;
} }
@@ -319,7 +406,7 @@ export class GraphManager extends EventEmitter<{
this.settings = settingValues; this.settings = settingValues;
this.settingTypes = settingTypes; this.settingTypes = settingTypes;
this.emit("settings", { types: settingTypes, values: settingValues }); this.emit('settings', { types: settingTypes, values: settingValues });
} }
getChildren(node: NodeInstance) { getChildren(node: NodeInstance) {
@@ -368,7 +455,7 @@ export class GraphManager extends EventEmitter<{
const inputType = to?.state?.type?.inputs?.[toSocket]?.type; const inputType = to?.state?.type?.inputs?.[toSocket]?.type;
if (outputType === inputType) { if (outputType === inputType) {
this.createEdge(from, fromSocket, to, toSocket, { this.createEdge(from, fromSocket, to, toSocket, {
applyUpdate: false, applyUpdate: false
}); });
continue; continue;
} }
@@ -403,7 +490,7 @@ export class GraphManager extends EventEmitter<{
// map old ids to new ids // map old ids to new ids
const idMap = new Map<number, number>(); const idMap = new Map<number, number>();
let startId = this.createNodeId() let startId = this.createNodeId();
nodes = nodes.map((node) => { nodes = nodes.map((node) => {
const id = startId++; const id = startId++;
@@ -420,7 +507,7 @@ export class GraphManager extends EventEmitter<{
const to = nodes.find((n) => n.id === idMap.get(edge[2])); const to = nodes.find((n) => n.id === idMap.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');
} }
to.state.parents = to.state.parents || []; to.state.parents = to.state.parents || [];
@@ -445,11 +532,11 @@ export class GraphManager extends EventEmitter<{
createNode({ createNode({
type, type,
position, position,
props = {}, props = {}
}: { }: {
type: NodeInstance["type"]; type: NodeInstance['type'];
position: NodeInstance["position"]; position: NodeInstance['position'];
props: NodeInstance["props"]; props: NodeInstance['props'];
}) { }) {
const nodeType = this.registry.getNode(type); const nodeType = this.registry.getNode(type);
if (!nodeType) { if (!nodeType) {
@@ -462,14 +549,14 @@ export class GraphManager extends EventEmitter<{
type, type,
position, position,
state: { type: nodeType }, state: { type: nodeType },
props, props
}); });
this.nodes.set(node.id, node); this.nodes.set(node.id, node);
this.save(); this.save();
return node return node;
} }
createEdge( createEdge(
@@ -477,17 +564,16 @@ export class GraphManager extends EventEmitter<{
fromSocket: number, fromSocket: number,
to: NodeInstance, to: NodeInstance,
toSocket: string, toSocket: string,
{ applyUpdate = true } = {}, { applyUpdate = true } = {}
): Edge | undefined { ): Edge | undefined {
const existingEdges = this.getEdgesToNode(to); const existingEdges = this.getEdgesToNode(to);
// check if this exact edge already exists // check if this exact edge already exists
const existingEdge = existingEdges.find( const existingEdge = existingEdges.find(
(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); logger.error('Edge already exists', existingEdge);
return; return;
} }
@@ -500,13 +586,13 @@ export class GraphManager extends EventEmitter<{
if (!areSocketsCompatible(fromSocketType, toSocketType)) { if (!areSocketsCompatible(fromSocketType, toSocketType)) {
logger.error( logger.error(
`Socket types do not match: ${fromSocketType} !== ${toSocketType}`, `Socket types do not match: ${fromSocketType} !== ${toSocketType}`
); );
return; return;
} }
const edgeToBeReplaced = this.edges.find( const edgeToBeReplaced = this.edges.find(
(e) => e[2].id === to.id && e[3] === toSocket, (e) => e[2].id === to.id && e[3] === toSocket
); );
if (edgeToBeReplaced) { if (edgeToBeReplaced) {
this.removeEdge(edgeToBeReplaced, { applyDeletion: false }); this.removeEdge(edgeToBeReplaced, { applyDeletion: false });
@@ -533,7 +619,7 @@ export class GraphManager extends EventEmitter<{
const nextState = this.history.undo(); const nextState = this.history.undo();
if (nextState) { if (nextState) {
this._init(nextState); this._init(nextState);
this.emit("save", this.serialize()); this.emit('save', this.serialize());
} }
} }
@@ -541,7 +627,7 @@ export class GraphManager extends EventEmitter<{
const nextState = this.history.redo(); const nextState = this.history.redo();
if (nextState) { if (nextState) {
this._init(nextState); this._init(nextState);
this.emit("save", this.serialize()); this.emit('save', this.serialize());
} }
} }
@@ -558,8 +644,8 @@ export class GraphManager extends EventEmitter<{
if (this.currentUndoGroup) return; if (this.currentUndoGroup) return;
const state = this.serialize(); const state = this.serialize();
this.history.save(state); this.history.save(state);
this.emit("save", state); this.emit('save', state);
logger.log("saving graphs", state); logger.log('saving graphs', state);
} }
getParentsOfNode(node: NodeInstance) { getParentsOfNode(node: NodeInstance) {
@@ -567,7 +653,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"); logger.warn('Infinite loop detected');
break; break;
} }
const parent = stack.pop(); const parent = stack.pop();
@@ -586,26 +672,28 @@ export class GraphManager extends EventEmitter<{
return []; return [];
} }
const definitions = typeof socket.index === "string" const definitions = typeof socket.index === 'string'
? allDefinitions.filter(s => { ? allDefinitions.filter(s => {
return s.outputs?.find(_s => Object return s.outputs?.find(_s =>
Object
.values(nodeType?.inputs || {}) .values(nodeType?.inputs || {})
.map(s => s.type) .map(s => s.type)
.includes(_s as NodeInput["type"]) .includes(_s as NodeInput['type'])
) );
}) })
: allDefinitions.filter(s => Object : allDefinitions.filter(s =>
Object
.values(s.inputs ?? {}) .values(s.inputs ?? {})
.find(s => { .find(s => {
if (s.hidden) return false; if (s.hidden) return false;
if (nodeType.outputs?.includes(s.type)) { if (nodeType.outputs?.includes(s.type)) {
return true return true;
} }
return s.accepts?.find(a => nodeType.outputs?.includes(a)) return s.accepts?.find(a => nodeType.outputs?.includes(a));
})) })
);
return definitions
return definitions;
} }
getPossibleSockets({ node, index }: Socket): [NodeInstance, string | number][] { getPossibleSockets({ node, index }: Socket): [NodeInstance, string | number][] {
@@ -615,11 +703,11 @@ export class GraphManager extends EventEmitter<{
const sockets: [NodeInstance, string | number][] = []; const sockets: [NodeInstance, string | number][] = [];
// if index is a string, we are an input looking for outputs // if index is a string, we are an input looking for outputs
if (typeof index === "string") { if (typeof index === 'string') {
// filter out self and child nodes // filter out self and child nodes
const children = new Set(this.getChildren(node).map((n) => n.id)); const children = new Set(this.getChildren(node).map((n) => n.id));
const nodes = this.getAllNodes().filter( const nodes = this.getAllNodes().filter(
(n) => n.id !== node.id && !children.has(n.id), (n) => n.id !== node.id && !children.has(n.id)
); );
const ownType = nodeType?.inputs?.[index].type; const ownType = nodeType?.inputs?.[index].type;
@@ -634,20 +722,20 @@ export class GraphManager extends EventEmitter<{
} }
} }
} }
} else if (typeof index === "number") { } else if (typeof index === 'number') {
// if index is a number, we are an output looking for inputs // if index is a number, we are an output looking for inputs
// filter out self and parent nodes // filter out self and parent nodes
const parents = new Set(this.getParentsOfNode(node).map((n) => n.id)); const parents = new Set(this.getParentsOfNode(node).map((n) => n.id));
const nodes = this.getAllNodes().filter( const nodes = this.getAllNodes().filter(
(n) => n.id !== node.id && !parents.has(n.id), (n) => n.id !== node.id && !parents.has(n.id)
); );
// get edges from this socket // get edges from this socket
const edges = new Map( const edges = new Map(
this.getEdgesFromNode(node) this.getEdgesFromNode(node)
.filter((e) => e[1] === index) .filter((e) => e[1] === index)
.map((e) => [e[2].id, e[3]]), .map((e) => [e[2].id, e[3]])
); );
const ownType = nodeType.outputs?.[index]; const ownType = nodeType.outputs?.[index];
@@ -660,8 +748,8 @@ export class GraphManager extends EventEmitter<{
otherType.push(...(inputs[key].accepts || [])); otherType.push(...(inputs[key].accepts || []));
if ( if (
areSocketsCompatible(ownType, otherType) && areSocketsCompatible(ownType, otherType)
edges.get(node.id) !== key && edges.get(node.id) !== key
) { ) {
sockets.push([node, key]); sockets.push([node, key]);
} }
@@ -674,7 +762,7 @@ export class GraphManager extends EventEmitter<{
removeEdge( removeEdge(
edge: Edge, edge: Edge,
{ applyDeletion = true }: { applyDeletion?: boolean } = {}, { applyDeletion = true }: { applyDeletion?: boolean } = {}
) { ) {
const id0 = edge[0].id; const id0 = edge[0].id;
const sid0 = edge[1]; const sid0 = edge[1];
@@ -682,21 +770,20 @@ export class GraphManager extends EventEmitter<{
const sid2 = edge[3]; const sid2 = edge[3];
const _edge = this.edges.find( const _edge = this.edges.find(
(e) => (e) => e[0].id === id0 && e[1] === sid0 && e[2].id === id2 && e[3] === sid2
e[0].id === id0 && e[1] === sid0 && e[2].id === id2 && e[3] === sid2,
); );
if (!_edge) return; if (!_edge) return;
if (edge[0].state.children) { if (edge[0].state.children) {
edge[0].state.children = edge[0].state.children.filter( edge[0].state.children = edge[0].state.children.filter(
(n: NodeInstance) => n.id !== id2, (n: NodeInstance) => n.id !== id2
); );
} }
if (edge[2].state.parents) { if (edge[2].state.parents) {
edge[2].state.parents = edge[2].state.parents.filter( edge[2].state.parents = edge[2].state.parents.filter(
(n: NodeInstance) => n.id !== id0, (n: NodeInstance) => n.id !== id0
); );
} }
@@ -705,7 +792,6 @@ export class GraphManager extends EventEmitter<{
this.execute(); this.execute();
this.save(); this.save();
} }
} }
getEdgesToNode(node: NodeInstance) { getEdgesToNode(node: NodeInstance) {

View File

@@ -1,36 +1,43 @@
import type { NodeInstance, Socket } from "@nodarium/types"; import type { NodeInstance, Socket } from '@nodarium/types';
import { getContext, setContext } from "svelte"; import { getContext, setContext } from 'svelte';
import { SvelteSet } from "svelte/reactivity"; import { SvelteSet } from 'svelte/reactivity';
import type { GraphManager } from "./graph-manager.svelte"; import type { OrthographicCamera, Vector3 } from 'three';
import type { OrthographicCamera } from "three"; import type { GraphManager } from './graph-manager.svelte';
const graphStateKey = Symbol('graph-state');
const graphStateKey = Symbol("graph-state");
export function getGraphState() { export function getGraphState() {
return getContext<GraphState>(graphStateKey); return getContext<GraphState>(graphStateKey);
} }
export function setGraphState(graphState: GraphState) { export function setGraphState(graphState: GraphState) {
return setContext(graphStateKey, graphState) return setContext(graphStateKey, graphState);
} }
const graphManagerKey = Symbol("graph-manager"); const graphManagerKey = Symbol('graph-manager');
export function getGraphManager() { export function getGraphManager() {
return getContext<GraphManager>(graphManagerKey) return getContext<GraphManager>(graphManagerKey);
} }
export function setGraphManager(manager: GraphManager) { export function setGraphManager(manager: GraphManager) {
return setContext(graphManagerKey, manager); return setContext(graphManagerKey, manager);
} }
export class GraphState { type EdgeData = {
x1: number;
y1: number;
points: Vector3[];
};
export class GraphState {
constructor(private graph: GraphManager) { constructor(private graph: GraphManager) {
$effect.root(() => { $effect.root(() => {
$effect(() => { $effect(() => {
localStorage.setItem("cameraPosition", `[${this.cameraPosition[0]},${this.cameraPosition[1]},${this.cameraPosition[2]}]`) localStorage.setItem(
}) 'cameraPosition',
}) `[${this.cameraPosition[0]},${this.cameraPosition[1]},${this.cameraPosition[2]}]`
const storedPosition = localStorage.getItem("cameraPosition") );
});
});
const storedPosition = localStorage.getItem('cameraPosition');
if (storedPosition) { if (storedPosition) {
try { try {
const d = JSON.parse(storedPosition); const d = JSON.parse(storedPosition);
@@ -38,7 +45,7 @@ export class GraphState {
this.cameraPosition[1] = d[1]; this.cameraPosition[1] = d[1];
this.cameraPosition[2] = d[2]; this.cameraPosition[2] = d[2];
} catch (e) { } catch (e) {
console.log("Failed to parsed stored camera position", e); console.log('Failed to parsed stored camera position', e);
} }
} }
} }
@@ -46,13 +53,16 @@ export class GraphState {
width = $state(100); width = $state(100);
height = $state(100); height = $state(100);
hoveredEdgeId = $state<string | null>(null);
edges = new Map<string, EdgeData>();
wrapper = $state<HTMLDivElement>(null!); wrapper = $state<HTMLDivElement>(null!);
rect: DOMRect = $derived( rect: DOMRect = $derived(
(this.wrapper && this.width && this.height) ? this.wrapper.getBoundingClientRect() : new DOMRect(0, 0, 0, 0), (this.wrapper && this.width && this.height) ? this.wrapper.getBoundingClientRect() : new DOMRect(0, 0, 0, 0)
); );
camera = $state<OrthographicCamera>(null!); camera = $state<OrthographicCamera>(null!);
cameraPosition: [number, number, number] = $state([0, 0, 4]); cameraPosition: [number, number, number] = $state([0, 0, 100]);
clipboard: null | { clipboard: null | {
nodes: NodeInstance[]; nodes: NodeInstance[];
@@ -63,7 +73,7 @@ export class GraphState {
this.cameraPosition[0] - this.width / this.cameraPosition[2] / 2, this.cameraPosition[0] - this.width / this.cameraPosition[2] / 2,
this.cameraPosition[0] + this.width / this.cameraPosition[2] / 2, this.cameraPosition[0] + this.width / this.cameraPosition[2] / 2,
this.cameraPosition[1] - this.height / this.cameraPosition[2] / 2, this.cameraPosition[1] - this.height / this.cameraPosition[2] / 2,
this.cameraPosition[1] + this.height / this.cameraPosition[2] / 2, this.cameraPosition[1] + this.height / this.cameraPosition[2] / 2
]); ]);
boxSelection = $state(false); boxSelection = $state(false);
@@ -71,8 +81,8 @@ export class GraphState {
addMenuPosition = $state<[number, number] | null>(null); addMenuPosition = $state<[number, number] | null>(null);
snapToGrid = $state(false); snapToGrid = $state(false);
showGrid = $state(true) showGrid = $state(true);
showHelp = $state(false) showHelp = $state(false);
cameraDown = [0, 0]; cameraDown = [0, 0];
mouseDownNodeId = -1; mouseDownNodeId = -1;
@@ -88,33 +98,49 @@ export class GraphState {
hoveredSocket = $state<Socket | null>(null); hoveredSocket = $state<Socket | null>(null);
possibleSockets = $state<Socket[]>([]); possibleSockets = $state<Socket[]>([]);
possibleSocketIds = $derived( possibleSocketIds = $derived(
new Set(this.possibleSockets.map((s) => `${s.node.id}-${s.index}`)), new Set(this.possibleSockets.map((s) => `${s.node.id}-${s.index}`))
); );
getEdges() {
return $state.snapshot(this.edges);
}
clearSelection() { clearSelection() {
this.selectedNodes.clear(); this.selectedNodes.clear();
} }
isBodyFocused = () => document?.activeElement?.nodeName !== "INPUT"; isBodyFocused = () => document?.activeElement?.nodeName !== 'INPUT';
setEdgeGeometry(edgeId: string, x1: number, y1: number, points: Vector3[]) {
this.edges.set(edgeId, { x1, y1, points });
}
removeEdgeGeometry(edgeId: string) {
this.edges.delete(edgeId);
}
getEdgeData() {
return this.edges;
}
updateNodePosition(node: NodeInstance) { updateNodePosition(node: NodeInstance) {
if ( if (
node.state.x === node.position[0] && node.state.x === node.position[0]
node.state.y === node.position[1] && node.state.y === node.position[1]
) { ) {
delete node.state.x; delete node.state.x;
delete node.state.y; delete node.state.y;
} }
if (node.state["x"] !== undefined && node.state["y"] !== undefined) { if (node.state['x'] !== undefined && node.state['y'] !== undefined) {
if (node.state.ref) { if (node.state.ref) {
node.state.ref.style.setProperty("--nx", `${node.state.x * 10}px`); node.state.ref.style.setProperty('--nx', `${node.state.x * 10}px`);
node.state.ref.style.setProperty("--ny", `${node.state.y * 10}px`); node.state.ref.style.setProperty('--ny', `${node.state.y * 10}px`);
} }
} else { } else {
if (node.state.ref) { if (node.state.ref) {
node.state.ref.style.setProperty("--nx", `${node.position[0] * 10}px`); node.state.ref.style.setProperty('--nx', `${node.position[0] * 10}px`);
node.state.ref.style.setProperty("--ny", `${node.position[1] * 10}px`); node.state.ref.style.setProperty('--ny', `${node.position[1] * 10}px`);
} }
} }
} }
@@ -134,18 +160,18 @@ export class GraphState {
getSocketPosition( getSocketPosition(
node: NodeInstance, node: NodeInstance,
index: string | number, index: string | number
): [number, number] { ): [number, number] {
if (typeof index === "number") { if (typeof index === 'number') {
return [ return [
(node?.state?.x ?? node.position[0]) + 20, (node?.state?.x ?? node.position[0]) + 20,
(node?.state?.y ?? node.position[1]) + 2.5 + 10 * index, (node?.state?.y ?? node.position[1]) + 2.5 + 10 * index
]; ];
} else { } else {
const _index = Object.keys(node.state?.type?.inputs || {}).indexOf(index); const _index = Object.keys(node.state?.type?.inputs || {}).indexOf(index);
return [ return [
node?.state?.x ?? node.position[0], node?.state?.x ?? node.position[0],
(node?.state?.y ?? node.position[1]) + 10 + 10 * _index, (node?.state?.y ?? node.position[1]) + 10 + 10 * _index
]; ];
} }
} }
@@ -159,26 +185,26 @@ export class GraphState {
if (!node?.inputs) { if (!node?.inputs) {
return 5; return 5;
} }
const height = const height = 5
5 + + 10
10 * * Object.keys(node.inputs).filter(
Object.keys(node.inputs).filter(
(p) => (p) =>
p !== "seed" && p !== 'seed'
node?.inputs && && node?.inputs
!("setting" in node?.inputs?.[p]) && && !('setting' in node?.inputs?.[p])
node.inputs[p].hidden !== true, && node.inputs[p].hidden !== true
).length; ).length;
this.nodeHeightCache[nodeTypeId] = height; this.nodeHeightCache[nodeTypeId] = height;
return height; return height;
} }
copyNodes() { copyNodes() {
if (this.activeNodeId === -1 && !this.selectedNodes?.size) if (this.activeNodeId === -1 && !this.selectedNodes?.size) {
return; return;
}
let nodes = [ let nodes = [
this.activeNodeId, this.activeNodeId,
...(this.selectedNodes?.values() || []), ...(this.selectedNodes?.values() || [])
] ]
.map((id) => this.graph.getNode(id)) .map((id) => this.graph.getNode(id))
.filter(b => !!b); .filter(b => !!b);
@@ -188,14 +214,14 @@ export class GraphState {
...node, ...node,
position: [ position: [
this.mousePosition[0] - node.position[0], this.mousePosition[0] - node.position[0],
this.mousePosition[1] - node.position[1], this.mousePosition[1] - node.position[1]
], ],
tmp: undefined, tmp: undefined
})); }));
this.clipboard = { this.clipboard = {
nodes: nodes, nodes: nodes,
edges: edges, edges: edges
}; };
} }
@@ -217,14 +243,13 @@ export class GraphState {
} }
} }
setDownSocket(socket: Socket) { setDownSocket(socket: Socket) {
this.activeSocket = socket; this.activeSocket = socket;
let { node, index, position } = socket; let { node, index, position } = socket;
// remove existing edge // remove existing edge
if (typeof index === "string") { if (typeof index === 'string') {
const edges = this.graph.getEdgesToNode(node); const edges = this.graph.getEdgesToNode(node);
for (const edge of edges) { for (const edge of edges) {
if (edge[3] === index) { if (edge[3] === index) {
@@ -241,7 +266,7 @@ export class GraphState {
this.activeSocket = { this.activeSocket = {
node, node,
index, index,
position, position
}; };
this.possibleSockets = this.graph this.possibleSockets = this.graph
@@ -250,18 +275,17 @@ export class GraphState {
return { return {
node, node,
index, index,
position: this.getSocketPosition(node, index), position: this.getSocketPosition(node, index)
}; };
}); });
}; }
projectScreenToWorld(x: number, y: number): [number, number] { projectScreenToWorld(x: number, y: number): [number, number] {
return [ return [
this.cameraPosition[0] + this.cameraPosition[0]
(x - this.width / 2) / this.cameraPosition[2], + (x - this.width / 2) / this.cameraPosition[2],
this.cameraPosition[1] + this.cameraPosition[1]
(y - this.height / 2) / this.cameraPosition[2], + (y - this.height / 2) / this.cameraPosition[2]
]; ];
} }
@@ -274,8 +298,8 @@ export class GraphState {
if (event.button === 0) { if (event.button === 0) {
// check if the clicked element is a node // check if the clicked element is a node
if (event.target instanceof HTMLElement) { if (event.target instanceof HTMLElement) {
const nodeElement = event.target.closest(".node"); const nodeElement = event.target.closest('.node');
const nodeId = nodeElement?.getAttribute?.("data-node-id"); const nodeId = nodeElement?.getAttribute?.('data-node-id');
if (nodeId) { if (nodeId) {
clickedNodeId = parseInt(nodeId, 10); clickedNodeId = parseInt(nodeId, 10);
} }
@@ -303,10 +327,10 @@ export class GraphState {
const height = this.getNodeHeight(node.type); const height = this.getNodeHeight(node.type);
const width = 20; const width = 20;
return ( return (
node.position[0] > this.cameraBounds[0] - width && node.position[0] > this.cameraBounds[0] - width
node.position[0] < this.cameraBounds[1] && && node.position[0] < this.cameraBounds[1]
node.position[1] > this.cameraBounds[2] - height && && node.position[1] > this.cameraBounds[2] - height
node.position[1] < this.cameraBounds[3] && node.position[1] < this.cameraBounds[3]
); );
}; }
} }

View File

@@ -11,8 +11,10 @@
import HelpView from "../components/HelpView.svelte"; import HelpView from "../components/HelpView.svelte";
import { getGraphManager, getGraphState } from "../graph-state.svelte"; import { getGraphManager, getGraphState } from "../graph-state.svelte";
import { HTML } from "@threlte/extras"; import { HTML } from "@threlte/extras";
import { FileDropEventManager, MouseEventManager } from "./events";
import { maxZoom, minZoom } from "./constants"; import { maxZoom, minZoom } from "./constants";
import Debug from "../debug/Debug.svelte";
import { FileDropEventManager } from "./drop.events";
import { MouseEventManager } from "./mouse.events";
const { const {
keymap, keymap,
@@ -174,9 +176,18 @@
{#each graph.edges as edge} {#each graph.edges as edge}
{@const [x1, y1, x2, y2] = getEdgePosition(edge)} {@const [x1, y1, x2, y2] = getEdgePosition(edge)}
<EdgeEl z={graphState.cameraPosition[2]} {x1} {y1} {x2} {y2} /> <EdgeEl
id={graph.getEdgeId(edge)}
z={graphState.cameraPosition[2]}
{x1}
{y1}
{x2}
{y2}
/>
{/each} {/each}
<Debug />
<HTML transform={false}> <HTML transform={false}>
<div <div
role="tree" role="tree"

View File

@@ -0,0 +1,107 @@
import { GraphSchema, type NodeId } from '@nodarium/types';
import type { GraphManager } from '../graph-manager.svelte';
import type { GraphState } from '../graph-state.svelte';
export class FileDropEventManager {
constructor(
private graph: GraphManager,
private state: GraphState
) { }
handleFileDrop(event: DragEvent) {
event.preventDefault();
this.state.isDragging = false;
if (!event.dataTransfer) return;
const nodeId = event.dataTransfer.getData('data/node-id') as NodeId;
let mx = event.clientX - this.state.rect.x;
let my = event.clientY - this.state.rect.y;
if (nodeId) {
let nodeOffsetX = event.dataTransfer.getData('data/node-offset-x');
let nodeOffsetY = event.dataTransfer.getData('data/node-offset-y');
if (nodeOffsetX && nodeOffsetY) {
mx += parseInt(nodeOffsetX);
my += parseInt(nodeOffsetY);
}
let props = {};
let rawNodeProps = event.dataTransfer.getData('data/node-props');
if (rawNodeProps) {
try {
props = JSON.parse(rawNodeProps);
} catch (e) { }
}
const pos = this.state.projectScreenToWorld(mx, my);
this.graph.registry.load([nodeId]).then(() => {
this.graph.createNode({
type: nodeId,
props,
position: pos
});
});
} else if (event.dataTransfer.files.length) {
const file = event.dataTransfer.files[0];
if (file.type === 'application/wasm') {
const reader = new FileReader();
reader.onload = async (e) => {
const buffer = e.target?.result;
if (buffer?.constructor === ArrayBuffer) {
const nodeType = await this.graph.registry.register(buffer);
this.graph.createNode({
type: nodeType.id,
props: {},
position: this.state.projectScreenToWorld(mx, my)
});
}
};
reader.readAsArrayBuffer(file);
} else if (file.type === 'application/json') {
const reader = new FileReader();
reader.onload = (e) => {
const buffer = e.target?.result as ArrayBuffer;
if (buffer) {
const state = GraphSchema.parse(JSON.parse(buffer.toString()));
this.graph.load(state);
}
};
reader.readAsText(file);
}
}
}
handleMouseLeave() {
this.state.isDragging = false;
this.state.isPanning = false;
}
handleDragEnter(e: DragEvent) {
e.preventDefault();
this.state.isDragging = true;
this.state.isPanning = false;
}
handleDragOver(e: DragEvent) {
e.preventDefault();
this.state.isDragging = true;
this.state.isPanning = false;
}
handleDragEnd(e: DragEvent) {
e.preventDefault();
this.state.isDragging = true;
this.state.isPanning = false;
}
getEventListenerProps() {
return {
ondragenter: (ev: DragEvent) => this.handleDragEnter(ev),
ondragover: (ev: DragEvent) => this.handleDragOver(ev),
ondragexit: (ev: DragEvent) => this.handleDragEnd(ev),
ondrop: (ev: DragEvent) => this.handleFileDrop(ev),
onmouseleave: () => this.handleMouseLeave()
};
}
}

View File

@@ -0,0 +1,110 @@
import type { Box } from '@nodarium/types';
import type { GraphManager } from '../graph-manager.svelte';
import type { GraphState } from '../graph-state.svelte';
import { distanceFromPointToSegment } from '../helpers';
export class EdgeInteractionManager {
constructor(
private graph: GraphManager,
private state: GraphState
) { }
private MIN_DISTANCE = 3;
private _boundingBoxes = new Map<string, Box>();
handleMouseDown() {
this._boundingBoxes.clear();
const possibleEdges = this.graph
.getPossibleDropOnEdges(this.state.activeNodeId)
.map(e => this.graph.getEdgeId(e));
const edges = this.state.getEdges();
for (const edge of edges) {
const edgeId = edge[0];
if (!possibleEdges.includes(edgeId)) {
edges.delete(edgeId);
}
}
for (const [edgeId, data] of edges) {
const points = data.points;
let minX = points[0].x + data.x1;
let maxX = points[0].x + data.x1;
let minY = points[0].z + data.y1;
let maxY = points[0].z + data.y1;
// Iterate through all points to find the true bounds
for (let i = 0; i < points.length; i++) {
const x = data.x1 + points[i].x;
const y = data.y1 + points[i].z;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
const boundingBox = {
minX: minX - this.MIN_DISTANCE,
maxX: maxX + this.MIN_DISTANCE,
minY: minY - this.MIN_DISTANCE,
maxY: maxY + this.MIN_DISTANCE
};
this._boundingBoxes.set(edgeId, boundingBox);
}
}
handleMouseMove() {
const [mouseX, mouseY] = this.state.mousePosition;
const hoveredEdgeIds: string[] = [];
const edges = this.state.getEdges();
// Check if mouse is inside any bounding box
for (const [edgeId, box] of this._boundingBoxes) {
const isInside = mouseX >= box.minX
&& mouseX <= box.maxX
&& mouseY >= box.minY
&& mouseY <= box.maxY;
if (isInside) {
hoveredEdgeIds.push(edgeId);
}
}
let hoveredEdgeId: string | null = null;
let hoveredEdgeDistance = Infinity;
const DENSITY = 10; // higher DENSITY = less points checked (yes density might not be the best name :-)
for (const edgeId of hoveredEdgeIds) {
const edge = edges.get(edgeId)!;
for (let i = 0; i < edge.points.length - DENSITY; i += DENSITY) {
const pointAx = edge.points[i].x + edge.x1;
const pointAy = edge.points[i].z + edge.y1;
const pointBx = edge.points[i + DENSITY].x + edge.x1;
const pointBy = edge.points[i + DENSITY].z + edge.y1;
const distance = distanceFromPointToSegment(pointAx, pointAy, pointBx, pointBy, mouseX, mouseY);
if (distance < this.MIN_DISTANCE) {
if (distance < hoveredEdgeDistance) {
hoveredEdgeDistance = distance;
hoveredEdgeId = edgeId;
}
}
}
}
this.state.hoveredEdgeId = hoveredEdgeId;
}
handleMouseUp() {
if (this.state.hoveredEdgeId) {
const edge = this.graph.getEdgeById(this.state.hoveredEdgeId);
if (edge) {
this.graph.dropNodeOnEdge(this.state.activeNodeId, edge);
}
this.state.hoveredEdgeId = null;
}
}
}

View File

@@ -1,127 +1,23 @@
import { GraphSchema, type NodeId, type NodeInstance } from "@nodarium/types"; import { animate, lerp } from '$lib/helpers';
import type { GraphManager } from "../graph-manager.svelte"; import { type NodeInstance } from '@nodarium/types';
import type { GraphState } from "../graph-state.svelte"; import type { GraphManager } from '../graph-manager.svelte';
import { animate, lerp } from "$lib/helpers"; import type { GraphState } from '../graph-state.svelte';
import { snapToGrid as snapPointToGrid } from "../helpers"; import { snapToGrid as snapPointToGrid } from '../helpers';
import { maxZoom, minZoom, zoomSpeed } from "./constants"; import { maxZoom, minZoom, zoomSpeed } from './constants';
import { EdgeInteractionManager } from './edge.events';
export class FileDropEventManager {
constructor(
private graph: GraphManager,
private state: GraphState
) { }
handleFileDrop(event: DragEvent) {
event.preventDefault();
this.state.isDragging = false;
if (!event.dataTransfer) return;
const nodeId = event.dataTransfer.getData("data/node-id") as NodeId;
let mx = event.clientX - this.state.rect.x;
let my = event.clientY - this.state.rect.y;
if (nodeId) {
let nodeOffsetX = event.dataTransfer.getData("data/node-offset-x");
let nodeOffsetY = event.dataTransfer.getData("data/node-offset-y");
if (nodeOffsetX && nodeOffsetY) {
mx += parseInt(nodeOffsetX);
my += parseInt(nodeOffsetY);
}
let props = {};
let rawNodeProps = event.dataTransfer.getData("data/node-props");
if (rawNodeProps) {
try {
props = JSON.parse(rawNodeProps);
} catch (e) { }
}
const pos = this.state.projectScreenToWorld(mx, my);
this.graph.registry.load([nodeId]).then(() => {
this.graph.createNode({
type: nodeId,
props,
position: pos,
});
});
} else if (event.dataTransfer.files.length) {
const file = event.dataTransfer.files[0];
if (file.type === "application/wasm") {
const reader = new FileReader();
reader.onload = async (e) => {
const buffer = e.target?.result;
if (buffer?.constructor === ArrayBuffer) {
const nodeType = await this.graph.registry.register(buffer);
this.graph.createNode({
type: nodeType.id,
props: {},
position: this.state.projectScreenToWorld(mx, my),
});
}
};
reader.readAsArrayBuffer(file);
} else if (file.type === "application/json") {
const reader = new FileReader();
reader.onload = (e) => {
const buffer = e.target?.result as ArrayBuffer;
if (buffer) {
const state = GraphSchema.parse(JSON.parse(buffer.toString()));
this.graph.load(state);
}
};
reader.readAsText(file);
}
}
}
handleMouseLeave() {
this.state.isDragging = false;
this.state.isPanning = false;
}
handleDragEnter(e: DragEvent) {
e.preventDefault();
this.state.isDragging = true;
this.state.isPanning = false;
}
handleDragOver(e: DragEvent) {
e.preventDefault();
this.state.isDragging = true;
this.state.isPanning = false;
}
handleDragEnd(e: DragEvent) {
e.preventDefault();
this.state.isDragging = true;
this.state.isPanning = false;
}
getEventListenerProps() {
return {
ondragenter: (ev: DragEvent) => this.handleDragEnter(ev),
ondragover: (ev: DragEvent) => this.handleDragOver(ev),
ondragexit: (ev: DragEvent) => this.handleDragEnd(ev),
ondrop: (ev: DragEvent) => this.handleFileDrop(ev),
onmouseleave: () => this.handleMouseLeave(),
}
}
}
export class MouseEventManager { export class MouseEventManager {
edgeInteractionManager: EdgeInteractionManager;
constructor( constructor(
private graph: GraphManager, private graph: GraphManager,
private state: GraphState private state: GraphState
) { } ) {
this.edgeInteractionManager = new EdgeInteractionManager(graph, state);
}
handleMouseUp(event: MouseEvent) { handleMouseUp(event: MouseEvent) {
this.edgeInteractionManager.handleMouseUp();
this.state.isPanning = false; this.state.isPanning = false;
if (!this.state.mouseDown) return; if (!this.state.mouseDown) return;
@@ -145,25 +41,23 @@ export class MouseEventManager {
const snapLevel = this.state.getSnapLevel(); const snapLevel = this.state.getSnapLevel();
activeNode.position[0] = snapPointToGrid( activeNode.position[0] = snapPointToGrid(
activeNode?.state?.x ?? activeNode.position[0], activeNode?.state?.x ?? activeNode.position[0],
5 / snapLevel, 5 / snapLevel
); );
activeNode.position[1] = snapPointToGrid( activeNode.position[1] = snapPointToGrid(
activeNode?.state?.y ?? activeNode.position[1], activeNode?.state?.y ?? activeNode.position[1],
5 / snapLevel, 5 / snapLevel
); );
} else { } else {
activeNode.position[0] = activeNode?.state?.x ?? activeNode.position[0]; activeNode.position[0] = activeNode?.state?.x ?? activeNode.position[0];
activeNode.position[1] = activeNode?.state?.y ?? activeNode.position[1]; activeNode.position[1] = activeNode?.state?.y ?? activeNode.position[1];
} }
const nodes = [ const nodes = [
...[...(this.state.selectedNodes?.values() || [])].map((id) => ...[...(this.state.selectedNodes?.values() || [])].map((id) => this.graph.getNode(id))
this.graph.getNode(id),
),
] as NodeInstance[]; ] as NodeInstance[];
const vec = [ const vec = [
activeNode.position[0] - (activeNode?.state.x || 0), activeNode.position[0] - (activeNode?.state.x || 0),
activeNode.position[1] - (activeNode?.state.y || 0), activeNode.position[1] - (activeNode?.state.y || 0)
]; ];
for (const node of nodes) { for (const node of nodes) {
@@ -179,9 +73,9 @@ export class MouseEventManager {
animate(500, (a: number) => { animate(500, (a: number) => {
for (const node of nodes) { for (const node of nodes) {
if ( if (
node?.state && node?.state
node.state["x"] !== undefined && && node.state['x'] !== undefined
node.state["y"] !== undefined && node.state['y'] !== undefined
) { ) {
node.state.x = lerp(node.state.x, node.position[0], a); node.state.x = lerp(node.state.x, node.position[0], a);
node.state.y = lerp(node.state.y, node.position[1], a); node.state.y = lerp(node.state.y, node.position[1], a);
@@ -195,24 +89,24 @@ export class MouseEventManager {
this.graph.save(); this.graph.save();
} else if (this.state.hoveredSocket && this.state.activeSocket) { } else if (this.state.hoveredSocket && this.state.activeSocket) {
if ( if (
typeof this.state.hoveredSocket.index === "number" && typeof this.state.hoveredSocket.index === 'number'
typeof this.state.activeSocket.index === "string" && typeof this.state.activeSocket.index === 'string'
) { ) {
this.graph.createEdge( this.graph.createEdge(
this.state.hoveredSocket.node, this.state.hoveredSocket.node,
this.state.hoveredSocket.index || 0, this.state.hoveredSocket.index || 0,
this.state.activeSocket.node, this.state.activeSocket.node,
this.state.activeSocket.index, this.state.activeSocket.index
); );
} else if ( } else if (
typeof this.state.activeSocket.index == "number" && typeof this.state.activeSocket.index == 'number'
typeof this.state.hoveredSocket.index === "string" && typeof this.state.hoveredSocket.index === 'string'
) { ) {
this.graph.createEdge( this.graph.createEdge(
this.state.activeSocket.node, this.state.activeSocket.node,
this.state.activeSocket.index || 0, this.state.activeSocket.index || 0,
this.state.hoveredSocket.node, this.state.hoveredSocket.node,
this.state.hoveredSocket.index, this.state.hoveredSocket.index
); );
} }
this.graph.save(); this.graph.save();
@@ -220,18 +114,18 @@ export class MouseEventManager {
// Handle automatic adding of nodes on ctrl+mouseUp // Handle automatic adding of nodes on ctrl+mouseUp
this.state.edgeEndPosition = [ this.state.edgeEndPosition = [
this.state.mousePosition[0], this.state.mousePosition[0],
this.state.mousePosition[1], this.state.mousePosition[1]
]; ];
if (typeof this.state.activeSocket.index === "number") { if (typeof this.state.activeSocket.index === 'number') {
this.state.addMenuPosition = [ this.state.addMenuPosition = [
this.state.mousePosition[0], this.state.mousePosition[0],
this.state.mousePosition[1] - 25 / this.state.cameraPosition[2], this.state.mousePosition[1] - 25 / this.state.cameraPosition[2]
]; ];
} else { } else {
this.state.addMenuPosition = [ this.state.addMenuPosition = [
this.state.mousePosition[0] - 155 / this.state.cameraPosition[2], this.state.mousePosition[0] - 155 / this.state.cameraPosition[2],
this.state.mousePosition[1] - 25 / this.state.cameraPosition[2], this.state.mousePosition[1] - 25 / this.state.cameraPosition[2]
]; ];
} }
return; return;
@@ -239,11 +133,11 @@ export class MouseEventManager {
// check if camera moved // check if camera moved
if ( if (
clickedNodeId === -1 && clickedNodeId === -1
!this.state.boxSelection && && !this.state.boxSelection
this.state.cameraDown[0] === this.state.cameraPosition[0] && && this.state.cameraDown[0] === this.state.cameraPosition[0]
this.state.cameraDown[1] === this.state.cameraPosition[1] && && this.state.cameraDown[1] === this.state.cameraPosition[1]
this.state.isBodyFocused() && this.state.isBodyFocused()
) { ) {
this.state.activeNodeId = -1; this.state.activeNodeId = -1;
this.state.clearSelection(); this.state.clearSelection();
@@ -257,16 +151,15 @@ export class MouseEventManager {
this.state.addMenuPosition = null; this.state.addMenuPosition = null;
} }
handleMouseDown(event: MouseEvent) { handleMouseDown(event: MouseEvent) {
if (this.state.mouseDown) return; if (this.state.mouseDown) return;
this.state.edgeEndPosition = null; this.state.edgeEndPosition = null;
if (event.target instanceof HTMLElement) { if (event.target instanceof HTMLElement) {
if ( if (
event.target.nodeName !== "CANVAS" && event.target.nodeName !== 'CANVAS'
!event.target.classList.contains("node") && && !event.target.classList.contains('node')
!event.target.classList.contains("content") && !event.target.classList.contains('content')
) { ) {
return; return;
} }
@@ -288,7 +181,7 @@ export class MouseEventManager {
this.state.activeNodeId = clickedNodeId; this.state.activeNodeId = clickedNodeId;
// if the selected node is the same as the clicked node // if the selected node is the same as the clicked node
} else if (this.state.activeNodeId === clickedNodeId) { } else if (this.state.activeNodeId === clickedNodeId) {
//$activeNodeId = -1; // $activeNodeId = -1;
// if the clicked node is different from the selected node and secondary // if the clicked node is different from the selected node and secondary
} else if (event.ctrlKey) { } else if (event.ctrlKey) {
this.state.selectedNodes.add(this.state.activeNodeId); this.state.selectedNodes.add(this.state.activeNodeId);
@@ -312,6 +205,7 @@ export class MouseEventManager {
this.state.activeNodeId = clickedNodeId; this.state.activeNodeId = clickedNodeId;
this.state.clearSelection(); this.state.clearSelection();
} }
this.edgeInteractionManager.handleMouseDown();
} else if (event.ctrlKey) { } else if (event.ctrlKey) {
this.state.boxSelection = true; this.state.boxSelection = true;
} }
@@ -335,7 +229,6 @@ export class MouseEventManager {
this.state.edgeEndPosition = null; this.state.edgeEndPosition = null;
} }
handleMouseMove(event: MouseEvent) { handleMouseMove(event: MouseEvent) {
let mx = event.clientX - this.state.rect.x; let mx = event.clientX - this.state.rect.x;
let my = event.clientY - this.state.rect.y; let my = event.clientY - this.state.rect.y;
@@ -351,8 +244,8 @@ export class MouseEventManager {
let _socket; let _socket;
for (const socket of this.state.possibleSockets) { for (const socket of this.state.possibleSockets) {
const dist = Math.sqrt( const dist = Math.sqrt(
(socket.position[0] - this.state.mousePosition[0]) ** 2 + (socket.position[0] - this.state.mousePosition[0]) ** 2
(socket.position[1] - this.state.mousePosition[1]) ** 2, + (socket.position[1] - this.state.mousePosition[1]) ** 2
); );
if (dist < smallestDist) { if (dist < smallestDist) {
smallestDist = dist; smallestDist = dist;
@@ -375,7 +268,7 @@ export class MouseEventManager {
event.stopPropagation(); event.stopPropagation();
const mouseD = this.state.projectScreenToWorld( const mouseD = this.state.projectScreenToWorld(
this.state.mouseDown[0], this.state.mouseDown[0],
this.state.mouseDown[1], this.state.mouseDown[1]
); );
const x1 = Math.min(mouseD[0], this.state.mousePosition[0]); const x1 = Math.min(mouseD[0], this.state.mousePosition[0]);
const x2 = Math.max(mouseD[0], this.state.mousePosition[0]); const x2 = Math.max(mouseD[0], this.state.mousePosition[0]);
@@ -397,6 +290,7 @@ export class MouseEventManager {
// here we are handling dragging of nodes // here we are handling dragging of nodes
if (this.state.activeNodeId !== -1 && this.state.mouseDownNodeId !== -1) { if (this.state.activeNodeId !== -1 && this.state.mouseDownNodeId !== -1) {
this.edgeInteractionManager.handleMouseMove();
const node = this.graph.getNode(this.state.activeNodeId); const node = this.graph.getNode(this.state.activeNodeId);
if (!node || event.buttons !== 1) return; if (!node || event.buttons !== 1) return;
@@ -405,10 +299,8 @@ export class MouseEventManager {
const oldX = node.state.downX || 0; const oldX = node.state.downX || 0;
const oldY = node.state.downY || 0; const oldY = node.state.downY || 0;
let newX = let newX = oldX + (mx - this.state.mouseDown[0]) / this.state.cameraPosition[2];
oldX + (mx - this.state.mouseDown[0]) / this.state.cameraPosition[2]; let newY = oldY + (my - this.state.mouseDown[1]) / this.state.cameraPosition[2];
let newY =
oldY + (my - this.state.mouseDown[1]) / this.state.cameraPosition[2];
if (event.ctrlKey) { if (event.ctrlKey) {
const snapLevel = this.state.getSnapLevel(); const snapLevel = this.state.getSnapLevel();
@@ -448,23 +340,19 @@ export class MouseEventManager {
// here we are handling panning of camera // here we are handling panning of camera
this.state.isPanning = true; this.state.isPanning = true;
let newX = let newX = this.state.cameraDown[0]
this.state.cameraDown[0] - - (mx - this.state.mouseDown[0]) / this.state.cameraPosition[2];
(mx - this.state.mouseDown[0]) / this.state.cameraPosition[2]; let newY = this.state.cameraDown[1]
let newY = - (my - this.state.mouseDown[1]) / this.state.cameraPosition[2];
this.state.cameraDown[1] -
(my - this.state.mouseDown[1]) / this.state.cameraPosition[2];
this.state.cameraPosition[0] = newX; this.state.cameraPosition[0] = newX;
this.state.cameraPosition[1] = newY; this.state.cameraPosition[1] = newY;
} }
handleMouseScroll(event: WheelEvent) { handleMouseScroll(event: WheelEvent) {
const bodyIsFocused = const bodyIsFocused = document.activeElement === document.body
document.activeElement === document.body || || document.activeElement === this.state.wrapper
document.activeElement === this.state.wrapper || || document?.activeElement?.id === 'graph';
document?.activeElement?.id === "graph";
if (!bodyIsFocused) return; if (!bodyIsFocused) return;
// Define zoom speed and clamp it between -1 and 1 // Define zoom speed and clamp it between -1 and 1
@@ -479,21 +367,19 @@ export class MouseEventManager {
maxZoom, maxZoom,
isNegative isNegative
? this.state.cameraPosition[2] / delta ? this.state.cameraPosition[2] / delta
: this.state.cameraPosition[2] * delta, : this.state.cameraPosition[2] * delta
), )
); );
// Calculate the ratio of the new zoom to the original zoom // Calculate the ratio of the new zoom to the original zoom
const zoomRatio = newZoom / this.state.cameraPosition[2]; const zoomRatio = newZoom / this.state.cameraPosition[2];
// Update camera position and zoom level // Update camera position and zoom level
this.state.cameraPosition[0] = this.state.mousePosition[0] - this.state.cameraPosition[0] = this.state.mousePosition[0]
(this.state.mousePosition[0] - this.state.cameraPosition[0]) / - (this.state.mousePosition[0] - this.state.cameraPosition[0])
zoomRatio; / zoomRatio;
this.state.cameraPosition[1] = this.state.mousePosition[1] - this.state.cameraPosition[1] = this.state.mousePosition[1]
(this.state.mousePosition[1] - this.state.cameraPosition[1]) / - (this.state.mousePosition[1] - this.state.cameraPosition[1])
zoomRatio, / zoomRatio, this.state.cameraPosition[2] = newZoom;
this.state.cameraPosition[2] = newZoom;
} }
} }

View File

@@ -8,7 +8,7 @@ export function lerp(a: number, b: number, t: number) {
export function animate( export function animate(
duration: number, duration: number,
callback: (progress: number) => void | false, callback: (progress: number) => void | false
) { ) {
const start = performance.now(); const start = performance.now();
const loop = (time: number) => { const loop = (time: number) => {
@@ -33,11 +33,10 @@ export function createNodePath({
cornerBottom = 0, cornerBottom = 0,
leftBump = false, leftBump = false,
rightBump = false, rightBump = false,
aspectRatio = 1, aspectRatio = 1
} = {}) { } = {}) {
return `M0,${cornerTop} return `M0,${cornerTop}
${ ${cornerTop
cornerTop
? ` V${cornerTop} ? ` V${cornerTop}
Q0,0 ${cornerTop * aspectRatio},0 Q0,0 ${cornerTop * aspectRatio},0
H${100 - cornerTop * aspectRatio} H${100 - cornerTop * aspectRatio}
@@ -48,13 +47,11 @@ export function createNodePath({
` `
} }
V${y - height / 2} V${y - height / 2}
${ ${rightBump
rightBump
? ` C${100 - depth},${y - height / 2} ${100 - depth},${y + height / 2} 100,${y + height / 2}` ? ` C${100 - depth},${y - height / 2} ${100 - depth},${y + height / 2} 100,${y + height / 2}`
: ` H100` : ` H100`
} }
${ ${cornerBottom
cornerBottom
? ` V${100 - cornerBottom} ? ` V${100 - cornerBottom}
Q100,100 ${100 - cornerBottom * aspectRatio},100 Q100,100 ${100 - cornerBottom * aspectRatio},100
H${cornerBottom * aspectRatio} H${cornerBottom * aspectRatio}
@@ -62,12 +59,11 @@ export function createNodePath({
` `
: `${leftBump ? `V100 H0` : `V100`}` : `${leftBump ? `V100 H0` : `V100`}`
} }
${ ${leftBump
leftBump
? ` V${y + height / 2} C${depth},${y + height / 2} ${depth},${y - height / 2} 0,${y - height / 2}` ? ` V${y + height / 2} C${depth},${y + height / 2} ${depth},${y - height / 2} 0,${y - height / 2}`
: ` H0` : ` H0`
} }
Z`.replace(/\s+/g, " "); Z`.replace(/\s+/g, ' ');
} }
export const debounce = (fn: Function, ms = 300) => { export const debounce = (fn: Function, ms = 300) => {
@@ -78,14 +74,13 @@ export const debounce = (fn: Function, ms = 300) => {
}; };
}; };
export const clone: <T>(v: T) => T = export const clone: <T>(v: T) => T = 'structedClone' in globalThis
"structedClone" in globalThis
? globalThis.structuredClone ? globalThis.structuredClone
: (obj) => JSON.parse(JSON.stringify(obj)); : (obj) => JSON.parse(JSON.stringify(obj));
export function withSubComponents<A, B extends Record<string, any>>( export function withSubComponents<A, B extends Record<string, any>>(
component: A, component: A,
subcomponents: B, subcomponents: B
): A & B { ): A & B {
Object.keys(subcomponents).forEach((key) => { Object.keys(subcomponents).forEach((key) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -93,3 +88,27 @@ export function withSubComponents<A, B extends Record<string, any>>(
}); });
return component as A & B; return component as A & B;
} }
export function distanceFromPointToSegment(
x1: number,
y1: number,
x2: number,
y2: number,
x0: number,
y0: number
): number {
const dx = x2 - x1;
const dy = y2 - y1;
if (dx === 0 && dy === 0) {
return Math.hypot(x0 - x1, y0 - y1);
}
const t = ((x0 - x1) * dx + (y0 - y1) * dy) / (dx * dx + dy * dy);
const clampedT = Math.max(0, Math.min(1, t));
const px = x1 + clampedT * dx;
const py = y1 + clampedT * dy;
return Math.hypot(x0 - px, y0 - py);
}

View File

@@ -1,18 +1,5 @@
export type { NodeInput } from "./inputs"; export type { AsyncCache, NodeRegistry, RuntimeExecutor, SyncCache } from './components';
export type { export type { NodeInput } from './inputs';
NodeRegistry, export type { Box, Edge, Graph, NodeDefinition, NodeId, NodeInstance, SerializedNode, Socket } from './types';
RuntimeExecutor, export { GraphSchema, NodeSchema } from './types';
SyncCache, export { NodeDefinitionSchema } from './types';
AsyncCache,
} from "./components";
export type {
SerializedNode,
NodeInstance,
NodeDefinition,
Socket,
NodeId,
Edge,
Graph,
} from "./types";
export { NodeSchema, GraphSchema } from "./types";
export { NodeDefinitionSchema } from "./types";

View File

@@ -1,9 +1,16 @@
import { z } from "zod"; import { z } from 'zod';
import { NodeInputSchema } from "./inputs"; import { NodeInputSchema } from './inputs';
export type Box = {
minX: number;
maxX: number;
minY: number;
maxY: number;
};
export const NodeIdSchema = z export const NodeIdSchema = z
.string() .string()
.regex(/^[^/]+\/[^/]+\/[^/]+$/, "Invalid NodeId format") .regex(/^[^/]+\/[^/]+\/[^/]+$/, 'Invalid NodeId format')
.transform((value) => value as `${string}/${string}/${string}`); .transform((value) => value as `${string}/${string}/${string}`);
export type NodeId = z.infer<typeof NodeIdSchema>; export type NodeId = z.infer<typeof NodeIdSchema>;
@@ -35,9 +42,9 @@ export const NodeDefinitionSchema = z.object({
meta: z meta: z
.object({ .object({
description: z.string().optional(), description: z.string().optional(),
title: z.string().optional(), title: z.string().optional()
}) })
.optional(), .optional()
}); });
export const NodeSchema = z.object({ export const NodeSchema = z.object({
@@ -49,13 +56,12 @@ export const NodeSchema = z.object({
meta: z meta: z
.object({ .object({
title: z.string().optional(), title: z.string().optional(),
lastModified: z.string().optional(), lastModified: z.string().optional()
}) })
.optional(), .optional(),
position: z.tuple([z.number(), z.number()]), position: z.tuple([z.number(), z.number()])
}); });
export type SerializedNode = z.infer<typeof NodeSchema>; export type SerializedNode = z.infer<typeof NodeSchema>;
export type NodeDefinition = z.infer<typeof NodeDefinitionSchema> & { export type NodeDefinition = z.infer<typeof NodeDefinitionSchema> & {
@@ -75,12 +81,12 @@ export const GraphSchema = z.object({
meta: z meta: z
.object({ .object({
title: z.string().optional(), title: z.string().optional(),
lastModified: z.string().optional(), lastModified: z.string().optional()
}) })
.optional(), .optional(),
settings: z.record(z.string(), z.any()).optional(), settings: z.record(z.string(), z.any()).optional(),
nodes: z.array(NodeSchema), nodes: z.array(NodeSchema),
edges: z.array(z.tuple([z.number(), z.number(), z.number(), z.string()])), edges: z.array(z.tuple([z.number(), z.number(), z.number(), z.string()]))
}); });
export type Graph = z.infer<typeof GraphSchema>; export type Graph = z.infer<typeof GraphSchema>;