Files
nodarium/app/src/lib/graph-interface/graph/Graph.svelte
Max Richter 1ea544e765
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 3m33s
chore: rename @nodes -> @nodarium for everything
2025-12-01 17:03:14 +01:00

717 lines
20 KiB
Svelte

<script lang="ts">
import type { Edge, Node, NodeType } from "@nodarium/types";
import { GraphSchema } from "@nodarium/types";
import { onMount } from "svelte";
import { createKeyMap } from "../../helpers/createKeyMap";
import AddMenu from "../components/AddMenu.svelte";
import Background from "../background/Background.svelte";
import BoxSelection from "../components/BoxSelection.svelte";
import EdgeEl from "../edges/Edge.svelte";
import NodeEl from "../node/Node.svelte";
import Camera from "../components/Camera.svelte";
import FloatingEdge from "../edges/FloatingEdge.svelte";
import {
animate,
lerp,
snapToGrid as snapPointToGrid,
} from "../helpers/index.js";
import { Canvas } from "@threlte/core";
import HelpView from "../components/HelpView.svelte";
import { getGraphManager, getGraphState } from "./state.svelte";
import { HTML } from "@threlte/extras";
const {
snapToGrid = $bindable(true),
showGrid = $bindable(true),
showHelp = $bindable(false),
keymap,
}: {
snapToGrid: boolean;
showGrid: boolean;
showHelp: boolean;
keymap: ReturnType<typeof createKeyMap>;
} = $props();
const minZoom = 1;
const maxZoom = 40;
let mouseDownNodeId = -1;
const cameraDown = [0, 0];
let isPanning = $state(false);
let isDragging = $state(false);
let hoveredNodeId = $state(-1);
const graph = getGraphManager();
const graphState = getGraphState();
function getEdgeId(edge: Edge) {
return `${edge[0].id}-${edge[1]}-${edge[2].id}-${edge[3]}`;
}
function getEdgePosition(edge: Edge) {
const fromNode = graph.nodes.get(edge[0].id);
const toNode = graph.nodes.get(edge[2].id);
// This check is important because nodes might not be there during some transitions.
if (!fromNode || !toNode) {
return [0, 0, 0, 0];
}
const pos1 = graphState.getSocketPosition(fromNode, edge[1]);
const pos2 = graphState.getSocketPosition(toNode, edge[3]);
return [pos1[0], pos1[1], pos2[0], pos2[1]];
}
function handleMouseMove(event: MouseEvent) {
let mx = event.clientX - graphState.rect.x;
let my = event.clientY - graphState.rect.y;
graphState.mousePosition = graphState.projectScreenToWorld(mx, my);
hoveredNodeId = graphState.getNodeIdFromEvent(event);
if (!graphState.mouseDown) return;
// we are creating a new edge here
if (graphState.activeSocket || graphState.possibleSockets?.length) {
let smallestDist = 1000;
let _socket;
for (const socket of graphState.possibleSockets) {
const dist = Math.sqrt(
(socket.position[0] - graphState.mousePosition[0]) ** 2 +
(socket.position[1] - graphState.mousePosition[1]) ** 2,
);
if (dist < smallestDist) {
smallestDist = dist;
_socket = socket;
}
}
if (_socket && smallestDist < 0.9) {
graphState.mousePosition = _socket.position;
graphState.hoveredSocket = _socket;
} else {
graphState.hoveredSocket = null;
}
return;
}
// handle box selection
if (graphState.boxSelection) {
event.preventDefault();
event.stopPropagation();
const mouseD = graphState.projectScreenToWorld(
graphState.mouseDown[0],
graphState.mouseDown[1],
);
const x1 = Math.min(mouseD[0], graphState.mousePosition[0]);
const x2 = Math.max(mouseD[0], graphState.mousePosition[0]);
const y1 = Math.min(mouseD[1], graphState.mousePosition[1]);
const y2 = Math.max(mouseD[1], graphState.mousePosition[1]);
for (const node of graph.nodes.values()) {
if (!node?.tmp) continue;
const x = node.position[0];
const y = node.position[1];
const height = graphState.getNodeHeight(node.type);
if (x > x1 - 20 && x < x2 && y > y1 - height && y < y2) {
graphState.selectedNodes?.add(node.id);
} else {
graphState.selectedNodes?.delete(node.id);
}
}
return;
}
// here we are handling dragging of nodes
if (graphState.activeNodeId !== -1 && mouseDownNodeId !== -1) {
const node = graph.getNode(graphState.activeNodeId);
if (!node || event.buttons !== 1) return;
node.tmp = node.tmp || {};
const oldX = node.tmp.downX || 0;
const oldY = node.tmp.downY || 0;
let newX =
oldX + (mx - graphState.mouseDown[0]) / graphState.cameraPosition[2];
let newY =
oldY + (my - graphState.mouseDown[1]) / graphState.cameraPosition[2];
if (event.ctrlKey) {
const snapLevel = graphState.getSnapLevel();
if (snapToGrid) {
newX = snapPointToGrid(newX, 5 / snapLevel);
newY = snapPointToGrid(newY, 5 / snapLevel);
}
}
if (!node.tmp.isMoving) {
const dist = Math.sqrt((oldX - newX) ** 2 + (oldY - newY) ** 2);
if (dist > 0.2) {
node.tmp.isMoving = true;
}
}
const vecX = oldX - newX;
const vecY = oldY - newY;
if (graphState.selectedNodes?.size) {
for (const nodeId of graphState.selectedNodes) {
const n = graph.getNode(nodeId);
if (!n?.tmp) continue;
n.tmp.x = (n?.tmp?.downX || 0) - vecX;
n.tmp.y = (n?.tmp?.downY || 0) - vecY;
graphState.updateNodePosition(n);
}
}
node.tmp.x = newX;
node.tmp.y = newY;
graphState.updateNodePosition(node);
return;
}
// here we are handling panning of camera
isPanning = true;
let newX =
cameraDown[0] -
(mx - graphState.mouseDown[0]) / graphState.cameraPosition[2];
let newY =
cameraDown[1] -
(my - graphState.mouseDown[1]) / graphState.cameraPosition[2];
graphState.setCameraTransform(newX, newY);
}
const zoomSpeed = 2;
function handleMouseScroll(event: WheelEvent) {
const bodyIsFocused =
document.activeElement === document.body ||
document.activeElement === graphState.wrapper ||
document?.activeElement?.id === "graph";
if (!bodyIsFocused) return;
// Define zoom speed and clamp it between -1 and 1
const isNegative = event.deltaY < 0;
const normalizedDelta = Math.abs(event.deltaY * 0.01);
const delta = Math.pow(0.95, zoomSpeed * normalizedDelta);
// Calculate new zoom level and clamp it between minZoom and maxZoom
const newZoom = Math.max(
minZoom,
Math.min(
maxZoom,
isNegative
? graphState.cameraPosition[2] / delta
: graphState.cameraPosition[2] * delta,
),
);
// Calculate the ratio of the new zoom to the original zoom
const zoomRatio = newZoom / graphState.cameraPosition[2];
// Update camera position and zoom level
graphState.setCameraTransform(
graphState.mousePosition[0] -
(graphState.mousePosition[0] - graphState.cameraPosition[0]) /
zoomRatio,
graphState.mousePosition[1] -
(graphState.mousePosition[1] - graphState.cameraPosition[1]) /
zoomRatio,
newZoom,
);
}
function handleMouseDown(event: MouseEvent) {
if (graphState.mouseDown) return;
graphState.edgeEndPosition = null;
if (event.target instanceof HTMLElement) {
if (
event.target.nodeName !== "CANVAS" &&
!event.target.classList.contains("node") &&
!event.target.classList.contains("content")
) {
return;
}
}
let mx = event.clientX - graphState.rect.x;
let my = event.clientY - graphState.rect.y;
graphState.mouseDown = [mx, my];
cameraDown[0] = graphState.cameraPosition[0];
cameraDown[1] = graphState.cameraPosition[1];
const clickedNodeId = graphState.getNodeIdFromEvent(event);
mouseDownNodeId = clickedNodeId;
// if we clicked on a node
if (clickedNodeId !== -1) {
if (graphState.activeNodeId === -1) {
graphState.activeNodeId = clickedNodeId;
// if the selected node is the same as the clicked node
} else if (graphState.activeNodeId === clickedNodeId) {
//$activeNodeId = -1;
// if the clicked node is different from the selected node and secondary
} else if (event.ctrlKey) {
graphState.selectedNodes.add(graphState.activeNodeId);
graphState.selectedNodes.delete(clickedNodeId);
graphState.activeNodeId = clickedNodeId;
// select the node
} else if (event.shiftKey) {
const activeNode = graph.getNode(graphState.activeNodeId);
const newNode = graph.getNode(clickedNodeId);
if (activeNode && newNode) {
const edge = graph.getNodesBetween(activeNode, newNode);
if (edge) {
graphState.selectedNodes.clear();
for (const node of edge) {
graphState.selectedNodes.add(node.id);
}
graphState.selectedNodes.add(clickedNodeId);
}
}
} else if (!graphState.selectedNodes.has(clickedNodeId)) {
graphState.activeNodeId = clickedNodeId;
graphState.clearSelection();
}
} else if (event.ctrlKey) {
graphState.boxSelection = true;
}
const node = graph.getNode(graphState.activeNodeId);
if (!node) return;
node.tmp = node.tmp || {};
node.tmp.downX = node.position[0];
node.tmp.downY = node.position[1];
if (graphState.selectedNodes) {
for (const nodeId of graphState.selectedNodes) {
const n = graph.getNode(nodeId);
if (!n) continue;
n.tmp = n.tmp || {};
n.tmp.downX = n.position[0];
n.tmp.downY = n.position[1];
}
}
graphState.edgeEndPosition = null;
}
function handleMouseUp(event: MouseEvent) {
isPanning = false;
if (!graphState.mouseDown) return;
const activeNode = graph.getNode(graphState.activeNodeId);
const clickedNodeId = graphState.getNodeIdFromEvent(event);
if (clickedNodeId !== -1) {
if (activeNode) {
if (!activeNode?.tmp?.isMoving && !event.ctrlKey && !event.shiftKey) {
graphState.activeNodeId = clickedNodeId;
graphState.clearSelection();
}
}
}
if (activeNode?.tmp?.isMoving) {
activeNode.tmp = activeNode.tmp || {};
activeNode.tmp.isMoving = false;
if (snapToGrid) {
const snapLevel = graphState.getSnapLevel();
activeNode.position[0] = snapPointToGrid(
activeNode?.tmp?.x ?? activeNode.position[0],
5 / snapLevel,
);
activeNode.position[1] = snapPointToGrid(
activeNode?.tmp?.y ?? activeNode.position[1],
5 / snapLevel,
);
} else {
activeNode.position[0] = activeNode?.tmp?.x ?? activeNode.position[0];
activeNode.position[1] = activeNode?.tmp?.y ?? activeNode.position[1];
}
const nodes = [
...[...(graphState.selectedNodes?.values() || [])].map((id) =>
graph.getNode(id),
),
] as Node[];
const vec = [
activeNode.position[0] - (activeNode?.tmp.x || 0),
activeNode.position[1] - (activeNode?.tmp.y || 0),
];
for (const node of nodes) {
if (!node) continue;
node.tmp = node.tmp || {};
const { x, y } = node.tmp;
if (x !== undefined && y !== undefined) {
node.position[0] = x + vec[0];
node.position[1] = y + vec[1];
}
}
nodes.push(activeNode);
animate(500, (a: number) => {
for (const node of nodes) {
if (
node?.tmp &&
node.tmp["x"] !== undefined &&
node.tmp["y"] !== undefined
) {
node.tmp.x = lerp(node.tmp.x, node.position[0], a);
node.tmp.y = lerp(node.tmp.y, node.position[1], a);
graphState.updateNodePosition(node);
if (node?.tmp?.isMoving) {
return false;
}
}
}
});
graph.save();
} else if (graphState.hoveredSocket && graphState.activeSocket) {
if (
typeof graphState.hoveredSocket.index === "number" &&
typeof graphState.activeSocket.index === "string"
) {
graph.createEdge(
graphState.hoveredSocket.node,
graphState.hoveredSocket.index || 0,
graphState.activeSocket.node,
graphState.activeSocket.index,
);
} else if (
typeof graphState.activeSocket.index == "number" &&
typeof graphState.hoveredSocket.index === "string"
) {
graph.createEdge(
graphState.activeSocket.node,
graphState.activeSocket.index || 0,
graphState.hoveredSocket.node,
graphState.hoveredSocket.index,
);
}
graph.save();
} else if (graphState.activeSocket && event.ctrlKey) {
// Handle automatic adding of nodes on ctrl+mouseUp
graphState.edgeEndPosition = [
graphState.mousePosition[0],
graphState.mousePosition[1],
];
if (typeof graphState.activeSocket.index === "number") {
graphState.addMenuPosition = [
graphState.mousePosition[0],
graphState.mousePosition[1] - 3,
];
} else {
graphState.addMenuPosition = [
graphState.mousePosition[0] - 20,
graphState.mousePosition[1] - 3,
];
}
return;
}
// check if camera moved
if (
clickedNodeId === -1 &&
!graphState.boxSelection &&
cameraDown[0] === graphState.cameraPosition[0] &&
cameraDown[1] === graphState.cameraPosition[1] &&
graphState.isBodyFocused()
) {
graphState.activeNodeId = -1;
graphState.clearSelection();
}
graphState.mouseDown = null;
graphState.boxSelection = false;
graphState.activeSocket = null;
graphState.possibleSockets = [];
graphState.hoveredSocket = null;
graphState.addMenuPosition = null;
}
function handleMouseLeave() {
isDragging = false;
isPanning = false;
}
function handleDrop(event: DragEvent) {
event.preventDefault();
isDragging = false;
if (!event.dataTransfer) return;
const nodeId = event.dataTransfer.getData("data/node-id") as NodeType;
let mx = event.clientX - graphState.rect.x;
let my = event.clientY - graphState.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 = graphState.projectScreenToWorld(mx, my);
graph.registry.load([nodeId]).then(() => {
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 graph.registry.register(buffer);
graph.createNode({
type: nodeType.id,
props: {},
position: graphState.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()));
graph.load(state);
}
};
reader.readAsText(file);
}
}
}
function handleDragEnter(e: DragEvent) {
e.preventDefault();
isDragging = true;
isPanning = false;
}
function handlerDragOver(e: DragEvent) {
e.preventDefault();
isDragging = true;
isPanning = false;
}
function handleDragEnd(e: DragEvent) {
e.preventDefault();
isDragging = true;
isPanning = false;
}
onMount(() => {
if (localStorage.getItem("cameraPosition")) {
const cPosition = JSON.parse(localStorage.getItem("cameraPosition")!);
if (Array.isArray(cPosition)) {
graphState.setCameraTransform(cPosition[0], cPosition[1], cPosition[2]);
}
}
});
</script>
<svelte:window onmousemove={handleMouseMove} onmouseup={handleMouseUp} />
<div
onwheel={handleMouseScroll}
bind:this={graphState.wrapper}
class="graph-wrapper"
class:is-panning={isPanning}
class:is-hovering={hoveredNodeId !== -1}
aria-label="Graph"
role="button"
tabindex="0"
bind:clientWidth={graphState.width}
bind:clientHeight={graphState.height}
ondragenter={handleDragEnter}
ondragover={handlerDragOver}
ondragexit={handleDragEnd}
ondrop={handleDrop}
onmouseleave={handleMouseLeave}
onkeydown={keymap.handleKeyboardEvent}
onmousedown={handleMouseDown}
>
<input
type="file"
accept="application/wasm,application/json"
id="drop-zone"
disabled={!isDragging}
ondragend={handleDragEnd}
ondragleave={handleDragEnd}
/>
<label for="drop-zone"></label>
<Canvas shadows={false} renderMode="on-demand" colorManagementEnabled={false}>
<Camera
bind:camera={graphState.camera}
position={graphState.cameraPosition}
/>
{#if showGrid !== false}
<Background
cameraPosition={graphState.cameraPosition}
{maxZoom}
{minZoom}
width={graphState.width}
height={graphState.height}
/>
{/if}
{#if graphState.boxSelection && graphState.mouseDown}
<BoxSelection
cameraPosition={graphState.cameraPosition}
p1={{
x:
graphState.cameraPosition[0] +
(graphState.mouseDown[0] - graphState.width / 2) /
graphState.cameraPosition[2],
y:
graphState.cameraPosition[1] +
(graphState.mouseDown[1] - graphState.height / 2) /
graphState.cameraPosition[2],
}}
p2={{ x: graphState.mousePosition[0], y: graphState.mousePosition[1] }}
/>
{/if}
{#if graph.status === "idle"}
{#if graphState.addMenuPosition}
<AddMenu />
{/if}
{#if graphState.activeSocket}
<FloatingEdge
z={graphState.cameraPosition[2]}
from={{
x: graphState.activeSocket.position[0],
y: graphState.activeSocket.position[1],
}}
to={{
x: graphState.edgeEndPosition?.[0] ?? graphState.mousePosition[0],
y: graphState.edgeEndPosition?.[1] ?? graphState.mousePosition[1],
}}
/>
{/if}
{#each graph.edges as edge (getEdgeId(edge))}
{@const [x1, y1, x2, y2] = getEdgePosition(edge)}
<EdgeEl
z={graphState.cameraPosition[2]}
from={{
x: x1,
y: y1,
}}
to={{
x: x2,
y: y2,
}}
/>
{/each}
<HTML transform={false}>
<div
role="tree"
id="graph"
tabindex="0"
class="wrapper"
style:transform={`scale(${graphState.cameraPosition[2] * 0.1})`}
class:hovering-sockets={graphState.activeSocket}
>
{#each graph.nodes.values() as node (node.id)}
<NodeEl
{node}
inView={graphState.isNodeInView(node)}
z={graphState.cameraPosition[2]}
/>
{/each}
</div>
</HTML>
{:else if graph.status === "loading"}
<span>Loading</span>
{:else if graph.status === "error"}
<span>Error</span>
{/if}
</Canvas>
</div>
{#if showHelp}
<HelpView registry={graph.registry} />
{/if}
<style>
.graph-wrapper {
position: relative;
z-index: 0;
transition: opacity 0.3s ease;
height: 100%;
}
.wrapper {
position: absolute;
z-index: 100;
width: 0px;
height: 0px;
}
.is-hovering {
cursor: pointer;
}
.is-panning {
cursor: grab;
}
input {
position: absolute;
z-index: 1;
width: 100%;
height: 100%;
background: var(--layer-2);
opacity: 0;
}
input:disabled {
opacity: 0;
pointer-events: none;
}
input:disabled + label {
opacity: 0;
pointer-events: none;
}
label {
position: absolute;
z-index: 1;
top: 10px;
left: 10px;
border-radius: 5px;
width: calc(100% - 20px);
height: calc(100% - 25px);
border: dashed 4px var(--layer-2);
background: var(--layer-1);
opacity: 0.5;
}
</style>