From e2cb21cdefa26d20e1be92e409cd6a3c53943bf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Vezy?= Date: Tue, 28 Apr 2026 10:43:36 +0200 Subject: [PATCH 1/8] Add dependency graph visualisation --- .gitignore | 4 +- frontend/README.md | 24 + frontend/index.html | 12 + frontend/package-lock.json | 2554 ++++++++++++++++++++ frontend/package.json | 29 + frontend/src/App.tsx | 131 + frontend/src/ModelNode.tsx | 44 + frontend/src/elkjs.d.ts | 3 + frontend/src/layout.ts | 37 + frontend/src/main.tsx | 9 + frontend/src/sampleGraph.ts | 49 + frontend/src/styles.css | 283 +++ frontend/src/types.ts | 46 + frontend/tsconfig.json | 20 + frontend/vite.config.ts | 10 + src/PlantSimEngine.jl | 6 + src/visualization/dependency_graph_view.jl | 948 ++++++++ test/runtests.jl | 4 + test/test-dependency-graph-view.jl | 66 + 19 files changed, 4278 insertions(+), 1 deletion(-) create mode 100644 frontend/README.md create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/ModelNode.tsx create mode 100644 frontend/src/elkjs.d.ts create mode 100644 frontend/src/layout.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/sampleGraph.ts create mode 100644 frontend/src/styles.css create mode 100644 frontend/src/types.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 src/visualization/dependency_graph_view.jl create mode 100644 test/test-dependency-graph-view.jl diff --git a/.gitignore b/.gitignore index 57263c7e7..6186a23bb 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ docs/Manifest.toml test/Manifest.toml docs/build/ -benchmark/Manifest.toml \ No newline at end of file +benchmark/Manifest.toml +frontend/node_modules/ +frontend/dist/ diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 000000000..c31b3100a --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,24 @@ +# PlantSimEngine Dependency Graph Viewer + +This is the React Flow frontend for the PlantSimEngine dependency graph viewer. +It consumes the JSON emitted by `PlantSimEngine.graph_view_json`. + +## Development + +```sh +npm install +npm run dev +``` + +The app falls back to a small sample graph when no embedded +` + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 000000000..c5d0a672f --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2554 @@ +{ + "name": "plantsimengine-graph-viewer", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "plantsimengine-graph-viewer", + "version": "0.1.0", + "dependencies": { + "@xyflow/react": "^12.10.0", + "elkjs": "^0.11.0", + "lucide-react": "^0.468.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "zustand": "^5.0.0" + }, + "devDependencies": { + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "^5.8.0", + "vite": "^7.0.0", + "vitest": "^3.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@xyflow/react": { + "version": "12.10.2", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.2.tgz", + "integrity": "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.76", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/react/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.76", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.76.tgz", + "integrity": "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.23", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.23.tgz", + "integrity": "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001791", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", + "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.344", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", + "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", + "dev": true, + "license": "ISC" + }, + "node_modules/elkjs": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.11.1.tgz", + "integrity": "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==", + "license": "EPL-2.0" + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.468.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", + "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zustand": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", + "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 000000000..4e20a5b98 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,29 @@ +{ + "name": "plantsimengine-graph-viewer", + "private": true, + "version": "0.1.0", + "type": "module", + "packageManager": "npm@11.6.1", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "test": "vitest run" + }, + "dependencies": { + "@xyflow/react": "^12.10.0", + "elkjs": "^0.11.0", + "lucide-react": "^0.468.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "zustand": "^5.0.0" + }, + "devDependencies": { + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "^5.8.0", + "vite": "^7.0.0", + "vitest": "^3.0.0" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 000000000..b780a8f34 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,131 @@ +import { useCallback, useEffect, useState } from "react"; +import { + Background, + BackgroundVariant, + Controls, + MiniMap, + ReactFlow, + addEdge, + useEdgesState, + useNodesState, + type Connection, + type Edge, + type Node, +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; +import { AlertTriangle, GitPullRequestArrow, RotateCcw, ScissorsLineDashed } from "lucide-react"; +import { ModelNode } from "./ModelNode"; +import { layoutGraph } from "./layout"; +import { sampleGraph } from "./sampleGraph"; +import type { DependencyGraphView, GraphEdgeData, GraphNodeData } from "./types"; +import "./styles.css"; + +const nodeTypes = { model: ModelNode }; + +export default function App() { + const [graph] = useState(loadInitialGraph()); + const [selected, setSelected] = useState(null); + const [nodes, setNodes, onNodesChange] = useNodesState>([]); + const [edges, setEdges, onEdgesChange] = useEdgesState>([]); + + useEffect(() => { + const nextNodes = graph.nodes.map((node) => ({ + id: node.id, + type: "model", + position: { x: 0, y: 0 }, + data: node, + })); + const nextEdges = graph.edges.map((edge) => ({ + id: edge.id, + source: edge.source, + target: edge.target, + sourceHandle: edge.sourcePort ?? undefined, + targetHandle: edge.targetPort ?? undefined, + label: edge.label, + animated: edge.scaleRelation === "multiscale", + className: `${edge.kind} ${edge.scaleRelation}`, + data: edge, + })); + layoutGraph(nextNodes, nextEdges).then((layouted) => { + setNodes(layouted); + setEdges(nextEdges); + }); + }, [graph, setEdges, setNodes]); + + const onConnect = useCallback((connection: Connection) => { + setEdges((current) => addEdge({ ...connection, type: "smoothstep", animated: true }, current)); + }, [setEdges]); + + const relayout = useCallback(() => { + layoutGraph(nodes, edges).then(setNodes); + }, [edges, nodes, setNodes]); + + return ( +
+
+
+
+
PlantSimEngine
+

Dependency Graph

+
+
+ {graph.nodes.length} models + {graph.edges.length} links + {graph.cyclic && cycle} +
+ +
+ setSelected(node.data)} + fitView + > + + + + +
+ +
+ ); +} + +function Row({ label, value }: { label: string; value: string }) { + return
{label}{value}
; +} + +function loadInitialGraph() { + const embedded = document.getElementById("pse-graph-data"); + if (embedded?.textContent) return JSON.parse(embedded.textContent) as DependencyGraphView; + const fromWindow = (window as Window & { PlantSimEngineGraph?: DependencyGraphView }).PlantSimEngineGraph; + return fromWindow ?? sampleGraph; +} diff --git a/frontend/src/ModelNode.tsx b/frontend/src/ModelNode.tsx new file mode 100644 index 000000000..cc167d52a --- /dev/null +++ b/frontend/src/ModelNode.tsx @@ -0,0 +1,44 @@ +import { Handle, Position, type Node, type NodeProps } from "@xyflow/react"; +import { Clock3, GitBranch, Layers3, Link2 } from "lucide-react"; +import type { GraphNodeData, GraphPort } from "./types"; + +type ModelFlowNode = Node; + +export function ModelNode({ data, selected }: NodeProps) { + return ( +
+
+
+
{data.process}
+
{data.modelType}
+
+ {data.role === "hard_dependency" ? : } +
+
+ {data.scale} + {data.rate} +
+
+ + +
+ {data.diagnostics.length > 0 &&
{data.diagnostics[0]}
} +
+ ); +} + +function PortColumn({ title, ports, side }: { title: string; ports: GraphPort[]; side: "input" | "output" }) { + return ( +
+
{title}
+ {ports.map((port) => ( +
+ {side === "input" && } + {port.name} + {port.mappingMode && } + {side === "output" && } +
+ ))} +
+ ); +} diff --git a/frontend/src/elkjs.d.ts b/frontend/src/elkjs.d.ts new file mode 100644 index 000000000..519073945 --- /dev/null +++ b/frontend/src/elkjs.d.ts @@ -0,0 +1,3 @@ +declare module "elkjs/lib/elk.bundled.js" { + export { default } from "elkjs"; +} diff --git a/frontend/src/layout.ts b/frontend/src/layout.ts new file mode 100644 index 000000000..ccd1a2da9 --- /dev/null +++ b/frontend/src/layout.ts @@ -0,0 +1,37 @@ +import ELK from "elkjs/lib/elk.bundled.js"; +import type { Edge, Node } from "@xyflow/react"; +import type { GraphEdgeData, GraphNodeData } from "./types"; + +const elk = new ELK(); + +export async function layoutGraph(nodes: Node[], edges: Edge[]) { + const graph = { + id: "root", + layoutOptions: { + "elk.algorithm": "layered", + "elk.direction": "RIGHT", + "elk.spacing.nodeNode": "58", + "elk.layered.spacing.nodeNodeBetweenLayers": "110", + "elk.layered.nodePlacement.strategy": "BRANDES_KOEPF", + "elk.edgeRouting": "ORTHOGONAL", + }, + children: nodes.map((node) => ({ + id: node.id, + width: 312, + height: Math.max(160, 112 + Math.max(node.data.inputs.length, node.data.outputs.length) * 28), + })), + edges: edges.map((edge) => ({ + id: edge.id, + sources: [edge.source], + targets: [edge.target], + })), + }; + + const result = await elk.layout(graph); + const positions = new Map((result.children ?? []).map((child) => [child.id, { x: child.x ?? 0, y: child.y ?? 0 }])); + + return nodes.map((node) => ({ + ...node, + position: positions.get(node.id) ?? node.position, + })); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 000000000..f8fc6f511 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,9 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/frontend/src/sampleGraph.ts b/frontend/src/sampleGraph.ts new file mode 100644 index 000000000..42b0d3dcc --- /dev/null +++ b/frontend/src/sampleGraph.ts @@ -0,0 +1,49 @@ +import type { DependencyGraphView } from "./types"; + +export const sampleGraph: DependencyGraphView = { + nodes: [ + { + id: "model:Default:lai", + process: "lai", + scale: "Default", + modelType: "ToyLAIModel", + role: "model", + rate: "default rate", + inputs: [{ id: "model:Default:lai:input:TT_cu", name: "TT_cu", role: "input", mappingMode: null, sourceScale: null, sourceVariable: null, previousTimeStep: false, default: "uninitialized" }], + outputs: [{ id: "model:Default:lai:output:LAI", name: "LAI", role: "output", mappingMode: null, sourceScale: null, sourceVariable: null, previousTimeStep: false, default: "Float64" }], + parent: null, + diagnostics: [], + }, + { + id: "model:Default:light_interception", + process: "light_interception", + scale: "Default", + modelType: "Beer", + role: "model", + rate: "default rate", + inputs: [{ id: "model:Default:light_interception:input:LAI", name: "LAI", role: "input", mappingMode: null, sourceScale: null, sourceVariable: null, previousTimeStep: false, default: "uninitialized" }], + outputs: [{ id: "model:Default:light_interception:output:aPPFD", name: "aPPFD", role: "output", mappingMode: null, sourceScale: null, sourceVariable: null, previousTimeStep: false, default: "Float64" }], + parent: null, + diagnostics: [], + }, + ], + edges: [ + { + id: "edge:sample", + source: "model:Default:lai", + target: "model:Default:light_interception", + sourcePort: "model:Default:lai:output:LAI", + targetPort: "model:Default:light_interception:input:LAI", + sourceVariable: "LAI", + targetVariable: "LAI", + kind: "soft_dependency", + scaleRelation: "same_scale", + label: "LAI", + diagnostics: [], + }, + ], + scales: ["Default"], + cyclic: false, + cycleNodes: [], + diagnostics: [], +}; diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 000000000..53a91a9cd --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,283 @@ +:root { + --bg: #f6f1e8; + --ink: #16202a; + --muted: #667085; + --panel: rgba(255, 255, 255, 0.78); + --line: rgba(102, 112, 133, 0.22); + --green: #1f6f5b; + --blue: #285f9f; + --red: #b13c4a; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + color: var(--ink); + background: + linear-gradient(140deg, rgba(31, 111, 91, 0.15), transparent 35%), + radial-gradient(circle at top right, rgba(40, 95, 159, 0.14), transparent 30%), + var(--bg); + font-family: "Avenir Next", "Segoe UI", sans-serif; +} + +.app-shell { + display: grid; + grid-template-columns: minmax(0, 1fr) 340px; + height: 100vh; +} + +.graph-panel { + position: relative; + min-width: 0; +} + +.topbar { + position: absolute; + z-index: 10; + top: 18px; + left: 18px; + right: 18px; + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + background: var(--panel); + border: 1px solid var(--line); + box-shadow: 0 18px 46px rgba(22, 32, 42, 0.12); + backdrop-filter: blur(14px); +} + +.eyebrow { + color: var(--muted); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +h1, +h2, +h3 { + margin: 0; + letter-spacing: 0; +} + +h1 { + font-size: 19px; +} + +.metrics { + display: flex; + gap: 8px; + margin-left: auto; +} + +.metrics span, +.node-meta span { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 5px 8px; + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.82); + font-size: 12px; +} + +.metrics .warn { + color: var(--red); + border-color: rgba(177, 60, 74, 0.35); +} + +.icon-button { + display: grid; + place-items: center; + width: 34px; + height: 34px; + border: 1px solid var(--line); + background: white; + color: var(--ink); + cursor: pointer; +} + +.react-flow { + background: transparent; +} + +.model-node { + width: 312px; + overflow: hidden; + background: rgba(255, 255, 255, 0.86); + border: 1px solid rgba(102, 112, 133, 0.26); + box-shadow: 0 20px 50px rgba(22, 32, 42, 0.16); +} + +.model-node.selected { + border-color: rgba(22, 32, 42, 0.65); +} + +.model-node.hard_dependency { + border-style: dashed; +} + +.node-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + padding: 13px 14px; + color: white; + background: linear-gradient(92deg, var(--green), var(--blue)); +} + +.process { + font-weight: 750; + font-size: 15px; +} + +.model-type { + margin-top: 2px; + font-size: 12px; + opacity: 0.9; +} + +.node-meta { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 10px 12px 0; +} + +.ports-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + padding: 12px; +} + +.port-title { + margin-bottom: 6px; + color: var(--muted); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.port { + position: relative; + display: flex; + align-items: center; + gap: 5px; + min-height: 24px; + margin: 4px 0; + padding: 4px 7px; + border: 1px solid rgba(102, 112, 133, 0.14); + background: rgba(255, 255, 255, 0.78); + font-size: 12px; +} + +.port.output { + justify-content: flex-end; +} + +.port.previous { + color: var(--red); +} + +.port.mapped { + border-color: rgba(40, 95, 159, 0.32); +} + +.react-flow__handle { + width: 9px; + height: 9px; + border: 0; + background: #475467; +} + +.react-flow__edge.multiscale path { + stroke-dasharray: 7 5; +} + +.react-flow__edge.mapped_variable path { + stroke: var(--blue); +} + +.react-flow__edge.hard_dependency path { + stroke: var(--red); +} + +.inspector { + border-left: 1px solid var(--line); + background: rgba(255, 255, 255, 0.62); + backdrop-filter: blur(14px); + padding: 18px; + overflow: auto; +} + +.inspector header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 14px; +} + +.inspector h2 { + font-size: 17px; +} + +.inspector h3 { + margin-top: 22px; + margin-bottom: 8px; + color: var(--muted); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.row { + display: grid; + grid-template-columns: 84px minmax(0, 1fr); + gap: 8px; + padding: 8px 0; + border-top: 1px solid rgba(102, 112, 133, 0.16); +} + +.row span { + color: var(--muted); +} + +.row strong { + overflow-wrap: anywhere; + font-weight: 620; +} + +.diagnostic, +.edit-suggestion, +.empty-state { + border: 1px solid rgba(102, 112, 133, 0.18); + background: rgba(255, 255, 255, 0.62); + padding: 10px; + color: var(--muted); +} + +.diagnostic, +.edit-suggestion { + display: flex; + align-items: center; + gap: 7px; + color: var(--red); + border-color: rgba(177, 60, 74, 0.26); + background: rgba(177, 60, 74, 0.08); +} + +@media (max-width: 900px) { + .app-shell { + grid-template-columns: 1fr; + } + + .inspector { + display: none; + } +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 000000000..c9b6f317e --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,46 @@ +export type GraphPort = { + id: string; + name: string; + role: "input" | "output"; + mappingMode: string | null; + sourceScale: string | null; + sourceVariable: string | null; + previousTimeStep: boolean; + default: string; +}; + +export type GraphNodeData = { + id: string; + process: string; + scale: string; + modelType: string; + role: "model" | "hard_dependency"; + rate: string; + inputs: GraphPort[]; + outputs: GraphPort[]; + parent: string | null; + diagnostics: string[]; +} & Record; + +export type GraphEdgeData = { + id: string; + source: string; + target: string; + sourcePort: string | null; + targetPort: string | null; + sourceVariable: string | null; + targetVariable: string | null; + kind: "soft_dependency" | "mapped_variable" | "hard_dependency"; + scaleRelation: "same_scale" | "multiscale"; + label: string; + diagnostics: string[]; +} & Record; + +export type DependencyGraphView = { + nodes: GraphNodeData[]; + edges: GraphEdgeData[]; + scales: string[]; + cyclic: boolean; + cycleNodes: string[]; + diagnostics: string[]; +}; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 000000000..c0ea4e10f --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 000000000..5e9021332 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + build: { + outDir: "dist", + emptyOutDir: true, + }, +}); diff --git a/src/PlantSimEngine.jl b/src/PlantSimEngine.jl index 1151b8f7a..f3d1a81c8 100644 --- a/src/PlantSimEngine.jl +++ b/src/PlantSimEngine.jl @@ -107,6 +107,9 @@ include("time/runtime/meteo_sampling.jl") # Simulation: include("run.jl") +# Dependency graph visualisation: +include("visualization/dependency_graph_view.jl") + # Fitting include("evaluation/fit.jl") @@ -138,6 +141,9 @@ export timespec, output_policy, timestep_hint, meteo_hint export input_bindings, meteo_bindings, meteo_window, output_routing, model_scope export run! export fit +export GraphPort, GraphNode, GraphEdge, DependencyGraphView +export graph_view, graph_view_json, write_graph_view +export AbstractGraphEdit, MarkPreviousTimeStep, apply_graph_edit # Re-exporting PlantMeteo main functions: export Atmosphere, TimeStepTable, Constants, Weather diff --git a/src/visualization/dependency_graph_view.jl b/src/visualization/dependency_graph_view.jl new file mode 100644 index 000000000..d05505347 --- /dev/null +++ b/src/visualization/dependency_graph_view.jl @@ -0,0 +1,948 @@ +""" + GraphPort + +A display-oriented input or output port on a model node. +""" +struct GraphPort + id::String + name::Symbol + role::Symbol + mapping_mode::Union{Nothing,String} + source_scale::Union{Nothing,Symbol} + source_variable::Union{Nothing,Symbol} + previous_timestep::Bool + default_label::String +end + +""" + GraphNode + +A display-oriented model node for dependency graph visualisation. +""" +struct GraphNode + id::String + process::Symbol + scale::Symbol + model_type::String + role::Symbol + rate::String + inputs::Vector{GraphPort} + outputs::Vector{GraphPort} + parent::Union{Nothing,String} + diagnostics::Vector{String} +end + +""" + GraphEdge + +A display-oriented dependency edge between model variable ports. +""" +struct GraphEdge + id::String + source::String + target::String + source_port::Union{Nothing,String} + target_port::Union{Nothing,String} + source_variable::Union{Nothing,Symbol} + target_variable::Union{Nothing,Symbol} + kind::Symbol + scale_relation::Symbol + label::String + diagnostics::Vector{String} +end + +""" + DependencyGraphView + +Renderer-independent graph representation used by dependency graph visualisers. +""" +struct DependencyGraphView + nodes::Vector{GraphNode} + edges::Vector{GraphEdge} + scales::Vector{Symbol} + cyclic::Bool + cycle_nodes::Vector{String} + diagnostics::Vector{String} +end + +abstract type AbstractGraphEdit end + +""" + MarkPreviousTimeStep(scale, process, variable) + +Declarative graph edit used by future interactive editors to request that a +model input should be considered from the previous timestep. +""" +struct MarkPreviousTimeStep <: AbstractGraphEdit + scale::Symbol + process::Symbol + variable::Symbol +end + +""" + graph_view(mapping) + graph_view(sim::GraphSimulation) + +Build a renderer-independent view of a dependency graph. +""" +function graph_view(mapping::ModelMapping; verbose::Bool=false) + diagnostics = String[] + graph = try + dep(mapping; verbose=verbose) + catch err + msg = sprint(showerror, err) + push!(diagnostics, msg) + return _graph_view_from_mapping_only(mapping, diagnostics) + end + + return graph_view(graph, mapping; diagnostics=diagnostics) +end + +function graph_view(sim::GraphSimulation; diagnostics::Vector{String}=String[]) + return graph_view(sim.dependency_graph, sim; diagnostics=diagnostics) +end + +function graph_view(graph::DependencyGraph, context=nothing; diagnostics::Vector{String}=String[]) + node_ids = IdDict{AbstractDependencyNode,String}() + nodes = GraphNode[] + edges = GraphEdge[] + + for node in traverse_dependency_graph(graph) + id = _graph_node_id(node, node_ids) + push!(nodes, _graph_node(node, id, context, node_ids)) + end + + for node in traverse_dependency_graph(graph, false) + child_id = node_ids[node] + if node.parent !== nothing + for parent in node.parent + parent_id = _graph_node_id(parent, node_ids) + append!(edges, _soft_edges(parent, node, parent_id, child_id)) + end + end + + for hard_child in node.hard_dependency + parent_id = child_id + child_hard_id = _graph_node_id(hard_child, node_ids) + push!(edges, GraphEdge( + "edge:hard:$(parent_id):$(child_hard_id)", + parent_id, + child_hard_id, + nothing, + nothing, + nothing, + nothing, + :hard_dependency, + node.scale == hard_child.scale ? :same_scale : :multiscale, + "hard dependency", + String[], + )) + end + end + + cyclic, cycle_vec = is_graph_cyclic(graph; warn=false) + cycle_nodes = cyclic ? [_model_node_id(last(pair), process(first(pair))) for pair in cycle_vec] : String[] + scales = sort!(unique([node.scale for node in nodes]); by=string) + return DependencyGraphView(nodes, edges, scales, cyclic, cycle_nodes, diagnostics) +end + +""" + graph_view_json(view) + graph_view_json(mapping) + +Return the graph view as JSON for browser renderers. +""" +graph_view_json(view::DependencyGraphView) = _json(_graph_view_dict(view)) +graph_view_json(mapping::ModelMapping; kwargs...) = graph_view_json(graph_view(mapping; kwargs...)) +graph_view_json(sim::GraphSimulation; kwargs...) = graph_view_json(graph_view(sim; kwargs...)) + +""" + write_graph_view(path, view) + write_graph_view(path, mapping) + +Write a standalone HTML dependency graph visualisation. +""" +function write_graph_view(path::AbstractString, view::DependencyGraphView) + mkpath(dirname(abspath(path))) + open(path, "w") do io + write(io, _graph_view_html(view)) + end + return abspath(path) +end + +write_graph_view(path::AbstractString, mapping::ModelMapping; kwargs...) = + write_graph_view(path, graph_view(mapping; kwargs...)) + +write_graph_view(path::AbstractString, sim::GraphSimulation; kwargs...) = + write_graph_view(path, graph_view(sim; kwargs...)) + +function apply_graph_edit(mapping::ModelMapping{MultiScale}, edit::MarkPreviousTimeStep) + haskey(mapping, edit.scale) || error("Cannot mark `$(edit.variable)` as previous timestep: scale `$(edit.scale)` is not present in the `ModelMapping`.") + + found = Ref(false) + data = Dict{Symbol,Any}() + for (scale, entry) in pairs(mapping) + data[scale] = scale == edit.scale ? _mark_previous_timestep_entry(entry, edit, found) : entry + end + + found[] || error("Cannot mark `$(edit.variable)` as previous timestep: process `$(edit.process)` was not found at scale `$(edit.scale)`.") + return ModelMapping(data; check=true, type_promotion=type_promotion(mapping)) +end + +function apply_graph_edit(mapping::ModelMapping, edit::AbstractGraphEdit) + error("Graph edit `$(typeof(edit))` is not supported for `$(typeof(mapping))`.") +end + +function _mark_previous_timestep_entry(entry::Tuple, edit::MarkPreviousTimeStep, found::Base.RefValue{Bool}) + return tuple((_mark_previous_timestep_item(item, edit, found) for item in entry)...) +end + +function _mark_previous_timestep_entry(entry, edit::MarkPreviousTimeStep, found::Base.RefValue{Bool}) + return _mark_previous_timestep_item(entry, edit, found) +end + +_mark_previous_timestep_item(item::Status, ::MarkPreviousTimeStep, ::Base.RefValue{Bool}) = item + +function _mark_previous_timestep_item(item, edit::MarkPreviousTimeStep, found::Base.RefValue{Bool}) + spec = as_model_spec(item) + process(model_(spec)) == edit.process || return item + edit.variable in keys(variables(model_(spec))) || error( + "Cannot mark `$(edit.variable)` as previous timestep for process `$(edit.process)` at scale `$(edit.scale)`: ", + "the variable is not declared as an input or output of `$(typeof(model_(spec)))`." + ) + + found[] = true + return ModelSpec(spec; multiscale=_mark_previous_timestep_mapping(spec.multiscale, edit.variable)) +end + +function _mark_previous_timestep_mapping(mapping, variable::Symbol) + mapped = isnothing(mapping) ? Any[] : Any[collect(mapping)...] + replaced = false + for i in eachindex(mapped) + item = mapped[i] + if item isa Pair + lhs = first(item) + if lhs isa PreviousTimeStep && lhs.variable == variable + replaced = true + break + elseif lhs == variable + mapped[i] = PreviousTimeStep(variable) => last(item) + replaced = true + break + end + elseif item isa PreviousTimeStep && item.variable == variable + replaced = true + break + end + end + replaced || push!(mapped, PreviousTimeStep(variable)) + return mapped +end + +function _graph_view_from_mapping_only(mapping::ModelMapping, diagnostics) + nodes = GraphNode[] + for (scale, entry) in pairs(mapping) + specs = parse_model_specs(entry) + for (process_name, spec) in specs + model = model_(spec) + id = _model_node_id(scale, process_name) + push!(nodes, GraphNode( + id, + process_name, + scale, + _type_label(typeof(model)), + :model, + _rate_label(spec), + _ports(id, :input, inputs_(model)), + _ports(id, :output, outputs_(model)), + nothing, + String[], + )) + end + end + scales = sort!(unique([node.scale for node in nodes]); by=string) + return DependencyGraphView(nodes, GraphEdge[], scales, any(occursin.("Cyclic", diagnostics)), String[], diagnostics) +end + +function _graph_node(node::AbstractDependencyNode, id::String, context, node_ids) + role = node isa SoftDependencyNode ? :model : :hard_dependency + parent = node.parent isa AbstractDependencyNode ? _graph_node_id(node.parent, node_ids) : nothing + spec = _model_spec(context, node.scale, node.process) + rate = isnothing(spec) ? _rate_label(node.value) : _rate_label(spec) + return GraphNode( + id, + node.process, + node.scale, + _type_label(typeof(node.value)), + role, + rate, + _ports(id, :input, _flatten_node_vars(node.inputs)), + _ports(id, :output, _flatten_node_vars(node.outputs)), + parent, + _node_diagnostics(node), + ) +end + +function _model_spec(mapping::ModelMapping, scale::Symbol, process_name::Symbol) + specs = get(mapping.info.model_specs, scale, nothing) + isnothing(specs) && return nothing + return get(specs, process_name, nothing) +end + +function _model_spec(sim::GraphSimulation, scale::Symbol, process_name::Symbol) + specs = get(sim.model_specs, scale, nothing) + isnothing(specs) && return nothing + return get(specs, process_name, nothing) +end + +_model_spec(::Any, ::Symbol, ::Symbol) = nothing + +function _node_diagnostics(node) + diagnostics = String[] + if node isa HardDependencyNode && !isempty(node.missing_dependency) + missing = [string(node.dependency[j]) for j in node.missing_dependency] + push!(diagnostics, "Missing hard dependencies: $(join(missing, ", "))") + end + return diagnostics +end + +_flatten_node_vars(vars::NamedTuple) = vars +_flatten_node_vars(vars::AbstractVector{<:Pair}) = flatten_vars(vars) +_flatten_node_vars(vars) = NamedTuple() + +function _ports(node_id::String, role::Symbol, vars::NamedTuple) + ports = GraphPort[] + for (name, value) in pairs(vars) + previous = value isa MappedVar && mapped_variable(value) isa PreviousTimeStep + previous |= name isa PreviousTimeStep + source_scale = _port_source_scale(value) + source_var = _port_source_variable(value) + push!(ports, GraphPort( + _port_id(node_id, role, name), + Symbol(name), + role, + _mapping_mode(value), + source_scale, + source_var, + previous, + _default_label(value), + )) + end + return ports +end + +_ports(node_id::String, role::Symbol, vars) = _ports(node_id, role, _flatten_node_vars(vars)) + +function _soft_edges(parent::SoftDependencyNode, child::SoftDependencyNode, parent_id::String, child_id::String) + parent_outputs = _flatten_node_vars(parent.outputs) + child_inputs = _flatten_node_vars(child.inputs) + edges = GraphEdge[] + + for (input_name, input_value) in pairs(child_inputs) + source_var = _source_var_for_parent(input_name, input_value, parent) + isnothing(source_var) && continue + haskey(parent_outputs, source_var) || continue + + scale_relation = parent.scale == child.scale ? :same_scale : :multiscale + kind = input_value isa MappedVar ? :mapped_variable : :soft_dependency + label = source_var == input_name ? string(input_name) : string(source_var, " -> ", input_name) + if scale_relation == :multiscale + label = string(parent.scale, ".", label, " -> ", child.scale) + end + push!(edges, GraphEdge( + "edge:soft:$(parent_id):$(_port_id(parent_id, :output, source_var)):$(child_id):$(_port_id(child_id, :input, input_name))", + parent_id, + child_id, + _port_id(parent_id, :output, source_var), + _port_id(child_id, :input, input_name), + source_var, + Symbol(input_name), + kind, + scale_relation, + label, + String[], + )) + end + + return edges +end + +function _source_var_for_parent(input_name, input_value, parent::SoftDependencyNode) + if input_value isa MappedVar + mapped_org = mapped_organ(input_value) + if mapped_org isa Symbol + mapped_org == parent.scale || return nothing + elseif mapped_org isa AbstractVector + parent.scale in mapped_org || return nothing + else + return nothing + end + mapped_variable(input_value) isa PreviousTimeStep && return nothing + return Symbol(source_variable(input_value, parent.scale)) + end + return Symbol(input_name) +end + +function _graph_node_id(node::AbstractDependencyNode, node_ids::IdDict{AbstractDependencyNode,String}) + haskey(node_ids, node) && return node_ids[node] + id = node isa SoftDependencyNode ? _model_node_id(node.scale, node.process) : _hard_node_id(node) + node_ids[node] = id + return id +end + +function _graph_node_id(parent::Union{Nothing,AbstractDependencyNode}, node_ids) + isnothing(parent) && return nothing + return _graph_node_id(parent, node_ids) +end + +_model_node_id(scale::Symbol, process_name::Symbol) = string("model:", scale, ":", process_name) +_hard_node_id(node::HardDependencyNode) = string("hard:", node.scale, ":", node.process, ":", objectid(node)) +_port_id(node_id::String, role::Symbol, name) = string(node_id, ":", role, ":", Symbol(name)) + +_type_label(type) = string(nameof(type)) + +function _rate_label(spec::ModelSpec) + if !isnothing(timestep(spec)) + return string("ModelSpec timestep: ", timestep(spec)) + end + ts = timespec(model_(spec)) + return ts == ClockSpec(1.0, 0.0) ? "default rate" : string("model timespec: ", ts) +end + +function _rate_label(model::AbstractModel) + ts = timespec(model) + return ts == ClockSpec(1.0, 0.0) ? "default rate" : string("model timespec: ", ts) +end + +_mapping_mode(value) = nothing +_mapping_mode(value::MappedVar{SingleNodeMapping}) = "single-node" +_mapping_mode(value::MappedVar{MultiNodeMapping}) = "multi-node" +_mapping_mode(value::MappedVar{SelfNodeMapping}) = "self-node" +_mapping_mode(value::RefVariable) = "same-scale-alias" + +_port_source_scale(value) = nothing +_port_source_scale(value::MappedVar{SingleNodeMapping}) = mapped_organ(value) +_port_source_scale(value::MappedVar{SelfNodeMapping}) = nothing +function _port_source_scale(value::MappedVar{MultiNodeMapping}) + scales = mapped_organ(value) + isempty(scales) && return nothing + return first(scales) +end + +_port_source_variable(value) = nothing +_port_source_variable(value::MappedVar) = source_variable(value) isa Symbol ? source_variable(value) : nothing +_port_source_variable(value::RefVariable) = value.reference_variable +_port_source_variable(value::UninitializedVar) = value.variable + +_default_label(value) = _short_value(value) +_default_label(value::MappedVar) = _short_value(mapped_default(value)) +_default_label(value::UninitializedVar) = string("uninitialized, default ", _short_value(value.value)) +_default_label(value::RefVariable) = string("alias of ", value.reference_variable) + +function _short_value(value) + value === nothing && return "nothing" + value isa Number && return string(value) + value isa AbstractString && return value + value isa AbstractArray && return string(typeof(value), " length ", length(value)) + return string(typeof(value)) +end + +function _graph_view_dict(view::DependencyGraphView) + return Dict( + "nodes" => [_node_dict(node) for node in view.nodes], + "edges" => [_edge_dict(edge) for edge in view.edges], + "scales" => string.(view.scales), + "cyclic" => view.cyclic, + "cycleNodes" => view.cycle_nodes, + "diagnostics" => view.diagnostics, + ) +end + +function _node_dict(node::GraphNode) + return Dict( + "id" => node.id, + "process" => string(node.process), + "scale" => string(node.scale), + "modelType" => node.model_type, + "role" => string(node.role), + "rate" => node.rate, + "inputs" => [_port_dict(port) for port in node.inputs], + "outputs" => [_port_dict(port) for port in node.outputs], + "parent" => node.parent, + "diagnostics" => node.diagnostics, + ) +end + +function _port_dict(port::GraphPort) + return Dict( + "id" => port.id, + "name" => string(port.name), + "role" => string(port.role), + "mappingMode" => port.mapping_mode, + "sourceScale" => isnothing(port.source_scale) ? nothing : string(port.source_scale), + "sourceVariable" => isnothing(port.source_variable) ? nothing : string(port.source_variable), + "previousTimeStep" => port.previous_timestep, + "default" => port.default_label, + ) +end + +function _edge_dict(edge::GraphEdge) + return Dict( + "id" => edge.id, + "source" => edge.source, + "target" => edge.target, + "sourcePort" => edge.source_port, + "targetPort" => edge.target_port, + "sourceVariable" => isnothing(edge.source_variable) ? nothing : string(edge.source_variable), + "targetVariable" => isnothing(edge.target_variable) ? nothing : string(edge.target_variable), + "kind" => string(edge.kind), + "scaleRelation" => string(edge.scale_relation), + "label" => edge.label, + "diagnostics" => edge.diagnostics, + ) +end + +function _json(value) + io = IOBuffer() + _write_json(io, value) + return String(take!(io)) +end + +function _write_json(io, value::AbstractDict) + print(io, "{") + first_item = true + for (key, val) in value + first_item || print(io, ",") + first_item = false + _write_json(io, string(key)) + print(io, ":") + _write_json(io, val) + end + print(io, "}") +end + +function _write_json(io, value::AbstractVector) + print(io, "[") + for (i, val) in pairs(value) + i == firstindex(value) || print(io, ",") + _write_json(io, val) + end + print(io, "]") +end + +_write_json(io, value::Nothing) = print(io, "null") +_write_json(io, value::Bool) = print(io, value ? "true" : "false") +_write_json(io, value::Real) = isfinite(value) ? print(io, value) : _write_json(io, string(value)) +_write_json(io, value::Symbol) = _write_json(io, string(value)) +_write_json(io, value::AbstractString) = print(io, "\"", _escape_json(value), "\"") +_write_json(io, value) = _write_json(io, string(value)) + +function _escape_json(s::AbstractString) + escaped = replace(s, "\\" => "\\\\", "\"" => "\\\"", "\n" => "\\n", "\r" => "\\r", "\t" => "\\t") + return replace(escaped, " "<\\/") +end + +function _graph_view_html(view::DependencyGraphView) + json = graph_view_json(view) + html = raw""" + + + + + +PlantSimEngine Dependency Graph + + + + +
+
+
+
PlantSimEngine Dependency Graph
+
+
+ +
+
+ +
+
+ +
+ + + +""" + return replace(html, "__PSE_GRAPH_JSON__" => json) +end diff --git a/test/runtests.jl b/test/runtests.jl index 99cc88cdb..df596d700 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -70,6 +70,10 @@ include("helper-functions.jl") include("test-toy_models.jl") end + @testset "Dependency graph view" begin + include("test-dependency-graph-view.jl") + end + @testset "MTG with multiscale mapping" begin include("test-mtg-multiscale.jl") include("test-mtg-dynamic.jl") diff --git a/test/test-dependency-graph-view.jl b/test/test-dependency-graph-view.jl new file mode 100644 index 000000000..d1f5008ce --- /dev/null +++ b/test/test-dependency-graph-view.jl @@ -0,0 +1,66 @@ +@testset "Dependency graph view" begin + mapping = ModelMapping( + ToyLAIModel(), + Beer(0.5), + ToyRUEGrowthModel(0.3); + status=(TT_cu=[10.0, 20.0],) + ) + + view = graph_view(mapping) + @test view isa DependencyGraphView + @test length(view.nodes) == 3 + @test !isempty(view.edges) + @test :Default in view.scales + @test any(node -> node.process == :light_interception, view.nodes) + @test any(edge -> edge.source_variable == :LAI && edge.target_variable == :LAI, view.edges) + @test any(edge -> edge.source_variable == :aPPFD && edge.target_variable == :aPPFD, view.edges) + + json = graph_view_json(view) + @test occursin("\"nodes\"", json) + @test occursin("\"edges\"", json) + @test occursin("ToyLAIModel", json) + + html_path = write_graph_view(joinpath(mktempdir(), "dependency_graph.html"), view) + @test isfile(html_path) + html = read(html_path, String) + @test occursin("PlantSimEngine Dependency Graph", html) + @test occursin("pse-graph-data", html) + + multiscale_mapping = ModelMapping( + :Plant => MultiScaleModel( + model=ToyCAllocationModel(), + mapped_variables=[ + :carbon_assimilation => [:Leaf], + :carbon_demand => [:Leaf, :Internode], + :carbon_allocation => [:Leaf, :Internode], + ], + ), + :Internode => ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), + :Leaf => ( + MultiScaleModel( + model=ToyAssimModel(), + mapped_variables=[:soil_water_content => :Soil => :soil_water_content], + ), + ToyCDemandModel(optimal_biomass=10.0, development_duration=200.0), + Status(aPPFD=1300.0, TT=10.0), + ), + :Soil => ToySoilWaterModel(), + ) + + multiscale_view = graph_view(multiscale_mapping) + @test Set(multiscale_view.scales) == Set([:Plant, :Internode, :Leaf, :Soil]) + @test any(edge -> edge.scale_relation == :multiscale, multiscale_view.edges) + @test any(edge -> edge.source_variable == :soil_water_content && edge.target_variable == :soil_water_content, multiscale_view.edges) + + edited_mapping = apply_graph_edit( + multiscale_mapping, + MarkPreviousTimeStep(:Leaf, :carbon_assimilation, :soil_water_content), + ) + edited_view = graph_view(edited_mapping) + @test !any( + edge -> edge.source_variable == :soil_water_content && + edge.target_variable == :soil_water_content && + edge.source != edge.target, + edited_view.edges, + ) +end From 5919642df89a7393a6af47f6e9460b1da7be8bf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Vezy?= Date: Wed, 29 Apr 2026 08:19:35 +0200 Subject: [PATCH 2/8] Refine graph styling with accent colors and arrow markers --- frontend/src/App.tsx | 150 ++++++++++++-- frontend/src/ModelNode.tsx | 33 +++- frontend/src/layout.ts | 34 +++- frontend/src/styles.css | 220 ++++++++++++++++----- frontend/src/types.ts | 7 + frontend/vite.config.ts | 1 + src/visualization/dependency_graph_view.jl | 96 ++++++++- test/test-dependency-graph-view.jl | 9 + 8 files changed, 464 insertions(+), 86 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b780a8f34..5d159cde8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,11 +1,11 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Background, - BackgroundVariant, Controls, MiniMap, ReactFlow, addEdge, + MarkerType, useEdgesState, useNodesState, type Connection, @@ -17,43 +17,54 @@ import { AlertTriangle, GitPullRequestArrow, RotateCcw, ScissorsLineDashed } fro import { ModelNode } from "./ModelNode"; import { layoutGraph } from "./layout"; import { sampleGraph } from "./sampleGraph"; -import type { DependencyGraphView, GraphEdgeData, GraphNodeData } from "./types"; +import type { DependencyGraphView, GraphEdgeData, GraphNodeData, GraphPort, RuntimeGraphNodeData } from "./types"; import "./styles.css"; const nodeTypes = { model: ModelNode }; +const edgeColors = { + base: "#a99a8c", + accent: "#1f7a53", +}; export default function App() { const [graph] = useState(loadInitialGraph()); const [selected, setSelected] = useState(null); - const [nodes, setNodes, onNodesChange] = useNodesState>([]); + const [activePort, setActivePort] = useState(null); + const [nodes, setNodes, onNodesChange] = useNodesState>([]); const [edges, setEdges, onEdgesChange] = useEdgesState>([]); + const highlight = useMemo(() => deriveHighlight(graph, activePort), [activePort, graph]); useEffect(() => { const nextNodes = graph.nodes.map((node) => ({ id: node.id, type: "model", position: { x: 0, y: 0 }, - data: node, - })); - const nextEdges = graph.edges.map((edge) => ({ - id: edge.id, - source: edge.source, - target: edge.target, - sourceHandle: edge.sourcePort ?? undefined, - targetHandle: edge.targetPort ?? undefined, - label: edge.label, - animated: edge.scaleRelation === "multiscale", - className: `${edge.kind} ${edge.scaleRelation}`, - data: edge, + data: runtimeNodeData(node, null, new Set(), setActivePort), })); + const nextEdges = graph.edges.map((edge) => flowEdge(edge, new Set(), false)); layoutGraph(nextNodes, nextEdges).then((layouted) => { setNodes(layouted); setEdges(nextEdges); }); }, [graph, setEdges, setNodes]); + useEffect(() => { + setNodes((current) => current.map((node) => ({ + ...node, + data: runtimeNodeData(node.data, activePort, highlight.ports, setActivePort), + }))); + setEdges((current) => current.map((edge) => edge.data ? flowEdge(edge.data, highlight.edges, Boolean(activePort)) : edge)); + }, [activePort, highlight.edges, highlight.ports, setEdges, setNodes]); + const onConnect = useCallback((connection: Connection) => { - setEdges((current) => addEdge({ ...connection, type: "smoothstep", animated: true }, current)); + setEdges((current) => addEdge({ + ...connection, + type: "smoothstep", + animated: true, + markerEnd: edgeMarker(false), + style: edgeStyle(false), + zIndex: 30, + }, current)); }, [setEdges]); const relayout = useCallback(() => { @@ -87,7 +98,7 @@ export default function App() { onNodeClick={(_, node) => setSelected(node.data)} fitView > - + @@ -129,3 +140,106 @@ function loadInitialGraph() { const fromWindow = (window as Window & { PlantSimEngineGraph?: DependencyGraphView }).PlantSimEngineGraph; return fromWindow ?? sampleGraph; } + +function runtimeNodeData( + node: GraphNodeData, + activePort: GraphPort | null, + highlightedPortIds: Set, + setActivePort: (port: GraphPort | null) => void, +): RuntimeGraphNodeData { + return { + ...node, + activePortId: activePort?.id ?? null, + highlightedPortIds: [...highlightedPortIds], + onPortEnter: setActivePort, + onPortLeave: () => setActivePort(null), + }; +} + +function flowEdge(edge: GraphEdgeData, highlightedEdgeIds: Set, hasActivePort: boolean): Edge { + const highlighted = highlightedEdgeIds.has(edge.id); + + return { + id: edge.id, + source: edge.source, + target: edge.target, + sourceHandle: edge.sourcePort ?? undefined, + targetHandle: edge.targetPort ?? undefined, + label: edge.label, + labelBgBorderRadius: 7, + labelBgPadding: [7, 4], + labelBgStyle: { + fill: highlighted ? "#fffaf2" : "#fffdfa", + stroke: highlighted ? edgeColors.accent : "#ded2c3", + strokeWidth: highlighted ? 1.25 : 1, + }, + labelStyle: { + fill: "#312721", + fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", + fontSize: 12, + fontWeight: 560, + }, + markerEnd: edgeMarker(highlighted), + type: "smoothstep", + animated: edge.scaleRelation === "multiscale", + className: `${edge.kind} ${edge.scaleRelation} ${highlighted ? "highlighted" : hasActivePort ? "dimmed" : ""}`, + style: edgeStyle(highlighted), + zIndex: highlighted ? 40 : 30, + data: edge, + }; +} + +function edgeMarker(highlighted: boolean) { + return { + type: MarkerType.ArrowClosed, + color: highlighted ? edgeColors.accent : edgeColors.base, + width: highlighted ? 10 : 9, + height: highlighted ? 10 : 9, + markerUnits: "userSpaceOnUse", + strokeWidth: 1.2, + }; +} + +function edgeStyle(highlighted: boolean) { + return { + stroke: highlighted ? edgeColors.accent : edgeColors.base, + strokeWidth: highlighted ? 3 : 2.2, + }; +} + +function deriveHighlight(graph: DependencyGraphView, activePort: GraphPort | null) { + const result = { + edges: new Set(), + nodes: new Set(), + ports: new Set(), + }; + if (!activePort) return result; + + result.ports.add(activePort.id); + const visitedPorts = new Set([activePort.id]); + const queue = [activePort.id]; + + while (queue.length > 0) { + const portId = queue.shift()!; + for (const edge of graph.edges) { + const sourcePort = edge.sourcePort; + const targetPort = edge.targetPort; + if (!sourcePort || !targetPort) continue; + if (sourcePort !== portId && targetPort !== portId) continue; + + result.edges.add(edge.id); + result.nodes.add(edge.source); + result.nodes.add(edge.target); + result.ports.add(sourcePort); + result.ports.add(targetPort); + + const nextPort = sourcePort === portId ? targetPort : sourcePort; + if (!visitedPorts.has(nextPort)) { + visitedPorts.add(nextPort); + queue.push(nextPort); + } + } + } + + return result; +} diff --git a/frontend/src/ModelNode.tsx b/frontend/src/ModelNode.tsx index cc167d52a..d888dc338 100644 --- a/frontend/src/ModelNode.tsx +++ b/frontend/src/ModelNode.tsx @@ -1,8 +1,8 @@ import { Handle, Position, type Node, type NodeProps } from "@xyflow/react"; import { Clock3, GitBranch, Layers3, Link2 } from "lucide-react"; -import type { GraphNodeData, GraphPort } from "./types"; +import type { GraphPort, RuntimeGraphNodeData } from "./types"; -type ModelFlowNode = Node; +type ModelFlowNode = Node; export function ModelNode({ data, selected }: NodeProps) { return ( @@ -15,24 +15,41 @@ export function ModelNode({ data, selected }: NodeProps) { {data.role === "hard_dependency" ? : }
- {data.scale} - {data.rate} + + {data.scale} + + + {data.rate} +
- - + +
{data.diagnostics.length > 0 &&
{data.diagnostics[0]}
} ); } -function PortColumn({ title, ports, side }: { title: string; ports: GraphPort[]; side: "input" | "output" }) { +function PortColumn({ title, ports, side, data }: { title: string; ports: GraphPort[]; side: "input" | "output"; data: RuntimeGraphNodeData }) { + const highlighted = new Set(data.highlightedPortIds ?? []); return (
{title}
{ports.map((port) => ( -
+
data.onPortEnter?.(port)} + onMouseLeave={() => data.onPortLeave?.()} + onPointerEnter={() => data.onPortEnter?.(port)} + onPointerLeave={() => data.onPortLeave?.()} + onClick={(event) => { + event.stopPropagation(); + data.onPortEnter?.(port); + }} + > {side === "input" && } {port.name} {port.mappingMode && } diff --git a/frontend/src/layout.ts b/frontend/src/layout.ts index ccd1a2da9..ad9412f32 100644 --- a/frontend/src/layout.ts +++ b/frontend/src/layout.ts @@ -1,10 +1,11 @@ import ELK from "elkjs/lib/elk.bundled.js"; import type { Edge, Node } from "@xyflow/react"; -import type { GraphEdgeData, GraphNodeData } from "./types"; +import type { GraphEdgeData, GraphPort, RuntimeGraphNodeData } from "./types"; const elk = new ELK(); +const NODE_WIDTH = 312; -export async function layoutGraph(nodes: Node[], edges: Edge[]) { +export async function layoutGraph(nodes: Node[], edges: Edge[]) { const graph = { id: "root", layoutOptions: { @@ -13,17 +14,22 @@ export async function layoutGraph(nodes: Node[], edges: Edge ({ id: node.id, - width: 312, - height: Math.max(160, 112 + Math.max(node.data.inputs.length, node.data.outputs.length) * 28), + width: NODE_WIDTH, + height: nodeHeight(node.data), + ports: [...node.data.inputs.map((port, index) => elkPort(port, index)), ...node.data.outputs.map((port, index) => elkPort(port, index))], + layoutOptions: { + "org.eclipse.elk.portConstraints": "FIXED_ORDER", + }, })), edges: edges.map((edge) => ({ id: edge.id, - sources: [edge.source], - targets: [edge.target], + sources: [edge.sourceHandle ?? edge.source], + targets: [edge.targetHandle ?? edge.target], })), }; @@ -35,3 +41,19 @@ export async function layoutGraph(nodes: Node[], edges: Edge svg { + color: var(--accent); } .process { - font-weight: 750; + font-weight: 820; font-size: 15px; + letter-spacing: 0; } .model-type { margin-top: 2px; font-size: 12px; - opacity: 0.9; + color: var(--muted); + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; } .node-meta { @@ -162,6 +260,7 @@ h1 { font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; } .port { @@ -172,9 +271,11 @@ h1 { min-height: 24px; margin: 4px 0; padding: 4px 7px; - border: 1px solid rgba(102, 112, 133, 0.14); - background: rgba(255, 255, 255, 0.78); + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(255, 253, 247, 0.9); font-size: 12px; + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; } .port.output { @@ -182,18 +283,29 @@ h1 { } .port.previous { - color: var(--red); + color: var(--clay); } .port.mapped { - border-color: rgba(40, 95, 159, 0.32); + border-color: rgba(31, 122, 83, 0.38); +} + +.port.highlighted { + border-color: var(--line); + background: #fffdfa; +} + +.port.active { + color: #fffdfa; + border-color: var(--accent); + background: var(--accent); } .react-flow__handle { width: 9px; height: 9px; - border: 0; - background: #475467; + border: 1px solid var(--line-strong); + background: var(--paper); } .react-flow__edge.multiscale path { @@ -205,12 +317,21 @@ h1 { } .react-flow__edge.hard_dependency path { - stroke: var(--red); + stroke: var(--clay); +} + +.react-flow__edge.highlighted path { + stroke-width: 3; + stroke: var(--accent); +} + +.react-flow__edge.dimmed { + opacity: 0.18; } .inspector { border-left: 1px solid var(--line); - background: rgba(255, 255, 255, 0.62); + background: rgba(255, 250, 242, 0.82); backdrop-filter: blur(14px); padding: 18px; overflow: auto; @@ -241,7 +362,7 @@ h1 { grid-template-columns: 84px minmax(0, 1fr); gap: 8px; padding: 8px 0; - border-top: 1px solid rgba(102, 112, 133, 0.16); + border-top: 1px solid rgba(183, 166, 150, 0.35); } .row span { @@ -256,8 +377,9 @@ h1 { .diagnostic, .edit-suggestion, .empty-state { - border: 1px solid rgba(102, 112, 133, 0.18); - background: rgba(255, 255, 255, 0.62); + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(255, 250, 242, 0.75); padding: 10px; color: var(--muted); } @@ -267,9 +389,9 @@ h1 { display: flex; align-items: center; gap: 7px; - color: var(--red); - border-color: rgba(177, 60, 74, 0.26); - background: rgba(177, 60, 74, 0.08); + color: var(--clay); + border-color: rgba(201, 97, 74, 0.28); + background: rgba(201, 97, 74, 0.09); } @media (max-width: 900px) { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index c9b6f317e..644bf5c42 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -22,6 +22,13 @@ export type GraphNodeData = { diagnostics: string[]; } & Record; +export type RuntimeGraphNodeData = GraphNodeData & { + activePortId?: string | null; + highlightedPortIds?: string[]; + onPortEnter?: (port: GraphPort) => void; + onPortLeave?: () => void; +}; + export type GraphEdgeData = { id: string; source: string; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 5e9021332..da209f813 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -6,5 +6,6 @@ export default defineConfig({ build: { outDir: "dist", emptyOutDir: true, + manifest: true, }, }); diff --git a/src/visualization/dependency_graph_view.jl b/src/visualization/dependency_graph_view.jl index d05505347..6e522b9f1 100644 --- a/src/visualization/dependency_graph_view.jl +++ b/src/visualization/dependency_graph_view.jl @@ -157,15 +157,15 @@ graph_view_json(mapping::ModelMapping; kwargs...) = graph_view_json(graph_view(m graph_view_json(sim::GraphSimulation; kwargs...) = graph_view_json(graph_view(sim; kwargs...)) """ - write_graph_view(path, view) - write_graph_view(path, mapping) + write_graph_view(path, view; renderer=:react) + write_graph_view(path, mapping; renderer=:react) Write a standalone HTML dependency graph visualisation. """ -function write_graph_view(path::AbstractString, view::DependencyGraphView) +function write_graph_view(path::AbstractString, view::DependencyGraphView; renderer::Symbol=:react) mkpath(dirname(abspath(path))) open(path, "w") do io - write(io, _graph_view_html(view)) + write(io, _graph_view_html(view; renderer=renderer)) end return abspath(path) end @@ -542,7 +542,93 @@ function _escape_json(s::AbstractString) return replace(escaped, " "<\\/") end -function _graph_view_html(view::DependencyGraphView) +function _graph_view_html(view::DependencyGraphView; renderer::Symbol=:react) + if renderer == :react + react_html = _react_graph_view_html(view) + isnothing(react_html) || return react_html + elseif renderer != :standalone + error("Unsupported dependency graph viewer renderer `$(renderer)`. Use `:react` or `:standalone`.") + end + return _standalone_graph_view_html(view) +end + +function _react_graph_view_html(view::DependencyGraphView) + assets_dir = _react_graph_viewer_assets_dir() + manifest_path = joinpath(assets_dir, ".vite", "manifest.json") + isfile(manifest_path) || return nothing + + manifest = _read_vite_manifest(manifest_path) + entry = _vite_entry(manifest) + isnothing(entry) && return nothing + + js_file = get(entry, "file", nothing) + isnothing(js_file) && return nothing + css_files = get(entry, "css", String[]) + js = read(joinpath(assets_dir, js_file), String) + css = join([read(joinpath(assets_dir, css_file), String) for css_file in css_files], "\n") + json = graph_view_json(view) + + html = raw""" + + + + + +PlantSimEngine Dependency Graph + + + + +
+ + + +""" + return replace( + html, + "__PSE_GRAPH_JSON__" => json, + "__PSE_GRAPH_CSS__" => css, + "__PSE_GRAPH_JS__" => js, + ) +end + +_react_graph_viewer_assets_dir() = joinpath(dirname(dirname(@__DIR__)), "frontend", "dist") + +function _read_vite_manifest(path::AbstractString) + text = read(path, String) + entries = Dict{String,Dict{String,Any}}() + entry_regex = Regex("\"([^\"]+)\"\\s*:\\s*\\{([^{}]*)\\}") + for entry_match in eachmatch(entry_regex, text) + key = entry_match.captures[1] + body = entry_match.captures[2] + entries[key] = _parse_flat_vite_manifest_entry(body) + end + return entries +end + +function _parse_flat_vite_manifest_entry(body::AbstractString) + entry = Dict{String,Any}() + string_field_regex = Regex("\"([^\"]+)\"\\s*:\\s*\"([^\"]*)\"") + array_field_regex = Regex("\"([^\"]+)\"\\s*:\\s*\\[([^\\]]*)\\]") + quoted_string_regex = Regex("\"([^\"]+)\"") + for m in eachmatch(string_field_regex, body) + entry[m.captures[1]] = m.captures[2] + end + for m in eachmatch(array_field_regex, body) + values = String[item.captures[1] for item in eachmatch(quoted_string_regex, m.captures[2])] + entry[m.captures[1]] = values + end + return entry +end + +function _vite_entry(manifest) + for value in values(manifest) + get(value, "isEntry", "") == "true" && return value + end + return get(manifest, "index.html", nothing) +end + +function _standalone_graph_view_html(view::DependencyGraphView) json = graph_view_json(view) html = raw""" diff --git a/test/test-dependency-graph-view.jl b/test/test-dependency-graph-view.jl index d1f5008ce..abaca17e2 100644 --- a/test/test-dependency-graph-view.jl +++ b/test/test-dependency-graph-view.jl @@ -25,6 +25,15 @@ html = read(html_path, String) @test occursin("PlantSimEngine Dependency Graph", html) @test occursin("pse-graph-data", html) + if isfile(joinpath(dirname(dirname(@__DIR__)), "frontend", "dist", ".vite", "manifest.json")) + @test occursin("react-flow", html) + end + + fallback_html_path = write_graph_view(joinpath(mktempdir(), "dependency_graph_fallback.html"), view; renderer=:standalone) + @test isfile(fallback_html_path) + fallback_html = read(fallback_html_path, String) + @test occursin("PlantSimEngine Dependency Graph", fallback_html) + @test occursin("canvas", fallback_html) multiscale_mapping = ModelMapping( :Plant => MultiScaleModel( From 8a56415849815cc4542b2378400252b65c6b5a8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Vezy?= Date: Wed, 29 Apr 2026 09:20:00 +0200 Subject: [PATCH 3/8] Add better layout for hard-dependency nodes --- frontend/src/App.tsx | 80 ++++++---- frontend/src/DependencyEdge.tsx | 94 ++++++++++++ frontend/src/ModelNode.tsx | 17 ++- frontend/src/sampleGraph.ts | 219 +++++++++++++++++++++++----- frontend/src/styles.css | 249 +++++++++++++++++++++++++++++++- 5 files changed, 580 insertions(+), 79 deletions(-) create mode 100644 frontend/src/DependencyEdge.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5d159cde8..988069f22 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ import { } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; import { AlertTriangle, GitPullRequestArrow, RotateCcw, ScissorsLineDashed } from "lucide-react"; +import { DependencyEdge } from "./DependencyEdge"; import { ModelNode } from "./ModelNode"; import { layoutGraph } from "./layout"; import { sampleGraph } from "./sampleGraph"; @@ -21,9 +22,12 @@ import type { DependencyGraphView, GraphEdgeData, GraphNodeData, GraphPort, Runt import "./styles.css"; const nodeTypes = { model: ModelNode }; +const edgeTypes = { dependency: DependencyEdge }; const edgeColors = { base: "#a99a8c", accent: "#1f7a53", + mapped: "#4f8d69", + hard: "#bf6a54", }; export default function App() { @@ -39,7 +43,7 @@ export default function App() { id: node.id, type: "model", position: { x: 0, y: 0 }, - data: runtimeNodeData(node, null, new Set(), setActivePort), + data: runtimeNodeData(node, null, new Set(), new Set(graph.cycleNodes), setActivePort), })); const nextEdges = graph.edges.map((edge) => flowEdge(edge, new Set(), false)); layoutGraph(nextNodes, nextEdges).then((layouted) => { @@ -51,7 +55,7 @@ export default function App() { useEffect(() => { setNodes((current) => current.map((node) => ({ ...node, - data: runtimeNodeData(node.data, activePort, highlight.ports, setActivePort), + data: runtimeNodeData(node.data, activePort, highlight.ports, new Set(graph.cycleNodes), setActivePort), }))); setEdges((current) => current.map((edge) => edge.data ? flowEdge(edge.data, highlight.edges, Boolean(activePort)) : edge)); }, [activePort, highlight.edges, highlight.ports, setEdges, setNodes]); @@ -59,11 +63,11 @@ export default function App() { const onConnect = useCallback((connection: Connection) => { setEdges((current) => addEdge({ ...connection, - type: "smoothstep", - animated: true, - markerEnd: edgeMarker(false), - style: edgeStyle(false), - zIndex: 30, + type: "dependency", + animated: true, + markerEnd: edgeMarker(edgeColors.base), + style: edgeStyle(edgeColors.base, false), + zIndex: 5, }, current)); }, [setEdges]); @@ -92,6 +96,7 @@ export default function App() { nodes={nodes} edges={edges} nodeTypes={nodeTypes} + edgeTypes={edgeTypes} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onConnect={onConnect} @@ -123,6 +128,21 @@ export default function App() { ) : (
Select a model node.
)} +

Variable

+ {activePort ? ( +
+
+ {activePort.name} + {activePort.role} +
+ + {activePort.mappingMode && } + {activePort.sourceScale && } + {activePort.previousTimeStep &&
uses previous timestep
} +
+ ) : ( +
Hover or click a variable to see its computed default.
+ )}

Diagnostics

{graph.diagnostics.length > 0 ? graph.diagnostics.map((item) =>
{item}
) :
No diagnostics.
} @@ -145,10 +165,12 @@ function runtimeNodeData( node: GraphNodeData, activePort: GraphPort | null, highlightedPortIds: Set, + cycleNodeIds: Set, setActivePort: (port: GraphPort | null) => void, ): RuntimeGraphNodeData { return { ...node, + cyclic: cycleNodeIds.has(node.id), activePortId: activePort?.id ?? null, highlightedPortIds: [...highlightedPortIds], onPortEnter: setActivePort, @@ -165,44 +187,38 @@ function flowEdge(edge: GraphEdgeData, highlightedEdgeIds: Set, hasActiv target: edge.target, sourceHandle: edge.sourcePort ?? undefined, targetHandle: edge.targetPort ?? undefined, - label: edge.label, - labelBgBorderRadius: 7, - labelBgPadding: [7, 4], - labelBgStyle: { - fill: highlighted ? "#fffaf2" : "#fffdfa", - stroke: highlighted ? edgeColors.accent : "#ded2c3", - strokeWidth: highlighted ? 1.25 : 1, - }, - labelStyle: { - fill: "#312721", - fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", - fontSize: 12, - fontWeight: 560, - }, - markerEnd: edgeMarker(highlighted), - type: "smoothstep", + markerEnd: edgeMarker(edgeColor(edge, highlighted)), + type: "dependency", animated: edge.scaleRelation === "multiscale", className: `${edge.kind} ${edge.scaleRelation} ${highlighted ? "highlighted" : hasActivePort ? "dimmed" : ""}`, - style: edgeStyle(highlighted), - zIndex: highlighted ? 40 : 30, - data: edge, + style: edgeStyle(edgeColor(edge, highlighted), highlighted), + selected: highlighted, + zIndex: highlighted ? 120 : 5, + data: { ...edge, highlighted, dimmed: hasActivePort && !highlighted }, }; } -function edgeMarker(highlighted: boolean) { +function edgeColor(edge: GraphEdgeData, highlighted: boolean) { + if (highlighted) return edgeColors.accent; + if (edge.kind === "hard_dependency") return edgeColors.hard; + if (edge.kind === "mapped_variable" || edge.scaleRelation === "multiscale") return edgeColors.mapped; + return edgeColors.base; +} + +function edgeMarker(color: string) { return { type: MarkerType.ArrowClosed, - color: highlighted ? edgeColors.accent : edgeColors.base, - width: highlighted ? 10 : 9, - height: highlighted ? 10 : 9, + color, + width: 9, + height: 9, markerUnits: "userSpaceOnUse", strokeWidth: 1.2, }; } -function edgeStyle(highlighted: boolean) { +function edgeStyle(color: string, highlighted: boolean) { return { - stroke: highlighted ? edgeColors.accent : edgeColors.base, + stroke: color, strokeWidth: highlighted ? 3 : 2.2, }; } diff --git a/frontend/src/DependencyEdge.tsx b/frontend/src/DependencyEdge.tsx new file mode 100644 index 000000000..4fd450d3d --- /dev/null +++ b/frontend/src/DependencyEdge.tsx @@ -0,0 +1,94 @@ +import { + BaseEdge, + EdgeLabelRenderer, + Position, + getSmoothStepPath, + type Edge, + type EdgeProps, +} from "@xyflow/react"; +import type { GraphEdgeData } from "./types"; + +type DependencyFlowEdge = Edge; + +export function DependencyEdge({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition = Position.Right, + targetPosition = Position.Left, + markerEnd, + style, + data, +}: EdgeProps) { + const [path, labelX, labelY] = getSmoothStepPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + borderRadius: 18, + offset: 28, + }); + + const label = data?.label; + const renamed = data?.sourceVariable && data?.targetVariable && data.sourceVariable !== data.targetVariable; + const highlighted = Boolean(data?.highlighted); + const dimmed = Boolean(data?.dimmed); + + return ( + <> + + {label && ( + + + +
+ {label} + {renamed && {data.sourceVariable} → {data.targetVariable}} + {data.scaleRelation === "multiscale" && multiscale} +
+
+ )} + + ); +} + +function EdgeTerminal({ className, x, y, side, color }: { className: string; x: number; y: number; side: Position; color: string }) { + return ( +
+ ); +} + +function terminalColor(data: GraphEdgeData, highlighted: boolean) { + if (highlighted) return "#1f7a53"; + if (data.kind === "hard_dependency") return "#bf6a54"; + if (data.kind === "mapped_variable" || data.scaleRelation === "multiscale") return "#1f7a53"; + return "#b7a696"; +} diff --git a/frontend/src/ModelNode.tsx b/frontend/src/ModelNode.tsx index d888dc338..a96251c55 100644 --- a/frontend/src/ModelNode.tsx +++ b/frontend/src/ModelNode.tsx @@ -1,12 +1,13 @@ import { Handle, Position, type Node, type NodeProps } from "@xyflow/react"; -import { Clock3, GitBranch, Layers3, Link2 } from "lucide-react"; +import { Clock3, GitBranch, Layers3, Link2, PhoneCall } from "lucide-react"; import type { GraphPort, RuntimeGraphNodeData } from "./types"; type ModelFlowNode = Node; export function ModelNode({ data, selected }: NodeProps) { + const cyclic = Boolean(data.cyclic); return ( -
+
{data.process}
@@ -15,6 +16,11 @@ export function ModelNode({ data, selected }: NodeProps) { {data.role === "hard_dependency" ? : }
+ {data.role === "hard_dependency" && ( + + called by parent + + )} {data.scale} @@ -40,7 +46,8 @@ function PortColumn({ title, ports, side, data }: { title: string; ports: GraphP
data.onPortEnter?.(port)} onMouseLeave={() => data.onPortLeave?.()} onPointerEnter={() => data.onPortEnter?.(port)} @@ -59,3 +66,7 @@ function PortColumn({ title, ports, side, data }: { title: string; ports: GraphP
); } + +function portValueLabel(port: GraphPort) { + return port.role === "input" ? "Default" : "Declaration"; +} diff --git a/frontend/src/sampleGraph.ts b/frontend/src/sampleGraph.ts index 42b0d3dcc..ae6948b95 100644 --- a/frontend/src/sampleGraph.ts +++ b/frontend/src/sampleGraph.ts @@ -1,49 +1,188 @@ -import type { DependencyGraphView } from "./types"; +import type { DependencyGraphView, GraphEdgeData, GraphNodeData, GraphPort } from "./types"; + +const scales = ["Scene", "Plant", "Leaf"]; export const sampleGraph: DependencyGraphView = { nodes: [ - { - id: "model:Default:lai", - process: "lai", - scale: "Default", - modelType: "ToyLAIModel", - role: "model", - rate: "default rate", - inputs: [{ id: "model:Default:lai:input:TT_cu", name: "TT_cu", role: "input", mappingMode: null, sourceScale: null, sourceVariable: null, previousTimeStep: false, default: "uninitialized" }], - outputs: [{ id: "model:Default:lai:output:LAI", name: "LAI", role: "output", mappingMode: null, sourceScale: null, sourceVariable: null, previousTimeStep: false, default: "Float64" }], - parent: null, - diagnostics: [], - }, - { - id: "model:Default:light_interception", - process: "light_interception", - scale: "Default", - modelType: "Beer", - role: "model", - rate: "default rate", - inputs: [{ id: "model:Default:light_interception:input:LAI", name: "LAI", role: "input", mappingMode: null, sourceScale: null, sourceVariable: null, previousTimeStep: false, default: "uninitialized" }], - outputs: [{ id: "model:Default:light_interception:output:aPPFD", name: "aPPFD", role: "output", mappingMode: null, sourceScale: null, sourceVariable: null, previousTimeStep: false, default: "Float64" }], - parent: null, - diagnostics: [], - }, + node("meteo", "Scene", "WeatherDriver", "hourly", [], [ + output("meteo", "Scene", "PPFD"), + output("meteo", "Scene", "Tair"), + output("meteo", "Scene", "VPD"), + ]), + node("lai", "Plant", "ToyLAIModel", "daily", [ + input("lai", "Plant", "TT_cu", { defaultValue: "0.0" }), + input("lai", "Plant", "biomass", { previousTimeStep: true, defaultValue: "PreviousTimeStep(Float64)" }), + ], [ + output("lai", "Plant", "LAI"), + ], ["biomass is read from the previous timestep to keep the growth/LAI feedback open."]), + node("light_interception", "Leaf", "BeerLambert", "hourly", [ + input("light_interception", "Leaf", "LAI", { mappingMode: "SingleNodeMapping", sourceScale: "Plant", sourceVariable: "LAI", defaultValue: "0.0" }), + input("light_interception", "Leaf", "PPFD", { mappingMode: "SingleNodeMapping", sourceScale: "Scene", sourceVariable: "PPFD", defaultValue: "0.0" }), + ], [ + output("light_interception", "Leaf", "aPPFD"), + ]), + node("stomatal_conductance", "Leaf", "MedlynGs", "hourly", [ + input("stomatal_conductance", "Leaf", "VPD", { mappingMode: "SingleNodeMapping", sourceScale: "Scene", sourceVariable: "VPD", defaultValue: "1.0" }), + input("stomatal_conductance", "Leaf", "psi_leaf", { mappingMode: "SingleNodeMapping", sourceScale: "Plant", sourceVariable: "psi_leaf", previousTimeStep: true, defaultValue: "PreviousTimeStep(-0.3)" }), + ], [ + output("stomatal_conductance", "Leaf", "gs"), + ]), + node("boundary_layer", "Leaf", "ForcedConvection", "hourly", [ + input("boundary_layer", "Leaf", "wind", { mappingMode: "SingleNodeMapping", sourceScale: "Scene", sourceVariable: "wind", defaultValue: "1.2" }), + input("boundary_layer", "Leaf", "leaf_width", { defaultValue: "0.04" }), + ], [ + output("boundary_layer", "Leaf", "gb"), + ], ["Hard dependency: called inside transpiration.run!, not scheduled as an independent soft node."], "hard_dependency", modelId("transpiration", "Leaf")), + node("photosynthesis", "Leaf", "Farquhar", "hourly", [ + input("photosynthesis", "Leaf", "aPPFD", { defaultValue: "0.0" }), + input("photosynthesis", "Leaf", "Tair", { mappingMode: "SingleNodeMapping", sourceScale: "Scene", sourceVariable: "Tair", defaultValue: "20.0" }), + input("photosynthesis", "Leaf", "gs", { defaultValue: "0.0" }), + ], [ + output("photosynthesis", "Leaf", "An"), + ]), + node("transpiration", "Leaf", "PenmanMonteith", "hourly", [ + input("transpiration", "Leaf", "gs", { defaultValue: "0.0" }), + input("transpiration", "Leaf", "VPD", { mappingMode: "SingleNodeMapping", sourceScale: "Scene", sourceVariable: "VPD", defaultValue: "1.0" }), + input("transpiration", "Leaf", "gb", { defaultValue: "0.0" }), + ], [ + output("transpiration", "Leaf", "E"), + ]), + node("water_balance", "Plant", "SoilPlantWater", "daily", [ + input("water_balance", "Plant", "transpiration", { mappingMode: "MultiNodeMapping", sourceScale: "Leaf", sourceVariable: "E", defaultValue: "RefVector length 0" }), + input("water_balance", "Plant", "soil_water", { defaultValue: "0.32" }), + ], [ + output("water_balance", "Plant", "psi_leaf"), + ]), + node("growth", "Plant", "CarbonAllocation", "daily", [ + input("growth", "Plant", "assimilation", { mappingMode: "MultiNodeMapping", sourceScale: "Leaf", sourceVariable: "An", defaultValue: "RefVector length 0" }), + input("growth", "Plant", "LAI", { defaultValue: "0.0" }), + ], [ + output("growth", "Plant", "biomass"), + ]), ], edges: [ - { - id: "edge:sample", - source: "model:Default:lai", - target: "model:Default:light_interception", - sourcePort: "model:Default:lai:output:LAI", - targetPort: "model:Default:light_interception:input:LAI", - sourceVariable: "LAI", - targetVariable: "LAI", - kind: "soft_dependency", - scaleRelation: "same_scale", - label: "LAI", - diagnostics: [], - }, + edge("meteo", "Scene", "PPFD", "light_interception", "Leaf", "PPFD", "mapped_variable", "multiscale", "PPFD"), + edge("lai", "Plant", "LAI", "light_interception", "Leaf", "LAI", "mapped_variable", "multiscale", "LAI"), + edge("light_interception", "Leaf", "aPPFD", "photosynthesis", "Leaf", "aPPFD", "soft_dependency", "same_scale", "aPPFD"), + edge("meteo", "Scene", "Tair", "photosynthesis", "Leaf", "Tair", "mapped_variable", "multiscale", "Tair"), + edge("meteo", "Scene", "VPD", "stomatal_conductance", "Leaf", "VPD", "mapped_variable", "multiscale", "VPD"), + edge("stomatal_conductance", "Leaf", "gs", "photosynthesis", "Leaf", "gs", "soft_dependency", "same_scale", "gs"), + edge("stomatal_conductance", "Leaf", "gs", "transpiration", "Leaf", "gs", "soft_dependency", "same_scale", "gs"), + hardEdge("transpiration", "Leaf", "boundary_layer", "Leaf", "calls"), + edge("meteo", "Scene", "VPD", "transpiration", "Leaf", "VPD", "mapped_variable", "multiscale", "VPD"), + edge("transpiration", "Leaf", "E", "water_balance", "Plant", "transpiration", "mapped_variable", "multiscale", "E → transpiration"), + edge("photosynthesis", "Leaf", "An", "growth", "Plant", "assimilation", "mapped_variable", "multiscale", "An → assimilation"), + edge("lai", "Plant", "LAI", "growth", "Plant", "LAI", "soft_dependency", "same_scale", "LAI"), ], - scales: ["Default"], + scales, cyclic: false, cycleNodes: [], - diagnostics: [], + diagnostics: [ + "Potential feedback stomatal_conductance.gs -> transpiration.E -> water_balance.psi_leaf -> stomatal_conductance.psi_leaf is opened with PreviousTimeStep.", + "Potential feedback growth.biomass -> lai.biomass is opened with PreviousTimeStep.", + ], +}; + +type PortOptions = { + mappingMode?: string; + sourceScale?: string; + sourceVariable?: string; + previousTimeStep?: boolean; + defaultValue?: string; }; + +function node( + process: string, + scale: string, + modelType: string, + rate: string, + inputs: GraphPort[], + outputs: GraphPort[], + diagnostics: string[] = [], + role: GraphNodeData["role"] = "model", + parent: string | null = null, +): GraphNodeData { + return { + id: modelId(process, scale), + process, + scale, + modelType, + role, + rate, + inputs, + outputs, + parent, + diagnostics, + }; +} + +function input(process: string, scale: string, name: string, options: PortOptions = {}): GraphPort { + return port(process, scale, name, "input", options); +} + +function output(process: string, scale: string, name: string, options: PortOptions = {}): GraphPort { + return port(process, scale, name, "output", { defaultValue: "Float64", ...options }); +} + +function port(process: string, scale: string, name: string, role: "input" | "output", options: PortOptions): GraphPort { + return { + id: portId(process, scale, role, name), + name, + role, + mappingMode: options.mappingMode ?? null, + sourceScale: options.sourceScale ?? null, + sourceVariable: options.sourceVariable ?? null, + previousTimeStep: options.previousTimeStep ?? false, + default: options.defaultValue ?? "uninitialized", + }; +} + +function edge( + sourceProcess: string, + sourceScale: string, + sourceVariable: string, + targetProcess: string, + targetScale: string, + targetVariable: string, + kind: GraphEdgeData["kind"], + scaleRelation: GraphEdgeData["scaleRelation"], + label: string, +): GraphEdgeData { + return { + id: `edge:${sourceScale}:${sourceProcess}:${sourceVariable}->${targetScale}:${targetProcess}:${targetVariable}`, + source: modelId(sourceProcess, sourceScale), + target: modelId(targetProcess, targetScale), + sourcePort: portId(sourceProcess, sourceScale, "output", sourceVariable), + targetPort: portId(targetProcess, targetScale, "input", targetVariable), + sourceVariable, + targetVariable, + kind, + scaleRelation, + label, + diagnostics: [], + }; +} + +function hardEdge(sourceProcess: string, sourceScale: string, targetProcess: string, targetScale: string, label: string): GraphEdgeData { + return { + id: `edge:hard:${sourceScale}:${sourceProcess}->${targetScale}:${targetProcess}`, + source: modelId(sourceProcess, sourceScale), + target: modelId(targetProcess, targetScale), + sourcePort: null, + targetPort: null, + sourceVariable: null, + targetVariable: null, + kind: "hard_dependency", + scaleRelation: sourceScale === targetScale ? "same_scale" : "multiscale", + label, + diagnostics: ["Hard dependency: target model is invoked manually by the caller."], + }; +} + +function modelId(process: string, scale: string) { + return `model:${scale}:${process}`; +} + +function portId(process: string, scale: string, role: "input" | "output", variable: string) { + return `${modelId(process, scale)}:${role}:${variable}`; +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index a7deec965..001ec6021 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -22,7 +22,11 @@ body { margin: 0; color: var(--ink); - background: var(--bg); + background: + radial-gradient(circle at 20% 24%, rgba(31, 122, 83, 0.045), transparent 30%), + radial-gradient(circle at 78% 68%, rgba(201, 144, 53, 0.055), transparent 34%), + linear-gradient(180deg, rgba(255, 250, 242, 0.26), transparent 42%), + var(--bg); font-family: "Avenir Next", "Trebuchet MS", "Segoe UI", sans-serif; } @@ -37,6 +41,19 @@ body { min-width: 0; } +.graph-panel::after { + content: ""; + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; + opacity: 0.2; + background-image: + radial-gradient(rgba(49, 39, 33, 0.11) 0.55px, transparent 0.55px); + background-size: 16px 16px; + mask-image: linear-gradient(to bottom, transparent 0%, black 22%, black 82%, transparent 100%); +} + .topbar { position: absolute; z-index: 10; @@ -176,6 +193,7 @@ h1 { .react-flow { background: transparent; + z-index: 1; } .react-flow__background { @@ -183,7 +201,19 @@ h1 { } .react-flow__edges { - z-index: 30; + z-index: 8; +} + +.react-flow__edges:has(.react-flow__edge.highlighted) { + z-index: 120; +} + +.react-flow__edgelabel-renderer { + z-index: 28; +} + +.react-flow__edgelabel-renderer:has(.edge-chip.highlighted) { + z-index: 121; } .react-flow__nodes { @@ -208,8 +238,44 @@ h1 { box-shadow: 0 20px 48px rgba(31, 122, 83, 0.14); } +.model-node.cyclic { + border-color: rgba(191, 106, 84, 0.62); + box-shadow: + 0 18px 42px var(--shadow), + 0 0 0 3px rgba(191, 106, 84, 0.08); +} + +.model-node.cyclic .node-header > svg { + color: var(--clay); +} + .model-node.hard_dependency { + width: 286px; + border-color: rgba(191, 106, 84, 0.55); border-style: dashed; + background: rgba(255, 246, 240, 0.94); + box-shadow: 0 14px 30px rgba(191, 106, 84, 0.12); +} + +.model-node.hard_dependency .node-header { + background: + linear-gradient(90deg, rgba(191, 106, 84, 0.08), transparent 60%), + var(--paper-strong); +} + +.model-node.hard_dependency .node-header > svg { + color: var(--clay); +} + +.model-node.hard_dependency .process::before { + content: "↳ "; + color: var(--clay); +} + +.hard-chip { + color: var(--clay); + border-color: rgba(191, 106, 84, 0.28) !important; + background: rgba(255, 246, 240, 0.9) !important; } .node-header { @@ -278,6 +344,35 @@ h1 { font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; } +.port::after { + content: attr(data-default); + position: absolute; + z-index: 60; + left: 50%; + bottom: calc(100% + 8px); + width: max-content; + max-width: 240px; + padding: 7px 9px; + color: var(--paper); + background: var(--ink); + border-radius: 9px; + box-shadow: 0 12px 28px rgba(56, 43, 35, 0.18); + font-family: "Avenir Next", "Trebuchet MS", "Segoe UI", sans-serif; + font-size: 11px; + line-height: 1.3; + white-space: normal; + opacity: 0; + pointer-events: none; + transform: translate(-50%, 4px); + transition: opacity 120ms ease, transform 120ms ease; +} + +.port:hover::after, +.port.active::after { + opacity: 1; + transform: translate(-50%, 0); +} + .port.output { justify-content: flex-end; } @@ -306,6 +401,8 @@ h1 { height: 9px; border: 1px solid var(--line-strong); background: var(--paper); + z-index: 25; + box-shadow: 0 0 0 3px rgba(255, 250, 242, 0.92); } .react-flow__edge.multiscale path { @@ -313,7 +410,7 @@ h1 { } .react-flow__edge.mapped_variable path { - stroke: var(--blue); + stroke: var(--accent); } .react-flow__edge.hard_dependency path { @@ -325,8 +422,122 @@ h1 { stroke: var(--accent); } +.react-flow__edge.highlighted { + z-index: 80 !important; +} + .react-flow__edge.dimmed { - opacity: 0.18; + opacity: 0.04; +} + +.edge-chip { + position: absolute; + z-index: -1; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 7px; + color: var(--ink); + background: rgba(255, 250, 242, 0.94); + border: 1px solid var(--line); + border-radius: 999px; + box-shadow: 0 8px 20px rgba(56, 43, 35, 0.1); + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; + font-size: 11px; + line-height: 1; + pointer-events: none; + white-space: nowrap; +} + +.edge-chip.mapped_variable, +.edge-chip.multiscale { + border-color: rgba(31, 122, 83, 0.3); + background: rgba(248, 250, 241, 0.96); +} + +.edge-chip.hard_dependency { + border-color: rgba(191, 106, 84, 0.32); + background: rgba(255, 246, 240, 0.96); +} + +.edge-chip small { + color: var(--muted); + font-size: 9px; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.edge-chip.highlighted { + z-index: 80; + color: #fffdfa; + border-color: var(--accent); + background: var(--accent); + box-shadow: 0 12px 24px rgba(31, 122, 83, 0.22); +} + +.edge-chip.highlighted small { + color: rgba(255, 253, 247, 0.72); +} + +.edge-chip.dimmed { + opacity: 0.04; +} + +.edge-terminal { + position: absolute; + z-index: 32; + --terminal-color: var(--line-strong); + width: 18px; + height: 10px; + pointer-events: none; + opacity: 0.95; +} + +.edge-terminal::before { + content: ""; + position: absolute; + top: 4px; + left: 2px; + right: 2px; + height: 2px; + border-radius: 999px; + background: var(--terminal-color); +} + +.edge-terminal.target::after { + content: ""; + position: absolute; + top: 1px; + width: 0; + height: 0; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; +} + +.edge-terminal[data-side="left"]::before { + left: 7px; +} + +.edge-terminal[data-side="right"]::before { + right: 7px; +} + +.edge-terminal.target[data-side="left"]::after { + left: 0; + border-right: 7px solid var(--terminal-color); +} + +.edge-terminal.target[data-side="right"]::after { + right: 0; + border-left: 7px solid var(--terminal-color); +} + +.edge-terminal.highlighted::before { + height: 3px; +} + +.edge-terminal.dimmed { + opacity: 0.12; } .inspector { @@ -374,6 +585,36 @@ h1 { font-weight: 620; } +.variable-card { + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(255, 250, 242, 0.75); + padding: 10px; +} + +.variable-card .row:first-of-type { + margin-top: 8px; +} + +.variable-card-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; +} + +.variable-card-title span { + overflow-wrap: anywhere; + font-weight: 720; +} + +.variable-card-title small { + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.08em; +} + .diagnostic, .edit-suggestion, .empty-state { From f9545497130fdaf6ca4dedd54bda704c62f03af6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Vezy?= Date: Wed, 29 Apr 2026 15:31:01 +0200 Subject: [PATCH 4/8] fix arrow direction --- frontend/src/styles.css | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 001ec6021..b13aa2142 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -523,13 +523,13 @@ h1 { } .edge-terminal.target[data-side="left"]::after { - left: 0; - border-right: 7px solid var(--terminal-color); + right: 0; + border-left: 7px solid var(--terminal-color); } .edge-terminal.target[data-side="right"]::after { - right: 0; - border-left: 7px solid var(--terminal-color); + left: 0; + border-right: 7px solid var(--terminal-color); } .edge-terminal.highlighted::before { From fd52819ef7dbf8a86706f9bd4bead347a9b71012 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Vezy?= Date: Wed, 29 Apr 2026 16:14:08 +0200 Subject: [PATCH 5/8] Add required initialization panel to graph viewer --- frontend/src/App.tsx | 82 +++++++++++++++++++-- frontend/src/DependencyEdge.tsx | 21 ++++-- frontend/src/ModelNode.tsx | 7 +- frontend/src/styles.css | 123 +++++++++++++++++++++++++++++++- frontend/src/types.ts | 1 + 5 files changed, 221 insertions(+), 13 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 988069f22..39fe5d7b2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,7 +13,7 @@ import { type Node, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; -import { AlertTriangle, GitPullRequestArrow, RotateCcw, ScissorsLineDashed } from "lucide-react"; +import { AlertTriangle, CircleAlert, GitPullRequestArrow, RotateCcw, ScissorsLineDashed } from "lucide-react"; import { DependencyEdge } from "./DependencyEdge"; import { ModelNode } from "./ModelNode"; import { layoutGraph } from "./layout"; @@ -34,16 +34,23 @@ export default function App() { const [graph] = useState(loadInitialGraph()); const [selected, setSelected] = useState(null); const [activePort, setActivePort] = useState(null); + const [showRequiredPanel, setShowRequiredPanel] = useState(false); const [nodes, setNodes, onNodesChange] = useNodesState>([]); const [edges, setEdges, onEdgesChange] = useEdgesState>([]); const highlight = useMemo(() => deriveHighlight(graph, activePort), [activePort, graph]); + const requiredInputPortIds = useMemo(() => deriveRequiredInputPorts(graph), [graph]); + const requiredInputs = useMemo(() => graph.nodes.flatMap((node) => ( + node.inputs + .filter((port) => requiredInputPortIds.has(port.id)) + .map((port) => ({ node, port })) + )), [graph, requiredInputPortIds]); useEffect(() => { const nextNodes = graph.nodes.map((node) => ({ id: node.id, type: "model", position: { x: 0, y: 0 }, - data: runtimeNodeData(node, null, new Set(), new Set(graph.cycleNodes), setActivePort), + data: runtimeNodeData(node, null, new Set(), requiredInputPortIds, new Set(graph.cycleNodes), setActivePort), })); const nextEdges = graph.edges.map((edge) => flowEdge(edge, new Set(), false)); layoutGraph(nextNodes, nextEdges).then((layouted) => { @@ -55,10 +62,10 @@ export default function App() { useEffect(() => { setNodes((current) => current.map((node) => ({ ...node, - data: runtimeNodeData(node.data, activePort, highlight.ports, new Set(graph.cycleNodes), setActivePort), + data: runtimeNodeData(node.data, activePort, highlight.ports, requiredInputPortIds, new Set(graph.cycleNodes), setActivePort), }))); setEdges((current) => current.map((edge) => edge.data ? flowEdge(edge.data, highlight.edges, Boolean(activePort)) : edge)); - }, [activePort, highlight.edges, highlight.ports, setEdges, setNodes]); + }, [activePort, graph.cycleNodes, highlight.edges, highlight.ports, requiredInputPortIds, setEdges, setNodes]); const onConnect = useCallback((connection: Connection) => { setEdges((current) => addEdge({ @@ -86,12 +93,53 @@ export default function App() {
{graph.nodes.length} models {graph.edges.length} links + {requiredInputs.length > 0 && ( + + )} {graph.cyclic && cycle}
+ {showRequiredPanel && ( +
+
+
+
Required Initializations
+

{requiredInputs.length} inputs

+
+ +
+ {requiredInputs.length > 0 ? ( +
+ {requiredInputs.map(({ node, port }) => ( + + ))} +
+ ) : ( +
Every input is computed by another model.
+ )} +
+ )} port.name).join(", ") || "none" } /> port.name).join(", ") || "none" } /> + {selected.inputs.filter((port) => requiredInputPortIds.has(port.id)).map((port) => ( +
{port.name} must be initialized
+ ))} {selected.inputs.filter((port) => port.previousTimeStep).map((port) => (
{port.name} uses previous timestep
))} @@ -138,11 +189,25 @@ export default function App() { {activePort.mappingMode && } {activePort.sourceScale && } + {requiredInputPortIds.has(activePort.id) &&
required initialization
} {activePort.previousTimeStep &&
uses previous timestep
}
) : (
Hover or click a variable to see its computed default.
)} +

Required Initializations

+ {requiredInputs.length > 0 ? ( +
+ {requiredInputs.map(({ node, port }) => ( + + ))} +
+ ) : ( +
Every input is computed by another model.
+ )}

Diagnostics

{graph.diagnostics.length > 0 ? graph.diagnostics.map((item) =>
{item}
) :
No diagnostics.
} @@ -165,6 +230,7 @@ function runtimeNodeData( node: GraphNodeData, activePort: GraphPort | null, highlightedPortIds: Set, + requiredInputPortIds: Set, cycleNodeIds: Set, setActivePort: (port: GraphPort | null) => void, ): RuntimeGraphNodeData { @@ -173,11 +239,19 @@ function runtimeNodeData( cyclic: cycleNodeIds.has(node.id), activePortId: activePort?.id ?? null, highlightedPortIds: [...highlightedPortIds], + requiredInputPortIds: [...requiredInputPortIds], onPortEnter: setActivePort, onPortLeave: () => setActivePort(null), }; } +function deriveRequiredInputPorts(graph: DependencyGraphView) { + const computedInputPortIds = new Set(graph.edges.map((edge) => edge.targetPort).filter(Boolean)); + return new Set(graph.nodes.flatMap((node) => ( + node.inputs.filter((port) => !computedInputPortIds.has(port.id)).map((port) => port.id) + ))); +} + function flowEdge(edge: GraphEdgeData, highlightedEdgeIds: Set, hasActivePort: boolean): Edge { const highlighted = highlightedEdgeIds.has(edge.id); diff --git a/frontend/src/DependencyEdge.tsx b/frontend/src/DependencyEdge.tsx index 4fd450d3d..158bffbf9 100644 --- a/frontend/src/DependencyEdge.tsx +++ b/frontend/src/DependencyEdge.tsx @@ -35,13 +35,16 @@ export function DependencyEdge({ const label = data?.label; const renamed = data?.sourceVariable && data?.targetVariable && data.sourceVariable !== data.targetVariable; + const showPrimaryLabel = Boolean(label) && !renamed; + const showScaleTag = data?.scaleRelation === "multiscale"; + const showChip = showPrimaryLabel || showScaleTag; const highlighted = Boolean(data?.highlighted); const dimmed = Boolean(data?.dimmed); return ( <> - {label && ( + {showChip && ( - {label} - {renamed && {data.sourceVariable} → {data.targetVariable}} - {data.scaleRelation === "multiscale" && multiscale} + {showPrimaryLabel && {label}} + {showScaleTag && multiscale}
)} @@ -74,12 +76,21 @@ export function DependencyEdge({ } function EdgeTerminal({ className, x, y, side, color }: { className: string; x: number; y: number; side: Position; color: string }) { + const xOffset = + className.includes("target") + ? side === Position.Left + ? -9 + : 9 + : side === Position.Left + ? 9 + : -9; + return (
diff --git a/frontend/src/ModelNode.tsx b/frontend/src/ModelNode.tsx index a96251c55..12f0d20f9 100644 --- a/frontend/src/ModelNode.tsx +++ b/frontend/src/ModelNode.tsx @@ -39,15 +39,16 @@ export function ModelNode({ data, selected }: NodeProps) { function PortColumn({ title, ports, side, data }: { title: string; ports: GraphPort[]; side: "input" | "output"; data: RuntimeGraphNodeData }) { const highlighted = new Set(data.highlightedPortIds ?? []); + const requiredInputs = new Set(data.requiredInputPortIds ?? []); return (
{title}
{ports.map((port) => (
data.onPortEnter?.(port)} onMouseLeave={() => data.onPortLeave?.()} onPointerEnter={() => data.onPortEnter?.(port)} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index b13aa2142..81a94c649 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -106,6 +106,7 @@ h1 { } .metrics span, +.metric-button, .node-meta span { display: inline-flex; align-items: center; @@ -118,6 +119,18 @@ h1 { font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; } +.metric-button { + color: var(--ink); + cursor: pointer; +} + +.metric-button.active, +.metric-button:hover, +.metric-button:focus-visible { + outline: none; + background: #fff6f0; +} + .meta-chip { position: relative; } @@ -178,6 +191,10 @@ h1 { border-color: rgba(201, 97, 74, 0.45); } +.metrics .warn svg { + flex: 0 0 auto; +} + .icon-button { display: grid; place-items: center; @@ -191,6 +208,41 @@ h1 { box-shadow: 0 8px 18px rgba(56, 43, 35, 0.08); } +.icon-button.compact { + width: 28px; + height: 28px; + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; + font-size: 14px; +} + +.required-panel { + position: absolute; + z-index: 40; + top: 116px; + right: 18px; + width: min(360px, calc(100vw - 36px)); + max-height: min(560px, calc(100vh - 142px)); + overflow: auto; + padding: 14px; + background: rgba(255, 250, 242, 0.96); + border: 1px solid rgba(191, 106, 84, 0.32); + border-radius: 14px; + box-shadow: 0 18px 45px var(--shadow); + backdrop-filter: blur(12px); +} + +.required-panel-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.required-panel h2 { + font-size: 18px; +} + .react-flow { background: transparent; z-index: 1; @@ -385,6 +437,22 @@ h1 { border-color: rgba(31, 122, 83, 0.38); } +.port.required-input { + border-color: rgba(191, 106, 84, 0.72); + background: + linear-gradient(90deg, rgba(191, 106, 84, 0.16), rgba(255, 253, 247, 0.92) 62%), + rgba(255, 253, 247, 0.9); + box-shadow: inset 3px 0 0 rgba(191, 106, 84, 0.74); +} + +.port.required-input .react-flow__handle { + border-color: var(--clay); + background: #fff6f0; + box-shadow: + 0 0 0 3px rgba(255, 250, 242, 0.92), + 0 0 0 6px rgba(191, 106, 84, 0.13); +} + .port.highlighted { border-color: var(--line); background: #fffdfa; @@ -617,6 +685,7 @@ h1 { .diagnostic, .edit-suggestion, +.initialization-note, .empty-state { border: 1px solid var(--line); border-radius: 12px; @@ -626,15 +695,67 @@ h1 { } .diagnostic, -.edit-suggestion { +.edit-suggestion, +.initialization-note { display: flex; align-items: center; gap: 7px; +} + +.diagnostic, +.edit-suggestion { color: var(--clay); border-color: rgba(201, 97, 74, 0.28); background: rgba(201, 97, 74, 0.09); } +.initialization-note { + color: #87533b; + border-color: rgba(191, 106, 84, 0.32); + background: rgba(191, 106, 84, 0.1); +} + +.initialization-list { + display: grid; + gap: 8px; +} + +.initialization-item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + width: 100%; + padding: 9px 10px; + color: var(--ink); + background: rgba(255, 250, 242, 0.78); + border: 1px solid rgba(191, 106, 84, 0.28); + border-left: 4px solid var(--clay); + border-radius: 10px; + font: inherit; + text-align: left; + cursor: pointer; +} + +.initialization-item:hover, +.initialization-item:focus-visible { + background: rgba(255, 246, 240, 0.96); + outline: none; +} + +.initialization-item span { + overflow: hidden; + color: var(--muted); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.initialization-item strong { + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; + font-size: 12px; +} + @media (max-width: 900px) { .app-shell { grid-template-columns: 1fr; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 644bf5c42..580429210 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -25,6 +25,7 @@ export type GraphNodeData = { export type RuntimeGraphNodeData = GraphNodeData & { activePortId?: string | null; highlightedPortIds?: string[]; + requiredInputPortIds?: string[]; onPortEnter?: (port: GraphPort) => void; onPortLeave?: () => void; }; From 43510ac7a97e1beec06bb172d3bee5eaf7aafc13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Vezy?= Date: Wed, 29 Apr 2026 16:41:42 +0200 Subject: [PATCH 6/8] Fix CI (use default architecture for runners, now that Github uses Apple silicon) --- .github/workflows/Benchmarks.yml | 6 ++---- .github/workflows/CI.yml | 8 +++----- .github/workflows/Integration.yml | 15 ++++----------- 3 files changed, 9 insertions(+), 20 deletions(-) diff --git a/.github/workflows/Benchmarks.yml b/.github/workflows/Benchmarks.yml index 361b98844..62663004f 100644 --- a/.github/workflows/Benchmarks.yml +++ b/.github/workflows/Benchmarks.yml @@ -1,13 +1,13 @@ name: Benchmarks on: pull_request_target: - branches: [main] + branches: [ main ] workflow_dispatch: permissions: pull-requests: write jobs: bench: - name: Julia ${{ matrix.version }} - ${{ matrix.os }} - ${{ matrix.arch }} - ${{ github.event_name }} + name: Julia ${{ matrix.version }} - ${{ matrix.os }} - ${{ github.event_name }} runs-on: ${{ matrix.os }} timeout-minutes: 60 strategy: @@ -17,8 +17,6 @@ jobs: - "1" os: - ubuntu-latest - arch: - - x64 steps: - uses: MilesCranmer/AirspeedVelocity.jl@action-v1 with: diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 4a5d7e130..fdfd8b9b4 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -13,10 +13,11 @@ concurrency: cancel-in-progress: ${{ startsWith(github.ref, 'refs/pull/') }} jobs: test: - name: Julia ${{ matrix.version }} - ${{ matrix.os }} - ${{ matrix.arch }} - ${{ github.event_name }} + name: Julia ${{ matrix.version }} - ${{ matrix.os }} - ${{ github.event_name }} runs-on: ${{ matrix.os }} timeout-minutes: 60 - permissions: # needed to allow julia-actions/cache to proactively delete old caches that it has created + permissions: + # needed to allow julia-actions/cache to proactively delete old caches that it has created actions: write contents: read strategy: @@ -29,14 +30,11 @@ jobs: - ubuntu-latest - macOS-latest - windows-latest - arch: - - x64 steps: - uses: actions/checkout@v6 - uses: julia-actions/setup-julia@v3 with: version: ${{ matrix.version }} - arch: ${{ matrix.arch }} - uses: julia-actions/cache@v3 - uses: julia-actions/julia-buildpkg@v1 - uses: julia-actions/julia-runtest@v1 diff --git a/.github/workflows/Integration.yml b/.github/workflows/Integration.yml index 152135acc..0b5bcf3da 100644 --- a/.github/workflows/Integration.yml +++ b/.github/workflows/Integration.yml @@ -13,10 +13,11 @@ concurrency: cancel-in-progress: ${{ startsWith(github.ref, 'refs/pull/') }} jobs: test: - name: Julia ${{ matrix.version }} - ${{ matrix.os }} - ${{ matrix.arch }} - ${{ github.event_name }} + name: Julia ${{ matrix.version }} - ${{ matrix.os }} - ${{ github.event_name }} runs-on: ${{ matrix.os }} timeout-minutes: 60 - permissions: # needed to allow julia-actions/cache to proactively delete old caches that it has created + permissions: + # needed to allow julia-actions/cache to proactively delete old caches that it has created actions: write contents: read strategy: @@ -26,22 +27,14 @@ jobs: - "1" os: - ubuntu-latest - arch: - - x64 package: - { user: PalmStudio, repo: XPalm.jl, branch: main, default: main } - - { - user: VEZY, - repo: PlantBioPhysics.jl, - branch: master, - default: master, - } + - { user: VEZY, repo: PlantBioPhysics.jl, branch: master, default: master } steps: - uses: actions/checkout@v6 - uses: julia-actions/setup-julia@v3 with: version: ${{ matrix.version }} - arch: ${{ matrix.arch }} - uses: julia-actions/julia-buildpkg@v1 - name: Clone Downstream uses: actions/checkout@v6 From 9bdd04b64a8a29d0d3e6b298bf2bf640cf9c7b06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Vezy?= Date: Wed, 29 Apr 2026 16:55:22 +0200 Subject: [PATCH 7/8] Update inputs.md fix docs --- docs/src/working_with_data/inputs.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/working_with_data/inputs.md b/docs/src/working_with_data/inputs.md index 93642a476..3c94c985b 100644 --- a/docs/src/working_with_data/inputs.md +++ b/docs/src/working_with_data/inputs.md @@ -64,7 +64,7 @@ outputs = run!( ) ``` -In multiscale runs, type promotion is used by [`GraphSimulation`](@ref) during status template creation, `RefVector` creation, output preallocation, and initialization from MTG node attributes. +In multiscale runs, type promotion is used by `GraphSimulation` during status template creation, `RefVector` creation, output preallocation, and initialization from MTG node attributes. ## Special considerations for new input types From 46ad56fa1b7b8e8fae29e82ffc1941627efa0250 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Vezy?= Date: Fri, 1 May 2026 09:03:21 +0200 Subject: [PATCH 8/8] Separate hard-call edges from variable dependencies --- frontend/.vite/deps/_metadata.json | 8 + frontend/.vite/deps/package.json | 3 + frontend/src/App.tsx | 16 +- frontend/src/DependencyEdge.tsx | 5 +- frontend/src/ModelNode.tsx | 2 + frontend/src/layout.ts | 19 +- frontend/src/styles.css | 13 ++ src/visualization/dependency_graph_view.jl | 203 +++++++++++++++++++-- test/test-dependency-graph-view.jl | 46 +++++ 9 files changed, 290 insertions(+), 25 deletions(-) create mode 100644 frontend/.vite/deps/_metadata.json create mode 100644 frontend/.vite/deps/package.json diff --git a/frontend/.vite/deps/_metadata.json b/frontend/.vite/deps/_metadata.json new file mode 100644 index 000000000..76dbdb36e --- /dev/null +++ b/frontend/.vite/deps/_metadata.json @@ -0,0 +1,8 @@ +{ + "hash": "9cb674f6", + "configHash": "2b83bb6c", + "lockfileHash": "dfbd2d0d", + "browserHash": "d060dbeb", + "optimized": {}, + "chunks": {} +} \ No newline at end of file diff --git a/frontend/.vite/deps/package.json b/frontend/.vite/deps/package.json new file mode 100644 index 000000000..3dbc1ca59 --- /dev/null +++ b/frontend/.vite/deps/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 39fe5d7b2..ed2c1edd6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -150,6 +150,9 @@ export default function App() { onConnect={onConnect} onNodeClick={(_, node) => setSelected(node.data)} fitView + fitViewOptions={{ padding: 0.08, minZoom: 0.03, maxZoom: 1 }} + minZoom={0.03} + maxZoom={2} > @@ -254,20 +257,21 @@ function deriveRequiredInputPorts(graph: DependencyGraphView) { function flowEdge(edge: GraphEdgeData, highlightedEdgeIds: Set, hasActivePort: boolean): Edge { const highlighted = highlightedEdgeIds.has(edge.id); + const isCallEdge = edge.kind === "hard_dependency" && !edge.sourcePort && !edge.targetPort; return { id: edge.id, source: edge.source, target: edge.target, - sourceHandle: edge.sourcePort ?? undefined, - targetHandle: edge.targetPort ?? undefined, - markerEnd: edgeMarker(edgeColor(edge, highlighted)), + sourceHandle: edge.sourcePort ?? (isCallEdge ? `${edge.source}:call-source` : undefined), + targetHandle: edge.targetPort ?? (isCallEdge ? `${edge.target}:call-target` : undefined), + markerEnd: isCallEdge ? undefined : edgeMarker(edgeColor(edge, highlighted)), type: "dependency", - animated: edge.scaleRelation === "multiscale", - className: `${edge.kind} ${edge.scaleRelation} ${highlighted ? "highlighted" : hasActivePort ? "dimmed" : ""}`, + animated: !isCallEdge && edge.scaleRelation === "multiscale", + className: `${edge.kind} ${isCallEdge ? "call_edge" : "variable_edge"} ${edge.scaleRelation} ${highlighted ? "highlighted" : hasActivePort ? "dimmed" : ""}`, style: edgeStyle(edgeColor(edge, highlighted), highlighted), selected: highlighted, - zIndex: highlighted ? 120 : 5, + zIndex: highlighted ? 120 : isCallEdge ? 3 : 5, data: { ...edge, highlighted, dimmed: hasActivePort && !highlighted }, }; } diff --git a/frontend/src/DependencyEdge.tsx b/frontend/src/DependencyEdge.tsx index 158bffbf9..6e6bd5e63 100644 --- a/frontend/src/DependencyEdge.tsx +++ b/frontend/src/DependencyEdge.tsx @@ -35,8 +35,9 @@ export function DependencyEdge({ const label = data?.label; const renamed = data?.sourceVariable && data?.targetVariable && data.sourceVariable !== data.targetVariable; - const showPrimaryLabel = Boolean(label) && !renamed; - const showScaleTag = data?.scaleRelation === "multiscale"; + const isCallEdge = data?.kind === "hard_dependency" && !data.sourcePort && !data.targetPort; + const showPrimaryLabel = Boolean(label) && !renamed && !isCallEdge; + const showScaleTag = data?.scaleRelation === "multiscale" && !isCallEdge; const showChip = showPrimaryLabel || showScaleTag; const highlighted = Boolean(data?.highlighted); const dimmed = Boolean(data?.dimmed); diff --git a/frontend/src/ModelNode.tsx b/frontend/src/ModelNode.tsx index 12f0d20f9..6fa73142d 100644 --- a/frontend/src/ModelNode.tsx +++ b/frontend/src/ModelNode.tsx @@ -8,6 +8,8 @@ export function ModelNode({ data, selected }: NodeProps) { const cyclic = Boolean(data.cyclic); return (
+ +
{data.process}
diff --git a/frontend/src/layout.ts b/frontend/src/layout.ts index ad9412f32..bc256d1f3 100644 --- a/frontend/src/layout.ts +++ b/frontend/src/layout.ts @@ -21,7 +21,12 @@ export async function layoutGraph(nodes: Node[], edges: Ed id: node.id, width: NODE_WIDTH, height: nodeHeight(node.data), - ports: [...node.data.inputs.map((port, index) => elkPort(port, index)), ...node.data.outputs.map((port, index) => elkPort(port, index))], + ports: [ + elkCallPort(node.id, "target"), + ...node.data.inputs.map((port, index) => elkPort(port, index)), + ...node.data.outputs.map((port, index) => elkPort(port, index)), + elkCallPort(node.id, "source"), + ], layoutOptions: { "org.eclipse.elk.portConstraints": "FIXED_ORDER", }, @@ -57,3 +62,15 @@ function elkPort(port: GraphPort, index: number) { }, }; } + +function elkCallPort(nodeId: string, role: "source" | "target") { + return { + id: `${nodeId}:call-${role}`, + width: 12, + height: 36, + layoutOptions: { + "org.eclipse.elk.port.side": role === "target" ? "WEST" : "EAST", + "org.eclipse.elk.port.index": role === "target" ? "-1" : "9999", + }, + }; +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 81a94c649..8ed987fa6 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -473,6 +473,13 @@ h1 { box-shadow: 0 0 0 3px rgba(255, 250, 242, 0.92); } +.react-flow__handle.call-handle { + width: 12px; + height: 36px; + opacity: 0; + pointer-events: none; +} + .react-flow__edge.multiscale path { stroke-dasharray: 7 5; } @@ -485,6 +492,12 @@ h1 { stroke: var(--clay); } +.react-flow__edge.call_edge path { + stroke-width: 1.7; + stroke-dasharray: 3 6; + opacity: 0.7; +} + .react-flow__edge.highlighted path { stroke-width: 3; stroke: var(--accent); diff --git a/src/visualization/dependency_graph_view.jl b/src/visualization/dependency_graph_view.jl index 6e522b9f1..39c67ece7 100644 --- a/src/visualization/dependency_graph_view.jl +++ b/src/visualization/dependency_graph_view.jl @@ -106,46 +106,217 @@ function graph_view(graph::DependencyGraph, context=nothing; diagnostics::Vector node_ids = IdDict{AbstractDependencyNode,String}() nodes = GraphNode[] edges = GraphEdge[] + edge_ids = Set{String}() for node in traverse_dependency_graph(graph) id = _graph_node_id(node, node_ids) push!(nodes, _graph_node(node, id, context, node_ids)) end - for node in traverse_dependency_graph(graph, false) + for node in traverse_dependency_graph(graph) child_id = node_ids[node] - if node.parent !== nothing + if node isa SoftDependencyNode && node.parent !== nothing for parent in node.parent parent_id = _graph_node_id(parent, node_ids) append!(edges, _soft_edges(parent, node, parent_id, child_id)) end end - for hard_child in node.hard_dependency + if node isa SoftDependencyNode + hard_children = node.hard_dependency + elseif node isa HardDependencyNode + hard_children = node.children + else + hard_children = HardDependencyNode[] + end + + for hard_child in hard_children parent_id = child_id child_hard_id = _graph_node_id(hard_child, node_ids) - push!(edges, GraphEdge( - "edge:hard:$(parent_id):$(child_hard_id)", - parent_id, - child_hard_id, - nothing, - nothing, - nothing, - nothing, - :hard_dependency, - node.scale == hard_child.scale ? :same_scale : :multiscale, - "hard dependency", - String[], - )) + _push_edge!(edges, edge_ids, _hard_edge(node, hard_child, parent_id, child_hard_id)) + end + + if node isa HardDependencyNode && node.parent isa AbstractDependencyNode + parent_id = _graph_node_id(node.parent, node_ids) + _push_edge!(edges, edge_ids, _hard_edge(node.parent, node, parent_id, child_id)) end end + _add_spec_mapped_input_edges!(edges, edge_ids, nodes, context) + _add_hard_input_edges!(edges, edge_ids, nodes) + _add_hard_output_edges!(edges, edge_ids, nodes) + cyclic, cycle_vec = is_graph_cyclic(graph; warn=false) cycle_nodes = cyclic ? [_model_node_id(last(pair), process(first(pair))) for pair in cycle_vec] : String[] scales = sort!(unique([node.scale for node in nodes]); by=string) return DependencyGraphView(nodes, edges, scales, cyclic, cycle_nodes, diagnostics) end +function _push_edge!(edges::Vector{GraphEdge}, edge_ids::Set{String}, edge::GraphEdge) + edge.id in edge_ids && return edges + push!(edges, edge) + push!(edge_ids, edge.id) + return edges +end + +function _hard_edge(parent::AbstractDependencyNode, child::HardDependencyNode, parent_id::String, child_id::String) + return GraphEdge( + "edge:hard:$(parent_id):$(child_id)", + parent_id, + child_id, + nothing, + nothing, + nothing, + nothing, + :hard_dependency, + parent.scale == child.scale ? :same_scale : :multiscale, + "hard dependency", + String[], + ) +end + +function _add_hard_output_edges!(edges::Vector{GraphEdge}, edge_ids::Set{String}, nodes::Vector{GraphNode}) + computed_inputs = Set(edge.target_port for edge in edges if !isnothing(edge.target_port)) + hard_outputs = Dict{Tuple{Symbol,Symbol},Vector{Tuple{GraphNode,GraphPort}}}() + for node in nodes + node.role == :hard_dependency || continue + for port in node.outputs + push!(get!(hard_outputs, (node.scale, port.name), Tuple{GraphNode,GraphPort}[]), (node, port)) + end + end + + for node in nodes + for input in node.inputs + input.id in computed_inputs && continue + producers = get(hard_outputs, (node.scale, input.name), Tuple{GraphNode,GraphPort}[]) + for (producer_node, output) in producers + producer_node.id == node.id && continue + edge = GraphEdge( + "edge:hard-output:$(producer_node.id):$(output.id):$(node.id):$(input.id)", + producer_node.id, + node.id, + output.id, + input.id, + output.name, + input.name, + :soft_dependency, + :same_scale, + string(input.name), + ["Computed by a hard dependency during an explicit model call."], + ) + _push_edge!(edges, edge_ids, edge) + push!(computed_inputs, input.id) + end + end + end + + return edges +end + +function _add_spec_mapped_input_edges!(edges::Vector{GraphEdge}, edge_ids::Set{String}, nodes::Vector{GraphNode}, context) + isnothing(context) && return edges + computed_inputs = Set(edge.target_port for edge in edges if !isnothing(edge.target_port)) + + for node in nodes + spec = _model_spec(context, node.scale, node.process) + isnothing(spec) && continue + for mapped in mapped_variables_(spec) + target_var = first(mapped) + target_var isa PreviousTimeStep && continue + target_var = Symbol(target_var) + target_input = _find_port(node.inputs, target_var) + isnothing(target_input) && continue + target_input.id in computed_inputs && continue + + for (source_scale, source_var) in _mapping_sources(last(mapped)) + source_output = _find_output_port(nodes, source_scale, source_var) + isnothing(source_output) && continue + source_node, source_port = source_output + scale_relation = source_node.scale == node.scale ? :same_scale : :multiscale + label = source_var == target_var ? string(target_var) : string(source_var, " -> ", target_var) + if scale_relation == :multiscale + label = string(source_node.scale, ".", label, " -> ", node.scale) + end + edge = GraphEdge( + "edge:mapped-spec:$(source_node.id):$(source_port.id):$(node.id):$(target_input.id)", + source_node.id, + node.id, + source_port.id, + target_input.id, + source_var, + target_var, + scale_relation == :multiscale ? :mapped_variable : :soft_dependency, + scale_relation, + label, + ["Mapped input declared on the model specification."], + ) + _push_edge!(edges, edge_ids, edge) + push!(computed_inputs, target_input.id) + end + end + end + + return edges +end + +_mapping_sources(source::Pair{Symbol,Symbol}) = (source,) +_mapping_sources(sources::AbstractVector) = sources + +function _add_hard_input_edges!(edges::Vector{GraphEdge}, edge_ids::Set{String}, nodes::Vector{GraphNode}) + node_by_id = Dict(node.id => node for node in nodes) + input_edge_by_target = Dict(edge.target_port => edge for edge in edges if !isnothing(edge.target_port)) + computed_inputs = Set(keys(input_edge_by_target)) + + for node in nodes + node.role == :hard_dependency || continue + isnothing(node.parent) && continue + parent = get(node_by_id, node.parent, nothing) + isnothing(parent) && continue + + for input in node.inputs + input.id in computed_inputs && continue + parent_input = _find_port(parent.inputs, input.name) + isnothing(parent_input) && continue + source_edge = get(input_edge_by_target, parent_input.id, nothing) + isnothing(source_edge) && continue + isnothing(source_edge.source_port) && continue + + edge = GraphEdge( + "edge:hard-input:$(source_edge.source):$(source_edge.source_port):$(node.id):$(input.id)", + source_edge.source, + node.id, + source_edge.source_port, + input.id, + source_edge.source_variable, + input.name, + source_edge.kind, + source_edge.scale_relation, + source_edge.label, + ["Forwarded to a hard dependency input through the owning model status."], + ) + _push_edge!(edges, edge_ids, edge) + input_edge_by_target[input.id] = edge + push!(computed_inputs, input.id) + end + end + + return edges +end + +function _find_port(ports::Vector{GraphPort}, name::Symbol) + index = findfirst(port -> port.name == name, ports) + isnothing(index) ? nothing : ports[index] +end + +function _find_output_port(nodes::Vector{GraphNode}, scale::Symbol, name::Symbol) + for node in nodes + node.scale == scale || continue + port = _find_port(node.outputs, name) + isnothing(port) || return (node, port) + end + return nothing +end + """ graph_view_json(view) graph_view_json(mapping) diff --git a/test/test-dependency-graph-view.jl b/test/test-dependency-graph-view.jl index abaca17e2..da25a7dd6 100644 --- a/test/test-dependency-graph-view.jl +++ b/test/test-dependency-graph-view.jl @@ -1,3 +1,30 @@ +abstract type AbstractGraphViewPlantAgeModel <: PlantSimEngine.AbstractModel end +abstract type AbstractGraphViewPhytomerEmissionModel <: PlantSimEngine.AbstractModel end +abstract type AbstractGraphViewInitiationAgeModel <: PlantSimEngine.AbstractModel end + +PlantSimEngine.process_(::Type{AbstractGraphViewPlantAgeModel}) = :graph_view_plant_age +PlantSimEngine.process_(::Type{AbstractGraphViewPhytomerEmissionModel}) = :graph_view_phytomer_emission +PlantSimEngine.process_(::Type{AbstractGraphViewInitiationAgeModel}) = :graph_view_initiation_age + +struct GraphViewPlantAgeModel <: AbstractGraphViewPlantAgeModel +end + +PlantSimEngine.inputs_(::GraphViewPlantAgeModel) = (day=-Inf,) +PlantSimEngine.outputs_(::GraphViewPlantAgeModel) = (plant_age=-Inf,) + +struct GraphViewPhytomerEmissionModel <: AbstractGraphViewPhytomerEmissionModel +end + +PlantSimEngine.inputs_(::GraphViewPhytomerEmissionModel) = NamedTuple() +PlantSimEngine.outputs_(::GraphViewPhytomerEmissionModel) = (last_phytomer=-Inf,) +PlantSimEngine.dep(::GraphViewPhytomerEmissionModel) = (graph_view_initiation_age=AbstractGraphViewInitiationAgeModel => (:Phytomer,),) + +struct GraphViewInitiationAgeModel <: AbstractGraphViewInitiationAgeModel +end + +PlantSimEngine.inputs_(::GraphViewInitiationAgeModel) = (plant_age=-Inf,) +PlantSimEngine.outputs_(::GraphViewInitiationAgeModel) = (initiation_age=-Inf,) + @testset "Dependency graph view" begin mapping = ModelMapping( ToyLAIModel(), @@ -72,4 +99,23 @@ edge.source != edge.target, edited_view.edges, ) + + hard_mapped_mapping = ModelMapping( + :Plant => ( + GraphViewPlantAgeModel(), + GraphViewPhytomerEmissionModel(), + Status(day=1.0), + ), + :Phytomer => MultiScaleModel( + model=GraphViewInitiationAgeModel(), + mapped_variables=[:plant_age => :Plant], + ), + ) + hard_mapped_view = graph_view(hard_mapped_mapping) + initiation_node = only(node for node in hard_mapped_view.nodes if node.process == :graph_view_initiation_age && node.scale == :Phytomer) + plant_age_input = only(port for port in initiation_node.inputs if port.name == :plant_age) + plant_age_edges = [edge for edge in hard_mapped_view.edges if edge.target_port == plant_age_input.id] + @test any(edge -> edge.kind == :mapped_variable && edge.source_variable == :plant_age && edge.target_variable == :plant_age, plant_age_edges) + @test !any(edge -> edge.source_variable == :last_phytomer, plant_age_edges) + @test any(edge -> edge.kind == :hard_dependency && isnothing(edge.source_port) && isnothing(edge.target_port), hard_mapped_view.edges) end