feat(plot): Figure with stacked rows and a synchronised x axis
This commit is contained in:
1 parent
7cc06f07f5
commit
8f1e60a105
11 files changed
+1665
-24
No files matched your search
@@ -49,6 +49,7 @@
|
||||
#include <plot/plot/annotation.hpp>
|
||||
#include <plot/plot/axis.hpp>
|
||||
#include <plot/plot/cursor.hpp>
|
||||
#include <plot/plot/figure.hpp>
|
||||
#include <plot/plot/grid.hpp>
|
||||
#include <plot/plot/legend.hpp>
|
||||
#include <plot/plot/measure.hpp>
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
#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 full width of the
|
||||
/// figure, directly under the last row, and zero-sized when the figure draws no
|
||||
/// shared axis (`set_x_labels_only_on_bottom(false)`, or no rows). It is outside
|
||||
/// every row rect, because the strip is the figure's -- the rows gave it up
|
||||
/// (`Plot::set_x_label_strip()`), which is what keeps the labels from appearing
|
||||
/// once per row.
|
||||
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 rows reserve no x label strip of their own and the
|
||||
/// figure draws one shared axis under the last row, from the last row's own ticks
|
||||
/// and transform: one set of labels, one baseline, no duplication.
|
||||
///
|
||||
/// **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_; }
|
||||
|
||||
/// 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.
|
||||
const FigureLayout& layout();
|
||||
const FigureLayout& layout() const noexcept { return layout_; }
|
||||
|
||||
// --- 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;
|
||||
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;
|
||||
bool labels_bottom_only_ = true;
|
||||
};
|
||||
|
||||
} // namespace plot
|
||||
@@ -42,7 +42,10 @@ struct PlotLayout {
|
||||
Rect x_title_area;
|
||||
/// Left strip width. The default is used when no text backend has been set.
|
||||
f32 y_axis_width = 56.0f;
|
||||
/// Bottom strip height. The default is used when no text backend has been set.
|
||||
/// Bottom strip height. The default is used when no text backend has been set, and
|
||||
/// it is 0.0f when the strip is not reserved at all -- see
|
||||
/// `Plot::set_x_label_strip()`, which is how a row of a `Figure` hands the band to
|
||||
/// the figure's one shared axis.
|
||||
f32 x_axis_height = 22.0f;
|
||||
/// Width of `y_title_area` and height of `x_title_area`, both 0.0f when nothing
|
||||
/// is reserved. Every titled axis of a direction gets its own text line inside
|
||||
@@ -131,6 +134,24 @@ class Plot {
|
||||
/// The scale the last layout was computed at. 1.0 until a host says otherwise.
|
||||
f32 ui_scale() const noexcept { return style_.ui_scale; }
|
||||
|
||||
/// Whether `layout()` reserves the bottom x label strip -- and, with it, the x
|
||||
/// title strip: a title is the labels' caption and goes where they do.
|
||||
///
|
||||
/// True by default, which is what a plot drawn on its own has always been. A
|
||||
/// `Figure` turns it off on every row while it draws one shared x axis under its
|
||||
/// last row (see `Figure::set_x_labels_only_on_bottom()`): the strip is not
|
||||
/// merely left blank, it is not reserved at all, so such a row's `axes` reaches
|
||||
/// the row rect's bottom edge and the row gives the band to the figure's shared
|
||||
/// axis instead of growing a second, duplicate one. Nothing else about the row
|
||||
/// changes -- its ticks still refresh and its grid still builds, because the
|
||||
/// tick list is what the shared axis and the grid are drawn from, not the strip.
|
||||
///
|
||||
/// The plot it belongs to draws no x tick labels while it is off (their strip
|
||||
/// has no height left to hold them); whoever turned it off is the one that puts
|
||||
/// them somewhere -- `Renderer::draw(Figure&)` draws them in the figure's strip.
|
||||
void set_x_label_strip(bool on) noexcept { x_label_strip_ = on; }
|
||||
bool x_label_strip() const noexcept { return x_label_strip_; }
|
||||
|
||||
/// The font layout() measures tick labels with. Borrowed, not owned, and may be
|
||||
/// null -- then the label areas fall back to PlotLayout's defaults and no string
|
||||
/// is measured: a plot with no renderer lays out with whatever the host sets
|
||||
@@ -534,6 +555,8 @@ class Plot {
|
||||
render::Style style_{};
|
||||
const render::TextBackend* text_ = nullptr;
|
||||
Legend legend_{};
|
||||
/// Whether the bottom x label strip is reserved at all; see set_x_label_strip().
|
||||
bool x_label_strip_ = true;
|
||||
/// Which handles a pointer is on, published by the input layer (see HotHandles).
|
||||
HotHandles hot_{};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include <plot/core/status.hpp>
|
||||
#include <plot/core/types.hpp>
|
||||
#include <plot/math/vec2.hpp>
|
||||
#include <plot/plot/figure.hpp>
|
||||
#include <plot/plot/plot.hpp>
|
||||
#include <plot/render/embedded_mono_font.hpp>
|
||||
#include <plot/render/buffer.hpp>
|
||||
@@ -130,6 +131,25 @@ class Renderer {
|
||||
/// vertical strokes and the label is a staged glyph run, so a frame with regions
|
||||
/// leaves behind exactly what a frame without them does.
|
||||
void draw(Plot& plot);
|
||||
/// Draws a whole `Figure`: every row in its own rect, then the figure's one shared
|
||||
/// x axis.
|
||||
|
||||
/// Each row is drawn by the *same* path as `draw(Plot&)` -- the viewport and the
|
||||
/// x-label policy come from `Figure::layout()`, which the figure hands out and
|
||||
/// which lays every row out against its rect -- so a row is chrome and data drawn
|
||||
/// exactly as a stand-alone plot is, and `draw(Plot&)` itself is unchanged for
|
||||
/// single-plot hosts.
|
||||
|
||||
/// The shared axis is the frame's last text: when the figure says labels belong on
|
||||
/// the bottom only (the default), the rows reserved no x label strip of their own
|
||||
/// and the labels of the last row's x axes are staged once, in the strip
|
||||
/// `FigureLayout::x_axis_strip` measured, under the last row. With that switched
|
||||
/// off, every row draws its own labels and this draws no shared axis at all.
|
||||
|
||||
/// Styling, the text backend and `ui_scale` are installed on **every** row before
|
||||
/// the figure computes its layout, so the strips the rows measure and the strokes
|
||||
/// this draws are one set of numbers, exactly as they are for one plot.
|
||||
void draw(Figure& figure);
|
||||
|
||||
/// Ends the frame, draining `glGetError()` in a loop so a stale error cannot be
|
||||
/// attributed to a later frame (see `last_frame_had_gl_error()`).
|
||||
@@ -320,11 +340,34 @@ class Renderer {
|
||||
/// the pen -- for the rotated run, the bottom-left corner of its first cell.
|
||||
f32 stage_text_clipped(Vec2 pos_px, const char* utf8, f32 budget_px, Vec2 axis);
|
||||
|
||||
/// Pins the text pipeline for a frame: the plot layer's backend at the size the
|
||||
/// style asks for, the cell every staged glyph is drawn in, and the atlas the
|
||||
/// backend publishes -- re-uploaded when its revision moved. False when a driver
|
||||
/// refused the re-upload, which the frame reports (see `last_frame_had_gl_error()`)
|
||||
/// and carries on from, drawing with the atlas it already had.
|
||||
bool begin_frame_text() noexcept;
|
||||
/// Hands `plot` everything the frame draws with: the renderer's text backend (a
|
||||
/// host's is replaced here, named in a debug build, as it always was), the style,
|
||||
/// and the `ui_scale` the plot converts that style's logical metrics by. Called
|
||||
/// before a plot is laid out, which is why it is separate from the drawing.
|
||||
void prepare_plot(Plot& plot);
|
||||
/// Draws one plot's frame with the layout the caller already computed:
|
||||
/// `draw(Plot&)` passes `plot.layout()`, and `draw(Figure&)` the layout
|
||||
/// `Figure::layout()` computed for that row -- so a figure's rows are drawn by
|
||||
/// exactly this path and neither entry point lays a plot out twice.
|
||||
void draw_plot(Plot& plot, const PlotLayout& layout);
|
||||
|
||||
void draw_grid(const Plot& plot);
|
||||
void draw_axis_lines(const Plot& plot);
|
||||
/// The chrome helpers take the frame's layout: `plot.layout()` recomputes the
|
||||
/// ticks, the strips and the grid, so it belongs to `draw()` alone.
|
||||
/// The chrome helpers take the frame's layout: `Plot::layout()` recomputes the
|
||||
/// ticks, the strips and the grid, so it is computed once, in `draw()`, and handed
|
||||
/// down -- never re-derived per helper.
|
||||
void draw_tick_labels(const Plot& plot, const PlotLayout& layout);
|
||||
/// The x tick labels of every x axis of `plot`, centred on their ticks in `strip`:
|
||||
/// what `draw_tick_labels()` uses with the plot's own `x_label_area`, and what
|
||||
/// `draw(Figure&)` uses with the figure's shared strip -- one rule for a plot's
|
||||
/// labels and a figure's, so the two can never place a label differently.
|
||||
void draw_x_tick_labels(const Plot& plot, const Rect& strip);
|
||||
void draw_titles(const Plot& plot, const PlotLayout& layout);
|
||||
/// The data helpers take it too; they read the axes rect and the transforms it
|
||||
/// pins.
|
||||
|
||||
Reference in new issue
Block a user