100 Commits

Author SHA1 Message Date
fdc4802d68 chore: format
All checks were successful
🚀 Release / release (push) Successful in 3m46s
2026-02-03 15:39:21 +01:00
5a30fce4ec chore(ci): make release script work with sh
Some checks failed
🚀 Release / release (push) Failing after 2m51s
2026-02-03 15:33:25 +01:00
0d0eb65ddd chore(ci): add jq and git to ci docker image 2026-02-03 15:33:12 +01:00
93ca436412 fix(ci): make scripts executable
Some checks failed
🚀 Release / release (push) Failing after 3m36s
2026-02-03 15:18:37 +01:00
ecdb986a96 chore(ci): debug some information
Some checks failed
🚀 Release / release (push) Failing after 3m28s
2026-02-03 15:12:47 +01:00
304abf2866 chore(ci): debug some information
Some checks failed
🚀 Release / release (push) Has been cancelled
2026-02-03 15:09:57 +01:00
a547d86946 chore(ci): debug some information
Some checks failed
🚀 Release / release (push) Has been cancelled
2026-02-03 15:09:22 +01:00
667d140883 docs: add information on how to release to readme
All checks were successful
🚀 Release / release (push) Successful in 3m37s
2026-02-03 14:59:12 +01:00
0ac65fd7a7 feat(ci): add release workflow
All checks were successful
🚀 Release / release (push) Successful in 3m34s
2026-02-03 14:52:24 +01:00
Max Richter
5437e062e1 feat(ci): add release workflow 2026-02-03 14:49:14 +01:00
Max Richter
1015d17afb fix(ci): put pnpm store in home instead of workspace
All checks were successful
🏗️ Build and Deploy / build_and_deploy (push) Successful in 3m45s
2026-02-03 13:15:31 +01:00
Max Richter
fd8e5e92d2 chore: run formatting 2026-02-03 13:14:06 +01:00
a2c2503a8e Merge pull request 'feat/ui-float' (#30) from feat/ui-float into main
Some checks failed
🏗️ Build and Deploy / build_and_deploy (push) Failing after 1m40s
Reviewed-on: #30
2026-02-03 13:11:06 +01:00
Max Richter
e18f75e1b8 refactor(ci): make it simpler 2026-02-03 13:10:08 +01:00
Max Richter
6a178dc3a7 fix(ci): correctly copy ui output to app 2026-02-03 13:07:05 +01:00
Max Richter
76cdfee018 feat(app): merge active node and graph settings panel 2026-02-03 13:04:50 +01:00
Max Richter
b19da950a6 refactor: use tailwind custom colors for themes
Use tailwind v4 @theme block so we can use bg-layer-0 instead of
bg-[--layer-0] for theme colors.
2026-02-03 12:18:44 +01:00
Max Richter
89e4cf8364 chore: use vite for auto building @nodarium/ui instead of chokidar
We already use vite for building and during dev. Can use a custom vite
plugin to automatically package ui after every change, so no need for
chokidar.
2026-02-03 12:18:44 +01:00
Max Richter
a28f15c256 chore(ci): simplify ci step
Some checks failed
🏗️ Build and Deploy / setup (push) Successful in 1m5s
🏗️ Build and Deploy / lint (push) Successful in 1m22s
🏗️ Build and Deploy / build_and_deploy (push) Failing after 1m51s
2026-02-02 17:48:11 +01:00
Max Richter
57e3a707c5 chore(ci): simplify ci step
Some checks failed
🏗️ Build and Deploy / setup (push) Successful in 1m12s
🏗️ Build and Deploy / build_and_deploy (push) Has been cancelled
🏗️ Build and Deploy / lint (push) Has been cancelled
2026-02-02 17:46:04 +01:00
Max Richter
dced7db3ad chore(ci): simplify ci step
Some checks failed
🏗️ Build and Deploy / build_and_deploy (push) Has been cancelled
2026-02-02 17:45:04 +01:00
Max Richter
c2dc538c05 fix(ci): make deploy step work
All checks were successful
🏗️ Build and Deploy / setup (push) Successful in 1m11s
🏗️ Build and Deploy / lint (push) Successful in 36s
🏗️ Build and Deploy / format (push) Successful in 40s
🏗️ Build and Deploy / typecheck (push) Successful in 37s
🏗️ Build and Deploy / build_and_deploy (push) Successful in 1m51s
2026-02-02 17:39:17 +01:00
Max Richter
9484b3599e chore: run formatting on all files
Some checks failed
🏗️ Build and Deploy / setup (push) Successful in 1m10s
🏗️ Build and Deploy / lint (push) Successful in 34s
🏗️ Build and Deploy / format (push) Successful in 38s
🏗️ Build and Deploy / typecheck (push) Successful in 33s
🏗️ Build and Deploy / build_and_deploy (push) Failing after 1m53s
2026-02-02 17:33:01 +01:00
Max Richter
3c168aa9b6 chore: add check script to ui package 2026-02-02 17:31:20 +01:00
Max Richter
812099c55d chore: run formatting
Some checks failed
🏗️ Build and Deploy / setup (push) Successful in 1m5s
🏗️ Build and Deploy / lint (push) Successful in 34s
🏗️ Build and Deploy / format (push) Successful in 40s
🏗️ Build and Deploy / typecheck (push) Failing after 12s
🏗️ Build and Deploy / build_and_deploy (push) Has been skipped
2026-02-02 17:07:43 +01:00
Max Richter
025921aeab feat(ci): run on every branch
Some checks failed
🏗️ Build and Deploy / setup (push) Successful in 1m6s
🏗️ Build and Deploy / lint (push) Successful in 51s
🏗️ Build and Deploy / format (push) Failing after 40s
🏗️ Build and Deploy / typecheck (push) Failing after 14s
🏗️ Build and Deploy / build_and_deploy (push) Has been skipped
2026-02-02 16:56:04 +01:00
Max Richter
abaf5245d3 feat(ci): run on every branch
Some checks failed
🏗️ Build and Deploy / lint (push) Has been cancelled
🏗️ Build and Deploy / format (push) Has been cancelled
🏗️ Build and Deploy / typecheck (push) Has been cancelled
🏗️ Build and Deploy / build_and_deploy (push) Has been cancelled
🏗️ Build and Deploy / setup (push) Has been cancelled
2026-02-02 16:51:20 +01:00
Max Richter
a53cee2d5c feat(ci): run on every branch
Some checks failed
🏗️ Build and Deploy / setup (push) Failing after 1m12s
🏗️ Build and Deploy / format (push) Has been skipped
🏗️ Build and Deploy / typecheck (push) Has been skipped
🏗️ Build and Deploy / lint (push) Has been skipped
🏗️ Build and Deploy / build_and_deploy (push) Has been skipped
2026-02-02 16:46:00 +01:00
Max Richter
7d91e53704 feat(ci): run on every branch
Some checks failed
🏗️ Build and Deploy / setup (push) Failing after 1m16s
🏗️ Build and Deploy / lint (push) Has been skipped
🏗️ Build and Deploy / format (push) Has been skipped
🏗️ Build and Deploy / typecheck (push) Has been skipped
🏗️ Build and Deploy / build_and_deploy (push) Has been skipped
2026-02-02 16:41:14 +01:00
Max Richter
18db3cb9f2 Merge branch 'chore/linting'
Some checks failed
🏗️ Build and Deploy / setup (push) Successful in 1m47s
🏗️ Build and Deploy / lint (push) Failing after 2s
🏗️ Build and Deploy / format (push) Failing after 2s
🏗️ Build and Deploy / typecheck (push) Failing after 1s
🏗️ Build and Deploy / build_and_deploy (push) Has been skipped
2026-02-02 16:32:36 +01:00
Max Richter
b6d3269478 feat(ci): run on every branch 2026-02-02 16:32:01 +01:00
7d18c10007 Merge pull request 'chore: setup linting' (#29) from chore/linting into main
Reviewed-on: #29
2026-02-02 16:28:42 +01:00
Max Richter
6f33cdf066 feat(ci): run on every branch 2026-02-02 16:28:08 +01:00
Max Richter
30e897468a chore: setup linting 2026-02-02 16:22:14 +01:00
Max Richter
137425b31b chore: rename .github to .gitea
Some checks failed
Deploy to GitHub Pages / build_site (push) Has been cancelled
2026-02-02 11:49:41 +01:00
Felix Hungenberg
5570d975f5 feat: unmigrate number into universal float, inherit step if unset
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 2m1s
2026-01-22 23:57:56 +01:00
Felix Hungenberg
8c1ba2ee65 feat: move add context menu within view if outside
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 1m59s
2026-01-22 23:26:56 +01:00
Felix Hungenberg
3e019e4e21 feat: don't move graph on right click drag 2026-01-22 23:26:26 +01:00
Felix Hungenberg
a58b19e935 Merge branch 'main' of github.com:jim-fx/nodarium
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 1m57s
2026-01-22 14:06:44 +01:00
Max Richter
6f5c5bb46e feat: change initial camera position so that nodes are visible
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 2m3s
2026-01-22 12:07:37 +01:00
7f2214f15c fix(utils): make sure we do not build a .wasm file for utils
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 1m54s
2026-01-21 17:24:54 +01:00
43ef563ae7 feat: show all nodes in add menu
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 1m57s
2026-01-21 17:08:47 +01:00
Felix Hungenberg
714d01da94 chore: move pnpm links to workspace (auto link) 2026-01-21 16:39:54 +01:00
Felix Hungenberg
92308fc43a chore: run ui debug server from root
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 1m57s
2026-01-21 16:36:20 +01:00
Felix Hungenberg
5adf67ed52 Merge branch 'main' of git.max-richter.dev:max/nodarium
Some checks failed
Deploy to GitHub Pages / build_site (push) Has been cancelled
2026-01-21 16:35:34 +01:00
Felix Hungenberg
f54cde734e fix: integer width 2026-01-21 16:35:13 +01:00
70d8095869 Merge pull request 'feat: project manager' (#21) from feat/project-manager into main
Some checks failed
Deploy to GitHub Pages / build_site (push) Has been cancelled
Reviewed-on: #21
2026-01-21 16:35:03 +01:00
Felix Hungenberg
2a90d5de3f chore: dev scripts & linting 2026-01-21 16:31:26 +01:00
d7e9e8b8de chore: remove some old console.logs 2026-01-21 16:01:11 +01:00
bdbaab25a4 feat: initial working version of project manager 2026-01-21 15:02:34 +01:00
Felix Hungenberg
c7bfb0f05b fix(ui): integrate number input to exports, ui page, benchmark
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 1m46s
2026-01-21 11:55:10 +01:00
Felix Hungenberg
d3a46af4c2 chore: update pnpm corepack up
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 1m47s
2026-01-21 11:24:53 +01:00
4c76c62a3e feat: add header element 2026-01-21 11:09:51 +01:00
36cf9211d2 fix: run pnpm i :)
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 1m46s
2026-01-20 19:28:35 +01:00
97a2ffb683 feat: use workspace instead of link for app/package.json
Some checks failed
Deploy to GitHub Pages / build_site (push) Failing after 6s
2026-01-20 19:18:17 +01:00
fffa8c7cdf feat: cleanup Dockerfiles and use prepared image for deployments
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 1m46s
2026-01-20 19:12:56 +01:00
de799c2d55 Merge pull request 'feat/remove-wasm-bindgen' (#19) from feat/remove-wasm-bindgen into main
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 1m45s
Reviewed-on: #19
2026-01-20 18:57:42 +01:00
24bef0460c Merge branch 'main' into feat/remove-wasm-bindgen 2026-01-20 18:57:34 +01:00
93b64fc7dd feat: add app/Dockerfile
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 2m4s
2026-01-20 18:50:54 +01:00
64ac28f60c chore: cleanup node buildscripts 2026-01-20 18:26:48 +01:00
bd0c2eaacd Merge remote-tracking branch 'origin/main' into feat/remove-wasm-bindgen 2026-01-20 18:04:56 +01:00
8693c63d16 feat: resize canvases to fit window height
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 2m3s
Closes #16

The canvases fit their parents size, so adding a wrapper with 100vh
solved it. https://threlte.xyz/docs/reference/core/canvas#size
2026-01-20 17:55:58 +01:00
fbd82bbdfa Merge pull request 'feat/drop-node-on-connection' (#18) from feat/drop-node-on-connection into main
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 2m2s
Reviewed-on: #18
2026-01-20 17:47:04 +01:00
63997ec262 Merge branch 'main' into feat/drop-node-on-connection 2026-01-20 17:46:55 +01:00
a3d10d6094 feat: drop node on edge
Closes #13
2026-01-20 17:46:09 +01:00
Felix Hungenberg
6b6038e546 feat: use new number input
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 2m2s
fix missing id in html
2026-01-20 16:49:56 +01:00
Felix Hungenberg
3e3d41ae98 feat: open keyboard shortcuts with ?
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 2m4s
2026-01-20 16:23:21 +01:00
Felix Hungenberg
a8c76a846e fmt: ignore pnpm-lock.YAML
All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 9m27s
2026-01-20 15:19:39 +01:00
f98b90dcd3 Merge remote-tracking branch 'origin/main' into feat/drop-node-on-connection 2026-01-20 15:17:17 +01:00
1ea7d6629f chore: remove ai comments from dockerfile 2026-01-20 14:44:20 +01:00
617dfb0c9d feat: add Dockerfile for app to deploy preview 2026-01-20 14:08:45 +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
8e5412c25c Merge remote-tracking branch 'origin/main' into feat/remove-wasm-bindgen 2026-01-19 16:26:06 +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
e84c715f4c chore: Add flake and direnv stuff 2026-01-19 12:51:33 +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
ecbcc814ed chore: remove store code 2026-01-19 01:29:28 +01:00
Max Richter
be97387252 feat: trying to remove wasm-bindgen 2026-01-19 01:29: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
288 changed files with 10640 additions and 11998 deletions

65
.dprint.jsonc Normal file
View File

@@ -0,0 +1,65 @@
{
"$schema": "https://dprint.dev/schemas/v0.json",
"indentWidth": 2,
"lineWidth": 100,
"typescript": {
// https://dprint.dev/plugins/typescript/config/
"quoteStyle": "preferSingle",
"trailingCommas": "never",
},
"json": {
// https://dprint.dev/plugins/json/config/
},
"markdown": {},
"toml": {},
"dockerfile": {},
"ruff": {},
"jupyter": {},
"malva": {},
"markup": {
// https://dprint.dev/plugins/markup_fmt/config/
"scriptIndent": true,
"styleIndent": true,
},
"yaml": {},
"graphql": {},
"exec": {
"cwd": "${configDir}",
"commands": [
{
"command": "rustfmt",
"exts": [
"rs",
],
"cacheKeyFiles": [
"rustfmt.toml",
"rust-toolchain.toml",
],
},
],
},
"excludes": [
"**/node_modules",
"**/build",
"**/.svelte-kit",
"**/package",
"**/*-lock.yaml",
"**/yaml.lock",
"**/.DS_Store",
],
"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",
"https://plugins.dprint.dev/biome-0.11.10.wasm",
],
}

1
.envrc Normal file
View File

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

472
.gitea/graphics/nodes.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 39 KiB

View File

@@ -0,0 +1,77 @@
#!/bin/sh
set -eu
TAG="$GITHUB_REF_NAME"
VERSION=$(echo "$TAG" | sed 's/^v//')
DATE=$(date +%Y-%m-%d)
echo "🚀 Creating release for $TAG (safe mode)"
# -------------------------------------------------------------------
# 1. Extract release notes from annotated tag
# -------------------------------------------------------------------
NOTES=$(git tag -l "$TAG" --format='%(contents)')
if [ -z "$NOTES" ]; then
echo "❌ Tag message is empty"
exit 1
fi
git checkout main
# -------------------------------------------------------------------
# 2. Update all package.json versions
# -------------------------------------------------------------------
echo "🔧 Updating package.json versions to $VERSION"
find . -name package.json ! -path "*/node_modules/*" | while read file; do
tmp_file="$file.tmp"
jq --arg v "$VERSION" '.version = $v' "$file" >"$tmp_file"
mv "$tmp_file" "$file"
done
# -------------------------------------------------------------------
# 3. Update CHANGELOG.md (prepend)
# -------------------------------------------------------------------
tmp_changelog="CHANGELOG.tmp"
{
echo "## $TAG ($DATE)"
echo ""
echo "$NOTES"
echo ""
echo "---"
echo ""
if [ -f CHANGELOG.md ]; then
cat CHANGELOG.md
fi
} >"$tmp_changelog"
mv "$tmp_changelog" CHANGELOG.md
# -------------------------------------------------------------------
# 4. Create release commit
# -------------------------------------------------------------------
git config user.name "release-bot"
git config user.email "release-bot@ci"
git add CHANGELOG.md $(find . -name package.json ! -path "*/node_modules/*")
# Skip commit if nothing changed
if git diff --cached --quiet; then
echo "No changes to commit for release $TAG"
exit 0
fi
git commit -m "chore(release): $TAG"
# -------------------------------------------------------------------
# 5. Push changes
# -------------------------------------------------------------------
git push origin main
echo "✅ Release commit for $TAG created successfully (tag untouched)"

View File

@@ -0,0 +1,58 @@
name: 🚀 Release
on:
push:
branches: ["*"]
tags: ["*"]
env:
PNPM_CACHE_FOLDER: ~/.pnpm-store
jobs:
release:
runs-on: ubuntu-latest
container: jimfx/nodes:latest
steps:
- name: 📑 Checkout Code
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: 💾 Setup pnpm Cache
uses: actions/cache@v4
with:
path: ${{ env.PNPM_CACHE_FOLDER }}
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
- name: 📦 Install Dependencies
run: pnpm install --frozen-lockfile --store-dir ${{ env.PNPM_CACHE_FOLDER }}
- name: 🧹 Lint
run: pnpm lint
- name: 🎨 Format Check
run: pnpm format:check
- name: 🧬 Type Check
run: pnpm check
- name: 🛠️ Build
run: pnpm build:deploy
- name: 🚀 Create Release Commit
if: github.ref_type == 'tag'
run: ./.gitea/scripts/create-release.sh
- name: 🏷️ Create Gitea Release
if: github.ref_type == 'tag'
uses: akkuman/gitea-release-action@v1
with:
tag_name: ${{ github.ref_name }}
release_name: Release ${{ github.ref_name }}
body_path: CHANGELOG.md
draft: false
prerelease: false

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -1,38 +0,0 @@
name: Deploy to GitHub Pages
on:
push:
branches: "main"
jobs:
build_site:
runs-on: ubuntu-latest
container: jimfx/nodes:latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: build
run: pnpm run build:deploy
- name: 🔑 Configure rclone
run: |
echo "$SSH_PRIVATE_KEY" > /tmp/id_rsa
chmod 600 /tmp/id_rsa
mkdir -p ~/.config/rclone
echo -e "[sftp-remote]\ntype = sftp\nhost = ${SSH_HOST}\nuser = ${SSH_USER}\nport = ${SSH_PORT}\nkey_file = /tmp/id_rsa" > ~/.config/rclone/rclone.conf
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_HOST: ${{ vars.SSH_HOST }}
SSH_PORT: ${{ vars.SSH_PORT }}
SSH_USER: ${{ vars.SSH_USER }}
- name: 🚀 Deploy Changed Files via rclone
run: |
echo "Uploading the rest"
rclone sync --update -v --progress --exclude _astro/** --stats 2s --stats-one-line ./app/build/ sftp-remote:${REMOTE_DIR} --transfers 4
env:
REMOTE_DIR: ${{ vars.REMOTE_DIR }}

1
.gitignore vendored
View File

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

367
Cargo.lock generated
View File

@@ -1,176 +1,80 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
version = 4
[[package]]
name = "autocfg"
version = "1.2.0"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1fdabc7756949593fe60f30ec81974b613357de856987752631dea1e3394c80"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "box"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"nodarium_macros",
"nodarium_utils",
"serde",
"serde-wasm-bindgen",
"wasm-bindgen",
"wasm-bindgen-test",
"web-sys",
]
[[package]]
name = "branch"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"glam",
"nodarium_macros",
"nodarium_utils",
"serde",
"serde-wasm-bindgen",
"wasm-bindgen",
"wasm-bindgen-test",
"web-sys",
]
[[package]]
name = "bumpalo"
version = "3.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
[[package]]
name = "cfg-if"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "console_error_panic_hook"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
dependencies = [
"cfg-if",
"wasm-bindgen",
]
[[package]]
name = "float"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"nodarium_macros",
"nodarium_utils",
"serde",
"serde-wasm-bindgen",
"wasm-bindgen",
"wasm-bindgen-test",
]
[[package]]
name = "glam"
version = "0.27.0"
version = "0.30.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e05e7e6723e3455f4818c7b26e855439f7546cf617ef669d1adedb8669e5cb9"
checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9"
[[package]]
name = "gravity"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"glam",
"nodarium_macros",
"nodarium_utils",
"noise",
"serde",
"serde-wasm-bindgen",
"wasm-bindgen",
"wasm-bindgen-test",
"web-sys",
]
[[package]]
name = "instance"
version = "0.1.0"
dependencies = [
"glam",
"nodarium_macros",
"nodarium_utils",
]
[[package]]
name = "itoa"
version = "1.0.11"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b"
[[package]]
name = "js-sys"
version = "0.3.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d"
dependencies = [
"wasm-bindgen",
]
[[package]]
name = "log"
version = "0.4.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "math"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"nodarium_macros",
"nodarium_utils",
"serde",
"serde-wasm-bindgen",
"wasm-bindgen",
"wasm-bindgen-test",
"web-sys",
]
[[package]]
name = "max-plantarium-triangle"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"nodarium_macros",
"nodarium_utils",
"serde",
"serde-wasm-bindgen",
"wasm-bindgen",
"wasm-bindgen-test",
"web-sys",
]
[[package]]
name = "max-plantarium-vec3"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"nodarium_macros",
"nodarium_utils",
"serde",
"serde-wasm-bindgen",
"wasm-bindgen",
"wasm-bindgen-test",
"web-sys",
]
[[package]]
name = "nodarium_instance"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"glam",
"nodarium_macros",
"nodarium_utils",
"serde",
"serde-wasm-bindgen",
"wasm-bindgen",
"wasm-bindgen-test",
"web-sys",
]
name = "memchr"
version = "2.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
[[package]]
name = "nodarium_macros"
@@ -180,7 +84,7 @@ dependencies = [
"quote",
"serde",
"serde_json",
"syn 1.0.109",
"syn",
]
[[package]]
@@ -195,29 +99,19 @@ dependencies = [
name = "nodarium_utils"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"glam",
"noise",
"noise 0.9.0",
"serde",
"serde_json",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "nodes-noise"
name = "noise"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"glam",
"nodarium_macros",
"nodarium_utils",
"noise",
"serde",
"serde-wasm-bindgen",
"wasm-bindgen",
"wasm-bindgen-test",
"web-sys",
"noise 0.9.0",
]
[[package]]
@@ -233,48 +127,35 @@ dependencies = [
[[package]]
name = "num-traits"
version = "0.2.18"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da0df0e5185db44f69b44f26786fe401b6c293d1907744beaa7fa62b2e5a517a"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
[[package]]
name = "output"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"glam",
"nodarium_macros",
"nodarium_utils",
"serde",
"serde_json",
"wasm-bindgen",
"wasm-bindgen-test",
"web-sys",
]
[[package]]
name = "proc-macro2"
version = "1.0.81"
version = "1.0.105"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d1597b0c024618f09a9c3b8655b7e430397a36d23fdafec26d6965e9eec3eba"
checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.36"
version = "1.0.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7"
checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a"
dependencies = [
"proc-macro2",
]
@@ -307,103 +188,76 @@ dependencies = [
name = "random"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"nodarium_macros",
"nodarium_utils",
"serde",
"serde-wasm-bindgen",
"wasm-bindgen",
"wasm-bindgen-test",
]
[[package]]
name = "rotate"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"glam",
"nodarium_macros",
"nodarium_utils",
"serde",
"serde-wasm-bindgen",
"wasm-bindgen",
"wasm-bindgen-test",
"web-sys",
]
[[package]]
name = "ryu"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1"
[[package]]
name = "scoped-tls"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
[[package]]
name = "serde"
version = "1.0.198"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9846a40c979031340571da2545a4e5b7c4163bdae79b301d5f86d03979451fcc"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde-wasm-bindgen"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3b4c031cd0d9014307d82b8abf653c0290fbdaeb4c02d00c63cf52f728628bf"
dependencies = [
"js-sys",
"serde",
"wasm-bindgen",
]
[[package]]
name = "serde_derive"
version = "1.0.198"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e88edab869b01783ba905e7d0153f9fc1a6505a96e4ad3018011eedb838566d9"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.60",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.116"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e17db7126d17feb94eb3fad46bf1a96b034e8aacbc2e775fe81505f8b0b2813"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"itoa",
"ryu",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "stem"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"nodarium_macros",
"nodarium_utils",
"serde",
"serde-wasm-bindgen",
"wasm-bindgen",
"wasm-bindgen-test",
"web-sys",
]
[[package]]
name = "syn"
version = "1.0.109"
version = "2.0.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
dependencies = [
"proc-macro2",
"quote",
@@ -411,119 +265,30 @@ dependencies = [
]
[[package]]
name = "syn"
version = "2.0.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "909518bc7b1c9b779f1bbf07f2929d35af9f0f37e47c6e9ef7f9dddc1e1821f3"
name = "triangle"
version = "0.1.0"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
"nodarium_macros",
"nodarium_utils",
]
[[package]]
name = "unicode-ident"
version = "1.0.12"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]]
name = "wasm-bindgen"
version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8"
name = "vec3"
version = "0.1.0"
dependencies = [
"cfg-if",
"wasm-bindgen-macro",
"nodarium_macros",
"nodarium_utils",
"serde",
]
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.92"
name = "zmij"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da"
dependencies = [
"bumpalo",
"log",
"once_cell",
"proc-macro2",
"quote",
"syn 2.0.60",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76bc14366121efc8dbb487ab05bcc9d346b3b5ec0eaa76e46594cabbe51762c0"
dependencies = [
"cfg-if",
"js-sys",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.60",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96"
[[package]]
name = "wasm-bindgen-test"
version = "0.3.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9bf62a58e0780af3e852044583deee40983e5886da43a271dd772379987667b"
dependencies = [
"console_error_panic_hook",
"js-sys",
"scoped-tls",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-bindgen-test-macro",
]
[[package]]
name = "wasm-bindgen-test-macro"
version = "0.3.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f89739351a2e03cb94beb799d47fb2cac01759b40ec441f7de39b00cbf7ef0"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.60",
]
[[package]]
name = "web-sys"
version = "0.3.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77afa9a11836342370f4817622a2f0f418b134426d91a82dfb48f532d2ec13ef"
dependencies = [
"js-sys",
"wasm-bindgen",
]
checksum = "94f63c051f4fe3c1509da62131a678643c5b6fbdc9273b2b79d4378ebda003d2"

View File

@@ -1,21 +1,19 @@
FROM node:24-alpine
RUN apk add --no-cache --update curl rclone g++
RUN RUSTUP_URL="https://sh.rustup.rs" \
&& curl --silent --show-error --location --fail --retry 3 --proto '=https' --tlsv1.2 --output /tmp/rustup-linux-install.sh $RUSTUP_URL \
&& sh /tmp/rustup-linux-install.sh -y
# Install all required packages in one layer
RUN apk add --no-cache curl git jq g++ make
# Set Rust paths
ENV RUSTUP_HOME=/usr/local/rustup \
CARGO_HOME=/usr/local/cargo \
PATH=/usr/local/cargo/bin:$PATH
CARGO_HOME=/usr/local/cargo \
PATH=/usr/local/cargo/bin:$PATH
# Install Rust, wasm target, and pnpm
RUN curl --silent --show-error --location --fail --retry 3 \
--proto '=https' --tlsv1.2 \
--output /tmp/rustup-init.sh https://sh.rustup.rs \
&& sh /tmp/rustup-init.sh -y --no-modify-path --profile minimal \
&& rm /tmp/rustup-init.sh \
&& rustup default stable \
&& rustup target add wasm32-unknown-unknown \
&& cargo install wasm-pack \
&& npm i -g pnpm
--proto '=https' --tlsv1.2 \
--output /tmp/rustup-init.sh https://sh.rustup.rs \
&& sh /tmp/rustup-init.sh -y --no-modify-path --profile minimal \
&& rm /tmp/rustup-init.sh \
&& rustup target add wasm32-unknown-unknown \
&& rm -rf /usr/local/rustup/toolchains/*/share/doc \
&& npm i -g pnpm

View File

@@ -2,17 +2,17 @@ Nodarium
<div align="center">
<a href="https://nodes.max-richter.dev/"><h2 align="center">Nodarium</h2></a>
<a href="https://nodes.max-richter.dev/"><h2 align="center">Nodarium</h2></a>
<p align="center">
Nodarium is a WebAssembly based visual programming language.
<p align="center">
Nodarium is a WebAssembly based visual programming language.
</p>
<img src=".github/graphics/nodes.svg" width="80%"/>
<img src=".gitea/graphics/nodes.svg" width="80%"/>
</div>
Currently this visual programming language is used to develop https://nodes.max-richter.dev, a procedural modelling tool for 3d-plants.
Currently this visual programming language is used to develop <https://nodes.max-richter.dev>, a procedural modelling tool for 3d-plants.
# Table of contents
@@ -22,7 +22,7 @@ Currently this visual programming language is used to develop https://nodes.max-
# Developing
### Install prerequisites:
### Install prerequisites
- [Node.js](https://nodejs.org/en/download)
- [pnpm](https://pnpm.io/installation)
@@ -50,4 +50,29 @@ pnpm dev
### [Now you can create your first node 🤓](./docs/DEVELOPING_NODES.md)
# Releasing
## Creating a Release
1. **Create an annotated tag** with your release notes:
```bash
git tag -a v1.0.0 -m "Release notes for this version"
git push origin v1.0.0
```
2. **The CI workflow will:**
- Run lint, format check, and type check
- Build the project
- Update all `package.json` versions to match the tag
- Generate/update `CHANGELOG.md`
- Create a release commit on `main`
- Publish a Gitea release
## Version Requirements
- Tag must match pattern `v*` (e.g., `v1.0.0`, `v2.3.1`)
- Tag message must not be empty (annotated tag required)
- Tag must be pushed from `main` branch
# Roadmap

1
app/.env Normal file
View File

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

32
app/Dockerfile Normal file
View File

@@ -0,0 +1,32 @@
FROM jimfx/nodes:latest AS builder
WORKDIR /app
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json Cargo.lock Cargo.toml ./
COPY packages/ ./packages/
COPY nodes/ ./nodes/
COPY app/package.json ./app/
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
--mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/app/target \
pnpm install --frozen-lockfile
COPY . .
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
--mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/app/target \
pnpm build:nodes && \
pnpm --filter @nodarium/app... build
FROM nginx:alpine AS runner
RUN rm /etc/nginx/conf.d/default.conf
COPY app/docker/app.conf /etc/nginx/conf.d/app.conf
COPY --from=builder /app/app/build /app
EXPOSE 80

10
app/docker/app.conf Normal file
View File

@@ -0,0 +1,10 @@
server {
listen 80;
server_name _;
root /app;
index index.html;
location / {
try_files \$uri \$uri/ /index.html;
}
}

37
app/eslint.config.mjs Normal file
View File

@@ -0,0 +1,37 @@
import { includeIgnoreFile } from '@eslint/compat';
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import { defineConfig } from 'eslint/config';
import globals from 'globals';
import path from 'node:path';
import ts from 'typescript-eslint';
import svelteConfig from './svelte.config.js';
const gitignorePath = path.resolve(import.meta.dirname, '.gitignore');
export default defineConfig(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
{
languageOptions: { globals: { ...globals.browser, ...globals.node } },
rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off'
}
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser,
tsconfigRootDir: import.meta.dirname,
svelteConfig
}
}
}
);

View File

@@ -7,40 +7,51 @@
"dev": "vite dev",
"build": "svelte-kit sync && vite build",
"test": "vitest",
"preview": "vite preview"
"preview": "vite preview",
"format": "dprint fmt -c '../.dprint.jsonc' .",
"format:check": "dprint check -c '../.dprint.jsonc' .",
"lint": "eslint .",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
},
"dependencies": {
"@nodarium/registry": "link:../packages/registry",
"@nodarium/ui": "link:../packages/ui",
"@nodarium/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/ui": "workspace:*",
"@nodarium/utils": "workspace:*",
"@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",
"wabt": "^1.0.39"
},
"devDependencies": {
"@iconify-json/tabler": "^1.2.23",
"@nodarium/types": "link:../packages/types",
"@eslint/compat": "^2.0.2",
"@eslint/js": "^9.39.2",
"@iconify-json/tabler": "^1.2.26",
"@iconify/tailwind4": "^1.2.1",
"@nodarium/types": "workspace:",
"@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",
"dprint": "^0.51.1",
"eslint": "^9.39.2",
"eslint-plugin-svelte": "^3.14.0",
"globals": "^17.3.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",
"typescript-eslint": "^8.54.0",
"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

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

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

14
app/src/app.d.ts vendored
View File

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

View File

@@ -1,29 +1,26 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/svelte.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
<title>Nodes</title>
<script>
var store = localStorage.getItem('node-settings');
if (store) {
try {
var value = JSON.parse(store);
var themes = ['dark', 'light', 'catppuccin'];
if (themes[value.theme]) {
document.documentElement.classList.add('theme-' + themes[value.theme]);
}
} catch (e) {}
}
</script>
</head>
<head>
<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>
var store = localStorage.getItem("node-settings");
if (store) {
try {
var value = JSON.parse(store);
var themes = ["dark", "light", "catppuccin"];
if (themes[value.theme]) {
document.documentElement.classList.add("theme-" + themes[value.theme]);
}
} catch (e) { }
}
</script>
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

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,9 +1,9 @@
<script lang="ts">
import { T } from "@threlte/core";
import BackgroundVert from "./Background.vert";
import BackgroundFrag from "./Background.frag";
import { colors } from "../graph/colors.svelte";
import { appSettings } from "$lib/settings/app-settings.svelte";
import { appSettings } from '$lib/settings/app-settings.svelte';
import { T } from '@threlte/core';
import { colors } from '../graph/colors.svelte';
import BackgroundFrag from './Background.frag';
import BackgroundVert from './Background.vert';
type Props = {
minZoom: number;
@@ -18,7 +18,7 @@
maxZoom = 150,
cameraPosition = [0, 1, 0],
width = globalThis?.innerWidth || 100,
height = globalThis?.innerHeight || 100,
height = globalThis?.innerHeight || 100
}: Props = $props();
let bw = $derived(width / cameraPosition[2]);
@@ -38,25 +38,25 @@
fragmentShader={BackgroundFrag}
uniforms={{
camPos: {
value: [0, 1, 0],
value: [0, 1, 0]
},
backgroundColor: {
value: colors["layer-0"],
value: colors['layer-0']
},
lineColor: {
value: colors["outline"],
value: colors['outline']
},
zoomLimits: {
value: [2, 50],
value: [2, 50]
},
dimensions: {
value: [100, 100],
},
value: [100, 100]
}
}}
uniforms.camPos.value={cameraPosition}
uniforms.backgroundColor.value={appSettings.value.theme &&
colors["layer-0"]}
uniforms.lineColor.value={appSettings.value.theme && colors["outline"]}
uniforms.backgroundColor.value={appSettings.value.theme
&& colors['layer-0']}
uniforms.lineColor.value={appSettings.value.theme && colors['outline']}
uniforms.zoomLimits.value={[minZoom, maxZoom]}
uniforms.dimensions.value={[width, height]}
/>

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import { HTML } from "@threlte/extras";
import { onMount } from "svelte";
import type { NodeInstance, NodeId } from "@nodarium/types";
import { getGraphManager, getGraphState } from "../graph/state.svelte";
import type { NodeId, NodeInstance } from '@nodarium/types';
import { HTML } from '@threlte/extras';
import { onMount } from 'svelte';
import { getGraphManager, getGraphState } from '../graph-state.svelte';
type Props = {
onnode: (n: NodeInstance) => void;
@@ -14,6 +14,7 @@
const graphState = getGraphState();
let input: HTMLInputElement;
let wrapper: HTMLDivElement;
let value = $state<string>();
let activeNodeId = $state<NodeId>();
@@ -22,10 +23,10 @@
: graph.getNodeDefinitions();
function filterNodes() {
return allNodes.filter((node) => node.id.includes(value ?? ""));
return allNodes.filter((node) => node.id.includes(value ?? ''));
}
const nodes = $derived(value === "" ? allNodes : filterNodes());
const nodes = $derived(value === '' ? allNodes : filterNodes());
$effect(() => {
if (nodes) {
if (activeNodeId === undefined) {
@@ -39,38 +40,38 @@
}
});
function handleNodeCreation(nodeType: NodeInstance["type"]) {
function handleNodeCreation(nodeType: NodeInstance['type']) {
if (!graphState.addMenuPosition) return;
onnode?.({
id: -1,
type: nodeType,
position: [...graphState.addMenuPosition],
props: {},
state: {},
state: {}
});
}
function handleKeyDown(event: KeyboardEvent) {
event.stopImmediatePropagation();
if (event.key === "Escape") {
if (event.key === 'Escape') {
graphState.addMenuPosition = null;
return;
}
if (event.key === "ArrowDown") {
if (event.key === 'ArrowDown') {
const index = nodes.findIndex((node) => node.id === activeNodeId);
activeNodeId = nodes[(index + 1) % nodes.length].id;
return;
}
if (event.key === "ArrowUp") {
if (event.key === 'ArrowUp') {
const index = nodes.findIndex((node) => node.id === activeNodeId);
activeNodeId = nodes[(index - 1 + nodes.length) % nodes.length].id;
return;
}
if (event.key === "Enter") {
if (event.key === 'Enter') {
if (activeNodeId && graphState.addMenuPosition) {
handleNodeCreation(activeNodeId);
}
@@ -81,6 +82,16 @@
onMount(() => {
input.disabled = false;
setTimeout(() => input.focus(), 50);
const rect = wrapper.getBoundingClientRect();
const deltaY = rect.bottom - window.innerHeight;
const deltaX = rect.right - window.innerWidth;
if (deltaY > 0) {
wrapper.style.marginTop = `-${deltaY + 30}px`;
}
if (deltaX > 0) {
wrapper.style.marginLeft = `-${deltaX + 30}px`;
}
});
</script>
@@ -89,7 +100,7 @@
position.z={graphState.addMenuPosition?.[1]}
transform={false}
>
<div class="add-menu-wrapper">
<div class="add-menu-wrapper" bind:this={wrapper}>
<div class="header">
<input
id="add-menu"
@@ -105,14 +116,14 @@
</div>
<div class="content">
{#each nodes as node}
{#each nodes as node (node.id)}
<div
class="result"
role="treeitem"
tabindex="0"
aria-selected={node.id === activeNodeId}
onkeydown={(event) => {
if (event.key === "Enter") {
if (event.key === 'Enter') {
handleNodeCreation(node.id);
}
}}
@@ -125,7 +136,7 @@
activeNodeId = node.id;
}}
>
{node.id.split("/").at(-1)}
{node.id.split('/').at(-1)}
</div>
{/each}
</div>
@@ -138,7 +149,7 @@
}
input {
background: var(--layer-0);
background: var(--color-layer-0);
font-family: var(--font-family);
border: none;
border-radius: 5px;
@@ -157,28 +168,30 @@
.add-menu-wrapper {
position: absolute;
background: var(--layer-1);
background: var(--color-layer-1);
border-radius: 7px;
overflow: hidden;
border: solid 2px var(--layer-2);
border: solid 2px var(--color-layer-2);
width: 150px;
}
.content {
min-height: none;
width: 100%;
color: var(--text-color);
max-height: 300px;
overflow-y: auto;
}
.result {
padding: 1em 0.9em;
border-bottom: solid 1px var(--layer-2);
border-bottom: solid 1px var(--color-layer-2);
opacity: 0.7;
font-size: 0.9em;
cursor: pointer;
}
.result[aria-selected="true"] {
background: var(--layer-2);
background: var(--color-layer-2);
opacity: 1;
}
</style>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { HTML } from "@threlte/extras";
import { HTML } from '@threlte/extras';
type Props = {
p1: { x: number; y: number };
@@ -10,7 +10,7 @@
const {
p1 = { x: 0, y: 0 },
p2 = { x: 0, y: 0 },
cameraPosition = [0, 1, 0],
cameraPosition = [0, 1, 0]
}: Props = $props();
const width = $derived(Math.abs(p1.x - p2.x) * cameraPosition[2]);
@@ -24,14 +24,15 @@
<div
class="box-selection"
style={`width: ${width}px; height: ${height}px;`}
></div>
>
</div>
</HTML>
<style>
.box-selection {
width: 40px;
height: 20px;
border: solid 2px var(--outline);
border: solid 2px var(--color-outline);
border-style: dashed;
border-radius: 2px;
}

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { T } from "@threlte/core";
import { type OrthographicCamera } from "three";
import { T } from '@threlte/core';
import { type OrthographicCamera } from 'three';
type Props = {
camera: OrthographicCamera;
position: [number, number, number];

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import type { NodeDefinition, NodeRegistry } from "@nodarium/types";
import { onMount } from "svelte";
import type { NodeDefinition, NodeRegistry } from '@nodarium/types';
import { onMount } from 'svelte';
let mx = $state(0);
let my = $state(0);
@@ -20,15 +20,15 @@
my = ev.clientY;
if (!target) return;
const closest = target?.closest?.("[data-node-type]");
const closest = target?.closest?.('[data-node-type]');
if (!closest) {
node = undefined;
return;
}
let nodeType = closest.getAttribute("data-node-type");
let nodeInput = closest.getAttribute("data-node-input");
let nodeType = closest.getAttribute('data-node-type');
let nodeInput = closest.getAttribute('data-node-input');
if (!nodeType) {
node = undefined;
@@ -40,9 +40,9 @@
onMount(() => {
const style = wrapper.parentElement?.style;
style?.setProperty("cursor", "help");
style?.setProperty('cursor', 'help');
return () => {
style?.removeProperty("cursor");
style?.removeProperty('cursor');
};
});
</script>
@@ -53,12 +53,12 @@
class="help-wrapper p-4"
class:visible={node}
bind:clientWidth={width}
style="--my:{my}px; --mx:{Math.min(mx, window.innerWidth - width - 20)}px;"
style="--my: {my}px; --mx: {Math.min(mx, window.innerWidth - width - 20)}px"
bind:this={wrapper}
>
<p class="m-0 text-light opacity-40 flex items-center gap-3 mb-4">
<span class="i-tabler-help block w-4 h-4"></span>
{node?.id.split("/").at(-1) || "Help"}
{node?.id.split('/').at(-1) || 'Help'}
{#if input}
<span>> {input}</span>
{/if}
@@ -77,7 +77,7 @@
{#if !input}
<div>
<span class="i-tabler-arrow-right opacity-30">-></span>
{node?.outputs?.map((o) => o).join(", ") ?? "nothing"}
{node?.outputs?.map((o) => o).join(', ') ?? 'nothing'}
</div>
{/if}
{/if}
@@ -88,12 +88,12 @@
position: fixed;
pointer-events: none;
transform: translate(var(--mx), var(--my));
background: var(--layer-1);
background: var(--color-layer-1);
border-radius: 5px;
top: 10px;
left: 10px;
max-width: 250px;
border: 1px solid var(--outline);
border: 1px solid var(--color-outline);
z-index: 10000;
display: none;
}

View File

@@ -1,19 +1,65 @@
<script lang="ts">
import { MeshLineGeometry, MeshLineMaterial } from '@threlte/extras';
import { points, lines } from './store.js';
import { T } from '@threlte/core';
import type { Box } from '@nodarium/types';
import { T } from '@threlte/core';
import { MeshLineGeometry, MeshLineMaterial } from '@threlte/extras';
import { Color, Vector3 } from 'three';
import { lines, points, rects } from './store.js';
type Line = {
points: Vector3[];
color?: Color;
};
function getEachKey(value: Vector3 | Box | Line): string {
if ('x' in value) {
return [value.x, value.y, value.z].join('-');
}
if ('minX' in value) {
return [value.maxX, value.minX, value.maxY, value.minY].join('-');
}
if ('points' in value) {
return getEachKey(value.points[Math.floor(value.points.length / 2)]);
}
return '';
}
</script>
{#each $points as point}
<T.Mesh position.x={point.x} position.y={point.y} position.z={point.z} rotation.x={-Math.PI / 2}>
<T.CircleGeometry args={[0.2, 32]} />
<T.MeshBasicMaterial color="red" />
</T.Mesh>
{#each $points as point (getEachKey(point))}
<T.Mesh
position.x={point.x}
position.y={point.y}
position.z={point.z}
rotation.x={-Math.PI / 2}
>
<T.CircleGeometry args={[0.2, 32]} />
<T.MeshBasicMaterial color="red" />
</T.Mesh>
{/each}
{#each $lines as line}
<T.Mesh>
<MeshLineGeometry points={line} />
<MeshLineMaterial color="red" linewidth={1} attenuate={false} />
</T.Mesh>
{#each $rects as rect, i (getEachKey(rect))}
<T.Mesh
position.x={(rect.minX + rect.maxX) / 2}
position.y={0}
position.z={(rect.minY + rect.maxY) / 2}
rotation.x={-Math.PI / 2}
>
<T.PlaneGeometry args={[rect.maxX - rect.minX, rect.maxY - rect.minY]} />
<T.MeshBasicMaterial
color={new Color().setHSL((i * 1.77) % 1, 1, 0.5)}
opacity={0.9}
/>
</T.Mesh>
{/each}
{#each $lines as line (getEachKey(line))}
<T.Mesh position.y={1}>
<MeshLineGeometry points={line.points} />
<MeshLineMaterial
color={line.color || 'red'}
linewidth={1}
attenuate={false}
/>
</T.Mesh>
{/each}

View File

@@ -1,5 +1,8 @@
import { Vector3 } from "three/src/math/Vector3.js";
import { lines, points } from "./store";
import type { Box } from '@nodarium/types';
import type { Color } from 'three';
import { Vector3 } from 'three/src/math/Vector3.js';
import Component from './Debug.svelte';
import { lines, points, rects } from './store';
export function debugPosition(x: number, y: number) {
points.update((p) => {
@@ -8,18 +11,27 @@ export function debugPosition(x: number, y: number) {
});
}
export function debugRect(rect: Box) {
rects.update((r) => {
r.push(rect);
return r;
});
}
export function clear() {
points.set([]);
lines.set([]);
rects.set([]);
}
export function debugLine(line: Vector3[]) {
export function debugLine(points: Vector3[], color?: Color) {
lines.update((l) => {
l.push(line);
l.push({ points, color });
return l;
});
}
import Component from "./Debug.svelte";
export default Component
export default Component;
export function clearLines() {
lines.set([]);
}

View File

@@ -1,6 +1,8 @@
import { writable } from "svelte/store";
import { Vector3 } from "three/src/math/Vector3.js";
import type { Box } from '@nodarium/types';
import { writable } from 'svelte/store';
import type { Color } from 'three';
import { Vector3 } from 'three/src/math/Vector3.js';
export const points = writable<Vector3[]>([]);
export const lines = writable<Vector3[][]>([]);
export const rects = writable<Box[]>([]);
export const lines = writable<{ points: Vector3[]; color?: Color }[]>([]);

View File

@@ -1,16 +1,18 @@
<script module lang="ts">
import { colors } from "../graph/colors.svelte";
import { colors } from '../graph/colors.svelte';
const circleMaterial = new MeshBasicMaterial({
color: colors.edge.clone(),
toneMapped: false,
toneMapped: false
});
let lineColor = $state(colors.edge.clone().convertSRGBToLinear());
$effect.root(() => {
$effect(() => {
appSettings.value.theme;
if (appSettings.value.theme === undefined) {
return;
}
circleMaterial.color = colors.edge.clone().convertSRGBToLinear();
lineColor = colors.edge.clone().convertSRGBToLinear();
});
@@ -20,17 +22,21 @@
new Vector2(0, 0),
new Vector2(0, 0),
new Vector2(0, 0),
new Vector2(0, 0),
new Vector2(0, 0)
);
</script>
<script lang="ts">
import { T } from "@threlte/core";
import { 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 { appSettings } from "$lib/settings/app-settings.svelte";
import { appSettings } from '$lib/settings/app-settings.svelte';
import { T } from '@threlte/core';
import { MeshLineGeometry, MeshLineMaterial } from '@threlte/extras';
import { onDestroy } from 'svelte';
import { MeshBasicMaterial, Vector3 } from 'three';
import { CubicBezierCurve } from 'three/src/extras/curves/CubicBezierCurve.js';
import { Vector2 } from 'three/src/math/Vector2.js';
import { getGraphState } from '../graph-state.svelte';
const graphState = getGraphState();
type Props = {
x1: number;
@@ -38,27 +44,28 @@
x2: number;
y2: number;
z: number;
id?: string;
};
const { x1, y1, x2, y2, z }: Props = $props();
const { x1, y1, x2, y2, z, id }: Props = $props();
const thickness = $derived(Math.max(0.001, 0.00082 * Math.exp(0.055 * z)));
let points = $state<Vector3[]>([]);
let lastId: string | null = null;
const curveId = $derived(`${x1}-${y1}-${x2}-${y2}`);
function update() {
const new_x = x2 - x1;
const new_y = y2 - y1;
const curveId = `${x1}-${y1}-${x2}-${y2}`;
if (lastId === curveId) {
return;
}
lastId = curveId;
const length = Math.floor(
Math.sqrt(Math.pow(new_x, 2) + Math.pow(new_y, 2)) / 4,
Math.sqrt(Math.pow(new_x, 2) + Math.pow(new_y, 2)) / 4
);
const samples = Math.max(length * 16, 10);
@@ -72,6 +79,15 @@
.getPoints(samples)
.map((p) => new Vector3(p.x, 0, p.y))
.flat();
if (id) {
graphState.setEdgeGeometry(
id,
x1,
y1,
$state.snapshot(points) as unknown as Vector3[]
);
}
}
$effect(() => {
@@ -79,6 +95,10 @@
update();
}
});
onDestroy(() => {
if (id) graphState.removeEdgeGeometry(id);
});
</script>
<T.Mesh
@@ -101,6 +121,18 @@
<T.CircleGeometry args={[0.5, 16]} />
</T.Mesh>
{#if graphState.hoveredEdgeId === id}
<T.Mesh position.x={x1} position.z={y1} position.y={0.1}>
<MeshLineGeometry {points} />
<MeshLineMaterial
width={thickness * 5}
color={lineColor}
opacity={0.5}
transparent
/>
</T.Mesh>
{/if}
<T.Mesh position.x={x1} position.z={y1} position.y={0.1}>
<MeshLineGeometry {points} />
<MeshLineMaterial width={thickness} color={lineColor} />

View File

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

View File

@@ -1,31 +1,33 @@
import throttle from '$lib/helpers/throttle';
import { RemoteNodeRegistry } from '$lib/node-registry/index';
import type {
Edge,
Graph,
NodeInstance,
NodeDefinition,
NodeInput,
NodeRegistry,
NodeId,
Socket,
} from "@nodarium/types";
import { fastHashString } from "@nodarium/utils";
import { SvelteMap } from "svelte/reactivity";
import EventEmitter from "./helpers/EventEmitter";
import { createLogger } from "@nodarium/utils";
import throttle from "$lib/helpers/throttle";
import { HistoryManager } from "./history-manager";
NodeInput,
NodeInstance,
NodeRegistry,
Socket
} from '@nodarium/types';
import { fastHashString } from '@nodarium/utils';
import { createLogger } from '@nodarium/utils';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import EventEmitter from './helpers/EventEmitter';
import { HistoryManager } from './history-manager';
const logger = createLogger("graph-manager");
const logger = createLogger('graph-manager');
logger.mute();
const clone =
"structuredClone" in self
? self.structuredClone
: (args: any) => JSON.parse(JSON.stringify(args));
const remoteRegistry = new RemoteNodeRegistry('');
const clone = 'structuredClone' in self
? self.structuredClone
: (args: unknown) => JSON.parse(JSON.stringify(args));
function areSocketsCompatible(
output: string | undefined,
inputs: string | (string | undefined)[] | undefined,
inputs: string | (string | undefined)[] | undefined
) {
if (Array.isArray(inputs) && output) {
return inputs.includes(output);
@@ -34,35 +36,34 @@ function areSocketsCompatible(
}
function areEdgesEqual(firstEdge: Edge, secondEdge: Edge) {
if (firstEdge[0].id !== secondEdge[0].id) {
return false;
}
if (firstEdge[1] !== secondEdge[1]) {
return false
return false;
}
if (firstEdge[2].id !== secondEdge[2].id) {
return false
return false;
}
if (firstEdge[3] !== secondEdge[3]) {
return false
return false;
}
return true
return true;
}
export class GraphManager extends EventEmitter<{
save: Graph;
result: any;
result: unknown;
settings: {
types: Record<string, NodeInput>;
values: Record<string, unknown>;
};
}> {
status = $state<"loading" | "idle" | "error">();
status = $state<'loading' | 'idle' | 'error'>();
loaded = false;
graph: Graph = { id: 0, nodes: [], edges: [] };
@@ -78,7 +79,7 @@ export class GraphManager extends EventEmitter<{
currentUndoGroup: number | null = null;
inputSockets = $derived.by(() => {
const s = new Set<string>();
const s = new SvelteSet<string>();
for (const edge of this.edges) {
s.add(`${edge[2].id}-${edge[3]}`);
}
@@ -88,7 +89,7 @@ export class GraphManager extends EventEmitter<{
history: HistoryManager = new HistoryManager();
execute = throttle(() => {
if (this.loaded === false) return;
this.emit("result", this.serialize());
this.emit('result', this.serialize());
}, 10);
constructor(public registry: NodeRegistry) {
@@ -100,27 +101,28 @@ export class GraphManager extends EventEmitter<{
id: node.id,
position: [...node.position],
type: node.type,
props: node.props,
props: node.props
})) as NodeInstance[];
const edges = this.edges.map((edge) => [
edge[0].id,
edge[1],
edge[2].id,
edge[3],
]) as Graph["edges"];
edge[3]
]) as Graph['edges'];
const serialized = {
id: this.graph.id,
settings: $state.snapshot(this.settings),
meta: $state.snapshot(this.graph.meta),
nodes,
edges,
edges
};
logger.log("serializing graph", serialized);
logger.log('serializing graph', serialized);
return clone($state.snapshot(serialized));
}
private lastSettingsHash = 0;
setSettings(settings: Record<string, unknown>) {
let hash = fastHashString(JSON.stringify(settings));
const hash = fastHashString(JSON.stringify(settings));
if (hash === this.lastSettingsHash) return;
this.lastSettingsHash = hash;
@@ -134,7 +136,7 @@ export class GraphManager extends EventEmitter<{
}
getLinkedNodes(node: NodeInstance) {
const nodes = new Set<NodeInstance>();
const nodes = new SvelteSet<NodeInstance>();
const stack = [node];
while (stack.length) {
const n = stack.pop();
@@ -148,6 +150,97 @@ export class GraphManager extends EventEmitter<{
return [...nodes.values()];
}
getEdgeId(e: Edge) {
return `${e[0].id}-${e[1]}-${e[2].id}-${e[3]}`;
}
getEdgeById(id: string): Edge | undefined {
return this.edges.find((e) => this.getEdgeId(e) === id);
}
dropNodeOnEdge(nodeId: number, edge: Edge) {
const draggedNode = this.getNode(nodeId);
if (!draggedNode || !draggedNode.state?.type) return;
const [fromNode, fromSocketIdx, toNode, toSocketKey] = edge;
const draggedInputs = Object.entries(draggedNode.state.type.inputs ?? {});
const draggedOutputs = draggedNode.state.type.outputs ?? [];
const edgeOutputSocketType = fromNode.state?.type?.outputs?.[fromSocketIdx];
const targetInput = toNode.state?.type?.inputs?.[toSocketKey];
const targetAcceptedTypes = [targetInput?.type, ...(targetInput?.accepts || [])];
const bestInputEntry = draggedInputs.find(([, input]) => {
const accepted = [input.type, ...(input.accepts || [])];
return areSocketsCompatible(edgeOutputSocketType, accepted);
});
const bestOutputIdx = draggedOutputs.findIndex(outputType =>
areSocketsCompatible(outputType, targetAcceptedTypes)
);
if (!bestInputEntry || bestOutputIdx === -1) {
logger.error('Could not find compatible sockets for drop');
return;
}
this.startUndoGroup();
this.removeEdge(edge, { applyDeletion: false });
this.createEdge(fromNode, fromSocketIdx, draggedNode, bestInputEntry[0], {
applyUpdate: false
});
this.createEdge(draggedNode, bestOutputIdx, toNode, toSocketKey, {
applyUpdate: false
});
this.saveUndoGroup();
this.execute();
}
getPossibleDropOnEdges(nodeId: number): Edge[] {
const draggedNode = this.getNode(nodeId);
if (!draggedNode || !draggedNode.state?.type) return [];
const draggedInputs = Object.values(draggedNode.state.type.inputs ?? {});
const draggedOutputs = draggedNode.state.type.outputs ?? [];
// Optimization: Pre-calculate parents to avoid cycles
const parentIds = new SvelteSet(this.getParentsOfNode(draggedNode).map(n => n.id));
return this.edges.filter((edge) => {
const [fromNode, fromSocketIdx, toNode, toSocketKey] = edge;
// 1. Prevent cycles: If the target node is already a parent, we can't drop here
if (parentIds.has(toNode.id)) return false;
// 2. Prevent self-dropping: Don't drop on edges already connected to this node
if (fromNode.id === nodeId || toNode.id === nodeId) return false;
// 3. Check if edge.source can plug into ANY draggedNode.input
const edgeOutputSocketType = fromNode.state?.type?.outputs?.[fromSocketIdx];
const canPlugIntoDragged = draggedInputs.some(input => {
const acceptedTypes = [input.type, ...(input.accepts || [])];
return areSocketsCompatible(edgeOutputSocketType, acceptedTypes);
});
if (!canPlugIntoDragged) return false;
// 4. Check if ANY draggedNode.output can plug into edge.target
const targetInput = toNode.state?.type?.inputs?.[toSocketKey];
const targetAcceptedTypes = [targetInput?.type, ...(targetInput?.accepts || [])];
const draggedCanPlugIntoTarget = draggedOutputs.some(outputType =>
areSocketsCompatible(outputType, targetAcceptedTypes)
);
return draggedCanPlugIntoTarget;
});
}
getEdgesBetweenNodes(nodes: NodeInstance[]): [number, number, number, string][] {
const edges = [];
for (const node of nodes) {
@@ -155,14 +248,14 @@ export class GraphManager extends EventEmitter<{
for (const child of children) {
if (nodes.includes(child)) {
const edge = this.edges.find(
(e) => e[0].id === node.id && e[2].id === child.id,
(e) => e[0].id === node.id && e[2].id === child.id
);
if (edge) {
edges.push([edge[0].id, edge[1], edge[2].id, edge[3]] as [
number,
number,
number,
string,
string
]);
}
}
@@ -173,24 +266,24 @@ export class GraphManager extends EventEmitter<{
}
private _init(graph: Graph) {
const nodes = new Map(
const nodes = new SvelteMap(
graph.nodes.map((node) => {
const nodeType = this.registry.getNode(node.type);
const n = node as NodeInstance;
if (nodeType) {
n.state = {
type: nodeType,
type: nodeType
};
}
return [node.id, n];
}),
})
);
const edges = graph.edges.map((edge) => {
this.edges = graph.edges.map((edge) => {
const from = nodes.get(edge[0]);
const to = nodes.get(edge[2]);
if (!from || !to) {
throw new Error("Edge references non-existing node");
throw new Error('Edge references non-existing node');
}
from.state.children = from.state.children || [];
from.state.children.push(to);
@@ -199,8 +292,6 @@ export class GraphManager extends EventEmitter<{
return [from, edge[1], to, edge[3]] as Edge;
});
this.edges = [...edges];
this.nodes.clear();
for (const [id, node] of nodes) {
this.nodes.set(id, node);
@@ -214,21 +305,36 @@ export class GraphManager extends EventEmitter<{
this.loaded = false;
this.graph = graph;
this.status = "loading";
this.status = 'loading';
this.id = graph.id;
logger.info("loading graph", $state.snapshot(graph));
logger.info('loading graph', { nodes: graph.nodes, edges: graph.edges, id: graph.id });
const nodeIds = Array.from(new Set([...graph.nodes.map((n) => n.type)]));
const nodeIds = Array.from(new SvelteSet([...graph.nodes.map((n) => n.type)]));
await this.registry.load(nodeIds);
logger.info("loaded node types", this.registry.getAllNodes());
// Fetch all nodes from all collections of the loaded nodes
const allCollections = new SvelteSet<`${string}/${string}`>();
for (const id of nodeIds) {
const [user, collection] = id.split('/');
allCollections.add(`${user}/${collection}`);
}
for (const collection of allCollections) {
remoteRegistry
.fetchCollection(collection)
.then((collection: { nodes: { id: NodeId }[] }) => {
const ids = collection.nodes.map((n) => n.id);
return this.registry.load(ids);
});
}
logger.info('loaded node types', this.registry.getAllNodes());
for (const node of this.graph.nodes) {
const nodeType = this.registry.getNode(node.type);
if (!nodeType) {
logger.error(`Node type not found: ${node.type}`);
this.status = "error";
this.status = 'error';
return;
}
// Turn into runtime node
@@ -248,16 +354,16 @@ export class GraphManager extends EventEmitter<{
for (const type of types) {
if (type.inputs) {
for (const key in type.inputs) {
let settingId = type.inputs[key].setting;
const settingId = type.inputs[key].setting;
if (settingId) {
settingTypes[settingId] = {
__node_type: type.id,
__node_input: key,
...type.inputs[key],
...type.inputs[key]
};
if (
settingValues[settingId] === undefined &&
"value" in type.inputs[key]
settingValues[settingId] === undefined
&& 'value' in type.inputs[key]
) {
settingValues[settingId] = type.inputs[key].value;
}
@@ -267,14 +373,14 @@ export class GraphManager extends EventEmitter<{
}
this.settings = settingValues;
this.emit("settings", { types: settingTypes, values: settingValues });
this.emit('settings', { types: settingTypes, values: settingValues });
this.history.reset();
this._init(this.graph);
this.save();
this.status = "idle";
this.status = 'idle';
this.loaded = true;
logger.log(`Graph loaded in ${performance.now() - a}ms`);
@@ -303,13 +409,13 @@ export class GraphManager extends EventEmitter<{
const settingValues = this.settings;
if (nodeType.inputs) {
for (const key in nodeType.inputs) {
let settingId = nodeType.inputs[key].setting;
const settingId = nodeType.inputs[key].setting;
if (settingId) {
settingTypes[settingId] = nodeType.inputs[key];
if (
settingValues &&
settingValues?.[settingId] === undefined &&
"value" in nodeType.inputs[key]
settingValues
&& settingValues?.[settingId] === undefined
&& 'value' in nodeType.inputs[key]
) {
settingValues[settingId] = nodeType.inputs[key].value;
}
@@ -319,7 +425,7 @@ export class GraphManager extends EventEmitter<{
this.settings = settingValues;
this.settingTypes = settingTypes;
this.emit("settings", { types: settingTypes, values: settingValues });
this.emit('settings', { types: settingTypes, values: settingValues });
}
getChildren(node: NodeInstance) {
@@ -368,7 +474,7 @@ export class GraphManager extends EventEmitter<{
const inputType = to?.state?.type?.inputs?.[toSocket]?.type;
if (outputType === inputType) {
this.createEdge(from, fromSocket, to, toSocket, {
applyUpdate: false,
applyUpdate: false
});
continue;
}
@@ -401,9 +507,9 @@ export class GraphManager extends EventEmitter<{
createGraph(nodes: NodeInstance[], edges: [number, number, number, string][]) {
// map old ids to new ids
const idMap = new Map<number, number>();
const idMap = new SvelteMap<number, number>();
let startId = this.createNodeId()
let startId = this.createNodeId();
nodes = nodes.map((node) => {
const id = startId++;
@@ -420,7 +526,7 @@ export class GraphManager extends EventEmitter<{
const to = nodes.find((n) => n.id === idMap.get(edge[2]));
if (!from || !to) {
throw new Error("Edge references non-existing node");
throw new Error('Edge references non-existing node');
}
to.state.parents = to.state.parents || [];
@@ -445,11 +551,11 @@ export class GraphManager extends EventEmitter<{
createNode({
type,
position,
props = {},
props = {}
}: {
type: NodeInstance["type"];
position: NodeInstance["position"];
props: NodeInstance["props"];
type: NodeInstance['type'];
position: NodeInstance['position'];
props: NodeInstance['props'];
}) {
const nodeType = this.registry.getNode(type);
if (!nodeType) {
@@ -462,14 +568,14 @@ export class GraphManager extends EventEmitter<{
type,
position,
state: { type: nodeType },
props,
props
});
this.nodes.set(node.id, node);
this.save();
return node
return node;
}
createEdge(
@@ -477,17 +583,16 @@ export class GraphManager extends EventEmitter<{
fromSocket: number,
to: NodeInstance,
toSocket: string,
{ applyUpdate = true } = {},
{ applyUpdate = true } = {}
): Edge | undefined {
const existingEdges = this.getEdgesToNode(to);
// check if this exact edge already exists
const existingEdge = existingEdges.find(
(e) => e[0].id === from.id && e[1] === fromSocket && e[3] === toSocket,
(e) => e[0].id === from.id && e[1] === fromSocket && e[3] === toSocket
);
if (existingEdge) {
logger.error("Edge already exists", existingEdge);
logger.error('Edge already exists', existingEdge);
return;
}
@@ -500,13 +605,13 @@ export class GraphManager extends EventEmitter<{
if (!areSocketsCompatible(fromSocketType, toSocketType)) {
logger.error(
`Socket types do not match: ${fromSocketType} !== ${toSocketType}`,
`Socket types do not match: ${fromSocketType} !== ${toSocketType}`
);
return;
}
const edgeToBeReplaced = this.edges.find(
(e) => e[2].id === to.id && e[3] === toSocket,
(e) => e[2].id === to.id && e[3] === toSocket
);
if (edgeToBeReplaced) {
this.removeEdge(edgeToBeReplaced, { applyDeletion: false });
@@ -533,7 +638,7 @@ export class GraphManager extends EventEmitter<{
const nextState = this.history.undo();
if (nextState) {
this._init(nextState);
this.emit("save", this.serialize());
this.emit('save', this.serialize());
}
}
@@ -541,7 +646,7 @@ export class GraphManager extends EventEmitter<{
const nextState = this.history.redo();
if (nextState) {
this._init(nextState);
this.emit("save", this.serialize());
this.emit('save', this.serialize());
}
}
@@ -558,8 +663,15 @@ export class GraphManager extends EventEmitter<{
if (this.currentUndoGroup) return;
const state = this.serialize();
this.history.save(state);
this.emit("save", state);
logger.log("saving graphs", state);
// This is some stupid race condition where the graph-manager emits a save event
// when the graph is not fully loaded
if (this.nodes.size === 0 && this.edges.length === 0) {
return;
}
this.emit('save', state);
logger.log('saving graphs', state);
}
getParentsOfNode(node: NodeInstance) {
@@ -567,7 +679,7 @@ export class GraphManager extends EventEmitter<{
const stack = node.state?.parents?.slice(0);
while (stack?.length) {
if (parents.length > 1000000) {
logger.warn("Infinite loop detected");
logger.warn('Infinite loop detected');
break;
}
const parent = stack.pop();
@@ -586,26 +698,28 @@ export class GraphManager extends EventEmitter<{
return [];
}
const definitions = typeof socket.index === "string"
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"])
)
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
: 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][] {
@@ -615,11 +729,11 @@ export class GraphManager extends EventEmitter<{
const sockets: [NodeInstance, string | number][] = [];
// if index is a string, we are an input looking for outputs
if (typeof index === "string") {
if (typeof index === 'string') {
// filter out self and child nodes
const children = new Set(this.getChildren(node).map((n) => n.id));
const children = new SvelteSet(this.getChildren(node).map((n) => n.id));
const nodes = this.getAllNodes().filter(
(n) => n.id !== node.id && !children.has(n.id),
(n) => n.id !== node.id && !children.has(n.id)
);
const ownType = nodeType?.inputs?.[index].type;
@@ -634,20 +748,20 @@ export class GraphManager extends EventEmitter<{
}
}
}
} else if (typeof index === "number") {
} else if (typeof index === 'number') {
// if index is a number, we are an output looking for inputs
// filter out self and parent nodes
const parents = new Set(this.getParentsOfNode(node).map((n) => n.id));
const parents = new SvelteSet(this.getParentsOfNode(node).map((n) => n.id));
const nodes = this.getAllNodes().filter(
(n) => n.id !== node.id && !parents.has(n.id),
(n) => n.id !== node.id && !parents.has(n.id)
);
// get edges from this socket
const edges = new Map(
const edges = new SvelteMap(
this.getEdgesFromNode(node)
.filter((e) => e[1] === index)
.map((e) => [e[2].id, e[3]]),
.map((e) => [e[2].id, e[3]])
);
const ownType = nodeType.outputs?.[index];
@@ -660,8 +774,8 @@ export class GraphManager extends EventEmitter<{
otherType.push(...(inputs[key].accepts || []));
if (
areSocketsCompatible(ownType, otherType) &&
edges.get(node.id) !== key
areSocketsCompatible(ownType, otherType)
&& edges.get(node.id) !== key
) {
sockets.push([node, key]);
}
@@ -674,7 +788,7 @@ export class GraphManager extends EventEmitter<{
removeEdge(
edge: Edge,
{ applyDeletion = true }: { applyDeletion?: boolean } = {},
{ applyDeletion = true }: { applyDeletion?: boolean } = {}
) {
const id0 = edge[0].id;
const sid0 = edge[1];
@@ -682,21 +796,20 @@ export class GraphManager extends EventEmitter<{
const sid2 = edge[3];
const _edge = this.edges.find(
(e) =>
e[0].id === id0 && e[1] === sid0 && e[2].id === id2 && e[3] === sid2,
(e) => e[0].id === id0 && e[1] === sid0 && e[2].id === id2 && e[3] === sid2
);
if (!_edge) return;
if (edge[0].state.children) {
edge[0].state.children = edge[0].state.children.filter(
(n: NodeInstance) => n.id !== id2,
(n: NodeInstance) => n.id !== id2
);
}
if (edge[2].state.parents) {
edge[2].state.parents = edge[2].state.parents.filter(
(n: NodeInstance) => n.id !== id0,
(n: NodeInstance) => n.id !== id0
);
}
@@ -705,7 +818,6 @@ export class GraphManager extends EventEmitter<{
this.execute();
this.save();
}
}
getEdgesToNode(node: NodeInstance) {

View File

@@ -1,41 +1,70 @@
import type { NodeInstance, Socket } from "@nodarium/types";
import { getContext, setContext } from "svelte";
import { SvelteSet } from "svelte/reactivity";
import type { GraphManager } from "../graph-manager.svelte";
import type { OrthographicCamera } from "three";
import type { NodeInstance, Socket } from '@nodarium/types';
import { getContext, setContext } from 'svelte';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import type { OrthographicCamera, Vector3 } from 'three';
import type { GraphManager } from './graph-manager.svelte';
const graphStateKey = Symbol("graph-state");
const graphStateKey = Symbol('graph-state');
export function getGraphState() {
return getContext<GraphState>(graphStateKey);
}
export function setGraphState(graphState: GraphState) {
return setContext(graphStateKey, graphState)
return setContext(graphStateKey, graphState);
}
const graphManagerKey = Symbol("graph-manager");
const graphManagerKey = Symbol('graph-manager');
export function getGraphManager() {
return getContext<GraphManager>(graphManagerKey)
return getContext<GraphManager>(graphManagerKey);
}
export function setGraphManager(manager: GraphManager) {
return setContext(graphManagerKey, manager);
}
export class GraphState {
type EdgeData = {
x1: number;
y1: number;
points: Vector3[];
};
constructor(private graph: GraphManager) { }
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);
hoveredEdgeId = $state<string | null>(null);
edges = new SvelteMap<string, EdgeData>();
wrapper = $state<HTMLDivElement>(null!);
rect: DOMRect = $derived(
(this.wrapper && this.width && this.height) ? this.wrapper.getBoundingClientRect() : new DOMRect(0, 0, 0, 0),
(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]);
cameraPosition: [number, number, number] = $state([140, 100, 3.5]);
clipboard: null | {
nodes: NodeInstance[];
@@ -46,7 +75,7 @@ export class GraphState {
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,
this.cameraPosition[1] + this.height / this.cameraPosition[2] / 2
]);
boxSelection = $state(false);
@@ -54,8 +83,8 @@ export class GraphState {
addMenuPosition = $state<[number, number] | null>(null);
snapToGrid = $state(false);
showGrid = $state(true)
showHelp = $state(false)
showGrid = $state(true);
showHelp = $state(false);
cameraDown = [0, 0];
mouseDownNodeId = -1;
@@ -71,51 +100,49 @@ export class GraphState {
hoveredSocket = $state<Socket | null>(null);
possibleSockets = $state<Socket[]>([]);
possibleSocketIds = $derived(
new Set(this.possibleSockets.map((s) => `${s.node.id}-${s.index}`)),
new SvelteSet(this.possibleSockets.map((s) => `${s.node.id}-${s.index}`))
);
getEdges() {
return $state.snapshot(this.edges);
}
clearSelection() {
this.selectedNodes.clear();
}
isBodyFocused = () => document?.activeElement?.nodeName !== "INPUT";
isBodyFocused = () => document?.activeElement?.nodeName !== 'INPUT';
setCameraTransform(
x = this.cameraPosition[0],
y = this.cameraPosition[1],
z = this.cameraPosition[2],
) {
if (this.camera) {
this.camera.position.x = x;
this.camera.position.z = y;
this.camera.zoom = z;
}
this.cameraPosition = [x, y, z];
localStorage.setItem("cameraPosition", JSON.stringify(this.cameraPosition));
setEdgeGeometry(edgeId: string, x1: number, y1: number, points: Vector3[]) {
this.edges.set(edgeId, { x1, y1, points });
}
removeEdgeGeometry(edgeId: string) {
this.edges.delete(edgeId);
}
getEdgeData() {
return this.edges;
}
updateNodePosition(node: NodeInstance) {
if (node.state.ref && node.state.mesh) {
if (node.state["x"] !== undefined && node.state["y"] !== undefined) {
node.state.ref.style.setProperty("--nx", `${node.state.x * 10}px`);
node.state.ref.style.setProperty("--ny", `${node.state.y * 10}px`);
node.state.mesh.position.x = node.state.x + 10;
node.state.mesh.position.z = node.state.y + this.getNodeHeight(node.type) / 2;
if (
node.state.x === node.position[0] &&
node.state.y === node.position[1]
) {
delete node.state.x;
delete node.state.y;
}
this.graph.edges = [...this.graph.edges];
} else {
node.state.ref.style.setProperty("--nx", `${node.position[0] * 10}px`);
node.state.ref.style.setProperty("--ny", `${node.position[1] * 10}px`);
node.state.mesh.position.x = node.position[0] + 10;
node.state.mesh.position.z =
node.position[1] + this.getNodeHeight(node.type) / 2;
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`);
}
}
}
@@ -128,25 +155,24 @@ export class GraphState {
return 4;
} else if (z > 11) {
return 2;
} else {
}
return 1;
}
getSocketPosition(
node: NodeInstance,
index: string | number,
index: string | number
): [number, number] {
if (typeof index === "number") {
if (typeof index === 'number') {
return [
(node?.state?.x ?? node.position[0]) + 20,
(node?.state?.y ?? node.position[1]) + 2.5 + 10 * index,
(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,
(node?.state?.y ?? node.position[1]) + 10 + 10 * _index
];
}
}
@@ -160,26 +186,26 @@ export class GraphState {
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;
const height = 5
+ 10
* Object.keys(node.inputs).filter(
(p) =>
p !== 'seed'
&& node?.inputs
&& !(node?.inputs?.[p] !== undefined && '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)
if (this.activeNodeId === -1 && !this.selectedNodes?.size) {
return;
}
let nodes = [
this.activeNodeId,
...(this.selectedNodes?.values() || []),
...(this.selectedNodes?.values() || [])
]
.map((id) => this.graph.getNode(id))
.filter(b => !!b);
@@ -189,14 +215,14 @@ export class GraphState {
...node,
position: [
this.mousePosition[0] - node.position[0],
this.mousePosition[1] - node.position[1],
this.mousePosition[1] - node.position[1]
],
tmp: undefined,
tmp: undefined
}));
this.clipboard = {
nodes: nodes,
edges: edges,
edges: edges
};
}
@@ -218,14 +244,13 @@ export class GraphState {
}
}
setDownSocket(socket: Socket) {
this.activeSocket = socket;
let { node, index, position } = socket;
// remove existing edge
if (typeof index === "string") {
if (typeof index === 'string') {
const edges = this.graph.getEdgesToNode(node);
for (const edge of edges) {
if (edge[3] === index) {
@@ -242,7 +267,7 @@ export class GraphState {
this.activeSocket = {
node,
index,
position,
position
};
this.possibleSockets = this.graph
@@ -251,32 +276,31 @@ export class GraphState {
return {
node,
index,
position: this.getSocketPosition(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],
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;
const mx = event.clientX - this.rect.x;
const 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");
const nodeElement = event.target.closest('.node');
const nodeId = nodeElement?.getAttribute?.('data-node-id');
if (nodeId) {
clickedNodeId = parseInt(nodeId, 10);
}
@@ -304,10 +328,14 @@ export class GraphState {
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]
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]
);
};
}
openNodePalette() {
this.addMenuPosition = [this.mousePosition[0], this.mousePosition[1]];
}
}

View File

@@ -1,22 +1,23 @@
<script lang="ts">
import type { Edge, NodeInstance } from "@nodarium/types";
import { onMount } from "svelte";
import { createKeyMap } from "../../helpers/createKeyMap";
import AddMenu from "../components/AddMenu.svelte";
import Background from "../background/Background.svelte";
import BoxSelection from "../components/BoxSelection.svelte";
import EdgeEl from "../edges/Edge.svelte";
import NodeEl from "../node/Node.svelte";
import Camera from "../components/Camera.svelte";
import { Canvas } from "@threlte/core";
import HelpView from "../components/HelpView.svelte";
import { getGraphManager, getGraphState } from "./state.svelte";
import { HTML } from "@threlte/extras";
import { FileDropEventManager, MouseEventManager } from "./events";
import { maxZoom, minZoom } from "./constants";
import type { Edge, NodeInstance } from '@nodarium/types';
import { Canvas } from '@threlte/core';
import { HTML } from '@threlte/extras';
import { createKeyMap } from '../../helpers/createKeyMap';
import Background from '../background/Background.svelte';
import AddMenu from '../components/AddMenu.svelte';
import BoxSelection from '../components/BoxSelection.svelte';
import Camera from '../components/Camera.svelte';
import HelpView from '../components/HelpView.svelte';
import Debug from '../debug/Debug.svelte';
import EdgeEl from '../edges/Edge.svelte';
import { getGraphManager, getGraphState } from '../graph-state.svelte';
import NodeEl from '../node/Node.svelte';
import { maxZoom, minZoom } from './constants';
import { FileDropEventManager } from './drop.events';
import { MouseEventManager } from './mouse.events';
const {
keymap,
keymap
}: {
keymap: ReturnType<typeof createKeyMap>;
} = $props();
@@ -44,19 +45,18 @@
const newNode = graph.createNode({
type: node.type,
position: node.position,
props: node.props,
props: node.props
});
if (!newNode) return;
if (graphState.activeSocket) {
if (typeof graphState.activeSocket.index === "number") {
const socketType =
graphState.activeSocket.node.state?.type?.outputs?.[
graphState.activeSocket.index
];
if (typeof graphState.activeSocket.index === 'number') {
const socketType = graphState.activeSocket.node.state?.type?.outputs?.[
graphState.activeSocket.index
];
const input = Object.entries(newNode?.state?.type?.inputs || {}).find(
(inp) => inp[1].type === socketType,
(inp) => inp[1].type === socketType
);
if (input) {
@@ -64,18 +64,17 @@
graphState.activeSocket.node,
graphState.activeSocket.index,
newNode,
input[0],
input[0]
);
}
} else {
const socketType =
graphState.activeSocket.node.state?.type?.inputs?.[
graphState.activeSocket.index
];
const socketType = graphState.activeSocket.node.state?.type?.inputs?.[
graphState.activeSocket.index
];
const output = newNode.state?.type?.outputs?.find((out) => {
if (socketType?.type === out) return true;
if (socketType?.accepts?.includes(out as any)) return true;
if ((socketType?.accepts as string[])?.includes(out)) return true;
return false;
});
@@ -84,7 +83,7 @@
newNode,
output.indexOf(output),
graphState.activeSocket.node,
graphState.activeSocket.index,
graphState.activeSocket.index
);
}
}
@@ -93,26 +92,18 @@
graphState.activeSocket = null;
graphState.addMenuPosition = null;
}
onMount(() => {
if (localStorage.getItem("cameraPosition")) {
const cPosition = JSON.parse(localStorage.getItem("cameraPosition")!);
if (Array.isArray(cPosition)) {
graphState.setCameraTransform(cPosition[0], cPosition[1], cPosition[2]);
}
}
});
</script>
<svelte:window
onmousemove={(ev) => mouseEvents.handleMouseMove(ev)}
onmouseup={(ev) => mouseEvents.handleMouseUp(ev)}
onmousemove={(ev) => mouseEvents.handleWindowMouseMove(ev)}
onmouseup={(ev) => mouseEvents.handleWindowMouseUp(ev)}
/>
<div
onwheel={(ev) => mouseEvents.handleMouseScroll(ev)}
bind:this={graphState.wrapper}
class="graph-wrapper"
style="height: 100%"
class:is-panning={graphState.isPanning}
class:is-hovering={graphState.hoveredNodeId !== -1}
aria-label="Graph"
@@ -122,6 +113,7 @@
bind:clientHeight={graphState.height}
onkeydown={(ev) => keymap.handleKeyboardEvent(ev)}
onmousedown={(ev) => mouseEvents.handleMouseDown(ev)}
oncontextmenu={(ev) => mouseEvents.handleContextMenu(ev)}
{...fileDropEvents.getEventListenerProps()}
>
<input
@@ -154,20 +146,18 @@
<BoxSelection
cameraPosition={graphState.cameraPosition}
p1={{
x:
graphState.cameraPosition[0] +
(graphState.mouseDown[0] - graphState.width / 2) /
graphState.cameraPosition[2],
y:
graphState.cameraPosition[1] +
(graphState.mouseDown[1] - graphState.height / 2) /
graphState.cameraPosition[2],
x: graphState.cameraPosition[0]
+ (graphState.mouseDown[0] - graphState.width / 2)
/ graphState.cameraPosition[2],
y: graphState.cameraPosition[1]
+ (graphState.mouseDown[1] - graphState.height / 2)
/ graphState.cameraPosition[2]
}}
p2={{ x: graphState.mousePosition[0], y: graphState.mousePosition[1] }}
/>
{/if}
{#if graph.status === "idle"}
{#if graph.status === 'idle'}
{#if graphState.addMenuPosition}
<AddMenu onnode={handleNodeCreation} />
{/if}
@@ -182,11 +172,20 @@
/>
{/if}
{#each graph.edges as edge}
{#each graph.edges as edge (edge)}
{@const [x1, y1, x2, y2] = getEdgePosition(edge)}
<EdgeEl z={graphState.cameraPosition[2]} {x1} {y1} {x2} {y2} />
<EdgeEl
id={graph.getEdgeId(edge)}
z={graphState.cameraPosition[2]}
{x1}
{y1}
{x2}
{y2}
/>
{/each}
<Debug />
<HTML transform={false}>
<div
role="tree"
@@ -205,9 +204,9 @@
{/each}
</div>
</HTML>
{:else if graph.status === "loading"}
{:else if graph.status === 'loading'}
<span>Loading</span>
{:else if graph.status === "error"}
{:else if graph.status === 'error'}
<span>Error</span>
{/if}
</Canvas>
@@ -245,7 +244,7 @@
z-index: 1;
width: 100%;
height: 100%;
background: var(--layer-2);
background: var(--color-layer-2);
opacity: 0;
}
input:disabled {
@@ -265,8 +264,8 @@
border-radius: 5px;
width: calc(100% - 20px);
height: calc(100% - 25px);
border: dashed 4px var(--layer-2);
background: var(--layer-1);
border: dashed 4px var(--color-layer-2);
background: var(--color-layer-1);
opacity: 0.5;
}
</style>

View File

@@ -1,25 +1,25 @@
<script lang="ts">
import type { Graph, NodeInstance, NodeRegistry } from "@nodarium/types";
import GraphEl from "./Graph.svelte";
import { GraphManager } from "../graph-manager.svelte";
import { createKeyMap } from "$lib/helpers/createKeyMap";
import { GraphState, setGraphManager, setGraphState } from "./state.svelte";
import { setupKeymaps } from "../keymaps";
import { createKeyMap } from '$lib/helpers/createKeyMap';
import type { Graph, NodeInstance, NodeRegistry } from '@nodarium/types';
import { GraphManager } from '../graph-manager.svelte';
import { GraphState, setGraphManager, setGraphState } from '../graph-state.svelte';
import { setupKeymaps } from '../keymaps';
import GraphEl from './Graph.svelte';
type Props = {
graph: Graph;
graph?: Graph;
registry: NodeRegistry;
settings?: Record<string, any>;
settings?: Record<string, unknown>;
activeNode?: NodeInstance;
showGrid?: boolean;
snapToGrid?: boolean;
showHelp?: boolean;
settingTypes?: Record<string, any>;
settingTypes?: Record<string, unknown>;
onsave?: (save: Graph) => void;
onresult?: (result: any) => void;
onresult?: (result: unknown) => void;
};
let {
@@ -32,11 +32,12 @@
showHelp = $bindable(false),
settingTypes = $bindable(),
onsave,
onresult,
onresult
}: Props = $props();
export const keymap = createKeyMap([]);
// svelte-ignore state_referenced_locally
export const manager = new GraphManager(registry);
setGraphManager(manager);
@@ -66,22 +67,20 @@
}
});
$effect(() => {
if (settingTypes && settings) {
manager.setSettings(settings);
}
});
manager.on("settings", (_settings) => {
manager.on('settings', (_settings) => {
settingTypes = { ...settingTypes, ..._settings.types };
settings = _settings.values;
});
manager.on("result", (result) => onresult?.(result));
manager.on('result', (result) => onresult?.(result));
manager.on("save", (save) => onsave?.(save));
manager.on('save', (save) => onsave?.(save));
manager.load(graph);
$effect(() => {
if (graph) {
manager.load(graph);
}
});
</script>
<GraphEl {keymap} />

View File

@@ -1,33 +1,33 @@
import { appSettings } from "$lib/settings/app-settings.svelte";
import { Color, LinearSRGBColorSpace } from "three";
import { appSettings } from '$lib/settings/app-settings.svelte';
import { Color, LinearSRGBColorSpace } from 'three';
const variables = [
"layer-0",
"layer-1",
"layer-2",
"layer-3",
"outline",
"active",
"selected",
"edge",
'layer-0',
'layer-1',
'layer-2',
'layer-3',
'outline',
'active',
'selected',
'edge'
] as const;
function getColor(variable: (typeof variables)[number]) {
const style = getComputedStyle(document.body.parentElement!);
let color = style.getPropertyValue(`--${variable}`);
const color = style.getPropertyValue(`--color-${variable}`);
return new Color().setStyle(color, LinearSRGBColorSpace);
}
export const colors = Object.fromEntries(
variables.map((v) => [v, getColor(v)]),
variables.map((v) => [v, getColor(v)])
) as Record<(typeof variables)[number], Color>;
$effect.root(() => {
$effect(() => {
if (!appSettings.value.theme || !("getComputedStyle" in globalThis)) return;
if (!appSettings.value.theme || !('getComputedStyle' in globalThis)) return;
const style = getComputedStyle(document.body.parentElement!);
for (const v of variables) {
const hex = style.getPropertyValue(`--${v}`);
const hex = style.getPropertyValue(`--color-${v}`);
colors[v].setStyle(hex, LinearSRGBColorSpace);
}
});

View File

@@ -0,0 +1,109 @@
import { GraphSchema, type NodeId } from '@nodarium/types';
import type { GraphManager } from '../graph-manager.svelte';
import type { GraphState } from '../graph-state.svelte';
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) {
const nodeOffsetX = event.dataTransfer.getData('data/node-offset-x');
const nodeOffsetY = event.dataTransfer.getData('data/node-offset-y');
if (nodeOffsetX && nodeOffsetY) {
mx += parseInt(nodeOffsetX);
my += parseInt(nodeOffsetY);
}
let props = {};
const rawNodeProps = event.dataTransfer.getData('data/node-props');
if (rawNodeProps) {
try {
props = JSON.parse(rawNodeProps);
} catch (e) {
console.error('Failed to parse node dropped', 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(nodeId, 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()
};
}
}

View File

@@ -0,0 +1,117 @@
import type { Box } from '@nodarium/types';
import type { GraphManager } from '../graph-manager.svelte';
import type { GraphState } from '../graph-state.svelte';
import { distanceFromPointToSegment } from '../helpers';
export class EdgeInteractionManager {
constructor(
private graph: GraphManager,
private state: GraphState
) {}
private MIN_DISTANCE = 3;
private _boundingBoxes = new Map<string, Box>();
handleMouseDown() {
this._boundingBoxes.clear();
const possibleEdges = this.graph
.getPossibleDropOnEdges(this.state.activeNodeId)
.map(e => this.graph.getEdgeId(e));
const edges = this.state.getEdges();
for (const edge of edges) {
const edgeId = edge[0];
if (!possibleEdges.includes(edgeId)) {
edges.delete(edgeId);
}
}
for (const [edgeId, data] of edges) {
const points = data.points;
let minX = points[0].x + data.x1;
let maxX = points[0].x + data.x1;
let minY = points[0].z + data.y1;
let maxY = points[0].z + data.y1;
// Iterate through all points to find the true bounds
for (let i = 0; i < points.length; i++) {
const x = data.x1 + points[i].x;
const y = data.y1 + points[i].z;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
const boundingBox = {
minX: minX - this.MIN_DISTANCE,
maxX: maxX + this.MIN_DISTANCE,
minY: minY - this.MIN_DISTANCE,
maxY: maxY + this.MIN_DISTANCE
};
this._boundingBoxes.set(edgeId, boundingBox);
}
}
handleMouseMove() {
const [mouseX, mouseY] = this.state.mousePosition;
const hoveredEdgeIds: string[] = [];
const edges = this.state.getEdges();
// Check if mouse is inside any bounding box
for (const [edgeId, box] of this._boundingBoxes) {
const isInside = mouseX >= box.minX
&& mouseX <= box.maxX
&& mouseY >= box.minY
&& mouseY <= box.maxY;
if (isInside) {
hoveredEdgeIds.push(edgeId);
}
}
let hoveredEdgeId: string | null = null;
let hoveredEdgeDistance = Infinity;
const DENSITY = 10; // higher DENSITY = less points checked (yes density might not be the best name :-)
for (const edgeId of hoveredEdgeIds) {
const edge = edges.get(edgeId)!;
for (let i = 0; i < edge.points.length - DENSITY; i += DENSITY) {
const pointAx = edge.points[i].x + edge.x1;
const pointAy = edge.points[i].z + edge.y1;
const pointBx = edge.points[i + DENSITY].x + edge.x1;
const pointBy = edge.points[i + DENSITY].z + edge.y1;
const distance = distanceFromPointToSegment(
pointAx,
pointAy,
pointBx,
pointBy,
mouseX,
mouseY
);
if (distance < this.MIN_DISTANCE) {
if (distance < hoveredEdgeDistance) {
hoveredEdgeDistance = distance;
hoveredEdgeId = edgeId;
}
}
}
}
this.state.hoveredEdgeId = hoveredEdgeId;
}
handleMouseUp() {
if (this.state.hoveredEdgeId) {
const edge = this.graph.getEdgeById(this.state.hoveredEdgeId);
if (edge) {
this.graph.dropNodeOnEdge(this.state.activeNodeId, edge);
}
this.state.hoveredEdgeId = null;
}
}
}

View File

@@ -1,127 +1,23 @@
import { GraphSchema, type NodeId, type NodeInstance } from "@nodarium/types";
import type { GraphManager } from "../graph-manager.svelte";
import type { GraphState } from "./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(),
}
}
}
import { animate, lerp } from '$lib/helpers';
import { type NodeInstance } from '@nodarium/types';
import type { GraphManager } from '../graph-manager.svelte';
import { type GraphState } from '../graph-state.svelte';
import { snapToGrid as snapPointToGrid } from '../helpers';
import { maxZoom, minZoom, zoomSpeed } from './constants';
import { EdgeInteractionManager } from './edge.events';
export class MouseEventManager {
edgeInteractionManager: EdgeInteractionManager;
constructor(
private graph: GraphManager,
private state: GraphState
) { }
) {
this.edgeInteractionManager = new EdgeInteractionManager(graph, state);
}
handleMouseUp(event: MouseEvent) {
handleWindowMouseUp(event: MouseEvent) {
this.edgeInteractionManager.handleMouseUp();
this.state.isPanning = false;
if (!this.state.mouseDown) return;
@@ -145,25 +41,23 @@ export class MouseEventManager {
const snapLevel = this.state.getSnapLevel();
activeNode.position[0] = snapPointToGrid(
activeNode?.state?.x ?? activeNode.position[0],
5 / snapLevel,
5 / snapLevel
);
activeNode.position[1] = snapPointToGrid(
activeNode?.state?.y ?? activeNode.position[1],
5 / snapLevel,
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),
),
...[...(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),
activeNode.position[1] - (activeNode?.state.y || 0)
];
for (const node of nodes) {
@@ -179,9 +73,9 @@ export class MouseEventManager {
animate(500, (a: number) => {
for (const node of nodes) {
if (
node?.state &&
node.state["x"] !== undefined &&
node.state["y"] !== undefined
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);
@@ -195,24 +89,24 @@ export class MouseEventManager {
this.graph.save();
} else if (this.state.hoveredSocket && this.state.activeSocket) {
if (
typeof this.state.hoveredSocket.index === "number" &&
typeof this.state.activeSocket.index === "string"
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,
this.state.activeSocket.index
);
} else if (
typeof this.state.activeSocket.index == "number" &&
typeof this.state.hoveredSocket.index === "string"
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.state.hoveredSocket.index
);
}
this.graph.save();
@@ -220,18 +114,18 @@ export class MouseEventManager {
// Handle automatic adding of nodes on ctrl+mouseUp
this.state.edgeEndPosition = [
this.state.mousePosition[0],
this.state.mousePosition[1],
this.state.mousePosition[1]
];
if (typeof this.state.activeSocket.index === "number") {
if (typeof this.state.activeSocket.index === 'number') {
this.state.addMenuPosition = [
this.state.mousePosition[0],
this.state.mousePosition[1] - 25 / this.state.cameraPosition[2],
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],
this.state.mousePosition[1] - 25 / this.state.cameraPosition[2]
];
}
return;
@@ -239,11 +133,11 @@ export class MouseEventManager {
// 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()
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();
@@ -257,23 +151,34 @@ export class MouseEventManager {
this.state.addMenuPosition = null;
}
handleContextMenu(event: MouseEvent) {
if (!this.state.addMenuPosition) {
event.preventDefault();
this.state.openNodePalette();
}
}
handleMouseDown(event: MouseEvent) {
// Right click
if (event.button === 2) {
return;
}
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")
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;
const mx = event.clientX - this.state.rect.x;
const my = event.clientY - this.state.rect.y;
this.state.mouseDown = [mx, my];
this.state.cameraDown[0] = this.state.cameraPosition[0];
@@ -288,7 +193,7 @@ export class MouseEventManager {
this.state.activeNodeId = clickedNodeId;
// if the selected node is the same as the clicked node
} else if (this.state.activeNodeId === clickedNodeId) {
//$activeNodeId = -1;
// $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);
@@ -312,6 +217,7 @@ export class MouseEventManager {
this.state.activeNodeId = clickedNodeId;
this.state.clearSelection();
}
this.edgeInteractionManager.handleMouseDown();
} else if (event.ctrlKey) {
this.state.boxSelection = true;
}
@@ -335,10 +241,9 @@ export class MouseEventManager {
this.state.edgeEndPosition = null;
}
handleMouseMove(event: MouseEvent) {
let mx = event.clientX - this.state.rect.x;
let my = event.clientY - this.state.rect.y;
handleWindowMouseMove(event: MouseEvent) {
const mx = event.clientX - this.state.rect.x;
const my = event.clientY - this.state.rect.y;
this.state.mousePosition = this.state.projectScreenToWorld(mx, my);
this.state.hoveredNodeId = this.state.getNodeIdFromEvent(event);
@@ -351,8 +256,8 @@ export class MouseEventManager {
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,
(socket.position[0] - this.state.mousePosition[0]) ** 2
+ (socket.position[1] - this.state.mousePosition[1]) ** 2
);
if (dist < smallestDist) {
smallestDist = dist;
@@ -375,7 +280,7 @@ export class MouseEventManager {
event.stopPropagation();
const mouseD = this.state.projectScreenToWorld(
this.state.mouseDown[0],
this.state.mouseDown[1],
this.state.mouseDown[1]
);
const x1 = Math.min(mouseD[0], this.state.mousePosition[0]);
const x2 = Math.max(mouseD[0], this.state.mousePosition[0]);
@@ -397,6 +302,7 @@ export class MouseEventManager {
// 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;
@@ -405,10 +311,8 @@ export class MouseEventManager {
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];
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();
@@ -448,22 +352,19 @@ export class MouseEventManager {
// 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];
const newX = this.state.cameraDown[0]
- (mx - this.state.mouseDown[0]) / this.state.cameraPosition[2];
const newY = this.state.cameraDown[1]
- (my - this.state.mouseDown[1]) / this.state.cameraPosition[2];
this.state.setCameraTransform(newX, newY);
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";
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
@@ -478,23 +379,20 @@ export class MouseEventManager {
maxZoom,
isNegative
? this.state.cameraPosition[2] / delta
: 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.setCameraTransform(
this.state.mousePosition[0] -
(this.state.mousePosition[0] - this.state.cameraPosition[0]) /
zoomRatio,
this.state.mousePosition[1] -
(this.state.mousePosition[1] - this.state.cameraPosition[1]) /
zoomRatio,
newZoom,
);
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,11 +1,11 @@
import throttle from "$lib/helpers/throttle";
import throttle from '$lib/helpers/throttle';
type EventMap = Record<string, unknown>;
type EventKey<T extends EventMap> = string & keyof T;
type EventReceiver<T> = (params: T, stuff?: Record<string, unknown>) => unknown;
export default class EventEmitter<
T extends EventMap = { [key: string]: unknown },
T extends EventMap = { [key: string]: unknown }
> {
index = 0;
public eventMap: T = {} as T;
@@ -32,11 +32,11 @@ export default class EventEmitter<
public on<K extends EventKey<T>>(
event: K,
cb: EventReceiver<T[K]>,
throttleTimer = 0,
throttleTimer = 0
) {
if (throttleTimer > 0) cb = throttle(cb, throttleTimer);
const cbs = Object.assign(this.cbs, {
[event]: [...(this.cbs[event] || []), cb],
[event]: [...(this.cbs[event] || []), cb]
});
this.cbs = cbs;
@@ -54,10 +54,10 @@ export default class EventEmitter<
*/
public once<K extends EventKey<T>>(
event: K,
cb: EventReceiver<T[K]>,
cb: EventReceiver<T[K]>
): () => void {
const cbsOnce = Object.assign(this.cbsOnce, {
[event]: [...(this.cbsOnce[event] || []), cb],
[event]: [...(this.cbsOnce[event] || []), cb]
});
this.cbsOnce = cbsOnce;

View File

@@ -8,7 +8,7 @@ export function lerp(a: number, b: number, t: number) {
export function animate(
duration: number,
callback: (progress: number) => void | false,
callback: (progress: number) => void | false
) {
const start = performance.now();
const loop = (time: number) => {
@@ -33,59 +33,52 @@ export function createNodePath({
cornerBottom = 0,
leftBump = false,
rightBump = false,
aspectRatio = 1,
aspectRatio = 1
} = {}) {
return `M0,${cornerTop}
${
cornerTop
? ` V${cornerTop}
cornerTop
? ` V${cornerTop}
Q0,0 ${cornerTop * aspectRatio},0
H${100 - cornerTop * aspectRatio}
Q100,0 100,${cornerTop}
`
: ` V0
: ` V0
H100
`
}
}
V${y - height / 2}
${
rightBump
? ` C${100 - depth},${y - height / 2} ${100 - depth},${y + height / 2} 100,${y + height / 2}`
: ` H100`
}
rightBump
? ` C${100 - depth},${y - height / 2} ${100 - depth},${y + height / 2} 100,${y + height / 2}`
: ` H100`
}
${
cornerBottom
? ` V${100 - cornerBottom}
cornerBottom
? ` V${100 - cornerBottom}
Q100,100 ${100 - cornerBottom * aspectRatio},100
H${cornerBottom * aspectRatio}
Q0,100 0,${100 - cornerBottom}
`
: `${leftBump ? `V100 H0` : `V100`}`
}
: `${leftBump ? `V100 H0` : `V100`}`
}
${
leftBump
? ` V${y + height / 2} C${depth},${y + height / 2} ${depth},${y - height / 2} 0,${y - height / 2}`
: ` H0`
}
Z`.replace(/\s+/g, " ");
leftBump
? ` V${y + height / 2} C${depth},${y + height / 2} ${depth},${y - height / 2} 0,${
y - height / 2
}`
: ` H0`
}
Z`.replace(/\s+/g, ' ');
}
export const debounce = (fn: Function, ms = 300) => {
let timeoutId: ReturnType<typeof setTimeout>;
return function (this: any, ...args: any[]) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn.apply(this, args), ms);
};
};
export const clone: <T>(v: T) => T = 'structedClone' in globalThis
? globalThis.structuredClone
: (obj) => JSON.parse(JSON.stringify(obj));
export const clone: <T>(v: T) => T =
"structedClone" in globalThis
? globalThis.structuredClone
: (obj) => JSON.parse(JSON.stringify(obj));
export function withSubComponents<A, B extends Record<string, any>>(
export function withSubComponents<A, B extends Record<string, unknown>>(
component: A,
subcomponents: B,
subcomponents: B
): A & B {
Object.keys(subcomponents).forEach((key) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -93,3 +86,27 @@ export function withSubComponents<A, B extends Record<string, any>>(
});
return component as A & B;
}
export function distanceFromPointToSegment(
x1: number,
y1: number,
x2: number,
y2: number,
x0: number,
y0: number
): number {
const dx = x2 - x1;
const dy = y2 - y1;
if (dx === 0 && dy === 0) {
return Math.hypot(x0 - x1, y0 - y1);
}
const t = ((x0 - x1) * dx + (y0 - y1) * dy) / (dx * dx + dy * dy);
const clampedT = Math.max(0, Math.min(1, t));
const px = x1 + clampedT * dx;
const py = y1 + clampedT * dy;
return Math.hypot(x0 - px, y0 - py);
}

View File

@@ -1,15 +1,14 @@
import { writable, type Writable } from "svelte/store";
import { type Writable, writable } from 'svelte/store';
function isStore(v: unknown): v is Writable<unknown> {
return v !== null && typeof v === "object" && "subscribe" in v && "set" in v;
return v !== null && typeof v === 'object' && 'subscribe' in v && 'set' in v;
}
const storeIds: Map<string, ReturnType<typeof createLocalStore>> = new Map();
const HAS_LOCALSTORAGE = "localStorage" in globalThis;
const HAS_LOCALSTORAGE = 'localStorage' in globalThis;
function createLocalStore<T>(key: string, initialValue: T | Writable<T>) {
let store: Writable<T>;
if (HAS_LOCALSTORAGE) {
@@ -36,18 +35,15 @@ function createLocalStore<T>(key: string, initialValue: T | Writable<T>) {
subscribe: store.subscribe,
set: store.set,
update: store.update
}
};
}
export default function localStore<T>(key: string, initialValue: T | Writable<T>): Writable<T> {
if (storeIds.has(key)) return storeIds.get(key) as Writable<T>;
const store = createLocalStore(key, initialValue)
const store = createLocalStore(key, initialValue);
storeIds.set(key, store);
return store
return store;
}

View File

@@ -1,21 +1,21 @@
import { create, type Delta } from "jsondiffpatch";
import type { Graph } from "@nodarium/types";
import { clone } from "./helpers/index.js";
import { createLogger } from "@nodarium/utils";
import type { Graph } from '@nodarium/types';
import { createLogger } from '@nodarium/utils';
import { create, type Delta } from 'jsondiffpatch';
import { clone } from './helpers/index.js';
const diff = create({
objectHash: function (obj, index) {
objectHash: function(obj, index) {
if (obj === null) return obj;
if ("id" in obj) return obj.id as string;
if ("_id" in obj) return obj._id as string;
if ('id' in obj) return obj.id as string;
if ('_id' in obj) return obj._id as string;
if (Array.isArray(obj)) {
return obj.join("-");
return obj.join('-');
}
return "$$index:" + index;
},
return '$$index:' + index;
}
});
const log = createLogger("history");
const log = createLogger('history');
log.mute();
export class HistoryManager {
@@ -26,7 +26,7 @@ export class HistoryManager {
private opts = {
debounce: 400,
maxHistory: 100,
maxHistory: 100
};
constructor({ maxHistory = 100, debounce = 100 } = {}) {
@@ -40,12 +40,12 @@ export class HistoryManager {
if (!this.state) {
this.state = clone(state);
this.initialState = this.state;
log.log("initial state saved");
log.log('initial state saved');
} else {
const newState = state;
const delta = diff.diff(this.state, newState);
if (delta) {
log.log("saving state");
log.log('saving state');
// Add the delta to history
if (this.index < this.history.length - 1) {
// Clear the history after the current index if new changes are made
@@ -61,7 +61,7 @@ export class HistoryManager {
}
this.state = newState;
} else {
log.log("no changes");
log.log('no changes');
}
}
}
@@ -75,7 +75,7 @@ export class HistoryManager {
undo() {
if (this.index === -1 && this.initialState) {
log.log("reached start, loading initial state");
log.log('reached start, loading initial state');
return clone(this.initialState);
} else {
const delta = this.history[this.index];
@@ -95,7 +95,7 @@ export class HistoryManager {
this.state = nextState;
return clone(nextState);
} else {
log.log("reached end");
log.log('reached end');
}
}
}

View File

@@ -1,16 +1,15 @@
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";
import { animate, lerp } from '$lib/helpers';
import type { createKeyMap } from '$lib/helpers/createKeyMap';
import { panelState } from '$lib/sidebar/PanelState.svelte';
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",
key: 'l',
description: 'Select linked nodes',
callback: () => {
const activeNode = graph.getNode(graphState.activeNodeId);
if (activeNode) {
@@ -20,56 +19,52 @@ export function setupKeymaps(keymap: Keymap, graph: GraphManager, graphState: Gr
graphState.selectedNodes.add(node.id);
}
}
},
}
});
keymap.addShortcut({
key: "?",
description: "Toggle Help",
key: '?',
description: 'Toggle Help',
callback: () => {
// TODO: fix this
// showHelp = !showHelp;
},
panelState.setActivePanel('shortcuts');
}
});
keymap.addShortcut({
key: "c",
key: 'c',
ctrl: true,
description: "Copy active nodes",
callback: () => graphState.copyNodes(),
description: 'Copy active nodes',
callback: () => graphState.copyNodes()
});
keymap.addShortcut({
key: "v",
key: 'v',
ctrl: true,
description: "Paste nodes",
callback: () => graphState.pasteNodes(),
description: 'Paste nodes',
callback: () => graphState.pasteNodes()
});
keymap.addShortcut({
key: "Escape",
description: "Deselect nodes",
key: 'Escape',
description: 'Deselect nodes',
callback: () => {
graphState.activeNodeId = -1;
graphState.clearSelection();
graphState.edgeEndPosition = null;
(document.activeElement as HTMLElement)?.blur();
},
}
});
keymap.addShortcut({
key: "A",
key: 'A',
shift: true,
description: "Add new Node",
callback: () => {
graphState.addMenuPosition = [graphState.mousePosition[0], graphState.mousePosition[1]];
},
description: 'Add new Node',
callback: () => graphState.openNodePalette()
});
keymap.addShortcut({
key: ".",
description: "Center camera",
key: '.',
description: 'Center camera',
callback: () => {
if (!graphState.isBodyFocused()) return;
@@ -88,71 +83,69 @@ export function setupKeymaps(keymap: Keymap, graph: GraphManager, graphState: Gr
const ease = (t: number) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t);
animate(500, (a: number) => {
graphState.setCameraTransform(
lerp(camX, average[0], ease(a)),
lerp(camY, average[1], ease(a)),
lerp(camZ, 2, ease(a)),
);
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",
key: 'a',
ctrl: true,
preventDefault: true,
description: "Select all nodes",
description: 'Select all nodes',
callback: () => {
if (!graphState.isBodyFocused()) return;
for (const node of graph.nodes.keys()) {
graphState.selectedNodes.add(node);
}
},
}
});
keymap.addShortcut({
key: "z",
key: 'z',
ctrl: true,
description: "Undo",
description: 'Undo',
callback: () => {
if (!graphState.isBodyFocused()) return;
graph.undo();
for (const node of graph.nodes.values()) {
graphState.updateNodePosition(node);
}
},
}
});
keymap.addShortcut({
key: "y",
key: 'y',
ctrl: true,
description: "Redo",
description: 'Redo',
callback: () => {
graph.redo();
for (const node of graph.nodes.values()) {
graphState.updateNodePosition(node);
}
},
}
});
keymap.addShortcut({
key: "s",
key: 's',
ctrl: true,
description: "Save",
description: 'Save',
preventDefault: true,
callback: () => {
const state = graph.serialize();
const blob = new Blob([JSON.stringify(state)], {
type: "application/json;charset=utf-8",
type: 'application/json;charset=utf-8'
});
FileSaver.saveAs(blob, "nodarium-graph.json");
},
FileSaver.saveAs(blob, 'nodarium-graph.json');
}
});
keymap.addShortcut({
key: ["Delete", "Backspace", "x"],
description: "Delete selected nodes",
key: ['Delete', 'Backspace', 'x'],
description: 'Delete selected nodes',
callback: (event) => {
if (!graphState.isBodyFocused()) return;
graph.startUndoGroup();
@@ -173,20 +166,18 @@ export function setupKeymaps(keymap: Keymap, graph: GraphManager, graphState: Gr
graphState.clearSelection();
}
graph.saveUndoGroup();
},
}
});
keymap.addShortcut({
key: "f",
description: "Smart Connect Nodes",
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,13 +1,13 @@
<script lang="ts">
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";
import NodeVert from "./Node.vert";
import NodeHtml from "./NodeHTML.svelte";
import { colors } from "../graph/colors.svelte";
import { appSettings } from "$lib/settings/app-settings.svelte";
import { appSettings } from '$lib/settings/app-settings.svelte';
import type { NodeInstance } from '@nodarium/types';
import { T } from '@threlte/core';
import { type Mesh } from 'three';
import { getGraphState } from '../graph-state.svelte';
import { colors } from '../graph/colors.svelte';
import NodeFrag from './Node.frag';
import NodeVert from './Node.vert';
import NodeHtml from './NodeHTML.svelte';
const graphState = getGraphState();
@@ -16,17 +16,17 @@
inView: boolean;
z: number;
};
let { node, inView, z }: Props = $props();
let { node = $bindable(), inView, z }: Props = $props();
const isActive = $derived(graphState.activeNodeId === node.id);
const isSelected = $derived(graphState.selectedNodes.has(node.id));
const strokeColor = $derived(
appSettings.value.theme &&
(isSelected
appSettings.value.theme
&& (isSelected
? colors.selected
: isActive
? colors.active
: colors.outline),
? colors.active
: colors.outline)
);
let meshRef: Mesh | undefined = $state();
@@ -42,8 +42,8 @@
</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}
@@ -55,16 +55,16 @@
fragmentShader={NodeFrag}
transparent
uniforms={{
uColorBright: { value: colors["layer-2"] },
uColorDark: { value: colors["layer-1"] },
uColorBright: { value: colors['layer-2'] },
uColorDark: { value: colors['layer-1'] },
uStrokeColor: { value: colors.outline.clone() },
uStrokeWidth: { value: 1.0 },
uWidth: { value: 20 },
uHeight: { value: height },
uHeight: { value: height }
}}
uniforms.uStrokeColor.value={strokeColor.clone()}
uniforms.uStrokeWidth.value={(7 - z) / 3}
/>
</T.Mesh>
<NodeHtml {node} {inView} {isActive} {isSelected} {z} />
<NodeHtml bind:node {inView} {isActive} {isSelected} {z} />

View File

@@ -1,16 +1,16 @@
<script lang="ts">
import type { NodeInstance, SerializedNode } from "@nodarium/types";
import NodeHeader from "./NodeHeader.svelte";
import NodeParameter from "./NodeParameter.svelte";
import { getGraphState } from "../graph/state.svelte";
import type { NodeInstance } from '@nodarium/types';
import { getGraphState } from '../graph-state.svelte';
import NodeHeader from './NodeHeader.svelte';
import NodeParameter from './NodeParameter.svelte';
let ref: HTMLDivElement;
const graphState = getGraphState();
type Props = {
node: SerializedNode | NodeInstance;
position?: "absolute" | "fixed" | "relative";
node: NodeInstance;
position?: 'absolute' | 'fixed' | 'relative';
isActive?: boolean;
isSelected?: boolean;
inView?: boolean;
@@ -19,29 +19,23 @@
let {
node = $bindable(),
position = "absolute",
position = 'absolute',
isActive = false,
isSelected = false,
inView = true,
z = 2,
z = 2
}: Props = $props();
// 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 =
"state" in node
? Object.entries(node.state?.type?.inputs || {}).filter(
(p) =>
p[1].type !== "seed" &&
!("setting" in p[1]) &&
p[1]?.hidden !== true,
)
: [];
const parameters = Object.entries(node.state?.type?.inputs || {}).filter(
(p) => p[1].type !== 'seed' && !('setting' in p[1]) && p[1]?.hidden !== true
);
$effect(() => {
if ("state" in node && !node.state.ref) {
if ('state' in node && !node.state.ref) {
node.state.ref = ref;
graphState?.updateNodePosition(node);
}
@@ -52,7 +46,7 @@
class="node {position}"
class:active={isActive}
style:--cz={z + zOffset}
style:display={inView && z > zLimit ? "block" : "none"}
style:display={inView && z > zLimit ? 'block' : 'none'}
class:selected={isSelected}
class:out-of-view={!inView}
data-node-id={node.id}
@@ -61,7 +55,7 @@
>
<NodeHeader {node} />
{#each parameters as [key, value], i}
{#each parameters as [key, value], i (key)}
<NodeParameter
bind:node
id={key}
@@ -77,22 +71,22 @@
user-select: none !important;
cursor: pointer;
width: 200px;
color: var(--text-color);
color: var(--color-text);
transform: translate3d(var(--nx), var(--ny), 0);
z-index: 1;
opacity: calc((var(--cz) - 2.5) / 3.5);
font-weight: 300;
--stroke: var(--outline);
--stroke: var(--color-outline);
--stroke-width: 2px;
}
.node.active {
--stroke: var(--active);
--stroke: var(--color-active);
--stroke-width: 2px;
}
.node.selected {
--stroke: var(--selected);
--stroke: var(--color-selected);
--stroke-width: 2px;
}
</style>

View File

@@ -1,57 +1,61 @@
<script lang="ts">
import { getGraphState } from "../graph/state.svelte.js";
import { createNodePath } from "../helpers/index.js";
import type { NodeInstance, SerializedNode } from "@nodarium/types";
import type { NodeInstance } from '@nodarium/types';
import { getGraphState } from '../graph-state.svelte';
import { createNodePath } from '../helpers/index.js';
const graphState = getGraphState();
const { node }: { node: NodeInstance | SerializedNode } = $props();
const { node }: { node: NodeInstance } = $props();
function handleMouseDown(event: MouseEvent) {
event.stopPropagation();
event.preventDefault();
if ("state" in node) {
if ('state' in node) {
graphState.setDownSocket?.({
node,
index: 0,
position: graphState.getSocketPosition?.(node, 0),
position: graphState.getSocketPosition?.(node, 0)
});
}
}
const cornerTop = 10;
const rightBump =
"state" in node ? !!node?.state?.type?.outputs?.length : false;
const rightBump = $derived(!!node?.state?.type?.outputs?.length);
const aspectRatio = 0.25;
const path = createNodePath({
depth: 5.5,
height: 34,
y: 49,
cornerTop,
rightBump,
aspectRatio,
});
const pathHover = createNodePath({
depth: 8.5,
height: 50,
y: 49,
cornerTop,
rightBump,
aspectRatio,
});
const path = $derived(
createNodePath({
depth: 5.5,
height: 34,
y: 49,
cornerTop,
rightBump,
aspectRatio
})
);
const pathHover = $derived(
createNodePath({
depth: 8.5,
height: 50,
y: 49,
cornerTop,
rightBump,
aspectRatio
})
);
</script>
<div class="wrapper" data-node-id={node.id} data-node-type={node.type}>
<div class="content">
{node.type.split("/").pop()}
{node.type.split('/').pop()}
</div>
<div
class="click-target"
role="button"
tabindex="0"
onmousedown={handleMouseDown}
></div>
>
</div>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100"
@@ -63,8 +67,7 @@
--hover-path: path("${pathHover}");
`}
>
<path vector-effect="non-scaling-stroke" stroke="white" stroke-width="0.1"
></path>
<path vector-effect="non-scaling-stroke" stroke="white" stroke-width="0.1"></path>
</svg>
</div>
@@ -105,10 +108,8 @@
svg path {
stroke-width: 0.2px;
transition:
d 0.3s ease,
fill 0.3s ease;
fill: var(--layer-2);
transition: d 0.3s ease, fill 0.3s ease;
fill: var(--color-layer-2);
stroke: var(--stroke);
stroke-width: var(--stroke-width);
d: var(--path);

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import type { NodeInstance, NodeInput } from "@nodarium/types";
import { Input } from "@nodarium/ui";
import type { GraphManager } from "../graph-manager.svelte";
import type { NodeInput, NodeInstance } from '@nodarium/types';
import { Input } from '@nodarium/ui';
import type { GraphManager } from '../graph-manager.svelte';
type Props = {
node: NodeInstance;
@@ -16,17 +16,18 @@
input,
id,
elementId = `input-${Math.random().toString(36).substring(7)}`,
graph,
graph
}: Props = $props();
function getDefaultValue() {
if (node?.props?.[id] !== undefined) return node?.props?.[id] as number;
if ("value" in input && input?.value !== undefined)
if ('value' in input && input?.value !== undefined) {
return input?.value as number;
if (input.type === "boolean") return 0;
if (input.type === "float") return 0.5;
if (input.type === "integer") return 0;
if (input.type === "select") return 0;
}
if (input.type === 'boolean') return 0;
if (input.type === 'float') return 0.5;
if (input.type === 'integer') return 0;
if (input.type === 'select') return 0;
return 0;
}

View File

@@ -1,16 +1,12 @@
<script lang="ts">
import type {
NodeInput as NodeInputType,
NodeInstance,
SerializedNode,
} from "@nodarium/types";
import { createNodePath } from "../helpers/index.js";
import NodeInput from "./NodeInput.svelte";
import { getGraphManager, getGraphState } from "../graph/state.svelte.js";
import type { NodeInput, NodeInstance } from '@nodarium/types';
import { getGraphManager, getGraphState } from '../graph-state.svelte';
import { createNodePath } from '../helpers';
import NodeInputEl from './NodeInput.svelte';
type Props = {
node: NodeInstance | SerializedNode;
input: NodeInputType;
node: NodeInstance;
input: NodeInput;
id: string;
isLast?: boolean;
};
@@ -19,9 +15,9 @@
let { node = $bindable(), input, id, isLast }: Props = $props();
const inputType = node?.state?.type?.inputs?.[id]!;
const inputType = $derived(node?.state?.type?.inputs?.[id]);
const socketId = `${node.id}-${id}`;
const socketId = $derived(`${node.id}-${id}`);
const graphState = getGraphState();
const graphId = graph?.id;
@@ -34,38 +30,44 @@
graphState.setDownSocket({
node,
index: id,
position: graphState.getSocketPosition?.(node, id),
position: graphState.getSocketPosition?.(node, id)
});
}
const leftBump = node.state?.type?.inputs?.[id].internal !== true;
const cornerBottom = isLast ? 5 : 0;
const leftBump = $derived(node.state?.type?.inputs?.[id].internal !== true);
const cornerBottom = $derived(isLast ? 5 : 0);
const aspectRatio = 0.5;
const path = createNodePath({
depth: 7,
height: 20,
y: 50.5,
cornerBottom,
leftBump,
aspectRatio,
});
const pathDisabled = createNodePath({
depth: 6,
height: 18,
y: 50.5,
cornerBottom,
leftBump,
aspectRatio,
});
const pathHover = createNodePath({
depth: 8,
height: 25,
y: 50.5,
cornerBottom,
leftBump,
aspectRatio,
});
const path = $derived(
createNodePath({
depth: 7,
height: 20,
y: 50.5,
cornerBottom,
leftBump,
aspectRatio
})
);
const pathDisabled = $derived(
createNodePath({
depth: 6,
height: 18,
y: 50.5,
cornerBottom,
leftBump,
aspectRatio
})
);
const pathHover = $derived(
createNodePath({
depth: 8,
height: 25,
y: 50.5,
cornerBottom,
leftBump,
aspectRatio
})
);
</script>
<div
@@ -76,11 +78,15 @@
>
{#key id && graphId}
<div class="content" class:disabled={graph?.inputSockets?.has(socketId)}>
{#if inputType.label !== ""}
<label for={elementId}>{input.label || id}</label>
{#if inputType?.label !== ''}
<label for={elementId} title={input.description}>{input.label || id}</label>
{/if}
{#if inputType.external !== true}
<NodeInput {graph} {elementId} bind:node {input} {id} />
<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}
<NodeInputEl {graph} {elementId} bind:node {input} {id} />
{/if}
</div>
@@ -92,7 +98,8 @@
onmousedown={handleMouseDown}
role="button"
tabindex="0"
></div>
>
</div>
{/if}
{/key}
@@ -167,10 +174,8 @@
}
svg path {
transition:
d 0.3s ease,
fill 0.3s ease;
fill: var(--layer-1);
transition: d 0.3s ease, fill 0.3s ease;
fill: var(--color-layer-1);
stroke: var(--stroke);
stroke-width: var(--stroke-width);
d: var(--path);
@@ -185,9 +190,6 @@
.content.disabled {
opacity: 0.2;
}
.content.disabled > * {
pointer-events: none;
}
.disabled svg path {
d: var(--hover-path-disabled) !important;

View File

@@ -1 +1,95 @@
{"settings":{"resolution.circle":26,"resolution.curve":39},"nodes":[{"id":9,"position":[220,80],"type":"max/plantarium/output","props":{}},{"id":10,"position":[95,80],"type":"max/plantarium/stem","props":{"amount":5,"length":11,"thickness":0.1}},{"id":14,"position":[195,80],"type":"max/plantarium/gravity","props":{"strength":0.38,"scale":39,"fixBottom":0,"directionalStrength":[1,1,1],"depth":1,"curviness":1}},{"id":15,"position":[120,80],"type":"max/plantarium/noise","props":{"strength":4.9,"scale":2.2,"fixBottom":1,"directionalStrength":[1,1,1],"depth":1,"octaves":1}},{"id":16,"position":[70,80],"type":"max/plantarium/vec3","props":{"0":0,"1":0,"2":0}},{"id":17,"position":[45,80],"type":"max/plantarium/random","props":{"min":-2,"max":2}},{"id":18,"position":[170,80],"type":"max/plantarium/branch","props":{"length":1.6,"thickness":0.69,"amount":36,"offsetSingle":0.5,"lowestBranch":0.46,"highestBranch":1,"depth":1,"rotation":180}},{"id":19,"position":[145,80],"type":"max/plantarium/gravity","props":{"strength":0.38,"scale":39,"fixBottom":0,"directionalStrength":[1,1,1],"depth":1,"curviness":1}},{"id":20,"position":[70,120],"type":"max/plantarium/random","props":{"min":0.073,"max":0.15}}],"edges":[[14,0,9,"input"],[10,0,15,"plant"],[16,0,10,"origin"],[17,0,16,"0"],[17,0,16,"2"],[18,0,14,"plant"],[15,0,19,"plant"],[19,0,18,"plant"],[20,0,10,"thickness"]]}
{
"settings": { "resolution.circle": 26, "resolution.curve": 39 },
"nodes": [
{ "id": 9, "position": [220, 80], "type": "max/plantarium/output", "props": {} },
{
"id": 10,
"position": [95, 80],
"type": "max/plantarium/stem",
"props": { "amount": 5, "length": 11, "thickness": 0.1 }
},
{
"id": 14,
"position": [195, 80],
"type": "max/plantarium/gravity",
"props": {
"strength": 0.38,
"scale": 39,
"fixBottom": 0,
"directionalStrength": [1, 1, 1],
"depth": 1,
"curviness": 1
}
},
{
"id": 15,
"position": [120, 80],
"type": "max/plantarium/noise",
"props": {
"strength": 4.9,
"scale": 2.2,
"fixBottom": 1,
"directionalStrength": [1, 1, 1],
"depth": 1,
"octaves": 1
}
},
{
"id": 16,
"position": [70, 80],
"type": "max/plantarium/vec3",
"props": { "0": 0, "1": 0, "2": 0 }
},
{
"id": 17,
"position": [45, 80],
"type": "max/plantarium/random",
"props": { "min": -2, "max": 2 }
},
{
"id": 18,
"position": [170, 80],
"type": "max/plantarium/branch",
"props": {
"length": 1.6,
"thickness": 0.69,
"amount": 36,
"offsetSingle": 0.5,
"lowestBranch": 0.46,
"highestBranch": 1,
"depth": 1,
"rotation": 180
}
},
{
"id": 19,
"position": [145, 80],
"type": "max/plantarium/gravity",
"props": {
"strength": 0.38,
"scale": 39,
"fixBottom": 0,
"directionalStrength": [1, 1, 1],
"depth": 1,
"curviness": 1
}
},
{
"id": 20,
"position": [70, 120],
"type": "max/plantarium/random",
"props": { "min": 0.073, "max": 0.15 }
}
],
"edges": [
[14, 0, 9, "input"],
[10, 0, 15, "plant"],
[16, 0, 10, "origin"],
[17, 0, 16, "0"],
[17, 0, 16, "2"],
[18, 0, 14, "plant"],
[15, 0, 19, "plant"],
[19, 0, 18, "plant"],
[20, 0, 10, "thickness"]
]
}

View File

@@ -1,11 +1,10 @@
import type { Graph } from "@nodarium/types";
import type { Graph } from '@nodarium/types';
export function grid(width: number, height: number) {
const graph: Graph = {
id: Math.floor(Math.random() * 100000),
edges: [],
nodes: [],
nodes: []
};
const amount = width * height;
@@ -18,19 +17,18 @@ export function grid(width: number, height: number) {
id: i,
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",
type: i == 0 ? 'max/plantarium/float' : 'max/plantarium/math'
});
graph.edges.push([i, 0, i + 1, i === amount - 1 ? "input" : "a",]);
graph.edges.push([i, 0, i + 1, i === amount - 1 ? 'input' : 'a']);
}
graph.nodes.push({
id: amount,
position: [width * 30, (height - 1) * 40],
type: "max/plantarium/output",
props: {},
type: 'max/plantarium/output',
props: {}
});
return graph;
}

View File

@@ -1,8 +1,7 @@
export { grid } from "./grid";
export { tree } from "./tree";
export { plant } from "./plant";
export { default as lottaFaces } from "./lotta-faces.json";
export { default as lottaNodes } from "./lotta-nodes.json";
export { default as defaultPlant } from "./default.json"
export { default as lottaNodesAndFaces } from "./lotta-nodes-and-faces.json";
export { default as defaultPlant } from './default.json';
export { grid } from './grid';
export { default as lottaFaces } from './lotta-faces.json';
export { default as lottaNodesAndFaces } from './lotta-nodes-and-faces.json';
export { default as lottaNodes } from './lotta-nodes.json';
export { plant } from './plant';
export { tree } from './tree';

View File

@@ -1 +1,44 @@
{"settings":{"resolution.circle":64,"resolution.curve":64,"randomSeed":false},"nodes":[{"id":9,"position":[260,0],"type":"max/plantarium/output","props":{}},{"id":18,"position":[185,0],"type":"max/plantarium/stem","props":{"amount":64,"length":12,"thickness":0.15}},{"id":19,"position":[210,0],"type":"max/plantarium/noise","props":{"scale":1.3,"strength":5.4}},{"id":20,"position":[235,0],"type":"max/plantarium/branch","props":{"length":0.8,"thickness":0.8,"amount":3}},{"id":21,"position":[160,0],"type":"max/plantarium/vec3","props":{"0":0.39,"1":0,"2":0.41}},{"id":22,"position":[130,0],"type":"max/plantarium/random","props":{"min":-2,"max":2}}],"edges":[[18,0,19,"plant"],[19,0,20,"plant"],[20,0,9,"input"],[21,0,18,"origin"],[22,0,21,"0"],[22,0,21,"2"]]}
{
"settings": { "resolution.circle": 64, "resolution.curve": 64, "randomSeed": false },
"nodes": [
{ "id": 9, "position": [260, 0], "type": "max/plantarium/output", "props": {} },
{
"id": 18,
"position": [185, 0],
"type": "max/plantarium/stem",
"props": { "amount": 64, "length": 12, "thickness": 0.15 }
},
{
"id": 19,
"position": [210, 0],
"type": "max/plantarium/noise",
"props": { "scale": 1.3, "strength": 5.4 }
},
{
"id": 20,
"position": [235, 0],
"type": "max/plantarium/branch",
"props": { "length": 0.8, "thickness": 0.8, "amount": 3 }
},
{
"id": 21,
"position": [160, 0],
"type": "max/plantarium/vec3",
"props": { "0": 0.39, "1": 0, "2": 0.41 }
},
{
"id": 22,
"position": [130, 0],
"type": "max/plantarium/random",
"props": { "min": -2, "max": 2 }
}
],
"edges": [
[18, 0, 19, "plant"],
[19, 0, 20, "plant"],
[20, 0, 9, "input"],
[21, 0, 18, "origin"],
[22, 0, 21, "0"],
[22, 0, 21, "2"]
]
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,12 +1,71 @@
export const plant = {
"settings": { "resolution.circle": 26, "resolution.curve": 39 },
"nodes": [
{ "id": 9, "position": [180, 80], "type": "max/plantarium/output", "props": {} },
{ "id": 10, "position": [55, 80], "type": "max/plantarium/stem", "props": { "amount": 1, "length": 11, "thickness": 0.71 } },
{ "id": 11, "position": [80, 80], "type": "max/plantarium/noise", "props": { "strength": 35, "scale": 4.6, "fixBottom": 1, "directionalStrength": [1, 0.74, 0.083], "depth": 1 } },
{ "id": 12, "position": [105, 80], "type": "max/plantarium/branch", "props": { "length": 3, "thickness": 0.6, "amount": 10, "rotation": 180, "offsetSingle": 0.34, "lowestBranch": 0.53, "highestBranch": 1, "depth": 1 } },
{ "id": 13, "position": [130, 80], "type": "max/plantarium/noise", "props": { "strength": 8, "scale": 7.7, "fixBottom": 1, "directionalStrength": [1, 0, 1], "depth": 1 } },
{ "id": 14, "position": [155, 80], "type": "max/plantarium/gravity", "props": { "strength": 0.11, "scale": 39, "fixBottom": 0, "directionalStrength": [1, 1, 1], "depth": 1, "curviness": 1 } }
'settings': { 'resolution.circle': 26, 'resolution.curve': 39 },
'nodes': [
{ 'id': 9, 'position': [180, 80], 'type': 'max/plantarium/output', 'props': {} },
{
'id': 10,
'position': [55, 80],
'type': 'max/plantarium/stem',
'props': { 'amount': 1, 'length': 11, 'thickness': 0.71 }
},
{
'id': 11,
'position': [80, 80],
'type': 'max/plantarium/noise',
'props': {
'strength': 35,
'scale': 4.6,
'fixBottom': 1,
'directionalStrength': [1, 0.74, 0.083],
'depth': 1
}
},
{
'id': 12,
'position': [105, 80],
'type': 'max/plantarium/branch',
'props': {
'length': 3,
'thickness': 0.6,
'amount': 10,
'rotation': 180,
'offsetSingle': 0.34,
'lowestBranch': 0.53,
'highestBranch': 1,
'depth': 1
}
},
{
'id': 13,
'position': [130, 80],
'type': 'max/plantarium/noise',
'props': {
'strength': 8,
'scale': 7.7,
'fixBottom': 1,
'directionalStrength': [1, 0, 1],
'depth': 1
}
},
{
'id': 14,
'position': [155, 80],
'type': 'max/plantarium/gravity',
'props': {
'strength': 0.11,
'scale': 39,
'fixBottom': 0,
'directionalStrength': [1, 1, 1],
'depth': 1,
'curviness': 1
}
}
],
"edges": [[10, 0, 11, "plant"], [11, 0, 12, "plant"], [12, 0, 13, "plant"], [13, 0, 14, "plant"], [14, 0, 9, "input"]]
}
'edges': [
[10, 0, 11, 'plant'],
[11, 0, 12, 'plant'],
[12, 0, 13, 'plant'],
[13, 0, 14, 'plant'],
[14, 0, 9, 'input']
]
};

View File

@@ -1,28 +1,26 @@
import type { Graph, SerializedNode } from "@nodarium/types";
import type { Graph, SerializedNode } from '@nodarium/types';
export function tree(depth: number): Graph {
const nodes: SerializedNode[] = [
{
id: 0,
type: "max/plantarium/output",
type: 'max/plantarium/output',
position: [0, 0]
},
{
id: 1,
type: "max/plantarium/math",
type: 'max/plantarium/math',
position: [-40, -10]
}
]
];
const edges: [number, number, number, string][] = [
[1, 0, 0, "input"]
[1, 0, 0, 'input']
];
for (let d = 0; d < depth; d++) {
const amount = Math.pow(2, d);
for (let i = 0; i < amount; i++) {
const id0 = amount * 2 + i * 2;
const id1 = amount * 2 + i * 2 + 1;
@@ -33,24 +31,22 @@ export function tree(depth: number): Graph {
nodes.push({
id: id0,
type: "max/plantarium/math",
position: [x, y],
type: 'max/plantarium/math',
position: [x, y]
});
edges.push([id0, 0, parent, "a"]);
edges.push([id0, 0, parent, 'a']);
nodes.push({
id: id1,
type: "max/plantarium/math",
position: [x, y + 35],
type: 'max/plantarium/math',
position: [x, y + 35]
});
edges.push([id1, 0, parent, "b"]);
edges.push([id1, 0, parent, 'b']);
}
}
return {
id: Math.floor(Math.random() * 100000),
nodes,
edges
};
}

View File

@@ -1,22 +1,24 @@
<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) {
index = getContext<() => number>("registerCell")();
}
const { children } = $props<{ children?: Snippet }>();
const sizes = getContext<{ value: string[] }>("sizes");
$effect(() => {
if (index === -1) {
index = getContext<() => number>('registerCell')();
}
});
const sizes = getContext<{ value: string[] }>('sizes');
let downSizes: string[] = [];
let downWidth = 0;
let mouseDown = false;
let startX = 0;
function handleMouseDown(event: MouseEvent) {
downSizes = [...sizes.value];
mouseDown = true;
startX = event.clientX;
downWidth = wrapper.getBoundingClientRect().width;
@@ -31,8 +33,8 @@
</script>
<svelte:window
on:mouseup={() => (mouseDown = false)}
on:mousemove={handleMouseMove}
onmouseup={() => (mouseDown = false)}
onmousemove={handleMouseMove}
/>
{#if index > 0}
@@ -40,12 +42,13 @@
class="seperator"
role="button"
tabindex="0"
on:mousedown={handleMouseDown}
></div>
onmousedown={handleMouseDown}
>
</div>
{/if}
<div class="cell" bind:this={wrapper}>
<slot />
{@render children?.()}
</div>
<style>
@@ -59,7 +62,7 @@
cursor: ew-resize;
height: 100%;
width: 1px;
background: var(--outline);
background: var(--color-outline);
}
.seperator::before {
content: "";

View File

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

View File

@@ -1,26 +1,26 @@
<script lang="ts">
import { setContext, getContext } from "svelte";
import { localState } from "$lib/helpers/localState.svelte";
import { localState } from '$lib/helpers/localState.svelte';
import { getContext, setContext } from 'svelte';
const gridId = getContext<string>("grid-id") || "grid-0";
const gridId = getContext<string>('grid-id') || 'grid-0';
let sizes = localState<string[]>(gridId, []);
const { children } = $props();
let registerIndex = 0;
setContext("registerCell", function () {
setContext('registerCell', function() {
let index = registerIndex;
registerIndex++;
if (registerIndex > sizes.value.length) {
sizes.value = [...sizes.value, "1fr"];
sizes.value = [...sizes.value, '1fr'];
}
return index;
});
setContext("sizes", sizes);
setContext('sizes', sizes);
const cols = $derived(
sizes.value.map((size, i) => `${i > 0 ? "1px " : ""}` + size).join(" "),
sizes.value.map((size, i) => `${i > 0 ? '1px ' : ''}` + size).join(' ')
);
</script>

View File

@@ -1,6 +1,6 @@
import { withSubComponents } from "$lib/helpers";
import Grid from "./Grid.svelte";
import Row from "./Row.svelte";
import Cell from "./Cell.svelte";
import { withSubComponents } from '$lib/helpers';
import Cell from './Cell.svelte';
import Grid from './Grid.svelte';
import Row from './Row.svelte';
export default withSubComponents(Grid, { Row, Cell });

View File

@@ -1,38 +1,39 @@
import { derived, get, writable } from "svelte/store";
import { derived, get, writable } from 'svelte/store';
type Shortcut = {
key: string | string[],
shift?: boolean,
ctrl?: boolean,
alt?: boolean,
preventDefault?: boolean,
description?: string,
callback: (event: KeyboardEvent) => void
export type ShortCut = {
key: string | string[];
shift?: boolean;
ctrl?: boolean;
alt?: boolean;
preventDefault?: boolean;
description?: string;
callback: (event: KeyboardEvent) => void;
};
function getShortcutId(shortcut: ShortCut) {
return `${shortcut.key}${shortcut.shift ? '+shift' : ''}${shortcut.ctrl ? '+ctrl' : ''}${
shortcut.alt ? '+alt' : ''
}`;
}
function getShortcutId(shortcut: Shortcut) {
return `${shortcut.key}${shortcut.shift ? "+shift" : ""}${shortcut.ctrl ? "+ctrl" : ""}${shortcut.alt ? "+alt" : ""}`;
}
export function createKeyMap(keys: Shortcut[]) {
export function createKeyMap(keys: ShortCut[]) {
const store = writable(new Map(keys.map(k => [getShortcutId(k), k])));
return {
handleKeyboardEvent: (event: KeyboardEvent) => {
const activeElement = document.activeElement as HTMLElement;
if (activeElement?.tagName === "INPUT" || activeElement?.tagName === "TEXTAREA") return;
if (activeElement?.tagName === 'INPUT' || activeElement?.tagName === 'TEXTAREA') return;
const key = [...get(store).values()].find(k => {
if (Array.isArray(k.key) ? !k.key.includes(event.key) : k.key !== event.key) return false;
if ("shift" in k && k.shift !== event.shiftKey) return false;
if ("ctrl" in k && k.ctrl !== event.ctrlKey) return false;
if ("alt" in k && k.alt !== event.altKey) return false;
if ('shift' in k && k.shift !== event.shiftKey) return false;
if ('ctrl' in k && k.ctrl !== event.ctrlKey) return false;
if ('alt' in k && k.alt !== event.altKey) return false;
return true;
});
if (key && key.preventDefault) event.preventDefault();
key?.callback(event);
},
addShortcut: (shortcut: Shortcut) => {
addShortcut: (shortcut: ShortCut) => {
if (Array.isArray(shortcut.key)) {
for (const k of shortcut.key) {
store.update(shortcuts => {
@@ -52,6 +53,5 @@ export function createKeyMap(keys: Shortcut[]) {
}
},
keys: derived(store, $store => Array.from($store.values()))
}
};
}

View File

@@ -18,7 +18,7 @@ export function animate(duration: number, callback: (progress: number) => void |
} else {
callback(1);
}
}
};
requestAnimationFrame(loop);
}
@@ -30,10 +30,11 @@ export function createNodePath({
cornerBottom = 0,
leftBump = false,
rightBump = false,
aspectRatio = 1,
aspectRatio = 1
} = {}) {
return `M0,${cornerTop}
${cornerTop
${
cornerTop
? ` V${cornerTop}
Q0,0 ${cornerTop * aspectRatio},0
H${100 - cornerTop * aspectRatio}
@@ -42,40 +43,45 @@ export function createNodePath({
: ` V0
H100
`
}
}
V${y - height / 2}
${rightBump
${
rightBump
? ` C${100 - depth},${y - height / 2} ${100 - depth},${y + height / 2} 100,${y + height / 2}`
: ` H100`
}
${cornerBottom
}
${
cornerBottom
? ` V${100 - cornerBottom}
Q100,100 ${100 - cornerBottom * aspectRatio},100
H${cornerBottom * aspectRatio}
Q0,100 0,${100 - cornerBottom}
`
: `${leftBump ? `V100 H0` : `V100`}`
}
${leftBump
? ` V${y + height / 2} C${depth},${y + height / 2} ${depth},${y - height / 2} 0,${y - height / 2}`
}
${
leftBump
? ` V${y + height / 2} C${depth},${y + height / 2} ${depth},${y - height / 2} 0,${
y - height / 2
}`
: ` H0`
}
Z`.replace(/\s+/g, " ");
}
Z`.replace(/\s+/g, ' ');
}
export const debounce = (fn: Function, ms = 300) => {
export const debounce = (fn: () => void, ms = 300) => {
let timeoutId: ReturnType<typeof setTimeout>;
return function (this: any, ...args: any[]) {
return function(this: unknown, ...args: unknown[]) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn.apply(this, args), ms);
timeoutId = setTimeout(() => fn.apply(this, args as []), ms);
};
};
export const clone: <T>(v: T) => T = "structedClone" in globalThis ? globalThis.structuredClone : (obj) => JSON.parse(JSON.stringify(obj));
export const clone: <T>(v: T) => T = 'structedClone' in globalThis
? globalThis.structuredClone
: (obj) => JSON.parse(JSON.stringify(obj));
export function withSubComponents<A, B extends Record<string, any>>(
export function withSubComponents<A, B extends Record<string, unknown>>(
component: A,
subcomponents: B
): A & B {
@@ -87,7 +93,7 @@ export function withSubComponents<A, B extends Record<string, any>>(
}
export function humanizeNumber(number: number): string {
const suffixes = ["", "K", "M", "B", "T"];
const suffixes = ['', 'K', 'M', 'B', 'T'];
if (number < 1000) {
return number.toString();
}
@@ -104,11 +110,15 @@ export function humanizeDuration(durationInMilliseconds: number) {
const millisecondsPerHour = 3600000;
const millisecondsPerDay = 86400000;
let days = Math.floor(durationInMilliseconds / millisecondsPerDay);
let hours = Math.floor((durationInMilliseconds % millisecondsPerDay) / millisecondsPerHour);
let minutes = Math.floor((durationInMilliseconds % millisecondsPerHour) / millisecondsPerMinute);
let seconds = Math.floor((durationInMilliseconds % millisecondsPerMinute) / millisecondsPerSecond);
let millis = durationInMilliseconds % millisecondsPerSecond;
const days = Math.floor(durationInMilliseconds / millisecondsPerDay);
const hours = Math.floor((durationInMilliseconds % millisecondsPerDay) / millisecondsPerHour);
const minutes = Math.floor(
(durationInMilliseconds % millisecondsPerHour) / millisecondsPerMinute
);
const seconds = Math.floor(
(durationInMilliseconds % millisecondsPerMinute) / millisecondsPerSecond
);
const millis = durationInMilliseconds % millisecondsPerSecond;
let durationString = '';
@@ -126,37 +136,15 @@ export function humanizeDuration(durationInMilliseconds: number) {
}
if (millis > 0 || durationString === '') {
durationString += millis + 'ms';
durationString += Math.floor(millis) + 'ms';
}
return durationString.trim();
}
// export function debounceAsyncFunction<T extends any[], R>(
// func: (...args: T) => Promise<R>
// ): (...args: T) => Promise<R> {
// let timeoutId: ReturnType<typeof setTimeout> | null = null;
// let lastPromise: Promise<R> | null = null;
// let lastReject: ((reason?: any) => void) | null = null;
//
// return (...args: T): Promise<R> => {
// if (timeoutId) {
// clearTimeout(timeoutId);
// if (lastReject) {
// lastReject(new Error("Debounced: Previous call was canceled."));
// }
// }
//
// return new Promise<R>((resolve, reject) => {
// lastReject = reject;
// timeoutId = setTimeout(() => {
// timeoutId = null;
// lastReject = null;
// lastPromise = func(...args).then(resolve, reject);
// }, 300); // Default debounce time is 300ms; you can make this configurable.
// });
// };
// }
export function debounceAsyncFunction<T extends (...args: any[]) => Promise<any>>(asyncFn: T): T {
export function debounceAsyncFunction<T extends (...args: never[]) => Promise<unknown>>(
asyncFn: T
): T {
let isRunning = false;
let latestArgs: Parameters<T> | null = null;
let resolveNext: (() => void) | null = null;
@@ -177,7 +165,7 @@ export function debounceAsyncFunction<T extends (...args: any[]) => Promise<any>
try {
// Execute with the latest arguments
const result = await asyncFn(...latestArgs!);
return result;
return result as ReturnType<T>;
} finally {
// Allow the next execution
isRunning = false;
@@ -190,48 +178,18 @@ export function debounceAsyncFunction<T extends (...args: any[]) => Promise<any>
}) as T;
}
// export function debounceAsyncFunction<T extends any[], R>(func: (...args: T) => Promise<R>): (...args: T) => Promise<R> {
// let currentPromise: Promise<R> | null = null;
// let nextArgs: T | null = null;
// let resolveNext: ((result: R) => void) | null = null;
//
// const debouncedFunction = async (...args: T): Promise<R> => {
// if (currentPromise) {
// // Store the latest arguments and create a new promise to resolve them later
// nextArgs = args;
// return new Promise<R>((resolve) => {
// resolveNext = resolve;
// });
// } else {
// // Execute the function immediately
// try {
// currentPromise = func(...args);
// const result = await currentPromise;
// return result;
// } finally {
// currentPromise = null;
// // If there are stored arguments, call the function again with the latest arguments
// if (nextArgs) {
// const argsToUse = nextArgs;
// const resolver = resolveNext;
// nextArgs = null;
// resolveNext = null;
// resolver!(await debouncedFunction(...argsToUse));
// }
// }
// }
// };
//
// return debouncedFunction;
// }
export function withArgsChangeOnly<T extends any[], R>(func: (...args: T) => R): (...args: T) => R {
export function withArgsChangeOnly<T extends unknown[], R>(
func: (...args: T) => R
): (...args: T) => R {
let lastArgs: T | undefined = undefined;
let lastResult: R;
return (...args: T): R => {
// Check if arguments are the same as last call
if (lastArgs && args.length === lastArgs.length && args.every((val, index) => val === lastArgs?.[index])) {
if (
lastArgs && args.length === lastArgs.length
&& args.every((val, index) => val === lastArgs?.[index])
) {
return lastResult; // Return cached result if arguments haven't changed
}
@@ -241,4 +199,3 @@ export function withArgsChangeOnly<T extends any[], R>(func: (...args: T) => R):
return lastResult; // Return new result
};
}

View File

@@ -1,8 +1,8 @@
import { browser } from "$app/environment";
import { browser } from '$app/environment';
export class LocalStore<T> {
value = $state<T>() as T;
key = "";
key = '';
constructor(key: string, value: T) {
this.key = key;

View File

@@ -1,15 +1,14 @@
import { writable, type Writable } from "svelte/store";
import { type Writable, writable } from 'svelte/store';
function isStore(v: unknown): v is Writable<unknown> {
return v !== null && typeof v === "object" && "subscribe" in v && "set" in v;
return v !== null && typeof v === 'object' && 'subscribe' in v && 'set' in v;
}
const storeIds: Map<string, ReturnType<typeof createLocalStore>> = new Map();
const HAS_LOCALSTORAGE = "localStorage" in globalThis;
const HAS_LOCALSTORAGE = 'localStorage' in globalThis;
function createLocalStore<T>(key: string, initialValue: T | Writable<T>) {
let store: Writable<T>;
if (HAS_LOCALSTORAGE) {
@@ -36,18 +35,15 @@ function createLocalStore<T>(key: string, initialValue: T | Writable<T>) {
subscribe: store.subscribe,
set: store.set,
update: store.update
}
};
}
export default function localStore<T>(key: string, initialValue: T | Writable<T>): Writable<T> {
if (storeIds.has(key)) return storeIds.get(key) as Writable<T>;
const store = createLocalStore(key, initialValue)
const store = createLocalStore(key, initialValue);
storeIds.set(key, store);
return store
return store;
}

View File

@@ -1,6 +1,6 @@
export default <T extends unknown[]>(
callback: (...args: T) => void,
delay: number,
delay: number
) => {
let isWaiting = false;

View File

@@ -1,6 +1,10 @@
<svg width="24" height="24" 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"/>
<path d="M16 10.5C9.33333 10.5 14.8889 6 8.22222 6H6M16 12.5C8.77778 12.5 14.8889 17 8.22222 17H6" stroke="currentColor" stroke-width="2"/>
<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" />
<path
d="M16 10.5C9.33333 10.5 14.8889 6 8.22222 6H6M16 12.5C8.77778 12.5 14.8889 17 8.22222 17H6"
stroke="currentColor"
stroke-width="2"
/>
</svg>

Before

Width:  |  Height:  |  Size: 519 B

After

Width:  |  Height:  |  Size: 509 B

View File

@@ -1,13 +1,14 @@
import { createWasmWrapper } from "@nodarium/utils";
import fs from "fs/promises";
import path from "path";
import { createWasmWrapper } from '@nodarium/utils';
import fs from 'fs/promises';
import path from 'path';
export async function getWasm(id: `${string}/${string}/${string}`) {
const filePath = path.resolve(`../nodes/${id}/pkg/index_bg.wasm`);
const filePath = path.resolve(`./static/nodes/${id}`);
try {
await fs.access(filePath);
} catch (e) {
console.error(`Failed to read node: ${id}`, e);
return null;
}
@@ -20,9 +21,11 @@ export async function getNodeWasm(id: `${string}/${string}/${string}`) {
const wasmBytes = await getWasm(id);
if (!wasmBytes) return null;
const wrapper = createWasmWrapper(wasmBytes);
return wrapper;
try {
return createWasmWrapper(wasmBytes.buffer);
} catch (error) {
console.error(`Failed to create node wrapper for node: ${id}`, error);
}
}
export async function getNode(id: `${string}/${string}/${string}`) {
@@ -36,12 +39,12 @@ export async function getNode(id: `${string}/${string}/${string}`) {
}
export async function getCollectionNodes(userId: `${string}/${string}`) {
const nodes = await fs.readdir(path.resolve(`../nodes/${userId}`));
const nodes = await fs.readdir(path.resolve(`./static/nodes/${userId}`));
return nodes
.filter((n) => n !== "pkg" && n !== ".template")
.filter((n) => n !== 'pkg' && n !== '.template')
.map((n) => {
return {
id: `${userId}/${n}`,
id: `${userId}/${n}`
};
});
}
@@ -50,20 +53,20 @@ export async function getCollection(userId: `${string}/${string}`) {
const nodes = await getCollectionNodes(userId);
return {
id: userId,
nodes,
nodes
};
}
export async function getUserCollections(userId: string) {
const collections = await fs.readdir(path.resolve(`../nodes/${userId}`));
const collections = await fs.readdir(path.resolve(`./static/nodes/${userId}`));
return Promise.all(
collections.map(async (n) => {
const nodes = await getCollectionNodes(`${userId}/${n}`);
return {
id: `${userId}/${n}`,
nodes,
nodes
};
}),
})
);
}
@@ -71,20 +74,20 @@ export async function getUser(userId: string) {
const collections = await getUserCollections(userId);
return {
id: userId,
collections,
collections
};
}
export async function getUsers() {
const nodes = await fs.readdir(path.resolve("../nodes"));
const nodes = await fs.readdir(path.resolve('./static/nodes'));
const users = await Promise.all(
nodes.map(async (n) => {
const collections = await getUserCollections(n);
return {
id: n,
collections,
collections
};
}),
})
);
return users;
}

View File

@@ -0,0 +1,2 @@
export * from './node-registry-cache';
export * from './node-registry-client';

View File

@@ -0,0 +1,56 @@
import type { AsyncCache } from '@nodarium/types';
import { type IDBPDatabase, openDB } from 'idb';
export class IndexDBCache implements AsyncCache<unknown> {
size: number = 100;
db: Promise<IDBPDatabase<unknown>>;
private _cache = new Map<string, unknown>();
constructor(id: string) {
this.db = openDB<unknown>('cache/' + id, 1, {
upgrade(db) {
db.createObjectStore('keyval');
}
});
}
async get<T>(key: string): Promise<T> {
let res = this._cache.get(key);
if (!res) {
res = await (await this.db).get('keyval', key);
}
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;
}
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

@@ -0,0 +1,168 @@
import {
type AsyncCache,
type NodeDefinition,
NodeDefinitionSchema,
type NodeRegistry
} from '@nodarium/types';
import { createLogger, createWasmWrapper } from '@nodarium/utils';
const log = createLogger('node-registry');
log.mute();
export class RemoteNodeRegistry implements NodeRegistry {
status: 'loading' | 'ready' | 'error' = 'loading';
private nodes: Map<string, NodeDefinition> = new Map();
constructor(
private url: string,
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`);
}
async fetchUser(userId: `${string}`) {
return this.fetchJson(`user/${userId}.json`);
}
async fetchCollection(userCollectionId: `${string}/${string}`) {
const col = await this.fetchJson(`nodes/${userCollectionId}.json`);
return col;
}
async fetchNodeDefinition(nodeId: `${string}/${string}/${string}`) {
return this.fetchJson(`nodes/${nodeId}.json`);
}
private async fetchNodeWasm(nodeId: `${string}/${string}/${string}`) {
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();
const nodes = (await Promise.all(
[...new Set(nodeIds).values()].map(async (id) => {
if (this.nodes.has(id)) {
return this.nodes.get(id)!;
}
const wasmBuffer = await this.fetchNodeWasm(id);
try {
return await this.register(id, wasmBuffer);
} catch (e) {
console.log('Failed to register: ', id);
console.error(e);
return;
}
})
)).filter(Boolean) as NodeDefinition[];
const duration = performance.now() - a;
log.group('loaded nodes in', duration, 'ms');
log.info(nodeIds);
log.info(nodes);
log.groupEnd();
this.status = 'ready';
return nodes;
}
async register(id: string, wasmBuffer: ArrayBuffer) {
let wrapper: ReturnType<typeof createWasmWrapper> = null!;
try {
wrapper = createWasmWrapper(wasmBuffer);
} catch (error) {
console.error(`Failed to create node wrapper for node: ${id}`, error);
}
const rawDefinition = wrapper.get_definition();
const definition = NodeDefinitionSchema.safeParse(rawDefinition);
if (definition.error) {
throw new Error(
'Failed to parse node definition from node:+\n' + JSON.stringify(rawDefinition, null, 2)
+ '\n'
+ definition.error
);
}
if (this.cache) {
this.cache.set(definition.data.id, wasmBuffer);
}
const node = {
...definition.data,
execute: wrapper.execute
};
this.nodes.set(definition.data.id, node);
return node;
}
getNode(id: string) {
return this.nodes.get(id);
}
getAllNodes() {
return [...this.nodes.values()];
}
}

View File

@@ -1,27 +1,28 @@
<script lang="ts">
import { Select } from "@nodarium/ui";
import type { Writable } from "svelte/store";
import { InputSelect } 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">
{#if activeUser}
<Select id="root" options={["root"]} bind:value={activeStore}></Select>
<InputSelect id="root" options={['root']} bind:value={activeStore}></InputSelect>
{#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}
@@ -34,7 +35,7 @@
<span>{activeUser}</span>
{/if}
{:else}
<Select id="root" options={["root"]} bind:value={activeStore}></Select>
<InputSelect id="root" options={['root']} bind:value={activeStore}></InputSelect>
{/if}
</div>
@@ -46,7 +47,7 @@
gap: 0.8em;
height: 35px;
box-sizing: border-box;
border-bottom: solid thin var(--outline);
border-bottom: solid thin var(--color-outline);
}
.breadcrumbs > button {
position: relative;

View File

@@ -1,39 +1,44 @@
<script lang="ts">
import NodeHtml from "$lib/graph-interface/node/NodeHTML.svelte";
import type { SerializedNode } from "@nodarium/types";
import NodeHtml from '$lib/graph-interface/node/NodeHTML.svelte';
import type { NodeDefinition, NodeId, NodeInstance } from '@nodarium/types';
import { onMount } from 'svelte';
const { node }: { node: SerializedNode } = $props();
const { node }: { node: NodeDefinition } = $props();
let dragging = $state(false);
let nodeData = $state({
id: 0,
type: node?.id,
position: [0, 0] as [number, number],
props: {},
tmp: {
type: node,
},
});
let nodeData = $state<NodeInstance>(null!);
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.toString());
if (nodeData.props) {
e.dataTransfer.setData("data/node-props", JSON.stringify(nodeData.props));
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('data/node-id', node.id.toString());
if (nodeData?.props) {
e.dataTransfer.setData('data/node-props', JSON.stringify(nodeData.props));
}
e.dataTransfer.setData(
"data/node-offset-x",
Math.round(box.left - e.clientX).toString(),
'data/node-offset-x',
Math.round(box.left - e.clientX).toString()
);
e.dataTransfer.setData(
"data/node-offset-y",
Math.round(box.top - e.clientY).toString(),
'data/node-offset-y',
Math.round(box.top - e.clientY).toString()
);
}
onMount(() => {
nodeData = {
id: 0,
type: node.id as unknown as NodeId,
position: [0, 0] as [number, number],
props: {},
state: {
type: node
}
};
});
</script>
<div class="node-wrapper" class:dragging>
@@ -46,7 +51,9 @@
tabindex="0"
ondragstart={handleDragStart}
>
<NodeHtml inView={true} position={"relative"} z={5} bind:node={nodeData} />
{#if nodeData}
<NodeHtml bind:node={nodeData} inView={true} position="relative" z={5} />
{/if}
</div>
</div>
@@ -61,7 +68,7 @@
}
.dragging {
border: dashed 2px var(--outline);
border: dashed 2px var(--color-outline);
}
.node-wrapper > div {
opacity: 1;

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 "@nodarium/registry";
import type { RemoteNodeRegistry } from '$lib/node-registry/index';
import BreadCrumbs from './BreadCrumbs.svelte';
import DraggableNode from './DraggableNode.svelte';
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}
@@ -25,12 +22,14 @@
{#await registry.fetchUsers()}
<div>Loading Users...</div>
{:then users}
{#each users as user}
{#each users as user (user.id)}
<button
on:click={() => {
$activeId = user.id;
}}>{user.id}</button
onclick={() => {
activeId = user.id;
}}
>
{user.id}
</button>
{/each}
{:catch error}
<div>{error.message}</div>
@@ -39,10 +38,10 @@
{#await registry.fetchUser(activeUser)}
<div>Loading User...</div>
{:then user}
{#each user.collections as collection}
{#each user.collections as collection (collection)}
<button
on:click={() => {
$activeId = collection.id;
onclick={() => {
activeId = collection.id;
}}
>
{collection.id.split(`/`)[1]}
@@ -55,7 +54,7 @@
{#await registry.fetchCollection(`${activeUser}/${activeCollection}`)}
<div>Loading Collection...</div>
{:then collection}
{#each collection.nodes as node}
{#each collection.nodes as node (node.id)}
{#await registry.fetchNodeDefinition(node.id)}
<div>Loading Node... {node.id}</div>
{:then node}

View File

@@ -1,29 +1,35 @@
<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();
let colors = ["red", "green", "blue"];
const total = $derived(values.reduce((acc, v) => acc + v, 0));
let colors = ['red', 'green', 'blue'];
</script>
<div class="wrapper">
<div class="bars">
{#each values as value, i}
<div class="bar bg-{colors[i]}" style="width: {(value / total) * 100}%;">
{#each values as value, i (value)}
<div
class="bar bg-{colors[i]}-400"
style:width={(value / total) * 100 + '%'}
>
{Math.round(value)}ms
</div>
{/each}
</div>
<div class="labels mt-2">
{#each values as _label, i}
<div class="text-{colors[i]}">{labels[i]}</div>
{#each values as _label, i (_label)}
<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,58 @@
<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) {
if (type === "%") {
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>
@@ -68,7 +74,7 @@
.wrapper {
position: relative;
border-bottom: solid thin var(--outline);
border-bottom: solid thin var(--color-outline);
display: flex;
}
p {
@@ -82,13 +88,13 @@
svg {
height: 124px;
margin: 24px 0px;
border-top: solid thin var(--outline);
border-bottom: solid thin var(--outline);
border-top: solid thin var(--color-outline);
border-bottom: solid thin var(--color-outline);
width: 100%;
}
polyline {
fill: none;
stroke: var(--layer-3);
stroke: var(--color-layer-3);
opacity: 0.5;
stroke-width: 1;
}

View File

@@ -1,24 +1,14 @@
<script lang="ts">
import Monitor from "./Monitor.svelte";
import { humanizeNumber } from "$lib/helpers";
import { Checkbox } from "@nodarium/ui";
import localStore from "$lib/helpers/localStore";
import { type PerformanceData } from "@nodarium/utils";
import BarSplit from "./BarSplit.svelte";
import { humanizeNumber } from '$lib/helpers';
import { InputCheckbox } from '@nodarium/ui';
import type { PerformanceData } from '@nodarium/utils';
import BarSplit from './BarSplit.svelte';
import Monitor from './Monitor.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,111 +20,136 @@
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('/')}`;
}
return t
.split('-')
.map((v) => v[0].toUpperCase() + v.slice(1))
.join(' ');
}
const viewerKeys = [
'total-vertices',
'total-faces',
'update-geometries',
'split-result'
];
// --- Small helpers that query `data` directly ---
function getAverage(key: string) {
const vals = data
.map((run) => run[key]?.[0])
.filter((v) => v !== undefined) as number[];
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;
}
function getLasts() {
return data.at(-1) || {};
}
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;
}, {});
});
function getTotalPerformance(onlyLast = false) {
if (onlyLast) {
return (
getLast("runtime") +
getLast("update-geometries") +
getLast("worker-transfer")
);
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/')
&& key !== 'total'
&& !key.includes('cache')
&& !viewerKeys.includes(key)
)
.sort((a, b) => b[1] - a[1]);
});
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]);
});
const viewerPerformanceData = $derived.by(() => {
const source = showAverage ? averages : lasts;
return Object.entries(source)
.filter(
([key]) =>
key !== 'total-vertices'
&& key !== 'total-faces'
&& viewerKeys.includes(key)
)
.sort((a, b) => b[1] - a[1]);
});
const splitValues = $derived.by(() => {
if (showAverage) {
return [
getAverage('worker-transfer'),
getAverage('runtime'),
getAverage('update-geometries')
];
}
return (
getAverage("runtime") +
getAverage("update-geometries") +
getAverage("worker-transfer")
);
}
return [
getLast('worker-transfer'),
getLast('runtime'),
getLast('update-geometries')
];
});
function getCacheRatio(onlyLast = false) {
let ratio = onlyLast ? getLast("cache-hit") : getAverage("cache-hit");
return Math.floor(ratio * 100);
}
const viewerKeys = [
"total-vertices",
"total-faces",
"update-geometries",
"split-result",
];
function getPerformanceData(onlyLast: boolean = false) {
let data = onlyLast ? getLasts() : getAverages();
return Object.entries(data)
.filter(
([key]) =>
!key.startsWith("node/") &&
key !== "total" &&
!key.includes("cache") &&
!viewerKeys.includes(key),
)
.sort((a, b) => b[1] - a[1]);
}
function getNodePerformanceData(onlyLast: boolean = false) {
let data = onlyLast ? getLasts() : getAverages();
return Object.entries(data)
.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)
.filter(
([key]) =>
key !== "total-vertices" &&
key !== "total-faces" &&
viewerKeys.includes(key),
)
.sort((a, b) => b[1] - a[1]);
}
function getTotalPoints() {
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["worker-transfer"]?.reduce((acc, v) => acc + v, 0) || 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)
);
});
}
return data.map((run) => {
return (
run["runtime"][0] +
run["update-geometries"][0] +
(run["worker-transfer"]?.[0] || 0)
(run['runtime']?.[0] || 0)
+ (run['update-geometries']?.[0] || 0)
+ (run['worker-transfer']?.[0] || 0)
);
});
}
});
function constructPoints(key: string) {
if (key === "total") {
return getTotalPoints();
if (key === 'total') {
return totalPoints;
}
return data.map((run) => {
if (key in run) {
@@ -148,166 +163,140 @@
});
}
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}
<div class="p-4 performance-tabler">
<div class="flex items-center gap-2">
<Checkbox id="show-total" bind:value={showAverage} />
<InputCheckbox id="show-total" bind:value={showAverage} />
<label for="show-total">Show Average</label>
</div>
{#if data.length !== 0}
<BarSplit
labels={["worker-transfer", "runtime", "update-geometries"]}
values={getSplitValues()}
/>
<BarSplit
labels={['worker-transfer', 'runtime', 'update-geometries']}
values={splitValues}
/>
<h3>General</h3>
<h3>General</h3>
<table>
<tbody>
<table>
<tbody>
<tr>
<td>
{computedTotalDisplay}<span>ms</span>
</td>
<td
class:active={activeType === 'total'}
onclick={() => (activeType = 'total')}
>
total<span>({computedFps}fps)</span>
</td>
</tr>
{#each performanceData as [key, value] (key)}
<tr>
<td>
{round(getTotalPerformance(!showAverage))}<span>ms</span>
</td>
<td>{round(value)}<span>ms</span></td>
<td
class:active={$activeType === "total"}
on:click={() => ($activeType = "total")}
class:active={activeType === key}
onclick={() => (activeType = key)}
>
total<span
>({Math.floor(
1000 / getTotalPerformance(showAverage),
)}fps)</span
>
{key}
</td>
</tr>
{#each getPerformanceData(!showAverage) as [key, value]}
<tr>
<td>
{round(value)}<span>ms</span>
</td>
<td
class:active={$activeType === key}
on:click={() => ($activeType = key)}
>
{key}
</td>
</tr>
{/each}
{/each}
<tr>
<td>{data.length}</td>
<td>Samples</td>
</tr>
</tbody>
<tbody>
<tr><td><h3>Nodes</h3></td></tr>
</tbody>
<tbody>
<tr>
<td>{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] (key)}
<tr>
<td>{data.length}</td>
<td>Samples</td>
</tr>
</tbody>
<tbody>
<tr>
<td>
<h3>Nodes</h3>
</td>
</tr>
</tbody>
<tbody>
<tr>
<td> {getCacheRatio(!showAverage)}<span>%</span> </td>
<td>{round(value)}<span>ms</span></td>
<td
class:active={$activeType === "cache-hit"}
on:click={() => ($activeType = "cache-hit")}>cache hits</td
class:active={activeType === key}
onclick={() => (activeType = key)}
>
</tr>
{#each getNodePerformanceData(!showAverage) as [key, value]}
<tr>
<td>
{round(value)}<span>ms</span>
</td>
<td
class:active={$activeType === key}
on:click={() => ($activeType = key)}
>
{key.split("/").slice(-1).join("/")}
</td>
</tr>
{/each}
</tbody>
<tbody>
<tr>
<td>
<h3>Viewer</h3>
{key.split('/').slice(-1).join('/')}
</td>
</tr>
</tbody>
<tbody>
{/each}
</tbody>
<tbody>
<tr><td><h3>Viewer</h3></td></tr>
</tbody>
<tbody>
<tr>
<td>{humanizeNumber(getLast('total-vertices'))}</td>
<td>Vertices</td>
</tr>
<tr>
<td>{humanizeNumber(getLast('total-faces'))}</td>
<td>Faces</td>
</tr>
{#each viewerPerformanceData as [key, value] (key)}
<tr>
<td>{humanizeNumber(getLast("total-vertices"))}</td>
<td>Vertices</td>
<td>{round(value)}<span>ms</span></td>
<td
class:active={activeType === key}
onclick={() => (activeType = key)}
>
{key.split('/').slice(-1).join('/')}
</td>
</tr>
<tr>
<td>{humanizeNumber(getLast("total-faces"))}</td>
<td>Faces</td>
</tr>
{#each getViewerPerformanceData(!showAverage) as [key, value]}
<tr>
<td>
{round(value)}<span>ms</span>
</td>
<td
class:active={$activeType === key}
on:click={() => ($activeType = key)}
>
{key.split("/").slice(-1).join("/")}
</td>
</tr>
{/each}
</tbody>
</table>
{:else}
<p>No runs available</p>
{/if}
{/each}
</tbody>
</table>
</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
@@ -10,14 +10,12 @@
const y = 100 - ((point - min) / (max - min)) * 100;
return `${x},${y}`;
})
.join(" ");
}
.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>
@@ -27,7 +25,7 @@
}
polyline {
fill: none;
stroke: var(--layer-3);
stroke: var(--color-layer-3);
opacity: 1;
stroke-width: 1;
}

View File

@@ -1,52 +1,53 @@
<script lang="ts">
import { humanizeDuration, humanizeNumber } from "$lib/helpers";
import localStore from "$lib/helpers/localStore";
import SmallGraph from "./SmallGraph.svelte";
import type { PerformanceData, PerformanceStore } from "@nodarium/utils";
import { humanizeDuration, humanizeNumber } from '$lib/helpers';
import { localState } from '$lib/helpers/localState.svelte';
import type { PerformanceData, PerformanceStore } from '@nodarium/utils';
import SmallGraph from './SmallGraph.svelte';
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,
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
style="cursor:pointer;"
on:click={() => ($open.runtime = !$open.runtime)}
style="cursor: pointer"
onclick={() => (open.value.runtime = !open.value.runtime)}
>
<td>{$open.runtime ? "-" : "+"} runtime </td>
<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")} />
<SmallGraph points={getPoints($store, 'runtime')} />
</td>
</tr>
{/if}
<tr style="cursor:pointer;" 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} />
@@ -55,12 +56,12 @@
{/if}
<tr>
<td>vertices </td>
<td>vertices</td>
<td>{humanizeNumber(vertices || 0)}</td>
</tr>
<tr>
<td>faces </td>
<td>faces</td>
<td>{humanizeNumber(faces || 0)}</td>
</tr>
</tbody>
@@ -73,14 +74,14 @@
top: 10px;
left: 10px;
z-index: 2;
background: var(--layer-0);
border: solid thin var(--outline);
background: var(--color-layer-0);
border: solid thin var(--color-outline);
border-collapse: collapse;
}
td {
padding: 4px;
padding-inline: 8px;
font-size: 0.8em;
border: solid thin var(--outline);
border: solid thin var(--color-outline);
}
</style>

View File

@@ -1 +1 @@
export { default as PerformanceViewer } from "./PerformanceViewer.svelte";
export { default as PerformanceViewer } from './PerformanceViewer.svelte';

View File

@@ -0,0 +1,107 @@
<script lang="ts">
import { defaultPlant, lottaFaces, plant } from '$lib/graph-templates';
import type { Graph } from '$lib/types';
import type { ProjectManager } from './project-manager.svelte';
const { projectManager } = $props<{ projectManager: ProjectManager }>();
let showNewProject = $state(false);
let newProjectName = $state('');
let selectedTemplate = $state('defaultPlant');
const templates = [
{
name: 'Default Plant',
value: 'defaultPlant',
graph: defaultPlant as unknown as Graph
},
{ name: 'Plant', value: 'plant', graph: plant as unknown as Graph },
{
name: 'Lotta Faces',
value: 'lottaFaces',
graph: lottaFaces as unknown as Graph
}
];
function handleCreate() {
const template = templates.find((t) => t.value === selectedTemplate) || templates[0];
projectManager.handleCreateProject(template.graph, newProjectName);
newProjectName = '';
showNewProject = false;
}
</script>
<header class="flex justify-between px-4 h-[70px] border-b-1 border-outline items-center">
<h3>Project</h3>
<button
class="px-3 py-1 bg-layer-0 rounded"
onclick={() => (showNewProject = !showNewProject)}
>
New
</button>
</header>
{#if showNewProject}
<div class="flex flex-col px-4 py-3 border-b-1 border-outline gap-2">
<input
type="text"
bind:value={newProjectName}
placeholder="Project name"
class="w-full px-2 py-2 bg-gray-800 border border-gray-700 rounded"
onkeydown={(e) => e.key === 'Enter' && handleCreate()}
/>
<select
bind:value={selectedTemplate}
class="w-full px-2 py-2 bg-gray-800 border border-gray-700 rounded"
>
{#each templates as template (template.name)}
<option value={template.value}>{template.name}</option>
{/each}
</select>
<button
class="cursor-pointer self-end px-3 py-1 bg-blue-600 rounded"
onclick={() => handleCreate()}
>
Create
</button>
</div>
{/if}
<div class="p-4 text-white min-h-screen">
{#if projectManager.loading}
<p>Loading...</p>
{/if}
<ul class="space-y-2">
{#each projectManager.projects as project (project.id)}
<li>
<div
class="
w-full text-left px-3 py-2 rounded cursor-pointer {projectManager
.activeProjectId.value === project.id
? 'bg-blue-600'
: 'bg-gray-800 hover:bg-gray-700'}
"
onclick={() => projectManager.handleSelectProject(project.id!)}
role="button"
tabindex="0"
onkeydown={(e) =>
e.key === 'Enter'
&& projectManager.handleSelectProject(project.id!)}
>
<div class="flex justify-between items-center">
<span>{project.meta?.title || 'Untitled'}</span>
<button
class="text-red-400 hover:text-red-300"
onclick={() => {
projectManager.handleDeleteProject(project.id!);
}}
>
×
</button>
</div>
</div>
</li>
{/each}
</ul>
</div>

View File

@@ -0,0 +1,53 @@
import type { Graph } from '@nodarium/types';
import { type IDBPDatabase, openDB } from 'idb';
export interface GraphDatabase {
projects: Graph;
}
const DB_NAME = 'nodarium-graphs';
const DB_VERSION = 1;
const STORE_NAME = 'graphs';
let dbPromise: Promise<IDBPDatabase<GraphDatabase>> | null = null;
export function getDB() {
if (!dbPromise) {
dbPromise = openDB<GraphDatabase>(DB_NAME, DB_VERSION, {
upgrade(db) {
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
}
}
});
}
return dbPromise;
}
export async function getGraph(id: number): Promise<Graph | undefined> {
const db = await getDB();
return db.get(STORE_NAME, id);
}
export async function saveGraph(graph: Graph): Promise<Graph> {
const db = await getDB();
// eslint-disable-next-line svelte/prefer-svelte-reactivity
graph.meta = { ...graph.meta, lastModified: new Date().toISOString() };
await db.put(STORE_NAME, graph);
return graph;
}
export async function deleteGraph(id: number): Promise<void> {
const db = await getDB();
await db.delete(STORE_NAME, id);
}
export async function getGraphs(): Promise<Graph[]> {
const db = await getDB();
return db.getAll(STORE_NAME);
}
export async function clear(): Promise<void> {
const db = await getDB();
return db.clear(STORE_NAME);
}

View File

@@ -0,0 +1,85 @@
import * as templates from '$lib/graph-templates';
import { localState } from '$lib/helpers/localState.svelte';
import type { Graph } from '@nodarium/types';
import * as db from './project-database.svelte';
export class ProjectManager {
public graph = $state<Graph>();
private projects = $state<Graph[]>([]);
private activeProjectId = localState<number | undefined>(
'node.activeProjectId',
undefined
);
public readonly loading = $derived(this.graph?.id !== this.activeProjectId.value);
constructor() {
this.init();
}
async saveGraph(g: Graph) {
db.saveGraph(g);
}
private async init() {
await db.getDB();
this.projects = await db.getGraphs();
if (this.activeProjectId.value !== undefined) {
const loadedGraph = await db.getGraph(this.activeProjectId.value);
if (loadedGraph) {
this.graph = loadedGraph;
}
}
if (!this.graph) {
if (this.projects?.length && this.projects[0]?.id !== undefined) {
this.graph = this.projects[0];
this.activeProjectId.value = this.graph.id;
}
}
if (!this.graph) {
this.handleCreateProject();
}
}
public handleCreateProject(
g: Graph = templates.defaultPlant as unknown as Graph,
title: string = 'New Project'
) {
let id = g?.id || 0;
while (this.projects.find((p) => p.id === id)) {
id++;
}
g.id = id;
if (!g.meta) g.meta = {};
if (!g.meta.title) g.meta.title = title;
db.saveGraph(g);
this.projects = [...this.projects, g];
this.handleSelectProject(id);
}
public async handleDeleteProject(projectId: number) {
await db.deleteGraph(projectId);
if (this.projects.length === 1) {
this.graph = undefined;
this.projects = [];
} else {
this.projects = this.projects.filter((p) => p.id !== projectId);
const id = this.projects[0].id;
if (id !== undefined) {
this.handleSelectProject(id);
}
}
}
public async handleSelectProject(id: number) {
if (this.activeProjectId.value !== id) {
const project = await db.getGraph(id);
this.graph = project;
this.activeProjectId.value = id;
}
}
}

View File

@@ -1,11 +1,11 @@
<script lang="ts">
import localStore from "$lib/helpers/localStore";
import { T, useTask } from "@threlte/core";
import { OrbitControls } from "@threlte/extras";
import { onMount } from "svelte";
import { Vector3 } from "three";
import type { PerspectiveCamera, Vector3Tuple } from "three";
import type { OrbitControls as OrbitControlsType } from "three/examples/jsm/controls/OrbitControls.js";
import localStore from '$lib/helpers/localStore';
import { T, useTask } from '@threlte/core';
import { OrbitControls } from '@threlte/extras';
import { onMount } from 'svelte';
import { Vector3 } from 'three';
import type { PerspectiveCamera, Vector3Tuple } from 'three';
import type { OrbitControls as OrbitControlsType } from 'three/examples/jsm/controls/OrbitControls.js';
let camera = $state<PerspectiveCamera>();
let controls = $state<OrbitControlsType>();
@@ -20,9 +20,9 @@
const cameraTransform = localStore<{
camera: Vector3Tuple;
target: Vector3Tuple;
}>("nodes.camera.transform", {
}>('nodes.camera.transform', {
camera: [10, 10, 10],
target: [0, 0, 0],
target: [0, 0, 0]
});
function saveCameraState() {
@@ -33,7 +33,7 @@
if (tPos.some((v) => isNaN(v)) || cPos.some((v) => isNaN(v))) return;
$cameraTransform = {
camera: cPos,
target: tPos,
target: tPos
};
}
@@ -54,13 +54,13 @@
$effect(() => {
if (
center &&
controls &&
centerCamera &&
(center.x !== controls.target.x ||
center.y !== controls.target.y ||
center.z !== controls.target.z) &&
!isRunning
center
&& controls
&& centerCamera
&& (center.x !== controls.target.x
|| center.y !== controls.target.y
|| center.z !== controls.target.z)
&& !isRunning
) {
isRunning = true;
task.start();

View File

@@ -1,23 +1,18 @@
<script lang="ts">
import { T, useTask, useThrelte } from "@threlte/core";
import { colors } from '$lib/graph-interface/graph/colors.svelte';
import { T, useTask, useThrelte } from '@threlte/core';
import { Grid, MeshLineGeometry, MeshLineMaterial, Text } from '@threlte/extras';
import {
Grid,
MeshLineGeometry,
MeshLineMaterial,
Text,
} from "@threlte/extras";
import {
type Group,
type BufferGeometry,
Vector3,
type Vector3Tuple,
Box3,
type BufferGeometry,
type Group,
Mesh,
MeshBasicMaterial,
} from "three";
import { appSettings } from "../settings/app-settings.svelte";
import Camera from "./Camera.svelte";
import { colors } from "$lib/graph-interface/graph/colors.svelte";
Vector3,
type Vector3Tuple
} from 'three';
import { appSettings } from '../settings/app-settings.svelte';
import Camera from './Camera.svelte';
const { renderStage, invalidate: _invalidate } = useThrelte();
@@ -32,24 +27,29 @@
lines,
centerCamera,
fps = $bindable(),
scene = $bindable(),
scene = $bindable()
}: Props = $props();
let geometries = $state.raw<BufferGeometry[]>([]);
let center = $state(new Vector3(0, 4, 0));
useTask(
(delta) => {
fps.push(1 / delta);
fps = fps.slice(-100);
},
{ stage: renderStage, autoInvalidate: false },
{ stage: renderStage, autoInvalidate: false }
);
export const invalidate = function () {
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,21 +62,30 @@
_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;
function isMesh(child: unknown): child is Mesh {
return (
child !== null
&& typeof child === 'object'
&& 'isObject3D' in child
&& child.isObject3D === true
&& 'material' in child
);
}
function isMatCapMaterial(material: any): material is MeshBasicMaterial {
return material.isMaterial && "matcap" in material;
function isMatCapMaterial(material: unknown): material is MeshBasicMaterial {
return (
material !== null
&& typeof material === 'object'
&& 'isMaterial' in material
&& material.isMaterial === true
&& 'matcap' in material
);
}
$effect(() => {
const wireframe = appSettings.value.debug.wireframe;
scene.traverse(function (child) {
if (isMesh(child) && isMatCapMaterial(child.material)) {
scene.traverse(function(child) {
if (isMesh(child) && isMatCapMaterial(child.material) && child.visible) {
child.material.wireframe = wireframe;
}
});
@@ -87,7 +96,7 @@
return [
geo.attributes.position.array[i],
geo.attributes.position.array[i + 1],
geo.attributes.position.array[i + 2],
geo.attributes.position.array[i + 2]
] as Vector3Tuple;
}
</script>
@@ -96,12 +105,12 @@
{#if appSettings.value.showGrid}
<Grid
cellColor={colors["outline"]}
cellColor={colors['outline']}
cellThickness={0.7}
infiniteGrid
sectionThickness={0.7}
sectionDistance={2}
sectionColor={colors["outline"]}
sectionColor={colors['outline']}
fadeDistance={50}
fadeStrength={10}
fadeOrigin={new Vector3(0, 0, 0)}
@@ -110,9 +119,9 @@
<T.Group>
{#if geometries}
{#each geometries as geo}
{#each geometries as geo (geo.id)}
{#if appSettings.value.debug.showIndices}
{#each geo.attributes.position.array as _, i}
{#each geo.attributes.position.array, i (i)}
{#if i % 3 === 0}
<Text fontSize={0.25} position={getPosition(geo, i)} />
{/if}
@@ -132,7 +141,7 @@
</T.Group>
{#if appSettings.value.debug.showStemLines && lines}
{#each lines as line}
{#each lines as line (line[0].x + '-' + line[0].y + '-' + '' + line[0].z)}
<T.Mesh>
<MeshLineGeometry points={line} />
<MeshLineMaterial width={0.1} color="red" depthTest={false} />

View File

@@ -1,23 +1,20 @@
<script lang="ts">
import { Canvas } from "@threlte/core";
import Scene from "./Scene.svelte";
import { Vector3 } from "three";
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";
import {
createGeometryPool,
createInstancedGeometryPool,
} from "./geometryPool";
import SmallPerformanceViewer from '$lib/performance/SmallPerformanceViewer.svelte';
import { appSettings } from '$lib/settings/app-settings.svelte';
import { decodeFloat, splitNestedArray } from '@nodarium/utils';
import type { PerformanceStore } from '@nodarium/utils';
import { Canvas } from '@threlte/core';
import { Vector3 } from 'three';
import { type Group, MeshMatcapMaterial, TextureLoader } from 'three';
import { createGeometryPool, createInstancedGeometryPool } from './geometryPool';
import Scene from './Scene.svelte';
const loader = new TextureLoader();
const matcap = loader.load("/matcap_green.jpg");
matcap.colorSpace = "srgb";
const matcap = loader.load('/matcap_green.jpg');
matcap.colorSpace = 'srgb';
const material = new MeshMatcapMaterial({
color: 0xffffff,
matcap,
matcap
});
let sceneComponent = $state<ReturnType<typeof Scene>>();
@@ -34,7 +31,7 @@
return {
totalFaces: meshes.totalFaces + faces.totalFaces,
totalVertices: meshes.totalVertices + faces.totalVertices,
totalVertices: meshes.totalVertices + faces.totalVertices
};
}
@@ -64,12 +61,12 @@
}
export const update = function update(result: Int32Array) {
perf.addPoint("split-result");
perf.addPoint('split-result');
const inputs = splitNestedArray(result);
perf.endPoint();
if (appSettings.value.debug.showStemLines) {
perf.addPoint("create-lines");
perf.addPoint('create-lines');
lines = inputs
.map((input) => {
if (input[0] === 0) {
@@ -80,13 +77,13 @@
perf.endPoint();
}
perf.addPoint("update-geometries");
perf.addPoint('update-geometries');
const { totalVertices, totalFaces } = updateGeometries(inputs, scene);
perf.endPoint();
perf.addPoint("total-vertices", totalVertices);
perf.addPoint("total-faces", totalFaces);
perf.addPoint('total-vertices', totalVertices);
perf.addPoint('total-faces', totalFaces);
sceneComponent?.invalidate();
};
</script>
@@ -95,12 +92,14 @@
<SmallPerformanceViewer {fps} store={perf} />
{/if}
<Canvas>
<Scene
bind:this={sceneComponent}
{lines}
{centerCamera}
bind:scene
bind:fps
/>
</Canvas>
<div style="height: 100%">
<Canvas>
<Scene
bind:this={sceneComponent}
{lines}
{centerCamera}
bind:scene
bind:fps
/>
</Canvas>
</div>

View File

@@ -1,4 +1,4 @@
import { fastHashArrayBuffer } from "@nodarium/utils";
import { fastHashArrayBuffer } from '@nodarium/utils';
import {
BufferAttribute,
BufferGeometry,
@@ -7,14 +7,14 @@ import {
InstancedMesh,
Material,
Matrix4,
Mesh,
} from "three";
Mesh
} 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);
const hash = new Int32Array(sampleCount);
for (let i = 0; i < sampleCount; i++) {
const index = i * sampleDistance;
@@ -28,18 +28,21 @@ export function createGeometryPool(parentScene: Group, material: Material) {
const scene = new Group();
parentScene.add(scene);
let meshes: Mesh[] = [];
const meshes: Mesh[] = [];
let totalVertices = 0;
let totalFaces = 0;
function updateSingleGeometry(
data: Int32Array,
existingMesh: Mesh | null = null,
existingMesh: Mesh | null = null
) {
let hash = fastArrayHash(data);
const hash = fastArrayHash(data);
let geometry = existingMesh ? existingMesh.geometry : new BufferGeometry();
const geometry = existingMesh ? existingMesh.geometry : new BufferGeometry();
if (existingMesh) {
existingMesh.visible = true;
}
// Extract data from the encoded array
// const geometryType = encodedData[index++];
@@ -62,8 +65,8 @@ export function createGeometryPool(parentScene: Group, material: Material) {
const vertices = new Float32Array(data.buffer, index * 4, vertexCount * 3);
index = index + vertexCount * 3;
let posAttribute = geometry.getAttribute(
"position",
const posAttribute = geometry.getAttribute(
'position'
) as BufferAttribute | null;
if (posAttribute && posAttribute.count === vertexCount) {
@@ -71,8 +74,8 @@ export function createGeometryPool(parentScene: Group, material: Material) {
posAttribute.needsUpdate = true;
} else {
geometry.setAttribute(
"position",
new Float32BufferAttribute(vertices, 3),
'position',
new Float32BufferAttribute(vertices, 3)
);
}
@@ -80,27 +83,27 @@ export function createGeometryPool(parentScene: Group, material: Material) {
index = index + vertexCount * 3;
if (
geometry.userData?.faceCount !== faceCount ||
geometry.userData?.vertexCount !== vertexCount
geometry.userData?.faceCount !== faceCount
|| geometry.userData?.vertexCount !== vertexCount
) {
// Add data to geometry
geometry.setIndex([...indices]);
}
const normalsAttribute = geometry.getAttribute(
"normal",
'normal'
) as BufferAttribute | null;
if (normalsAttribute && normalsAttribute.count === vertexCount) {
normalsAttribute.set(normals, 0);
normalsAttribute.needsUpdate = true;
} else {
geometry.setAttribute("normal", new Float32BufferAttribute(normals, 3));
geometry.setAttribute('normal', new Float32BufferAttribute(normals, 3));
}
geometry.userData = {
vertexCount,
faceCount,
hash,
hash
};
if (!existingMesh) {
@@ -116,22 +119,21 @@ export function createGeometryPool(parentScene: Group, material: Material) {
totalFaces = 0;
for (let i = 0; i < Math.max(newData.length, meshes.length); i++) {
const existingMesh = meshes[i];
let input = newData[i];
const input = newData[i];
if (input) {
updateSingleGeometry(input, existingMesh || null);
} else if (existingMesh) {
existingMesh.visible = false;
scene.remove(existingMesh);
}
}
return { totalVertices, totalFaces };
},
}
};
}
export function createInstancedGeometryPool(
parentScene: Group,
material: Material,
material: Material
) {
const scene = new Group();
parentScene.add(scene);
@@ -142,11 +144,11 @@ export function createInstancedGeometryPool(
function updateSingleInstance(
data: Int32Array,
existingInstance: InstancedMesh | null = null,
existingInstance: InstancedMesh | null = null
) {
let hash = fastArrayHash(data);
const hash = fastArrayHash(data);
let geometry = existingInstance
const geometry = existingInstance
? existingInstance.geometry
: new BufferGeometry();
@@ -167,8 +169,8 @@ export function createInstancedGeometryPool(
const indices = data.subarray(index, indicesEnd);
index = indicesEnd;
if (
geometry.userData?.faceCount !== faceCount ||
geometry.userData?.vertexCount !== vertexCount
geometry.userData?.faceCount !== faceCount
|| geometry.userData?.vertexCount !== vertexCount
) {
// Add data to geometry
geometry.setIndex([...indices]);
@@ -177,34 +179,34 @@ export function createInstancedGeometryPool(
// Vertices
const vertices = new Float32Array(data.buffer, index * 4, vertexCount * 3);
index = index + vertexCount * 3;
let posAttribute = geometry.getAttribute(
"position",
const posAttribute = geometry.getAttribute(
'position'
) as BufferAttribute | null;
if (posAttribute && posAttribute.count === vertexCount) {
posAttribute.set(vertices, 0);
posAttribute.needsUpdate = true;
} else {
geometry.setAttribute(
"position",
new Float32BufferAttribute(vertices, 3),
'position',
new Float32BufferAttribute(vertices, 3)
);
}
const normals = new Float32Array(data.buffer, index * 4, vertexCount * 3);
index = index + vertexCount * 3;
const normalsAttribute = geometry.getAttribute(
"normal",
'normal'
) as BufferAttribute | null;
if (normalsAttribute && normalsAttribute.count === vertexCount) {
normalsAttribute.set(normals, 0);
normalsAttribute.needsUpdate = true;
} else {
geometry.setAttribute("normal", new Float32BufferAttribute(normals, 3));
geometry.setAttribute('normal', new Float32BufferAttribute(normals, 3));
}
if (
existingInstance &&
instanceCount > existingInstance.geometry.userData.count
existingInstance
&& instanceCount > existingInstance.geometry.userData.count
) {
scene.remove(existingInstance);
instances.splice(instances.indexOf(existingInstance), 1);
@@ -224,12 +226,12 @@ export function createInstancedGeometryPool(
const matrices = new Float32Array(
data.buffer,
index * 4,
instanceCount * 16,
instanceCount * 16
);
for (let i = 0; i < instanceCount; i++) {
const matrix = new Matrix4().fromArray(
matrices.subarray(i * 16, i * 16 + 16),
matrices.subarray(i * 16, i * 16 + 16)
);
existingInstance.setMatrixAt(i, matrix);
}
@@ -239,9 +241,9 @@ export function createInstancedGeometryPool(
faceCount,
count: Math.max(
instanceCount,
existingInstance.geometry.userData.count || 0,
existingInstance.geometry.userData.count || 0
),
hash,
hash
};
existingInstance.instanceMatrix.needsUpdate = true;
@@ -253,15 +255,14 @@ export function createInstancedGeometryPool(
totalFaces = 0;
for (let i = 0; i < Math.max(newData.length, instances.length); i++) {
const existingMesh = instances[i];
let input = newData[i];
const input = newData[i];
if (input) {
updateSingleInstance(input, existingMesh || null);
} else if (existingMesh) {
existingMesh.visible = false;
scene.remove(existingMesh);
}
}
return { totalVertices, totalFaces };
},
}
};
}

View File

@@ -1,4 +1,3 @@
export * from "./runtime-executor"
export * from "./runtime-executor-cache"
export * from "./worker-runtime-executor"
export * from './runtime-executor';
export * from './runtime-executor-cache';
export * from './worker-runtime-executor';

View File

@@ -1,18 +1,18 @@
import type { Graph, RuntimeExecutor } from "@nodarium/types";
import type { Graph, RuntimeExecutor } from '@nodarium/types';
export class RemoteRuntimeExecutor implements RuntimeExecutor {
constructor(private url: string) {}
constructor(private url: string) { }
async execute(graph: Graph, settings: Record<string, any>): Promise<Int32Array> {
const res = await fetch(this.url, { method: "POST", body: JSON.stringify({ graph, settings }) });
async execute(graph: Graph, settings: Record<string, unknown>): Promise<Int32Array> {
const res = await fetch(this.url, {
method: 'POST',
body: JSON.stringify({ graph, settings })
});
if (!res.ok) {
throw new Error(`Failed to execute graph`);
}
return new Int32Array(await res.arrayBuffer());
}
}

View File

@@ -1,19 +1,33 @@
import { type SyncCache } from "@nodarium/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

@@ -4,47 +4,47 @@ import type {
NodeInput,
NodeRegistry,
RuntimeExecutor,
SyncCache,
} from "@nodarium/types";
SyncCache
} from '@nodarium/types';
import {
concatEncodedArrays,
createLogger,
encodeFloat,
fastHashArrayBuffer,
type PerformanceStore,
} from "@nodarium/utils";
import type { RuntimeNode } from "./types";
type PerformanceStore
} from '@nodarium/utils';
import type { RuntimeNode } from './types';
const log = createLogger("runtime-executor");
const log = createLogger('runtime-executor');
log.mute();
function getValue(input: NodeInput, value?: unknown) {
if (value === undefined && "value" in input) {
if (value === undefined && 'value' in input) {
value = input.value;
}
if (input.type === "float") {
if (input.type === 'float') {
return encodeFloat(value as number);
}
if (Array.isArray(value)) {
if (input.type === "vec3") {
if (input.type === 'vec3') {
return [
0,
value.length + 1,
...value.map((v) => encodeFloat(v)),
1,
1,
1
] as number[];
}
return [0, value.length + 1, ...value, 1, 1] as number[];
}
if (typeof value === "boolean") {
if (typeof value === 'boolean') {
return value ? 1 : 0;
}
if (typeof value === "number") {
if (typeof value === 'number') {
return value;
}
@@ -64,14 +64,14 @@ export class MemoryRuntimeExecutor implements RuntimeExecutor {
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") {
throw new Error("Node registry is not ready");
if (this.registry.status !== 'ready') {
throw new Error('Node registry is not ready');
}
await this.registry.load(graph.nodes.map((node) => node.type));
@@ -98,26 +98,23 @@ export class MemoryRuntimeExecutor implements RuntimeExecutor {
depth: 0,
children: [],
parents: [],
inputNodes: {},
}
return n
})
inputNodes: {}
};
return n;
});
const outputNode = graphNodes.find((node) =>
node.type.endsWith("/output"),
);
const outputNode = graphNodes.find((node) => node.type.endsWith('/output'));
if (!outputNode) {
throw new Error("No output node found");
throw new Error('No output node found');
}
const nodeMap = new Map(
graphNodes.map((node) => [node.id, node]),
graphNodes.map((node) => [node.id, node])
);
// loop through all edges and assign the parent and child nodes to each node
for (const edge of graph.edges) {
const [parentId, _parentOutput, childId, childInput] = edge;
const [parentId, /*_parentOutput*/, childId, childInput] = edge;
const parent = nodeMap.get(parentId);
const child = nodeMap.get(childId);
if (parent && child) {
@@ -146,7 +143,7 @@ export class MemoryRuntimeExecutor implements RuntimeExecutor {
}
async execute(graph: Graph, settings: Record<string, unknown>) {
this.perf?.addPoint("runtime");
this.perf?.addPoint('runtime');
let a = performance.now();
@@ -154,7 +151,7 @@ export class MemoryRuntimeExecutor implements RuntimeExecutor {
const [outputNode, nodes] = await this.addMetaData(graph);
let b = performance.now();
this.perf?.addPoint("collect-metadata", b - a);
this.perf?.addPoint('collect-metadata', b - a);
/*
* Here we sort the nodes into buckets, which we then execute one by one
@@ -169,13 +166,13 @@ export class MemoryRuntimeExecutor implements RuntimeExecutor {
// we execute the nodes from the bottom up
const sortedNodes = nodes.sort(
(a, b) => (b.state?.depth || 0) - (a.state?.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"]) {
if (settings['randomSeed']) {
this.seed = Math.floor(Math.random() * 100000000);
}
@@ -192,7 +189,7 @@ export class MemoryRuntimeExecutor implements RuntimeExecutor {
// Collect the inputs for the node
const inputs = Object.entries(node_type.inputs || {}).map(
([key, input]) => {
if (input.type === "seed") {
if (input.type === 'seed') {
return this.seed;
}
@@ -206,7 +203,7 @@ export class MemoryRuntimeExecutor implements RuntimeExecutor {
if (inputNode) {
if (results[inputNode.id] === undefined) {
throw new Error(
`Node ${node.type} is missing input from node ${inputNode.type}`,
`Node ${node.type} is missing input from node ${inputNode.type}`
);
}
return results[inputNode.id];
@@ -218,44 +215,45 @@ export class MemoryRuntimeExecutor implements RuntimeExecutor {
}
return getValue(input);
},
}
);
b = performance.now();
this.perf?.addPoint("collected-inputs", b - a);
this.perf?.addPoint('collected-inputs', b - a);
try {
a = performance.now();
const encoded_inputs = concatEncodedArrays(inputs);
b = performance.now();
this.perf?.addPoint("encoded-inputs", b - a);
this.perf?.addPoint('encoded-inputs', b - a);
a = performance.now();
let inputHash = `node-${node.id}-${fastHashArrayBuffer(encoded_inputs)}`;
const inputHash = `node-${node.id}-${fastHashArrayBuffer(encoded_inputs)}`;
b = performance.now();
this.perf?.addPoint("hash-inputs", b - a);
this.perf?.addPoint('hash-inputs', b - a);
let cachedValue = this.cache?.get(inputHash);
const cachedValue = this.cache?.get(inputHash);
if (cachedValue !== undefined) {
log.log(`Using cached value for ${node_type.id || node.id}`);
this.perf?.addPoint("cache-hit", 1);
this.perf?.addPoint('cache-hit', 1);
results[node.id] = cachedValue as Int32Array;
continue;
}
this.perf?.addPoint("cache-hit", 0);
this.perf?.addPoint('cache-hit', 0);
log.group(`executing ${node_type.id || node.id}`);
log.group(`executing ${node_type.id}-${node.id}`);
log.log(`Inputs:`, inputs);
a = performance.now();
results[node.id] = node_type.execute(encoded_inputs);
log.log('Executed', node.type, node.id);
b = performance.now();
if (this.cache) {
if (this.cache && node.id !== outputNode.id) {
this.cache.set(inputHash, results[node.id]);
}
this.perf?.addPoint("node/" + node_type.id, b - a);
log.log("Result:", results[node.id]);
this.perf?.addPoint('node/' + node_type.id, b - a);
log.log('Result:', results[node.id]);
log.groupEnd();
} catch (e) {
log.groupEnd();
@@ -270,7 +268,7 @@ export class MemoryRuntimeExecutor implements RuntimeExecutor {
this.cache.size = sortedNodes.length * 2;
}
this.perf?.endPoint("runtime");
this.perf?.endPoint('runtime');
return res as unknown as Int32Array;
}

View File

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

View File

@@ -1,23 +1,41 @@
import { MemoryRuntimeExecutor } from "./runtime-executor";
import { RemoteNodeRegistry, IndexDBCache } from "@nodarium/registry";
import type { Graph } from "@nodarium/types";
import { createPerformanceStore } from "@nodarium/utils";
import { IndexDBCache, RemoteNodeRegistry } from '$lib/node-registry/index';
import type { Graph } from '@nodarium/types';
import { createPerformanceStore } from '@nodarium/utils';
import { MemoryRuntimeExecutor } from './runtime-executor';
import { MemoryRuntimeCache } from './runtime-executor-cache';
const indexDbCache = new IndexDBCache("node-registry");
const nodeRegistry = new RemoteNodeRegistry("", indexDbCache);
const indexDbCache = new IndexDBCache('node-registry');
const nodeRegistry = new RemoteNodeRegistry('', indexDbCache);
const executor = new MemoryRuntimeExecutor(nodeRegistry);
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>,
settings: Record<string, unknown>
): Promise<Int32Array> {
await nodeRegistry.load(graph.nodes.map((n) => n.type));
performanceStore.startRun();
let res = await executor.execute(graph, settings);
const res = await executor.execute(graph, settings);
performanceStore.stopRun();
return res;
}

View File

@@ -1,9 +1,10 @@
/// <reference types="vite-plugin-comlink/client" />
import type { Graph, RuntimeExecutor } from "@nodarium/types";
import type { Graph, RuntimeExecutor } from '@nodarium/types';
export class WorkerRuntimeExecutor implements RuntimeExecutor {
private worker = new ComlinkWorker<typeof import('./worker-runtime-executor-backend.ts')>(new URL(`./worker-runtime-executor-backend.ts`, import.meta.url));
private worker = new ComlinkWorker<typeof import('./worker-runtime-executor-backend.ts')>(
new URL(`./worker-runtime-executor-backend.ts`, import.meta.url)
);
async execute(graph: Graph, settings: Record<string, unknown>) {
return this.worker.executeGraph(graph, settings);
@@ -11,5 +12,10 @@ 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,11 @@
<script lang="ts">
import NestedSettings from "./NestedSettings.svelte";
import { localState } from "$lib/helpers/localState.svelte";
import type { NodeInput } from "@nodarium/types";
import Input from "@nodarium/ui";
import { localState } from '$lib/helpers/localState.svelte';
import type { NodeInput } from '@nodarium/types';
import Input from '@nodarium/ui';
import { onMount } from 'svelte';
import NestedSettings from './NestedSettings.svelte';
type Button = { type: "button"; callback: () => void; label?: string };
type Button = { type: 'button'; label?: string };
type InputType = NodeInput | Button;
@@ -12,7 +13,7 @@
interface SettingsGroup {
title?: string;
[key: string]: any;
[key: string]: unknown;
}
type SettingsType = Record<string, SettingsNode>;
@@ -31,44 +32,49 @@
};
// Local persistent state for <details> sections
const openSections = localState<Record<string, boolean>>("open-details", {});
const openSections = localState<Record<string, boolean>>('open-details', {});
let { id, key = "", value = $bindable(), type, depth = 0 }: Props = $props();
let { id, key = '', value = $bindable(), type, depth = 0 }: Props = $props();
function isNodeInput(v: SettingsNode | undefined): v is InputType {
return !!v && typeof v === "object" && "type" in v;
return !!v && typeof v === 'object' && 'type' in v;
}
function getDefaultValue(): unknown {
if (key === "" || key === "title") return;
function getDefaultValue(): NodeInput['value'] | undefined {
if (key === '' || key === 'title') return;
const node = type[key];
const node = type[key] as SettingsNode;
const inputValue = value[key];
if (!isNodeInput(node)) return;
const anyNode = node as any;
// select input: use index into options
if (Array.isArray(anyNode.options)) {
if (value?.[key] !== undefined) {
return anyNode.options.indexOf(value[key]);
if ('options' in node && Array.isArray(node.options)) {
if (typeof inputValue === 'string') {
return node.options.indexOf(inputValue);
}
return 0;
}
if (value?.[key] !== undefined) return value[key];
// If the component is supplied with a default value use that
if (inputValue !== undefined && typeof inputValue !== 'object') {
return inputValue;
}
if ("value" in node && anyNode.value !== undefined) {
return anyNode.value;
if ('value' in node) {
const nodeValue = node.value;
if (nodeValue !== null && nodeValue !== undefined) {
return nodeValue;
}
}
switch (node.type) {
case "boolean":
case 'boolean':
return 0;
case "float":
case 'float':
return 0.5;
case "integer":
case "select":
case 'integer':
case 'select':
return 0;
default:
return 0;
@@ -77,52 +83,63 @@
let internalValue = $state(getDefaultValue());
let open = $state(openSections.value[id]);
// Persist <details> open/closed state for groups
if (depth > 0 && !isNodeInput(type[key!])) {
$effect(() => {
if (open !== undefined) {
openSections.value[id] = open;
}
});
}
let open = $state(false);
// Sync internalValue back into `value`
$effect(() => {
if (key === "" || internalValue === undefined) return;
if (key === '' || internalValue === undefined) return;
const node = type[key];
if (
isNodeInput(node) &&
Array.isArray((node as any).options) &&
typeof internalValue === "number"
isNodeInput(node)
&& 'options' in node
&& Array.isArray(node.options)
&& typeof internalValue === 'number'
) {
value[key] = (node as any)?.options?.[internalValue] as any;
} else {
value[key] = internalValue as any;
value[key] = node?.options?.[internalValue];
} else if (internalValue) {
value[key] = internalValue;
}
});
function handleClick() {
const callback = value[key] as unknown as () => void;
callback();
}
onMount(() => {
open = openSections.value[id];
// Persist <details> open/closed state for groups
if (depth > 0 && !isNodeInput(type[key!])) {
$effect(() => {
if (open !== undefined) {
openSections.value[id] = open;
}
});
}
});
</script>
{#if key && isNodeInput(type?.[key])}
{@const inputType = type[key]}
<!-- Leaf input -->
<div class="input input-{type[key].type}" class:first-level={depth === 1}>
{#if type[key].type === "button"}
<button onclick={() => "callback" in type[key] && type[key].callback()}>
{type[key].label || key}
<div class="input input-{inputType.type}" class:first-level={depth === 1}>
{#if inputType.type === 'button'}
<button onclick={handleClick}>
{inputType.label || key}
</button>
{:else}
{#if type[key].label !== ""}
<label for={id}>{type[key].label || key}</label>
{#if inputType.label !== ''}
<label for={id}>{inputType.label || key}</label>
{/if}
<Input {id} input={type[key]} bind:value={internalValue} />
<Input {id} input={inputType} bind:value={internalValue} />
{/if}
</div>
{:else if depth === 0}
<!-- Root: iterate over top-level keys -->
{#each Object.keys(type ?? {}).filter((k) => k !== "title") as childKey}
{#each Object.keys(type ?? {}).filter((k) => k !== 'title') as childKey (childKey)}
<NestedSettings
id={`${id}.${childKey}`}
key={childKey}
@@ -140,7 +157,7 @@
<details bind:open>
<summary><p>{(type[key] as SettingsGroup).title || key}</p></summary>
<div class="content">
{#each Object.keys(type[key] as SettingsGroup).filter((k) => k !== "title") as childKey}
{#each Object.keys(type[key] as SettingsGroup).filter((k) => k !== 'title') as childKey (childKey)}
<NestedSettings
id={`${id}.${childKey}`}
key={childKey}
@@ -194,6 +211,11 @@
padding-left: 1em;
padding-right: 1em;
padding-bottom: 1px;
gap: 3px;
}
.first-level.input-boolean {
gap: 10px;
}
button {
@@ -201,11 +223,10 @@
}
hr {
position: absolute;
margin: 0;
left: 0;
right: 0;
border: none;
border-bottom: solid thin var(--outline);
border-bottom: solid thin var(--color-outline);
}
</style>

View File

@@ -1,156 +1,163 @@
import { localState } from "$lib/helpers/localState.svelte";
import { localState } from '$lib/helpers/localState.svelte';
const themes = [
"dark",
"light",
"catppuccin",
"solarized",
"high-contrast",
"nord",
"dracula",
'dark',
'light',
'catppuccin',
'solarized',
'high-contrast',
'nord',
'dracula'
] as const;
export const AppSettingTypes = {
theme: {
type: "select",
type: 'select',
options: themes,
label: "Theme",
value: themes[0],
label: 'Theme',
value: themes[0]
},
showGrid: {
type: "boolean",
label: "Show Grid",
value: true,
type: 'boolean',
label: 'Show Grid',
value: true
},
centerCamera: {
type: "boolean",
label: "Center Camera",
value: true,
type: 'boolean',
label: 'Center Camera',
value: true
},
nodeInterface: {
title: "Node Interface",
title: 'Node Interface',
showNodeGrid: {
type: "boolean",
label: "Show Grid",
value: true,
type: 'boolean',
label: 'Show Grid',
value: true
},
snapToGrid: {
type: "boolean",
label: "Snap to Grid",
value: true,
type: 'boolean',
label: 'Snap to Grid',
value: true
},
showHelp: {
type: "boolean",
label: "Show Help",
value: false,
},
type: 'boolean',
label: 'Show Help',
value: false
}
},
debug: {
title: "Debug",
title: 'Debug',
wireframe: {
type: "boolean",
label: "Wireframe",
value: false,
type: 'boolean',
label: 'Wireframe',
value: false
},
useWorker: {
type: "boolean",
label: "Execute runtime in worker",
value: true,
type: 'boolean',
label: 'Execute in WebWorker',
value: true
},
showIndices: {
type: "boolean",
label: "Show Indices",
value: false,
type: 'boolean',
label: 'Show Indices',
value: false
},
showPerformancePanel: {
type: "boolean",
label: "Show Performance Panel",
value: false,
type: 'boolean',
label: 'Show Performance Panel',
value: false
},
showBenchmarkPanel: {
type: "boolean",
label: "Show Benchmark Panel",
value: false,
type: 'boolean',
label: 'Show Benchmark Panel',
value: false
},
showVertices: {
type: "boolean",
label: "Show Vertices",
value: false,
type: 'boolean',
label: 'Show Vertices',
value: false
},
showStemLines: {
type: "boolean",
label: "Show Stem Lines",
value: false,
type: 'boolean',
label: 'Show Stem Lines',
value: false
},
showGraphJson: {
type: "boolean",
label: "Show Graph Source",
value: false,
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",
title: 'Stress Test',
amount: {
type: "integer",
type: 'integer',
min: 2,
max: 15,
value: 4,
value: 4
},
loadGrid: {
type: "button",
label: "Load Grid",
type: 'button',
label: 'Load Grid'
},
loadTree: {
type: "button",
label: "Load Tree",
type: 'button',
label: 'Load Tree'
},
lottaFaces: {
type: "button",
label: "Load 'lots of faces'",
type: 'button',
label: "Load 'lots of faces'"
},
lottaNodes: {
type: "button",
label: "Load 'lots of nodes'",
type: 'button',
label: "Load 'lots of nodes'"
},
lottaNodesAndFaces: {
type: "button",
label: "Load 'lots of nodes and faces'",
},
},
},
type: 'button',
label: "Load 'lots of nodes and faces'"
}
}
}
} as const;
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]>
}
type SettingsToStore<T> = T extends { type: 'button' } ? () => void
: T extends { value: infer V } ? V extends readonly string[] ? V[number]
: V
: T extends object ? {
-readonly [K in keyof T as T[K] extends object ? K : never]: SettingsToStore<T[K]>;
}
: never;
export function settingsToStore<T>(settings: T): SettingsToStore<T> {
const result = {} as any;
const result = {} as Record<string, unknown>;
for (const key in settings) {
const value = settings[key];
if (value && typeof value === "object") {
if ("value" in value) {
if (value && typeof value === 'object') {
if ('value' in value) {
result[key] = value.value;
} else {
result[key] = settingsToStore(value);
}
}
}
return result;
return result as SettingsToStore<T>;
}
export let appSettings = localState(
"app-settings",
settingsToStore(AppSettingTypes),
export const appSettings = localState(
'app-settings',
settingsToStore(AppSettingTypes)
);
$effect.root(() => {
@@ -160,7 +167,7 @@ $effect.root(() => {
const newClassName = `theme-${theme}`;
if (classes) {
for (const className of classes) {
if (className.startsWith("theme-") && className !== newClassName) {
if (className.startsWith('theme-') && className !== newClassName) {
classes.remove(className);
}
}

View File

@@ -1,6 +1,6 @@
import type { NodeInput } from "@nodarium/types";
import type { NodeInput } from '@nodarium/types';
type Button = { type: "button"; label?: string };
type Button = { type: 'button'; label?: string };
export type SettingsStore = {
[key: string]: SettingsStore | string | number | boolean;
@@ -23,5 +23,5 @@ export type SettingsValue = Record<
>;
export function isNodeInput(v: SettingsNode | undefined): v is InputType {
return !!v && "type" in v;
return !!v && 'type' in v;
}

View File

@@ -1,48 +1,52 @@
<script lang="ts">
import { getContext } from "svelte";
import type { Readable } from "svelte/store";
import { type Snippet } from 'svelte';
import { type Panel, 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");
let panel = $state<Panel>(null!);
$: if (typeof hidden === "boolean") {
setVisibility(id, !hidden);
}
$effect(() => {
panelState.unregisterPanel(id);
panel = panelState.registerPanel(id, icon, classes, hidden);
});
const registerPanel =
getContext<
(id: string, icon: string, classes: string) => Readable<boolean>
>("registerPanel");
let visible = registerPanel(id, icon, classes);
$effect(() => {
panel.hidden = hidden;
});
</script>
{#if $visible}
{#if panelState.activePanel.value === id}
<div class="wrapper" class:hidden>
{#if title}
<header>
<h3>{title}</h3>
<header class="bg-layer-2">
<h3 class="font-bold">{title}</h3>
</header>
{/if}
<slot />
{@render children?.()}
</div>
{/if}
<style>
header {
border-bottom: solid thin var(--outline);
height: 69px;
border-bottom: solid thin var(--color-outline);
height: 70px;
display: flex;
align-items: center;
padding-left: 1em;
}
h3 {
margin: 0px;
}
</style>

View File

@@ -0,0 +1,44 @@
import { localState } from '$lib/helpers/localState.svelte';
export type Panel = {
icon: string;
classes: string;
hidden?: boolean;
};
class PanelState {
panels = $state<Record<string, Panel>>({});
activePanel = localState<string | boolean>('node.activePanel', '');
get keys() {
return Object.keys(this.panels);
}
public unregisterPanel(id: string) {
delete this.panels[id];
}
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];
}
}
public setActivePanel(panelId: string) {
this.activePanel.value = panelId;
}
}
export const panelState = new PanelState();

View File

@@ -1,77 +1,30 @@
<script lang="ts">
import localStore from "$lib/helpers/localStore";
import { setContext } from "svelte";
import { derived } from "svelte/store";
import { type Snippet } from 'svelte';
import { panelState as state } from './PanelState.svelte';
let panels: Record<
string,
{
icon: string;
id: string;
classes: string;
visible?: boolean;
}
> = {};
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="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>
@@ -85,15 +38,13 @@
height: 100%;
right: 0px;
transform: translateX(calc(100% - 30px));
transition:
transform 0.2s,
background 0.2s ease;
transition: transform 0.2s, background 0.2s ease;
width: 30%;
min-width: 350px;
}
.content {
background: var(--layer-1);
background: var(--color-layer-1);
z-index: 10;
position: relative;
max-height: 100vh;
@@ -103,7 +54,7 @@
.tabs {
display: flex;
flex-direction: column;
border-right: solid thin var(--outline);
border-right: solid thin var(--color-outline);
}
.tabs > button {
@@ -114,9 +65,9 @@
border: none;
display: flex;
align-items: center;
border-bottom: solid thin var(--outline);
border-left: solid thin var(--outline);
background: var(--layer-1);
border-bottom: solid thin var(--color-outline);
border-left: solid thin var(--color-outline);
background: var(--color-layer-1);
}
.tabs > button > span {
@@ -124,7 +75,7 @@
}
.tabs > button.active {
background: var(--layer-2);
background: var(--color-layer-2);
}
.tabs > button.active span {

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