`layout_.axes` reached the viewport's right edge, so the rightmost x tick label -- centred on its tick -- had half of itself outside the plot, and the newest sample's ink landed on the frame. `Plot::set_right_gutter()` reserves a band there, out of the same `1 - kMinAxesShare` allowance the label and title strips are served from, so it can no more squeeze `axes` below the half-viewport floor than they can. `Figure` sets one value on every row, the left gutter's reason: the rows' axes have to end on a common column as well as start on one. Nothing is drawn in the band, so unlike the strips it is a want and never a measurement: no measuring pass, and it cannot feed the layout's fixed point. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
344 lines
19 KiB
C++
344 lines
19 KiB
C++
// SPDX-License-Identifier: MIT
|
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
|
|
#pragma once
|
|
#include <span>
|
|
#include <vector>
|
|
|
|
#include <plot/core/rect.hpp>
|
|
#include <plot/core/status.hpp>
|
|
#include <plot/core/types.hpp>
|
|
#include <plot/plot/axis.hpp>
|
|
#include <plot/plot/plot.hpp>
|
|
|
|
namespace plot {
|
|
|
|
/// The pixel geometry of a `Figure`: one rect per row, the strip the figure's one
|
|
/// shared x axis lives in, and the gap between two rows.
|
|
///
|
|
/// All of it is in the same pixel space as `Rect` and `PlotLayout` -- origin
|
|
/// top-left, device pixels -- and all of it is *computed* here and consumed by the
|
|
/// renderer, exactly as `LegendLayout` and the region geometry are: the renderer
|
|
/// never derives a row rect or a strip of its own, so what a host reads back and
|
|
/// what a frame paints are one set of numbers.
|
|
struct FigureLayout {
|
|
/// One rect per row, top to bottom, in row order: the **viewport** each row's
|
|
/// `Plot` was given, not the `axes` inside it. A row's own label strips, title
|
|
/// strips and axes come from its own `Plot::layout()` against this rect, so a row
|
|
/// with a y title keeps its column and no two rows can disagree about chrome.
|
|
std::vector<Rect> rows;
|
|
/// The strip the figure's one shared x axis is drawn in: the bottom row's own
|
|
/// `PlotLayout::x_label_area`, at the bottom of the bottom row's rect and so as
|
|
/// wide as that row's axes rather than the whole figure. Read back from the row
|
|
/// rather than derived here, so a host and the frame cannot disagree about where
|
|
/// the axis is. It is nonzero whenever the figure has rows and the bottom row
|
|
/// reserves a label strip -- including the case where every row labels itself, in
|
|
/// which case it is simply the bottom row's own strip, one of several.
|
|
///
|
|
/// It is the rect the controller's axis-zoom hit test reads (`x_label_area` is what
|
|
/// a middle drag in a label strip enters `DragMode::AxisZoomX` from), which is why
|
|
/// the shared axis lives in a row rather than in a band of its own: a strip outside
|
|
/// every row would be a zero-height rect no press can ever land in.
|
|
Rect x_axis_strip;
|
|
/// The vertical gap between two rows, in device pixels. 0 when the rows tile the
|
|
/// figure edge to edge, which is the default; it is dropped for a figure too
|
|
/// short to hold it, so the rows never lose their height to a gap.
|
|
f32 row_gap = 0.0f;
|
|
};
|
|
|
|
/// Several plots stacked in one column, sharing one x axis.
|
|
///
|
|
/// A `Figure` owns its rows' `Plot`s -- one per row, laid out top to bottom in the
|
|
/// figure's viewport -- and the link groups that make their x ranges one range. It
|
|
/// is pure CPU state like `Plot`: no GL context, no font of its own, and the
|
|
/// renderer that draws it is the one that installs the text backend and hands each
|
|
/// row its metrics. `Renderer::draw(Figure&)` draws it by giving every row its
|
|
/// viewport and then drawing it exactly as it draws a single plot, plus the one
|
|
/// shared x axis under the last row; `Renderer::draw(Plot&)` is untouched.
|
|
///
|
|
/// **The shared x axis.** Row heights are an equal division of the figure by
|
|
/// default, `set_row_weights()` gives a row more of it, and every row keeps a floor
|
|
/// height so chrome can never erase a plot. When `set_x_labels_only_on_bottom()` is
|
|
/// on -- the default -- the figure's one x axis is the **bottom row's own**: the rows
|
|
/// above reserve no x chrome at all, and the bottom row keeps its own label and title
|
|
/// strips, so those labels and that title are drawn once, from the bottom row's own
|
|
/// ticks and transform. That places the shared axis *inside* a row, where the
|
|
/// controller's axis-zoom hit test can reach it, instead of in a band outside every
|
|
/// row that no gesture could land in.
|
|
///
|
|
/// **The x link.** `plot(i).set_x_range()` and a controller gesture both reach a
|
|
/// member's x range the same way, so a figure cannot see either of them happen.
|
|
/// What it can see is the *result*: `layout()` compares each member's x range with
|
|
/// what it last pushed into it, and a member that holds anything else was changed
|
|
/// from outside -- so that becomes the group's range and is pushed to every member
|
|
/// before the frame is laid out. A pan or a zoom anywhere therefore moves every row
|
|
/// together, and the comparison is what makes the propagation terminate: a push
|
|
/// writes exactly the value the next comparison reads, so the group never re-pushes
|
|
/// what it just wrote and a frame's propagation settles in one pass.
|
|
///
|
|
/// A group also owns whether its rows *follow*, which is why it exists rather than
|
|
/// the axes being linked to each other. `XFollow::Live` reinterprets
|
|
/// `Plot::set_x_range()`'s argument as a span and an offset behind the live edge,
|
|
/// and recomputes the window from the newest sample every layout; a group pushes
|
|
/// *one* window to many axes, so the follow state has to be the group's or the same
|
|
/// range would mean a different window in each member. With `set_x_link_follow(g,
|
|
/// XFollow::Live)` the group derives its window once per frame from the newest x
|
|
/// across the traces of all its members -- exactly as a single `Live` axis does --
|
|
/// and pushes it to every member, so a streaming multi-row figure has one window
|
|
/// and no per-row drift. A member's own axis must therefore be `Off`: `link_x()`
|
|
/// refuses a `Live` axis with a `Status` rather than quietly reinterpreting the
|
|
/// caller's state.
|
|
class Figure {
|
|
public:
|
|
/// A figure of `rows` rows. Every row starts with its own `Plot` and in group 0,
|
|
/// which is the *unlinked* group: rows that share nothing, each with the range
|
|
/// its own plot was given.
|
|
explicit Figure(u32 rows);
|
|
|
|
u32 row_count() const noexcept { return static_cast<u32>(plots_.size()); }
|
|
/// One row's plot. A row that does not exist yields a default-constructed `Plot`
|
|
/// rather than reading out of bounds -- the rule every accessor on `Plot` follows
|
|
/// -- so a stale row can only ever affect itself.
|
|
Plot& plot(u32 row) noexcept;
|
|
const Plot& plot(u32 row) const noexcept;
|
|
|
|
// --- geometry ---------------------------------------------------------
|
|
|
|
/// The rectangle the whole figure occupies, in framebuffer pixels.
|
|
void set_viewport(Rect px) noexcept { viewport_ = px; }
|
|
const Rect& viewport() const noexcept { return viewport_; }
|
|
|
|
/// How many of the figure's height each row gets, relative to the others:
|
|
/// `weights[i]` is row i's share. Normalised, so any positive scale does. Refused
|
|
/// -- nothing changes -- when the count does not match the rows, when a weight is
|
|
/// not a positive finite number, or when the sum is not positive: a zero or
|
|
/// negative weight has no share of anything to give a row, and the rect that would
|
|
/// follow from it is degenerate.
|
|
Status set_row_weights(std::span<const f32> weights);
|
|
/// Back to an equal division of the figure.
|
|
void clear_row_weights() noexcept { row_weights_.clear(); }
|
|
bool has_row_weights() const noexcept { return !row_weights_.empty(); }
|
|
|
|
/// The gap left between two rows, in device pixels (0.0 by default, so the rows
|
|
/// tile the figure). Negative values are clamped to 0, and a gap too large for the
|
|
/// figure to hold is dropped for that layout rather than squeezing the rows to
|
|
/// nothing.
|
|
void set_row_gap(f32 device_px) noexcept { row_gap_ = device_px > 0.0f ? device_px : 0.0f; }
|
|
f32 row_gap() const noexcept { return row_gap_; }
|
|
|
|
/// A floor under the figure's common left gutter, in device pixels. The gutter
|
|
/// `layout()` imposes is the maximum over the rows' *measured* needs, so it follows
|
|
/// the tick labels: a `TickFormat::Si` axis whose range moves from `1.5` to `150` to
|
|
/// `1.5k` changes the width of its labels, and with it `axes.x` and `axes.w` of every
|
|
/// row. A caller that needs a layout which does not depend on content reserves the
|
|
/// widest label its formatter can produce once, through this, and the figure takes the
|
|
/// larger of the two. Negative values are clamped to 0 (the default: measure only).
|
|
void set_min_y_gutter(f32 device_px) noexcept {
|
|
min_y_gutter_ = device_px > 0.0f ? device_px : 0.0f;
|
|
}
|
|
f32 min_y_gutter() const noexcept { return min_y_gutter_; }
|
|
|
|
/// The empty band every row keeps to the right of its `axes`, in device pixels (0.0
|
|
/// by default, so `axes` reaches the viewport's right edge). It is set here rather
|
|
/// than row by row for the left gutter's reason: the rows' axes have to line up, and
|
|
/// they end on a common column only if they are told one width. See
|
|
/// `Plot::set_right_gutter()` for what it is for. Negative values are clamped to 0.
|
|
void set_right_gutter(f32 device_px) noexcept {
|
|
right_gutter_ = device_px > 0.0f ? device_px : 0.0f;
|
|
}
|
|
f32 right_gutter() const noexcept { return right_gutter_; }
|
|
|
|
/// Whether the shared x axis is the figure's: every row reserves no x label strip
|
|
/// and one shared axis is drawn under the last row (the default), or every row
|
|
/// draws its own labels and the figure draws nothing extra.
|
|
void set_x_labels_only_on_bottom(bool on) noexcept { labels_bottom_only_ = on; }
|
|
bool x_labels_only_on_bottom() const noexcept { return labels_bottom_only_; }
|
|
|
|
/// Recomputes the row rects, hands each row its viewport and its x-label policy,
|
|
/// advances the link groups, and returns the layout. Call it once per frame; the
|
|
/// const overload never recomputes.
|
|
///
|
|
/// This is also where a row's `PlotLayout` becomes well-defined: a row's chrome is
|
|
/// laid out against `rows[i]` by its own `Plot::layout()`, which is why the figure
|
|
/// computes rects and not axes.
|
|
///
|
|
/// It is also the one place the rows' left chrome can be made to agree (spec §16):
|
|
/// each row is laid out once to learn the gutter its own labels and title column
|
|
/// need, the figure takes the maximum and imposes it on every row through
|
|
/// `Plot::set_min_y_gutter()` -- never below `set_min_y_gutter()`'s own floor -- before
|
|
/// laying the rows out once more, so every row's
|
|
/// `axes.x` and `axes.w` are equal and a vertical feature lands in the same pixel
|
|
/// column in each. Two passes per row per call, not a loop: the gutter is a maximum
|
|
/// over rows and a row's own strip width does not depend on it.
|
|
const FigureLayout& layout();
|
|
const FigureLayout& layout() const noexcept { return layout_; }
|
|
|
|
// --- the crosshair ----------------------------------------------------
|
|
|
|
/// Places one crosshair x on **every** row (spec §15): the same data coordinate, so each
|
|
/// row's readout lists its own traces at that instant and the rows read as one hover
|
|
/// rather than N. That is what "synced if multiple plots available" means -- one pointer
|
|
/// position, one x, N rows of values -- and it is the figure's to do rather than a host's
|
|
/// because only the figure knows its rows.
|
|
///
|
|
/// Vertical-only, deliberately: the rows of a figure share *x* (`Figure`'s whole reason
|
|
/// for stacking them and linking their x windows) and not y -- row 1 may be volts and row
|
|
/// 2 degrees -- so a horizontal line across the figure would be a value that means
|
|
/// different things in each row. A row whose series has no sample at `x` lists the trace
|
|
/// with no value rather than inventing one.
|
|
///
|
|
/// A host that *does* know which row the pointer is in -- every adapter does, from its own
|
|
/// pointer position -- uses the four-argument overload below instead, which adds the pointer
|
|
/// row's own horizontal line; this overload is for a caller that has only a coordinate.
|
|
///
|
|
/// A row is placed with `Plot::set_crosshair(x, active)`, which refuses a coordinate that
|
|
/// is not a number; such a call therefore leaves every row's crosshair as it was.
|
|
void set_crosshair(f64 x, bool active);
|
|
|
|
/// Places the crosshair with the pointer's **row** and that row's local y (spec §17): the
|
|
/// shared x goes to every row -- vertical lines everywhere, as the plain overload does -- and
|
|
/// the horizontal line is drawn **only in `hovered_row`**, where its y is a value of *that*
|
|
/// row's own scale. It is the fix for the reported defect -- a figure showing no horizontal
|
|
/// line at all -- without inventing a figure-wide y, which would mean a different value in
|
|
/// every row: the line appears where the pointer is, and no row ever shows another row's y.
|
|
///
|
|
/// `y_in_row` is a data coordinate on `hovered_row`'s y axis 0, exactly as `Crosshair::y`'s
|
|
/// two-coordinate placement is; a host reads it from the row's own y transform at the
|
|
/// pointer's pixel row. `hovered_row` outside `[0, row_count())` is treated as "no row under
|
|
/// the pointer": every row gets the vertical line and none gets a horizontal one, the same
|
|
/// state the single-coordinate overload produces.
|
|
///
|
|
/// Refused, changing nothing, when `x` is not a number -- matching `set_crosshair(x, active)`
|
|
/// and `Plot::set_crosshair()`'s own rule. A non-finite `y_in_row` leaves `hovered_row` with
|
|
/// the vertical line only (the y is dropped, the hover is not), because a row with no valid y
|
|
/// is still a row the pointer is in.
|
|
void set_crosshair(f64 x, u32 hovered_row, f64 y_in_row, bool active);
|
|
|
|
/// Clears the crosshair on every row. A row that never had one is unaffected.
|
|
void clear_crosshair();
|
|
|
|
// --- the x link -------------------------------------------------------
|
|
|
|
/// A new link group, empty. Groups are numbered from 1 -- 0 is the unlinked
|
|
/// group every row starts in -- and an id a figure never handed out names no
|
|
/// group, so the setters below refuse it rather than inventing one.
|
|
u32 add_x_link();
|
|
/// How many groups there are, excluding the unlinked group 0.
|
|
u32 x_link_count() const noexcept { return static_cast<u32>(links_.size()); }
|
|
|
|
/// Moves one row into a link group and applies that group's range to it at once,
|
|
/// so the row is on the group's window from this call rather than from the next
|
|
/// frame. `group` 0 takes the row back out of its group, leaving it with the range
|
|
/// it holds now.
|
|
///
|
|
/// A group with no range yet -- one that no row and no `set_x_link_range()` has
|
|
/// given one -- adopts the joining row's current range, which is what makes the
|
|
/// first row into a group the one that frames it.
|
|
///
|
|
/// Refused, changing nothing, when the row does not exist, when the group does not
|
|
/// exist, when the row's `Plot` has no x axis 0 to link, and when that axis is
|
|
/// `XFollow::Live`: a link group owns the shared x state, including whether it
|
|
/// follows, and a live axis would reinterpret the group's one range as a second,
|
|
/// per-row window. `XFollow::Off` is the group's to set
|
|
/// (`set_x_link_follow()`), not the axis'.
|
|
Status link_x(u32 row, u32 group);
|
|
/// The group a row is in, or 0 when it is in none.
|
|
u32 x_link(u32 row) const noexcept;
|
|
|
|
/// Sets a group's shared range and pushes it to every member.
|
|
///
|
|
/// While the group follows (`set_x_link_follow()`), the argument is
|
|
/// **reinterpreted exactly as `Plot::set_x_range()` reinterprets it on a `Live`
|
|
/// axis**: it states the *span* the window should have and, through its maximum,
|
|
/// the *offset* behind the live edge the window should sit at, and the window is
|
|
/// recomputed from the newest sample every frame. A span that is not a span, and a
|
|
/// bound that is not a number, are refused. With the group not following, the
|
|
/// range is stored as given, which is what an `Off` axis does.
|
|
Status set_x_link_range(u32 group, Range r);
|
|
/// A group's shared range as it stands -- the window its members hold, as of the
|
|
/// last `layout()`. A default range for group 0 or a group that does not exist.
|
|
Range x_link_range(u32 group) const noexcept;
|
|
|
|
/// Arms or disarms following on a whole group; the group then derives one window
|
|
/// from the newest x across its members' traces and pushes it to all of them each
|
|
/// frame (see the class comment). Arming places the group's window at its live
|
|
/// edge at once, from the span it already had, so `x_link_range()` reports the
|
|
/// window the next layout will produce; disarming leaves the range exactly where it
|
|
/// is. Refused for a group that does not exist.
|
|
Status set_x_link_follow(u32 group, XFollow follow);
|
|
/// A group's follow state. `Off` for group 0 and for a group that does not exist.
|
|
XFollow x_link_follow(u32 group) const noexcept;
|
|
|
|
private:
|
|
/// One link group: the range its members share, the scroll state behind that
|
|
/// range while it follows, the rows in it (ascending) and, in step with them, the
|
|
/// range this figure last pushed into each.
|
|
///
|
|
/// `pushed` is what makes the propagation terminate. A member is changed from
|
|
/// outside by a caller or the controller, which the figure cannot observe; it can
|
|
/// only compare what a member holds now with what it wrote there. Without the
|
|
/// written value the comparison would be against the group's own range, and a
|
|
/// group that pushed a window would then read its own window back as an external
|
|
/// change and re-derive the offset from it on every frame -- a drift, and for a
|
|
/// following group a window that never settles.
|
|
struct XLink {
|
|
Range range{0.0, 1.0};
|
|
/// The span and offset behind the live edge, the scroll state a following group
|
|
/// owns: the two things a user changes (a zoom changes the span, a pan the
|
|
/// offset) and the only parts of the window that survive a per-frame recompute.
|
|
/// Unused while `follow` is `Off`.
|
|
f64 span = 0.0;
|
|
f64 offset = 0.0;
|
|
XFollow follow = XFollow::Off;
|
|
bool has_range = false;
|
|
/// The group's live edge, as the last `place_live_window()` found it, and whether it
|
|
/// found one at all. Published to every member by `push_link()` (`Plot::set_x_live_owner`)
|
|
/// so the input layer can see that the member's window is following even though the
|
|
/// member's own axis must be `Off`.
|
|
f64 live_newest = 0.0;
|
|
bool has_live = false;
|
|
std::vector<u32> rows;
|
|
std::vector<Range> pushed;
|
|
};
|
|
|
|
/// The retained window of a group's live edge: the newest x any visible streaming
|
|
/// trace bound to x axis 0 of any member holds, and the oldest such trace still
|
|
/// reaches back to. False -- `out` untouched -- when no member has a live edge at
|
|
/// all, i.e. when there is nothing to follow. O(rows + traces) and
|
|
/// allocation-free: `layout()` runs it on every frame.
|
|
bool live_edge(const XLink& group, Range& out) const;
|
|
/// Places a following group's window at its live edge -- `[newest - offset - span,
|
|
/// newest - offset]`, with the offset clamped against what the members still retain
|
|
/// so the window can never be scrolled over samples the rings have dropped. False,
|
|
/// and a no-op, when the group is not following, has no live edge, or holds no
|
|
/// usable span.
|
|
bool place_live_window(XLink& group);
|
|
/// Writes the group's range into every member and records it as the pushed value,
|
|
/// which is the one place a member's range is set from here.
|
|
void push_link(XLink& group);
|
|
/// Takes a row out of every group it is in.
|
|
void unlink_row(u32 row) noexcept;
|
|
/// Adopts a range a member was changed to from outside into its group. False when
|
|
/// every member still holds what was pushed.
|
|
bool adopt_member_changes(XLink& group);
|
|
/// Advances every group for this frame: adopt what a member was changed to, place
|
|
/// a following group's window, push both to all members.
|
|
void sync_x_links();
|
|
|
|
std::vector<Plot> plots_;
|
|
/// What `plot()` yields for a row that does not exist, so a stale row can be used
|
|
/// without reading out of bounds. Owned rather than a static, so one figure's
|
|
/// stray row can never write into another's.
|
|
Plot fallback_;
|
|
Rect viewport_{};
|
|
std::vector<f32> row_weights_;
|
|
std::vector<XLink> links_;
|
|
FigureLayout layout_{};
|
|
f32 row_gap_ = 0.0f;
|
|
f32 min_y_gutter_ = 0.0f;
|
|
f32 right_gutter_ = 0.0f;
|
|
bool labels_bottom_only_ = true;
|
|
};
|
|
|
|
} // namespace plot
|