feat: extract graph-interface into seperate package

This commit is contained in:
2024-04-10 15:40:01 +02:00
parent 404fcbfe39
commit 2ed1501747
93 changed files with 2193 additions and 1788 deletions

View File

@ -0,0 +1,31 @@
/** @type { import("eslint").Linter.Config } */
module.exports = {
root: true,
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:svelte/recommended',
'prettier'
],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020,
extraFileExtensions: ['.svelte']
},
env: {
browser: true,
es2017: true,
node: true
},
overrides: [
{
files: ['*.svelte'],
parser: 'svelte-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser'
}
}
]
};

View File

@ -0,0 +1,4 @@
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

View File

@ -0,0 +1,8 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

View File

@ -0,0 +1,65 @@
{
"name": "@nodes/graph-interface",
"version": "0.0.1",
"scripts": {
"dev": "svelte-package --watch",
"build": "vite build && npm run package",
"postbuild": "pnpm run package",
"preview": "vite preview",
"package": "svelte-kit sync && svelte-package && publint",
"prepublishOnly": "npm run package",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"svelte": "./dist/index.js"
}
},
"files": [
"dist",
"!dist/**/*.test.*",
"!dist/**/*.spec.*"
],
"dependencies": {
"@nodes/ui": "link:../ui",
"@nodes/types": "link:../types",
"@threlte/core": "^7.1.0",
"@threlte/extras": "^8.7.5",
"@threlte/flex": "^1.0.1",
"@types/three": "^0.159.0",
"jsondiffpatch": "^0.6.0",
"three": "^0.159.0",
"vite-plugin-glsl": "^1.2.1"
},
"peerDependencies": {
"svelte": "^4.0.0"
},
"devDependencies": {
"@histoire/plugin-svelte": "^0.17.9",
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/package": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"@types/eslint": "^8.56.0",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.35.1",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2",
"publint": "^0.1.9",
"svelte": "^4.2.7",
"svelte-check": "^3.6.0",
"tslib": "^2.4.1",
"typescript": "^5.0.0",
"vite": "^5.0.11"
},
"svelte": "./dist/index.js",
"types": "./dist/index.d.ts",
"type": "module"
}

View File

@ -0,0 +1,164 @@
<script lang="ts">
import type { GraphManager } from './graph-manager.js';
import { HTML } from '@threlte/extras';
import { onMount } from 'svelte';
export let position: [x: number, y: number] | null;
export let graph: GraphManager;
let input: HTMLInputElement;
let value: string = '';
let activeNodeId: string = '';
const allNodes = graph.getNodeTypes();
function filterNodes() {
return allNodes.filter((node) => node.id.includes(value));
}
$: nodes = value === '' ? allNodes : filterNodes();
$: if (nodes) {
if (activeNodeId === '') {
activeNodeId = nodes[0].id;
} else if (nodes.length) {
const node = nodes.find((node) => node.id === activeNodeId);
if (!node) {
activeNodeId = nodes[0].id;
}
}
}
function handleKeyDown(event: KeyboardEvent) {
event.stopImmediatePropagation();
const value = (event.target as HTMLInputElement).value;
if (event.key === 'Escape') {
position = null;
return;
}
if (event.key === 'ArrowDown') {
const index = nodes.findIndex((node) => node.id === activeNodeId);
activeNodeId = nodes[(index + 1) % nodes.length].id;
return;
}
if (event.key === 'ArrowUp') {
const index = nodes.findIndex((node) => node.id === activeNodeId);
activeNodeId = nodes[(index - 1 + nodes.length) % nodes.length].id;
return;
}
if (event.key === 'Enter') {
if (activeNodeId && position) {
graph.createNode({ type: activeNodeId, position });
position = null;
}
return;
}
}
onMount(() => {
input.disabled = false;
setTimeout(() => input.focus(), 50);
});
</script>
<HTML position.x={position?.[0]} position.z={position?.[1]} transform={false}>
<div class="add-menu-wrapper">
<div class="header">
<input
id="add-menu"
type="text"
aria-label="Search for a node type"
role="searchbox"
placeholder="Search..."
disabled={false}
on:keydown={handleKeyDown}
bind:value
bind:this={input}
/>
</div>
<div class="content">
{#each nodes as node}
<div
class="result"
role="treeitem"
tabindex="0"
aria-selected={node.id === activeNodeId}
on:keydown={(event) => {
if (event.key === 'Enter') {
if (position) {
graph.createNode({ type: node.id, position });
position = null;
}
}
}}
on:mousedown={() => {
if (position) {
graph.createNode({ type: node.id, position });
position = null;
}
}}
on:focus={() => {
activeNodeId = node.id;
}}
class:selected={node.id === activeNodeId}
on:mouseover={() => {
activeNodeId = node.id;
}}
>
{node.id}
</div>
{/each}
</div>
</div>
</HTML>
<style>
input {
background: var(--background-color-lighter);
font-family: var(--font-family);
border: none;
color: var(--text-color);
padding: 0.8em;
width: calc(100% - 2px);
box-sizing: border-box;
font-size: 1em;
margin-left: 1px;
margin-top: 1px;
}
input:focus {
outline: solid 2px rgba(255, 255, 255, 0.2);
}
.add-menu-wrapper {
position: absolute;
background: var(--background-color);
border-radius: 7px;
overflow: hidden;
border: solid 2px var(--background-color-lighter);
width: 150px;
}
.content {
min-height: none;
width: 100%;
color: var(--text-color);
}
.result {
padding: 1em 0.9em;
border-bottom: solid 1px var(--background-color-lighter);
opacity: 0.7;
font-size: 0.9em;
cursor: pointer;
}
.result[aria-selected='true'] {
background: var(--background-color-lighter);
opacity: 1;
}
</style>

View File

@ -0,0 +1,28 @@
<script lang="ts">
import { HTML } from '@threlte/extras';
export let p1 = { x: 0, y: 0 };
export let p2 = { x: 0, y: 0 };
export let cameraPosition = [0, 1, 0];
$: width = Math.abs(p1.x - p2.x) * cameraPosition[2];
$: height = Math.abs(p1.y - p2.y) * cameraPosition[2];
$: x = Math.max(p1.x, p2.x) - width / cameraPosition[2];
$: y = Math.max(p1.y, p2.y) - height / cameraPosition[2];
</script>
<HTML position.x={x} position.z={y} transform={false}>
<div class="box-selection" style={`width: ${width}px; height: ${height}px;`}></div>
</HTML>
<style>
.box-selection {
width: 40px;
height: 20px;
border: solid 0.2px rgba(200, 200, 200, 0.8);
border-style: dashed;
border-radius: 2px;
}
</style>

View File

@ -0,0 +1,18 @@
<script lang="ts">
import { T } from "@threlte/core";
import { type OrthographicCamera } from "three";
export let camera: OrthographicCamera | undefined = undefined;
export let position: [number, number, number] = [0, 0, 4];
</script>
<T.OrthographicCamera
bind:ref={camera}
position.x={0}
position.y={10}
position.z={0}
rotation.x={-Math.PI / 2}
zoom={position[2]}
makeDefault
/>

View File

@ -0,0 +1,98 @@
precision highp float;
varying vec2 vUv;
const float PI = 3.14159265359;
uniform vec2 dimensions;
uniform vec3 camPos;
uniform vec2 zoomLimits;
uniform vec3 backgroundColor;
float grid(float x, float y, float divisions, float thickness) {
x = fract(x * divisions);
x = min(x, 1.0 - x);
float xdelta = fwidth(x);
x = smoothstep(x - xdelta, x + xdelta, thickness);
y = fract(y * divisions);
y = min(y, 1.0 - y);
float ydelta = fwidth(y);
y = smoothstep(y - ydelta, y + ydelta, thickness);
return clamp(x + y, 0.0, 1.0);
}
float circle_grid(float x, float y, float divisions, float circleRadius) {
float gridX = mod(x + divisions/2.0, divisions) - divisions / 2.0;
float gridY = mod(y + divisions/2.0, divisions) - divisions / 2.0;
// Calculate the distance from the center of the grid
float gridDistance = length(vec2(gridX, gridY));
// Use smoothstep to create a smooth transition at the edges of the circle
float circle = 1.0 - smoothstep(circleRadius - 0.5, circleRadius + 0.5, gridDistance);
return circle;
}
float lerp(float a, float b,float t) {
return a * (1.0 - t) + b * t;
}
void main(void) {
float cx = camPos.x;
float cy = camPos.y;
float cz = camPos.z;
float width = dimensions.x;
float height = dimensions.y;
float minZ = zoomLimits.x;
float maxZ = zoomLimits.y;
float divisions = 0.1/cz;
float thickness = 0.05/cz;
float delta = 0.1 / 2.0;
float nz = (cz - minZ) / (maxZ - minZ);
float ux = (vUv.x-0.5) * width + cx*cz;
float uy = (vUv.y-0.5) * height - cy*cz;
//extra small grid
float m1 = grid(ux, uy, divisions*4.0, thickness*4.0) * 0.1;
float m2 = grid(ux, uy, divisions*16.0, thickness*16.0) * 0.03;
float xsmall = max(m1, m2);
float s3 = circle_grid(ux, uy, cz/1.6, 1.0) * 0.2;
xsmall = max(xsmall, s3);
// small grid
float c1 = grid(ux, uy, divisions, thickness) * 0.2;
float c2 = grid(ux, uy, divisions*2.0, thickness) * 0.1;
float small = max(c1, c2);
float s1 = circle_grid(ux, uy, cz*10.0, 2.0) * 0.2;
small = max(small, s1);
// large grid
float c3 = grid(ux, uy, divisions/8.0, thickness/8.0) * 0.1;
float c4 = grid(ux, uy, divisions/2.0, thickness/4.0) * 0.05;
float large = max(c3, c4);
float s2 = circle_grid(ux, uy, cz*20.0, 1.0) * 0.2;
large = max(large, s2);
float c = mix(large, small, min(nz*2.0+0.05, 1.0));
c = mix(c, xsmall, max(min((nz-0.3)/0.7, 1.0), 0.0));
vec3 color = mix(backgroundColor, vec3(1.0), c*0.5);
gl_FragColor = vec4(color, 1.0);
}

View File

@ -0,0 +1,21 @@
<script lang="ts">
import type { Hst } from "@histoire/plugin-svelte";
export let Hst: Hst;
import Background from "./Background.svelte";
import { Canvas } from "@threlte/core";
import Camera from "../Camera.svelte";
let width = globalThis.innerWidth || 100;
let height = globalThis.innerHeight || 100;
let cameraPosition: [number, number, number] = [0, 1, 0];
</script>
<svelte:window bind:innerWidth={width} bind:innerHeight={height} />
<Hst.Story>
<Canvas shadows={false}>
<Camera bind:position={cameraPosition} />
<Background {cameraPosition} {width} {height} />
</Canvas>
</Hst.Story>

View File

@ -0,0 +1,52 @@
<script lang="ts">
import { T } from '@threlte/core';
import BackgroundVert from './Background.vert';
import BackgroundFrag from './Background.frag';
import { colors } from '../graph/stores.js';
export let minZoom = 4;
export let maxZoom = 150;
export let cameraPosition: [number, number, number] = [0, 1, 0];
export let width = globalThis?.innerWidth || 100;
export let height = globalThis?.innerHeight || 100;
let bw = 2;
let bh = 2;
$: if (width && height) {
bw = width / cameraPosition[2];
bh = height / cameraPosition[2];
}
</script>
<T.Group position.x={cameraPosition[0]} position.z={cameraPosition[1]} position.y={-1.0}>
<T.Mesh rotation.x={-Math.PI / 2} position.y={0.2} scale.x={bw} scale.y={bh}>
<T.PlaneGeometry args={[1, 1]} />
<T.ShaderMaterial
transparent
vertexShader={BackgroundVert}
fragmentShader={BackgroundFrag}
uniforms={{
camPos: {
value: [0, 1, 0]
},
backgroundColor: {
value: [0, 0, 0]
},
zoomLimits: {
value: [2, 50]
},
dimensions: {
value: [100, 100]
}
}}
uniforms.camPos.value={cameraPosition}
uniforms.backgroundColor.value={$colors.backgroundColorDarker}
uniforms.zoomLimits.value={[minZoom, maxZoom]}
uniforms.dimensions.value={[width, height]}
/>
</T.Mesh>
</T.Group>

View File

@ -0,0 +1,15 @@
varying vec2 vUv;
varying vec3 vPosition;
void main() {
vUv = uv;
vec4 modelPosition = modelMatrix * vec4(position, 1.0);
vec4 viewPosition = viewMatrix * modelPosition;
vec4 projectedPosition = projectionMatrix * viewPosition;
gl_Position = projectedPosition;
}

View File

@ -0,0 +1,19 @@
<script lang="ts">
import { MeshLineGeometry, MeshLineMaterial } from '@threlte/extras';
import { points, lines } from './store.js';
import { T } from '@threlte/core';
</script>
{#each $points as point}
<T.Mesh position.x={point.x} position.y={point.y} position.z={point.z} rotation.x={-Math.PI / 2}>
<T.CircleGeometry args={[0.2, 32]} />
<T.MeshBasicMaterial color="red" />
</T.Mesh>
{/each}
{#each $lines as line}
<T.Mesh>
<MeshLineGeometry points={line} />
<MeshLineMaterial color="red" linewidth={1} attenuate={false} />
</T.Mesh>
{/each}

View File

@ -0,0 +1,25 @@
import { Vector3 } from "three/src/math/Vector3.js";
import { lines, points } from "./store";
export function debugPosition(x: number, y: number) {
points.update((p) => {
p.push(new Vector3(x, 1, y));
return p;
});
}
export function clear() {
points.set([]);
lines.set([]);
}
export function debugLine(line: Vector3[]) {
lines.update((l) => {
l.push(line);
return l;
});
}
import Component from "./Debug.svelte";
export default Component

View File

@ -0,0 +1,6 @@
import { writable } from "svelte/store";
import { Vector3 } from "three/src/math/Vector3.js";
export const points = writable<Vector3[]>([]);
export const lines = writable<Vector3[][]>([]);

View File

@ -0,0 +1,112 @@
<script context="module" lang="ts">
const color = new Color(0x202020);
color.convertLinearToSRGB();
const color2 = color.clone();
color2.convertSRGBToLinear();
const circleMaterial = new MeshBasicMaterial({
color,
toneMapped: false,
});
const lineCache = new Map<number, BufferGeometry>();
const curve = new CubicBezierCurve(
new Vector2(0, 0),
new Vector2(0, 0),
new Vector2(0, 0),
new Vector2(0, 0),
);
</script>
<script lang="ts">
import { T } from "@threlte/core";
import { MeshLineMaterial } from "@threlte/extras";
import { BufferGeometry, MeshBasicMaterial, Vector3 } from "three";
import { Color } from "three/src/math/Color.js";
import { CubicBezierCurve } from "three/src/extras/curves/CubicBezierCurve.js";
import { Vector2 } from "three/src/math/Vector2.js";
import { createEdgeGeometry } from "./createEdgeGeometry";
export let from: { x: number; y: number };
export let to: { x: number; y: number };
let samples = 5;
let geometry: BufferGeometry;
let lastId: number | null = null;
const primeA = 31;
const primeB = 37;
export const update = function () {
const new_x = to.x - from.x;
const new_y = to.y - from.y;
const curveId = new_x * primeA + new_y * primeB;
if (lastId === curveId) {
return;
}
const mid = new Vector2(new_x / 2, new_y / 2);
if (lineCache.has(curveId)) {
geometry = lineCache.get(curveId)!;
return;
}
const length = Math.floor(
Math.sqrt(Math.pow(new_x, 2) + Math.pow(new_y, 2)) / 4,
);
samples = Math.min(Math.max(10, length), 60);
curve.v0.set(0, 0);
curve.v1.set(mid.x, 0);
curve.v2.set(mid.x, new_y);
curve.v3.set(new_x, new_y);
const points = curve
.getPoints(samples)
.map((p) => new Vector3(p.x, 0, p.y))
.flat();
geometry = createEdgeGeometry(points);
lineCache.set(curveId, geometry);
};
$: if (from || to) {
update();
}
</script>
<T.Mesh
position.x={from.x}
position.z={from.y}
position.y={0.8}
rotation.x={-Math.PI / 2}
material={circleMaterial}
>
<T.CircleGeometry args={[0.3, 16]} />
</T.Mesh>
<T.Mesh
position.x={to.x}
position.z={to.y}
position.y={0.8}
rotation.x={-Math.PI / 2}
material={circleMaterial}
>
<T.CircleGeometry args={[0.3, 16]} />
</T.Mesh>
{#if geometry}
<T.Mesh position.x={from.x} position.z={from.y} position.y={0.1} {geometry}>
<MeshLineMaterial
width={4}
attenuate={false}
color={color2}
toneMapped={false}
/>
</T.Mesh>
{/if}

View File

@ -0,0 +1,8 @@
<script lang="ts">
import Edge from "./Edge.svelte";
export let from: { x: number; y: number };
export let to: { x: number; y: number };
</script>
<Edge {from} {to} />

View File

@ -0,0 +1,100 @@
import { BufferGeometry, Vector3, BufferAttribute } from 'three'
import { setXY, setXYZ, setXYZW, setXYZXYZ } from './utils.js'
export function createEdgeGeometry(points: Vector3[]) {
let shape = 'none'
let shapeFunction = (p: number) => 1
// When the component first runs we create the buffer geometry and allocate the buffer attributes
let pointCount = points.length
let counters: number[] = []
let counterIndex = 0
let side: number[] = []
let widthArray: number[] = []
let doubleIndex = 0
let uvArray: number[] = []
let uvIndex = 0
let indices: number[] = []
let indicesIndex = 0
if (shape === 'taper') {
shapeFunction = (p: number) => 1 * Math.pow(4 * p * (1 - p), 1)
}
for (let j = 0; j < pointCount; j++) {
const c = j / points.length
counters[counterIndex + 0] = c
counters[counterIndex + 1] = c
counterIndex += 2
setXY(side, doubleIndex, 1, -1)
let width = shape === 'none' ? 1 : shapeFunction(j / (pointCount - 1))
setXY(widthArray, doubleIndex, width, width)
doubleIndex += 2
setXYZW(uvArray, uvIndex, j / (pointCount - 1), 0, j / (pointCount - 1), 1)
uvIndex += 4
if (j < pointCount - 1) {
const n = j * 2
setXYZ(indices, indicesIndex, n + 0, n + 1, n + 2)
setXYZ(indices, indicesIndex + 3, n + 2, n + 1, n + 3)
indicesIndex += 6
}
}
const geometry = new BufferGeometry()
// create these buffer attributes at the correct length but leave them empty for now
geometry.setAttribute('position', new BufferAttribute(new Float32Array(pointCount * 6), 3))
geometry.setAttribute('previous', new BufferAttribute(new Float32Array(pointCount * 6), 3))
geometry.setAttribute('next', new BufferAttribute(new Float32Array(pointCount * 6), 3))
// create and populate these buffer attributes
geometry.setAttribute('counters', new BufferAttribute(new Float32Array(counters), 1))
geometry.setAttribute('side', new BufferAttribute(new Float32Array(side), 1))
geometry.setAttribute('width', new BufferAttribute(new Float32Array(widthArray), 1))
geometry.setAttribute('uv', new BufferAttribute(new Float32Array(uvArray), 2))
geometry.setIndex(new BufferAttribute(new Uint16Array(indices), 1))
let positions: number[] = []
let previous: number[] = []
let next: number[] = []
let positionIndex = 0
let previousIndex = 0
let nextIndex = 0
setXYZXYZ(previous, previousIndex, points[0].x, points[0].y, points[0].z)
previousIndex += 6
for (let j = 0; j < pointCount; j++) {
const p = points[j]
setXYZXYZ(positions, positionIndex, p.x, p.y, p.z)
positionIndex += 6
if (j < pointCount - 1) {
setXYZXYZ(previous, previousIndex, p.x, p.y, p.z)
previousIndex += 6
}
if (j > 0 && j + 1 <= pointCount) {
setXYZXYZ(next, nextIndex, p.x, p.y, p.z)
nextIndex += 6
}
}
setXYZXYZ(
next,
nextIndex,
points[pointCount - 1].x,
points[pointCount - 1].y,
points[pointCount - 1].z
)
const positionAttribute = (geometry.getAttribute('position') as BufferAttribute).set(positions)
const previousAttribute = (geometry.getAttribute('previous') as BufferAttribute).set(previous)
const nextAttribute = (geometry.getAttribute('next') as BufferAttribute).set(next)
positionAttribute.needsUpdate = true
previousAttribute.needsUpdate = true
nextAttribute.needsUpdate = true
geometry.computeBoundingSphere()
return geometry;
}

View File

@ -0,0 +1,34 @@
export const setXYZXYZ = (array: number[], location: number, x: number, y: number, z: number) => {
array[location + 0] = x
array[location + 1] = y
array[location + 2] = z
array[location + 3] = x
array[location + 4] = y
array[location + 5] = z
}
export const setXY = (array: number[], location: number, x: number, y: number) => {
array[location + 0] = x
array[location + 1] = y
}
export const setXYZ = (array: number[], location: number, x: number, y: number, z: number) => {
array[location + 0] = x
array[location + 1] = y
array[location + 2] = z
}
export const setXYZW = (
array: number[],
location: number,
x: number,
y: number,
z: number,
w: number
) => {
array[location + 0] = x
array[location + 1] = y
array[location + 2] = z
array[location + 3] = w
}

View File

@ -0,0 +1,526 @@
import { writable, type Writable } from "svelte/store";
import type { Graph, Node, Edge, Socket, NodeRegistry, } from "@nodes/types";
import { HistoryManager } from "./history-manager.js"
import EventEmitter from "./helpers/EventEmitter.js";
import throttle from "./helpers/throttle.js";
import { createLogger } from "./helpers/index.js";
const logger = createLogger("graph-manager");
export class GraphManager extends EventEmitter<{ "save": Graph, "result": any }> {
status: Writable<"loading" | "idle" | "error"> = writable("loading");
loaded = false;
graph: Graph = { id: 0, nodes: [], edges: [] };
id = writable(0);
private _nodes: Map<number, Node> = new Map();
nodes: Writable<Map<number, Node>> = writable(new Map());
private _edges: Edge[] = [];
edges: Writable<Edge[]> = writable([]);
currentUndoGroup: number | null = null;
inputSockets: Writable<Set<string>> = writable(new Set());
history: HistoryManager = new HistoryManager();
constructor(private nodeRegistry: NodeRegistry) {
super();
this.nodes.subscribe((nodes) => {
this._nodes = nodes;
});
this.edges.subscribe((edges) => {
this._edges = edges;
const s = new Set<string>();
for (const edge of edges) {
s.add(`${edge[2].id}-${edge[3]}`);
}
this.inputSockets.set(s);
});
this.execute = throttle(() => this._execute(), 50);
}
serialize(): Graph {
logger.log("serializing graph")
const nodes = Array.from(this._nodes.values()).map(node => ({
id: node.id,
position: [...node.position],
type: node.type,
props: node.props,
})) as Node[];
const edges = this._edges.map(edge => [edge[0].id, edge[1], edge[2].id, edge[3]]) as Graph["edges"];
return { id: this.graph.id, nodes, edges };
}
execute() { }
_execute() {
if (this.loaded === false) return;
this.emit("result", this.serialize());
}
getNodeTypes() {
return this.nodeRegistry.getAllNodes();
}
getLinkedNodes(node: Node) {
const nodes = new Set<Node>();
const stack = [node];
while (stack.length) {
const n = stack.pop();
if (!n) continue;
nodes.add(n);
const children = this.getChildrenOfNode(n);
const parents = this.getParentsOfNode(n);
const newNodes = [...children, ...parents].filter(n => !nodes.has(n));
stack.push(...newNodes);
}
return [...nodes.values()];
}
getEdgesBetweenNodes(nodes: Node[]): [number, number, number, string][] {
const edges = [];
for (const node of nodes) {
const children = node.tmp?.children || [];
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);
if (edge) {
edges.push([edge[0].id, edge[1], edge[2].id, edge[3]] as [number, number, number, string]);
}
}
}
}
return edges;
}
private _init(graph: Graph) {
const nodes = new Map(graph.nodes.map(node => {
const nodeType = this.nodeRegistry.getNode(node.type);
if (nodeType) {
node.tmp = {
type: nodeType
};
}
return [node.id, node]
}));
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");
};
from.tmp = from.tmp || {};
from.tmp.children = from.tmp.children || [];
from.tmp.children.push(to);
to.tmp = to.tmp || {};
to.tmp.parents = to.tmp.parents || [];
to.tmp.parents.push(from);
return [from, edge[1], to, edge[3]] as Edge;
})
this.edges.set(edges);
this.nodes.set(nodes);
this.execute();
}
async load(graph: Graph) {
const a = performance.now();
this.loaded = false;
this.graph = graph;
this.status.set("loading");
this.id.set(graph.id);
const nodeIds = Array.from(new Set([...graph.nodes.map(n => n.type)]));
await this.nodeRegistry.load(nodeIds);
for (const node of this.graph.nodes) {
const nodeType = this.nodeRegistry.getNode(node.type);
if (!nodeType) {
logger.error(`Node type not found: ${node.type}`);
this.status.set("error");
return;
}
node.tmp = node.tmp || {};
node.tmp.type = nodeType;
}
this.history.reset();
this._init(this.graph);
this.save();
this.status.set("idle");
this.loaded = true;
logger.log(`Graph loaded in ${performance.now() - a}ms`);
setTimeout(() => this.execute(), 100);
}
getAllNodes() {
return Array.from(this._nodes.values());
}
getNode(id: number) {
return this._nodes.get(id);
}
getNodeType(id: string) {
return this.nodeRegistry.getNode(id);
}
getChildrenOfNode(node: Node) {
const children = [];
const stack = node.tmp?.children?.slice(0);
while (stack?.length) {
const child = stack.pop();
if (!child) continue;
children.push(child);
stack.push(...child.tmp?.children || []);
}
return children;
}
getNodesBetween(from: Node, to: Node): Node[] | undefined {
// < - - - - from
const toParents = this.getParentsOfNode(to);
// < - - - - from - - - - to
const fromParents = this.getParentsOfNode(from);
if (toParents.includes(from)) {
const fromChildren = this.getChildrenOfNode(from);
return toParents.filter(n => fromChildren.includes(n));
} else if (fromParents.includes(to)) {
const toChildren = this.getChildrenOfNode(to);
return fromParents.filter(n => toChildren.includes(n));
} else {
// these two nodes are not connected
return;
}
}
removeNode(node: Node, { restoreEdges = false } = {}) {
const edgesToNode = this._edges.filter((edge) => edge[2].id === node.id);
const edgesFromNode = this._edges.filter((edge) => edge[0].id === node.id);
for (const edge of [...edgesToNode, ...edgesFromNode]) {
this.removeEdge(edge, { applyDeletion: false });
}
if (restoreEdges) {
const outputSockets = edgesToNode.map(e => [e[0], e[1]] as const);
const inputSockets = edgesFromNode.map(e => [e[2], e[3]] as const);
for (const [to, toSocket] of inputSockets) {
for (const [from, fromSocket] of outputSockets) {
const outputType = from.tmp?.type?.outputs?.[fromSocket];
const inputType = to?.tmp?.type?.inputs?.[toSocket]?.type;
if (outputType === inputType) {
this.createEdge(from, fromSocket, to, toSocket, { applyUpdate: false });
continue;
}
}
}
}
this.edges.set(this._edges);
this.nodes.update((nodes) => {
nodes.delete(node.id);
return nodes;
});
this.execute()
this.save();
}
createNodeId() {
const max = Math.max(...this._nodes.keys());
return max + 1;
}
createGraph(nodes: Node[], edges: [number, number, number, string][]) {
// map old ids to new ids
const idMap = new Map<number, number>();
const startId = this.createNodeId();
nodes = nodes.map((node, i) => {
const id = startId + i;
idMap.set(node.id, id);
const type = this.nodeRegistry.getNode(node.type);
if (!type) {
throw new Error(`Node type not found: ${node.type}`);
}
return { ...node, id, tmp: { type } };
});
const _edges = edges.map(edge => {
const from = nodes.find(n => n.id === idMap.get(edge[0]));
const to = nodes.find(n => n.id === idMap.get(edge[2]));
if (!from || !to) {
throw new Error("Edge references non-existing node");
}
to.tmp = to.tmp || {};
to.tmp.parents = to.tmp.parents || [];
to.tmp.parents.push(from);
from.tmp = from.tmp || {};
from.tmp.children = from.tmp.children || [];
from.tmp.children.push(to);
return [from, edge[1], to, edge[3]] as Edge;
});
for (const node of nodes) {
this._nodes.set(node.id, node);
}
this._edges.push(..._edges);
this.nodes.set(this._nodes);
this.edges.set(this._edges);
this.save();
return nodes;
}
createNode({ type, position, props = {} }: { type: Node["type"], position: Node["position"], props: Node["props"] }) {
const nodeType = this.nodeRegistry.getNode(type);
if (!nodeType) {
logger.error(`Node type not found: ${type}`);
return;
}
const node: Node = { id: this.createNodeId(), type, position, tmp: { type: nodeType }, props };
this.nodes.update((nodes) => {
nodes.set(node.id, node);
return nodes;
});
this.save();
}
createEdge(from: Node, fromSocket: number, to: Node, toSocket: string, { applyUpdate = true } = {}) {
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);
if (existingEdge) {
logger.error("Edge already exists", existingEdge);
return;
};
// check if socket types match
const fromSocketType = from.tmp?.type?.outputs?.[fromSocket];
const toSocketType = to.tmp?.type?.inputs?.[toSocket]?.type;
if (fromSocketType !== toSocketType) {
logger.error(`Socket types do not match: ${fromSocketType} !== ${toSocketType}`);
return;
}
const edgeToBeReplaced = this._edges.find(e => e[2].id === to.id && e[3] === toSocket);
if (edgeToBeReplaced) {
this.removeEdge(edgeToBeReplaced, { applyDeletion: applyUpdate });
}
if (applyUpdate) {
this.edges.update((edges) => {
return [...edges, [from, fromSocket, to, toSocket]];
});
} else {
this._edges.push([from, fromSocket, to, toSocket]);
}
from.tmp = from.tmp || {};
from.tmp.children = from.tmp.children || [];
from.tmp.children.push(to);
to.tmp = to.tmp || {};
to.tmp.parents = to.tmp.parents || [];
to.tmp.parents.push(from);
this.execute();
if (applyUpdate) {
this.save();
}
}
undo() {
const nextState = this.history.undo();
if (nextState) {
this._init(nextState);
this.emit("save", this.serialize());
}
}
redo() {
const nextState = this.history.redo();
if (nextState) {
this._init(nextState);
this.emit("save", this.serialize());
}
}
startUndoGroup() {
this.currentUndoGroup = 1;
}
saveUndoGroup() {
this.currentUndoGroup = null;
this.save();
}
save() {
if (this.currentUndoGroup) return;
const state = this.serialize();
this.history.save(state);
this.emit("save", state);
logger.log("saving graph");
}
getParentsOfNode(node: Node) {
const parents = [];
const stack = node.tmp?.parents?.slice(0);
while (stack?.length) {
if (parents.length > 1000000) {
logger.warn("Infinite loop detected")
break;
}
const parent = stack.pop();
if (!parent) continue;
parents.push(parent);
stack.push(...parent.tmp?.parents || []);
}
return parents.reverse();
}
getPossibleSockets({ node, index }: Socket): [Node, string | number][] {
const nodeType = node?.tmp?.type;
if (!nodeType) return [];
const sockets: [Node, string | number][] = []
// if index is a string, we are an input looking for outputs
if (typeof index === "string") {
// filter out self and child nodes
const children = new Set(this.getChildrenOfNode(node).map(n => n.id));
const nodes = this.getAllNodes().filter(n => n.id !== node.id && !children.has(n.id));
const ownType = nodeType?.inputs?.[index].type;
for (const node of nodes) {
const nodeType = node?.tmp?.type;
const inputs = nodeType?.outputs;
if (!inputs) continue;
for (let index = 0; index < inputs.length; index++) {
if (inputs[index] === ownType) {
sockets.push([node, index]);
}
}
}
} 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));
// get edges from this socket
const edges = new Map(this.getEdgesFromNode(node).filter(e => e[1] === index).map(e => [e[2].id, e[3]]));
const ownType = nodeType.outputs?.[index];
for (const node of nodes) {
const inputs = node?.tmp?.type?.inputs;
if (!inputs) continue;
for (const key in inputs) {
if (inputs[key].type === ownType && edges.get(node.id) !== key) {
sockets.push([node, key]);
}
}
}
}
return sockets;
}
removeEdge(edge: Edge, { applyDeletion = true }: { applyDeletion?: boolean } = {}) {
const id0 = edge[0].id;
const sid0 = edge[1];
const id2 = edge[2].id;
const sid2 = edge[3];
const _edge = this._edges.find((e) => e[0].id === id0 && e[1] === sid0 && e[2].id === id2 && e[3] === sid2);
if (!_edge) return;
edge[0].tmp = edge[0].tmp || {};
if (edge[0].tmp.children) {
edge[0].tmp.children = edge[0].tmp.children.filter(n => n.id !== id2);
}
edge[2].tmp = edge[2].tmp || {};
if (edge[2].tmp.parents) {
edge[2].tmp.parents = edge[2].tmp.parents.filter(n => n.id !== id0);
}
if (applyDeletion) {
this.edges.update((edges) => {
return edges.filter(e => e !== _edge);
});
this.execute();
this.save();
} else {
this._edges = this._edges.filter(e => e !== _edge);
}
}
getEdgesToNode(node: Node) {
return this._edges
.filter((edge) => edge[2].id === node.id)
.map((edge) => {
const from = this.getNode(edge[0].id);
const to = this.getNode(edge[2].id);
if (!from || !to) return;
return [from, edge[1], to, edge[3]] as const;
})
.filter(Boolean) as unknown as [Node, number, Node, string][];
}
getEdgesFromNode(node: Node) {
return this._edges
.filter((edge) => edge[0].id === node.id)
.map((edge) => {
const from = this.getNode(edge[0].id);
const to = this.getNode(edge[2].id);
if (!from || !to) return;
return [from, edge[1], to, edge[3]] as const;
})
.filter(Boolean) as unknown as [Node, number, Node, string][];
}
}

View File

@ -0,0 +1,765 @@
<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;
$: if (cameraPosition && loaded) {
localStorage.setItem('cameraPosition', JSON.stringify(cameraPosition));
}
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: number, y: number, z: number) {
if (!camera) return;
camera.position.x = x;
camera.position.z = y;
camera.zoom = z;
cameraPosition = [x, y, z];
}
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').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) {
$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]);
}
}
loaded = true;
});
</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>

View 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>

View 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} />

View File

@ -0,0 +1,6 @@
import type { GraphManager } from "../graph-manager.js";
import { getContext } from "svelte";
export function getGraphManager(): GraphManager {
return getContext("graphManager");
}

View 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();
})
}

View File

@ -0,0 +1,66 @@
import throttle from './throttle.js';
type EventMap = Record<string, unknown>;
type EventKey<T extends EventMap> = string & keyof T;
type EventReceiver<T> = (params: T, stuff?: Record<string, unknown>) => unknown;
export default class EventEmitter<T extends EventMap = { [key: string]: unknown }> {
index = 0;
public eventMap: T = {} as T;
constructor() {
}
private cbs: { [key: string]: ((data?: unknown) => unknown)[] } = {};
private cbsOnce: { [key: string]: ((data?: unknown) => unknown)[] } = {};
/**
* Emit an event with optional data to all the listeners
* @param {string} event Name of the event to emit
* @param data Data to send along
*/
public emit(event: string, data?: unknown) {
if (event in this.cbs) {
this.cbs[event].forEach((c) => c(data));
}
if (event in this.cbsOnce) {
this.cbsOnce[event].forEach((c) => c(data));
delete this.cbsOnce[event];
}
}
public on<K extends EventKey<T>>(event: K, cb: EventReceiver<T[K]>, throttleTimer = 0) {
if (throttleTimer > 0) cb = throttle(cb, throttleTimer);
const cbs = Object.assign(this.cbs, {
[event]: [...(this.cbs[event] || []), cb],
});
this.cbs = cbs;
// console.log('New EventEmitter ', this.constructor.name);
return () => {
cbs[event]?.splice(cbs[event].indexOf(cb), 1);
};
}
/**
* Register a special listener which only gets called once
* @param {string} event Name of the event to listen to
* @param {function} cb Listener, gets called everytime the event is emitted
* @returns {function} Returns a function which removes the listener when called
*/
public once<K extends EventKey<T>>(event: K, cb: EventReceiver<T[K]>): () => void {
this.cbsOnce[event] = [...(this.cbsOnce[event] || []), cb];
return () => {
this.cbsOnce[event].splice(this.cbsOnce[event].indexOf(cb), 1);
};
}
public destroyEventEmitter() {
Object.keys(this.cbs).forEach((key) => {
delete this.cbs[key];
});
Object.keys(this.cbsOnce).forEach((key) => delete this.cbsOnce[key]);
this.cbs = {};
this.cbsOnce = {};
}
}

View File

@ -0,0 +1,107 @@
export function snapToGrid(value: number, gridSize: number = 10) {
return Math.round(value / gridSize) * gridSize;
}
export function lerp(a: number, b: number, t: number) {
return a + (b - a) * t;
}
export function animate(duration: number, callback: (progress: number) => void | false) {
const start = performance.now();
const loop = (time: number) => {
const progress = (time - start) / duration;
if (progress < 1) {
const res = callback(progress);
if (res !== false) {
requestAnimationFrame(loop);
}
} else {
callback(1);
}
}
requestAnimationFrame(loop);
}
export function createNodePath({
depth = 8,
height = 20,
y = 50,
cornerTop = 0,
cornerBottom = 0,
leftBump = false,
rightBump = false,
aspectRatio = 1,
} = {}) {
return `M0,${cornerTop}
${cornerTop
? ` V${cornerTop}
Q0,0 ${cornerTop * aspectRatio},0
H${100 - cornerTop * aspectRatio}
Q100,0 100,${cornerTop}
`
: ` 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}
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, " ");
}
export const debounce = (fn: Function, ms = 300) => {
let timeoutId: ReturnType<typeof setTimeout>;
return function (this: any, ...args: any[]) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn.apply(this, args), ms);
};
};
export const clone: <T>(v: T) => T = "structedClone" in globalThis ? globalThis.structuredClone : (obj) => JSON.parse(JSON.stringify(obj));
export const createLogger = (() => {
let maxLength = 5;
return (scope: string) => {
maxLength = Math.max(maxLength, scope.length);
let muted = false;
return {
log: (...args: any[]) => !muted && console.log(`[%c${scope.padEnd(maxLength, " ")}]:`, "color: #888", ...args),
info: (...args: any[]) => !muted && console.info(`[%c${scope.padEnd(maxLength, " ")}]:`, "color: #888", ...args),
warn: (...args: any[]) => !muted && console.warn(`[%c${scope.padEnd(maxLength, " ")}]:`, "color: #888", ...args),
error: (...args: any[]) => console.error(`[%c${scope.padEnd(maxLength, " ")}]:`, "color: #f88", ...args),
mute() {
muted = true;
},
unmute() {
muted = false;
}
}
}
})();
export function withSubComponents<A, B extends Record<string, any>>(
component: A,
subcomponents: B
): A & B {
Object.keys(subcomponents).forEach((key) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(component as any)[key] = (subcomponents as any)[key];
});
return component as A & B;
}

View File

@ -0,0 +1,53 @@
import { writable, type Writable } from "svelte/store";
function isStore(v: unknown): v is Writable<unknown> {
return v !== null && typeof v === "object" && "subscribe" in v && "set" in v;
}
const storeIds: Map<string, ReturnType<typeof createLocalStore>> = new Map();
const HAS_LOCALSTORAGE = "localStorage" in globalThis;
function createLocalStore<T>(key: string, initialValue: T | Writable<T>) {
let store: Writable<T>;
if (HAS_LOCALSTORAGE) {
const localValue = localStorage.getItem(key);
const value = localValue ? JSON.parse(localValue) : null;
if (value === null) {
if (isStore(initialValue)) {
store = initialValue;
} else {
store = writable(initialValue);
}
} else {
store = writable(value);
}
} else {
return isStore(initialValue) ? initialValue : writable(initialValue);
}
store.subscribe((value) => {
localStorage.setItem(key, JSON.stringify(value));
});
return {
subscribe: store.subscribe,
set: store.set,
update: store.update
}
}
export default function localStore<T>(key: string, initialValue: T | Writable<T>): Writable<T> {
if (storeIds.has(key)) return storeIds.get(key) as Writable<T>;
const store = createLocalStore(key, initialValue)
storeIds.set(key, store);
return store
}

View File

@ -0,0 +1,20 @@
export default <R, A extends any[]>(
fn: (...args: A) => R,
delay: number
): ((...args: A) => R) => {
let wait = false;
return (...args: A) => {
if (wait) return undefined;
const val = fn(...args);
wait = true;
setTimeout(() => {
wait = false;
}, delay);
return val;
}
};

View File

@ -0,0 +1,101 @@
import { create, type Delta } from "jsondiffpatch";
import type { Graph } from "@nodes/types";
import { createLogger, clone } from "./helpers/index.js";
const diff = create({
objectHash: function (obj, index) {
if (obj === null) return obj;
if ("id" in obj) return obj.id;
if (Array.isArray(obj)) {
return obj.join("-")
}
return obj?.id || obj._id || '$$index:' + index;
}
})
const log = createLogger("history")
export class HistoryManager {
index: number = -1;
history: Delta[] = [];
private initialState: Graph | undefined;
private state: Graph | undefined;
private opts = {
debounce: 400,
maxHistory: 100,
}
constructor({ maxHistory = 100, debounce = 100 } = {}) {
this.history = [];
this.index = -1;
this.opts.debounce = debounce;
this.opts.maxHistory = maxHistory;
globalThis["_history"] = this;
}
save(state: Graph) {
if (!this.state) {
this.state = clone(state);
this.initialState = this.state;
log.log("initial state saved")
} else {
const newState = state;
const delta = diff.diff(this.state, newState);
if (delta) {
log.log("saving state")
// Add the delta to history
if (this.index < this.history.length - 1) {
// Clear the history after the current index if new changes are made
this.history.splice(this.index + 1);
}
this.history.push(delta);
this.index++;
// Limit the size of the history
if (this.history.length > this.opts.maxHistory) {
this.history.shift();
}
this.state = newState;
} else {
log.log("no changes")
}
}
}
reset() {
this.history = [];
this.index = -1;
this.state = undefined;
this.initialState = undefined;
}
undo() {
if (this.index === -1 && this.initialState) {
log.log("reached start, loading initial state")
return clone(this.initialState);
} else {
const delta = this.history[this.index];
const prevState = diff.unpatch(this.state, delta) as Graph;
this.state = prevState;
this.index = Math.max(-1, this.index - 1);
return clone(prevState);
}
}
redo() {
if (this.index <= this.history.length - 1) {
const nextIndex = Math.min(this.history.length - 1, this.index + 1);
const delta = this.history[nextIndex];
const nextState = diff.patch(this.state, delta) as Graph;
this.index = nextIndex;
this.state = nextState;
return clone(nextState);
} else {
log.log("reached end")
}
}
}

View File

@ -0,0 +1,2 @@
import Wrapper from './graph/Wrapper.svelte';
export default Wrapper;

View File

@ -0,0 +1,67 @@
varying vec2 vUv;
uniform float uWidth;
uniform float uHeight;
uniform vec3 uColorDark;
uniform vec3 uColorBright;
uniform vec3 uSelectedColor;
uniform vec3 uActiveColor;
uniform bool uSelected;
uniform bool uActive;
uniform float uStrokeWidth;
float msign(in float x) { return (x < 0.0) ? -1.0 : 1.0; }
vec4 roundedBoxSDF( in vec2 p, in vec2 b, in float r, in float s) {
vec2 q = abs(p) - b + r;
float l = b.x + b.y + 1.570796 * r;
float k1 = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
float k2 = ((q.x > 0.0) ? atan(q.y, q.x) : 1.570796);
float k3 = 3.0 + 2.0 * msign(min(p.x, -p.y)) - msign(p.x);
float k4 = msign(p.x * p.y);
float k5 = r * k2 + max(-q.x, 0.0);
float ra = s * round(k1 / s);
float l2 = l + 1.570796 * ra;
return vec4(k1 - ra, k3 * l2 + k4 * (b.y + ((q.y > 0.0) ? k5 + k2 * ra : q.y)), 4.0 * l2, k1);
}
void main(){
float y = (1.0-vUv.y) * uHeight;
float x = vUv.x * uWidth;
vec2 size = vec2(uWidth, uHeight);
vec2 uv = (vUv - 0.5) * 2.0;
float u_border_radius = 0.4;
vec4 distance = roundedBoxSDF(uv * size, size, u_border_radius*2.0, 0.0);
if (distance.w > 0.0 ) {
// outside
gl_FragColor = vec4(0.0,0.0,0.0, 0.0);
}else{
if (distance.w > -uStrokeWidth || mod(y+5.0, 10.0) < uStrokeWidth/2.0) {
// draw the outer stroke
if (uSelected) {
gl_FragColor = vec4(uSelectedColor, 1.0);
} else if (uActive) {
gl_FragColor = vec4(uActiveColor, 1.0);
} else {
gl_FragColor = vec4(uColorBright, 1.0);
}
}else if (y<5.0){
// draw the header
gl_FragColor = vec4(uColorBright, 1.0);
}else{
gl_FragColor = vec4(uColorDark, 1.0);
}
}
}

View File

@ -0,0 +1,127 @@
<script lang="ts">
import type { Node } from '@nodes/types';
import { getContext, onMount } from 'svelte';
import NodeHeader from './NodeHeader.svelte';
import NodeParameter from './NodeParameter.svelte';
import { activeNodeId, selectedNodes } from '../graph/stores.js';
import { T } from '@threlte/core';
import { Color, type Mesh } from 'three';
import NodeFrag from './Node.frag';
import NodeVert from './Node.vert';
export let node: Node;
export let inView = true;
export let z = 2;
$: isActive = $activeNodeId === node.id;
$: isSelected = !!$selectedNodes?.has(node.id);
const updateNodePosition = getContext<(n: Node) => void>('updateNodePosition');
const getNodeHeight = getContext<(n: string) => number>('getNodeHeight');
const type = node?.tmp?.type;
const parameters = Object.entries(type?.inputs || {}).filter((p) => p[1].type !== 'seed');
let ref: HTMLDivElement;
let meshRef: Mesh;
const height = getNodeHeight(node.type);
$: if (node && ref && meshRef) {
node.tmp = node.tmp || {};
node.tmp.ref = ref;
node.tmp.mesh = meshRef;
updateNodePosition(node);
}
onMount(() => {
node.tmp = node.tmp || {};
node.tmp.ref = ref;
node.tmp.mesh = meshRef;
updateNodePosition(node);
});
const colorDark = new Color();
colorDark.setStyle('#151515');
//colorDark.();
const colorBright = new Color('#202020');
//colorBright.convertLinearToSRGB();
</script>
<T.Mesh
position.x={node.position[0] + 10}
position.z={node.position[1] + height / 2}
position.y={0.8}
rotation.x={-Math.PI / 2}
bind:ref={meshRef}
visible={z < 7}
>
<T.PlaneGeometry args={[20, height]} radius={1} />
<T.ShaderMaterial
vertexShader={NodeVert}
fragmentShader={NodeFrag}
transparent
uniforms={{
uColorBright: { value: colorBright },
uColorDark: { value: colorDark },
uSelectedColor: { value: new Color('#9d5f28') },
uActiveColor: { value: new Color('white') },
uSelected: { value: false },
uActive: { value: false },
uStrokeWidth: { value: 1.0 },
uWidth: { value: 20 },
uHeight: { value: height }
}}
uniforms.uSelected.value={isSelected}
uniforms.uActive.value={isActive}
uniforms.uStrokeWidth.value={(7 - z) / 3}
/>
</T.Mesh>
<div
class="node"
class:active={isActive}
class:selected={isSelected}
class:out-of-view={!inView}
data-node-id={node.id}
bind:this={ref}
>
<NodeHeader {node} />
{#each parameters as [key, value], i}
<NodeParameter bind:node id={key} input={value} isLast={i == parameters.length - 1} />
{/each}
</div>
<style>
.node {
position: absolute;
box-sizing: border-box;
user-select: none !important;
cursor: pointer;
width: 200px;
color: var(--text-color);
transform: translate3d(var(--nx), var(--ny), 0);
z-index: 1;
font-weight: 300;
--stroke: var(--background-color-lighter);
--stroke-width: 2px;
}
.node.active {
--stroke: white;
--stroke-width: 1px;
}
.node.selected {
--stroke: #9d5f28;
--stroke-width: 1px;
}
.node.out-of-view {
display: none;
}
</style>

View File

@ -0,0 +1,15 @@
varying vec2 vUv;
varying vec3 vPosition;
void main() {
vUv = uv;
vec4 modelPosition = modelMatrix * vec4(position, 1.0);
vec4 viewPosition = viewMatrix * modelPosition;
vec4 projectedPosition = projectionMatrix * viewPosition;
gl_Position = projectedPosition;
}

View File

@ -0,0 +1,129 @@
<script lang="ts">
import { createNodePath } from '../helpers/index.js';
import type { Node, Socket } from '@nodes/types';
import { getContext } from 'svelte';
export let node: Node;
const setDownSocket = getContext<(socket: Socket) => void>('setDownSocket');
const getSocketPosition =
getContext<(node: Node, index: number) => [number, number]>('getSocketPosition');
function handleMouseDown(event: MouseEvent) {
event.stopPropagation();
event.preventDefault();
setDownSocket({
node,
index: 0,
position: getSocketPosition(node, 0)
});
}
const cornerTop = 10;
const rightBump = !!node?.tmp?.type?.outputs?.length;
const aspectRatio = 0.25;
const path = createNodePath({
depth: 4,
height: 24,
y: 50,
cornerTop,
rightBump,
aspectRatio
});
const pathDisabled = createNodePath({
depth: 0,
height: 15,
y: 50,
cornerTop,
rightBump,
aspectRatio
});
const pathHover = createNodePath({
depth: 5,
height: 30,
y: 50,
cornerTop,
rightBump,
aspectRatio
});
</script>
<div class="wrapper" data-node-id={node.id}>
<div class="content">
{node.type.split('/').pop()}
</div>
<div class="click-target" role="button" tabindex="0" on:mousedown={handleMouseDown} />
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100"
width="100"
height="100"
preserveAspectRatio="none"
style={`
--path: path("${path}");
--hover-path: path("${pathHover}");
`}
>
<path vector-effect="non-scaling-stroke" stroke="white" stroke-width="0.1"></path>
</svg>
</div>
<style>
.wrapper {
position: relative;
width: 100%;
height: 50px;
}
.click-target {
position: absolute;
right: 0px;
top: 50%;
transform: translateX(50%) translateY(-50%);
height: 30px;
width: 30px;
z-index: 100;
border-radius: 50%;
/* background: red; */
/* opacity: 0.2; */
}
.click-target:hover + svg path {
d: var(--hover-path);
}
svg {
position: absolute;
top: 0;
left: 0;
z-index: -1;
box-sizing: border-box;
width: 100%;
height: 100%;
overflow: visible;
}
svg path {
stroke-width: 0.2px;
transition:
d 0.3s ease,
fill 0.3s ease;
fill: var(--background-color-lighter);
stroke: var(--stroke);
stroke-width: var(--stroke-width);
d: var(--path);
}
.content {
font-size: 1em;
display: flex;
align-items: center;
padding-left: 20px;
height: 100%;
}
svg:hover path {
d: var(--hover-path) !important;
}
</style>

View File

@ -0,0 +1,23 @@
<script lang="ts">
import type { Node, NodeInput } from '@nodes/types';
import { getGraphManager } from '../graph/context.js';
import { Input } from '@nodes/ui';
export let node: Node;
export let input: NodeInput;
export let id: string;
export let label: string | undefined;
const graph = getGraphManager();
let value = node?.props?.[id] ?? input.value;
$: if (node?.props?.[id] !== value) {
node.props = { ...node.props, [id]: value };
graph.save();
graph.execute();
}
</script>
<label for="asd">{label || id}</label>
<Input {input} bind:value />

View File

@ -0,0 +1,174 @@
<script lang="ts">
import type { NodeInput as NodeInputType, Socket, Node as NodeType } from '@nodes/types';
import { getContext } from 'svelte';
import { createNodePath } from '../helpers/index.js';
import { possibleSocketIds } from '../graph/stores.js';
import { getGraphManager } from '../graph/context.js';
import NodeInput from './NodeInput.svelte';
export let node: NodeType;
export let input: NodeInputType;
export let id: string;
export let isLast = false;
const socketId = `${node.id}-${id}`;
const graph = getGraphManager();
const graphId = graph.id;
const inputSockets = graph.inputSockets;
const setDownSocket = getContext<(socket: Socket) => void>('setDownSocket');
const getSocketPosition =
getContext<(node: NodeType, index: string) => [number, number]>('getSocketPosition');
function handleMouseDown(ev: MouseEvent) {
ev.preventDefault();
ev.stopPropagation();
setDownSocket({
node,
index: id,
position: getSocketPosition(node, id)
});
}
const leftBump = node.tmp?.type?.inputs?.[id].internal !== true;
const cornerBottom = isLast ? 5 : 0;
const aspectRatio = 0.5;
const path = createNodePath({
depth: 4,
height: 12,
y: 51,
cornerBottom,
leftBump,
aspectRatio
});
const pathDisabled = createNodePath({
depth: 0,
height: 15,
y: 50,
cornerBottom,
leftBump,
aspectRatio
});
const pathHover = createNodePath({
depth: 6,
height: 18,
y: 50.5,
cornerBottom,
leftBump,
aspectRatio
});
</script>
<div class="wrapper" class:disabled={$possibleSocketIds && !$possibleSocketIds.has(socketId)}>
{#key id && graphId}
{#if node?.tmp?.type?.inputs?.[id]?.external !== true}
<div class="content" class:disabled={$inputSockets.has(socketId)}>
<NodeInput {node} {input} {id} label={input.label} />
</div>
{/if}
{#if node?.tmp?.type?.inputs?.[id]?.internal !== true}
<div class="large target" on:mousedown={handleMouseDown} role="button" tabindex="0" />
<div class="small target" on:mousedown={handleMouseDown} role="button" tabindex="0" />
{/if}
{/key}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100"
width="100"
height="100"
preserveAspectRatio="none"
style={`
--path: path("${path}");
--hover-path: path("${pathHover}");
--hover-path-disabled: path("${pathDisabled}");
`}
>
<path vector-effect="non-scaling-stroke"></path>
</svg>
</div>
<style>
.wrapper {
position: relative;
width: 100%;
height: 100px;
transform: translateY(-0.5px);
}
.target {
position: absolute;
border-radius: 50%;
top: 50%;
transform: translateY(-50%) translateX(-50%);
/* background: red; */
/* opacity: 0.1; */
}
.small.target {
width: 30px;
height: 30px;
}
.large.target {
width: 60px;
height: 60px;
cursor: unset;
pointer-events: none;
}
:global(.hovering-sockets) .large.target {
pointer-events: all;
}
.content {
position: relative;
padding: 10px 20px;
display: flex;
flex-direction: column;
height: 100%;
justify-content: space-around;
box-sizing: border-box;
}
:global(.zoom-small) .content {
display: none;
}
svg {
position: absolute;
box-sizing: border-box;
width: 100%;
height: 100%;
overflow: visible;
top: 0;
left: 0;
z-index: -1;
}
svg path {
transition:
d 0.3s ease,
fill 0.3s ease;
fill: var(--background-color);
stroke: var(--stroke);
stroke-width: var(--stroke-width);
d: var(--path);
}
:global(.hovering-sockets) .large:hover ~ svg path {
d: var(--hover-path);
}
.content.disabled {
opacity: 0.2;
pointer-events: none;
}
.disabled svg path {
d: var(--hover-path-disabled) !important;
}
</style>

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,18 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"module": "NodeNext",
"moduleResolution": "NodeNext",
"types": [
"vite-plugin-glsl/ext"
]
}
}

View File

@ -0,0 +1,18 @@
import { sveltekit } from '@sveltejs/kit/vite';
import glsl from "vite-plugin-glsl";
import { defineConfig } from 'vite';
import { exec } from 'child_process';
const dev = import.meta.env;
export default defineConfig({
plugins: [sveltekit(), glsl(), {
name: 'postbuild-commands', // the name of your custom plugin. Could be anything.
closeBundle: async () => {
return;
// run pnpm run package
exec('pnpm run package', (err, stdout, stderr) => {
console.log(stdout);
});
}
},]
});

13
packages/ui/.eslintignore Normal file
View File

@ -0,0 +1,13 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

11
packages/ui/.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
.DS_Store
node_modules
/build
/dist
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
packages/ui/.npmrc Normal file
View File

@ -0,0 +1 @@
engine-strict=true

58
packages/ui/README.md Normal file
View File

@ -0,0 +1,58 @@
# create-svelte
Everything you need to build a Svelte library, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/main/packages/create-svelte).
Read more about creating a library [in the docs](https://kit.svelte.dev/docs/packaging).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```bash
# create a new project in the current directory
npm create svelte@latest
# create a new project in my-app
npm create svelte@latest my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
Everything inside `src/lib` is part of your library, everything inside `src/routes` can be used as a showcase or preview app.
## Building
To build your library:
```bash
npm run package
```
To create a production version of your showcase app:
```bash
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.
## Publishing
Go into the `package.json` and give your package the desired name through the `"name"` option. Also consider adding a `"license"` field and point it to a `LICENSE` file which you can create from a template (one popular option is the [MIT license](https://opensource.org/license/mit/)).
To publish your library to [npm](https://www.npmjs.com):
```bash
npm publish
```

View File

@ -1,5 +1,5 @@
{
"name": "@nodes/input-elements",
"name": "@nodes/ui",
"version": "0.0.1",
"scripts": {
"dev": "vite dev",

13
packages/ui/src/app.d.ts vendored Normal file
View File

@ -0,0 +1,13 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

12
packages/ui/src/app.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div>%sveltekit.body%</div>
</body>
</html>

View File

@ -0,0 +1,21 @@
<script lang="ts">
export let title = "Details";
</script>
<details>
<summary>{title}</summary>
<slot />
</details>
<style>
details {
padding: 1em;
color: white;
background-color: #202020;
outline: solid 0.1px white;
border-radius: 2px;
font-weight: 300;
font-size: 0.9em;
}
</style>

View File

@ -5,7 +5,8 @@ import Float from "./elements/Float.svelte";
import Integer from "./elements/Integer.svelte";
import Select from "./elements/Select.svelte";
import Checkbox from "./elements/Checkbox.svelte";
import Details from "./Details.svelte";
export { Float, Integer, Select, Checkbox };
export { Float, Integer, Select, Checkbox, Input, Details };
export default Input;

View File

@ -0,0 +1,3 @@
<h1>Welcome to your library project</h1>
<p>Create your package using @sveltejs/package and preview/showcase your work with SvelteKit</p>
<p>Visit <a href="https://kit.svelte.dev">kit.svelte.dev</a> to read the documentation</p>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,18 @@
import adapter from '@sveltejs/adapter-auto';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter()
}
};
export default config;