feat(plot): Figure with stacked rows and a synchronised x axis
This commit is contained in:
1 parent
7cc06f07f5
commit
8f1e60a105
11 files changed
+1664
-23
No files matched your search
@@ -27,6 +27,7 @@ add_library(plot_core
|
|||||||
src/plot/grid.cpp
|
src/plot/grid.cpp
|
||||||
src/plot/measure.cpp
|
src/plot/measure.cpp
|
||||||
src/plot/plot.cpp
|
src/plot/plot.cpp
|
||||||
|
src/plot/figure.cpp
|
||||||
src/input/controller.cpp
|
src/input/controller.cpp
|
||||||
)
|
)
|
||||||
add_library(plot::core ALIAS plot_core)
|
add_library(plot::core ALIAS plot_core)
|
||||||
|
|||||||
@@ -49,6 +49,7 @@
|
|||||||
#include <plot/plot/annotation.hpp>
|
#include <plot/plot/annotation.hpp>
|
||||||
#include <plot/plot/axis.hpp>
|
#include <plot/plot/axis.hpp>
|
||||||
#include <plot/plot/cursor.hpp>
|
#include <plot/plot/cursor.hpp>
|
||||||
|
#include <plot/plot/figure.hpp>
|
||||||
#include <plot/plot/grid.hpp>
|
#include <plot/plot/grid.hpp>
|
||||||
#include <plot/plot/legend.hpp>
|
#include <plot/plot/legend.hpp>
|
||||||
#include <plot/plot/measure.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;
|
Rect x_title_area;
|
||||||
/// Left strip width. The default is used when no text backend has been set.
|
/// Left strip width. The default is used when no text backend has been set.
|
||||||
f32 y_axis_width = 56.0f;
|
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;
|
f32 x_axis_height = 22.0f;
|
||||||
/// Width of `y_title_area` and height of `x_title_area`, both 0.0f when nothing
|
/// 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
|
/// 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.
|
/// The scale the last layout was computed at. 1.0 until a host says otherwise.
|
||||||
f32 ui_scale() const noexcept { return style_.ui_scale; }
|
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
|
/// 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
|
/// 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
|
/// is measured: a plot with no renderer lays out with whatever the host sets
|
||||||
@@ -534,6 +555,8 @@ class Plot {
|
|||||||
render::Style style_{};
|
render::Style style_{};
|
||||||
const render::TextBackend* text_ = nullptr;
|
const render::TextBackend* text_ = nullptr;
|
||||||
Legend legend_{};
|
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).
|
/// Which handles a pointer is on, published by the input layer (see HotHandles).
|
||||||
HotHandles hot_{};
|
HotHandles hot_{};
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
#include <plot/core/status.hpp>
|
#include <plot/core/status.hpp>
|
||||||
#include <plot/core/types.hpp>
|
#include <plot/core/types.hpp>
|
||||||
#include <plot/math/vec2.hpp>
|
#include <plot/math/vec2.hpp>
|
||||||
|
#include <plot/plot/figure.hpp>
|
||||||
#include <plot/plot/plot.hpp>
|
#include <plot/plot/plot.hpp>
|
||||||
#include <plot/render/embedded_mono_font.hpp>
|
#include <plot/render/embedded_mono_font.hpp>
|
||||||
#include <plot/render/buffer.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
|
/// vertical strokes and the label is a staged glyph run, so a frame with regions
|
||||||
/// leaves behind exactly what a frame without them does.
|
/// leaves behind exactly what a frame without them does.
|
||||||
void draw(Plot& plot);
|
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
|
/// 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()`).
|
/// 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.
|
/// 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);
|
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_grid(const Plot& plot);
|
||||||
void draw_axis_lines(const Plot& plot);
|
void draw_axis_lines(const Plot& plot);
|
||||||
/// The chrome helpers take the frame's layout: `plot.layout()` recomputes the
|
/// The chrome helpers take the frame's layout: `Plot::layout()` recomputes the
|
||||||
/// ticks, the strips and the grid, so it belongs to `draw()` alone.
|
/// 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);
|
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);
|
void draw_titles(const Plot& plot, const PlotLayout& layout);
|
||||||
/// The data helpers take it too; they read the axes rect and the transforms it
|
/// The data helpers take it too; they read the axes rect and the transforms it
|
||||||
/// pins.
|
/// pins.
|
||||||
|
|||||||
@@ -0,0 +1,392 @@
|
|||||||
|
#include <plot/plot/figure.hpp>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstddef>
|
||||||
|
|
||||||
|
namespace plot {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
/// The height a row never drops below, in device pixels: the floor that keeps a
|
||||||
|
/// plot's chrome from erasing it in a figure with many rows or a small viewport. It
|
||||||
|
/// yields to the figure -- it is capped at an equal share of the height there is --
|
||||||
|
/// rather than pushing rows past the viewport.
|
||||||
|
///
|
||||||
|
/// Device pixels, like every rect a figure computes: a `Figure` carries no `Style`
|
||||||
|
/// -- the renderer's is the one its rows are laid out with -- so there is no logical
|
||||||
|
/// unit here to convert from. A host that wants a taller floor gives the figure a
|
||||||
|
/// taller viewport, or gives the rows weights.
|
||||||
|
constexpr f32 kMinRowHeightPx = 24.0f;
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
Figure::Figure(u32 rows) : plots_(rows) {}
|
||||||
|
|
||||||
|
Plot& Figure::plot(u32 row) noexcept {
|
||||||
|
return row < plots_.size() ? plots_[row] : fallback_;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Plot& Figure::plot(u32 row) const noexcept {
|
||||||
|
return row < plots_.size() ? plots_[row] : fallback_;
|
||||||
|
}
|
||||||
|
|
||||||
|
Status Figure::set_row_weights(std::span<const f32> weights) {
|
||||||
|
if (weights.size() != plots_.size()) {
|
||||||
|
return Status{ErrorCode::InvalidArgument, "row weights must name every row"};
|
||||||
|
}
|
||||||
|
f32 total = 0.0f;
|
||||||
|
for (const f32 w : weights) {
|
||||||
|
if (!std::isfinite(w) || !(w > 0.0f)) {
|
||||||
|
return Status{ErrorCode::InvalidArgument,
|
||||||
|
"row weights must be positive finite numbers: a weight is a share of "
|
||||||
|
"the figure, and a row with none of it has no rect"};
|
||||||
|
}
|
||||||
|
total += w;
|
||||||
|
}
|
||||||
|
if (!std::isfinite(total) || !(total > 0.0f)) {
|
||||||
|
return Status{ErrorCode::InvalidArgument, "row weights must sum to a positive weight"};
|
||||||
|
}
|
||||||
|
row_weights_.assign(weights.begin(), weights.end());
|
||||||
|
return Status{};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- the x link
|
||||||
|
|
||||||
|
u32 Figure::add_x_link() {
|
||||||
|
links_.emplace_back();
|
||||||
|
return static_cast<u32>(links_.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
u32 Figure::x_link(u32 row) const noexcept {
|
||||||
|
for (std::size_t g = 0; g < links_.size(); ++g) {
|
||||||
|
for (const u32 member : links_[g].rows) {
|
||||||
|
if (member == row) return static_cast<u32>(g) + 1u;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0u;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Figure::unlink_row(u32 row) noexcept {
|
||||||
|
for (XLink& group : links_) {
|
||||||
|
for (std::size_t i = 0; i < group.rows.size(); ++i) {
|
||||||
|
if (group.rows[i] != row) continue;
|
||||||
|
group.rows.erase(group.rows.begin() + static_cast<std::ptrdiff_t>(i));
|
||||||
|
group.pushed.erase(group.pushed.begin() + static_cast<std::ptrdiff_t>(i));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Status Figure::link_x(u32 row, u32 group) {
|
||||||
|
if (row >= plots_.size()) {
|
||||||
|
return Status{ErrorCode::InvalidArgument, "no such row"};
|
||||||
|
}
|
||||||
|
if (group > x_link_count()) {
|
||||||
|
return Status{ErrorCode::InvalidArgument, "no such x link group"};
|
||||||
|
}
|
||||||
|
Plot& p = plots_[row];
|
||||||
|
if (p.x_axis_count() == 0u) {
|
||||||
|
return Status{ErrorCode::InvalidArgument,
|
||||||
|
"the row has no x axis to link: a figure links every row's x axis 0"};
|
||||||
|
}
|
||||||
|
// A link group owns the shared x state, including whether it follows. A `Live`
|
||||||
|
// axis reinterprets the one range the group pushes as *its own* span and offset
|
||||||
|
// behind its own live edge, so linking one would leave the caller with a state the
|
||||||
|
// group cannot drive and cannot read -- refused rather than quietly changed.
|
||||||
|
if (p.x_follow(0u) == XFollow::Live) {
|
||||||
|
return Status{ErrorCode::InvalidArgument,
|
||||||
|
"x axis 0 follows live; the link group owns the shared x range and the "
|
||||||
|
"follow state (set_x_link_follow), so the member's axis must be Off"};
|
||||||
|
}
|
||||||
|
|
||||||
|
unlink_row(row);
|
||||||
|
if (group == 0u) return Status{};
|
||||||
|
|
||||||
|
XLink& g = links_[group - 1u];
|
||||||
|
const auto at = std::lower_bound(g.rows.begin(), g.rows.end(), row);
|
||||||
|
const std::size_t index = static_cast<std::size_t>(at - g.rows.begin());
|
||||||
|
g.rows.insert(at, row);
|
||||||
|
g.pushed.insert(g.pushed.begin() + static_cast<std::ptrdiff_t>(index), Range{});
|
||||||
|
if (!g.has_range) {
|
||||||
|
// The first row into a group is the one that frames it: there is no shared range
|
||||||
|
// to apply yet, and adopting this row's is what makes the next joiner land on a
|
||||||
|
// range rather than resetting everyone's.
|
||||||
|
g.range = p.x_range(0u);
|
||||||
|
g.span = std::max(0.0, g.range.max - g.range.min);
|
||||||
|
g.has_range = true;
|
||||||
|
g.pushed[index] = g.range;
|
||||||
|
return Status{};
|
||||||
|
}
|
||||||
|
p.set_x_range(0u, g.range);
|
||||||
|
g.pushed[index] = g.range;
|
||||||
|
return Status{};
|
||||||
|
}
|
||||||
|
|
||||||
|
Status Figure::set_x_link_range(u32 group, Range r) {
|
||||||
|
if (group == 0u || group > x_link_count()) {
|
||||||
|
return Status{ErrorCode::InvalidArgument, "no such x link group"};
|
||||||
|
}
|
||||||
|
if (!std::isfinite(r.min) || !std::isfinite(r.max)) {
|
||||||
|
return Status{ErrorCode::InvalidArgument, "an x range must be finite"};
|
||||||
|
}
|
||||||
|
XLink& g = links_[group - 1u];
|
||||||
|
Range live{};
|
||||||
|
if (g.follow == XFollow::Live && live_edge(g, live)) {
|
||||||
|
// Under a following group the argument is not a pair of positions: it states the
|
||||||
|
// span the window should have and, through its maximum, where the window sits
|
||||||
|
// behind the live edge -- the same reinterpretation `Plot::set_x_range()` makes
|
||||||
|
// on a `Live` axis, applied to the group's own edge. A span that is not a span is
|
||||||
|
// refused rather than stored, because a following group has nothing to place a
|
||||||
|
// window from otherwise.
|
||||||
|
const f64 lo = std::min(r.min, r.max);
|
||||||
|
const f64 hi = std::max(r.min, r.max);
|
||||||
|
if (!(hi > lo)) {
|
||||||
|
return Status{ErrorCode::InvalidArgument,
|
||||||
|
"a following group's range states the window's span, so its bounds "
|
||||||
|
"must differ"};
|
||||||
|
}
|
||||||
|
g.span = hi - lo;
|
||||||
|
g.offset = live.max - hi;
|
||||||
|
g.has_range = true;
|
||||||
|
place_live_window(g);
|
||||||
|
push_link(g);
|
||||||
|
return Status{};
|
||||||
|
}
|
||||||
|
// Not following, or following with nothing streaming to follow: the range is
|
||||||
|
// stored as given, which is what an `Off` axis does.
|
||||||
|
g.range = r;
|
||||||
|
g.span = std::max(0.0, r.max - r.min);
|
||||||
|
g.has_range = true;
|
||||||
|
push_link(g);
|
||||||
|
return Status{};
|
||||||
|
}
|
||||||
|
|
||||||
|
Range Figure::x_link_range(u32 group) const noexcept {
|
||||||
|
return (group == 0u || group > x_link_count()) ? Range{} : links_[group - 1u].range;
|
||||||
|
}
|
||||||
|
|
||||||
|
Status Figure::set_x_link_follow(u32 group, XFollow follow) {
|
||||||
|
if (group == 0u || group > x_link_count()) {
|
||||||
|
return Status{ErrorCode::InvalidArgument, "no such x link group"};
|
||||||
|
}
|
||||||
|
XLink& g = links_[group - 1u];
|
||||||
|
g.follow = follow;
|
||||||
|
if (follow != XFollow::Live) {
|
||||||
|
// Disarming leaves the range exactly where it is, as it does on a single axis:
|
||||||
|
// the last live window becomes an ordinary stored range.
|
||||||
|
return Status{};
|
||||||
|
}
|
||||||
|
// Arming starts from the span the group already holds -- as `Plot::set_x_follow()`
|
||||||
|
// starts from the span the axis already holds -- and places the window at the live
|
||||||
|
// edge at once, so x_link_range() reports the window the next layout produces.
|
||||||
|
g.span = std::max(0.0, g.range.max - g.range.min);
|
||||||
|
place_live_window(g);
|
||||||
|
push_link(g);
|
||||||
|
return Status{};
|
||||||
|
}
|
||||||
|
|
||||||
|
XFollow Figure::x_link_follow(u32 group) const noexcept {
|
||||||
|
return (group == 0u || group > x_link_count()) ? XFollow::Off : links_[group - 1u].follow;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Figure::live_edge(const XLink& group, Range& out) const {
|
||||||
|
bool any = false;
|
||||||
|
for (const u32 row : group.rows) {
|
||||||
|
const Plot& p = plots_[row];
|
||||||
|
for (u32 t = 0; t < p.trace_count(); ++t) {
|
||||||
|
const Trace& trace = p.trace(t);
|
||||||
|
// The predicate `Plot::live_x_bounds()` applies to one axis, applied across the
|
||||||
|
// group: a visible trace on x axis 0 whose series owns a sliding window. Only
|
||||||
|
// such a series has an edge that moves on its own, so an `ArraySeries` -- whose
|
||||||
|
// bounds change only when the caller replaces the data -- cannot anchor a
|
||||||
|
// window that is recomputed every frame.
|
||||||
|
if (trace.axis_x != 0u || !trace.visible() || !trace.series->streaming()) continue;
|
||||||
|
const Range b = trace.series->x_bounds();
|
||||||
|
if (!std::isfinite(b.min) || !std::isfinite(b.max)) continue;
|
||||||
|
if (!any) {
|
||||||
|
out = b;
|
||||||
|
any = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// The union of what the members retain, exactly as one axis takes the union of
|
||||||
|
// its traces': the newest x any member holds is the edge the window is anchored
|
||||||
|
// to, and the oldest any of them still reaches back to bounds how far it can be
|
||||||
|
// scrolled away.
|
||||||
|
out.min = std::min(out.min, b.min);
|
||||||
|
out.max = std::max(out.max, b.max);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return any;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Figure::place_live_window(XLink& group) {
|
||||||
|
if (group.follow != XFollow::Live) return false;
|
||||||
|
Range live{};
|
||||||
|
if (!live_edge(group, live)) return false;
|
||||||
|
// The span is the group's own -- what the user zoomed to, or what the last range
|
||||||
|
// it was given stated -- and the follow window only moves it: the span is what
|
||||||
|
// makes this a scroll rather than a rescale. A span that is not a span is nothing
|
||||||
|
// to place a window from, so the group's range is left exactly as it is.
|
||||||
|
if (!std::isfinite(group.span) || !(group.span > 0.0)) return false;
|
||||||
|
if (!std::isfinite(group.offset)) group.offset = 0.0;
|
||||||
|
// The offset can outlive the window it names: a ring that dropped samples moves the
|
||||||
|
// oldest x forward under it, so it is clamped against what the members hold *now*.
|
||||||
|
// An offset past that would put the window's right edge behind the oldest sample
|
||||||
|
// left, i.e. over data that is gone.
|
||||||
|
group.offset = std::clamp(group.offset, 0.0, std::max(0.0, live.max - live.min));
|
||||||
|
group.range = Range{live.max - group.offset - group.span, live.max - group.offset};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Figure::push_link(XLink& group) {
|
||||||
|
for (std::size_t i = 0; i < group.rows.size(); ++i) {
|
||||||
|
plots_[group.rows[i]].set_x_range(0u, group.range);
|
||||||
|
group.pushed[i] = group.range;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Figure::adopt_member_changes(XLink& group) {
|
||||||
|
for (std::size_t i = 0; i < group.rows.size(); ++i) {
|
||||||
|
const Range now = plots_[group.rows[i]].x_range(0u);
|
||||||
|
const Range& pushed = group.pushed[i];
|
||||||
|
if (now.min == pushed.min && now.max == pushed.max) continue;
|
||||||
|
group.range = now;
|
||||||
|
group.has_range = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Figure::sync_x_links() {
|
||||||
|
for (XLink& group : links_) {
|
||||||
|
if (group.rows.empty()) continue;
|
||||||
|
const bool adopted = adopt_member_changes(group);
|
||||||
|
if (group.follow == XFollow::Live) {
|
||||||
|
// A group that was changed from outside restates its scroll state the way a
|
||||||
|
// single `Live` axis does -- the new range's span, and the offset its maximum
|
||||||
|
// implies -- and against the group's own edge, not the edge of the row the
|
||||||
|
// change arrived on. Then the window is recomputed from the live edge, which is
|
||||||
|
// the whole of what following is.
|
||||||
|
if (adopted) {
|
||||||
|
Range live{};
|
||||||
|
if (live_edge(group, live)) {
|
||||||
|
const f64 span = std::max(0.0, group.range.max - group.range.min);
|
||||||
|
if (span > 0.0) {
|
||||||
|
group.span = span;
|
||||||
|
group.offset = live.max - group.range.max;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
place_live_window(group);
|
||||||
|
}
|
||||||
|
// Pushed after the window was placed, so a member never sees a range this frame's
|
||||||
|
// recompute is about to replace, and so the pushed value is what the next frame's
|
||||||
|
// comparison reads: that equality is what makes this terminate.
|
||||||
|
push_link(group);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- geometry
|
||||||
|
|
||||||
|
const FigureLayout& Figure::layout() {
|
||||||
|
const u32 rows = row_count();
|
||||||
|
layout_.rows.assign(rows, Rect{});
|
||||||
|
layout_.row_gap = row_gap_;
|
||||||
|
layout_.x_axis_strip = Rect{};
|
||||||
|
if (rows == 0u) {
|
||||||
|
layout_.row_gap = 0.0f;
|
||||||
|
return layout_;
|
||||||
|
}
|
||||||
|
|
||||||
|
const f32 figure_h = std::max(0.0f, viewport_.h);
|
||||||
|
// The shared strip's height is what one of these rows' own x label strip would ask
|
||||||
|
// for, and only a plot can measure that: it is the text backend's line height plus
|
||||||
|
// the label padding, clamped against the area it has. So the last row is laid out
|
||||||
|
// against the whole figure once, with its own strip switched back on, and its
|
||||||
|
// answer is what the figure reserves. The probe's viewport is the figure's rather
|
||||||
|
// than the row's on purpose -- the strip belongs to the figure, so the row's own
|
||||||
|
// budget must not shrink it.
|
||||||
|
//
|
||||||
|
// That probe is also why the strip cannot eat the rows, and why there is no second
|
||||||
|
// clamp here to say so: the plot's own chrome cap (`Plot::layout()`'s kMinAxesShare)
|
||||||
|
// already holds a label strip to half of the viewport it was laid out against, and
|
||||||
|
// the viewport it was laid out against is the figure. A figure therefore always has
|
||||||
|
// at least half its height to divide between the rows.
|
||||||
|
f32 strip_h = 0.0f;
|
||||||
|
if (labels_bottom_only_) {
|
||||||
|
Plot& probe = plots_.back();
|
||||||
|
probe.set_viewport(viewport_);
|
||||||
|
probe.set_x_label_strip(true);
|
||||||
|
strip_h = probe.layout().x_axis_height;
|
||||||
|
}
|
||||||
|
|
||||||
|
const f32 content_h = figure_h - strip_h;
|
||||||
|
// The gap is dropped, rather than squeezing the rows, when the figure cannot hold
|
||||||
|
// it: a figure one pixel tall with four rows still gives every row a positive
|
||||||
|
// height.
|
||||||
|
f32 gap = row_gap_;
|
||||||
|
f32 usable = content_h - gap * static_cast<f32>(rows - 1u);
|
||||||
|
if (usable <= 0.0f) {
|
||||||
|
gap = 0.0f;
|
||||||
|
usable = content_h;
|
||||||
|
}
|
||||||
|
// ... and the height to divide is clamped non-negative here, so every height below
|
||||||
|
// is a share of a non-negative total and no row rect can come out negative: a row
|
||||||
|
// rect is handed to a plot, and a plot laid out against a negative one is not a
|
||||||
|
// case that layout arithmetic is defined for. The clamp cannot fire -- the strip is
|
||||||
|
// at most half the figure (above), so there is always at least half a figure of
|
||||||
|
// height to divide -- and it is what keeps that reasoning in the arithmetic rather
|
||||||
|
// than in a comment.
|
||||||
|
usable = std::max(0.0f, usable);
|
||||||
|
|
||||||
|
// The floor, then the weights' share of what is left. Giving every row the floor
|
||||||
|
// first is what makes a weight a *share of the leftover* rather than a height: a
|
||||||
|
// row can never be squeezed out of the figure by another row's weight, and the
|
||||||
|
// heights still sum to exactly the usable height, so the rows tile the figure and
|
||||||
|
// the last one ends on its bottom edge.
|
||||||
|
const bool equal = row_weights_.empty();
|
||||||
|
const f32 floor_px = std::min(kMinRowHeightPx, usable / static_cast<f32>(rows));
|
||||||
|
const f32 extra = usable - floor_px * static_cast<f32>(rows);
|
||||||
|
f32 total_w = 0.0f;
|
||||||
|
for (const f32 w : row_weights_) total_w += w;
|
||||||
|
const auto share_of = [&](u32 row) {
|
||||||
|
return equal ? 1.0f / static_cast<f32>(rows) : row_weights_[row] / total_w;
|
||||||
|
};
|
||||||
|
|
||||||
|
f32 y = viewport_.y;
|
||||||
|
f32 consumed = 0.0f;
|
||||||
|
for (u32 row = 0; row < rows; ++row) {
|
||||||
|
// The last row closes the figure exactly, absorbing the rounding of the ones
|
||||||
|
// above it, so a tiled figure has no seam and no row past its bottom edge.
|
||||||
|
const f32 end = row + 1u == rows ? usable : consumed + floor_px + extra * share_of(row);
|
||||||
|
layout_.rows[row] = Rect{viewport_.x, y, viewport_.w, end - consumed};
|
||||||
|
consumed = end;
|
||||||
|
y = layout_.rows[row].bottom() + gap;
|
||||||
|
}
|
||||||
|
layout_.row_gap = gap;
|
||||||
|
|
||||||
|
for (u32 row = 0; row < rows; ++row) {
|
||||||
|
Plot& p = plots_[row];
|
||||||
|
p.set_viewport(layout_.rows[row]);
|
||||||
|
// The rows give their x label strip to the figure's one shared axis; with the
|
||||||
|
// shared axis switched off each row keeps its own and draws its own labels.
|
||||||
|
p.set_x_label_strip(!labels_bottom_only_);
|
||||||
|
}
|
||||||
|
// After the viewports, so a row's ticks and grid -- built from its axes rect -- and
|
||||||
|
// its transforms describe this frame's rects; and before the rows lay themselves
|
||||||
|
// out below, so a following group's window is the range they lay out with.
|
||||||
|
sync_x_links();
|
||||||
|
// Every row is laid out against its own rect here, so after this call each row's
|
||||||
|
// chrome is current: `plot(i).layout()` reads back what the figure computed rather
|
||||||
|
// than what the row last held. That is the contract `Renderer::draw(Figure&)`
|
||||||
|
// relies on -- it draws each row with the layout this produced, and `draw(Plot&)`
|
||||||
|
// is the one that computes its own.
|
||||||
|
for (u32 row = 0; row < rows; ++row) plots_[row].layout();
|
||||||
|
|
||||||
|
// The strip hugs the figure's bottom, directly under the last row (a gap, if any,
|
||||||
|
// separates the two exactly as it separates two rows).
|
||||||
|
layout_.x_axis_strip = Rect{viewport_.x, viewport_.bottom() - strip_h, viewport_.w, strip_h};
|
||||||
|
return layout_;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace plot
|
||||||
+12
-2
@@ -661,6 +661,12 @@ const PlotLayout& Plot::layout() {
|
|||||||
y_width = y_axes_.empty() ? 0.0f : 2.0f * pad;
|
y_width = y_axes_.empty() ? 0.0f : 2.0f * pad;
|
||||||
x_height = text_->height() + 2.0f * pad;
|
x_height = text_->height() + 2.0f * pad;
|
||||||
}
|
}
|
||||||
|
// A row of a `Figure` gives its x strip to the figure's one shared axis, so it
|
||||||
|
// reserves none: the seed and the fixed point below both start from zero, and
|
||||||
|
// `axes` then reaches the viewport's bottom edge. Set here rather than in the
|
||||||
|
// loop, because the loop's `need_x` is what the strip wants and this overrides
|
||||||
|
// the want, not the measurement.
|
||||||
|
if (!x_label_strip_) x_height = 0.0f;
|
||||||
// The title strips, though, are needed before the loop and never change, unlike
|
// The title strips, though, are needed before the loop and never change, unlike
|
||||||
// the label strips: a strip holds exactly one text line per titled axis and no
|
// the label strips: a strip holds exactly one text line per titled axis and no
|
||||||
// tick label can change that. Nothing at all is reserved without a text backend
|
// tick label can change that. Nothing at all is reserved without a text backend
|
||||||
@@ -670,7 +676,7 @@ const PlotLayout& Plot::layout() {
|
|||||||
// per titled y axis *wide*, because the y title is drawn rotated and so spends
|
// per titled y axis *wide*, because the y title is drawn rotated and so spends
|
||||||
// its length along the axes' height and takes a column, not a row, per titled
|
// its length along the axes' height and takes a column, not a row, per titled
|
||||||
// axis (see y_title_strip_width).
|
// axis (see y_title_strip_width).
|
||||||
const u32 x_titles = titled_x_axis_count();
|
const u32 x_titles = x_label_strip_ ? titled_x_axis_count() : 0u;
|
||||||
const f32 y_title_w = text_ != nullptr ? y_title_strip_width() : 0.0f;
|
const f32 y_title_w = text_ != nullptr ? y_title_strip_width() : 0.0f;
|
||||||
const f32 x_title_h = text_ != nullptr && x_titles != 0u
|
const f32 x_title_h = text_ != nullptr && x_titles != 0u
|
||||||
? static_cast<f32>(x_titles) * text_->height() + 2.0f * pad
|
? static_cast<f32>(x_titles) * text_->height() + 2.0f * pad
|
||||||
@@ -710,8 +716,12 @@ const PlotLayout& Plot::layout() {
|
|||||||
// The same caps as above, so this compares like with like and the loop still
|
// The same caps as above, so this compares like with like and the loop still
|
||||||
// reaches its fixed point on the second pass.
|
// reaches its fixed point on the second pass.
|
||||||
const f32 need_y = std::min(std::clamp(y_label_width(), 0.0f, viewport_.w), y_budget);
|
const f32 need_y = std::min(std::clamp(y_label_width(), 0.0f, viewport_.w), y_budget);
|
||||||
|
// Overridden, not merely unmeasured: the strip is not reserved, so the fixed
|
||||||
|
// point has to be zero for a row that gave its strip away.
|
||||||
const f32 need_x =
|
const f32 need_x =
|
||||||
std::min(std::clamp(text_->height() + 2.0f * pad, 0.0f, viewport_.h), x_budget);
|
x_label_strip_
|
||||||
|
? std::min(std::clamp(text_->height() + 2.0f * pad, 0.0f, viewport_.h), x_budget)
|
||||||
|
: 0.0f;
|
||||||
if (need_y == y_width && need_x == x_height) break; // strips are a fixed point
|
if (need_y == y_width && need_x == x_height) break; // strips are a fixed point
|
||||||
y_width = need_y;
|
y_width = need_y;
|
||||||
x_height = need_x;
|
x_height = need_x;
|
||||||
|
|||||||
+87
-18
@@ -288,11 +288,7 @@ void Renderer::begin_frame(u32 fb_w, u32 fb_h, Rect viewport_px) noexcept {
|
|||||||
gl::glBlendFunc(gl::GL_SRC_ALPHA, gl::GL_ONE_MINUS_SRC_ALPHA);
|
gl::glBlendFunc(gl::GL_SRC_ALPHA, gl::GL_ONE_MINUS_SRC_ALPHA);
|
||||||
}
|
}
|
||||||
|
|
||||||
void Renderer::draw(Plot& plot) {
|
bool Renderer::begin_frame_text() noexcept {
|
||||||
stats_ = FrameStats{};
|
|
||||||
if (!ready_) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// The plot reserves its label strips from TextBackend metrics, so it must
|
// The plot reserves its label strips from TextBackend metrics, so it must
|
||||||
// measure with the size the text pipeline draws with, and re-read the style
|
// measure with the size the text pipeline draws with, and re-read the style
|
||||||
// each frame so a changed axis_font_px takes effect on the next one.
|
// each frame so a changed axis_font_px takes effect on the next one.
|
||||||
@@ -308,9 +304,10 @@ void Renderer::draw(Plot& plot) {
|
|||||||
// never changes costs a comparison a frame. A driver that refuses the re-upload
|
// never changes costs a comparison a frame. A driver that refuses the re-upload
|
||||||
// is reported the way the stores report a refused fence: the frame draws on (it
|
// is reported the way the stores report a refused fence: the frame draws on (it
|
||||||
// has the previous atlas), and `last_frame_had_gl_error()` says so.
|
// has the previous atlas), and `last_frame_had_gl_error()` says so.
|
||||||
if (!atlas_.sync(font_backend_)) {
|
return atlas_.sync(font_backend_);
|
||||||
gl_error_ = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Renderer::prepare_plot(Plot& plot) {
|
||||||
#ifndef NDEBUG
|
#ifndef NDEBUG
|
||||||
// The renderer owns the text of the frames it draws: every label goes through
|
// The renderer owns the text of the frames it draws: every label goes through
|
||||||
// the text pipeline in `default_font_`, so the plot is handed this backend's metrics
|
// the text pipeline in `default_font_`, so the plot is handed this backend's metrics
|
||||||
@@ -332,17 +329,78 @@ void Renderer::draw(Plot& plot) {
|
|||||||
// propagation point the contract names, and it is what keeps the strips the
|
// propagation point the contract names, and it is what keeps the strips the
|
||||||
// layout reserves and the strokes drawn into them at one scale.
|
// layout reserves and the strokes drawn into them at one scale.
|
||||||
plot.set_ui_scale(style_.ui_scale);
|
plot.set_ui_scale(style_.ui_scale);
|
||||||
// One layout for the whole frame: autoscale, the strip fixed-point loop with
|
}
|
||||||
// its tick rebuilds, and the grid build all run in here, and every helper
|
|
||||||
// below reads the strips, the axes rect and the transforms it pins.
|
void Renderer::draw(Plot& plot) {
|
||||||
const PlotLayout& layout = plot.layout();
|
stats_ = FrameStats{};
|
||||||
|
if (!ready_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!begin_frame_text()) {
|
||||||
|
gl_error_ = true;
|
||||||
|
}
|
||||||
|
prepare_plot(plot);
|
||||||
|
// The plot's one layout for the frame: autoscale, the strip fixed-point loop with
|
||||||
|
// its tick rebuilds, and the grid build all run in here, and every helper below
|
||||||
|
// reads the strips, the axes rect and the transforms it pins.
|
||||||
|
draw_plot(plot, plot.layout());
|
||||||
|
// The frame's single text flush, after every line batch of the frame: text is
|
||||||
|
// texture-bound, so it is one draw call whatever it holds (see draw_plot).
|
||||||
|
flush_text();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Renderer::draw(Figure& figure) {
|
||||||
|
stats_ = FrameStats{};
|
||||||
|
if (!ready_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!begin_frame_text()) {
|
||||||
|
gl_error_ = true;
|
||||||
|
}
|
||||||
|
// Every row is prepared before the figure lays itself out, because a row's strips
|
||||||
|
// are measured from the metrics this installs: a row measuring with one backend and
|
||||||
|
// drawing with another is the defect the propagation point exists to prevent.
|
||||||
|
const u32 rows = figure.row_count();
|
||||||
|
for (u32 row = 0; row < rows; ++row) prepare_plot(figure.plot(row));
|
||||||
|
|
||||||
|
// The figure's one layout for the frame. It hands every row its rect and its
|
||||||
|
// x-label policy, advances the link groups, and lays every row out against its own
|
||||||
|
// rect -- which is why the rows below are drawn with the layout it produced rather
|
||||||
|
// than laying each row out a second time. Nothing draws before this: a row's
|
||||||
|
// viewport is the figure's to set.
|
||||||
|
const FigureLayout& layout = figure.layout();
|
||||||
|
for (u32 row = 0; row < rows; ++row) {
|
||||||
|
Plot& plot = figure.plot(row);
|
||||||
|
// The layout the figure just computed, read through the non-recomputing
|
||||||
|
// overload -- the one layout of this frame, not a second derivation of it.
|
||||||
|
draw_plot(plot, static_cast<const Plot&>(plot).layout());
|
||||||
|
}
|
||||||
|
|
||||||
|
// The figure's one shared x axis, staged last so it joins the frame's single text
|
||||||
|
// flush: the labels of the last row's x axes, in the strip the figure measured
|
||||||
|
// under it. The rows reserved no strip of their own for them (see
|
||||||
|
// `Plot::set_x_label_strip()`), which is what keeps one set of labels from being
|
||||||
|
// drawn once per row; with the figure set to label every row, there is no shared
|
||||||
|
// strip and each row drew its own above.
|
||||||
|
if (rows != 0u && figure.x_labels_only_on_bottom() && layout.x_axis_strip.h > 0.0f) {
|
||||||
|
draw_x_tick_labels(figure.plot(rows - 1u), layout.x_axis_strip);
|
||||||
|
}
|
||||||
|
flush_text();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Renderer::draw_plot(Plot& plot, const PlotLayout& layout) {
|
||||||
|
|
||||||
// Draw order: chrome, then data, then the overlays over it, and the text last (it
|
// Draw order: chrome, then data, then the overlays over it, and the text last (it
|
||||||
// is texture-bound, so it is one draw call after every line batch). The legend
|
// is texture-bound, so it is one draw call after every line batch). The legend
|
||||||
// closes the line batches: its panel is a backplate, so it is drawn over
|
// closes the line batches: its panel is a backplate, so it is drawn over
|
||||||
// everything geometric -- a legend under a trace is a legend nobody can read --
|
// everything geometric -- a legend under a trace is a legend nobody can read --
|
||||||
// while its names join the frame's single text flush with every other label, which
|
// while its names wait for the frame's single text flush with every other label,
|
||||||
// is what keeps the two-pipeline order the whole frame is built on.
|
// which is what keeps the two-pipeline order the whole frame is built on.
|
||||||
|
//
|
||||||
|
// The flush itself belongs to the frame, not to one plot: `draw(Plot&)` flushes
|
||||||
|
// after this returns, and `draw(Figure&)` after it has drawn every row and the
|
||||||
|
// figure's shared x axis, so a figure is one text draw call rather than one per
|
||||||
|
// row -- and the shared axis' labels join the same batch as the labels above them.
|
||||||
draw_grid(plot);
|
draw_grid(plot);
|
||||||
draw_axis_lines(plot);
|
draw_axis_lines(plot);
|
||||||
draw_tick_labels(plot, layout);
|
draw_tick_labels(plot, layout);
|
||||||
@@ -355,7 +413,6 @@ void Renderer::draw(Plot& plot) {
|
|||||||
draw_measures(plot, layout);
|
draw_measures(plot, layout);
|
||||||
draw_annotations(plot);
|
draw_annotations(plot);
|
||||||
draw_legend(plot, layout);
|
draw_legend(plot, layout);
|
||||||
flush_text();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void Renderer::end_frame() noexcept {
|
void Renderer::end_frame() noexcept {
|
||||||
@@ -597,20 +654,32 @@ void Renderer::draw_axis_lines(const Plot& plot) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void Renderer::draw_tick_labels(const Plot& plot, const PlotLayout& layout) {
|
void Renderer::draw_x_tick_labels(const Plot& plot, const Rect& strip) {
|
||||||
|
// Centred under their tick, on the strip's single text line -- the one rule for a
|
||||||
|
// plot's own label strip and for the strip a figure shares out under its last row.
|
||||||
|
// A plot that reserved no strip of its own hands its labels to the figure, which
|
||||||
|
// is why this takes the rect rather than reading the layout it came from.
|
||||||
const f32 line_h = font_backend_.height();
|
const f32 line_h = font_backend_.height();
|
||||||
|
|
||||||
// X labels: centred under their tick, on the strip's single text line.
|
|
||||||
for (u32 i = 0; i < plot.x_axis_count(); ++i) {
|
for (u32 i = 0; i < plot.x_axis_count(); ++i) {
|
||||||
const Transform transform = plot.x_transform(i);
|
const Transform transform = plot.x_transform(i);
|
||||||
for (const Tick& tick : plot.x_axis(i).ticks) {
|
for (const Tick& tick : plot.x_axis(i).ticks) {
|
||||||
if (!tick.major || tick.label.empty()) continue;
|
if (!tick.major || tick.label.empty()) continue;
|
||||||
const f32 width = font_backend_.measure(tick.label.c_str());
|
const f32 width = font_backend_.measure(tick.label.c_str());
|
||||||
stage_text({static_cast<f32>(transform.to_screen_x(tick.value)) - 0.5f * width,
|
stage_text({static_cast<f32>(transform.to_screen_x(tick.value)) - 0.5f * width,
|
||||||
layout.x_label_area.y + 0.5f * (layout.x_label_area.h - line_h)},
|
strip.y + 0.5f * (strip.h - line_h)},
|
||||||
tick.label.c_str());
|
tick.label.c_str());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Renderer::draw_tick_labels(const Plot& plot, const PlotLayout& layout) {
|
||||||
|
const f32 line_h = font_backend_.height();
|
||||||
|
|
||||||
|
// X labels: unless the plot gave its x label strip away -- a row of a `Figure`
|
||||||
|
// whose figure drew one shared axis instead -- in which case it has no strip to
|
||||||
|
// put them in and their absence is the point. A plot with no strip would otherwise
|
||||||
|
// stage its labels just above `axes`' bottom edge, i.e. over its own data.
|
||||||
|
if (plot.x_label_strip()) draw_x_tick_labels(plot, layout.x_label_area);
|
||||||
|
|
||||||
// Y labels: right-aligned in their strip, centred on the tick and pulled back
|
// Y labels: right-aligned in their strip, centred on the tick and pulled back
|
||||||
// inside the strip at the two extremes (a label centred on the top tick would
|
// inside the strip at the two extremes (a label centred on the top tick would
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ add_executable(plot_tests
|
|||||||
test_ticks.cpp
|
test_ticks.cpp
|
||||||
test_measure.cpp
|
test_measure.cpp
|
||||||
test_plot.cpp
|
test_plot.cpp
|
||||||
|
test_figure.cpp
|
||||||
test_render_smoke.cpp
|
test_render_smoke.cpp
|
||||||
test_controller.cpp
|
test_controller.cpp
|
||||||
egl_headless.cpp
|
egl_headless.cpp
|
||||||
@@ -134,9 +135,11 @@ file(GENERATE OUTPUT "${plot_umbrella_check_source}" CONTENT
|
|||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
plot::Plot plot;
|
plot::Plot plot;
|
||||||
|
plot::Figure figure(2);
|
||||||
plot::Renderer renderer;
|
plot::Renderer renderer;
|
||||||
plot::data::StreamSeries series(16, 0.0, 1.0);
|
plot::data::StreamSeries series(16, 0.0, 1.0);
|
||||||
plot::input::Controller controller(plot);
|
plot::input::Controller controller(plot);
|
||||||
|
(void)figure;
|
||||||
(void)renderer;
|
(void)renderer;
|
||||||
(void)series;
|
(void)series;
|
||||||
(void)controller;
|
(void)controller;
|
||||||
|
|||||||
@@ -0,0 +1,557 @@
|
|||||||
|
// `Figure`: several plots stacked in one column, sharing one x range.
|
||||||
|
//
|
||||||
|
// The layout cases pin the geometry the renderer draws with -- the row rects, the
|
||||||
|
// weights, the floor and the shared strip -- and the link cases pin the propagation
|
||||||
|
// contract: one range for a whole group, applied to every member in one pass, and a
|
||||||
|
// group that follows deriving one window from the newest sample across all of its
|
||||||
|
// members rather than one per row.
|
||||||
|
#include "harness.hpp"
|
||||||
|
|
||||||
|
#include <plot/data/array_series.hpp>
|
||||||
|
#include <plot/data/stream_series.hpp>
|
||||||
|
#include <plot/plot/figure.hpp>
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr plot::f32 kW = 640.0f;
|
||||||
|
constexpr plot::f32 kH = 480.0f;
|
||||||
|
|
||||||
|
plot::Rect whole_figure() { return plot::Rect{0.0f, 0.0f, kW, kH}; }
|
||||||
|
|
||||||
|
/// A figure whose every row has a y axis and an x axis, so a row is a plot in the
|
||||||
|
/// shape the layout tests care about: it loses width to a y strip and height to
|
||||||
|
/// nothing else (the x strip belongs to the figure under `labels_bottom_only`).
|
||||||
|
plot::Figure figure_with_axes(plot::u32 rows) {
|
||||||
|
plot::Figure figure(rows);
|
||||||
|
figure.set_viewport(whole_figure());
|
||||||
|
for (plot::u32 row = 0; row < rows; ++row) {
|
||||||
|
plot::Plot& p = figure.plot(row);
|
||||||
|
p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
|
||||||
|
p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
||||||
|
}
|
||||||
|
return figure;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rows of a laid-out figure, so a case can assert on them without repeating the
|
||||||
|
/// call that produces them.
|
||||||
|
std::vector<plot::Rect> rows_of(plot::Figure& figure) {
|
||||||
|
return figure.layout().rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool rows_overlap(const plot::Rect& a, const plot::Rect& b) {
|
||||||
|
return a.x < b.right() && b.x < a.right() && a.y < b.bottom() && b.y < a.bottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------- layout
|
||||||
|
|
||||||
|
/// The rows tile the figure: they cover it from top to bottom without a gap and
|
||||||
|
/// without two of them overlapping, which is what makes the shared strip's position
|
||||||
|
/// -- and every row's axes -- a statement rather than a hope.
|
||||||
|
PLOT_TEST(row_rects_tile_the_figure_without_overlap) {
|
||||||
|
plot::Figure figure = figure_with_axes(3u);
|
||||||
|
const std::vector<plot::Rect> rows = rows_of(figure);
|
||||||
|
PLOT_CHECK(rows.size() == 3u);
|
||||||
|
|
||||||
|
// The figure's width, straight through: a row's own y strips are inside its rect,
|
||||||
|
// not carved out of the figure.
|
||||||
|
for (const plot::Rect& row : rows) {
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(row.x), 0.0, 1e-6);
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(row.w), static_cast<plot::f64>(kW), 1e-6);
|
||||||
|
PLOT_CHECK(row.h > 0.0f);
|
||||||
|
}
|
||||||
|
// Top to bottom, in row order, touching: each row starts where the one above it
|
||||||
|
// ended, and the last one ends on the rows' region's bottom edge (the figure's
|
||||||
|
// height less the shared strip beneath it).
|
||||||
|
const plot::FigureLayout layout = figure.layout();
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(rows[0].y), 0.0, 1e-6);
|
||||||
|
for (std::size_t i = 1; i < rows.size(); ++i) {
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(rows[i].y), static_cast<plot::f64>(rows[i - 1].bottom()),
|
||||||
|
1e-6);
|
||||||
|
}
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(rows.back().bottom()),
|
||||||
|
static_cast<plot::f64>(layout.x_axis_strip.y), 1e-6);
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(layout.x_axis_strip.bottom()), static_cast<plot::f64>(kH),
|
||||||
|
1e-6);
|
||||||
|
// ... and the strip shares the figure's width with the rows above it.
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(layout.x_axis_strip.w), static_cast<plot::f64>(kW), 1e-6);
|
||||||
|
PLOT_CHECK(layout.x_axis_strip.h > 0.0f);
|
||||||
|
|
||||||
|
for (std::size_t i = 0; i < rows.size(); ++i) {
|
||||||
|
for (std::size_t j = i + 1; j < rows.size(); ++j) {
|
||||||
|
PLOT_CHECK(!rows_overlap(rows[i], rows[j]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A gap keeps the rows apart by exactly that much and is not overlap by another
|
||||||
|
// name: the rows cover the region between the strip and the top, minus the gaps.
|
||||||
|
figure.set_row_gap(10.0f);
|
||||||
|
const std::vector<plot::Rect> spaced = rows_of(figure);
|
||||||
|
PLOT_CHECK(spaced.size() == 3u);
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(spaced[1].y - spaced[0].bottom()), 10.0, 1e-6);
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(spaced[2].y - spaced[1].bottom()), 10.0, 1e-6);
|
||||||
|
PLOT_CHECK(spaced[0].h < rows[0].h); // the gaps came out of the rows
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(spaced.back().bottom()),
|
||||||
|
static_cast<plot::f64>(figure.layout().x_axis_strip.y), 1e-6);
|
||||||
|
|
||||||
|
// A figure with no rows has no geometry -- and no row to reach for while computing
|
||||||
|
// it, which is the case that would read past the end of its own plot list.
|
||||||
|
plot::Figure empty(0u);
|
||||||
|
empty.set_viewport(whole_figure());
|
||||||
|
PLOT_CHECK(empty.row_count() == 0u);
|
||||||
|
PLOT_CHECK(empty.layout().rows.empty());
|
||||||
|
PLOT_CHECK(empty.layout().x_axis_strip.h == 0.0f);
|
||||||
|
PLOT_CHECK(empty.layout().row_gap == 0.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Weights change how the figure's height is divided -- a taller main plot, shorter
|
||||||
|
/// rows under it -- and the rows still account for all of it: the sum of the heights
|
||||||
|
/// plus the gaps is the figure less the shared strip, so no weight can leave a band
|
||||||
|
/// unclaimed or push a row past the bottom.
|
||||||
|
PLOT_TEST(row_weights_change_relative_heights_and_sum_to_the_viewport) {
|
||||||
|
plot::Figure figure = figure_with_axes(3u);
|
||||||
|
const std::vector<plot::Rect> equal = rows_of(figure);
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(equal[0].h), static_cast<plot::f64>(equal[1].h), 1e-4);
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(equal[1].h), static_cast<plot::f64>(equal[2].h), 1e-4);
|
||||||
|
|
||||||
|
const float main_taller[] = {3.0f, 1.0f, 1.0f};
|
||||||
|
plot::Status st = figure.set_row_weights(main_taller);
|
||||||
|
PLOT_CHECK(st);
|
||||||
|
PLOT_CHECK(figure.has_row_weights());
|
||||||
|
// By value, not a reference into the figure: a later `layout()` rewrites the
|
||||||
|
// figure's own vectors in place, and a case that kept pointing at them would
|
||||||
|
// compare a division with itself.
|
||||||
|
const plot::FigureLayout layout = figure.layout();
|
||||||
|
const std::vector<plot::Rect> rows = layout.rows;
|
||||||
|
PLOT_CHECK(rows[0].h > rows[1].h);
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(rows[1].h), static_cast<plot::f64>(rows[2].h), 1e-4);
|
||||||
|
// Monotone in the weights: a bigger share is a taller row, and what the taller row
|
||||||
|
// gained the others lost -- the figure's height is divided, never added to. Every
|
||||||
|
// row keeps the floor, so a weight is a share of what is left above it and the
|
||||||
|
// *order* is the exact statement, not the ratio.
|
||||||
|
const float main_taller_still[] = {6.0f, 1.0f, 1.0f};
|
||||||
|
PLOT_CHECK(figure.set_row_weights(main_taller_still));
|
||||||
|
const std::vector<plot::Rect> taller = rows_of(figure);
|
||||||
|
PLOT_CHECK(taller[0].h > rows[0].h);
|
||||||
|
PLOT_CHECK(taller[1].h < rows[1].h);
|
||||||
|
PLOT_CHECK(taller[0].h + taller[1].h + taller[2].h < rows[0].h + rows[1].h + rows[2].h + 1e-3f);
|
||||||
|
PLOT_CHECK(figure.set_row_weights(main_taller));
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(figure.layout().rows[0].h),
|
||||||
|
static_cast<plot::f64>(rows[0].h), 1e-4);
|
||||||
|
|
||||||
|
plot::f32 total = 0.0f;
|
||||||
|
for (const plot::Rect& row : rows) total += row.h;
|
||||||
|
total += layout.row_gap * 2.0f + layout.x_axis_strip.h;
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(total), static_cast<plot::f64>(kH), 1e-3);
|
||||||
|
|
||||||
|
// Any positive scale is the same division: normalised, not absolute.
|
||||||
|
const float same_ratio[] = {30.0f, 10.0f, 10.0f};
|
||||||
|
st = figure.set_row_weights(same_ratio);
|
||||||
|
PLOT_CHECK(st);
|
||||||
|
const std::vector<plot::Rect> scaled = rows_of(figure);
|
||||||
|
for (std::size_t i = 0; i < rows.size(); ++i) {
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(scaled[i].h), static_cast<plot::f64>(rows[i].h), 1e-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back to an equal division.
|
||||||
|
figure.clear_row_weights();
|
||||||
|
PLOT_CHECK(!figure.has_row_weights());
|
||||||
|
const std::vector<plot::Rect> again = rows_of(figure);
|
||||||
|
for (std::size_t i = 0; i < again.size(); ++i) {
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(again[i].h), static_cast<plot::f64>(equal[i].h), 1e-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A weight set that cannot divide the figure is refused outright -- a row with no
|
||||||
|
/// share has no rect to give it, and a weight the arithmetic cannot use would produce
|
||||||
|
/// exactly that -- and the figure keeps the division it had.
|
||||||
|
PLOT_TEST(degenerate_row_weights_are_rejected) {
|
||||||
|
plot::Figure figure = figure_with_axes(3u);
|
||||||
|
const std::vector<plot::Rect> before = rows_of(figure);
|
||||||
|
|
||||||
|
const float zero[] = {1.0f, 0.0f, 1.0f};
|
||||||
|
PLOT_CHECK(!figure.set_row_weights(zero));
|
||||||
|
const float negative[] = {1.0f, -2.0f, 1.0f};
|
||||||
|
PLOT_CHECK(!figure.set_row_weights(negative));
|
||||||
|
const float none[] = {0.0f, 0.0f, 0.0f};
|
||||||
|
PLOT_CHECK(!figure.set_row_weights(none));
|
||||||
|
const float nan_weight[] = {1.0f, std::nanf(""), 1.0f};
|
||||||
|
PLOT_CHECK(!figure.set_row_weights(nan_weight));
|
||||||
|
const float inf_weight[] = {1.0f, INFINITY, 1.0f};
|
||||||
|
PLOT_CHECK(!figure.set_row_weights(inf_weight));
|
||||||
|
// The wrong count is not a division of *these* rows either.
|
||||||
|
const float too_few[] = {1.0f, 1.0f};
|
||||||
|
PLOT_CHECK(!figure.set_row_weights(too_few));
|
||||||
|
const float too_many[] = {1.0f, 1.0f, 1.0f, 1.0f};
|
||||||
|
PLOT_CHECK(!figure.set_row_weights(too_many));
|
||||||
|
|
||||||
|
PLOT_CHECK(!figure.has_row_weights());
|
||||||
|
const std::vector<plot::Rect> after = rows_of(figure);
|
||||||
|
PLOT_CHECK(after.size() == before.size());
|
||||||
|
for (std::size_t i = 0; i < after.size(); ++i) {
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(after[i].h), static_cast<plot::f64>(before[i].h), 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A refused set is refused *before* it is stored, so one good set after six bad
|
||||||
|
// ones is what the figure divides by.
|
||||||
|
const float good[] = {2.0f, 1.0f, 1.0f};
|
||||||
|
PLOT_CHECK(figure.set_row_weights(good));
|
||||||
|
const std::vector<plot::Rect> weighted = rows_of(figure);
|
||||||
|
PLOT_CHECK(weighted[0].h > weighted[1].h);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every row keeps a positive height, whatever the figure's size and whatever the
|
||||||
|
/// weights say -- a row that is not there is a plot that is not there, silently. The
|
||||||
|
/// shared strip yields to the rows for the same reason: chrome may shrink a figure,
|
||||||
|
/// never erase one.
|
||||||
|
PLOT_TEST(every_row_keeps_a_positive_height) {
|
||||||
|
// Five rows in a figure shorter than the strip one row would ask for.
|
||||||
|
plot::Figure tiny(5u);
|
||||||
|
tiny.set_viewport({0.0f, 0.0f, 200.0f, 12.0f});
|
||||||
|
tiny.set_row_gap(6.0f); // more gap than the figure has height
|
||||||
|
const plot::FigureLayout layout = tiny.layout();
|
||||||
|
PLOT_CHECK(layout.rows.size() == 5u);
|
||||||
|
plot::f32 total = 0.0f;
|
||||||
|
for (const plot::Rect& row : layout.rows) {
|
||||||
|
PLOT_CHECK(row.h > 0.0f);
|
||||||
|
total += row.h;
|
||||||
|
}
|
||||||
|
// The rows took what they needed from the gap: a gap that does not fit is dropped,
|
||||||
|
// so what is left of the figure is the rows' and the strip's, not the gap's.
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(total + layout.x_axis_strip.h), 12.0, 1e-3);
|
||||||
|
|
||||||
|
// A wildly unbalanced weight set still cannot squeeze a row out. The tiny weights
|
||||||
|
// here are small enough that a row's *share* of the leftover underflows to zero
|
||||||
|
// once it is expressed as a float -- which is exactly the share that would have been
|
||||||
|
// its whole height without a floor under it.
|
||||||
|
plot::Figure lopsided(4u);
|
||||||
|
lopsided.set_viewport({0.0f, 50.0f, 400.0f, 300.0f});
|
||||||
|
const float lopsided_weights[] = {1.0f, 1e-45f, 1.0f, 1e-45f};
|
||||||
|
PLOT_CHECK(lopsided.set_row_weights(lopsided_weights));
|
||||||
|
for (const plot::Rect& row : lopsided.layout().rows) {
|
||||||
|
PLOT_CHECK(row.h > 0.0f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- the x link
|
||||||
|
|
||||||
|
/// Linking a row to a group that already has a range puts it on that range at once,
|
||||||
|
/// not on the next frame: the group's window is the group's, and a member is on it
|
||||||
|
/// from the call that made it a member.
|
||||||
|
PLOT_TEST(link_x_applies_the_group_range_immediately) {
|
||||||
|
plot::Figure figure = figure_with_axes(3u);
|
||||||
|
PLOT_CHECK(figure.x_link(0u) == 0u); // every row starts unlinked
|
||||||
|
PLOT_CHECK(figure.x_link(1u) == 0u);
|
||||||
|
const plot::u32 group = figure.add_x_link();
|
||||||
|
PLOT_CHECK(group == 1u); // 0 is the unlinked group, so ids start at 1
|
||||||
|
PLOT_CHECK(figure.add_x_link() == 2u);
|
||||||
|
PLOT_CHECK(figure.x_link_count() == 2u);
|
||||||
|
|
||||||
|
PLOT_CHECK(figure.link_x(0u, group));
|
||||||
|
PLOT_CHECK(figure.x_link(0u) == group);
|
||||||
|
// The first row into an empty group frames it, so the group is not a range the
|
||||||
|
// caller never chose.
|
||||||
|
PLOT_CHECK_NEAR(figure.x_link_range(group).min, 0.0, 1e-12);
|
||||||
|
PLOT_CHECK_NEAR(figure.x_link_range(group).max, 100.0, 1e-12);
|
||||||
|
|
||||||
|
PLOT_CHECK(figure.set_x_link_range(group, {10.0, 20.0}));
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).min, 10.0, 1e-12);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).max, 20.0, 1e-12);
|
||||||
|
|
||||||
|
// The row joining now lands on the group's window rather than keeping its own.
|
||||||
|
figure.plot(1u).set_x_range(0u, {500.0, 900.0});
|
||||||
|
PLOT_CHECK(figure.link_x(1u, group));
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(1u).x_range(0u).min, 10.0, 1e-12);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(1u).x_range(0u).max, 20.0, 1e-12);
|
||||||
|
|
||||||
|
// Group 0 is the way back out, and a row that leaves keeps the range it holds.
|
||||||
|
PLOT_CHECK(figure.link_x(1u, 0u));
|
||||||
|
PLOT_CHECK(figure.x_link(1u) == 0u);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(1u).x_range(0u).min, 10.0, 1e-12);
|
||||||
|
|
||||||
|
// A row or a group that does not exist is refused, and nothing moves.
|
||||||
|
PLOT_CHECK(!figure.link_x(9u, group));
|
||||||
|
PLOT_CHECK(!figure.link_x(0u, 9u));
|
||||||
|
PLOT_CHECK(!figure.set_x_link_range(0u, {1.0, 2.0})); // group 0 has no shared range
|
||||||
|
PLOT_CHECK(!figure.set_x_link_range(9u, {1.0, 2.0}));
|
||||||
|
PLOT_CHECK(!figure.set_x_link_follow(0u, plot::XFollow::Live));
|
||||||
|
PLOT_CHECK(figure.x_link(0u) == group);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).min, 10.0, 1e-12);
|
||||||
|
|
||||||
|
// A row with no x axis has nothing to link, and linking it must not pretend
|
||||||
|
// otherwise.
|
||||||
|
plot::Figure bare(1u);
|
||||||
|
const plot::u32 g = bare.add_x_link();
|
||||||
|
PLOT_CHECK(!bare.link_x(0u, g));
|
||||||
|
PLOT_CHECK(bare.x_link(0u) == 0u);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A group owns one range, so the range any single member is given becomes every
|
||||||
|
/// member's before the next frame -- whether it arrived from a caller or from a
|
||||||
|
/// controller, which reach a member exactly the same way.
|
||||||
|
PLOT_TEST(setting_a_range_on_any_member_propagates_to_all) {
|
||||||
|
plot::Figure figure = figure_with_axes(3u);
|
||||||
|
const plot::u32 group = figure.add_x_link();
|
||||||
|
for (plot::u32 row = 0; row < 3u; ++row) PLOT_CHECK(figure.link_x(row, group));
|
||||||
|
PLOT_CHECK(figure.set_x_link_range(group, {5.0, 15.0}));
|
||||||
|
|
||||||
|
// Set on each member in turn: each one is the group's range from the next layout,
|
||||||
|
// and every other member holds it too.
|
||||||
|
const double values[][2] = {{100.0, 200.0}, {-40.0, -10.0}, {0.5, 0.75}};
|
||||||
|
for (plot::u32 row = 0; row < 3u; ++row) {
|
||||||
|
figure.plot(row).set_x_range(0u, {values[row][0], values[row][1]});
|
||||||
|
figure.layout();
|
||||||
|
for (plot::u32 member = 0; member < 3u; ++member) {
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(member).x_range(0u).min, values[row][0], 1e-12);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(member).x_range(0u).max, values[row][1], 1e-12);
|
||||||
|
}
|
||||||
|
PLOT_CHECK_NEAR(figure.x_link_range(group).min, values[row][0], 1e-12);
|
||||||
|
PLOT_CHECK_NEAR(figure.x_link_range(group).max, values[row][1], 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A member with auto scale armed and data of its own cannot drag the group back:
|
||||||
|
// the push applies the group's range, and applying a range is what clears auto
|
||||||
|
// scale -- so a linked row is never re-fitted out of the group's window, however
|
||||||
|
// many frames go by. The trace's own bounds are a thousand wide, so a row that
|
||||||
|
// fitted itself would be unmistakable.
|
||||||
|
plot::data::ArraySeries wide(std::vector<plot::f32>(64u, 0.5f), 1000.0, 1.0);
|
||||||
|
figure.plot(2u).add_trace({&wide, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u});
|
||||||
|
figure.plot(2u).set_x_auto_scale(0u, true);
|
||||||
|
PLOT_CHECK(figure.set_x_link_range(group, {1.0, 2.0}));
|
||||||
|
for (int frame = 0; frame < 3; ++frame) {
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(2u).x_range(0u).min, 1.0, 1e-12);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(2u).x_range(0u).max, 2.0, 1e-12);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).min, 1.0, 1e-12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The propagation settles in one pass and stays settled: after a frame every member
|
||||||
|
/// holds the group's range, and a member written back with a range that *is* the
|
||||||
|
/// group's -- the write a re-propagating implementation would read as a change -- does
|
||||||
|
/// not move it again. This is the case that catches a write-back loop: a design that
|
||||||
|
/// compared a member against the group's range instead of against what it pushed would
|
||||||
|
/// re-derive the group's state from its own output on every frame.
|
||||||
|
PLOT_TEST(propagation_is_idempotent_and_terminates) {
|
||||||
|
plot::Figure figure = figure_with_axes(2u);
|
||||||
|
const plot::u32 group = figure.add_x_link();
|
||||||
|
PLOT_CHECK(figure.link_x(0u, group));
|
||||||
|
PLOT_CHECK(figure.link_x(1u, group));
|
||||||
|
PLOT_CHECK(figure.set_x_link_range(group, {0.0, 10.0}));
|
||||||
|
|
||||||
|
// A long alternating sequence: a change on one row, one layout, and both rows
|
||||||
|
// agree. "One pass" is exactly this -- a second layout with no new change moves
|
||||||
|
// nothing, so the frame after a change is the frame that settles it.
|
||||||
|
for (plot::u32 step = 0; step < 500u; ++step) {
|
||||||
|
const plot::u32 row = step % 2u;
|
||||||
|
const double lo = static_cast<double>(step) * 0.25;
|
||||||
|
figure.plot(row).set_x_range(0u, {lo, lo + 3.0});
|
||||||
|
figure.layout();
|
||||||
|
for (plot::u32 member = 0; member < 2u; ++member) {
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(member).x_range(0u).min, lo, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(member).x_range(0u).max, lo + 3.0, 1e-9);
|
||||||
|
}
|
||||||
|
// Nothing left to propagate: the members already hold what the group holds, so
|
||||||
|
// further frames are the identity on the range.
|
||||||
|
const plot::Range settled = figure.x_link_range(group);
|
||||||
|
for (int frame = 0; frame < 3; ++frame) {
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK_NEAR(figure.x_link_range(group).min, settled.min, 1e-12);
|
||||||
|
PLOT_CHECK_NEAR(figure.x_link_range(group).max, settled.max, 1e-12);
|
||||||
|
for (plot::u32 member = 0; member < 2u; ++member) {
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(member).x_range(0u).min, settled.min, 1e-12);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(member).x_range(0u).max, settled.max, 1e-12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... and deliberately written back in: a member handed the group's own range --
|
||||||
|
// what a propagated range is -- must not be taken for a new change and pushed
|
||||||
|
// again, in a frame or in a hundred.
|
||||||
|
const plot::Range held = figure.x_link_range(group);
|
||||||
|
for (int frame = 0; frame < 100; ++frame) {
|
||||||
|
figure.plot(1u).set_x_range(0u, held);
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK_NEAR(figure.x_link_range(group).min, held.min, 1e-12);
|
||||||
|
PLOT_CHECK_NEAR(figure.x_link_range(group).max, held.max, 1e-12);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).min, held.min, 1e-12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rows that are not in a group are not in one: each keeps whatever its own plot was
|
||||||
|
/// given, however many frames go by, and a group's window is nobody else's business.
|
||||||
|
PLOT_TEST(unlinked_plots_keep_independent_ranges) {
|
||||||
|
plot::Figure figure = figure_with_axes(3u);
|
||||||
|
figure.plot(0u).set_x_range(0u, {1.0, 2.0});
|
||||||
|
figure.plot(1u).set_x_range(0u, {10.0, 40.0});
|
||||||
|
figure.plot(2u).set_x_range(0u, {-5.0, 5.0});
|
||||||
|
|
||||||
|
// A group elsewhere in the figure, to prove it is the group that shares and not
|
||||||
|
// the figure: adding one changes no row.
|
||||||
|
const plot::u32 group = figure.add_x_link();
|
||||||
|
PLOT_CHECK(figure.x_link_count() == 1u);
|
||||||
|
|
||||||
|
for (int frame = 0; frame < 5; ++frame) {
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).min, 1.0, 1e-12);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).max, 2.0, 1e-12);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(1u).x_range(0u).min, 10.0, 1e-12);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(1u).x_range(0u).max, 40.0, 1e-12);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(2u).x_range(0u).min, -5.0, 1e-12);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(2u).x_range(0u).max, 5.0, 1e-12);
|
||||||
|
}
|
||||||
|
PLOT_CHECK(figure.x_link(0u) == 0u && figure.x_link(1u) == 0u && figure.x_link(2u) == 0u);
|
||||||
|
|
||||||
|
// Linking one row does not touch the others.
|
||||||
|
PLOT_CHECK(figure.link_x(1u, group));
|
||||||
|
PLOT_CHECK_NEAR(figure.x_link_range(group).min, 10.0, 1e-12);
|
||||||
|
PLOT_CHECK_NEAR(figure.x_link_range(group).max, 40.0, 1e-12);
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).min, 1.0, 1e-12);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).max, 2.0, 1e-12);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(2u).x_range(0u).min, -5.0, 1e-12);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(2u).x_range(0u).max, 5.0, 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `Live` axis owns its own range -- its own span, its own offset, its own live
|
||||||
|
/// edge -- so a link group cannot own it too. Linking one is refused with a `Status`
|
||||||
|
/// rather than silently reinterpreted: the caller is told, and the axis is left
|
||||||
|
/// exactly as it was.
|
||||||
|
PLOT_TEST(linking_a_live_axis_is_refused_with_a_status) {
|
||||||
|
plot::Figure figure = figure_with_axes(2u);
|
||||||
|
const plot::u32 group = figure.add_x_link();
|
||||||
|
figure.plot(0u).set_x_follow(0u, plot::XFollow::Live);
|
||||||
|
PLOT_CHECK(figure.plot(0u).x_follow(0u) == plot::XFollow::Live);
|
||||||
|
const plot::Range before = figure.plot(0u).x_range(0u);
|
||||||
|
|
||||||
|
const plot::Status st = figure.link_x(0u, group);
|
||||||
|
PLOT_CHECK(!st);
|
||||||
|
PLOT_CHECK(st.code == plot::ErrorCode::InvalidArgument);
|
||||||
|
PLOT_CHECK(st.message[0] != '\0'); // a refusal a caller can read
|
||||||
|
PLOT_CHECK(figure.x_link(0u) == 0u);
|
||||||
|
// ... and it is still Live, with the range it had: a refusal that changed the
|
||||||
|
// state would be the silent re-interpretation it refuses to be.
|
||||||
|
PLOT_CHECK(figure.plot(0u).x_follow(0u) == plot::XFollow::Live);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).min, before.min, 1e-12);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).max, before.max, 1e-12);
|
||||||
|
|
||||||
|
// The other row links, and the group's window leaves the live axis alone.
|
||||||
|
PLOT_CHECK(figure.link_x(1u, group));
|
||||||
|
PLOT_CHECK(figure.set_x_link_range(group, {3.0, 4.0}));
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(1u).x_range(0u).min, 3.0, 1e-12);
|
||||||
|
PLOT_CHECK(figure.plot(0u).x_follow(0u) == plot::XFollow::Live);
|
||||||
|
|
||||||
|
// Turning the follow off is what makes the axis linkable -- the group's state is
|
||||||
|
// the group's, so the axis has to be `Off` first.
|
||||||
|
figure.plot(0u).set_x_follow(0u, plot::XFollow::Off);
|
||||||
|
PLOT_CHECK(figure.link_x(0u, group));
|
||||||
|
PLOT_CHECK(figure.x_link(0u) == group);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).min, 3.0, 1e-12);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).max, 4.0, 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A following group advances every row together: the window is derived once per
|
||||||
|
/// frame from the newest x across the members' traces, exactly as one `Live` axis'
|
||||||
|
/// would be, so a streaming multi-row figure has one window and no per-row drift --
|
||||||
|
/// each row sees the same edge even though only one of them is being pushed to. The
|
||||||
|
/// two rows hold *different* amounts of data, so "the group's edge" and "this row's
|
||||||
|
/// edge" are different numbers and the assertions can tell them apart.
|
||||||
|
PLOT_TEST(a_following_group_advances_all_rows_together) {
|
||||||
|
plot::data::StreamSeries left(1024u, 0.0, 1.0);
|
||||||
|
plot::data::StreamSeries right(1024u, 0.0, 1.0);
|
||||||
|
for (int i = 0; i < 300; ++i) {
|
||||||
|
left.push(static_cast<plot::f32>(std::sin(i * 0.1)));
|
||||||
|
}
|
||||||
|
for (int i = 0; i < 200; ++i) {
|
||||||
|
right.push(static_cast<plot::f32>(std::cos(i * 0.1)));
|
||||||
|
}
|
||||||
|
left.acquire();
|
||||||
|
right.acquire();
|
||||||
|
|
||||||
|
plot::Figure figure = figure_with_axes(2u);
|
||||||
|
figure.plot(0u).add_trace({&left, plot::Color::hex(0x4FA3FF), 1.5f, 0u, 0u});
|
||||||
|
figure.plot(1u).add_trace({&right, plot::Color::hex(0xFF8080), 1.5f, 0u, 0u});
|
||||||
|
|
||||||
|
const plot::u32 group = figure.add_x_link();
|
||||||
|
PLOT_CHECK(figure.link_x(0u, group));
|
||||||
|
PLOT_CHECK(figure.link_x(1u, group));
|
||||||
|
PLOT_CHECK(figure.set_x_link_follow(group, plot::XFollow::Live));
|
||||||
|
PLOT_CHECK(figure.x_link_follow(group) == plot::XFollow::Live);
|
||||||
|
// Under `Live` a range is a span and an offset behind the live edge, not a pair of
|
||||||
|
// positions, so the group is told "ten wide, ending at the edge" -- the speed row's
|
||||||
|
// newest x, which is where the group's window will stay.
|
||||||
|
const plot::f64 edge0 = left.x_bounds().max;
|
||||||
|
PLOT_CHECK(figure.set_x_link_range(group, {edge0 - 10.0, edge0}));
|
||||||
|
|
||||||
|
const auto both_rows_hold = [&](plot::Range& out) {
|
||||||
|
const plot::Range a = figure.plot(0u).x_range(0u);
|
||||||
|
const plot::Range b = figure.plot(1u).x_range(0u);
|
||||||
|
PLOT_CHECK_NEAR(a.min, b.min, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(a.max, b.max, 1e-9);
|
||||||
|
out = a;
|
||||||
|
};
|
||||||
|
|
||||||
|
figure.layout();
|
||||||
|
plot::Range previous{};
|
||||||
|
both_rows_hold(previous);
|
||||||
|
// The window ends at the edge the range said it should end at, and is ten wide:
|
||||||
|
// both members hold it, and the slower row's own edge is not where it ends.
|
||||||
|
PLOT_CHECK_NEAR(previous.max, edge0, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(previous.max - previous.min, 10.0, 1e-9);
|
||||||
|
PLOT_CHECK(right.x_bounds().max < previous.max - 50.0);
|
||||||
|
|
||||||
|
// The producer keeps going -- the two rows at different rates, so their *own* live
|
||||||
|
// edges are different and a window derived from one row's stream would be
|
||||||
|
// unmistakable -- and every frame moves *both* rows to the same window, the one the
|
||||||
|
// group derives from the newest sample it can see.
|
||||||
|
for (int step = 1; step <= 60; ++step) {
|
||||||
|
for (int i = 0; i < 4; ++i) {
|
||||||
|
left.push(static_cast<plot::f32>(std::sin(step * 0.1)));
|
||||||
|
}
|
||||||
|
for (int i = 0; i < 2; ++i) {
|
||||||
|
right.push(static_cast<plot::f32>(std::cos(step * 0.1)));
|
||||||
|
}
|
||||||
|
left.acquire();
|
||||||
|
right.acquire();
|
||||||
|
figure.layout();
|
||||||
|
plot::Range now{};
|
||||||
|
both_rows_hold(now);
|
||||||
|
PLOT_CHECK(now.max > previous.max); // the window advanced
|
||||||
|
PLOT_CHECK_NEAR(now.max - now.min, 10.0, 1e-9); // and kept its span
|
||||||
|
// The window's right end is the *newest* of the two rows' edges, which is the
|
||||||
|
// faster row's -- the slower row's own edge would be well behind it.
|
||||||
|
PLOT_CHECK(left.x_bounds().max > right.x_bounds().max + 10.0);
|
||||||
|
PLOT_CHECK_NEAR(now.max, left.x_bounds().max, 1e-9);
|
||||||
|
previous = now;
|
||||||
|
}
|
||||||
|
PLOT_CHECK(previous.max > edge0);
|
||||||
|
|
||||||
|
// Panning a member while the group follows is a *span-and-offset* statement, so it
|
||||||
|
// moves all rows together and leaves following on -- the group's edge still owns
|
||||||
|
// the window's right end.
|
||||||
|
figure.plot(1u).set_x_range(0u, {previous.max - 60.0, previous.max - 40.0});
|
||||||
|
figure.layout();
|
||||||
|
plot::Range panned{};
|
||||||
|
both_rows_hold(panned);
|
||||||
|
PLOT_CHECK_NEAR(panned.max - panned.min, 20.0, 1e-9); // the span the pan stated
|
||||||
|
PLOT_CHECK_NEAR(panned.max, left.x_bounds().max - 40.0, 1e-9); // 40 behind the edge
|
||||||
|
PLOT_CHECK(figure.x_link_follow(group) == plot::XFollow::Live);
|
||||||
|
PLOT_CHECK(figure.plot(0u).x_follow(0u) == plot::XFollow::Off); // members stay Off
|
||||||
|
|
||||||
|
// Disarming leaves the window where it is; the rows are still one range.
|
||||||
|
PLOT_CHECK(figure.set_x_link_follow(group, plot::XFollow::Off));
|
||||||
|
const plot::Range frozen = figure.x_link_range(group);
|
||||||
|
for (int frame = 0; frame < 5; ++frame) {
|
||||||
|
left.push(1.0f);
|
||||||
|
left.acquire();
|
||||||
|
figure.layout();
|
||||||
|
both_rows_hold(previous);
|
||||||
|
PLOT_CHECK_NEAR(figure.x_link_range(group).min, frozen.min, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(figure.x_link_range(group).max, frozen.max, 1e-9);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
#include <plot/data/array_series.hpp>
|
#include <plot/data/array_series.hpp>
|
||||||
#include <plot/render/buffer.hpp>
|
#include <plot/render/buffer.hpp>
|
||||||
#include <plot/render/detail/ring_stats.hpp>
|
#include <plot/render/detail/ring_stats.hpp>
|
||||||
|
#include <plot/plot/figure.hpp>
|
||||||
#include <plot/render/renderer.hpp>
|
#include <plot/render/renderer.hpp>
|
||||||
#include <plot/input/controller.hpp>
|
#include <plot/input/controller.hpp>
|
||||||
|
|
||||||
@@ -454,6 +455,22 @@ std::vector<plot::u8> read_frame(plottest::HeadlessContext& ctx) {
|
|||||||
return frame;
|
return frame;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The pixels of an already-read frame that hold `text`-coloured ink, anti-aliased
|
||||||
|
/// fringe and all (see `text_ink`). `predicate_pixels` takes a predicate and not a
|
||||||
|
/// colour, so a count that has to name *one* colour reads the frame this way -- and
|
||||||
|
/// reading the frame once is what makes counting it twice cheap enough to compare two
|
||||||
|
/// whole frames.
|
||||||
|
plot::u32 frame_text_ink(const std::vector<plot::u8>& frame, plot::u32 fb_w, plot::u32 fb_h,
|
||||||
|
plot::Color text) {
|
||||||
|
plot::u32 count = 0;
|
||||||
|
for (plot::u32 y = 0; y < fb_h; ++y) {
|
||||||
|
for (plot::u32 x = 0; x < fb_w; ++x) {
|
||||||
|
if (text_ink(&frame[(plot::usize(y) * fb_w + x) * 4u], text)) ++count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
/// Holds one GL entry point at a stub for as long as it lives. The entry points
|
/// Holds one GL entry point at a stub for as long as it lives. The entry points
|
||||||
/// are the library's own function pointers (`plot/gl/api.hpp`), so a driver
|
/// are the library's own function pointers (`plot/gl/api.hpp`), so a driver
|
||||||
/// refusal -- a map or a fence that fails while the caps still advertise support
|
/// refusal -- a map or a fence that fails while the caps still advertise support
|
||||||
@@ -3430,3 +3447,280 @@ PLOT_TEST(grabbed_handle_is_highlighted) {
|
|||||||
|
|
||||||
ctx.destroy();
|
ctx.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
/// Two traces whose colours no other element of these frames produces, each with a
|
||||||
|
/// predicate that refuses the other's: the top row's is blue (`trace_pixel`), the
|
||||||
|
/// second row's magenta (`magenta_pixel`). One figure's rows can then be told apart by
|
||||||
|
/// their pixels, which is what makes "the rows hold different content" a statement
|
||||||
|
/// about the rows and not about the frame.
|
||||||
|
const plot::Color kRowBlue = plot::Color::hex(0x40C0FF);
|
||||||
|
const plot::Color kRowMagenta = plot::Color::hex(0xFF00FF);
|
||||||
|
|
||||||
|
/// A sampled wave, one sample per x unit, so a row's trace crosses every column of its
|
||||||
|
/// axes exactly once: every coverage statement below is then about the row it names.
|
||||||
|
std::vector<plot::f32> wave(plot::u32 n, plot::f32 phase) {
|
||||||
|
std::vector<plot::f32> y(n);
|
||||||
|
for (plot::u32 i = 0; i < n; ++i) {
|
||||||
|
y[i] = 0.8f * std::sin(phase + 0.05f * static_cast<plot::f32>(i));
|
||||||
|
}
|
||||||
|
return y;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gives one figure row the axes and the trace these cases draw: an x axis spanning
|
||||||
|
/// the series and a fixed y axis, so the geometry a row is drawn with is the geometry
|
||||||
|
/// the case measured.
|
||||||
|
void add_row_trace(plot::Plot& p, const plot::data::Series& s, plot::Color color) {
|
||||||
|
const plot::u32 ax = p.add_x_axis({"t", s.x_bounds(), false, plot::AxisPlace::Bottom, false});
|
||||||
|
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
||||||
|
p.add_trace({&s, color, 1.5f, ax, ay});
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
/// A figure's rows are drawn into their own rects, so what one row renders never
|
||||||
|
/// reaches another's: two rows carrying traces of two colours put each colour in its
|
||||||
|
/// own row's rect and in no other, which is the whole point of stacking plots rather
|
||||||
|
/// than drawing one.
|
||||||
|
PLOT_TEST(two_rows_render_distinct_content) {
|
||||||
|
plottest::HeadlessContext ctx(kW, kH);
|
||||||
|
plot::Status st;
|
||||||
|
PLOT_CHECK(ctx.create(&st));
|
||||||
|
PLOT_CHECK(plot::gl::load(nullptr));
|
||||||
|
plot::Renderer r;
|
||||||
|
PLOT_CHECK(r.init(&st));
|
||||||
|
|
||||||
|
const std::vector<plot::f32> top = wave(640u, 0.0f);
|
||||||
|
const std::vector<plot::f32> bottom = wave(640u, 1.0f);
|
||||||
|
plot::data::ArraySeries top_series(top, 0.0, 1.0);
|
||||||
|
plot::data::ArraySeries bottom_series(bottom, 0.0, 1.0);
|
||||||
|
|
||||||
|
plot::Figure figure(2u);
|
||||||
|
figure.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||||
|
add_row_trace(figure.plot(0u), top_series, kRowBlue);
|
||||||
|
add_row_trace(figure.plot(1u), bottom_series, kRowMagenta);
|
||||||
|
|
||||||
|
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||||
|
r.draw(figure);
|
||||||
|
r.end_frame();
|
||||||
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||||
|
dump_if_requested(r, "figure_two_rows.ppm");
|
||||||
|
|
||||||
|
const std::vector<plot::u8> frame = read_frame(ctx);
|
||||||
|
const plot::FigureLayout layout = figure.layout();
|
||||||
|
PLOT_CHECK(layout.rows.size() == 2u);
|
||||||
|
const plot::Rect& row0 = layout.rows[0];
|
||||||
|
const plot::Rect& row1 = layout.rows[1];
|
||||||
|
PLOT_CHECK(row0.h > 0.0f && row1.h > 0.0f);
|
||||||
|
|
||||||
|
// Each row's own colour is present in its own rect...
|
||||||
|
PLOT_CHECK(predicate_pixels(frame, kW, row0, trace_pixel) > 0u);
|
||||||
|
PLOT_CHECK(predicate_pixels(frame, kW, row1, magenta_pixel) > 0u);
|
||||||
|
// ... and each row's colour is absent from the other's, which is what "the rows
|
||||||
|
// hold distinct content" means: a frame drawn as one plot, or one row drawn over
|
||||||
|
// the other, fails here whichever way it mixes them.
|
||||||
|
PLOT_CHECK(predicate_pixels(frame, kW, row1, trace_pixel) == 0u);
|
||||||
|
PLOT_CHECK(predicate_pixels(frame, kW, row0, magenta_pixel) == 0u);
|
||||||
|
// The frame holds exactly the two rows' traces: nothing of either colour was drawn
|
||||||
|
// outside the rect the row owns.
|
||||||
|
PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, trace_pixel) ==
|
||||||
|
predicate_pixels(frame, kW, row0, trace_pixel));
|
||||||
|
PLOT_CHECK(predicate_pixels(frame, kW, kFrameRect, magenta_pixel) ==
|
||||||
|
predicate_pixels(frame, kW, row1, magenta_pixel));
|
||||||
|
|
||||||
|
// Each trace is drawn inside its row's *axes*, not merely somewhere in the row: the
|
||||||
|
// row's y strip is the other half of the rect and holds no data.
|
||||||
|
const plot::PlotLayout& layout0 = figure.plot(0u).layout();
|
||||||
|
const plot::PlotLayout& layout1 = figure.plot(1u).layout();
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(layout0.axes.h), static_cast<plot::f64>(row0.h), 1e-3);
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(layout1.axes.h), static_cast<plot::f64>(row1.h), 1e-3);
|
||||||
|
PLOT_CHECK(predicate_pixels(frame, kW, layout0.axes, trace_pixel) > 0u);
|
||||||
|
PLOT_CHECK(predicate_pixels(frame, kW, layout1.axes, magenta_pixel) > 0u);
|
||||||
|
// The rows are stacked, not overlapped: the second starts below the first.
|
||||||
|
PLOT_CHECK(row1.y >= row0.bottom() - 1e-3f);
|
||||||
|
|
||||||
|
ctx.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The figure draws *one* x axis. With labels on the bottom only -- the default --
|
||||||
|
/// the rows reserve no x label strip of their own and their labels appear once, in
|
||||||
|
/// the strip the figure measured under the last row; the interior row's labels
|
||||||
|
/// are not in the frame at all. Switching the figure to label every row puts them in
|
||||||
|
/// every row, so the difference between the two frames is exactly the ink the shared
|
||||||
|
/// axis exists to prevent -- which is what makes the count below a statement rather
|
||||||
|
/// than a scan of somewhere nothing is ever drawn.
|
||||||
|
PLOT_TEST(shared_x_axis_is_drawn_once) {
|
||||||
|
plottest::HeadlessContext ctx(kW, kH);
|
||||||
|
plot::Status st;
|
||||||
|
PLOT_CHECK(ctx.create(&st));
|
||||||
|
PLOT_CHECK(plot::gl::load(nullptr));
|
||||||
|
plot::Renderer r;
|
||||||
|
PLOT_CHECK(r.init(&st));
|
||||||
|
|
||||||
|
const std::vector<plot::f32> samples = wave(640u, 0.0f);
|
||||||
|
plot::data::ArraySeries series(samples, 0.0, 1.0);
|
||||||
|
|
||||||
|
plot::Figure figure(3u);
|
||||||
|
figure.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||||
|
for (plot::u32 row = 0; row < 3u; ++row) add_row_trace(figure.plot(row), series, kRowBlue);
|
||||||
|
PLOT_CHECK(figure.x_labels_only_on_bottom());
|
||||||
|
|
||||||
|
const auto draw_frame = [&] {
|
||||||
|
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||||
|
r.draw(figure);
|
||||||
|
r.end_frame();
|
||||||
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||||
|
return read_frame(ctx);
|
||||||
|
};
|
||||||
|
|
||||||
|
const plot::Color ink = r.style().axis_text;
|
||||||
|
|
||||||
|
// --- labels on the bottom only: one set, and it is the figure's -----------------
|
||||||
|
const std::vector<plot::u8> shared = draw_frame();
|
||||||
|
dump_if_requested(r, "figure_shared_x.ppm");
|
||||||
|
const plot::FigureLayout layout = figure.layout();
|
||||||
|
PLOT_CHECK(layout.rows.size() == 3u);
|
||||||
|
PLOT_CHECK(layout.x_axis_strip.h > 0.0f);
|
||||||
|
// The strip is the figure's, under the last row: the rows end where it starts, and
|
||||||
|
// it ends on the figure's bottom edge.
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(layout.rows.back().bottom()),
|
||||||
|
static_cast<plot::f64>(layout.x_axis_strip.y), 1e-3);
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(layout.x_axis_strip.bottom()),
|
||||||
|
static_cast<plot::f64>(kH), 1e-3);
|
||||||
|
const plot::u32 strip_ink = scan_rect_ink(ctx, layout.x_axis_strip, ink).pixels;
|
||||||
|
PLOT_CHECK(strip_ink > 0u);
|
||||||
|
|
||||||
|
// Every row gave its x label strip away: a row's axes reaches its rect's bottom edge
|
||||||
|
// and the row has no band inside itself for a second set of labels to be drawn in.
|
||||||
|
for (plot::u32 row = 0; row < 3u; ++row) {
|
||||||
|
const plot::PlotLayout& row_layout = figure.plot(row).layout();
|
||||||
|
PLOT_CHECK(!figure.plot(row).x_label_strip());
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(row_layout.x_axis_height), 0.0, 1e-6);
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(row_layout.x_label_area.h), 0.0, 1e-6);
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(row_layout.axes.bottom()),
|
||||||
|
static_cast<plot::f64>(layout.rows[row].bottom()), 1e-3);
|
||||||
|
}
|
||||||
|
// ... and the band a second copy of them *would* land in holds none. A row that
|
||||||
|
// kept no strip of its own would have its x labels staged against the top of the
|
||||||
|
// zero-height strip it does have, i.e. centred on its `axes`' bottom edge, so the
|
||||||
|
// band is the figure's strip height at the bottom of the row. Two insets keep it to
|
||||||
|
// labels and nothing else: four columns past `axes.x` clear the row's y axis line
|
||||||
|
// (the vertical edge `text_ink`, which is deliberately blind to one chalk tone or
|
||||||
|
// the other, happily accepts), and two rows above the row's bottom edge clear the
|
||||||
|
// row's own x axis line.
|
||||||
|
for (plot::u32 row = 0; row + 1u < 3u; ++row) {
|
||||||
|
const plot::PlotLayout& row_layout = figure.plot(row).layout();
|
||||||
|
const plot::Rect band{row_layout.axes.x + 4.0f,
|
||||||
|
layout.rows[row].bottom() - layout.x_axis_strip.h,
|
||||||
|
row_layout.axes.w - 8.0f, layout.x_axis_strip.h - 2.0f};
|
||||||
|
PLOT_CHECK(band.h > 0.0f);
|
||||||
|
PLOT_CHECK(scan_rect_ink(ctx, band, ink).pixels == 0u);
|
||||||
|
}
|
||||||
|
const plot::u32 shared_ink = frame_text_ink(shared, kW, kH, ink);
|
||||||
|
|
||||||
|
// --- every row labels itself: the same labels, in three places -----------------
|
||||||
|
figure.set_x_labels_only_on_bottom(false);
|
||||||
|
const std::vector<plot::u8> per_row = draw_frame();
|
||||||
|
const plot::FigureLayout per_row_layout = figure.layout();
|
||||||
|
PLOT_CHECK(per_row_layout.x_axis_strip.h == 0.0f); // no shared strip any more
|
||||||
|
PLOT_CHECK(per_row_layout.rows.size() == 3u);
|
||||||
|
for (plot::u32 row = 0; row < 3u; ++row) {
|
||||||
|
const plot::PlotLayout& own = figure.plot(row).layout();
|
||||||
|
PLOT_CHECK(figure.plot(row).x_label_strip());
|
||||||
|
PLOT_CHECK(own.x_label_area.h > 0.0f);
|
||||||
|
// A row's own labels are in its own strip, inside its own rect.
|
||||||
|
PLOT_CHECK(own.x_label_area.y >= per_row_layout.rows[row].y);
|
||||||
|
PLOT_CHECK(own.x_label_area.bottom() <= per_row_layout.rows[row].bottom() + 1e-3f);
|
||||||
|
PLOT_CHECK(scan_rect_ink(ctx, own.x_label_area, ink).pixels > 0u);
|
||||||
|
}
|
||||||
|
const plot::u32 per_row_ink = frame_text_ink(per_row, kW, kH, ink);
|
||||||
|
|
||||||
|
// The two interior row's labels are the difference -- they are what the shared axis
|
||||||
|
// replaced, and in the default frame they are simply not there. The y labels, the
|
||||||
|
// chrome and the traces are in both frames, and the shared strip's labels are one
|
||||||
|
// row's worth, so a frame that drew the x labels twice per row cannot land here.
|
||||||
|
PLOT_CHECK(per_row_ink > shared_ink);
|
||||||
|
PLOT_CHECK(per_row_ink - shared_ink >= strip_ink / 2u);
|
||||||
|
|
||||||
|
ctx.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A resize recomputes the layout: the rows are re-derived from the new viewport --
|
||||||
|
/// their rects, the shared strip, the axes and the transforms they pin -- rather than
|
||||||
|
/// rescaled from, or left at, the rects of the frame before. The second frame here is
|
||||||
|
/// taller than the first, so a stale layout would paint the rows at the old
|
||||||
|
/// coordinates and a scaled one would put them somewhere between the two; the pixels
|
||||||
|
/// say which happened.
|
||||||
|
PLOT_TEST(a_row_resize_recomputes_rather_than_rescaling_a_stale_layout) {
|
||||||
|
plottest::HeadlessContext ctx(kW, kH);
|
||||||
|
plot::Status st;
|
||||||
|
PLOT_CHECK(ctx.create(&st));
|
||||||
|
PLOT_CHECK(plot::gl::load(nullptr));
|
||||||
|
plot::Renderer r;
|
||||||
|
PLOT_CHECK(r.init(&st));
|
||||||
|
|
||||||
|
const std::vector<plot::f32> top = wave(640u, 0.0f);
|
||||||
|
const std::vector<plot::f32> bottom = wave(640u, 1.0f);
|
||||||
|
plot::data::ArraySeries top_series(top, 0.0, 1.0);
|
||||||
|
plot::data::ArraySeries bottom_series(bottom, 0.0, 1.0);
|
||||||
|
|
||||||
|
plot::Figure figure(2u);
|
||||||
|
add_row_trace(figure.plot(0u), top_series, kRowBlue);
|
||||||
|
add_row_trace(figure.plot(1u), bottom_series, kRowMagenta);
|
||||||
|
|
||||||
|
// First frame: half the height, so the rows are short and the second row of the
|
||||||
|
// taller frame below is nowhere near where this one puts it.
|
||||||
|
const plot::f32 short_h = 0.5f * static_cast<plot::f32>(kH);
|
||||||
|
figure.set_viewport({0, 0, static_cast<plot::f32>(kW), short_h});
|
||||||
|
r.begin_frame(kW, kH, figure.viewport());
|
||||||
|
r.draw(figure);
|
||||||
|
r.end_frame();
|
||||||
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||||
|
const plot::FigureLayout short_layout = figure.layout();
|
||||||
|
PLOT_CHECK(short_layout.rows.size() == 2u);
|
||||||
|
|
||||||
|
// Second frame: the whole framebuffer.
|
||||||
|
figure.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||||
|
r.begin_frame(kW, kH, figure.viewport());
|
||||||
|
r.draw(figure);
|
||||||
|
r.end_frame();
|
||||||
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||||
|
dump_if_requested(r, "figure_resized.ppm");
|
||||||
|
|
||||||
|
const std::vector<plot::u8> frame = read_frame(ctx);
|
||||||
|
const plot::FigureLayout layout = figure.layout();
|
||||||
|
PLOT_CHECK(layout.rows.size() == 2u);
|
||||||
|
const plot::Rect& row1 = layout.rows[1];
|
||||||
|
|
||||||
|
// The rows are the *new* figure's: twice the first frame's height, to the pixel,
|
||||||
|
// and the second row reaches the shared strip rather than stopping where the short
|
||||||
|
// frame's strip was.
|
||||||
|
PLOT_CHECK(row1.h > 1.5f * short_layout.rows[1].h);
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(row1.bottom()),
|
||||||
|
static_cast<plot::f64>(layout.x_axis_strip.y), 1e-3);
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(layout.x_axis_strip.bottom()),
|
||||||
|
static_cast<plot::f64>(kH), 1e-3);
|
||||||
|
PLOT_CHECK(row1.y > short_layout.rows[1].y);
|
||||||
|
|
||||||
|
// The transforms the frame drew with are the new rects': the second row's axes
|
||||||
|
// spans its rect exactly, and its trace is in there -- not at the old coordinates.
|
||||||
|
const plot::PlotLayout& row1_layout = figure.plot(1u).layout();
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(row1_layout.axes.y), static_cast<plot::f64>(row1.y), 1e-3);
|
||||||
|
PLOT_CHECK_NEAR(static_cast<plot::f64>(row1_layout.axes.bottom()),
|
||||||
|
static_cast<plot::f64>(row1.bottom()), 1e-3);
|
||||||
|
PLOT_CHECK(predicate_pixels(frame, kW, row1_layout.axes, magenta_pixel) > 0u);
|
||||||
|
// Nothing of the second row's trace is above it, where the short frame's row 1 was:
|
||||||
|
// a stale layout would have left the magenta trace up there.
|
||||||
|
PLOT_CHECK(predicate_pixels(frame, kW, plot::Rect{0.0f, 0.0f, static_cast<plot::f32>(kW), row1.y},
|
||||||
|
magenta_pixel) == 0u);
|
||||||
|
// And the top row is still the top row: its own trace is above the second row and
|
||||||
|
// nowhere below it.
|
||||||
|
PLOT_CHECK(predicate_pixels(frame, kW, layout.rows[0], trace_pixel) > 0u);
|
||||||
|
PLOT_CHECK(predicate_pixels(frame, kW,
|
||||||
|
plot::Rect{0.0f, row1.y, static_cast<plot::f32>(kW),
|
||||||
|
static_cast<plot::f32>(kH) - row1.y},
|
||||||
|
trace_pixel) == 0u);
|
||||||
|
|
||||||
|
ctx.destroy();
|
||||||
|
}
|
||||||
Reference in new issue
Block a user