diff --git a/Project.toml b/Project.toml index 7db8b1067..84eae66b4 100644 --- a/Project.toml +++ b/Project.toml @@ -10,6 +10,8 @@ DataAPI = "9a962f9c-6df0-11e9-0e5d-c546b8b5ee8a" DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" FLoops = "cc61a311-1640-44b5-9fba-1b764f453329" +InteractiveUtils = "b77e0a4c-d291-57a0-90e8-8db25a27a240" +JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" Markdown = "d6f4376e-aef5-505a-96c1-9c027394607a" MultiScaleTreeGraph = "dd4a991b-8a45-4075-bede-262ee62d5583" PlantMeteo = "4630fe09-e0fb-4da5-a846-781cb73437b6" @@ -18,6 +20,12 @@ Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" Tables = "bd369af6-aec1-5ad0-b16a-f7cc5008161c" Term = "22787eb5-b846-44ae-b979-8e399b8463ab" +[weakdeps] +HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3" + +[extensions] +PlantSimEngineGraphEditorExt = "HTTP" + [compat] AbstractTrees = "0.4" CSV = "0.10" @@ -25,6 +33,9 @@ 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" diff --git a/docs/make.jl b/docs/make.jl index 57639391f..b7f72f37c 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -34,6 +34,7 @@ makedocs(; "Detailed first simulation" => "./step_by_step/detailed_first_example.md", "Coupling" => "./step_by_step/simple_model_coupling.md", "Model Switching" => "./step_by_step/model_switching.md", + "Graph visualization and editing" => "./step_by_step/graph_visualization_editor.md", "Quick examples" => "./step_by_step/quick_and_dirty_examples.md", "Implementing a process" => "./step_by_step/implement_a_process.md", "Implementing a model" => "./step_by_step/implement_a_model.md", diff --git a/docs/src/step_by_step/graph_visualization_editor.md b/docs/src/step_by_step/graph_visualization_editor.md new file mode 100644 index 000000000..8ae09bdaf --- /dev/null +++ b/docs/src/step_by_step/graph_visualization_editor.md @@ -0,0 +1,86 @@ +# Graph visualization and editing + +`PlantSimEngine` can export a dependency graph view from a [`ModelMapping`](@ref). The static viewer is available from the core package and does not require any web server dependency. + +```julia +using PlantSimEngine +using PlantSimEngine.Examples + +mapping = ModelMapping( + ToyLAIModel(), + Beer(0.5); + status=(TT_cu=1.0:200.0,), +) + +write_graph_view("dependency_graph.html", mapping) +``` + +The same serialization path is used by the interactive editor. The editor is implemented as a Julia package extension, so the HTTP/WebSocket stack is loaded only when [`HTTP.jl`](https://github.com/JuliaWeb/HTTP.jl) is available and loaded in the active session. + +```julia +using PlantSimEngine +using PlantSimEngine.Examples +using HTTP + +mapping = ModelMapping( + ToyLAIModel(), + Beer(0.5); + status=(TT_cu=1.0:200.0,), +) + +session = edit_graph(mapping) +session.url +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 browser sends edit commands to Julia over a WebSocket. Julia remains the source of truth: it applies the edit, rebuilds the [`ModelMapping`](@ref), recompiles graph diagnostics, and sends the updated graph back to the browser. + +To stop the HTTP/WebSocket session, run: + +```julia +close(session) +``` + +Use [`current_mapping`](@ref) to recover the latest mapping from the session: + +```julia +edited_mapping = current_mapping(session) +close(session) +``` + +The web editor also exposes a dedicated "Mapping code" panel. It shows the current [`ModelMapping`](@ref) as Julia code, and can write that code to a `.jl` file so it can be copied/pasted or reused in scripts. The 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 editor extension currently supports the same edit operations as the Julia API: + +- add, remove, and replace a model at a scale; +- update an existing model's parameter values, scale, or rate from the inspector; +- keep the model default rate, or set a custom `ClockSpec(dt, phase)` when adding a model; +- set a mapped input variable, either from the inspector or by drawing a connection from an output port to an input port; +- map a scalar source value or a vector of values from one or several source scales; +- mark or unmark a variable as [`PreviousTimeStep`](@ref); +- undo and redo edits inside the live session. + +If `HTTP` is not loaded, `edit_graph(mapping)` throws an error explaining that the interactive editor requires `using HTTP`. Static graph visualization through [`write_graph_view`](@ref), `graph_view`, and [`graph_view_json`](@ref) remains available without loading `HTTP`. diff --git a/ext/PlantSimEngineGraphEditorExt.jl b/ext/PlantSimEngineGraphEditorExt.jl new file mode 100644 index 000000000..2f785cf0e --- /dev/null +++ b/ext/PlantSimEngineGraphEditorExt.jl @@ -0,0 +1,779 @@ +module PlantSimEngineGraphEditorExt + +import HTTP +import JSON +import PlantSimEngine +import PlantSimEngine: edit_graph, current_mapping, apply_edit!, undo!, redo! + +mutable struct GraphEditorSession{M,G,S} <: PlantSimEngine.AbstractGraphEditorSession + mapping::M + mtg::G + history::Vector{M} + future::Vector{M} + server::S + host::String + port::Int + 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 HTTP.forceclose(session.server) +end + +function Base.show(io::IO, session::GraphEditorSession) + print(io, "GraphEditorSession(url=\"$(session.url)\", host=\"$(session.host)\", port=$(session.port))") +end + +function Base.show(io::IO, ::MIME"text/plain", session::GraphEditorSession) + println(io, "PlantSimEngineGraphEditorExt.GraphEditorSession") + println(io, " Open in browser: $(session.url)") + println(io, " Local state JSON: $(session.url)/state") + println(io, " Quit session: close(session)") + println(io, " Current mapping: current_mapping(session)") + 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) + +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. +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, +) + # 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) + session = GraphEditorSession( + mapping, + mtg, + typeof(mapping)[], + typeof(mapping)[], + server, + String(host), + actual_port, + "http://$(host):$(actual_port)", + 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 + +_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) + push!(session.history, session.mapping) + empty!(session.future) + session.mapping = PlantSimEngine.apply_graph_edit(session.mapping, edit) + return session.mapping +end + +function undo!(session::GraphEditorSession) + isempty(session.history) && return session.mapping + push!(session.future, session.mapping) + session.mapping = pop!(session.history) + return session.mapping +end + +function redo!(session::GraphEditorSession) + isempty(session.future) && return session.mapping + push!(session.history, session.mapping) + session.mapping = pop!(session.future) + return session.mapping +end + +function _handle_http(session::GraphEditorSession, http::HTTP.Stream) + if HTTP.WebSockets.isupgrade(http.message) + return HTTP.WebSockets.upgrade(http) do ws + _handle_websocket(session, ws) + end + end + + req = http.message + path = HTTP.URI(req.target).path + response = if path == "/" || path == "/index.html" + (200, ["Content-Type" => "text/html; charset=utf-8"], _editor_html(session)) + elseif path == "/state" + (200, ["Content-Type" => "application/json"], _state_json(session)) + else + (404, ["Content-Type" => "text/plain; charset=utf-8"], "Not found") + end + status, headers, body = response + HTTP.setstatus(http, status) + for header in headers + HTTP.setheader(http, header) + end + HTTP.setheader(http, "Content-Length" => string(sizeof(body))) + HTTP.startwrite(http) + write(http, body) + return nothing +end + +""" + _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" => "ws://$(session.host):$(session.port)/ws")) + return """ + + + + + +PlantSimEngine Graph Editor + + + + +
+

PlantSimEngine Graph Editor

+

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

+

Current graph state is available at /state.

+

+
+ + + +""" +end + +function _react_editor_html(session::GraphEditorSession) + assets_dir = _frontend_dist_dir() + manifest_path = joinpath(assets_dir, ".vite", "manifest.json") + isfile(manifest_path) || return nothing + + manifest = JSON.parse(read(manifest_path, String)) + entry = nothing + for value in values(manifest) + if get(value, "isEntry", false) == true + entry = value + break + end + end + isnothing(entry) && (entry = get(manifest, "index.html", nothing)) + isnothing(entry) && return nothing + + js_file = get(entry, "file", nothing) + isnothing(js_file) && return nothing + css_files = get(entry, "css", Any[]) + js = read(joinpath(assets_dir, js_file), String) + css = join([read(joinpath(assets_dir, css_file), String) for css_file in css_files], "\n") + graph_json = PlantSimEngine.graph_view_json(session.mapping) + config_json = replace(JSON.json(Dict("websocketUrl" => "ws://$(session.host):$(session.port)/ws")), " "<\\/") + + return """ + + + + + +PlantSimEngine Graph Editor + + + + + +
+ + + +""" +end + +_frontend_dist_dir() = normpath(joinpath(@__DIR__, "..", "frontend", "dist")) + +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_model_modules!(modules, PlantSimEngine.model_(PlantSimEngine.as_model_spec(item))) + end + item isa PlantSimEngine.AbstractModel && return _collect_model_modules!(modules, item) + 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 _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))))") + return code +end + +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/src/App.tsx b/frontend/src/App.tsx index 5f8bc7d93..c3e70180f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,9 @@ -import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { Background, Controls, MiniMap, ReactFlow, - addEdge, MarkerType, useEdgesState, useNodesState, @@ -18,6 +17,7 @@ import { AlertTriangle, CircleAlert, Filter, + FolderOpen, GitPullRequestArrow, Network, RotateCcw, @@ -30,12 +30,31 @@ import { DependencyEdge } from "./DependencyEdge"; import { ModelNode } from "./ModelNode"; import { layoutGraph, type LayoutMode } from "./layout"; import { sampleGraph } from "./sampleGraph"; -import type { DependencyGraphView, GraphEdgeData, GraphNodeData, GraphPort, RuntimeGraphNodeData } from "./types"; +import type { DependencyGraphView, GraphEdgeData, GraphEditorState, GraphNodeData, GraphPort, 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 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; @@ -101,12 +120,32 @@ const layoutLabels: Record = { call_stack: "Call stack", }; +const valueTypeChoices = ["float", "integer", "boolean", "symbol", "string", "nothing", "julia"]; + export default function App() { - const [graph] = useState(loadInitialGraph()); + const [graph, setGraph] = useState(loadInitialGraph()); + const [editorModels, setEditorModels] = useState([]); + const [editorSocket, setEditorSocket] = useState(null); + const [editorConnected, setEditorConnected] = useState(false); + const [canUndo, setCanUndo] = useState(false); + const [canRedo, setCanRedo] = useState(false); + const [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 [showSearchResults, setShowSearchResults] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [layoutMode, setLayoutMode] = useState("data_flow"); @@ -115,20 +154,31 @@ export default function App() { const [collapsedScales, setCollapsedScales] = useState>(() => new Set()); const [pinnedFocus, setPinnedFocus] = useState(null); const [selectedEdge, setSelectedEdge] = useState(null); + 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) && @@ -141,6 +191,119 @@ export default function App() { [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 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(() => { const nextNodes = visibleNodeData.map((node) => ({ @@ -152,10 +315,14 @@ export default function App() { highlightedPortIds: new Set(), focusedPortIds: new Set(), requiredInputPortIds, + candidatePortIds, cycleNodeIds: new Set(graph.cycleNodes), focusedNodeIds: new Set(), hasActiveFocus: false, + activeCandidatePortId, setActivePort, + setCandidatePopover: toggleCandidatePopover, + removeGraphModel, }), })); const nextEdges = visibleEdgeData.map((edge) => flowEdge(edge, new Set(), new Set(), false, false)); @@ -163,7 +330,7 @@ export default function App() { setNodes(layouted); setEdges(nextEdges); }); - }, [graph.cycleNodes, layoutMode, requiredInputPortIds, setEdges, setNodes, visibleEdgeData, visibleNodeData]); + }, [activeCandidatePortId, candidatePortIds, graph.cycleNodes, layoutMode, removeGraphModel, requiredInputPortIds, setEdges, setNodes, toggleCandidatePopover, visibleEdgeData, visibleNodeData]); useEffect(() => { const focusEdges = focus.active ? focus.edges : new Set(); @@ -174,25 +341,40 @@ export default function App() { highlightedPortIds: hoverHighlight.ports, focusedPortIds: focus.ports, requiredInputPortIds, + candidatePortIds, cycleNodeIds: new Set(graph.cycleNodes), focusedNodeIds: focus.nodes, hasActiveFocus: focus.active, + activeCandidatePortId, setActivePort, + setCandidatePopover: toggleCandidatePopover, + removeGraphModel, }), }))); setEdges((current) => current.map((edge) => edge.data ? flowEdge(edge.data, hoverHighlight.edges, focusEdges, Boolean(activePort), focus.active) : edge)); - }, [activePort, focus, graph.cycleNodes, hoverHighlight.edges, hoverHighlight.ports, requiredInputPortIds, setEdges, setNodes]); + }, [activeCandidatePortId, activePort, candidatePortIds, focus, graph.cycleNodes, hoverHighlight.edges, hoverHighlight.ports, removeGraphModel, requiredInputPortIds, setEdges, setNodes, toggleCandidatePopover]); + + useEffect(() => { + if (candidatePopover && !candidatePortIds.has(candidatePopover.portId)) setCandidatePopover(null); + }, [candidatePopover, candidatePortIds]); const onConnect = useCallback((connection: Connection) => { - setEdges((current) => addEdge({ - ...connection, - type: "dependency", - animated: true, - markerEnd: edgeMarker(edgeColors.base), - style: edgeStyle(edgeColors.base, false), - zIndex: 5, - }, current)); - }, [setEdges]); + 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, layoutMode).then(setNodes); @@ -292,9 +474,18 @@ export default function App() { }, [flowInstance, focusEdge, focusNode, graph.edges, nodeById, nodes, portById]); return ( -
+
+ +
PlantSimEngine

Dependency Graph

@@ -377,8 +568,29 @@ export default function App() {
+ +
+ + + + +
+ + {editorSocket && ( +
+ {editorConnected ? "live" : "offline"} + + +
+ )}
+ {editorFeedback && ( +
+ {editorFeedback.text} +
+ )} + @@ -394,6 +606,18 @@ export default function App() { )} + {showOpenPanel && ( + { + sendEditorCommand({ action: "open_mapping_code", path }); + setShowOpenPanel(false); + }} + onClose={() => setShowOpenPanel(false)} + /> + )} + setShowSearchResults(false)} + onPaneClick={() => { + setShowSearchResults(false); + setCandidatePopover(null); + setShowOpenPanel(false); + }} onEdgeClick={(_, edge) => { if (edge.data) { + setCandidatePopover(null); setSelectedEdge(edge.data); setSelected(null); setActivePort(null); @@ -413,6 +642,7 @@ export default function App() { } }} onNodeClick={(_, node) => { + setCandidatePopover(null); setSelectedEdge(null); setSelected(node.data); }} @@ -425,33 +655,238 @@ export default function App() { + + {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)} + /> + )}
- + )} + + {pendingConnection && ( + { + sendEditorCommand(command); + setPendingConnection(null); + }} + onCancel={() => setPendingConnection(null)} /> -

Required Initializations

- -

Diagnostics

- {graph.diagnostics.length > 0 ? graph.diagnostics.map((item) =>
{item}
) :
No diagnostics.
} - + )}
); } +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 (
@@ -565,6 +1000,64 @@ function WarningList({ ); } +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 InspectorDetails({ selected, selectedEdge, @@ -574,7 +1067,13 @@ function InspectorDetails({ outgoingEdges, nodeById, portById, + graphNodes, onFocusEdge, + models, + scales, + onAddScale, + onCommand, + editorConnected, }: { selected: GraphNodeData | null; selectedEdge: GraphEdgeData | null; @@ -584,7 +1083,13 @@ function InspectorDetails({ 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 ( <> @@ -605,6 +1110,17 @@ function InspectorDetails({ {selected.inputs.filter((port) => port.previousTimeStep).map((port) => (
{port.name} uses previous timestep
))} + {selected.role === "model" && ( + + )}
) : !selectedEdge ? (
Select a model node.
@@ -624,6 +1140,15 @@ function InspectorDetails({ {activePort.previousTimeStep &&
uses previous timestep
} + {activePort.role === "input" && ( + + )} ) : (
Hover, click, or search a variable to see where it comes from and where it goes.
@@ -702,10 +1227,710 @@ function EdgeList({ ); } +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 + +
+