diff --git a/include/plot/input/controller.hpp b/include/plot/input/controller.hpp index 806ce1d..ffc0f7a 100644 --- a/include/plot/input/controller.hpp +++ b/include/plot/input/controller.hpp @@ -124,6 +124,18 @@ using plot::RegionGrab; /// the same answer for a host that wants it directly -- a resize cursor, a tooltip, /// a status line. /// +/// ## The crosshair (spec §15) +/// +/// A pointer over the data area places the plot's crosshair at the data coordinates under +/// it (`Plot::set_crosshair()`), and a pointer that leaves `axes` clears it +/// (`Plot::clear_crosshair()`). That is a *hover*, not a gesture: no button is involved, and +/// a press, a wheel notch or a key stroke that moves the data under the pointer re-anchors +/// the crosshair to the same pointer position rather than clearing it -- it follows the +/// pointer, so anything the pointer did not move leaves it where the pointer is. The +/// per-signal values a readout shows are the plot's (`PlotLayout::crosshair`), not this +/// layer's: the controller says where the pointer is and nothing about what a crosshair +/// means. +/// /// ## Zoom and pan on a live x axis (spec §12) /// /// An x axis armed with `XFollow::Live` keeps following through every gesture @@ -262,6 +274,16 @@ class Controller { /// leave alone, the bounds and the grabbed data x a body move needs to preserve /// the width -- so a changed pointer motion never accumulates error. bool begin_handle_drag(const PointerEvent& e); + /// Places the plot's crosshair under the pointer, or clears it when the pointer is not + /// over the data area (spec §15). Called after every event that can move the pointer *or* + /// the data under it -- a pointer event, a wheel notch, a key stroke, a viewport change -- + /// so the crosshair is where the pointer is and reads the data the frame will draw, and + /// never at a coordinate a zoom has already moved out from under it. + /// + /// A press does not clear it: the crosshair follows the pointer, and a pointer that is + /// still over the axes is still pointing at whatever gesture just started. The pointer is + /// the only thing that clears it, by leaving `axes`. + void update_crosshair(f32 px, f32 py); /// Publishes what the pointer is on to the plot, for the renderer to highlight /// (see HotHandles). Called after every event that can move the geometry under the /// pointer -- a pointer event, a wheel notch, a key stroke -- so hover and grab are diff --git a/include/plot/plot.hpp b/include/plot/plot.hpp index bbff599..6ab53a7 100644 --- a/include/plot/plot.hpp +++ b/include/plot/plot.hpp @@ -48,6 +48,7 @@ #include #include +#include #include #include #include diff --git a/include/plot/plot/crosshair.hpp b/include/plot/plot/crosshair.hpp new file mode 100644 index 0000000..9a63b79 --- /dev/null +++ b/include/plot/plot/crosshair.hpp @@ -0,0 +1,198 @@ +#pragma once +#include +#include + +#include +#include +#include +#include + +namespace plot { + +/// The crosshair's state: where it is, which of its two lines it draws, and whether the +/// per-signal readout is wanted. +/// +/// It is a *hover* instrument, not a cursor: a cursor is put where the caller wants it and +/// stays there, while the crosshair follows the pointer and exists only while the pointer +/// is over the data area. So it is plain state rather than an element of a collection -- +/// there is at most one per plot, and `Plot::set_crosshair()` is what places it. +/// +/// **Its coordinates are data coordinates of the primary axis pair** (x axis 0, y axis 0), +/// like a `Region`'s bounds and the grid: one hover is one instant and one value, whatever +/// the strips around the axes happen to be. `vertical`/`horizontal` say which lines are +/// drawn -- the single-coordinate `Plot::set_crosshair()` leaves the vertical one only, +/// and a `Figure` is always vertical-only because its rows share x and not y. +/// +/// **The readout's values are interpolated, and deliberately so.** The y a row reports for +/// a trace is *linear interpolation between the two samples adjacent to `x`*, not a sample +/// and not an interval statistic -- see `crosshair_span()` for the rule stated in full, and +/// for why it is the opposite convention from `Series::reduce()`/`sum()`/`sumsq()`. +struct Crosshair { + /// Whether the crosshair is drawn at all. Set by `Plot::set_crosshair()`; cleared by + /// `Plot::clear_crosshair()`, which `input::Controller` calls when the pointer leaves the + /// axes. + bool active = false; + /// Draw the vertical line (the column of `x`). On for both `set_crosshair()` overloads. + bool vertical = true; + /// Draw the horizontal line (the row of `y`). On for the two-coordinate overload only: + /// a crosshair placed at one coordinate has no second value to mark a row with. + bool horizontal = true; + /// List the visible traces and their interpolated values in a panel beside the pointer. + /// On by default, and free to leave on: a plot with no visible trace has no rows to + /// measure and the panel is then not placed at all. + bool show_readout = true; + /// The data x of the vertical line, on x axis 0. + f64 x = 0.0; + /// The data y of the horizontal line, on y axis 0. Meaningless for a vertical-only + /// crosshair, which draws no row and anchors its readout at the top of the axes. + f64 y = 0.0; +}; + +/// The two samples of a uniform-grid series that a data `x` lies between: what the +/// crosshair's lookup needs, and all it needs. +/// +/// `begin` is the index of the last sample at or before `x`, `end` the index of the first +/// sample at or after it, and `t` where `x` sits between them (`0` at `begin`, `1` at +/// `end`). `end - begin` is therefore 0 when `x` lands exactly on a sample -- including the +/// two ends of the window, which are samples -- and 1 otherwise, so a caller reads **at +/// most two samples** and never scans: the span is one division and one floor of index +/// arithmetic, which is O(1) in the series' length. The crosshair runs on every pointer +/// move, so that is a requirement rather than a nicety. +/// +/// **Interpolated, not exact.** The value a caller builds from this span by +/// `crosshair_interpolate()` between the two samples is the signal *at* `x`, which is +/// generally between two samples -- the opposite convention from `Series::reduce()`, +/// `sum()` and `sumsq()`, which are **exact over a sample range**. The two conventions sit +/// next to each other in this library on purpose and they do not agree: `reduce()` answers +/// "what are the samples' extremes over this interval", this answers "what is the signal at +/// this instant". A readout that pretended to be exact would be quoting an extreme or a +/// mean of a window as if it were the value under the pointer. +/// +/// `valid` is false -- and every field left at its default -- for an **empty series**, a +/// non-positive `dx()`, a non-finite `x`, and an `x` strictly outside the series' window +/// `[x0, x0 + (size-1)*dx]`. An out-of-window x yields *no* value rather than the nearest +/// sample's, which would be a clamped lie about the data under the pointer; the readout row +/// it belongs to says so instead (`CrosshairRow::valid`). +struct CrosshairSpan { + /// Index of the last sample whose x is at or before the query. + u64 begin = 0; + /// Index of the first sample whose x is at or after the query: `begin` when the query + /// lands on a sample, `begin + 1` between two of them. + u64 end = 0; + /// The query's position between the two samples, in `[0, 1]`. + f64 t = 0.0; + bool valid = false; +}; + +/// The sample span `x` falls in, on `series`; see `CrosshairSpan` for the rule, for the +/// interpolation convention it is half of, and for the cases that yield `valid == false`. +/// +/// O(1) and allocation-free: a subtraction, a division and a floor. The series' own sample +/// fetch is the caller's -- `Plot::crosshair_value()` reads the span through +/// `Series::gather()` into a scratch buffer it owns, so no lookup on the pointer path +/// allocates. +CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept; + +/// The interpolated y at `t` between two samples: `y_begin` at `t == 0`, `y_end` at +/// `t == 1`. The one place the interpolation's arithmetic lives, so the lookup's rule and +/// its documentation cannot drift apart. +inline f64 crosshair_interpolate(f64 y_begin, f64 y_end, f64 t) noexcept { + return y_begin + (y_end - y_begin) * t; +} + +/// One trace's line of the readout: which trace it is about, the value under the pointer +/// and the boxes the renderer stages. +/// +/// **Every visible trace gets a row, named or not** -- the readout is about what is under +/// the pointer, so the legend's rule of skipping unnamed traces does not apply here. An +/// unnamed trace's row carries a stable positional label instead (`label`, built by +/// `crosshair_unnamed_label()`), so the rows stay identifiable without a name being +/// invented for the trace. +/// +/// A row whose trace has no sample at the crosshair's x -- its series does not reach that +/// far, or holds nothing -- is `valid == false` and carries **no value text**: the trace is +/// still listed, because it *is* one of the signals under the pointer, but no number is +/// fabricated for it and no marker is drawn at a y that does not exist. +struct CrosshairRow { + /// Index of the trace this row is about: `Plot::trace(trace)` is what the label, the + /// swatch colour and the marker position come from. + u32 trace = 0; + /// False when the trace's series has no sample at the crosshair's x (see the struct + /// comment). `y`, `value` and `marker` are then meaningless. + bool valid = false; + /// True when the row's chrome was placed inside the panel. The panel is clamped to the + /// axes and drops rows from the end when they do not fit -- exactly as a legend drops + /// entries -- so a row that is not listed is still a listed *trace*, with a marker, just + /// not a line in a panel that has no room for it. + bool listed = false; + /// The trace's interpolated y at the crosshair's x, on the trace's own y axis. Only + /// meaningful when `valid`. + f64 y = 0.0; + /// The trace's name, or the positional placeholder of an unnamed one. + std::string label; + /// The formatted value, written in the trace's y axis' own tick format (`Axis::format_value()`, + /// so a readout reads exactly like the labels beside it), and empty when `!valid`. + std::string value; + /// The trace's intersection with the crosshair, in framebuffer pixels: the pen the + /// renderer draws the marker at. Placed through the *trace's own* axis pair, so the + /// marker is where that trace is actually drawn. Meaningless when `!valid`. + Vec2 marker{}; + /// The swatch's box, `Legend::swatch_w` x `Legend::swatch_h` centred in the row. Empty + /// when `!listed`. + Rect swatch{}; + /// The label's line box: its top-left is the pen the run is staged at and its height one + /// text line. Empty when `!listed`. + Rect label_box{}; + /// The value's line box, one gutter (the legend's padding) after the label's. Empty when + /// `!listed` or `!valid`: a row with no value stages no run. + Rect value_box{}; +}; + +/// The measured crosshair: the two lines' stroke boxes, the readout panel and one row per +/// visible trace, in the same device-pixel space as `PlotLayout` and filled alongside it by +/// `Plot::layout()`. +/// +/// It is a member of `PlotLayout` rather than a second call, for the legend's reason: the +/// numbers a test asserts and the numbers the renderer draws are one set. Every rect is +/// **inside `PlotLayout::axes`** by construction -- a line's stroke box is held half a +/// stroke inside the rect, and the panel is placed beside the pointer, flipped when it +/// would cross an edge and then clamped, so no crosshair pixel can land on a label strip or +/// a neighbouring figure row. Task 1's axes clip is the second line of defence, not the +/// first. +/// +/// Everything is default -- no lines, no panel, no rows -- when the crosshair is not +/// active, and the rows alone are empty when there is no visible trace. +struct CrosshairLayout { + /// True when the crosshair was active for this layout. + bool active = false; + /// The vertical line's stroke box: `Style::line_width_px` wide in device pixels, as tall + /// as `axes`, centred on the column of `Crosshair::x`. Zero-sized when the vertical line + /// is not drawn. + Rect vertical{}; + /// The horizontal line's stroke box, the same instrument turned a quarter turn. Zero-sized + /// when the horizontal line is not drawn. + Rect horizontal{}; + /// The readout panel, inside `axes`. Zero-sized when there is nothing to read out (no + /// visible trace, nothing to measure text with, no room for even one row), when + /// `Crosshair::show_readout` is off, or when the panel could not be placed inside the + /// axes at all. + Rect panel{}; + /// One row per visible trace, in trace order -- see `CrosshairRow`. Present whether or + /// not the panel could list them, so the markers are drawn even in a plot too small to + /// hold a panel. + std::vector rows; + + bool empty() const noexcept { return !active; } +}; + +/// The label an **unnamed** trace's readout row carries: `#` and the trace's position, e.g. +/// `#2`. +/// +/// Positional on purpose. The readout lists a trace because it is under the pointer, not +/// because it has an identity, so an unnamed trace needs *some* stable caption or its rows +/// would be indistinguishable -- and inventing a name for it (or borrowing a neighbour's) +/// would be a claim about the data that nothing supports. The position is derived from the +/// trace id alone, so the same trace keeps the same caption frame after frame. +std::string crosshair_unnamed_label(u32 trace); + +} // namespace plot diff --git a/include/plot/plot/figure.hpp b/include/plot/plot/figure.hpp index 17fe19e..17380db 100644 --- a/include/plot/plot/figure.hpp +++ b/include/plot/plot/figure.hpp @@ -139,6 +139,27 @@ class Figure { const FigureLayout& layout(); const FigureLayout& layout() const noexcept { return layout_; } + // --- the crosshair ---------------------------------------------------- + + /// Places one crosshair x on **every** row (spec §15): the same data coordinate, so each + /// row's readout lists its own traces at that instant and the rows read as one hover + /// rather than N. That is what "synced if multiple plots available" means -- one pointer + /// position, one x, N rows of values -- and it is the figure's to do rather than a host's + /// because only the figure knows its rows. + /// + /// Vertical-only, deliberately: the rows of a figure share *x* (`Figure`'s whole reason + /// for stacking them and linking their x windows) and not y -- row 1 may be volts and row + /// 2 degrees -- so a horizontal line across the figure would be a value that means + /// different things in each row. A row whose series has no sample at `x` lists the trace + /// with no value rather than inventing one. + /// + /// A row is placed with `Plot::set_crosshair(x, active)`, which refuses a coordinate that + /// is not a number; such a call therefore leaves every row's crosshair as it was. + void set_crosshair(f64 x, bool active); + + /// Clears the crosshair on every row. A row that never had one is unaffected. + void clear_crosshair(); + // --- the x link ------------------------------------------------------- /// A new link group, empty. Groups are numbered from 1 -- 0 is the unlinked diff --git a/include/plot/plot/plot.hpp b/include/plot/plot/plot.hpp index 6ca7b50..b9a56b9 100644 --- a/include/plot/plot/plot.hpp +++ b/include/plot/plot/plot.hpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -96,6 +97,13 @@ struct PlotLayout { /// controller's hit test reads the very same rects, so where a region is drawn and /// where a pointer can grab it cannot drift apart. std::vector regions; + /// The crosshair's geometry, when one is active: the two lines' stroke boxes, the + /// readout panel and one row per visible trace with the interpolated value the pointer + /// is over. Placed here rather than derived by the renderer, exactly as the legend and + /// the regions are -- and, like them, entirely inside `axes`: the panel is flipped and + /// clamped against the rect it was measured for, so nothing of the readout can be drawn + /// over a label strip or into a figure's neighbouring row. + CrosshairLayout crosshair; }; /// What a pointer is on, as of the last pointer event: the cursor marker under it, the @@ -530,6 +538,56 @@ class Plot { /// a caller can move the legend -- or switch it off -- between frames. void set_legend(const Legend& legend) noexcept { legend_ = legend; } + // --- crosshair -------------------------------------------------------- + + /// Places a **vertical-only** crosshair at a data x: the column of `x`, a marker on + /// every visible trace at that x, and the readout panel beside it (spec §15). + /// + /// This is the form for a caller that has a coordinate and not a pointer -- "show me + /// every signal at this instant" -- and for `Figure::set_crosshair()`, which applies one + /// x to every row: a figure's rows share x and not y, so a horizontal line across them + /// would be a claim about y that the figure does not make. + /// + /// **Refused** -- nothing changes -- when `x` is not a number: a crosshair there has no + /// column to draw and no value to read, and refusing it leaves the previous position + /// rather than a crosshair no gesture could undo. `active == false` clears it exactly as + /// `clear_crosshair()` does. + void set_crosshair(f64 x, bool active); + + /// Places a crosshair at both coordinates: the vertical line and the readout of the + /// first form, plus the horizontal line at `y`. This is the form `input::Controller` + /// uses, because a pointer over the axes has both coordinates. + /// + /// Refused, changing nothing, when either coordinate is not a number (see the first + /// form). + void set_crosshair(f64 x, f64 y, bool active); + + /// Hides the crosshair, leaving where it was: the same state a plot has before any + /// crosshair is placed, so the next `set_crosshair()` re-anchors it. `input::Controller` + /// calls this when the pointer leaves the axes. + void clear_crosshair() noexcept { crosshair_.active = false; } + + /// The crosshair's state: where it is, which lines it draws and whether the readout is + /// on. Read-only -- `set_crosshair()` and `clear_crosshair()` are how it moves -- so the + /// coordinates it holds and the geometry `layout()` places from them cannot disagree. + const Crosshair& crosshair() const noexcept { return crosshair_; } + + /// The **interpolated** value of `trace`'s series at a data `x`: `crosshair_span()` + /// between the two adjacent samples, then `crosshair_interpolate()` -- the same lookup + /// the readout's rows are built from, exposed for a host that wants the number for + /// itself (a status line, a tooltip of its own). + /// + /// False, leaving `out_y` untouched, for an id that names no trace, a trace with no + /// series, and an `x` the series has no sample at -- the cases `CrosshairSpan::valid` + /// documents, plus the trace itself. Never a clamped or nearest-sample answer: see + /// `crosshair_span()` for why an out-of-window x is not a value. + /// + /// O(1) and allocation-free once the plot has looked a value up before: the span is + /// index arithmetic and the samples are read through one `Series::gather()` of at most + /// two samples into a buffer the plot owns. It is called on every layout -- and therefore + /// on every pointer move -- once per visible trace, so nothing here may scan a series. + bool crosshair_value(u32 trace, f64 x, f64& out_y) const; + private: /// One measure plus its cache. The dirty flag is what keeps measurement() from /// re-evaluating every frame; `last_series`, `last_size` and `last_x0` catch a @@ -636,6 +694,12 @@ class Plot { /// to list -- an empty legend is what keeps the feature free for a caller who /// never names a trace. void build_legend(); + /// Measures the crosshair into `layout_.crosshair`: the two lines' stroke boxes, one row + /// per visible trace with its interpolated value and marker, and the readout panel -- + /// placed beside the crosshair, flipped where it would cross an edge and clamped, so + /// every rect it produces is inside `axes`. A plot with no visible trace still gets its + /// lines: the rows are the readout's, not the crosshair's. + void build_crosshair(); Rect viewport_{}; render::Style style_{}; @@ -664,6 +728,20 @@ class Plot { /// about to drop. A member rather than a local, so a steady-state frame's legend /// build allocates nothing. std::vector legend_name_px_; + /// The crosshair's state (see Crosshair): at most one per plot, placed by + /// `set_crosshair()` and read back by `crosshair()` and `layout()`. + Crosshair crosshair_{}; + /// The samples `crosshair_value()` reads a span through: `Series::gather()` appends + /// `(x, y)` pairs and never clears, so the lookup clears this and refills it with the at + /// most two samples a span covers. Sized to hold them from construction -- four floats is + /// two pairs -- so a lookup on the pointer path reallocates nothing even the first time, + /// and `mutable` for the same reason `measures_` is: a lookup is not a mutation a caller + /// can observe. + mutable std::vector crosshair_scratch_{0.0f, 0.0f, 0.0f, 0.0f}; + /// Scratch for build_crosshair(): the content width (label + gutter + value) of every row, + /// prefix-maxed in place, for `legend_name_px_`'s reason -- the panel's width after a row + /// was dropped from the end is the width of a row still listed, read in O(1). + std::vector crosshair_content_px_; PlotLayout layout_{}; Grid grid_; diff --git a/include/plot/render/renderer.hpp b/include/plot/render/renderer.hpp index 1cc8bd2..b1a10b0 100644 --- a/include/plot/render/renderer.hpp +++ b/include/plot/render/renderer.hpp @@ -487,6 +487,18 @@ class Renderer { /// them again, so the panel a test asserts and the panel this draws are the same /// numbers. void draw_legend(const Plot& plot, const PlotLayout& layout); + /// The crosshair: the two lines, a marker per trace, and the readout panel with a swatch and + /// a value per listed row. Every rect -- the two stroke boxes, the panel, the markers' + /// positions and the rows' boxes -- comes from `PlotLayout::crosshair`, which placed it + /// inside `axes`; this stages exactly that geometry, so the crosshair a test measures and the + /// crosshair a frame paints are one set of numbers. Clipped to `axes` with the rest of the + /// interior passes, which is what keeps a marker for an off-scale sample inside the rect. + /// + /// The panel is a backplate like the legend's, so it is drawn after the lines and the + /// markers and before any of the frame's text. The rows' values were formatted by the plot + /// (in the trace's own y axis' format) and are staged here as they stand -- this layer + /// formats nothing. + void draw_crosshair(const Plot& plot, const PlotLayout& layout); /// The built-in backend the renderer owns, and the face the frames draw with: /// `set_text_backend()` writes `font_`, which starts here and comes back here diff --git a/include/plot/render/style.hpp b/include/plot/render/style.hpp index 5a74f1e..fed6365 100644 --- a/include/plot/render/style.hpp +++ b/include/plot/render/style.hpp @@ -41,6 +41,13 @@ struct Style { Color axis_text = Color::hex(0xC8D0DC); Color cursor = Color::hex(0xFFB020); Color measure_fill = Color::hex(0xFFB020, 0.15f); + /// The crosshair's colour: its two lines, its markers' own colour excepted, and the + /// readout panel's border (spec §15). Deliberately **not** `cursor`: the cursor accent is + /// an orange that a caller's own cursors, a region's bounds and its fill all already use, + /// while the crosshair is a passive hover instrument that has to read as *chrome* over a + /// dense trace rather than as another handle to grab. A pale cyan does that against the + /// dark default theme, and it is a colour no default element of a frame shares. + Color crosshair = Color::hex(0xB8E8FF); // --- the scale ---------------------------------------------------------- diff --git a/src/input/controller.cpp b/src/input/controller.cpp index 539cab9..a28b049 100644 --- a/src/input/controller.cpp +++ b/src/input/controller.cpp @@ -113,6 +113,10 @@ void Controller::set_viewport(Rect px) { // transforms read. plot_->set_viewport(px); plot_->layout(); + // A resize moves the axes (and with them the data under a stationary pointer), so the + // crosshair is re-placed against the new rect -- and cleared if the pointer is now outside + // it, which is the same rule as a pointer that left. + if (has_pointer_) update_crosshair(pointer_x_, pointer_y_); // The hover the layout is part of: a handle under the pointer may be somewhere // else now, and the highlight the renderer draws reads what this publishes. publish_hot(); @@ -132,6 +136,11 @@ void Controller::on_pointer(const PointerEvent& raw) { } else if (e.down && mode_ != DragMode::None) { update_drag(e); } + // The crosshair is placed (or cleared) from the pointer's own position, after the gesture + // above: a press does not clear it -- the pointer is still over the axes, still pointing at + // whatever it just grabbed -- and a drag keeps it re-anchored to the pointer as the data + // moves under it. + update_crosshair(e.x, e.y); // Every exit above ends here: a hover that moved, a gesture that opened, and a // gesture that closed all change what is under the pointer, and the publish is // what makes the renderer's highlight (and a host's resize cursor) follow. @@ -174,6 +183,11 @@ void Controller::on_wheel(const WheelEvent& raw) { // emphasizes the handle the pointer is over *now*, rather than the one it was over before, // without waiting for the mouse to move again. plot_->layout(); + // The data under the pointer moved with the ranges, so the crosshair -- which is a data + // coordinate -- is re-anchored to the pointer rather than left on the value it was placed + // at: it follows the pointer, and this is the one thing that moves the data without moving + // the pointer. + if (has_pointer_) update_crosshair(pointer_x_, pointer_y_); publish_hot(); } @@ -206,11 +220,31 @@ void Controller::on_key(const KeyEvent& e) { // A key stroke that moved a range moved the geometry under a stationary pointer, exactly // as a wheel notch does: refresh the layout and republish what is hot, so the highlight a // frame draws is the handle the pointer is over now (see on_wheel). A key this controller - // has no gesture for changes nothing and returns above. + // has no gesture for changes nothing and returns above -- and so does not touch the + // crosshair either. plot_->layout(); + if (has_pointer_) update_crosshair(pointer_x_, pointer_y_); publish_hot(); } +void Controller::update_crosshair(f32 px, f32 py) { + // Inside the data area the crosshair is at the coordinates under the pointer; outside it + // there is no data to read, so there is no crosshair -- the null answer, rather than a + // value clamped to the nearest edge of the window. + const PlotLayout& layout = std::as_const(*plot_).layout(); + if (!layout.axes.contains(px, py)) { + plot_->clear_crosshair(); + return; + } + // Both coordinates, on the primary axis pair (the same pair the grid and a region's fill + // are placed through): a pointer over the axes knows its x and its y, so a single plot's + // crosshair is the full cross. A `Figure` is driven by `Figure::set_crosshair()` instead, + // which applies one x to every row and draws no horizontal line -- its rows share x and + // not y. + plot_->set_crosshair(plot_->x_transform(0u).to_data_x(static_cast(px)), + plot_->y_transform(0u).to_data_y(static_cast(py)), true); +} + u64 Controller::hovered_cursor() const { if (!has_pointer_) return kInvalid; return cursor_at(pointer_x_, pointer_y_); diff --git a/src/plot/figure.cpp b/src/plot/figure.cpp index 07acf9a..b7c9d0b 100644 --- a/src/plot/figure.cpp +++ b/src/plot/figure.cpp @@ -410,4 +410,18 @@ const FigureLayout& Figure::layout() { 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::clear_crosshair() { + for (Plot& row : plots_) row.clear_crosshair(); +} + } // namespace plot diff --git a/src/plot/plot.cpp b/src/plot/plot.cpp index 5ec3065..d30ff02 100644 --- a/src/plot/plot.cpp +++ b/src/plot/plot.cpp @@ -3,6 +3,7 @@ #include #include +#include namespace plot { namespace { @@ -133,6 +134,77 @@ bool fit_axis_range(Axis& axis, Range bounds) { 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) { @@ -806,6 +878,10 @@ const PlotLayout& Plot::layout() { // interval map to the pixel columns the frame will actually use. build_regions(); build_legend(); + // The crosshair last: its panel is fitted into `axes` and its lines are held inside it, and + // its rows are the interpolated values at a data x -- so it wants the transforms the + // settled layout pins, exactly as the legend and the regions do. + build_crosshair(); return layout_; } @@ -927,17 +1003,11 @@ void Plot::build_legend() { // plot that lays out exactly as it did before legends existed. if (text_ == nullptr || !legend_.visible) return; - // Every one of these is a logical size -- the legend is chrome a user reads -- - // while `text_->height()` and the name measures are device: converting each one - // once, into a local, is what keeps the panel's arithmetic in device pixels - // throughout, like the strips it is fitted into. - const f32 pad = style_.device(legend_.pad); + // 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); - const f32 swatch_w = style_.device(legend_.swatch_w); - const f32 swatch_h = style_.device(legend_.swatch_h); - const f32 row_gap = style_.device(legend_.row_gap); - const f32 line_h = text_->height(); - const f32 row_h = std::max(swatch_h, line_h); // 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(); @@ -970,9 +1040,9 @@ void Plot::build_legend() { const bool truncated = keep < entries; const f32 widest = truncated ? std::max(legend_name_px_[keep - 1u], ellipsis_w) : legend_name_px_[keep - 1u]; - const f32 rows = static_cast(keep + (truncated ? 1u : 0u)); - panel_w = 2.0f * pad + swatch_w + pad + widest; - panel_h = 2.0f * pad + rows * row_h + (rows - 1.0f) * row_gap; + 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 } @@ -995,18 +1065,17 @@ void Plot::build_legend() { // 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. - const f32 name_x = x + pad + swatch_w + pad; - f32 row_y = y + pad; - for (u32 i = 0; i < traces_.size() && legend.rows.size() < keep; ++i) { + 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 = Rect{x + pad, row_y + 0.5f * (row_h - swatch_h), swatch_w, swatch_h}; - row.name = Rect{name_x, row_y + 0.5f * (row_h - line_h), - text_->measure(trace.name.c_str()), line_h}; + 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); - row_y += row_h + row_gap; + ++placed; } if (keep < entries) { // Entries were dropped; the marker takes the row after the last kept one, with no @@ -1014,7 +1083,8 @@ void Plot::build_legend() { // lines up with the names above it. LegendRow marker; marker.ellipsis = true; - marker.name = Rect{name_x, row_y + 0.5f * (row_h - line_h), ellipsis_w, line_h}; + 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); } } @@ -1072,4 +1142,253 @@ void Plot::build_regions() { } } +// --- crosshair (spec §15) -------------------------------------------------- + +CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept { + CrosshairSpan span; + const u64 n = series.size(); + const f64 x0 = series.x0(); + const f64 dx = series.dx(); + // No samples, no grid, no finite origin: nothing to interpolate between, and `index` + // below would be a division by zero or a NaN -- whose floor is undefined -- rather than a + // sample index. `!(dx > 0.0)` is also the NaN test for `dx`. + if (n == 0u || !(dx > 0.0) || !std::isfinite(x0) || !std::isfinite(x)) return span; + + // The window is closed at both ends -- the oldest and the newest sample are samples too -- + // and a query outside it has no neighbours to interpolate between. 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. + const f64 newest = x0 + static_cast(n - 1u) * dx; + if (x < x0 || x > newest) return span; + + // Index arithmetic, not a search: the grid is uniform, so the sample at or before `x` is + // `floor((x - x0) / dx)` and there is nothing to look up. Two special cases keep the span + // honest at the ends and under rounding -- an index past the last sample collapses onto + // it, and a `t` that rounds up to 1.0 (a query one ulp above a sample) collapses onto the + // sample it has reached rather than reporting an interpolation of width zero. + const f64 index = (x - x0) / dx; + const u64 i = index <= 0.0 ? 0u : static_cast(std::floor(index)); + if (i >= n - 1u) { + span.begin = span.end = n - 1u; + span.t = 0.0; + span.valid = true; + return span; + } + const f64 t = index - static_cast(i); + if (!(t > 0.0)) { + span.begin = span.end = i; // exactly on sample `i` + } else if (t >= 1.0) { + span.begin = span.end = i + 1u; // exactly on the sample after it + } else { + span.begin = i; + span.end = i + 1u; + span.t = t; + } + span.valid = true; + return span; +} + +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 { + 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(); + series->gather(0u, span.begin, span.end + 1u, 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]); + out_y = crosshair_interpolate(y_begin, y_end, span.t); + return true; +} + +void Plot::build_crosshair() { + CrosshairLayout& crosshair = layout_.crosshair; + crosshair.active = crosshair_.active; + crosshair.vertical = Rect{}; + crosshair.horizontal = Rect{}; + 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. + const f32 stroke = style_.device(style_.line_width_px); + 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.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); + row.value.clear(); + // The value, and with it the marker: a trace with no sample at this x writes no number + // and gets no marker, rather than a value clamped to the end of its series. + if (crosshair_value(i, crosshair_.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(crosshair_.x)), + static_cast(y_transform(trace.axis_y).to_screen_y(row.y))}; + } + } + 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 when it has one -- an invalid + // row is narrower, and the panel is sized by the widest row that will be listed, which the + // prefix-max reads in O(1) after a row was dropped from the end. + crosshair_content_px_.clear(); + for (const CrosshairRow& row : crosshair.rows) { + f32 content = text_->measure(row.label.c_str()); + if (row.valid) content += 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}; + if (row.valid) { + 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 diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index 6ea24d6..d84ae06 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -494,6 +494,10 @@ void Renderer::draw_plot(Plot& plot, const PlotLayout& layout) { draw_measures(plot, layout); draw_annotations(plot); draw_legend(plot, layout); + // The crosshair last of the interior passes: it is what the pointer is on, so its panel is + // drawn over the legend and every other overlay rather than under them, and its text joins + // the frame's single flush with everything else. + draw_crosshair(plot, layout); clear_clip(); draw_tick_labels(plot, layout); draw_titles(plot, layout); @@ -1170,6 +1174,83 @@ void Renderer::draw_legend(const Plot& plot, const PlotLayout& layout) { } } +void Renderer::draw_crosshair(const Plot& plot, const PlotLayout& layout) { + const CrosshairLayout& crosshair = layout.crosshair; + if (!crosshair.active) return; + + const f32 stroke = style_.device(style_.line_width_px); + // The two lines. Each is staged as one data-space segment at the *centre* of the stroke box + // the layout placed, spanning that box's own extent, so the pixels this draws are the box + // rather than a second placement of the same rule: the layout held the box inside `axes`, + // and the clip keeps the half pixel the rasteriser puts outside the box's fractional edges. + const Mapping data = mapping(plot, 0u, 0u); + if (crosshair.vertical.w > 0.0f) { + const f64 x = plot.x_transform(0u).to_data_x( + static_cast(crosshair.vertical.x + 0.5f * crosshair.vertical.w)); + const Range y_range = plot.y_range(0u); + stage_segment(x, y_range.min, x, y_range.max, plot.x_axis(0u).log, plot.y_axis(0u).log); + flush_lines(data, style_.crosshair, stroke); + } + if (crosshair.horizontal.h > 0.0f) { + const f64 y = plot.y_transform(0u).to_data_y( + static_cast(crosshair.horizontal.y + 0.5f * crosshair.horizontal.h)); + const Range x_range = plot.x_range(0u); + stage_segment(x_range.min, y, x_range.max, y, plot.x_axis(0u).log, plot.y_axis(0u).log); + flush_lines(data, style_.crosshair, stroke); + } + + // A small cross at each trace's intersection, in the trace's own colour -- opaque, like a + // legend's swatch, so a trace drawn at 15% still has a marker that can be seen. The arm + // length is a logical size like every other marker's, and the position is the layout's: a + // marker for a sample far outside the visible y range is staged where the trace is and the + // axes clip keeps it inside, rather than being skipped by a range test of this layer's own. + const f32 arm = style_.device(3.0f); + for (const CrosshairRow& row : crosshair.rows) { + if (!row.valid) continue; + Color color = plot.trace(row.trace).color; + color.a = 1.0f; + lines_.add_segment({row.marker.x - arm, row.marker.y}, {row.marker.x + arm, row.marker.y}); + lines_.add_segment({row.marker.x, row.marker.y - arm}, {row.marker.x, row.marker.y + arm}); + flush_lines(pixel_mapping(), color, style_.device(1.0f)); + } + + // The readout: the same backplate a legend has -- the frame's background at a partial + // alpha, so the data stays legible through it -- with its border in the crosshair's own + // colour, which is what makes the panel read as part of the instrument that placed it + // rather than as a second legend. + if (crosshair.panel.w > 0.0f && crosshair.panel.h > 0.0f) { + const Rect& panel = crosshair.panel; + Color background = style_.background; + background.a *= kLegendPanelAlpha; + fill_pixels(panel, background); + // The crisp-hairline rule of the legend's border: the outermost *lit* columns and rows of + // the fill's span, not the measured rect's fractional edges, so all four sides are one + // hairline rather than three bright ones and a dim one. + const f32 left = std::ceil(panel.x - 0.5f) + 0.5f; + const f32 right = std::ceil(panel.right() - 0.5f) - 0.5f; + const f32 top = std::ceil(panel.y - 0.5f) + 0.5f; + const f32 bottom = std::ceil(panel.bottom() - 0.5f) - 0.5f; + lines_.add_segment({left, top}, {right, top}); + lines_.add_segment({left, bottom}, {right, bottom}); + lines_.add_segment({left, top}, {left, bottom}); + lines_.add_segment({right, top}, {right, bottom}); + flush_lines(pixel_mapping(), style_.crosshair, style_.device(1.0f)); + } + + for (const CrosshairRow& row : crosshair.rows) { + if (!row.listed) continue; + // The swatch is the trace's colour opaque, as a legend's is: it is the one element that + // says which signal the row is about. + Color swatch = plot.trace(row.trace).color; + swatch.a = 1.0f; + fill_pixels(row.swatch, swatch); + stage_text({row.label_box.x, row.label_box.y}, row.label.c_str()); + // A row with no sample at this x has no value to write -- and it is *not* written as a + // zero or a dash: the box is empty and the number is simply absent (see CrosshairRow). + if (row.valid) stage_text({row.value_box.x, row.value_box.y}, row.value.c_str()); + } +} + // -------------------------------------------------------------------- dump bool Renderer::dump_frame_ppm(const char* path, Status* st) const {