feat: track images with git lfs

This commit is contained in:
2024-04-04 19:17:27 +02:00
parent 5f2b2f59be
commit 9776b5a84a
193 changed files with 3295 additions and 4620 deletions

29
app/.gitignore vendored Normal file
View File

@ -0,0 +1,29 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
.svelte-kit/
vite.config.ts.timestamp*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
build/

7
app/README.md Normal file
View File

@ -0,0 +1,7 @@
# Tauri + Svelte + Typescript
This template should help get you started developing with Tauri, Svelte and TypeScript in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer).

8
app/histoire.config.ts Normal file
View File

@ -0,0 +1,8 @@
import { defineConfig } from 'histoire'
import { HstSvelte } from '@histoire/plugin-svelte'
export default defineConfig({
plugins: [
HstSvelte(),
],
})

49
app/package.json Normal file
View File

@ -0,0 +1,49 @@
{
"name": "@nodes/app",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"tauri:dev": "tauri dev",
"story:dev": "histoire dev",
"story:build": "histoire build",
"story:preview": "histoire preview"
},
"dependencies": {
"@nodes/input-elements": "link:../packages/input-elements",
"@sveltejs/kit": "^2.5.0",
"@tauri-apps/api": "2.0.0-beta.2",
"@tauri-apps/plugin-shell": "^2.0.0-beta.0",
"@threlte/core": "^7.1.0",
"@threlte/extras": "^8.7.5",
"@threlte/flex": "^1.0.1",
"@types/three": "^0.159.0",
"input-elements": "link:../packages/input-elements",
"jsondiffpatch": "^0.6.0",
"meshline": "^3.2.0",
"plantarium-nodes-math": "link:../nodes/math/pkg",
"three": "^0.159.0",
"three.meshline": "^1.4.0"
},
"devDependencies": {
"@histoire/plugin-svelte": "^0.17.9",
"@nodes/types": "link:../packages/types",
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/vite-plugin-svelte": "^3.0.1",
"@tauri-apps/cli": "2.0.0-beta.3",
"@tsconfig/svelte": "^5.0.2",
"@zerodevx/svelte-json-view": "^1.0.9",
"histoire": "^0.17.9",
"internal-ip": "^7.0.0",
"svelte": "^4.2.8",
"svelte-check": "^3.4.6",
"tslib": "^2.6.0",
"typescript": "^5.0.2",
"vite": "^5.1.4",
"vite-plugin-glsl": "^1.2.1",
"vite-plugin-wasm": "^3.3.0"
}
}

1576
app/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

4
app/src-tauri/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
# Generated by Cargo
# will have compiled files and executables
/target/

5250
app/src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

26
app/src-tauri/Cargo.toml Normal file
View File

@ -0,0 +1,26 @@
[package]
name = "tauri-app"
version = "0.0.0"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "tauri_app_lib"
crate-type = ["lib", "cdylib"]
[build-dependencies]
tauri-build = { version = "2.0.0-beta", features = [] }
[dependencies]
tauri = { version = "2.0.0-beta", features = [] }
tauri-plugin-shell = "2.0.0-beta"
serde_json = "1.0"
wasmtime = "18.0.1"
[features]
# this feature is used for production builds or when `devPath` points to the filesystem
# DO NOT REMOVE!!
custom-protocol = ["tauri/custom-protocol"]

3
app/src-tauri/build.rs Normal file
View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
{}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -0,0 +1,5 @@
(module
(func (export "answer") (result i32)
i32.const 42
)
)

80
app/src-tauri/src/lib.rs Normal file
View File

@ -0,0 +1,80 @@
use std::env;
use std::path::PathBuf;
use wasmtime::*;
// Learn more about Tauri commands at https://tauri.app/v1/guides/features/command
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
#[tauri::command]
fn insult(name: &str) -> String {
format!("Hello, {}! Fuck you from Rust!", name)
}
fn get_current_working_dir() -> std::io::Result<PathBuf> {
env::current_dir()
}
#[tauri::command]
fn run_nodes() -> Result<i32, String> {
// An engine stores and configures global compilation settings like
// optimization level, enabled wasm features, etc.
let engine = Engine::default();
let pwd = get_current_working_dir().expect("Cant get wkring dir");
println!(
"{}",
pwd.into_os_string().into_string().expect("cant unwrap")
);
// We start off by creating a `Module` which represents a compiled form
// of our input wasm module. In this case it'll be JIT-compiled after
// we parse the text format.
let module =
Module::from_file(&engine, "src/hello.wat").expect("Could not instatiate hello.wat");
// A `Store` is what will own instances, functions, globals, etc. All wasm
// items are stored within a `Store`, and it's what we'll always be using to
// interact with the wasm world. Custom data can be stored in stores but for
// now we just use `()`.
let mut store = Store::new(&engine, ());
// With a compiled `Module` we can then instantiate it, creating
// an `Instance` which we can actually poke at functions on.
let instance = Instance::new(&mut store, &module, &[]).expect("Could not instatiate module");
// The `Instance` gives us access to various exported functions and items,
// which we access here to pull out our `answer` exported function and
// run it.
let answer = instance
.get_func(&mut store, "answer")
.expect("`answer` was not an exported function");
// There's a few ways we can call the `answer` `Func` value. The easiest
// is to statically assert its signature with `typed` (in this case
// asserting it takes no arguments and returns one i32) and then call it.
let answer = answer
.typed::<(), i32>(&store)
.expect("Can't access answer function");
// And finally we can call our function! Note that the error propagation
// with `?` is done to handle the case where the wasm function traps.
let result = answer
.call(&mut store, ())
.expect("Could not call function");
println!("Answer: {:?}", result);
Ok(result)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.invoke_handler(tauri::generate_handler![greet, run_nodes, insult])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
tauri_app_lib::run()
}

View File

@ -0,0 +1,34 @@
{
"productName": "tauri-app",
"version": "0.0.0",
"identifier": "nodes.max.richter.dev",
"build": {
"beforeDevCommand": "pnpm dev",
"devUrl": "http://localhost:5173",
"beforeBuildCommand": "pnpm build",
"frontendDist": "../build"
},
"app": {
"windows": [
{
"title": "tauri-app",
"width": 800,
"height": 600
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}

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

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

15
app/src/app.html Normal file
View 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>

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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>

View File

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

View File

@ -0,0 +1,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}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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}

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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>

View File

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

View File

@ -0,0 +1,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>

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

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

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

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

View File

@ -0,0 +1,2 @@
export { grid } from "./grid";
export { tree } from "./tree";

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

View 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 = {};
}
}

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

View File

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

View File

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

View File

@ -0,0 +1,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];
}
}

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

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

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

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

View File

View File

@ -0,0 +1,5 @@
import { writable } from "svelte/store";
export const settings = writable({
useHtml: false
});

View File

@ -0,0 +1,5 @@
<script lang="ts">
import "./app.css";
</script>
<slot />

View File

@ -0,0 +1,2 @@
export const prerender = true
export const ssr = false

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

Binary file not shown.

Binary file not shown.

1
app/static/svelte.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

6
app/static/tauri.svg Normal file
View File

@ -0,0 +1,6 @@
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

1
app/static/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

18
app/svelte.config.js Normal file
View File

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

21
app/tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler",
"types": [
"vite-plugin-glsl/ext"
]
}
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

9
app/tsconfig.node.json Normal file
View File

@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler"
},
"include": ["vite.config.ts"]
}

11
app/vite.config.ts Normal file
View File

@ -0,0 +1,11 @@
import { sveltekit } from '@sveltejs/kit/vite'
import { defineConfig } from 'vite'
import glsl from "vite-plugin-glsl";
import wasm from "vite-plugin-wasm";
export default defineConfig({
plugins: [sveltekit(), glsl(), wasm()],
ssr: {
noExternal: ['three'],
}
})