47 Commits

Author SHA1 Message Date
f98b90dcd3 Merge remote-tracking branch 'origin/main' into feat/drop-node-on-connection 2026-01-20 15:17:17 +01:00
Felix Hungenberg
c46bf9e64f ci: fix lockfile
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 2m1s
2026-01-19 16:57:54 +01:00
Felix Hungenberg
0cfd1e5c96 feat(ui): add id prop for inputs
Some checks failed
Deploy to GitHub Pages / build_site (push) Failing after 5s
2026-01-19 16:43:41 +01:00
Felix Hungenberg
ecfd4d5f2f feat(ui): cleanup integer input
remove warnings, migrate deprecated (dispatch to prop), include min max switch from float input, include esc/enter from float input, include precision, include partial styling from float input
2026-01-19 16:43:07 +01:00
Felix Hungenberg
03102fdc75 ci: improve dev setup
Some checks failed
Deploy to GitHub Pages / build_site (push) Failing after 7s
2026-01-19 16:33:24 +01:00
Felix Hungenberg
0bd00b0192 feat(app): add description and input debug info to node params 2026-01-19 16:32:19 +01:00
Felix Hungenberg
edbc71ed8f add dprint config 2026-01-19 16:31:27 +01:00
Felix Hungenberg
4485cef82b chore(app): remove old (pnpm v6 - now 9) pnpm lock 2026-01-19 16:30:45 +01:00
cdef71265e Add svelte language server
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 2m45s
2026-01-19 16:26:15 +01:00
2904c13c41 feat: init 2026-01-19 16:25:29 +01:00
450262b4ae fix(app): remove unused func
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 2m7s
2026-01-19 14:24:47 +01:00
11de746c01 feat(app): allow disabling of runtime/registry caches
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 1m58s
2026-01-19 14:22:14 +01:00
83cb2bd950 feat: move analytics script to env
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 2m2s
2026-01-19 14:04:00 +01:00
f5cea555cd chore: Add flake and direnv stuff
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 2m7s
2026-01-19 12:50:12 +01:00
Max Richter
987ece2a4b fix: update performance bars to work with tailwind
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 2m15s
2026-01-18 18:55:18 +01:00
Max Richter
8d2e3f006b fix: make graph source work 2026-01-18 18:54:53 +01:00
Max Richter
80d3e117b4 feat: update sidebar to svelte-5
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 2m1s
2026-01-18 18:39:02 +01:00
Max Richter
8a540522dd chore: replace unocss with tailwind
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 2m4s
2026-01-18 17:11:47 +01:00
Max Richter
a11214072f chore: some updates
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 2m6s
2026-01-18 16:27:42 +01:00
d068828b68 refactor: rename state.svelte.ts to graph-state.svelte.ts
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 1m59s
2025-12-09 20:00:52 +01:00
3565a18364 feat: cache everything in node store not only wasm 2025-12-05 14:19:29 +01:00
73be4fdd73 feat: better handle node position updates 2025-12-05 14:19:11 +01:00
702c3ee6cf feat: better handle camera positioning 2025-12-05 14:18:56 +01:00
98672eb702 fix: error that changes in active node panel did not get saved 2025-12-05 12:28:30 +01:00
3eafdc50b1 feat: keep benchmark result if panel is hidden 2025-12-05 11:49:10 +01:00
Max Richter
548e445eb7 fix: correctly show hide geometries in geometrypool
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 2m4s
2025-12-03 22:59:06 +01:00
db77a4fd94 Merge pull request 'refactor: split ui/runtime/serialized node types' (#10) from refactor/split-node-runtime-types into main
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 2m3s
Reviewed-on: #10
2025-12-03 19:19:17 +01:00
Max Richter
7ae1fae3b9 refactor: split ui/runtime/serialized node types
Closes #6
2025-12-03 19:18:56 +01:00
1126cf8f9f feat: dont use custom edge geometry
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 1m55s
2025-12-03 10:33:24 +01:00
Max Richter
ef479d0557 chore: update
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 3m50s
2025-12-02 17:31:58 +01:00
Max Richter
a1c926c3cf fix: better handle randomGeneration 2025-12-02 17:27:34 +01:00
ca8b1e15ac chore: cleanup edge and node code
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 2m8s
2025-12-02 16:59:43 +01:00
4878d02705 refactor: remove unneeded random var in node 2025-12-02 16:59:29 +01:00
2b4c81f557 fix: make sure new nodes are reactive
Closes #7
2025-12-02 16:59:11 +01:00
d178f812fb refactor: move event handlers to own classes 2025-12-02 16:58:31 +01:00
669a2c7991 docs: remove placeholder content in readme 2025-12-02 15:20:26 +01:00
becd7a1eb3 fix: make sure we do not pass svelte state into comlink
cant clone proxies
2025-12-02 15:20:13 +01:00
d140f42468 feat: better a18n for node parameters
Dunno of a18n would even be possible for the node graph
2025-12-02 15:19:48 +01:00
be835e5cff fix: better stroke width and color for edges 2025-12-02 15:00:41 +01:00
Max Richter
6229becfd8 fix: display add menu at correct position
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 1m58s
2025-12-01 22:39:43 +01:00
Max Richter
af944cefaa chore: disable cache from runtime executor 2025-12-01 22:39:06 +01:00
Max Richter
a1ea56093c fix: correctly handle node wrapper resizing
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 1m57s
2025-12-01 19:48:40 +01:00
Max Richter
1850e21810 fix: make clipboard work 2025-12-01 19:30:44 +01:00
Max Richter
7e51cc5ea1 chore: some updates
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 1m58s
2025-12-01 18:29:47 +01:00
Max Richter
1ea544e765 chore: rename @nodes -> @nodarium for everything
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 3m33s
2025-12-01 17:03:14 +01:00
e5658b8a7e feat: initial auto connect nodes
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 2m35s
2025-11-26 17:27:32 +01:00
d3a9b3f056 fix: make node wasm loading work
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 2m32s
2025-11-26 12:10:25 +01:00
133 changed files with 6855 additions and 6701 deletions

59
.dprint.jsonc Normal file
View File

@@ -0,0 +1,59 @@
{
"$schema": "https://dprint.dev/schemas/v0.json",
"indentWidth": 2,
"typescript": {
// https://dprint.dev/plugins/typescript/config/
"quoteStyle": "preferSingle",
"trailingCommas": "never"
},
"json": {
},
"markdown": {
},
"toml": {
},
"dockerfile": {
},
"ruff": {
},
"jupyter": {
},
"malva": {
},
"markup": {
},
"yaml": {
},
"graphql": {
},
"exec": {
"cwd": "${configDir}",
"commands": [{
"command": "rustfmt --edition 2024",
"exts": ["rs"],
// add the config files for automatic cache invalidation when the rust version or rustfmt config changes
"cacheKeyFiles": [
"rustfmt.toml",
"rust-toolchain.toml"
]
}]
},
"excludes": [
"**/node_modules",
"**/*-lock.json"
],
"plugins": [
"https://plugins.dprint.dev/typescript-0.95.13.wasm",
"https://plugins.dprint.dev/json-0.21.1.wasm",
"https://plugins.dprint.dev/markdown-0.20.0.wasm",
"https://plugins.dprint.dev/toml-0.7.0.wasm",
"https://plugins.dprint.dev/dockerfile-0.3.3.wasm",
"https://plugins.dprint.dev/ruff-0.6.11.wasm",
"https://plugins.dprint.dev/jupyter-0.2.1.wasm",
"https://plugins.dprint.dev/g-plane/malva-v0.15.1.wasm",
"https://plugins.dprint.dev/g-plane/markup_fmt-v0.25.3.wasm",
"https://plugins.dprint.dev/g-plane/pretty_yaml-v0.5.1.wasm",
"https://plugins.dprint.dev/g-plane/pretty_graphql-v0.2.3.wasm",
"https://plugins.dprint.dev/exec-0.6.0.json@a054130d458f124f9b5c91484833828950723a5af3f8ff2bd1523bd47b83b364"
]
}

1
.envrc Normal file
View File

@@ -0,0 +1 @@
use flake

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ node_modules/
# Added by cargo
/target
.direnv/

1
app/.env Normal file
View File

@@ -0,0 +1 @@
PUBLIC_ANALYTIC_SCRIPT=""

View File

@@ -1,7 +1 @@
# 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).
# Nodarium App

View File

@@ -1,5 +1,5 @@
{
"name": "@nodes/app",
"name": "@nodarium/app",
"private": true,
"version": "0.0.0",
"type": "module",
@@ -10,37 +10,37 @@
"preview": "vite preview"
},
"dependencies": {
"@nodes/registry": "link:../packages/registry",
"@nodes/ui": "link:../packages/ui",
"@nodes/utils": "link:../packages/utils",
"@sveltejs/kit": "^2.49.0",
"@threlte/core": "8.3.0",
"@threlte/extras": "9.7.0",
"@types/three": "^0.181.0",
"@unocss/reset": "^66.5.9",
"@nodarium/registry": "link:../packages/registry",
"@nodarium/ui": "link:../packages/ui",
"@nodarium/utils": "link:../packages/utils",
"@sveltejs/kit": "^2.50.0",
"@tailwindcss/vite": "^4.1.18",
"@threlte/core": "8.3.1",
"@threlte/extras": "9.7.1",
"comlink": "^4.4.2",
"file-saver": "^2.0.5",
"idb": "^8.0.3",
"jsondiffpatch": "^0.7.3",
"three": "^0.181.2"
"tailwindcss": "^4.1.18",
"three": "^0.182.0"
},
"devDependencies": {
"@iconify-json/tabler": "^1.2.23",
"@nodes/types": "link:../packages/types",
"@iconify-json/tabler": "^1.2.26",
"@iconify/tailwind4": "^1.2.1",
"@nodarium/types": "link:../packages/types",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tsconfig/svelte": "^5.0.6",
"@types/file-saver": "^2.0.7",
"@unocss/preset-icons": "^66.5.9",
"svelte": "^5.43.14",
"svelte-check": "^4.3.4",
"@types/three": "^0.182.0",
"svelte": "^5.46.4",
"svelte-check": "^4.3.5",
"tslib": "^2.8.1",
"typescript": "^5.9.3",
"unocss": "^66.5.9",
"vite": "^7.2.4",
"vite": "^7.3.1",
"vite-plugin-comlink": "^5.3.0",
"vite-plugin-glsl": "^1.5.4",
"vite-plugin-glsl": "^1.5.5",
"vite-plugin-wasm": "^3.5.0",
"vitest": "^4.0.13"
"vitest": "^4.0.17"
}
}

1576
app/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

6
app/src/app.css Normal file
View File

@@ -0,0 +1,6 @@
@import "tailwindcss";
@source "../../packages/ui/**/*.svelte";
@plugin "@iconify/tailwind4" {
prefix: "i";
icon-sets: from-folder(custom, "./src/lib/icons")
};

View File

@@ -5,7 +5,6 @@
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/svelte.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script defer src="https://umami.max-richter.dev/script.js" data-website-id="585c442b-0524-4874-8955-f9853b44b17e"></script>
%sveltekit.head%
<title>Nodes</title>
<script>

2
app/src/lib/config.ts Normal file
View File

@@ -0,0 +1,2 @@
import { PUBLIC_ANALYTIC_SCRIPT } from "$env/static/public";
export const ANALYTIC_SCRIPT = PUBLIC_ANALYTIC_SCRIPT;

View File

@@ -1,21 +1,25 @@
<script lang="ts">
import type { GraphManager } from "./graph-manager.svelte";
import { HTML } from "@threlte/extras";
import { onMount } from "svelte";
import type { NodeType } from "@nodes/types";
import type { NodeInstance, NodeId } from "@nodarium/types";
import { getGraphManager, getGraphState } from "../graph-state.svelte";
type Props = {
position: [x: number, y: number] | null;
graph: GraphManager;
onnode: (n: NodeInstance) => void;
};
let { position = $bindable(), graph }: Props = $props();
const { onnode }: Props = $props();
const graph = getGraphManager();
const graphState = getGraphState();
let input: HTMLInputElement;
let value = $state<string>();
let activeNodeId = $state<NodeType>();
let activeNodeId = $state<NodeId>();
const allNodes = graph.getNodeDefinitions();
const allNodes = graphState.activeSocket
? graph.getPossibleNodes(graphState.activeSocket)
: graph.getNodeDefinitions();
function filterNodes() {
return allNodes.filter((node) => node.id.includes(value ?? ""));
@@ -25,7 +29,7 @@
$effect(() => {
if (nodes) {
if (activeNodeId === undefined) {
activeNodeId = nodes[0].id;
activeNodeId = nodes?.[0]?.id;
} else if (nodes.length) {
const node = nodes.find((node) => node.id === activeNodeId);
if (!node) {
@@ -35,11 +39,22 @@
}
});
function handleNodeCreation(nodeType: NodeInstance["type"]) {
if (!graphState.addMenuPosition) return;
onnode?.({
id: -1,
type: nodeType,
position: [...graphState.addMenuPosition],
props: {},
state: {},
});
}
function handleKeyDown(event: KeyboardEvent) {
event.stopImmediatePropagation();
if (event.key === "Escape") {
position = null;
graphState.addMenuPosition = null;
return;
}
@@ -56,9 +71,8 @@
}
if (event.key === "Enter") {
if (activeNodeId && position) {
graph.createNode({ type: activeNodeId, position, props: {} });
position = null;
if (activeNodeId && graphState.addMenuPosition) {
handleNodeCreation(activeNodeId);
}
return;
}
@@ -70,7 +84,11 @@
});
</script>
<HTML position.x={position?.[0]} position.z={position?.[1]} transform={false}>
<HTML
position.x={graphState.addMenuPosition?.[0]}
position.z={graphState.addMenuPosition?.[1]}
transform={false}
>
<div class="add-menu-wrapper">
<div class="header">
<input
@@ -95,18 +113,10 @@
aria-selected={node.id === activeNodeId}
onkeydown={(event) => {
if (event.key === "Enter") {
if (position) {
graph.createNode({ type: node.id, position, props: {} });
position = null;
}
}
}}
onmousedown={() => {
if (position) {
graph.createNode({ type: node.id, position, props: {} });
position = null;
handleNodeCreation(node.id);
}
}}
onmousedown={() => handleNodeCreation(node.id)}
onfocus={() => {
activeNodeId = node.id;
}}

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import type { NodeDefinition, NodeRegistry } from "@nodes/types";
import { onDestroy, onMount } from "svelte";
import type { NodeDefinition, NodeRegistry } from "@nodarium/types";
import { onMount } from "svelte";
let mx = $state(0);
let my = $state(0);

View File

@@ -5,15 +5,17 @@
color: colors.edge.clone(),
toneMapped: false,
});
let lineColor = $state(colors.edge.clone().convertSRGBToLinear());
$effect.root(() => {
$effect(() => {
appSettings.value.theme;
circleMaterial.color = colors.edge.clone().convertSRGBToLinear();
lineColor = colors.edge.clone().convertSRGBToLinear();
});
});
const lineCache = new Map<number, BufferGeometry>();
const curve = new CubicBezierCurve(
new Vector2(0, 0),
new Vector2(0, 0),
@@ -24,46 +26,36 @@
<script lang="ts">
import { T } from "@threlte/core";
import { MeshLineMaterial } from "@threlte/extras";
import { BufferGeometry, MeshBasicMaterial, Vector3 } from "three";
import { MeshLineGeometry, MeshLineMaterial } from "@threlte/extras";
import { MeshBasicMaterial, Vector3 } from "three";
import { CubicBezierCurve } from "three/src/extras/curves/CubicBezierCurve.js";
import { Vector2 } from "three/src/math/Vector2.js";
import { createEdgeGeometry } from "./createEdgeGeometry.js";
import { appSettings } from "$lib/settings/app-settings.svelte";
type Props = {
from: { x: number; y: number };
to: { x: number; y: number };
x1: number;
y1: number;
x2: number;
y2: number;
z: number;
};
const { from, to, z }: Props = $props();
const { x1, y1, x2, y2, z }: Props = $props();
let geometry: BufferGeometry | null = $state(null);
const thickness = $derived(Math.max(0.001, 0.00082 * Math.exp(0.055 * z)));
const lineColor = $derived(
appSettings.value.theme && colors.edge.clone().convertSRGBToLinear(),
);
let points = $state<Vector3[]>([]);
let lastId: number | null = null;
const primeA = 31;
const primeB = 37;
let lastId: string | null = null;
function update() {
const new_x = to.x - from.x;
const new_y = to.y - from.y;
const curveId = new_x * primeA + new_y * primeB;
const new_x = x2 - x1;
const new_y = y2 - y1;
const curveId = `${x1}-${y1}-${x2}-${y2}`;
if (lastId === curveId) {
return;
}
const mid = new Vector2(new_x / 2, new_y / 2);
if (lineCache.has(curveId)) {
geometry = lineCache.get(curveId)!;
return;
}
lastId = curveId;
const length = Math.floor(
Math.sqrt(Math.pow(new_x, 2) + Math.pow(new_y, 2)) / 4,
@@ -72,29 +64,26 @@
const samples = Math.max(length * 16, 10);
curve.v0.set(0, 0);
curve.v1.set(mid.x, 0);
curve.v2.set(mid.x, new_y);
curve.v1.set(new_x / 2, 0);
curve.v2.set(new_x / 2, new_y);
curve.v3.set(new_x, new_y);
const points = curve
points = curve
.getPoints(samples)
.map((p) => new Vector3(p.x, 0, p.y))
.flat();
geometry = createEdgeGeometry(points);
lineCache.set(curveId, geometry);
}
$effect(() => {
if (from || to) {
if (x1 || x2 || y1 || y2) {
update();
}
});
</script>
<T.Mesh
position.x={from.x}
position.z={from.y}
position.x={x1}
position.z={y1}
position.y={0.8}
rotation.x={-Math.PI / 2}
material={circleMaterial}
@@ -103,8 +92,8 @@
</T.Mesh>
<T.Mesh
position.x={to.x}
position.z={to.y}
position.x={x2}
position.z={y2}
position.y={0.8}
rotation.x={-Math.PI / 2}
material={circleMaterial}
@@ -112,11 +101,7 @@
<T.CircleGeometry args={[0.5, 16]} />
</T.Mesh>
{#if geometry}
<T.Mesh position.x={from.x} position.z={from.y} position.y={0.1} {geometry}>
<MeshLineMaterial
width={Math.max(z * 0.00012, 0.00003)}
color={lineColor}
/>
</T.Mesh>
{/if}
<T.Mesh position.x={x1} position.z={y1} position.y={0.1}>
<MeshLineGeometry {points} />
<MeshLineMaterial width={thickness} color={lineColor} />
</T.Mesh>

View File

@@ -1,12 +0,0 @@
<script lang="ts">
import Edge from "./Edge.svelte";
type Props = {
from: { x: number; y: number };
to: { x: number; y: number };
z: number;
};
const { from, to, z }: Props = $props();
</script>
<Edge {from} {to} {z} />

View File

@@ -1,116 +0,0 @@
import { BufferAttribute, BufferGeometry, Vector3 } from 'three';
import { setXY, setXYZ, setXYZW, setXYZXYZ } from './utils.js';
export function createEdgeGeometry(points: Vector3[]) {
const length = points[0].distanceTo(points[points.length - 1]);
const startRadius = 8;
const constantWidth = 2;
const taperFraction = 0.8 / length;
function ease(t: number) {
return t * t * (3 - 2 * t);
}
let shapeFunction = (alpha: number) => {
if (alpha < taperFraction) {
const easedAlpha = ease(alpha / taperFraction);
return startRadius + (constantWidth - startRadius) * easedAlpha;
} else if (alpha > 1 - taperFraction) {
const easedAlpha = ease((alpha - (1 - taperFraction)) / taperFraction);
return constantWidth + (startRadius - constantWidth) * easedAlpha;
} else {
return constantWidth;
}
};
// 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
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 = 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

@@ -1,21 +1,22 @@
import type {
Edge,
Graph,
Node,
NodeInstance,
NodeDefinition,
NodeInput,
NodeRegistry,
NodeType,
NodeId,
Socket,
} from "@nodes/types";
import { fastHashString } from "@nodes/utils";
} from "@nodarium/types";
import { fastHashString } from "@nodarium/utils";
import { SvelteMap } from "svelte/reactivity";
import EventEmitter from "./helpers/EventEmitter";
import { createLogger } from "@nodes/utils";
import { createLogger } from "@nodarium/utils";
import throttle from "$lib/helpers/throttle";
import { HistoryManager } from "./history-manager";
const logger = createLogger("graph-manager");
// logger.mute();
logger.mute();
const clone =
"structuredClone" in self
@@ -32,6 +33,27 @@ function areSocketsCompatible(
return inputs === output;
}
function areEdgesEqual(firstEdge: Edge, secondEdge: Edge) {
if (firstEdge[0].id !== secondEdge[0].id) {
return false;
}
if (firstEdge[1] !== secondEdge[1]) {
return false
}
if (firstEdge[2].id !== secondEdge[2].id) {
return false
}
if (firstEdge[3] !== secondEdge[3]) {
return false
}
return true
}
export class GraphManager extends EventEmitter<{
save: Graph;
result: any;
@@ -46,7 +68,7 @@ export class GraphManager extends EventEmitter<{
graph: Graph = { id: 0, nodes: [], edges: [] };
id = $state(0);
nodes = new SvelteMap<number, Node>();
nodes = new SvelteMap<number, NodeInstance>();
edges = $state<Edge[]>([]);
@@ -79,7 +101,7 @@ export class GraphManager extends EventEmitter<{
position: [...node.position],
type: node.type,
props: node.props,
})) as Node[];
})) as NodeInstance[];
const edges = this.edges.map((edge) => [
edge[0].id,
edge[1],
@@ -88,7 +110,7 @@ export class GraphManager extends EventEmitter<{
]) as Graph["edges"];
const serialized = {
id: this.graph.id,
settings: this.settings,
settings: $state.snapshot(this.settings),
nodes,
edges,
};
@@ -111,14 +133,14 @@ export class GraphManager extends EventEmitter<{
return this.registry.getAllNodes();
}
getLinkedNodes(node: Node) {
const nodes = new Set<Node>();
getLinkedNodes(node: NodeInstance) {
const nodes = new Set<NodeInstance>();
const stack = [node];
while (stack.length) {
const n = stack.pop();
if (!n) continue;
nodes.add(n);
const children = this.getChildrenOfNode(n);
const children = this.getChildren(n);
const parents = this.getParentsOfNode(n);
const newNodes = [...children, ...parents].filter((n) => !nodes.has(n));
stack.push(...newNodes);
@@ -126,10 +148,10 @@ export class GraphManager extends EventEmitter<{
return [...nodes.values()];
}
getEdgesBetweenNodes(nodes: Node[]): [number, number, number, string][] {
getEdgesBetweenNodes(nodes: NodeInstance[]): [number, number, number, string][] {
const edges = [];
for (const node of nodes) {
const children = node.tmp?.children || [];
const children = node.state?.children || [];
for (const child of children) {
if (nodes.includes(child)) {
const edge = this.edges.find(
@@ -152,15 +174,15 @@ export class GraphManager extends EventEmitter<{
private _init(graph: Graph) {
const nodes = new Map(
graph.nodes.map((node: Node) => {
graph.nodes.map((node) => {
const nodeType = this.registry.getNode(node.type);
const n = node as NodeInstance;
if (nodeType) {
node.tmp = {
random: (Math.random() - 0.5) * 2,
n.state = {
type: nodeType,
};
}
return [node.id, node];
return [node.id, n];
}),
);
@@ -170,12 +192,10 @@ export class GraphManager extends EventEmitter<{
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);
from.state.children = from.state.children || [];
from.state.children.push(to);
to.state.parents = to.state.parents || [];
to.state.parents.push(from);
return [from, edge[1], to, edge[3]] as Edge;
});
@@ -211,9 +231,10 @@ export class GraphManager extends EventEmitter<{
this.status = "error";
return;
}
node.tmp = node.tmp || {};
node.tmp.random = (Math.random() - 0.5) * 2;
node.tmp.type = nodeType;
// Turn into runtime node
const n = node as NodeInstance;
n.state = {};
n.state.type = nodeType;
}
// load settings
@@ -272,7 +293,7 @@ export class GraphManager extends EventEmitter<{
return this.registry.getNode(id);
}
async loadNode(id: NodeType) {
async loadNodeType(id: NodeId) {
await this.registry.load([id]);
const nodeType = this.registry.getNode(id);
@@ -297,33 +318,32 @@ export class GraphManager extends EventEmitter<{
}
this.settings = settingValues;
console.log("GraphManager.setSettings", settingValues);
this.settingTypes = settingTypes;
this.emit("settings", { types: settingTypes, values: settingValues });
}
getChildrenOfNode(node: Node) {
getChildren(node: NodeInstance) {
const children = [];
const stack = node.tmp?.children?.slice(0);
const stack = node.state?.children?.slice(0);
while (stack?.length) {
const child = stack.pop();
if (!child) continue;
children.push(child);
stack.push(...(child.tmp?.children || []));
stack.push(...(child.state?.children || []));
}
return children;
}
getNodesBetween(from: Node, to: Node): Node[] | undefined {
getNodesBetween(from: NodeInstance, to: NodeInstance): NodeInstance[] | undefined {
// < - - - - from
const toParents = this.getParentsOfNode(to);
// < - - - - from - - - - to
const fromParents = this.getParentsOfNode(from);
if (toParents.includes(from)) {
const fromChildren = this.getChildrenOfNode(from);
const fromChildren = this.getChildren(from);
return toParents.filter((n) => fromChildren.includes(n));
} else if (fromParents.includes(to)) {
const toChildren = this.getChildrenOfNode(to);
const toChildren = this.getChildren(to);
return fromParents.filter((n) => toChildren.includes(n));
} else {
// these two nodes are not connected
@@ -331,7 +351,7 @@ export class GraphManager extends EventEmitter<{
}
}
removeNode(node: Node, { restoreEdges = false } = {}) {
removeNode(node: NodeInstance, { 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]) {
@@ -344,8 +364,8 @@ export class GraphManager extends EventEmitter<{
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;
const outputType = from.state?.type?.outputs?.[fromSocket];
const inputType = to?.state?.type?.inputs?.[toSocket]?.type;
if (outputType === inputType) {
this.createEdge(from, fromSocket, to, toSocket, {
applyUpdate: false,
@@ -361,19 +381,32 @@ export class GraphManager extends EventEmitter<{
this.save();
}
createNodeId() {
const max = Math.max(0, ...this.nodes.keys());
return max + 1;
smartConnect(from: NodeInstance, to: NodeInstance): Edge | undefined {
const inputs = Object.entries(to.state?.type?.inputs ?? {});
const outputs = from.state?.type?.outputs ?? [];
for (let i = 0; i < inputs.length; i++) {
const [inputName, input] = inputs[0];
for (let o = 0; o < outputs.length; o++) {
const output = outputs[0];
if (input.type === output) {
return this.createEdge(from, o, to, inputName);
}
}
}
}
createGraph(nodes: Node[], edges: [number, number, number, string][]) {
createNodeId() {
return Math.max(0, ...this.nodes.keys()) + 1;
}
createGraph(nodes: NodeInstance[], edges: [number, number, number, string][]) {
// map old ids to new ids
const idMap = new Map<number, number>();
const startId = this.createNodeId();
let startId = this.createNodeId()
nodes = nodes.map((node, i) => {
const id = startId + i;
nodes = nodes.map((node) => {
const id = startId++;
idMap.set(node.id, id);
const type = this.registry.getNode(node.type);
if (!type) {
@@ -390,13 +423,11 @@ export class GraphManager extends EventEmitter<{
throw new Error("Edge references non-existing node");
}
to.tmp = to.tmp || {};
to.tmp.parents = to.tmp.parents || [];
to.tmp.parents.push(from);
to.state.parents = to.state.parents || [];
to.state.parents.push(from);
from.tmp = from.tmp || {};
from.tmp.children = from.tmp.children || [];
from.tmp.children.push(to);
from.state.children = from.state.children || [];
from.state.children.push(to);
return [from, edge[1], to, edge[3]] as Edge;
});
@@ -416,9 +447,9 @@ export class GraphManager extends EventEmitter<{
position,
props = {},
}: {
type: Node["type"];
position: Node["position"];
props: Node["props"];
type: NodeInstance["type"];
position: NodeInstance["position"];
props: NodeInstance["props"];
}) {
const nodeType = this.registry.getNode(type);
if (!nodeType) {
@@ -426,26 +457,29 @@ export class GraphManager extends EventEmitter<{
return;
}
const node: Node = {
const node: NodeInstance = $state({
id: this.createNodeId(),
type,
position,
tmp: { type: nodeType },
state: { type: nodeType },
props,
};
});
this.nodes.set(node.id, node);
this.save();
return node
}
createEdge(
from: Node,
from: NodeInstance,
fromSocket: number,
to: Node,
to: NodeInstance,
toSocket: string,
{ applyUpdate = true } = {},
) {
): Edge | undefined {
const existingEdges = this.getEdgesToNode(to);
// check if this exact edge already exists
@@ -458,10 +492,10 @@ export class GraphManager extends EventEmitter<{
}
// check if socket types match
const fromSocketType = from.tmp?.type?.outputs?.[fromSocket];
const toSocketType = [to.tmp?.type?.inputs?.[toSocket]?.type];
if (to.tmp?.type?.inputs?.[toSocket]?.accepts) {
toSocketType.push(...(to?.tmp?.type?.inputs?.[toSocket]?.accepts || []));
const fromSocketType = from.state?.type?.outputs?.[fromSocket];
const toSocketType = [to.state?.type?.inputs?.[toSocket]?.type];
if (to.state?.type?.inputs?.[toSocket]?.accepts) {
toSocketType.push(...(to?.state?.type?.inputs?.[toSocket]?.accepts || []));
}
if (!areSocketsCompatible(fromSocketType, toSocketType)) {
@@ -478,24 +512,21 @@ export class GraphManager extends EventEmitter<{
this.removeEdge(edgeToBeReplaced, { applyDeletion: false });
}
if (applyUpdate) {
this.edges.push([from, fromSocket, to, toSocket]);
} else {
this.edges.push([from, fromSocket, to, toSocket]);
}
const edge = [from, fromSocket, to, toSocket] as Edge;
from.tmp = from.tmp || {};
from.tmp.children = from.tmp.children || [];
from.tmp.children.push(to);
this.edges.push(edge);
to.tmp = to.tmp || {};
to.tmp.parents = to.tmp.parents || [];
to.tmp.parents.push(from);
from.state.children = from.state.children || [];
from.state.children.push(to);
to.state.parents = to.state.parents || [];
to.state.parents.push(from);
if (applyUpdate) {
this.save();
}
this.execute();
return edge;
}
undo() {
@@ -531,9 +562,9 @@ export class GraphManager extends EventEmitter<{
logger.log("saving graphs", state);
}
getParentsOfNode(node: Node) {
getParentsOfNode(node: NodeInstance) {
const parents = [];
const stack = node.tmp?.parents?.slice(0);
const stack = node.state?.parents?.slice(0);
while (stack?.length) {
if (parents.length > 1000000) {
logger.warn("Infinite loop detected");
@@ -542,21 +573,51 @@ export class GraphManager extends EventEmitter<{
const parent = stack.pop();
if (!parent) continue;
parents.push(parent);
stack.push(...(parent.tmp?.parents || []));
stack.push(...(parent.state?.parents || []));
}
return parents.reverse();
}
getPossibleSockets({ node, index }: Socket): [Node, string | number][] {
const nodeType = node?.tmp?.type;
getPossibleNodes(socket: Socket): NodeDefinition[] {
const allDefinitions = this.getNodeDefinitions();
const nodeType = socket.node.state?.type;
if (!nodeType) {
return [];
}
const definitions = typeof socket.index === "string"
? allDefinitions.filter(s => {
return s.outputs?.find(_s => Object
.values(nodeType?.inputs || {})
.map(s => s.type)
.includes(_s as NodeInput["type"])
)
})
: allDefinitions.filter(s => Object
.values(s.inputs ?? {})
.find(s => {
if (s.hidden) return false;
if (nodeType.outputs?.includes(s.type)) {
return true
}
return s.accepts?.find(a => nodeType.outputs?.includes(a))
}))
return definitions
}
getPossibleSockets({ node, index }: Socket): [NodeInstance, string | number][] {
const nodeType = node?.state?.type;
if (!nodeType) return [];
const sockets: [Node, string | number][] = [];
const sockets: [NodeInstance, 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 children = new Set(this.getChildren(node).map((n) => n.id));
const nodes = this.getAllNodes().filter(
(n) => n.id !== node.id && !children.has(n.id),
);
@@ -564,7 +625,7 @@ export class GraphManager extends EventEmitter<{
const ownType = nodeType?.inputs?.[index].type;
for (const node of nodes) {
const nodeType = node?.tmp?.type;
const nodeType = node?.state?.type;
const inputs = nodeType?.outputs;
if (!inputs) continue;
for (let index = 0; index < inputs.length; index++) {
@@ -592,7 +653,7 @@ export class GraphManager extends EventEmitter<{
const ownType = nodeType.outputs?.[index];
for (const node of nodes) {
const inputs = node?.tmp?.type?.inputs;
const inputs = node?.state?.type?.inputs;
if (!inputs) continue;
for (const key in inputs) {
const otherType = [inputs[key].type];
@@ -624,32 +685,30 @@ export class GraphManager extends EventEmitter<{
(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: Node) => n.id !== id2,
if (edge[0].state.children) {
edge[0].state.children = edge[0].state.children.filter(
(n: NodeInstance) => n.id !== id2,
);
}
edge[2].tmp = edge[2].tmp || {};
if (edge[2].tmp.parents) {
edge[2].tmp.parents = edge[2].tmp.parents.filter(
(n: Node) => n.id !== id0,
if (edge[2].state.parents) {
edge[2].state.parents = edge[2].state.parents.filter(
(n: NodeInstance) => n.id !== id0,
);
}
this.edges = this.edges.filter((e) => !areEdgesEqual(e, edge));
if (applyDeletion) {
this.edges = this.edges.filter((e) => e !== _edge);
this.execute();
this.save();
} else {
this.edges = this.edges.filter((e) => e !== _edge);
}
}
getEdgesToNode(node: Node) {
}
getEdgesToNode(node: NodeInstance) {
return this.edges
.filter((edge) => edge[2].id === node.id)
.map((edge) => {
@@ -658,10 +717,10 @@ export class GraphManager extends EventEmitter<{
if (!from || !to) return;
return [from, edge[1], to, edge[3]] as const;
})
.filter(Boolean) as unknown as [Node, number, Node, string][];
.filter(Boolean) as unknown as [NodeInstance, number, NodeInstance, string][];
}
getEdgesFromNode(node: Node) {
getEdgesFromNode(node: NodeInstance) {
return this.edges
.filter((edge) => edge[0].id === node.id)
.map((edge) => {
@@ -670,6 +729,6 @@ export class GraphManager extends EventEmitter<{
if (!from || !to) return;
return [from, edge[1], to, edge[3]] as const;
})
.filter(Boolean) as unknown as [Node, number, Node, string][];
.filter(Boolean) as unknown as [NodeInstance, number, NodeInstance, string][];
}
}

View File

@@ -0,0 +1,322 @@
import type { NodeInstance, Socket } from "@nodarium/types";
import { getContext, setContext } from "svelte";
import { SvelteMap, SvelteSet } from "svelte/reactivity";
import type { GraphManager } from "./graph-manager.svelte";
import type { Mesh, OrthographicCamera, Vector3 } from "three";
const graphStateKey = Symbol("graph-state");
export function getGraphState() {
return getContext<GraphState>(graphStateKey);
}
export function setGraphState(graphState: GraphState) {
return setContext(graphStateKey, graphState)
}
const graphManagerKey = Symbol("graph-manager");
export function getGraphManager() {
return getContext<GraphManager>(graphManagerKey)
}
export function setGraphManager(manager: GraphManager) {
return setContext(graphManagerKey, manager);
}
export class GraphState {
constructor(private graph: GraphManager) {
$effect.root(() => {
$effect(() => {
localStorage.setItem("cameraPosition", `[${this.cameraPosition[0]},${this.cameraPosition[1]},${this.cameraPosition[2]}]`)
})
})
const storedPosition = localStorage.getItem("cameraPosition")
if (storedPosition) {
try {
const d = JSON.parse(storedPosition);
this.cameraPosition[0] = d[0];
this.cameraPosition[1] = d[1];
this.cameraPosition[2] = d[2];
} catch (e) {
console.log("Failed to parsed stored camera position", e);
}
}
}
width = $state(100);
height = $state(100);
edgeGeometries = new SvelteMap<string, { geo: Mesh, points: Vector3[] }>();
wrapper = $state<HTMLDivElement>(null!);
rect: DOMRect = $derived(
(this.wrapper && this.width && this.height) ? this.wrapper.getBoundingClientRect() : new DOMRect(0, 0, 0, 0),
);
camera = $state<OrthographicCamera>(null!);
cameraPosition: [number, number, number] = $state([0, 0, 4]);
clipboard: null | {
nodes: NodeInstance[];
edges: [number, number, number, string][];
} = null;
cameraBounds = $derived([
this.cameraPosition[0] - this.width / this.cameraPosition[2] / 2,
this.cameraPosition[0] + this.width / this.cameraPosition[2] / 2,
this.cameraPosition[1] - this.height / this.cameraPosition[2] / 2,
this.cameraPosition[1] + this.height / this.cameraPosition[2] / 2,
]);
boxSelection = $state(false);
edgeEndPosition = $state<[number, number] | null>();
addMenuPosition = $state<[number, number] | null>(null);
snapToGrid = $state(false);
showGrid = $state(true)
showHelp = $state(false)
cameraDown = [0, 0];
mouseDownNodeId = -1;
isPanning = $state(false);
isDragging = $state(false);
hoveredNodeId = $state(-1);
mousePosition = $state([0, 0]);
mouseDown = $state<[number, number] | null>(null);
activeNodeId = $state(-1);
selectedNodes = new SvelteSet<number>();
activeSocket = $state<Socket | null>(null);
hoveredSocket = $state<Socket | null>(null);
possibleSockets = $state<Socket[]>([]);
possibleSocketIds = $derived(
new Set(this.possibleSockets.map((s) => `${s.node.id}-${s.index}`)),
);
clearSelection() {
this.selectedNodes.clear();
}
isBodyFocused = () => document?.activeElement?.nodeName !== "INPUT";
setEdgeGeometry(edgeId: string, edgeGeometry: { geo: Mesh, points: Vector3[] }) {
this.edgeGeometries.set(edgeId, edgeGeometry);
}
removeEdgeGeometry(edgeId: string) {
this.edgeGeometries.delete(edgeId);
}
updateNodePosition(node: NodeInstance) {
if (
node.state.x === node.position[0] &&
node.state.y === node.position[1]
) {
delete node.state.x;
delete node.state.y;
}
if (node.state["x"] !== undefined && node.state["y"] !== undefined) {
if (node.state.ref) {
node.state.ref.style.setProperty("--nx", `${node.state.x * 10}px`);
node.state.ref.style.setProperty("--ny", `${node.state.y * 10}px`);
}
} else {
if (node.state.ref) {
node.state.ref.style.setProperty("--nx", `${node.position[0] * 10}px`);
node.state.ref.style.setProperty("--ny", `${node.position[1] * 10}px`);
}
}
}
getSnapLevel() {
const z = this.cameraPosition[2];
if (z > 66) {
return 8;
} else if (z > 55) {
return 4;
} else if (z > 11) {
return 2;
} else {
}
return 1;
}
getSocketPosition(
node: NodeInstance,
index: string | number,
): [number, number] {
if (typeof index === "number") {
return [
(node?.state?.x ?? node.position[0]) + 20,
(node?.state?.y ?? node.position[1]) + 2.5 + 10 * index,
];
} else {
const _index = Object.keys(node.state?.type?.inputs || {}).indexOf(index);
return [
node?.state?.x ?? node.position[0],
(node?.state?.y ?? node.position[1]) + 10 + 10 * _index,
];
}
}
private nodeHeightCache: Record<string, number> = {};
getNodeHeight(nodeTypeId: string) {
if (nodeTypeId in this.nodeHeightCache) {
return this.nodeHeightCache[nodeTypeId];
}
const node = this.graph.getNodeType(nodeTypeId);
if (!node?.inputs) {
return 5;
}
const height =
5 +
10 *
Object.keys(node.inputs).filter(
(p) =>
p !== "seed" &&
node?.inputs &&
!("setting" in node?.inputs?.[p]) &&
node.inputs[p].hidden !== true,
).length;
this.nodeHeightCache[nodeTypeId] = height;
return height;
}
copyNodes() {
if (this.activeNodeId === -1 && !this.selectedNodes?.size)
return;
let nodes = [
this.activeNodeId,
...(this.selectedNodes?.values() || []),
]
.map((id) => this.graph.getNode(id))
.filter(b => !!b);
const edges = this.graph.getEdgesBetweenNodes(nodes);
nodes = nodes.map((node) => ({
...node,
position: [
this.mousePosition[0] - node.position[0],
this.mousePosition[1] - node.position[1],
],
tmp: undefined,
}));
this.clipboard = {
nodes: nodes,
edges: edges,
};
}
pasteNodes() {
if (!this.clipboard) return;
const nodes = this.clipboard.nodes
.map((node) => {
node.position[0] = this.mousePosition[0] - node.position[0];
node.position[1] = this.mousePosition[1] - node.position[1];
return node;
})
.filter(Boolean) as NodeInstance[];
const newNodes = this.graph.createGraph(nodes, this.clipboard.edges);
this.selectedNodes.clear();
for (const node of newNodes) {
this.selectedNodes.add(node.id);
}
}
setDownSocket(socket: Socket) {
this.activeSocket = socket;
let { node, index, position } = socket;
// remove existing edge
if (typeof index === "string") {
const edges = this.graph.getEdgesToNode(node);
for (const edge of edges) {
if (edge[3] === index) {
node = edge[0];
index = edge[1];
position = this.getSocketPosition(node, index);
this.graph.removeEdge(edge);
break;
}
}
}
this.mouseDown = position;
this.activeSocket = {
node,
index,
position,
};
this.possibleSockets = this.graph
.getPossibleSockets(this.activeSocket)
.map(([node, index]) => {
return {
node,
index,
position: this.getSocketPosition(node, index),
};
});
};
projectScreenToWorld(x: number, y: number): [number, number] {
return [
this.cameraPosition[0] +
(x - this.width / 2) / this.cameraPosition[2],
this.cameraPosition[1] +
(y - this.height / 2) / this.cameraPosition[2],
];
}
getNodeIdFromEvent(event: MouseEvent) {
let clickedNodeId = -1;
let mx = event.clientX - this.rect.x;
let my = event.clientY - this.rect.y;
if (event.button === 0) {
// check if the clicked element is a node
if (event.target instanceof HTMLElement) {
const nodeElement = event.target.closest(".node");
const nodeId = nodeElement?.getAttribute?.("data-node-id");
if (nodeId) {
clickedNodeId = parseInt(nodeId, 10);
}
}
// if we do not have an active node,
// we are going to check if we clicked on a node by coordinates
if (clickedNodeId === -1) {
const [downX, downY] = this.projectScreenToWorld(mx, my);
for (const node of this.graph.nodes.values()) {
const x = node.position[0];
const y = node.position[1];
const height = this.getNodeHeight(node.type);
if (downX > x && downX < x + 20 && downY > y && downY < y + height) {
clickedNodeId = node.id;
break;
}
}
}
}
return clickedNodeId;
}
isNodeInView(node: NodeInstance) {
const height = this.getNodeHeight(node.type);
const width = 20;
return (
node.position[0] > this.cameraBounds[0] - width &&
node.position[0] < this.cameraBounds[1] &&
node.position[1] > this.cameraBounds[2] - height &&
node.position[1] < this.cameraBounds[3]
);
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,102 +0,0 @@
<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 { getGraphState } from "./state.svelte";
import { useThrelte } from "@threlte/core";
import { appSettings } from "$lib/settings/app-settings.svelte";
type Props = {
nodes: Map<number, NodeType>;
edges: EdgeType[];
cameraPosition: [number, number, number];
};
const { nodes, edges, cameraPosition = [0, 0, 4] }: Props = $props();
const { invalidate } = useThrelte();
$effect(() => {
appSettings.value.theme;
invalidate();
});
const graphState = getGraphState();
const isNodeInView = getContext<(n: NodeType) => boolean>("isNodeInView");
const getSocketPosition =
getContext<(node: NodeType, index: string | number) => [number, number]>(
"getSocketPosition",
);
const edgePositions = $derived(
edges.map((edge) => {
const fromNode = nodes.get(edge[0].id);
const toNode = nodes.get(edge[2].id);
// This check is important because nodes might not be there during some transitions.
if (!fromNode || !toNode) {
return [0, 0, 0, 0];
}
const pos1 = getSocketPosition(fromNode, edge[1]);
const pos2 = getSocketPosition(toNode, 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 edgePositions as edge (`${edge.join("-")}`)}
{@const [x1, y1, x2, y2] = edge}
<Edge
z={cameraPosition[2]}
from={{
x: x1,
y: y1,
}}
to={{
x: x2,
y: y2,
}}
/>
{/each}
<HTML transform={false}>
<div
role="tree"
id="graph"
tabindex="0"
class="wrapper"
style:transform={`scale(${cameraPosition[2] * 0.1})`}
class:hovering-sockets={graphState.activeSocket}
>
{#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;
}
</style>

View File

@@ -1,14 +1,14 @@
<script lang="ts">
import type { Graph, Node, NodeRegistry } from "@nodes/types";
import type { Graph, NodeInstance, NodeRegistry } from "@nodarium/types";
import GraphEl from "./Graph.svelte";
import { GraphManager } from "../graph-manager.svelte";
import { setContext } from "svelte";
import { debounce } from "$lib/helpers";
import { createKeyMap } from "$lib/helpers/createKeyMap";
import { GraphState } from "./state.svelte";
const graphState = new GraphState();
setContext("graphState", graphState);
import {
GraphState,
setGraphManager,
setGraphState,
} from "../graph-state.svelte";
import { setupKeymaps } from "../keymaps";
type Props = {
graph: Graph;
@@ -16,7 +16,7 @@
settings?: Record<string, any>;
activeNode?: Node;
activeNode?: NodeInstance;
showGrid?: boolean;
snapToGrid?: boolean;
showHelp?: boolean;
@@ -31,8 +31,8 @@
registry,
settings = $bindable(),
activeNode = $bindable(),
showGrid,
snapToGrid,
showGrid = $bindable(true),
snapToGrid = $bindable(true),
showHelp = $bindable(false),
settingTypes = $bindable(),
onsave,
@@ -40,10 +40,20 @@
}: Props = $props();
export const keymap = createKeyMap([]);
setContext("keymap", keymap);
export const manager = new GraphManager(registry);
setContext("graphManager", manager);
setGraphManager(manager);
const graphState = new GraphState(manager);
$effect(() => {
graphState.showGrid = showGrid;
graphState.snapToGrid = snapToGrid;
graphState.showHelp = showHelp;
});
setGraphState(graphState);
setupKeymaps(keymap, manager, graphState);
$effect(() => {
if (graphState.activeNodeId !== -1) {
@@ -53,13 +63,16 @@
}
});
const updateSettings = debounce((s: Record<string, any>) => {
manager.setSettings(s);
}, 200);
$effect(() => {
if (!graphState.addMenuPosition) {
graphState.edgeEndPosition = null;
graphState.activeSocket = null;
}
});
$effect(() => {
if (settingTypes && settings) {
updateSettings(settings);
manager.setSettings(settings);
}
});
@@ -75,4 +88,4 @@
manager.load(graph);
</script>
<GraphEl bind:showGrid bind:snapToGrid bind:showHelp />
<GraphEl {keymap} />

View File

@@ -0,0 +1,3 @@
export const minZoom = 1;
export const maxZoom = 40;
export const zoomSpeed = 2;

View File

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

View File

@@ -0,0 +1,523 @@
import { GraphSchema, type NodeId, type NodeInstance } from "@nodarium/types";
import type { GraphManager } from "../graph-manager.svelte";
import type { GraphState } from "../graph-state.svelte";
import { animate, lerp } from "$lib/helpers";
import { snapToGrid as snapPointToGrid } from "../helpers";
import { maxZoom, minZoom, zoomSpeed } from "./constants";
export class FileDropEventManager {
constructor(
private graph: GraphManager,
private state: GraphState
) { }
handleFileDrop(event: DragEvent) {
event.preventDefault();
this.state.isDragging = false;
if (!event.dataTransfer) return;
const nodeId = event.dataTransfer.getData("data/node-id") as NodeId;
let mx = event.clientX - this.state.rect.x;
let my = event.clientY - this.state.rect.y;
if (nodeId) {
let nodeOffsetX = event.dataTransfer.getData("data/node-offset-x");
let nodeOffsetY = event.dataTransfer.getData("data/node-offset-y");
if (nodeOffsetX && nodeOffsetY) {
mx += parseInt(nodeOffsetX);
my += parseInt(nodeOffsetY);
}
let props = {};
let rawNodeProps = event.dataTransfer.getData("data/node-props");
if (rawNodeProps) {
try {
props = JSON.parse(rawNodeProps);
} catch (e) { }
}
const pos = this.state.projectScreenToWorld(mx, my);
this.graph.registry.load([nodeId]).then(() => {
this.graph.createNode({
type: nodeId,
props,
position: pos,
});
});
} else if (event.dataTransfer.files.length) {
const file = event.dataTransfer.files[0];
if (file.type === "application/wasm") {
const reader = new FileReader();
reader.onload = async (e) => {
const buffer = e.target?.result;
if (buffer?.constructor === ArrayBuffer) {
const nodeType = await this.graph.registry.register(buffer);
this.graph.createNode({
type: nodeType.id,
props: {},
position: this.state.projectScreenToWorld(mx, my),
});
}
};
reader.readAsArrayBuffer(file);
} else if (file.type === "application/json") {
const reader = new FileReader();
reader.onload = (e) => {
const buffer = e.target?.result as ArrayBuffer;
if (buffer) {
const state = GraphSchema.parse(JSON.parse(buffer.toString()));
this.graph.load(state);
}
};
reader.readAsText(file);
}
}
}
handleMouseLeave() {
this.state.isDragging = false;
this.state.isPanning = false;
}
handleDragEnter(e: DragEvent) {
e.preventDefault();
this.state.isDragging = true;
this.state.isPanning = false;
}
handleDragOver(e: DragEvent) {
e.preventDefault();
this.state.isDragging = true;
this.state.isPanning = false;
}
handleDragEnd(e: DragEvent) {
e.preventDefault();
this.state.isDragging = true;
this.state.isPanning = false;
}
getEventListenerProps() {
return {
ondragenter: (ev: DragEvent) => this.handleDragEnter(ev),
ondragover: (ev: DragEvent) => this.handleDragOver(ev),
ondragexit: (ev: DragEvent) => this.handleDragEnd(ev),
ondrop: (ev: DragEvent) => this.handleFileDrop(ev),
onmouseleave: () => this.handleMouseLeave(),
}
}
}
class EdgeInteractionManager {
constructor(
private graph: GraphManager,
private state: GraphState) { };
handleMouseDown() {
const edges = this.graph.edges;
console.log(edges)
}
handleMouseMove() {
}
handleMouseUp() {
}
}
export class MouseEventManager {
edgeInteractionManager: EdgeInteractionManager
constructor(
private graph: GraphManager,
private state: GraphState
) {
this.edgeInteractionManager = new EdgeInteractionManager(graph, state);
}
handleMouseUp(event: MouseEvent) {
this.edgeInteractionManager.handleMouseUp();
this.state.isPanning = false;
if (!this.state.mouseDown) return;
const activeNode = this.graph.getNode(this.state.activeNodeId);
const clickedNodeId = this.state.getNodeIdFromEvent(event);
if (clickedNodeId !== -1) {
if (activeNode) {
if (!activeNode?.state?.isMoving && !event.ctrlKey && !event.shiftKey) {
this.state.activeNodeId = clickedNodeId;
this.state.clearSelection();
}
}
}
if (activeNode?.state?.isMoving) {
activeNode.state = activeNode.state || {};
activeNode.state.isMoving = false;
if (this.state.snapToGrid) {
const snapLevel = this.state.getSnapLevel();
activeNode.position[0] = snapPointToGrid(
activeNode?.state?.x ?? activeNode.position[0],
5 / snapLevel,
);
activeNode.position[1] = snapPointToGrid(
activeNode?.state?.y ?? activeNode.position[1],
5 / snapLevel,
);
} else {
activeNode.position[0] = activeNode?.state?.x ?? activeNode.position[0];
activeNode.position[1] = activeNode?.state?.y ?? activeNode.position[1];
}
const nodes = [
...[...(this.state.selectedNodes?.values() || [])].map((id) =>
this.graph.getNode(id),
),
] as NodeInstance[];
const vec = [
activeNode.position[0] - (activeNode?.state.x || 0),
activeNode.position[1] - (activeNode?.state.y || 0),
];
for (const node of nodes) {
if (!node) continue;
node.state = node.state || {};
const { x, y } = node.state;
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?.state &&
node.state["x"] !== undefined &&
node.state["y"] !== undefined
) {
node.state.x = lerp(node.state.x, node.position[0], a);
node.state.y = lerp(node.state.y, node.position[1], a);
this.state.updateNodePosition(node);
if (node?.state?.isMoving) {
return false;
}
}
}
});
this.graph.save();
} else if (this.state.hoveredSocket && this.state.activeSocket) {
if (
typeof this.state.hoveredSocket.index === "number" &&
typeof this.state.activeSocket.index === "string"
) {
this.graph.createEdge(
this.state.hoveredSocket.node,
this.state.hoveredSocket.index || 0,
this.state.activeSocket.node,
this.state.activeSocket.index,
);
} else if (
typeof this.state.activeSocket.index == "number" &&
typeof this.state.hoveredSocket.index === "string"
) {
this.graph.createEdge(
this.state.activeSocket.node,
this.state.activeSocket.index || 0,
this.state.hoveredSocket.node,
this.state.hoveredSocket.index,
);
}
this.graph.save();
} else if (this.state.activeSocket && event.ctrlKey) {
// Handle automatic adding of nodes on ctrl+mouseUp
this.state.edgeEndPosition = [
this.state.mousePosition[0],
this.state.mousePosition[1],
];
if (typeof this.state.activeSocket.index === "number") {
this.state.addMenuPosition = [
this.state.mousePosition[0],
this.state.mousePosition[1] - 25 / this.state.cameraPosition[2],
];
} else {
this.state.addMenuPosition = [
this.state.mousePosition[0] - 155 / this.state.cameraPosition[2],
this.state.mousePosition[1] - 25 / this.state.cameraPosition[2],
];
}
return;
}
// check if camera moved
if (
clickedNodeId === -1 &&
!this.state.boxSelection &&
this.state.cameraDown[0] === this.state.cameraPosition[0] &&
this.state.cameraDown[1] === this.state.cameraPosition[1] &&
this.state.isBodyFocused()
) {
this.state.activeNodeId = -1;
this.state.clearSelection();
}
this.state.mouseDown = null;
this.state.boxSelection = false;
this.state.activeSocket = null;
this.state.possibleSockets = [];
this.state.hoveredSocket = null;
this.state.addMenuPosition = null;
}
handleMouseDown(event: MouseEvent) {
if (this.state.mouseDown) return;
this.state.edgeEndPosition = null;
if (event.target instanceof HTMLElement) {
if (
event.target.nodeName !== "CANVAS" &&
!event.target.classList.contains("node") &&
!event.target.classList.contains("content")
) {
return;
}
}
let mx = event.clientX - this.state.rect.x;
let my = event.clientY - this.state.rect.y;
this.state.mouseDown = [mx, my];
this.state.cameraDown[0] = this.state.cameraPosition[0];
this.state.cameraDown[1] = this.state.cameraPosition[1];
const clickedNodeId = this.state.getNodeIdFromEvent(event);
this.state.mouseDownNodeId = clickedNodeId;
// if we clicked on a node
if (clickedNodeId !== -1) {
if (this.state.activeNodeId === -1) {
this.state.activeNodeId = clickedNodeId;
// if the selected node is the same as the clicked node
} else if (this.state.activeNodeId === clickedNodeId) {
//$activeNodeId = -1;
// if the clicked node is different from the selected node and secondary
} else if (event.ctrlKey) {
this.state.selectedNodes.add(this.state.activeNodeId);
this.state.selectedNodes.delete(clickedNodeId);
this.state.activeNodeId = clickedNodeId;
// select the node
} else if (event.shiftKey) {
const activeNode = this.graph.getNode(this.state.activeNodeId);
const newNode = this.graph.getNode(clickedNodeId);
if (activeNode && newNode) {
const edge = this.graph.getNodesBetween(activeNode, newNode);
if (edge) {
this.state.selectedNodes.clear();
for (const node of edge) {
this.state.selectedNodes.add(node.id);
}
this.state.selectedNodes.add(clickedNodeId);
}
}
} else if (!this.state.selectedNodes.has(clickedNodeId)) {
this.state.activeNodeId = clickedNodeId;
this.state.clearSelection();
}
this.edgeInteractionManager.handleMouseDown();
} else if (event.ctrlKey) {
this.state.boxSelection = true;
}
const node = this.graph.getNode(this.state.activeNodeId);
if (!node) return;
node.state = node.state || {};
node.state.downX = node.position[0];
node.state.downY = node.position[1];
if (this.state.selectedNodes) {
for (const nodeId of this.state.selectedNodes) {
const n = this.graph.getNode(nodeId);
if (!n) continue;
n.state = n.state || {};
n.state.downX = n.position[0];
n.state.downY = n.position[1];
}
}
this.state.edgeEndPosition = null;
}
handleMouseMove(event: MouseEvent) {
let mx = event.clientX - this.state.rect.x;
let my = event.clientY - this.state.rect.y;
this.state.mousePosition = this.state.projectScreenToWorld(mx, my);
this.state.hoveredNodeId = this.state.getNodeIdFromEvent(event);
if (!this.state.mouseDown) return;
// we are creating a new edge here
if (this.state.activeSocket || this.state.possibleSockets?.length) {
let smallestDist = 1000;
let _socket;
for (const socket of this.state.possibleSockets) {
const dist = Math.sqrt(
(socket.position[0] - this.state.mousePosition[0]) ** 2 +
(socket.position[1] - this.state.mousePosition[1]) ** 2,
);
if (dist < smallestDist) {
smallestDist = dist;
_socket = socket;
}
}
if (_socket && smallestDist < 0.9) {
this.state.mousePosition = _socket.position;
this.state.hoveredSocket = _socket;
} else {
this.state.hoveredSocket = null;
}
return;
}
// handle box selection
if (this.state.boxSelection) {
event.preventDefault();
event.stopPropagation();
const mouseD = this.state.projectScreenToWorld(
this.state.mouseDown[0],
this.state.mouseDown[1],
);
const x1 = Math.min(mouseD[0], this.state.mousePosition[0]);
const x2 = Math.max(mouseD[0], this.state.mousePosition[0]);
const y1 = Math.min(mouseD[1], this.state.mousePosition[1]);
const y2 = Math.max(mouseD[1], this.state.mousePosition[1]);
for (const node of this.graph.nodes.values()) {
if (!node?.state) continue;
const x = node.position[0];
const y = node.position[1];
const height = this.state.getNodeHeight(node.type);
if (x > x1 - 20 && x < x2 && y > y1 - height && y < y2) {
this.state.selectedNodes?.add(node.id);
} else {
this.state.selectedNodes?.delete(node.id);
}
}
return;
}
// here we are handling dragging of nodes
if (this.state.activeNodeId !== -1 && this.state.mouseDownNodeId !== -1) {
this.edgeInteractionManager.handleMouseMove();
const node = this.graph.getNode(this.state.activeNodeId);
if (!node || event.buttons !== 1) return;
node.state = node.state || {};
const oldX = node.state.downX || 0;
const oldY = node.state.downY || 0;
let newX =
oldX + (mx - this.state.mouseDown[0]) / this.state.cameraPosition[2];
let newY =
oldY + (my - this.state.mouseDown[1]) / this.state.cameraPosition[2];
if (event.ctrlKey) {
const snapLevel = this.state.getSnapLevel();
if (this.state.snapToGrid) {
newX = snapPointToGrid(newX, 5 / snapLevel);
newY = snapPointToGrid(newY, 5 / snapLevel);
}
}
if (!node.state.isMoving) {
const dist = Math.sqrt((oldX - newX) ** 2 + (oldY - newY) ** 2);
if (dist > 0.2) {
node.state.isMoving = true;
}
}
const vecX = oldX - newX;
const vecY = oldY - newY;
if (this.state.selectedNodes?.size) {
for (const nodeId of this.state.selectedNodes) {
const n = this.graph.getNode(nodeId);
if (!n?.state) continue;
n.state.x = (n?.state?.downX || 0) - vecX;
n.state.y = (n?.state?.downY || 0) - vecY;
this.state.updateNodePosition(n);
}
}
node.state.x = newX;
node.state.y = newY;
this.state.updateNodePosition(node);
return;
}
// here we are handling panning of camera
this.state.isPanning = true;
let newX =
this.state.cameraDown[0] -
(mx - this.state.mouseDown[0]) / this.state.cameraPosition[2];
let newY =
this.state.cameraDown[1] -
(my - this.state.mouseDown[1]) / this.state.cameraPosition[2];
this.state.cameraPosition[0] = newX;
this.state.cameraPosition[1] = newY;
}
handleMouseScroll(event: WheelEvent) {
const bodyIsFocused =
document.activeElement === document.body ||
document.activeElement === this.state.wrapper ||
document?.activeElement?.id === "graph";
if (!bodyIsFocused) return;
// Define zoom speed and clamp it between -1 and 1
const isNegative = event.deltaY < 0;
const normalizedDelta = Math.abs(event.deltaY * 0.01);
const delta = Math.pow(0.95, zoomSpeed * normalizedDelta);
// Calculate new zoom level and clamp it between minZoom and maxZoom
const newZoom = Math.max(
minZoom,
Math.min(
maxZoom,
isNegative
? this.state.cameraPosition[2] / delta
: this.state.cameraPosition[2] * delta,
),
);
// Calculate the ratio of the new zoom to the original zoom
const zoomRatio = newZoom / this.state.cameraPosition[2];
// Update camera position and zoom level
this.state.cameraPosition[0] = this.state.mousePosition[0] -
(this.state.mousePosition[0] - this.state.cameraPosition[0]) /
zoomRatio;
this.state.cameraPosition[1] = this.state.mousePosition[1] -
(this.state.mousePosition[1] - this.state.cameraPosition[1]) /
zoomRatio,
this.state.cameraPosition[2] = newZoom;
}
}

View File

@@ -1,21 +0,0 @@
import type { Socket } from "@nodes/types";
import { getContext } from "svelte";
import { SvelteSet } from "svelte/reactivity";
export function getGraphState() {
return getContext<GraphState>("graphState");
}
export class GraphState {
activeNodeId = $state(-1);
selectedNodes = new SvelteSet<number>();
activeSocket = $state<Socket | null>(null);
hoveredSocket = $state<Socket | null>(null);
possibleSockets = $state<Socket[]>([]);
possibleSocketIds = $derived(
new Set(this.possibleSockets.map((s) => `${s.node.id}-${s.index}`)),
);
clearSelection() {
this.selectedNodes.clear();
}
}

View File

@@ -1,7 +1,7 @@
import { create, type Delta } from "jsondiffpatch";
import type { Graph } from "@nodes/types";
import type { Graph } from "@nodarium/types";
import { clone } from "./helpers/index.js";
import { createLogger } from "@nodes/utils";
import { createLogger } from "@nodarium/utils";
const diff = create({
objectHash: function (obj, index) {
@@ -16,7 +16,7 @@ const diff = create({
});
const log = createLogger("history");
// log.mute();
log.mute();
export class HistoryManager {
index: number = -1;

View File

@@ -0,0 +1,190 @@
import { animate, lerp } from "$lib/helpers";
import type { createKeyMap } from "$lib/helpers/createKeyMap";
import FileSaver from "file-saver";
import type { GraphManager } from "./graph-manager.svelte";
import type { GraphState } from "./graph-state.svelte";
type Keymap = ReturnType<typeof createKeyMap>;
export function setupKeymaps(keymap: Keymap, graph: GraphManager, graphState: GraphState) {
keymap.addShortcut({
key: "l",
description: "Select linked nodes",
callback: () => {
const activeNode = graph.getNode(graphState.activeNodeId);
if (activeNode) {
const nodes = graph.getLinkedNodes(activeNode);
graphState.selectedNodes.clear();
for (const node of nodes) {
graphState.selectedNodes.add(node.id);
}
}
},
});
keymap.addShortcut({
key: "?",
description: "Toggle Help",
callback: () => {
// TODO: fix this
// showHelp = !showHelp;
},
});
keymap.addShortcut({
key: "c",
ctrl: true,
description: "Copy active nodes",
callback: () => graphState.copyNodes(),
});
keymap.addShortcut({
key: "v",
ctrl: true,
description: "Paste nodes",
callback: () => graphState.pasteNodes(),
});
keymap.addShortcut({
key: "Escape",
description: "Deselect nodes",
callback: () => {
graphState.activeNodeId = -1;
graphState.clearSelection();
graphState.edgeEndPosition = null;
(document.activeElement as HTMLElement)?.blur();
},
});
keymap.addShortcut({
key: "A",
shift: true,
description: "Add new Node",
callback: () => {
graphState.addMenuPosition = [graphState.mousePosition[0], graphState.mousePosition[1]];
},
});
keymap.addShortcut({
key: ".",
description: "Center camera",
callback: () => {
if (!graphState.isBodyFocused()) return;
const average = [0, 0];
for (const node of graph.nodes.values()) {
average[0] += node.position[0];
average[1] += node.position[1];
}
average[0] = average[0] ? average[0] / graph.nodes.size : 0;
average[1] = average[1] ? average[1] / graph.nodes.size : 0;
const camX = graphState.cameraPosition[0];
const camY = graphState.cameraPosition[1];
const camZ = graphState.cameraPosition[2];
const ease = (t: number) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t);
animate(500, (a: number) => {
graphState.cameraPosition[0] = lerp(camX, average[0], ease(a));
graphState.cameraPosition[1] = lerp(camY, average[1], ease(a));
graphState.cameraPosition[2] = lerp(camZ, 2, ease(a))
if (graphState.mouseDown) return false;
});
},
});
keymap.addShortcut({
key: "a",
ctrl: true,
preventDefault: true,
description: "Select all nodes",
callback: () => {
if (!graphState.isBodyFocused()) return;
for (const node of graph.nodes.keys()) {
graphState.selectedNodes.add(node);
}
},
});
keymap.addShortcut({
key: "z",
ctrl: true,
description: "Undo",
callback: () => {
if (!graphState.isBodyFocused()) return;
graph.undo();
for (const node of graph.nodes.values()) {
graphState.updateNodePosition(node);
}
},
});
keymap.addShortcut({
key: "y",
ctrl: true,
description: "Redo",
callback: () => {
graph.redo();
for (const node of graph.nodes.values()) {
graphState.updateNodePosition(node);
}
},
});
keymap.addShortcut({
key: "s",
ctrl: true,
description: "Save",
preventDefault: true,
callback: () => {
const state = graph.serialize();
const blob = new Blob([JSON.stringify(state)], {
type: "application/json;charset=utf-8",
});
FileSaver.saveAs(blob, "nodarium-graph.json");
},
});
keymap.addShortcut({
key: ["Delete", "Backspace", "x"],
description: "Delete selected nodes",
callback: (event) => {
if (!graphState.isBodyFocused()) return;
graph.startUndoGroup();
if (graphState.activeNodeId !== -1) {
const node = graph.getNode(graphState.activeNodeId);
if (node) {
graph.removeNode(node, { restoreEdges: event.ctrlKey });
graphState.activeNodeId = -1;
}
}
if (graphState.selectedNodes) {
for (const nodeId of graphState.selectedNodes) {
const node = graph.getNode(nodeId);
if (node) {
graph.removeNode(node, { restoreEdges: event.ctrlKey });
}
}
graphState.clearSelection();
}
graph.saveUndoGroup();
},
});
keymap.addShortcut({
key: "f",
description: "Smart Connect Nodes",
callback: () => {
const nodes = [...graphState.selectedNodes.values()]
.map((g) => graph.getNode(g))
.filter((n) => !!n);
const edge = graph.smartConnect(nodes[0], nodes[1]);
if (!edge) graph.smartConnect(nodes[1], nodes[0]);
},
});
}

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import type { Node } from "@nodes/types";
import { getContext, onMount } from "svelte";
import { getGraphState } from "../graph/state.svelte";
import type { NodeInstance } from "@nodarium/types";
import { getGraphState } from "../graph-state.svelte";
import { T } from "@threlte/core";
import { type Mesh } from "three";
import NodeFrag from "./Node.frag";
@@ -13,7 +12,7 @@
const graphState = getGraphState();
type Props = {
node: Node;
node: NodeInstance;
inView: boolean;
z: number;
};
@@ -21,40 +20,30 @@
const isActive = $derived(graphState.activeNodeId === node.id);
const isSelected = $derived(graphState.selectedNodes.has(node.id));
let strokeColor = $state(colors.selected);
$effect(() => {
appSettings.value.theme;
strokeColor = isSelected
const strokeColor = $derived(
appSettings.value.theme &&
(isSelected
? colors.selected
: isActive
? colors.active
: colors.outline;
});
const updateNodePosition =
getContext<(n: Node) => void>("updateNodePosition");
const getNodeHeight = getContext<(n: string) => number>("getNodeHeight");
: colors.outline),
);
let meshRef: Mesh | undefined = $state();
const height = getNodeHeight?.(node.type);
const height = graphState.getNodeHeight(node.type);
$effect(() => {
if (!node?.tmp) node.tmp = {};
node.tmp.mesh = meshRef;
});
onMount(() => {
if (!node.tmp) node.tmp = {};
node.tmp.mesh = meshRef;
updateNodePosition?.(node);
if (meshRef && !node.state?.mesh) {
node.state.mesh = meshRef;
graphState.updateNodePosition(node);
}
});
</script>
<T.Mesh
position.x={node.position[0] + 10}
position.z={node.position[1] + height / 2}
position.x={(node.state.x ?? node.position[0]) + 10}
position.z={(node.state.y ?? node.position[1]) + height / 2}
position.y={0.8}
rotation.x={-Math.PI / 2}
bind:ref={meshRef}

View File

@@ -1,13 +1,15 @@
<script lang="ts">
import type { Node } from "@nodes/types";
import type { NodeInstance } from "@nodarium/types";
import NodeHeader from "./NodeHeader.svelte";
import NodeParameter from "./NodeParameter.svelte";
import { getContext, onMount } from "svelte";
import { getGraphState } from "../graph-state.svelte";
let ref: HTMLDivElement;
const graphState = getGraphState();
type Props = {
node: Node;
node: NodeInstance;
position?: "absolute" | "fixed" | "relative";
isActive?: boolean;
isSelected?: boolean;
@@ -24,21 +26,20 @@
z = 2,
}: Props = $props();
const zOffset = (node.tmp?.random || 0) * 0.5;
// If we dont have a random offset, all nodes becom visible at the same zoom level -> stuttering
const zOffset = Math.random() - 0.5;
const zLimit = 2 - zOffset;
const parameters = Object.entries(node?.tmp?.type?.inputs || {}).filter(
const parameters = Object.entries(node.state?.type?.inputs || {}).filter(
(p) =>
p[1].type !== "seed" && !("setting" in p[1]) && p[1]?.hidden !== true,
);
const updateNodePosition =
getContext<(n: Node) => void>("updateNodePosition");
onMount(() => {
node.tmp = node.tmp || {};
node.tmp.ref = ref;
updateNodePosition?.(node);
$effect(() => {
if ("state" in node && !node.state.ref) {
node.state.ref = ref;
graphState?.updateNodePosition(node);
}
});
</script>

View File

@@ -1,28 +1,26 @@
<script lang="ts">
import { getGraphState } from "../graph-state.svelte";
import { createNodePath } from "../helpers/index.js";
import type { Node, Socket } from "@nodes/types";
import { getContext } from "svelte";
import type { NodeInstance } from "@nodarium/types";
const { node }: { node: Node } = $props();
const graphState = getGraphState();
const setDownSocket = getContext<(socket: Socket) => void>("setDownSocket");
const getSocketPosition =
getContext<(node: Node, index: number) => [number, number]>(
"getSocketPosition",
);
const { node }: { node: NodeInstance } = $props();
function handleMouseDown(event: MouseEvent) {
event.stopPropagation();
event.preventDefault();
setDownSocket?.({
if ("state" in node) {
graphState.setDownSocket?.({
node,
index: 0,
position: getSocketPosition?.(node, 0),
position: graphState.getSocketPosition?.(node, 0),
});
}
}
const cornerTop = 10;
const rightBump = !!node?.tmp?.type?.outputs?.length;
const rightBump = !!node?.state?.type?.outputs?.length;
const aspectRatio = 0.25;
const path = createNodePath({
@@ -33,14 +31,6 @@
rightBump,
aspectRatio,
});
// const pathDisabled = createNodePath({
// depth: 0,
// height: 15,
// y: 50,
// cornerTop,
// rightBump,
// aspectRatio,
// });
const pathHover = createNodePath({
depth: 8.5,
height: 50,

View File

@@ -1,13 +1,14 @@
<script lang="ts">
import type { Node, NodeInput } from "@nodes/types";
import { getGraphManager } from "../graph/context.js";
import { Input } from "@nodes/ui";
import type { NodeInstance, NodeInput } from "@nodarium/types";
import { Input } from "@nodarium/ui";
import type { GraphManager } from "../graph-manager.svelte";
type Props = {
node: Node;
node: NodeInstance;
input: NodeInput;
id: string;
elementId?: string;
graph?: GraphManager;
};
const {
@@ -15,10 +16,9 @@
input,
id,
elementId = `input-${Math.random().toString(36).substring(7)}`,
graph,
}: Props = $props();
const graph = getGraphManager();
function getDefaultValue() {
if (node?.props?.[id] !== undefined) return node?.props?.[id] as number;
if ("value" in input && input?.value !== undefined)

View File

@@ -1,51 +1,40 @@
<script lang="ts">
import type {
NodeInput as NodeInputType,
Socket,
Node as NodeType,
} from "@nodes/types";
import { getContext } from "svelte";
import { createNodePath } from "../helpers/index.js";
import { getGraphManager } from "../graph/context.js";
import NodeInput from "./NodeInput.svelte";
import { getGraphState } from "../graph/state.svelte.js";
import type { NodeInput, NodeInstance } from "@nodarium/types";
import { createNodePath } from "../helpers";
import NodeInputEl from "./NodeInput.svelte";
import { getGraphManager, getGraphState } from "../graph-state.svelte";
type Props = {
node: NodeType;
input: NodeInputType;
node: NodeInstance;
input: NodeInput;
id: string;
isLast?: boolean;
};
const graph = getGraphManager();
let { node = $bindable(), input, id, isLast }: Props = $props();
const inputType = node?.tmp?.type?.inputs?.[id]!;
const inputType = node?.state?.type?.inputs?.[id]!;
const socketId = `${node.id}-${id}`;
const graph = getGraphManager();
const graphState = getGraphState();
const graphId = graph?.id;
const elementId = `input-${Math.random().toString(36).substring(7)}`;
const setDownSocket = getContext<(socket: Socket) => void>("setDownSocket");
const getSocketPosition =
getContext<(node: NodeType, index: string) => [number, number]>(
"getSocketPosition",
);
function handleMouseDown(ev: MouseEvent) {
ev.preventDefault();
ev.stopPropagation();
setDownSocket?.({
graphState.setDownSocket({
node,
index: id,
position: getSocketPosition?.(node, id),
position: graphState.getSocketPosition?.(node, id),
});
}
const leftBump = node.tmp?.type?.inputs?.[id].internal !== true;
const leftBump = node.state?.type?.inputs?.[id].internal !== true;
const cornerBottom = isLast ? 5 : 0;
const aspectRatio = 0.5;
@@ -84,21 +73,21 @@
{#key id && graphId}
<div class="content" class:disabled={graph?.inputSockets?.has(socketId)}>
{#if inputType.label !== ""}
<label for={elementId}>{input.label || id}</label>
<label for={elementId} title={input.description}
>{input.label || id}</label
>
{/if}
<span
class="absolute i-[tabler--help-circle] size-4 block top-2 right-2 opacity-30"
title={JSON.stringify(input, null, 2)}
></span>
{#if inputType.external !== true}
<NodeInput {elementId} bind:node {input} {id} />
<NodeInputEl {graph} {elementId} bind:node {input} {id} />
{/if}
</div>
{#if node?.tmp?.type?.inputs?.[id]?.internal !== true}
<div
data-node-socket
class="large target"
onmousedown={handleMouseDown}
role="button"
tabindex="0"
></div>
{#if node?.state?.type?.inputs?.[id]?.internal !== true}
<div data-node-socket class="large target"></div>
<div
data-node-socket
class="small target"
@@ -198,9 +187,6 @@
.content.disabled {
opacity: 0.2;
}
.content.disabled > * {
pointer-events: none;
}
.disabled svg path {
d: var(--hover-path-disabled) !important;

View File

@@ -1,20 +0,0 @@
import type { Node, NodeDefinition } from "@nodes/types";
export type GraphNode = Node & {
tmp?: {
depth?: number;
mesh?: any;
random?: number;
parents?: Node[];
children?: Node[];
inputNodes?: Record<string, Node>;
type?: NodeDefinition;
downX?: number;
downY?: number;
x?: number;
y?: number;
ref?: HTMLElement;
visible?: boolean;
isMoving?: boolean;
};
};

View File

@@ -1,4 +1,4 @@
import type { Graph } from "@nodes/types";
import type { Graph } from "@nodarium/types";
export function grid(width: number, height: number) {
@@ -16,9 +16,6 @@ export function grid(width: number, height: number) {
graph.nodes.push({
id: i,
tmp: {
visible: false,
},
position: [x * 30, y * 40],
props: i == 0 ? { value: 0 } : { op_type: 0, a: 1, b: 0.05 },
type: i == 0 ? "max/plantarium/float" : "max/plantarium/math",
@@ -29,9 +26,6 @@ export function grid(width: number, height: number) {
graph.nodes.push({
id: amount,
tmp: {
visible: false,
},
position: [width * 30, (height - 1) * 40],
type: "max/plantarium/output",
props: {},

View File

@@ -1,8 +1,8 @@
import type { Graph, Node } from "@nodes/types";
import type { Graph, SerializedNode } from "@nodarium/types";
export function tree(depth: number): Graph {
const nodes: Node[] = [
const nodes: SerializedNode[] = [
{
id: 0,
type: "max/plantarium/output",

View File

@@ -1,12 +1,16 @@
<script lang="ts">
import { getContext } from "svelte";
import { getContext, type Snippet } from "svelte";
let index = -1;
let index = $state(-1);
let wrapper: HTMLDivElement;
$: if (index === -1) {
const { children } = $props<{ children?: Snippet }>();
$effect(() => {
if (index === -1) {
index = getContext<() => number>("registerCell")();
}
});
const sizes = getContext<{ value: string[] }>("sizes");
@@ -31,8 +35,8 @@
</script>
<svelte:window
on:mouseup={() => (mouseDown = false)}
on:mousemove={handleMouseMove}
onmouseup={() => (mouseDown = false)}
onmousemove={handleMouseMove}
/>
{#if index > 0}
@@ -40,12 +44,12 @@
class="seperator"
role="button"
tabindex="0"
on:mousedown={handleMouseDown}
onmousedown={handleMouseDown}
></div>
{/if}
<div class="cell" bind:this={wrapper}>
<slot />
{@render children?.()}
</div>
<style>

View File

@@ -1,9 +1,9 @@
<script lang="ts">
import { setContext } from "svelte";
import { setContext, type Snippet } from "svelte";
export let id = "grid-0";
const { children, id } = $props<{ children?: Snippet; id?: string }>();
setContext("grid-id", id);
</script>
<slot {id} />
{@render children({ id })}

View File

@@ -126,7 +126,7 @@ export function humanizeDuration(durationInMilliseconds: number) {
}
if (millis > 0 || durationString === '') {
durationString += millis + 'ms';
durationString += Math.floor(millis) + 'ms';
}
return durationString.trim();

View File

@@ -1,4 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="17" y="8" width="5" height="7" rx="1" stroke="currentColor" stroke-width="2"/>
<rect x="2" y="3" width="5" height="7" rx="1" stroke="currentColor" stroke-width="2"/>
<rect x="2" y="14" width="5" height="7" rx="1" stroke="currentColor" stroke-width="2"/>

Before

Width:  |  Height:  |  Size: 519 B

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -1,4 +1,4 @@
import { createWasmWrapper } from "@nodes/utils";
import { createWasmWrapper } from "@nodarium/utils";
import fs from "fs/promises";
import path from "path";

View File

@@ -1,10 +1,11 @@
<script lang="ts">
import { Select } from "@nodes/ui";
import type { Writable } from "svelte/store";
import { Select } from "@nodarium/ui";
let activeStore = 0;
export let activeId: Writable<string>;
$: [activeUser, activeCollection, activeNode] = $activeId.split(`/`);
let activeStore = $state(0);
let { activeId }: { activeId: string } = $props();
const [activeUser, activeCollection, activeNode] = $derived(
activeId.split(`/`),
);
</script>
<div class="breadcrumbs">
@@ -12,16 +13,16 @@
<Select id="root" options={["root"]} bind:value={activeStore}></Select>
{#if activeCollection}
<button
on:click={() => {
$activeId = activeUser;
onclick={() => {
activeId = activeUser;
}}
>
{activeUser}
</button>
{#if activeNode}
<button
on:click={() => {
$activeId = `${activeUser}/${activeCollection}`;
onclick={() => {
activeId = `${activeUser}/${activeCollection}`;
}}
>
{activeCollection}

View File

@@ -1,27 +1,27 @@
<script lang="ts">
import NodeHtml from "$lib/graph-interface/node/NodeHTML.svelte";
import type { NodeDefinition } from "@nodes/types";
import type { NodeDefinition, NodeId, NodeInstance } from "@nodarium/types";
export let node: NodeDefinition;
const { node }: { node: NodeDefinition } = $props();
let dragging = false;
let dragging = $state(false);
let nodeData = {
let nodeData = $state<NodeInstance>({
id: 0,
type: node?.id,
type: node.id as unknown as NodeId,
position: [0, 0] as [number, number],
props: {},
tmp: {
state: {
type: node,
},
};
});
function handleDragStart(e: DragEvent) {
dragging = true;
const box = (e?.target as HTMLElement)?.getBoundingClientRect();
if (e.dataTransfer === null) return;
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("data/node-id", node.id);
e.dataTransfer.setData("data/node-id", node.id.toString());
if (nodeData.props) {
e.dataTransfer.setData("data/node-props", JSON.stringify(nodeData.props));
}
@@ -38,15 +38,15 @@
<div class="node-wrapper" class:dragging>
<div
on:dragend={() => {
ondragend={() => {
dragging = false;
}}
draggable={true}
role="button"
tabindex="0"
on:dragstart={handleDragStart}
ondragstart={handleDragStart}
>
<NodeHtml inView={true} position={"relative"} z={5} bind:node={nodeData} />
<NodeHtml bind:node={nodeData} inView={true} position={"relative"} z={5} />
</div>
</div>

View File

@@ -1,19 +1,16 @@
<script lang="ts">
import { writable } from "svelte/store";
import BreadCrumbs from "./BreadCrumbs.svelte";
import DraggableNode from "./DraggableNode.svelte";
import type { RemoteNodeRegistry } from "@nodes/registry";
import type { RemoteNodeRegistry } from "@nodarium/registry";
export let registry: RemoteNodeRegistry;
const { registry }: { registry: RemoteNodeRegistry } = $props();
const activeId = writable("max/plantarium");
let activeId = $state("max/plantarium");
let showBreadCrumbs = false;
// const activeId = localStore<
// `${string}` | `${string}/${string}` | `${string}/${string}/${string}`
// >("nodes.store.activeId", "");
$: [activeUser, activeCollection, activeNode] = $activeId.split(`/`);
const [activeUser, activeCollection, activeNode] = $derived(
activeId.split(`/`),
);
</script>
{#if showBreadCrumbs}
@@ -27,8 +24,8 @@
{:then users}
{#each users as user}
<button
on:click={() => {
$activeId = user.id;
onclick={() => {
activeId = user.id;
}}>{user.id}</button
>
{/each}
@@ -41,8 +38,8 @@
{:then user}
{#each user.collections as collection}
<button
on:click={() => {
$activeId = collection.id;
onclick={() => {
activeId = collection.id;
}}
>
{collection.id.split(`/`)[1]}

View File

@@ -1,6 +1,3 @@
<script lang="ts">
</script>
<span class="spinner"></span>
<style>

View File

@@ -1,8 +1,12 @@
<script lang="ts">
export let labels: string[] = [];
export let values: number[] = [];
type Props = {
labels: string[];
values: number[];
};
$: total = values.reduce((acc, v) => acc + v, 0);
const { labels, values }: Props = $props();
const total = $derived(values.reduce((acc, v) => acc + v, 0));
let colors = ["red", "green", "blue"];
</script>
@@ -10,7 +14,10 @@
<div class="wrapper">
<div class="bars">
{#each values as value, i}
<div class="bar bg-{colors[i]}" style="width: {(value / total) * 100}%;">
<div
class="bar bg-{colors[i]}-400"
style="width: {(value / total) * 100}%;"
>
{Math.round(value)}ms
</div>
{/each}
@@ -18,12 +25,11 @@
<div class="labels mt-2">
{#each values as _label, i}
<div class="text-{colors[i]}">{labels[i]}</div>
<div class="text-{colors[i]}-400">{labels[i]}</div>
{/each}
</div>
<span
class="bg-red bg-green bg-yellow bg-blue text-red text-green text-yellow text-blue"
class="bg-red-400 bg-green-400 bg-blue-400 text-red-400 text-green-400 text-blue-400"
></span>
</div>

View File

@@ -1,52 +1,59 @@
<script lang="ts">
export let points: number[];
type Props = {
points: number[];
type?: string;
title?: string;
max?: number;
min?: number;
};
export let type = "ms";
export let title = "Performance";
export let max: number | undefined = undefined;
export let min: number | undefined = undefined;
let {
points,
type = "ms",
title = "Performance",
max,
min,
}: Props = $props();
function getMax(m?: number) {
let internalMax = $derived(max ?? Math.max(...points));
let internalMin = $derived(min ?? Math.min(...points))!;
const maxText = $derived.by(() => {
if (type === "%") {
return 100;
}
if (m !== undefined) {
if (m < 1) {
return Math.floor(m * 100) / 100;
if (internalMax !== undefined) {
if (internalMax < 1) {
return Math.floor(internalMax * 100) / 100;
}
if (m < 10) {
return Math.floor(m * 10) / 10;
if (internalMax < 10) {
return Math.floor(internalMax * 10) / 10;
}
return Math.floor(m);
return Math.floor(internalMax);
}
return 1;
}
});
function constructPath() {
max = max !== undefined ? max : Math.max(...points);
min = min !== undefined ? min : Math.min(...points);
const mi = min as number;
const ma = max as number;
return points
const path = $derived(
points
.map((point, i) => {
const x = (i / (points.length - 1)) * 100;
const y = 100 - ((point - mi) / (ma - mi)) * 100;
const y =
100 - ((point - internalMin) / (internalMax - internalMin)) * 100;
return `${x},${y}`;
})
.join(" ");
}
.join(" "),
);
</script>
<div class="wrapper">
<p>{title}</p>
<span class="min">{Math.floor(min || 0)}{type}</span>
<span class="max">{getMax(max)}{type}</span>
<span class="min">{Math.floor(internalMin || 0)}{type}</span>
<span class="max">{maxText}{type}</span>
<svg preserveAspectRatio="none" viewBox="0 0 100 100">
{#key points}
<polyline vector-effect="non-scaling-stroke" points={constructPath()} />
{/key}
<polyline vector-effect="non-scaling-stroke" points={path} />
</svg>
</div>

View File

@@ -1,24 +1,14 @@
<script lang="ts">
import Monitor from "./Monitor.svelte";
import { humanizeNumber } from "$lib/helpers";
import { Checkbox } from "@nodes/ui";
import localStore from "$lib/helpers/localStore";
import { type PerformanceData } from "@nodes/utils";
import { Checkbox } from "@nodarium/ui";
import type { PerformanceData } from "@nodarium/utils";
import BarSplit from "./BarSplit.svelte";
export let data: PerformanceData;
const { data }: { data: PerformanceData } = $props();
let activeType = localStore<string>("nodes.performance.active-type", "total");
let showAverage = true;
function getAverage(key: string) {
return (
data
.map((run) => run[key]?.[0])
.filter((v) => v !== undefined)
.reduce((acc, run) => acc + run, 0) / data.length
);
}
let activeType = $state("total");
let showAverage = $state(true);
function round(v: number) {
if (v < 1) {
@@ -30,45 +20,15 @@
return Math.floor(v);
}
function getAverages() {
let lastRun = data.at(-1);
if (!lastRun) return {};
return Object.keys(lastRun).reduce(
(acc, key) => {
acc[key] = getAverage(key);
return acc;
},
{} as Record<string, number>,
);
function getTitle(t: string) {
if (t.includes("/")) {
return `Node ${t.split("/").slice(-1).join("/")}`;
}
function getLast(key: string) {
return data.at(-1)?.[key]?.[0] || 0;
}
function getLasts() {
return data.at(-1) || {};
}
function getTotalPerformance(onlyLast = false) {
if (onlyLast) {
return (
getLast("runtime") +
getLast("update-geometries") +
getLast("worker-transfer")
);
}
return (
getAverage("runtime") +
getAverage("update-geometries") +
getAverage("worker-transfer")
);
}
function getCacheRatio(onlyLast = false) {
let ratio = onlyLast ? getLast("cache-hit") : getAverage("cache-hit");
return Math.floor(ratio * 100);
return t
.split("-")
.map((v) => v[0].toUpperCase() + v.slice(1))
.join(" ");
}
const viewerKeys = [
@@ -78,10 +38,53 @@
"split-result",
];
function getPerformanceData(onlyLast: boolean = false) {
let data = onlyLast ? getLasts() : getAverages();
// --- Small helpers that query `data` directly ---
function getAverage(key: string) {
const vals = data
.map((run) => run[key]?.[0])
.filter((v) => v !== undefined) as number[];
return Object.entries(data)
if (vals.length === 0) return 0;
return vals.reduce((acc, v) => acc + v, 0) / vals.length;
}
function getLast(key: string) {
return data.at(-1)?.[key]?.[0] || 0;
}
const averages = $derived.by(() => {
const lr = data.at(-1);
if (!lr) return {} as Record<string, number>;
return Object.keys(lr).reduce((acc: Record<string, number>, key) => {
acc[key] = getAverage(key);
return acc;
}, {});
});
const lasts = $derived.by(() => data.at(-1) || {});
const totalPerformance = $derived.by(() => {
const onlyLast =
getLast("runtime") +
getLast("update-geometries") +
getLast("worker-transfer");
const average =
getAverage("runtime") +
getAverage("update-geometries") +
getAverage("worker-transfer");
return { onlyLast, average };
});
const cacheRatio = $derived.by(() => {
return {
onlyLast: Math.floor(getLast("cache-hit") * 100),
average: Math.floor(getAverage("cache-hit") * 100),
};
});
const performanceData = $derived.by(() => {
const source = showAverage ? averages : lasts;
return Object.entries(source)
.filter(
([key]) =>
!key.startsWith("node/") &&
@@ -90,19 +93,18 @@
!viewerKeys.includes(key),
)
.sort((a, b) => b[1] - a[1]);
}
});
function getNodePerformanceData(onlyLast: boolean = false) {
let data = onlyLast ? getLasts() : getAverages();
return Object.entries(data)
const nodePerformanceData = $derived.by(() => {
const source = showAverage ? averages : lasts;
return Object.entries(source)
.filter(([key]) => key.startsWith("node/"))
.sort((a, b) => b[1] - a[1]);
}
});
function getViewerPerformanceData(onlyLast: boolean = false) {
let data = onlyLast ? getLasts() : getAverages();
return Object.entries(data)
const viewerPerformanceData = $derived.by(() => {
const source = showAverage ? averages : lasts;
return Object.entries(source)
.filter(
([key]) =>
key !== "total-vertices" &&
@@ -110,14 +112,29 @@
viewerKeys.includes(key),
)
.sort((a, b) => b[1] - a[1]);
}
});
function getTotalPoints() {
const splitValues = $derived.by(() => {
if (showAverage) {
return [
getAverage("worker-transfer"),
getAverage("runtime"),
getAverage("update-geometries"),
];
}
return [
getLast("worker-transfer"),
getLast("runtime"),
getLast("update-geometries"),
];
});
const totalPoints = $derived.by(() => {
if (showAverage) {
return data.map((run) => {
return (
run["runtime"].reduce((acc, v) => acc + v, 0) +
run["update-geometries"].reduce((acc, v) => acc + v, 0) +
(run["runtime"]?.reduce((acc, v) => acc + v, 0) || 0) +
(run["update-geometries"]?.reduce((acc, v) => acc + v, 0) || 0) +
(run["worker-transfer"]?.reduce((acc, v) => acc + v, 0) || 0)
);
});
@@ -125,16 +142,16 @@
return data.map((run) => {
return (
run["runtime"][0] +
run["update-geometries"][0] +
(run["runtime"]?.[0] || 0) +
(run["update-geometries"]?.[0] || 0) +
(run["worker-transfer"]?.[0] || 0)
);
});
}
});
function constructPoints(key: string) {
if (key === "total") {
return getTotalPoints();
return totalPoints;
}
return data.map((run) => {
if (key in run) {
@@ -148,47 +165,33 @@
});
}
function getSplitValues(): number[] {
if (showAverage) {
return [
getAverage("worker-transfer"),
getAverage("runtime"),
getAverage("update-geometries"),
];
}
const computedTotalDisplay = $derived.by(() =>
round(showAverage ? totalPerformance.average : totalPerformance.onlyLast),
);
return [
getLast("worker-transfer"),
getLast("runtime"),
getLast("update-geometries"),
];
}
function getTitle(t: string) {
if (t.includes("/")) {
return `Node ${t.split("/").slice(-1).join("/")}`;
}
return t
.split("-")
.map((v) => v[0].toUpperCase() + v.slice(1))
.join(" ");
}
const computedFps = $derived.by(() =>
Math.floor(
1000 /
(showAverage
? totalPerformance.average || 1
: totalPerformance.onlyLast || 1),
),
);
</script>
{#key $activeType && data}
{#if $activeType === "cache-hit"}
{#if data.length !== 0}
{#if activeType === "cache-hit"}
<Monitor
title="Cache Hits"
points={constructPoints($activeType)}
points={constructPoints(activeType)}
min={0}
max={1}
type="%"
/>
{:else}
<Monitor
title={getTitle($activeType)}
points={constructPoints($activeType)}
title={getTitle(activeType)}
points={constructPoints(activeType)}
/>
{/if}
@@ -198,10 +201,9 @@
<label for="show-total">Show Average</label>
</div>
{#if data.length !== 0}
<BarSplit
labels={["worker-transfer", "runtime", "update-geometries"]}
values={getSplitValues()}
values={splitValues}
/>
<h3>General</h3>
@@ -210,27 +212,22 @@
<tbody>
<tr>
<td>
{round(getTotalPerformance(!showAverage))}<span>ms</span>
{computedTotalDisplay}<span>ms</span>
</td>
<td
class:active={$activeType === "total"}
on:click={() => ($activeType = "total")}
>
total<span
>({Math.floor(
1000 / getTotalPerformance(showAverage),
)}fps)</span
class:active={activeType === "total"}
onclick={() => (activeType = "total")}
>
total<span>({computedFps}fps)</span>
</td>
</tr>
{#each getPerformanceData(!showAverage) as [key, value]}
{#each performanceData as [key, value]}
<tr>
<td>
{round(value)}<span>ms</span>
</td>
<td>{round(value)}<span>ms</span></td>
<td
class:active={$activeType === key}
on:click={() => ($activeType = key)}
class:active={activeType === key}
onclick={() => (activeType = key)}
>
{key}
</td>
@@ -242,43 +239,43 @@
<td>Samples</td>
</tr>
</tbody>
<tbody>
<tr>
<td>
<h3>Nodes</h3>
</td>
</tr>
</tbody>
<tbody>
<tr>
<td> {getCacheRatio(!showAverage)}<span>%</span> </td>
<td
class:active={$activeType === "cache-hit"}
on:click={() => ($activeType = "cache-hit")}>cache hits</td
>
</tr>
{#each getNodePerformanceData(!showAverage) as [key, value]}
<tr>
<td>
{round(value)}<span>ms</span>
</td>
<tbody>
<tr><td><h3>Nodes</h3></td></tr>
</tbody>
<tbody>
<tr>
<td
class:active={$activeType === key}
on:click={() => ($activeType = key)}
>{showAverage ? cacheRatio.average : cacheRatio.onlyLast}<span
>%</span
></td
>
<td
class:active={activeType === "cache-hit"}
onclick={() => (activeType = "cache-hit")}
>
cache hits
</td>
</tr>
{#each nodePerformanceData as [key, value]}
<tr>
<td>{round(value)}<span>ms</span></td>
<td
class:active={activeType === key}
onclick={() => (activeType = key)}
>
{key.split("/").slice(-1).join("/")}
</td>
</tr>
{/each}
</tbody>
<tbody>
<tr>
<td>
<h3>Viewer</h3>
</td>
</tr>
<tr><td><h3>Viewer</h3></td></tr>
</tbody>
<tbody>
<tr>
<td>{humanizeNumber(getLast("total-vertices"))}</td>
@@ -288,14 +285,13 @@
<td>{humanizeNumber(getLast("total-faces"))}</td>
<td>Faces</td>
</tr>
{#each getViewerPerformanceData(!showAverage) as [key, value]}
{#each viewerPerformanceData as [key, value]}
<tr>
<td>
{round(value)}<span>ms</span>
</td>
<td>{round(value)}<span>ms</span></td>
<td
class:active={$activeType === key}
on:click={() => ($activeType = key)}
class:active={activeType === key}
onclick={() => (activeType = key)}
>
{key.split("/").slice(-1).join("/")}
</td>
@@ -303,11 +299,10 @@
{/each}
</tbody>
</table>
{:else}
<p>No runs available</p>
{/if}
</div>
{/key}
{:else}
<p>No runs available</p>
{/if}
<style>
h3 {

View File

@@ -1,7 +1,7 @@
<script lang="ts">
export let points: number[];
const { points }: { points: number[] } = $props();
function constructPath() {
const path = $derived.by(() => {
const max = Math.max(...points);
const min = Math.min(...points);
return points
@@ -11,13 +11,11 @@
return `${x},${y}`;
})
.join(" ");
}
});
</script>
<svg preserveAspectRatio="none" viewBox="0 0 100 100">
{#key points}
<polyline vector-effect="non-scaling-stroke" points={constructPath()} />
{/key}
<polyline vector-effect="non-scaling-stroke" points={path} />
</svg>
<style>

View File

@@ -1,35 +1,36 @@
<script lang="ts">
import { humanizeDuration, humanizeNumber } from "$lib/helpers";
import localStore from "$lib/helpers/localStore";
import { localState } from "$lib/helpers/localState.svelte";
import SmallGraph from "./SmallGraph.svelte";
import type { PerformanceData, PerformanceStore } from "@nodes/utils";
import type { PerformanceData, PerformanceStore } from "@nodarium/utils";
export let store: PerformanceStore;
const { store, fps }: { store: PerformanceStore; fps: number[] } = $props();
const open = localStore("node.performance.small.open", {
const open = localState("node.performance.small.open", {
runtime: false,
fps: false,
});
$: vertices = $store?.at(-1)?.["total-vertices"]?.[0] || 0;
$: faces = $store?.at(-1)?.["total-faces"]?.[0] || 0;
$: runtime = $store?.at(-1)?.["runtime"]?.[0] || 0;
const vertices = $derived($store?.at(-1)?.["total-vertices"]?.[0] || 0);
const faces = $derived($store?.at(-1)?.["total-faces"]?.[0] || 0);
const runtime = $derived($store?.at(-1)?.["runtime"]?.[0] || 0);
function getPoints(data: PerformanceData, key: string) {
return data?.map((run) => run[key]?.[0] || 0) || [];
}
export let fps: number[] = [];
</script>
<div class="wrapper">
<table>
<tbody>
<tr on:click={() => ($open.runtime = !$open.runtime)}>
<td>{$open.runtime ? "-" : "+"} runtime </td>
<tr
style="cursor:pointer;"
onclick={() => (open.value.runtime = !open.value.runtime)}
>
<td>{open.value.runtime ? "-" : "+"} runtime </td>
<td>{humanizeDuration(runtime || 1000)}</td>
</tr>
{#if $open.runtime}
{#if open.value.runtime}
<tr>
<td colspan="2">
<SmallGraph points={getPoints($store, "runtime")} />
@@ -37,13 +38,16 @@
</tr>
{/if}
<tr on:click={() => ($open.fps = !$open.fps)}>
<td>{$open.fps ? "-" : "+"} fps </td>
<tr
style="cursor:pointer;"
onclick={() => (open.value.fps = !open.value.fps)}
>
<td>{open.value.fps ? "-" : "+"} fps </td>
<td>
{Math.floor(fps[fps.length - 1])}fps
</td>
</tr>
{#if $open.fps}
{#if open.value.fps}
<tr>
<td colspan="2">
<SmallGraph points={fps} />
@@ -74,9 +78,6 @@
border: solid thin var(--outline);
border-collapse: collapse;
}
tr {
cursor: pointer;
}
td {
padding: 4px;
padding-inline: 8px;

View File

@@ -35,6 +35,9 @@
scene = $bindable(),
}: Props = $props();
let geometries = $state.raw<BufferGeometry[]>([]);
let center = $state(new Vector3(0, 4, 0));
useTask(
(delta) => {
fps.push(1 / delta);
@@ -45,11 +48,13 @@
export const invalidate = function () {
if (scene) {
geometries = scene.children
.filter((child) => "geometry" in child && child.isObject3D)
.map((child) => {
return (child as Mesh).geometry;
const geos: BufferGeometry[] = [];
scene.traverse(function (child) {
if (isMesh(child)) {
geos.push(child.geometry);
}
});
geometries = geos;
}
if (geometries && scene && centerCamera) {
@@ -62,9 +67,6 @@
_invalidate();
};
let geometries = $state<BufferGeometry[]>();
let center = $state(new Vector3(0, 4, 0));
function isMesh(child: Mesh | any): child is Mesh {
return child.isObject3D && "material" in child;
}
@@ -76,7 +78,7 @@
$effect(() => {
const wireframe = appSettings.value.debug.wireframe;
scene.traverse(function (child) {
if (isMesh(child) && isMatCapMaterial(child.material)) {
if (isMesh(child) && isMatCapMaterial(child.material) && child.visible) {
child.material.wireframe = wireframe;
}
});
@@ -90,6 +92,13 @@
geo.attributes.position.array[i + 2],
] as Vector3Tuple;
}
// $effect(() => {
// console.log({
// geometries: $state.snapshot(geometries),
// indices: appSettings.value.debug.showIndices,
// });
// });
</script>
<Camera {center} {centerCamera} />

View File

@@ -2,8 +2,8 @@
import { Canvas } from "@threlte/core";
import Scene from "./Scene.svelte";
import { Vector3 } from "three";
import { decodeFloat, splitNestedArray } from "@nodes/utils";
import type { PerformanceStore } from "@nodes/utils";
import { decodeFloat, splitNestedArray } from "@nodarium/utils";
import type { PerformanceStore } from "@nodarium/utils";
import { appSettings } from "$lib/settings/app-settings.svelte";
import SmallPerformanceViewer from "$lib/performance/SmallPerformanceViewer.svelte";
import { MeshMatcapMaterial, TextureLoader, type Group } from "three";

View File

@@ -1,4 +1,4 @@
import { fastHashArrayBuffer } from "@nodes/utils";
import { fastHashArrayBuffer } from "@nodarium/utils";
import {
BufferAttribute,
BufferGeometry,
@@ -11,7 +11,7 @@ import {
} from "three";
function fastArrayHash(arr: Int32Array) {
const sampleDistance = Math.max(Math.floor(arr.length / 100), 1);
const sampleDistance = Math.max(Math.floor(arr.length / 1000), 1);
const sampleCount = Math.floor(arr.length / sampleDistance);
let hash = new Int32Array(sampleCount);
@@ -40,6 +40,9 @@ export function createGeometryPool(parentScene: Group, material: Material) {
let hash = fastArrayHash(data);
let geometry = existingMesh ? existingMesh.geometry : new BufferGeometry();
if (existingMesh) {
existingMesh.visible = true;
}
// Extract data from the encoded array
// const geometryType = encodedData[index++];
@@ -121,7 +124,6 @@ export function createGeometryPool(parentScene: Group, material: Material) {
updateSingleGeometry(input, existingMesh || null);
} else if (existingMesh) {
existingMesh.visible = false;
scene.remove(existingMesh);
}
}
return { totalVertices, totalFaces };
@@ -206,19 +208,16 @@ export function createInstancedGeometryPool(
existingInstance &&
instanceCount > existingInstance.geometry.userData.count
) {
console.log("recreating instance");
scene.remove(existingInstance);
instances.splice(instances.indexOf(existingInstance), 1);
existingInstance = new InstancedMesh(geometry, material, instanceCount);
scene.add(existingInstance);
instances.push(existingInstance);
} else if (!existingInstance) {
console.log("creating instance");
existingInstance = new InstancedMesh(geometry, material, instanceCount);
scene.add(existingInstance);
instances.push(existingInstance);
} else {
console.log("updating instance");
existingInstance.count = instanceCount;
}
@@ -261,7 +260,6 @@ export function createInstancedGeometryPool(
updateSingleInstance(input, existingMesh || null);
} else if (existingMesh) {
existingMesh.visible = false;
scene.remove(existingMesh);
}
}
return { totalVertices, totalFaces };

View File

@@ -1,4 +1,4 @@
import type { Graph, RuntimeExecutor } from "@nodes/types";
import type { Graph, RuntimeExecutor } from "@nodarium/types";
export class RemoteRuntimeExecutor implements RuntimeExecutor {

View File

@@ -1,19 +1,33 @@
import { type SyncCache } from "@nodes/types";
import { type SyncCache } from "@nodarium/types";
export class MemoryRuntimeCache implements SyncCache {
private map = new Map<string, unknown>();
size: number;
private cache: [string, unknown][] = [];
size = 50;
constructor(size = 50) {
this.size = size;
}
get<T>(key: string): T | undefined {
return this.cache.find(([k]) => k === key)?.[1] as T;
}
set<T>(key: string, value: T): void {
this.cache.push([key, value]);
this.cache = this.cache.slice(-this.size);
}
clear(): void {
this.cache = [];
if (!this.map.has(key)) return undefined;
const value = this.map.get(key) as T;
this.map.delete(key);
this.map.set(key, value);
return value;
}
set<T>(key: string, value: T): void {
if (this.map.has(key)) {
this.map.delete(key);
}
this.map.set(key, value);
while (this.map.size > this.size) {
const oldestKey = this.map.keys().next().value as string;
this.map.delete(oldestKey);
}
}
clear(): void {
this.map.clear();
}
}

View File

@@ -1,22 +1,22 @@
import type {
Graph,
Node,
NodeDefinition,
NodeInput,
NodeRegistry,
RuntimeExecutor,
SyncCache,
} from "@nodes/types";
} from "@nodarium/types";
import {
concatEncodedArrays,
createLogger,
encodeFloat,
fastHashArrayBuffer,
type PerformanceStore,
} from "@nodes/utils";
} from "@nodarium/utils";
import type { RuntimeNode } from "./types";
const log = createLogger("runtime-executor");
// log.mute();
log.mute();
function getValue(input: NodeInput, value?: unknown) {
if (value === undefined && "value" in input) {
@@ -58,14 +58,16 @@ function getValue(input: NodeInput, value?: unknown) {
export class MemoryRuntimeExecutor implements RuntimeExecutor {
private definitionMap: Map<string, NodeDefinition> = new Map();
private randomSeed = Math.floor(Math.random() * 100000000);
private seed = Math.floor(Math.random() * 100000000);
perf?: PerformanceStore;
constructor(
private registry: NodeRegistry,
private cache?: SyncCache<Int32Array>,
) {}
public cache?: SyncCache<Int32Array>,
) {
this.cache = undefined;
}
private async getNodeDefinitions(graph: Graph) {
if (this.registry.status !== "ready") {
@@ -90,18 +92,27 @@ export class MemoryRuntimeExecutor implements RuntimeExecutor {
// First, lets check if all nodes have a definition
this.definitionMap = await this.getNodeDefinitions(graph);
const outputNode = graph.nodes.find((node) =>
const graphNodes = graph.nodes.map(node => {
const n = node as RuntimeNode;
n.state = {
depth: 0,
children: [],
parents: [],
inputNodes: {},
}
return n
})
const outputNode = graphNodes.find((node) =>
node.type.endsWith("/output"),
) as Node;
);
if (!outputNode) {
throw new Error("No output node found");
}
outputNode.tmp = outputNode.tmp || {};
outputNode.tmp.depth = 0;
const nodeMap = new Map<number, Node>(
graph.nodes.map((node) => [node.id, node]),
const nodeMap = new Map(
graphNodes.map((node) => [node.id, node]),
);
// loop through all edges and assign the parent and child nodes to each node
@@ -110,14 +121,9 @@ export class MemoryRuntimeExecutor implements RuntimeExecutor {
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;
parent.state.children.push(child);
child.state.parents.push(parent);
child.state.inputNodes[childInput] = parent;
}
}
@@ -128,20 +134,10 @@ export class MemoryRuntimeExecutor implements RuntimeExecutor {
while (stack.length) {
const node = stack.pop();
if (!node) continue;
node.tmp = node.tmp || {};
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;
for (const parent of node.state.parents) {
parent.state = parent.state || {};
parent.state.depth = node.state.depth + 1;
stack.push(parent);
} else {
parent.tmp.depth = Math.max(parent.tmp.depth, node.tmp.depth + 1);
}
}
}
nodes.push(node);
}
@@ -173,16 +169,20 @@ export class MemoryRuntimeExecutor implements RuntimeExecutor {
// we execute the nodes from the bottom up
const sortedNodes = nodes.sort(
(a, b) => (b.tmp?.depth || 0) - (a.tmp?.depth || 0),
(a, b) => (b.state?.depth || 0) - (a.state?.depth || 0),
);
// here we store the intermediate results of the nodes
const results: Record<string, Int32Array> = {};
if (settings["randomSeed"]) {
this.seed = Math.floor(Math.random() * 100000000);
}
for (const node of sortedNodes) {
const node_type = this.definitionMap.get(node.type)!;
if (!node_type || !node.tmp || !node_type.execute) {
if (!node_type || !node.state || !node_type.execute) {
log.warn(`Node ${node.id} has no definition`);
continue;
}
@@ -193,11 +193,7 @@ export class MemoryRuntimeExecutor implements RuntimeExecutor {
const inputs = Object.entries(node_type.inputs || {}).map(
([key, input]) => {
if (input.type === "seed") {
if (settings["randomSeed"] === true) {
return Math.floor(Math.random() * 100000000);
} else {
return this.randomSeed;
}
return this.seed;
}
// If the input is linked to a setting, we use that value
@@ -206,7 +202,7 @@ export class MemoryRuntimeExecutor implements RuntimeExecutor {
}
// check if the input is connected to another node
const inputNode = node.tmp?.inputNodes?.[key];
const inputNode = node.state.inputNodes[key];
if (inputNode) {
if (results[inputNode.id] === undefined) {
throw new Error(

View File

@@ -0,0 +1,10 @@
import type { SerializedNode } from "@nodarium/types";
type RuntimeState = {
depth: number
parents: RuntimeNode[],
children: RuntimeNode[],
inputNodes: Record<string, RuntimeNode>
}
export type RuntimeNode = SerializedNode & { state: RuntimeState }

View File

@@ -1,17 +1,34 @@
import { MemoryRuntimeExecutor } from "./runtime-executor";
import { RemoteNodeRegistry, IndexDBCache } from "@nodes/registry";
import type { Graph } from "@nodes/types";
import { createPerformanceStore } from "@nodes/utils";
import { RemoteNodeRegistry, IndexDBCache } from "@nodarium/registry";
import type { Graph } from "@nodarium/types";
import { createPerformanceStore } from "@nodarium/utils";
import { MemoryRuntimeCache } from "./runtime-executor-cache";
const cache = new MemoryRuntimeCache();
const indexDbCache = new IndexDBCache("node-registry");
const nodeRegistry = new RemoteNodeRegistry("", indexDbCache);
const cache = new MemoryRuntimeCache()
const executor = new MemoryRuntimeExecutor(nodeRegistry, cache);
const performanceStore = createPerformanceStore();
executor.perf = performanceStore;
export async function setUseRegistryCache(useCache: boolean) {
if (useCache) {
nodeRegistry.cache = indexDbCache;
} else {
nodeRegistry.cache = undefined;
}
}
export async function setUseRuntimeCache(useCache: boolean) {
if (useCache) {
executor.cache = cache;
} else {
executor.cache = undefined;
}
}
export async function executeGraph(
graph: Graph,
settings: Record<string, unknown>,

View File

@@ -1,5 +1,5 @@
/// <reference types="vite-plugin-comlink/client" />
import type { Graph, RuntimeExecutor } from "@nodes/types";
import type { Graph, RuntimeExecutor } from "@nodarium/types";
export class WorkerRuntimeExecutor implements RuntimeExecutor {
@@ -11,5 +11,11 @@ export class WorkerRuntimeExecutor implements RuntimeExecutor {
async getPerformanceData() {
return this.worker.getPerformanceData();
}
set useRuntimeCache(useCache: boolean) {
this.worker.setUseRuntimeCache(useCache);
}
set useRegistryCache(useCache: boolean) {
this.worker.setUseRegistryCache(useCache);
}
}

View File

@@ -1,10 +1,10 @@
<script lang="ts">
import NestedSettings from "./NestedSettings.svelte";
import { localState } from "$lib/helpers/localState.svelte";
import type { NodeInput } from "@nodes/types";
import Input from "@nodes/ui";
import type { NodeInput } from "@nodarium/types";
import Input from "@nodarium/ui";
type Button = { type: "button"; label?: string };
type Button = { type: "button"; callback: () => void; label?: string };
type InputType = NodeInput | Button;
@@ -99,7 +99,7 @@
Array.isArray((node as any).options) &&
typeof internalValue === "number"
) {
value[key] = (node as any).options[internalValue] as any;
value[key] = (node as any)?.options?.[internalValue] as any;
} else {
value[key] = internalValue as any;
}
@@ -110,11 +110,13 @@
<!-- Leaf input -->
<div class="input input-{type[key].type}" class:first-level={depth === 1}>
{#if type[key].type === "button"}
<button onclick={() => type[key].callback()}>
<button onclick={() => "callback" in type[key] && type[key].callback()}>
{type[key].label || key}
</button>
{:else}
{#if type[key].label !== ""}
<label for={id}>{type[key].label || key}</label>
{/if}
<Input {id} input={type[key]} bind:value={internalValue} />
{/if}
</div>
@@ -194,6 +196,10 @@
padding-bottom: 1px;
}
button {
cursor: pointer;
}
hr {
position: absolute;
margin: 0;

View File

@@ -1,6 +1,4 @@
import { localState } from "$lib/helpers/localState.svelte";
import type { NodeInput } from "@nodes/types";
import type { SettingsType } from ".";
const themes = [
"dark",
@@ -10,7 +8,7 @@ const themes = [
"high-contrast",
"nord",
"dracula",
];
] as const;
export const AppSettingTypes = {
theme: {
@@ -49,11 +47,6 @@ export const AppSettingTypes = {
},
debug: {
title: "Debug",
amount: {
type: "number",
label: "Amount",
value: 4,
},
wireframe: {
type: "boolean",
label: "Wireframe",
@@ -61,7 +54,7 @@ export const AppSettingTypes = {
},
useWorker: {
type: "boolean",
label: "Execute runtime in worker",
label: "Execute in WebWorker",
value: true,
},
showIndices: {
@@ -89,6 +82,24 @@ export const AppSettingTypes = {
label: "Show Stem Lines",
value: false,
},
showGraphJson: {
type: "boolean",
label: "Show Graph Source",
value: false,
},
cache: {
title: "Cache",
useRuntimeCache: {
type: "boolean",
label: "Node Results",
value: true,
},
useRegistryCache: {
type: "boolean",
label: "Node Source",
value: true,
},
},
stressTest: {
title: "Stress Test",
amount: {
@@ -119,32 +130,23 @@ export const AppSettingTypes = {
},
},
},
} as const satisfies SettingsType;
} as const;
type IsInputDefinition<T> = T extends NodeInput ? T : never;
type HasTitle = { title: string };
type Widen<T> = T extends boolean
? boolean
: T extends number
? number
: T extends string
? string
: T;
type ExtractSettingsValues<T> = {
-readonly [K in keyof T]: T[K] extends HasTitle
? ExtractSettingsValues<Omit<T[K], "title">>
: T[K] extends IsInputDefinition<T[K]>
? T[K] extends { value: infer V }
? Widen<V>
: never
: T[K] extends Record<string, any>
? ExtractSettingsValues<T[K]>
type SettingsToStore<T> =
T extends { value: infer V }
? V extends readonly string[]
? V[number]
: V
: T extends any[]
? {}
: T extends object
? {
[K in keyof T as T[K] extends object ? K : never]:
SettingsToStore<T[K]>
}
: never;
};
export function settingsToStore<T>(settings: T): ExtractSettingsValues<T> {
export function settingsToStore<T>(settings: T): SettingsToStore<T> {
const result = {} as any;
for (const key in settings) {
const value = settings[key];

View File

@@ -1,4 +1,4 @@
import type { NodeInput } from "@nodes/types";
import type { NodeInput } from "@nodarium/types";
type Button = { type: "button"; label?: string };

View File

@@ -1,43 +1,46 @@
<script lang="ts">
import { getContext } from "svelte";
import type { Readable } from "svelte/store";
import { getContext, type Snippet } from "svelte";
import type { PanelState } from "./PanelState.svelte";
export let id: string;
export let icon: string = "";
export let title = "";
export let classes = "";
export let hidden: boolean | undefined = undefined;
const {
id,
icon = "",
title = "",
classes = "",
hidden,
children,
} = $props<{
id: string;
icon?: string;
title?: string;
classes?: string;
hidden?: boolean;
children?: Snippet;
}>();
const setVisibility =
getContext<(id: string, visible: boolean) => void>("setVisibility");
const panelState = getContext<PanelState>("panel-state");
$: if (typeof hidden === "boolean") {
setVisibility(id, !hidden);
}
const registerPanel =
getContext<
(id: string, icon: string, classes: string) => Readable<boolean>
>("registerPanel");
let visible = registerPanel(id, icon, classes);
const panel = panelState.registerPanel(id, icon, classes, hidden);
$effect(() => {
panel.hidden = hidden;
});
</script>
{#if $visible}
{#if panelState.activePanel.value === id}
<div class="wrapper" class:hidden>
{#if title}
<header>
<h3>{title}</h3>
</header>
{/if}
<slot />
{@render children?.()}
</div>
{/if}
<style>
header {
border-bottom: solid thin var(--outline);
height: 69px;
height: 70px;
display: flex;
align-items: center;
padding-left: 1em;

View File

@@ -0,0 +1,35 @@
import { localState } from "$lib/helpers/localState.svelte";
type Panel = {
icon: string;
classes: string;
hidden?: boolean;
}
export class PanelState {
panels = $state<Record<string, Panel>>({});
activePanel = localState<string | boolean>("node.activePanel", "")
get keys() {
return Object.keys(this.panels);
}
public registerPanel(id: string, icon: string, classes: string, hidden: boolean): Panel {
const state = $state({
icon: icon,
classes: classes,
hidden: hidden,
});
this.panels[id] = state;
return state;
}
public toggleOpen() {
if (this.activePanel.value) {
this.activePanel.value = false;
} else {
this.activePanel.value = this.keys[0]
}
}
}

View File

@@ -1,77 +1,35 @@
<script lang="ts">
import localStore from "$lib/helpers/localStore";
import { setContext } from "svelte";
import { derived } from "svelte/store";
import { setContext, type Snippet } from "svelte";
import { PanelState } from "./PanelState.svelte";
let panels: Record<
string,
{
icon: string;
id: string;
classes: string;
visible?: boolean;
}
> = {};
const state = new PanelState();
setContext("panel-state", state);
const activePanel = localStore<keyof typeof panels | false>(
"nodes.settings.activePanel",
false,
);
$: keys = panels
? (Object.keys(panels) as unknown as (keyof typeof panels)[]).filter(
(key) => !!panels[key]?.id,
)
: [];
setContext("setVisibility", (id: string, visible: boolean) => {
panels[id].visible = visible;
panels = { ...panels };
});
setContext("registerPanel", (id: string, icon: string, classes: string) => {
panels[id] = { id, icon, classes };
return derived(activePanel, ($activePanel) => {
return $activePanel === id;
});
});
function setActivePanel(panel: keyof typeof panels | false) {
if (panel === $activePanel) {
$activePanel = false;
} else if (panel) {
$activePanel = panel;
} else {
$activePanel = false;
}
}
const { children } = $props<{ children?: Snippet }>();
</script>
<div class="wrapper" class:visible={$activePanel}>
<div class="wrapper" class:visible={state.activePanel.value}>
<div class="tabs">
<button
aria-label="Close"
on:click={() => {
setActivePanel($activePanel ? false : keys[0]);
}}
>
<span class="absolute i-tabler-chevron-left w-6 h-6 block"></span>
<button aria-label="Close" onclick={() => state.toggleOpen()}>
<span class="icon-[tabler--settings]"></span>
<span class="absolute i-[tabler--chevron-left] w-6 h-6 block"></span>
</button>
{#each keys as panel (panels[panel].id)}
{#if panels[panel].visible !== false}
{#each state.keys as panelId (panelId)}
{#if !state.panels[panelId].hidden}
<button
aria-label={panel}
class="tab {panels[panel].classes}"
class:active={panel === $activePanel}
on:click={() => setActivePanel(panel)}
aria-label={panelId}
class="tab {state.panels[panelId].classes}"
class:active={panelId === state.activePanel.value}
onclick={() => (state.activePanel.value = panelId)}
>
<span class={`block w-6 h-6 ${panels[panel].icon}`}></span>
<span class={`block w-6 h-6 iconify ${state.panels[panelId].icon}`}
></span>
</button>
{/if}
{/each}
</div>
<div class="content">
<slot />
{@render children?.()}
</div>
</div>
@@ -116,7 +74,7 @@
align-items: center;
border-bottom: solid thin var(--outline);
border-left: solid thin var(--outline);
background: var(--layer-0);
background: var(--layer-1);
}
.tabs > button > span {
@@ -124,7 +82,7 @@
}
.tabs > button.active {
background: var(--layer-1);
background: var(--layer-2);
}
.tabs > button.active span {

View File

@@ -1,16 +1,15 @@
<script lang="ts">
import type { Node, NodeInput } from "@nodes/types";
import type { NodeInstance, NodeInput } from "@nodarium/types";
import NestedSettings from "$lib/settings/NestedSettings.svelte";
import type { GraphManager } from "$lib/graph-interface/graph-manager.svelte";
type Props = {
manager: GraphManager;
node: Node;
node: NodeInstance;
};
const { manager, node }: Props = $props();
const { manager, node = $bindable() }: Props = $props();
const nodeDefinition = filterInputs(node.tmp?.type?.inputs);
function filterInputs(inputs?: Record<string, NodeInput>) {
const _inputs = $state.snapshot(inputs);
return Object.fromEntries(
@@ -20,18 +19,19 @@
})
.map(([key, value]) => {
//@ts-ignore
value.__node_type = node?.tmp?.type.id;
value.__node_type = node.state?.type.id;
//@ts-ignore
value.__node_input = key;
return [key, value];
}),
);
}
const nodeDefinition = filterInputs(node.state?.type?.inputs);
type Store = Record<string, number | number[]>;
let store = $state<Store>(createStore(node?.props, nodeDefinition));
function createStore(
props: Node["props"],
props: NodeInstance["props"],
inputs: Record<string, NodeInput>,
): Store {
const store: Store = {};
@@ -64,6 +64,7 @@
lastPropsHash = propsHash;
if (needsUpdate) {
manager.save();
manager.execute();
}
}
@@ -75,8 +76,12 @@
});
</script>
<NestedSettings
{#if Object.keys(nodeDefinition).length}
<NestedSettings
id="activeNodeSettings"
bind:value={store}
type={nodeDefinition}
/>
/>
{:else}
<p class="mx-4">Node has no settings</p>
{/if}

View File

@@ -1,24 +1,24 @@
<script lang="ts">
import type { Node } from "@nodes/types";
import type { NodeInstance } from "@nodarium/types";
import type { GraphManager } from "$lib/graph-interface/graph-manager.svelte";
import ActiveNodeSelected from "./ActiveNodeSelected.svelte";
type Props = {
manager: GraphManager;
node: Node | undefined;
node: NodeInstance | undefined;
};
const { manager, node }: Props = $props();
let { manager, node = $bindable() }: Props = $props();
</script>
{#if node}
{#key node.id}
{#if node}
<ActiveNodeSelected {manager} {node} />
<ActiveNodeSelected {manager} bind:node />
{:else}
<p class="mx-4">Active Node has no Settings</p>
{/if}
{/key}
{:else}
<p class="mx-4">No active node</p>
<p class="mx-4">No node selected</p>
{/if}

View File

@@ -1,9 +1,15 @@
<script lang="ts" module>
let result:
| { stdev: number; avg: number; duration: number; samples: number[] }
| undefined = $state();
</script>
<script lang="ts">
import localStore from "$lib/helpers/localStore";
import { Integer } from "@nodes/ui";
import { Integer } from "@nodarium/ui";
import { writable } from "svelte/store";
import { humanizeDuration } from "$lib/helpers";
import Monitor from "$lib/performance/Monitor.svelte";
import { localState } from "$lib/helpers/localState.svelte";
function calculateStandardDeviation(array: number[]) {
const n = array.length;
@@ -12,18 +18,18 @@
array.map((x) => Math.pow(x - mean, 2)).reduce((a, b) => a + b) / n,
);
}
type Props = {
run: () => Promise<any>;
};
export let run: () => Promise<any>;
const { run }: Props = $props();
let isRunning = false;
let amount = localStore<number>("nodes.benchmark.samples", 500);
let samples = 0;
let isRunning = $state(false);
let amount = localState<number>("nodes.benchmark.samples", 500);
let samples = $state(0);
let warmUp = writable(0);
let warmUpAmount = 10;
let state = "";
let result:
| { stdev: number; avg: number; duration: number; samples: number[] }
| undefined;
let status = "";
const copyContent = async (text?: string | number) => {
if (!text) return;
@@ -56,7 +62,7 @@
let results = [];
// perform run
for (let i = 0; i < $amount; i++) {
for (let i = 0; i < amount.value; i++) {
const a = performance.now();
await run();
samples = i;
@@ -73,10 +79,9 @@
}
</script>
{state}
{status}
<div class="wrapper" class:running={isRunning}>
{#if isRunning}
{#if result}
<h3>Finished ({humanizeDuration(result.duration)})</h3>
<div class="monitor-wrapper">
@@ -85,43 +90,42 @@
<label for="bench-avg">Average </label>
<button
id="bench-avg"
on:keydown={(ev) => ev.key === "Enter" && copyContent(result?.avg)}
on:click={() => copyContent(result?.avg)}
onkeydown={(ev) => ev.key === "Enter" && copyContent(result?.avg)}
onclick={() => copyContent(result?.avg)}
>{Math.floor(result.avg * 100) / 100}</button
>
<i
role="button"
tabindex="0"
on:keydown={(ev) => ev.key === "Enter" && copyContent(result?.avg)}
on:click={() => copyContent(result?.avg)}>(click to copy)</i
onkeydown={(ev) => ev.key === "Enter" && copyContent(result?.avg)}
onclick={() => copyContent(result?.avg)}>(click to copy)</i
>
<label for="bench-stdev">Standard Deviation σ</label>
<button id="bench-stdev" on:click={() => copyContent(result?.stdev)}
<button id="bench-stdev" onclick={() => copyContent(result?.stdev)}
>{Math.floor(result.stdev * 100) / 100}</button
>
<i
role="button"
tabindex="0"
on:keydown={(ev) => ev.key === "Enter" && copyContent(result?.avg)}
on:click={() => copyContent(result?.stdev + "")}>(click to copy)</i
onkeydown={(ev) => ev.key === "Enter" && copyContent(result?.avg)}
onclick={() => copyContent(result?.stdev + "")}>(click to copy)</i
>
<div>
<button on:click={() => (isRunning = false)}>reset</button>
<button onclick={() => (isRunning = false)}>reset</button>
</div>
{:else}
{:else if isRunning}
<p>WarmUp ({$warmUp}/{warmUpAmount})</p>
<progress value={$warmUp} max={warmUpAmount}
>{Math.floor(($warmUp / warmUpAmount) * 100)}%</progress
>
<p>Progress ({samples}/{$amount})</p>
<progress value={samples} max={$amount}
>{Math.floor((samples / $amount) * 100)}%</progress
<p>Progress ({samples}/{amount.value})</p>
<progress value={samples} max={amount.value}
>{Math.floor((samples / amount.value) * 100)}%</progress
>
{/if}
{:else}
<label for="bench-samples">Samples</label>
<Integer id="bench-sample" bind:value={$amount} max={1000} />
<button on:click={benchmark} disabled={isRunning}> start </button>
<Integer id="bench-sample" bind:value={amount.value} max={1000} />
<button onclick={benchmark} disabled={isRunning}> start </button>
{/if}
</div>

View File

@@ -15,7 +15,7 @@
FileSaver.saveAs(blob, name + "." + extension);
};
export let scene: Group;
const { scene } = $props<{ scene: Group }>();
let gltfExporter: GLTFExporter;
async function exportGltf() {
@@ -53,7 +53,7 @@
}
</script>
<div class="p-2">
<button on:click={exportObj}> export obj </button>
<button on:click={exportGltf}> export gltf </button>
<div class="p-4">
<button onclick={exportObj}> export obj </button>
<button onclick={exportGltf}> export gltf </button>
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { Graph } from "$lib/types";
const { graph }: { graph: Graph } = $props();
function convert(g: Graph): string {
return JSON.stringify(
{
...g,
nodes: g.nodes.map((n: any) => ({ ...n, tmp: undefined })),
},
null,
2,
);
}
</script>
<pre>
{convert(graph)}
</pre>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import type { createKeyMap } from "$lib/helpers/createKeyMap";
import { ShortCut } from "@nodes/ui";
import { ShortCut } from "@nodarium/ui";
import { get } from "svelte/store";
type Props = {
@@ -11,10 +11,10 @@
};
let { keymaps }: Props = $props();
console.log({ keymaps });
</script>
<table class="wrapper">
<div class="p-4">
<table class="wrapper">
<tbody>
{#each keymaps as keymap}
<tr>
@@ -39,7 +39,8 @@
{/each}
{/each}
</tbody>
</table>
</table>
</div>
<style>
.wrapper {

View File

@@ -1,8 +1,6 @@
import type {
Graph,
Node as NodeType,
NodeDefinition,
NodeInput,
RuntimeExecutor,
} from "@nodes/types";
} from "@nodarium/types";
export type { Graph, NodeDefinition, NodeInput };

View File

@@ -1,7 +1,15 @@
<script lang="ts">
import "@nodes/ui/app.css";
import "virtual:uno.css";
import "@unocss/reset/normalize.css";
import "@nodarium/ui/app.css";
import "../app.css";
import type { Snippet } from "svelte";
import * as config from "$lib/config";
const { children } = $props<{ children?: Snippet }>();
</script>
<slot />
{@render children?.()}
<svelte:head>
{#if config.ANALYTIC_SCRIPT}
{@html config.ANALYTIC_SCRIPT}
{/if}
</svelte:head>

View File

@@ -2,7 +2,7 @@
import Grid from "$lib/grid";
import GraphInterface from "$lib/graph-interface";
import * as templates from "$lib/graph-templates";
import type { Graph, Node } from "@nodes/types";
import type { Graph, NodeInstance } from "@nodarium/types";
import Viewer from "$lib/result-viewer/Viewer.svelte";
import {
appSettings,
@@ -23,10 +23,11 @@
WorkerRuntimeExecutor,
MemoryRuntimeExecutor,
} from "$lib/runtime";
import { IndexDBCache, RemoteNodeRegistry } from "@nodes/registry";
import { createPerformanceStore } from "@nodes/utils";
import { IndexDBCache, RemoteNodeRegistry } from "@nodarium/registry";
import { createPerformanceStore } from "@nodarium/utils";
import BenchmarkPanel from "$lib/sidebar/panels/BenchmarkPanel.svelte";
import { debounceAsyncFunction } from "$lib/helpers";
import GraphSource from "$lib/sidebar/panels/GraphSource.svelte";
let performanceStore = createPerformanceStore();
@@ -41,7 +42,26 @@
appSettings.value.debug.useWorker ? workerRuntime : memoryRuntime,
);
let activeNode = $state<Node | undefined>(undefined);
$effect(() => {
workerRuntime.useRegistryCache =
appSettings.value.debug.cache.useRuntimeCache;
workerRuntime.useRuntimeCache =
appSettings.value.debug.cache.useRegistryCache;
if (appSettings.value.debug.cache.useRegistryCache) {
nodeRegistry.cache = registryCache;
} else {
nodeRegistry.cache = undefined;
}
if (appSettings.value.debug.cache.useRuntimeCache) {
memoryRuntime.cache = runtimeCache;
} else {
memoryRuntime.cache = undefined;
}
});
let activeNode = $state<NodeInstance | undefined>(undefined);
let scene = $state<Group>(null!);
let graph = $state(
@@ -49,6 +69,9 @@
? JSON.parse(localStorage.getItem("graph")!)
: templates.defaultPlant,
);
function handleSave(graph: Graph) {
localStorage.setItem("graph", JSON.stringify(graph));
}
let graphInterface = $state<ReturnType<typeof GraphInterface>>(null!);
let viewerComponent = $state<ReturnType<typeof Viewer>>();
@@ -65,7 +88,7 @@
{
key: "r",
description: "Regenerate the plant model",
callback: randomGenerate,
callback: () => randomGenerate(),
},
]);
let graphSettings = $state<Record<string, any>>({});
@@ -84,17 +107,14 @@
randomSeed: { type: "boolean", value: false },
});
let runIndex = 0;
const handleUpdate = debounceAsyncFunction(
async (g: Graph, s: Record<string, any> = graphSettings) => {
runIndex++;
async function update(
g: Graph,
s: Record<string, any> = $state.snapshot(graphSettings),
) {
performanceStore.startRun();
try {
let a = performance.now();
const graphResult = await runtime.execute(
$state.snapshot(g),
$state.snapshot(s),
);
const graphResult = await runtime.execute(g, s);
let b = performance.now();
if (appSettings.value.debug.useWorker) {
@@ -116,38 +136,37 @@
} finally {
performanceStore.stopRun();
}
},
);
}
const handleUpdate = debounceAsyncFunction(update);
$effect(() => {
//@ts-ignore
AppSettingTypes.debug.stressTest.loadGrid.callback = () => {
graph = templates.grid(
appSettings.value.debug.amount.value,
appSettings.value.debug.amount.value,
manager.load(
templates.grid(
appSettings.value.debug.stressTest.amount,
appSettings.value.debug.stressTest.amount,
),
);
};
//@ts-ignore
AppSettingTypes.debug.stressTest.loadTree.callback = () => {
graph = templates.tree(appSettings.value.debug.amount.value);
manager.load(templates.tree(appSettings.value.debug.stressTest.amount));
};
//@ts-ignore
AppSettingTypes.debug.stressTest.lottaFaces.callback = () => {
graph = templates.lottaFaces;
manager.load(templates.lottaFaces as unknown as Graph);
};
//@ts-ignore
AppSettingTypes.debug.stressTest.lottaNodes.callback = () => {
graph = templates.lottaNodes;
manager.load(templates.lottaNodes as unknown as Graph);
};
//@ts-ignore
AppSettingTypes.debug.stressTest.lottaNodesAndFaces.callback = () => {
graph = templates.lottaNodesAndFaces;
manager.load(templates.lottaNodesAndFaces as unknown as Graph);
};
});
function handleSave(graph: Graph) {
localStorage.setItem("graph", JSON.stringify(graph));
}
</script>
<svelte:document onkeydown={applicationKeymap.handleKeyboardEvent} />
@@ -178,7 +197,7 @@
onsave={(graph) => handleSave(graph)}
/>
<Sidebar>
<Panel id="general" title="General" icon="i-tabler-settings">
<Panel id="general" title="General" icon="i-[tabler--settings]">
<NestedSettings
id="general"
bind:value={appSettings.value}
@@ -188,51 +207,55 @@
<Panel
id="shortcuts"
title="Keyboard Shortcuts"
icon="i-tabler-keyboard"
icon="i-[tabler--keyboard]"
>
<Keymap
keymaps={[
{ keymap: applicationKeymap, title: "Application" },
{ keymap: graphInterface.keymap, title: "Node-Editor" },
{ keymap: graphInterface?.keymap, title: "Node-Editor" },
]}
/>
</Panel>
<Panel id="exports" title="Exporter" icon="i-tabler-package-export">
<Panel id="exports" title="Exporter" icon="i-[tabler--package-export]">
<ExportSettings {scene} />
</Panel>
<Panel
id="node-store"
classes="text-green-400"
title="Node Store"
icon="i-tabler-database"
icon="i-[tabler--database] bg-green-400"
>
<NodeStore registry={nodeRegistry} />
</Panel>
<Panel
id="performance"
title="Performance"
classes="text-red-400"
hidden={!appSettings.value.debug.showPerformancePanel}
icon="i-tabler-brand-speedtest"
icon="i-[tabler--brand-speedtest] bg-red-400"
>
{#if $performanceStore}
<PerformanceViewer data={$performanceStore} />
{/if}
</Panel>
<Panel
id="graph-source"
title="Graph Source"
hidden={!appSettings.value.debug.showGraphJson}
icon="i-[tabler--code]"
>
<GraphSource graph={graph && manager.serialize()} />
</Panel>
<Panel
id="benchmark"
title="Benchmark"
classes="text-red-400"
hidden={!appSettings.value.debug.showBenchmarkPanel}
icon="i-tabler-graph"
icon="i-[tabler--graph] bg-red-400"
>
<BenchmarkPanel run={randomGenerate} />
</Panel>
<Panel
id="graph-settings"
title="Graph Settings"
classes="text-blue-400"
icon="i-custom-graph"
icon="i-[custom--graph] bg-blue-400"
>
<NestedSettings
id="graph-settings"
@@ -243,10 +266,9 @@
<Panel
id="active-node"
title="Node Settings"
classes="text-blue-400"
icon="i-tabler-adjustments"
icon="i-[tabler--adjustments] bg-blue-400"
>
<ActiveNodeSettings {manager} node={activeNode} />
<ActiveNodeSettings {manager} bind:node={activeNode} />
</Panel>
</Sidebar>
</Grid.Cell>

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import Grid from "$lib/grid";
import Panel from "$lib/sidebar/Panel.svelte";
import Sidebar from "$lib/sidebar/Sidebar.svelte";
</script>
<Grid.Row>
<Grid.Cell></Grid.Cell>
<Grid.Cell>
<Sidebar>
<Panel
id="node-store"
classes="text-green-400"
title="Node Store"
icon="i-[tabler--database]"
>
<div class="p-4">
<input type="text" class="bg-red rounded-sm p-2" />
</div>
</Panel>
</Sidebar>
</Grid.Cell>
</Grid.Row>
<style>
:global body {
height: 100vh;
}
</style>

View File

@@ -1,20 +0,0 @@
// uno.config.ts
import { defineConfig } from 'unocss'
import presetIcons from '@unocss/preset-icons'
import { presetUno } from 'unocss'
import fs from 'fs'
const icons = Object.fromEntries(fs.readdirSync('./src/lib/icons')
.map(name => [name.replace(".svg", ""), fs.readFileSync(`./src/lib/icons/${name}`, 'utf-8')]))
export default defineConfig({
presets: [
presetUno(),
presetIcons({
collections: {
custom: icons
}
}),
]
})

View File

@@ -1,14 +1,14 @@
import { sveltekit } from '@sveltejs/kit/vite';
import UnoCSS from 'unocss/vite';
import { defineConfig } from 'vite';
import tailwindcss from '@tailwindcss/vite'
import comlink from 'vite-plugin-comlink';
import glsl from "vite-plugin-glsl";
import wasm from "vite-plugin-wasm";
export default defineConfig({
plugins: [
tailwindcss(),
comlink(),
UnoCSS(),
sveltekit(),
glsl(),
wasm()

View File

@@ -4,7 +4,7 @@ This guide will help you developing your first Nodarium Node written in Rust. As
## Prerequesites
You need to have [Rust](https://www.rust-lang.org/tools/install) and [wasm-pack](https://rustwasm.github.io/wasm-pack/book/) installed. Rust is the language we are going to develop our node in and wasm-pack helps us compile our rust code into a webassembly file.
You need to have [Rust](https://www.rust-lang.org/tools/install) and [wasm-pack](https://rustwasm.github.io/docs/wasm-pack/) installed. Rust is the language we are going to develop our node in and wasm-pack helps us compile our rust code into a webassembly file.
```bash
# install rust
@@ -26,6 +26,7 @@ Now we create the definition file of the node.
Here we define what kind of inputs our node will expect and what kind of output it produces. If you want to dive deeper into this topic, have a look at [NODE_DEFINITION.md](./NODE_DEFINITION.md).
`src/definition.json`
```json
{
"id": "my-name/my-namespace/zylinder-node",
@@ -35,7 +36,7 @@ Here we define what kind of inputs our node will expect and what kind of output
"inputs": {
"height": {
"type": "float",
"value": 2,
"value": 2
},
"radius": {
"type": "float",
@@ -44,6 +45,7 @@ Here we define what kind of inputs our node will expect and what kind of output
}
}
```
If we take a look at the `src/lib.rs` file we see that `src/definition.json` is included with the following line:
```rust

27
flake.lock generated Normal file
View File

@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1768564909,
"narHash": "sha256-Kell/SpJYVkHWMvnhqJz/8DqQg2b6PguxVWOuadbHCc=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "e4bae1bd10c9c57b2cf517953ab70060a828ee6f",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

41
flake.nix Normal file
View File

@@ -0,0 +1,41 @@
{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
};
outputs = {nixpkgs, ...}: let
systems = ["aarch64-darwin" "x86_64-linux"];
eachSystem = function:
nixpkgs.lib.genAttrs systems (system:
function {
inherit system;
pkgs = nixpkgs.legacyPackages.${system};
});
in {
devShells = eachSystem ({pkgs, ...}: {
default = pkgs.mkShellNoCC {
packages = [
# general deps
pkgs.nodejs_24
pkgs.pnpm_10
# wasm/rust stuff
pkgs.rustc
pkgs.cargo
pkgs.rust-analyzer
pkgs.rustfmt
pkgs.wasm-bindgen-cli
pkgs.wasm-pack
pkgs.lld
# frontend
pkgs.vscode-langservers-extracted
pkgs.typescript-language-server
pkgs.prettier
pkgs.tailwindcss-language-server
pkgs.svelte-language-server
];
};
});
};
}

View File

@@ -4,9 +4,10 @@
"build:story": "pnpm -r --filter 'ui' story:build",
"build:app": "BASE_PATH=/ui pnpm -r --filter 'ui' build && pnpm -r --filter 'app' build",
"build:nodes": "pnpm -r --filter './nodes/**' build",
"dev:nodes": "pnpm -r --parallel --filter './nodes/**' dev",
"build:deploy": "pnpm build",
"dev": "pnpm -r --filter 'app' --filter './packages/node-registry' dev"
"dev:all": "pnpm -r dev",
"dev:nodes": "pnpm -r --parallel --filter './nodes/**' dev",
"dev": "pnpm -r --filter 'app' --filter './packages/ui' dev"
},
"packageManager": "pnpm@10.23.0"
"packageManager": "pnpm@10.24.0"
}

View File

@@ -1,5 +1,5 @@
{
"name": "@nodes/registry",
"name": "@nodarium/registry",
"version": "0.0.0",
"description": "",
"main": "src/index.ts",
@@ -10,8 +10,8 @@
"author": "",
"license": "ISC",
"dependencies": {
"@nodes/types": "link:../types",
"@nodes/utils": "link:../utils",
"@nodarium/types": "link:../types",
"@nodarium/utils": "link:../utils",
"idb": "^8.0.3"
}
}

View File

@@ -1,22 +1,22 @@
import type { AsyncCache } from '@nodes/types';
import type { AsyncCache } from '@nodarium/types';
import { openDB, type IDBPDatabase } from 'idb';
export class IndexDBCache implements AsyncCache<ArrayBuffer> {
export class IndexDBCache implements AsyncCache<unknown> {
size: number = 100;
db: Promise<IDBPDatabase<ArrayBuffer>>;
private _cache = new Map<string, ArrayBuffer>();
db: Promise<IDBPDatabase<unknown>>;
private _cache = new Map<string, unknown>();
constructor(id: string) {
this.db = openDB<ArrayBuffer>('cache/' + id, 1, {
this.db = openDB<unknown>('cache/' + id, 1, {
upgrade(db) {
db.createObjectStore('keyval');
},
});
}
async get(key: string) {
async get<T>(key: string): Promise<T> {
let res = this._cache.get(key);
if (!res) {
res = await (await this.db).get('keyval', key);
@@ -24,13 +24,33 @@ export class IndexDBCache implements AsyncCache<ArrayBuffer> {
if (res) {
this._cache.set(key, res);
}
return res as T;
}
async getArrayBuffer(key: string) {
const res = await this.get(key);
if (!res) return;
if (res instanceof ArrayBuffer) {
return res;
}
async set(key: string, value: ArrayBuffer) {
return
}
async getString(key: string) {
const res = await this.get(key);
if (!res) return;
if (typeof res === "string") {
return res;
}
return
}
async set(key: string, value: unknown) {
this._cache.set(key, value);
const db = await this.db;
await db.put('keyval', value, key);
}
clear() {
this.db.then(db => db.clear('keyval'));
}

View File

@@ -3,41 +3,72 @@ import {
type AsyncCache,
type NodeDefinition,
type NodeRegistry,
} from "@nodes/types";
import { createLogger, createWasmWrapper } from "@nodes/utils";
} from "@nodarium/types";
import { createLogger, createWasmWrapper } from "@nodarium/utils";
const log = createLogger("node-registry");
// log.mute();
log.mute();
export class RemoteNodeRegistry implements NodeRegistry {
status: "loading" | "ready" | "error" = "loading";
private nodes: Map<string, NodeDefinition> = new Map();
async fetchJson(url: string) {
const response = await fetch(`${this.url}/${url}`);
if (!response.ok) {
log.error(`Failed to load ${url}`, { response, url, host: this.url });
throw new Error(`Failed to load ${url}`);
}
return response.json();
}
async fetchArrayBuffer(url: string) {
const response = await fetch(`${this.url}/${url}`);
if (!response.ok) {
log.error(`Failed to load ${url}`, { response, url, host: this.url });
throw new Error(`Failed to load ${url}`);
}
return response.arrayBuffer();
}
constructor(
private url: string,
private cache?: AsyncCache<ArrayBuffer>,
) {}
public cache?: AsyncCache<ArrayBuffer | string>,
) { }
async fetchJson(url: string, skipCache = false) {
const finalUrl = `${this.url}/${url}`;
if (!skipCache && this.cache) {
const cachedValue = await this.cache?.get<string>(finalUrl);
if (cachedValue) {
// fetch again in the background, maybe implement that only refetch after a certain time
this.fetchJson(url, true)
return JSON.parse(cachedValue);
}
}
const response = await fetch(finalUrl);
if (!response.ok) {
log.error(`Failed to load ${url}`, { response, url, host: this.url });
throw new Error(`Failed to load ${url}`);
}
const result = await response.json();
this.cache?.set(finalUrl, JSON.stringify(result));
return result;
}
async fetchArrayBuffer(url: string, skipCache = false) {
const finalUrl = `${this.url}/${url}`;
if (!skipCache && this.cache) {
const cachedNode = await this.cache?.get<ArrayBuffer>(finalUrl);
if (cachedNode) {
// fetch again in the background, maybe implement that only refetch after a certain time
this.fetchArrayBuffer(url, true)
return cachedNode;
}
}
const response = await fetch(finalUrl);
if (!response.ok) {
log.error(`Failed to load ${url}`, { response, url, host: this.url });
throw new Error(`Failed to load ${url}`);
}
const buffer = await response.arrayBuffer();
this.cache?.set(finalUrl, buffer);
return buffer;
}
async fetchUsers() {
return this.fetchJson(`nodes/users.json`);
@@ -48,7 +79,8 @@ export class RemoteNodeRegistry implements NodeRegistry {
}
async fetchCollection(userCollectionId: `${string}/${string}`) {
return this.fetchJson(`nodes/${userCollectionId}.json`);
const col = await this.fetchJson(`nodes/${userCollectionId}.json`);
return col
}
async fetchNodeDefinition(nodeId: `${string}/${string}/${string}`) {
@@ -56,19 +88,15 @@ export class RemoteNodeRegistry implements NodeRegistry {
}
private async fetchNodeWasm(nodeId: `${string}/${string}/${string}`) {
const cachedNode = this.cache?.get(nodeId);
if(cachedNode){
return cachedNode;
}
const node = this.fetchArrayBuffer(`nodes/${nodeId}.wasm`);
if (node) {
return node;
}
const node = await this.fetchArrayBuffer(`nodes/${nodeId}.wasm`);
if (!node) {
throw new Error(`Failed to load node wasm ${nodeId}`);
}
return node;
}
async load(nodeIds: `${string}/${string}/${string}`[]) {
const a = performance.now();

View File

@@ -10,10 +10,7 @@
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@hey-api/client-fetch": "^0.13.1"
},
"devDependencies": {
"@hey-api/openapi-ts": "^0.88.0"
"@hey-api/openapi-ts": "^0.90.4"
}
}

View File

@@ -0,0 +1,16 @@
// This file is auto-generated by @hey-api/openapi-ts
import { type ClientOptions, type Config, createClient, createConfig } from './client';
import type { ClientOptions as ClientOptions2 } from './types.gen';
/**
* The `createClientConfig()` function will be called on client initialization
* and the returned object will become the client's initial configuration.
*
* You may want to initialize your client this way instead of calling
* `setConfig()`. This is useful for example if you're using Next.js
* to ensure your client always has the correct values.
*/
export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (override?: Config<ClientOptions & T>) => Config<Required<ClientOptions> & T>;
export const client = createClient(createConfig<ClientOptions2>());

View File

@@ -0,0 +1,311 @@
// This file is auto-generated by @hey-api/openapi-ts
import { createSseClient } from '../core/serverSentEvents.gen';
import type { HttpMethod } from '../core/types.gen';
import { getValidRequestBody } from '../core/utils.gen';
import type {
Client,
Config,
RequestOptions,
ResolvedRequestOptions,
} from './types.gen';
import {
buildUrl,
createConfig,
createInterceptors,
getParseAs,
mergeConfigs,
mergeHeaders,
setAuthParams,
} from './utils.gen';
type ReqInit = Omit<RequestInit, 'body' | 'headers'> & {
body?: any;
headers: ReturnType<typeof mergeHeaders>;
};
export const createClient = (config: Config = {}): Client => {
let _config = mergeConfigs(createConfig(), config);
const getConfig = (): Config => ({ ..._config });
const setConfig = (config: Config): Config => {
_config = mergeConfigs(_config, config);
return getConfig();
};
const interceptors = createInterceptors<
Request,
Response,
unknown,
ResolvedRequestOptions
>();
const beforeRequest = async (options: RequestOptions) => {
const opts = {
..._config,
...options,
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
headers: mergeHeaders(_config.headers, options.headers),
serializedBody: undefined,
};
if (opts.security) {
await setAuthParams({
...opts,
security: opts.security,
});
}
if (opts.requestValidator) {
await opts.requestValidator(opts);
}
if (opts.body !== undefined && opts.bodySerializer) {
opts.serializedBody = opts.bodySerializer(opts.body);
}
// remove Content-Type header if body is empty to avoid sending invalid requests
if (opts.body === undefined || opts.serializedBody === '') {
opts.headers.delete('Content-Type');
}
const url = buildUrl(opts);
return { opts, url };
};
const request: Client['request'] = async (options) => {
// @ts-expect-error
const { opts, url } = await beforeRequest(options);
const requestInit: ReqInit = {
redirect: 'follow',
...opts,
body: getValidRequestBody(opts),
};
let request = new Request(url, requestInit);
for (const fn of interceptors.request.fns) {
if (fn) {
request = await fn(request, opts);
}
}
// fetch must be assigned here, otherwise it would throw the error:
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
const _fetch = opts.fetch!;
let response: Response;
try {
response = await _fetch(request);
} catch (error) {
// Handle fetch exceptions (AbortError, network errors, etc.)
let finalError = error;
for (const fn of interceptors.error.fns) {
if (fn) {
finalError = (await fn(
error,
undefined as any,
request,
opts,
)) as unknown;
}
}
finalError = finalError || ({} as unknown);
if (opts.throwOnError) {
throw finalError;
}
// Return error response
return opts.responseStyle === 'data'
? undefined
: {
error: finalError,
request,
response: undefined as any,
};
}
for (const fn of interceptors.response.fns) {
if (fn) {
response = await fn(response, request, opts);
}
}
const result = {
request,
response,
};
if (response.ok) {
const parseAs =
(opts.parseAs === 'auto'
? getParseAs(response.headers.get('Content-Type'))
: opts.parseAs) ?? 'json';
if (
response.status === 204 ||
response.headers.get('Content-Length') === '0'
) {
let emptyData: any;
switch (parseAs) {
case 'arrayBuffer':
case 'blob':
case 'text':
emptyData = await response[parseAs]();
break;
case 'formData':
emptyData = new FormData();
break;
case 'stream':
emptyData = response.body;
break;
case 'json':
default:
emptyData = {};
break;
}
return opts.responseStyle === 'data'
? emptyData
: {
data: emptyData,
...result,
};
}
let data: any;
switch (parseAs) {
case 'arrayBuffer':
case 'blob':
case 'formData':
case 'text':
data = await response[parseAs]();
break;
case 'json': {
// Some servers return 200 with no Content-Length and empty body.
// response.json() would throw; read as text and parse if non-empty.
const text = await response.text();
data = text ? JSON.parse(text) : {};
break;
}
case 'stream':
return opts.responseStyle === 'data'
? response.body
: {
data: response.body,
...result,
};
}
if (parseAs === 'json') {
if (opts.responseValidator) {
await opts.responseValidator(data);
}
if (opts.responseTransformer) {
data = await opts.responseTransformer(data);
}
}
return opts.responseStyle === 'data'
? data
: {
data,
...result,
};
}
const textError = await response.text();
let jsonError: unknown;
try {
jsonError = JSON.parse(textError);
} catch {
// noop
}
const error = jsonError ?? textError;
let finalError = error;
for (const fn of interceptors.error.fns) {
if (fn) {
finalError = (await fn(error, response, request, opts)) as string;
}
}
finalError = finalError || ({} as string);
if (opts.throwOnError) {
throw finalError;
}
// TODO: we probably want to return error and improve types
return opts.responseStyle === 'data'
? undefined
: {
error: finalError,
...result,
};
};
const makeMethodFn =
(method: Uppercase<HttpMethod>) => (options: RequestOptions) =>
request({ ...options, method });
const makeSseFn =
(method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
const { opts, url } = await beforeRequest(options);
return createSseClient({
...opts,
body: opts.body as BodyInit | null | undefined,
headers: opts.headers as unknown as Record<string, string>,
method,
onRequest: async (url, init) => {
let request = new Request(url, init);
for (const fn of interceptors.request.fns) {
if (fn) {
request = await fn(request, opts);
}
}
return request;
},
serializedBody: getValidRequestBody(opts) as
| BodyInit
| null
| undefined,
url,
});
};
return {
buildUrl,
connect: makeMethodFn('CONNECT'),
delete: makeMethodFn('DELETE'),
get: makeMethodFn('GET'),
getConfig,
head: makeMethodFn('HEAD'),
interceptors,
options: makeMethodFn('OPTIONS'),
patch: makeMethodFn('PATCH'),
post: makeMethodFn('POST'),
put: makeMethodFn('PUT'),
request,
setConfig,
sse: {
connect: makeSseFn('CONNECT'),
delete: makeSseFn('DELETE'),
get: makeSseFn('GET'),
head: makeSseFn('HEAD'),
options: makeSseFn('OPTIONS'),
patch: makeSseFn('PATCH'),
post: makeSseFn('POST'),
put: makeSseFn('PUT'),
trace: makeSseFn('TRACE'),
},
trace: makeMethodFn('TRACE'),
} as Client;
};

View File

@@ -0,0 +1,25 @@
// This file is auto-generated by @hey-api/openapi-ts
export type { Auth } from '../core/auth.gen';
export type { QuerySerializerOptions } from '../core/bodySerializer.gen';
export {
formDataBodySerializer,
jsonBodySerializer,
urlSearchParamsBodySerializer,
} from '../core/bodySerializer.gen';
export { buildClientParams } from '../core/params.gen';
export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen';
export { createClient } from './client.gen';
export type {
Client,
ClientOptions,
Config,
CreateClientConfig,
Options,
RequestOptions,
RequestResult,
ResolvedRequestOptions,
ResponseStyle,
TDataShape,
} from './types.gen';
export { createConfig, mergeHeaders } from './utils.gen';

View File

@@ -0,0 +1,241 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Auth } from '../core/auth.gen';
import type {
ServerSentEventsOptions,
ServerSentEventsResult,
} from '../core/serverSentEvents.gen';
import type {
Client as CoreClient,
Config as CoreConfig,
} from '../core/types.gen';
import type { Middleware } from './utils.gen';
export type ResponseStyle = 'data' | 'fields';
export interface Config<T extends ClientOptions = ClientOptions>
extends Omit<RequestInit, 'body' | 'headers' | 'method'>,
CoreConfig {
/**
* Base URL for all requests made by this client.
*/
baseUrl?: T['baseUrl'];
/**
* Fetch API implementation. You can use this option to provide a custom
* fetch instance.
*
* @default globalThis.fetch
*/
fetch?: typeof fetch;
/**
* Please don't use the Fetch client for Next.js applications. The `next`
* options won't have any effect.
*
* Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead.
*/
next?: never;
/**
* Return the response data parsed in a specified format. By default, `auto`
* will infer the appropriate method from the `Content-Type` response header.
* You can override this behavior with any of the {@link Body} methods.
* Select `stream` if you don't want to parse response data at all.
*
* @default 'auto'
*/
parseAs?:
| 'arrayBuffer'
| 'auto'
| 'blob'
| 'formData'
| 'json'
| 'stream'
| 'text';
/**
* Should we return only data or multiple fields (data, error, response, etc.)?
*
* @default 'fields'
*/
responseStyle?: ResponseStyle;
/**
* Throw an error instead of returning it in the response?
*
* @default false
*/
throwOnError?: T['throwOnError'];
}
export interface RequestOptions<
TData = unknown,
TResponseStyle extends ResponseStyle = 'fields',
ThrowOnError extends boolean = boolean,
Url extends string = string,
> extends Config<{
responseStyle: TResponseStyle;
throwOnError: ThrowOnError;
}>,
Pick<
ServerSentEventsOptions<TData>,
| 'onSseError'
| 'onSseEvent'
| 'sseDefaultRetryDelay'
| 'sseMaxRetryAttempts'
| 'sseMaxRetryDelay'
> {
/**
* Any body that you want to add to your request.
*
* {@link https://developer.mozilla.org/docs/Web/API/fetch#body}
*/
body?: unknown;
path?: Record<string, unknown>;
query?: Record<string, unknown>;
/**
* Security mechanism(s) to use for the request.
*/
security?: ReadonlyArray<Auth>;
url: Url;
}
export interface ResolvedRequestOptions<
TResponseStyle extends ResponseStyle = 'fields',
ThrowOnError extends boolean = boolean,
Url extends string = string,
> extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> {
serializedBody?: string;
}
export type RequestResult<
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = boolean,
TResponseStyle extends ResponseStyle = 'fields',
> = ThrowOnError extends true
? Promise<
TResponseStyle extends 'data'
? TData extends Record<string, unknown>
? TData[keyof TData]
: TData
: {
data: TData extends Record<string, unknown>
? TData[keyof TData]
: TData;
request: Request;
response: Response;
}
>
: Promise<
TResponseStyle extends 'data'
?
| (TData extends Record<string, unknown>
? TData[keyof TData]
: TData)
| undefined
: (
| {
data: TData extends Record<string, unknown>
? TData[keyof TData]
: TData;
error: undefined;
}
| {
data: undefined;
error: TError extends Record<string, unknown>
? TError[keyof TError]
: TError;
}
) & {
request: Request;
response: Response;
}
>;
export interface ClientOptions {
baseUrl?: string;
responseStyle?: ResponseStyle;
throwOnError?: boolean;
}
type MethodFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = 'fields',
>(
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>,
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
type SseFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = 'fields',
>(
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>,
) => Promise<ServerSentEventsResult<TData, TError>>;
type RequestFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = 'fields',
>(
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'> &
Pick<
Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>,
'method'
>,
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
type BuildUrlFn = <
TData extends {
body?: unknown;
path?: Record<string, unknown>;
query?: Record<string, unknown>;
url: string;
},
>(
options: TData & Options<TData>,
) => string;
export type Client = CoreClient<
RequestFn,
Config,
MethodFn,
BuildUrlFn,
SseFn
> & {
interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>;
};
/**
* The `createClientConfig()` function will be called on client initialization
* and the returned object will become the client's initial configuration.
*
* You may want to initialize your client this way instead of calling
* `setConfig()`. This is useful for example if you're using Next.js
* to ensure your client always has the correct values.
*/
export type CreateClientConfig<T extends ClientOptions = ClientOptions> = (
override?: Config<ClientOptions & T>,
) => Config<Required<ClientOptions> & T>;
export interface TDataShape {
body?: unknown;
headers?: unknown;
path?: unknown;
query?: unknown;
url: string;
}
type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>;
export type Options<
TData extends TDataShape = TDataShape,
ThrowOnError extends boolean = boolean,
TResponse = unknown,
TResponseStyle extends ResponseStyle = 'fields',
> = OmitKeys<
RequestOptions<TResponse, TResponseStyle, ThrowOnError>,
'body' | 'path' | 'query' | 'url'
> &
([TData] extends [never] ? unknown : Omit<TData, 'url'>);

View File

@@ -0,0 +1,332 @@
// This file is auto-generated by @hey-api/openapi-ts
import { getAuthToken } from '../core/auth.gen';
import type { QuerySerializerOptions } from '../core/bodySerializer.gen';
import { jsonBodySerializer } from '../core/bodySerializer.gen';
import {
serializeArrayParam,
serializeObjectParam,
serializePrimitiveParam,
} from '../core/pathSerializer.gen';
import { getUrl } from '../core/utils.gen';
import type { Client, ClientOptions, Config, RequestOptions } from './types.gen';
export const createQuerySerializer = <T = unknown>({
parameters = {},
...args
}: QuerySerializerOptions = {}) => {
const querySerializer = (queryParams: T) => {
const search: string[] = [];
if (queryParams && typeof queryParams === 'object') {
for (const name in queryParams) {
const value = queryParams[name];
if (value === undefined || value === null) {
continue;
}
const options = parameters[name] || args;
if (Array.isArray(value)) {
const serializedArray = serializeArrayParam({
allowReserved: options.allowReserved,
explode: true,
name,
style: 'form',
value,
...options.array,
});
if (serializedArray) search.push(serializedArray);
} else if (typeof value === 'object') {
const serializedObject = serializeObjectParam({
allowReserved: options.allowReserved,
explode: true,
name,
style: 'deepObject',
value: value as Record<string, unknown>,
...options.object,
});
if (serializedObject) search.push(serializedObject);
} else {
const serializedPrimitive = serializePrimitiveParam({
allowReserved: options.allowReserved,
name,
value: value as string,
});
if (serializedPrimitive) search.push(serializedPrimitive);
}
}
}
return search.join('&');
};
return querySerializer;
};
/**
* Infers parseAs value from provided Content-Type header.
*/
export const getParseAs = (
contentType: string | null,
): Exclude<Config['parseAs'], 'auto'> => {
if (!contentType) {
// If no Content-Type header is provided, the best we can do is return the raw response body,
// which is effectively the same as the 'stream' option.
return 'stream';
}
const cleanContent = contentType.split(';')[0]?.trim();
if (!cleanContent) {
return;
}
if (
cleanContent.startsWith('application/json') ||
cleanContent.endsWith('+json')
) {
return 'json';
}
if (cleanContent === 'multipart/form-data') {
return 'formData';
}
if (
['application/', 'audio/', 'image/', 'video/'].some((type) =>
cleanContent.startsWith(type),
)
) {
return 'blob';
}
if (cleanContent.startsWith('text/')) {
return 'text';
}
return;
};
const checkForExistence = (
options: Pick<RequestOptions, 'auth' | 'query'> & {
headers: Headers;
},
name?: string,
): boolean => {
if (!name) {
return false;
}
if (
options.headers.has(name) ||
options.query?.[name] ||
options.headers.get('Cookie')?.includes(`${name}=`)
) {
return true;
}
return false;
};
export const setAuthParams = async ({
security,
...options
}: Pick<Required<RequestOptions>, 'security'> &
Pick<RequestOptions, 'auth' | 'query'> & {
headers: Headers;
}) => {
for (const auth of security) {
if (checkForExistence(options, auth.name)) {
continue;
}
const token = await getAuthToken(auth, options.auth);
if (!token) {
continue;
}
const name = auth.name ?? 'Authorization';
switch (auth.in) {
case 'query':
if (!options.query) {
options.query = {};
}
options.query[name] = token;
break;
case 'cookie':
options.headers.append('Cookie', `${name}=${token}`);
break;
case 'header':
default:
options.headers.set(name, token);
break;
}
}
};
export const buildUrl: Client['buildUrl'] = (options) =>
getUrl({
baseUrl: options.baseUrl as string,
path: options.path,
query: options.query,
querySerializer:
typeof options.querySerializer === 'function'
? options.querySerializer
: createQuerySerializer(options.querySerializer),
url: options.url,
});
export const mergeConfigs = (a: Config, b: Config): Config => {
const config = { ...a, ...b };
if (config.baseUrl?.endsWith('/')) {
config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1);
}
config.headers = mergeHeaders(a.headers, b.headers);
return config;
};
const headersEntries = (headers: Headers): Array<[string, string]> => {
const entries: Array<[string, string]> = [];
headers.forEach((value, key) => {
entries.push([key, value]);
});
return entries;
};
export const mergeHeaders = (
...headers: Array<Required<Config>['headers'] | undefined>
): Headers => {
const mergedHeaders = new Headers();
for (const header of headers) {
if (!header) {
continue;
}
const iterator =
header instanceof Headers
? headersEntries(header)
: Object.entries(header);
for (const [key, value] of iterator) {
if (value === null) {
mergedHeaders.delete(key);
} else if (Array.isArray(value)) {
for (const v of value) {
mergedHeaders.append(key, v as string);
}
} else if (value !== undefined) {
// assume object headers are meant to be JSON stringified, i.e. their
// content value in OpenAPI specification is 'application/json'
mergedHeaders.set(
key,
typeof value === 'object' ? JSON.stringify(value) : (value as string),
);
}
}
}
return mergedHeaders;
};
type ErrInterceptor<Err, Res, Req, Options> = (
error: Err,
response: Res,
request: Req,
options: Options,
) => Err | Promise<Err>;
type ReqInterceptor<Req, Options> = (
request: Req,
options: Options,
) => Req | Promise<Req>;
type ResInterceptor<Res, Req, Options> = (
response: Res,
request: Req,
options: Options,
) => Res | Promise<Res>;
class Interceptors<Interceptor> {
fns: Array<Interceptor | null> = [];
clear(): void {
this.fns = [];
}
eject(id: number | Interceptor): void {
const index = this.getInterceptorIndex(id);
if (this.fns[index]) {
this.fns[index] = null;
}
}
exists(id: number | Interceptor): boolean {
const index = this.getInterceptorIndex(id);
return Boolean(this.fns[index]);
}
getInterceptorIndex(id: number | Interceptor): number {
if (typeof id === 'number') {
return this.fns[id] ? id : -1;
}
return this.fns.indexOf(id);
}
update(
id: number | Interceptor,
fn: Interceptor,
): number | Interceptor | false {
const index = this.getInterceptorIndex(id);
if (this.fns[index]) {
this.fns[index] = fn;
return id;
}
return false;
}
use(fn: Interceptor): number {
this.fns.push(fn);
return this.fns.length - 1;
}
}
export interface Middleware<Req, Res, Err, Options> {
error: Interceptors<ErrInterceptor<Err, Res, Req, Options>>;
request: Interceptors<ReqInterceptor<Req, Options>>;
response: Interceptors<ResInterceptor<Res, Req, Options>>;
}
export const createInterceptors = <Req, Res, Err, Options>(): Middleware<
Req,
Res,
Err,
Options
> => ({
error: new Interceptors<ErrInterceptor<Err, Res, Req, Options>>(),
request: new Interceptors<ReqInterceptor<Req, Options>>(),
response: new Interceptors<ResInterceptor<Res, Req, Options>>(),
});
const defaultQuerySerializer = createQuerySerializer({
allowReserved: false,
array: {
explode: true,
style: 'form',
},
object: {
explode: true,
style: 'deepObject',
},
});
const defaultHeaders = {
'Content-Type': 'application/json',
};
export const createConfig = <T extends ClientOptions = ClientOptions>(
override: Config<Omit<ClientOptions, keyof T> & T> = {},
): Config<Omit<ClientOptions, keyof T> & T> => ({
...jsonBodySerializer,
headers: defaultHeaders,
parseAs: 'auto',
querySerializer: defaultQuerySerializer,
...override,
});

View File

@@ -0,0 +1,42 @@
// This file is auto-generated by @hey-api/openapi-ts
export type AuthToken = string | undefined;
export interface Auth {
/**
* Which part of the request do we use to send the auth?
*
* @default 'header'
*/
in?: 'header' | 'query' | 'cookie';
/**
* Header or query parameter name.
*
* @default 'Authorization'
*/
name?: string;
scheme?: 'basic' | 'bearer';
type: 'apiKey' | 'http';
}
export const getAuthToken = async (
auth: Auth,
callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken,
): Promise<string | undefined> => {
const token =
typeof callback === 'function' ? await callback(auth) : callback;
if (!token) {
return;
}
if (auth.scheme === 'bearer') {
return `Bearer ${token}`;
}
if (auth.scheme === 'basic') {
return `Basic ${btoa(token)}`;
}
return token;
};

View File

@@ -0,0 +1,100 @@
// This file is auto-generated by @hey-api/openapi-ts
import type {
ArrayStyle,
ObjectStyle,
SerializerOptions,
} from './pathSerializer.gen';
export type QuerySerializer = (query: Record<string, unknown>) => string;
export type BodySerializer = (body: any) => any;
type QuerySerializerOptionsObject = {
allowReserved?: boolean;
array?: Partial<SerializerOptions<ArrayStyle>>;
object?: Partial<SerializerOptions<ObjectStyle>>;
};
export type QuerySerializerOptions = QuerySerializerOptionsObject & {
/**
* Per-parameter serialization overrides. When provided, these settings
* override the global array/object settings for specific parameter names.
*/
parameters?: Record<string, QuerySerializerOptionsObject>;
};
const serializeFormDataPair = (
data: FormData,
key: string,
value: unknown,
): void => {
if (typeof value === 'string' || value instanceof Blob) {
data.append(key, value);
} else if (value instanceof Date) {
data.append(key, value.toISOString());
} else {
data.append(key, JSON.stringify(value));
}
};
const serializeUrlSearchParamsPair = (
data: URLSearchParams,
key: string,
value: unknown,
): void => {
if (typeof value === 'string') {
data.append(key, value);
} else {
data.append(key, JSON.stringify(value));
}
};
export const formDataBodySerializer = {
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
body: T,
): FormData => {
const data = new FormData();
Object.entries(body).forEach(([key, value]) => {
if (value === undefined || value === null) {
return;
}
if (Array.isArray(value)) {
value.forEach((v) => serializeFormDataPair(data, key, v));
} else {
serializeFormDataPair(data, key, value);
}
});
return data;
},
};
export const jsonBodySerializer = {
bodySerializer: <T>(body: T): string =>
JSON.stringify(body, (_key, value) =>
typeof value === 'bigint' ? value.toString() : value,
),
};
export const urlSearchParamsBodySerializer = {
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
body: T,
): string => {
const data = new URLSearchParams();
Object.entries(body).forEach(([key, value]) => {
if (value === undefined || value === null) {
return;
}
if (Array.isArray(value)) {
value.forEach((v) => serializeUrlSearchParamsPair(data, key, v));
} else {
serializeUrlSearchParamsPair(data, key, value);
}
});
return data.toString();
},
};

View File

@@ -0,0 +1,176 @@
// This file is auto-generated by @hey-api/openapi-ts
type Slot = 'body' | 'headers' | 'path' | 'query';
export type Field =
| {
in: Exclude<Slot, 'body'>;
/**
* Field name. This is the name we want the user to see and use.
*/
key: string;
/**
* Field mapped name. This is the name we want to use in the request.
* If omitted, we use the same value as `key`.
*/
map?: string;
}
| {
in: Extract<Slot, 'body'>;
/**
* Key isn't required for bodies.
*/
key?: string;
map?: string;
}
| {
/**
* Field name. This is the name we want the user to see and use.
*/
key: string;
/**
* Field mapped name. This is the name we want to use in the request.
* If `in` is omitted, `map` aliases `key` to the transport layer.
*/
map: Slot;
};
export interface Fields {
allowExtra?: Partial<Record<Slot, boolean>>;
args?: ReadonlyArray<Field>;
}
export type FieldsConfig = ReadonlyArray<Field | Fields>;
const extraPrefixesMap: Record<string, Slot> = {
$body_: 'body',
$headers_: 'headers',
$path_: 'path',
$query_: 'query',
};
const extraPrefixes = Object.entries(extraPrefixesMap);
type KeyMap = Map<
string,
| {
in: Slot;
map?: string;
}
| {
in?: never;
map: Slot;
}
>;
const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
if (!map) {
map = new Map();
}
for (const config of fields) {
if ('in' in config) {
if (config.key) {
map.set(config.key, {
in: config.in,
map: config.map,
});
}
} else if ('key' in config) {
map.set(config.key, {
map: config.map,
});
} else if (config.args) {
buildKeyMap(config.args, map);
}
}
return map;
};
interface Params {
body: unknown;
headers: Record<string, unknown>;
path: Record<string, unknown>;
query: Record<string, unknown>;
}
const stripEmptySlots = (params: Params) => {
for (const [slot, value] of Object.entries(params)) {
if (value && typeof value === 'object' && !Object.keys(value).length) {
delete params[slot as Slot];
}
}
};
export const buildClientParams = (
args: ReadonlyArray<unknown>,
fields: FieldsConfig,
) => {
const params: Params = {
body: {},
headers: {},
path: {},
query: {},
};
const map = buildKeyMap(fields);
let config: FieldsConfig[number] | undefined;
for (const [index, arg] of args.entries()) {
if (fields[index]) {
config = fields[index];
}
if (!config) {
continue;
}
if ('in' in config) {
if (config.key) {
const field = map.get(config.key)!;
const name = field.map || config.key;
if (field.in) {
(params[field.in] as Record<string, unknown>)[name] = arg;
}
} else {
params.body = arg;
}
} else {
for (const [key, value] of Object.entries(arg ?? {})) {
const field = map.get(key);
if (field) {
if (field.in) {
const name = field.map || key;
(params[field.in] as Record<string, unknown>)[name] = value;
} else {
params[field.map] = value;
}
} else {
const extra = extraPrefixes.find(([prefix]) =>
key.startsWith(prefix),
);
if (extra) {
const [prefix, slot] = extra;
(params[slot] as Record<string, unknown>)[
key.slice(prefix.length)
] = value;
} else if ('allowExtra' in config && config.allowExtra) {
for (const [slot, allowed] of Object.entries(config.allowExtra)) {
if (allowed) {
(params[slot as Slot] as Record<string, unknown>)[key] = value;
break;
}
}
}
}
}
}
}
stripEmptySlots(params);
return params;
};

View File

@@ -0,0 +1,181 @@
// This file is auto-generated by @hey-api/openapi-ts
interface SerializeOptions<T>
extends SerializePrimitiveOptions,
SerializerOptions<T> {}
interface SerializePrimitiveOptions {
allowReserved?: boolean;
name: string;
}
export interface SerializerOptions<T> {
/**
* @default true
*/
explode: boolean;
style: T;
}
export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited';
export type ArraySeparatorStyle = ArrayStyle | MatrixStyle;
type MatrixStyle = 'label' | 'matrix' | 'simple';
export type ObjectStyle = 'form' | 'deepObject';
type ObjectSeparatorStyle = ObjectStyle | MatrixStyle;
interface SerializePrimitiveParam extends SerializePrimitiveOptions {
value: string;
}
export const separatorArrayExplode = (style: ArraySeparatorStyle) => {
switch (style) {
case 'label':
return '.';
case 'matrix':
return ';';
case 'simple':
return ',';
default:
return '&';
}
};
export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => {
switch (style) {
case 'form':
return ',';
case 'pipeDelimited':
return '|';
case 'spaceDelimited':
return '%20';
default:
return ',';
}
};
export const separatorObjectExplode = (style: ObjectSeparatorStyle) => {
switch (style) {
case 'label':
return '.';
case 'matrix':
return ';';
case 'simple':
return ',';
default:
return '&';
}
};
export const serializeArrayParam = ({
allowReserved,
explode,
name,
style,
value,
}: SerializeOptions<ArraySeparatorStyle> & {
value: unknown[];
}) => {
if (!explode) {
const joinedValues = (
allowReserved ? value : value.map((v) => encodeURIComponent(v as string))
).join(separatorArrayNoExplode(style));
switch (style) {
case 'label':
return `.${joinedValues}`;
case 'matrix':
return `;${name}=${joinedValues}`;
case 'simple':
return joinedValues;
default:
return `${name}=${joinedValues}`;
}
}
const separator = separatorArrayExplode(style);
const joinedValues = value
.map((v) => {
if (style === 'label' || style === 'simple') {
return allowReserved ? v : encodeURIComponent(v as string);
}
return serializePrimitiveParam({
allowReserved,
name,
value: v as string,
});
})
.join(separator);
return style === 'label' || style === 'matrix'
? separator + joinedValues
: joinedValues;
};
export const serializePrimitiveParam = ({
allowReserved,
name,
value,
}: SerializePrimitiveParam) => {
if (value === undefined || value === null) {
return '';
}
if (typeof value === 'object') {
throw new Error(
'Deeply-nested arrays/objects arent supported. Provide your own `querySerializer()` to handle these.',
);
}
return `${name}=${allowReserved ? value : encodeURIComponent(value)}`;
};
export const serializeObjectParam = ({
allowReserved,
explode,
name,
style,
value,
valueOnly,
}: SerializeOptions<ObjectSeparatorStyle> & {
value: Record<string, unknown> | Date;
valueOnly?: boolean;
}) => {
if (value instanceof Date) {
return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`;
}
if (style !== 'deepObject' && !explode) {
let values: string[] = [];
Object.entries(value).forEach(([key, v]) => {
values = [
...values,
key,
allowReserved ? (v as string) : encodeURIComponent(v as string),
];
});
const joinedValues = values.join(',');
switch (style) {
case 'form':
return `${name}=${joinedValues}`;
case 'label':
return `.${joinedValues}`;
case 'matrix':
return `;${name}=${joinedValues}`;
default:
return joinedValues;
}
}
const separator = separatorObjectExplode(style);
const joinedValues = Object.entries(value)
.map(([key, v]) =>
serializePrimitiveParam({
allowReserved,
name: style === 'deepObject' ? `${name}[${key}]` : key,
value: v as string,
}),
)
.join(separator);
return style === 'label' || style === 'matrix'
? separator + joinedValues
: joinedValues;
};

View File

@@ -0,0 +1,136 @@
// This file is auto-generated by @hey-api/openapi-ts
/**
* JSON-friendly union that mirrors what Pinia Colada can hash.
*/
export type JsonValue =
| null
| string
| number
| boolean
| JsonValue[]
| { [key: string]: JsonValue };
/**
* Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes.
*/
export const queryKeyJsonReplacer = (_key: string, value: unknown) => {
if (
value === undefined ||
typeof value === 'function' ||
typeof value === 'symbol'
) {
return undefined;
}
if (typeof value === 'bigint') {
return value.toString();
}
if (value instanceof Date) {
return value.toISOString();
}
return value;
};
/**
* Safely stringifies a value and parses it back into a JsonValue.
*/
export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => {
try {
const json = JSON.stringify(input, queryKeyJsonReplacer);
if (json === undefined) {
return undefined;
}
return JSON.parse(json) as JsonValue;
} catch {
return undefined;
}
};
/**
* Detects plain objects (including objects with a null prototype).
*/
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
if (value === null || typeof value !== 'object') {
return false;
}
const prototype = Object.getPrototypeOf(value as object);
return prototype === Object.prototype || prototype === null;
};
/**
* Turns URLSearchParams into a sorted JSON object for deterministic keys.
*/
const serializeSearchParams = (params: URLSearchParams): JsonValue => {
const entries = Array.from(params.entries()).sort(([a], [b]) =>
a.localeCompare(b),
);
const result: Record<string, JsonValue> = {};
for (const [key, value] of entries) {
const existing = result[key];
if (existing === undefined) {
result[key] = value;
continue;
}
if (Array.isArray(existing)) {
(existing as string[]).push(value);
} else {
result[key] = [existing, value];
}
}
return result;
};
/**
* Normalizes any accepted value into a JSON-friendly shape for query keys.
*/
export const serializeQueryKeyValue = (
value: unknown,
): JsonValue | undefined => {
if (value === null) {
return null;
}
if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
return value;
}
if (
value === undefined ||
typeof value === 'function' ||
typeof value === 'symbol'
) {
return undefined;
}
if (typeof value === 'bigint') {
return value.toString();
}
if (value instanceof Date) {
return value.toISOString();
}
if (Array.isArray(value)) {
return stringifyToJsonValue(value);
}
if (
typeof URLSearchParams !== 'undefined' &&
value instanceof URLSearchParams
) {
return serializeSearchParams(value);
}
if (isPlainObject(value)) {
return stringifyToJsonValue(value);
}
return undefined;
};

View File

@@ -0,0 +1,266 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Config } from './types.gen';
export type ServerSentEventsOptions<TData = unknown> = Omit<
RequestInit,
'method'
> &
Pick<Config, 'method' | 'responseTransformer' | 'responseValidator'> & {
/**
* Fetch API implementation. You can use this option to provide a custom
* fetch instance.
*
* @default globalThis.fetch
*/
fetch?: typeof fetch;
/**
* Implementing clients can call request interceptors inside this hook.
*/
onRequest?: (url: string, init: RequestInit) => Promise<Request>;
/**
* Callback invoked when a network or parsing error occurs during streaming.
*
* This option applies only if the endpoint returns a stream of events.
*
* @param error The error that occurred.
*/
onSseError?: (error: unknown) => void;
/**
* Callback invoked when an event is streamed from the server.
*
* This option applies only if the endpoint returns a stream of events.
*
* @param event Event streamed from the server.
* @returns Nothing (void).
*/
onSseEvent?: (event: StreamEvent<TData>) => void;
serializedBody?: RequestInit['body'];
/**
* Default retry delay in milliseconds.
*
* This option applies only if the endpoint returns a stream of events.
*
* @default 3000
*/
sseDefaultRetryDelay?: number;
/**
* Maximum number of retry attempts before giving up.
*/
sseMaxRetryAttempts?: number;
/**
* Maximum retry delay in milliseconds.
*
* Applies only when exponential backoff is used.
*
* This option applies only if the endpoint returns a stream of events.
*
* @default 30000
*/
sseMaxRetryDelay?: number;
/**
* Optional sleep function for retry backoff.
*
* Defaults to using `setTimeout`.
*/
sseSleepFn?: (ms: number) => Promise<void>;
url: string;
};
export interface StreamEvent<TData = unknown> {
data: TData;
event?: string;
id?: string;
retry?: number;
}
export type ServerSentEventsResult<
TData = unknown,
TReturn = void,
TNext = unknown,
> = {
stream: AsyncGenerator<
TData extends Record<string, unknown> ? TData[keyof TData] : TData,
TReturn,
TNext
>;
};
export const createSseClient = <TData = unknown>({
onRequest,
onSseError,
onSseEvent,
responseTransformer,
responseValidator,
sseDefaultRetryDelay,
sseMaxRetryAttempts,
sseMaxRetryDelay,
sseSleepFn,
url,
...options
}: ServerSentEventsOptions): ServerSentEventsResult<TData> => {
let lastEventId: string | undefined;
const sleep =
sseSleepFn ??
((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
const createStream = async function* () {
let retryDelay: number = sseDefaultRetryDelay ?? 3000;
let attempt = 0;
const signal = options.signal ?? new AbortController().signal;
while (true) {
if (signal.aborted) break;
attempt++;
const headers =
options.headers instanceof Headers
? options.headers
: new Headers(options.headers as Record<string, string> | undefined);
if (lastEventId !== undefined) {
headers.set('Last-Event-ID', lastEventId);
}
try {
const requestInit: RequestInit = {
redirect: 'follow',
...options,
body: options.serializedBody,
headers,
signal,
};
let request = new Request(url, requestInit);
if (onRequest) {
request = await onRequest(url, requestInit);
}
// fetch must be assigned here, otherwise it would throw the error:
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
const _fetch = options.fetch ?? globalThis.fetch;
const response = await _fetch(request);
if (!response.ok)
throw new Error(
`SSE failed: ${response.status} ${response.statusText}`,
);
if (!response.body) throw new Error('No body in SSE response');
const reader = response.body
.pipeThrough(new TextDecoderStream())
.getReader();
let buffer = '';
const abortHandler = () => {
try {
reader.cancel();
} catch {
// noop
}
};
signal.addEventListener('abort', abortHandler);
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += value;
// Normalize line endings: CRLF -> LF, then CR -> LF
buffer = buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const chunks = buffer.split('\n\n');
buffer = chunks.pop() ?? '';
for (const chunk of chunks) {
const lines = chunk.split('\n');
const dataLines: Array<string> = [];
let eventName: string | undefined;
for (const line of lines) {
if (line.startsWith('data:')) {
dataLines.push(line.replace(/^data:\s*/, ''));
} else if (line.startsWith('event:')) {
eventName = line.replace(/^event:\s*/, '');
} else if (line.startsWith('id:')) {
lastEventId = line.replace(/^id:\s*/, '');
} else if (line.startsWith('retry:')) {
const parsed = Number.parseInt(
line.replace(/^retry:\s*/, ''),
10,
);
if (!Number.isNaN(parsed)) {
retryDelay = parsed;
}
}
}
let data: unknown;
let parsedJson = false;
if (dataLines.length) {
const rawData = dataLines.join('\n');
try {
data = JSON.parse(rawData);
parsedJson = true;
} catch {
data = rawData;
}
}
if (parsedJson) {
if (responseValidator) {
await responseValidator(data);
}
if (responseTransformer) {
data = await responseTransformer(data);
}
}
onSseEvent?.({
data,
event: eventName,
id: lastEventId,
retry: retryDelay,
});
if (dataLines.length) {
yield data as any;
}
}
}
} finally {
signal.removeEventListener('abort', abortHandler);
reader.releaseLock();
}
break; // exit loop on normal completion
} catch (error) {
// connection failed or aborted; retry after delay
onSseError?.(error);
if (
sseMaxRetryAttempts !== undefined &&
attempt >= sseMaxRetryAttempts
) {
break; // stop after firing error
}
// exponential backoff: double retry each attempt, cap at 30s
const backoff = Math.min(
retryDelay * 2 ** (attempt - 1),
sseMaxRetryDelay ?? 30000,
);
await sleep(backoff);
}
}
};
const stream = createStream();
return { stream };
};

View File

@@ -0,0 +1,118 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Auth, AuthToken } from './auth.gen';
import type {
BodySerializer,
QuerySerializer,
QuerySerializerOptions,
} from './bodySerializer.gen';
export type HttpMethod =
| 'connect'
| 'delete'
| 'get'
| 'head'
| 'options'
| 'patch'
| 'post'
| 'put'
| 'trace';
export type Client<
RequestFn = never,
Config = unknown,
MethodFn = never,
BuildUrlFn = never,
SseFn = never,
> = {
/**
* Returns the final request URL.
*/
buildUrl: BuildUrlFn;
getConfig: () => Config;
request: RequestFn;
setConfig: (config: Config) => Config;
} & {
[K in HttpMethod]: MethodFn;
} & ([SseFn] extends [never]
? { sse?: never }
: { sse: { [K in HttpMethod]: SseFn } });
export interface Config {
/**
* Auth token or a function returning auth token. The resolved value will be
* added to the request payload as defined by its `security` array.
*/
auth?: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken;
/**
* A function for serializing request body parameter. By default,
* {@link JSON.stringify()} will be used.
*/
bodySerializer?: BodySerializer | null;
/**
* An object containing any HTTP headers that you want to pre-populate your
* `Headers` object with.
*
* {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
*/
headers?:
| RequestInit['headers']
| Record<
string,
| string
| number
| boolean
| (string | number | boolean)[]
| null
| undefined
| unknown
>;
/**
* The request method.
*
* {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more}
*/
method?: Uppercase<HttpMethod>;
/**
* A function for serializing request query parameters. By default, arrays
* will be exploded in form style, objects will be exploded in deepObject
* style, and reserved characters are percent-encoded.
*
* This method will have no effect if the native `paramsSerializer()` Axios
* API function is used.
*
* {@link https://swagger.io/docs/specification/serialization/#query View examples}
*/
querySerializer?: QuerySerializer | QuerySerializerOptions;
/**
* A function validating request data. This is useful if you want to ensure
* the request conforms to the desired shape, so it can be safely sent to
* the server.
*/
requestValidator?: (data: unknown) => Promise<unknown>;
/**
* A function transforming response data before it's returned. This is useful
* for post-processing data, e.g. converting ISO strings into Date objects.
*/
responseTransformer?: (data: unknown) => Promise<unknown>;
/**
* A function validating response data. This is useful if you want to ensure
* the response conforms to the desired shape, so it can be safely passed to
* the transformers and returned to the user.
*/
responseValidator?: (data: unknown) => Promise<unknown>;
}
type IsExactlyNeverOrNeverUndefined<T> = [T] extends [never]
? true
: [T] extends [never | undefined]
? [undefined] extends [T]
? false
: true
: false;
export type OmitNever<T extends Record<string, unknown>> = {
[K in keyof T as IsExactlyNeverOrNeverUndefined<T[K]> extends true
? never
: K]: T[K];
};

View File

@@ -0,0 +1,143 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { BodySerializer, QuerySerializer } from './bodySerializer.gen';
import {
type ArraySeparatorStyle,
serializeArrayParam,
serializeObjectParam,
serializePrimitiveParam,
} from './pathSerializer.gen';
export interface PathSerializer {
path: Record<string, unknown>;
url: string;
}
export const PATH_PARAM_RE = /\{[^{}]+\}/g;
export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
let url = _url;
const matches = _url.match(PATH_PARAM_RE);
if (matches) {
for (const match of matches) {
let explode = false;
let name = match.substring(1, match.length - 1);
let style: ArraySeparatorStyle = 'simple';
if (name.endsWith('*')) {
explode = true;
name = name.substring(0, name.length - 1);
}
if (name.startsWith('.')) {
name = name.substring(1);
style = 'label';
} else if (name.startsWith(';')) {
name = name.substring(1);
style = 'matrix';
}
const value = path[name];
if (value === undefined || value === null) {
continue;
}
if (Array.isArray(value)) {
url = url.replace(
match,
serializeArrayParam({ explode, name, style, value }),
);
continue;
}
if (typeof value === 'object') {
url = url.replace(
match,
serializeObjectParam({
explode,
name,
style,
value: value as Record<string, unknown>,
valueOnly: true,
}),
);
continue;
}
if (style === 'matrix') {
url = url.replace(
match,
`;${serializePrimitiveParam({
name,
value: value as string,
})}`,
);
continue;
}
const replaceValue = encodeURIComponent(
style === 'label' ? `.${value as string}` : (value as string),
);
url = url.replace(match, replaceValue);
}
}
return url;
};
export const getUrl = ({
baseUrl,
path,
query,
querySerializer,
url: _url,
}: {
baseUrl?: string;
path?: Record<string, unknown>;
query?: Record<string, unknown>;
querySerializer: QuerySerializer;
url: string;
}) => {
const pathUrl = _url.startsWith('/') ? _url : `/${_url}`;
let url = (baseUrl ?? '') + pathUrl;
if (path) {
url = defaultPathSerializer({ path, url });
}
let search = query ? querySerializer(query) : '';
if (search.startsWith('?')) {
search = search.substring(1);
}
if (search) {
url += `?${search}`;
}
return url;
};
export function getValidRequestBody(options: {
body?: unknown;
bodySerializer?: BodySerializer | null;
serializedBody?: unknown;
}) {
const hasBody = options.body !== undefined;
const isSerializedBody = hasBody && options.bodySerializer;
if (isSerializedBody) {
if ('serializedBody' in options) {
const hasSerializedBody =
options.serializedBody !== undefined && options.serializedBody !== '';
return hasSerializedBody ? options.serializedBody : null;
}
// not all clients implement a serializedBody property (i.e. client-axios)
return options.body !== '' ? options.body : null;
}
// plain/text body
if (hasBody) {
return options.body;
}
// no body was provided
return undefined;
}

View File

@@ -1,3 +1,4 @@
// This file is auto-generated by @hey-api/openapi-ts
export * from './sdk.gen';
export * from './types.gen';
export { getNodesByUserBySystemByNodeId_byVersionJson, getNodesByUserBySystemByNodeId_byVersionWasm, getNodesByUserBySystemByNodeIdJson, getNodesByUserBySystemByNodeIdVersionsJson, getNodesByUserBySystemByNodeIdWasm, getNodesByUserBySystemJson, getNodesByUserJson, getUsersByUserIdJson, getUsersUsersJson, type Options, postNodes } from './sdk.gen';
export type { ClientOptions, GetNodesByUserBySystemByNodeId_byVersionJsonData, GetNodesByUserBySystemByNodeId_byVersionJsonResponse, GetNodesByUserBySystemByNodeId_byVersionJsonResponses, GetNodesByUserBySystemByNodeId_byVersionWasmData, GetNodesByUserBySystemByNodeId_byVersionWasmResponses, GetNodesByUserBySystemByNodeIdJsonData, GetNodesByUserBySystemByNodeIdJsonResponse, GetNodesByUserBySystemByNodeIdJsonResponses, GetNodesByUserBySystemByNodeIdVersionsJsonData, GetNodesByUserBySystemByNodeIdVersionsJsonResponse, GetNodesByUserBySystemByNodeIdVersionsJsonResponses, GetNodesByUserBySystemByNodeIdWasmData, GetNodesByUserBySystemByNodeIdWasmResponses, GetNodesByUserBySystemJsonData, GetNodesByUserBySystemJsonResponse, GetNodesByUserBySystemJsonResponses, GetNodesByUserJsonData, GetNodesByUserJsonResponse, GetNodesByUserJsonResponses, GetUsersByUserIdJsonData, GetUsersByUserIdJsonResponse, GetUsersByUserIdJsonResponses, GetUsersUsersJsonData, GetUsersUsersJsonResponse, GetUsersUsersJsonResponses, NodeDefinition, NodeInput, PostNodesData, PostNodesResponse, PostNodesResponses, User } from './types.gen';

Some files were not shown because too many files have changed in this diff Show More