From 241e0f1dcde0ed89fa90df76be69e83c17d5fc93 Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Fri, 28 Aug 2026 10:29:04 +0200 Subject: [PATCH 1/3] feat(plotting): bring the Makie backend to parity with the Plots backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `CTBaseMakie` was a proof of concept (`render` only). Bring it level with `CTBasePlots` on the whole IR: - `seriestype` dispatch — `:steppost` → `Makie.stairs!(…; step=:post)`, `:scatter` → `Makie.scatter!`, `:path` → `Makie.lines!` (was: everything drawn as lines); - `z_order` — series drawn back-to-front, same `_z_rank` ordering as Plots; - `HLine` / `VLine` decorations via `Makie.hlines!` / `vlines!` with style translation (box bounds, initial/final-time markers); - user-kwarg partition — `(series_user, axes_user)`; axis attributes (`legend`, `ylims`, grid/scale/ticks) forwarded to every cell, unknown keys dropped; - real `render!` overlay — targets existing `Makie.Axis` blocks by deterministic leaf order; an empty target figure is filled as if by `render` (via the new `_render_into!`). `test_contract_makie.jl` mirrors `test_contract.jl` on the same IR fixture (overlay, decorations, stairs, forwarded attributes). Docs: `CairoMakie` docs dep, executed Makie `@example` in the plotting guide, `CTBaseMakie` on the API reference internals page. `make.jl` `warnonly` gains `:external_cross_references` so the self-referencing `InterLinks` bootstrap (first build produces `objects.inv`, second resolves) is non-fatal — matches CTModels.jl. Part of control-toolbox/CTModels.jl#408. Co-Authored-By: Claude Sonnet 5 --- docs/Project.toml | 2 + docs/api_reference.jl | 1 + docs/make.jl | 9 +- docs/src/guide/plotting.md | 18 +- ext/CTBaseMakie.jl | 335 ++++++++++++++++----- src/Plotting/contract.jl | 12 +- test/suite/plotting/test_contract_makie.jl | 68 ++++- 7 files changed, 340 insertions(+), 105 deletions(-) diff --git a/docs/Project.toml b/docs/Project.toml index 62d99914..013f250b 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -1,5 +1,6 @@ [deps] ADTypes = "47edcb42-4c32-4615-8424-f2b9edc5f35b" +CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0" Coverage = "a2441757-f6aa-5fb2-8edb-039e3f45d037" DifferentiationInterface = "a0c0ee7d-e4b9-4e03-894e-1c5f64a51d63" Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" @@ -13,6 +14,7 @@ Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80" [compat] ADTypes = "1" +CairoMakie = "0.15" Coverage = "1" DifferentiationInterface = "0.7" Documenter = "1" diff --git a/docs/api_reference.jl b/docs/api_reference.jl index 353bb467..bf1f1f46 100644 --- a/docs/api_reference.jl +++ b/docs/api_reference.jl @@ -277,6 +277,7 @@ function generate_api_reference(src_dir::String) ), ), (:CTBasePlots, ext(joinpath("CTBasePlots.jl"))), + (:CTBaseMakie, ext(joinpath("CTBaseMakie.jl"))), ] extmod = Base.get_extension(CTBase, sym) isnothing(extmod) && @warn "Extension $sym is not loaded" diff --git a/docs/make.jl b/docs/make.jl index 0e73171e..4ab9a33e 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -19,6 +19,7 @@ using Test using Coverage using DifferentiationInterface using Plots +using CairoMakie # Make extension modules available in Main so that @docs blocks can resolve # qualified bindings like CTFlowsSciMLIntegrator.SciMLIntegrationResult. @@ -28,6 +29,7 @@ for _ext_sym in ( :DocumenterReference, :CTBaseDifferentiationInterface, :CTBasePlots, + :CTBaseMakie, ) _m = Base.get_extension(CTBase, _ext_sym) isnothing(_m) || @eval Main const $_ext_sym = $_m @@ -96,7 +98,12 @@ with_api_reference(src_dir) do api_pages draft=draft, remotes=nothing, # Disable remote links. Needed for DocumenterReference plugins=[links], - warnonly=[:cross_references], + # `:external_cross_references` — the self-referencing `InterLinks` "CTBase" + # entry only resolves against `build/1/objects.inv`, absent on the very first + # build of a fresh checkout; that first build then completes with @errors and + # produces the inventory, and a second build is clean (see Handbook + # documentation.md). Matches CTModels.jl. + warnonly=[:cross_references, :external_cross_references], sitename="CTBase.jl", format=DocumenterVitepress.MarkdownVitepress(; repo=repo_url, devbranch="main", devurl="dev", sidebar_drawer=true diff --git a/docs/src/guide/plotting.md b/docs/src/guide/plotting.md index ea1d2497..749a2bed 100644 --- a/docs/src/guide/plotting.md +++ b/docs/src/guide/plotting.md @@ -178,13 +178,10 @@ full = CTBase.Plotting.Stacked([state_node, control_node]) [`CTBase.Plotting.PlotsBackend`](@ref) is the concrete Plots.jl backend — its `render`/`render!` methods live in the `CTBasePlots` extension. -[`CTBase.Plotting.MakieBackend`](@ref) is a proof-of-concept -[Makie.jl](https://docs.makie.org) backend (issue `CTModels#366`); its `render` -method lives in the `CTBaseMakie` extension, loaded automatically when `Makie` is -available (for example via `CairoMakie` or `GLMakie`). It implements `render` only -for the common figure shapes — `render!` (overlay), reference-line decorations, -`:steppost`/`:scatter` series types and `z_order` are not handled yet and are -tracked in a parity follow-up. +[`CTBase.Plotting.MakieBackend`](@ref) is the [Makie.jl](https://docs.makie.org) +backend, at feature parity with the Plots backend; its `render` / `render!` methods +live in the `CTBaseMakie` extension, loaded automatically when `Makie` is available +(for example via `CairoMakie` or `GLMakie`). [`CTBase.Plotting.render`](@ref) turns a `Figure` into a backend figure. [`CTBase.Plotting.render!`](@ref) overlays a `Figure` onto an existing backend target, targeting cells by the deterministic @@ -201,6 +198,13 @@ Once `Plots` is loaded, `render(fig)` produces a Plots.jl plot: CTBase.Plotting.render(fig) ``` +The same `fig` rendered through the Makie backend: + +```@example plot +using CairoMakie: CairoMakie +CTBase.Plotting.render(CTBase.Plotting.MakieBackend(), fig) +``` + ## Leaf Traversal [`CTBase.Plotting.leaves`](@ref) returns the `Leaf` nodes of a layout tree in deterministic diff --git a/ext/CTBaseMakie.jl b/ext/CTBaseMakie.jl index 1973cb3d..9ac48327 100644 --- a/ext/CTBaseMakie.jl +++ b/ext/CTBaseMakie.jl @@ -1,27 +1,22 @@ module CTBaseMakie # ============================================================================= -# CTBaseMakie — the Makie.jl backend for CTBase.Plotting (proof of concept). +# CTBaseMakie — the Makie.jl backend for CTBase.Plotting. # -# It adds a method to `Plotting.render` on `MakieBackend`, turning the -# backend-agnostic IR (weighted tree of Axes) into a laid-out Makie.Figure: the -# weighted `HBox`/`VBox` tree maps directly onto nested `Makie.GridLayout`s with -# `rowsize!`/`colsize!` set to `Makie.Auto(weight)`. -# -# Scope (issue CTModels#366): `render` only, for the common `plot(sol)` shapes. -# NOT handled yet (tracked in the parity follow-up): -# - `render!` (overlay) — throws `NotImplemented`; -# - `Decoration`s (`HLine`/`VLine`); -# - `:steppost` / `:scatter` series types — every series is drawn with `lines!`; -# - `z_order`. -# This is the ONLY place that depends on Makie. +# It adds methods to `Plotting.render`/`render!` on `MakieBackend`, turning the +# backend-agnostic IR (weighted tree of Axes) into a laid-out, styled +# `Makie.Figure`: the weighted `HBox`/`VBox` tree maps onto nested +# `Makie.GridLayout`s with `rowsize!`/`colsize!` set to `Makie.Auto(weight)`. +# This is the ONLY place that depends on Makie. It mirrors the `CTBasePlots` +# backend feature for feature: weighted layout, per-series style, `seriestype` +# dispatch, `z_order` draw ordering, `HLine`/`VLine` decorations, user-kwarg +# partition and `render!` overlay. # ============================================================================= using Makie: Makie using DocStringExtensions: TYPEDSIGNATURES using CTBase: Plotting -using CTBase: Exceptions # --- style translation : neutral vocabulary -> Makie attributes --------------- @@ -38,44 +33,123 @@ _makie_color(c) = c """ $(TYPEDSIGNATURES) -Translate a neutral series `style` `NamedTuple` into Makie `lines!` attributes. +Return the neutral `seriestype` of a style `NamedTuple` (`:path` when absent). +""" +_seriestype(style::NamedTuple) = get(style, :seriestype, :path) + +""" +$(TYPEDSIGNATURES) -Keeps `color` (via [`_makie_color`](@ref)), `linewidth`, `linestyle` and `alpha`; -drops `seriestype` and `z_order` (not handled by this POC); merges -`backend_kwargs` as the escape hatch for raw Makie options. +Return the `z_order` of a style `NamedTuple`, defaulting to `:normal`. +""" +_z(style::NamedTuple) = get(style, :z_order, :normal) + +""" +$(TYPEDSIGNATURES) + +Map a `z_order` symbol to a numeric rank for draw-order sorting: `:back` → 0, +`:normal` → 1, `:front` → 2 (same ranking as the Plots backend). +""" +_z_rank(z::Symbol) = z === :back ? 0 : z === :front ? 2 : 1 + +""" + _STYLE_DROP + +Style keys consumed by the renderer itself (not forwarded as plot attributes): +`backend_kwargs` is the raw-Makie escape hatch, `z_order` drives draw order, +`seriestype` selects the plotting function, `label` is set explicitly per series. +""" +const _STYLE_DROP = (:backend_kwargs, :z_order, :seriestype, :label) + +""" +$(TYPEDSIGNATURES) + +Translate a neutral series/decoration `style` `NamedTuple` into Makie attributes: +keep `color` (via [`_makie_color`](@ref)), `linewidth`, `linestyle`, `alpha`, …; +drop the [`_STYLE_DROP`](@ref) keys; merge `backend_kwargs` last as the escape +hatch for raw Makie options. """ function _translate_style(style::NamedTuple) kept = NamedTuple() for k in keys(style) - (k === :backend_kwargs || k === :z_order || k === :seriestype) && continue + k in _STYLE_DROP && continue v = k === :color ? _makie_color(style[k]) : style[k] kept = merge(kept, NamedTuple{(k,)}((v,))) end return merge(kept, get(style, :backend_kwargs, NamedTuple())) end +""" +$(TYPEDSIGNATURES) + +Drop line-only attributes (`linewidth`, `linestyle`) that `Makie.scatter!` rejects. +""" +_drop_line_attrs(nt::NamedTuple) = + NamedTuple(p for p in pairs(nt) if p[1] !== :linewidth && p[1] !== :linestyle) + +# --- user keyword-argument partition ----------------------------------------- + """ _SERIES_USER_KEYS -User keyword arguments forwarded to every `lines!` call by [`Plotting.render`](@extref); -all other user kwargs are ignored by this POC backend. +User keyword arguments forwarded to every series plot (`lines!` / `stairs!` / +`scatter!`). +""" +const _SERIES_USER_KEYS = (:color, :linewidth, :linestyle, :alpha, :marker, :markersize) + """ -const _SERIES_USER_KEYS = (:color, :linewidth, :linestyle, :alpha) + _AXIS_USER_KEYS + +User keyword arguments forwarded to every `Makie.Axis` constructor. `legend` and +`ylims` are handled explicitly and are not in this list; any other unknown key is +silently ignored (the Plots backend warns; `Makie.Axis` cannot accept it). +""" +const _AXIS_USER_KEYS = ( + :xscale, + :yscale, + :xgridvisible, + :ygridvisible, + :xticksvisible, + :yticksvisible, + :xticklabelsvisible, + :yticklabelsvisible, + :xreversed, + :yreversed, + :xautolimitmargin, + :yautolimitmargin, + :xticks, + :yticks, + :aspect, +) + +""" + _RESERVED_AXES_KEYS + +Axis keys the renderer sets itself; a user override of these (except `legend` / +`ylims`, handled explicitly) is ignored to preserve the computed layout. +""" +const _RESERVED_AXES_KEYS = (:title, :xlabel, :ylabel, :legend, :ylims) """ $(TYPEDSIGNATURES) -Keep only the series-relevant user keyword arguments (`_SERIES_USER_KEYS`), -translating `color` the same way series styles are translated. +Split user keyword arguments into `(series_user, axes_user)`: series attributes +([`_SERIES_USER_KEYS`](@ref)) forwarded to every series, and axis attributes +([`_AXIS_USER_KEYS`](@ref) plus `legend` / `ylims`) forwarded to every cell. +Unrecognised keys are dropped. """ -function _series_user(kwargs) - nt = NamedTuple() +function _partition_user(; kwargs...) + series = NamedTuple() + axs = NamedTuple() for (k, v) in kwargs - k in _SERIES_USER_KEYS || continue - v = k === :color ? _makie_color(v) : v - nt = merge(nt, NamedTuple{(k,)}((v,))) + if k in _SERIES_USER_KEYS + vv = k === :color ? _makie_color(v) : v + series = merge(series, NamedTuple{(k,)}((vv,))) + elseif k === :legend || k === :ylims || k in _AXIS_USER_KEYS + axs = merge(axs, NamedTuple{(k,)}((v,))) + end end - return nt + return series, axs end # --- ylims resolution (ported from CTBasePlots._resolve_ylims) ---------------- @@ -103,17 +177,77 @@ function _resolve_ylims(ax::Plotting.Axes) return (hi - lo) ≤ 1e-8 ? (lo - 1.0, hi + 1.0) : nothing end +""" +$(TYPEDSIGNATURES) + +Map a Plots-style legend position symbol (`:bottomright`, `:topleft`, …) to the +closest `Makie.axislegend` position; unknown symbols fall back to `:rt`. +""" +function _legend_position(s::Symbol) + m = ( + topright=:rt, + topleft=:lt, + bottomright=:rb, + bottomleft=:lb, + top=:ct, + bottom=:cb, + left=:lc, + right=:rc, + best=:rt, + ) + return get(m, s, :rt) +end + +# --- drawing one series / one decoration ------------------------------------- + +""" +$(TYPEDSIGNATURES) + +Draw one [`CTBase.Plotting.Series`](@extref) into `axis`, dispatching on its neutral +`seriestype`: `:path` → `Makie.lines!`, `:steppost` → `Makie.stairs!(…; step=:post)`, +`:scatter` → `Makie.scatter!`. `user` keyword arguments and the translated style +are forwarded. +""" +function _draw_one!(axis, s::Plotting.Series; user...) + attrs = _translate_style(s.style) + lbl = isempty(s.label) ? nothing : s.label + st = _seriestype(s.style) + if st === :steppost + Makie.stairs!(axis, s.x, s.y; step=:post, label=lbl, attrs..., user...) + elseif st === :scatter + Makie.scatter!(axis, s.x, s.y; label=lbl, _drop_line_attrs(attrs)..., user...) + else + Makie.lines!(axis, s.x, s.y; label=lbl, attrs..., user...) + end + return axis +end + +""" +$(TYPEDSIGNATURES) + +Draw a decoration (`HLine` or `VLine`) into `axis` via `Makie.hlines!` / `vlines!`, +with style translation. Decorations carry no legend entry. +""" +function _draw_decoration!(axis, d::Plotting.HLine) + Makie.hlines!(axis, d.value; _translate_style(d.style)...) + return axis +end +function _draw_decoration!(axis, d::Plotting.VLine) + Makie.vlines!(axis, d.value; _translate_style(d.style)...) + return axis +end + # --- drawing one Axes ------------------------------------------------------- """ $(TYPEDSIGNATURES) -Create a `Makie.Axis` at grid position `gp` and draw every [`CTBase.Plotting.Series`](@extref) -of `ax` into it with `Makie.lines!` (POC: all series types are drawn as lines). -`series_user` attributes are forwarded to every series. `ax.decorations` are -ignored by this POC backend. +Create a `Makie.Axis` at grid position `gp` for `ax`: title / labels / semantic +font sizes, the resolved y-limits (`ylims` in `axes_user` overrides the IR +default), and any forwarded [`_AXIS_USER_KEYS`](@ref) attribute. """ -function _draw_axes!(gp, ax::Plotting.Axes; series_user=NamedTuple()) +function _new_axis!(gp, ax::Plotting.Axes; axes_user=NamedTuple()) + extra = NamedTuple(p for p in pairs(axes_user) if !(p[1] in _RESERVED_AXES_KEYS)) axis = Makie.Axis( gp; title=ax.title, @@ -122,21 +256,43 @@ function _draw_axes!(gp, ax::Plotting.Axes; series_user=NamedTuple()) titlesize=Plotting._TITLE_FONT_SIZE, xlabelsize=Plotting._LABEL_FONT_SIZE, ylabelsize=Plotting._LABEL_FONT_SIZE, + extra..., ) - for s in ax.series - Makie.lines!( - axis, - s.x, - s.y; - label=(isempty(s.label) ? nothing : s.label), - _translate_style(s.style)..., - series_user..., - ) - end - yl = _resolve_ylims(ax) + yl = haskey(axes_user, :ylims) ? axes_user[:ylims] : _resolve_ylims(ax) yl === nothing || Makie.ylims!(axis, yl[1], yl[2]) - if ax.legend && any(!isempty(s.label) for s in ax.series) - Makie.axislegend(axis) + return axis +end + +""" +$(TYPEDSIGNATURES) + +Draw every series of `ax` into `axis` in `z_order`, then its decorations. +`series_user` is forwarded to every series. When `overlay` is `false`, a legend is +added if the IR asks for one (or `legend` forces it) and any series is labelled; +`overlay=true` skips the legend (the target axis keeps its own). +""" +function _draw_into_axis!( + axis, + ax::Plotting.Axes; + series_user=NamedTuple(), + legend::Union{Bool,Symbol,Nothing}=nothing, + overlay::Bool=false, +) + order = sortperm(collect(1:length(ax.series)); by=i -> _z_rank(_z(ax.series[i].style))) + for i in order + _draw_one!(axis, ax.series[i]; series_user...) + end + for d in ax.decorations + _draw_decoration!(axis, d) + end + overlay && return axis + want_legend = legend === nothing ? ax.legend : (legend !== false) + if want_legend && any(!isempty(s.label) for s in ax.series) + if legend isa Symbol + Makie.axislegend(axis; position=_legend_position(legend)) + else + Makie.axislegend(axis) + end end return axis end @@ -162,73 +318,92 @@ Recursively render a layout `node` into the `Makie.GridLayout` `gl`. A single-child box is rendered directly into `gl` (it carries no geometry). """ -function _render_node!(gl, node::Plotting.Leaf; series_user=NamedTuple()) - _draw_axes!(gl[1, 1], node.axes; series_user=series_user) +function _render_node!(gl, node::Plotting.Leaf; series_user=NamedTuple(), axes_user=NamedTuple()) + axis = _new_axis!(gl[1, 1], node.axes; axes_user=axes_user) + _draw_into_axis!( + axis, node.axes; series_user=series_user, legend=get(axes_user, :legend, nothing) + ) return gl end -function _render_node!(gl, node::Plotting.VBox; series_user=NamedTuple()) +function _render_node!(gl, node::Plotting.VBox; series_user=NamedTuple(), axes_user=NamedTuple()) if length(node.children) == 1 - return _render_node!(gl, node.children[1]; series_user=series_user) + return _render_node!( + gl, node.children[1]; series_user=series_user, axes_user=axes_user + ) end w = _normalized(node.weights) for (i, c) in enumerate(node.children) sub = gl[i, 1] = Makie.GridLayout() - _render_node!(sub, c; series_user=series_user) + _render_node!(sub, c; series_user=series_user, axes_user=axes_user) Makie.rowsize!(gl, i, Makie.Auto(w[i])) end return gl end -function _render_node!(gl, node::Plotting.HBox; series_user=NamedTuple()) +function _render_node!(gl, node::Plotting.HBox; series_user=NamedTuple(), axes_user=NamedTuple()) if length(node.children) == 1 - return _render_node!(gl, node.children[1]; series_user=series_user) + return _render_node!( + gl, node.children[1]; series_user=series_user, axes_user=axes_user + ) end w = _normalized(node.weights) for (j, c) in enumerate(node.children) sub = gl[1, j] = Makie.GridLayout() - _render_node!(sub, c; series_user=series_user) + _render_node!(sub, c; series_user=series_user, axes_user=axes_user) Makie.colsize!(gl, j, Makie.Auto(w[j])) end return gl end +# --- render / render! ------------------------------------------------------- + """ $(TYPEDSIGNATURES) -Render `fig` into a new `Makie.Figure` (Makie backend, POC). - -Series attributes among `kwargs` (`color`, `linewidth`, `linestyle`, `alpha`) are -forwarded to every series; other user kwargs are ignored. The figure size comes -from [`CTBase.Plotting.default_size`](@extref); a non-`nothing` `fig.title` is added as a -spanning `Makie.Label`. +Populate the `Makie.Figure` `f` with `fig`: the weighted tree becomes nested +`GridLayout`s, `kwargs` are partitioned by [`_partition_user`](@ref), and a +non-`nothing` `fig.title` is added as a spanning `Makie.Label`. Returns `f`. """ -function Plotting.render(::Plotting.MakieBackend, fig::Plotting.Figure; kwargs...) - su = _series_user(kwargs) - f = Makie.Figure(; size=Plotting.default_size(fig)) +function _render_into!(f::Makie.Figure, fig::Plotting.Figure; kwargs...) + series_user, axes_user = _partition_user(; kwargs...) root = f[1, 1] = Makie.GridLayout() - _render_node!(root, fig.root; series_user=su) - if fig.title !== nothing + _render_node!(root, fig.root; series_user=series_user, axes_user=axes_user) + fig.title === nothing || Makie.Label(f[0, :], fig.title; fontsize=16, font=:bold) - end return f end """ $(TYPEDSIGNATURES) -Overlay is not implemented by the Makie POC backend. +Render `fig` into a new `Makie.Figure` (Makie backend). Series attributes among +`kwargs` (`color`, `linewidth`, `linestyle`, `alpha`, …) are forwarded to every +series; axis attributes (`legend`, `ylims`, grid/scale/ticks) to every cell. The +figure size comes from [`CTBase.Plotting.default_size`](@extref). +""" +function Plotting.render(::Plotting.MakieBackend, fig::Plotting.Figure; kwargs...) + return _render_into!(Makie.Figure(; size=Plotting.default_size(fig)), fig; kwargs...) +end + +""" +$(TYPEDSIGNATURES) -# Throws -- `CTBase.Exceptions.NotImplemented`: always — Makie overlay is tracked in the - parity follow-up of CTModels#366. +Overlay `fig` onto an existing `Makie.Figure` `target`, pairing each leaf of the +layout tree with the target's `Makie.Axis` blocks in deterministic +[`CTBase.Plotting.leaves`](@extref) order; only series and decorations are added, the +axes are left untouched. An empty `target` (no axes yet) is filled as if by +[`CTBase.Plotting.render`](@extref). """ -function Plotting.render!(::Plotting.MakieBackend, target, ::Plotting.Figure; kwargs...) - return throw( - Exceptions.NotImplemented( - "Makie overlay (render!) is not implemented"; - suggestion="use the Plots backend for overlays, or wait for the parity follow-up of CTModels#366", - context="CTBaseMakie", - ), - ) +function Plotting.render!( + ::Plotting.MakieBackend, target::Makie.Figure, fig::Plotting.Figure; kwargs... +) + axs = [c for c in target.content if c isa Makie.Axis] + isempty(axs) && return _render_into!(target, fig; kwargs...) + series_user, _ = _partition_user(; kwargs...) + for (i, leaf) in enumerate(Plotting.leaves(fig.root)) + i <= length(axs) || break + _draw_into_axis!(axs[i], leaf.axes; series_user=series_user, overlay=true) + end + return target end end # module CTBaseMakie diff --git a/src/Plotting/contract.jl b/src/Plotting/contract.jl index 6cbed68c..68e65d81 100644 --- a/src/Plotting/contract.jl +++ b/src/Plotting/contract.jl @@ -29,13 +29,11 @@ struct PlotsBackend <: AbstractPlottingBackend end """ $(TYPEDEF) -The [Makie.jl](https://docs.makie.org) backend. The type lives here in `src`; its -[`CTBase.Plotting.render`](@extref) method lives in the `CTBaseMakie` extension, loaded -automatically once `Makie` is available (e.g. via `CairoMakie` / `GLMakie`). - -Added for issue CTModels#366 as a proof-of-concept: `render` only — `render!` -(overlay), reference-line decorations, `:steppost`/`:scatter` series types and -`z_order` are not handled yet. +The [Makie.jl](https://docs.makie.org) backend, at feature parity with +[`CTBase.Plotting.PlotsBackend`](@extref). The type lives here in `src`; its +[`CTBase.Plotting.render`](@extref) / [`CTBase.Plotting.render!`](@extref) methods live in the +`CTBaseMakie` extension, loaded automatically once `Makie` is available (e.g. via +`CairoMakie` / `GLMakie`). """ struct MakieBackend <: AbstractPlottingBackend end diff --git a/test/suite/plotting/test_contract_makie.jl b/test/suite/plotting/test_contract_makie.jl index ee6f9a23..e380c5bd 100644 --- a/test/suite/plotting/test_contract_makie.jl +++ b/test/suite/plotting/test_contract_makie.jl @@ -1,13 +1,15 @@ module TestPlottingContractMakie # ============================================================================= -# Contract tests for the Makie backend (CTBaseMakie extension, POC). +# Contract tests for the Makie backend (CTBaseMakie extension). # # Loaded with CairoMakie so `Makie` is present and the extension is active. -# The IR itself is tested in test_ir / test_lowering; here we only check that +# The IR itself is tested in test_ir / test_lowering; here we check that # `Plotting.render(MakieBackend(), fig)` turns the weighted tree into a laid-out -# `Makie.Figure` with the right number of axes, weights, size and title, and that -# the unsupported paths (overlay, unknown backend) error as specified. +# `Makie.Figure` with the right axes, weights, size, title, series types, +# decorations and forwarded attributes, that `render!` overlays onto an existing +# figure, and that an unknown backend errors as specified. Parity target: +# test_contract.jl (the Plots backend) on the same `_figure()` IR. # ============================================================================= using Test: Test @@ -44,9 +46,11 @@ function _figure(; title=nothing) return Plotting.Figure(root; title=title) end -_n_axes(f) = count(x -> x isa Makie.Axis, f.content) +_axes(f) = [x for x in f.content if x isa Makie.Axis] +_n_axes(f) = length(_axes(f)) _n_legends(f) = count(x -> x isa Makie.Legend, f.content) _n_labels(f) = count(x -> x isa Makie.Label, f.content) +_count_plots(axis, ::Type{T}) where {T} = count(p -> p isa T, axis.scene.plots) function test_contract_makie() Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "Plotting Makie backend contract" begin @@ -152,20 +156,64 @@ function test_contract_makie() Test.@test line.linestyle[] !== nothing # :dash expands to a dash pattern end - Test.@testset "user kwargs render without error" begin + Test.@testset "steppost and scatter series map to Stairs / Scatter" begin + # the control panel of `_figure()` is `seriestype=:steppost` + f = Plotting.render(Plotting.MakieBackend(), _figure()) + ctrl = _axes(f)[3] # 2 state cells, then control + Test.@test _count_plots(ctrl, Makie.Stairs) == 1 + Test.@test _count_plots(ctrl, Makie.Lines) == 0 + t = collect(range(0.0, 1.0, 11)) + sc = Plotting.Series(t, t; style=(seriestype=:scatter,)) + g = Plotting.render( + Plotting.MakieBackend(), Plotting.Figure(Plotting.Leaf(Plotting.Axes([sc]))) + ) + Test.@test _count_plots(_axes(g)[1], Makie.Scatter) == 1 + end + + Test.@testset "decorations are drawn as hlines / vlines" begin + f = Plotting.render(Plotting.MakieBackend(), _figure()) + axs = _axes(f) + # each state cell carries the two shared VLines + Test.@test _count_plots(axs[1], Makie.VLines) == 2 + Test.@test _count_plots(axs[2], Makie.VLines) == 2 + # the control cell carries the two HLines + Test.@test _count_plots(axs[3], Makie.HLines) == 2 + end + + Test.@testset "user kwargs: series vs axis attributes" begin fig = _figure() Test.@test Plotting.render(Plotting.MakieBackend(), fig; color=:green) isa Makie.Figure - # a non-series kwarg is ignored, not an error + # an unknown kwarg is dropped, not an error Test.@test Plotting.render( Plotting.MakieBackend(), fig; color=3, bins=:auto ) isa Makie.Figure + # a series color reaches the Lines plot + g = Plotting.render(Plotting.MakieBackend(), fig; color=:red) + line = first(p for p in _axes(g)[1].scene.plots if p isa Makie.Lines) + Test.@test line.color[] == Makie.to_color(:red) + # an axis attribute reaches every cell + h = Plotting.render(Plotting.MakieBackend(), fig; ygridvisible=false) + Test.@test all(ax -> ax.ygridvisible[] == false, _axes(h)) end - Test.@testset "overlay is not implemented" begin - Test.@test_throws Exceptions.NotImplemented Plotting.render!( - Plotting.MakieBackend(), nothing, _figure() + Test.@testset "render! overlay keeps axis count and targets by leaf order" begin + fig = _figure() + f = Plotting.render(Plotting.MakieBackend(), fig) + n = _n_axes(f) + nlines = _count_plots(_axes(f)[1], Makie.Lines) + out = Plotting.render!( + Plotting.MakieBackend(), f, _figure(); color=1, linestyle=:dash ) + Test.@test out === f + Test.@test _n_axes(f) == n # no new axes + Test.@test _count_plots(_axes(f)[1], Makie.Lines) == nlines + 1 + end + + Test.@testset "render! fills an empty figure as if by render" begin + f = Makie.Figure() + Plotting.render!(Plotting.MakieBackend(), f, _figure()) + Test.@test _n_axes(f) == Plotting.n_leaves(_figure()) end Test.@testset "unknown backend still errors (guards the fallback refactor)" begin From 1a2624f94ac84de0ca3702b15bbd8231de6549f8 Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Fri, 28 Aug 2026 13:17:23 +0200 Subject: [PATCH 2/3] fix(plotting): a user `label=` shows the legend on a :split cell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_lower_split` sets `legend=false` on every cell (the component name is the y-label, not a legend entry). Both backends took that literally, so `plot(sol; label="s1")` set the series label but never showed it — a regression from the pre-engine recipe, where Plots auto-revealed the legend for any labelled series. It also affected the Makie backend, which dropped the `label` kwarg entirely (not in `_SERIES_USER_KEYS`). - `CTBaseMakie`: `:label` added to `_SERIES_USER_KEYS`; `_draw_into_axis!` shows the legend when a series carries a non-empty label (IR or user `label`). - `CTBasePlots`: `_draw_axes!` picks `:best` instead of `false` when a series is labelled (IR or user `label`). Default `:split` figures are unchanged (no labels → no legend). Part of control-toolbox/CTModels.jl#408. Co-Authored-By: Claude Sonnet 5 --- ext/CTBaseMakie.jl | 15 ++++++++++----- ext/CTBasePlots.jl | 7 ++++++- test/suite/plotting/test_contract.jl | 10 ++++++++++ test/suite/plotting/test_contract_makie.jl | 10 ++++++++-- 4 files changed, 34 insertions(+), 8 deletions(-) diff --git a/ext/CTBaseMakie.jl b/ext/CTBaseMakie.jl index 9ac48327..0599667a 100644 --- a/ext/CTBaseMakie.jl +++ b/ext/CTBaseMakie.jl @@ -95,7 +95,8 @@ _drop_line_attrs(nt::NamedTuple) = User keyword arguments forwarded to every series plot (`lines!` / `stairs!` / `scatter!`). """ -const _SERIES_USER_KEYS = (:color, :linewidth, :linestyle, :alpha, :marker, :markersize) +const _SERIES_USER_KEYS = + (:color, :linewidth, :linestyle, :alpha, :marker, :markersize, :label) """ _AXIS_USER_KEYS @@ -268,8 +269,9 @@ $(TYPEDSIGNATURES) Draw every series of `ax` into `axis` in `z_order`, then its decorations. `series_user` is forwarded to every series. When `overlay` is `false`, a legend is -added if the IR asks for one (or `legend` forces it) and any series is labelled; -`overlay=true` skips the legend (the target axis keeps its own). +added if the IR asks for one (`:group`), if `legend` forces it, or if a series +carries a non-empty label — from the IR or from a user `label=`; `overlay=true` +skips the legend (the target axis keeps its own). """ function _draw_into_axis!( axis, @@ -286,8 +288,11 @@ function _draw_into_axis!( _draw_decoration!(axis, d) end overlay && return axis - want_legend = legend === nothing ? ax.legend : (legend !== false) - if want_legend && any(!isempty(s.label) for s in ax.series) + _labeled = + !isempty(get(series_user, :label, "")) || + any(!isempty(s.label) for s in ax.series) + want_legend = legend === nothing ? (ax.legend || _labeled) : (legend !== false) + if want_legend && _labeled if legend isa Symbol Makie.axislegend(axis; position=_legend_position(legend)) else diff --git a/ext/CTBasePlots.jl b/ext/CTBasePlots.jl index 2ef8ad36..954e3e11 100644 --- a/ext/CTBasePlots.jl +++ b/ext/CTBasePlots.jl @@ -192,7 +192,12 @@ function _draw_axes!( overlay && return p # user `legend` / `ylims` override the IR defaults; other user subplot attributes # (grid, framestyle, …) are applied as-is; reserved metadata keys are protected. - legend_val = get(axes_user, :legend, ax.legend ? :best : false) + # The legend shows when the IR asks for it (`:group`) or when a series carries a + # non-empty label — from the IR or from a user `label=` on an otherwise + # legend-less `:split` cell. + _labeled = + !isempty(get(series_user, :label, "")) || any(s -> !isempty(s.label), ax.series) + legend_val = get(axes_user, :legend, (ax.legend || _labeled) ? :best : false) yl = haskey(axes_user, :ylims) ? axes_user[:ylims] : _resolve_ylims(ax) extra = NamedTuple(kw for kw in pairs(axes_user) if !(kw[1] in _RESERVED_AXES_KEYS)) attrs = (; diff --git a/test/suite/plotting/test_contract.jl b/test/suite/plotting/test_contract.jl index 0ccbf68d..bc3c5b29 100644 --- a/test/suite/plotting/test_contract.jl +++ b/test/suite/plotting/test_contract.jl @@ -115,6 +115,16 @@ function test_contract() Test.@test any(s -> s[:label] == "sol", plt.subplots[1].series_list) Test.@test any(s -> s[:label] == "", plt.subplots[1].series_list) end + + Test.@testset "a user label turns the legend on for a :split cell" begin + # :split cells have the legend off by default; a user `label` makes it + # visible so overlaid solutions can be told apart. + fig = _figure() + plain = Plotting.render(fig) + Test.@test all(sp -> sp[:legend_position] === :none, plain.subplots) + labelled = Plotting.render(fig; label="sol") + Test.@test all(sp -> sp[:legend_position] !== :none, labelled.subplots) + end end return nothing end diff --git a/test/suite/plotting/test_contract_makie.jl b/test/suite/plotting/test_contract_makie.jl index e380c5bd..9bf3e730 100644 --- a/test/suite/plotting/test_contract_makie.jl +++ b/test/suite/plotting/test_contract_makie.jl @@ -188,13 +188,19 @@ function test_contract_makie() Test.@test Plotting.render( Plotting.MakieBackend(), fig; color=3, bins=:auto ) isa Makie.Figure - # a series color reaches the Lines plot - g = Plotting.render(Plotting.MakieBackend(), fig; color=:red) + # series color and label reach the Lines plot + g = Plotting.render(Plotting.MakieBackend(), fig; color=:red, label="sol") line = first(p for p in _axes(g)[1].scene.plots if p isa Makie.Lines) Test.@test line.color[] == Makie.to_color(:red) + Test.@test line.label[] == "sol" # an axis attribute reaches every cell h = Plotting.render(Plotting.MakieBackend(), fig; ygridvisible=false) Test.@test all(ax -> ax.ygridvisible[] == false, _axes(h)) + # a user `label` on a legend-less :split figure turns the legend on + Test.@test _n_legends(Plotting.render(Plotting.MakieBackend(), _figure())) == 0 + Test.@test _n_legends( + Plotting.render(Plotting.MakieBackend(), _figure(); label="sol") + ) >= 1 end Test.@testset "render! overlay keeps axis count and targets by leaf order" begin From 1b71e9ecd0acf15dba37f160fc5160493e6564d6 Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Fri, 28 Aug 2026 16:10:11 +0200 Subject: [PATCH 3/3] docs: name the Makie backend everywhere the engine's backends are listed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Backend Contract section and Function Reference already covered MakieBackend; the Architecture Overview diagram/prose and the "no backend loaded" note still mentioned only Plots. Bring them level, and add a "User Attributes: Series vs Axis" subsection explaining how each backend partitions user kwargs into series vs axis attributes — Plots introspects Plots.attributes(:Series), Makie uses curated whitelists and drops unknown keys. Co-Authored-By: Claude Sonnet 5 --- docs/src/getting-started.md | 2 +- docs/src/guide/plotting.md | 49 ++++++++++++++++++++++++++++++++----- docs/src/index.md | 4 +-- 3 files changed, 46 insertions(+), 9 deletions(-) diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md index 33b3719a..969449c9 100644 --- a/docs/src/getting-started.md +++ b/docs/src/getting-started.md @@ -149,5 +149,5 @@ For more, see the **[Data guide](guide/data.md)**. | Semantic color roles and themes | [Color System](guide/color-system.md) | | Linear and piecewise-constant interpolation | [Interpolation](guide/interpolation.md) | | Unicode subscript/superscript helpers | [Unicode Helpers](guide/unicode.md) | -| Backend-agnostic plotting IR and render contract | [Plotting Engine](guide/plotting.md) | +| Backend-agnostic plotting IR and render contract (Plots.jl and Makie.jl backends) | [Plotting Engine](guide/plotting.md) | | Full API reference | API Reference (left sidebar) | diff --git a/docs/src/guide/plotting.md b/docs/src/guide/plotting.md index 749a2bed..41f04cd2 100644 --- a/docs/src/guide/plotting.md +++ b/docs/src/guide/plotting.md @@ -26,13 +26,15 @@ Panel ──lower──► IR (Series, Axes, Leaf/HBox/VBox, Figure) │ │ │ │ render / render! ▼ ▼ -Combinators Backend (Plots.jl via CTBasePlots extension) -(Stacked / Paired / Grid) +Combinators Backend +(Stacked / Paired / Grid) ├─ Plots.jl → CTBasePlots extension + └─ Makie.jl → CTBaseMakie extension ``` All IR types and transforms live in `src` (no backend dependency). Only the drawing -lives in a weak-dependency extension (`CTBasePlots`, loaded automatically when -`Plots` is available). +lives in weak-dependency extensions — `CTBasePlots` for [Plots.jl](https://docs.juliaplots.org) +and `CTBaseMakie` for [Makie.jl](https://docs.makie.org) — each loaded automatically +when its backend package (`Plots`, or `CairoMakie` / `GLMakie`) is available. ## Intermediate Representation @@ -189,8 +191,9 @@ leaf order (see [`CTBase.Plotting.leaves`](@ref)). Without a backend loaded, the fallback throws an [`CTBase.Exceptions.ExtensionError`](@ref). This cannot be demonstrated -in these docs because `Plots` is loaded by `make.jl` to produce the -examples below, which causes the `CTBasePlots` extension to be active. +in these docs because `make.jl` loads both `Plots` and `CairoMakie` to +produce the examples below, so the `CTBasePlots` and `CTBaseMakie` +extensions are both active. Once `Plots` is loaded, `render(fig)` produces a Plots.jl plot: @@ -205,6 +208,40 @@ using CairoMakie: CairoMakie CTBase.Plotting.render(CTBase.Plotting.MakieBackend(), fig) ``` +## User Attributes: Series vs Axis + +`render(fig; kwargs...)` and `render!` accept extra keyword arguments and split them +into **series attributes** — forwarded to every curve — and **axis attributes** — +applied to every cell. `legend` and `ylims` are special-cased: a user value +overrides the IR default. The two backends make that split differently. + +The **Plots backend** (`CTBasePlots._partition_user`) asks Plots itself: +`Plots.attributes(:Series)` is the authoritative set of series-attribute names, so +any kwarg in it goes to the series and everything else to the subplot. A key Plots +does not recognise still reaches Plots, which emits its own warning. Layout keys the +renderer owns (`CTBasePlots._RESERVED_AXES_KEYS`: `subplot`, `title`, `xlabel`, +`ylabel`, `legend`, `ylims`, `titlefont`, `guidefontsize`) are dropped so the +computed layout survives. + +The **Makie backend** (`CTBaseMakie._partition_user`) has nothing to introspect — +Makie exposes no `attributes(:Series)` analogue — so it carries curated whitelists: +`CTBaseMakie._SERIES_USER_KEYS` (`color`, `linewidth`, `linestyle`, `alpha`, +`marker`, `markersize`, `label`) for series, `CTBaseMakie._AXIS_USER_KEYS` (a fixed +list of `Makie.Axis` constructor keys) plus `legend` / `ylims` for cells, with +`CTBaseMakie._RESERVED_AXES_KEYS` protected. **A kwarg in neither whitelist is +silently dropped** — a bare `Makie.Axis` throws on an unknown keyword, so unknown +keys cannot be forwarded the way Plots tolerates. + +For a case layer or a caller this means: + +- The portable surface is the neutral style vocabulary (`color`, `linewidth`, + `linestyle`, `alpha`, `seriestype`, `z_order`) plus `legend` and `ylims` — these + behave identically on both backends. +- A genuinely backend-specific option belongs in a [`CTBase.Plotting.Series`](@ref) + style's `backend_kwargs` escape hatch, not in a `render` kwarg: only the matching + backend honours it, and the Makie whitelist would drop it from a `render` call + anyway. + ## Leaf Traversal [`CTBase.Plotting.leaves`](@ref) returns the `Leaf` nodes of a layout tree in deterministic diff --git a/docs/src/index.md b/docs/src/index.md index 162f0350..17e8f52a 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -36,7 +36,7 @@ It provides the **base layer** shared by all packages: common types, structured | [`CTBase.Interpolation`](@ref) | Linear and piecewise-constant interpolation with flat extrapolation | | [`CTBase.DevTools`](@ref) | Developer tools with tag-based dispatch for `run_tests`, `postprocess_coverage`, and `automatic_reference_documentation` | | [`CTBase.Unicode`](@ref) | Unicode subscript/superscript helpers for display | -| [`CTBase.Plotting`](@ref) | Backend-agnostic plotting IR: series, axes, layout tree, and render contract | +| [`CTBase.Plotting`](@ref) | Backend-agnostic plotting IR: series, axes, layout tree, and a render contract with Plots.jl and Makie.jl backends | ## User Guides @@ -56,6 +56,6 @@ It provides the **base layer** shared by all packages: common types, structured - **[Color System](guide/color-system.md)** — semantic color roles, built-in themes, and runtime customization. - **[Interpolation](guide/interpolation.md)** — linear and piecewise-constant interpolation with flat extrapolation. - **[Unicode Helpers](guide/unicode.md)** — subscript and superscript character generation for display. -- **[Plotting Engine](guide/plotting.md)** — backend-agnostic plotting IR, panels, combinators, and render contract. +- **[Plotting Engine](guide/plotting.md)** — backend-agnostic plotting IR, panels, combinators, and a render contract with Plots.jl and Makie.jl backends. To browse the complete API, see the **API Reference** section in the left sidebar.