From 2904c13c41ad8e5016e9560f96159306a08955dc Mon Sep 17 00:00:00 2001 From: Max Richter Date: Mon, 19 Jan 2026 16:25:29 +0100 Subject: [PATCH 1/2] feat: init --- .../lib/graph-interface/graph-state.svelte.ts | 14 ++++++++-- app/src/lib/graph-interface/graph/events.ts | 26 ++++++++++++++++++- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/app/src/lib/graph-interface/graph-state.svelte.ts b/app/src/lib/graph-interface/graph-state.svelte.ts index 717cd97..a5a6b7a 100644 --- a/app/src/lib/graph-interface/graph-state.svelte.ts +++ b/app/src/lib/graph-interface/graph-state.svelte.ts @@ -1,8 +1,8 @@ import type { NodeInstance, Socket } from "@nodarium/types"; import { getContext, setContext } from "svelte"; -import { SvelteSet } from "svelte/reactivity"; +import { SvelteMap, SvelteSet } from "svelte/reactivity"; import type { GraphManager } from "./graph-manager.svelte"; -import type { OrthographicCamera } from "three"; +import type { Mesh, OrthographicCamera, Vector3 } from "three"; const graphStateKey = Symbol("graph-state"); @@ -46,6 +46,8 @@ export class GraphState { width = $state(100); height = $state(100); + edgeGeometries = new SvelteMap(); + wrapper = $state(null!); rect: DOMRect = $derived( (this.wrapper && this.width && this.height) ? this.wrapper.getBoundingClientRect() : new DOMRect(0, 0, 0, 0), @@ -97,6 +99,14 @@ export class GraphState { isBodyFocused = () => document?.activeElement?.nodeName !== "INPUT"; + setEdgeGeometry(edgeId: string, edgeGeometry: { geo: Mesh, points: Vector3[] }) { + this.edgeGeometries.set(edgeId, edgeGeometry); + } + + removeEdgeGeometry(edgeId: string) { + this.edgeGeometries.delete(edgeId); + } + updateNodePosition(node: NodeInstance) { if ( node.state.x === node.position[0] && diff --git a/app/src/lib/graph-interface/graph/events.ts b/app/src/lib/graph-interface/graph/events.ts index fda0513..9a1ddd3 100644 --- a/app/src/lib/graph-interface/graph/events.ts +++ b/app/src/lib/graph-interface/graph/events.ts @@ -112,16 +112,38 @@ export class FileDropEventManager { } +class EdgeInteractionManager { + constructor( + private graph: GraphManager, + private state: GraphState) { }; + + handleMouseDown() { + const edges = this.graph.edges; + console.log(edges) + } + + handleMouseMove() { + } + + handleMouseUp() { + } +} + + export class MouseEventManager { + edgeInteractionManager: EdgeInteractionManager constructor( private graph: GraphManager, private state: GraphState - ) { } + ) { + this.edgeInteractionManager = new EdgeInteractionManager(graph, state); + } handleMouseUp(event: MouseEvent) { + this.edgeInteractionManager.handleMouseUp(); this.state.isPanning = false; if (!this.state.mouseDown) return; @@ -312,6 +334,7 @@ export class MouseEventManager { this.state.activeNodeId = clickedNodeId; this.state.clearSelection(); } + this.edgeInteractionManager.handleMouseDown(); } else if (event.ctrlKey) { this.state.boxSelection = true; } @@ -397,6 +420,7 @@ export class MouseEventManager { // here we are handling dragging of nodes if (this.state.activeNodeId !== -1 && this.state.mouseDownNodeId !== -1) { + this.edgeInteractionManager.handleMouseMove(); const node = this.graph.getNode(this.state.activeNodeId); if (!node || event.buttons !== 1) return; From a3d10d6094a26425f3dee2226419dea50f0e7aca Mon Sep 17 00:00:00 2001 From: Max Richter Date: Tue, 20 Jan 2026 17:46:09 +0100 Subject: [PATCH 2/2] feat: drop node on edge Closes #13 --- .../lib/graph-interface/debug/Debug.svelte | 47 ++- app/src/lib/graph-interface/debug/index.ts | 26 +- app/src/lib/graph-interface/debug/store.ts | 10 +- app/src/lib/graph-interface/edges/Edge.svelte | 34 ++- .../graph-interface/graph-manager.svelte.ts | 280 ++++++++++++------ .../lib/graph-interface/graph-state.svelte.ts | 150 +++++----- .../lib/graph-interface/graph/Graph.svelte | 15 +- .../lib/graph-interface/graph/drop.events.ts | 107 +++++++ .../lib/graph-interface/graph/edge.events.ts | 110 +++++++ .../graph/{events.ts => mouse.events.ts} | 246 ++++----------- app/src/lib/graph-interface/helpers/index.ts | 75 +++-- packages/types/src/index.ts | 23 +- packages/types/src/types.ts | 26 +- 13 files changed, 710 insertions(+), 439 deletions(-) create mode 100644 app/src/lib/graph-interface/graph/drop.events.ts create mode 100644 app/src/lib/graph-interface/graph/edge.events.ts rename app/src/lib/graph-interface/graph/{events.ts => mouse.events.ts} (63%) diff --git a/app/src/lib/graph-interface/debug/Debug.svelte b/app/src/lib/graph-interface/debug/Debug.svelte index ce105f1..22fe27c 100644 --- a/app/src/lib/graph-interface/debug/Debug.svelte +++ b/app/src/lib/graph-interface/debug/Debug.svelte @@ -1,19 +1,44 @@ {#each $points as point} - - - - + + + + +{/each} + +{#each $rects as rect, i} + + + + {/each} {#each $lines as line} - - - - + + + + {/each} diff --git a/app/src/lib/graph-interface/debug/index.ts b/app/src/lib/graph-interface/debug/index.ts index fc131cb..34d66e6 100644 --- a/app/src/lib/graph-interface/debug/index.ts +++ b/app/src/lib/graph-interface/debug/index.ts @@ -1,5 +1,7 @@ -import { Vector3 } from "three/src/math/Vector3.js"; -import { lines, points } from "./store"; +import type { Box } from '@nodarium/types'; +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) { 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() { points.set([]); lines.set([]); + rects.set([]); } -export function debugLine(line: Vector3[]) { +export function debugLine(points: Vector3[], color?: Color) { lines.update((l) => { - l.push(line); + l.push({ points, color }); return l; }); } -import Component from "./Debug.svelte"; - -export default Component +export default Component; +export function clearLines() { + lines.set([]); +} diff --git a/app/src/lib/graph-interface/debug/store.ts b/app/src/lib/graph-interface/debug/store.ts index 59d869f..6638180 100644 --- a/app/src/lib/graph-interface/debug/store.ts +++ b/app/src/lib/graph-interface/debug/store.ts @@ -1,6 +1,8 @@ -import { writable } from "svelte/store"; -import { Vector3 } from "three/src/math/Vector3.js"; +import type { Box } from '@nodarium/types'; +import { writable } from 'svelte/store'; +import type { Color } from 'three'; +import { Vector3 } from 'three/src/math/Vector3.js'; export const points = writable([]); - -export const lines = writable([]); +export const rects = writable([]); +export const lines = writable<{ points: Vector3[]; color?: Color }[]>([]); diff --git a/app/src/lib/graph-interface/edges/Edge.svelte b/app/src/lib/graph-interface/edges/Edge.svelte index 187eaf7..7180b5c 100644 --- a/app/src/lib/graph-interface/edges/Edge.svelte +++ b/app/src/lib/graph-interface/edges/Edge.svelte @@ -31,6 +31,10 @@ import { CubicBezierCurve } from "three/src/extras/curves/CubicBezierCurve.js"; import { Vector2 } from "three/src/math/Vector2.js"; import { appSettings } from "$lib/settings/app-settings.svelte"; + import { getGraphState } from "../graph-state.svelte"; + import { onDestroy } from "svelte"; + + const graphState = getGraphState(); type Props = { x1: number; @@ -38,20 +42,21 @@ x2: number; y2: 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))); let points = $state([]); let lastId: string | null = null; + const curveId = $derived(`${x1}-${y1}-${x2}-${y2}`); function update() { const new_x = x2 - x1; const new_y = y2 - y1; - const curveId = `${x1}-${y1}-${x2}-${y2}`; if (lastId === curveId) { return; } @@ -72,6 +77,15 @@ .getPoints(samples) .map((p) => new Vector3(p.x, 0, p.y)) .flat(); + + if (id) { + graphState.setEdgeGeometry( + id, + x1, + y1, + $state.snapshot(points) as unknown as Vector3[], + ); + } } $effect(() => { @@ -79,6 +93,10 @@ update(); } }); + + onDestroy(() => { + if (id) graphState.removeEdgeGeometry(id); + }); +{#if graphState.hoveredEdgeId === id} + + + + +{/if} + diff --git a/app/src/lib/graph-interface/graph-manager.svelte.ts b/app/src/lib/graph-interface/graph-manager.svelte.ts index c9feb5c..0f29368 100644 --- a/app/src/lib/graph-interface/graph-manager.svelte.ts +++ b/app/src/lib/graph-interface/graph-manager.svelte.ts @@ -1,31 +1,30 @@ +import throttle from '$lib/helpers/throttle'; import type { Edge, Graph, - NodeInstance, NodeDefinition, - NodeInput, - NodeRegistry, NodeId, - Socket, -} from "@nodarium/types"; -import { fastHashString } from "@nodarium/utils"; -import { SvelteMap } from "svelte/reactivity"; -import EventEmitter from "./helpers/EventEmitter"; -import { createLogger } from "@nodarium/utils"; -import throttle from "$lib/helpers/throttle"; -import { HistoryManager } from "./history-manager"; + NodeInput, + NodeInstance, + NodeRegistry, + Socket +} from '@nodarium/types'; +import { fastHashString } from '@nodarium/utils'; +import { createLogger } from '@nodarium/utils'; +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(); -const clone = - "structuredClone" in self - ? self.structuredClone - : (args: any) => JSON.parse(JSON.stringify(args)); +const clone = 'structuredClone' in self + ? self.structuredClone + : (args: any) => JSON.parse(JSON.stringify(args)); function areSocketsCompatible( output: string | undefined, - inputs: string | (string | undefined)[] | undefined, + inputs: string | (string | undefined)[] | undefined ) { if (Array.isArray(inputs) && output) { return inputs.includes(output); @@ -34,24 +33,23 @@ function areSocketsCompatible( } function areEdgesEqual(firstEdge: Edge, secondEdge: Edge) { - if (firstEdge[0].id !== secondEdge[0].id) { return false; } if (firstEdge[1] !== secondEdge[1]) { - return false + return false; } if (firstEdge[2].id !== secondEdge[2].id) { - return false + return false; } if (firstEdge[3] !== secondEdge[3]) { - return false + return false; } - return true + return true; } export class GraphManager extends EventEmitter<{ @@ -62,7 +60,7 @@ export class GraphManager extends EventEmitter<{ values: Record; }; }> { - status = $state<"loading" | "idle" | "error">(); + status = $state<'loading' | 'idle' | 'error'>(); loaded = false; graph: Graph = { id: 0, nodes: [], edges: [] }; @@ -88,7 +86,7 @@ export class GraphManager extends EventEmitter<{ history: HistoryManager = new HistoryManager(); execute = throttle(() => { if (this.loaded === false) return; - this.emit("result", this.serialize()); + this.emit('result', this.serialize()); }, 10); constructor(public registry: NodeRegistry) { @@ -100,21 +98,21 @@ export class GraphManager extends EventEmitter<{ id: node.id, position: [...node.position], type: node.type, - props: node.props, + props: node.props })) as NodeInstance[]; const edges = this.edges.map((edge) => [ edge[0].id, edge[1], edge[2].id, - edge[3], - ]) as Graph["edges"]; + edge[3] + ]) as Graph['edges']; const serialized = { id: this.graph.id, settings: $state.snapshot(this.settings), nodes, - edges, + edges }; - logger.log("serializing graph", serialized); + logger.log('serializing graph', serialized); return clone($state.snapshot(serialized)); } @@ -148,6 +146,95 @@ export class GraphManager extends EventEmitter<{ 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][] { const edges = []; for (const node of nodes) { @@ -155,14 +242,14 @@ export class GraphManager extends EventEmitter<{ for (const child of children) { if (nodes.includes(child)) { 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) { edges.push([edge[0].id, edge[1], edge[2].id, edge[3]] as [ number, number, number, - string, + string ]); } } @@ -179,18 +266,18 @@ export class GraphManager extends EventEmitter<{ const n = node as NodeInstance; if (nodeType) { n.state = { - type: nodeType, + type: nodeType }; } return [node.id, n]; - }), + }) ); const edges = graph.edges.map((edge) => { const from = nodes.get(edge[0]); const to = nodes.get(edge[2]); 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.push(to); @@ -214,21 +301,21 @@ export class GraphManager extends EventEmitter<{ this.loaded = false; this.graph = graph; - this.status = "loading"; + this.status = 'loading'; 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)])); 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) { const nodeType = this.registry.getNode(node.type); if (!nodeType) { logger.error(`Node type not found: ${node.type}`); - this.status = "error"; + this.status = 'error'; return; } // Turn into runtime node @@ -253,11 +340,11 @@ export class GraphManager extends EventEmitter<{ settingTypes[settingId] = { __node_type: type.id, __node_input: key, - ...type.inputs[key], + ...type.inputs[key] }; if ( - settingValues[settingId] === undefined && - "value" in type.inputs[key] + settingValues[settingId] === undefined + && 'value' in type.inputs[key] ) { settingValues[settingId] = type.inputs[key].value; } @@ -267,14 +354,14 @@ export class GraphManager extends EventEmitter<{ } this.settings = settingValues; - this.emit("settings", { types: settingTypes, values: settingValues }); + this.emit('settings', { types: settingTypes, values: settingValues }); this.history.reset(); this._init(this.graph); this.save(); - this.status = "idle"; + this.status = 'idle'; this.loaded = true; logger.log(`Graph loaded in ${performance.now() - a}ms`); @@ -307,9 +394,9 @@ export class GraphManager extends EventEmitter<{ if (settingId) { settingTypes[settingId] = nodeType.inputs[key]; if ( - settingValues && - settingValues?.[settingId] === undefined && - "value" in nodeType.inputs[key] + settingValues + && settingValues?.[settingId] === undefined + && 'value' in nodeType.inputs[key] ) { settingValues[settingId] = nodeType.inputs[key].value; } @@ -319,7 +406,7 @@ export class GraphManager extends EventEmitter<{ this.settings = settingValues; this.settingTypes = settingTypes; - this.emit("settings", { types: settingTypes, values: settingValues }); + this.emit('settings', { types: settingTypes, values: settingValues }); } getChildren(node: NodeInstance) { @@ -368,7 +455,7 @@ export class GraphManager extends EventEmitter<{ const inputType = to?.state?.type?.inputs?.[toSocket]?.type; if (outputType === inputType) { this.createEdge(from, fromSocket, to, toSocket, { - applyUpdate: false, + applyUpdate: false }); continue; } @@ -403,7 +490,7 @@ export class GraphManager extends EventEmitter<{ // map old ids to new ids const idMap = new Map(); - let startId = this.createNodeId() + let startId = this.createNodeId(); nodes = nodes.map((node) => { const id = startId++; @@ -420,7 +507,7 @@ export class GraphManager extends EventEmitter<{ const to = nodes.find((n) => n.id === idMap.get(edge[2])); 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 || []; @@ -445,11 +532,11 @@ export class GraphManager extends EventEmitter<{ createNode({ type, position, - props = {}, + props = {} }: { - type: NodeInstance["type"]; - position: NodeInstance["position"]; - props: NodeInstance["props"]; + type: NodeInstance['type']; + position: NodeInstance['position']; + props: NodeInstance['props']; }) { const nodeType = this.registry.getNode(type); if (!nodeType) { @@ -462,14 +549,14 @@ export class GraphManager extends EventEmitter<{ type, position, state: { type: nodeType }, - props, + props }); this.nodes.set(node.id, node); this.save(); - return node + return node; } createEdge( @@ -477,17 +564,16 @@ export class GraphManager extends EventEmitter<{ fromSocket: number, to: NodeInstance, toSocket: string, - { applyUpdate = true } = {}, + { applyUpdate = true } = {} ): Edge | undefined { - const existingEdges = this.getEdgesToNode(to); // check if this exact edge already exists 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) { - logger.error("Edge already exists", existingEdge); + logger.error('Edge already exists', existingEdge); return; } @@ -500,13 +586,13 @@ export class GraphManager extends EventEmitter<{ if (!areSocketsCompatible(fromSocketType, toSocketType)) { logger.error( - `Socket types do not match: ${fromSocketType} !== ${toSocketType}`, + `Socket types do not match: ${fromSocketType} !== ${toSocketType}` ); return; } 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) { this.removeEdge(edgeToBeReplaced, { applyDeletion: false }); @@ -533,7 +619,7 @@ export class GraphManager extends EventEmitter<{ const nextState = this.history.undo(); if (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(); if (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; const state = this.serialize(); this.history.save(state); - this.emit("save", state); - logger.log("saving graphs", state); + this.emit('save', state); + logger.log('saving graphs', state); } getParentsOfNode(node: NodeInstance) { @@ -567,7 +653,7 @@ export class GraphManager extends EventEmitter<{ const stack = node.state?.parents?.slice(0); while (stack?.length) { if (parents.length > 1000000) { - logger.warn("Infinite loop detected"); + logger.warn('Infinite loop detected'); break; } const parent = stack.pop(); @@ -586,26 +672,28 @@ export class GraphManager extends EventEmitter<{ return []; } - const definitions = typeof socket.index === "string" + const definitions = typeof socket.index === 'string' ? allDefinitions.filter(s => { - return s.outputs?.find(_s => Object - .values(nodeType?.inputs || {}) - .map(s => s.type) - .includes(_s as NodeInput["type"]) - ) + return s.outputs?.find(_s => + Object + .values(nodeType?.inputs || {}) + .map(s => s.type) + .includes(_s as NodeInput['type']) + ); }) - : allDefinitions.filter(s => Object - .values(s.inputs ?? {}) - .find(s => { - if (s.hidden) return false; - if (nodeType.outputs?.includes(s.type)) { - return true - } - return s.accepts?.find(a => nodeType.outputs?.includes(a)) - })) - - return definitions + : allDefinitions.filter(s => + Object + .values(s.inputs ?? {}) + .find(s => { + if (s.hidden) return false; + if (nodeType.outputs?.includes(s.type)) { + return true; + } + return s.accepts?.find(a => nodeType.outputs?.includes(a)); + }) + ); + return definitions; } getPossibleSockets({ node, index }: Socket): [NodeInstance, string | number][] { @@ -615,11 +703,11 @@ export class GraphManager extends EventEmitter<{ const sockets: [NodeInstance, string | number][] = []; // 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 const children = new Set(this.getChildren(node).map((n) => n.id)); 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; @@ -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 // filter out self and parent nodes const parents = new Set(this.getParentsOfNode(node).map((n) => n.id)); 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 const edges = new Map( this.getEdgesFromNode(node) .filter((e) => e[1] === index) - .map((e) => [e[2].id, e[3]]), + .map((e) => [e[2].id, e[3]]) ); const ownType = nodeType.outputs?.[index]; @@ -660,8 +748,8 @@ export class GraphManager extends EventEmitter<{ otherType.push(...(inputs[key].accepts || [])); if ( - areSocketsCompatible(ownType, otherType) && - edges.get(node.id) !== key + areSocketsCompatible(ownType, otherType) + && edges.get(node.id) !== key ) { sockets.push([node, key]); } @@ -674,7 +762,7 @@ export class GraphManager extends EventEmitter<{ removeEdge( edge: Edge, - { applyDeletion = true }: { applyDeletion?: boolean } = {}, + { applyDeletion = true }: { applyDeletion?: boolean } = {} ) { const id0 = edge[0].id; const sid0 = edge[1]; @@ -682,21 +770,20 @@ export class GraphManager extends EventEmitter<{ const sid2 = edge[3]; const _edge = this.edges.find( - (e) => - e[0].id === id0 && e[1] === sid0 && e[2].id === id2 && e[3] === sid2, + (e) => e[0].id === id0 && e[1] === sid0 && e[2].id === id2 && e[3] === sid2 ); if (!_edge) return; if (edge[0].state.children) { edge[0].state.children = edge[0].state.children.filter( - (n: NodeInstance) => n.id !== id2, + (n: NodeInstance) => n.id !== id2 ); } if (edge[2].state.parents) { 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.save(); } - } getEdgesToNode(node: NodeInstance) { diff --git a/app/src/lib/graph-interface/graph-state.svelte.ts b/app/src/lib/graph-interface/graph-state.svelte.ts index a5a6b7a..7301b13 100644 --- a/app/src/lib/graph-interface/graph-state.svelte.ts +++ b/app/src/lib/graph-interface/graph-state.svelte.ts @@ -1,36 +1,43 @@ -import type { NodeInstance, Socket } from "@nodarium/types"; -import { getContext, setContext } from "svelte"; -import { SvelteMap, SvelteSet } from "svelte/reactivity"; -import type { GraphManager } from "./graph-manager.svelte"; -import type { Mesh, OrthographicCamera, Vector3 } from "three"; +import type { NodeInstance, Socket } from '@nodarium/types'; +import { getContext, setContext } from 'svelte'; +import { SvelteSet } from 'svelte/reactivity'; +import type { OrthographicCamera, Vector3 } from 'three'; +import type { GraphManager } from './graph-manager.svelte'; - -const graphStateKey = Symbol("graph-state"); +const graphStateKey = Symbol('graph-state'); export function getGraphState() { return getContext(graphStateKey); } 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() { - return getContext(graphManagerKey) + return getContext(graphManagerKey); } export function setGraphManager(manager: GraphManager) { return setContext(graphManagerKey, manager); } -export class GraphState { +type EdgeData = { + x1: number; + y1: number; + points: Vector3[]; +}; +export class GraphState { constructor(private graph: GraphManager) { $effect.root(() => { $effect(() => { - localStorage.setItem("cameraPosition", `[${this.cameraPosition[0]},${this.cameraPosition[1]},${this.cameraPosition[2]}]`) - }) - }) - const storedPosition = localStorage.getItem("cameraPosition") + localStorage.setItem( + 'cameraPosition', + `[${this.cameraPosition[0]},${this.cameraPosition[1]},${this.cameraPosition[2]}]` + ); + }); + }); + const storedPosition = localStorage.getItem('cameraPosition'); if (storedPosition) { try { const d = JSON.parse(storedPosition); @@ -38,7 +45,7 @@ export class GraphState { this.cameraPosition[1] = d[1]; this.cameraPosition[2] = d[2]; } catch (e) { - console.log("Failed to parsed stored camera position", e); + console.log('Failed to parsed stored camera position', e); } } } @@ -46,15 +53,16 @@ export class GraphState { width = $state(100); height = $state(100); - edgeGeometries = new SvelteMap(); + hoveredEdgeId = $state(null); + edges = new Map(); wrapper = $state(null!); 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(null!); - cameraPosition: [number, number, number] = $state([0, 0, 4]); + cameraPosition: [number, number, number] = $state([0, 0, 100]); clipboard: null | { nodes: NodeInstance[]; @@ -65,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[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); @@ -73,8 +81,8 @@ export class GraphState { addMenuPosition = $state<[number, number] | null>(null); snapToGrid = $state(false); - showGrid = $state(true) - showHelp = $state(false) + showGrid = $state(true); + showHelp = $state(false); cameraDown = [0, 0]; mouseDownNodeId = -1; @@ -90,41 +98,49 @@ export class GraphState { hoveredSocket = $state(null); possibleSockets = $state([]); 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() { this.selectedNodes.clear(); } - isBodyFocused = () => document?.activeElement?.nodeName !== "INPUT"; + isBodyFocused = () => document?.activeElement?.nodeName !== 'INPUT'; - setEdgeGeometry(edgeId: string, edgeGeometry: { geo: Mesh, points: Vector3[] }) { - this.edgeGeometries.set(edgeId, edgeGeometry); + setEdgeGeometry(edgeId: string, x1: number, y1: number, points: Vector3[]) { + this.edges.set(edgeId, { x1, y1, points }); } removeEdgeGeometry(edgeId: string) { - this.edgeGeometries.delete(edgeId); + this.edges.delete(edgeId); + } + + getEdgeData() { + return this.edges; } updateNodePosition(node: NodeInstance) { if ( - node.state.x === node.position[0] && - node.state.y === node.position[1] + node.state.x === node.position[0] + && node.state.y === node.position[1] ) { delete node.state.x; 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) { - 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('--nx', `${node.state.x * 10}px`); + node.state.ref.style.setProperty('--ny', `${node.state.y * 10}px`); } } else { if (node.state.ref) { - 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('--nx', `${node.position[0] * 10}px`); + node.state.ref.style.setProperty('--ny', `${node.position[1] * 10}px`); } } } @@ -144,18 +160,18 @@ export class GraphState { getSocketPosition( node: NodeInstance, - index: string | number, + index: string | number ): [number, number] { - if (typeof index === "number") { + if (typeof index === 'number') { return [ (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 { const _index = Object.keys(node.state?.type?.inputs || {}).indexOf(index); return [ node?.state?.x ?? node.position[0], - (node?.state?.y ?? node.position[1]) + 10 + 10 * _index, + (node?.state?.y ?? node.position[1]) + 10 + 10 * _index ]; } } @@ -169,26 +185,26 @@ export class GraphState { if (!node?.inputs) { return 5; } - const height = - 5 + - 10 * - Object.keys(node.inputs).filter( + const height = 5 + + 10 + * Object.keys(node.inputs).filter( (p) => - p !== "seed" && - node?.inputs && - !("setting" in node?.inputs?.[p]) && - node.inputs[p].hidden !== true, + p !== 'seed' + && node?.inputs + && !('setting' in node?.inputs?.[p]) + && node.inputs[p].hidden !== true ).length; this.nodeHeightCache[nodeTypeId] = height; return height; } copyNodes() { - if (this.activeNodeId === -1 && !this.selectedNodes?.size) + if (this.activeNodeId === -1 && !this.selectedNodes?.size) { return; + } let nodes = [ this.activeNodeId, - ...(this.selectedNodes?.values() || []), + ...(this.selectedNodes?.values() || []) ] .map((id) => this.graph.getNode(id)) .filter(b => !!b); @@ -198,14 +214,14 @@ export class GraphState { ...node, position: [ this.mousePosition[0] - node.position[0], - this.mousePosition[1] - node.position[1], + this.mousePosition[1] - node.position[1] ], - tmp: undefined, + tmp: undefined })); this.clipboard = { nodes: nodes, - edges: edges, + edges: edges }; } @@ -227,14 +243,13 @@ export class GraphState { } } - setDownSocket(socket: Socket) { this.activeSocket = socket; let { node, index, position } = socket; // remove existing edge - if (typeof index === "string") { + if (typeof index === 'string') { const edges = this.graph.getEdgesToNode(node); for (const edge of edges) { if (edge[3] === index) { @@ -251,7 +266,7 @@ export class GraphState { this.activeSocket = { node, index, - position, + position }; this.possibleSockets = this.graph @@ -260,18 +275,17 @@ export class GraphState { return { node, index, - position: this.getSocketPosition(node, index), + position: this.getSocketPosition(node, index) }; }); - }; - + } projectScreenToWorld(x: number, y: number): [number, number] { return [ - this.cameraPosition[0] + - (x - this.width / 2) / this.cameraPosition[2], - this.cameraPosition[1] + - (y - this.height / 2) / this.cameraPosition[2], + this.cameraPosition[0] + + (x - this.width / 2) / this.cameraPosition[2], + this.cameraPosition[1] + + (y - this.height / 2) / this.cameraPosition[2] ]; } @@ -284,8 +298,8 @@ export class GraphState { if (event.button === 0) { // check if the clicked element is a node if (event.target instanceof HTMLElement) { - const nodeElement = event.target.closest(".node"); - const nodeId = nodeElement?.getAttribute?.("data-node-id"); + const nodeElement = event.target.closest('.node'); + const nodeId = nodeElement?.getAttribute?.('data-node-id'); if (nodeId) { clickedNodeId = parseInt(nodeId, 10); } @@ -313,10 +327,10 @@ export class GraphState { const height = this.getNodeHeight(node.type); const width = 20; return ( - node.position[0] > this.cameraBounds[0] - width && - node.position[0] < this.cameraBounds[1] && - node.position[1] > this.cameraBounds[2] - height && - node.position[1] < this.cameraBounds[3] + node.position[0] > this.cameraBounds[0] - width + && node.position[0] < this.cameraBounds[1] + && node.position[1] > this.cameraBounds[2] - height + && node.position[1] < this.cameraBounds[3] ); - }; + } } diff --git a/app/src/lib/graph-interface/graph/Graph.svelte b/app/src/lib/graph-interface/graph/Graph.svelte index c6f6b57..5e7485b 100644 --- a/app/src/lib/graph-interface/graph/Graph.svelte +++ b/app/src/lib/graph-interface/graph/Graph.svelte @@ -11,8 +11,10 @@ import HelpView from "../components/HelpView.svelte"; import { getGraphManager, getGraphState } from "../graph-state.svelte"; import { HTML } from "@threlte/extras"; - import { FileDropEventManager, MouseEventManager } from "./events"; import { maxZoom, minZoom } from "./constants"; + import Debug from "../debug/Debug.svelte"; + import { FileDropEventManager } from "./drop.events"; + import { MouseEventManager } from "./mouse.events"; const { keymap, @@ -174,9 +176,18 @@ {#each graph.edges as edge} {@const [x1, y1, x2, y2] = getEdgePosition(edge)} - + {/each} + +
{ + 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() + }; + } +} diff --git a/app/src/lib/graph-interface/graph/edge.events.ts b/app/src/lib/graph-interface/graph/edge.events.ts new file mode 100644 index 0000000..7fb9e21 --- /dev/null +++ b/app/src/lib/graph-interface/graph/edge.events.ts @@ -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(); + + 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; + } + } +} diff --git a/app/src/lib/graph-interface/graph/events.ts b/app/src/lib/graph-interface/graph/mouse.events.ts similarity index 63% rename from app/src/lib/graph-interface/graph/events.ts rename to app/src/lib/graph-interface/graph/mouse.events.ts index 9a1ddd3..c856a77 100644 --- a/app/src/lib/graph-interface/graph/events.ts +++ b/app/src/lib/graph-interface/graph/mouse.events.ts @@ -1,144 +1,18 @@ -import { GraphSchema, type NodeId, type NodeInstance } from "@nodarium/types"; -import type { GraphManager } from "../graph-manager.svelte"; -import type { GraphState } from "../graph-state.svelte"; -import { animate, lerp } from "$lib/helpers"; -import { snapToGrid as snapPointToGrid } from "../helpers"; -import { maxZoom, minZoom, zoomSpeed } from "./constants"; - - -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(), - } - } -} - - -class EdgeInteractionManager { - constructor( - private graph: GraphManager, - private state: GraphState) { }; - - handleMouseDown() { - const edges = this.graph.edges; - console.log(edges) - } - - handleMouseMove() { - } - - handleMouseUp() { - } -} - +import { animate, lerp } from '$lib/helpers'; +import { type NodeInstance } from '@nodarium/types'; +import type { GraphManager } from '../graph-manager.svelte'; +import type { GraphState } from '../graph-state.svelte'; +import { snapToGrid as snapPointToGrid } from '../helpers'; +import { maxZoom, minZoom, zoomSpeed } from './constants'; +import { EdgeInteractionManager } from './edge.events'; export class MouseEventManager { - - edgeInteractionManager: EdgeInteractionManager + edgeInteractionManager: EdgeInteractionManager; constructor( private graph: GraphManager, private state: GraphState ) { - this.edgeInteractionManager = new EdgeInteractionManager(graph, state); } @@ -167,25 +41,23 @@ export class MouseEventManager { const snapLevel = this.state.getSnapLevel(); activeNode.position[0] = snapPointToGrid( activeNode?.state?.x ?? activeNode.position[0], - 5 / snapLevel, + 5 / snapLevel ); activeNode.position[1] = snapPointToGrid( activeNode?.state?.y ?? activeNode.position[1], - 5 / snapLevel, + 5 / snapLevel ); } else { activeNode.position[0] = activeNode?.state?.x ?? activeNode.position[0]; activeNode.position[1] = activeNode?.state?.y ?? activeNode.position[1]; } const nodes = [ - ...[...(this.state.selectedNodes?.values() || [])].map((id) => - this.graph.getNode(id), - ), + ...[...(this.state.selectedNodes?.values() || [])].map((id) => this.graph.getNode(id)) ] as NodeInstance[]; const vec = [ 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) { @@ -201,9 +73,9 @@ export class MouseEventManager { animate(500, (a: number) => { for (const node of nodes) { if ( - node?.state && - node.state["x"] !== undefined && - node.state["y"] !== undefined + node?.state + && node.state['x'] !== undefined + && node.state['y'] !== undefined ) { node.state.x = lerp(node.state.x, node.position[0], a); node.state.y = lerp(node.state.y, node.position[1], a); @@ -217,24 +89,24 @@ export class MouseEventManager { this.graph.save(); } else if (this.state.hoveredSocket && this.state.activeSocket) { if ( - typeof this.state.hoveredSocket.index === "number" && - typeof this.state.activeSocket.index === "string" + typeof this.state.hoveredSocket.index === 'number' + && typeof this.state.activeSocket.index === 'string' ) { this.graph.createEdge( this.state.hoveredSocket.node, this.state.hoveredSocket.index || 0, this.state.activeSocket.node, - this.state.activeSocket.index, + this.state.activeSocket.index ); } else if ( - typeof this.state.activeSocket.index == "number" && - typeof this.state.hoveredSocket.index === "string" + typeof this.state.activeSocket.index == 'number' + && typeof this.state.hoveredSocket.index === 'string' ) { this.graph.createEdge( this.state.activeSocket.node, this.state.activeSocket.index || 0, this.state.hoveredSocket.node, - this.state.hoveredSocket.index, + this.state.hoveredSocket.index ); } this.graph.save(); @@ -242,18 +114,18 @@ export class MouseEventManager { // Handle automatic adding of nodes on ctrl+mouseUp this.state.edgeEndPosition = [ 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.mousePosition[0], - this.state.mousePosition[1] - 25 / this.state.cameraPosition[2], + this.state.mousePosition[1] - 25 / this.state.cameraPosition[2] ]; } else { this.state.addMenuPosition = [ 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; @@ -261,11 +133,11 @@ export class MouseEventManager { // check if camera moved if ( - clickedNodeId === -1 && - !this.state.boxSelection && - this.state.cameraDown[0] === this.state.cameraPosition[0] && - this.state.cameraDown[1] === this.state.cameraPosition[1] && - this.state.isBodyFocused() + clickedNodeId === -1 + && !this.state.boxSelection + && this.state.cameraDown[0] === this.state.cameraPosition[0] + && this.state.cameraDown[1] === this.state.cameraPosition[1] + && this.state.isBodyFocused() ) { this.state.activeNodeId = -1; this.state.clearSelection(); @@ -279,16 +151,15 @@ export class MouseEventManager { this.state.addMenuPosition = null; } - handleMouseDown(event: MouseEvent) { if (this.state.mouseDown) return; this.state.edgeEndPosition = null; if (event.target instanceof HTMLElement) { if ( - event.target.nodeName !== "CANVAS" && - !event.target.classList.contains("node") && - !event.target.classList.contains("content") + event.target.nodeName !== 'CANVAS' + && !event.target.classList.contains('node') + && !event.target.classList.contains('content') ) { return; } @@ -310,7 +181,7 @@ export class MouseEventManager { this.state.activeNodeId = clickedNodeId; // if the selected node is the same as the clicked node } else if (this.state.activeNodeId === clickedNodeId) { - //$activeNodeId = -1; + // $activeNodeId = -1; // if the clicked node is different from the selected node and secondary } else if (event.ctrlKey) { this.state.selectedNodes.add(this.state.activeNodeId); @@ -358,7 +229,6 @@ export class MouseEventManager { this.state.edgeEndPosition = null; } - handleMouseMove(event: MouseEvent) { let mx = event.clientX - this.state.rect.x; let my = event.clientY - this.state.rect.y; @@ -374,8 +244,8 @@ export class MouseEventManager { let _socket; for (const socket of this.state.possibleSockets) { const dist = Math.sqrt( - (socket.position[0] - this.state.mousePosition[0]) ** 2 + - (socket.position[1] - this.state.mousePosition[1]) ** 2, + (socket.position[0] - this.state.mousePosition[0]) ** 2 + + (socket.position[1] - this.state.mousePosition[1]) ** 2 ); if (dist < smallestDist) { smallestDist = dist; @@ -398,7 +268,7 @@ export class MouseEventManager { event.stopPropagation(); const mouseD = this.state.projectScreenToWorld( this.state.mouseDown[0], - this.state.mouseDown[1], + this.state.mouseDown[1] ); const x1 = Math.min(mouseD[0], this.state.mousePosition[0]); const x2 = Math.max(mouseD[0], this.state.mousePosition[0]); @@ -429,10 +299,8 @@ export class MouseEventManager { const oldX = node.state.downX || 0; const oldY = node.state.downY || 0; - let newX = - oldX + (mx - this.state.mouseDown[0]) / this.state.cameraPosition[2]; - let newY = - oldY + (my - this.state.mouseDown[1]) / this.state.cameraPosition[2]; + let newX = oldX + (mx - this.state.mouseDown[0]) / this.state.cameraPosition[2]; + let newY = oldY + (my - this.state.mouseDown[1]) / this.state.cameraPosition[2]; if (event.ctrlKey) { const snapLevel = this.state.getSnapLevel(); @@ -472,23 +340,19 @@ export class MouseEventManager { // here we are handling panning of camera this.state.isPanning = true; - let newX = - this.state.cameraDown[0] - - (mx - this.state.mouseDown[0]) / this.state.cameraPosition[2]; - let newY = - this.state.cameraDown[1] - - (my - this.state.mouseDown[1]) / this.state.cameraPosition[2]; + let newX = this.state.cameraDown[0] + - (mx - this.state.mouseDown[0]) / this.state.cameraPosition[2]; + let newY = this.state.cameraDown[1] + - (my - this.state.mouseDown[1]) / this.state.cameraPosition[2]; this.state.cameraPosition[0] = newX; this.state.cameraPosition[1] = newY; } - handleMouseScroll(event: WheelEvent) { - const bodyIsFocused = - document.activeElement === document.body || - document.activeElement === this.state.wrapper || - document?.activeElement?.id === "graph"; + const bodyIsFocused = document.activeElement === document.body + || document.activeElement === this.state.wrapper + || document?.activeElement?.id === 'graph'; if (!bodyIsFocused) return; // Define zoom speed and clamp it between -1 and 1 @@ -503,21 +367,19 @@ export class MouseEventManager { maxZoom, isNegative ? 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 const zoomRatio = newZoom / this.state.cameraPosition[2]; // Update camera position and zoom level - this.state.cameraPosition[0] = this.state.mousePosition[0] - - (this.state.mousePosition[0] - this.state.cameraPosition[0]) / - zoomRatio; - this.state.cameraPosition[1] = this.state.mousePosition[1] - - (this.state.mousePosition[1] - this.state.cameraPosition[1]) / - zoomRatio, - this.state.cameraPosition[2] = newZoom; + this.state.cameraPosition[0] = this.state.mousePosition[0] + - (this.state.mousePosition[0] - this.state.cameraPosition[0]) + / zoomRatio; + this.state.cameraPosition[1] = this.state.mousePosition[1] + - (this.state.mousePosition[1] - this.state.cameraPosition[1]) + / zoomRatio, this.state.cameraPosition[2] = newZoom; } - } diff --git a/app/src/lib/graph-interface/helpers/index.ts b/app/src/lib/graph-interface/helpers/index.ts index f3f9d0f..8a26774 100644 --- a/app/src/lib/graph-interface/helpers/index.ts +++ b/app/src/lib/graph-interface/helpers/index.ts @@ -8,7 +8,7 @@ export function lerp(a: number, b: number, t: number) { export function animate( duration: number, - callback: (progress: number) => void | false, + callback: (progress: number) => void | false ) { const start = performance.now(); const loop = (time: number) => { @@ -33,41 +33,37 @@ export function createNodePath({ cornerBottom = 0, leftBump = false, rightBump = false, - aspectRatio = 1, + aspectRatio = 1 } = {}) { return `M0,${cornerTop} - ${ - cornerTop - ? ` V${cornerTop} + ${cornerTop + ? ` V${cornerTop} Q0,0 ${cornerTop * aspectRatio},0 H${100 - cornerTop * aspectRatio} Q100,0 100,${cornerTop} ` - : ` V0 + : ` V0 H100 ` - } + } V${y - height / 2} - ${ - rightBump - ? ` C${100 - depth},${y - height / 2} ${100 - depth},${y + height / 2} 100,${y + height / 2}` - : ` H100` - } - ${ - cornerBottom - ? ` V${100 - cornerBottom} + ${rightBump + ? ` C${100 - depth},${y - height / 2} ${100 - depth},${y + height / 2} 100,${y + height / 2}` + : ` H100` + } + ${cornerBottom + ? ` V${100 - cornerBottom} Q100,100 ${100 - cornerBottom * aspectRatio},100 H${cornerBottom * aspectRatio} Q0,100 0,${100 - cornerBottom} ` - : `${leftBump ? `V100 H0` : `V100`}` - } - ${ - leftBump - ? ` V${y + height / 2} C${depth},${y + height / 2} ${depth},${y - height / 2} 0,${y - height / 2}` - : ` H0` - } - Z`.replace(/\s+/g, " "); + : `${leftBump ? `V100 H0` : `V100`}` + } + ${leftBump + ? ` V${y + height / 2} C${depth},${y + height / 2} ${depth},${y - height / 2} 0,${y - height / 2}` + : ` H0` + } + Z`.replace(/\s+/g, ' '); } export const debounce = (fn: Function, ms = 300) => { @@ -78,14 +74,13 @@ export const debounce = (fn: Function, ms = 300) => { }; }; -export const clone: (v: T) => T = - "structedClone" in globalThis - ? globalThis.structuredClone - : (obj) => JSON.parse(JSON.stringify(obj)); +export const clone: (v: T) => T = 'structedClone' in globalThis + ? globalThis.structuredClone + : (obj) => JSON.parse(JSON.stringify(obj)); export function withSubComponents>( component: A, - subcomponents: B, + subcomponents: B ): A & B { Object.keys(subcomponents).forEach((key) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -93,3 +88,27 @@ export function withSubComponents>( }); 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); +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 962f6bf..476c9e0 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,18 +1,5 @@ -export type { NodeInput } from "./inputs"; -export type { - NodeRegistry, - RuntimeExecutor, - SyncCache, - AsyncCache, -} from "./components"; -export type { - SerializedNode, - NodeInstance, - NodeDefinition, - Socket, - NodeId, - Edge, - Graph, -} from "./types"; -export { NodeSchema, GraphSchema } from "./types"; -export { NodeDefinitionSchema } from "./types"; +export type { AsyncCache, NodeRegistry, RuntimeExecutor, SyncCache } from './components'; +export type { NodeInput } from './inputs'; +export type { Box, Edge, Graph, NodeDefinition, NodeId, NodeInstance, SerializedNode, Socket } from './types'; +export { GraphSchema, NodeSchema } from './types'; +export { NodeDefinitionSchema } from './types'; diff --git a/packages/types/src/types.ts b/packages/types/src/types.ts index 278b7f6..2a9167b 100644 --- a/packages/types/src/types.ts +++ b/packages/types/src/types.ts @@ -1,9 +1,16 @@ -import { z } from "zod"; -import { NodeInputSchema } from "./inputs"; +import { z } from 'zod'; +import { NodeInputSchema } from './inputs'; + +export type Box = { + minX: number; + maxX: number; + minY: number; + maxY: number; +}; export const NodeIdSchema = z .string() - .regex(/^[^/]+\/[^/]+\/[^/]+$/, "Invalid NodeId format") + .regex(/^[^/]+\/[^/]+\/[^/]+$/, 'Invalid NodeId format') .transform((value) => value as `${string}/${string}/${string}`); export type NodeId = z.infer; @@ -35,9 +42,9 @@ export const NodeDefinitionSchema = z.object({ meta: z .object({ description: z.string().optional(), - title: z.string().optional(), + title: z.string().optional() }) - .optional(), + .optional() }); export const NodeSchema = z.object({ @@ -49,13 +56,12 @@ export const NodeSchema = z.object({ meta: z .object({ title: z.string().optional(), - lastModified: z.string().optional(), + lastModified: z.string().optional() }) .optional(), - position: z.tuple([z.number(), z.number()]), + position: z.tuple([z.number(), z.number()]) }); - export type SerializedNode = z.infer; export type NodeDefinition = z.infer & { @@ -75,12 +81,12 @@ export const GraphSchema = z.object({ meta: z .object({ title: z.string().optional(), - lastModified: z.string().optional(), + lastModified: z.string().optional() }) .optional(), settings: z.record(z.string(), z.any()).optional(), 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;