feat: some moving around
This commit is contained in:
767
app/src/lib/graph-interface/graph/Graph.svelte
Normal file
767
app/src/lib/graph-interface/graph/Graph.svelte
Normal file
@@ -0,0 +1,767 @@
|
||||
<script lang="ts">
|
||||
import { animate, lerp, snapToGrid } from '../helpers/index.js';
|
||||
import { LinearSRGBColorSpace } from 'three';
|
||||
import { Canvas } from '@threlte/core';
|
||||
import type { OrthographicCamera } from 'three';
|
||||
import Background from '../background/Background.svelte';
|
||||
import type { GraphManager } from '../graph-manager.js';
|
||||
import { onMount, setContext } from 'svelte';
|
||||
import Camera from '../Camera.svelte';
|
||||
import GraphView from './GraphView.svelte';
|
||||
import type { Node, Node as NodeType, Socket } from '@nodes/types';
|
||||
import FloatingEdge from '../edges/FloatingEdge.svelte';
|
||||
import {
|
||||
activeNodeId,
|
||||
activeSocket,
|
||||
hoveredSocket,
|
||||
possibleSockets,
|
||||
possibleSocketIds,
|
||||
selectedNodes
|
||||
} from './stores.js';
|
||||
import BoxSelection from '../BoxSelection.svelte';
|
||||
import AddMenu from '../AddMenu.svelte';
|
||||
|
||||
export let graph: GraphManager;
|
||||
setContext('graphManager', graph);
|
||||
const status = graph.status;
|
||||
const nodes = graph.nodes;
|
||||
const edges = graph.edges;
|
||||
|
||||
let wrapper: HTMLDivElement;
|
||||
$: rect =
|
||||
wrapper && width ? wrapper.getBoundingClientRect() : { x: 0, y: 0, width: 0, height: 0 };
|
||||
|
||||
let camera: OrthographicCamera;
|
||||
const minZoom = 1;
|
||||
const maxZoom = 40;
|
||||
let mousePosition = [0, 0];
|
||||
let mouseDown: null | [number, number] = null;
|
||||
let mouseDownId = -1;
|
||||
let boxSelection = false;
|
||||
let loaded = false;
|
||||
const cameraDown = [0, 0];
|
||||
let cameraPosition: [number, number, number] = [0, 0, 4];
|
||||
let addMenuPosition: [number, number] | null = null;
|
||||
let clipboard: null | {
|
||||
nodes: Node[];
|
||||
edges: [number, number, number, string][];
|
||||
} = null;
|
||||
|
||||
let width = globalThis?.innerWidth ?? 100;
|
||||
let height = globalThis?.innerHeight ?? 100;
|
||||
|
||||
let cameraBounds = [-1000, 1000, -1000, 1000];
|
||||
$: cameraBounds = [
|
||||
cameraPosition[0] - width / cameraPosition[2] / 2,
|
||||
cameraPosition[0] + width / cameraPosition[2] / 2,
|
||||
cameraPosition[1] - height / cameraPosition[2] / 2,
|
||||
cameraPosition[1] + height / cameraPosition[2] / 2
|
||||
];
|
||||
function setCameraTransform(x = cameraPosition[0], y = cameraPosition[1], z = cameraPosition[2]) {
|
||||
if (camera) {
|
||||
camera.position.x = x;
|
||||
camera.position.z = y;
|
||||
camera.zoom = z;
|
||||
}
|
||||
cameraPosition = [x, y, z];
|
||||
localStorage.setItem('cameraPosition', JSON.stringify(cameraPosition));
|
||||
}
|
||||
|
||||
export let debug = {};
|
||||
$: debug = {
|
||||
activeNodeId: $activeNodeId,
|
||||
activeSocket: $activeSocket ? `${$activeSocket?.node.id}-${$activeSocket?.index}` : null,
|
||||
hoveredSocket: $hoveredSocket ? `${$hoveredSocket?.node.id}-${$hoveredSocket?.index}` : null,
|
||||
selectedNodes: [...($selectedNodes?.values() || [])],
|
||||
cameraPosition
|
||||
};
|
||||
|
||||
function updateNodePosition(node: NodeType) {
|
||||
if (node?.tmp?.ref) {
|
||||
if (node.tmp['x'] !== undefined && node.tmp['y'] !== undefined) {
|
||||
node.tmp.ref.style.setProperty('--nx', `${node.tmp.x * 10}px`);
|
||||
node.tmp.ref.style.setProperty('--ny', `${node.tmp.y * 10}px`);
|
||||
node.tmp.mesh.position.x = node.tmp.x + 10;
|
||||
node.tmp.mesh.position.z = node.tmp.y + getNodeHeight(node.type) / 2;
|
||||
if (node.tmp.x === node.position[0] && node.tmp.y === node.position[1]) {
|
||||
delete node.tmp.x;
|
||||
delete node.tmp.y;
|
||||
}
|
||||
} else {
|
||||
node.tmp.ref.style.setProperty('--nx', `${node.position[0] * 10}px`);
|
||||
node.tmp.ref.style.setProperty('--ny', `${node.position[1] * 10}px`);
|
||||
node.tmp.mesh.position.x = node.position[0] + 10;
|
||||
node.tmp.mesh.position.z = node.position[1] + getNodeHeight(node.type) / 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
setContext('updateNodePosition', updateNodePosition);
|
||||
|
||||
const nodeHeightCache: Record<string, number> = {};
|
||||
function getNodeHeight(nodeTypeId: string) {
|
||||
if (nodeTypeId in nodeHeightCache) {
|
||||
return nodeHeightCache[nodeTypeId];
|
||||
}
|
||||
const node = graph.getNodeType(nodeTypeId);
|
||||
if (!node?.inputs) {
|
||||
return 5;
|
||||
}
|
||||
const height =
|
||||
5 +
|
||||
10 *
|
||||
Object.keys(node.inputs)
|
||||
.filter((i) => i !== 'seed')
|
||||
.filter((p) => node?.inputs && !('setting' in node?.inputs?.[p])).length;
|
||||
nodeHeightCache[nodeTypeId] = height;
|
||||
return height;
|
||||
}
|
||||
setContext('getNodeHeight', getNodeHeight);
|
||||
|
||||
setContext('isNodeInView', (node: NodeType) => {
|
||||
const height = getNodeHeight(node.type);
|
||||
const width = 20;
|
||||
return (
|
||||
node.position[0] > cameraBounds[0] - width &&
|
||||
node.position[0] < cameraBounds[1] &&
|
||||
node.position[1] > cameraBounds[2] - height &&
|
||||
node.position[1] < cameraBounds[3]
|
||||
);
|
||||
});
|
||||
|
||||
function getNodeIdFromEvent(event: MouseEvent) {
|
||||
let clickedNodeId = -1;
|
||||
|
||||
let mx = event.clientX - rect.x;
|
||||
let my = event.clientY - rect.y;
|
||||
|
||||
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');
|
||||
if (nodeId) {
|
||||
clickedNodeId = parseInt(nodeId, 10);
|
||||
}
|
||||
}
|
||||
|
||||
// if we do not have an active node,
|
||||
// we are going to check if we clicked on a node by coordinates
|
||||
if (clickedNodeId === -1) {
|
||||
const [downX, downY] = projectScreenToWorld(mx, my);
|
||||
for (const node of $nodes.values()) {
|
||||
const x = node.position[0];
|
||||
const y = node.position[1];
|
||||
const height = getNodeHeight(node.type);
|
||||
if (downX > x && downX < x + 20 && downY > y && downY < y + height) {
|
||||
clickedNodeId = node.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return clickedNodeId;
|
||||
}
|
||||
|
||||
setContext('setDownSocket', (socket: Socket) => {
|
||||
$activeSocket = socket;
|
||||
|
||||
let { node, index, position } = socket;
|
||||
|
||||
// remove existing edge
|
||||
if (typeof index === 'string') {
|
||||
const edges = graph.getEdgesToNode(node);
|
||||
for (const edge of edges) {
|
||||
if (edge[3] === index) {
|
||||
node = edge[0];
|
||||
index = edge[1];
|
||||
position = getSocketPosition(node, index);
|
||||
graph.removeEdge(edge);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mouseDown = position;
|
||||
$activeSocket = {
|
||||
node,
|
||||
index,
|
||||
position
|
||||
};
|
||||
|
||||
$possibleSockets = graph.getPossibleSockets($activeSocket).map(([node, index]) => {
|
||||
return {
|
||||
node,
|
||||
index,
|
||||
position: getSocketPosition(node, index)
|
||||
};
|
||||
});
|
||||
$possibleSocketIds = new Set($possibleSockets.map((s) => `${s.node.id}-${s.index}`));
|
||||
});
|
||||
|
||||
function getSnapLevel() {
|
||||
const z = cameraPosition[2];
|
||||
if (z > 66) {
|
||||
return 8;
|
||||
} else if (z > 55) {
|
||||
return 4;
|
||||
} else if (z > 11) {
|
||||
return 2;
|
||||
} else {
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
function getSocketPosition(node: NodeType, index: string | number): [number, number] {
|
||||
if (typeof index === 'number') {
|
||||
return [
|
||||
(node?.tmp?.x ?? node.position[0]) + 20,
|
||||
(node?.tmp?.y ?? node.position[1]) + 2.5 + 10 * index
|
||||
];
|
||||
} else {
|
||||
const _index = Object.keys(node.tmp?.type?.inputs || {}).indexOf(index);
|
||||
return [
|
||||
node?.tmp?.x ?? node.position[0],
|
||||
(node?.tmp?.y ?? node.position[1]) + 10 + 10 * _index
|
||||
];
|
||||
}
|
||||
}
|
||||
setContext('getSocketPosition', getSocketPosition);
|
||||
|
||||
function projectScreenToWorld(x: number, y: number): [number, number] {
|
||||
return [
|
||||
cameraPosition[0] + (x - width / 2) / cameraPosition[2],
|
||||
cameraPosition[1] + (y - height / 2) / cameraPosition[2]
|
||||
];
|
||||
}
|
||||
|
||||
function handleMouseMove(event: MouseEvent) {
|
||||
let mx = event.clientX - rect.x;
|
||||
let my = event.clientY - rect.y;
|
||||
|
||||
mousePosition = projectScreenToWorld(mx, my);
|
||||
|
||||
if (!mouseDown) return;
|
||||
|
||||
// we are creating a new edge here
|
||||
if ($possibleSockets?.length) {
|
||||
let smallestDist = 1000;
|
||||
let _socket;
|
||||
for (const socket of $possibleSockets) {
|
||||
const dist = Math.sqrt(
|
||||
(socket.position[0] - mousePosition[0]) ** 2 +
|
||||
(socket.position[1] - mousePosition[1]) ** 2
|
||||
);
|
||||
if (dist < smallestDist) {
|
||||
smallestDist = dist;
|
||||
_socket = socket;
|
||||
}
|
||||
}
|
||||
|
||||
if (_socket && smallestDist < 0.9) {
|
||||
mousePosition = _socket.position;
|
||||
$hoveredSocket = _socket;
|
||||
} else {
|
||||
$hoveredSocket = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// handle box selection
|
||||
if (boxSelection) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const mouseD = projectScreenToWorld(mouseDown[0], mouseDown[1]);
|
||||
const x1 = Math.min(mouseD[0], mousePosition[0]);
|
||||
const x2 = Math.max(mouseD[0], mousePosition[0]);
|
||||
const y1 = Math.min(mouseD[1], mousePosition[1]);
|
||||
const y2 = Math.max(mouseD[1], mousePosition[1]);
|
||||
for (const node of $nodes.values()) {
|
||||
if (!node?.tmp) continue;
|
||||
const x = node.position[0];
|
||||
const y = node.position[1];
|
||||
const height = getNodeHeight(node.type);
|
||||
if (x > x1 - 20 && x < x2 && y > y1 - height && y < y2) {
|
||||
$selectedNodes?.add(node.id);
|
||||
} else {
|
||||
$selectedNodes?.delete(node.id);
|
||||
}
|
||||
}
|
||||
$selectedNodes = $selectedNodes;
|
||||
return;
|
||||
}
|
||||
|
||||
// here we are handling dragging of nodes
|
||||
if ($activeNodeId !== -1 && mouseDownId !== -1) {
|
||||
const node = graph.getNode($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 - mouseDown[0]) / cameraPosition[2];
|
||||
let newY = oldY + (my - mouseDown[1]) / cameraPosition[2];
|
||||
|
||||
if (event.ctrlKey) {
|
||||
const snapLevel = getSnapLevel();
|
||||
newX = snapToGrid(newX, 5 / snapLevel);
|
||||
newY = snapToGrid(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 ($selectedNodes?.size) {
|
||||
for (const nodeId of $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;
|
||||
updateNodePosition(n);
|
||||
}
|
||||
}
|
||||
|
||||
node.tmp.x = newX;
|
||||
node.tmp.y = newY;
|
||||
|
||||
updateNodePosition(node);
|
||||
|
||||
$edges = $edges;
|
||||
return;
|
||||
}
|
||||
|
||||
// here we are handling panning of camera
|
||||
let newX = cameraDown[0] - (mx - mouseDown[0]) / cameraPosition[2];
|
||||
let newY = cameraDown[1] - (my - mouseDown[1]) / cameraPosition[2];
|
||||
|
||||
setCameraTransform(newX, newY, cameraPosition[2]);
|
||||
}
|
||||
|
||||
const zoomSpeed = 2;
|
||||
function handleMouseScroll(event: WheelEvent) {
|
||||
const bodyIsFocused =
|
||||
document.activeElement === document.body ||
|
||||
document.activeElement === 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 ? cameraPosition[2] / delta : cameraPosition[2] * delta)
|
||||
);
|
||||
|
||||
// Calculate the ratio of the new zoom to the original zoom
|
||||
const zoomRatio = newZoom / cameraPosition[2];
|
||||
|
||||
// Update camera position and zoom level
|
||||
setCameraTransform(
|
||||
mousePosition[0] - (mousePosition[0] - cameraPosition[0]) / zoomRatio,
|
||||
mousePosition[1] - (mousePosition[1] - cameraPosition[1]) / zoomRatio,
|
||||
newZoom
|
||||
);
|
||||
}
|
||||
|
||||
function handleMouseDown(event: MouseEvent) {
|
||||
if (mouseDown) return;
|
||||
|
||||
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 - rect.x;
|
||||
let my = event.clientY - rect.y;
|
||||
|
||||
mouseDown = [mx, my];
|
||||
cameraDown[0] = cameraPosition[0];
|
||||
cameraDown[1] = cameraPosition[1];
|
||||
|
||||
const clickedNodeId = getNodeIdFromEvent(event);
|
||||
mouseDownId = clickedNodeId;
|
||||
|
||||
// if we clicked on a node
|
||||
if (clickedNodeId !== -1) {
|
||||
if ($activeNodeId === -1) {
|
||||
$activeNodeId = clickedNodeId;
|
||||
// if the selected node is the same as the clicked node
|
||||
} else if ($activeNodeId === clickedNodeId) {
|
||||
//$activeNodeId = -1;
|
||||
// if the clicked node is different from the selected node and secondary
|
||||
} else if (event.ctrlKey) {
|
||||
$selectedNodes = $selectedNodes || new Set();
|
||||
$selectedNodes.add($activeNodeId);
|
||||
$selectedNodes.delete(clickedNodeId);
|
||||
$activeNodeId = clickedNodeId;
|
||||
// select the node
|
||||
} else if (event.shiftKey) {
|
||||
const activeNode = graph.getNode($activeNodeId);
|
||||
const newNode = graph.getNode(clickedNodeId);
|
||||
if (activeNode && newNode) {
|
||||
const edge = graph.getNodesBetween(activeNode, newNode);
|
||||
if (edge) {
|
||||
const selected = new Set(edge.map((n) => n.id));
|
||||
selected.add(clickedNodeId);
|
||||
$selectedNodes = selected;
|
||||
}
|
||||
}
|
||||
} else if (!$selectedNodes?.has(clickedNodeId)) {
|
||||
$activeNodeId = clickedNodeId;
|
||||
$selectedNodes?.clear();
|
||||
$selectedNodes = $selectedNodes;
|
||||
}
|
||||
} else if (event.ctrlKey) {
|
||||
boxSelection = true;
|
||||
}
|
||||
const node = graph.getNode($activeNodeId);
|
||||
if (!node) return;
|
||||
node.tmp = node.tmp || {};
|
||||
node.tmp.downX = node.position[0];
|
||||
node.tmp.downY = node.position[1];
|
||||
if ($selectedNodes) {
|
||||
for (const nodeId of $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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function copyNodes() {
|
||||
if ($activeNodeId === -1 && !$selectedNodes?.size) return;
|
||||
let _nodes = [$activeNodeId, ...($selectedNodes?.values() || [])]
|
||||
.map((id) => graph.getNode(id))
|
||||
.filter(Boolean) as Node[];
|
||||
|
||||
const _edges = graph.getEdgesBetweenNodes(_nodes);
|
||||
|
||||
_nodes = _nodes.map((_node) => {
|
||||
const node = globalThis.structuredClone({
|
||||
..._node,
|
||||
tmp: {
|
||||
downX: mousePosition[0] - _node.position[0],
|
||||
downY: mousePosition[1] - _node.position[1]
|
||||
}
|
||||
});
|
||||
return node;
|
||||
});
|
||||
|
||||
clipboard = {
|
||||
nodes: _nodes,
|
||||
edges: _edges
|
||||
};
|
||||
}
|
||||
|
||||
function pasteNodes() {
|
||||
if (!clipboard) return;
|
||||
|
||||
const _nodes = clipboard.nodes
|
||||
.map((node) => {
|
||||
node.tmp = node.tmp || {};
|
||||
node.position[0] = mousePosition[0] - (node?.tmp?.downX || 0);
|
||||
node.position[1] = mousePosition[1] - (node?.tmp?.downY || 0);
|
||||
return node;
|
||||
})
|
||||
.filter(Boolean) as Node[];
|
||||
|
||||
const newNodes = graph.createGraph(_nodes, clipboard.edges);
|
||||
$selectedNodes = new Set(newNodes.map((n) => n.id));
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
const bodyIsFocused =
|
||||
document.activeElement === document.body || document?.activeElement?.id === 'graph';
|
||||
|
||||
if (event.key === 'l') {
|
||||
const activeNode = graph.getNode($activeNodeId);
|
||||
if (activeNode) {
|
||||
const nodes = graph.getLinkedNodes(activeNode);
|
||||
$selectedNodes = new Set(nodes.map((n) => n.id));
|
||||
}
|
||||
console.log(activeNode);
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
$activeNodeId = -1;
|
||||
$selectedNodes?.clear();
|
||||
$selectedNodes = $selectedNodes;
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
}
|
||||
|
||||
if (event.key === 'A' && event.shiftKey) {
|
||||
addMenuPosition = [mousePosition[0], mousePosition[1]];
|
||||
}
|
||||
|
||||
if (event.key === '.') {
|
||||
const average = [0, 0];
|
||||
for (const node of $nodes.values()) {
|
||||
average[0] += node.position[0];
|
||||
average[1] += node.position[1];
|
||||
}
|
||||
average[0] = average[0] ? average[0] / $nodes.size : 0;
|
||||
average[1] = average[1] ? average[1] / $nodes.size : 0;
|
||||
|
||||
const camX = cameraPosition[0];
|
||||
const camY = cameraPosition[1];
|
||||
const camZ = cameraPosition[2];
|
||||
|
||||
const ease = (t: number) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t);
|
||||
|
||||
animate(500, (a: number) => {
|
||||
setCameraTransform(
|
||||
lerp(camX, average[0], ease(a)),
|
||||
lerp(camY, average[1], ease(a)),
|
||||
lerp(camZ, 2, ease(a))
|
||||
);
|
||||
if (mouseDown) return false;
|
||||
});
|
||||
}
|
||||
|
||||
if (event.key === 'a' && event.ctrlKey && bodyIsFocused) {
|
||||
$selectedNodes = new Set($nodes.keys());
|
||||
}
|
||||
|
||||
if (event.key === 'c' && event.ctrlKey) {
|
||||
copyNodes();
|
||||
}
|
||||
|
||||
if (event.key === 'v' && event.ctrlKey) {
|
||||
pasteNodes();
|
||||
}
|
||||
|
||||
if (event.key === 'z' && event.ctrlKey) {
|
||||
graph.undo();
|
||||
for (const node of $nodes.values()) {
|
||||
updateNodePosition(node);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === 'y' && event.ctrlKey) {
|
||||
graph.redo();
|
||||
for (const node of $nodes.values()) {
|
||||
updateNodePosition(node);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(event.key === 'Delete' || event.key === 'Backspace' || event.key === 'x') &&
|
||||
bodyIsFocused
|
||||
) {
|
||||
graph.startUndoGroup();
|
||||
if ($activeNodeId !== -1) {
|
||||
const node = graph.getNode($activeNodeId);
|
||||
if (node) {
|
||||
graph.removeNode(node, { restoreEdges: event.ctrlKey });
|
||||
$activeNodeId = -1;
|
||||
}
|
||||
}
|
||||
if ($selectedNodes) {
|
||||
for (const nodeId of $selectedNodes) {
|
||||
const node = graph.getNode(nodeId);
|
||||
if (node) {
|
||||
graph.removeNode(node, { restoreEdges: event.ctrlKey });
|
||||
}
|
||||
}
|
||||
$selectedNodes.clear();
|
||||
$selectedNodes = $selectedNodes;
|
||||
}
|
||||
graph.saveUndoGroup();
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseUp(event: MouseEvent) {
|
||||
const activeNode = graph.getNode($activeNodeId);
|
||||
|
||||
const clickedNodeId = getNodeIdFromEvent(event);
|
||||
|
||||
if (clickedNodeId !== -1) {
|
||||
if (activeNode) {
|
||||
if (!activeNode?.tmp?.isMoving && !event.ctrlKey && !event.shiftKey) {
|
||||
$selectedNodes?.clear();
|
||||
$selectedNodes = $selectedNodes;
|
||||
$activeNodeId = clickedNodeId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (activeNode?.tmp?.isMoving) {
|
||||
activeNode.tmp = activeNode.tmp || {};
|
||||
activeNode.tmp.isMoving = false;
|
||||
const snapLevel = getSnapLevel();
|
||||
activeNode.position[0] = snapToGrid(
|
||||
activeNode?.tmp?.x ?? activeNode.position[0],
|
||||
5 / snapLevel
|
||||
);
|
||||
activeNode.position[1] = snapToGrid(
|
||||
activeNode?.tmp?.y ?? activeNode.position[1],
|
||||
5 / snapLevel
|
||||
);
|
||||
const nodes = [
|
||||
...[...($selectedNodes?.values() || [])].map((id) => graph.getNode(id))
|
||||
] as NodeType[];
|
||||
|
||||
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);
|
||||
updateNodePosition(node);
|
||||
if (node?.tmp?.isMoving) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$edges = $edges;
|
||||
});
|
||||
graph.save();
|
||||
} else if ($hoveredSocket && $activeSocket) {
|
||||
if (typeof $hoveredSocket.index === 'number' && typeof $activeSocket.index === 'string') {
|
||||
graph.createEdge(
|
||||
$hoveredSocket.node,
|
||||
$hoveredSocket.index || 0,
|
||||
$activeSocket.node,
|
||||
$activeSocket.index
|
||||
);
|
||||
} else if (
|
||||
typeof $activeSocket.index == 'number' &&
|
||||
typeof $hoveredSocket.index === 'string'
|
||||
) {
|
||||
graph.createEdge(
|
||||
$activeSocket.node,
|
||||
$activeSocket.index || 0,
|
||||
$hoveredSocket.node,
|
||||
$hoveredSocket.index
|
||||
);
|
||||
}
|
||||
graph.save();
|
||||
}
|
||||
|
||||
// check if camera moved
|
||||
if (
|
||||
clickedNodeId === -1 &&
|
||||
!boxSelection &&
|
||||
cameraDown[0] === cameraPosition[0] &&
|
||||
cameraDown[1] === cameraPosition[1]
|
||||
) {
|
||||
$activeNodeId = -1;
|
||||
$selectedNodes?.clear();
|
||||
$selectedNodes = $selectedNodes;
|
||||
}
|
||||
|
||||
mouseDown = null;
|
||||
boxSelection = false;
|
||||
$activeSocket = null;
|
||||
$possibleSockets = [];
|
||||
$possibleSocketIds = null;
|
||||
$hoveredSocket = null;
|
||||
addMenuPosition = null;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (localStorage.getItem('cameraPosition')) {
|
||||
const cPosition = JSON.parse(localStorage.getItem('cameraPosition')!);
|
||||
if (Array.isArray(cPosition)) {
|
||||
setCameraTransform(cPosition[0], cPosition[1], cPosition[2]);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window on:mousemove={handleMouseMove} on:mouseup={handleMouseUp} />
|
||||
|
||||
<div
|
||||
on:wheel={handleMouseScroll}
|
||||
bind:this={wrapper}
|
||||
class="wrapper"
|
||||
aria-label="Graph"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
bind:clientWidth={width}
|
||||
bind:clientHeight={height}
|
||||
on:keydown={handleKeyDown}
|
||||
on:mousedown={handleMouseDown}
|
||||
>
|
||||
<Canvas
|
||||
shadows={false}
|
||||
renderMode="on-demand"
|
||||
colorManagementEnabled={false}
|
||||
colorSpace={LinearSRGBColorSpace}
|
||||
>
|
||||
<Camera bind:camera position={cameraPosition} />
|
||||
|
||||
<Background {cameraPosition} {maxZoom} {minZoom} {width} {height} />
|
||||
|
||||
{#if boxSelection && mouseDown}
|
||||
<BoxSelection
|
||||
{cameraPosition}
|
||||
p1={{
|
||||
x: cameraPosition[0] + (mouseDown[0] - width / 2) / cameraPosition[2],
|
||||
y: cameraPosition[1] + (mouseDown[1] - height / 2) / cameraPosition[2]
|
||||
}}
|
||||
p2={{ x: mousePosition[0], y: mousePosition[1] }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if $status === 'idle'}
|
||||
{#if addMenuPosition}
|
||||
<AddMenu bind:position={addMenuPosition} {graph} />
|
||||
{/if}
|
||||
|
||||
{#if $activeSocket}
|
||||
<FloatingEdge
|
||||
from={{ x: $activeSocket.position[0], y: $activeSocket.position[1] }}
|
||||
to={{ x: mousePosition[0], y: mousePosition[1] }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<GraphView {nodes} {edges} {cameraPosition} />
|
||||
{:else if $status === 'loading'}
|
||||
<span>Loading</span>
|
||||
{:else if $status === 'error'}
|
||||
<span>Error</span>
|
||||
{/if}
|
||||
</Canvas>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.wrapper {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
78
app/src/lib/graph-interface/graph/GraphView.svelte
Normal file
78
app/src/lib/graph-interface/graph/GraphView.svelte
Normal file
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import type { Edge as EdgeType, Node as NodeType } from '@nodes/types';
|
||||
import { HTML } from '@threlte/extras';
|
||||
import Edge from '../edges/Edge.svelte';
|
||||
import Node from '../node/Node.svelte';
|
||||
import { getContext, onMount } from 'svelte';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import { activeSocket } from './stores.js';
|
||||
|
||||
export let nodes: Writable<Map<number, NodeType>>;
|
||||
export let edges: Writable<EdgeType[]>;
|
||||
|
||||
export let cameraPosition = [0, 0, 4];
|
||||
|
||||
const isNodeInView = getContext<(n: NodeType) => boolean>('isNodeInView');
|
||||
|
||||
const getSocketPosition =
|
||||
getContext<(node: NodeType, index: string | number) => [number, number]>('getSocketPosition');
|
||||
|
||||
function getEdgePosition(edge: EdgeType) {
|
||||
const pos1 = getSocketPosition(edge[0], edge[1]);
|
||||
const pos2 = getSocketPosition(edge[2], edge[3]);
|
||||
|
||||
return [pos1[0], pos1[1], pos2[0], pos2[1]];
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
for (const node of $nodes.values()) {
|
||||
if (node?.tmp?.ref) {
|
||||
node.tmp.ref.style.setProperty('--nx', `${node.position[0] * 10}px`);
|
||||
node.tmp.ref.style.setProperty('--ny', `${node.position[1] * 10}px`);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#each $edges as edge (`${edge[0].id}-${edge[1]}-${edge[2].id}-${edge[3]}`)}
|
||||
{@const pos = getEdgePosition(edge)}
|
||||
{@const [x1, y1, x2, y2] = pos}
|
||||
<Edge
|
||||
from={{
|
||||
x: x1,
|
||||
y: y1
|
||||
}}
|
||||
to={{
|
||||
x: x2,
|
||||
y: y2
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<HTML transform={false}>
|
||||
<div
|
||||
role="tree"
|
||||
id="graph"
|
||||
tabindex="0"
|
||||
class="wrapper"
|
||||
class:zoom-small={cameraPosition[2] < 2}
|
||||
class:hovering-sockets={activeSocket}
|
||||
style={`--cz: ${cameraPosition[2]}; --node-display: ${cameraPosition[2] < 2 ? 'none' : 'block'};`}
|
||||
>
|
||||
{#each $nodes.values() as node (node.id)}
|
||||
<Node {node} inView={cameraPosition && isNodeInView(node)} z={cameraPosition[2]} />
|
||||
{/each}
|
||||
</div>
|
||||
</HTML>
|
||||
|
||||
<style>
|
||||
.wrapper {
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
width: 0px;
|
||||
height: 0px;
|
||||
transform: scale(calc(var(--cz) * 0.1));
|
||||
display: var(--node-display, block);
|
||||
opacity: calc((var(--cz) - 2.5) / 3.5);
|
||||
}
|
||||
</style>
|
||||
25
app/src/lib/graph-interface/graph/Wrapper.svelte
Normal file
25
app/src/lib/graph-interface/graph/Wrapper.svelte
Normal file
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import type { Graph, NodeRegistry } from '@nodes/types';
|
||||
import GraphEl from './Graph.svelte';
|
||||
import { GraphManager } from '../graph-manager.js';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let registry: NodeRegistry;
|
||||
export let graph: Graph;
|
||||
|
||||
const manager = new GraphManager(registry);
|
||||
|
||||
manager.on('result', (result) => {
|
||||
dispatch('result', result);
|
||||
});
|
||||
|
||||
manager.on('save', (save) => {
|
||||
dispatch('save', save);
|
||||
});
|
||||
|
||||
manager.load(graph);
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
</script>
|
||||
|
||||
<GraphEl graph={manager} />
|
||||
6
app/src/lib/graph-interface/graph/context.ts
Normal file
6
app/src/lib/graph-interface/graph/context.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { GraphManager } from "../graph-manager.js";
|
||||
import { getContext } from "svelte";
|
||||
|
||||
export function getGraphManager(): GraphManager {
|
||||
return getContext("graphManager");
|
||||
}
|
||||
42
app/src/lib/graph-interface/graph/stores.ts
Normal file
42
app/src/lib/graph-interface/graph/stores.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { Socket } from "@nodes/types";
|
||||
import { writable, type Writable } from "svelte/store";
|
||||
import { Color } from "three/src/math/Color.js";
|
||||
|
||||
export const activeNodeId: Writable<number> = writable(-1);
|
||||
export const selectedNodes: Writable<Set<number> | null> = writable(new Set());
|
||||
|
||||
export const activeSocket: Writable<Socket | null> = writable(null);
|
||||
export const hoveredSocket: Writable<Socket | null> = writable(null);
|
||||
export const possibleSockets: Writable<Socket[]> = writable([]);
|
||||
export const possibleSocketIds: Writable<Set<string> | null> = writable(null);
|
||||
|
||||
export const colors = writable({
|
||||
backgroundColorDarker: new Color().setStyle("#101010"),
|
||||
backgroundColor: new Color().setStyle("#151515"),
|
||||
backgroundColorLighter: new Color().setStyle("#202020")
|
||||
});
|
||||
|
||||
if ("getComputedStyle" in globalThis) {
|
||||
|
||||
const body = document.body;
|
||||
|
||||
function updateColors() {
|
||||
|
||||
const style = getComputedStyle(body);
|
||||
const backgroundColorDarker = style.getPropertyValue("--background-color-darker");
|
||||
const backgroundColor = style.getPropertyValue("--background-color");
|
||||
const backgroundColorLighter = style.getPropertyValue("--background-color-lighter");
|
||||
|
||||
colors.update(col => {
|
||||
col.backgroundColorDarker.setStyle(backgroundColorDarker);
|
||||
col.backgroundColor.setStyle(backgroundColor);
|
||||
col.backgroundColorLighter.setStyle(backgroundColorLighter);
|
||||
return col;
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
body.addEventListener("transitionstart", () => {
|
||||
updateColors();
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user