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 01/39] 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 02/39] 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 03/39] 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 04/39] 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 05/39] 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 06/39] 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 07/39] 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 08/39] 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 From defa3d2a0eadcb636aa236b31fbfbaa6e940769b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Vezy?= Date: Sun, 3 May 2026 14:52:43 +0200 Subject: [PATCH 09/39] Update Benchmarks.yml --- .github/workflows/Benchmarks.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/Benchmarks.yml b/.github/workflows/Benchmarks.yml index c44d442d4..43bca7313 100644 --- a/.github/workflows/Benchmarks.yml +++ b/.github/workflows/Benchmarks.yml @@ -23,5 +23,5 @@ jobs: julia-version: ${{ matrix.version }} bench-on: ${{ github.event.pull_request.head.sha }} extra-pkgs: | - https://github.com/PalmStudio/XPalm.jl#main - https://github.com/VEZY/PlantBiophysics.jl#master + https://github.com/PalmStudio/XPalm.jl + https://github.com/VEZY/PlantBiophysics.jl From 27f40112683e61b077605a452f49d1e3bf2c7c2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Vezy?= Date: Sun, 3 May 2026 16:00:04 +0200 Subject: [PATCH 10/39] Update Benchmarks.yml --- .github/workflows/Benchmarks.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/Benchmarks.yml b/.github/workflows/Benchmarks.yml index 43bca7313..89c514f49 100644 --- a/.github/workflows/Benchmarks.yml +++ b/.github/workflows/Benchmarks.yml @@ -22,6 +22,5 @@ jobs: with: julia-version: ${{ matrix.version }} bench-on: ${{ github.event.pull_request.head.sha }} - extra-pkgs: | - https://github.com/PalmStudio/XPalm.jl - https://github.com/VEZY/PlantBiophysics.jl + extra-pkgs: >- + https://github.com/PalmStudio/XPalm.jl#main,https://github.com/VEZY/PlantBiophysics.jl#master From ff44aff77815f11fea8b98be2a5cb7def97623c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Vezy?= Date: Sun, 3 May 2026 17:37:11 +0200 Subject: [PATCH 11/39] Reduce number of inits: only keep the model that computes it, not the ones that map into the variable --- frontend/src/App.tsx | 919 +++++++++++++++++++++++++++----- frontend/src/DependencyEdge.tsx | 4 + frontend/src/ModelNode.tsx | 12 +- frontend/src/layout.ts | 79 ++- frontend/src/nodeSizing.ts | 28 + frontend/src/styles.css | 328 +++++++++++- frontend/src/types.ts | 3 + 7 files changed, 1214 insertions(+), 159 deletions(-) create mode 100644 frontend/src/nodeSizing.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ed2c1edd6..a56b7a784 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import { Background, Controls, @@ -11,16 +11,63 @@ import { type Connection, type Edge, type Node, + type ReactFlowInstance, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; -import { AlertTriangle, CircleAlert, GitPullRequestArrow, RotateCcw, ScissorsLineDashed } from "lucide-react"; +import { + AlertTriangle, + CircleAlert, + Filter, + GitPullRequestArrow, + Network, + RotateCcw, + Route, + ScissorsLineDashed, + Search, + X, +} from "lucide-react"; import { DependencyEdge } from "./DependencyEdge"; import { ModelNode } from "./ModelNode"; -import { layoutGraph } from "./layout"; +import { layoutGraph, type LayoutMode } from "./layout"; import { sampleGraph } from "./sampleGraph"; import type { DependencyGraphView, GraphEdgeData, GraphNodeData, GraphPort, RuntimeGraphNodeData } from "./types"; import "./styles.css"; +type EdgeFilterKey = "dataFlow" | "mapped" | "callStack"; +type EdgeFilters = Record; +type FocusMode = "none" | "upstream" | "downstream" | "neighborhood"; + +type SearchResult = { + id: string; + kind: "model" | "input" | "output"; + node: GraphNodeData; + port?: GraphPort; + label: string; + detail: string; +}; + +type RequiredInput = { + node: GraphNodeData; + port: GraphPort; + reason: "previous_time_step" | "mapped_unresolved" | "user_initialization"; +}; + +type ValidationWarning = { + id: string; + title: string; + detail: string; + nodeId?: string; + portId?: string; + edgeId?: string; +}; + +type FocusState = { + active: boolean; + edges: Set; + nodes: Set; + ports: Set; +}; + const nodeTypes = { model: ModelNode }; const edgeTypes = { dependency: DependencyEdge }; const edgeColors = { @@ -30,69 +77,179 @@ const edgeColors = { hard: "#bf6a54", }; +const defaultEdgeFilters: EdgeFilters = { + dataFlow: true, + mapped: true, + callStack: true, +}; + +const focusLabels: Record = { + none: "No focus", + upstream: "Upstream", + downstream: "Downstream", + neighborhood: "Both", +}; + +const layoutLabels: Record = { + data_flow: "Data-flow", + compact: "Compact", + scale_grouped: "Scale grouped", + call_stack: "Call stack", +}; + export default function App() { const [graph] = useState(loadInitialGraph()); const [selected, setSelected] = useState(null); const [activePort, setActivePort] = useState(null); const [showRequiredPanel, setShowRequiredPanel] = useState(false); + const [showWarningsPanel, setShowWarningsPanel] = useState(false); + const [showSearchResults, setShowSearchResults] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [layoutMode, setLayoutMode] = useState("data_flow"); + const [focusMode, setFocusMode] = useState("neighborhood"); + const [edgeFilters, setEdgeFilters] = useState(defaultEdgeFilters); + const [flowInstance, setFlowInstance] = useState, Edge> | null>(null); const [nodes, setNodes, onNodesChange] = useNodesState>([]); const [edges, setEdges, onEdgesChange] = useEdgesState>([]); - const highlight = useMemo(() => deriveHighlight(graph, activePort), [activePort, graph]); + + const nodeById = useMemo(() => new Map(graph.nodes.map((node) => [node.id, node])), [graph]); + const portById = useMemo(() => buildPortIndex(graph), [graph]); + const incomingByPort = useMemo(() => groupEdgesByPort(graph.edges, "targetPort"), [graph.edges]); + const outgoingByPort = useMemo(() => groupEdgesByPort(graph.edges, "sourcePort"), [graph.edges]); 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]); + const requiredInputs = useMemo(() => deriveRequiredInputs(graph, requiredInputPortIds, incomingByPort), [graph, incomingByPort, requiredInputPortIds]); + const warningItems = useMemo(() => deriveValidationWarnings(graph, requiredInputPortIds, incomingByPort), [graph, incomingByPort, requiredInputPortIds]); + const searchResults = useMemo(() => deriveSearchResults(graph, searchQuery), [graph, searchQuery]); + const visibleEdgeData = useMemo(() => graph.edges.filter((edge) => edgeMatchesFilters(edge, edgeFilters)), [edgeFilters, graph.edges]); + const hoverHighlight = useMemo(() => deriveHighlight(graph, activePort), [activePort, graph]); + const focus = useMemo( + () => deriveFocus(graph, selected?.id ?? null, activePort, focusMode), + [activePort, focusMode, graph, selected?.id], + ); useEffect(() => { const nextNodes = graph.nodes.map((node) => ({ id: node.id, type: "model", position: { x: 0, y: 0 }, - data: runtimeNodeData(node, null, new Set(), requiredInputPortIds, new Set(graph.cycleNodes), setActivePort), + data: runtimeNodeData(node, { + activePort: null, + highlightedPortIds: new Set(), + focusedPortIds: new Set(), + requiredInputPortIds, + cycleNodeIds: new Set(graph.cycleNodes), + focusedNodeIds: new Set(), + hasActiveFocus: false, + setActivePort, + }), })); - const nextEdges = graph.edges.map((edge) => flowEdge(edge, new Set(), false)); - layoutGraph(nextNodes, nextEdges).then((layouted) => { + const nextEdges = visibleEdgeData.map((edge) => flowEdge(edge, new Set(), new Set(), false, false)); + layoutGraph(nextNodes, nextEdges, layoutMode).then((layouted) => { setNodes(layouted); setEdges(nextEdges); }); - }, [graph, setEdges, setNodes]); + }, [graph, layoutMode, requiredInputPortIds, setEdges, setNodes, visibleEdgeData]); useEffect(() => { + const focusEdges = focus.active ? focus.edges : new Set(); setNodes((current) => current.map((node) => ({ ...node, - data: runtimeNodeData(node.data, activePort, highlight.ports, requiredInputPortIds, new Set(graph.cycleNodes), setActivePort), + data: runtimeNodeData(node.data, { + activePort, + highlightedPortIds: hoverHighlight.ports, + focusedPortIds: focus.ports, + requiredInputPortIds, + cycleNodeIds: new Set(graph.cycleNodes), + focusedNodeIds: focus.nodes, + hasActiveFocus: focus.active, + setActivePort, + }), }))); - setEdges((current) => current.map((edge) => edge.data ? flowEdge(edge.data, highlight.edges, Boolean(activePort)) : edge)); - }, [activePort, graph.cycleNodes, highlight.edges, highlight.ports, requiredInputPortIds, setEdges, setNodes]); + setEdges((current) => current.map((edge) => edge.data ? flowEdge(edge.data, hoverHighlight.edges, focusEdges, Boolean(activePort), focus.active) : edge)); + }, [activePort, focus, graph.cycleNodes, hoverHighlight.edges, hoverHighlight.ports, requiredInputPortIds, setEdges, setNodes]); const onConnect = useCallback((connection: Connection) => { setEdges((current) => addEdge({ ...connection, type: "dependency", - animated: true, - markerEnd: edgeMarker(edgeColors.base), - style: edgeStyle(edgeColors.base, false), + animated: true, + markerEnd: edgeMarker(edgeColors.base), + style: edgeStyle(edgeColors.base, false), zIndex: 5, }, current)); }, [setEdges]); const relayout = useCallback(() => { - layoutGraph(nodes, edges).then(setNodes); - }, [edges, nodes, setNodes]); + layoutGraph(nodes, edges, layoutMode).then(setNodes); + }, [edges, layoutMode, nodes, setNodes]); + + const focusNode = useCallback((node: GraphNodeData, port?: GraphPort | null) => { + setSelected(node); + setActivePort(port ?? null); + const renderedNode = nodes.find((item) => item.id === node.id); + if (renderedNode && flowInstance) { + flowInstance.setCenter(renderedNode.position.x + 156, renderedNode.position.y + 90, { zoom: 0.85, duration: 520 }); + } + }, [flowInstance, nodes]); + + const focusEdge = useCallback((edge: GraphEdgeData) => { + const port = edge.targetPort ? portById.get(edge.targetPort)?.port : edge.sourcePort ? portById.get(edge.sourcePort)?.port : null; + const node = port?.id === edge.targetPort ? nodeById.get(edge.target) : nodeById.get(edge.source); + if (node) focusNode(node, port ?? null); + }, [focusNode, nodeById, portById]); + + const toggleEdgeFilter = useCallback((key: EdgeFilterKey) => { + setEdgeFilters((current) => ({ ...current, [key]: !current[key] })); + }, []); return (
-
-
+
+
PlantSimEngine

Dependency Graph

+ +
+ + { + setSearchQuery(event.target.value); + setShowSearchResults(true); + }} + onFocus={() => setShowSearchResults(true)} + /> + {searchQuery && ( + + )} + {showSearchResults && searchQuery.trim().length > 0 && ( +
+ {searchResults.length > 0 ? searchResults.map((result) => ( + + )) :
No match.
} +
+ )} +
+
{graph.nodes.length} models - {graph.edges.length} links + {visibleEdgeData.length}/{graph.edges.length} links {requiredInputs.length > 0 && ( )} + {warningItems.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.
- )} -
+ setShowRequiredPanel(false)}> + + )} + + {showWarningsPanel && ( + setShowWarningsPanel(false)}> + [edge.id, edge]))} onFocusNode={focusNode} onFocusEdge={focusEdge} /> + + )} + setSelected(node.data)} + onInit={setFlowInstance} + onPaneClick={() => setShowSearchResults(false)} + onNodeClick={(_, node) => { + setSelected(node.data); + }} fitView fitViewOptions={{ padding: 0.08, minZoom: 0.03, maxZoom: 1 }} minZoom={0.03} @@ -159,58 +327,24 @@ export default function App() {
+ @@ -218,6 +352,202 @@ export default function App() { ); } +function RelationshipLegend({ filters, onToggle }: { filters: EdgeFilters; onToggle: (key: EdgeFilterKey) => void }) { + return ( +
+
Relationships
+ + + +
red inputs need initialization
+
+ ); +} + +function FloatingPanel({ className, title, subtitle, onClose, children }: { className: string; title: string; subtitle: string; onClose: () => void; children: ReactNode }) { + return ( +
+
+
+
{title}
+

{subtitle}

+
+ +
+ {children} +
+ ); +} + +function RequiredInputList({ groups, onSelect, compact = false }: { groups: Map; onSelect: (node: GraphNodeData, port?: GraphPort | null) => void; compact?: boolean }) { + if (groups.size === 0) return
Every input is computed by another model.
; + return ( +
+ {[...groups.entries()].map(([group, items]) => ( +
+

{group}

+ {items.map(({ node, port, reason }) => ( + + ))} +
+ ))} +
+ ); +} + +function WarningList({ + warnings, + nodeById, + portById, + edgeById, + onFocusNode, + onFocusEdge, +}: { + warnings: ValidationWarning[]; + nodeById: Map; + portById: Map; + edgeById: Map; + onFocusNode: (node: GraphNodeData, port?: GraphPort | null) => void; + onFocusEdge: (edge: GraphEdgeData) => void; +}) { + if (warnings.length === 0) return
No validation warnings.
; + return ( +
+ {warnings.map((warning) => ( + + ))} +
+ ); +} + +function InspectorDetails({ + selected, + activePort, + requiredInputPortIds, + incomingEdges, + outgoingEdges, + nodeById, + portById, + onFocusEdge, +}: { + selected: GraphNodeData | null; + activePort: GraphPort | null; + requiredInputPortIds: Set; + incomingEdges: GraphEdgeData[]; + outgoingEdges: GraphEdgeData[]; + nodeById: Map; + portById: Map; + onFocusEdge: (edge: GraphEdgeData) => void; +}) { + return ( + <> + {selected ? ( +
+ + + + + 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
+ ))} +
+ ) : ( +
Select a model node.
+ )} + +

Variable Provenance

+ {activePort ? ( +
+
+ {activePort.name} + {activePort.role} +
+ + {activePort.mappingMode && } + {activePort.sourceScale && } + {requiredInputPortIds.has(activePort.id) &&
required initialization
} + {activePort.previousTimeStep &&
uses previous timestep
} + + +
+ ) : ( +
Hover, click, or search a variable to see where it comes from and where it goes.
+ )} + + ); +} + +function EdgeList({ + title, + edges, + direction, + nodeById, + portById, + onFocusEdge, +}: { + title: string; + edges: GraphEdgeData[]; + direction: "incoming" | "outgoing"; + nodeById: Map; + portById: Map; + onFocusEdge: (edge: GraphEdgeData) => void; +}) { + return ( +
+

{title}

+ {edges.length > 0 ? edges.map((edge) => { + const source = nodeById.get(edge.source); + const target = nodeById.get(edge.target); + const sourcePort = edge.sourcePort ? portById.get(edge.sourcePort)?.port : null; + const targetPort = edge.targetPort ? portById.get(edge.targetPort)?.port : null; + const main = direction === "incoming" + ? `${source?.scale ?? "?"}.${source?.process ?? "?"}.${sourcePort?.name ?? edge.sourceVariable ?? "model"}` + : `${target?.scale ?? "?"}.${target?.process ?? "?"}.${targetPort?.name ?? edge.targetVariable ?? "model"}`; + return ( + + ); + }) :
No {title.toLowerCase()} edge.
} +
+ ); +} + function Row({ label, value }: { label: string; value: string }) { return
{label}{value}
; } @@ -231,48 +561,162 @@ function loadInitialGraph() { function runtimeNodeData( node: GraphNodeData, - activePort: GraphPort | null, - highlightedPortIds: Set, - requiredInputPortIds: Set, - cycleNodeIds: Set, - setActivePort: (port: GraphPort | null) => void, + options: { + activePort: GraphPort | null; + highlightedPortIds: Set; + focusedPortIds: Set; + requiredInputPortIds: Set; + cycleNodeIds: Set; + focusedNodeIds: Set; + hasActiveFocus: boolean; + setActivePort: (port: GraphPort | null) => void; + }, ): RuntimeGraphNodeData { return { ...node, - cyclic: cycleNodeIds.has(node.id), - activePortId: activePort?.id ?? null, - highlightedPortIds: [...highlightedPortIds], - requiredInputPortIds: [...requiredInputPortIds], - onPortEnter: setActivePort, - onPortLeave: () => setActivePort(null), + cyclic: options.cycleNodeIds.has(node.id), + activePortId: options.activePort?.id ?? null, + highlightedPortIds: [...options.highlightedPortIds], + focusedPortIds: [...options.focusedPortIds], + requiredInputPortIds: [...options.requiredInputPortIds], + focused: options.focusedNodeIds.has(node.id), + dimmed: options.hasActiveFocus && !options.focusedNodeIds.has(node.id), + onPortEnter: options.setActivePort, + onPortLeave: () => options.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) - ))); + const computedInputPortIds = new Set(graph.edges.map((edge) => edge.targetPort).filter(isString)); + const required = new Set(); + const requiredKeys = new Set(); + + for (const node of graph.nodes) { + for (const port of node.inputs) { + if (computedInputPortIds.has(port.id)) continue; + const canonical = canonicalInitializationPort(graph, node, port, computedInputPortIds); + if (!canonical) continue; + const key = initializationKey(canonical.node, canonical.port); + if (requiredKeys.has(key)) continue; + requiredKeys.add(key); + required.add(canonical.port.id); + } + } + + return required; } -function flowEdge(edge: GraphEdgeData, highlightedEdgeIds: Set, hasActivePort: boolean): Edge { +function deriveRequiredInputs(graph: DependencyGraphView, requiredInputPortIds: Set, incomingByPort: Map) { + return graph.nodes.flatMap((node) => ( + node.inputs + .filter((port) => requiredInputPortIds.has(port.id)) + .map((port): RequiredInput => ({ + node, + port, + reason: requiredReason(port, incomingByPort.get(port.id) ?? []), + })) + )); +} + +function requiredReason(port: GraphPort, incomingEdges: GraphEdgeData[]): RequiredInput["reason"] { + if (port.previousTimeStep) return "previous_time_step"; + if (port.mappingMode && incomingEdges.length === 0) return "mapped_unresolved"; + return "user_initialization"; +} + +function canonicalInitializationPort( + graph: DependencyGraphView, + node: GraphNodeData, + port: GraphPort, + computedInputPortIds: Set, + visited = new Set(), +): { node: GraphNodeData; port: GraphPort } | null { + if (visited.has(port.id)) return { node, port }; + visited.add(port.id); + + if (!port.sourceScale) return { node, port }; + + const sourceVariable = port.sourceVariable ?? port.name; + const sourceInput = findInputPort(graph, port.sourceScale, sourceVariable); + if (sourceInput) { + if (computedInputPortIds.has(sourceInput.port.id)) return null; + return canonicalInitializationPort(graph, sourceInput.node, sourceInput.port, computedInputPortIds, visited); + } + + const sourceOutput = findOutputPort(graph, port.sourceScale, sourceVariable); + if (sourceOutput) return null; + + return { node, port }; +} + +function findInputPort(graph: DependencyGraphView, scale: string, variable: string) { + let fallback: { node: GraphNodeData; port: GraphPort } | null = null; + for (const node of graph.nodes) { + if (node.scale !== scale) continue; + const port = node.inputs.find((candidate) => candidate.name === variable); + if (!port) continue; + if (!fallback) fallback = { node, port }; + if (!port.sourceScale) return { node, port }; + } + return fallback; +} + +function findOutputPort(graph: DependencyGraphView, scale: string, variable: string) { + for (const node of graph.nodes) { + if (node.scale !== scale) continue; + const port = node.outputs.find((candidate) => candidate.name === variable); + if (port) return { node, port }; + } + return null; +} + +function initializationKey(node: GraphNodeData, port: GraphPort) { + return `${node.scale}:${port.name}`; +} + +function groupRequiredInputs(requiredInputs: RequiredInput[]) { + const grouped = new Map(); + for (const item of requiredInputs) { + const key = `${item.node.scale}.${item.node.process}`; + const group = grouped.get(key) ?? []; + group.push(item); + grouped.set(key, group); + } + return grouped; +} + +function requiredReasonLabel(reason: RequiredInput["reason"]) { + if (reason === "previous_time_step") return "previous step"; + if (reason === "mapped_unresolved") return "unresolved mapping"; + return "user init"; +} + +function flowEdge( + edge: GraphEdgeData, + highlightedEdgeIds: Set, + focusedEdgeIds: Set, + hasActivePort: boolean, + hasActiveFocus: boolean, +): Edge { const highlighted = highlightedEdgeIds.has(edge.id); - const isCallEdge = edge.kind === "hard_dependency" && !edge.sourcePort && !edge.targetPort; + const focused = focusedEdgeIds.has(edge.id); + const callEdge = isCallEdge(edge); + const dimmed = (hasActivePort && !highlighted) || (hasActiveFocus && !focused && !highlighted); return { id: edge.id, source: edge.source, target: edge.target, - 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)), + sourceHandle: edge.sourcePort ?? (callEdge ? `${edge.source}:call-source` : undefined), + targetHandle: edge.targetPort ?? (callEdge ? `${edge.target}:call-target` : undefined), + markerEnd: callEdge ? undefined : edgeMarker(edgeColor(edge, highlighted || focused)), type: "dependency", - 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 : isCallEdge ? 3 : 5, - data: { ...edge, highlighted, dimmed: hasActivePort && !highlighted }, + animated: !callEdge && edge.scaleRelation === "multiscale", + className: `${edge.kind} ${callEdge ? "call_edge" : "variable_edge"} ${edge.scaleRelation} ${focused ? "focused" : ""} ${highlighted ? "highlighted" : dimmed ? "dimmed" : ""}`, + style: edgeStyle(edgeColor(edge, highlighted || focused), highlighted || focused), + selected: highlighted || focused, + zIndex: highlighted ? 120 : focused ? 90 : callEdge ? 3 : 5, + data: { ...edge, highlighted, focused, dimmed }, }; } @@ -302,11 +746,7 @@ function edgeStyle(color: string, highlighted: boolean) { } function deriveHighlight(graph: DependencyGraphView, activePort: GraphPort | null) { - const result = { - edges: new Set(), - nodes: new Set(), - ports: new Set(), - }; + const result = emptyFocusState(); if (!activePort) return result; result.ports.add(activePort.id); @@ -337,3 +777,212 @@ function deriveHighlight(graph: DependencyGraphView, activePort: GraphPort | nul return result; } + +function deriveFocus(graph: DependencyGraphView, selectedNodeId: string | null, activePort: GraphPort | null, mode: FocusMode): FocusState { + const result = emptyFocusState(); + if (mode === "none") return result; + + const seeds = new Set(); + if (activePort) seeds.add(activePort.id); + if (selectedNodeId) { + const node = graph.nodes.find((item) => item.id === selectedNodeId); + node?.inputs.forEach((port) => seeds.add(port.id)); + node?.outputs.forEach((port) => seeds.add(port.id)); + result.nodes.add(selectedNodeId); + } + if (seeds.size === 0) return result; + + result.active = true; + const visited = new Set(seeds); + const queue = [...seeds]; + seeds.forEach((seed) => result.ports.add(seed)); + + while (queue.length > 0) { + const portId = queue.shift()!; + for (const edge of graph.edges) { + if (!edge.sourcePort || !edge.targetPort) continue; + const upstream = mode === "upstream" || mode === "neighborhood"; + const downstream = mode === "downstream" || mode === "neighborhood"; + const nextPort = downstream && edge.sourcePort === portId + ? edge.targetPort + : upstream && edge.targetPort === portId + ? edge.sourcePort + : null; + if (!nextPort) continue; + + result.edges.add(edge.id); + result.nodes.add(edge.source); + result.nodes.add(edge.target); + result.ports.add(edge.sourcePort); + result.ports.add(edge.targetPort); + if (!visited.has(nextPort)) { + visited.add(nextPort); + queue.push(nextPort); + } + } + } + + for (const edge of graph.edges) { + if (!isCallEdge(edge)) continue; + if (result.nodes.has(edge.source) || result.nodes.has(edge.target)) { + result.edges.add(edge.id); + result.nodes.add(edge.source); + result.nodes.add(edge.target); + } + } + + return result; +} + +function deriveSearchResults(graph: DependencyGraphView, query: string): SearchResult[] { + const normalized = query.trim().toLowerCase(); + if (!normalized) return []; + + const results: SearchResult[] = []; + for (const node of graph.nodes) { + const nodeHaystack = `${node.scale} ${node.process} ${node.modelType} ${node.rate}`.toLowerCase(); + if (nodeHaystack.includes(normalized)) { + results.push({ + id: `model:${node.id}`, + kind: "model", + node, + label: `${node.scale}.${node.process}`, + detail: node.modelType, + }); + } + for (const port of [...node.inputs, ...node.outputs]) { + const portHaystack = `${node.scale} ${node.process} ${node.modelType} ${port.name} ${port.role}`.toLowerCase(); + if (portHaystack.includes(normalized)) { + results.push({ + id: `port:${port.id}`, + kind: port.role, + node, + port, + label: `${port.name}`, + detail: `${port.role} in ${node.scale}.${node.process}`, + }); + } + } + } + + return results.slice(0, 18); +} + +function deriveValidationWarnings(graph: DependencyGraphView, requiredInputPortIds: Set, incomingByPort: Map): ValidationWarning[] { + const warnings: ValidationWarning[] = []; + const outputs = new Map>(); + + for (const node of graph.nodes) { + for (const port of node.outputs) { + const key = `${node.scale}:${port.name}`; + const group = outputs.get(key) ?? []; + group.push({ node, port }); + outputs.set(key, group); + } + for (const port of node.inputs) { + const incoming = incomingByPort.get(port.id) ?? []; + if (requiredInputPortIds.has(port.id) && incoming.length > 0) { + warnings.push({ + id: `required-with-edge:${port.id}`, + title: "Input marked init but connected", + detail: `${node.scale}.${node.process}.${port.name} has incoming data-flow edges and should not be required.`, + nodeId: node.id, + portId: port.id, + }); + } + if (port.mappingMode && requiredInputPortIds.has(port.id) && !port.previousTimeStep) { + warnings.push({ + id: `unresolved-mapping:${port.id}`, + title: "Mapped input has no producer", + detail: `${node.scale}.${node.process}.${port.name} declares mapping metadata but no source output was found.`, + nodeId: node.id, + portId: port.id, + }); + } + } + } + + for (const [key, group] of outputs) { + if (group.length <= 1) continue; + const [scale, variable] = key.split(":"); + warnings.push({ + id: `multiple-producers:${key}`, + title: "Multiple producers", + detail: `${scale}.${variable} is output by ${group.length} models; check whether ownership is intentional.`, + nodeId: group[0].node.id, + portId: group[0].port.id, + }); + } + + for (const edge of graph.edges) { + if (edge.diagnostics.some((item) => item.includes("Forwarded to a hard dependency"))) { + warnings.push({ + id: `hard-forward:${edge.id}`, + title: "Hard input forwarding", + detail: `${edge.targetVariable ?? "input"} is satisfied through the owning model status before a hard dependency call.`, + edgeId: edge.id, + }); + } + if (edge.scaleRelation === "multiscale" && edge.kind !== "mapped_variable" && !isCallEdge(edge)) { + warnings.push({ + id: `implicit-cross-scale:${edge.id}`, + title: "Implicit cross-scale edge", + detail: `${edge.sourceVariable ?? "source"} -> ${edge.targetVariable ?? "target"} crosses scales without a mapped-variable edge kind.`, + edgeId: edge.id, + }); + } + } + + return warnings; +} + +function buildPortIndex(graph: DependencyGraphView) { + const index = new Map(); + for (const node of graph.nodes) { + for (const port of [...node.inputs, ...node.outputs]) index.set(port.id, { node, port }); + } + return index; +} + +function groupEdgesByPort(edges: GraphEdgeData[], side: "sourcePort" | "targetPort") { + const groups = new Map(); + for (const edge of edges) { + const portId = edge[side]; + if (!portId) continue; + const group = groups.get(portId) ?? []; + group.push(edge); + groups.set(portId, group); + } + return groups; +} + +function edgeMatchesFilters(edge: GraphEdgeData, filters: EdgeFilters) { + if (isCallEdge(edge)) return filters.callStack; + if (edge.kind === "mapped_variable" || edge.scaleRelation === "multiscale") return filters.mapped; + return filters.dataFlow; +} + +function edgeKindLabel(edge: GraphEdgeData) { + if (isCallEdge(edge)) return "call stack"; + if (edge.kind === "mapped_variable") return "mapped variable"; + if (edge.diagnostics.some((item) => item.includes("Forwarded to a hard dependency"))) return "hard input forwarding"; + if (edge.diagnostics.some((item) => item.includes("Computed by a hard dependency"))) return "hard output"; + return "soft dependency"; +} + +function isCallEdge(edge: GraphEdgeData) { + return edge.kind === "hard_dependency" && !edge.sourcePort && !edge.targetPort; +} + +function emptyFocusState(): FocusState { + return { + active: false, + edges: new Set(), + nodes: new Set(), + ports: new Set(), + }; +} + +function isString(value: unknown): value is string { + return typeof value === "string"; +} diff --git a/frontend/src/DependencyEdge.tsx b/frontend/src/DependencyEdge.tsx index 6e6bd5e63..64e6c0128 100644 --- a/frontend/src/DependencyEdge.tsx +++ b/frontend/src/DependencyEdge.tsx @@ -33,6 +33,10 @@ export function DependencyEdge({ offset: 28, }); + if (!data) { + return ; + } + const label = data?.label; const renamed = data?.sourceVariable && data?.targetVariable && data.sourceVariable !== data.targetVariable; const isCallEdge = data?.kind === "hard_dependency" && !data.sourcePort && !data.targetPort; diff --git a/frontend/src/ModelNode.tsx b/frontend/src/ModelNode.tsx index 6fa73142d..d42967b27 100644 --- a/frontend/src/ModelNode.tsx +++ b/frontend/src/ModelNode.tsx @@ -1,13 +1,20 @@ import { Handle, Position, type Node, type NodeProps } from "@xyflow/react"; import { Clock3, GitBranch, Layers3, Link2, PhoneCall } from "lucide-react"; import type { GraphPort, RuntimeGraphNodeData } from "./types"; +import { nodeWidth } from "./nodeSizing"; type ModelFlowNode = Node; export function ModelNode({ data, selected }: NodeProps) { const cyclic = Boolean(data.cyclic); + const dimmed = Boolean(data.dimmed); + const focused = Boolean(data.focused); return ( -
+
@@ -41,13 +48,14 @@ 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 focused = new Set(data.focusedPortIds ?? []); const requiredInputs = new Set(data.requiredInputPortIds ?? []); return (
{title}
{ports.map((port) => (
[], edges: Edge[]) { +export async function layoutGraph(nodes: Node[], edges: Edge[], mode: LayoutMode = "data_flow") { + const layoutEdges = mode === "call_stack" ? edges.filter((edge) => isCallEdge(edge.data)) : edges; 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.layered.crossingMinimization.semiInteractive": "true", - "elk.edgeRouting": "ORTHOGONAL", - }, + layoutOptions: layoutOptions(mode), children: nodes.map((node) => ({ id: node.id, - width: NODE_WIDTH, + width: nodeWidth(node.data), height: nodeHeight(node.data), ports: [ elkCallPort(node.id, "target"), @@ -31,7 +25,7 @@ export async function layoutGraph(nodes: Node[], edges: Ed "org.eclipse.elk.portConstraints": "FIXED_ORDER", }, })), - edges: edges.map((edge) => ({ + edges: layoutEdges.map((edge) => ({ id: edge.id, sources: [edge.sourceHandle ?? edge.source], targets: [edge.targetHandle ?? edge.target], @@ -40,11 +34,62 @@ export async function layoutGraph(nodes: Node[], edges: Ed const result = await elk.layout(graph); const positions = new Map((result.children ?? []).map((child) => [child.id, { x: child.x ?? 0, y: child.y ?? 0 }])); + const scaleOffsets = mode === "scale_grouped" ? scaleBandOffsets(nodes) : new Map(); + + return nodes.map((node) => { + const position = positions.get(node.id) ?? node.position; + return { + ...node, + position: { + x: position.x, + y: position.y + (scaleOffsets.get(node.data.scale) ?? 0), + }, + }; + }); +} + +function layoutOptions(mode: LayoutMode): Record { + if (mode === "compact") { + return { + "elk.algorithm": "layered", + "elk.direction": "RIGHT", + "elk.spacing.nodeNode": "28", + "elk.layered.spacing.nodeNodeBetweenLayers": "52", + "elk.layered.nodePlacement.strategy": "BRANDES_KOEPF", + "elk.layered.crossingMinimization.semiInteractive": "true", + "elk.edgeRouting": "ORTHOGONAL", + }; + } + + if (mode === "call_stack") { + return { + "elk.algorithm": "layered", + "elk.direction": "DOWN", + "elk.spacing.nodeNode": "46", + "elk.layered.spacing.nodeNodeBetweenLayers": "76", + "elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX", + "elk.edgeRouting": "ORTHOGONAL", + }; + } + + return { + "elk.algorithm": "layered", + "elk.direction": "RIGHT", + "elk.spacing.nodeNode": mode === "scale_grouped" ? "72" : "58", + "elk.layered.spacing.nodeNodeBetweenLayers": mode === "scale_grouped" ? "130" : "110", + "elk.layered.nodePlacement.strategy": "BRANDES_KOEPF", + "elk.layered.crossingMinimization.semiInteractive": "true", + "elk.edgeRouting": "ORTHOGONAL", + }; +} + +function scaleBandOffsets(nodes: Node[]) { + const scales = [...new Set(nodes.map((node) => node.data.scale))].sort(); + return new Map(scales.map((scale, index) => [scale, index * 260])); +} - return nodes.map((node) => ({ - ...node, - position: positions.get(node.id) ?? node.position, - })); +function isCallEdge(edge?: GraphEdgeData) { + return edge?.kind === "hard_dependency" && !edge.sourcePort && !edge.targetPort; } function nodeHeight(node: RuntimeGraphNodeData) { diff --git a/frontend/src/nodeSizing.ts b/frontend/src/nodeSizing.ts new file mode 100644 index 000000000..a9e4147bd --- /dev/null +++ b/frontend/src/nodeSizing.ts @@ -0,0 +1,28 @@ +import type { RuntimeGraphNodeData } from "./types"; + +const MIN_NODE_WIDTH = 312; +const MAX_NODE_WIDTH = 620; +const CARD_PADDING = 24; +const GRID_GAP = 10; +const PORT_HORIZONTAL_PADDING = 26; +const MONO_CHAR_WIDTH = 8.1; + +export function nodeWidth(node: RuntimeGraphNodeData) { + const longestInput = longestPortName(node.inputs); + const longestOutput = longestPortName(node.outputs); + const inputWidth = portColumnWidth(longestInput); + const outputWidth = portColumnWidth(longestOutput); + return clamp(Math.ceil(CARD_PADDING + inputWidth + GRID_GAP + outputWidth), MIN_NODE_WIDTH, MAX_NODE_WIDTH); +} + +function longestPortName(ports: RuntimeGraphNodeData["inputs"]) { + return ports.reduce((longest, port) => Math.max(longest, port.name.length), 0); +} + +function portColumnWidth(characters: number) { + return Math.ceil(PORT_HORIZONTAL_PADDING + characters * MONO_CHAR_WIDTH); +} + +function clamp(value: number, min: number, max: number) { + return Math.max(min, Math.min(max, value)); +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 8ed987fa6..d7f6f9799 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -79,6 +79,98 @@ body { background: var(--accent); } +.graph-workbench { + flex-wrap: wrap; +} + +.brand-block { + min-width: 164px; +} + +.search-box { + position: relative; + display: flex; + align-items: center; + gap: 8px; + flex: 1 1 280px; + max-width: 460px; + min-width: 220px; + padding: 7px 9px; + border: 1px solid var(--line); + border-radius: 11px; + background: rgba(255, 253, 247, 0.86); +} + +.search-box input { + width: 100%; + min-width: 0; + border: 0; + outline: 0; + color: var(--ink); + background: transparent; + font: inherit; + font-size: 13px; +} + +.clear-search { + display: grid; + place-items: center; + width: 20px; + height: 20px; + border: 0; + border-radius: 999px; + background: rgba(183, 166, 150, 0.18); + color: var(--muted); + cursor: pointer; +} + +.search-results { + position: absolute; + z-index: 80; + top: calc(100% + 8px); + left: 0; + right: 0; + display: grid; + gap: 6px; + max-height: 360px; + overflow: auto; + padding: 8px; + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(255, 250, 242, 0.98); + box-shadow: 0 18px 45px var(--shadow); +} + +.search-result { + display: grid; + gap: 2px; + padding: 8px 9px; + border: 1px solid transparent; + border-radius: 9px; + background: transparent; + color: var(--ink); + text-align: left; + cursor: pointer; +} + +.search-result:hover, +.search-result:focus-visible { + border-color: rgba(31, 122, 83, 0.24); + background: var(--accent-soft); + outline: none; +} + +.search-result strong { + overflow-wrap: anywhere; + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; + font-size: 12px; +} + +.search-result span { + color: var(--muted); + font-size: 11px; +} + .eyebrow { color: var(--muted); font-size: 11px; @@ -191,6 +283,11 @@ h1 { border-color: rgba(201, 97, 74, 0.45); } +.metrics .caution { + color: var(--ochre); + border-color: rgba(201, 144, 53, 0.45); +} + .metrics .warn svg { flex: 0 0 auto; } @@ -215,7 +312,96 @@ h1 { font-size: 14px; } -.required-panel { +.toolbar-group { + display: flex; + align-items: center; + gap: 8px; +} + +.select-control { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 8px; + border: 1px solid var(--line); + border-radius: 10px; + background: rgba(255, 250, 242, 0.9); + color: var(--muted); +} + +.select-control select { + max-width: 132px; + border: 0; + outline: 0; + background: transparent; + color: var(--ink); + font: inherit; + font-size: 12px; +} + +.relationship-legend { + position: absolute; + z-index: 35; + top: 116px; + left: 18px; + display: grid; + gap: 7px; + width: 190px; + padding: 10px; + border: 1px solid var(--line); + border-radius: 13px; + background: rgba(255, 250, 242, 0.92); + box-shadow: 0 18px 45px var(--shadow); + backdrop-filter: blur(10px); +} + +.legend-title, +.legend-note { + display: flex; + align-items: center; + gap: 6px; + color: var(--muted); + font-size: 11px; + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; +} + +.relationship-legend button { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 7px; + border: 1px solid transparent; + border-radius: 9px; + background: transparent; + color: var(--muted); + font: inherit; + font-size: 12px; + cursor: pointer; +} + +.relationship-legend button.active { + color: var(--ink); + border-color: rgba(31, 122, 83, 0.22); + background: var(--accent-soft); +} + +.legend-line { + width: 28px; + height: 0; + border-top: 2px solid var(--line-strong); +} + +.legend-line.mapped { + border-color: var(--accent); + border-style: dashed; +} + +.legend-line.call { + border-color: var(--clay); + border-style: dashed; +} + +.floating-panel { position: absolute; z-index: 40; top: 116px; @@ -231,7 +417,11 @@ h1 { backdrop-filter: blur(12px); } -.required-panel-header { +.warnings-panel { + border-color: rgba(201, 144, 53, 0.35); +} + +.floating-panel-header { display: flex; align-items: flex-start; justify-content: space-between; @@ -239,7 +429,7 @@ h1 { margin-bottom: 12px; } -.required-panel h2 { +.floating-panel h2 { font-size: 18px; } @@ -273,7 +463,6 @@ h1 { } .model-node { - width: 312px; overflow: visible; background: rgba(255, 250, 242, 0.96); border: 1px solid var(--line); @@ -290,6 +479,18 @@ h1 { box-shadow: 0 20px 48px rgba(31, 122, 83, 0.14); } +.model-node.focused { + border-color: rgba(31, 122, 83, 0.62); + box-shadow: + 0 20px 48px rgba(31, 122, 83, 0.14), + 0 0 0 4px rgba(31, 122, 83, 0.08); +} + +.model-node.dimmed { + opacity: 0.2; + filter: grayscale(0.2); +} + .model-node.cyclic { border-color: rgba(191, 106, 84, 0.62); box-shadow: @@ -302,7 +503,6 @@ h1 { } .model-node.hard_dependency { - width: 286px; border-color: rgba(191, 106, 84, 0.55); border-style: dashed; background: rgba(255, 246, 240, 0.94); @@ -458,6 +658,11 @@ h1 { background: #fffdfa; } +.port.focused { + border-color: rgba(31, 122, 83, 0.58); + background: var(--accent-soft); +} + .port.active { color: #fffdfa; border-color: var(--accent); @@ -503,10 +708,18 @@ h1 { stroke: var(--accent); } +.react-flow__edge.focused path { + stroke-width: 2.8; +} + .react-flow__edge.highlighted { z-index: 80 !important; } +.react-flow__edge.focused { + z-index: 70 !important; +} + .react-flow__edge.dimmed { opacity: 0.04; } @@ -733,6 +946,24 @@ h1 { gap: 8px; } +.initialization-list.compact { + gap: 6px; +} + +.initialization-group { + display: grid; + gap: 7px; +} + +.initialization-group h4, +.provenance-block h4 { + margin: 6px 0 0; + color: var(--muted); + font-size: 11px; + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; + font-weight: 700; +} + .initialization-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -769,6 +1000,88 @@ h1 { font-size: 12px; } +.initialization-item small { + grid-column: 1 / -1; + color: var(--muted); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.initialization-item.previous_time_step { + border-left-color: var(--ochre); +} + +.initialization-item.mapped_unresolved { + border-left-color: var(--accent); +} + +.warning-list, +.provenance-block { + display: grid; + gap: 8px; +} + +.warning-item, +.provenance-edge { + display: grid; + gap: 3px; + width: 100%; + padding: 9px 10px; + border: 1px solid rgba(201, 144, 53, 0.28); + border-left: 4px solid var(--ochre); + border-radius: 10px; + background: rgba(255, 250, 242, 0.78); + color: var(--ink); + font: inherit; + text-align: left; + cursor: pointer; +} + +.provenance-edge { + border-color: rgba(183, 166, 150, 0.42); + border-left-color: var(--line-strong); +} + +.provenance-edge.mapped_variable { + border-left-color: var(--accent); +} + +.provenance-edge.hard_dependency { + border-left-color: var(--clay); +} + +.warning-item:hover, +.warning-item:focus-visible, +.provenance-edge:hover, +.provenance-edge:focus-visible { + background: rgba(255, 246, 240, 0.96); + outline: none; +} + +.warning-item strong, +.provenance-edge strong { + overflow-wrap: anywhere; + font-size: 12px; +} + +.warning-item span, +.provenance-edge span, +.provenance-edge small { + color: var(--muted); + font-size: 11px; + line-height: 1.25; +} + +.provenance-block { + margin-top: 10px; +} + +.empty-state.compact { + padding: 7px 8px; + font-size: 11px; +} + @media (max-width: 900px) { .app-shell { grid-template-columns: 1fr; @@ -777,4 +1090,9 @@ h1 { .inspector { display: none; } + + .relationship-legend, + .floating-panel { + top: 150px; + } } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 580429210..b52e502cf 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -25,7 +25,10 @@ export type GraphNodeData = { export type RuntimeGraphNodeData = GraphNodeData & { activePortId?: string | null; highlightedPortIds?: string[]; + focusedPortIds?: string[]; requiredInputPortIds?: string[]; + dimmed?: boolean; + focused?: boolean; onPortEnter?: (port: GraphPort) => void; onPortLeave?: () => void; }; From e50b7ececbe1cc23f6f4af9a506aeca1994851c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Vezy?= Date: Mon, 4 May 2026 15:29:04 +0200 Subject: [PATCH 12/39] Highlight only producers when clicking on "multiple producers" warning --- frontend/src/App.tsx | 173 +++++++++++++++++++++++++++++----------- frontend/src/styles.css | 25 ++++++ 2 files changed, 153 insertions(+), 45 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a56b7a784..ed7712744 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -54,10 +54,14 @@ type RequiredInput = { type ValidationWarning = { id: string; + severity: "error" | "warning" | "info"; + category: "init" | "mapping" | "ownership" | "hard_dependency" | "cross_scale"; title: string; detail: string; nodeId?: string; + nodeIds?: string[]; portId?: string; + portIds?: string[]; edgeId?: string; }; @@ -108,6 +112,7 @@ export default function App() { const [layoutMode, setLayoutMode] = useState("data_flow"); const [focusMode, setFocusMode] = useState("neighborhood"); const [edgeFilters, setEdgeFilters] = useState(defaultEdgeFilters); + const [pinnedFocus, setPinnedFocus] = useState(null); const [flowInstance, setFlowInstance] = useState, Edge> | null>(null); const [nodes, setNodes, onNodesChange] = useNodesState>([]); const [edges, setEdges, onEdgesChange] = useEdgesState>([]); @@ -119,13 +124,15 @@ export default function App() { const requiredInputPortIds = useMemo(() => deriveRequiredInputPorts(graph), [graph]); const requiredInputs = useMemo(() => deriveRequiredInputs(graph, requiredInputPortIds, incomingByPort), [graph, incomingByPort, requiredInputPortIds]); const warningItems = useMemo(() => deriveValidationWarnings(graph, requiredInputPortIds, incomingByPort), [graph, incomingByPort, requiredInputPortIds]); + const actionableWarningItems = useMemo(() => warningItems.filter((item) => item.severity !== "info"), [warningItems]); const searchResults = useMemo(() => deriveSearchResults(graph, searchQuery), [graph, searchQuery]); const visibleEdgeData = useMemo(() => graph.edges.filter((edge) => edgeMatchesFilters(edge, edgeFilters)), [edgeFilters, graph.edges]); const hoverHighlight = useMemo(() => deriveHighlight(graph, activePort), [activePort, graph]); - const focus = useMemo( + const traversalFocus = useMemo( () => deriveFocus(graph, selected?.id ?? null, activePort, focusMode), [activePort, focusMode, graph, selected?.id], ); + const focus = useMemo(() => pinnedFocus?.active ? pinnedFocus : traversalFocus, [pinnedFocus, traversalFocus]); useEffect(() => { const nextNodes = graph.nodes.map((node) => ({ @@ -184,6 +191,7 @@ export default function App() { }, [edges, layoutMode, nodes, setNodes]); const focusNode = useCallback((node: GraphNodeData, port?: GraphPort | null) => { + setPinnedFocus(null); setSelected(node); setActivePort(port ?? null); const renderedNode = nodes.find((item) => item.id === node.id); @@ -202,6 +210,55 @@ export default function App() { setEdgeFilters((current) => ({ ...current, [key]: !current[key] })); }, []); + const focusWarning = useCallback((warning: ValidationWarning) => { + if (warning.portIds?.length) { + const nextFocus = emptyFocusState(); + nextFocus.active = true; + for (const portId of warning.portIds) { + const target = portById.get(portId); + if (!target) continue; + nextFocus.ports.add(portId); + nextFocus.nodes.add(target.node.id); + } + setPinnedFocus(nextFocus); + const first = portById.get(warning.portIds[0]); + if (first) { + setSelected(null); + setActivePort(null); + if (flowInstance && warning.nodeIds && warning.nodeIds.length > 1) { + flowInstance.fitView({ + nodes: warning.nodeIds.map((id) => ({ id })), + padding: 0.28, + duration: 520, + maxZoom: 0.95, + }); + } else { + const renderedNode = nodes.find((item) => item.id === first.node.id); + if (renderedNode && flowInstance) { + flowInstance.setCenter(renderedNode.position.x + 156, renderedNode.position.y + 90, { zoom: 0.9, duration: 520 }); + } + } + } + return; + } + + setPinnedFocus(null); + if (warning.edgeId) { + const edge = graph.edges.find((item) => item.id === warning.edgeId); + if (edge) focusEdge(edge); + return; + } + if (warning.portId) { + const target = portById.get(warning.portId); + if (target) focusNode(target.node, target.port); + return; + } + if (warning.nodeId) { + const node = nodeById.get(warning.nodeId); + if (node) focusNode(node); + } + }, [flowInstance, focusEdge, focusNode, graph.edges, nodeById, nodes, portById]); + return (
@@ -259,13 +316,13 @@ export default function App() { {requiredInputs.length} init )} - {warningItems.length > 0 && ( + {actionableWarningItems.length > 0 && ( )} {graph.cyclic && cycle} @@ -299,8 +356,8 @@ export default function App() { )} {showWarningsPanel && ( - setShowWarningsPanel(false)}> - [edge.id, edge]))} onFocusNode={focusNode} onFocusEdge={focusEdge} /> + setShowWarningsPanel(false)}> + )} @@ -403,47 +460,34 @@ function RequiredInputList({ groups, onSelect, compact = false }: { groups: Map< function WarningList({ warnings, - nodeById, - portById, - edgeById, - onFocusNode, - onFocusEdge, + onFocusWarning, }: { warnings: ValidationWarning[]; - nodeById: Map; - portById: Map; - edgeById: Map; - onFocusNode: (node: GraphNodeData, port?: GraphPort | null) => void; - onFocusEdge: (edge: GraphEdgeData) => void; + onFocusWarning: (warning: ValidationWarning) => void; }) { if (warnings.length === 0) return
No validation warnings.
; + const grouped = groupValidationWarnings(warnings); return (
- {warnings.map((warning) => ( - - ))} + {(["error", "warning", "info"] as const).map((severity) => { + const items = grouped.get(severity) ?? []; + if (items.length === 0) return null; + return ( +
+

{validationSeverityLabel(severity)} ({items.length})

+ {items.map((warning) => ( + + ))} +
+ ); + })}
); } @@ -884,6 +928,8 @@ function deriveValidationWarnings(graph: DependencyGraphView, requiredInputPortI if (requiredInputPortIds.has(port.id) && incoming.length > 0) { warnings.push({ id: `required-with-edge:${port.id}`, + severity: "error", + category: "init", title: "Input marked init but connected", detail: `${node.scale}.${node.process}.${port.name} has incoming data-flow edges and should not be required.`, nodeId: node.id, @@ -893,6 +939,8 @@ function deriveValidationWarnings(graph: DependencyGraphView, requiredInputPortI if (port.mappingMode && requiredInputPortIds.has(port.id) && !port.previousTimeStep) { warnings.push({ id: `unresolved-mapping:${port.id}`, + severity: "warning", + category: "mapping", title: "Mapped input has no producer", detail: `${node.scale}.${node.process}.${port.name} declares mapping metadata but no source output was found.`, nodeId: node.id, @@ -905,12 +953,17 @@ function deriveValidationWarnings(graph: DependencyGraphView, requiredInputPortI for (const [key, group] of outputs) { if (group.length <= 1) continue; const [scale, variable] = key.split(":"); + const producerLabels = group.map(({ node }) => `${node.scale}.${node.process}`).join(", "); warnings.push({ id: `multiple-producers:${key}`, + severity: "warning", + category: "ownership", title: "Multiple producers", - detail: `${scale}.${variable} is output by ${group.length} models; check whether ownership is intentional.`, + detail: `${scale}.${variable} is output by ${group.length} models at the same scale: ${producerLabels}.`, nodeId: group[0].node.id, + nodeIds: group.map(({ node }) => node.id), portId: group[0].port.id, + portIds: group.map(({ port }) => port.id), }); } @@ -918,16 +971,20 @@ function deriveValidationWarnings(graph: DependencyGraphView, requiredInputPortI if (edge.diagnostics.some((item) => item.includes("Forwarded to a hard dependency"))) { warnings.push({ id: `hard-forward:${edge.id}`, + severity: "info", + category: "hard_dependency", title: "Hard input forwarding", - detail: `${edge.targetVariable ?? "input"} is satisfied through the owning model status before a hard dependency call.`, + detail: `${edge.targetVariable ?? "input"} is satisfied through the owning model status before a hard dependency call. This is expected for declared hard dependencies.`, edgeId: edge.id, }); } if (edge.scaleRelation === "multiscale" && edge.kind !== "mapped_variable" && !isCallEdge(edge)) { warnings.push({ id: `implicit-cross-scale:${edge.id}`, - title: "Implicit cross-scale edge", - detail: `${edge.sourceVariable ?? "source"} -> ${edge.targetVariable ?? "target"} crosses scales without a mapped-variable edge kind.`, + severity: "info", + category: "cross_scale", + title: "Inferred cross-scale edge", + detail: `${edge.sourceVariable ?? "source"} -> ${edge.targetVariable ?? "target"} crosses scales through graph inference rather than a direct mapped-variable edge.`, edgeId: edge.id, }); } @@ -936,6 +993,32 @@ function deriveValidationWarnings(graph: DependencyGraphView, requiredInputPortI return warnings; } +function groupValidationWarnings(warnings: ValidationWarning[]) { + const grouped = new Map(); + for (const warning of warnings) { + const group = grouped.get(warning.severity) ?? []; + group.push(warning); + grouped.set(warning.severity, group); + } + return grouped; +} + +function validationSeverityLabel(severity: ValidationWarning["severity"]) { + if (severity === "error") return "Likely bugs"; + if (severity === "warning") return "Review"; + return "Information"; +} + +function mergeFocusStates(primary: FocusState, secondary: FocusState | null): FocusState { + if (!secondary?.active) return primary; + return { + active: primary.active || secondary.active, + edges: new Set([...primary.edges, ...secondary.edges]), + nodes: new Set([...primary.nodes, ...secondary.nodes]), + ports: new Set([...primary.ports, ...secondary.ports]), + }; +} + function buildPortIndex(graph: DependencyGraphView) { const index = new Map(); for (const node of graph.nodes) { diff --git a/frontend/src/styles.css b/frontend/src/styles.css index d7f6f9799..2702fc2f3 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1022,6 +1022,19 @@ h1 { gap: 8px; } +.warning-group { + display: grid; + gap: 7px; +} + +.warning-group h4 { + margin: 6px 0 0; + color: var(--muted); + font-size: 11px; + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; + font-weight: 700; +} + .warning-item, .provenance-edge { display: grid; @@ -1038,6 +1051,18 @@ h1 { cursor: pointer; } +.warning-item.error { + border-color: rgba(191, 106, 84, 0.34); + border-left-color: var(--clay); + background: rgba(191, 106, 84, 0.08); +} + +.warning-item.info { + border-color: rgba(127, 143, 115, 0.26); + border-left-color: var(--sage); + background: rgba(127, 143, 115, 0.08); +} + .provenance-edge { border-color: rgba(183, 166, 150, 0.42); border-left-color: var(--line-strong); From fee44cca3e74d840aeebd59905ac262e5505cca8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Vezy?= Date: Mon, 4 May 2026 17:15:42 +0200 Subject: [PATCH 13/39] Refine cross-scale validation warnings --- frontend/src/App.tsx | 33 ++++++++++++++++++++-- frontend/src/types.ts | 1 + src/visualization/dependency_graph_view.jl | 9 ++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ed7712744..42df06e57 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -915,9 +915,11 @@ function deriveSearchResults(graph: DependencyGraphView, query: string): SearchR function deriveValidationWarnings(graph: DependencyGraphView, requiredInputPortIds: Set, incomingByPort: Map): ValidationWarning[] { const warnings: ValidationWarning[] = []; const outputs = new Map>(); + const nodeById = new Map(graph.nodes.map((node) => [node.id, node])); for (const node of graph.nodes) { for (const port of node.outputs) { + if (!isOwnOutput(node, port)) continue; const key = `${node.scale}:${port.name}`; const group = outputs.get(key) ?? []; group.push({ node, port }); @@ -978,13 +980,21 @@ function deriveValidationWarnings(graph: DependencyGraphView, requiredInputPortI edgeId: edge.id, }); } - if (edge.scaleRelation === "multiscale" && edge.kind !== "mapped_variable" && !isCallEdge(edge)) { + const sourceNode = nodeById.get(edge.source); + const targetNode = nodeById.get(edge.target); + const crossesScale = Boolean(sourceNode && targetNode && sourceNode.scale !== targetNode.scale); + if ( + crossesScale && + edge.kind !== "mapped_variable" && + !isCallEdge(edge) && + !hasSameScaleProducerForTarget(edge, targetNode, nodeById, incomingByPort) + ) { warnings.push({ id: `implicit-cross-scale:${edge.id}`, severity: "info", category: "cross_scale", title: "Inferred cross-scale edge", - detail: `${edge.sourceVariable ?? "source"} -> ${edge.targetVariable ?? "target"} crosses scales through graph inference rather than a direct mapped-variable edge.`, + detail: `${sourceNode?.scale}.${sourceNode?.process}.${edge.sourceVariable ?? "source"} -> ${targetNode?.scale}.${targetNode?.process}.${edge.targetVariable ?? "target"} crosses scales through graph inference rather than a direct mapped-variable edge.`, edgeId: edge.id, }); } @@ -993,6 +1003,25 @@ function deriveValidationWarnings(graph: DependencyGraphView, requiredInputPortI return warnings; } +function hasSameScaleProducerForTarget( + edge: GraphEdgeData, + targetNode: GraphNodeData | undefined, + nodeById: Map, + incomingByPort: Map, +) { + if (!targetNode || !edge.targetPort) return false; + const incoming = incomingByPort.get(edge.targetPort) ?? []; + return incoming.some((candidate) => { + if (candidate.id === edge.id || !candidate.sourcePort || !candidate.targetPort) return false; + const candidateSource = nodeById.get(candidate.source); + return candidateSource?.scale === targetNode.scale; + }); +} + +function isOwnOutput(node: GraphNodeData, port: GraphPort) { + return !node.ownOutputIds || node.ownOutputIds.includes(port.id); +} + function groupValidationWarnings(warnings: ValidationWarning[]) { const grouped = new Map(); for (const warning of warnings) { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index b52e502cf..3f43f240a 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -18,6 +18,7 @@ export type GraphNodeData = { rate: string; inputs: GraphPort[]; outputs: GraphPort[]; + ownOutputIds?: string[]; parent: string | null; diagnostics: string[]; } & Record; diff --git a/src/visualization/dependency_graph_view.jl b/src/visualization/dependency_graph_view.jl index 39c67ece7..da63cb789 100644 --- a/src/visualization/dependency_graph_view.jl +++ b/src/visualization/dependency_graph_view.jl @@ -28,6 +28,7 @@ struct GraphNode rate::String inputs::Vector{GraphPort} outputs::Vector{GraphPort} + own_output_ids::Vector{String} parent::Union{Nothing,String} diagnostics::Vector{String} end @@ -426,6 +427,7 @@ function _graph_view_from_mapping_only(mapping::ModelMapping, diagnostics) _rate_label(spec), _ports(id, :input, inputs_(model)), _ports(id, :output, outputs_(model)), + [_port_id(id, :output, name) for name in keys(outputs_(model))], nothing, String[], )) @@ -449,6 +451,7 @@ function _graph_node(node::AbstractDependencyNode, id::String, context, node_ids rate, _ports(id, :input, _flatten_node_vars(node.inputs)), _ports(id, :output, _flatten_node_vars(node.outputs)), + _own_output_ids(node, id), parent, _node_diagnostics(node), ) @@ -481,6 +484,11 @@ _flatten_node_vars(vars::NamedTuple) = vars _flatten_node_vars(vars::AbstractVector{<:Pair}) = flatten_vars(vars) _flatten_node_vars(vars) = NamedTuple() +function _own_output_ids(node::AbstractDependencyNode, node_id::String) + own_outputs = _flatten_node_vars(outputs_(node.value)) + return [_port_id(node_id, :output, name) for name in keys(own_outputs)] +end + function _ports(node_id::String, role::Symbol, vars::NamedTuple) ports = GraphPort[] for (name, value) in pairs(vars) @@ -639,6 +647,7 @@ function _node_dict(node::GraphNode) "rate" => node.rate, "inputs" => [_port_dict(port) for port in node.inputs], "outputs" => [_port_dict(port) for port in node.outputs], + "ownOutputIds" => node.own_output_ids, "parent" => node.parent, "diagnostics" => node.diagnostics, ) From 24b16b554d4f9692b75ea659c468771017826de4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Vezy?= Date: Mon, 4 May 2026 17:44:21 +0200 Subject: [PATCH 14/39] Add view filtering with scales --- frontend/src/App.tsx | 122 ++++++++++++++++++++++++++++++++++++++-- frontend/src/styles.css | 91 ++++++++++++++++++++++++++++++ 2 files changed, 207 insertions(+), 6 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 42df06e57..5f8bc7d93 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -112,7 +112,9 @@ export default function App() { const [layoutMode, setLayoutMode] = useState("data_flow"); const [focusMode, setFocusMode] = useState("neighborhood"); const [edgeFilters, setEdgeFilters] = useState(defaultEdgeFilters); + const [collapsedScales, setCollapsedScales] = useState>(() => new Set()); const [pinnedFocus, setPinnedFocus] = useState(null); + const [selectedEdge, setSelectedEdge] = useState(null); const [flowInstance, setFlowInstance] = useState, Edge> | null>(null); const [nodes, setNodes, onNodesChange] = useNodesState>([]); const [edges, setEdges, onEdgesChange] = useEdgesState>([]); @@ -126,7 +128,13 @@ export default function App() { const warningItems = useMemo(() => deriveValidationWarnings(graph, requiredInputPortIds, incomingByPort), [graph, incomingByPort, requiredInputPortIds]); const actionableWarningItems = useMemo(() => warningItems.filter((item) => item.severity !== "info"), [warningItems]); const searchResults = useMemo(() => deriveSearchResults(graph, searchQuery), [graph, searchQuery]); - const visibleEdgeData = useMemo(() => graph.edges.filter((edge) => edgeMatchesFilters(edge, edgeFilters)), [edgeFilters, graph.edges]); + const visibleNodeData = useMemo(() => graph.nodes.filter((node) => !collapsedScales.has(node.scale)), [collapsedScales, graph.nodes]); + const visibleNodeIds = useMemo(() => new Set(visibleNodeData.map((node) => node.id)), [visibleNodeData]); + const visibleEdgeData = useMemo(() => graph.edges.filter((edge) => ( + edgeMatchesFilters(edge, edgeFilters) && + visibleNodeIds.has(edge.source) && + visibleNodeIds.has(edge.target) + )), [edgeFilters, graph.edges, visibleNodeIds]); const hoverHighlight = useMemo(() => deriveHighlight(graph, activePort), [activePort, graph]); const traversalFocus = useMemo( () => deriveFocus(graph, selected?.id ?? null, activePort, focusMode), @@ -135,7 +143,7 @@ export default function App() { const focus = useMemo(() => pinnedFocus?.active ? pinnedFocus : traversalFocus, [pinnedFocus, traversalFocus]); useEffect(() => { - const nextNodes = graph.nodes.map((node) => ({ + const nextNodes = visibleNodeData.map((node) => ({ id: node.id, type: "model", position: { x: 0, y: 0 }, @@ -155,7 +163,7 @@ export default function App() { setNodes(layouted); setEdges(nextEdges); }); - }, [graph, layoutMode, requiredInputPortIds, setEdges, setNodes, visibleEdgeData]); + }, [graph.cycleNodes, layoutMode, requiredInputPortIds, setEdges, setNodes, visibleEdgeData, visibleNodeData]); useEffect(() => { const focusEdges = focus.active ? focus.edges : new Set(); @@ -192,8 +200,15 @@ export default function App() { const focusNode = useCallback((node: GraphNodeData, port?: GraphPort | null) => { setPinnedFocus(null); + setSelectedEdge(null); setSelected(node); setActivePort(port ?? null); + setCollapsedScales((current) => { + if (!current.has(node.scale)) return current; + const next = new Set(current); + next.delete(node.scale); + return next; + }); const renderedNode = nodes.find((item) => item.id === node.id); if (renderedNode && flowInstance) { flowInstance.setCenter(renderedNode.position.x + 156, renderedNode.position.y + 90, { zoom: 0.85, duration: 520 }); @@ -210,6 +225,21 @@ export default function App() { setEdgeFilters((current) => ({ ...current, [key]: !current[key] })); }, []); + const toggleScale = useCallback((scale: string) => { + setSelected(null); + setSelectedEdge(null); + setActivePort(null); + setPinnedFocus(null); + setCollapsedScales((current) => { + const next = new Set(current); + if (next.has(scale)) next.delete(scale); + else next.add(scale); + return next; + }); + }, []); + + const expandAllScales = useCallback(() => setCollapsedScales(new Set()), []); + const focusWarning = useCallback((warning: ValidationWarning) => { if (warning.portIds?.length) { const nextFocus = emptyFocusState(); @@ -224,6 +254,7 @@ export default function App() { const first = portById.get(warning.portIds[0]); if (first) { setSelected(null); + setSelectedEdge(null); setActivePort(null); if (flowInstance && warning.nodeIds && warning.nodeIds.length > 1) { flowInstance.fitView({ @@ -243,6 +274,7 @@ export default function App() { } setPinnedFocus(null); + setSelectedEdge(null); if (warning.edgeId) { const edge = graph.edges.find((item) => item.id === warning.edgeId); if (edge) focusEdge(edge); @@ -305,7 +337,7 @@ export default function App() {
- {graph.nodes.length} models + {visibleNodeData.length}/{graph.nodes.length} models {visibleEdgeData.length}/{graph.edges.length} links {requiredInputs.length > 0 && (
; + onToggle: (scale: string) => void; + onExpandAll: () => void; +}) { + return ( +
+
Scales
+
+ {scales.map((scale) => { + const collapsed = collapsedScales.has(scale); + return ( + + ); + })} +
+ {collapsedScales.size > 0 && } +
+ ); +} + function FloatingPanel({ className, title, subtitle, onClose, children }: { className: string; title: string; subtitle: string; onClose: () => void; children: ReactNode }) { return (
@@ -494,6 +567,7 @@ function WarningList({ function InspectorDetails({ selected, + selectedEdge, activePort, requiredInputPortIds, incomingEdges, @@ -503,6 +577,7 @@ function InspectorDetails({ onFocusEdge, }: { selected: GraphNodeData | null; + selectedEdge: GraphEdgeData | null; activePort: GraphPort | null; requiredInputPortIds: Set; incomingEdges: GraphEdgeData[]; @@ -513,6 +588,9 @@ function InspectorDetails({ }) { return ( <> + {selectedEdge && ( + + )} {selected ? (
@@ -528,9 +606,9 @@ function InspectorDetails({
{port.name} uses previous timestep
))}
- ) : ( + ) : !selectedEdge ? (
Select a model node.
- )} + ) : null}

Variable Provenance

{activePort ? ( @@ -554,6 +632,38 @@ function InspectorDetails({ ); } +function EdgeDetails({ + edge, + nodeById, + portById, +}: { + edge: GraphEdgeData; + nodeById: Map; + portById: Map; +}) { + const source = nodeById.get(edge.source); + const target = nodeById.get(edge.target); + const sourcePort = edge.sourcePort ? portById.get(edge.sourcePort)?.port : null; + const targetPort = edge.targetPort ? portById.get(edge.targetPort)?.port : null; + return ( +
+
+ {edgeKindLabel(edge)} + {edge.scaleRelation} +
+ + + + + + + {edge.diagnostics.length > 0 ? edge.diagnostics.map((item) => ( +
{item}
+ )) :
No edge diagnostics.
} +
+ ); +} + function EdgeList({ title, edges, diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 2702fc2f3..432e7e0ba 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -401,6 +401,75 @@ h1 { border-style: dashed; } +.scale-controls { + position: absolute; + z-index: 35; + top: 314px; + left: 18px; + display: grid; + gap: 8px; + width: 190px; + padding: 10px; + border: 1px solid var(--line); + border-radius: 13px; + background: rgba(255, 250, 242, 0.92); + box-shadow: 0 18px 45px var(--shadow); + backdrop-filter: blur(10px); +} + +.scale-list { + display: grid; + gap: 6px; +} + +.scale-list button, +.scale-reset { + display: grid; + gap: 2px; + width: 100%; + padding: 7px 8px; + border: 1px solid transparent; + border-radius: 9px; + background: transparent; + color: var(--muted); + font: inherit; + text-align: left; + cursor: pointer; +} + +.scale-list button.active { + color: var(--ink); + border-color: rgba(31, 122, 83, 0.22); + background: var(--accent-soft); +} + +.scale-list button.collapsed { + border-color: rgba(183, 166, 150, 0.34); + border-style: dashed; + background: rgba(183, 166, 150, 0.08); +} + +.scale-list span { + overflow-wrap: anywhere; + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; + font-size: 12px; + font-weight: 700; +} + +.scale-list small { + color: var(--muted); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.scale-reset { + color: var(--accent); + border-color: rgba(31, 122, 83, 0.2); + background: rgba(31, 122, 83, 0.08); + text-align: center; +} + .floating-panel { position: absolute; z-index: 40; @@ -886,6 +955,23 @@ h1 { padding: 10px; } +.edge-detail-card { + display: grid; + gap: 4px; + margin-bottom: 14px; + padding: 10px; + border: 1px solid rgba(31, 122, 83, 0.24); + border-radius: 12px; + background: + linear-gradient(135deg, rgba(31, 122, 83, 0.08), transparent 58%), + rgba(255, 250, 242, 0.82); +} + +.edge-detail-card .diagnostic, +.edge-detail-card .empty-state { + margin-top: 6px; +} + .variable-card .row:first-of-type { margin-top: 8px; } @@ -1117,7 +1203,12 @@ h1 { } .relationship-legend, + .scale-controls, .floating-panel { top: 150px; } + + .scale-controls { + left: 220px; + } } From 11c6af9f3e70f5e214d23425af6af27b07df6915 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Vezy?= Date: Tue, 5 May 2026 06:54:28 +0200 Subject: [PATCH 15/39] Update AGENTS guidance for Julia process handling --- Project.toml | 4 + frontend/src/App.tsx | 148 +++++++- frontend/src/styles.css | 96 +++++ frontend/src/types.ts | 35 ++ lib/PlantSimEngineGraphEditor/Project.toml | 16 + .../src/PlantSimEngineGraphEditor.jl | 285 +++++++++++++++ src/PlantSimEngine.jl | 8 +- src/model_discovery.jl | 242 +++++++++++++ src/visualization/dependency_graph_view.jl | 332 +++++++++++++++--- test/test-dependency-graph-view.jl | 88 +++++ 10 files changed, 1205 insertions(+), 49 deletions(-) create mode 100644 lib/PlantSimEngineGraphEditor/Project.toml create mode 100644 lib/PlantSimEngineGraphEditor/src/PlantSimEngineGraphEditor.jl create mode 100644 src/model_discovery.jl diff --git a/Project.toml b/Project.toml index 7db8b1067..a4d1a1921 100644 --- a/Project.toml +++ b/Project.toml @@ -10,6 +10,8 @@ DataAPI = "9a962f9c-6df0-11e9-0e5d-c546b8b5ee8a" DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" FLoops = "cc61a311-1640-44b5-9fba-1b764f453329" +InteractiveUtils = "b77e0a4c-d291-57a0-90e8-8db25a27a240" +JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" Markdown = "d6f4376e-aef5-505a-96c1-9c027394607a" MultiScaleTreeGraph = "dd4a991b-8a45-4075-bede-262ee62d5583" PlantMeteo = "4630fe09-e0fb-4da5-a846-781cb73437b6" @@ -25,6 +27,8 @@ DataAPI = "1.15" DataFrames = "1" Dates = "1.10" FLoops = "0.2" +InteractiveUtils = "1.10" +JSON = "1" Markdown = "1.10" MultiScaleTreeGraph = "0.15.1" PlantMeteo = "0.8.2" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5f8bc7d93..351bdcd57 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -30,7 +30,7 @@ import { DependencyEdge } from "./DependencyEdge"; import { ModelNode } from "./ModelNode"; import { layoutGraph, type LayoutMode } from "./layout"; import { sampleGraph } from "./sampleGraph"; -import type { DependencyGraphView, GraphEdgeData, GraphNodeData, GraphPort, RuntimeGraphNodeData } from "./types"; +import type { DependencyGraphView, GraphEdgeData, GraphEditorState, GraphNodeData, GraphPort, ModelDescriptor, RuntimeGraphNodeData } from "./types"; import "./styles.css"; type EdgeFilterKey = "dataFlow" | "mapped" | "callStack"; @@ -102,7 +102,12 @@ const layoutLabels: Record = { }; export default function App() { - const [graph] = useState(loadInitialGraph()); + const [graph, setGraph] = useState(loadInitialGraph()); + const [editorModels, setEditorModels] = useState([]); + const [editorSocket, setEditorSocket] = useState(null); + const [editorConnected, setEditorConnected] = useState(false); + const [canUndo, setCanUndo] = useState(false); + const [canRedo, setCanRedo] = useState(false); const [selected, setSelected] = useState(null); const [activePort, setActivePort] = useState(null); const [showRequiredPanel, setShowRequiredPanel] = useState(false); @@ -142,6 +147,29 @@ export default function App() { ); const focus = useMemo(() => pinnedFocus?.active ? pinnedFocus : traversalFocus, [pinnedFocus, traversalFocus]); + useEffect(() => { + const config = loadEditorConfig(); + if (!config?.websocketUrl) return; + + const socket = new WebSocket(config.websocketUrl); + setEditorSocket(socket); + socket.addEventListener("open", () => setEditorConnected(true)); + socket.addEventListener("close", () => setEditorConnected(false)); + socket.addEventListener("message", (event) => { + const payload = JSON.parse(event.data) as GraphEditorState; + if (payload.graph) setGraph(payload.graph); + if (payload.models) setEditorModels(payload.models); + setCanUndo(Boolean(payload.canUndo)); + setCanRedo(Boolean(payload.canRedo)); + }); + return () => socket.close(); + }, []); + + const sendEditorCommand = useCallback((command: Record) => { + if (!editorSocket || editorSocket.readyState !== WebSocket.OPEN) return; + editorSocket.send(JSON.stringify(command)); + }, [editorSocket]); + useEffect(() => { const nextNodes = visibleNodeData.map((node) => ({ id: node.id, @@ -377,6 +405,13 @@ export default function App() {
+ {editorSocket && ( +
+ {editorConnected ? "live" : "offline"} + + +
+ )}
@@ -447,6 +482,12 @@ export default function App() {

Diagnostics

{graph.diagnostics.length > 0 ? graph.diagnostics.map((item) =>
{item}
) :
No diagnostics.
} + {editorModels.length > 0 && ( + <> +

Available Models

+ + + )} ); @@ -706,6 +747,103 @@ function Row({ label, value }: { label: string; value: string }) { return
{label}{value}
; } +function ModelBrowser({ + models, + scales, + onCommand, + disabled, +}: { + models: ModelDescriptor[]; + scales: string[]; + onCommand: (command: Record) => void; + disabled: boolean; +}) { + const [modelType, setModelType] = useState(models[0]?.type ?? ""); + const [scale, setScale] = useState(scales[0] ?? "Default"); + const selected = models.find((model) => model.type === modelType) ?? models[0]; + + useEffect(() => { + if (!models.some((model) => model.type === modelType)) setModelType(models[0]?.type ?? ""); + }, [modelType, models]); + + useEffect(() => { + if (!scales.includes(scale)) setScale(scales[0] ?? "Default"); + }, [scale, scales]); + + if (!selected) return
No model type is available.
; + return ( +
+ + + +
+ ); +} + +function ModelParameterForm({ + model, + scale, + disabled, + onCommand, +}: { + model: ModelDescriptor; + scale: string; + disabled: boolean; + onCommand: (command: Record) => void; +}) { + const initialValues = useMemo(() => Object.fromEntries(model.constructor.fields.map((field) => [field.name, parameterDefaultValue(field.default)])), [model]); + const initialTypes = useMemo(() => Object.fromEntries(model.constructor.fields.map((field) => [field.name, field.inferredChoice])), [model]); + const [values, setValues] = useState>(initialValues); + const [types, setTypes] = useState>(initialTypes); + + const setSharedType = useCallback((fieldName: string, nextType: string) => { + const field = model.constructor.fields.find((item) => item.name === fieldName); + const group = field?.typeParameter ? model.constructor.parameterGroups[field.typeParameter] ?? [fieldName] : [fieldName]; + setTypes((current) => ({ ...current, ...Object.fromEntries(group.map((name) => [name, nextType])) })); + }, [model]); + + const addModel = useCallback(() => { + const parameters = Object.fromEntries(model.constructor.fields.map((field) => [ + field.name, + { type: types[field.name] ?? field.inferredChoice, value: values[field.name] ?? "" }, + ])); + onCommand({ action: "edit", kind: "add_model", scale, modelType: model.type, parameters }); + }, [model, onCommand, scale, types, values]); + + return ( +
+ {model.name} + {model.process ?? "unknown process"} + {model.constructor.fields.map((field) => ( +
+ + setValues((current) => ({ ...current, [field.name]: event.target.value }))} /> + +
+ ))} + +
+ ); +} + +function parameterDefaultValue(value: unknown) { + if (value === null || typeof value === "undefined") return ""; + if (typeof value === "string" && value.startsWith(":")) return value.slice(1); + return String(value); +} + function loadInitialGraph() { const embedded = document.getElementById("pse-graph-data"); if (embedded?.textContent) return JSON.parse(embedded.textContent) as DependencyGraphView; @@ -713,6 +851,12 @@ function loadInitialGraph() { return fromWindow ?? sampleGraph; } +function loadEditorConfig() { + const embedded = document.getElementById("pse-editor-config"); + if (!embedded?.textContent) return null; + return JSON.parse(embedded.textContent) as { websocketUrl?: string }; +} + function runtimeNodeData( node: GraphNodeData, options: { diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 432e7e0ba..9f46cf422 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1193,6 +1193,102 @@ h1 { font-size: 11px; } +.live-session { + border-left: 1px solid rgba(183, 166, 150, 0.36); + padding-left: 10px; +} + +.live-pill { + display: inline-flex; + align-items: center; + height: 28px; + padding: 0 9px; + border: 1px solid rgba(191, 106, 84, 0.38); + border-radius: 999px; + color: var(--clay); + background: rgba(191, 106, 84, 0.08); + font-size: 11px; + font-weight: 800; + text-transform: uppercase; +} + +.live-pill.connected { + border-color: rgba(31, 122, 83, 0.34); + color: var(--accent); + background: rgba(31, 122, 83, 0.09); +} + +.metric-button:disabled { + cursor: not-allowed; + opacity: 0.42; +} + +.model-browser { + display: grid; + gap: 7px; +} + +.model-browser-control { + display: grid; + gap: 4px; +} + +.model-browser-control span, +.parameter-row label { + color: var(--muted); + font-size: 11px; + font-weight: 700; +} + +.model-browser-control select, +.parameter-row input, +.parameter-row select { + min-width: 0; + border: 1px solid rgba(183, 166, 150, 0.44); + border-radius: 8px; + background: rgba(255, 255, 255, 0.78); + color: var(--ink); + font: inherit; +} + +.model-browser-control select { + height: 32px; + padding: 0 8px; +} + +.model-browser-item { + display: grid; + gap: 7px; + padding: 8px 9px; + border: 1px solid rgba(183, 166, 150, 0.34); + border-radius: 10px; + background: rgba(255, 255, 255, 0.62); +} + +.model-browser-item strong { + overflow-wrap: anywhere; + font-size: 12px; +} + +.model-browser-item span { + color: var(--muted); + font-size: 11px; +} + +.parameter-row { + display: grid; + grid-template-columns: minmax(0, 0.75fr) minmax(0, 1fr) minmax(78px, 0.8fr); + gap: 6px; + align-items: center; +} + +.parameter-row input, +.parameter-row select { + height: 28px; + padding: 0 7px; + 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 3f43f240a..7fa36e82a 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -54,5 +54,40 @@ export type DependencyGraphView = { scales: string[]; cyclic: boolean; cycleNodes: string[]; + cycleEdges?: string[]; diagnostics: string[]; }; + +export type ModelConstructorField = { + name: string; + declaredType: string; + hasDefault: boolean; + default: unknown; + defaultType: string | null; + typeParameter: string | null; + inferredChoice: string; + choices: string[]; +}; + +export type ModelDescriptor = { + type: string; + name: string; + process: string | null; + processType: string | null; + inputs: Record; + outputs: Record; + constructor: { + fields: ModelConstructorField[]; + parameterGroups: Record; + hasZeroArgConstructor: boolean; + }; +}; + +export type GraphEditorState = { + ok: boolean; + graph: DependencyGraphView; + models: ModelDescriptor[]; + canUndo: boolean; + canRedo: boolean; + url: string; +}; diff --git a/lib/PlantSimEngineGraphEditor/Project.toml b/lib/PlantSimEngineGraphEditor/Project.toml new file mode 100644 index 000000000..749b7ad69 --- /dev/null +++ b/lib/PlantSimEngineGraphEditor/Project.toml @@ -0,0 +1,16 @@ +name = "PlantSimEngineGraphEditor" +uuid = "7cf0a04e-a9b1-4d1f-a906-d86a8c54d709" +version = "0.1.0" +authors = ["Rémi Vezy "] + +[deps] +HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3" +JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" +PlantSimEngine = "9a576370-710b-4269-adf9-4f603a9c6423" + +[sources] +PlantSimEngine = {path = "../.."} + +[compat] +HTTP = "1" +JSON = "1" diff --git a/lib/PlantSimEngineGraphEditor/src/PlantSimEngineGraphEditor.jl b/lib/PlantSimEngineGraphEditor/src/PlantSimEngineGraphEditor.jl new file mode 100644 index 000000000..62811d49c --- /dev/null +++ b/lib/PlantSimEngineGraphEditor/src/PlantSimEngineGraphEditor.jl @@ -0,0 +1,285 @@ +module PlantSimEngineGraphEditor + +import HTTP +import JSON +import PlantSimEngine + +export GraphEditorSession, edit_graph, current_mapping, apply_edit!, undo!, redo!, close + +mutable struct GraphEditorSession{M,G,S} + mapping::M + mtg::G + history::Vector{M} + future::Vector{M} + server::S + host::String + port::Int + url::String +end + +current_mapping(session::GraphEditorSession) = session.mapping +Base.close(session::GraphEditorSession) = close(session.server) + +""" + edit_graph(mapping; mtg=nothing, host="127.0.0.1", port=8765) + +Start a local graph editor session. The returned session owns the current +`ModelMapping`; call `current_mapping(session)` to recover the edited mapping. +""" +function edit_graph(mapping; mtg=nothing, host::AbstractString="127.0.0.1", port::Integer=8765) + session_ref = Ref{Any}() + handler = http -> _handle_http(session_ref[], http) + server = HTTP.listen!(handler, host, port; listenany=true, verbose=false) + actual_port = HTTP.port(server) + session = GraphEditorSession( + mapping, + mtg, + typeof(mapping)[], + typeof(mapping)[], + server, + String(host), + actual_port, + "http://$(host):$(actual_port)", + ) + session_ref[] = session + return session +end + +function apply_edit!(session::GraphEditorSession, edit::PlantSimEngine.AbstractGraphEdit) + push!(session.history, session.mapping) + empty!(session.future) + session.mapping = PlantSimEngine.apply_graph_edit(session.mapping, edit) + return session.mapping +end + +function undo!(session::GraphEditorSession) + isempty(session.history) && return session.mapping + push!(session.future, session.mapping) + session.mapping = pop!(session.history) + return session.mapping +end + +function redo!(session::GraphEditorSession) + isempty(session.future) && return session.mapping + push!(session.history, session.mapping) + session.mapping = pop!(session.future) + return session.mapping +end + +function _handle_http(session::GraphEditorSession, http::HTTP.Stream) + if HTTP.WebSockets.isupgrade(http.message) + return HTTP.WebSockets.upgrade(http) do ws + _handle_websocket(session, ws) + end + end + + req = http.message + path = HTTP.URI(req.target).path + response = if path == "/" || path == "/index.html" + (200, ["Content-Type" => "text/html; charset=utf-8"], _editor_html(session)) + elseif path == "/state" + (200, ["Content-Type" => "application/json"], _state_json(session)) + else + (404, ["Content-Type" => "text/plain; charset=utf-8"], "Not found") + end + status, headers, body = response + HTTP.setstatus(http, status) + for header in headers + HTTP.setheader(http, header) + end + HTTP.setheader(http, "Content-Length" => string(sizeof(body))) + HTTP.startwrite(http) + write(http, body) + return nothing +end + +function _handle_websocket(session::GraphEditorSession, ws) + HTTP.WebSockets.send(ws, _state_json(session)) + try + for message in ws + command = JSON.parse(String(message)) + response = _handle_command!(session, command) + HTTP.WebSockets.send(ws, JSON.json(response)) + end + catch err + HTTP.WebSockets.send(ws, JSON.json(_error_payload(err))) + end +end + +function _handle_command!(session::GraphEditorSession, command) + action = get(command, "action", "") + try + if action == "undo" + undo!(session) + elseif action == "redo" + redo!(session) + elseif action == "edit" + edit = _edit_from_command(command) + apply_edit!(session, edit) + else + error("Unsupported graph editor command action `$action`.") + end + return _state_payload(session; ok=true) + catch err + return _state_payload(session; ok=false, diagnostics=[sprint(showerror, err)]) + end +end + +function _edit_from_command(command) + kind = get(command, "kind", "") + kind == "mark_previous_timestep" && return PlantSimEngine.MarkPreviousTimeStep( + Symbol(command["scale"]), + Symbol(command["process"]), + Symbol(command["variable"]), + ) + kind == "unmark_previous_timestep" && return PlantSimEngine.UnmarkPreviousTimeStep( + Symbol(command["scale"]), + Symbol(command["process"]), + Symbol(command["variable"]), + ) + kind == "remove_model" && return PlantSimEngine.RemoveModel( + Symbol(command["scale"]), + Symbol(command["process"]), + ) + kind == "set_mapped_variable" && return PlantSimEngine.SetMappedVariable( + Symbol(command["scale"]), + Symbol(command["process"]), + Symbol(command["variable"]), + Symbol(command["sourceScale"]), + Symbol(command["sourceVariable"]), + Symbol(get(command, "mode", "single")), + ) + if kind in ("add_model", "replace_model") + model_type = _resolve_model_type(command["modelType"]) + parameters = _parameters_from_command(get(command, "parameters", Dict())) + if kind == "add_model" + return PlantSimEngine.AddModel(Symbol(command["scale"]), model_type, parameters) + end + return PlantSimEngine.ReplaceModel(Symbol(command["scale"]), Symbol(command["process"]), model_type, parameters) + end + error("Unsupported graph edit kind `$kind`.") +end + +function _resolve_model_type(label) + for model_type in PlantSimEngine.available_models() + string(model_type) == label && return model_type + string(nameof(model_type)) == label && return model_type + end + error("No loaded PlantSimEngine model type matches `$label`. Load the package that defines it first.") +end + +function _parameters_from_command(parameters) + pairs = Pair{Symbol,Any}[] + for (key, value) in parameters + push!(pairs, Symbol(key) => _parse_parameter_value(value)) + end + return (; pairs...) +end + +function _parse_parameter_value(value) + value isa AbstractDict || return value + choice = Symbol(get(value, "type", "julia")) + raw = get(value, "value", nothing) + choice == :float && return Float64(raw) + choice == :integer && return Int(raw) + choice == :boolean && return Bool(raw) + choice == :symbol && return Symbol(raw) + choice == :string && return String(raw) + choice == :nothing && return nothing + choice == :julia && return Core.eval(Main, Meta.parse(String(raw))) + return raw +end + +function _state_payload(session::GraphEditorSession; ok::Bool=true, diagnostics::Vector{String}=String[]) + graph = JSON.parse(PlantSimEngine.graph_view_json(session.mapping)) + append!(graph["diagnostics"], diagnostics) + return Dict( + "ok" => ok, + "graph" => graph, + "models" => [PlantSimEngine.model_descriptor(T) for T in PlantSimEngine.available_models()], + "canUndo" => !isempty(session.history), + "canRedo" => !isempty(session.future), + "url" => session.url, + ) +end + +_state_json(session::GraphEditorSession) = JSON.json(_state_payload(session)) +_error_payload(err) = Dict("ok" => false, "diagnostics" => [sprint(showerror, err)]) + +function _editor_html(session::GraphEditorSession) + react_html = _react_editor_html(session) + isnothing(react_html) || return react_html + + graph_json = PlantSimEngine.graph_view_json(session.mapping) + config_json = JSON.json(Dict("websocketUrl" => "ws://$(session.host):$(session.port)/ws")) + return """ + + + + + +PlantSimEngine Graph Editor + + + + +
+

PlantSimEngine Graph Editor

+

This live session is running. The React editor can connect to ws://$(session.host):$(session.port)/ws.

+

Current graph state is available at /state.

+

+
+ + + +""" +end + +function _react_editor_html(session::GraphEditorSession) + assets_dir = _frontend_dist_dir() + manifest_path = joinpath(assets_dir, ".vite", "manifest.json") + isfile(manifest_path) || return nothing + + manifest = JSON.parse(read(manifest_path, String)) + entry = nothing + for value in values(manifest) + if get(value, "isEntry", false) == true + entry = value + break + end + end + isnothing(entry) && (entry = get(manifest, "index.html", nothing)) + isnothing(entry) && return nothing + + js_file = get(entry, "file", nothing) + isnothing(js_file) && return nothing + css_files = get(entry, "css", Any[]) + js = read(joinpath(assets_dir, js_file), String) + css = join([read(joinpath(assets_dir, css_file), String) for css_file in css_files], "\n") + graph_json = PlantSimEngine.graph_view_json(session.mapping) + config_json = replace(JSON.json(Dict("websocketUrl" => "ws://$(session.host):$(session.port)/ws")), " "<\\/") + + return """ + + + + + +PlantSimEngine Graph Editor + + + + + +
+ + + +""" +end + +_frontend_dist_dir() = normpath(joinpath(@__DIR__, "..", "..", "..", "frontend", "dist")) + +end diff --git a/src/PlantSimEngine.jl b/src/PlantSimEngine.jl index f3d1a81c8..e12f7fb7d 100644 --- a/src/PlantSimEngine.jl +++ b/src/PlantSimEngine.jl @@ -5,8 +5,10 @@ import DataFrames import Tables import DataAPI import Dates +import InteractiveUtils import CSV # For reading csv files with variables() +import JSON # For graph JSON serialization # For graph dependency: import AbstractTrees @@ -94,6 +96,7 @@ include("processes/model_initialisation.jl") include("processes/models_inputs_outputs.jl") include("processes/process_generation.jl") include("checks/dimensions.jl") +include("model_discovery.jl") # Multi-rate runtime: include("time/runtime/clocks.jl") @@ -142,8 +145,9 @@ 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 +export graph_view, graph_view_json, write_graph_view, compile_graph_view +export available_processes, available_models, model_descriptor, model_constructor_descriptor +export AbstractGraphEdit, AddModel, RemoveModel, ReplaceModel, SetMappedVariable, MarkPreviousTimeStep, UnmarkPreviousTimeStep, apply_graph_edit # Re-exporting PlantMeteo main functions: export Atmosphere, TimeStepTable, Constants, Weather diff --git a/src/model_discovery.jl b/src/model_discovery.jl new file mode 100644 index 000000000..1be004468 --- /dev/null +++ b/src/model_discovery.jl @@ -0,0 +1,242 @@ +const _MODEL_PARAMETER_TYPE_CHOICES = ( + :float, + :integer, + :boolean, + :symbol, + :string, + :nothing, + :julia, +) + +const _MODEL_DISCOVERY_EXCLUDED_NAMES = Set{Symbol}([ + :MultiScaleModel, + :ModelSpec, +]) + +""" + available_processes() + +Return process abstract model types visible in the current Julia session. + +Packages become discoverable after the user loads them with `using PackageName`. +""" +function available_processes() + processes = Type[] + for T in _abstract_model_subtypes() + _is_process_type(T) || continue + push!(processes, T) + end + return sort!(unique(processes); by=T -> string(process_(T))) +end + +""" + available_models() + available_models(process::Symbol) + available_models(process_type::Type{<:AbstractModel}) + +Return model implementation types visible in the current Julia session. +""" +function available_models() + models = Type[] + for T in _abstract_model_subtypes() + _is_available_model_type(T) || continue + push!(models, T) + end + return sort!(unique(models); by=T -> string(_process_name_for_type(T), ".", nameof(T))) +end + +function available_models(process_name::Symbol) + filter(T -> _process_name_for_type(T) == process_name, available_models()) +end + +function available_models(process_type::Type{<:AbstractModel}) + process_name = _is_process_type(process_type) ? process_(process_type) : _process_name_for_type(process_type) + return available_models(process_name) +end + +""" + model_descriptor(::Type{<:AbstractModel}) + +Return renderer-friendly metadata for one model implementation type. +""" +function model_descriptor(::Type{T}) where {T<:AbstractModel} + process_type = _process_type_for_model(T) + process_name = isnothing(process_type) ? nothing : process_(process_type) + constructor = model_constructor_descriptor(T) + return Dict{String,Any}( + "type" => string(T), + "name" => string(nameof(T)), + "process" => isnothing(process_name) ? nothing : string(process_name), + "processType" => isnothing(process_type) ? nothing : string(process_type), + "inputs" => _model_var_descriptor(T, inputs_), + "outputs" => _model_var_descriptor(T, outputs_), + "timespec" => _safe_string_trait(T, timespec), + "outputPolicy" => _safe_string_trait(T, output_policy), + "timestepHint" => _safe_string_trait(T, timestep_hint), + "meteoHint" => _safe_string_trait(T, meteo_hint), + "constructor" => constructor, + ) +end + +""" + model_constructor_descriptor(::Type{<:AbstractModel}) + +Return best-effort constructor metadata inferred from struct fields and an +optional zero-argument constructor. +""" +function model_constructor_descriptor(::Type{T}) where {T<:AbstractModel} + unwrapped_type = Base.unwrap_unionall(T) + names = collect(fieldnames(unwrapped_type)) + declared_types = collect(fieldtypes(unwrapped_type)) + default_instance = _try_zero_arg_model(T) + has_defaults = !isnothing(default_instance) + + fields = Dict{String,Any}[] + parameter_groups = Dict{String,Vector{String}}() + for (i, name) in pairs(names) + declared = declared_types[i] + default = has_defaults ? getfield(default_instance, name) : nothing + default_type = has_defaults ? typeof(default) : nothing + parameter_key = _field_type_parameter_key(declared) + inferred_choice = _parameter_choice(default_type, declared) + field_name = string(name) + if !isnothing(parameter_key) + push!(get!(parameter_groups, parameter_key, String[]), field_name) + end + + push!(fields, Dict{String,Any}( + "name" => field_name, + "declaredType" => string(declared), + "hasDefault" => has_defaults, + "default" => has_defaults ? _jsonable_value(default) : nothing, + "defaultType" => isnothing(default_type) ? nothing : string(default_type), + "typeParameter" => parameter_key, + "inferredChoice" => string(inferred_choice), + "choices" => string.(_MODEL_PARAMETER_TYPE_CHOICES), + )) + end + + return Dict{String,Any}( + "type" => string(T), + "name" => string(nameof(T)), + "fields" => fields, + "parameterGroups" => parameter_groups, + "hasZeroArgConstructor" => has_defaults, + "constructible" => true, + "positional" => true, + "keyword" => false, + ) +end + +function _abstract_model_subtypes(root::Type=AbstractModel) + found = Type[] + for child in InteractiveUtils.subtypes(root) + push!(found, child) + append!(found, _abstract_model_subtypes(child)) + end + return found +end + +function _is_process_type(T::Type) + isabstracttype(T) || return false + T === AbstractModel && return false + try + process_(T) + return true + catch + return false + end +end + +function _is_available_model_type(T::Type) + isabstracttype(T) && return false + nameof(T) in _MODEL_DISCOVERY_EXCLUDED_NAMES && return false + isnothing(_process_type_for_model(T)) && return false + return true +end + +function _process_type_for_model(T::Type) + current = T + while current !== Any && current !== AbstractModel + _is_process_type(current) && return current + current = supertype(current) + end + return nothing +end + +function _process_name_for_type(T::Type) + process_type = _process_type_for_model(T) + isnothing(process_type) && return Symbol(nameof(T)) + return process_(process_type) +end + +function _model_var_descriptor(::Type{T}, accessor) where {T<:AbstractModel} + instance = _try_zero_arg_model(T) + isnothing(instance) && return Dict{String,Any}() + vars = try + accessor(instance) + catch err + return Dict{String,Any}("_error" => sprint(showerror, err)) + end + return Dict(string(k) => _jsonable_value(v) for (k, v) in pairs(vars)) +end + +function _safe_string_trait(::Type{T}, trait) where {T<:AbstractModel} + value = try + trait(T) + catch + instance = _try_zero_arg_model(T) + isnothing(instance) && return nothing + try + trait(instance) + catch err + return string("error: ", sprint(showerror, err)) + end + end + return string(value) +end + +function _try_zero_arg_model(::Type{T}) where {T<:AbstractModel} + try + return T() + catch + return nothing + end +end + +function _field_type_parameter_key(field_type) + field_type isa TypeVar && return string(field_type.name) + return nothing +end + +function _parameter_choice(default_type, declared_type) + !isnothing(default_type) && return _parameter_choice_from_type(default_type) + return _parameter_choice_from_type(declared_type) +end + +function _parameter_choice_from_type(T) + try + T === Nothing && return :nothing + T === Any && return :float + T isa TypeVar && return :float + T === Bool && return :boolean + T <: Integer && return :integer + T <: AbstractFloat && return :float + T <: Real && return :float + T <: Symbol && return :symbol + T <: AbstractString && return :string + catch + return :float + end + return :julia +end + +function _jsonable_value(value) + value === nothing && return nothing + value isa Bool && return value + value isa Real && isfinite(value) && return value + value isa Symbol && return string(":", value) + value isa AbstractString && return value + value isa AbstractArray && return string(typeof(value), " length ", length(value)) + return string(value) +end diff --git a/src/visualization/dependency_graph_view.jl b/src/visualization/dependency_graph_view.jl index da63cb789..5fdb24857 100644 --- a/src/visualization/dependency_graph_view.jl +++ b/src/visualization/dependency_graph_view.jl @@ -63,11 +63,39 @@ struct DependencyGraphView scales::Vector{Symbol} cyclic::Bool cycle_nodes::Vector{String} + cycle_edges::Vector{String} diagnostics::Vector{String} end abstract type AbstractGraphEdit end +struct AddModel{P} <: AbstractGraphEdit + scale::Symbol + model_type::Type + parameters::P +end + +struct RemoveModel <: AbstractGraphEdit + scale::Symbol + process::Symbol +end + +struct ReplaceModel{P} <: AbstractGraphEdit + scale::Symbol + process::Symbol + model_type::Type + parameters::P +end + +struct SetMappedVariable <: AbstractGraphEdit + scale::Symbol + process::Symbol + variable::Symbol + source_scale::Symbol + source_variable::Symbol + mode::Symbol +end + """ MarkPreviousTimeStep(scale, process, variable) @@ -80,25 +108,53 @@ struct MarkPreviousTimeStep <: AbstractGraphEdit variable::Symbol end +struct UnmarkPreviousTimeStep <: AbstractGraphEdit + scale::Symbol + process::Symbol + variable::Symbol +end + """ + compile_graph_view(mapping; strict=false) graph_view(mapping) graph_view(sim::GraphSimulation) Build a renderer-independent view of a dependency graph. """ -function graph_view(mapping::ModelMapping; verbose::Bool=false) +function compile_graph_view(mapping::ModelMapping; verbose::Bool=false, strict::Bool=false) diagnostics = String[] - graph = try + graph, diagnostics = try dep(mapping; verbose=verbose) catch err + strict && rethrow() msg = sprint(showerror, err) push!(diagnostics, msg) - return _graph_view_from_mapping_only(mapping, diagnostics) - end + graph_for_view = _dependency_graph_for_view(mapping, diagnostics) + isnothing(graph_for_view) && return _graph_view_from_mapping_only(mapping, diagnostics) + graph_for_view + end, diagnostics return graph_view(graph, mapping; diagnostics=diagnostics) end +function graph_view(mapping::ModelMapping; kwargs...) + return compile_graph_view(mapping; kwargs...) +end + +function _dependency_graph_for_view(mapping::ModelMapping{MultiScale}, diagnostics) + try + soft_dep_graphs_roots, hard_dep_dict = hard_dependencies(mapping; verbose=false) + mapped_vars = mapped_variables(mapping, soft_dep_graphs_roots, verbose=false) + reverse_multiscale_mapping = reverse_mapping(mapped_vars, all=false) + return soft_dependencies_multiscale(soft_dep_graphs_roots, reverse_multiscale_mapping, hard_dep_dict) + catch err + push!(diagnostics, sprint(showerror, err)) + return nothing + end +end + +_dependency_graph_for_view(::ModelMapping, diagnostics) = nothing + function graph_view(sim::GraphSimulation; diagnostics::Vector{String}=String[]) return graph_view(sim.dependency_graph, sim; diagnostics=diagnostics) end @@ -149,8 +205,23 @@ function graph_view(graph::DependencyGraph, context=nothing; diagnostics::Vector 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[] + cycle_edges = cyclic ? _cycle_edge_ids(edges, cycle_nodes) : String[] scales = sort!(unique([node.scale for node in nodes]); by=string) - return DependencyGraphView(nodes, edges, scales, cyclic, cycle_nodes, diagnostics) + return DependencyGraphView(nodes, edges, scales, cyclic, cycle_nodes, cycle_edges, diagnostics) +end + +function _cycle_edge_ids(edges::Vector{GraphEdge}, cycle_nodes::Vector{String}) + ids = String[] + length(cycle_nodes) < 2 && return ids + for i in 1:(length(cycle_nodes)-1) + source = cycle_nodes[i + 1] + target = cycle_nodes[i] + for edge in edges + edge.source == source && edge.target == target || continue + push!(ids, edge.id) + end + end + return unique(ids) end function _push_edge!(edges::Vector{GraphEdge}, edge_ids::Set{String}, edge::GraphEdge) @@ -361,10 +432,181 @@ function apply_graph_edit(mapping::ModelMapping{MultiScale}, edit::MarkPreviousT return ModelMapping(data; check=true, type_promotion=type_promotion(mapping)) end +function apply_graph_edit(mapping::ModelMapping{MultiScale}, edit::UnmarkPreviousTimeStep) + haskey(mapping, edit.scale) || error("Cannot unmark `$(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 ? _unmark_previous_timestep_entry(entry, edit, found) : entry + end + + found[] || error("Cannot unmark `$(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{MultiScale}, edit::AddModel) + haskey(mapping, edit.scale) || error("Cannot add model: scale `$(edit.scale)` is not present in the `ModelMapping`.") + model = _construct_graph_edit_model(edit.model_type, edit.parameters) + process_name = process(model) + any(existing -> process(existing) == process_name, get_models(mapping[edit.scale])) && error( + "Cannot add `$(typeof(model))` at scale `$(edit.scale)`: process `$process_name` already exists at this scale." + ) + + data = Dict{Symbol,Any}() + for (scale, entry) in pairs(mapping) + data[scale] = scale == edit.scale ? _insert_model_entry(entry, model) : entry + end + return ModelMapping(data; check=true, type_promotion=type_promotion(mapping)) +end + +function apply_graph_edit(mapping::ModelMapping{MultiScale}, edit::RemoveModel) + haskey(mapping, edit.scale) || error("Cannot remove model: 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 ? _remove_model_entry(entry, edit.process, found) : entry + end + found[] || error("Cannot remove model: 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{MultiScale}, edit::ReplaceModel) + haskey(mapping, edit.scale) || error("Cannot replace model: scale `$(edit.scale)` is not present in the `ModelMapping`.") + model = _construct_graph_edit_model(edit.model_type, edit.parameters) + process(model) == edit.process || error( + "Cannot replace process `$(edit.process)` with `$(typeof(model))`: replacement model implements process `$(process(model))`." + ) + found = Ref(false) + data = Dict{Symbol,Any}() + for (scale, entry) in pairs(mapping) + data[scale] = scale == edit.scale ? _replace_model_entry(entry, edit.process, model, found) : entry + end + found[] || error("Cannot replace model: 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{MultiScale}, edit::SetMappedVariable) + haskey(mapping, edit.scale) || error("Cannot set mapping: 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 ? _set_mapped_variable_entry(entry, edit, found) : entry + end + found[] || error("Cannot set mapping for `$(edit.variable)`: 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 _construct_graph_edit_model(model_type::Type, parameters::NamedTuple) + names = fieldnames(model_type) + if isempty(parameters) + try + return model_type() + catch + end + end + isempty(names) && isempty(parameters) && return model_type() + values = Any[] + for name in names + haskey(parameters, name) || error("Missing constructor parameter `$name` for model `$(model_type)`.") + push!(values, parameters[name]) + end + return model_type(values...) +end + +_construct_graph_edit_model(model_type::Type, parameters::AbstractDict) = + _construct_graph_edit_model(model_type, (; (Symbol(k) => v for (k, v) in pairs(parameters))...)) + +function _insert_model_entry(entry::Tuple, model) + items = Any[] + inserted = false + for item in entry + if !inserted && item isa Status + push!(items, model) + inserted = true + end + push!(items, item) + end + inserted || push!(items, model) + return tuple(items...) +end + +_insert_model_entry(entry, model) = _insert_model_entry((entry,), model) + +function _remove_model_entry(entry::Tuple, process_name::Symbol, found::Base.RefValue{Bool}) + items = Any[] + for item in entry + if !(item isa Status) && process(model_(as_model_spec(item))) == process_name + found[] = true + continue + end + push!(items, item) + end + return tuple(items...) +end + +_remove_model_entry(entry, process_name::Symbol, found::Base.RefValue{Bool}) = + _remove_model_entry((entry,), process_name, found) + +function _replace_model_entry(entry::Tuple, process_name::Symbol, model, found::Base.RefValue{Bool}) + return tuple((_replace_model_item(item, process_name, model, found) for item in entry)...) +end + +_replace_model_entry(entry, process_name::Symbol, model, found::Base.RefValue{Bool}) = + _replace_model_item(entry, process_name, model, found) + +function _replace_model_item(item::Status, ::Symbol, model, ::Base.RefValue{Bool}) + return item +end + +function _replace_model_item(item, process_name::Symbol, model, found::Base.RefValue{Bool}) + spec = as_model_spec(item) + process(model_(spec)) == process_name || return item + found[] = true + return ModelSpec(spec; model=model) +end + +function _set_mapped_variable_entry(entry::Tuple, edit::SetMappedVariable, found::Base.RefValue{Bool}) + return tuple((_set_mapped_variable_item(item, edit, found) for item in entry)...) +end + +_set_mapped_variable_entry(entry, edit::SetMappedVariable, found::Base.RefValue{Bool}) = + _set_mapped_variable_item(entry, edit, found) + +_set_mapped_variable_item(item::Status, ::SetMappedVariable, ::Base.RefValue{Bool}) = item + +function _set_mapped_variable_item(item, edit::SetMappedVariable, 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 map `$(edit.variable)` for process `$(edit.process)` at scale `$(edit.scale)`: ", + "the variable is not declared as an input or output of `$(typeof(model_(spec)))`." + ) + found[] = true + source = edit.mode in (:multi, :multi_node, :vector) ? + [edit.source_scale => edit.source_variable] : + edit.source_scale => edit.source_variable + return ModelSpec(spec; multiscale=_set_mapping_item(spec.multiscale, edit.variable, source)) +end + +function _set_mapping_item(mapping, variable::Symbol, source) + mapped = isnothing(mapping) ? Any[] : Any[collect(mapping)...] + for i in eachindex(mapped) + item = mapped[i] + lhs = item isa Pair ? first(item) : item + lhs_var = lhs isa PreviousTimeStep ? lhs.variable : lhs + lhs_var == variable || continue + mapped[i] = lhs isa PreviousTimeStep ? PreviousTimeStep(variable) => source : variable => source + return mapped + end + push!(mapped, variable => source) + return mapped +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 @@ -411,6 +653,41 @@ function _mark_previous_timestep_mapping(mapping, variable::Symbol) return mapped end +function _unmark_previous_timestep_entry(entry::Tuple, edit::UnmarkPreviousTimeStep, found::Base.RefValue{Bool}) + return tuple((_unmark_previous_timestep_item(item, edit, found) for item in entry)...) +end + +_unmark_previous_timestep_entry(entry, edit::UnmarkPreviousTimeStep, found::Base.RefValue{Bool}) = + _unmark_previous_timestep_item(entry, edit, found) + +_unmark_previous_timestep_item(item::Status, ::UnmarkPreviousTimeStep, ::Base.RefValue{Bool}) = item + +function _unmark_previous_timestep_item(item, edit::UnmarkPreviousTimeStep, found::Base.RefValue{Bool}) + spec = as_model_spec(item) + process(model_(spec)) == edit.process || return item + found[] = true + return ModelSpec(spec; multiscale=_unmark_previous_timestep_mapping(spec.multiscale, edit.variable)) +end + +function _unmark_previous_timestep_mapping(mapping, variable::Symbol) + isnothing(mapping) && return mapping + mapped = Any[] + for item in mapping + if item isa Pair && first(item) isa PreviousTimeStep && first(item).variable == variable + source = last(item) + if source == (Symbol("") => variable) + continue + end + push!(mapped, variable => source) + elseif item isa PreviousTimeStep && item.variable == variable + continue + else + push!(mapped, item) + end + end + return mapped +end + function _graph_view_from_mapping_only(mapping::ModelMapping, diagnostics) nodes = GraphNode[] for (scale, entry) in pairs(mapping) @@ -434,7 +711,9 @@ function _graph_view_from_mapping_only(mapping::ModelMapping, diagnostics) end end scales = sort!(unique([node.scale for node in nodes]); by=string) - return DependencyGraphView(nodes, GraphEdge[], scales, any(occursin.("Cyclic", diagnostics)), String[], diagnostics) + cyclic = any(occursin.("Cyclic", diagnostics)) + cycle_nodes = cyclic ? [node.id for node in nodes] : String[] + return DependencyGraphView(nodes, GraphEdge[], scales, cyclic, cycle_nodes, String[], diagnostics) end function _graph_node(node::AbstractDependencyNode, id::String, context, node_ids) @@ -633,6 +912,7 @@ function _graph_view_dict(view::DependencyGraphView) "scales" => string.(view.scales), "cyclic" => view.cyclic, "cycleNodes" => view.cycle_nodes, + "cycleEdges" => view.cycle_edges, "diagnostics" => view.diagnostics, ) end @@ -682,45 +962,7 @@ function _edge_dict(edge::GraphEdge) ) 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 +_json(value) = replace(JSON.json(value), " "<\\/") function _graph_view_html(view::DependencyGraphView; renderer::Symbol=:react) if renderer == :react diff --git a/test/test-dependency-graph-view.jl b/test/test-dependency-graph-view.jl index da25a7dd6..3431d1c86 100644 --- a/test/test-dependency-graph-view.jl +++ b/test/test-dependency-graph-view.jl @@ -1,10 +1,16 @@ abstract type AbstractGraphViewPlantAgeModel <: PlantSimEngine.AbstractModel end abstract type AbstractGraphViewPhytomerEmissionModel <: PlantSimEngine.AbstractModel end abstract type AbstractGraphViewInitiationAgeModel <: PlantSimEngine.AbstractModel end +abstract type AbstractGraphViewParamModel <: PlantSimEngine.AbstractModel end +abstract type AbstractGraphViewCyclePlantModel <: PlantSimEngine.AbstractModel end +abstract type AbstractGraphViewCycleLeafModel <: 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 +PlantSimEngine.process_(::Type{AbstractGraphViewParamModel}) = :graph_view_param +PlantSimEngine.process_(::Type{AbstractGraphViewCyclePlantModel}) = :graph_view_cycle_plant +PlantSimEngine.process_(::Type{AbstractGraphViewCycleLeafModel}) = :graph_view_cycle_leaf struct GraphViewPlantAgeModel <: AbstractGraphViewPlantAgeModel end @@ -25,6 +31,35 @@ end PlantSimEngine.inputs_(::GraphViewInitiationAgeModel) = (plant_age=-Inf,) PlantSimEngine.outputs_(::GraphViewInitiationAgeModel) = (initiation_age=-Inf,) +struct GraphViewParamModel{T} <: AbstractGraphViewParamModel + a::T + b::T +end + +PlantSimEngine.inputs_(::GraphViewParamModel) = (x=-Inf,) +PlantSimEngine.outputs_(::GraphViewParamModel) = (y=-Inf,) + +struct GraphViewDefaultParamModel <: AbstractGraphViewParamModel + alpha::Float64 + mode::Symbol +end + +GraphViewDefaultParamModel() = GraphViewDefaultParamModel(1.5, :fast) +PlantSimEngine.inputs_(::GraphViewDefaultParamModel) = (x=-Inf,) +PlantSimEngine.outputs_(::GraphViewDefaultParamModel) = (z=-Inf,) + +struct GraphViewCyclePlantModel <: AbstractGraphViewCyclePlantModel +end + +PlantSimEngine.inputs_(::GraphViewCyclePlantModel) = (y=-Inf,) +PlantSimEngine.outputs_(::GraphViewCyclePlantModel) = (x=-Inf,) + +struct GraphViewCycleLeafModel <: AbstractGraphViewCycleLeafModel +end + +PlantSimEngine.inputs_(::GraphViewCycleLeafModel) = (x=-Inf,) +PlantSimEngine.outputs_(::GraphViewCycleLeafModel) = (y=-Inf,) + @testset "Dependency graph view" begin mapping = ModelMapping( ToyLAIModel(), @@ -118,4 +153,57 @@ PlantSimEngine.outputs_(::GraphViewInitiationAgeModel) = (initiation_age=-Inf,) @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) + + @test AbstractGraphViewParamModel in available_processes() + @test GraphViewParamModel in available_models(:graph_view_param) + descriptor = model_constructor_descriptor(GraphViewParamModel) + fields = descriptor["fields"] + @test length(fields) == 2 + @test fields[1]["typeParameter"] == "T" + @test fields[2]["typeParameter"] == "T" + @test descriptor["parameterGroups"]["T"] == ["a", "b"] + @test fields[1]["inferredChoice"] == "float" + + default_descriptor = model_constructor_descriptor(GraphViewDefaultParamModel) + default_fields = default_descriptor["fields"] + @test default_descriptor["hasZeroArgConstructor"] + @test default_fields[1]["default"] == 1.5 + @test default_fields[1]["inferredChoice"] == "float" + @test default_fields[2]["default"] == ":fast" + @test default_fields[2]["inferredChoice"] == "symbol" + + add_mapping = ModelMapping(:Plant => (GraphViewPlantAgeModel(), Status(day=1.0))) + added_mapping = apply_graph_edit(add_mapping, AddModel(:Plant, GraphViewPhytomerEmissionModel, NamedTuple())) + @test any(m -> process(m) == :graph_view_phytomer_emission, PlantSimEngine.get_models(added_mapping[:Plant])) + @test_throws "already exists" apply_graph_edit(added_mapping, AddModel(:Plant, GraphViewPhytomerEmissionModel, NamedTuple())) + removed_mapping = apply_graph_edit(added_mapping, RemoveModel(:Plant, :graph_view_phytomer_emission)) + @test !any(m -> process(m) == :graph_view_phytomer_emission, PlantSimEngine.get_models(removed_mapping[:Plant])) + replaced_mapping = apply_graph_edit(add_mapping, ReplaceModel(:Plant, :graph_view_plant_age, GraphViewPlantAgeModel, NamedTuple())) + @test only(PlantSimEngine.get_models(replaced_mapping[:Plant])) isa GraphViewPlantAgeModel + + mapped_edit_mapping = apply_graph_edit( + hard_mapped_mapping, + SetMappedVariable(:Phytomer, :graph_view_initiation_age, :plant_age, :Plant, :plant_age, :single), + ) + mapped_spec = PlantSimEngine.parse_model_specs(mapped_edit_mapping[:Phytomer])[:graph_view_initiation_age] + @test first(PlantSimEngine.mapped_variables_(mapped_spec)) == (:plant_age => (:Plant => :plant_age)) + + unmarked_mapping = apply_graph_edit(edited_mapping, UnmarkPreviousTimeStep(:Leaf, :carbon_assimilation, :soil_water_content)) + unmarked_view = graph_view(unmarked_mapping) + @test any( + edge -> edge.source_variable == :soil_water_content && + edge.target_variable == :soil_water_content && + edge.source != edge.target, + unmarked_view.edges, + ) + + cyclic_mapping = ModelMapping( + :Plant => MultiScaleModel(GraphViewCyclePlantModel(), [:y => [:Leaf]]), + :Leaf => MultiScaleModel(GraphViewCycleLeafModel(), [:x => :Plant]), + ) + @test_throws "Cyclic dependency detected" dep(cyclic_mapping) + cyclic_view = graph_view(cyclic_mapping) + @test cyclic_view.cyclic + @test !isempty(cyclic_view.cycle_nodes) + @test occursin("Cyclic dependency detected", join(cyclic_view.diagnostics, "\n")) end From 4a13d75c51d1d26b1f11b687a9d7bdb2f875b830 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Vezy?= Date: Tue, 5 May 2026 07:27:07 +0200 Subject: [PATCH 16/39] Move graph editor into a PlantSimEngine extension --- Project.toml | 7 ++++++ .../PlantSimEngineGraphEditorExt.jl | 14 ++++++----- lib/PlantSimEngineGraphEditor/Project.toml | 16 ------------ src/PlantSimEngine.jl | 2 ++ src/visualization/graph_editor_api.jl | 25 +++++++++++++++++++ 5 files changed, 42 insertions(+), 22 deletions(-) rename lib/PlantSimEngineGraphEditor/src/PlantSimEngineGraphEditor.jl => ext/PlantSimEngineGraphEditorExt.jl (94%) delete mode 100644 lib/PlantSimEngineGraphEditor/Project.toml create mode 100644 src/visualization/graph_editor_api.jl diff --git a/Project.toml b/Project.toml index a4d1a1921..84eae66b4 100644 --- a/Project.toml +++ b/Project.toml @@ -20,6 +20,12 @@ Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" Tables = "bd369af6-aec1-5ad0-b16a-f7cc5008161c" Term = "22787eb5-b846-44ae-b979-8e399b8463ab" +[weakdeps] +HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3" + +[extensions] +PlantSimEngineGraphEditorExt = "HTTP" + [compat] AbstractTrees = "0.4" CSV = "0.10" @@ -27,6 +33,7 @@ DataAPI = "1.15" DataFrames = "1" Dates = "1.10" FLoops = "0.2" +HTTP = "1" InteractiveUtils = "1.10" JSON = "1" Markdown = "1.10" diff --git a/lib/PlantSimEngineGraphEditor/src/PlantSimEngineGraphEditor.jl b/ext/PlantSimEngineGraphEditorExt.jl similarity index 94% rename from lib/PlantSimEngineGraphEditor/src/PlantSimEngineGraphEditor.jl rename to ext/PlantSimEngineGraphEditorExt.jl index 62811d49c..fa29a9b22 100644 --- a/lib/PlantSimEngineGraphEditor/src/PlantSimEngineGraphEditor.jl +++ b/ext/PlantSimEngineGraphEditorExt.jl @@ -1,12 +1,11 @@ -module PlantSimEngineGraphEditor +module PlantSimEngineGraphEditorExt import HTTP import JSON import PlantSimEngine +import PlantSimEngine: edit_graph, current_mapping, apply_edit!, undo!, redo! -export GraphEditorSession, edit_graph, current_mapping, apply_edit!, undo!, redo!, close - -mutable struct GraphEditorSession{M,G,S} +mutable struct GraphEditorSession{M,G,S} <: PlantSimEngine.AbstractGraphEditorSession mapping::M mtg::G history::Vector{M} @@ -25,8 +24,11 @@ Base.close(session::GraphEditorSession) = close(session.server) Start a local graph editor session. The returned session owns the current `ModelMapping`; call `current_mapping(session)` to recover the edited mapping. + +This method is provided by the `PlantSimEngineGraphEditorExt` package extension. +Load `HTTP` in the active session to make it available. """ -function edit_graph(mapping; mtg=nothing, host::AbstractString="127.0.0.1", port::Integer=8765) +function edit_graph(mapping::PlantSimEngine.ModelMapping; mtg=nothing, host::AbstractString="127.0.0.1", port::Integer=8765) session_ref = Ref{Any}() handler = http -> _handle_http(session_ref[], http) server = HTTP.listen!(handler, host, port; listenany=true, verbose=false) @@ -280,6 +282,6 @@ function _react_editor_html(session::GraphEditorSession) """ end -_frontend_dist_dir() = normpath(joinpath(@__DIR__, "..", "..", "..", "frontend", "dist")) +_frontend_dist_dir() = normpath(joinpath(@__DIR__, "..", "frontend", "dist")) end diff --git a/lib/PlantSimEngineGraphEditor/Project.toml b/lib/PlantSimEngineGraphEditor/Project.toml deleted file mode 100644 index 749b7ad69..000000000 --- a/lib/PlantSimEngineGraphEditor/Project.toml +++ /dev/null @@ -1,16 +0,0 @@ -name = "PlantSimEngineGraphEditor" -uuid = "7cf0a04e-a9b1-4d1f-a906-d86a8c54d709" -version = "0.1.0" -authors = ["Rémi Vezy "] - -[deps] -HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3" -JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" -PlantSimEngine = "9a576370-710b-4269-adf9-4f603a9c6423" - -[sources] -PlantSimEngine = {path = "../.."} - -[compat] -HTTP = "1" -JSON = "1" diff --git a/src/PlantSimEngine.jl b/src/PlantSimEngine.jl index e12f7fb7d..198c9b63f 100644 --- a/src/PlantSimEngine.jl +++ b/src/PlantSimEngine.jl @@ -112,6 +112,7 @@ include("run.jl") # Dependency graph visualisation: include("visualization/dependency_graph_view.jl") +include("visualization/graph_editor_api.jl") # Fitting include("evaluation/fit.jl") @@ -146,6 +147,7 @@ export run! export fit export GraphPort, GraphNode, GraphEdge, DependencyGraphView export graph_view, graph_view_json, write_graph_view, compile_graph_view +export AbstractGraphEditorSession, edit_graph, current_mapping, apply_edit!, undo!, redo! export available_processes, available_models, model_descriptor, model_constructor_descriptor export AbstractGraphEdit, AddModel, RemoveModel, ReplaceModel, SetMappedVariable, MarkPreviousTimeStep, UnmarkPreviousTimeStep, apply_graph_edit diff --git a/src/visualization/graph_editor_api.jl b/src/visualization/graph_editor_api.jl new file mode 100644 index 000000000..b1e85f385 --- /dev/null +++ b/src/visualization/graph_editor_api.jl @@ -0,0 +1,25 @@ +abstract type AbstractGraphEditorSession end + +function _graph_editor_missing_http() + throw(ArgumentError("Interactive graph editing requires HTTP.jl. Load it with `using HTTP` before calling `edit_graph`.")) +end + +function edit_graph(args...; kwargs...) + _graph_editor_missing_http() +end + +function current_mapping(session::AbstractGraphEditorSession) + _graph_editor_missing_http() +end + +function apply_edit!(session::AbstractGraphEditorSession, edit::AbstractGraphEdit) + _graph_editor_missing_http() +end + +function undo!(session::AbstractGraphEditorSession) + _graph_editor_missing_http() +end + +function redo!(session::AbstractGraphEditorSession) + _graph_editor_missing_http() +end From 936c5be02370af45cd531d79fc940a04a4f4d314 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Vezy?= Date: Tue, 5 May 2026 10:28:36 +0200 Subject: [PATCH 17/39] Add docs and more tests for graph editor --- docs/make.jl | 1 + .../graph_visualization_editor.md | 51 ++++++++++++++++++ ext/PlantSimEngineGraphEditorExt.jl | 6 +-- src/visualization/dependency_graph_view.jl | 9 ++++ src/visualization/graph_editor_api.jl | 40 ++++++++++++++ test/Project.toml | 1 + test/runtests.jl | 4 ++ test/test-graph-editor-extension.jl | 52 +++++++++++++++++++ 8 files changed, 161 insertions(+), 3 deletions(-) create mode 100644 docs/src/step_by_step/graph_visualization_editor.md create mode 100644 test/test-graph-editor-extension.jl diff --git a/docs/make.jl b/docs/make.jl index 57639391f..b7f72f37c 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -34,6 +34,7 @@ makedocs(; "Detailed first simulation" => "./step_by_step/detailed_first_example.md", "Coupling" => "./step_by_step/simple_model_coupling.md", "Model Switching" => "./step_by_step/model_switching.md", + "Graph visualization and editing" => "./step_by_step/graph_visualization_editor.md", "Quick examples" => "./step_by_step/quick_and_dirty_examples.md", "Implementing a process" => "./step_by_step/implement_a_process.md", "Implementing a model" => "./step_by_step/implement_a_model.md", diff --git a/docs/src/step_by_step/graph_visualization_editor.md b/docs/src/step_by_step/graph_visualization_editor.md new file mode 100644 index 000000000..9aeaabc86 --- /dev/null +++ b/docs/src/step_by_step/graph_visualization_editor.md @@ -0,0 +1,51 @@ +# Graph visualization and editing + +`PlantSimEngine` can export a dependency graph view from a [`ModelMapping`](@ref). The static viewer is available from the core package and does not require any web server dependency. + +```julia +using PlantSimEngine +using PlantSimEngine.Examples + +mapping = ModelMapping( + ToyLAIModel(), + Beer(0.5); + status=(TT_cu=1.0:200.0,), +) + +write_graph_view("dependency_graph.html", mapping) +``` + +The same serialization path is used by the interactive editor. The editor is implemented as a Julia package extension, so the HTTP/WebSocket stack is loaded only when [`HTTP.jl`](https://github.com/JuliaWeb/HTTP.jl) is available and loaded in the active session. + +```julia +using PlantSimEngine +using PlantSimEngine.Examples +using HTTP + +mapping = ModelMapping( + ToyLAIModel(), + Beer(0.5); + status=(TT_cu=1.0:200.0,), +) + +session = edit_graph(mapping) +session.url +``` + +Open `session.url` in a browser to use the live editor. The browser sends edit commands to Julia over a WebSocket. Julia remains the source of truth: it applies the edit, rebuilds the [`ModelMapping`](@ref), recompiles graph diagnostics, and sends the updated graph back to the browser. + +Use [`current_mapping`](@ref) to recover the latest mapping from the session: + +```julia +edited_mapping = current_mapping(session) +close(session) +``` + +The editor extension currently supports the same edit operations as the Julia API: + +- add, remove, and replace a model at a scale; +- set a mapped input variable; +- mark or unmark a variable as [`PreviousTimeStep`](@ref); +- undo and redo edits inside the live session. + +If `HTTP` is not loaded, `edit_graph(mapping)` throws an error explaining that the interactive editor requires `using HTTP`. Static graph visualization through [`write_graph_view`](@ref), `graph_view`, and [`graph_view_json`](@ref) remains available without loading `HTTP`. diff --git a/ext/PlantSimEngineGraphEditorExt.jl b/ext/PlantSimEngineGraphEditorExt.jl index fa29a9b22..8bf6b641f 100644 --- a/ext/PlantSimEngineGraphEditorExt.jl +++ b/ext/PlantSimEngineGraphEditorExt.jl @@ -182,9 +182,9 @@ function _parse_parameter_value(value) value isa AbstractDict || return value choice = Symbol(get(value, "type", "julia")) raw = get(value, "value", nothing) - choice == :float && return Float64(raw) - choice == :integer && return Int(raw) - choice == :boolean && return Bool(raw) + choice == :float && return parse(Float64, raw) + choice == :integer && return parse(Int, raw) + choice == :boolean && return parse(Bool, raw) choice == :symbol && return Symbol(raw) choice == :string && return String(raw) choice == :nothing && return nothing diff --git a/src/visualization/dependency_graph_view.jl b/src/visualization/dependency_graph_view.jl index 5fdb24857..b9423a614 100644 --- a/src/visualization/dependency_graph_view.jl +++ b/src/visualization/dependency_graph_view.jl @@ -67,6 +67,15 @@ struct DependencyGraphView diagnostics::Vector{String} end +""" + AbstractGraphEdit + +Abstract supertype for declarative dependency-graph editor commands. + +Concrete edits such as `AddModel`, `RemoveModel`, `ReplaceModel`, +`SetMappedVariable`, `MarkPreviousTimeStep`, and `UnmarkPreviousTimeStep` are +applied with `apply_graph_edit`. +""" abstract type AbstractGraphEdit end struct AddModel{P} <: AbstractGraphEdit diff --git a/src/visualization/graph_editor_api.jl b/src/visualization/graph_editor_api.jl index b1e85f385..cfb0d67fd 100644 --- a/src/visualization/graph_editor_api.jl +++ b/src/visualization/graph_editor_api.jl @@ -4,22 +4,62 @@ function _graph_editor_missing_http() throw(ArgumentError("Interactive graph editing requires HTTP.jl. Load it with `using HTTP` before calling `edit_graph`.")) end +""" + edit_graph(mapping; kwargs...) + +Start an interactive graph editor session for a [`ModelMapping`](@ref). + +The HTTP-backed method is provided by the `PlantSimEngineGraphEditorExt` +package extension. Load `HTTP` in the active Julia session before calling this +function: + +```julia +using PlantSimEngine +using HTTP + +session = edit_graph(mapping) +``` +""" function edit_graph(args...; kwargs...) _graph_editor_missing_http() end +""" + current_mapping(session) + +Return the current [`ModelMapping`](@ref) stored by an interactive graph editor +session. +""" function current_mapping(session::AbstractGraphEditorSession) _graph_editor_missing_http() end +""" + apply_edit!(session, edit) + +Apply an [`AbstractGraphEdit`](@ref) to an interactive graph editor session and +return the rebuilt [`ModelMapping`](@ref). +""" function apply_edit!(session::AbstractGraphEditorSession, edit::AbstractGraphEdit) _graph_editor_missing_http() end +""" + undo!(session) + +Undo the latest edit in an interactive graph editor session and return the +current [`ModelMapping`](@ref). +""" function undo!(session::AbstractGraphEditorSession) _graph_editor_missing_http() end +""" + redo!(session) + +Redo the latest undone edit in an interactive graph editor session and return +the current [`ModelMapping`](@ref). +""" function redo!(session::AbstractGraphEditorSession) _graph_editor_missing_http() end diff --git a/test/Project.toml b/test/Project.toml index f806effc7..dde1bb0e6 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -5,6 +5,7 @@ CSV = "336ed68f-0bac-5ca0-87d4-7b16caf5d00b" DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" +HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3" MultiScaleTreeGraph = "dd4a991b-8a45-4075-bede-262ee62d5583" PlantMeteo = "4630fe09-e0fb-4da5-a846-781cb73437b6" PlantSimEngine = "9a576370-710b-4269-adf9-4f603a9c6423" diff --git a/test/runtests.jl b/test/runtests.jl index df596d700..39c43df04 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -74,6 +74,10 @@ include("helper-functions.jl") include("test-dependency-graph-view.jl") end + @testset "Dependency graph editor extension" begin + include("test-graph-editor-extension.jl") + end + @testset "MTG with multiscale mapping" begin include("test-mtg-multiscale.jl") include("test-mtg-dynamic.jl") diff --git a/test/test-graph-editor-extension.jl b/test/test-graph-editor-extension.jl new file mode 100644 index 000000000..97e6adfc3 --- /dev/null +++ b/test/test-graph-editor-extension.jl @@ -0,0 +1,52 @@ +using HTTP + +@testset "HTTP extension loading and WebSocket edits" begin + @test Base.get_extension(PlantSimEngine, :PlantSimEngineGraphEditorExt) !== nothing + + mapping = ModelMapping( + :Leaf => ( + ToyLAIModel(), + Beer(0.5), + Status(TT_cu=1.0, LAI=2.0), + ), + ) + session = edit_graph(mapping; port=0) + + try + @test session isa AbstractGraphEditorSession + @test startswith(session.url, "http://127.0.0.1:") + @test current_mapping(session) === mapping + + state_response = HTTP.get(string(session.url, "/state")) + @test state_response.status == 200 + state = PlantSimEngine.JSON.parse(String(state_response.body)) + @test state["ok"] + @test haskey(state, "graph") + @test haskey(state, "models") + + websocket_url = replace(session.url, "http://" => "ws://") * "/ws" + HTTP.WebSockets.open(websocket_url) do ws + initial = PlantSimEngine.JSON.parse(String(HTTP.WebSockets.receive(ws))) + @test initial["ok"] + @test haskey(initial, "graph") + + command = PlantSimEngine.JSON.json(Dict( + "action" => "edit", + "kind" => "remove_model", + "scale" => "Leaf", + "process" => "light_interception", + )) + HTTP.WebSockets.send(ws, command) + + response = PlantSimEngine.JSON.parse(String(HTTP.WebSockets.receive(ws))) + @test response["ok"] + @test response["canUndo"] + @test !any( + model -> process(model) == :light_interception, + PlantSimEngine.get_models(current_mapping(session)[:Leaf]), + ) + end + finally + close(session) + end +end From 8ca4159508f37580288da7d6fc614d40b04d5b16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Vezy?= Date: Tue, 5 May 2026 13:27:39 +0200 Subject: [PATCH 18/39] Add browser-opening graph editor session UI --- .../graph_visualization_editor.md | 17 +- ext/PlantSimEngineGraphEditorExt.jl | 156 +++++++++++++- frontend/src/App.tsx | 201 +++++++++++++++--- frontend/src/styles.css | 71 ++++++- frontend/src/types.ts | 3 + src/visualization/dependency_graph_view.jl | 16 +- src/visualization/graph_editor_api.jl | 2 + test/test-graph-editor-extension.jl | 66 +++++- 8 files changed, 488 insertions(+), 44 deletions(-) diff --git a/docs/src/step_by_step/graph_visualization_editor.md b/docs/src/step_by_step/graph_visualization_editor.md index 9aeaabc86..ba0c959cf 100644 --- a/docs/src/step_by_step/graph_visualization_editor.md +++ b/docs/src/step_by_step/graph_visualization_editor.md @@ -30,9 +30,22 @@ mapping = ModelMapping( session = edit_graph(mapping) session.url +session ``` -Open `session.url` in a browser to use the live editor. The browser sends edit commands to Julia over a WebSocket. Julia remains the source of truth: it applies the edit, rebuilds the [`ModelMapping`](@ref), recompiles graph diagnostics, and sends the updated graph back to the browser. +By default, `edit_graph` opens `session.url` in the system default browser. Pass `open_browser=false` to keep the session headless, for example in scripts or tests: + +```julia +session = edit_graph(mapping; open_browser=false) +``` + +The browser sends edit commands to Julia over a WebSocket. Julia remains the source of truth: it applies the edit, rebuilds the [`ModelMapping`](@ref), recompiles graph diagnostics, and sends the updated graph back to the browser. + +To stop the HTTP/WebSocket session, run: + +```julia +close(session) +``` Use [`current_mapping`](@ref) to recover the latest mapping from the session: @@ -41,6 +54,8 @@ edited_mapping = current_mapping(session) close(session) ``` +The web editor also exposes a dedicated "Mapping code" panel. It shows the current [`ModelMapping`](@ref) as Julia code, and can write that code to a `.jl` file so it can be copied/pasted or reused in scripts. + The editor extension currently supports the same edit operations as the Julia API: - add, remove, and replace a model at a scale; diff --git a/ext/PlantSimEngineGraphEditorExt.jl b/ext/PlantSimEngineGraphEditorExt.jl index 8bf6b641f..ac2c3fb87 100644 --- a/ext/PlantSimEngineGraphEditorExt.jl +++ b/ext/PlantSimEngineGraphEditorExt.jl @@ -14,21 +14,53 @@ mutable struct GraphEditorSession{M,G,S} <: PlantSimEngine.AbstractGraphEditorSe host::String port::Int url::String + last_saved_path::Union{Nothing,String} end current_mapping(session::GraphEditorSession) = session.mapping -Base.close(session::GraphEditorSession) = close(session.server) +function Base.close(session::GraphEditorSession) + isopen(session.server) || return nothing + return HTTP.forceclose(session.server) +end + +function Base.show(io::IO, session::GraphEditorSession) + print(io, "GraphEditorSession(url=\"$(session.url)\", host=\"$(session.host)\", port=$(session.port))") +end + +function Base.show(io::IO, ::MIME"text/plain", session::GraphEditorSession) + println(io, "PlantSimEngineGraphEditorExt.GraphEditorSession") + println(io, " Open in browser: $(session.url)") + println(io, " Local state JSON: $(session.url)/state") + println(io, " Quit session: close(session)") + println(io, " Current mapping: current_mapping(session)") + println(io, " Save mapping code: use the \"Mapping code\" panel in the web editor") +end + +current_mapping_code(session::GraphEditorSession) = _model_mapping_to_julia(session.mapping) """ - edit_graph(mapping; mtg=nothing, host="127.0.0.1", port=8765) + edit_graph(mapping; mtg=nothing, host="127.0.0.1", port=8765, open_browser=true) Start a local graph editor session. The returned session owns the current `ModelMapping`; call `current_mapping(session)` to recover the edited mapping. +Single-scale mappings are automatically normalized to multiscale form at the :Default scale. +By default, the session URL is opened with the system default browser. Pass +`open_browser=false` to disable this, for example in scripts or tests. + This method is provided by the `PlantSimEngineGraphEditorExt` package extension. Load `HTTP` in the active session to make it available. """ -function edit_graph(mapping::PlantSimEngine.ModelMapping; mtg=nothing, host::AbstractString="127.0.0.1", port::Integer=8765) +function edit_graph( + mapping::PlantSimEngine.ModelMapping; + mtg=nothing, + host::AbstractString="127.0.0.1", + port::Integer=8765, + open_browser::Bool=true, +) + # Normalize single-scale to multiscale form for uniform handling downstream + mapping = _normalize_to_multiscale(mapping) + session_ref = Ref{Any}() handler = http -> _handle_http(session_ref[], http) server = HTTP.listen!(handler, host, port; listenany=true, verbose=false) @@ -42,11 +74,32 @@ function edit_graph(mapping::PlantSimEngine.ModelMapping; mtg=nothing, host::Abs String(host), actual_port, "http://$(host):$(actual_port)", + nothing, ) session_ref[] = session + open_browser && _open_in_default_browser(session.url) return session end +function _open_in_default_browser(url::AbstractString) + try + if Sys.isapple() + run(`open $url`) + elseif Sys.iswindows() + run(`cmd /c start "" $url`) + elseif !isnothing(Sys.which("xdg-open")) + run(`xdg-open $url`) + else + @warn "Could not open graph editor automatically because no supported default-browser command was found." url + return false + end + return true + catch err + @warn "Could not open graph editor automatically. Open the session URL manually." url exception = (err, catch_backtrace()) + return false + end +end + function apply_edit!(session::GraphEditorSession, edit::PlantSimEngine.AbstractGraphEdit) push!(session.history, session.mapping) empty!(session.future) @@ -95,6 +148,22 @@ function _handle_http(session::GraphEditorSession, http::HTTP.Stream) return nothing end +""" + _normalize_to_multiscale(mapping::PlantSimEngine.ModelMapping{PlantSimEngine.SingleScale}) + +Convert a single-scale ModelMapping to multiscale form at the :Default scale. +This ensures all downstream logic only deals with MultiScale mappings. +""" +function _normalize_to_multiscale(mapping::PlantSimEngine.ModelMapping{PlantSimEngine.SingleScale}) + entry = mapping[:Default] # Returns tuple of (models..., status) + return PlantSimEngine.ModelMapping(:Default => entry; check=true, type_promotion=PlantSimEngine.type_promotion(mapping)) +end + +function _normalize_to_multiscale(mapping::PlantSimEngine.ModelMapping{PlantSimEngine.MultiScale}) + # Already multiscale, return as is + return mapping +end + function _handle_websocket(session::GraphEditorSession, ws) HTTP.WebSockets.send(ws, _state_json(session)) try @@ -118,6 +187,9 @@ function _handle_command!(session::GraphEditorSession, command) elseif action == "edit" edit = _edit_from_command(command) apply_edit!(session, edit) + elseif action == "write_mapping_code" + raw_path = get(command, "path", "") + _write_mapping_code!(session, String(raw_path)) else error("Unsupported graph editor command action `$action`.") end @@ -197,11 +269,14 @@ function _state_payload(session::GraphEditorSession; ok::Bool=true, diagnostics: append!(graph["diagnostics"], diagnostics) return Dict( "ok" => ok, + "diagnostics" => diagnostics, "graph" => graph, "models" => [PlantSimEngine.model_descriptor(T) for T in PlantSimEngine.available_models()], "canUndo" => !isempty(session.history), "canRedo" => !isempty(session.future), "url" => session.url, + "mappingCode" => current_mapping_code(session), + "lastSavedPath" => session.last_saved_path, ) end @@ -284,4 +359,79 @@ end _frontend_dist_dir() = normpath(joinpath(@__DIR__, "..", "frontend", "dist")) +function _write_mapping_code!(session::GraphEditorSession, raw_path::AbstractString) + path = strip(String(raw_path)) + isempty(path) && error("The output path is empty. Provide a .jl file path.") + full_path = isabspath(path) ? normpath(path) : normpath(joinpath(pwd(), path)) + mkpath(dirname(full_path)) + write(full_path, current_mapping_code(session) * "\n") + session.last_saved_path = full_path + return full_path +end + +function _model_mapping_to_julia(mapping::PlantSimEngine.ModelMapping) + io = IOBuffer() + println(io, "mapping = ModelMapping(") + for scale in keys(mapping) + println(io, " :$(scale) => (") + for item in _scale_items(mapping[scale]) + println(io, " $(_mapping_item_to_code(item)),") + end + println(io, " ),") + end + print(io, ")") + return String(take!(io)) +end + +_scale_items(entry) = entry isa Tuple ? entry : (entry,) + +function _mapping_item_to_code(item) + if item isa PlantSimEngine.MultiScaleModel + model_code = repr(PlantSimEngine.model_(item)) + mapped_code = _mapped_variables_to_code(PlantSimEngine.mapped_variables_(item)) + return "MultiScaleModel(model=$(model_code), mapped_variables=$(mapped_code))" + end + return repr(item) +end + +function _mapped_variables_to_code(mapped_variables) + isempty(mapped_variables) && return "[]" + return "[" * join((_mapped_variable_to_code(i) for i in mapped_variables), ", ") * "]" +end + +function _mapped_variable_to_code(mapping) + lhs = first(mapping) + rhs = last(mapping) + lhs_code = _mapped_lhs_to_code(lhs) + variable = _mapped_variable_symbol(lhs) + rhs_code = _mapped_rhs_to_code(rhs, variable) + return "$(lhs_code) => $(rhs_code)" +end + +_mapped_variable_symbol(variable::Symbol) = variable +_mapped_variable_symbol(variable::PlantSimEngine.PreviousTimeStep) = variable.variable + +_mapped_lhs_to_code(variable::Symbol) = string(":", variable) +_mapped_lhs_to_code(variable::PlantSimEngine.PreviousTimeStep) = "PreviousTimeStep(:$(variable.variable))" + +function _mapped_rhs_to_code(rhs::Pair{Symbol,Symbol}, variable::Symbol) + source_scale = first(rhs) + source_variable = last(rhs) + if source_scale == Symbol("") + return "(Symbol(\"\") => :$(source_variable))" + end + if source_variable == variable + return ":$(source_scale)" + end + return "(:$(source_scale) => :$(source_variable))" +end + +function _mapped_rhs_to_code(rhs::AbstractVector{<:Pair{Symbol,Symbol}}, variable::Symbol) + compact = all(last(i) == variable for i in rhs) + if compact + return "[" * join((":" * string(first(i)) for i in rhs), ", ") * "]" + end + return "[" * join(("(:$(first(i)) => :$(last(i)))" for i in rhs), ", ") * "]" +end + end diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 351bdcd57..5d7257c94 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -36,6 +36,7 @@ import "./styles.css"; type EdgeFilterKey = "dataFlow" | "mapped" | "callStack"; type EdgeFilters = Record; type FocusMode = "none" | "upstream" | "downstream" | "neighborhood"; +type SidePanel = "inspector" | "add_model" | "mapping_code" | null; type SearchResult = { id: string; @@ -108,6 +109,12 @@ export default function App() { const [editorConnected, setEditorConnected] = useState(false); const [canUndo, setCanUndo] = useState(false); const [canRedo, setCanRedo] = useState(false); + const [activePanel, setActivePanel] = useState("inspector"); + const [mappingCode, setMappingCode] = useState(""); + const [lastSavedPath, setLastSavedPath] = useState(null); + const [editorFeedback, setEditorFeedback] = useState<{ kind: "error" | "info"; text: string } | null>(null); + const [savePath, setSavePath] = useState("mapping.generated.jl"); + const [customScales, setCustomScales] = useState([]); const [selected, setSelected] = useState(null); const [activePort, setActivePort] = useState(null); const [showRequiredPanel, setShowRequiredPanel] = useState(false); @@ -134,6 +141,10 @@ export default function App() { const actionableWarningItems = useMemo(() => warningItems.filter((item) => item.severity !== "info"), [warningItems]); const searchResults = useMemo(() => deriveSearchResults(graph, searchQuery), [graph, searchQuery]); const visibleNodeData = useMemo(() => graph.nodes.filter((node) => !collapsedScales.has(node.scale)), [collapsedScales, graph.nodes]); + const editorScales = useMemo(() => { + const merged = [...graph.scales, ...customScales]; + return [...new Set(merged)]; + }, [customScales, graph.scales]); const visibleNodeIds = useMemo(() => new Set(visibleNodeData.map((node) => node.id)), [visibleNodeData]); const visibleEdgeData = useMemo(() => graph.edges.filter((edge) => ( edgeMatchesFilters(edge, edgeFilters) && @@ -153,23 +164,52 @@ export default function App() { const socket = new WebSocket(config.websocketUrl); setEditorSocket(socket); - socket.addEventListener("open", () => setEditorConnected(true)); - socket.addEventListener("close", () => setEditorConnected(false)); + socket.addEventListener("open", () => { + setEditorConnected(true); + setEditorFeedback(null); + }); + socket.addEventListener("close", () => { + setEditorConnected(false); + setEditorFeedback({ kind: "error", text: "Graph editor connection closed. Refresh the page or restart the Julia session." }); + }); socket.addEventListener("message", (event) => { const payload = JSON.parse(event.data) as GraphEditorState; if (payload.graph) setGraph(payload.graph); if (payload.models) setEditorModels(payload.models); + if (typeof payload.mappingCode === "string") setMappingCode(payload.mappingCode); + if (typeof payload.lastSavedPath === "string") setLastSavedPath(payload.lastSavedPath); setCanUndo(Boolean(payload.canUndo)); setCanRedo(Boolean(payload.canRedo)); + if (payload.ok === false) { + const message = payload.diagnostics?.[0] ?? "Graph editor command failed."; + setEditorFeedback({ kind: "error", text: message }); + } else if (payload.diagnostics?.length) { + setEditorFeedback({ kind: "info", text: payload.diagnostics[0] }); + } else { + setEditorFeedback(null); + } }); return () => socket.close(); }, []); const sendEditorCommand = useCallback((command: Record) => { - if (!editorSocket || editorSocket.readyState !== WebSocket.OPEN) return; + if (!editorSocket || editorSocket.readyState !== WebSocket.OPEN) { + setEditorFeedback({ kind: "error", text: "Graph editor is offline; command was not sent." }); + return; + } editorSocket.send(JSON.stringify(command)); }, [editorSocket]); + const togglePanel = useCallback((panel: Exclude) => { + setActivePanel((current) => current === panel ? null : panel); + }, []); + + const addCustomScale = useCallback((rawScale: string) => { + const scale = rawScale.trim(); + if (!scale) return; + setCustomScales((current) => current.includes(scale) || graph.scales.includes(scale) ? current : [...current, scale]); + }, [graph.scales]); + useEffect(() => { const nextNodes = visibleNodeData.map((node) => ({ id: node.id, @@ -405,6 +445,13 @@ export default function App() {
+ +
+ + + +
+ {editorSocket && (
{editorConnected ? "live" : "offline"} @@ -414,6 +461,12 @@ export default function App() { )}
+ {editorFeedback && ( +
+ {editorFeedback.text} +
+ )} + @@ -462,33 +515,67 @@ export default function App() { - + {activePanel && ( + + )} ); } @@ -747,19 +834,57 @@ function Row({ label, value }: { label: string; value: string }) { return
{label}{value}
; } +function MappingCodePanel({ + code, + savePath, + lastSavedPath, + onSavePathChange, + onSave, +}: { + code: string; + savePath: string; + lastSavedPath: string | null; + onSavePathChange: (path: string) => void; + onSave: () => void; +}) { + const copyCode = useCallback(async () => { + if (!code) return; + await navigator.clipboard.writeText(code); + }, [code]); + + return ( +
+
+ Current Julia mapping + +
+