Skip to content

Plotting Engine ​

The CTBase.Plotting submodule is a generic, domain-free plotting engine. It manipulates a backend-agnostic intermediate representation (IR): a weighted tree of titled axes carrying series and decorations. The engine knows nothing about states, controls, costates, or optimal control — case layers (CTModels, CTFlows) build the IR and hand it to a backend via CTBase.Plotting.render.

Architecture Overview ​

text
Case layer (CTModels, CTFlows, …)
    │
    │  builds
    ▼
Panel  ──lower──►  IR (Series, Axes, Leaf/HBox/VBox, Figure)
    │                           │
    │                           │  render / render!
    ▼                           ▼
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 weak-dependency extensions — CTBasePlots for Plots.jl and CTBaseMakie for Makie.jl — each loaded automatically when its backend package (Plots, or CairoMakie / GLMakie) is available.

Intermediate Representation ​

Series and Decorations ​

A CTBase.Plotting.Series is one plotted curve: (x, y) points with an optional label and a neutral style vocabulary (color, linewidth, linestyle, alpha, seriestype, z_order). A backend_kwargs NamedTuple provides an escape hatch for backend-specific attributes.

julia
using CTBase: CTBase

s = CTBase.Plotting.Series(
    [0.0, 1.0, 2.0],
    [0.0, 1.0, 0.0];
    label="x(t)",
    style=(color=:blue, linewidth=2),
)
Series "x(t)" (3 points)
  style: (color = :blue, linewidth = 2)

CTBase.Plotting.HLine and CTBase.Plotting.VLine are reference lines drawn on top of series (e.g. box bounds, initial/final time markers).

An CTBase.Plotting.Annotation is a text drawn in a cell, for example the label of a phase. Its abscissa is a data coordinate, but its ordinate is relative to the y-axis range: 0 is the bottom of the axis, 1 its top, and the default 0.92 sits near the top. The text therefore keeps its place whatever the data. The style accepts fontsize, color, halign (:left, :center, :right) and valign (:bottom, :center, :top).

julia
a = CTBase.Plotting.Annotation(0.5, "bang"; style=(color=:gray,))
Annotation "bang" at x=0.5, y=0.92 (relative)  style: (color = :gray,)

Lines and annotations are subtypes of CTBase.Plotting.AbstractDecoration. A new kind of decoration is a new subtype with a drawing method in each backend extension; a backend without that method throws a NotImplemented error when it renders it.

Axes ​

An CTBase.Plotting.Axes is a single drawable cell: one axis system holding a list of Series, optional decorations, labels, and y-limit settings.

julia
ax = CTBase.Plotting.Axes(
    [s];
    title="State",
    xlabel="t",
    ylabel="x",
)
Axes "State" (1 series)
├─ Series "x(t)" (3 points)
│    style: (color = :blue, linewidth = 2)
└─ xlabel: t, ylabel: x

The ylims field supports:

  • nothing — backend default

  • (lo, hi) — fixed limits

  • :auto — backend auto-scaling

  • :auto_guarded — auto, but widen near-constant series so the axis does not collapse

Layout Tree ​

The layout is a weighted tree with three node types:

NodeRole
CTBase.Plotting.LeafA single cell wrapping one Axes
CTBase.Plotting.HBoxColumns side by side (weights = relative widths)
CTBase.Plotting.VBoxRows stacked vertically (weights = relative heights)
julia
leaf1 = CTBase.Plotting.Leaf(ax)
leaf2 = CTBase.Plotting.Leaf(CTBase.Plotting.Axes(
    [CTBase.Plotting.Series([0.0, 1.0], [1.0, 0.0]; label="u(t)")];
    title="Control", xlabel="t", ylabel="u",
))

# Stack state above control
tree = CTBase.Plotting.VBox([leaf1, leaf2])
VBox (2 children)
├─ Leaf
│  └─ Axes "State" (1 series)
│     ├─ Series "x(t)" (3 points)
│     │    style: (color = :blue, linewidth = 2)
│     └─ xlabel: t, ylabel: x
└─ Leaf
   └─ Axes "Control" (1 series)
      ├─ Series "u(t)" (2 points)
      └─ xlabel: t, ylabel: u

Figure ​

A CTBase.Plotting.Figure bundles a layout root with optional overall size and title. When size === nothing, the engine computes a default from the tree shape.

julia
fig = CTBase.Plotting.Figure(tree; title="Solution")
Figure "Solution"
└─ VBox (2 children)
   ├─ Leaf
   │  └─ Axes "State" (1 series)
   │     ├─ Series "x(t)" (3 points)
   │     │    style: (color = :blue, linewidth = 2)
   │     └─ xlabel: t, ylabel: x
   └─ Leaf
      └─ Axes "Control" (1 series)
         ├─ Series "u(t)" (2 points)
         └─ xlabel: t, ylabel: u

Case-Layer Building Blocks ​

Panel ​

A CTBase.Plotting.Panel is the case layer's convenient input unit: a titled group of components sharing one time grid. It is not part of the rendered IR — the CTBase.Plotting.lower step turns it into Leaf/Axes nodes.

julia
t = collect(0.0:0.1:1.0)
data = hcat(sin.(t), cos.(t))  # (n_times, 2)

panel = CTBase.Plotting.Panel(
    t, data;
    title="State",
    labels=["x₁", "x₂"],
)
Panel "State" (2 components, 11 points)
├─ x₁
└─ x₂

Lowering ​

CTBase.Plotting.lower turns a Panel into a layout node. Two layouts are available:

  • :split (default) — one cell per component (ylabel = component name, xlabel on the bottom cell only, title on the top cell only, no legend).

  • :group — one cell with all components overlaid and a legend.

julia
node = CTBase.Plotting.lower(panel; layout=:split)
VBox (2 children)
├─ Leaf
│  └─ Axes "State" (1 series)
│     ├─ Series "" (11 points)
│     └─ ylabel: x₁
└─ Leaf
   └─ Axes "" (1 series)
      ├─ Series "" (11 points)
      └─ xlabel: t, ylabel: x₂

Optional keyword arguments:

  • time: :default (real time) or :normalize/:normalise (rescale to [0, 1]).

  • time_name: x-axis label for the bottom cell.

  • vlines: vertical reference lines attached to every cell.

  • hlines: per-component horizontal reference lines.

  • annotations: texts attached to the top cell in :split (the one with the title), or to the single cell in :group. Like vlines, their abscissae are in the plotted time, already normalised when time=:normalize.

julia
labelled = CTBase.Plotting.lower(
    panel;
    vlines=[CTBase.Plotting.VLine(1.0)],
    annotations=[
        CTBase.Plotting.Annotation(0.5, "phase 1"),
        CTBase.Plotting.Annotation(1.5, "phase 2"),
    ],
)
VBox (2 children)
├─ Leaf
│  └─ Axes "State" (1 series)
│     ├─ Series "" (11 points)
│     ├─ VLine(1.0)
│     ├─ Annotation("phase 1")
│     ├─ Annotation("phase 2")
│     └─ ylabel: x₁
└─ Leaf
   └─ Axes "" (1 series)
      ├─ Series "" (11 points)
      ├─ VLine(1.0)
      └─ xlabel: t, ylabel: x₂

Trajectories made of phases ​

A trajectory computed phase by phase has switching times, at which the state, costate or control may jump, and a label per phase. Three building blocks turn this structure into plot data; the case layer reads the switching times, the limits and the labels, and passes them as plain data:

  • CTBase.Plotting.splice_jumps inserts the left and right limits at every switching time into the samples of a panel, so that a jump is drawn as a vertical segment; with connect=false, the curve is broken between the two limits instead, so that no segment suggests values the trajectory does not take;

  • CTBase.Plotting.switching_vlines returns the vertical lines at the switching times, for the vlines of lower;

  • CTBase.Plotting.phase_annotations returns the labels as texts centred on each phase, for the annotations of lower. A phase with an empty label gets no text.

The last two take time like lower, and return abscissae in the plotted time.

julia
t = collect(range(0.0, 2.0, 21))
x = reshape([s < 1 ? s : s + 1 for s in t], :, 1)     # a jump of +1 at t = 1
xs, data = CTBase.Plotting.splice_jumps(t, x, [1.0], [1.0;;], [2.0;;])
jump_panel = CTBase.Plotting.Panel(xs, data; title="state", labels=["x"])
CTBase.Plotting.lower(
    jump_panel;
    vlines=CTBase.Plotting.switching_vlines(t, [1.0]; style=(linestyle=:dash,)),
    annotations=CTBase.Plotting.phase_annotations(t, [1.0], ["phase 1", "phase 2"]),
)
Leaf
└─ Axes "state" (1 series)
   ├─ Series "" (22 points)
   ├─ VLine(1.0)
   ├─ Annotation("phase 1")
   ├─ Annotation("phase 2")
   └─ xlabel: t, ylabel: x

Combinators ​

Level-2 declarative layout builders operate on already-lowered nodes:

CombinatorLayout
CTBase.Plotting.StackedVertical stack (VBox) with auto row weights
CTBase.Plotting.PairedSide-by-side (HBox) with auto column weights
CTBase.Plotting.GridRectangular grid of nodes

With weights=:auto (default), each child's weight is its extent along the combinator's axis — so cells stay uniform in size even when children have different numbers of rows or columns.

julia
state_node = CTBase.Plotting.lower(panel; layout=:split)
control_panel = CTBase.Plotting.Panel(
    t, reshape(t, :, 1);
    title="Control", labels=["u"],
)
control_node = CTBase.Plotting.lower(control_panel; layout=:split)

# Stack state above control
full = CTBase.Plotting.Stacked([state_node, control_node])
VBox (2 children)
├─ VBox (2 children)
│  ├─ Leaf
│  │  └─ Axes "State" (1 series)
│  │     ├─ Series "" (11 points)
│  │     └─ ylabel: x₁
│  └─ Leaf
│     └─ Axes "" (1 series)
│        ├─ Series "" (11 points)
│        └─ xlabel: t, ylabel: x₂
└─ Leaf
   └─ Axes "Control" (1 series)
      ├─ Series "" (21 points)
      └─ xlabel: t, ylabel: u

Backend Contract ​

CTBase.Plotting.AbstractPlottingBackend is the supertype for rendering backends. CTBase.Plotting.PlotsBackend is the concrete Plots.jl backend — its render/render! methods live in the CTBasePlots extension.

CTBase.Plotting.MakieBackend is the Makie.jl 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 turns a Figure into a backend figure. CTBase.Plotting.render! overlays a Figure onto an existing backend target, targeting cells by the deterministic leaf order (see CTBase.Plotting.leaves).

Without a backend loaded, the fallback throws an CTBase.Exceptions.ExtensionError. This cannot be demonstrated 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:

julia
CTBase.Plotting.render(fig)

The same fig rendered through the Makie backend:

julia
using CairoMakie: CairoMakie
CTBase.Plotting.render(CTBase.Plotting.MakieBackend(), fig)

User Attributes ​

render(fig; kwargs...) and render! accept extra keyword arguments. The label keywords are resolved first, on the IR; the rest are split into series attributes — forwarded to every curve — and axis attributes — applied to every cell.

Labels: title, xlabel, ylabel ​

The cell labels are computed from the IR, so a label keyword cannot simply be forwarded to the backend — it would hit every cell. render resolves them once on the backend-agnostic IR (Plotting._resolve_labels), so both backends agree:

  • xlabel replaces the x label of the cells that already carry one (the bottom cell of a split panel, the time label of a group cell);

  • title replaces the overall figure title (an empty string removes it);

  • ylabel is not applied — each cell carries one name per component — and a warning suggests changing it after rendering with the backend's own API.

julia
CTBase.Plotting.render(fig; title="Relabelled", xlabel="time")

render! overlays onto existing axes and leaves them untouched: it warns that title, xlabel and ylabel are ignored.

Series vs axis attributes ​

legend and ylims are special-cased: a user value overrides the IR default. The two backends split the remaining keywords 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, legend, ylims, titlefont, guidefontsize) are protected so the computed layout survives; subplot, titlefont and guidefontsize are ignored with a warning.

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, ylims, title and xlabel — these behave identically on both backends.

  • A genuinely backend-specific option belongs in a CTBase.Plotting.Series 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 returns the Leaf nodes of a layout tree in deterministic depth-first order. This order is the contract for targeting existing cells by index when overlaying with render!.

Function Reference ​

CategorySymbols
IRCTBase.Plotting.Series, CTBase.Plotting.AbstractDecoration, CTBase.Plotting.HLine, CTBase.Plotting.VLine, CTBase.Plotting.Annotation, CTBase.Plotting.Axes, CTBase.Plotting.Leaf, CTBase.Plotting.HBox, CTBase.Plotting.VBox, CTBase.Plotting.Figure, CTBase.Plotting.leaves
Building blocksCTBase.Plotting.Panel, CTBase.Plotting.lower
PhasesCTBase.Plotting.splice_jumps, CTBase.Plotting.switching_vlines, CTBase.Plotting.phase_annotations
CombinatorsCTBase.Plotting.Stacked, CTBase.Plotting.Paired, CTBase.Plotting.Grid
BackendCTBase.Plotting.AbstractPlottingBackend, CTBase.Plotting.PlotsBackend, CTBase.Plotting.MakieBackend, CTBase.Plotting.render, CTBase.Plotting.render!

See Also ​