From eee7c34dd59ba44601e02991a4deaa86e433fa0f Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 28 Sep 2026 20:17:01 +0200 Subject: [PATCH] feat(plot): the figure crosshair marks the hovered row, and the stroke is thin, dashed and configurable --- adapters/glfw/glfw_host.cpp | 11 +++++++---- include/plot/plot/crosshair.hpp | 30 ++++++++++++++++++++++++++++++ include/plot/plot/figure.hpp | 23 +++++++++++++++++++++++ include/plot/plot/plot.hpp | 11 +++++++++++ include/plot/render/program.hpp | 5 +++++ include/plot/render/renderer.hpp | 7 ++++++- include/plot/render/style.hpp | 15 +++++++++++++++ src/plot/figure.cpp | 16 ++++++++++++++++ src/plot/plot.cpp | 15 ++++++++++++++- src/render/program.cpp | 4 ++++ src/render/renderer.cpp | 17 +++++++++++++---- src/render/shaders.cpp | 24 ++++++++++++++++++++++-- 12 files changed, 166 insertions(+), 12 deletions(-) diff --git a/adapters/glfw/glfw_host.cpp b/adapters/glfw/glfw_host.cpp index 48c442a..46c56e7 100644 --- a/adapters/glfw/glfw_host.cpp +++ b/adapters/glfw/glfw_host.cpp @@ -494,12 +494,15 @@ void Host::sync_figure_crosshair(const std::vector& rows) noexcept { if (hovered >= row_count) return; } - // Applied to the whole figure: one x, every row. A row whose controller has *no* crosshair (the - // pointer left its axes, which is the only thing that clears one) clears the figure's, so the - // rows cannot be left showing a hover the pointer has abandoned. + // Applied to the whole figure: one x, every row; and the pointer row's **own** y, only to that + // row -- so the row under the pointer gets the one horizontal line, and no row ever shows + // another row's y (spec §17). The y is the row's own data coordinate, placed by its controller + // from the pointer's pixel row, and it is never spread to a sibling. A row whose controller has + // *no* crosshair (the pointer left its axes, which is the only thing that clears one) clears the + // figure's, so the rows cannot be left showing a hover the pointer has abandoned. const Plot& row = figure_->plot(hovered); if (row.crosshair().active) { - figure_->set_crosshair(row.crosshair().x, true); + figure_->set_crosshair(row.crosshair().x, hovered, row.crosshair().y, true); spread_x_ = row.crosshair().x; spread_active_ = true; } else { diff --git a/include/plot/plot/crosshair.hpp b/include/plot/plot/crosshair.hpp index 88396f1..f801667 100644 --- a/include/plot/plot/crosshair.hpp +++ b/include/plot/plot/crosshair.hpp @@ -9,6 +9,22 @@ namespace plot { +/// A per-`Crosshair` override of the three style knobs spec §17 adds, in **logical** +/// pixels, so one plot's crosshair can differ -- be thicker, or fatter-dashed -- without a +/// caller touching the global `render::Style` the rest of the frame draws with. +/// +/// A negative value means "not overridden": the field falls back to the matching +/// `Style::crosshair_*` metric, so a default-constructed `CrosshairStyle` (every field +/// `-1`) is exactly the global style and the common case costs nothing. `width`, `dash_on` +/// and `dash_off` are independent, so a caller may override only the dash, or only the +/// width, and leave the rest to the style. +struct CrosshairStyle { + f32 width = -1.0f; ///< logical stroke width; `< 0` takes `Style::crosshair_width`. + f32 dash_on = -1.0f; ///< logical ink length; `< 0` takes `Style::crosshair_dash_on`. + f32 dash_off = -1.0f; ///< logical gap; `< 0` takes `Style::crosshair_dash_off`, and `0` + ///< (once resolved) means solid. +}; + /// The crosshair's state: where it is, which of its two lines it draws, and whether the /// per-signal readout is wanted. /// @@ -46,6 +62,10 @@ struct Crosshair { /// 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; + /// This plot's own stroke and dash, when it wants to differ from the global style; a + /// default-constructed `CrosshairStyle` defers every field to `render::Style` (see + /// `CrosshairStyle`). Set through `Plot::set_crosshair_style()`. + CrosshairStyle style{}; }; /// The two samples of a uniform-grid series that a data `x` lies between: what the @@ -177,6 +197,16 @@ struct CrosshairLayout { /// 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 stroke width the two lines are drawn at, in **device** pixels: the crosshair's own + /// `CrosshairStyle::width` when it overrides it, else `Style::crosshair_width`, both through + /// `Style::device()` -- resolved here once, so the stroke boxes above and the renderer's + /// strokes are one number rather than two that must agree. + f32 stroke = 0.0f; + /// The dash pattern in **device** pixels: `x` the ink length, `y` the gap. `y <= 0` means + /// solid -- the state whenever no dash is asked for, which is what keeps the shared line + /// pipeline drawing every other stroke in the library unchanged. The dash is a fragment-shader + /// effect on `v_along`, so this is the only CPU-side change it makes (spec §17). + Vec2 dash{}; /// 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 diff --git a/include/plot/plot/figure.hpp b/include/plot/plot/figure.hpp index a3d0442..af1ce1f 100644 --- a/include/plot/plot/figure.hpp +++ b/include/plot/plot/figure.hpp @@ -161,10 +161,33 @@ class Figure { /// 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 host that *does* know which row the pointer is in -- every adapter does, from its own + /// pointer position -- uses the four-argument overload below instead, which adds the pointer + /// row's own horizontal line; this overload is for a caller that has only a coordinate. + /// /// 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); + /// Places the crosshair with the pointer's **row** and that row's local y (spec §17): the + /// shared x goes to every row -- vertical lines everywhere, as the plain overload does -- and + /// the horizontal line is drawn **only in `hovered_row`**, where its y is a value of *that* + /// row's own scale. It is the fix for the reported defect -- a figure showing no horizontal + /// line at all -- without inventing a figure-wide y, which would mean a different value in + /// every row: the line appears where the pointer is, and no row ever shows another row's y. + /// + /// `y_in_row` is a data coordinate on `hovered_row`'s y axis 0, exactly as `Crosshair::y`'s + /// two-coordinate placement is; a host reads it from the row's own y transform at the + /// pointer's pixel row. `hovered_row` outside `[0, row_count())` is treated as "no row under + /// the pointer": every row gets the vertical line and none gets a horizontal one, the same + /// state the single-coordinate overload produces. + /// + /// Refused, changing nothing, when `x` is not a number -- matching `set_crosshair(x, active)` + /// and `Plot::set_crosshair()`'s own rule. A non-finite `y_in_row` leaves `hovered_row` with + /// the vertical line only (the y is dropped, the hover is not), because a row with no valid y + /// is still a row the pointer is in. + void set_crosshair(f64 x, u32 hovered_row, f64 y_in_row, bool active); + /// Clears the crosshair on every row. A row that never had one is unaffected. void clear_crosshair(); diff --git a/include/plot/plot/plot.hpp b/include/plot/plot/plot.hpp index 3d3a785..44df6ad 100644 --- a/include/plot/plot/plot.hpp +++ b/include/plot/plot/plot.hpp @@ -591,6 +591,17 @@ class Plot { /// **not** part of `set_crosshair()`, so placing a hover cannot silently change it. void set_crosshair_readout(bool on) noexcept { crosshair_.show_readout = on; } + /// Overrides this plot's crosshair stroke and dash, in logical pixels, without touching the + /// global style every other plot draws with (spec §17): each field left negative in `s` + /// defers to the matching `Style::crosshair_*` metric, so `CrosshairStyle{}` (the default) + /// is exactly the global style. Set it once, like a colour -- it is not part of + /// `set_crosshair()`, so placing a hover cannot silently change how the stroke looks. A + /// `dash_off` that resolves to `0` draws a solid line. + void set_crosshair_style(const CrosshairStyle& s) noexcept { crosshair_.style = s; } + /// The crosshair's style override, as set -- read back rather than the *resolved* stroke and + /// dash, which live on `CrosshairLayout` because they depend on `ui_scale`. + const CrosshairStyle& crosshair_style() const noexcept { return crosshair_.style; } + /// 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. diff --git a/include/plot/render/program.hpp b/include/plot/render/program.hpp index 03e3400..8a1090f 100644 --- a/include/plot/render/program.hpp +++ b/include/plot/render/program.hpp @@ -27,6 +27,11 @@ class LineProgram { void set_viewport(Vec2 px) const noexcept; /// `u_width`: stroke width in pixels. void set_width(f32 px) const noexcept; + /// `u_dash`: the dash pattern in pixels -- `x` the ink length, `y` the gap, and a + /// non-positive `y` (the default a caller passes) means **solid**. It is a fragment-shader + /// discard on the interpolated `v_along`, so it costs no geometry, and it is the one uniform + /// that distinguishes the crosshair's stroke from every other line in the library. + void set_dash(Vec2 on_off) const noexcept; /// `u_color`: stroke colour, alpha included. void set_color(Color color) const noexcept; diff --git a/include/plot/render/renderer.hpp b/include/plot/render/renderer.hpp index 313e623..1182483 100644 --- a/include/plot/render/renderer.hpp +++ b/include/plot/render/renderer.hpp @@ -365,7 +365,12 @@ class Renderer { /// Uploads the staged line batch, maps it with (`scale`, `offset`), rasterises /// it in one colour and empties the batch. A no-op for an empty batch, so the /// caller can flush unconditionally. - void flush_lines(const Mapping& map, Color color, f32 width) noexcept; + /// + /// `dash` is the pattern in pixels for the fragment shader's discard: `x` the ink + /// length, `y` the gap, and a non-positive `y` -- the default every caller but the + /// crosshair passes -- means **solid**. It is set on every flush, so one draw's dash + /// can never leak into the next. + void flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash = Vec2{}) noexcept; /// Draws every staged glyph in `style().axis_text` and empties the batch. Text /// is texture-bound, so this runs once per frame, after all line geometry. void flush_text() noexcept; diff --git a/include/plot/render/style.hpp b/include/plot/render/style.hpp index 095c338..b8509d4 100644 --- a/include/plot/render/style.hpp +++ b/include/plot/render/style.hpp @@ -54,6 +54,21 @@ struct Style { /// already distinct, so no dashes. Color crosshair = Color::hex(0xE6EBF2); + /// The crosshair's stroke and dash, in logical pixels (spec §17). The crosshair is a + /// *passive* hover instrument drawn over the data, so its default **recedes**: a + /// thinner stroke than a trace's (`Trace::width`'s 1.5) so it reads as chrome rather + /// than as data, and a dash so it is distinguishable from the grid and the traces at + /// a glance. + /// + /// `crosshair_dash_on` is the ink length and `crosshair_dash_off` the gap; **`off == 0` + /// means solid**, which is the value every *other* line in the library is drawn at, so + /// the one pipeline serves them unchanged (the dash is a fragment-shader discard on + /// `v_along`, so no geometry is added). A `Crosshair` may override all three for one + /// plot without touching this global style. + f32 crosshair_width = 0.75f; + f32 crosshair_dash_on = 4.0f; + f32 crosshair_dash_off = 4.0f; + // --- the scale ---------------------------------------------------------- /// Device pixels per logical unit. 1.0 is the 1x display every default here was diff --git a/src/plot/figure.cpp b/src/plot/figure.cpp index 2035208..88dfa69 100644 --- a/src/plot/figure.cpp +++ b/src/plot/figure.cpp @@ -449,6 +449,22 @@ void Figure::set_crosshair(f64 x, bool active) { for (Plot& row : plots_) row.set_crosshair(x, active); } +void Figure::set_crosshair(f64 x, u32 hovered_row, f64 y_in_row, bool active) { + // The shared x first, on every row -- the same state the single-coordinate overload leaves, + // so the vertical lines and every row's readout are placed by one rule whether or not the + // pointer's row is known. A bad x is refused by the row call, which changes nothing, so the + // y below must not run either or the hover would be half-placed. + if (active && !std::isfinite(x)) return; + for (Plot& row : plots_) row.set_crosshair(x, active); + // Then the pointer row's own y: the *only* row that gets a horizontal line, because the y is + // a value on that row's scale and no other row's. A row that does not exist (the pointer is in + // the gap between rows) leaves every row vertical-only, which is the honest answer -- there is + // no row under the pointer to mark -- and a non-finite y leaves the row vertical-only rather + // than clearing a hover the pointer still has. + if (!active || hovered_row >= plots_.size()) return; + plots_[hovered_row].set_crosshair(x, y_in_row, true); +} + void Figure::clear_crosshair() { for (Plot& row : plots_) row.clear_crosshair(); } diff --git a/src/plot/plot.cpp b/src/plot/plot.cpp index 3472978..3905072 100644 --- a/src/plot/plot.cpp +++ b/src/plot/plot.cpp @@ -1265,6 +1265,8 @@ void Plot::build_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(); @@ -1286,7 +1288,18 @@ void Plot::build_crosshair() { // 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); + // + // 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 = diff --git a/src/render/program.cpp b/src/render/program.cpp index 1bb2745..8a70a7f 100644 --- a/src/render/program.cpp +++ b/src/render/program.cpp @@ -23,6 +23,10 @@ void LineProgram::set_viewport(Vec2 px) const noexcept { program_.set_uniform("u void LineProgram::set_width(f32 px) const noexcept { program_.set_uniform("u_width", px); } +void LineProgram::set_dash(Vec2 on_off) const noexcept { + program_.set_uniform("u_dash", on_off); +} + void LineProgram::set_color(Color color) const noexcept { program_.set_uniform("u_color", color); } bool TextProgram::create(Status* st) noexcept { diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index 4f7bad9..0532887 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -568,7 +568,7 @@ void Renderer::upload_text_vertices(std::span interleaved) noexcept { text_vao_.upload(interleaved.data(), static_cast(interleaved.size() / 6u)); } -void Renderer::flush_lines(const Mapping& map, Color color, f32 width) noexcept { +void Renderer::flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash) noexcept { const u32 segments = lines_.segment_count(); if (segments != 0 && ready_) { upload_vertices(lines_.data()); @@ -577,6 +577,9 @@ void Renderer::flush_lines(const Mapping& map, Color color, f32 width) noexcept line_program_.set_offset(map.offset); line_program_.set_viewport({viewport_.w, viewport_.h}); line_program_.set_width(width); + // Set on *every* flush, solid included, so the crosshair's dash cannot leak into the + // grid, a trace or a cursor drawn after it (see draw_crosshair_lines()). + line_program_.set_dash(dash); line_program_.set_color(color); line_vao_.bind(); // One triangle strip per instance, four corners from gl_VertexID. @@ -1192,25 +1195,31 @@ void Renderer::draw_crosshair_lines(const Plot& plot, const PlotLayout& layout) const CrosshairLayout& crosshair = layout.crosshair; if (crosshair.empty()) return; - const f32 stroke = style_.device(style_.line_width_px); + // The stroke and dash the layout already resolved from the crosshair's own override and the + // global style, in device pixels -- one source, so the boxes the layout held inside `axes` and + // the strokes drawn here cannot disagree (spec §17). + const f32 stroke = crosshair.stroke; // 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. + // + // The dash rides on the same segment: `v_along` is measured from the axes edge the line is + // staged from, so no geometry is added and the phase does not depend on the transform. 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); + flush_lines(data, style_.crosshair, stroke, crosshair.dash); } 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); + flush_lines(data, style_.crosshair, stroke, crosshair.dash); } // A small cross at each trace's intersection, in the trace's own colour -- opaque, like a diff --git a/src/render/shaders.cpp b/src/render/shaders.cpp index 63b0fa5..0bb1147 100644 --- a/src/render/shaders.cpp +++ b/src/render/shaders.cpp @@ -18,7 +18,12 @@ uniform vec2 u_viewport; // render target size, pixels uniform float u_width; // stroke width, pixels out float v_dist; // signed distance to the segment axis, pixels -out float v_along; // distance travelled from a_p0, pixels +// Distance travelled from a_p0, pixels -- and the dash's phase anchor. Both of the +// crosshair's lines are staged from an *axes edge* (its vertical from `y_range.min`, +// the axes' bottom; its horizontal from `x_range.min`, the axes' left), so a dash measured +// from here is measured from a fixed edge of the rect: it does not crawl as the view pans +// or zooms, and a stationary crosshair renders identically frame to frame. +out float v_along; void main() { vec2 p0 = a_p0 * u_scale + u_offset; @@ -52,14 +57,29 @@ void main() { )"; const char* const kLineFS = R"(#version 330 core -in float v_dist; // signed distance to the segment axis, pixels +in float v_dist; // signed distance to the segment axis, pixels +in float v_along; // distance travelled from a_p0, pixels uniform float u_width; // stroke width, pixels uniform vec4 u_color; +uniform vec2 u_dash; // dash pattern in pixels: x = ink, y = gap; y <= 0 is solid out vec4 o_color; void main() { + // The dash (spec §17): a fragment-shader discard, never new geometry -- the vertices + // stay in data space and the pattern is one per-draw uniform. `v_along` interpolates the + // distance from `a_p0` across the quad in pixels, so `mod` measures the pattern along the + // line rather than in screen space, which is what keeps it anchored to the segment while + // the view pans. `y <= 0` is solid, so every other stroke in the library -- grid, traces, + // axis lines, regions, cursors, the legend border -- draws unchanged by this uniform's + // default. + if (u_dash.y > 0.0) { + float period = u_dash.x + u_dash.y; + if (period > 0.0 && mod(v_along, period) >= u_dash.x) { + discard; // in the gap of the pattern + } + } float half_w = u_width * 0.5; float d = abs(v_dist); if (d > half_w + 1.0) {