Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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]
Expand All @@ -41,6 +44,7 @@ test = [
"ForwardDiff",
"Markdown",
"MarkdownAST",
"Plots",
"Test"
]

Expand All @@ -54,5 +58,6 @@ Documenter = "1"
ForwardDiff = "0.10, 1"
Markdown = "1"
MarkdownAST = "0.1"
Plots = "1"
Test = "1"
julia = "1.10"
191 changes: 191 additions & 0 deletions ext/CTBasePlots.jl
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions src/CTBase.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
65 changes: 65 additions & 0 deletions src/Plotting/Plotting.jl
Original file line number Diff line number Diff line change
@@ -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
73 changes: 73 additions & 0 deletions src/Plotting/combinators.jl
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading