diff --git a/.github/workflows/Benchmarks.yml b/.github/workflows/Benchmarks.yml index ab6623ff7..381e2742d 100644 --- a/.github/workflows/Benchmarks.yml +++ b/.github/workflows/Benchmarks.yml @@ -7,8 +7,7 @@ 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: @@ -18,11 +17,9 @@ jobs: - "1" os: - ubuntu-latest - arch: - - x64 steps: - uses: MilesCranmer/AirspeedVelocity.jl@action-v1 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,https://github.com/VEZY/PlantBiophysics.jl \ No newline at end of file diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index fdfd8b9b4..80cdee1e0 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -75,3 +75,53 @@ jobs: using PlantSimEngine DocMeta.setdocmeta!(PlantSimEngine, :DocTestSetup, :(using PlantSimEngine); recursive=true) doctest(PlantSimEngine) + graph-editor-e2e: + name: Graph editor E2E + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + actions: write + contents: read + steps: + - uses: actions/checkout@v6 + - uses: julia-actions/setup-julia@v3 + with: + version: "1" + - uses: julia-actions/cache@v3 + - name: Configure test environment + shell: julia --project=test --color=yes {0} + run: | + using Pkg + Pkg.develop(PackageSpec(path=pwd())) + Pkg.instantiate() + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: frontend/package-lock.json + - name: Install frontend dependencies + working-directory: frontend + run: npm ci + - name: Run frontend unit tests + working-directory: frontend + run: npm test + - name: Typecheck graph editor frontend + working-directory: frontend + run: npm run typecheck + - name: Build graph editor frontend + working-directory: frontend + run: npm run build + - name: Install Playwright browser + working-directory: frontend + run: npx playwright install --with-deps chromium + - name: Run graph editor E2E tests + working-directory: frontend + run: npm run test:e2e + - name: Upload Playwright artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: graph-editor-playwright-artifacts + path: | + frontend/playwright-report/ + frontend/test-results/ diff --git a/.gitignore b/.gitignore index f81ea322b..a7d1af9c5 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ docs/Manifest.toml test/Manifest.toml docs/build/ benchmark/Manifest.toml -frontend \ No newline at end of file +frontend/node_modules/ +docs/src/www/simple_dependency_graph.html diff --git a/Project.toml b/Project.toml index 7db8b1067..e9e132255 100644 --- a/Project.toml +++ b/Project.toml @@ -10,14 +10,23 @@ 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" +Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" SHA = "ea8e919c-243c-51af-8825-aaa63cd721ce" 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" @@ -25,9 +34,13 @@ DataAPI = "1.15" DataFrames = "1" Dates = "1.10" FLoops = "0.2" +HTTP = "1" +InteractiveUtils = "1.10" +JSON = "1" Markdown = "1.10" MultiScaleTreeGraph = "0.15.1" PlantMeteo = "0.8.2" +Random = "1.10" SHA = "0.7.0" Statistics = "1.10" Tables = "1" diff --git a/docs/make.jl b/docs/make.jl index 7c3dd6a01..e73b539d3 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -1,6 +1,7 @@ #using Pkg #Pkg.develop("PlantSimEngine") using PlantSimEngine +using PlantSimEngine.Examples using PlantMeteo using DataFrames, CSV using Documenter @@ -8,6 +9,19 @@ using CairoMakie DocMeta.setdocmeta!(PlantSimEngine, :DocTestSetup, :(using PlantSimEngine, PlantMeteo, DataFrames, CSV, CairoMakie); recursive=true) +function build_graph_viewer_example() + mapping = ModelMapping( + ToyDegreeDaysCumulModel(), + ToyLAIModel(), + Beer(0.5), + ) + path = joinpath(@__DIR__, "src", "www", "simple_dependency_graph.html") + write_graph_view(path, mapping) + return nothing +end + +build_graph_viewer_example() + makedocs(; modules=[PlantSimEngine], authors="Rémi Vezy and contributors", @@ -30,10 +44,11 @@ makedocs(; "Key Concepts" => "./prerequisites/key_concepts.md", "Julia language basics" => "./prerequisites/julia_basics.md", ], - "Step by step - Single-scale simulations" => [ - "Detailed first simulation" => "./step_by_step/detailed_first_example.md", - "Coupling" => "./step_by_step/simple_model_coupling.md", + "Getting Started" => [ + "First simulation" => "./step_by_step/detailed_first_example.md", + "Model 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/index.md b/docs/src/index.md index 631157d41..5bfd4488a 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -49,7 +49,16 @@ Depth = 5 **Why choose PlantSimEngine?** -- **Simplicity**: Write less code, focus on your model's logic, and let the framework handle the rest. +- **Simplicity**: Write less code, focus on your model's logic, and let the framework handle the rest. You can also inspect and edit model coupling directly from the graph view: + +```@raw html + +``` + - **Modularity**: Each model component can be developed, tested, and improved independently. Assemble complex simulations by reusing pre-built, high-quality modules. - **Standardisation**: Clear, enforceable guidelines ensure that all models adhere to best practices. This built-in consistency means that once you implement a model, it works seamlessly with others in the ecosystem. - **Optimised Performance**: Don't re-invent the wheel. Delegating low-level tasks to PlantSimEngine guarantees that your model will benefit from every improvement in the framework. Enjoy faster prototyping, robust simulations, and efficient execution using Julia's high-performance capabilities. @@ -74,6 +83,7 @@ Depth = 5 ## Batteries included +- **Interactive graph editor**: Compose your model by interactively adding sub-models in a graph editor, and let the framework handle the coupling and execution. - **Automated Management**: Seamlessly handle inputs, outputs, time-steps, objects, and dependency resolution. - **Iterative Development**: Fast and interactive prototyping of models with built-in constraints to avoid errors and sensible defaults to streamline the model writing process. - **Control Your Degrees of Freedom**: Fix variables to constant values or force to observations, use simpler models for specific processes to reduce complexity. 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..839a5339a --- /dev/null +++ b/docs/src/step_by_step/graph_visualization_editor.md @@ -0,0 +1,229 @@ +# Graph visualization and editing + +`PlantSimEngine` can display the dependency graph created from a [`ModelMapping`](@ref). Use it when you want to check which model computes which variable, inspect missing initial values, explain a model pipeline in documentation, or interactively build and revise a mapping. + +There are two entry points: + +- [`write_graph_view`](@ref) writes a standalone HTML viewer. This is available from `PlantSimEngine` itself and does not start a server. +- [`edit_graph`](@ref) starts a local browser editor. This is loaded by a Julia package extension when `HTTP.jl` is available and loaded in the session. + +## Static graph viewer + +The static viewer is the right tool for documentation, reports, or any read-only inspection. It contains the graph, search, the inspector, scale filters, relationship filters, and overview/detail modes, but it does not modify the [`ModelMapping`](@ref). + +```@setup graph_viewer +using PlantSimEngine +using PlantSimEngine.Examples +``` + +Here is a small pedagogical mapping with three models: + +```@example graph_viewer +mapping = ModelMapping( + ToyDegreeDaysCumulModel(), + ToyLAIModel(), + Beer(0.5), +) +nothing # hide +``` + +The thermal time model computes `TT_cu`, the LAI model consumes `TT_cu` and computes `LAI`, and the Beer model consumes `LAI` and computes `aPPFD`. The generated viewer below is the same HTML file you would get by calling [`write_graph_view`](@ref): + +```@raw html + +``` + +To write the viewer yourself: + +```julia +using PlantSimEngine +using PlantSimEngine.Examples + +mapping = ModelMapping( + ToyDegreeDaysCumulModel(), + ToyLAIModel(), + Beer(0.5), +) + +write_graph_view("dependency_graph.html", mapping) +``` + +The returned file path is absolute, so you can print it, open it in a browser, or embed it in another documentation site. + +## Embedding a graph in package documentation + +For package documentation built with Documenter, generate the HTML file before `makedocs` and place it somewhere under `docs/src`, for example `docs/src/www/model_graph.html`: + +```julia +# docs/make.jl +using Documenter +using PlantSimEngine +using YourPackage + +mapping = YourPackage.default_mapping() +write_graph_view(joinpath(@__DIR__, "src", "www", "model_graph.html"), mapping) + +makedocs(; + # ... +) +``` + +Then embed it from a markdown page: + +```html + +``` + +Use the right relative path for the page where the iframe lives. A page in `docs/src/multiscale/` usually needs `../www/model_graph.html`; a page at the root of `docs/src/` usually needs `www/model_graph.html`. + +!!! tip + This is the same pattern used to show large package mappings, such as the XPalm dependency graph, directly inside package documentation. The viewer is static, so it works on GitHub Pages without a Julia server. + +## Interactive editor + +The interactive editor uses the same graph JSON as the static viewer, but it keeps a WebSocket connection open to Julia. Julia remains the source of truth: the browser sends edit commands, Julia applies them to the [`ModelMapping`](@ref), recompiles graph diagnostics, and sends the updated graph back to the browser. + +The editor is implemented as a package extension. Load `HTTP` before calling [`edit_graph`](@ref): + +```julia +using PlantSimEngine +using PlantSimEngine.Examples +using HTTP + +mapping = ModelMapping( + ToyLAIModel(), + Beer(0.5); + status=(TT_cu=1.0,), +) + +session = edit_graph(mapping) +session.url +session +``` + +To start from a blank graph and build a mapping from scratch, omit the mapping: + +```julia +session = edit_graph() +``` + +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 URL contains a session token and the server listens on `127.0.0.1` by default. Treat that URL as a local capability: anyone who can reach it can edit the live mapping. If you intentionally bind to another host, pass `allow_remote=true` only on a trusted network. + +To stop the HTTP/WebSocket session, run: + +```julia +close(session) +``` + +Use [`current_mapping`](@ref) to recover the latest mapping from the session: + +```julia +edited_mapping = current_mapping(session) +close(session) +``` + +!!! note + 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`. + +## What you can edit + +The editor supports the same mapping operations as the Julia graph-edit API: + +- add a model by choosing a scale, a model type, parameter values, and a rate; +- update an existing model's parameter values, scale, or rate from the inspector; +- remove a model from the inspector or from the selected model node; +- add new scales while configuring a model; +- set a mapped input variable from the inspector; +- draw a connection from an output port to an input port to create a mapping; +- map a scalar source value or a vector of values from one or several source scales; +- mark or unmark a variable as [`PreviousTimeStep`](@ref); +- use undo and redo inside the live session. + +The `+` buttons beside variables are suggestions from the current model library: + +- on an input, `+` lists models that can compute that variable as an output; +- on an output, `+` lists models that can consume that variable as an input. + +Clicking a suggested model opens the add-model panel with that model preselected, so you can set its scale, parameters, and rate before adding it. + +## Cycles + +The simulation dependency graph must be acyclic when it runs. The viewer can still compile a non-throwing graph view for cyclic or incomplete mappings, so the editor can show the problem instead of failing immediately. + +When a cycle is detected: + +- cycle edges are drawn in red; +- the cycle call-to-action asks you to choose a break point in the graph; +- clicking the scissors button on a highlighted input wraps that input in [`PreviousTimeStep`](@ref). + +This means the consumer model uses the variable value from the previous timestep, so that current-step dependency is removed and the graph can run again. + +## Mapping code and saving + +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 generated file is intentionally plain Julia: it imports the packages needed by the selected models and defines a top-level `mapping` variable: + +```julia +using PlantSimEngine +using PlantSimEngine.Examples + +mapping = ModelMapping( + # ... +) +``` + +After writing a file once, every successful edit, undo, redo, or recent-file load automatically rewrites that same file. The session also keeps a recovery autosave in the temporary directory. The top-left "Open" button can reopen a mapping script from a file path or from the recent mapping list. Use git or another version-control system for mapping scripts that matter for a simulation workflow. + +The `Status(...)` entries in generated code are rebuilt from the current mapping. Variables computed by models are omitted, even if they were present in the original status, and only variables still required for initialization are kept. + +Because the generated script only defines `mapping`, users can include it directly from a simulation script: + +```julia +include("mapping.generated.jl") +run!(mapping, meteo) +``` + +## Models from external packages + +The editor does not use a separate model registry. It discovers models from the Julia session by traversing the loaded subtype tree under [`AbstractModel`](@ref). + +This means packages become available when you load them: + +```julia +using PlantSimEngine +using PlantSimEngine.Examples +using PlantBiophysics +using HTTP + +session = edit_graph() +``` + +After `using PlantBiophysics`, the editor can list the process and model types that `PlantBiophysics` loaded into the session, provided those models follow the normal PlantSimEngine contract: + +- process abstract types are subtypes of [`AbstractModel`](@ref); +- concrete model structs are subtypes of those process types; +- models define `inputs_` and `outputs_`; +- model parameters are stored in struct fields, with an optional zero-argument constructor for default values. + +Constructor fields become parameter rows in the add-model and edit-model panels. For parametric models, fields that share the same type parameter also share the same type dropdown. The available parameter type choices are `float`, `integer`, `boolean`, `symbol`, `string`, `nothing`, and `julia`. Julia validates the final constructor call; if construction fails, the diagnostic is returned to the editor. + +You can inspect the currently visible library from Julia: + +```@example graph_viewer +available_models(:light_interception) +``` + +If a package is not loaded with `using PackageName`, its model types are not present in the Julia session and the editor cannot list them. diff --git a/examples/ToySingleToMultiScale.jl b/examples/ToySingleToMultiScale.jl index e5389c60e..af43b48a9 100644 --- a/examples/ToySingleToMultiScale.jl +++ b/examples/ToySingleToMultiScale.jl @@ -53,8 +53,7 @@ struct ToyTt_CuModel <: AbstractTt_CuModel end function PlantSimEngine.run!(::ToyTt_CuModel, models, status, meteo, constants, extra=nothing) - status.TT_cu += - meteo.TT + status.TT_cu += meteo.TT end function PlantSimEngine.inputs_(::ToyTt_CuModel) diff --git a/ext/PlantSimEngineGraphEditorExt.jl b/ext/PlantSimEngineGraphEditorExt.jl new file mode 100644 index 000000000..d3ef026e2 --- /dev/null +++ b/ext/PlantSimEngineGraphEditorExt.jl @@ -0,0 +1,892 @@ +module PlantSimEngineGraphEditorExt + +import HTTP +import JSON +import PlantSimEngine +import PlantSimEngine: edit_graph, current_mapping, apply_edit!, undo!, redo! +import Random + +mutable struct GraphEditorSession{M,G,S} <: PlantSimEngine.AbstractGraphEditorSession + mapping::M + mtg::G + history::Vector{M} + future::Vector{M} + server::S + host::String + port::Int + token::String + url::String + last_saved_path::Union{Nothing,String} + save_target_path::Union{Nothing,String} + autosave_path::Union{Nothing,String} + last_autosaved_path::Union{Nothing,String} + recent_file_path::String + recent_mapping_paths::Vector{String} +end + +current_mapping(session::GraphEditorSession) = session.mapping +function Base.close(session::GraphEditorSession) + isopen(session.server) || return nothing + return close(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: $(_state_url(session))") + println(io, " Quit session: close(session)") + println(io, " Current mapping: current_mapping(session)") + isnothing(session.save_target_path) || println(io, " Auto-saving edits to: $(session.save_target_path)") + isnothing(session.autosave_path) || println(io, " Recovery autosave: $(session.autosave_path)") + 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, open_browser=true, autosave=true, allow_remote=false) + +Start a local graph editor session. The returned session owns the current +`ModelMapping`; call `current_mapping(session)` to recover the edited mapping. +Call `edit_graph()` without a mapping to start from an empty scratch editor. + +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. +The URL includes a session token and the server is restricted to localhost +unless `allow_remote=true` is passed explicitly. +When `autosave=true`, a recovery script is written to the temporary directory. +After saving through the web editor, every successful graph edit, undo, redo, +or recent-file load rewrites the saved Julia script. + +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=_empty_editor_mapping(); + mtg=nothing, + host::AbstractString="127.0.0.1", + port::Integer=8765, + open_browser::Bool=true, + autosave::Bool=true, + autosave_path::Union{Nothing,AbstractString}=nothing, + recent_file_path::Union{Nothing,AbstractString}=nothing, + allow_remote::Bool=false, +) + if !_is_loopback_host(host) && !allow_remote + error("Graph editor sessions are limited to localhost by default. Pass `allow_remote=true` only for a trusted network environment.") + end + + # 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) + actual_port = HTTP.port(server) + token = _session_token() + session = GraphEditorSession( + mapping, + mtg, + typeof(mapping)[], + typeof(mapping)[], + server, + String(host), + actual_port, + token, + "http://$(host):$(actual_port)/?token=$(token)", + nothing, + nothing, + autosave ? _normalized_output_path(isnothing(autosave_path) ? _default_autosave_path() : autosave_path) : nothing, + nothing, + _normalized_output_path(isnothing(recent_file_path) ? _default_recent_file_path() : recent_file_path), + _load_recent_mapping_paths(isnothing(recent_file_path) ? _default_recent_file_path() : recent_file_path), + ) + session_ref[] = session + _persist_session_mapping!(session; write_save_target=false) + open_browser && _open_in_default_browser(session.url) + return session +end + +_session_token() = bytes2hex(rand(Random.RandomDevice(), UInt8, 16)) + +function _is_loopback_host(host::AbstractString) + value = lowercase(strip(String(host))) + return value in ("127.0.0.1", "localhost", "::1", "[::1]", "0:0:0:0:0:0:0:1") +end + +_base_url(session::GraphEditorSession) = "http://$(session.host):$(session.port)" +_state_url(session::GraphEditorSession) = "$(_base_url(session))/state?token=$(session.token)" +_websocket_url(session::GraphEditorSession) = "ws://$(session.host):$(session.port)/ws?token=$(session.token)" + +_empty_editor_mapping() = + PlantSimEngine._build_model_mapping(PlantSimEngine.MultiScale, Dict{Symbol,Tuple}(); validated=false) + +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) + updated_mapping = PlantSimEngine.apply_graph_edit(session.mapping, edit) + push!(session.history, session.mapping) + empty!(session.future) + session.mapping = updated_mapping + 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) + req = http.message + path = HTTP.URI(req.target).path + + if HTTP.WebSockets.isupgrade(http.message) + _authorized_request(session, req) || return _write_http_response(http, 403, ["Content-Type" => "text/plain; charset=utf-8"], "Forbidden graph editor session token.") + _authorized_origin(session, req) || return _write_http_response(http, 403, ["Content-Type" => "text/plain; charset=utf-8"], "Forbidden graph editor websocket origin.") + return HTTP.WebSockets.upgrade(http) do ws + _handle_websocket(session, ws) + end + end + + response = if path == "/" || path == "/index.html" || path == "/state" + _authorized_request(session, req) || return _write_http_response(http, 403, ["Content-Type" => "text/plain; charset=utf-8"], "Forbidden graph editor session token.") + if path == "/state" + (200, ["Content-Type" => "application/json"], _state_json(session)) + else + (200, ["Content-Type" => "text/html; charset=utf-8"], _editor_html(session)) + end + else + (404, ["Content-Type" => "text/plain; charset=utf-8"], "Not found") + end + status, headers, body = response + return _write_http_response(http, status, headers, body) +end + +function _write_http_response(http::HTTP.Stream, status::Integer, headers, body::AbstractString) + HTTP.setstatus(http, status) + for header in headers + HTTP.setheader(http, header) + end + HTTP.setheader(http, "Connection" => "close") + HTTP.setheader(http, "Content-Length" => string(sizeof(body))) + HTTP.startwrite(http) + write(http, body) + return nothing +end + +function _authorized_request(session::GraphEditorSession, req) + token = _request_token(req) + return !isnothing(token) && token == session.token +end + +function _request_token(req) + header = HTTP.header(req, "X-PlantSimEngine-Graph-Token", "") + isempty(header) || return String(header) + return _query_param(String(req.target), "token") +end + +function _query_param(target::AbstractString, name::AbstractString) + query = String(HTTP.URI(target).query) + isempty(query) && return nothing + for part in split(query, '&') + pair = split(part, '='; limit=2) + length(pair) == 2 || continue + first(pair) == name && return last(pair) + end + return nothing +end + +function _authorized_origin(session::GraphEditorSession, req) + origin = HTTP.header(req, "Origin", "") + isempty(origin) && return true + return String(origin) == _base_url(session) +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) + _websocket_send(ws, _state_json(session)) || return nothing + try + for message in ws + command = JSON.parse(String(message)) + response = _handle_command!(session, command) + _websocket_send(ws, JSON.json(response)) || return nothing + end + catch err + _is_websocket_close_error(err) && return nothing + _websocket_send(ws, JSON.json(_error_payload(err))) + end + return nothing +end + +function _websocket_send(ws, payload::AbstractString) + try + HTTP.WebSockets.send(ws, payload) + return true + catch err + _is_websocket_close_error(err) && return false + rethrow() + end +end + +function _is_websocket_close_error(err) + err isa EOFError && return true + err isa Base.IOError && return true + return false +end + +function _handle_command!(session::GraphEditorSession, command) + action = get(command, "action", "") + try + persist = false + if action == "undo" + undo!(session) + persist = true + elseif action == "redo" + redo!(session) + persist = true + elseif action == "edit" + edit = _edit_from_command(command) + apply_edit!(session, edit) + persist = true + elseif action == "write_mapping_code" + raw_path = get(command, "path", "") + _write_mapping_code!(session, String(raw_path)) + elseif action == "open_mapping_code" + raw_path = get(command, "path", "") + _open_mapping_code!(session, String(raw_path)) + persist = true + else + error("Unsupported graph editor command action `$action`.") + end + diagnostics = persist ? _persist_session_mapping!(session) : String[] + return _state_payload(session; ok=isempty(diagnostics), diagnostics=diagnostics) + 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"]), + ) + if kind == "update_model" + model_type = _resolve_model_type(command["modelType"]) + parameters = _parameters_from_command(get(command, "parameters", Dict())) + timestep = _timestep_from_command(get(command, "timestep", nothing); default_sentinel=true) + return PlantSimEngine.UpdateModel( + Symbol(command["scale"]), + Symbol(command["process"]), + Symbol(get(command, "targetScale", command["scale"])), + model_type, + parameters, + timestep, + ) + end + 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")), + Symbol.(get(command, "extraSourceScales", [])), + ) + kind == "set_initialization" && return PlantSimEngine.SetStatusVariable( + Symbol(command["scale"]), + Symbol(command["variable"]), + _parse_parameter_value(get(command, "value", Dict("type" => "julia", "value" => "nothing"))), + ) + if kind in ("add_model", "replace_model") + model_type = _resolve_model_type(command["modelType"]) + parameters = _parameters_from_command(get(command, "parameters", Dict())) + timestep = _timestep_from_command(get(command, "timestep", nothing)) + if kind == "add_model" + return PlantSimEngine.AddModel(Symbol(command["scale"]), model_type, parameters, timestep) + end + return PlantSimEngine.ReplaceModel(Symbol(command["scale"]), Symbol(command["process"]), model_type, parameters, timestep) + end + error("Unsupported graph edit kind `$kind`.") +end + +function _timestep_from_command(timestep; default_sentinel::Bool=false) + isnothing(timestep) && return nothing + timestep isa AbstractDict || error("Unsupported timestep payload `$(timestep)`.") + mode = String(get(timestep, "mode", "default")) + mode == "default" && return (default_sentinel ? :default : nothing) + mode == "clock" || error("Unsupported timestep mode `$mode`. Use `default` or `clock`.") + dt = _parse_real(get(timestep, "dt", "1.0")) + phase = _parse_real(get(timestep, "phase", "0.0")) + return PlantSimEngine.ClockSpec(dt, phase) +end + +_parse_real(value::Real) = Float64(value) +_parse_real(value) = parse(Float64, String(value)) + +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 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 + 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)) + isempty(get(graph, "scales", Any[])) && (graph["scales"] = ["Default"]) + 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), + "initializations" => _initialization_payload(session.mapping), + "lastSavedPath" => session.last_saved_path, + "saveTargetPath" => session.save_target_path, + "autosavePath" => session.autosave_path, + "lastAutosavedPath" => session.last_autosaved_path, + "recentMappings" => session.recent_mapping_paths, + ) +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" => _websocket_url(session))) + return """ + + + + + +PlantSimEngine Graph Editor + + + + +
+

PlantSimEngine Graph Editor

+

This live session is running. The React editor can connect to $(_websocket_url(session)).

+

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" => _websocket_url(session))), " "<\\/") + + return """ + + + + + +PlantSimEngine Graph Editor + + + + + +
+ + + +""" +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 = _normalized_output_path(path) + _atomic_write(full_path, current_mapping_code(session) * "\n") + session.last_saved_path = full_path + session.save_target_path = full_path + _remember_recent_mapping!(session, full_path) + return full_path +end + +function _open_mapping_code!(session::GraphEditorSession, raw_path::AbstractString) + path = strip(String(raw_path)) + isempty(path) && error("The input path is empty. Provide a .jl file path.") + full_path = _normalized_output_path(path) + isfile(full_path) || error("No mapping code file exists at `$full_path`.") + mapping = _mapping_from_julia_file(full_path) + push!(session.history, session.mapping) + empty!(session.future) + session.mapping = _normalize_to_multiscale(mapping) + session.save_target_path = full_path + session.last_saved_path = full_path + _remember_recent_mapping!(session, full_path) + return session.mapping +end + +function _mapping_from_julia_file(path::AbstractString) + module_ = Module(gensym(:PlantSimEngineGraphEditorMapping)) + Core.eval(module_, :(using Base)) + Core.eval(module_, :(using PlantSimEngine)) + result = Core.eval(module_, Meta.parse("begin\n" * read(path, String) * "\nend")) + mapping = isdefined(module_, :mapping) ? getfield(module_, :mapping) : result + mapping isa PlantSimEngine.ModelMapping || (!isdefined(module_, :mapping) && error("Mapping code `$path` must define a top-level `mapping` variable.")) + mapping isa PlantSimEngine.ModelMapping || error("`mapping` in `$path` is a $(typeof(mapping)), not a PlantSimEngine.ModelMapping.") + return mapping +end + +function _persist_session_mapping!(session::GraphEditorSession; write_save_target::Bool=true) + diagnostics = String[] + if write_save_target && !isnothing(session.save_target_path) + try + _atomic_write(session.save_target_path, current_mapping_code(session) * "\n") + session.last_saved_path = session.save_target_path + catch err + push!(diagnostics, "Could not auto-save mapping code to $(session.save_target_path): $(sprint(showerror, err))") + end + end + if !isnothing(session.autosave_path) + try + _atomic_write(session.autosave_path, current_mapping_code(session) * "\n") + session.last_autosaved_path = session.autosave_path + catch err + push!(diagnostics, "Could not write recovery autosave to $(session.autosave_path): $(sprint(showerror, err))") + end + end + return diagnostics +end + +function _atomic_write(path::AbstractString, content::AbstractString) + full_path = _normalized_output_path(path) + mkpath(dirname(full_path)) + tmp = tempname(dirname(full_path)) + try + write(tmp, content) + mv(tmp, full_path; force=true) + finally + isfile(tmp) && rm(tmp; force=true) + end + return full_path +end + +function _normalized_output_path(path::AbstractString) + stripped = strip(String(path)) + return isabspath(stripped) ? normpath(stripped) : normpath(joinpath(pwd(), stripped)) +end + +function _default_autosave_path() + stamp = string(round(Int, time() * 1000)) + suffix = string(rand(UInt32); base=16) + return joinpath(tempdir(), "PlantSimEngineGraphEditor", "session-$stamp-$suffix", "mapping.autosave.jl") +end + +_default_recent_file_path() = joinpath(DEPOT_PATH[1], "config", "PlantSimEngine", "graph_editor_recent.json") + +function _load_recent_mapping_paths(path::AbstractString) + full_path = _normalized_output_path(path) + isfile(full_path) || return String[] + try + payload = JSON.parse(read(full_path, String)) + values = payload isa AbstractDict ? get(payload, "paths", String[]) : payload + return [String(item) for item in values if item isa AbstractString && isfile(String(item))] + catch + return String[] + end +end + +function _remember_recent_mapping!(session::GraphEditorSession, path::AbstractString) + full_path = _normalized_output_path(path) + filter!(item -> item != full_path, session.recent_mapping_paths) + pushfirst!(session.recent_mapping_paths, full_path) + length(session.recent_mapping_paths) > 10 && resize!(session.recent_mapping_paths, 10) + _write_recent_mapping_paths(session) + return session.recent_mapping_paths +end + +function _write_recent_mapping_paths(session::GraphEditorSession) + content = JSON.json(Dict("paths" => session.recent_mapping_paths)) + try + _atomic_write(session.recent_file_path, content * "\n") + catch err + @warn "Could not update graph editor recent mappings." path = session.recent_file_path exception = (err, catch_backtrace()) + end + return session.recent_file_path +end + +function _initialization_payload(mapping::PlantSimEngine.ModelMapping) + required_by_scale = _required_status_variables(mapping) + payload = Any[] + for scale in sort!(collect(keys(required_by_scale)); by=string) + status = _scale_status(mapping, scale) + for variable in sort!(collect(required_by_scale[scale]); by=string) + value_payload = isnothing(status) || !(variable in keys(status)) ? + _status_value_payload(nothing; provided=false) : + _status_value_payload(status[variable]; provided=true) + push!( + payload, + merge( + Dict( + "scale" => string(scale), + "name" => string(variable), + ), + value_payload + ) + ) + end + end + return payload +end + +function _scale_status(mapping::PlantSimEngine.ModelMapping, scale::Symbol) + haskey(mapping, scale) || return nothing + for item in _scale_items(mapping[scale]) + item isa PlantSimEngine.Status && return item + end + return nothing +end + +function _status_value_payload(value; provided::Bool) + choice, label = _status_value_choice(value, provided) + return Dict( + "value" => label, + "type" => choice, + "provided" => provided, + ) +end + +_status_value_choice(::Nothing, provided::Bool) = provided ? ("nothing", "") : ("julia", "") +_status_value_choice(value::Bool, ::Bool) = ("boolean", string(value)) +_status_value_choice(value::Integer, ::Bool) = ("integer", string(value)) +_status_value_choice(value::AbstractFloat, ::Bool) = ("float", string(value)) +_status_value_choice(value::Symbol, ::Bool) = ("symbol", string(value)) +_status_value_choice(value::AbstractString, ::Bool) = ("string", String(value)) +_status_value_choice(value, ::Bool) = ("julia", repr(value)) + +function _model_mapping_to_julia(mapping::PlantSimEngine.ModelMapping) + io = IOBuffer() + for statement in _using_statements(mapping) + println(io, statement) + end + println(io) + if isempty(keys(mapping)) + println(io, "# Add at least one model in the graph editor to generate a ModelMapping.") + print(io, "# mapping = ModelMapping(...)") + return String(take!(io)) + end + required_status_variables = _required_status_variables(mapping) + println(io, "mapping = ModelMapping(") + for scale in keys(mapping) + println(io, " :$(scale) => (") + items = _scale_items(mapping[scale]) + required = get(required_status_variables, scale, Set{Symbol}()) + for item in items + code = _mapping_item_to_code(item, required) + isnothing(code) && continue + println(io, " $(code),") + end + println(io, " ),") + end + print(io, ")") + return String(take!(io)) +end + +_scale_items(entry) = entry isa Tuple ? entry : (entry,) + +function _using_statements(mapping::PlantSimEngine.ModelMapping) + modules = Set{Module}([PlantSimEngine]) + for scale in keys(mapping) + for item in _scale_items(mapping[scale]) + _collect_mapping_modules!(modules, item) + end + end + return ["using $(_module_name(module_))" for module_ in sort!(collect(modules); by=_module_sort_key)] +end + +function _collect_mapping_modules!(modules::Set{Module}, item) + item isa PlantSimEngine.Status && return modules + if item isa PlantSimEngine.ModelSpec || item isa PlantSimEngine.MultiScaleModel + return _collect_spec_modules!(modules, PlantSimEngine.as_model_spec(item)) + end + item isa PlantSimEngine.AbstractModel && return _collect_model_modules!(modules, item) + return modules +end + +function _collect_spec_modules!(modules::Set{Module}, spec::PlantSimEngine.ModelSpec) + _collect_model_modules!(modules, PlantSimEngine.model_(spec)) + _collect_value_modules!(modules, PlantSimEngine.mapped_variables_(spec)) + _collect_value_modules!(modules, PlantSimEngine.timestep(spec)) + _collect_value_modules!(modules, spec.input_bindings) + _collect_value_modules!(modules, spec.meteo_bindings) + _collect_value_modules!(modules, spec.meteo_window) + _collect_value_modules!(modules, spec.output_routing) + _collect_value_modules!(modules, spec.scope) + return modules +end + +function _collect_model_modules!(modules::Set{Module}, model::PlantSimEngine.AbstractModel) + module_ = parentmodule(typeof(model)) + module_ in (Base, Core, Main) || push!(modules, module_) + return modules +end + +function _collect_value_modules!(modules::Set{Module}, value) + value === nothing && return modules + if value isa Type + module_ = parentmodule(value) + module_ in (Base, Core, Main) || push!(modules, module_) + return modules + end + module_ = parentmodule(typeof(value)) + module_ in (Base, Core, Main) || push!(modules, module_) + if value isa Pair + _collect_value_modules!(modules, first(value)) + _collect_value_modules!(modules, last(value)) + elseif value isa NamedTuple + for item in values(value) + _collect_value_modules!(modules, item) + end + elseif value isa Tuple || value isa AbstractArray + for item in value + _collect_value_modules!(modules, item) + end + end + return modules +end + +function _module_name(module_::Module) + return join(string.(Base.fullname(module_)), ".") +end + +function _module_sort_key(module_::Module) + module_ === PlantSimEngine && return "" + return _module_name(module_) +end + +function _required_status_variables(mapping::PlantSimEngine.ModelMapping) + stripped = Dict{Symbol,Any}() + status_only_scales = Set{Symbol}() + for scale in keys(mapping) + items = [item for item in _scale_items(mapping[scale]) if !(item isa PlantSimEngine.Status)] + if isempty(items) + push!(status_only_scales, scale) + else + stripped[scale] = tuple(items...) + end + end + + required = isempty(stripped) ? + Dict{Symbol,Vector{Symbol}}() : + PlantSimEngine.to_initialize(PlantSimEngine.ModelMapping(stripped; check=true, type_promotion=PlantSimEngine.type_promotion(mapping))) + + required_by_scale = Dict{Symbol,Set{Symbol}}( + scale => Set{Symbol}(variables) + for (scale, variables) in pairs(required) + ) + for scale in status_only_scales + required_by_scale[scale] = Set{Symbol}() + for item in _scale_items(mapping[scale]) + item isa PlantSimEngine.Status || continue + union!(required_by_scale[scale], keys(item)) + end + end + return required_by_scale +end + +function _mapping_item_to_code(item, required_status_variables=nothing) + if item isa PlantSimEngine.Status + return _status_to_code(item, required_status_variables) + end + if item isa PlantSimEngine.ModelSpec || item isa PlantSimEngine.MultiScaleModel + return _model_spec_to_code(PlantSimEngine.as_model_spec(item)) + end + return repr(item) +end + +function _status_to_code(status::PlantSimEngine.Status, required_variables) + isnothing(required_variables) && (required_variables = Set{Symbol}(keys(status))) + kept = Pair{Symbol,Any}[ + name => status[name] + for name in keys(status) + if name in required_variables + ] + isempty(kept) && return nothing + return "Status(" * join(("$(first(item)) = $(repr(last(item)))" for item in kept), ", ") * ")" +end + +function _model_spec_to_code(spec::PlantSimEngine.ModelSpec) + code = "ModelSpec($(repr(PlantSimEngine.model_(spec))))" + mapped_variables = PlantSimEngine.mapped_variables_(spec) + isempty(mapped_variables) || (code *= " |> MultiScaleModel($(_mapped_variables_to_code(mapped_variables)))") + isnothing(PlantSimEngine.timestep(spec)) || (code *= " |> TimeStepModel($(_timestep_to_code(PlantSimEngine.timestep(spec))))") + _is_empty_namedtuple(spec.input_bindings) || (code *= " |> InputBindings($(_julia_code(spec.input_bindings)))") + _is_empty_namedtuple(spec.meteo_bindings) || (code *= " |> MeteoBindings($(_julia_code(spec.meteo_bindings)))") + isnothing(spec.meteo_window) || (code *= " |> MeteoWindow($(_julia_code(spec.meteo_window)))") + _is_empty_namedtuple(spec.output_routing) || (code *= " |> OutputRouting($(_julia_code(spec.output_routing)))") + _is_default_scope(spec.scope) || (code *= " |> ScopeModel($(_julia_code(spec.scope)))") + return code +end + +_julia_code(value) = repr(value) +_is_empty_namedtuple(value) = value isa NamedTuple && isempty(keys(value)) +_is_default_scope(scope) = scope == :global + +function _timestep_to_code(timestep::PlantSimEngine.ClockSpec) + return "ClockSpec($(repr(timestep.dt)), $(repr(timestep.phase)))" +end + +_timestep_to_code(timestep) = repr(timestep) + +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/.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/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/e2e/graph-editor.spec.ts b/frontend/e2e/graph-editor.spec.ts new file mode 100644 index 000000000..5077a896c --- /dev/null +++ b/frontend/e2e/graph-editor.spec.ts @@ -0,0 +1,164 @@ +import { expect, test, type APIRequestContext, type Locator, type Page } from "@playwright/test"; +import type { GraphEditorState, GraphNodeData } from "../src/types"; +import { startGraphEditorServer, type GraphEditorServer } from "./graphEditorServer"; + +test.describe.serial("PlantSimEngine graph editor", () => { + let server: GraphEditorServer; + + test.beforeAll(async () => { + server = await startGraphEditorServer(); + }); + + test.afterAll(async () => { + await server?.stop(); + }); + + test("starts a real Julia editor session", async ({ page, request }) => { + await page.goto(server.url); + + await expect(page.getByText("Dependency Graph")).toBeVisible(); + + const state = await getState(request, server.url); + expect(state.ok).toBe(true); + expect(state.graph.cyclic).toBe(false); + expect(state.graph.nodes.some((node) => node.modelType.includes("ToyLAIModel"))).toBe(true); + expect(state.graph.nodes.some((node) => node.modelType.includes("Beer"))).toBe(true); + }); + + test("updates, adds, maps, removes, creates a cycle, and breaks it", async ({ page, request }) => { + await page.goto(server.url); + + const beer = await findNode(request, server.url, (node) => node.modelType.includes("Beer")); + await openInspectorPanel(page); + await page.getByTestId(`model-node-${beer.scale}-${beer.process}`).click(); + await expect(page.getByTestId("existing-model-editor")).toBeVisible(); + await page.getByTestId("edit-param-k").fill("0.8"); + await page.getByTestId("update-model-submit").click(); + await waitForState(request, server.url, (state) => { + const updated = state.graph.nodes.find((node) => node.process === beer.process && node.scale === beer.scale); + return updated?.modelParameters?.k?.value === "0.8"; + }); + + await openAddModelPanel(page); + await page.getByTestId("add-model-scale").selectOption("Default"); + await selectOptionContaining(page.getByTestId("add-model-type"), "ToyDegreeDaysCumulModel"); + await page.getByTestId("add-model-submit").click(); + const degreeDays = await waitForNode(request, server.url, (node) => node.modelType.includes("ToyDegreeDaysCumulModel")); + + await openInspectorPanel(page); + await page.getByTestId(`model-node-${degreeDays.scale}-${degreeDays.process}`).click(); + await page.getByTestId("remove-model-submit").click(); + await waitForState(request, server.url, (state) => + !state.graph.nodes.some((node) => node.modelType.includes("ToyDegreeDaysCumulModel")) + ); + + await openAddModelPanel(page); + await page.getByTestId("add-model-scale").selectOption("Default"); + await selectOptionContaining(page.getByTestId("add-model-type"), "ToyDegreeDaysCumulModel"); + await page.getByTestId("add-model-submit").click(); + const mappedDegreeDays = await waitForNode(request, server.url, (node) => node.modelType.includes("ToyDegreeDaysCumulModel")); + + const lai = await findNode(request, server.url, (node) => node.modelType.includes("ToyLAIModel")); + await openInspectorPanel(page); + await page.getByTestId(`port-input-${lai.scale}-${lai.process}-TT_cu`).click(); + await expect(page.getByTestId("mapping-source-output")).toBeVisible(); + await selectOptionContaining(page.getByTestId("mapping-source-output"), `${mappedDegreeDays.scale}.${mappedDegreeDays.process}.TT_cu`); + await page.getByTestId("mapping-apply").click(); + await waitForState(request, server.url, (state) => state.ok && !state.graph.cyclic); + + await openAddModelPanel(page); + await page.getByTestId("add-model-scale").selectOption("Default"); + await selectOptionContaining(page.getByTestId("add-model-type"), "ReebE2E"); + await page.getByTestId("add-param-k").fill("0.6"); + await page.getByTestId("add-model-submit").click(); + + await waitForState(request, server.url, (state) => state.graph.cyclic === true); + await expect(page.getByTestId("cycle-break-prompt")).toBeVisible(); + await expect(page.locator(".react-flow__edge.cycle_edge")).toHaveCount(2); + + await page.getByTestId("cycle-break-choose").click(); + await expect(page.locator(".port-cycle-break-button")).not.toHaveCount(0); + await page.locator(".port-cycle-break-button").first().click(); + + await waitForState(request, server.url, (state) => + state.graph.cyclic === false && state.mappingCode.includes("PreviousTimeStep(:") + ); + await expect(page.getByTestId("cycle-break-prompt")).toHaveCount(0); + + await page.getByTestId("toolbar-mapping-code").click(); + await expect(page.getByTestId("mapping-code")).toHaveValue(/PreviousTimeStep\(:/); + }); +}); + +async function openInspectorPanel(page: Page) { + await page.getByTestId("toolbar-add-model").click(); + await page.getByTestId("toolbar-inspector").click(); +} + +async function openAddModelPanel(page: Page) { + await page.getByTestId("toolbar-add-model").click(); + await expect(page.getByTestId("add-model-panel")).toBeVisible(); +} + +async function getState(request: APIRequestContext, baseURL: string): Promise { + const response = await request.get(stateURL(baseURL)); + if (!response.ok()) { + throw new Error(`Expected /state to return 2xx, got ${response.status()}:\n${await response.text()}`); + } + return await response.json() as GraphEditorState; +} + +function stateURL(baseURL: string): string { + const url = new URL(baseURL); + const token = url.searchParams.get("token"); + url.pathname = "/state"; + url.search = ""; + if (token) url.searchParams.set("token", token); + return url.toString(); +} + +async function waitForState( + request: APIRequestContext, + baseURL: string, + predicate: (state: GraphEditorState) => boolean, + timeoutMs = 15_000, +): Promise { + const deadline = Date.now() + timeoutMs; + let latest = await getState(request, baseURL); + while (Date.now() < deadline) { + if (predicate(latest)) return latest; + await new Promise((resolve) => setTimeout(resolve, 250)); + latest = await getState(request, baseURL); + } + throw new Error(`Timed out waiting for editor state. Latest state:\n${JSON.stringify(latest, null, 2)}`); +} + +async function findNode( + request: APIRequestContext, + baseURL: string, + predicate: (node: GraphNodeData) => boolean, +): Promise { + const state = await getState(request, baseURL); + const node = state.graph.nodes.find(predicate); + expect(node, `Expected graph node in ${state.graph.nodes.map((item) => item.modelType).join(", ")}`).toBeTruthy(); + return node!; +} + +async function waitForNode( + request: APIRequestContext, + baseURL: string, + predicate: (node: GraphNodeData) => boolean, +): Promise { + const state = await waitForState(request, baseURL, (candidate) => candidate.graph.nodes.some(predicate)); + return state.graph.nodes.find(predicate)!; +} + +async function selectOptionContaining(select: Locator, text: string) { + const value = await select.evaluate((element, needle) => { + const selectElement = element as HTMLSelectElement; + const option = [...selectElement.options].find((item) => item.textContent?.includes(needle)); + return option?.value ?? null; + }, text); + expect(value, `Expected select option containing ${text}`).toBeTruthy(); + await select.selectOption(value!); +} diff --git a/frontend/e2e/graphEditorServer.ts b/frontend/e2e/graphEditorServer.ts new file mode 100644 index 000000000..9fcbc708c --- /dev/null +++ b/frontend/e2e/graphEditorServer.ts @@ -0,0 +1,82 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(dirname, "../.."); +const serverScript = path.join(repoRoot, "test", "fixtures", "graph_editor_e2e_server.jl"); + +export type GraphEditorServer = { + url: string; + stop: () => Promise; +}; + +export async function startGraphEditorServer(): Promise { + if (process.env.PSE_GRAPH_EDITOR_URL) { + return { + url: process.env.PSE_GRAPH_EDITOR_URL, + stop: async () => {}, + }; + } + + const proc = spawn("julia", ["--project=test", "--startup-file=no", serverScript], { + cwd: repoRoot, + env: { ...process.env, JULIA_NUM_THREADS: process.env.JULIA_NUM_THREADS ?? "2" }, + }); + + let log = ""; + const url = await new Promise((resolve, reject) => { + let settled = false; + let timeout: ReturnType; + + const consume = (chunk: Buffer) => { + if (settled) return; + log += chunk.toString(); + const match = log.match(/PSE_GRAPH_EDITOR_URL=(http:\/\/127\.0\.0\.1:\d+[^\s]*)/); + if (match) { + settled = true; + clearTimeout(timeout); + resolve(match[1]); + } + }; + + const fail = (error: Error) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + void stopProcess(proc).finally(() => reject(error)); + }; + + timeout = setTimeout(() => { + fail(new Error(`Timed out waiting for graph editor URL.\n${log}`)); + }, 90_000); + + proc.stdout.on("data", consume); + proc.stderr.on("data", consume); + proc.once("exit", (code, signal) => { + fail(new Error(`Graph editor process exited before startup: code=${code} signal=${signal}\n${log}`)); + }); + proc.once("error", (error) => { + fail(error); + }); + }); + + return { + url, + stop: () => stopProcess(proc), + }; +} + +function stopProcess(proc: ChildProcessWithoutNullStreams): Promise { + if (proc.killed || proc.exitCode !== null) return Promise.resolve(); + return new Promise((resolve) => { + const killTimer = setTimeout(() => { + if (!proc.killed && proc.exitCode === null) proc.kill("SIGKILL"); + }, 3_000); + proc.once("exit", () => { + clearTimeout(killTimer); + resolve(); + }); + proc.kill("SIGTERM"); + }); +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 000000000..0159e8aff --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + PlantSimEngine Dependency Graph + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 000000000..5fbe30597 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2618 @@ +{ + "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": { + "@playwright/test": "^1.60.0", + "@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/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "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/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "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..c715dd081 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,33 @@ +{ + "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 --passWithNoTests --exclude \"e2e/**\"", + "typecheck": "tsc --noEmit", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --headed --debug" + }, + "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": { + "@playwright/test": "^1.60.0", + "@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/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 000000000..532fc1152 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,24 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: false, + workers: 1, + timeout: 60_000, + expect: { + timeout: 10_000, + }, + reporter: process.env.CI ? [["dot"], ["html", { open: "never" }]] : "list", + use: { + baseURL: process.env.PSE_GRAPH_EDITOR_URL ?? "http://127.0.0.1:8765", + trace: "retain-on-failure", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 000000000..8e9d3d943 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,2708 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { + Background, + Controls, + MiniMap, + ReactFlow, + MarkerType, + useEdgesState, + useNodesState, + type Connection, + type Edge, + type Node, + type ReactFlowInstance, +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; +import { + AlertTriangle, + CircleAlert, + Filter, + FolderOpen, + GitPullRequestArrow, + Network, + RotateCcw, + Route, + ScissorsLineDashed, + Search, + X, +} from "lucide-react"; +import { DependencyEdge } from "./DependencyEdge"; +import { ModelNode } from "./ModelNode"; +import { layoutGraph, type LayoutMode } from "./layout"; +import { sampleGraph } from "./sampleGraph"; +import type { DependencyGraphView, GraphEdgeData, GraphEditorState, GraphNodeData, GraphPort, InitializationDescriptor, ModelDescriptor, RuntimeGraphNodeData } from "./types"; +import "./styles.css"; + +type EdgeFilterKey = "dataFlow" | "mapped" | "callStack"; +type EdgeFilters = Record; +type FocusMode = "none" | "upstream" | "downstream" | "neighborhood"; +type SidePanel = "inspector" | "add_model" | "initializations" | "mapping_code" | null; +type GraphViewMode = "overview" | "detail"; + +type PendingMappingConnection = { + sourceNode: GraphNodeData; + sourcePort: GraphPort; + targetNode: GraphNodeData; + targetPort: GraphPort; +}; + +type CandidatePopover = { + portId: string; + anchor: { x: number; y: number }; +}; + +type AddModelSelection = { + modelType: string; + scale: string; + requestId: number; +}; + +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 CycleBreakOption = { + edge: GraphEdgeData; + node: GraphNodeData; + port: GraphPort; +}; + +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; +}; + +type FocusState = { + active: boolean; + edges: Set; + nodes: Set; + ports: Set; +}; + +const nodeTypes = { model: ModelNode }; +const edgeTypes = { dependency: DependencyEdge }; +const edgeColors = { + base: "#a99a8c", + accent: "#1f7a53", + mapped: "#4f8d69", + 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", + overview: "Overview", +}; + +const valueTypeChoices = ["float", "integer", "boolean", "symbol", "string", "nothing", "julia"]; + +export default function App() { + 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 [activePanel, setActivePanel] = useState("inspector"); + const [mappingCode, setMappingCode] = useState(""); + const [initializations, setInitializations] = useState([]); + const [lastSavedPath, setLastSavedPath] = useState(null); + const [saveTargetPath, setSaveTargetPath] = useState(null); + const [autosavePath, setAutosavePath] = useState(null); + const [lastAutosavedPath, setLastAutosavedPath] = useState(null); + const [recentMappings, setRecentMappings] = useState([]); + 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 [pendingConnection, setPendingConnection] = useState(null); + const [showRequiredPanel, setShowRequiredPanel] = useState(false); + const [showWarningsPanel, setShowWarningsPanel] = useState(false); + const [showOpenPanel, setShowOpenPanel] = useState(false); + const [showRelationshipsPanel, setShowRelationshipsPanel] = useState(false); + const [showScalesPanel, setShowScalesPanel] = useState(false); + const [showSearchResults, setShowSearchResults] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [layoutMode, setLayoutMode] = useState("data_flow"); + const [focusMode, setFocusMode] = useState("neighborhood"); + const [viewMode, setViewMode] = useState(() => defaultGraphViewMode(loadInitialGraph())); + const [viewModeTouched, setViewModeTouched] = useState(false); + const [edgeFilters, setEdgeFilters] = useState(defaultEdgeFilters); + const [collapsedScales, setCollapsedScales] = useState>(() => new Set()); + const [pinnedFocus, setPinnedFocus] = useState(null); + const [selectedEdge, setSelectedEdge] = useState(null); + const [cycleBreakMode, setCycleBreakMode] = useState(false); + const [candidatePopover, setCandidatePopover] = useState(null); + const [addModelSelection, setAddModelSelection] = useState(null); + const [addModelFocusRequest, setAddModelFocusRequest] = useState(0); + const [highlightAddModelPanel, setHighlightAddModelPanel] = useState(false); + const [flowInstance, setFlowInstance] = useState, Edge> | null>(null); + const [nodes, setNodes, onNodesChange] = useNodesState>([]); + const [edges, setEdges, onEdgesChange] = useEdgesState>([]); + const sidePanelRef = useRef(null); + + 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 candidatePortIds = useMemo(() => deriveCandidatePortIds(graph, editorModels, incomingByPort), [editorModels, graph, incomingByPort]); + 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 visibleNodeData = useMemo(() => graph.nodes.filter((node) => !collapsedScales.has(node.scale)), [collapsedScales, graph.nodes]); + const editorScales = useMemo(() => { + const graphScales = graph.scales.length > 0 ? graph.scales : ["Default"]; + const merged = [...graphScales, ...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) && + visibleNodeIds.has(edge.source) && + visibleNodeIds.has(edge.target) + )), [edgeFilters, graph.edges, visibleNodeIds]); + const cycleBreakOptions = useMemo(() => deriveCycleBreakOptions(graph, nodeById, portById), [graph, nodeById, portById]); + const cycleBreakPortIds = useMemo(() => new Set(cycleBreakOptions.map((option) => option.port.id)), [cycleBreakOptions]); + const hoverHighlight = useMemo(() => deriveHighlight(graph, activePort), [activePort, graph]); + const traversalFocus = useMemo( + () => deriveFocus(graph, selected?.id ?? null, activePort, focusMode), + [activePort, focusMode, graph, selected?.id], + ); + const focus = useMemo(() => pinnedFocus?.active ? pinnedFocus : traversalFocus, [pinnedFocus, traversalFocus]); + const activeCandidatePortId = candidatePopover?.portId ?? null; + const candidatePopoverInfo = useMemo(() => { + if (!candidatePopover) return null; + const portInfo = portById.get(candidatePopover.portId); + if (!portInfo || !candidatePortIds.has(candidatePopover.portId)) return null; + const { port } = portInfo; + const field = port.role === "input" ? "outputs" : "inputs"; + const models = editorModels + .filter((model) => Object.prototype.hasOwnProperty.call(modelVariableDeclarations(model, field), port.name)) + .sort((left, right) => left.name.localeCompare(right.name)); + if (models.length === 0) return null; + return { + anchor: candidatePopover.anchor, + node: portInfo.node, + port, + title: port.role === "input" ? "Models That Compute" : "Models That Consume", + models, + }; + }, [candidatePopover, candidatePortIds, editorModels, portById]); + + const toggleCandidatePopover = useCallback((port: GraphPort, anchor: { x: number; y: number }) => { + setActivePort(port); + setCandidatePopover((current) => current?.portId === port.id ? null : { portId: port.id, anchor }); + }, []); + + useEffect(() => { + const config = loadEditorConfig(); + if (!config?.websocketUrl) return; + + const socket = new WebSocket(config.websocketUrl); + setEditorSocket(socket); + 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 (Array.isArray(payload.initializations)) setInitializations(payload.initializations); + setLastSavedPath(typeof payload.lastSavedPath === "string" ? payload.lastSavedPath : null); + setSaveTargetPath(typeof payload.saveTargetPath === "string" ? payload.saveTargetPath : null); + if (typeof payload.saveTargetPath === "string") setSavePath(payload.saveTargetPath); + setAutosavePath(typeof payload.autosavePath === "string" ? payload.autosavePath : null); + setLastAutosavedPath(typeof payload.lastAutosavedPath === "string" ? payload.lastAutosavedPath : null); + if (Array.isArray(payload.recentMappings)) setRecentMappings(payload.recentMappings); + 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) { + setEditorFeedback({ kind: "error", text: "Graph editor is offline; command was not sent." }); + return; + } + editorSocket.send(JSON.stringify(command)); + }, [editorSocket]); + + const breakCycleAtPort = useCallback((port: GraphPort) => { + const target = portById.get(port.id); + if (!target || port.role !== "input") return; + sendEditorCommand({ + action: "edit", + kind: "mark_previous_timestep", + scale: target.node.scale, + process: target.node.process, + variable: port.name, + }); + setCycleBreakMode(false); + setPinnedFocus(null); + setSelectedEdge(null); + setSelected(target.node); + setActivePort(port); + }, [portById, sendEditorCommand]); + + const removeGraphModel = useCallback((node: GraphNodeData) => { + const target = removableMappingNode(node, nodeById); + if (!target) { + setEditorFeedback({ kind: "error", text: `Cannot remove ${node.process}: no owning ModelMapping model was found.` }); + return; + } + sendEditorCommand({ + action: "edit", + kind: "remove_model", + scale: target.scale, + process: target.process, + }); + setSelected((current) => current?.id === node.id || current?.id === target.id ? null : current); + setActivePort(null); + setSelectedEdge(null); + }, [nodeById, sendEditorCommand]); + + const togglePanel = useCallback((panel: Exclude) => { + setActivePanel((current) => current === panel ? null : panel); + }, []); + + const openAddModelPanel = useCallback(() => { + setActivePanel("add_model"); + setHighlightAddModelPanel(true); + setAddModelFocusRequest(Date.now()); + }, []); + + 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(() => { + if (activePanel !== "add_model" || !highlightAddModelPanel) return; + sidePanelRef.current?.scrollIntoView({ block: "nearest", inline: "nearest" }); + sidePanelRef.current?.focus({ preventScroll: true }); + const timeout = window.setTimeout(() => setHighlightAddModelPanel(false), 1800); + return () => window.clearTimeout(timeout); + }, [activePanel, highlightAddModelPanel, addModelFocusRequest]); + + useEffect(() => { + if (!viewModeTouched) setViewMode(defaultGraphViewMode(graph)); + }, [graph, viewModeTouched]); + + useEffect(() => { + const nextNodes = visibleNodeData.map((node) => ({ + id: node.id, + type: "model", + position: { x: 0, y: 0 }, + data: runtimeNodeData(node, { + activePort: null, + highlightedPortIds: new Set(), + focusedPortIds: new Set(), + requiredInputPortIds, + candidatePortIds, + cycleNodeIds: new Set(graph.cycleNodes), + cycleBreakPortIds, + cycleBreakMode, + focusedNodeIds: new Set(), + hasActiveFocus: false, + activeCandidatePortId, + setActivePort, + setCandidatePopover: toggleCandidatePopover, + breakCycleAtPort, + removeGraphModel, + viewMode, + }), + })); + const nextEdges = visibleEdgeData.map((edge) => flowEdge(edge, new Set(), new Set(), false, false)); + layoutGraph(nextNodes, nextEdges, effectiveLayoutMode(viewMode, layoutMode)).then((layouted) => { + setNodes(layouted); + setEdges(nextEdges); + }); + }, [activeCandidatePortId, breakCycleAtPort, candidatePortIds, cycleBreakMode, cycleBreakPortIds, graph.cycleNodes, layoutMode, removeGraphModel, requiredInputPortIds, setEdges, setNodes, toggleCandidatePopover, viewMode, visibleEdgeData, visibleNodeData]); + + useEffect(() => { + const focusEdges = focus.active ? focus.edges : new Set(); + setNodes((current) => current.map((node) => ({ + ...node, + data: runtimeNodeData(node.data, { + activePort, + highlightedPortIds: hoverHighlight.ports, + focusedPortIds: focus.ports, + requiredInputPortIds, + candidatePortIds, + cycleNodeIds: new Set(graph.cycleNodes), + cycleBreakPortIds, + cycleBreakMode, + focusedNodeIds: focus.nodes, + hasActiveFocus: focus.active, + activeCandidatePortId, + setActivePort, + setCandidatePopover: toggleCandidatePopover, + breakCycleAtPort, + removeGraphModel, + viewMode, + }), + }))); + setEdges((current) => current.map((edge) => edge.data ? flowEdge(edge.data, hoverHighlight.edges, focusEdges, Boolean(activePort), focus.active) : edge)); + }, [activeCandidatePortId, activePort, breakCycleAtPort, candidatePortIds, cycleBreakMode, cycleBreakPortIds, focus, graph.cycleNodes, hoverHighlight.edges, hoverHighlight.ports, removeGraphModel, requiredInputPortIds, setEdges, setNodes, toggleCandidatePopover, viewMode]); + + useEffect(() => { + if (candidatePopover && !candidatePortIds.has(candidatePopover.portId)) setCandidatePopover(null); + }, [candidatePopover, candidatePortIds]); + + const onConnect = useCallback((connection: Connection) => { + if (!editorConnected) return; + const sourcePortId = connection.sourceHandle; + const targetPortId = connection.targetHandle; + if (!sourcePortId || !targetPortId) return; + const sourceInfo = portById.get(sourcePortId); + const targetInfo = portById.get(targetPortId); + if (!sourceInfo || !targetInfo) return; + // Only handle output-to-input connections. + if (sourceInfo.port.role !== "output" || targetInfo.port.role !== "input") return; + setPendingConnection({ + sourceNode: sourceInfo.node, + sourcePort: sourceInfo.port, + targetNode: targetInfo.node, + targetPort: targetInfo.port, + }); + }, [editorConnected, portById]); + + const relayout = useCallback(() => { + layoutGraph(nodes, edges, effectiveLayoutMode(viewMode, layoutMode)).then(setNodes); + }, [edges, layoutMode, nodes, setNodes, viewMode]); + + 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 }); + } + }, [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 chooseCycleBreakPoint = useCallback(() => { + setCycleBreakMode(true); + setViewModeTouched(true); + setViewMode("detail"); + setActivePanel("inspector"); + setSelected(null); + setSelectedEdge(null); + setCandidatePopover(null); + setActivePort(null); + + const nextFocus = emptyFocusState(); + nextFocus.active = true; + for (const option of cycleBreakOptions) { + nextFocus.edges.add(option.edge.id); + nextFocus.nodes.add(option.edge.source); + nextFocus.nodes.add(option.edge.target); + if (option.edge.sourcePort) nextFocus.ports.add(option.edge.sourcePort); + nextFocus.ports.add(option.port.id); + } + setPinnedFocus(nextFocus); + + if (flowInstance && cycleBreakOptions.length > 0) { + const nodeIds = [...new Set(cycleBreakOptions.flatMap((option) => [option.edge.source, option.edge.target]))]; + flowInstance.fitView({ + nodes: nodeIds.map((id) => ({ id })), + padding: 0.36, + duration: 520, + maxZoom: 1.05, + }); + } + }, [cycleBreakOptions, flowInstance]); + + useEffect(() => { + if (!graph.cyclic) setCycleBreakMode(false); + }, [graph.cyclic]); + + const toggleEdgeFilter = useCallback((key: EdgeFilterKey) => { + 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(); + 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); + setSelectedEdge(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); + setSelectedEdge(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 ( +
+
+
+ + +
+
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.
} +
+ )} +
+ +
+ {visibleNodeData.length}/{graph.nodes.length} models + {visibleEdgeData.length}/{graph.edges.length} links + {requiredInputs.length > 0 && ( + + )} + {actionableWarningItems.length > 0 && ( + + )} + {graph.cyclic && cycle} +
+ +
+ + + + +
+ +
+ + +
+ +
+ + {editorSocket && ( + <> + + + + + )} +
+ + {editorSocket && ( +
+ {editorConnected ? "live" : "offline"} + + +
+ )} +
+ + {editorFeedback && ( +
+ {editorFeedback.text} +
+ )} + + {graph.cyclic && ( + + )} + + {showRelationshipsPanel && } + {showScalesPanel && } + + {showRequiredPanel && ( + setShowRequiredPanel(false)}> + + + )} + + {showWarningsPanel && ( + setShowWarningsPanel(false)}> + + + )} + + {showOpenPanel && ( + { + sendEditorCommand({ action: "open_mapping_code", path }); + setShowOpenPanel(false); + }} + onClose={() => setShowOpenPanel(false)} + /> + )} + + { + setShowSearchResults(false); + setCandidatePopover(null); + setShowOpenPanel(false); + setShowRelationshipsPanel(false); + setShowScalesPanel(false); + }} + onEdgeClick={(_, edge) => { + if (edge.data) { + setCandidatePopover(null); + setSelectedEdge(edge.data); + setSelected(null); + setActivePort(null); + setPinnedFocus(null); + } + }} + onNodeClick={(_, node) => { + setCandidatePopover(null); + setSelectedEdge(null); + setSelected(node.data); + }} + fitView + fitViewOptions={{ padding: viewMode === "overview" ? 0.14 : 0.08, minZoom: 0.03, maxZoom: viewMode === "overview" ? 1.25 : 1 }} + minZoom={0.03} + maxZoom={2} + > + + + + + + {candidatePopoverInfo && ( + { + const requestId = Date.now(); + setAddModelSelection({ + modelType: model.type, + scale: candidatePopoverInfo.node.scale, + requestId, + }); + setAddModelFocusRequest(requestId); + setHighlightAddModelPanel(true); + setActivePanel("add_model"); + setCandidatePopover(null); + }} + onClose={() => setCandidatePopover(null)} + /> + )} +
+ + {activePanel && ( + + )} + + {pendingConnection && ( + { + sendEditorCommand(command); + setPendingConnection(null); + }} + onCancel={() => setPendingConnection(null)} + /> + )} +
+ ); +} + +function MappingDialog({ + connection, + scales, + onConfirm, + onCancel, +}: { + connection: PendingMappingConnection; + scales: string[]; + onConfirm: (command: Record) => void; + onCancel: () => void; +}) { + const [mode, setMode] = useState<"single" | "multi">("single"); + const [selectedScales, setSelectedScales] = useState([connection.sourceNode.scale]); + + const toggleScale = (scale: string) => { + setSelectedScales((current) => + current.includes(scale) ? current.filter((s) => s !== scale) : [...current, scale] + ); + }; + + const handleConfirm = () => { + const command: Record = { + action: "edit", + kind: "set_mapped_variable", + scale: connection.targetNode.scale, + process: connection.targetNode.process, + variable: connection.targetPort.name, + sourceScale: connection.sourceNode.scale, + sourceVariable: connection.sourcePort.name, + mode: mode === "single" && connection.sourceNode.scale === connection.targetNode.scale ? "same_scale" : mode, + }; + if (mode === "multi") { + const extras = selectedScales.filter((s) => s !== connection.sourceNode.scale); + if (extras.length > 0) command.extraSourceScales = extras; + } + onConfirm(command); + }; + + return ( +
+
e.stopPropagation()}> +
+
Variable Mapping
+ +
+ +
+
+
+ Source + {connection.sourceNode.scale} + {connection.sourceNode.process}.{connection.sourcePort.name} +
+
->
+
+ Target + {connection.targetNode.scale} + {connection.targetNode.process}.{connection.targetPort.name} +
+
+ +
+
Mapping mode
+ + +
+ + {mode === "multi" && ( +
+
Source scales
+ {scales.map((scale) => ( + + ))} +
+ )} +
+ +
+ + +
+
+
+ ); +} + +function RelationshipLegend({ filters, onToggle }: { filters: EdgeFilters; onToggle: (key: EdgeFilterKey) => void }) { + return ( +
+
Relationships
+ + + +
red inputs need initialization
+
+ ); +} + +function ScaleControls({ + scales, + collapsedScales, + onToggle, + onExpandAll, +}: { + scales: string[]; + collapsedScales: Set; + 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 ( +
+
+
+
{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, + onFocusWarning, +}: { + warnings: ValidationWarning[]; + onFocusWarning: (warning: ValidationWarning) => void; +}) { + if (warnings.length === 0) return
No validation warnings.
; + const grouped = groupValidationWarnings(warnings); + return ( +
+ {(["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) => ( + + ))} +
+ ); + })} +
+ ); +} + +function OpenMappingPanel({ + recentMappings, + disabled, + onOpen, + onClose, +}: { + recentMappings: string[]; + disabled: boolean; + onOpen: (path: string) => void; + onClose: () => void; +}) { + const [path, setPath] = useState(""); + + const openPath = () => { + const trimmed = path.trim(); + if (!trimmed) return; + onOpen(trimmed); + }; + + return ( + +
+ +
+
+ Recent mappings +
+ {recentMappings.length > 0 ? ( +
+ {recentMappings.map((item) => ( + + ))} +
+ ) :
No recent mapping.
} +
+
+
+ ); +} + +function CycleBreakPrompt({ + active, + optionCount, + editorConnected, + onChoose, +}: { + active: boolean; + optionCount: number; + editorConnected: boolean; + onChoose: () => void; +}) { + return ( +
+
+ Cycle detected + + Choose which variable to decouple. The selected input will be wrapped in PreviousTimeStep, so that model uses the value from the previous timestep and is disconnected from this current-step variable within a run. + +
+ +
+ ); +} + +function InspectorDetails({ + selected, + selectedEdge, + activePort, + requiredInputPortIds, + incomingEdges, + outgoingEdges, + nodeById, + portById, + graphNodes, + onFocusEdge, + models, + scales, + onAddScale, + onCommand, + editorConnected, +}: { + selected: GraphNodeData | null; + selectedEdge: GraphEdgeData | null; + activePort: GraphPort | null; + requiredInputPortIds: Set; + incomingEdges: GraphEdgeData[]; + outgoingEdges: GraphEdgeData[]; + nodeById: Map; + portById: Map; + graphNodes: GraphNodeData[]; + onFocusEdge: (edge: GraphEdgeData) => void; + models: ModelDescriptor[]; + scales: string[]; + onAddScale: (scale: string) => void; + onCommand: (command: Record) => void; + editorConnected: boolean; +}) { + return ( + <> + {selectedEdge && ( + + )} + {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
+ ))} + {selected.role === "model" && ( + + )} +
+ ) : !selectedEdge ? ( +
Select a model node.
+ ) : null} + +

Variable Provenance

+ {activePort ? ( +
+
+ {activePort.name} + {activePort.role} +
+ + {activePort.mappingMode && } + {activePort.sourceScale && } + {requiredInputPortIds.has(activePort.id) &&
required initialization
} + {activePort.previousTimeStep &&
uses previous timestep
} + + + {activePort.role === "input" && ( + + )} +
+ ) : ( +
Hover, click, or search a variable to see where it comes from and where it goes.
+ )} + + ); +} + +function EdgeDetails({ + edge, + nodeById, + portById, + onCommand, + editorConnected, +}: { + edge: GraphEdgeData; + nodeById: Map; + portById: Map; + onCommand: (command: Record) => void; + editorConnected: boolean; +}) { + 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 breakable = isCycleEdge(edge) && target && targetPort && targetPort.role === "input"; + return ( +
+
+ {edgeKindLabel(edge)} + {edge.scaleRelation} +
+ + + + + + + {breakable && ( + + )} + {edge.diagnostics.length > 0 ? edge.diagnostics.map((item) => ( +
{item}
+ )) :
No edge diagnostics.
} +
+ ); +} + +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 ModelCandidatePopover({ + anchor, + title, + variable, + role, + models, + onSelectModel, + onClose, +}: { + anchor: { x: number; y: number }; + title: string; + variable: string; + role: "input" | "output"; + models: ModelDescriptor[]; + onSelectModel: (model: ModelDescriptor) => void; + onClose: () => void; +}) { + const field = role === "input" ? "outputs" : "inputs"; + const fieldLabel = role === "input" ? "Outputs" : "Inputs"; + return ( +
event.stopPropagation()}> +
+
+
{title}
+

{variable}

+
+ +
+
+ {models.map((model) => { + const declarations = modelVariableDeclarations(model, field); + return ( + + ); + })} +
+
+ ); +} + +function candidatePopoverStyle(anchor: { x: number; y: number }) { + if (typeof window === "undefined") return { left: anchor.x, top: anchor.y }; + const margin = 12; + const width = Math.min(360, window.innerWidth - margin * 2); + const maxHeight = Math.min(420, window.innerHeight - margin * 2); + const opensLeft = anchor.x + width + margin > window.innerWidth; + const left = Math.min( + Math.max(opensLeft ? anchor.x - width - 10 : anchor.x + 10, margin), + Math.max(margin, window.innerWidth - width - margin), + ); + const top = Math.min( + Math.max(anchor.y - 28, margin), + Math.max(margin, window.innerHeight - maxHeight - margin), + ); + return { left, top, width, maxHeight }; +} + +function modelVariableDeclarations(model: ModelDescriptor, field: "inputs" | "outputs"): Record { + const declarations = model[field]; + if (!declarations || typeof declarations !== "object" || Array.isArray(declarations)) return {}; + return declarations; +} + +function Row({ label, value }: { label: string; value: string }) { + return
{label}{value}
; +} + +function RateEditor({ + mode, + dt, + phase, + defaultLabel, + onModeChange, + onDtChange, + onPhaseChange, +}: { + mode: "default" | "clock"; + dt: string; + phase: string; + defaultLabel: string; + onModeChange: (mode: "default" | "clock") => void; + onDtChange: (value: string) => void; + onPhaseChange: (value: string) => void; +}) { + return ( +
+ + {mode === "default" ? ( +
Uses model default: {defaultLabel}
+ ) : ( +
+ + +
+ )} +
+ ); +} + +function ExistingModelEditor({ + node, + models, + scales, + onAddScale, + onCommand, + disabled, +}: { + node: GraphNodeData; + models: ModelDescriptor[]; + scales: string[]; + onAddScale: (scale: string) => void; + onCommand: (command: Record) => void; + disabled: boolean; +}) { + const matchingModels = useMemo(() => { + const sameProcess = models.filter((model) => model.process === node.process); + return sameProcess.length > 0 ? sameProcess : models; + }, [models, node.process]); + const initialModel = matchingModels.find((model) => model.name === node.modelType || model.type === node.modelType) ?? matchingModels[0]; + const [modelType, setModelType] = useState(initialModel?.type ?? node.modelType); + const selectedModel = matchingModels.find((model) => model.type === modelType) ?? initialModel; + const [targetScale, setTargetScale] = useState(node.scale); + const [newScale, setNewScale] = useState(""); + const initialValues = useMemo(() => { + if (!selectedModel) return {}; + return Object.fromEntries(selectedModel.constructor.fields.map((field) => [ + field.name, + node.modelParameters?.[field.name]?.value ?? parameterDefaultValue(field.default), + ])); + }, [node.modelParameters, selectedModel]); + const initialTypes = useMemo(() => { + if (!selectedModel) return {}; + return Object.fromEntries(selectedModel.constructor.fields.map((field) => [ + field.name, + node.modelParameters?.[field.name]?.type ?? field.inferredChoice, + ])); + }, [node.modelParameters, selectedModel]); + const [values, setValues] = useState>(initialValues); + const [types, setTypes] = useState>(initialTypes); + const initialTimestep = node.timestep ?? { mode: "default" as const, dt: "1.0", phase: "0.0" }; + const [rateMode, setRateMode] = useState<"default" | "clock">(initialTimestep.mode === "clock" ? "clock" : "default"); + const [rateDt, setRateDt] = useState(initialTimestep.dt ?? "1.0"); + const [ratePhase, setRatePhase] = useState(initialTimestep.phase ?? "0.0"); + + useEffect(() => { + setValues(initialValues); + setTypes(initialTypes); + }, [initialTypes, initialValues]); + + const setSharedType = useCallback((fieldName: string, nextType: string) => { + if (!selectedModel) return; + const field = selectedModel.constructor.fields.find((item) => item.name === fieldName); + const group = field?.typeParameter ? selectedModel.constructor.parameterGroups[field.typeParameter] ?? [fieldName] : [fieldName]; + setTypes((current) => ({ ...current, ...Object.fromEntries(group.map((name) => [name, nextType])) })); + }, [selectedModel]); + + const parameters = useCallback(() => { + if (!selectedModel) return {}; + return Object.fromEntries(selectedModel.constructor.fields.map((field) => [ + field.name, + { type: types[field.name] ?? field.inferredChoice, value: values[field.name] ?? "" }, + ])); + }, [selectedModel, types, values]); + + if (!selectedModel) return null; + const timestep = rateMode === "clock" ? { mode: "clock", dt: rateDt, phase: ratePhase } : { mode: "default" }; + + return ( +
+

Edit Model

+ + + + + {selectedModel.constructor.fields.map((field) => ( +
+ + setValues((current) => ({ ...current, [field.name]: event.target.value }))} /> + +
+ ))} +
+ + +
+
+ ); +} + +function VariableMappingEditor({ + target, + graphNodes, + disabled, + onCommand, +}: { + target: { node: GraphNodeData; port: GraphPort } | null; + graphNodes: GraphNodeData[]; + disabled: boolean; + onCommand: (command: Record) => void; +}) { + const sourceOptions = useMemo(() => { + if (!target) return []; + return graphNodes + .flatMap((node) => node.outputs.map((port) => ({ node, port }))) + .filter(({ node, port }) => node.id !== target.node.id || port.name !== target.port.name) + .sort((left, right) => `${left.node.scale}.${left.node.process}.${left.port.name}`.localeCompare(`${right.node.scale}.${right.node.process}.${right.port.name}`)); + }, [graphNodes, target]); + const [sourceId, setSourceId] = useState(""); + const [mode, setMode] = useState<"single" | "multi">("single"); + const [extraScales, setExtraScales] = useState([]); + + useEffect(() => { + setSourceId(sourceOptions[0]?.port.id ?? ""); + setMode("single"); + setExtraScales([]); + }, [sourceOptions]); + + if (!target) return null; + const selected = sourceOptions.find((item) => item.port.id === sourceId) ?? sourceOptions[0] ?? null; + const candidateExtraScales = selected + ? [...new Set(sourceOptions + .filter((item) => item.port.name === selected.port.name && item.node.scale !== selected.node.scale) + .map((item) => item.node.scale))] + : []; + + const toggleExtraScale = (scale: string) => { + setExtraScales((current) => + current.includes(scale) ? current.filter((item) => item !== scale) : [...current, scale] + ); + }; + + const apply = () => { + if (!selected) return; + const command: Record = { + action: "edit", + kind: "set_mapped_variable", + scale: target.node.scale, + process: target.node.process, + variable: target.port.name, + sourceScale: selected.node.scale, + sourceVariable: selected.port.name, + mode: mode === "single" && selected.node.scale === target.node.scale ? "same_scale" : mode, + }; + if (mode === "multi" && extraScales.length > 0) command.extraSourceScales = extraScales; + onCommand(command); + }; + + return ( +
+

Set Mapping

+ {sourceOptions.length === 0 ? ( +
No output variable is available as a source.
+ ) : ( + <> + +
+ + +
+ {mode === "multi" && candidateExtraScales.length > 0 && ( +
+ {candidateExtraScales.map((scale) => ( + + ))} +
+ )} + + + )} +
+ ); +} + +function InitializationPanel({ + initializations, + disabled, + onCommand, +}: { + initializations: InitializationDescriptor[]; + disabled: boolean; + onCommand: (command: Record) => void; +}) { + const grouped = useMemo(() => { + const groups = new Map(); + for (const item of initializations) { + const group = groups.get(item.scale) ?? []; + group.push(item); + groups.set(item.scale, group); + } + return groups; + }, [initializations]); + + if (initializations.length === 0) { + return
No explicit status initialization is required by the current ModelMapping.
; + } + + return ( +
+ {[...grouped.entries()].map(([scale, items]) => ( +
+

{scale}

+ {items.map((item) => ( + + ))} +
+ ))} +
+ ); +} + +function InitializationRow({ + item, + disabled, + onCommand, +}: { + item: InitializationDescriptor; + disabled: boolean; + onCommand: (command: Record) => void; +}) { + const [value, setValue] = useState(item.value); + const [type, setType] = useState(item.type); + + useEffect(() => { + setValue(item.value); + setType(item.type); + }, [item]); + + return ( +
+ + setValue(event.target.value)} + placeholder={item.provided ? "" : "initial value"} + /> + + + {item.provided ? "Stored in Status" : "Missing from Status"} +
+ ); +} + +function MappingCodePanel({ + code, + savePath, + lastSavedPath, + saveTargetPath, + autosavePath, + lastAutosavedPath, + onSavePathChange, + onSave, + disabled, +}: { + code: string; + savePath: string; + lastSavedPath: string | null; + saveTargetPath: string | null; + autosavePath: string | null; + lastAutosavedPath: string | null; + onSavePathChange: (path: string) => void; + onSave: () => void; + disabled: boolean; +}) { + const copyCode = useCallback(async () => { + if (!code) return; + await navigator.clipboard.writeText(code); + }, [code]); + + return ( +
+
+ Current Julia mapping + +
+