From 39f30d913f64fa86603a7a9b428e3723ae00a402 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 5 Oct 2026 18:21:28 +0200 Subject: [PATCH] fix: the t0 caption is chrome anchored to the axis, not an annotation in the data The caption that names the relative x labels' anchor was an `Annotation`, whose anchor is a data coordinate -- so it crawled across the plot under a pan and drifted under a zoom, which is how it was reported. It is now `Plot::set_x_note()`: the bottom row's caption, drawn by the renderer in the axes' bottom-left corner in pixels, on the legend's own backplate. The text stays the host's, because only the host knows that label + caption has to be the exact absolute time (`%.6g`, where `format_time()` would round to three figures). Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 15 +++++++++++++++ README.md | 12 +++++++++--- deps/plotter | 2 +- src/plot_host.cpp | 43 +++++++++++++++++-------------------------- src/plot_host.hpp | 5 ----- src/ui.cpp | 15 +++++---------- 6 files changed, 47 insertions(+), 45 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index df31fde..291b976 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1437,6 +1437,21 @@ far the largest file. is exactly enough), so the bare comparison picked a different form from one frame to the next. The guard is `[timelabels-probe]`'s `distinct=1`, which asserts what the form is *for* rather than which form a given window gets. +- **The `t0 = …` caption is chrome, never an annotation.** It is `Plot::set_x_note()` (added to + `deps/plotter` for this), drawn by `Renderer::draw_x_note()` on the legend's own + `draw_readout_panel()` backplate in the bottom-left corner of the axes, in **pixels**. It was an + `Annotation` first, and an annotation's anchor is a *data* coordinate: the caption therefore + crawled across the plot under a pan and drifted under a zoom, which is what "[t0=...s] move + slightly around when user zoom/pan" was. Three things follow. The **text is the host's**, because + the caption has to name the anchor precisely enough that label + caption is the exact absolute + time and `format_time()` would round it to three figures (`"t0 = %.6g s"`); only the **bottom + row** of the figure carries one, every other row being set to `""`; and the renderer measures it + rather than `Plot::layout()` — it reserves no space and nothing can be picked on it, so there is + no rect to read back, and a caption explaining the x labels must not be able to move the layout + those labels were measured into. The guards are the library's + `the_x_note_is_chrome_that_a_pan_or_a_zoom_does_not_move` (the footprint is the pixels a frame + with the caption differs from one without, and a pan plus a 64× zoom must give the same box to + the pixel) and `[timelabels-probe]`'s `note=`, which reads `Plot::x_note()` back. - **`fit_x()` restores the *whole of what the view may show*, which is not always `window_s_`.** Live, that is the newest `window_s_` at the live edge (`set_time_window(window_s_)`, the same path a resume takes). Pinned — a held event, a filling capture or a paused view — it is the diff --git a/README.md b/README.md index 8d34333..5f2b5bd 100644 --- a/README.md +++ b/README.md @@ -95,9 +95,15 @@ feedback loop), the magnitude is the larger of the two endpoints, and the compar 1.05 band leaving the relative form plus a ULP of slack entering it — a step *equal* to the resolution is readable (`1.73s`, `1.74s`, `1.75s`: one in the last digit is exactly enough), so a decision taken on the last bits of two differently computed doubles picked a different form from -one frame to the next. The caption is an annotation -inside the axes rather than the axis title on purpose: its text changes with the window, and a -title of a changing width would relayout the plot every frame. Everything a user copies out — +one frame to the next. The caption is **chrome** — `Plot::set_x_note()`, added to the plotter for +it, drawn on the legend's own backplate in the bottom-left corner of the axes — rather than the +axis title or an annotation. Not the title, because its text changes with the window and a title +of a changing width would relayout the plot every frame; not an annotation, because an +annotation's anchor is a *data* coordinate, so the caption crawled across the plot under a pan and +drifted under a zoom, which is what it was reported as. The library test is +`the_x_note_is_chrome_that_a_pan_or_a_zoom_does_not_move`: the footprint is measured as the pixels +a frame with the caption differs from the same frame without one, and a pan to another decade plus +a 64× zoom must produce the same box to the pixel. Everything a user copies out — the cursors' `T0`/`T1`/`dT`, a measurement, an export — stays in absolute stream seconds. The anchor is taken by the *axis* on every layout (`Axis::origin_follows_range`), not written diff --git a/deps/plotter b/deps/plotter index 03c41ad..59c2e45 160000 --- a/deps/plotter +++ b/deps/plotter @@ -1 +1 @@ -Subproject commit 03c41adbec42516f133035557227d082fb2ab188 +Subproject commit 59c2e45af7428e1357180ef39185bccb3f422d57 diff --git a/src/plot_host.cpp b/src/plot_host.cpp index 0a481c9..f7c2298 100644 --- a/src/plot_host.cpp +++ b/src/plot_host.cpp @@ -30,8 +30,6 @@ constexpr plot::u32 kRulerBColor = 0x8FA8C8u; /// The measurements' own colour, distinct from both cursor rulers so a readout mark cannot be /// mistaken for something the user placed. constexpr plot::u32 kMeasureColor = 0x9BE8A0u; -/// The window-start caption's colour: quiet chrome, brighter than a grid line. -constexpr plot::u32 kTimeNoteColor = 0xC8C8C8u; constexpr plot::u32 kTriggerColor = 0xFF5B5Bu; /// The smallest difference a `TickFormat::Time` label can *write* at the magnitude `t` -- what @@ -170,11 +168,6 @@ void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) { r.plot->set_cursor_stroke(r.measure_v[mi], kMeasureStrokeWidth, plot::Vec2{kMeasureDashOn, kMeasureDashOff}); } - // The window-start caption for the relative x labels: an annotation, hidden until the - // labels are relative. Inside the axes on purpose -- its text changes with the window, and - // an axis title of a changing width would relayout the plot every frame. - r.time_origin_note = r.plot->add_annotation( - plot::Annotation{0.0, 0.0, "", plot::Color::hex(kTimeNoteColor), r.axis_x, r.axis_y, false}); r.trigger_marker = r.plot->add_cursor(kParked, plot::Color::hex(kTriggerColor)); // A marker, not an instrument: it must sit exactly on the edge's time, so its // snap is Free and the controller never rounds it onto a sample. @@ -581,27 +574,25 @@ bool RowSet::update_time_labels() { r.plot->set_x_axis_origin_from_range(r.axis_x, relative_x_); if (!relative_x_) r.plot->set_x_axis_origin(r.axis_x, 0.0); // The caption belongs with the labels, which a figure draws once, on the bottom row (the - // one whose x axis is the figure's). Every row gets it parked. + // one whose x axis is the figure's). Every other row gets an empty one, which draws nothing. const bool bottom = i == rows_ - 1; - if (r.time_origin_note >= r.plot->annotation_count()) continue; - plot::Annotation note = r.plot->annotation(r.time_origin_note); - note.visible = relative_x_ && bottom; - if (note.visible) { - // Inside the axes, just above the axis line, at their left edge: the reader adds this - // to a tick, so it sits beside the ticks. - const plot::Range yr = r.plot->y_range(r.axis_y); - note.x = view.min; - note.y = yr.min + 0.04 * (yr.span() > 0.0 ? yr.span() : 1.0); - char text[64]; - // The anchor the *drawn* labels were built with: the axis' own origin, which the last - // layout set from its range. Reading it back (rather than keeping our own copy) is what - // makes the reader's addition exact -- label plus caption is that tick's absolute time. - const double shown = r.plot->x_axis_origin(r.axis_x); - x_origin_ = shown; - std::snprintf(text, sizeof(text), "t0 = %.6g s", shown); - note.text = text; + if (!relative_x_ || !bottom) { + r.plot->set_x_note(""); + continue; } - r.plot->set_annotation(r.time_origin_note, note); + char text[64]; + // The anchor the *drawn* labels were built with: the axis' own origin, which the last + // layout set from its range. Reading it back (rather than keeping our own copy) is what + // makes the reader's addition exact -- label plus caption is that tick's absolute time. + const double shown = r.plot->x_axis_origin(r.axis_x); + x_origin_ = shown; + std::snprintf(text, sizeof(text), "t0 = %.6g s", shown); + // Chrome, not an annotation: an annotation is anchored to a *data* coordinate, so the caption + // crawled across the plot as the view panned and drifted as it zoomed -- and it came with the + // annotation's own pin cross, which pointed at nothing. `set_x_note()` places it in the axes' + // bottom-left corner in pixels, on the legend's backplate, which is where the labels it + // qualifies are. + r.plot->set_x_note(text); } return changed; } diff --git a/src/plot_host.hpp b/src/plot_host.hpp index 50e5e0c..f9f77fa 100644 --- a/src/plot_host.hpp +++ b/src/plot_host.hpp @@ -106,11 +106,6 @@ class RowSet { /// it keeps its position whatever the cursor toggle says. plot::u32 trigger_marker = 0; - /// The caption naming the window's start while the x labels are relative: an annotation - /// inside the axes, so its text can change every frame without moving the layout (an axis - /// *title* would, and it would be a different width each time). - plot::u32 time_origin_note = 0; - /// The measurements' readout marks: a pool created with the plot (a `Plot` cannot add or /// drop a cursor after the fact) and parked unless a measurement is using the slot. std::array measure_h{}; diff --git a/src/ui.cpp b/src/ui.cpp index e83329a..3771105 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -7264,10 +7264,7 @@ gboolean timelabels_probe_step(gpointer user) { ticks += t.label; } } - if (last.time_origin_note < last.plot->annotation_count()) { - const plot::Annotation& an = last.plot->annotation(last.time_origin_note); - if (an.visible) note = an.text; - } + note = last.plot->x_note(); } double first_value = 0.0; plot::Range shown{}; @@ -8317,14 +8314,12 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit } { // The x label form: relative to the window's own start (with the caption that names - // that start) while the span is sub-second, absolute otherwise. `x_origin_set` is read - // back out of the plot, so this is what the axis is actually formatting with. + // that start) when the labels could not carry the tick step absolutely, absolute + // otherwise. `x_origin_set` is read back out of the plot, so this is what the axis is + // actually formatting with, and `note` is the plot's own caption string. const RowSet::Row& rlt = rows.row(rows.row_count() - 1); std::string note; - if (rlt.plot != nullptr && rlt.time_origin_note < rlt.plot->annotation_count()) { - const plot::Annotation& a = rlt.plot->annotation(rlt.time_origin_note); - if (a.visible) note = a.text; - } + if (rlt.plot != nullptr) note = rlt.plot->x_note(); // The labels themselves, from the axis the reader looks at: the assertion is what the // ticks *say*, not only which mode is on. std::string ticks;