// SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Martino Ferrari #include #include #include #include #include namespace plot { namespace { /// Layout passes. The left strip's width depends on the tick labels, which depend /// on the plot width the strip leaves; the loop stops as soon as the strip stops /// changing, so a normal plot converges on the second pass and never reaches this. constexpr u32 kMaxLayoutPasses = 4; /// Share of the viewport the axes rect keeps however much chrome the label and /// title strips ask for. A long SI label or a title wider than a narrow panel can /// ask for the whole viewport, and an axes rect with no width or no height draws /// nothing at all -- no grid, no tick, no trace -- without reporting anything, so /// the strips give way instead. The label strip is what makes the tick values /// readable, so it is served first and the title strip takes what is left; a title /// longer than its share of the strip is clipped to it rather than allowed to grow /// it (see `Renderer::draw_titles()`). constexpr f32 kMinAxesShare = 0.5f; const Axis& default_axis() noexcept { static const Axis axis{}; return axis; } /// An axis coordinate in the space the live follow arithmetic runs in: log10 when /// the axis is logarithmic, the value itself otherwise -- the same space the /// controller's `to_space`/`from_space` gestures run in, and the space `Axis::offset` /// and a `Live` window's span are stated in. A log axis therefore *scrolls* decades /// and holds decades under follow: a linear span on a log axis would rescale the /// view on every frame (the defect this fixes). f64 to_axis_space(f64 v, bool log) noexcept { return log ? Transform::log10_positive(v) : v; } f64 from_axis_space(f64 v, bool log) noexcept { return log ? std::pow(10.0, v) : v; } /// The sample range a data interval covers: `[begin, end)` over the samples whose /// x lies inside `[lo, hi]`, both ends included -- exactly the samples a region's /// statistics are over. The brackets come from `Series::neighbours()`, which names /// the samples at or either side of a bound *by the series' own x coordinates* /// (`x_at()`), so a series whose samples are not on a uniform grid -- an XY series /// -- is covered exactly where its samples actually are, not where a `dx()` grid /// would have put them. Still O(1): two neighbour lookups. /// /// False when the interval holds no sample at all, which is a real case: an /// interval between two adjacent samples on a sparse series, one entirely before or /// after the window, and a non-finite bound. bool covered_samples(const data::Series& series, f64 lo, f64 hi, u64& begin, u64& end) { const u64 n = series.size(); if (n == 0u || !std::isfinite(lo) || !std::isfinite(hi) || !(lo <= hi)) return false; // The first sample at or after `lo`: the right neighbour of the bound, unless the // bound lands on a sample -- which is then itself the first one inside. A bound // outside the window covers everything on its side: before the oldest sample the // coverage starts at 0, after the newest there is nothing inside. u64 i0 = 0; u64 i1 = 0; if (!series.neighbours(lo, i0, i1)) { if (series.x_at(0u) > lo) { i1 = 0u; } else { return false; } } else if (series.x_at(i0) >= lo) { i1 = i0; } const u64 b = i1; // One past the last sample at or before `hi`, from the same lookup: the left // neighbour of the bound, or the sample the bound landed on. u64 j0 = 0; u64 j1 = 0; if (!series.neighbours(hi, j0, j1)) { if (series.x_at(n - 1u) < hi) { j0 = n - 1u; } else { return false; } } else if (series.x_at(j1) <= hi) { j0 = j1; } const u64 last = j0; if (last < b) return false; // the interval falls between two adjacent samples begin = b; end = last + 1u; return true; } /// True when a pair of bounds can be a region's bounds: both of them finite. The one place /// this is decided, so `add_region()` and `set_region()` cannot disagree about what they /// will hold -- and so no NaN ever reaches the `std::min`/`std::max` below, which propagate /// it into *both* stored bounds and would leave a region no drag could recover from. bool finite_bounds(f64 x0, f64 x1) noexcept { return std::isfinite(x0) && std::isfinite(x1); } const Measurement& invalid_measurement() noexcept { static const Measurement measurement{}; return measurement; } Axis axis_from(const AxisDesc& desc) { Axis axis; axis.label = desc.label; axis.range = desc.range; axis.auto_scale = desc.auto_scale; axis.place = desc.place; axis.log = desc.log; axis.format = desc.format; return axis; } /// Fits `axis`' range to the union of its traces' bounds, returning false when the /// union cannot be fitted and the range is then left untouched. /// /// A linear axis takes 2% of the span on both ends; a zero-span union (a /// single-sample series) is widened instead of padded, because a degenerate range /// has no span to take 2% of and would leave the transform's inverse ambiguous. /// /// A log axis needs strictly positive data, and this is where a log axis could /// otherwise be left broken: an all-negative union has no positive value to show, /// and padding it would produce `min > max` once the lower edge was clamped to a /// positive bound. Instead the positive part of the union is used -- `[hi*1e-3, /// hi]` when the union straddles or starts below zero, since zero has no place on /// a log axis -- the pad is multiplicative (2% of the span in log space), and a /// union with no positive value at all is refused outright. The caller keeps its /// range and is free to retry, which is what lets a stream that starts at zero /// begin fitting by itself once it goes positive. bool fit_axis_range(Axis& axis, Range bounds) { const f64 lo = std::min(bounds.min, bounds.max); const f64 hi = std::max(bounds.min, bounds.max); if (!std::isfinite(lo) || !std::isfinite(hi)) return false; if (axis.log) { if (!(hi > 0.0)) return false; // no positive sample: not auto-fittable const f64 lower = lo > 0.0 ? lo : hi * 1e-3; if (!(lower > 0.0) || lower > hi) return false; if (lower == hi) { axis.range = Range{lower / 10.0, hi * 10.0}; // one decade either side return true; } const f64 factor = std::pow(hi / lower, 0.02); // 2% of the span, in log space axis.range = Range{lower / factor, hi * factor}; return true; } if (hi > lo) { const f64 pad = (hi - lo) * 0.02; axis.range = Range{lo - pad, hi + pad}; return true; } const f64 pad = std::fabs(lo) * 1e-3 + 0.5; axis.range = Range{lo - pad, hi + pad}; return true; } /// How far the crosshair's readout panel is held off the crosshair, in **logical** pixels: /// the hand's own distance, so a panel is the same distance from the pointer on a 2x display /// as on a 1x one. It is also the offset the edge flip mirrors, so the panel appears on the /// other side of the crosshair by the same amount rather than jumping across it. constexpr f32 kCrosshairPanelOffsetLogical = 12.0f; /// The chrome a **readout panel** is built from, in device pixels: what the padding inside /// the panel is, what a row's swatch is, the gap between two rows, and the two heights a row /// is measured in. /// /// One struct rather than a set of locals per panel, because the legend and the crosshair's /// readout are the same object: a padding, a swatch, a line of text and a gap between rows. /// Two panels sized by two copies of that arithmetic is how features that are meant to look /// like siblings drift apart, and the readout would then need its own fit rule, its own /// placement rule and its own row arithmetic as well. struct ReadoutChrome { f32 pad = 0.0f; ///< panel edge to content, and the gutter between a row's runs. f32 swatch_w = 0.0f; ///< the colour swatch's box. f32 swatch_h = 0.0f; f32 row_gap = 0.0f; ///< blank space between two rows. f32 line_h = 0.0f; ///< one text line, as the backend measures it. f32 row_h = 0.0f; ///< `max(swatch_h, line_h)`: a small swatch is centred, not stretched. }; /// The chrome of a panel styled by `cfg` -- the plot's `Legend`, whose knobs are the /// *panel's*: both panels spend them -- measured against `text` at the plot's own scale. /// Every size in `cfg` is logical, so each is converted exactly once, here. ReadoutChrome readout_chrome(const Legend& cfg, const render::Style& style, const render::TextBackend& text) { ReadoutChrome chrome; chrome.pad = style.device(cfg.pad); chrome.swatch_w = style.device(cfg.swatch_w); chrome.swatch_h = style.device(cfg.swatch_h); chrome.row_gap = style.device(cfg.row_gap); chrome.line_h = text.height(); chrome.row_h = std::max(chrome.swatch_h, chrome.line_h); return chrome; } /// The height `rows` rows occupy: one padding top and bottom, the rows, and a gap between /// every pair of them. f32 readout_panel_height(const ReadoutChrome& chrome, u32 rows) { const f32 count = static_cast(rows); return 2.0f * chrome.pad + count * chrome.row_h + (count - 1.0f) * chrome.row_gap; } /// The width a panel needs for a content column `content_px` wide: a padding, the swatch, the /// gutter after it, the content, and the closing padding. f32 readout_panel_width(const ReadoutChrome& chrome, f32 content_px) { return 2.0f * chrome.pad + chrome.swatch_w + chrome.pad + content_px; } /// The top of row `index`: one padding in, and one row plus one gap per row before it. f32 readout_row_top(const ReadoutChrome& chrome, f32 panel_y, u32 index) { return panel_y + chrome.pad + static_cast(index) * (chrome.row_h + chrome.row_gap); } /// A row's swatch box, centred in the row's height. Rect readout_swatch(const ReadoutChrome& chrome, const Rect& panel, u32 index) { return Rect{panel.x + chrome.pad, readout_row_top(chrome, panel.y, index) + 0.5f * (chrome.row_h - chrome.swatch_h), chrome.swatch_w, chrome.swatch_h}; } /// The pen a row's first text run is staged at: one swatch and one gutter in, one text line /// centred in the row's height. Vec2 readout_text_pen(const ReadoutChrome& chrome, const Rect& panel, u32 index) { return Vec2{panel.x + chrome.pad + chrome.swatch_w + chrome.pad, readout_row_top(chrome, panel.y, index) + 0.5f * (chrome.row_h - chrome.line_h)}; } } // namespace u32 Plot::add_x_axis(const AxisDesc& desc) { x_axes_.push_back(axis_from(desc)); return static_cast(x_axes_.size() - 1); } u32 Plot::add_y_axis(const AxisDesc& desc) { y_axes_.push_back(axis_from(desc)); return static_cast(y_axes_.size() - 1); } u32 Plot::add_trace(const TraceDesc& desc) { Trace trace; trace.series = desc.series; trace.color = desc.color; trace.width = desc.width; trace.axis_x = desc.axis_x; trace.axis_y = desc.axis_y; trace.name = desc.name; trace.mode = desc.mode; trace.dash_on = desc.dash_on; trace.dash_off = desc.dash_off; trace.marker = desc.marker; trace.marker_size_px = desc.marker_size_px; trace.marker_every = desc.marker_every; traces_.push_back(trace); mark_measures_dirty(); return static_cast(traces_.size() - 1); } u32 Plot::add_cursor(f64 x, Color color) { Cursor cursor; // The default orientation: a marker at a data x. `set_cursor_x()` writes `x` for this // orientation and refuses it for the other (see CursorOrientation). cursor.x = x; cursor.color = color; cursor.x = snapped_x(cursor); cursors_.push_back(cursor); mark_measures_dirty(); return static_cast(cursors_.size() - 1); } u32 Plot::add_h_ruler(f64 y, Color color) { Cursor ruler; ruler.orientation = CursorOrientation::Horizontal; ruler.y = y; ruler.color = color; ruler.y = snapped_y(ruler); cursors_.push_back(ruler); // No mark_measures_dirty(): a measurement is read from two *x* coordinates (see // MeasureSlot, and `measurement()`'s own sample fetch), and a ruler holds neither, so // no cached result can have gone stale by adding one. return static_cast(cursors_.size() - 1); } u32 Plot::add_measure(u32 cursor_a, u32 cursor_b, u32 trace) { MeasureSlot slot; slot.cursor_a = cursor_a; slot.cursor_b = cursor_b; slot.trace = trace; measures_.push_back(slot); return static_cast(measures_.size() - 1); } u32 Plot::add_annotation(const Annotation& annotation) { annotations_.push_back(annotation); return static_cast(annotations_.size() - 1); } void Plot::set_cursor_color(u32 id, Color color) { if (id >= cursors_.size()) return; cursors_[id].color = color; } void Plot::set_cursor_stroke(u32 id, f32 width, Vec2 dash) { if (id >= cursors_.size()) return; cursors_[id].width = width; cursors_[id].dash = dash; } void Plot::set_cursor_selectable(u32 id, bool selectable) { if (id >= cursors_.size()) return; cursors_[id].selectable = selectable; } void Plot::set_x_axis_origin(u32 axis, f64 origin) { if (axis >= x_axes_.size()) return; x_axes_[axis].origin = origin; } void Plot::set_y_axis_origin(u32 axis, f64 origin) { if (axis >= y_axes_.size()) return; y_axes_[axis].origin = origin; } void Plot::set_x_axis_origin_from_range(u32 axis, bool on) { if (axis >= x_axes_.size()) return; x_axes_[axis].origin_follows_range = on; } void Plot::set_y_axis_origin_from_range(u32 axis, bool on) { if (axis >= y_axes_.size()) return; y_axes_[axis].origin_follows_range = on; } void Plot::set_x_note(std::string text) { x_note_ = std::move(text); } void Plot::set_invalid_spans(std::span spans) { invalid_spans_.assign(spans.begin(), spans.end()); } f64 Plot::x_axis_origin(u32 axis) const { return axis < x_axes_.size() ? x_axes_[axis].origin : 0.0; } f64 Plot::y_axis_origin(u32 axis) const { return axis < y_axes_.size() ? y_axes_[axis].origin : 0.0; } void Plot::set_annotation(u32 id, const Annotation& annotation) { if (id >= annotations_.size()) return; annotations_[id] = annotation; } u32 Plot::add_region(const Region& region) { // A region whose bounds are not numbers is not an interval this plot can hold, and a // `Free` one stored that way could never be dragged back (every motion re-derives both // bounds from the stored ones), so it is refused rather than stored: nothing is added, // and the id handed back names no region -- `region()` reads a default for it and // `region_count()` does not count it. if (!finite_bounds(region.x0, region.x1)) return static_cast(kInvalid); // Ordered on the way in, so no caller -- and no drag -- can leave `x0 > x1` // behind: the store is the one place this invariant is enforced. Region stored = region; stored.x0 = std::min(region.x0, region.x1); stored.x1 = std::max(region.x0, region.x1); regions_.push_back(stored); const u32 id = static_cast(regions_.size() - 1); // A region that asks to snap lands on the sample grid as it is added, exactly as // `add_cursor()` places a snapping cursor: what a caller hands over is then on the // samples of the traces the plot already holds. snap_region(id); return id; } const Axis& Plot::x_axis(u32 axis) const { return axis < x_axes_.size() ? x_axes_[axis] : default_axis(); } const Axis& Plot::y_axis(u32 axis) const { return axis < y_axes_.size() ? y_axes_[axis] : default_axis(); } const Trace& Plot::trace(u32 id) const { static const Trace fallback{}; return id < traces_.size() ? traces_[id] : fallback; } const Cursor& Plot::cursor(u32 id) const { static const Cursor fallback{}; return id < cursors_.size() ? cursors_[id] : fallback; } f32 Plot::cursor_screen_coordinate(u32 id) const { if (id >= cursors_.size()) return 0.0f; const Cursor& cursor = cursors_[id]; // Which coordinate is the marker's, decided once, in `Cursor`: the axis reached for is the // one the cursor's own orientation names, and the other orientation's coordinate is not // consulted at all -- a ruler is never placed by a dead x. if (cursor.is_horizontal()) { return static_cast( y_transform(cursor.coordinate_axis()).to_screen_y(cursor.coordinate())); } return static_cast(x_transform(cursor.coordinate_axis()).to_screen_x(cursor.coordinate())); } const Annotation& Plot::annotation(u32 id) const { static const Annotation fallback{}; return id < annotations_.size() ? annotations_[id] : fallback; } const Region& Plot::region(u32 id) const { static const Region fallback{}; return id < regions_.size() ? regions_[id] : fallback; } Range Plot::x_range(u32 axis) const { return x_axis(axis).range; } Range Plot::y_range(u32 axis) const { return y_axis(axis).range; } void Plot::set_x_range(u32 axis, Range r) { if (axis >= x_axes_.size()) return; Axis& a = x_axes_[axis]; Range live{}; if (a.follow == XFollow::Live && live_x_bounds(axis, live)) { // Under Live the caller is stating a *span* and where the window sits relative // to the live edge, not a pair of absolute positions: the next layout() // recomputes the window from the series' newest x, so storing `r` verbatim // would be a position that survives exactly until the next frame. The offset is // what carries the position -- derived here from the live edge as it is *now*, // which is what makes a zoom keep its anchor still and a pan shift by the // drag's data delta -- and place_live_x_window() clamps it and stores the // window that follows from it, so the state is consistent before the next // layout rather than only after it. // // This branch is taken unconditionally on a live axis, and what it refuses to // store is what would strand it: `place_live_x_window()` needs a positive span, // and every gesture keeps a non-positive one out of its arithmetic, so a // degenerate range stored here would leave the axis following nothing, movable // by nothing, while `x_follow()` still said Live. An inverted range is a // caller's ordering slip, not a request to break the axis' `min <= max` // invariant -- the rest of the library reads a range as a range -- so it is // normalised; a span that is not a span is ignored outright, leaving the span // and offset the axis holds. `auto_fit_x()` is how a window is framed either // way. // // The offset is taken in **axis space** (log10 when the axis is logarithmic): // "how far behind the live edge" is a distance the axis' own transform measures, // so a log axis states it in decades and the window that follows from it holds // decades, not a decaying linear width. The span itself is read back from the // stored range by place_live_x_window(), in the same space. const f64 lo = std::min(r.min, r.max); const f64 hi = std::max(r.min, r.max); if (!std::isfinite(lo) || !std::isfinite(hi) || !(hi > lo)) return; a.range = Range{lo, hi}; a.offset = to_axis_space(live.max, a.log) - to_axis_space(hi, a.log); place_live_x_window(axis); a.auto_scale = false; // as ever: a range a caller chose is not an auto-fit return; } // A bound that is not a number is not a range: storing one would hand the // transforms a NaN (every mapping of it is NaN) and clear `auto_scale`, the one // way back -- so the call is refused outright and the axis keeps the range *and* // the auto-scale state it had. The same rule `set_region()` applies to a region's // bounds, and for the same reason. if (!std::isfinite(r.min) || !std::isfinite(r.max)) return; // Off, or Live with nothing streaming to follow: the range is stored verbatim, // which is what every caller of this setter got before follow existed. a.range = r; a.auto_scale = false; } void Plot::set_y_range(u32 axis, Range r) { if (axis >= y_axes_.size()) return; // The x setter's refusal, on the y side: a non-finite bound would leave the axis // with no range to draw and no auto-scale to recover with. if (!std::isfinite(r.min) || !std::isfinite(r.max)) return; y_axes_[axis].range = r; y_axes_[axis].auto_scale = false; } void Plot::set_x_auto_scale(u32 axis, bool on) { if (axis < x_axes_.size()) x_axes_[axis].auto_scale = on; } void Plot::set_y_auto_scale(u32 axis, bool on) { if (axis < y_axes_.size()) y_axes_[axis].auto_scale = on; } void Plot::set_x_follow(u32 axis, XFollow f) { if (axis >= x_axes_.size()) return; // A figure link group owns this axis' shared window *and* its follow state: // `Figure::link_x()` refuses a `Live` member joiner, and arming `Live` here *after* // linking would be the same state reached by the back door -- `push_link()` would // write the group's range into an axis that reinterprets it against its own live // edge, `adopt_member_changes()` would read it straight back as an external change, // and the group would track one member instead of the union of them. Refused. if (f == XFollow::Live && x_axes_[axis].link_member) return; x_axes_[axis].follow = f; // Arming a live axis places its window at once, so x_range() reports the window // the next layout produces rather than the one the axis held while it was Off. // Nothing else follows from it: the follow state is only ever changed here, and // the range the axis already held is the span `Live` starts from (see the header). place_live_x_window(axis); } void Plot::set_x_link_member(u32 axis, bool linked) noexcept { if (axis >= x_axes_.size()) return; x_axes_[axis].link_member = linked; } XFollow Plot::x_follow(u32 axis) const { return axis < x_axes_.size() ? x_axes_[axis].follow : XFollow::Off; } void Plot::set_x_offset(u32 axis, f64 units_behind_live) { if (axis >= x_axes_.size()) return; // No such offset: a NaN would place a window that is not a window, and the last // usable one is a better answer than that for a value no gesture can undo. The // units are **axis space** ones -- data units on a linear axis, decades on a log // one -- matching how `set_x_range()` derives the offset and how // `place_live_x_window()` spends it. if (!std::isfinite(units_behind_live)) return; x_axes_[axis].offset = std::max(0.0, units_behind_live); // The upper end of the clamp is what the traces still retain, which only they // know: place_live_x_window() applies it, and with it brings the visible window // along so x_range() and x_offset() agree before the next layout. A no-op on an // axis that is not Live -- the value waits there for when following is armed. place_live_x_window(axis); } f64 Plot::x_offset(u32 axis) const { return axis < x_axes_.size() ? x_axes_[axis].offset : 0.0; } bool Plot::x_live_edge(u32 axis, f64& newest) const { Range live{}; if (axis >= x_axes_.size() || !live_x_bounds(axis, live)) return false; newest = live.max; return true; } void Plot::set_x_live_owner(u32 axis, bool live, f64 newest) noexcept { if (axis >= x_axes_.size()) return; x_axes_[axis].live_owner = live; x_axes_[axis].live_owner_newest = live ? newest : 0.0; } bool Plot::x_live_owner(u32 axis, f64& newest) const noexcept { if (axis >= x_axes_.size() || !x_axes_[axis].live_owner) return false; newest = x_axes_[axis].live_owner_newest; return true; } bool Plot::live_x_bounds(u32 axis, Range& out) const { bool any = false; for (const Trace& t : traces_) { // Only a streaming series has a live edge. An array's bounds cover everything // it holds and change only when the caller replaces the data, so `Live` on such // a trace would be an anchor that never moves -- the axis is left to its stored // range instead (see place_live_x_window). if (t.axis_x != axis || !t.visible() || !t.series->streaming()) continue; const Range b = t.series->x_bounds(); if (!std::isfinite(b.min) || !std::isfinite(b.max)) continue; if (!any) { out = b; any = true; } else { // The union of what the traces retain: the oldest x any of them still holds // and the newest any of them holds, so the window is clamped against the ring // that reaches furthest back and anchored to the sample that is newest. out.min = std::min(out.min, b.min); out.max = std::max(out.max, b.max); } } return any; } bool Plot::follows_live(u32 axis) const { if (x_axes_[axis].follow != XFollow::Live) return false; Range live{}; return live_x_bounds(axis, live); } bool Plot::place_live_x_window(u32 axis) { Axis& a = x_axes_[axis]; if (a.follow != XFollow::Live) return false; Range live{}; if (!live_x_bounds(axis, live)) return false; // The span and the offset are distances in **axis space** -- log10 when the axis // is logarithmic -- the space the controller's gestures are computed in. The span // is the axis' own -- what the user zoomed to, or what the last fit produced -- // and the follow window only moves it: the span is what makes this a scroll // rather than a rescale. On a log axis a *linear* span would rescale the view // once the newest sample moved: ten units of span at 1e0 is ten decades, at 1e9 // it is nothing -- while the log-space span holds the visible decades constant // whatever the edge does. A span that is not a span is nothing to place a window // from, so the range is left exactly as it is. const f64 hi_space = to_axis_space(a.range.max, a.log); const f64 lo_space = to_axis_space(a.range.min, a.log); const f64 span = hi_space - lo_space; if (!std::isfinite(span) || !(span > 0.0)) return false; // 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 traces 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. In axis space, like the span: the // retained window's length is measured by the same ruler that measures the // offset, or a log axis could hold an offset larger than everything it retains. const f64 newest_space = to_axis_space(live.max, a.log); const f64 retained = std::max(0.0, newest_space - to_axis_space(live.min, a.log)); a.offset = std::clamp(a.offset, 0.0, retained); const f64 right = newest_space - a.offset; a.range = Range{from_axis_space(right - span, a.log), from_axis_space(right, a.log)}; return true; } Transform Plot::x_transform(u32 axis) const { const Axis& a = x_axis(axis); const Rect r = layout_.axes; // Only the x half of the transform is meaningful; the dummy y range keeps the // returned Transform's y mapping an identity rather than a second, conflicting // axis mapping. A collapsed rect or range is widened inside from_range. return Transform::from_range(a.range, Range{}, static_cast(r.x), static_cast(r.right()), 0.0, 1.0, a.log, false); } Transform Plot::y_transform(u32 axis) const { const Axis& a = y_axis(axis); const Rect r = layout_.axes; // Screen y grows downwards, so the range's minimum maps to the rect's bottom // and its maximum to the top: py0/py1 are passed bottom-first on purpose. return Transform::from_range(Range{}, a.range, 0.0, 1.0, static_cast(r.bottom()), static_cast(r.y), false, a.log); } bool Plot::trace_bounds_union(bool x_side, u32 axis, Range& out) const { bool any = false; for (const Trace& t : traces_) { if ((x_side ? t.axis_x : t.axis_y) != axis || !t.visible()) continue; // null series: skip const Range bounds = x_side ? t.series->x_bounds() : t.series->y_bounds(); if (!std::isfinite(bounds.min) || !std::isfinite(bounds.max)) continue; if (!any) { out = bounds; any = true; } else { out.min = std::min(out.min, bounds.min); out.max = std::max(out.max, bounds.max); } } return any; } bool Plot::fit_x_axis(u32 axis) { Range union_range{}; if (!trace_bounds_union(/*x_side=*/true, axis, union_range)) return false; return fit_axis_range(x_axes_[axis], union_range); } bool Plot::fit_y_axis(u32 axis) { Range union_range{}; if (!trace_bounds_union(/*x_side=*/false, axis, union_range)) return false; return fit_axis_range(y_axes_[axis], union_range); } void Plot::auto_fit_x() { for (u32 i = 0; i < x_axes_.size(); ++i) { // Fitting re-arms auto scaling, so the next layout keeps the axis framed. An // axis with nothing bound to it has nothing to fit and nothing to re-arm. if (!fit_x_axis(i)) continue; x_axes_[i].auto_scale = true; // Under Live a fit is also the way *back* to the live edge: the span becomes the // one that was just fitted, and following means the window ends at the newest // sample, so the offset returns to zero. Following stays armed -- a fit is not // how a plot stops following, set_x_follow() is. if (follows_live(i)) x_axes_[i].offset = 0.0; place_live_x_window(i); } } void Plot::auto_fit_y(u32 axis) { if (axis >= y_axes_.size()) return; if (fit_y_axis(axis)) y_axes_[axis].auto_scale = true; } void Plot::autoscale_axes() { for (u32 i = 0; i < x_axes_.size(); ++i) { // A Live axis with a live edge is owned by the follow path, and auto scale is // inert on it: a fit here would overwrite the span the follow window is built // from -- the user's zoom -- on every frame, which is exactly the fight spec §12 // removes. auto_fit_x() is how a live axis is framed. if (x_axes_[i].auto_scale && !follows_live(i)) fit_x_axis(i); } for (u32 i = 0; i < y_axes_.size(); ++i) { if (y_axes_[i].auto_scale) fit_y_axis(i); } } void Plot::set_cursor_x(u32 id, f64 x, CursorPlacement placement) { if (id >= cursors_.size()) return; Cursor& cursor = cursors_[id]; // `x` is the vertical cursor's coordinate and nobody else's: a ruler's `x` is dead, and // writing it would leave the struct holding two coordinates that disagree about where the // cursor is (see CursorOrientation). if (cursor.is_horizontal()) return; cursor.x = x; // `Free` stores the value and stops: a drag moves the marker under the pointer and the // snap is a separate act (`snap_cursor()`), which is what keeps the two orientations // agreeing about *when* they land on their grid -- see CursorPlacement. if (placement == CursorPlacement::Snapped) cursor.x = snapped_x(cursor); mark_measures_dirty(); } void Plot::snap_cursor(u32 id) { if (id >= cursors_.size()) return; const Cursor& cursor = cursors_[id]; // One act, the orientation's own rule: the ruler's half of the same shape a region's // `snap_region()` has. Both setters default to `Snapped`, so re-storing the coordinate // the cursor already holds is what applies the rule. if (cursor.is_horizontal()) { set_cursor_y(id, cursor.y); } else { set_cursor_x(id, cursor.x); } } void Plot::set_cursor_snap(u32 id, SnapMode mode) { if (id >= cursors_.size()) return; Cursor& cursor = cursors_[id]; // The vertical snap, on a vertical cursor: a ruler's snap is `HRulerSnap`, and the two // must not drift into meaning each other's job -- `NearestSample` is a sample of an x // grid, which a ruler has no notion of. if (cursor.is_horizontal()) return; cursor.snap = mode; cursor.x = snapped_x(cursor); mark_measures_dirty(); } void Plot::set_cursor_y(u32 id, f64 y, CursorPlacement placement) { if (id >= cursors_.size()) return; Cursor& ruler = cursors_[id]; if (!ruler.is_horizontal()) return; ruler.y = y; // As `set_cursor_x()`: `Free` is the drag's half of the gesture, and `snap_cursor()` // commits the ruler's own rule on release. if (placement == CursorPlacement::Snapped) ruler.y = snapped_y(ruler); // No mark_measures_dirty(), for `add_h_ruler()`'s reason: a measurement is read from two // x coordinates, and a ruler's y is not one of them. } void Plot::set_cursor_h_snap(u32 id, HRulerSnap mode) { if (id >= cursors_.size()) return; Cursor& ruler = cursors_[id]; if (!ruler.is_horizontal()) return; ruler.h_snap = mode; ruler.y = snapped_y(ruler); } const Measurement& Plot::measurement(u32 id) const { if (id >= measures_.size()) return invalid_measurement(); const MeasureSlot& slot = measures_[id]; const data::Series* series = nullptr; if (slot.trace < traces_.size()) series = traces_[slot.trace].series; const u64 samples = series != nullptr ? series->size() : 0; // A saturated ring keeps `samples` constant while its window slides: x0() is // what moves, and with it the sample each cursor snaps to and the interval they // measure. Caching on (pointer, count) alone would freeze the readout at the // first saturated frame until a cursor moved. const f64 x0 = series != nullptr ? series->x0() : 0.0; // Recompute only when someone moved a cursor, replaced the trace's series, grew // it, or slid the window under it (x0 above). Everything else is a cache hit, // which is what keeps a steady frame from re-evaluating every measure. if (slot.dirty || series != slot.last_series || samples != slot.last_size || x0 != slot.last_x0) { #ifdef PLOT_COUNT_MEASURE_RECOMPUTES detail::measure_recomputes().fetch_add(1, std::memory_order_relaxed); #endif slot.result = Measurement{}; if (series != nullptr && samples > 0 && slot.cursor_a < cursors_.size() && slot.cursor_b < cursors_.size()) { const Cursor& ca = cursors_[slot.cursor_a]; const Cursor& cb = cursors_[slot.cursor_b]; // R8: a measurement is read from two *x* coordinates, and a horizontal ruler // has no x -- it is the dead field of its orientation (see set_cursor_x()). // Evaluating the pair anyway would silently measure from x=0 and report // numbers about nothing, so the result stays invalid instead; the id the // caller holds is real, and `valid == false` is its answer. if (!ca.is_horizontal() && !cb.is_horizontal()) { // The cursors land on samples through the series' own lookups // (`snap_index` clamps like the old pyramid snap did, and `x_at()` is // where the sample really is), so an XY series measures between the // samples its own x array names rather than between indices a uniform // `dx()` guesses. const u64 a = series->snap_index(ca.x); const u64 b = series->snap_index(cb.x); // snap_index() clamps to [0, size-1], so [a, b+1) is inside the series // whenever the cursor pair is not inverted. An inverted pair is not an // interval. if (a <= b) slot.result = Measure::evaluate(*series, a, b + 1); } } slot.last_series = series; slot.last_size = samples; slot.last_x0 = x0; slot.dirty = false; } return slot.result; } void Plot::invalidate_measurements() noexcept { mark_measures_dirty(); // The region statistics key on the same series identity, so a mutation those keys // cannot see has to invalidate them too (see the contract). for (RegionStatsSlot& slot : region_stats_) slot.dirty = true; } void Plot::set_region(u32 id, f64 x0, f64 x1) { if (id >= regions_.size()) return; // A bound that is not a number is not a bound, and `std::min`/`std::max` would carry it // through the whole pair: accepting one would replace the finite partner as well, so the // region could never be dragged back. Refused outright -- as the other bound setters in // this file refuse one -- which leaves the interval it already held, and with it the // statistics cached for that interval. if (!finite_bounds(x0, x1)) return; // min/max, so an inverted pair is stored ordered rather than clamped: a drag that // crosses one bound over the other *swaps* the interval, which is what keeps the // bound the pointer holds movable instead of pinned at the other one. regions_[id].x0 = std::min(x0, x1); regions_[id].x1 = std::max(x0, x1); mark_region_stats_dirty(id); } void Plot::snap_region(u32 id) { if (id >= regions_.size()) return; const Region& region = regions_[id]; if (region.snap != RegionSnap::NearestSample) return; // A region names no axis, so its coordinates are on the plot's primary x axis -- // the axis its fill and the grid are drawn against (see Region). set_region(id, snapped_x(region.x0, 0u), snapped_x(region.x1, 0u)); } void Plot::remove_region(u32 id) { if (id >= regions_.size()) return; regions_.erase(regions_.begin() + static_cast(id)); // The published hot handles name a region by index and are re-indexed with everything // else: the removed region's own handle is cleared (nothing is hot there any more, and a // frame drawn before the next event would otherwise highlight the region that slid into // its slot), and a handle naming a region above the hole follows it down. The `zone` is // the grab that went with that index, so it is cleared with it -- a stale zone describing // a region that is no longer there would colour the next region's bound. // // A host must not remove a region *mid-drag*: the controller holds the id it grabbed, and // this setter cannot re-index a controller's private `drag_region_`. The gesture's release // would then edit whichever region now carries that id. Remove regions between gestures. if (hot_.region != kInvalid) { if (hot_.region == id) { hot_.region = kInvalid; hot_.zone = RegionGrab::None; } else if (hot_.region > id) { hot_.region -= 1u; } } // The cache is keyed by region index, so the removed region's entries name nothing // and every entry above the hole names a region one slot further down. The // surviving regions' own intervals are untouched -- erase keeps their order -- so // their cached statistics stay true and are only re-indexed, not dropped. std::erase_if(region_stats_, [id](const RegionStatsSlot& slot) { return slot.region == id; }); for (RegionStatsSlot& slot : region_stats_) { if (slot.region > id) slot.region -= 1u; } } const RegionStats& Plot::region_stats(u32 region, u32 trace) const { static const RegionStats none{}; if (region >= regions_.size() || trace >= traces_.size()) return none; RegionStatsSlot* slot = nullptr; for (RegionStatsSlot& candidate : region_stats_) { if (candidate.region == region && candidate.trace == trace) { slot = &candidate; break; } } if (slot == nullptr) { // Filled on demand, one entry per pair a caller asks about: a plot with a dozen // regions and a dozen traces never pays for the cross product it never queried. region_stats_.push_back(RegionStatsSlot{}); slot = ®ion_stats_.back(); slot->region = region; slot->trace = trace; } const data::Series* series = traces_[trace].series; const u64 samples = series != nullptr ? series->size() : 0; // A saturated ring keeps `samples` constant while its window slides: `x0()` is // what moves, and with it which samples the region's data interval covers. Caching // on (pointer, count) alone would freeze the readout at the first saturated frame // -- the same defect the measurement cache avoids with this same third key. const f64 x0 = series != nullptr ? series->x0() : 0.0; if (slot->dirty || series != slot->last_series || samples != slot->last_size || x0 != slot->last_x0) { slot->result = series != nullptr ? evaluate_region_stats(regions_[region], *series) : RegionStats{}; slot->last_series = series; slot->last_size = samples; slot->last_x0 = x0; slot->dirty = false; } return slot->result; } RegionStats Plot::evaluate_region_stats(const Region& region, const data::Series& series) { RegionStats stats; u64 begin = 0; u64 end = 0; if (!covered_samples(series, region.x0, region.x1, begin, end)) return stats; // Exact interval queries on Series, never the pyramid's *_buckets forms: this // interval is not bucket-aligned in general (that is the whole point of a free // region), and a bucket-grained answer would silently include samples outside it. const Range extremes = series.reduce(begin, end); const f64 total = series.sum(begin, end); const f64 squared = series.sumsq(begin, end); const f64 n = static_cast(end - begin); stats.x0 = region.x0; stats.x1 = region.x1; stats.width = region.x1 - region.x0; stats.min = extremes.min; stats.max = extremes.max; stats.mean = total / n; stats.rms = std::sqrt(squared / n); stats.integral = total * series.dx(); // left-Riemann, the measurement's convention stats.count = end - begin; stats.valid = true; return stats; } MeasureSpan Plot::measure_span(u32 id) const noexcept { // An id that names nothing, or a measure built from cursor ids that were never // registered, is not a span between two markers: `valid` says so rather than // letting a caller shade a band between cursors 0 and 1 by accident. if (id >= measures_.size()) return MeasureSpan{}; const MeasureSlot& slot = measures_[id]; if (slot.cursor_a >= cursors_.size() || slot.cursor_b >= cursors_.size()) return MeasureSpan{}; // A band is drawn between two *x* coordinates, and a horizontal ruler has none // (the same dead field `measurement()` refuses under R8): the span says invalid // rather than naming a band one edge of which sits at x=0. if (cursors_[slot.cursor_a].is_horizontal() || cursors_[slot.cursor_b].is_horizontal()) { return MeasureSpan{}; } return MeasureSpan{slot.cursor_a, slot.cursor_b, true}; } const PlotLayout& Plot::layout() { // Belt and braces for R1: `set_viewport()` normalises a hostile rect, but every // `std::clamp` below needs `lo <= hi` to be defined, so the dimensions the chrome // is clamped against are read non-negative here too -- layout() is on the path of // every host, every frame, and must be undefined nowhere. const f32 vw = viewport_.w > 0.0f ? viewport_.w : 0.0f; const f32 vh = viewport_.h > 0.0f ? viewport_.h : 0.0f; // Live x windows are placed first, so everything below that derives from a range // -- the ticks, the grid, the transforms the controller hit-tests through, and the // range a caller reads back -- describes the live edge of *this* frame rather than // the position the window had when the axis was last touched, and the renderer, // the controller and a caller all see one window per frame. O(1) per axis and // allocation-free, so a frame pays nothing for it. for (u32 i = 0; i < x_axes_.size(); ++i) place_live_x_window(i); // Axes still on auto scale refit here, once per layout, so a growing stream // stays framed. A caller gesture clears auto_scale (set_x_range/set_y_range), // which is precisely what stops this from fighting the range the user chose -- // and a live axis is not fitted at all, because the follow path above owns it. autoscale_axes(); // The label padding is a logical size, so it is converted once, here: every strip // and inset below reads this value in device pixels, like the backend metrics it // is added to. const f32 pad = style_.device(style_.grid_label_pad); // Seeds: PlotLayout's defaults when nobody can measure text, otherwise the // metrics the text backend does know (its line height, and just the padding // until the first tick list says how wide the labels are). f32 y_width = PlotLayout{}.y_axis_width; f32 x_height = PlotLayout{}.x_axis_height; if (text_ != nullptr) { y_width = y_axes_.empty() ? 0.0f : 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 -- the // bottom row's own strip, where that axis lives -- 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. const bool x_labels = x_strip_has_labels(x_strip_); if (!x_labels) x_height = 0.0f; // 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 // tick label can change that. Nothing at all is reserved without a text backend // -- there is then no title to measure, which is what keeps the two defaults // above meaningful for the no-text-backend case. The x strip is one text line // per titled x axis tall (they are stacked in it); the y strip is one text line // 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 // axis (see y_title_strip_width). // The title strip is its own part of the mode: a mode that reserves the labels and // no title reserves no row for a title either, and one that reserves the title and no // labels still gets its row -- below where the labels would have been. const u32 x_titles = x_strip_has_title(x_strip_) ? titled_x_axis_count() : 0u; const f32 y_title_w = text_ != nullptr ? y_title_strip_width() : 0.0f; const f32 x_title_h = text_ != nullptr && x_titles != 0u ? static_cast(x_titles) * text_->height() + 2.0f * pad : 0.0f; for (u32 pass = 0;; ++pass) { // Chrome is measured against the viewport, then capped so the axes keeps // kMinAxesShare of it -- the label strip first, the title strip out of what is // left of the allowance -- which is what keeps axes.w and axes.h positive // however long the labels and titles are. `viewport_` is what a huge label set // is clamped to first, so neither strip can push the rect outside it. y_width = std::clamp(y_width, 0.0f, vw); x_height = std::clamp(x_height, 0.0f, vh); const f32 y_budget = (1.0f - kMinAxesShare) * vw; const f32 x_budget = (1.0f - kMinAxesShare) * vh; // The figure's common gutter, when one was imposed: a floor on `y_width + y_title_w` // so every row's axes starts at the same x. It is served by the *label* strip -- the // title column is one text line per titled axis and cannot be widened to fill a gutter // -- and it is applied before the title column below, so the pair still fits the cap // (the floor is clamped to the same budget) and the invariant `axes.w >= share` holds. // `min_y_gutter_` is 0.0 unless a figure set it, so this is a no-op for a lone plot. const f32 y_floor = std::min(min_y_gutter_, y_budget); y_width = std::min(std::max(y_width, y_floor - y_title_w), y_budget); x_height = std::min(x_height, x_budget); const f32 y_title_width = std::min(y_title_w, y_budget - y_width); const f32 x_title_height = std::min(x_title_h, x_budget - x_height); // The right-hand band, out of what is left of the same allowance the two left // strips were served from -- so the three together cannot take more than // `1 - kMinAxesShare` of the width however much each asks for. Nothing is drawn // in it, so unlike the strips above it is a want and never a measurement, and it // cannot feed the fixed point below: the y labels are chosen from the row's range // and height, so a narrower `axes` does not change what they need. const f32 right_gutter = std::min(right_gutter_, y_budget - (y_width + y_title_width)); layout_.y_axis_width = y_width; layout_.x_axis_height = x_height; layout_.y_title_width = y_title_width; layout_.x_title_height = x_title_height; layout_.right_gutter = right_gutter; layout_.axes = Rect{viewport_.x + y_width + y_title_width, viewport_.y, vw - (y_width + y_title_width + right_gutter), vh - (x_height + x_title_height)}; layout_.y_title_area = Rect{viewport_.x, layout_.axes.y, y_title_width, layout_.axes.h}; layout_.y_label_area = Rect{layout_.y_title_area.right(), layout_.axes.y, y_width, layout_.axes.h}; layout_.x_label_area = Rect{layout_.axes.x, layout_.axes.bottom(), layout_.axes.w, x_height}; layout_.x_title_area = Rect{layout_.axes.x, layout_.x_label_area.bottom(), layout_.axes.w, x_title_height}; refresh_ticks(); if (text_ == nullptr || pass + 1 >= kMaxLayoutPasses) break; // The same caps as above, so this compares like with like and the loop still // reaches its fixed point on the second pass. const f32 need_y = std::min(std::clamp(y_label_width(), 0.0f, vw), 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 = x_labels ? std::min(std::clamp(text_->height() + 2.0f * pad, 0.0f, vh), x_budget) : 0.0f; // What the strip *wants* is the label's own width or the figure's floor, whichever is // greater -- the floor is a maximum over sources, settled by one `max` and not by // asking the loop to grow the strip towards it pass by pass. Comparing the measured // need alone would never agree once a floor sits above it, and the loop would run its // cap out; comparing the want is what keeps it a fixed point here too. const f32 want_y = std::min(std::max(need_y, y_floor - y_title_w), y_budget); if (want_y == y_width && need_x == x_height) break; // strips are a fixed point y_width = want_y; x_height = need_x; } build_grid(); // The regions and the legend are measured last, from the finished `axes`: both // live inside the data area and reserve nothing, so neither can move the strips // above nor depend on anything but the rect they settled on. The regions are // measured from the *placed* live windows, which is what makes a region's data // interval map to the pixel columns the frame will actually use. build_regions(); build_invalid_spans(); build_legend(); // The crosshair last: its panel is fitted into `axes` and its lines are held inside it, and // its rows are the sample values nearest a data x -- so it wants the transforms the // settled layout pins, exactly as the legend and the regions do. build_crosshair(); return layout_; } void Plot::refresh_ticks() { // Ticks are generated for the final axes size: an x axis' density depends on // the plot's width and a y axis' on its height. The minimum spacing is a logical // size, so it is converted here: a 2x display therefore gets the same *count* of // major ticks over the same logical width rather than twice as many. for (Axis& a : x_axes_) a.refresh(layout_.axes.w, style_.device(style_.tick_min_px), true); for (Axis& a : y_axes_) a.refresh(layout_.axes.h, style_.device(style_.tick_min_px), true); } void Plot::build_grid() { if (x_axes_.empty() || y_axes_.empty()) { grid_.clear(); return; } grid_.build(x_axes_[0].ticks, x_axes_[0].range, y_axes_[0].ticks, y_axes_[0].range); } void Plot::mark_measures_dirty() noexcept { for (MeasureSlot& slot : measures_) slot.dirty = true; } void Plot::mark_region_stats_dirty(u32 region) noexcept { for (RegionStatsSlot& slot : region_stats_) { if (slot.region == region) slot.dirty = true; } } const data::Series* Plot::snap_series(u32 axis_x) const { for (const Trace& t : traces_) { if (t.axis_x != axis_x || !t.visible()) continue; // null series: skip return t.series; } return nullptr; } f64 Plot::snapped_x(f64 x, u32 axis_x) const { const data::Series* s = snap_series(axis_x); if (s == nullptr) return x; // Through the series' own index arithmetic (`snap_index`/`x_at`), so a series // whose samples are not on a uniform grid snaps to the sample that is really // nearest, not to where a `dx()` grid would have put one. return s->x_at(s->snap_index(x)); } f64 Plot::snapped_x(const Cursor& c) const { return c.snap == SnapMode::NearestSample ? snapped_x(c.x, c.axis_x) : c.x; } f64 Plot::snapped_y(const Cursor& c) const { if (c.h_snap != HRulerSnap::NearestTick) return c.y; if (!std::isfinite(c.y)) return c.y; // no tick is nearer than a value that is not one if (c.axis_y >= y_axes_.size()) return c.y; // The tick set of the *last* layout(), never a second tick computation: `refresh_ticks()` // is the one place ticks are generated, and the controller lays the plot out before every // pointer event, so on the drag path this is the frame's own set (see the header's note on // the one-frame staleness a direct setter can see). const std::vector& ticks = y_axes_[c.axis_y].ticks; if (ticks.empty()) return c.y; const Tick* nearest = &ticks.front(); f64 best = std::fabs(nearest->value - c.y); for (const Tick& tick : ticks) { const f64 d = std::fabs(tick.value - c.y); // Strictly nearer, so a value exactly between two ticks lands on the lower one rather // than on whichever the iteration happened to visit last. if (d < best) { best = d; nearest = &tick; } } return nearest->value; } u32 Plot::titled_x_axis_count() const noexcept { u32 n = 0; for (const Axis& a : x_axes_) { if (!a.label.empty()) ++n; } return n; } u32 Plot::titled_y_axis_count() const noexcept { u32 n = 0; for (const Axis& a : y_axes_) { if (!a.label.empty()) ++n; } return n; } f32 Plot::y_title_strip_width() const { // One text line per titled y axis, not the widest title's own width: the title // is drawn rotated to read bottom-to-top, so it spends the axes' height and // each titled axis needs a column of its own -- which is what keeps a Left/Right // pair of titles from being drawn over each other now that they no longer stack // down a taller strip. if (text_ == nullptr) return 0.0f; const u32 titles = titled_y_axis_count(); if (titles == 0u) return 0.0f; return static_cast(titles) * text_->height() + 2.0f * style_.device(style_.grid_label_pad); } f32 Plot::y_label_width() const { if (text_ == nullptr || y_axes_.empty()) return 0.0f; f32 widest = 0.0f; for (const Axis& a : y_axes_) { for (const Tick& tick : a.ticks) { if (!tick.major || tick.label.empty()) continue; widest = std::max(widest, text_->measure(tick.label.c_str())); } } return widest + 2.0f * style_.device(style_.grid_label_pad); } void Plot::build_legend() { LegendLayout& legend = layout_.legend; legend.rows.clear(); legend.panel = Rect{}; // Nothing to measure with, or nothing asked for: an empty layout, and with it a // plot that lays out exactly as it did before legends existed. if (text_ == nullptr || !legend_.visible) return; // The panel's own chrome -- padding, swatch, row gap, line height -- measured once, in // device pixels, by the one helper the crosshair's readout uses too: the two panels are // the same object, so they cannot be sized by two copies of this arithmetic. const ReadoutChrome chrome = readout_chrome(legend_, style_, *text_); const f32 margin = style_.device(legend_.margin); // Entry widths, prefix-maxed: dropping from the end then costs no re-measure, and // the panel's width after a drop is the width of an entry that is still listed. legend_name_px_.clear(); for (const Trace& trace : traces_) { // A trace with no series, no samples, or no name is not an entry -- the same // "visible" test every other consumer of a trace makes, plus the name. if (!trace.visible() || trace.name.empty()) continue; const f32 width = text_->measure(trace.name.c_str()); legend_name_px_.push_back(legend_name_px_.empty() ? width : std::max(legend_name_px_.back(), width)); } const u32 entries = static_cast(legend_name_px_.size()); if (entries == 0u) return; const f32 ellipsis_w = text_->measure(kLegendEllipsis); const f32 room_w = layout_.axes.w - 2.0f * margin; const f32 room_h = layout_.axes.h - 2.0f * margin; // Fit, from every entry down: the panel is a row per entry with `row_gap` between, // and, once an entry has been dropped, one more row for the marker that says so. // The marker is part of the panel, so it is part of what has to fit -- a legend // that dropped entries and then overhung the axes would be the defect the drop // exists to avoid. When even one entry plus the marker has no room, there is no // panel at all: a plot's data area is not a place to draw a legend that does not // fit it. u32 keep = entries; f32 panel_w = 0.0f; f32 panel_h = 0.0f; for (;;) { const bool truncated = keep < entries; const f32 widest = truncated ? std::max(legend_name_px_[keep - 1u], ellipsis_w) : legend_name_px_[keep - 1u]; const u32 rows = keep + (truncated ? 1u : 0u); panel_w = readout_panel_width(chrome, widest); panel_h = readout_panel_height(chrome, rows); if (panel_w <= room_w && panel_h <= room_h) break; if (--keep == 0u) return; // not even one entry and its marker fit } // Anchored in the corner `place` names and held `margin` off both edges, which is // the inset the fit above was measured against -- so the panel is inside `axes` by // construction and can never cross it, whatever corner it was put in. f32 x = layout_.axes.x + margin; f32 y = layout_.axes.y + margin; if (legend_.place == LegendPlace::TopRight || legend_.place == LegendPlace::BottomRight) { x = layout_.axes.right() - margin - panel_w; } if (legend_.place == LegendPlace::BottomLeft || legend_.place == LegendPlace::BottomRight) { y = layout_.axes.bottom() - margin - panel_h; } legend.panel = Rect{x, y, panel_w, panel_h}; // The rows themselves: the swatch and the name's line box, both placed here rather // than left to a renderer to re-derive (one placement rule, one place to read it). // The name box starts one swatch and one gutter in -- exactly what the panel's // width allowed for -- and both boxes are centred in the row's height, so a swatch // shorter than the text line is centred on it instead of sitting on its baseline. u32 placed = 0; for (u32 i = 0; i < traces_.size() && placed < keep; ++i) { const Trace& trace = traces_[i]; if (!trace.visible() || trace.name.empty()) continue; LegendRow row; row.trace = i; row.swatch = readout_swatch(chrome, legend.panel, placed); const Vec2 pen = readout_text_pen(chrome, legend.panel, placed); row.name = Rect{pen.x, pen.y, text_->measure(trace.name.c_str()), chrome.line_h}; legend.rows.push_back(row); ++placed; } if (keep < entries) { // Entries were dropped; the marker takes the row after the last kept one, with no // swatch -- there is no trace to colour -- and the name column's own x, so it // lines up with the names above it. LegendRow marker; marker.ellipsis = true; const Vec2 pen = readout_text_pen(chrome, legend.panel, placed); marker.name = Rect{pen.x, pen.y, ellipsis_w, chrome.line_h}; legend.rows.push_back(marker); } } void Plot::build_regions() { layout_.regions.clear(); if (regions_.empty()) return; const Rect& axes = layout_.axes; // A region's coordinates are on the primary x axis (see Region). The transform is // taken *here*, after the live windows have been placed and the strips have // settled, so the columns this produces are the ones the frame draws with -- and // the controller hit-tests these very numbers rather than mapping the bounds // through the transform a second time. const Transform tx = x_transform(0u); const f32 handle_w = style_.device(style_.handle_width_px); const f32 pad = style_.device(style_.grid_label_pad); const f32 line_h = text_ != nullptr ? text_->height() : 0.0f; for (u32 i = 0; i < regions_.size(); ++i) { const Region& region = regions_[i]; // An invisible region is not in the layout at all: the layout is exactly what a // frame draws and what a pointer can hit, so the two cannot disagree about // whether a region is there. if (!region.visible) continue; RegionLayout out; out.region = i; const f32 lo = static_cast(tx.to_screen_x(region.x0)); const f32 hi = static_cast(tx.to_screen_x(region.x1)); // One tab per bound, centred on it, so the bound's column is the tab's centre // line (`handle_column()`) rather than a second number that could drift from it. out.left_handle = Rect{lo - 0.5f * handle_w, axes.y, handle_w, axes.h}; out.right_handle = Rect{hi - 0.5f * handle_w, axes.y, handle_w, axes.h}; // The fill is the interval clipped to the axes' columns: a region that has // scrolled out of the window has nothing to shade, and one wider than the window // is not allowed to paint over the label strips. const f32 first = std::clamp(lo, axes.x, axes.right()); const f32 last = std::clamp(hi, axes.x, axes.right()); out.fill = Rect{first, axes.y, last - first, axes.h}; // The label goes on the first text line inside the fill, clamped to `axes` -- the // measurement readout's rule, and for its reason: a caption starting at a fill // edge near the right of the plot would otherwise run over the axis and out of // the panel. A label with no room across the axes is dropped rather than // overhanging them, and a zero-width fill (an off-window region) draws none. if (!region.label.empty() && line_h > 0.0f && out.fill.w > 0.0f) { const f32 width = text_->measure(region.label.c_str()); if (width + 2.0f * pad <= axes.w) { out.label = Rect{std::clamp(out.fill.x + pad, axes.x + pad, axes.right() - width - pad), axes.y + pad, width, line_h}; } } layout_.regions.push_back(out); } } void Plot::build_invalid_spans() { layout_.invalid_spans.clear(); if (invalid_spans_.empty()) return; const Rect& axes = layout_.axes; // The primary x axis' transform, taken here for `build_regions()`' reason: after the live // windows have been placed and the strips have settled, so these are the columns the frame // draws with and a band is anchored to the data by construction rather than by a second // mapping that would have to be kept in step with this one. const Transform tx = x_transform(0u); // A hole can be narrower than a column -- one missing sample in a window of hundreds of // thousands -- and a fill rounded to zero width would draw nothing, losing exactly the case // the band is most needed for. One device pixel is the floor. const f32 min_w = std::max(1.0f, style_.device(1.0f)); for (const InvalidSpan& span : invalid_spans_) { const f64 a = std::min(span.x0, span.x1); const f64 b = std::max(span.x0, span.x1); if (!std::isfinite(a) || !std::isfinite(b)) continue; const f32 lo = static_cast(tx.to_screen_x(a)); const f32 hi = static_cast(tx.to_screen_x(b)); // Scrolled out of the window: nothing to shade. Tested before the floor is applied, so a // span off the left edge is dropped rather than pinned to it as a one-pixel band that // accuses the samples at the window's start. if (hi < axes.x || lo > axes.right()) continue; const f32 first = std::clamp(lo, axes.x, axes.right()); const f32 last = std::clamp(hi, axes.x, axes.right()); const f32 w = std::min(std::max(last - first, min_w), axes.w); // Widening a band at the right edge would run it over the axis line, so the floor is taken // out of the left instead: the span's own edge is kept wherever there is room for it. const f32 x = std::clamp(first, axes.x, axes.right() - w); layout_.invalid_spans.push_back(InvalidSpanLayout{Rect{x, axes.y, w, axes.h}, span.repaired}); } } // --- crosshair (spec §15) -------------------------------------------------- CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept { CrosshairSpan span; const u64 n = series.size(); // No samples and no finite query: nothing to bracket. if (n == 0u || !std::isfinite(x)) return span; // The bracketing samples, straight from the series (`neighbours()`/`x_at()`): // the window is closed at both ends -- the oldest and the newest sample are // samples too -- and a query outside it has no neighbours at all. Answering // with the nearest sample's value instead is the clamped lie // this refuses: the readout would report a number as if it were under the // pointer when the series does not reach that far. The series owns where its // samples are, so an XY series is bracketed by its own x array exactly, where // the old uniform-grid index arithmetic would have read samples that do not // exist at the positions it assumed. u64 i0 = 0; u64 i1 = 0; if (!series.neighbours(x, i0, i1)) return span; // A query that landed **on** a sample is a span of one, whichever side the // series' bracket reported: a sample at the query is the answer, and the two // ends of the window are samples too -- a bracket of (0, 1) at a query sitting // on sample 0 would read as a span with a width it does not have. Decided on // the samples' own coordinates, so it holds for any `neighbours()` impl. if (series.x_at(i0) == x) { span.begin = span.end = i0; span.t = 0.0; span.valid = true; return span; } if (series.x_at(i1) == x) { span.begin = span.end = i1; span.t = 0.0; span.valid = true; return span; } if (i0 == i1) { // The series bracketed a single sample for a query that is not on it: there // is no second sample to choose between, and this one is the honest answer. span.begin = span.end = i0; span.t = 0.0; } else { span.begin = i0; span.end = i1; // `t` from the samples' own x coordinates, so "which is nearer" is exact on // any grid, uniform or not. A degenerate pair (two samples sharing an x) // reads the earlier sample. const f64 xa = series.x_at(i0); const f64 xb = series.x_at(i1); span.t = xb > xa ? (x - xa) / (xb - xa) : 0.0; if (!(span.t > 0.0)) { span.t = 0.0; // a rounding step below the earlier sample is the sample } else if (span.t > 1.0) { span.t = 1.0; } } span.valid = true; return span; } f64 crosshair_interpolate(f64 y_begin, f64 y_end, f64 t) noexcept { // This form, not `(1 - t) * a + t * b`: at `t == 0` it is `y_begin` and at `t == 1` it is // `y_end`, both exactly, so a query that landed on a sample reads that sample back whatever // mode is in force. The symmetric form rounds at both ends. return y_begin + t * (y_end - y_begin); } std::string crosshair_unnamed_label(u32 trace) { char buf[16]; std::snprintf(buf, sizeof buf, "#%u", static_cast(trace)); return std::string(buf); } void Plot::set_crosshair(f64 x, bool active) { // A coordinate that is not a number is not a position: it has no column to draw and no // value to read, and its floor as a sample index is undefined. Refused, like // `set_x_offset()` refuses one, rather than stored for the renderer to trip over -- the // crosshair stays where it was, which is a state the pointer can move it out of. if (active && !std::isfinite(x)) return; crosshair_.x = x; crosshair_.active = active; // One coordinate, one line: the vertical column of `x`, its markers and the readout. There // is no y to draw a row at, and the readout anchors itself at the top of the axes instead // of at a value the caller never gave (see `build_crosshair()`). crosshair_.vertical = true; crosshair_.horizontal = false; } void Plot::set_crosshair(f64 x, f64 y, bool active) { if (active && (!std::isfinite(x) || !std::isfinite(y))) return; set_crosshair(x, active); crosshair_.y = y; crosshair_.horizontal = true; } bool Plot::crosshair_value(u32 trace, f64 x, f64& out_y) const { f64 ignored = 0.0; return crosshair_point(trace, x, ignored, out_y); } bool Plot::crosshair_point(u32 trace, f64 x, f64& out_x, f64& out_y) const { if (trace >= traces_.size()) return false; const data::Series* series = traces_[trace].series; if (series == nullptr) return false; const CrosshairSpan span = crosshair_span(*series, x); if (!span.valid) return false; // The samples themselves: `Series::gather()` at level 0 is the one public way to a raw // sample, and it appends `(x, y)` pairs -- one pair when the query landed on a sample, two // when it fell between them. The scratch is cleared and refilled rather than reallocated, // and sized for two pairs from construction, so the pointer path allocates nothing. crosshair_scratch_.clear(); // The origin is the query itself: only y is read out of the pairs below -- the x comes back // through `x_at()` -- so this names the one origin that cannot lose precision for a caller // that might. series->gather(0u, span.begin, span.end + 1u, x, crosshair_scratch_); const usize pairs = static_cast(span.end - span.begin + 1u); if (crosshair_scratch_.size() != 2u * pairs) return false; // a series that produced nothing const f64 y_begin = static_cast(crosshair_scratch_[1u]); const f64 y_end = static_cast(crosshair_scratch_[2u * pairs - 1u]); if (crosshair_.value_mode == CrosshairValue::Interpolated) { // The signal the samples imply *at* this x, so the point is the query's own x: that is // where an interpolated number lives, and the marker on the polyline there is honest // about it. out_x = x; out_y = crosshair_interpolate(y_begin, y_end, span.t); return true; } // `NearestSample`: the nearer of the two, and **its own x with it**. `span.t` is measured on // the samples' own coordinates, so "nearer" is exact on any grid; a tie reads the earlier // sample, which is the bias `crosshair_span()` already takes for a degenerate pair. The x // comes back through `x_at()` rather than from the gathered pairs, which are relative to a // caller-named origin by the `Series::gather()` contract -- adding it back here would be a // second reconstruction of a coordinate the series will hand over directly. const bool take_begin = span.t <= 0.5; out_x = series->x_at(take_begin ? span.begin : span.end); out_y = take_begin ? y_begin : y_end; return true; } void Plot::build_crosshair() { CrosshairLayout& crosshair = layout_.crosshair; crosshair.active = crosshair_.active; crosshair.vertical = Rect{}; crosshair.horizontal = Rect{}; crosshair.stroke = 0.0f; crosshair.dash = Vec2{}; crosshair.panel = Rect{}; if (!crosshair_.active) { crosshair.rows.clear(); return; } const Rect& axes = layout_.axes; const Transform tx = x_transform(0u); const Transform ty = y_transform(0u); // A coordinate held inside a pair of bounds that may be inverted or closer together than // the stroke: `axes` is whatever the host gave the plot, and `std::clamp` is undefined for // an unordered pair. const auto inside = [](f32 v, f32 lo, f32 hi) { return std::clamp(v, std::min(lo, hi), std::max(lo, hi)); }; // The two lines, each a stroke box held *inside* the axes by half its own width. The // renderer stages the stroke across the box's centre line, so this is the whole placement // rule: the box is where the line may be drawn, and Task 1's clip never has to trim one // that already fits. The lines are placed through the primary axis pair, like the grid and // a region's fill -- one crosshair is one column and one row of the data area. // // The stroke and the dash are resolved *here*, once, from the crosshair's own override where // it has one and the global style otherwise, and converted to device pixels through // `device()`. The boxes below and the renderer's strokes are then one set of numbers rather // than two that must be kept in step (spec §17): `stroke` is the device width, `dash` the // device (on, off) pair with `off <= 0` meaning solid. const CrosshairStyle& cs = crosshair_.style; const f32 stroke = style_.device(cs.width >= 0.0f ? cs.width : style_.crosshair_width); crosshair.stroke = stroke; crosshair.dash = Vec2{style_.device(cs.dash_on >= 0.0f ? cs.dash_on : style_.crosshair_dash_on), style_.device(cs.dash_off >= 0.0f ? cs.dash_off : style_.crosshair_dash_off)}; const f32 half = 0.5f * stroke; if (crosshair_.vertical) { const f32 at = inside(static_cast(tx.to_screen_x(crosshair_.x)), axes.x + half, axes.right() - half); crosshair.vertical = Rect{at - half, axes.y, stroke, axes.h}; } if (crosshair_.horizontal) { const f32 at = inside(static_cast(ty.to_screen_y(crosshair_.y)), axes.y + half, axes.bottom() - half); crosshair.horizontal = Rect{axes.x, at - half, axes.w, stroke}; } // The rows: one per *visible* trace -- a trace with no series or no samples is not under // the pointer, so it has nothing to say -- named or not, because the readout is about what // the pointer is over and not about identity. The vector is filled in place and its entries // reused, strings and all, so a steady-state frame's readout allocates nothing. u32 count = 0; for (u32 i = 0; i < traces_.size(); ++i) { const Trace& trace = traces_[i]; if (!trace.visible()) continue; if (count == crosshair.rows.size()) crosshair.rows.emplace_back(); CrosshairRow& row = crosshair.rows[count++]; row.trace = i; row.valid = false; row.listed = false; row.x = 0.0; row.y = 0.0; row.marker = Vec2{}; row.swatch = Rect{}; row.label_box = Rect{}; row.value_box = Rect{}; row.label.assign(trace.name.empty() ? crosshair_unnamed_label(i) : trace.name); // The value where the trace has one at this x, and the em dash where it does not. The row is // listed either way, so the absence has to be *said* -- a blank value cell reads as a value // that has not arrived -- and the dash is measured into the panel's width like any other // value, so the row's boxes are placed by one rule. // // The **point**, not just the number: `crosshair_point()` reports the x the value belongs to // as well, which in the default `NearestSample` mode is the quoted sample's own and not the // pointer's. Both coordinates come from that one call, so the mark and the text cannot // disagree about which datum is being read (see `CrosshairRow::marker`). if (crosshair_point(i, crosshair_.x, row.x, row.y)) { row.valid = true; char value[32]; // The trace's *own* y axis formats the number, so a readout reads exactly like the // tick labels beside it -- the rule a horizontal ruler's label already follows. y_axis(trace.axis_y).format_value(row.y, value, sizeof value); row.value.assign(value); // Placed through the trace's own axis pair, so the marker sits where that trace is // actually drawn however its axes are scaled. row.marker = Vec2{static_cast(x_transform(trace.axis_x).to_screen_x(row.x)), static_cast(y_transform(trace.axis_y).to_screen_y(row.y))}; } else { row.value.assign(kCrosshairNoValue); } } crosshair.rows.resize(count); // The panel needs text to measure and a row to list; the lines and the markers above do // not, which is why this is where they part company. if (count == 0u || !crosshair_.show_readout || text_ == nullptr) return; const ReadoutChrome chrome = readout_chrome(legend_, style_, *text_); // Each row's content: its label, the gutter, and its value -- which is a number or the em dash, // so every row is the same two runs and the panel is sized by the widest *listed* one, read in // O(1) by the prefix-max after a row was dropped from the end. crosshair_content_px_.clear(); for (const CrosshairRow& row : crosshair.rows) { const f32 content = text_->measure(row.label.c_str()) + chrome.pad + text_->measure(row.value.c_str()); crosshair_content_px_.push_back(crosshair_content_px_.empty() ? content : std::max(crosshair_content_px_.back(), content)); } // The room inside `axes`, held off its edges by the panel's own margin -- the inset the // legend is fitted into and the budget this panel has, one number for both panels. No room // at all is no panel at all: a plot too small for a panel is not a place to draw one. const f32 margin = style_.device(legend_.margin); const f32 room_w = axes.w - 2.0f * margin; const f32 room_h = axes.h - 2.0f * margin; if (!(room_w > 0.0f) || !(room_h > 0.0f)) return; // Rows are dropped from the end until the panel fits, as a legend's entries are: a panel // that has to overhang to list the last trace would be drawn over a label strip, which is // the defect Task 1 fixed. The dropped rows keep their markers -- only the list is cut. u32 keep = count; f32 panel_w = 0.0f; f32 panel_h = 0.0f; for (;;) { panel_w = readout_panel_width(chrome, crosshair_content_px_[keep - 1u]); panel_h = readout_panel_height(chrome, keep); if (panel_w <= room_w && panel_h <= room_h) break; if (--keep == 0u) return; // not even one row fits } // Beside the pointer: one offset down and to the right of it, flipped on an axis where // that would leave the room, and finally clamped into it. With the fitted size the two // clamp bounds are ordered, so "the panel is inside `axes`" is arithmetic rather than // hope -- and the flip is what keeps it *near* the pointer rather than pinned to a corner // whenever the crosshair is in the right or the bottom half of the plot. const f32 offset = style_.device(kCrosshairPanelOffsetLogical); const f32 anchor_x = inside(static_cast(tx.to_screen_x(crosshair_.x)), axes.x, axes.right()); // A vertical-only crosshair has no pointer row, so the panel hangs from the top of the // axes: there is no `y` a caller gave it and inventing one would place the panel at a value // nothing points at. const f32 anchor_y = crosshair_.horizontal ? inside(static_cast(ty.to_screen_y(crosshair_.y)), axes.y, axes.bottom()) : axes.y + margin; f32 px = anchor_x + offset; f32 py = anchor_y + offset; if (px + panel_w > axes.right() - margin) px = anchor_x - offset - panel_w; if (py + panel_h > axes.bottom() - margin) py = anchor_y - offset - panel_h; px = inside(px, axes.x + margin, axes.right() - margin - panel_w); py = inside(py, axes.y + margin, axes.bottom() - margin - panel_h); crosshair.panel = Rect{px, py, panel_w, panel_h}; // The listed rows' boxes, placed here rather than left to a renderer to re-derive (one // placement rule, one place to read it). Values are right-aligned on the panel's inner // edge, which is what makes a column of numbers readable; the panel's width was measured // from label plus gutter plus value, so the two runs cannot meet even on the widest row. for (u32 i = 0; i < keep; ++i) { CrosshairRow& row = crosshair.rows[i]; row.listed = true; row.swatch = readout_swatch(chrome, crosshair.panel, i); const Vec2 pen = readout_text_pen(chrome, crosshair.panel, i); row.label_box = Rect{pen.x, pen.y, text_->measure(row.label.c_str()), chrome.line_h}; // Every listed row stages a value run: the number, or `kCrosshairNoValue` where the trace has // no sample here. A blank cell would read as a value still to come, which is a different thing // from "nothing at this x". const f32 width = text_->measure(row.value.c_str()); row.value_box = Rect{crosshair.panel.right() - chrome.pad - width, pen.y, width, chrome.line_h}; } } } // namespace plot