diff --git a/Project.toml b/Project.toml index 30c9d8b8..6514003a 100644 --- a/Project.toml +++ b/Project.toml @@ -13,10 +13,12 @@ DifferentiationInterface = "a0c0ee7d-e4b9-4e03-894e-1c5f64a51d63" Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" Markdown = "d6f4376e-aef5-505a-96c1-9c027394607a" MarkdownAST = "d0879d2d-cac2-40c8-9cee-1863dc0c7391" +Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [extensions] CTBaseDifferentiationInterface = ["DifferentiationInterface"] +CTBasePlots = ["Plots"] CoveragePostprocessing = ["Coverage"] DocumenterReference = ["Documenter", "Markdown", "MarkdownAST"] TestRunner = ["Test"] @@ -30,6 +32,7 @@ Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210" Markdown = "d6f4376e-aef5-505a-96c1-9c027394607a" MarkdownAST = "d0879d2d-cac2-40c8-9cee-1863dc0c7391" +Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [targets] @@ -41,6 +44,7 @@ test = [ "ForwardDiff", "Markdown", "MarkdownAST", + "Plots", "Test" ] @@ -54,5 +58,6 @@ Documenter = "1" ForwardDiff = "0.10, 1" Markdown = "1" MarkdownAST = "0.1" +Plots = "1" Test = "1" julia = "1.10" diff --git a/ext/CTBasePlots.jl b/ext/CTBasePlots.jl new file mode 100644 index 00000000..de46f6c0 --- /dev/null +++ b/ext/CTBasePlots.jl @@ -0,0 +1,191 @@ +module CTBasePlots + +# ============================================================================= +# CTBasePlots — the Plots.jl backend for CTBase.Plotting. +# +# It adds methods to `Plotting.render`/`render!` on `PlotsBackend`, turning the +# backend-agnostic IR (weighted tree of Axes) into a laid-out, styled Plots.Plot. +# This is the ONLY place that depends on Plots. It is a generalised port of the +# CTFlows PlotEngine: weighted layout, per-series style, decorations, x per cell. +# +# Docstrings deferred (Handbook convention). +# ============================================================================= + +using Plots: Plots +import DocStringExtensions: TYPEDSIGNATURES + +import CTBase.Plotting: + Plotting, + PlotsBackend, + Figure, + Axes, + Series, + HLine, + VLine, + Decoration, + Leaf, + HBox, + VBox, + AbstractLayoutNode, + leaves, + default_size, + render, + render! + +# --- fonts / margins (semantic sizes come from src; Plots objects made here) -- +const _TITLE_FONT = Plots.font(Plotting._TITLE_FONT_SIZE, Plots.default(:fontfamily)) +const _LABEL_FONT_SIZE = Plotting._LABEL_FONT_SIZE +const _LEFT_MARGIN = 5 * Plots.Measures.mm +const _BOTTOM_MARGIN = 5 * Plots.Measures.mm + +# --- style translation : neutral vocabulary -> Plots attributes --------------- +# Neutral keys pass straight through (Plots understands them). `z_order` drives +# draw order (Plots has no z attribute), `backend_kwargs` is the escape hatch. +function _translate_style(style::NamedTuple) + bk = get(style, :backend_kwargs, NamedTuple()) + kept = NamedTuple() + for k in keys(style) + (k === :backend_kwargs || k === :z_order) && continue + kept = merge(kept, NamedTuple{(k,)}((style[k],))) + end + return merge(kept, bk) +end + +_z(style::NamedTuple) = get(style, :z_order, :normal) +_z_rank(z::Symbol) = + if z === :back + 0 + elseif z === :front + 2 + else + 1 + end + +# Keep only user kwargs that Plots accepts as series attributes (R2). +function _keep_series_attributes(; kwargs...) + ok = Plots.attributes(:Series) + return NamedTuple(kw for kw in kwargs if kw[1] in ok) +end + +# --- ylims resolution --------------------------------------------------------- +# `nothing` -> don't touch; tuple -> set; :auto -> auto; :auto_guarded -> widen a +# (near-)constant series so the axis does not collapse. +function _resolve_ylims(ax::Axes) + yl = ax.ylims + yl === :auto_guarded || return yl + isempty(ax.series) && return :auto + ys = reduce(vcat, (s.y for s in ax.series)) + isempty(ys) && return :auto + lo, hi = extrema(ys) + return (hi - lo) ≤ 1e-8 ? (lo - 1.0, hi + 1.0) : :auto +end + +# --- drawing one Axes into subplot `sp` of plot `p` --------------------------- + +function _draw_series!(p, s::Series, sp::Int; user...) + Plots.plot!( + p, + s.x, + s.y; + subplot=sp, + label=(isempty(s.label) ? "" : s.label), + user..., + _translate_style(s.style)..., + ) + return p +end + +function _draw_decoration!(p, d::HLine, sp::Int) + Plots.hline!(p, [d.value]; subplot=sp, label="", _translate_style(d.style)...) + return p +end +function _draw_decoration!(p, d::VLine, sp::Int) + Plots.vline!(p, [d.value]; subplot=sp, label="", _translate_style(d.style)...) + return p +end + +# Draw series (in z-order) + decorations of `ax` into subplot `sp`. When +# `overlay` is true, only series/decorations are added (axis metadata untouched). +function _draw_axes!(p, ax::Axes, sp::Int; overlay::Bool=false, user...) + order = sortperm(collect(1:length(ax.series)); by=i -> _z_rank(_z(ax.series[i].style))) + for i in order + _draw_series!(p, ax.series[i], sp; user...) + end + for d in ax.decorations + _draw_decoration!(p, d, sp) + end + overlay && return p + yl = _resolve_ylims(ax) + attrs = (; + subplot=sp, + title=ax.title, + xlabel=ax.xlabel, + ylabel=ax.ylabel, + legend=(ax.legend ? :best : false), + titlefont=_TITLE_FONT, + guidefontsize=_LABEL_FONT_SIZE, + ) + yl === nothing ? Plots.plot!(p; attrs...) : Plots.plot!(p; attrs..., ylims=yl) + return p +end + +# --- new render : recursive composition of the weighted tree ------------------ + +_normalized(w) = collect(Float64, w) ./ sum(w) + +function _render_node(node::Leaf; user...) + p = Plots.plot() + _draw_axes!(p, node.axes, 1; user...) + return p +end +# A single-child box carries no geometry of its own: render the child directly +# (Plots.grid rejects a lone height/width of 1.0, and a 1×1 wrapper is useless). +function _render_node(node::VBox; user...) + length(node.children) == 1 && return _render_node(node.children[1]; user...) + subs = [_render_node(c; user...) for c in node.children] + return Plots.plot( + subs...; layout=Plots.grid(length(subs), 1; heights=_normalized(node.weights)) + ) +end +function _render_node(node::HBox; user...) + length(node.children) == 1 && return _render_node(node.children[1]; user...) + subs = [_render_node(c; user...) for c in node.children] + return Plots.plot( + subs...; layout=Plots.grid(1, length(subs); widths=_normalized(node.weights)) + ) +end + +""" +$(TYPEDSIGNATURES) + +Render `fig` into a new `Plots.Plot` (Plots backend). User `kwargs` are filtered +to Plots series attributes and forwarded to every series. +""" +function render(::PlotsBackend, fig::Figure; kwargs...) + user = _keep_series_attributes(; kwargs...) + p = _render_node(fig.root; user...) + sz = default_size(fig) + root_attrs = (; size=sz, left_margin=_LEFT_MARGIN, bottom_margin=_BOTTOM_MARGIN) + if fig.title === nothing + Plots.plot!(p; root_attrs...) + else + Plots.plot!(p; root_attrs..., plot_title=fig.title) + end + return p +end + +""" +$(TYPEDSIGNATURES) + +Overlay `fig` onto an existing Plots plot `target`, targeting existing subplots +by the deterministic leaf order. Series/decorations are added; axes untouched. +""" +function render!(::PlotsBackend, target, fig::Figure; kwargs...) + user = _keep_series_attributes(; kwargs...) + for (i, leaf) in enumerate(leaves(fig.root)) + _draw_axes!(target, leaf.axes, i; overlay=true, user...) + end + return target +end + +end # module CTBasePlots diff --git a/src/CTBase.jl b/src/CTBase.jl index 4c99ab53..108c65f0 100644 --- a/src/CTBase.jl +++ b/src/CTBase.jl @@ -60,4 +60,8 @@ using .DevTools include(joinpath(@__DIR__, "Interpolation", "Interpolation.jl")) using .Interpolation +# Plotting module - generic, domain-free plotting engine (IR + backend contract) +include(joinpath(@__DIR__, "Plotting", "Plotting.jl")) +using .Plotting + end diff --git a/src/Plotting/Plotting.jl b/src/Plotting/Plotting.jl new file mode 100644 index 00000000..b0d116ca --- /dev/null +++ b/src/Plotting/Plotting.jl @@ -0,0 +1,65 @@ +""" + Plotting + +Generic, domain-free plotting engine for the Control Toolbox. + +It manipulates a backend-agnostic intermediate representation (IR): a weighted +tree ([`Leaf`](@ref)/[`HBox`](@ref)/[`VBox`](@ref)) of titled [`Axes`](@ref) +carrying [`Series`](@ref) and [`Decoration`](@ref)s. It knows nothing about +states, controls, costates, trajectories or optimal control — the *case layers* +(CTModels, CTFlows) build the IR and hand it to a backend via [`render`](@ref). + +The IR and all its transforms live here in `src` (no backend dependency); only the +drawing lives in an extension (`CTBasePlots` for Plots.jl). See the design report +in `CTModels.jl/.reports/dev/plot_engine_ctbase_report.md`. + +# Public API +- IR: [`Series`](@ref), [`HLine`](@ref), [`VLine`](@ref), [`Axes`](@ref), + [`Leaf`](@ref), [`HBox`](@ref), [`VBox`](@ref), [`Figure`](@ref), [`leaves`](@ref) +- case-layer building blocks: [`Panel`](@ref), [`Stacked`](@ref), [`Paired`](@ref), + [`Grid`](@ref) +- backend contract: [`AbstractPlottingBackend`](@ref), [`PlotsBackend`](@ref), + [`render`](@ref), [`render!`](@ref) +""" +module Plotting + +# ============================================================================= +# Files (all backend-free, live in `src`): +# - ir.jl : the IR itself (pure data): Series, HLine/VLine, Axes, +# Leaf/HBox/VBox, Figure — plus deterministic leaf traversal. +# - panel.jl : Panel (a titled group of components, with its own x grid +# and optional per-component style) + replaceable defaults. +# - combinators.jl : level-2 declarative layout: Stacked / Paired / Grid. +# - lowering.jl : Panel/combinator -> Axes/tree (weights, ylims guard, time). +# - heuristics.jl : figure-size heuristics driven by the weighted tree. +# - contract.jl : AbstractPlottingBackend, PlotsBackend, render/render! (stubs here; +# the Plots methods live in ext/CTBasePlots.jl). +# +# Only the drawing lives in the extension. See the design report in +# CTModels.jl/.reports/dev/plot_engine_ctbase_report.md. +# ============================================================================= + +import DocStringExtensions: TYPEDEF, TYPEDSIGNATURES, TYPEDFIELDS +import CTBase.Exceptions + +include(joinpath(@__DIR__, "ir.jl")) +include(joinpath(@__DIR__, "panel.jl")) +include(joinpath(@__DIR__, "combinators.jl")) +include(joinpath(@__DIR__, "lowering.jl")) +include(joinpath(@__DIR__, "heuristics.jl")) +include(joinpath(@__DIR__, "contract.jl")) + +# --- IR ---------------------------------------------------------------------- +export Series, HLine, VLine, Axes +export AbstractLayoutNode, Leaf, HBox, VBox, Figure +export leaves + +# --- case-layer building blocks ---------------------------------------------- +export Panel +export Stacked, Paired, Grid + +# --- backend contract -------------------------------------------------------- +export AbstractPlottingBackend, PlotsBackend +export render, render! + +end # module Plotting diff --git a/src/Plotting/combinators.jl b/src/Plotting/combinators.jl new file mode 100644 index 00000000..2fd12bdf --- /dev/null +++ b/src/Plotting/combinators.jl @@ -0,0 +1,73 @@ +# ============================================================================= +# combinators.jl — level-2 declarative layout: Stacked / Paired / Grid. +# +# These are pure, typed tree builders over already-lowered layout NODES (see +# lowering.jl for Panel -> node). They add no geometry beyond weights. +# +# Weight policy `:auto` (decision D4): a child's weight is its number of drawable +# cells (`n_leaves`). Stacking N panels of k components each thus gives every cell +# the same height (proportional to the number of lines); explicit weights override. +# +# Docstrings deferred (Handbook convention). +# ============================================================================= + +# Resolve `:auto` weights to a concrete Float64 vector; otherwise pass through +# (the HBox/VBox constructor validates length and positivity). +function _resolve_weights(children::AbstractVector{<:AbstractLayoutNode}, weights) + weights === :auto && return Float64[n_leaves(c) for c in children] + return collect(Float64, weights) +end + +""" +$(TYPEDSIGNATURES) + +Stack `children` vertically into a [`VBox`](@ref). With `weights=:auto` (default, +decision D4) each child's weight is its number of cells, so stacking panels of +different heights keeps every cell the same height; pass an explicit weight vector +to override. +""" +function Stacked(children::AbstractVector{<:AbstractLayoutNode}; weights=:auto) + return VBox(children, _resolve_weights(children, weights)) +end + +""" +$(TYPEDSIGNATURES) + +Place `children` side by side into an [`HBox`](@ref) (e.g. state | costate). Same +`:auto` weight policy as [`Stacked`](@ref). A two-argument form +`Paired(left, right)` is provided for the common pair. +""" +function Paired(children::AbstractVector{<:AbstractLayoutNode}; weights=:auto) + return HBox(children, _resolve_weights(children, weights)) +end +function Paired(left::AbstractLayoutNode, right::AbstractLayoutNode; weights=:auto) + Paired(AbstractLayoutNode[left, right]; weights=weights) +end + +""" +$(TYPEDSIGNATURES) + +Arrange a rectangular matrix of nodes into a grid: `cells[i, j]` is row `i`, +column `j` (e.g. the `:group` n×m layout). Each row becomes an [`HBox`](@ref) and +the rows are stacked in a [`VBox`](@ref). Row and column weights default to equal; +pass `row_weights` / `col_weights` to override. +""" +function Grid( + cells::AbstractMatrix{<:AbstractLayoutNode}; + row_weights=ones(size(cells, 1)), + col_weights=ones(size(cells, 2)), +) + nr, nc = size(cells) + length(col_weights) == nc || throw( + Exceptions.IncorrectArgument( + "Grid needs one column weight per column"; + got="$(length(col_weights)) col_weights, $nc columns", + expected="length(col_weights) == size(cells, 2)", + ), + ) + rows = AbstractLayoutNode[ + HBox(AbstractLayoutNode[cells[i, j] for j in 1:nc], collect(Float64, col_weights)) + for i in 1:nr + ] + return VBox(rows, collect(Float64, row_weights)) +end diff --git a/src/Plotting/contract.jl b/src/Plotting/contract.jl new file mode 100644 index 00000000..5dd081ae --- /dev/null +++ b/src/Plotting/contract.jl @@ -0,0 +1,56 @@ +# ============================================================================= +# contract.jl — the backend contract (types + stubs live in src). +# +# `render`/`render!` are owned here so any package can call them on the always- +# available `Plotting` types. Backends add methods on their concrete backend type +# from a weak-dependency extension (function ownership pattern). With no backend +# loaded, the fallback throws a structured `ExtensionError` telling the user what +# to load. See ext/CTBasePlots.jl for the Plots methods. +# +# Docstrings deferred (Handbook convention). +# ============================================================================= + +""" +$(TYPEDEF) + +Supertype of rendering backends. A backend adds methods to [`render`](@ref) / +[`render!`](@ref) on its concrete type from a weak-dependency extension; the +fallback here errors when no backend is loaded. +""" +abstract type AbstractPlottingBackend end + +""" +$(TYPEDEF) + +The [Plots.jl](https://docs.juliaplots.org) backend. The type lives here in `src`; +its [`render`](@ref)/[`render!`](@ref) methods live in the `CTBasePlots` +extension, loaded automatically once `Plots` is available. +""" +struct PlotsBackend <: AbstractPlottingBackend end + +# Backend used when a caller does not pass one explicitly. +default_backend() = PlotsBackend() + +""" +$(TYPEDSIGNATURES) + +Render `fig` into a backend figure. Fallback (no backend loaded) errors with an +`ExtensionError`; a backend extension overrides this on its concrete type. +""" +function render(::AbstractPlottingBackend, ::Figure; kwargs...) + throw(Exceptions.ExtensionError(:Plots)) +end + +""" +$(TYPEDSIGNATURES) + +Overlay `fig` onto an existing backend `target`, targeting existing cells by the +deterministic leaf order (see [`leaves`](@ref)). +""" +function render!(::AbstractPlottingBackend, target, ::Figure; kwargs...) + throw(Exceptions.ExtensionError(:Plots)) +end + +# Default-backend conveniences. +render(fig::Figure; kwargs...) = render(default_backend(), fig; kwargs...) +render!(target, fig::Figure; kwargs...) = render!(default_backend(), target, fig; kwargs...) diff --git a/src/Plotting/heuristics.jl b/src/Plotting/heuristics.jl new file mode 100644 index 00000000..16cbdb9f --- /dev/null +++ b/src/Plotting/heuristics.jl @@ -0,0 +1,43 @@ +# ============================================================================= +# heuristics.jl — figure-size heuristics, driven by the weighted tree. +# +# Generalises the CTFlows engine sizes (`__size_plot`, `__size_group`): width +# grows with the number of columns, height with the number of stacked rows. Same +# per-row height and width formula so :split and :group stay visually consistent. +# +# Docstrings deferred (Handbook convention). +# ============================================================================= + +const _ROW_HEIGHT = 180 +const _HEIGHT_PAD = 60 +_width(cols::Integer) = max(600, 340 * cols) + +""" +$(TYPEDSIGNATURES) + +Return the `(rows, cols)` cell shape of a layout node: a [`Leaf`](@ref) is +`(1, 1)`; a [`VBox`](@ref) sums child rows and takes the max of child columns; an +[`HBox`](@ref) does the transpose. Drives [`default_size`](@ref). +""" +grid_shape(::Leaf) = (1, 1) +function grid_shape(node::VBox) + shapes = grid_shape.(node.children) + return (sum(first, shapes), maximum(last, shapes)) +end +function grid_shape(node::HBox) + shapes = grid_shape.(node.children) + return (maximum(first, shapes), sum(last, shapes)) +end + +""" +$(TYPEDSIGNATURES) + +Default figure size for a layout `node`: width from the number of columns, height +from the number of stacked rows. +""" +function default_size(node::AbstractLayoutNode) + rows, cols = grid_shape(node) + return (_width(cols), _ROW_HEIGHT * rows + _HEIGHT_PAD) +end + +default_size(fig::Figure) = fig.size === nothing ? default_size(fig.root) : fig.size diff --git a/src/Plotting/ir.jl b/src/Plotting/ir.jl new file mode 100644 index 00000000..1847547f --- /dev/null +++ b/src/Plotting/ir.jl @@ -0,0 +1,266 @@ +# ============================================================================= +# ir.jl — the intermediate representation (pure data, no backend dependency) +# +# Docstrings are intentionally deferred (Handbook convention: docstrings last). +# Brief comments here document structure and invariants only. +# ============================================================================= + +# --- series and decorations -------------------------------------------------- + +""" +$(TYPEDEF) + +One plotted curve: a set of `(x, y)` points. Used both for time series (`x` a time +grid) and for phase plots (`x`, `y` two state components). An empty `label` draws +no legend entry. + +`style` uses the neutral, backend-agnostic vocabulary — `color`, `linewidth`, +`linestyle`, `alpha`, `seriestype` (`:path`, `:steppost`, `:scatter`), `z_order` +(`:back`/`:front`) — plus `backend_kwargs::NamedTuple` as an escape hatch for +backend-specific attributes (decision D2). + +# Fields +$(TYPEDFIELDS) +""" +struct Series + x::Vector{Float64} + y::Vector{Float64} + label::String + style::NamedTuple +end + +function Series( + x::AbstractVector, + y::AbstractVector; + label::AbstractString="", + style::NamedTuple=NamedTuple(), +) + length(x) == length(y) || throw( + Exceptions.IncorrectArgument( + "a Series needs x and y of equal length"; + got="length(x)=$(length(x)), length(y)=$(length(y))", + expected="length(x) == length(y)", + ), + ) + return Series(collect(Float64, x), collect(Float64, y), String(label), style) +end + +""" +$(TYPEDEF) + +A horizontal reference line at height `value` (e.g. a box bound). `style` follows +the same neutral vocabulary as [`Series`](@ref). + +# Fields +$(TYPEDFIELDS) +""" +struct HLine + value::Float64 + style::NamedTuple +end +HLine(value::Real; style::NamedTuple=NamedTuple()) = HLine(Float64(value), style) + +""" +$(TYPEDEF) + +A vertical reference line at abscissa `value` (e.g. initial/final time). `style` +follows the same neutral vocabulary as [`Series`](@ref). + +# Fields +$(TYPEDFIELDS) +""" +struct VLine + value::Float64 + style::NamedTuple +end +VLine(value::Real; style::NamedTuple=NamedTuple()) = VLine(Float64(value), style) + +""" +Either an [`HLine`](@ref) or a [`VLine`](@ref) — a reference line drawn on an +[`Axes`](@ref) on top of its series. +""" +const Decoration = Union{HLine,VLine} + +# --- one cell = one axis system ---------------------------------------------- + +""" +$(TYPEDEF) + +One drawable cell: a single axis system holding a list of [`Series`](@ref), +optional [`Decoration`](@ref)s drawn on top, and its labels/limits. Domain-free — +the case layer decides what a cell *means*. + +`ylims` is one of: +- `nothing` — leave the backend default, +- `(lo, hi)` — fixed limits, +- `:auto` — backend auto-scaling, +- `:auto_guarded` — auto, but widen a (near-)constant series so the axis does not + collapse (resolved from the series data, either at lowering or by the backend). + +# Fields +$(TYPEDFIELDS) +""" +struct Axes + title::String + xlabel::String + ylabel::String + series::Vector{Series} + decorations::Vector{Decoration} + legend::Bool + ylims::Union{Nothing,Tuple{Float64,Float64},Symbol} +end + +function Axes( + series::AbstractVector{Series}; + title::AbstractString="", + xlabel::AbstractString="", + ylabel::AbstractString="", + decorations::AbstractVector=Decoration[], + legend::Bool=false, + ylims::Union{Nothing,Tuple{Real,Real},Symbol}=:auto_guarded, +) + yl = ylims isa Tuple ? (Float64(ylims[1]), Float64(ylims[2])) : ylims + return Axes( + String(title), + String(xlabel), + String(ylabel), + collect(Series, series), + collect(Decoration, decorations), + legend, + yl, + ) +end + +# --- layout : weighted tree (IR level 3) ------------------------------------- + +""" +$(TYPEDEF) + +Supertype of the weighted layout tree: [`Leaf`](@ref) (a cell), [`HBox`](@ref) +(columns side by side) and [`VBox`](@ref) (rows stacked). This is the +backend-agnostic geometry a renderer turns into subplots. +""" +abstract type AbstractLayoutNode end + +""" +$(TYPEDEF) + +A leaf of the layout tree: a single drawable cell wrapping one [`Axes`](@ref). +""" +struct Leaf <: AbstractLayoutNode + axes::Axes +end + +""" +$(TYPEDEF) + +Horizontal juxtaposition of child nodes (columns placed side by side). `weights` +are relative column widths, one per child (strictly positive). See also +[`VBox`](@ref). + +# Fields +$(TYPEDFIELDS) +""" +struct HBox <: AbstractLayoutNode + children::Vector{AbstractLayoutNode} + weights::Vector{Float64} + function HBox( + children::AbstractVector{<:AbstractLayoutNode}, weights::AbstractVector{<:Real} + ) + _check_box(children, weights, "HBox") + return new(collect(AbstractLayoutNode, children), collect(Float64, weights)) + end +end + +""" +$(TYPEDEF) + +Vertical stacking of child nodes (rows one under another). `weights` are relative +row heights, one per child (strictly positive). See also [`HBox`](@ref). + +# Fields +$(TYPEDFIELDS) +""" +struct VBox <: AbstractLayoutNode + children::Vector{AbstractLayoutNode} + weights::Vector{Float64} + function VBox( + children::AbstractVector{<:AbstractLayoutNode}, weights::AbstractVector{<:Real} + ) + _check_box(children, weights, "VBox") + return new(collect(AbstractLayoutNode, children), collect(Float64, weights)) + end +end + +# Equal-weight convenience constructors. +function HBox(children::AbstractVector{<:AbstractLayoutNode}) + HBox(children, ones(length(children))) +end +function VBox(children::AbstractVector{<:AbstractLayoutNode}) + VBox(children, ones(length(children))) +end + +function _check_box(children, weights, who::String) + isempty(children) && throw( + Exceptions.IncorrectArgument( + "a $who needs at least one child"; got="0 children", expected="≥ 1 child" + ), + ) + length(children) == length(weights) || throw( + Exceptions.IncorrectArgument( + "a $who needs one weight per child"; + got="$(length(children)) children, $(length(weights)) weights", + expected="length(weights) == length(children)", + ), + ) + all(>(0), weights) || throw( + Exceptions.IncorrectArgument( + "$who weights must be strictly positive"; + got="weights=$(collect(weights))", + expected="all weights > 0", + ), + ) + return nothing +end + +# --- figure ------------------------------------------------------------------ + +""" +$(TYPEDEF) + +A complete figure: a layout `root` plus optional overall `size` and `title`. +`size === nothing` defers to the engine size heuristic ([`default_size`](@ref)); +`title === nothing` draws no overall title. + +# Fields +$(TYPEDFIELDS) +""" +struct Figure + root::AbstractLayoutNode + size::Union{Nothing,Tuple{Int,Int}} + title::Union{Nothing,String} +end + +function Figure(root::AbstractLayoutNode; size=nothing, title=nothing) + sz = size === nothing ? nothing : (Int(size[1]), Int(size[2])) + ti = title === nothing ? nothing : String(title) + return Figure(root, sz, ti) +end + +# --- deterministic leaf traversal -------------------------------------------- + +""" +$(TYPEDSIGNATURES) + +Return the [`Leaf`](@ref)s of a node in **deterministic** order: depth-first, +children visited in stored order. This order is the contract used to target +existing cells by index when overlaying with [`render!`](@ref). +""" +leaves(leaf::Leaf) = Leaf[leaf] +function leaves(node::Union{HBox,VBox}) + reduce(vcat, (leaves(c) for c in node.children); init=Leaf[]) +end + +# Number of drawable cells in a node/figure. +n_leaves(node::AbstractLayoutNode) = length(leaves(node)) +n_leaves(fig::Figure) = n_leaves(fig.root) diff --git a/src/Plotting/lowering.jl b/src/Plotting/lowering.jl new file mode 100644 index 00000000..83cb17d4 --- /dev/null +++ b/src/Plotting/lowering.jl @@ -0,0 +1,128 @@ +# ============================================================================= +# lowering.jl — Panel -> layout node (Leaf / VBox of Leaves). +# +# Pure, backend-free. Turns a Panel into IR cells: +# - :split -> one cell per component (ylabel = component name, xlabel on the +# bottom cell only, title on the top cell only, no legend); +# - :group -> one cell, components overlaid, a legend to tell them apart. +# Applies the time transform, the ylims guard, per-component style, and attaches +# decorations (shared vertical lines to every cell; per-component horizontal +# lines to their cell in :split, all merged into the single cell in :group). +# +# Docstrings deferred (Handbook convention). +# ============================================================================= + +# Compute the plotted time axis from the `time` option. +function _time_axis(times, time::Symbol) + if time === :default + return collect(float.(times)) + elseif time === :normalize || time === :normalise + t0, tf = first(times), last(times) + return tf > t0 ? collect((times .- t0) ./ (tf - t0)) : collect(float.(times .- t0)) + else + throw( + Exceptions.IncorrectArgument( + "Invalid time normalization"; + got="time=$time", + expected=":default, :normalize or :normalise", + context="Plotting._time_axis", + ), + ) + end +end + +# y-range guard for a (near-)constant series: widen so the axis does not collapse. +# Returns a concrete `(lo, hi)` when constant, `:auto` otherwise. +function _ylims_guard(y::AbstractArray) + isempty(y) && return :auto + lo, hi = extrema(y) + return (hi - lo) ≤ 1e-8 ? (lo - 1.0, hi + 1.0) : :auto +end + +# Decorations for component `i` in :split: its own horizontal lines then the +# shared vertical lines. +function _cell_decorations(hlines, vlines, i::Integer) + own = (i ≤ length(hlines)) ? hlines[i] : HLine[] + return Decoration[own..., vlines...] +end + +""" +$(TYPEDSIGNATURES) + +Lower a [`Panel`](@ref) into a layout node. + +# Keyword arguments +- `layout`: `:split` (one cell per component) or `:group` (one cell, components + overlaid with a legend). +- `time`: `:default` (real time) or `:normalize`/`:normalise` (rescale to `[0, 1]`). +- `time_name`: x-axis label carried by the bottom cell (`:split`) or the cell + (`:group`). +- `vlines`: vertical reference lines attached to **every** produced cell (e.g. + initial/final time markers). +- `hlines`: per-component horizontal reference lines; `hlines[i]` is attached to + component `i` in `:split`, and all are merged into the single cell in `:group`. +""" +function lower( + p::Panel; + layout::Symbol=__layout(), + time::Symbol=__time(), + time_name::String="t", + vlines::AbstractVector{VLine}=VLine[], + hlines::AbstractVector=Vector{HLine}[], +) + x = _time_axis(p.x, time) + if layout === :split + return _lower_split(p, x, time_name, vlines, hlines) + elseif layout === :group + return _lower_group(p, x, time_name, vlines, hlines) + else + throw( + Exceptions.IncorrectArgument( + "Invalid layout"; + got="layout=$layout", + expected=":split or :group", + context="Plotting.lower", + ), + ) + end +end + +function _lower_split(p::Panel, x, time_name, vlines, hlines) + k = n_components(p) + cells = AbstractLayoutNode[] + for i in 1:k + y = p.data[:, i] + ax = Axes( + [Series(x, y; label="", style=component_style(p, i))]; + title=(i == 1 ? p.title : ""), + ylabel=p.labels[i], + xlabel=(i == k ? time_name : ""), + decorations=_cell_decorations(hlines, vlines, i), + legend=false, + ylims=_ylims_guard(y), + ) + push!(cells, Leaf(ax)) + end + return length(cells) == 1 ? cells[1] : VBox(cells, ones(k)) +end + +function _lower_group(p::Panel, x, time_name, vlines, hlines) + k = n_components(p) + series = [ + Series(x, p.data[:, i]; label=p.labels[i], style=component_style(p, i)) for i in 1:k + ] + merged = Decoration[] + for hl in hlines + append!(merged, hl) + end + append!(merged, vlines) + ax = Axes( + series; + title=p.title, + xlabel=time_name, + decorations=merged, + legend=true, + ylims=_ylims_guard(p.data), + ) + return Leaf(ax) +end diff --git a/src/Plotting/panel.jl b/src/Plotting/panel.jl new file mode 100644 index 00000000..7d6e8a65 --- /dev/null +++ b/src/Plotting/panel.jl @@ -0,0 +1,96 @@ +# ============================================================================= +# panel.jl — Panel: the case layer's convenient input unit, plus engine defaults. +# +# A Panel is a titled group of components sharing one time grid (e.g. all state +# components). It is *not* part of the rendered IR: the lowering turns Panels into +# Axes/Leaf nodes. Compared to the original CTFlows engine a Panel now carries: +# - its OWN x grid (multi-grid support: distinct grids per group), +# - an optional per-component style vector (finer styling than one shared style). +# +# Docstrings deferred (Handbook convention). Comments document structure only. +# ============================================================================= + +""" +$(TYPEDEF) + +A titled group of components sharing one time grid — the case layer's convenient +input unit (e.g. all state components). It is not part of the rendered IR: the +[`lower`](@ref) step turns a `Panel` into [`Leaf`](@ref)/[`Axes`](@ref) nodes. + +Each panel carries **its own** time grid `x` (so different groups may live on +different grids) and, optionally, a per-component `styles` vector (finer than the +single shared `style`). `data` is `(n_times, n_components)`. + +# Fields +$(TYPEDFIELDS) +""" +struct Panel + x::Vector{Float64} # this panel's own time grid + title::String + labels::Vector{String} # one name per component + data::Matrix{Float64} # (n_times, n_components) + style::NamedTuple # shared default style + styles::Vector{NamedTuple} # [] -> use `style`; else one style per component +end + +function Panel( + x::AbstractVector, + data::AbstractMatrix; + title::AbstractString="", + labels::AbstractVector=String[], + style::NamedTuple=NamedTuple(), + styles::AbstractVector=NamedTuple[], +) + n, k = size(data) + length(x) == n || throw( + Exceptions.IncorrectArgument( + "a Panel needs x and data with matching time length"; + got="length(x)=$(length(x)), size(data,1)=$n", + expected="length(x) == size(data, 1)", + ), + ) + lbls = isempty(labels) ? fill("", k) : collect(String, labels) + length(lbls) == k || throw( + Exceptions.IncorrectArgument( + "a Panel needs one label per component"; + got="$(length(lbls)) labels, $k components", + expected="length(labels) == size(data, 2)", + ), + ) + sty = collect(NamedTuple, styles) + (isempty(sty) || length(sty) == k) || throw( + Exceptions.IncorrectArgument( + "per-component styles must match the number of components"; + got="$(length(sty)) styles, $k components", + expected="length(styles) == 0 or == size(data, 2)", + ), + ) + return Panel( + collect(Float64, x), String(title), lbls, collect(Float64, data), style, sty + ) +end + +# Number of components (columns) of a panel. +n_components(p::Panel) = size(p.data, 2) + +# The style to apply to component `i`: its own if a per-component vector is set, +# otherwise the shared style. +component_style(p::Panel, i::Integer) = isempty(p.styles) ? p.style : p.styles[i] + +# ============================================================================= +# Replaceable defaults (double-underscore = semantic default a caller may override) +# ============================================================================= + +# Default overall layout: :split (one subplot per component). Alt: :group. +__layout() = :split + +# Default time-axis handling: :default (real time). Alt: :normalize / :normalise. +__time() = :default + +# Default series style: no override. +__style() = NamedTuple() + +# Semantic font sizes (points). Kept backend-free here; the Plots renderer turns +# them into `Plots.font`. Consistent across the engine. +const _TITLE_FONT_SIZE = 10 +const _LABEL_FONT_SIZE = 10 diff --git a/test/suite/plotting/test_combinators.jl b/test/suite/plotting/test_combinators.jl new file mode 100644 index 00000000..afaa545b --- /dev/null +++ b/test/suite/plotting/test_combinators.jl @@ -0,0 +1,66 @@ +module TestPlottingCombinators + +using Test: Test +import CTBase.Plotting +import CTBase.Exceptions + +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +_leaf() = Plotting.Leaf(Plotting.Axes([Plotting.Series([0.0, 1.0], [0.0, 1.0])])) +_col(k) = Plotting.VBox([_leaf() for _ in 1:k], ones(k)) # a column of k cells + +function test_combinators() + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "Plotting combinators" begin + Test.@testset "Stacked :auto weights ∝ cells" begin + # a 2-cell column and a single leaf -> auto weights [2, 1] + s = Plotting.Stacked(Plotting.AbstractLayoutNode[_col(2), _leaf()]) + Test.@test s isa Plotting.VBox + Test.@test s.weights == [2.0, 1.0] + Test.@test Plotting.n_leaves(s) == 3 + end + + Test.@testset "Stacked explicit weights" begin + s = Plotting.Stacked( + Plotting.AbstractLayoutNode[_leaf(), _leaf()]; weights=[3, 1] + ) + Test.@test s.weights == [3.0, 1.0] + Test.@test_throws Exceptions.IncorrectArgument Plotting.Stacked( + Plotting.AbstractLayoutNode[_leaf(), _leaf()]; weights=[1.0] + ) + end + + Test.@testset "Paired" begin + p = Plotting.Paired(_col(2), _col(2)) + Test.@test p isa Plotting.HBox + Test.@test p.weights == [2.0, 2.0] + Test.@test Plotting.n_leaves(p) == 4 + # unequal columns -> auto widths follow cell counts + p2 = Plotting.Paired(_col(3), _col(1)) + Test.@test p2.weights == [3.0, 1.0] + end + + Test.@testset "Grid" begin + cells = reshape( + Plotting.AbstractLayoutNode[_leaf(), _leaf(), _leaf(), _leaf()], 2, 2 + ) + g = Plotting.Grid(cells) + Test.@test g isa Plotting.VBox + Test.@test length(g.children) == 2 # two rows + Test.@test all(c -> c isa Plotting.HBox, g.children) + Test.@test Plotting.n_leaves(g) == 4 + # a 1×n group grid + row = reshape(Plotting.AbstractLayoutNode[_leaf(), _leaf(), _leaf()], 1, 3) + gr = Plotting.Grid(row) + Test.@test Plotting.grid_shape(gr) == (1, 3) + Test.@test_throws Exceptions.IncorrectArgument Plotting.Grid( + row; col_weights=[1.0, 1.0] + ) + end + end + return nothing +end + +end # module TestPlottingCombinators + +test_combinators() = TestPlottingCombinators.test_combinators() diff --git a/test/suite/plotting/test_contract.jl b/test/suite/plotting/test_contract.jl new file mode 100644 index 00000000..f50c2d5a --- /dev/null +++ b/test/suite/plotting/test_contract.jl @@ -0,0 +1,110 @@ +module TestPlottingContract + +using Test: Test +import CTBase.Plotting +import CTBase.Exceptions +using Plots: Plots + +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +# Fake backend at module top level (Handbook: fake types never inside functions). +# Having no specific render method, it hits the abstract fallback -> ExtensionError, +# which is exactly the "no backend loaded" behaviour, testable with Plots present. +struct DummyBackend <: Plotting.AbstractPlottingBackend end + +# Build a small figure: state (2 comps) stacked over control (1 comp), decorated. +function _figure(; title=nothing) + t = collect(range(0.0, 2.0, 51)) + px = Plotting.Panel(t, [(t .^ 2) ./ 2 t]; title="state", labels=["q", "v"]) + pu = Plotting.Panel( + t, + reshape([x < 1 ? 1.0 : -1.0 for x in t], :, 1); + title="control", + labels=["u"], + style=(seriestype=:steppost,), + ) + nx = Plotting.lower( + px; layout=:split, vlines=[Plotting.VLine(0.0), Plotting.VLine(2.0)] + ) + nu = Plotting.lower( + pu; layout=:split, hlines=[[Plotting.HLine(-1.0), Plotting.HLine(1.0)]] + ) + root = Plotting.Stacked(Plotting.AbstractLayoutNode[nx, nu]) + return Plotting.Figure(root; title=title) +end + +function test_contract() + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "Plotting backend contract" begin + Test.@testset "unknown backend -> ExtensionError" begin + fig = _figure() + Test.@test_throws Exceptions.ExtensionError Plotting.render(DummyBackend(), fig) + Test.@test_throws Exceptions.ExtensionError Plotting.render!( + DummyBackend(), nothing, fig + ) + end + + Test.@testset "render produces a Plots.Plot with the right cells" begin + fig = _figure() + plt = Plotting.render(fig) + Test.@test plt isa Plots.Plot + Test.@test length(plt.subplots) == 3 # 2 state + 1 control + Test.@test plt.attr[:size] == (600, 600) # heuristic + # explicit backend and default backend agree on cell count + Test.@test length(Plotting.render(Plotting.PlotsBackend(), fig).subplots) == 3 + end + + Test.@testset "figure title adds a title subplot" begin + plt = Plotting.render(_figure(; title="min-time")) + Test.@test length(plt.subplots) == 4 # 3 cells + title subplot + end + + Test.@testset "size override" begin + t = collect(range(0.0, 1.0, 11)) + p = Plotting.Panel(t, [t 2t]; labels=["a", "b"]) + fig = Plotting.Figure(Plotting.lower(p; layout=:split); size=(900, 400)) + Test.@test Plotting.render(fig).attr[:size] == (900, 400) + end + + Test.@testset "group and paired layouts" begin + t = collect(range(0.0, 1.0, 11)) + px = Plotting.Panel(t, [t 2t]; labels=["a", "b"]) + pu = Plotting.Panel(t, reshape(t, :, 1); labels=["u"]) + grid = Plotting.Grid( + reshape( + Plotting.AbstractLayoutNode[ + Plotting.lower(px; layout=:group), Plotting.lower(pu; layout=:group) + ], + 1, + 2, + ), + ) + Test.@test length(Plotting.render(Plotting.Figure(grid)).subplots) == 2 + paired = Plotting.Paired( + Plotting.lower(px; layout=:split), Plotting.lower(px; layout=:split) + ) + Test.@test length(Plotting.render(Plotting.Figure(paired)).subplots) == 4 + end + + Test.@testset "render! overlay keeps cell count and targets by leaf order" begin + fig = _figure() + plt = Plotting.render(fig) + n = length(plt.subplots) + out = Plotting.render!(plt, _figure(); color=1, linestyle=:dash) + Test.@test out === plt + Test.@test length(plt.subplots) == n # no new subplots + end + + Test.@testset "user kwargs filtered to series attributes (R2)" begin + fig = _figure() + # `size` and `bins` are not series attributes: dropped, no error, still valid + Test.@test Plotting.render(fig; color=3, size=(900, 600), bins=:auto) isa + Plots.Plot + end + end + return nothing +end + +end # module TestPlottingContract + +test_contract() = TestPlottingContract.test_contract() diff --git a/test/suite/plotting/test_heuristics.jl b/test/suite/plotting/test_heuristics.jl new file mode 100644 index 00000000..c7650484 --- /dev/null +++ b/test/suite/plotting/test_heuristics.jl @@ -0,0 +1,44 @@ +module TestPlottingHeuristics + +using Test: Test +import CTBase.Plotting + +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +_leaf() = Plotting.Leaf(Plotting.Axes([Plotting.Series([0.0, 1.0], [0.0, 1.0])])) +_col(k) = Plotting.VBox([_leaf() for _ in 1:k], ones(k)) + +function test_heuristics() + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "Plotting heuristics" begin + Test.@testset "grid_shape" begin + Test.@test Plotting.grid_shape(_leaf()) == (1, 1) + Test.@test Plotting.grid_shape(_col(3)) == (3, 1) # VBox stacks rows + Test.@test Plotting.grid_shape(Plotting.HBox([_leaf(), _leaf()])) == (1, 2) + # paired columns: rows = max column height, cols = sum + Test.@test Plotting.grid_shape(Plotting.HBox([_col(2), _col(3)])) == (3, 2) + # nested group grid 2×2 + cells = reshape( + Plotting.AbstractLayoutNode[_leaf(), _leaf(), _leaf(), _leaf()], 2, 2 + ) + Test.@test Plotting.grid_shape(Plotting.Grid(cells)) == (2, 2) + end + + Test.@testset "default_size" begin + # single column of 3 -> width 600, height 180*3 + 60 + Test.@test Plotting.default_size(_col(3)) == (600, 600) + # two columns -> width 340*2, one row + Test.@test Plotting.default_size(Plotting.HBox([_leaf(), _leaf()])) == + (680, 240) + # Figure size override wins + f = Plotting.Figure(_col(3); size=(800, 800)) + Test.@test Plotting.default_size(f) == (800, 800) + Test.@test Plotting.default_size(Plotting.Figure(_col(3))) == (600, 600) + end + end + return nothing +end + +end # module TestPlottingHeuristics + +test_heuristics() = TestPlottingHeuristics.test_heuristics() diff --git a/test/suite/plotting/test_ir.jl b/test/suite/plotting/test_ir.jl new file mode 100644 index 00000000..07c2fe97 --- /dev/null +++ b/test/suite/plotting/test_ir.jl @@ -0,0 +1,87 @@ +module TestPlottingIR + +using Test: Test +import CTBase.Plotting +import CTBase.Exceptions + +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +_leaf(v=[0.0, 1.0]) = Plotting.Leaf(Plotting.Axes([Plotting.Series([0.0, 1.0], v)])) + +function test_ir() + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "Plotting IR" begin + Test.@testset "Series" begin + s = Plotting.Series([0.0, 1.0], [2.0, 3.0]) + Test.@test s.label == "" + Test.@test s.style == NamedTuple() + Test.@test s.x == [0.0, 1.0] + s2 = Plotting.Series(1:3, [1, 2, 3]; label="u", style=(color=2,)) + Test.@test s2.label == "u" + Test.@test s2.x == [1.0, 2.0, 3.0] + Test.@test eltype(s2.y) === Float64 + Test.@test_throws Exceptions.IncorrectArgument Plotting.Series( + [1.0], [1.0, 2.0] + ) + end + + Test.@testset "HLine / VLine / Decoration" begin + Test.@test Plotting.HLine(1).value === 1.0 + Test.@test Plotting.VLine(2.0; style=(color=:red,)).style == (color=:red,) + Test.@test Plotting.HLine(0) isa Plotting.Decoration + Test.@test Plotting.VLine(0) isa Plotting.Decoration + end + + Test.@testset "Axes defaults" begin + ax = Plotting.Axes([Plotting.Series([0.0], [0.0])]) + Test.@test ax.title == "" && ax.xlabel == "" && ax.ylabel == "" + Test.@test ax.legend == false + Test.@test ax.decorations == Plotting.Decoration[] + Test.@test ax.ylims === :auto_guarded + ax2 = Plotting.Axes( + Plotting.Series[]; title="t", ylabel="y", legend=true, ylims=(0, 1) + ) + Test.@test ax2.ylims === (0.0, 1.0) + Test.@test ax2.legend == true + end + + Test.@testset "Leaf / HBox / VBox construction" begin + a, b = _leaf(), _leaf() + Test.@test Plotting.HBox([a, b]).weights == [1.0, 1.0] # equal default + Test.@test Plotting.VBox([a, b], [2, 1]).weights == [2.0, 1.0] + # one weight per child + Test.@test_throws Exceptions.IncorrectArgument Plotting.HBox([a, b], [1.0]) + # positive weights + Test.@test_throws Exceptions.IncorrectArgument Plotting.VBox([a, b], [1.0, 0.0]) + # non-empty + Test.@test_throws Exceptions.IncorrectArgument Plotting.HBox( + Plotting.AbstractLayoutNode[] + ) + end + + Test.@testset "deterministic leaf traversal" begin + a, b, c = _leaf([1.0, 1.0]), _leaf([2.0, 2.0]), _leaf([3.0, 3.0]) + tree = Plotting.VBox([Plotting.HBox([a, b]), c], [2, 1]) + ls = Plotting.leaves(tree) + Test.@test length(ls) == 3 + Test.@test Plotting.n_leaves(tree) == 3 + # depth-first, children in stored order: a, b, then c + Test.@test [l.axes.series[1].y[1] for l in ls] == [1.0, 2.0, 3.0] + Test.@test Plotting.leaves(a) == [a] + end + + Test.@testset "Figure" begin + f = Plotting.Figure(_leaf()) + Test.@test f.size === nothing && f.title === nothing + f2 = Plotting.Figure(_leaf(); size=(800, 600), title="fig") + Test.@test f2.size === (800, 600) + Test.@test f2.title == "fig" + Test.@test Plotting.n_leaves(f2) == 1 + end + end + return nothing +end + +end # module TestPlottingIR + +test_ir() = TestPlottingIR.test_ir() diff --git a/test/suite/plotting/test_lowering.jl b/test/suite/plotting/test_lowering.jl new file mode 100644 index 00000000..6bb26c15 --- /dev/null +++ b/test/suite/plotting/test_lowering.jl @@ -0,0 +1,119 @@ +module TestPlottingLowering + +using Test: Test +import CTBase.Plotting +import CTBase.Exceptions + +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +function test_lowering() + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "Plotting lowering" begin + t = collect(range(0.0, 2.0, 11)) + + Test.@testset "Panel construction guards" begin + Test.@test_throws Exceptions.IncorrectArgument Plotting.Panel( + [0.0, 1.0], [0.0 1.0] + ) # x length ≠ rows + Test.@test_throws Exceptions.IncorrectArgument Plotting.Panel( + t, [t t]; labels=["only-one"] + ) + Test.@test_throws Exceptions.IncorrectArgument Plotting.Panel( + t, + [t t]; + styles=[(color=1,)], # one style, two components + ) + p = Plotting.Panel(t, [t 2t]; labels=["a", "b"]) + Test.@test Plotting.n_components(p) == 2 + Test.@test Plotting.component_style(p, 1) == NamedTuple() + end + + Test.@testset "split: one cell per component, labels placed" begin + p = Plotting.Panel(t, [t 2t 3t]; title="state", labels=["a", "b", "c"]) + node = Plotting.lower(p; layout=:split) + Test.@test node isa Plotting.VBox + ls = Plotting.leaves(node) + Test.@test length(ls) == 3 + Test.@test ls[1].axes.title == "state" # title on top cell only + Test.@test ls[2].axes.title == "" + Test.@test ls[1].axes.ylabel == "a" + Test.@test ls[1].axes.xlabel == "" # xlabel on bottom only + Test.@test ls[3].axes.xlabel == "t" + Test.@test all(l -> l.axes.legend == false, ls) + end + + Test.@testset "split with a single component returns a Leaf" begin + p = Plotting.Panel(t, reshape(t, :, 1); labels=["u"]) + Test.@test Plotting.lower(p; layout=:split) isa Plotting.Leaf + end + + Test.@testset "group: one cell, components overlaid, legend on" begin + p = Plotting.Panel(t, [t 2t]; title="state", labels=["a", "b"]) + node = Plotting.lower(p; layout=:group) + Test.@test node isa Plotting.Leaf + Test.@test length(node.axes.series) == 2 + Test.@test node.axes.legend == true + Test.@test [s.label for s in node.axes.series] == ["a", "b"] + end + + Test.@testset "time transform" begin + p = Plotting.Panel(t, reshape(t, :, 1); labels=["u"]) + def = Plotting.lower(p; layout=:split, time=:default) + Test.@test def.axes.series[1].x == t + nrm = Plotting.lower(p; layout=:split, time=:normalize) + Test.@test nrm.axes.series[1].x[1] == 0.0 + Test.@test nrm.axes.series[1].x[end] == 1.0 + Test.@test_throws Exceptions.IncorrectArgument Plotting.lower(p; time=:bogus) + end + + Test.@testset "layout guard" begin + p = Plotting.Panel(t, reshape(t, :, 1); labels=["u"]) + Test.@test_throws Exceptions.IncorrectArgument Plotting.lower(p; layout=:bogus) + end + + Test.@testset "ylims guard for a constant series" begin + p = Plotting.Panel(t, reshape(fill(5.0, length(t)), :, 1); labels=["c"]) + leaf = Plotting.lower(p; layout=:split) + Test.@test leaf.axes.ylims == (4.0, 6.0) # widened around 5 + p2 = Plotting.Panel(t, reshape(t, :, 1); labels=["u"]) + Test.@test Plotting.lower(p2; layout=:split).axes.ylims === :auto + end + + Test.@testset "decorations: vlines on every cell, hlines per component" begin + p = Plotting.Panel(t, [t 2t]; labels=["a", "b"]) + node = Plotting.lower( + p; + layout=:split, + vlines=[Plotting.VLine(0.0), Plotting.VLine(2.0)], + hlines=[[Plotting.HLine(-1.0)], Plotting.HLine[]], + ) + ls = Plotting.leaves(node) + # cell 1: its hline + the two vlines ; cell 2: only the two vlines + Test.@test length(ls[1].axes.decorations) == 3 + Test.@test length(ls[2].axes.decorations) == 2 + Test.@test count(d -> d isa Plotting.VLine, ls[1].axes.decorations) == 2 + # group merges everything into the single cell + g = Plotting.lower( + p; + layout=:group, + vlines=[Plotting.VLine(0.0)], + hlines=[[Plotting.HLine(-1.0)], [Plotting.HLine(1.0)]], + ) + Test.@test length(g.axes.decorations) == 3 + end + + Test.@testset "per-component style" begin + p = Plotting.Panel( + t, [t 2t]; labels=["a", "b"], styles=[(color=1,), (color=2,)] + ) + ls = Plotting.leaves(Plotting.lower(p; layout=:split)) + Test.@test ls[1].axes.series[1].style == (color=1,) + Test.@test ls[2].axes.series[1].style == (color=2,) + end + end + return nothing +end + +end # module TestPlottingLowering + +test_lowering() = TestPlottingLowering.test_lowering()