Files
plotter/src/plot/figure.cpp
T
Martino Ferrari 1f5ce0d4a5 fix(plot,figure): re-index hot region, logical row floor, refuse live on a linked axis
Three smaller findings at the plot/figure seam:

* `Plot::remove_region()` re-indexed `region_stats_` but left `hot_.region`
  naming the old index, so a frame after a removal highlighted the wrong
  region's bound and an open drag edited a different region. The published
  handle (and its zone) is now cleared or decremented with the cache, and the
  contract documents that a host must not remove a region mid-drag.

* `Figure`'s `kMinRowHeightPx` was applied as a *device*-pixel floor while
  chrome is logical, so at 2x the guarantee was half as strong exactly where
  the chrome is twice as large. It is now a logical constant converted through
  the row's own `ui_scale()`, 1:1 for a non-positive scale as `Style::device()`
  does.

* `Figure::link_x()` refused a member whose axis 0 was `XFollow::Live`, but
  nothing refused `set_x_follow(0u, XFollow::Live)` *after* linking. The group
  then wrote its range into an axis that reinterpreted it against its own live
  edge, read it back as an external change, and tracked one member instead of
  the union. `Plot` now carries a linkage flag (`set_x_link_member()`), set by
  `link_x()` and cleared by `unlink_row()`, and `set_x_follow()` refuses `Live`
  on a linked axis.

Tests: arming `Live` on a linked member leaves it `Off` and unlinking restores
the ability to arm it; the row-height floor is 24 and 48 device pixels at 1x
and 2x respectively.
2026-09-28 22:36:37 +02:00

493 lines
23 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/plot/figure.hpp>
#include <algorithm>
#include <cmath>
#include <cstddef>
namespace plot {
namespace {
/// The height a row never drops below, in **logical** pixels -- the same unit as every other
/// chrome size, converted to device pixels by the row's own `ui_scale()` where the floor is
/// applied. 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.
///
/// A `Figure` carries no `Style` -- the renderer's is the one its rows are laid out with --
/// but every row's `Plot` has the `ui_scale` the renderer installed on it, so the conversion
/// is the row's own and the guarantee is the same *physical* height at any DPI. A host that
/// wants a taller floor gives the figure a taller viewport, or gives the rows weights.
constexpr f32 kMinRowHeightLogical = 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 {
// A row that leaves its group stops following anything, so the hint a following group
// published to it goes with the membership: leaving `has_live` set behind would leave a
// former member anchoring a zoom on an edge it no longer follows. Its linkage flag goes
// with it too: an unlinked row is an ordinary plot again, free to arm `Live` itself.
if (row < plots_.size()) {
plots_[row].set_x_live_owner(0u, false, 0.0);
plots_[row].set_x_link_member(0u, false);
}
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{});
// A member's axis 0 is now owned by the group (its window and its follow state), so
// `set_x_follow(Live)` on it is refused from here on -- the same rule the refusal above
// applies at the door.
p.set_x_link_member(0u, true);
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. The live edge goes with the
// follow state -- nothing may anchor on an edge no group is following any more -- and
// this path does not go through `push_link()`, which is the other place the hint is
// written, so it is cleared here.
for (const u32 row : g.rows) plots_[row].set_x_live_owner(0u, false, 0.0);
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) {
// Every early return leaves the group with no live edge published: a group that is not
// following, one whose members hold nothing streaming, and one whose span is not a span
// all have no edge for a member's controller to anchor on.
group.has_live = false;
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.live_newest = live.max;
group.has_live = true;
group.range = Range{live.max - group.offset - group.span, live.max - group.offset};
return true;
}
void Figure::push_link(XLink& group) {
// The member's window is the group's and the group is what follows, so the edge is
// published with it: a gesture that asks where the live edge is (the controller's wheel
// anchor, outside `axes`) reads the group's edge rather than extrapolating from the
// pointer, which is the difference between a zoom that keeps the window on the newest
// sample and one that walks it back. A group that is not following publishes "no owner",
// which is what clears the hint the frame after `set_x_link_follow(g, Off)`.
const bool owned = group.follow == XFollow::Live && group.has_live;
for (std::size_t i = 0; i < group.rows.size(); ++i) {
Plot& p = plots_[group.rows[i]];
p.set_x_range(0u, group.range);
p.set_x_live_owner(0u, owned, group.live_newest);
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 rows tile the figure itself: the shared x axis is the *bottom row's own*, so
// nothing is taken off the figure for it. That is what keeps the axis reachable --
// its strip is a real `PlotLayout::x_label_area` inside the bottom row's rect, which
// is the rect the controller's axis-zoom hit test reads and the strip an x title is
// drawn in -- and it is why an interior row reserves no x chrome at all instead of
// leaving a band no one owns.
const f32 content_h = figure_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. Nothing above can make it negative -- the
// figure's height is clamped at zero and a gap that would eat it is dropped, both
// just above -- so the clamp is the arithmetic carrying its own invariant rather
// than a comment asking the reader to carry it.
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();
// The floor is a logical size like every other chrome metric, so it is converted through
// the row's own `ui_scale()` -- the one the renderer installed on every row -- which is
// what makes the guarantee the same *physical* height at any DPI instead of half as strong
// at 2x exactly where the chrome it protects is twice as large. A scale that is not
// positive is not a scale and converts 1:1, exactly as `Style::device()` documents.
const f32 floor_scale = plots_[0].ui_scale();
const f32 floor_device = floor_scale > 0.0f ? kMinRowHeightLogical * floor_scale
: kMinRowHeightLogical;
const f32 floor_px = std::min(floor_device, 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;
}
// The gaps are only *between* rows: the rows advance by `gap`, but they divide
// `usable`, which is already the figure less every gap, so the last row lands on the
// figure's bottom edge for any `row_gap_` -- the figure is tiled, not padded at the
// end. `row_gap` reports the gap actually left, which is 0 when the figure was too
// short to hold the one asked for.
layout_.row_gap = gap;
const u32 bottom_row = rows - 1u;
for (u32 row = 0; row < rows; ++row) {
Plot& p = plots_[row];
p.set_viewport(layout_.rows[row]);
// One x axis for the whole figure, and it is the bottom row's: every row keeps its
// x chrome -- labels, title and the strip the controller's axis zoom is found in --
// and every other row gives all of it to that one, so the labels and the title are
// not drawn once per row. A figure told to label every row keeps them everywhere,
// and then there is no shared axis to speak of: each row has its own.
p.set_x_strip_mode(labels_bottom_only_ && row != bottom_row ? XStrip::None : XStrip::Both);
}
// 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, 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.
//
// The rows' axes have to line up (spec §16), so this is where the figure's **common
// left gutter** is decided -- a row cannot see its siblings' tick labels, so the one
// width they can agree on has to be handed to them from here.
//
// Two passes, and no more. The first lays every row out with no floor, so each
// reports the gutter its own labels and title column need; the figure keeps the
// maximum. The second re-lays every row with that maximum imposed on its
// `Plot::set_min_y_gutter()`, which widens a row's strip to fill it, so all the rows'
// `axes` start and end on the same columns. A fixed-point loop -- re-measuring until
// the rows stop moving -- is the tempting wrong answer and is not needed: the gutter
// is a *maximum* over rows, and a row's own strip width does not depend on any other
// row's or on the width imposed on it (y tick labels are chosen from the row's own
// range and height, and a title column is one text line per titled axis), so one read
// per row settles the answer exactly. Measuring with a floor from the previous frame
// would be the only reason to iterate, and clearing it before the measurement pass is
// what removes it.
f32 gutter = 0.0f;
for (u32 row = 0; row < rows; ++row) {
plots_[row].set_min_y_gutter(0.0f);
const PlotLayout& row_layout = plots_[row].layout();
gutter = std::max(gutter, row_layout.y_axis_width + row_layout.y_title_width);
}
// A one-row figure imposes exactly what that row measured, so it lays out as the plot
// would on its own; a figure whose rows all need the same strip imposes that, which is
// also what each would have done alone.
for (u32 row = 0; row < rows; ++row) {
plots_[row].set_min_y_gutter(gutter);
plots_[row].layout();
}
// The figure's one x axis is the bottom row's, so the strip it is drawn in is that
// row's own label area -- the rect the controller hit-tests and the renderer stages
// its labels in, read back here rather than derived again, so a host and the frame
// cannot disagree about where the shared axis is. It is zero-sized only when there
// are no rows at all: with `set_x_labels_only_on_bottom(false)` it is simply the last
// row's own strip, one of several.
layout_.x_axis_strip = plots_.back().layout().x_label_area;
return layout_;
}
// ----------------------------------------------------------------- crosshair
void Figure::set_crosshair(f64 x, bool active) {
// One x, every row: the readouts are synced because they are told the same coordinate, not
// because a renderer copies one row's to the others. `Plot::set_crosshair()` refuses a
// coordinate that is not a number, so a garbage value leaves every row's crosshair as it
// was rather than clearing some rows and not others.
for (Plot& row : plots_) row.set_crosshair(x, active);
}
void Figure::set_crosshair(f64 x, u32 hovered_row, f64 y_in_row, bool active) {
// The shared x first, on every row -- the same state the single-coordinate overload leaves,
// so the vertical lines and every row's readout are placed by one rule whether or not the
// pointer's row is known. A bad x is refused by the row call, which changes nothing, so the
// y below must not run either or the hover would be half-placed.
if (active && !std::isfinite(x)) return;
for (Plot& row : plots_) row.set_crosshair(x, active);
// Then the pointer row's own y: the *only* row that gets a horizontal line, because the y is
// a value on that row's scale and no other row's. A row that does not exist (the pointer is in
// the gap between rows) leaves every row vertical-only, which is the honest answer -- there is
// no row under the pointer to mark -- and a non-finite y leaves the row vertical-only rather
// than clearing a hover the pointer still has.
if (!active || hovered_row >= plots_.size()) return;
plots_[hovered_row].set_crosshair(x, y_in_row, true);
}
void Figure::clear_crosshair() {
for (Plot& row : plots_) row.clear_crosshair();
}
} // namespace plot