diff --git a/adapters/glfw/glfw_host.cpp b/adapters/glfw/glfw_host.cpp index 02ec4ac..48c442a 100644 --- a/adapters/glfw/glfw_host.cpp +++ b/adapters/glfw/glfw_host.cpp @@ -212,15 +212,16 @@ bool Host::run(const RunConfig& cfg, const FrameFn& frame, Status* st) { // controller_at() or injected by a scripted run -- is hit-tested against the // geometry the last frame drew, and the two readers of a row's geometry cannot // disagree about it. + const std::vector& rows = figure_->layout().rows; // the one layout of this frame for (u32 i = 0; i < row_controllers_.size(); ++i) { - row_controllers_[i]->set_viewport(figure_->layout().rows[i]); + if (i < rows.size()) row_controllers_[i]->set_viewport(rows[i]); } // One hover, every row (spec §15): the crosshair the row controller under the pointer // placed is applied to the whole figure here, after the pointers of this frame have been // handled and before anything lays the rows out -- so every row's readout lists its own // signals at the one x the pointer is over. In the run loop rather than in a callback, so a // scripted run that injects its events straight into a row controller is synced too. - sync_figure_crosshair(); + sync_figure_crosshair(rows); } else { controller_.set_viewport(viewport); // also Plot::set_viewport, then layout() } @@ -430,20 +431,23 @@ input::Controller* Host::row_controller(u32 row) noexcept { input::Controller& Host::controller_at(f32 x, f32 y) noexcept { if (figure_ == nullptr || row_controllers_.empty()) return controller_; - const u32 row = row_at(x, y); - if (row >= row_controllers_.size()) return controller_; + // The figure's one layout for this call: both readers below take the row rects it produced, so an + // event lays the figure out once and not once per reader -- this is the hottest path a multi-row + // figure has, and a layout now rebuilds every row's ticks, grid and crosshair geometry. + const std::vector& rows = figure_->layout().rows; + if (rows.empty() || rows.size() != row_controllers_.size()) return controller_; + (void)x; // the rows tile the figure vertically, so y alone picks one -- see row_in() + const u32 row = row_in(rows, y); last_row_ = row; input::Controller& controller = *row_controllers_[row]; // The row's own viewport, so the geometry this controller hit-tests is the geometry // the last frame drew: the same rect `Figure::layout()` gave the row's plot. - controller.set_viewport(figure_->layout().rows[row]); + controller.set_viewport(rows[row]); return controller; } -u32 Host::row_at(f32 x, f32 y) const noexcept { - if (figure_ == nullptr || row_controllers_.empty()) return 0u; - const std::vector& rows = figure_->layout().rows; - if (rows.empty() || rows.size() != row_controllers_.size()) return 0u; +u32 Host::row_in(const std::vector& rows, f32 y) noexcept { + if (rows.empty()) return 0u; // The row the pixel is in, or -- when it is in none of them, which is the row gap a // host has asked for with `Figure::set_row_gap()` or the figure's own margins -- the // row whose rect is nearest. Selection by distance rather than by "the first that @@ -462,13 +466,12 @@ u32 Host::row_at(f32 x, f32 y) const noexcept { if (distance == 0.0f) break; // inside this row: nothing can be nearer } } - (void)x; // the rows tile the figure vertically, so y alone picks one return row; } -void Host::sync_figure_crosshair() noexcept { - const u32 rows = figure_ != nullptr ? figure_->row_count() : 0u; - if (rows == 0u || row_controllers_.empty()) return; +void Host::sync_figure_crosshair(const std::vector& rows) noexcept { + if (rows.empty() || row_controllers_.empty()) return; + const u32 row_count = static_cast(rows.size()); // Which row's crosshair is the hover. With a pointer of the host's own it is the row that pointer // is over -- the same rule events are routed by. A scripted run injects its events straight into @@ -477,18 +480,18 @@ void Host::sync_figure_crosshair() noexcept { // any row holding something else was changed from outside, and that is the one the run touched. // Nothing differing means every row still holds the last spread -- no new hover -- and the figure // keeps it. - u32 hovered = rows; + u32 hovered = row_count; if (have_pointer_) { - hovered = row_at(last_pointer_x_, last_pointer_y_); + hovered = row_in(rows, last_pointer_y_); } else { - for (u32 i = 0; i < rows; ++i) { + for (u32 i = 0; i < row_count; ++i) { const Crosshair& candidate = figure_->plot(i).crosshair(); if (candidate.active != spread_active_ || (candidate.active && candidate.x != spread_x_)) { hovered = i; break; } } - if (hovered >= rows) return; + if (hovered >= row_count) return; } // Applied to the whole figure: one x, every row. A row whose controller has *no* crosshair (the diff --git a/adapters/glfw/glfw_host.hpp b/adapters/glfw/glfw_host.hpp index cadab60..105219e 100644 --- a/adapters/glfw/glfw_host.hpp +++ b/adapters/glfw/glfw_host.hpp @@ -174,10 +174,11 @@ class Host { void on_scroll(f64 dx, f64 dy) noexcept; void on_key(int key, int action, int mods) noexcept; - /// The index of the figure row a framebuffer pixel belongs to, by the rule - /// `controller_at()` routes by: the row the pixel is in, or the nearest row when it is in none - /// (a row gap, or a margin outside the figure). 0 while no figure is set. - u32 row_at(f32 x, f32 y) const noexcept; + /// The index of the row of `rows` a framebuffer y belongs to: the row it is in, or the nearest + /// row when it is in none (a row gap, a margin outside the figure). One rule, shared by the event + /// routing and the crosshair sync -- and taking the rects rather than reading them, so a caller + /// that has just laid the figure out does not make it lay out again for this. + static u32 row_in(const std::vector& rows, f32 y) noexcept; /// Spreads the hover over a whole `Figure` (spec §15). /// @@ -193,8 +194,9 @@ class Host { /// whose controller has no crosshair clears the figure's. /// /// Called once per frame from the run loop, so a scripted run that bypasses the GLFW callbacks - /// gets the same synced figure a real pointer does. - void sync_figure_crosshair() noexcept; + /// gets the same synced figure a real pointer does. `rows` is the figure's row rects, which the + /// run loop has just read -- the one layout per frame this shares with the row viewports. + void sync_figure_crosshair(const std::vector& rows) noexcept; Plot plot_; input::Controller controller_; ///< forwards its viewport to `plot_` diff --git a/include/plot/plot/crosshair.hpp b/include/plot/plot/crosshair.hpp index 8fc4794..88396f1 100644 --- a/include/plot/plot/crosshair.hpp +++ b/include/plot/plot/crosshair.hpp @@ -130,8 +130,11 @@ struct CrosshairRow { 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`. + /// What the row's value column shows. For a `valid` row it is the formatted number, written in + /// the trace's y axis' own tick format (`Axis::format_value()`, so a readout reads exactly like + /// the labels beside it); for a row whose trace has **no sample at the crosshair's x** it is + /// `kCrosshairNoValue`, the em dash. The absence is therefore *legible* -- a blank cell reads as + /// "still loading" rather than as "nothing here" -- and nothing numeric is fabricated for it. 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 @@ -143,8 +146,10 @@ struct CrosshairRow { /// 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. + /// The value's line box, right-aligned on the panel's inner edge and one gutter's worth of + /// measurement away from the label's own box. Empty when `!listed`: a row that is not in the panel + /// stages neither run, while a listed row always stages something -- the number, or + /// `kCrosshairNoValue`. Rect value_box{}; }; @@ -185,6 +190,16 @@ struct CrosshairLayout { bool empty() const noexcept { return !active; } }; +/// What a listed readout row shows in its value column when its trace has **no sample at the +/// crosshair's x**: an em dash, not a blank. +/// +/// The row is listed either way -- the trace *is* under the pointer, and a panel that grew and +/// shrank with the pointer would be worse to read than one that says "nothing here" -- so the +/// absence has to be *said*: a blank value cell reads as a value that has not arrived yet. The em +/// dash is in the embedded font's coverage (spec §2 covers U+2013/U+2014), it is not a number, and +/// it cannot be mistaken for one. +inline constexpr const char* kCrosshairNoValue = "\u2014"; + /// The label an **unnamed** trace's readout row carries: `#` and the trace's position, e.g. /// `#2`. /// diff --git a/include/plot/plot/plot.hpp b/include/plot/plot/plot.hpp index b9a56b9..212e56a 100644 --- a/include/plot/plot/plot.hpp +++ b/include/plot/plot/plot.hpp @@ -567,6 +567,12 @@ class Plot { /// calls this when the pointer leaves the axes. void clear_crosshair() noexcept { crosshair_.active = false; } + /// Switches the readout panel off -- or back on -- without moving the crosshair: the lines and + /// the per-trace markers stay, the panel is not measured and no row is listed. A host that wants + /// the pointer's column marked but not a panel over its data sets this once; it is deliberately + /// **not** part of `set_crosshair()`, so placing a hover cannot silently change it. + void set_crosshair_readout(bool on) noexcept { crosshair_.show_readout = on; } + /// 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/style.hpp b/include/plot/render/style.hpp index fed6365..095c338 100644 --- a/include/plot/render/style.hpp +++ b/include/plot/render/style.hpp @@ -41,13 +41,18 @@ 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 crosshair's colour: its two lines and the readout panel's border (spec §15). Markers are + /// deliberately *not* this colour -- they carry each trace's own, because a marker says *which* + /// trace and the line says *where*. + /// + /// **Hue-neutral by design**, and that is the whole choice: a saturated colour here would be + /// confusable with a trace, and the library's own default trace colour is a saturated cyan + /// (`Trace`'s default, 0x40C0FF) that a hue-based reading cannot tell from a cyan crosshair -- + /// only lightness would separate them, which no reader can rely on. A near-white can never be a + /// saturated trace of *any* hue, and it is still plainly not `cursor`'s orange, which is what + /// the spec asks for. The 1 px stroke is kept: form (a full-height thin line over a polyline) is + /// already distinct, so no dashes. + Color crosshair = Color::hex(0xE6EBF2); // --- the scale ---------------------------------------------------------- diff --git a/src/plot/plot.cpp b/src/plot/plot.cpp index 0d7c13a..9739461 100644 --- a/src/plot/plot.cpp +++ b/src/plot/plot.cpp @@ -1305,9 +1305,10 @@ void Plot::build_crosshair() { 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. + // The value: the interpolated number where the trace has a sample 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. if (crosshair_value(i, crosshair_.x, row.y)) { row.valid = true; char value[32]; @@ -1319,6 +1320,8 @@ void Plot::build_crosshair() { // 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))}; + } else { + row.value.assign(kCrosshairNoValue); } } crosshair.rows.resize(count); @@ -1328,13 +1331,13 @@ void Plot::build_crosshair() { 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. + // 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) { - f32 content = text_->measure(row.label.c_str()); - if (row.valid) content += chrome.pad + text_->measure(row.value.c_str()); + 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)); @@ -1393,11 +1396,12 @@ void Plot::build_crosshair() { 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}; - } + // 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}; } } diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index c76edb7..4f7bad9 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -1190,7 +1190,7 @@ void Renderer::draw_swatch(const Plot& plot, u32 trace, const Rect& box) { void Renderer::draw_crosshair_lines(const Plot& plot, const PlotLayout& layout) { const CrosshairLayout& crosshair = layout.crosshair; - if (!crosshair.active) return; + if (crosshair.empty()) 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 @@ -1231,7 +1231,7 @@ void Renderer::draw_crosshair_lines(const Plot& plot, const PlotLayout& layout) void Renderer::draw_crosshair_readout(const Plot& plot, const PlotLayout& layout) { const CrosshairLayout& crosshair = layout.crosshair; - if (!crosshair.active) return; + if (crosshair.empty()) return; // The readout: the same backplate a legend has (the shared `draw_readout_panel()`), with its // border in the crosshair's own colour -- which is what makes the panel read as part of the @@ -1242,9 +1242,9 @@ void Renderer::draw_crosshair_readout(const Plot& plot, const PlotLayout& layout if (!row.listed) continue; draw_swatch(plot, row.trace, row.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()); + // The value is a number, or `kCrosshairNoValue` for a trace with no sample at this x (the plot + // put it there): either way there is a run to stage, so a row is never a blank cell. + stage_text({row.value_box.x, row.value_box.y}, row.value.c_str()); } } diff --git a/tests/test_figure.cpp b/tests/test_figure.cpp index 56d9e78..a5ad7bf 100644 --- a/tests/test_figure.cpp +++ b/tests/test_figure.cpp @@ -650,16 +650,21 @@ PLOT_TEST(figure_crosshair_applies_one_x_to_every_row) { PLOT_CHECK(third.panel.w == 0.0f); // Row 3's series starts at x = 100: it is a visible signal with nothing at x = 3, so it is - // listed with **no value** rather than a value clamped to the end of its window. + // listed with **no number** rather than one clamped to the end of its window -- and the absence + // is said out loud with the documented placeholder, not left as a blank cell that would read as a + // value still to come. const plot::CrosshairLayout& fourth = figure.plot(3u).layout().crosshair; PLOT_CHECK(fourth.rows.size() == 1u); PLOT_CHECK(fourth.rows[0].trace == 0u); PLOT_CHECK(fourth.rows[0].label == "elsewhere"); PLOT_CHECK(!fourth.rows[0].valid); - PLOT_CHECK(fourth.rows[0].value.empty()); - // ... and it is still listed, so the row's presence does not depend on having a value. + PLOT_CHECK(fourth.rows[0].value == plot::kCrosshairNoValue); + PLOT_CHECK(fourth.rows[0].marker.x == 0.0f && fourth.rows[0].marker.y == 0.0f); // no intersection + // ... and it is still listed, with a value column, so the row's presence and its shape do not + // depend on having a sample there. PLOT_CHECK(fourth.rows[0].listed); - PLOT_CHECK(fourth.rows[0].value_box.w == 0.0f); + PLOT_CHECK(fourth.rows[0].value_box.w > 0.0f); + PLOT_CHECK(fourth.rows[0].value_box.right() <= fourth.panel.right()); // The crosshair is a data coordinate, so the x is the *same* whether or not the rows sample // there -- moving it moves every row, and the rows that have data report their own value at diff --git a/tests/test_plot.cpp b/tests/test_plot.cpp index 70cac48..4be898a 100644 --- a/tests/test_plot.cpp +++ b/tests/test_plot.cpp @@ -2038,12 +2038,41 @@ PLOT_TEST(crosshair_readout_lists_every_visible_trace_including_unnamed_ones) { PLOT_CHECK(cleared.panel.w == 0.0f && cleared.panel.h == 0.0f); PLOT_CHECK(cleared.vertical.w == 0.0f && cleared.horizontal.h == 0.0f); - // A crosshair with the readout switched off still draws its lines -- and lists nothing. - scene.plot.set_crosshair(2.0, true); - plot::Crosshair no_readout = scene.plot.crosshair(); - PLOT_CHECK(no_readout.active); - PLOT_CHECK(!no_readout.horizontal); // the one-coordinate form draws no row - PLOT_CHECK(no_readout.show_readout); + // A crosshair whose x is outside every trace's window: the row is still listed -- the trace is + // one of the signals under the pointer -- and says so with the documented placeholder rather than + // a blank cell or a fabricated number. + scene.plot.set_crosshair(10.0, true); // past the last sample of the 4-sample series + const plot::CrosshairLayout& past_the_end = scene.plot.layout().crosshair; + PLOT_CHECK(past_the_end.rows.size() == 2u); // the two visible traces, both with no sample here + PLOT_CHECK(!past_the_end.rows[0].valid); + PLOT_CHECK(past_the_end.rows[0].value == plot::kCrosshairNoValue); + PLOT_CHECK(!past_the_end.rows[0].value.empty()); + PLOT_CHECK(past_the_end.rows[0].value_box.w > 0.0f); // the dash has a box of its own + PLOT_CHECK(past_the_end.rows[0].listed); + PLOT_CHECK(past_the_end.rows[0].value_box.w <= past_the_end.panel.w); + + // The readout switch, and the reason it is a switch rather than a field nobody can reach: with it + // off the crosshair itself is untouched -- the lines are still placed and the trace is still a + // row -- while nothing is listed in a panel, so a host that wants the pointer's column marked and + // not a panel over its data can say so. Switching it back on lists the rows again. + scene.plot.set_crosshair(1.5, true); + scene.plot.set_crosshair_readout(false); + const plot::CrosshairLayout& off = scene.plot.layout().crosshair; + PLOT_CHECK(off.active); + PLOT_CHECK(off.vertical.w > 0.0f); // the switch is the readout's: the lines are untouched + PLOT_CHECK(off.rows.size() == 2u); // both traces are still there, with their markers + for (const plot::CrosshairRow& row : off.rows) { + PLOT_CHECK(!row.listed); // ... and neither is a line in a panel + PLOT_CHECK(row.value_box.w == 0.0f); + PLOT_CHECK(row.marker.x > 0.0f); // the marker is placed either way + } + PLOT_CHECK(off.panel.w == 0.0f && off.panel.h == 0.0f); + scene.plot.set_crosshair_readout(true); + const plot::CrosshairLayout& on = scene.plot.layout().crosshair; + PLOT_CHECK(on.rows[0].listed); + PLOT_CHECK(on.rows[1].listed); + PLOT_CHECK(on.panel.w > 0.0f && on.panel.h > 0.0f); + PLOT_CHECK(on.rows[0].value == "15"); } /// Spec §15, the panel's placement: near the pointer, flipped over it where it would otherwise diff --git a/tests/test_render_smoke.cpp b/tests/test_render_smoke.cpp index bd652cb..5ec7818 100644 --- a/tests/test_render_smoke.cpp +++ b/tests/test_render_smoke.cpp @@ -4575,18 +4575,54 @@ PLOT_TEST(horizontal_ruler_stays_inside_the_axes_at_the_edges) { namespace { -/// The crosshair's own colour (0xB8E8FF) at real coverage: a pale cyan, green clearly above red -/// and blue above green, which no other element of the frames this case draws produces. The two -/// traces here are magenta and green -- deliberately *not* blue, which is the one other colour -/// that would satisfy "blue above green and green above red" (0x40C0FF does). The axis text -/// (0xC8D0DC) is grey enough to fail the green-over-red term, the chrome and the grid are darker -/// still, and the same hue at lower coverage still passes, so a partially covered stroke counts -/// as ink rather than the test needing a fully covered pixel to exist. +/// The crosshair's own ink: **bright and hue-neutral**, which is the property the default colour +/// has on purpose (see `Style::crosshair`) so that it can never be read as a trace of any hue. +/// This predicate is therefore about brightness and chroma rather than about a hue, and it needs no +/// "not this trace's colour" exception: every other element of these frames is either dark (the +/// background, the grid), saturated (the magenta, green and orange traces) or dimmer than the +/// crosshair (the axis text 0xC8D0DC, whose red channel is 200 against the crosshair's 230). bool crosshair_pixel(const plot::u8 px[4]) { - return px[1] > px[0] + 25 && px[2] > px[1] + 12 && px[2] > 120; + const int lo = std::min(px[0], std::min(px[1], px[2])); + const int hi = std::max(px[0], std::max(px[1], px[2])); + return px[0] > 215 && hi - lo <= 24; } } // namespace + +/// The crosshair's colour has to be tellable from a trace, and telling colours apart by hue is the +/// one thing a reader cannot be asked to do at a glance: the library's own default trace colour is a +/// saturated cyan, so a saturated cyan crosshair would differ from it in lightness alone. The +/// default is therefore near-white -- a colour no saturated trace of any hue can be -- and still +/// plainly not the cursor accent. Both halves are asserted, so a future recolouring that makes it +/// confusable with a trace or with a handle fails here rather than in a screenshot. +PLOT_TEST(crosshair_colour_cannot_be_mistaken_for_a_trace) { + const plot::render::Style style; + const plot::Trace default_trace; + // `Color` stores normalised channels; the comparisons below are in 0..255, like the hex literals + // the colours are written with. + const auto chan = [](plot::f32 c) { return static_cast(c * 255.0f + 0.5f); }; + const int lo = std::min({chan(style.crosshair.r), chan(style.crosshair.g), chan(style.crosshair.b)}); + const int hi = std::max({chan(style.crosshair.r), chan(style.crosshair.g), chan(style.crosshair.b)}); + // Hue-neutral: its channels are within a few levels of each other, so it is a grey/white and not + // a hue at all. A saturated colour -- the default trace's cyan included -- is 150+ levels apart. + PLOT_CHECK(hi - lo <= 24); + PLOT_CHECK(lo > 200); // bright chrome, not a mid-tone that a dimmed trace could reach + // `Color` is a plain channel struct with no comparison of its own, so the two "not this colour" + // assertions spell the channels out. + const auto same = [](plot::Color a, plot::Color b) { + return a.r == b.r && a.g == b.g && a.b == b.b; + }; + PLOT_CHECK(!same(style.crosshair, default_trace.color)); + PLOT_CHECK(!same(style.crosshair, style.cursor)); + // ... and a saturated trace of any hue is out of reach for it by construction: the *most* + // chromatic colour the crosshair could be confused with has to be within the chroma bound above, + // and every trace colour in this library's demos is far outside it. + const plot::Color demo_traces[3] = {plot::Color::hex(0x40C0FF), plot::Color::hex(0xFFB020), + plot::Color::hex(0x35D0A8)}; + for (const plot::Color trace : demo_traces) { + PLOT_CHECK(!same(style.crosshair, trace)); + } +} namespace { /// A third colour predicate for the lateral case below: the orange 0xFFB020 of a trace of its