feat: track images with git lfs
This commit is contained in:
13
app/src/app.d.ts
vendored
Normal file
13
app/src/app.d.ts
vendored
Normal 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 {};
|
15
app/src/app.html
Normal file
15
app/src/app.html
Normal file
@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/svelte.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
|
||||
</html>
|
164
app/src/lib/components/AddMenu.svelte
Normal file
164
app/src/lib/components/AddMenu.svelte
Normal file
@ -0,0 +1,164 @@
|
||||
<script lang="ts">
|
||||
import type { GraphManager } from "$lib/graph-manager";
|
||||
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="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);
|
||||
}
|
||||
|
||||
.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>
|
31
app/src/lib/components/BoxSelection.svelte
Normal file
31
app/src/lib/components/BoxSelection.svelte
Normal file
@ -0,0 +1,31 @@
|
||||
<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="selection"
|
||||
style={`width: ${width}px; height: ${height}px;`}
|
||||
></div>
|
||||
</HTML>
|
||||
|
||||
<style>
|
||||
.selection {
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
border: solid 0.2px rgba(200, 200, 200, 0.8);
|
||||
border-style: dashed;
|
||||
border-radius: 2px;
|
||||
}
|
||||
</style>
|
18
app/src/lib/components/Camera.svelte
Normal file
18
app/src/lib/components/Camera.svelte
Normal 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
|
||||
/>
|
21
app/src/lib/components/Details.svelte
Normal file
21
app/src/lib/components/Details.svelte
Normal 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>
|
98
app/src/lib/components/background/Background.frag
Normal file
98
app/src/lib/components/background/Background.frag
Normal 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);
|
||||
}
|
21
app/src/lib/components/background/Background.story.svelte
Normal file
21
app/src/lib/components/background/Background.story.svelte
Normal 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>
|
56
app/src/lib/components/background/Background.svelte
Normal file
56
app/src/lib/components/background/Background.svelte
Normal file
@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import { T } from "@threlte/core";
|
||||
|
||||
import BackgroundVert from "./Background.vert";
|
||||
import BackgroundFrag from "./Background.frag";
|
||||
import { colors } from "../graph/stores";
|
||||
|
||||
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>
|
15
app/src/lib/components/background/Background.vert
Normal file
15
app/src/lib/components/background/Background.vert
Normal 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;
|
||||
}
|
||||
|
0
app/src/lib/components/background/index.ts
Normal file
0
app/src/lib/components/background/index.ts
Normal file
24
app/src/lib/components/debug/Debug.svelte
Normal file
24
app/src/lib/components/debug/Debug.svelte
Normal file
@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { MeshLineGeometry, MeshLineMaterial } from "@threlte/extras";
|
||||
import { points, lines } from "./store";
|
||||
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}
|
25
app/src/lib/components/debug/index.ts
Normal file
25
app/src/lib/components/debug/index.ts
Normal 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
|
6
app/src/lib/components/debug/store.ts
Normal file
6
app/src/lib/components/debug/store.ts
Normal 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[][]>([]);
|
112
app/src/lib/components/edges/Edge.svelte
Normal file
112
app/src/lib/components/edges/Edge.svelte
Normal 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}
|
8
app/src/lib/components/edges/FloatingEdge.svelte
Normal file
8
app/src/lib/components/edges/FloatingEdge.svelte
Normal 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} />
|
100
app/src/lib/components/edges/createEdgeGeometry.ts
Normal file
100
app/src/lib/components/edges/createEdgeGeometry.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import { BufferGeometry, Vector3, BufferAttribute } from 'three'
|
||||
import { setXY, setXYZ, setXYZW, setXYZXYZ } from './utils'
|
||||
|
||||
|
||||
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;
|
||||
|
||||
}
|
34
app/src/lib/components/edges/utils.ts
Normal file
34
app/src/lib/components/edges/utils.ts
Normal 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
|
||||
}
|
756
app/src/lib/components/graph/Graph.svelte
Normal file
756
app/src/lib/components/graph/Graph.svelte
Normal file
@ -0,0 +1,756 @@
|
||||
<script lang="ts">
|
||||
import { animate, lerp, snapToGrid } from "$lib/helpers";
|
||||
import type { OrthographicCamera } from "three";
|
||||
import Background from "../background/Background.svelte";
|
||||
import type { GraphManager } from "$lib/graph-manager";
|
||||
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";
|
||||
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;
|
||||
const graphId = graph.id;
|
||||
|
||||
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).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;
|
||||
|
||||
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(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
);
|
||||
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) {
|
||||
mousePosition = projectScreenToWorld(event.clientX, event.clientY);
|
||||
|
||||
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 + (event.clientX - mouseDown[0]) / cameraPosition[2];
|
||||
let newY = oldY + (event.clientY - 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] - (event.clientX - mouseDown[0]) / cameraPosition[2];
|
||||
let newY =
|
||||
cameraDown[1] - (event.clientY - mouseDown[1]) / cameraPosition[2];
|
||||
|
||||
setCameraTransform(newX, newY, cameraPosition[2]);
|
||||
}
|
||||
|
||||
const zoomSpeed = 2;
|
||||
function handleMouseScroll(event: WheelEvent) {
|
||||
const bodyIsFocused =
|
||||
document.activeElement === document.body ||
|
||||
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;
|
||||
mouseDown = [event.clientX, event.clientY];
|
||||
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:document
|
||||
on:mousemove={handleMouseMove}
|
||||
on:mouseup={handleMouseUp}
|
||||
on:mousedown={handleMouseDown}
|
||||
on:keydown={handleKeyDown}
|
||||
/>
|
||||
|
||||
<svelte:window
|
||||
on:wheel={handleMouseScroll}
|
||||
bind:innerWidth={width}
|
||||
bind:innerHeight={height}
|
||||
/>
|
||||
|
||||
<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}
|
84
app/src/lib/components/graph/GraphView.svelte
Normal file
84
app/src/lib/components/graph/GraphView.svelte
Normal file
@ -0,0 +1,84 @@
|
||||
<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";
|
||||
|
||||
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>
|
6
app/src/lib/components/graph/context.ts
Normal file
6
app/src/lib/components/graph/context.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import type { GraphManager } from "$lib/graph-manager";
|
||||
import { getContext } from "svelte";
|
||||
|
||||
export function getGraphManager(): GraphManager {
|
||||
return getContext("graphManager");
|
||||
}
|
0
app/src/lib/components/graph/index.ts
Normal file
0
app/src/lib/components/graph/index.ts
Normal file
43
app/src/lib/components/graph/stores.ts
Normal file
43
app/src/lib/components/graph/stores.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { browser } from "$app/environment";
|
||||
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 (browser) {
|
||||
|
||||
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();
|
||||
})
|
||||
}
|
67
app/src/lib/components/node/Node.frag
Normal file
67
app/src/lib/components/node/Node.frag
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
133
app/src/lib/components/node/Node.svelte
Normal file
133
app/src/lib/components/node/Node.svelte
Normal file
@ -0,0 +1,133 @@
|
||||
<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";
|
||||
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 || {});
|
||||
|
||||
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>
|
15
app/src/lib/components/node/Node.vert
Normal file
15
app/src/lib/components/node/Node.vert
Normal 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;
|
||||
}
|
||||
|
137
app/src/lib/components/node/NodeHeader.svelte
Normal file
137
app/src/lib/components/node/NodeHeader.svelte
Normal file
@ -0,0 +1,137 @@
|
||||
<script lang="ts">
|
||||
import { createNodePath } from "$lib/helpers";
|
||||
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} / {node.id}
|
||||
</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>
|
21
app/src/lib/components/node/NodeInput.svelte
Normal file
21
app/src/lib/components/node/NodeInput.svelte
Normal file
@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import type { Node, NodeInput } from "@nodes/types";
|
||||
import { getGraphManager } from "../graph/context";
|
||||
import Input from "@nodes/input-elements";
|
||||
|
||||
export let node: Node;
|
||||
export let input: NodeInput;
|
||||
export let id: string;
|
||||
|
||||
const graph = getGraphManager();
|
||||
|
||||
let value = node?.props?.[id] ?? input.value;
|
||||
|
||||
$: if (node?.props?.[id] !== value) {
|
||||
node.props = { ...node.props, [id]: value };
|
||||
graph.execute();
|
||||
}
|
||||
</script>
|
||||
|
||||
<label for="asd">{id}</label>
|
||||
<Input {input} bind:value />
|
193
app/src/lib/components/node/NodeParameter.svelte
Normal file
193
app/src/lib/components/node/NodeParameter.svelte
Normal file
@ -0,0 +1,193 @@
|
||||
<script lang="ts">
|
||||
import type { NodeInput as NodeInputType, Socket, Node } from "@nodes/types";
|
||||
import { getContext } from "svelte";
|
||||
import { createNodePath } from "$lib/helpers";
|
||||
import { possibleSocketIds } from "../graph/stores";
|
||||
import { getGraphManager } from "../graph/context";
|
||||
import NodeInput from "./NodeInput.svelte";
|
||||
|
||||
export let node: Node;
|
||||
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: Node, 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}
|
||||
<div class="content" class:disabled={$inputSockets.has(socketId)}>
|
||||
<NodeInput {node} {input} {id} />
|
||||
</div>
|
||||
|
||||
{#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);
|
||||
/* fill: #131313; */
|
||||
}
|
||||
|
||||
:global(.hovering-sockets) .small:hover ~ svg path {
|
||||
/* fill: #161616; */
|
||||
}
|
||||
|
||||
.content.disabled {
|
||||
opacity: 0.2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.disabled svg path {
|
||||
d: var(--hover-path-disabled) !important;
|
||||
/* fill: #060606 !important; */
|
||||
}
|
||||
</style>
|
537
app/src/lib/graph-manager.ts
Normal file
537
app/src/lib/graph-manager.ts
Normal file
@ -0,0 +1,537 @@
|
||||
import { writable, type Writable } from "svelte/store";
|
||||
import type { Graph, Node, Edge, Socket, NodeRegistry, RuntimeExecutor, } from "@nodes/s";
|
||||
import { HistoryManager } from "./history-manager";
|
||||
import * as templates from "./graphs";
|
||||
import EventEmitter from "./helpers/EventEmitter";
|
||||
import throttle from "./helpers/throttle";
|
||||
import { createLogger } from "./helpers";
|
||||
|
||||
const logger = createLogger("graph-manager");
|
||||
|
||||
export class GraphManager extends EventEmitter<{ "save": Graph }> {
|
||||
|
||||
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, private runtime: RuntimeExecutor) {
|
||||
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;
|
||||
const start = performance.now();
|
||||
const result = this.runtime.execute(this.serialize());
|
||||
const end = performance.now();
|
||||
logger.log(`Execution took ${end - start}ms -> ${result}`);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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`);
|
||||
}
|
||||
|
||||
|
||||
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][];
|
||||
}
|
||||
|
||||
createTemplate<T extends keyof typeof templates>(template: T, ...args: Parameters<typeof templates[T]>) {
|
||||
switch (template) {
|
||||
case "grid":
|
||||
return templates.grid(args?.[0] || 5, args?.[1] || 5);
|
||||
case "tree":
|
||||
return templates.tree(args?.[0] || 4);
|
||||
default:
|
||||
throw new Error(`Template not found: ${template}`);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
42
app/src/lib/graphs/grid.ts
Normal file
42
app/src/lib/graphs/grid.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import type { Graph } from "@nodes/types";
|
||||
|
||||
export function grid(width: number, height: number) {
|
||||
|
||||
const graph: Graph = {
|
||||
id: Math.floor(Math.random() * 100000),
|
||||
edges: [],
|
||||
nodes: [],
|
||||
};
|
||||
|
||||
const amount = width * height;
|
||||
|
||||
for (let i = 0; i < amount; i++) {
|
||||
const x = i % width;
|
||||
const y = Math.floor(i / height);
|
||||
|
||||
graph.nodes.push({
|
||||
id: i,
|
||||
tmp: {
|
||||
visible: false,
|
||||
},
|
||||
position: [x * 30, y * 40],
|
||||
props: i == 0 ? { value: 0 } : {},
|
||||
type: i == 0 ? "input/float" : "math",
|
||||
});
|
||||
|
||||
graph.edges.push([i, 0, i + 1, i === amount - 1 ? "input" : "a",]);
|
||||
}
|
||||
|
||||
graph.nodes.push({
|
||||
id: amount,
|
||||
tmp: {
|
||||
visible: false,
|
||||
},
|
||||
position: [width * 30, (height - 1) * 40],
|
||||
type: "output",
|
||||
props: {},
|
||||
});
|
||||
|
||||
return graph;
|
||||
|
||||
}
|
2
app/src/lib/graphs/index.ts
Normal file
2
app/src/lib/graphs/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { grid } from "./grid";
|
||||
export { tree } from "./tree";
|
56
app/src/lib/graphs/tree.ts
Normal file
56
app/src/lib/graphs/tree.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import type { Graph, Node } from "@nodes/types";
|
||||
|
||||
export function tree(depth: number): Graph {
|
||||
|
||||
const nodes: Node[] = [
|
||||
{
|
||||
id: 0,
|
||||
type: "output",
|
||||
position: [0, 0]
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
type: "math",
|
||||
position: [-40, -10]
|
||||
}
|
||||
]
|
||||
|
||||
const edges: [number, number, number, string][] = [
|
||||
[1, 0, 0, "input"]
|
||||
];
|
||||
|
||||
for (let d = 0; d < depth; d++) {
|
||||
const amount = Math.pow(2, d);
|
||||
for (let i = 0; i < amount; i++) {
|
||||
|
||||
const id0 = amount * 2 + i * 2;
|
||||
const id1 = amount * 2 + i * 2 + 1;
|
||||
|
||||
const parent = Math.floor(id0 / 2);
|
||||
|
||||
const x = -(d + 1) * 50 - 40;
|
||||
const y = i * 80 - amount * 35;
|
||||
|
||||
nodes.push({
|
||||
id: id0,
|
||||
type: "math",
|
||||
position: [x, y],
|
||||
});
|
||||
edges.push([id0, 0, parent, "a"]);
|
||||
nodes.push({
|
||||
id: id1,
|
||||
type: "math",
|
||||
position: [x, y + 35],
|
||||
});
|
||||
edges.push([id1, 0, parent, "b"]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
id: Math.floor(Math.random() * 100000),
|
||||
nodes,
|
||||
edges
|
||||
};
|
||||
|
||||
}
|
69
app/src/lib/helpers/EventEmitter.ts
Normal file
69
app/src/lib/helpers/EventEmitter.ts
Normal file
@ -0,0 +1,69 @@
|
||||
|
||||
import throttle from './throttle';
|
||||
|
||||
|
||||
|
||||
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 = {};
|
||||
}
|
||||
}
|
96
app/src/lib/helpers/index.ts
Normal file
96
app/src/lib/helpers/index.ts
Normal file
@ -0,0 +1,96 @@
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
20
app/src/lib/helpers/throttle.ts
Normal file
20
app/src/lib/helpers/throttle.ts
Normal 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;
|
||||
}
|
||||
};
|
101
app/src/lib/history-manager.ts
Normal file
101
app/src/lib/history-manager.ts
Normal file
@ -0,0 +1,101 @@
|
||||
import { create, type Delta } from "jsondiffpatch";
|
||||
import type { Graph } from "@nodes/types";
|
||||
import { createLogger, clone } from "./helpers";
|
||||
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
39
app/src/lib/node-registry.ts
Normal file
39
app/src/lib/node-registry.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import type { NodeRegistry, NodeType } from "@nodes/types";
|
||||
|
||||
import * as d from "plantarium-nodes-math";
|
||||
|
||||
const nodeTypes: NodeType[] = [
|
||||
{
|
||||
id: "input/float",
|
||||
inputs: {
|
||||
"value": { type: "float", value: 0.1, internal: true },
|
||||
},
|
||||
outputs: ["float"],
|
||||
execute: ({ value }) => { return value }
|
||||
},
|
||||
{
|
||||
id: d.get_id(),
|
||||
inputs: JSON.parse(d.get_input_types()),
|
||||
outputs: d.get_outputs(),
|
||||
execute: ({ op_type, a, b }) => {
|
||||
return d.execute(op_type, a, b);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "output",
|
||||
inputs: {
|
||||
"input": { type: "float" },
|
||||
},
|
||||
outputs: [],
|
||||
}
|
||||
]
|
||||
|
||||
export class MemoryNodeRegistry implements NodeRegistry {
|
||||
getNode(id: string): NodeType | undefined {
|
||||
return nodeTypes.find((nodeType) => nodeType.id === id);
|
||||
}
|
||||
getAllNodes(): NodeType[] {
|
||||
return [...nodeTypes];
|
||||
}
|
||||
}
|
||||
|
52
app/src/lib/panzoom/domController.ts
Normal file
52
app/src/lib/panzoom/domController.ts
Normal file
@ -0,0 +1,52 @@
|
||||
export default function makeDomController(domElement: HTMLElement) {
|
||||
const elementValid = isDomElement(domElement);
|
||||
if (!elementValid) {
|
||||
throw new Error(
|
||||
'panzoom requires DOM element to be attached to the DOM tree',
|
||||
);
|
||||
}
|
||||
|
||||
const owner = domElement.parentElement;
|
||||
domElement.scrollTop = 0;
|
||||
|
||||
const api = {
|
||||
getBBox: getBBox,
|
||||
getOwner: getOwner,
|
||||
applyTransform: applyTransform,
|
||||
};
|
||||
|
||||
return api;
|
||||
|
||||
function getOwner() {
|
||||
return owner;
|
||||
}
|
||||
|
||||
function getBBox() {
|
||||
// TODO: We should probably cache this?
|
||||
return {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: domElement.clientWidth,
|
||||
height: domElement.clientHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function applyTransform(transform: { scale: number; x: number; y: number }) {
|
||||
// TODO: Should we cache this?
|
||||
domElement.style.transformOrigin = '0 0 0';
|
||||
domElement.style.transform =
|
||||
'matrix(' +
|
||||
transform.scale +
|
||||
', 0, 0, ' +
|
||||
transform.scale +
|
||||
', ' +
|
||||
transform.x +
|
||||
', ' +
|
||||
transform.y +
|
||||
')';
|
||||
}
|
||||
}
|
||||
|
||||
export function isDomElement(element: HTMLElement) {
|
||||
return element && element.parentElement && element.style;
|
||||
}
|
773
app/src/lib/panzoom/index.ts
Normal file
773
app/src/lib/panzoom/index.ts
Normal file
@ -0,0 +1,773 @@
|
||||
import type NodeSystemView from '../../view/NodeSystemView';
|
||||
import makeDomController from './domController';
|
||||
import kinetic from './kinetic';
|
||||
|
||||
interface Bounds {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
}
|
||||
export interface Transform {
|
||||
x: number;
|
||||
y: number;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
export interface TransformOrigin {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface PanZoomController {
|
||||
getOwner: () => Element;
|
||||
applyTransform: (transform: Transform) => void;
|
||||
}
|
||||
|
||||
interface PanZoomOptions {
|
||||
filterKey?: () => boolean;
|
||||
bounds?: boolean | Bounds;
|
||||
maxZoom?: number;
|
||||
minZoom?: number;
|
||||
boundsPadding?: number;
|
||||
zoomDoubleClickSpeed?: number;
|
||||
zoomSpeed?: number;
|
||||
initialX?: number;
|
||||
initialY?: number;
|
||||
initialZoom?: number;
|
||||
pinchSpeed?: number;
|
||||
beforeWheel?: (e: WheelEvent) => void;
|
||||
beforeMouseDown?: (e: MouseEvent) => void;
|
||||
autocenter?: boolean;
|
||||
onTouch?: (e: TouchEvent) => void;
|
||||
onTransform?: (t: Transform) => void;
|
||||
onDoubleClick?: (e: Event) => void;
|
||||
smoothScroll?: Record<string, unknown>;
|
||||
controller?: PanZoomController;
|
||||
enableTextSelection?: boolean;
|
||||
disableKeyboardInteraction?: boolean;
|
||||
transformOrigin?: TransformOrigin;
|
||||
view?: NodeSystemView;
|
||||
}
|
||||
|
||||
const defaultZoomSpeed = 0.2;
|
||||
|
||||
/**
|
||||
* Creates a new instance of panzoom, so that an object can be panned and zoomed
|
||||
*
|
||||
* @param {DOMElement} domElement where panzoom should be attached.
|
||||
* @param {Object} options that configure behavior.
|
||||
*/
|
||||
export function createPanZoom(
|
||||
domElement: HTMLElement,
|
||||
options: PanZoomOptions,
|
||||
) {
|
||||
const panController = makeDomController(domElement);
|
||||
|
||||
const owner = panController.getOwner();
|
||||
// just to avoid GC pressure, every time we do intermediate transform
|
||||
// we return this object. For internal use only. Never give it back to the consumer of this library
|
||||
const storedCTMResult = { x: 0, y: 0 };
|
||||
|
||||
let isDirty = false;
|
||||
const transform = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
};
|
||||
|
||||
// TODO: likely need to unite pinchSpeed with zoomSpeed
|
||||
const pinchSpeed =
|
||||
typeof options.pinchSpeed === 'number' ? options.pinchSpeed : 1;
|
||||
const bounds = options.bounds;
|
||||
const maxZoom =
|
||||
typeof options.maxZoom === 'number'
|
||||
? options.maxZoom
|
||||
: Number.POSITIVE_INFINITY;
|
||||
const minZoom = typeof options.minZoom === 'number' ? options.minZoom : 0;
|
||||
|
||||
const boundsPadding =
|
||||
typeof options.boundsPadding === 'number' ? options.boundsPadding : 0.05;
|
||||
|
||||
const speed =
|
||||
typeof options.zoomSpeed === 'number'
|
||||
? options.zoomSpeed
|
||||
: defaultZoomSpeed;
|
||||
let transformOrigin = parseTransformOrigin(options.transformOrigin);
|
||||
|
||||
validateBounds(bounds);
|
||||
|
||||
let frameAnimation: number;
|
||||
let touchInProgress = false;
|
||||
|
||||
// We only need to fire panstart when actual move happens
|
||||
let panstartFired = false;
|
||||
|
||||
// cache mouse coordinates here
|
||||
let mouseX: number;
|
||||
let mouseY: number;
|
||||
|
||||
let pinchZoomLength: number;
|
||||
|
||||
const smoothScroll = kinetic(getPoint, scroll, options.smoothScroll);
|
||||
|
||||
let zoomToAnimation: { cancel: () => void };
|
||||
|
||||
let multiTouch: boolean;
|
||||
let paused = false;
|
||||
|
||||
listenForEvents();
|
||||
|
||||
const api = {
|
||||
dispose,
|
||||
moveBy,
|
||||
moveTo,
|
||||
smoothMoveTo,
|
||||
centerOn,
|
||||
zoomTo: publicZoomTo,
|
||||
zoomAbs,
|
||||
|
||||
pause,
|
||||
resume,
|
||||
isPaused,
|
||||
|
||||
getTransform: getTransformModel,
|
||||
|
||||
setTransform,
|
||||
|
||||
getTransformOrigin,
|
||||
setTransformOrigin,
|
||||
};
|
||||
|
||||
const initialX =
|
||||
typeof options.initialX === 'number' ? options.initialX : transform.x;
|
||||
const initialY =
|
||||
typeof options.initialY === 'number' ? options.initialY : transform.y;
|
||||
const initialZoom =
|
||||
typeof options.initialZoom === 'number'
|
||||
? options.initialZoom
|
||||
: transform.scale;
|
||||
|
||||
if (
|
||||
initialX != transform.x ||
|
||||
initialY != transform.y ||
|
||||
initialZoom != transform.scale
|
||||
) {
|
||||
zoomAbs(initialX, initialY, initialZoom);
|
||||
}
|
||||
|
||||
return api;
|
||||
|
||||
function pause() {
|
||||
releaseEvents();
|
||||
paused = true;
|
||||
}
|
||||
|
||||
function resume() {
|
||||
if (paused) {
|
||||
listenForEvents();
|
||||
paused = false;
|
||||
}
|
||||
}
|
||||
|
||||
function isPaused() {
|
||||
return paused;
|
||||
}
|
||||
|
||||
function transformToScreen(x: number, y: number) {
|
||||
storedCTMResult.x = x;
|
||||
storedCTMResult.y = y;
|
||||
|
||||
return storedCTMResult;
|
||||
}
|
||||
|
||||
function setTransform(x: number, y: number, s: number) {
|
||||
transform.x = x;
|
||||
transform.y = y;
|
||||
transform.scale = s;
|
||||
makeDirty();
|
||||
}
|
||||
|
||||
function getTransformModel() {
|
||||
// TODO: should this be read only?
|
||||
return transform;
|
||||
}
|
||||
|
||||
function getTransformOrigin() {
|
||||
return transformOrigin;
|
||||
}
|
||||
|
||||
function setTransformOrigin(newTransformOrigin: TransformOrigin) {
|
||||
transformOrigin = parseTransformOrigin(newTransformOrigin);
|
||||
}
|
||||
|
||||
function getPoint() {
|
||||
return {
|
||||
x: transform.x,
|
||||
y: transform.y,
|
||||
};
|
||||
}
|
||||
|
||||
function moveTo(x: number, y: number) {
|
||||
transform.x = x;
|
||||
transform.y = y;
|
||||
|
||||
keepTransformInsideBounds();
|
||||
|
||||
makeDirty();
|
||||
}
|
||||
|
||||
function moveBy(dx: number, dy: number) {
|
||||
moveTo(transform.x + dx, transform.y + dy);
|
||||
}
|
||||
|
||||
function keepTransformInsideBounds() {
|
||||
const boundingBox = getBoundingBox();
|
||||
if (!boundingBox) return;
|
||||
|
||||
let adjusted = false;
|
||||
const clientRect = getClientRect();
|
||||
|
||||
let diff = boundingBox.left - clientRect.right;
|
||||
if (diff > 0) {
|
||||
transform.x += diff;
|
||||
adjusted = true;
|
||||
}
|
||||
// check the other side:
|
||||
diff = boundingBox.right - clientRect.left;
|
||||
if (diff < 0) {
|
||||
transform.x += diff;
|
||||
adjusted = true;
|
||||
}
|
||||
|
||||
// y axis:
|
||||
diff = boundingBox.top - clientRect.bottom;
|
||||
if (diff > 0) {
|
||||
// we adjust transform, so that it matches exactly our bounding box:
|
||||
// transform.y = boundingBox.top - (boundingBox.height + boundingBox.y) * transform.scale =>
|
||||
// transform.y = boundingBox.top - (clientRect.bottom - transform.y) =>
|
||||
// transform.y = diff + transform.y =>
|
||||
transform.y += diff;
|
||||
adjusted = true;
|
||||
}
|
||||
|
||||
diff = boundingBox.bottom - clientRect.top;
|
||||
if (diff < 0) {
|
||||
transform.y += diff;
|
||||
adjusted = true;
|
||||
}
|
||||
return adjusted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns bounding box that should be used to restrict scene movement.
|
||||
*/
|
||||
function getBoundingBox() {
|
||||
if (!bounds) return; // client does not want to restrict movement
|
||||
|
||||
if (typeof bounds === 'boolean') {
|
||||
// for boolean type we use parent container bounds
|
||||
const ownerRect = owner.getBoundingClientRect();
|
||||
const sceneWidth = ownerRect.width;
|
||||
const sceneHeight = ownerRect.height;
|
||||
|
||||
return {
|
||||
left: sceneWidth * boundsPadding,
|
||||
top: sceneHeight * boundsPadding,
|
||||
right: sceneWidth * (1 - boundsPadding),
|
||||
bottom: sceneHeight * (1 - boundsPadding),
|
||||
};
|
||||
}
|
||||
|
||||
return bounds;
|
||||
}
|
||||
|
||||
function getClientRect() {
|
||||
const bbox = panController.getBBox();
|
||||
const leftTop = client(bbox.left, bbox.top);
|
||||
|
||||
return {
|
||||
left: leftTop.x,
|
||||
top: leftTop.y,
|
||||
right: bbox.width * transform.scale + leftTop.x,
|
||||
bottom: bbox.height * transform.scale + leftTop.y,
|
||||
};
|
||||
}
|
||||
|
||||
function client(x: number, y: number) {
|
||||
return {
|
||||
x: x * transform.scale + transform.x,
|
||||
y: y * transform.scale + transform.y,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDirty() {
|
||||
isDirty = true;
|
||||
|
||||
frameAnimation = window.requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
function zoomByRatio(clientX: number, clientY: number, ratio: number) {
|
||||
if (isNaN(clientX) || isNaN(clientY) || isNaN(ratio)) {
|
||||
throw new Error('zoom requires valid numbers');
|
||||
}
|
||||
|
||||
const newScale = transform.scale * ratio;
|
||||
|
||||
if (newScale < minZoom) {
|
||||
if (transform.scale === minZoom) return;
|
||||
|
||||
ratio = minZoom / transform.scale;
|
||||
}
|
||||
if (newScale > maxZoom) {
|
||||
if (transform.scale === maxZoom) return;
|
||||
|
||||
ratio = maxZoom / transform.scale;
|
||||
}
|
||||
|
||||
const size = transformToScreen(clientX, clientY);
|
||||
|
||||
transform.x = size.x - ratio * (size.x - transform.x);
|
||||
transform.y = size.y - ratio * (size.y - transform.y);
|
||||
|
||||
// TODO: https://github.com/anvaka/panzoom/issues/112
|
||||
if (bounds && boundsPadding === 1 && minZoom === 1) {
|
||||
transform.scale *= ratio;
|
||||
keepTransformInsideBounds();
|
||||
} else {
|
||||
const transformAdjusted = keepTransformInsideBounds();
|
||||
if (!transformAdjusted) transform.scale *= ratio;
|
||||
}
|
||||
|
||||
makeDirty();
|
||||
}
|
||||
|
||||
function zoomAbs(clientX: number, clientY: number, zoomLevel: number) {
|
||||
const ratio = zoomLevel / transform.scale;
|
||||
zoomByRatio(clientX, clientY, ratio);
|
||||
}
|
||||
|
||||
function centerOn(ui: SVGElement) {
|
||||
const parent = ui.ownerSVGElement;
|
||||
if (!parent)
|
||||
throw new Error('ui element is required to be within the scene');
|
||||
|
||||
// TODO: should i use controller's screen CTM?
|
||||
const clientRect = ui.getBoundingClientRect();
|
||||
const cx = clientRect.left + clientRect.width / 2;
|
||||
const cy = clientRect.top + clientRect.height / 2;
|
||||
|
||||
const container = parent.getBoundingClientRect();
|
||||
const dx = container.width / 2 - cx;
|
||||
const dy = container.height / 2 - cy;
|
||||
|
||||
internalMoveBy(dx, dy);
|
||||
}
|
||||
|
||||
function smoothMoveTo(x: number, y: number) {
|
||||
internalMoveBy(x - transform.x, y - transform.y);
|
||||
}
|
||||
|
||||
function internalMoveBy(dx: number, dy: number) {
|
||||
return moveBy(dx, dy);
|
||||
}
|
||||
|
||||
function scroll(x: number, y: number) {
|
||||
cancelZoomAnimation();
|
||||
moveTo(x, y);
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
releaseEvents();
|
||||
}
|
||||
|
||||
function listenForEvents() {
|
||||
owner.addEventListener('mousedown', onMouseDown, { passive: true });
|
||||
owner.addEventListener('dblclick', onDoubleClick, { passive: false });
|
||||
owner.addEventListener('touchstart', onTouch, { passive: true });
|
||||
owner.addEventListener('keydown', onKeyDown);
|
||||
|
||||
// Need to listen on the owner container, so that we are not limited
|
||||
// by the size of the scrollable domElement
|
||||
owner.addEventListener('wheel', onMouseWheel, { passive: true });
|
||||
|
||||
makeDirty();
|
||||
}
|
||||
|
||||
function releaseEvents() {
|
||||
owner.removeEventListener('wheel', onMouseWheel);
|
||||
owner.removeEventListener('mousedown', onMouseDown);
|
||||
owner.removeEventListener('keydown', onKeyDown);
|
||||
owner.removeEventListener('dblclick', onDoubleClick);
|
||||
owner.removeEventListener('touchstart', onTouch);
|
||||
|
||||
if (frameAnimation) {
|
||||
window.cancelAnimationFrame(frameAnimation);
|
||||
frameAnimation = 0;
|
||||
}
|
||||
|
||||
smoothScroll.cancel();
|
||||
|
||||
releaseDocumentMouse();
|
||||
releaseTouches();
|
||||
|
||||
triggerPanEnd();
|
||||
}
|
||||
|
||||
function frame() {
|
||||
if (isDirty) applyTransform();
|
||||
}
|
||||
|
||||
function applyTransform() {
|
||||
isDirty = false;
|
||||
|
||||
// TODO: Should I allow to cancel this?
|
||||
panController.applyTransform(transform);
|
||||
|
||||
frameAnimation = 0;
|
||||
|
||||
if (options.onTransform) {
|
||||
options.onTransform(transform);
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
// let x = 0,
|
||||
// y = 0,
|
||||
let z = 0;
|
||||
if (e.key === 'ArrowUp') {
|
||||
// y = 1; // up
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
// y = -1; // down
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
// x = 1; // left
|
||||
} else if (e.key === 'ArrowRigh') {
|
||||
// x = -1; // right
|
||||
} else if (e.key === '-') {
|
||||
// DASH or SUBTRACT
|
||||
z = 1; // `-` - zoom out
|
||||
} else if (e.key === '=' || e.key === '+') {
|
||||
// EQUAL SIGN or ADD
|
||||
z = -1; // `=` - zoom in (equal sign on US layout is under `+`)
|
||||
}
|
||||
if (z) {
|
||||
const scaleMultiplier = getScaleMultiplier(z * 100);
|
||||
const offset = transformOrigin ? getTransformOriginOffset() : midPoint();
|
||||
publicZoomTo(offset.x, offset.y, scaleMultiplier);
|
||||
}
|
||||
}
|
||||
|
||||
function midPoint() {
|
||||
const ownerRect = owner.getBoundingClientRect();
|
||||
return {
|
||||
x: ownerRect.width / 2,
|
||||
y: ownerRect.height / 2,
|
||||
};
|
||||
}
|
||||
|
||||
function onTouch(e: TouchEvent) {
|
||||
// let the override the touch behavior
|
||||
beforeTouch(e);
|
||||
|
||||
if (e.touches.length === 1) {
|
||||
return handleSingleFingerTouch(e);
|
||||
} else if (e.touches.length === 2) {
|
||||
// handleTouchMove() will care about pinch zoom.
|
||||
pinchZoomLength = getPinchZoomLength(e.touches[0], e.touches[1]);
|
||||
multiTouch = true;
|
||||
startTouchListenerIfNeeded();
|
||||
}
|
||||
}
|
||||
|
||||
function beforeTouch(e: TouchEvent) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function beforeDoubleClick(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
function handleSingleFingerTouch(e: TouchEvent) {
|
||||
const touch = e.touches[0];
|
||||
const offset = getOffsetXY(touch);
|
||||
const point = transformToScreen(offset.x, offset.y);
|
||||
mouseX = point.x;
|
||||
mouseY = point.y;
|
||||
|
||||
smoothScroll.cancel();
|
||||
startTouchListenerIfNeeded();
|
||||
}
|
||||
|
||||
function startTouchListenerIfNeeded() {
|
||||
if (touchInProgress) {
|
||||
// no need to do anything, as we already listen to events;
|
||||
return;
|
||||
}
|
||||
|
||||
touchInProgress = true;
|
||||
document.addEventListener('touchmove', handleTouchMove);
|
||||
document.addEventListener('touchend', handleTouchEnd);
|
||||
document.addEventListener('touchcancel', handleTouchEnd);
|
||||
}
|
||||
|
||||
function handleTouchMove(e: TouchEvent) {
|
||||
if (e.touches.length === 1) {
|
||||
e.stopPropagation();
|
||||
const touch = e.touches[0];
|
||||
|
||||
const offset = getOffsetXY(touch);
|
||||
const point = transformToScreen(offset.x, offset.y);
|
||||
|
||||
const dx = point.x - mouseX;
|
||||
const dy = point.y - mouseY;
|
||||
|
||||
if (dx !== 0 && dy !== 0) {
|
||||
triggerPanStart();
|
||||
}
|
||||
mouseX = point.x;
|
||||
mouseY = point.y;
|
||||
internalMoveBy(dx, dy);
|
||||
} else if (e.touches.length === 2) {
|
||||
// it's a zoom, let's find direction
|
||||
multiTouch = true;
|
||||
const t1 = e.touches[0];
|
||||
const t2 = e.touches[1];
|
||||
const currentPinchLength = getPinchZoomLength(t1, t2);
|
||||
|
||||
// since the zoom speed is always based on distance from 1, we need to apply
|
||||
// pinch speed only on that distance from 1:
|
||||
const scaleMultiplier =
|
||||
1 + (currentPinchLength / pinchZoomLength - 1) * pinchSpeed;
|
||||
|
||||
const firstTouchPoint = getOffsetXY(t1);
|
||||
const secondTouchPoint = getOffsetXY(t2);
|
||||
mouseX = (firstTouchPoint.x + secondTouchPoint.x) / 2;
|
||||
mouseY = (firstTouchPoint.y + secondTouchPoint.y) / 2;
|
||||
if (transformOrigin) {
|
||||
const offset = getTransformOriginOffset();
|
||||
mouseX = offset.x;
|
||||
mouseY = offset.y;
|
||||
}
|
||||
|
||||
publicZoomTo(mouseX, mouseY, scaleMultiplier);
|
||||
|
||||
pinchZoomLength = currentPinchLength;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchEnd(e: TouchEvent) {
|
||||
if (e.touches.length > 0) {
|
||||
const offset = getOffsetXY(e.touches[0]);
|
||||
const point = transformToScreen(offset.x, offset.y);
|
||||
mouseX = point.x;
|
||||
mouseY = point.y;
|
||||
}
|
||||
}
|
||||
|
||||
function getPinchZoomLength(finger1: Touch, finger2: Touch) {
|
||||
const dx = finger1.clientX - finger2.clientX;
|
||||
const dy = finger1.clientY - finger2.clientY;
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
function onDoubleClick(e: MouseEvent) {
|
||||
beforeDoubleClick(e);
|
||||
}
|
||||
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
if (touchInProgress) {
|
||||
// modern browsers will fire mousedown for touch events too
|
||||
// we do not want this: touch is handled separately.
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (e.target !== owner && e.target !== domElement) return;
|
||||
|
||||
// for IE, left click == 1
|
||||
// for Firefox, left click == 0
|
||||
const isLeftButton =
|
||||
(e.button === 1 && window.event !== null) || e.button === 0;
|
||||
if (!isLeftButton) return;
|
||||
|
||||
smoothScroll.cancel();
|
||||
|
||||
const offset = getOffsetXY(e);
|
||||
const point = transformToScreen(offset.x, offset.y);
|
||||
mouseX = point.x;
|
||||
mouseY = point.y;
|
||||
|
||||
// We need to listen on document itself, since mouse can go outside of the
|
||||
// window, and we will loose it
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
// no need to worry about mouse events when touch is happening
|
||||
if (touchInProgress) return;
|
||||
|
||||
if (e.ctrlKey) return;
|
||||
|
||||
triggerPanStart();
|
||||
|
||||
const offset = getOffsetXY(e);
|
||||
const point = transformToScreen(offset.x, offset.y);
|
||||
const dx = point.x - mouseX;
|
||||
const dy = point.y - mouseY;
|
||||
|
||||
mouseX = point.x;
|
||||
mouseY = point.y;
|
||||
|
||||
internalMoveBy(dx, dy);
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
triggerPanEnd();
|
||||
releaseDocumentMouse();
|
||||
}
|
||||
|
||||
function releaseDocumentMouse() {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
panstartFired = false;
|
||||
}
|
||||
|
||||
function releaseTouches() {
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
document.removeEventListener('touchend', handleTouchEnd);
|
||||
document.removeEventListener('touchcancel', handleTouchEnd);
|
||||
panstartFired = false;
|
||||
multiTouch = false;
|
||||
touchInProgress = false;
|
||||
}
|
||||
|
||||
function onMouseWheel(e: WheelEvent) {
|
||||
smoothScroll.cancel();
|
||||
|
||||
let delta = e.deltaY;
|
||||
if (e.deltaMode > 0) delta *= 100;
|
||||
|
||||
const scaleMultiplier = getScaleMultiplier(delta);
|
||||
|
||||
if (scaleMultiplier !== 1) {
|
||||
const offset = transformOrigin
|
||||
? getTransformOriginOffset()
|
||||
: getOffsetXY(e);
|
||||
publicZoomTo(offset.x, offset.y, scaleMultiplier);
|
||||
}
|
||||
}
|
||||
|
||||
function getOffsetXY(e: MouseEvent | Touch) {
|
||||
// let offsetX, offsetY;
|
||||
// I tried using e.offsetX, but that gives wrong results for svg, when user clicks on a path.
|
||||
const ownerRect = owner.getBoundingClientRect();
|
||||
const offsetX = e.clientX - ownerRect.left;
|
||||
const offsetY = e.clientY - ownerRect.top;
|
||||
|
||||
return { x: offsetX, y: offsetY };
|
||||
}
|
||||
|
||||
function getTransformOriginOffset() {
|
||||
const ownerRect = owner.getBoundingClientRect();
|
||||
return {
|
||||
x: ownerRect.width * transformOrigin.x,
|
||||
y: ownerRect.height * transformOrigin.y,
|
||||
};
|
||||
}
|
||||
|
||||
function publicZoomTo(
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
scaleMultiplier: number,
|
||||
) {
|
||||
smoothScroll.cancel();
|
||||
cancelZoomAnimation();
|
||||
return zoomByRatio(clientX, clientY, scaleMultiplier);
|
||||
}
|
||||
|
||||
function cancelZoomAnimation() {
|
||||
if (zoomToAnimation) {
|
||||
zoomToAnimation.cancel();
|
||||
zoomToAnimation = null;
|
||||
}
|
||||
}
|
||||
|
||||
function getScaleMultiplier(delta: number) {
|
||||
const sign = Math.sign(delta);
|
||||
const deltaAdjustedSpeed = Math.min(0.25, Math.abs((speed * delta) / 128));
|
||||
return 1 - sign * deltaAdjustedSpeed;
|
||||
}
|
||||
|
||||
function triggerPanStart() {
|
||||
if (!panstartFired) {
|
||||
panstartFired = true;
|
||||
smoothScroll.start();
|
||||
}
|
||||
}
|
||||
|
||||
function triggerPanEnd() {
|
||||
if (panstartFired) {
|
||||
// we should never run smooth scrolling if it was multiTouch (pinch zoom animation):
|
||||
if (!multiTouch) smoothScroll.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseTransformOrigin(options: TransformOrigin) {
|
||||
if (!options) return;
|
||||
if (typeof options === 'object') {
|
||||
if (!isNumber(options.x) || !isNumber(options.y)) failTransformOrigin();
|
||||
return options;
|
||||
}
|
||||
|
||||
failTransformOrigin();
|
||||
}
|
||||
|
||||
function failTransformOrigin() {
|
||||
throw new Error(
|
||||
[
|
||||
'Cannot parse transform origin.',
|
||||
'Some good examples:',
|
||||
' "center center" can be achieved with {x: 0.5, y: 0.5}',
|
||||
' "top center" can be achieved with {x: 0.5, y: 0}',
|
||||
' "bottom right" can be achieved with {x: 1, y: 1}',
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
function validateBounds(bounds: boolean | Bounds) {
|
||||
if (!bounds) return;
|
||||
if (typeof bounds === 'boolean') return; // this is okay
|
||||
// otherwise need to be more thorough:
|
||||
const validBounds =
|
||||
isNumber(bounds.left) &&
|
||||
isNumber(bounds.top) &&
|
||||
isNumber(bounds.bottom) &&
|
||||
isNumber(bounds.right);
|
||||
|
||||
if (!validBounds)
|
||||
throw new Error(
|
||||
'Bounds object is not valid. It can be: ' +
|
||||
'undefined, boolean (true|false) or an object {left, top, right, bottom}',
|
||||
);
|
||||
}
|
||||
|
||||
function isNumber(x: number) {
|
||||
return Number.isFinite(x);
|
||||
}
|
||||
|
||||
// IE 11 does not support isNaN:
|
||||
function isNaN(value: unknown) {
|
||||
if (Number.isNaN) {
|
||||
return Number.isNaN(value);
|
||||
}
|
||||
|
||||
return value !== value;
|
||||
}
|
146
app/src/lib/panzoom/kinetic.ts
Normal file
146
app/src/lib/panzoom/kinetic.ts
Normal file
@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Allows smooth kinetic scrolling of the surface
|
||||
*/
|
||||
export default function kinetic(
|
||||
getPoint: () => { x: number; y: number },
|
||||
scroll: (x: number, y: number) => void,
|
||||
settings: Record<string, unknown>,
|
||||
) {
|
||||
if (typeof settings !== 'object') {
|
||||
// setting could come as boolean, we should ignore it, and use an object.
|
||||
settings = {};
|
||||
}
|
||||
|
||||
const minVelocity =
|
||||
typeof settings.minVelocity === 'number' ? settings.minVelocity : 5;
|
||||
const amplitude =
|
||||
typeof settings.amplitude === 'number' ? settings.amplitude : 0.25;
|
||||
const cancelAnimationFrame =
|
||||
typeof settings.cancelAnimationFrame === 'function'
|
||||
? settings.cancelAnimationFrame
|
||||
: getCancelAnimationFrame();
|
||||
const requestAnimationFrame =
|
||||
typeof settings.requestAnimationFrame === 'function'
|
||||
? settings.requestAnimationFrame
|
||||
: getRequestAnimationFrame();
|
||||
|
||||
let lastPoint: { x: number; y: number };
|
||||
let timestamp: number;
|
||||
const timeConstant = 342;
|
||||
|
||||
let ticker: unknown;
|
||||
let vx: number, targetX: number, ax: number;
|
||||
let vy: number, targetY: number, ay: number;
|
||||
|
||||
let raf: unknown;
|
||||
|
||||
return {
|
||||
start: start,
|
||||
stop: stop,
|
||||
cancel: dispose,
|
||||
};
|
||||
|
||||
function dispose() {
|
||||
cancelAnimationFrame(ticker);
|
||||
cancelAnimationFrame(raf);
|
||||
}
|
||||
|
||||
function start() {
|
||||
lastPoint = getPoint();
|
||||
|
||||
ax = ay = vx = vy = 0;
|
||||
timestamp = Date.now();
|
||||
|
||||
cancelAnimationFrame(ticker);
|
||||
cancelAnimationFrame(raf);
|
||||
|
||||
// we start polling the point position to accumulate velocity
|
||||
// Once we stop(), we will use accumulated velocity to keep scrolling
|
||||
// an object.
|
||||
ticker = requestAnimationFrame(track);
|
||||
}
|
||||
|
||||
function track() {
|
||||
const now = Date.now();
|
||||
const elapsed = now - timestamp;
|
||||
timestamp = now;
|
||||
|
||||
const currentPoint = getPoint();
|
||||
|
||||
const dx = currentPoint.x - lastPoint.x;
|
||||
const dy = currentPoint.y - lastPoint.y;
|
||||
|
||||
lastPoint = currentPoint;
|
||||
|
||||
const dt = 1000 / (1 + elapsed);
|
||||
|
||||
// moving average
|
||||
vx = 0.8 * dx * dt + 0.2 * vx;
|
||||
vy = 0.8 * dy * dt + 0.2 * vy;
|
||||
|
||||
ticker = requestAnimationFrame(track);
|
||||
}
|
||||
|
||||
function stop() {
|
||||
cancelAnimationFrame(ticker);
|
||||
cancelAnimationFrame(raf);
|
||||
|
||||
const currentPoint = getPoint();
|
||||
|
||||
targetX = currentPoint.x;
|
||||
targetY = currentPoint.y;
|
||||
timestamp = Date.now();
|
||||
|
||||
if (vx < -minVelocity || vx > minVelocity) {
|
||||
ax = amplitude * vx;
|
||||
targetX += ax;
|
||||
}
|
||||
|
||||
if (vy < -minVelocity || vy > minVelocity) {
|
||||
ay = amplitude * vy;
|
||||
targetY += ay;
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(autoScroll);
|
||||
}
|
||||
|
||||
function autoScroll() {
|
||||
const elapsed = Date.now() - timestamp;
|
||||
|
||||
let moving = false;
|
||||
let dx = 0;
|
||||
let dy = 0;
|
||||
|
||||
if (ax) {
|
||||
dx = -ax * Math.exp(-elapsed / timeConstant);
|
||||
|
||||
if (dx > 0.5 || dx < -0.5) moving = true;
|
||||
else dx = ax = 0;
|
||||
}
|
||||
|
||||
if (ay) {
|
||||
dy = -ay * Math.exp(-elapsed / timeConstant);
|
||||
|
||||
if (dy > 0.5 || dy < -0.5) moving = true;
|
||||
else dy = ay = 0;
|
||||
}
|
||||
|
||||
if (moving) {
|
||||
scroll(targetX + dx, targetY + dy);
|
||||
raf = requestAnimationFrame(autoScroll);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getCancelAnimationFrame() {
|
||||
if (typeof cancelAnimationFrame === 'function') return cancelAnimationFrame;
|
||||
return clearTimeout;
|
||||
}
|
||||
|
||||
function getRequestAnimationFrame() {
|
||||
if (typeof requestAnimationFrame === 'function') return requestAnimationFrame;
|
||||
|
||||
return function (handler: () => void) {
|
||||
return setTimeout(handler, 16);
|
||||
};
|
||||
}
|
137
app/src/lib/runtime-executor.ts
Normal file
137
app/src/lib/runtime-executor.ts
Normal file
@ -0,0 +1,137 @@
|
||||
import type { Graph, NodeRegistry, NodeType, RuntimeExecutor } from "@nodes/types";
|
||||
|
||||
export class MemoryRuntimeExecutor implements RuntimeExecutor {
|
||||
|
||||
constructor(private registry: NodeRegistry) { }
|
||||
|
||||
private getNodeTypes(graph: Graph) {
|
||||
const typeMap = new Map<string, NodeType>();
|
||||
for (const node of graph.nodes) {
|
||||
if (!typeMap.has(node.type)) {
|
||||
const type = this.registry.getNode(node.type);
|
||||
if (type) {
|
||||
typeMap.set(node.type, type);
|
||||
}
|
||||
}
|
||||
}
|
||||
return typeMap;
|
||||
}
|
||||
|
||||
private addMetaData(graph: Graph) {
|
||||
|
||||
// First, lets check if all nodes have a type
|
||||
const typeMap = this.getNodeTypes(graph);
|
||||
|
||||
const outputNode = graph.nodes.find(node => node.type === "output");
|
||||
if (!outputNode) {
|
||||
throw new Error("No output node found");
|
||||
}
|
||||
outputNode.tmp = outputNode.tmp || {};
|
||||
outputNode.tmp.depth = 0;
|
||||
|
||||
const nodeMap = new Map(graph.nodes.map(node => [node.id, node]));
|
||||
|
||||
// loop through all edges and assign the parent and child nodes to each node
|
||||
for (const edge of graph.edges) {
|
||||
const [parentId, _parentOutput, childId, childInput] = edge;
|
||||
const parent = nodeMap.get(parentId);
|
||||
const child = nodeMap.get(childId);
|
||||
if (parent && child) {
|
||||
parent.tmp = parent.tmp || {};
|
||||
parent.tmp.children = parent.tmp.children || [];
|
||||
parent.tmp.children.push(child);
|
||||
child.tmp = child.tmp || {};
|
||||
child.tmp.parents = child.tmp.parents || [];
|
||||
child.tmp.parents.push(parent);
|
||||
child.tmp.inputNodes = child.tmp.inputNodes || {};
|
||||
child.tmp.inputNodes[childInput] = parent;
|
||||
}
|
||||
}
|
||||
|
||||
const nodes = []
|
||||
|
||||
// loop through all the nodes and assign each nodes its depth
|
||||
const stack = [outputNode];
|
||||
while (stack.length) {
|
||||
const node = stack.pop();
|
||||
if (node) {
|
||||
node.tmp = node.tmp || {};
|
||||
|
||||
node.tmp.type = typeMap.get(node.type);
|
||||
|
||||
if (node?.tmp?.depth === undefined) {
|
||||
node.tmp.depth = 0;
|
||||
}
|
||||
if (node?.tmp?.parents !== undefined) {
|
||||
for (const parent of node.tmp.parents) {
|
||||
parent.tmp = parent.tmp || {};
|
||||
if (parent.tmp?.depth === undefined) {
|
||||
parent.tmp.depth = node.tmp.depth + 1;
|
||||
stack.push(parent);
|
||||
} else {
|
||||
parent.tmp.depth = Math.max(parent.tmp.depth, node.tmp.depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nodes.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
return [outputNode, nodes] as const;
|
||||
}
|
||||
|
||||
execute(graph: Graph) {
|
||||
|
||||
// Then we add some metadata to the graph
|
||||
const [outputNode, nodes] = this.addMetaData(graph);
|
||||
|
||||
/*
|
||||
* Here we sort the nodes into buckets, which we then execute one by one
|
||||
* +-b2-+-b1-+---b0---+
|
||||
* | | | |
|
||||
* | n3 | n2 | Output |
|
||||
* | n6 | n4 | Level |
|
||||
* | | n5 | |
|
||||
* | | | |
|
||||
* +----+----+--------+
|
||||
*/
|
||||
|
||||
// we execute the nodes from the bottom up
|
||||
const sortedNodes = nodes.sort((a, b) => (b.tmp?.depth || 0) - (a.tmp?.depth || 0));
|
||||
|
||||
|
||||
// here we store the intermediate results of the nodes
|
||||
const results: Record<string, string | boolean | number> = {};
|
||||
|
||||
for (const node of sortedNodes) {
|
||||
if (node?.tmp && node?.tmp?.type?.execute) {
|
||||
const inputs: Record<string, string | number | boolean> = {};
|
||||
for (const [key, input] of Object.entries(node.tmp.type.inputs || {})) {
|
||||
|
||||
// check if the input is connected to another node
|
||||
const inputNode = node.tmp.inputNodes?.[key];
|
||||
if (inputNode) {
|
||||
if (results[inputNode.id] === undefined) {
|
||||
throw new Error("Input node has no result");
|
||||
}
|
||||
inputs[key] = results[inputNode.id];
|
||||
continue;
|
||||
}
|
||||
|
||||
// if the input is not connected to another node, we use the value from the node itself
|
||||
inputs[key] = node.props?.[key] ?? input?.value;
|
||||
}
|
||||
|
||||
// execute the node and store the result
|
||||
results[node.id] = node.tmp.type.execute(inputs) as number;;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// return the result of the parent of the output node
|
||||
return results[outputNode.tmp?.parents?.[0].id as number] as string
|
||||
|
||||
}
|
||||
|
||||
}
|
0
app/src/lib/stores/localStore.ts
Normal file
0
app/src/lib/stores/localStore.ts
Normal file
5
app/src/lib/stores/settings.ts
Normal file
5
app/src/lib/stores/settings.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
export const settings = writable({
|
||||
useHtml: false
|
||||
});
|
5
app/src/routes/+layout.svelte
Normal file
5
app/src/routes/+layout.svelte
Normal file
@ -0,0 +1,5 @@
|
||||
<script lang="ts">
|
||||
import "./app.css";
|
||||
</script>
|
||||
|
||||
<slot />
|
2
app/src/routes/+layout.ts
Normal file
2
app/src/routes/+layout.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export const prerender = true
|
||||
export const ssr = false
|
88
app/src/routes/+page.svelte
Normal file
88
app/src/routes/+page.svelte
Normal file
@ -0,0 +1,88 @@
|
||||
<script lang="ts">
|
||||
import { Canvas } from "@threlte/core";
|
||||
import { GraphManager } from "$lib/graph-manager";
|
||||
import Graph from "$lib/components/graph/Graph.svelte";
|
||||
import { MemoryRuntimeExecutor } from "$lib/runtime-executor";
|
||||
import { MemoryNodeRegistry } from "$lib/node-registry";
|
||||
import { LinearSRGBColorSpace } from "three";
|
||||
import Details from "$lib/components/Details.svelte";
|
||||
import { JsonView } from "@zerodevx/svelte-json-view";
|
||||
|
||||
const nodeRegistry = new MemoryNodeRegistry();
|
||||
const runtimeExecutor = new MemoryRuntimeExecutor(nodeRegistry);
|
||||
|
||||
const graphManager = new GraphManager(nodeRegistry, runtimeExecutor);
|
||||
|
||||
let graph = localStorage.getItem("graph");
|
||||
if (graph) {
|
||||
graphManager.load(JSON.parse(graph));
|
||||
} else {
|
||||
graphManager.load(graphManager.createTemplate("tree", 5));
|
||||
}
|
||||
|
||||
graphManager.on("save", (graph) => {
|
||||
localStorage.setItem("graph", JSON.stringify(graph));
|
||||
});
|
||||
|
||||
let debug: undefined;
|
||||
</script>
|
||||
|
||||
<div class="wrapper">
|
||||
<Details>
|
||||
<button
|
||||
on:click={() => graphManager.load(graphManager.createTemplate("tree", 5))}
|
||||
>load tree</button
|
||||
>
|
||||
<br />
|
||||
<br />
|
||||
<button
|
||||
on:click={() =>
|
||||
graphManager.load(graphManager.createTemplate("grid", 3, 3))}
|
||||
>load grid</button
|
||||
>
|
||||
<br />
|
||||
<br />
|
||||
<JsonView json={debug} />
|
||||
</Details>
|
||||
</div>
|
||||
|
||||
<div id="canvas-wrapper">
|
||||
<Canvas
|
||||
shadows={false}
|
||||
renderMode="on-demand"
|
||||
colorManagementEnabled={false}
|
||||
colorSpace={LinearSRGBColorSpace}
|
||||
>
|
||||
<!-- <PerfMonitor /> -->
|
||||
<Graph graph={graphManager} bind:debug />
|
||||
</Canvas>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#canvas-wrapper {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
}
|
||||
|
||||
:global(html) {
|
||||
background: rgb(13, 19, 32);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(13, 19, 32, 1) 0%,
|
||||
rgba(8, 12, 21, 1) 100%
|
||||
);
|
||||
}
|
||||
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
64
app/src/routes/app.css
Normal file
64
app/src/routes/app.css
Normal file
@ -0,0 +1,64 @@
|
||||
/* fira-code-300 - latin */
|
||||
@font-face {
|
||||
font-display: swap;
|
||||
/* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: 'Fira Code';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
src: url('/fonts/fira-code-v22-latin-300.woff2') format('woff2');
|
||||
/* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
|
||||
/* fira-code-600 - latin */
|
||||
@font-face {
|
||||
font-display: swap;
|
||||
/* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: 'Fira Code';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
src: url('/fonts/fira-code-v22-latin-600.woff2') format('woff2');
|
||||
/* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
|
||||
:root {
|
||||
--font-family: 'Fira Code', monospace;
|
||||
font-family: var(--font-family);
|
||||
|
||||
/* Spacing */
|
||||
--spacing-xs: 4px;
|
||||
/* Extra small spacing */
|
||||
--spacing-sm: 8px;
|
||||
/* Small spacing */
|
||||
--spacing-md: 16px;
|
||||
/* Medium spacing */
|
||||
--spacing-lg: 24px;
|
||||
/* Large spacing */
|
||||
--spacing-xl: 32px;
|
||||
/* Extra large spacing */
|
||||
|
||||
}
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
|
||||
/* Secondary color */
|
||||
--secondary-color: #6c757d;
|
||||
/* Background color */
|
||||
--background-color-lighter: #202020;
|
||||
--background-color: #151515;
|
||||
--background-color-darker: #101010;
|
||||
--text-color: #aeaeae;
|
||||
|
||||
background-color: var(--background-color-darker);
|
||||
}
|
||||
|
||||
body.theme-catppuccin {
|
||||
--text-color: #CDD6F4;
|
||||
--background-color-lighter: #313244;
|
||||
--background-color: #1E1E2E;
|
||||
--background-color-darker: #11111b;
|
||||
}
|
||||
|
||||
/* canvas { */
|
||||
/* display: none !important; */
|
||||
/* } */
|
Reference in New Issue
Block a user