// SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Martino Ferrari #include #include #include #include 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 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(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(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(i)); group.pushed.erase(group.pushed.begin() + static_cast(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(at - g.rows.begin()); g.rows.insert(at, row); g.pushed.insert(g.pushed.begin() + static_cast(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(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(rows)); const f32 extra = usable - floor_px * static_cast(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(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