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.
+ +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 """ + + +
+ + +This live session is running. The React editor can connect to ws://$(session.host):$(session.port)/ws.
Current graph state is available at /state.
+ +