3 Commits
Author SHA1 Message Date
Martino FerrariandClaude Opus 4.6 2f4530c128 fix: a zoomed trace is drawn on the x its readout quotes
gather() staged x as f32 relative to the series' own x0() -- the start of
the whole retained window -- so the quantisation was a fraction of the
ring rather than of the view: 1.9 us at 20 s of retained data, which at a
10 us view across a 716 px axis is over 100 px between where a sample is
drawn and the x a readout quotes for it (crosshair_point() reads x_at()
in exact f64). It halved on every zoom out, which is what made it read as
"zooming in makes the crosshair select a point beside the data".

The origin is now the caller's (plotter c2c8c59): the renderer passes the
visible range's minimum, so the error is a fixed 2^-24 of what is on
screen -- about 4e-5 px over a 700 px axis -- at any zoom. The parameter
sits before out_xy so every call site is a compile error rather than a
silent change of behaviour.

The renderer no longer reads series.x0() at paint time at all, which
removes two of the three symptoms of the torn window_x0_ read by
construction; the ink probe's now-vacuous kept_fixed_x0 walk goes with
them, replaced by a drawn-vs-quoted pixel figure. neighbours() still
tests the visible range against x_at(0), so the cache stays load-bearing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-05 18:49:54 +02:00
Martino FerrariandClaude Opus 4.6 39f30d913f 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 <noreply@anthropic.com>
2026-10-05 18:21:28 +02:00
Martino FerrariandClaude Opus 4.6 e2985aa159 fix: the x labels go relative on what a label can write, not on the span
A `t0 = ...` caption appeared on every sub-second window, including a
triggered one whose axis is already relative to the edge -- so the ticks
were re-anchored away from the one position a triggered axis exists to
show, and the caption had nothing to explain.

The form is now decided by the tick step against `TickFormat::Time`'s own
resolution at the axis' magnitude (three significant figures below a
minute, whole seconds through `m:ss` to a day, three figures of days
above). The span said nothing about that and was wrong both ways: a 2 s
window at twenty minutes of uptime reads `20:00` on every tick and stayed
absolute.

The step is read back from the last layout's majors (a tick value depends
on the range and the axis width, not on the label form); with no ticks yet
the form is left alone rather than guessed, since the hysteresis would
latch a guess. Entering the relative form carries a ULP of slack, because
`step == res` is readable and a bare comparison of two differently
computed doubles flipped the form between frames.

`[timelabels-probe]` and the dump gain `distinct=`, which asserts that no
two major labels read the same -- the thing the relative form is for, and
the only reading that holds whatever the stream's uptime is.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-05 18:01:41 +02:00
9 changed files with 366 additions and 165 deletions

No files matched your search

+108 -27
View File
@@ -435,20 +435,31 @@ Two lines of the self-check output are assertions in disguise:
*not* every channel, and never a stream's timestamp signal, which is what an acquired-but-
unplotted list put first and auto-selected), and `drop_h` must be a combo box's height
(tens of pixels — a scrolled-window wrapper asked it to grow to 160).
- `[timelabels-probe] <action> relative=D window=.. origin=.. note=[..] ticks=[..]
first_value=.. first_rel=.. range=[..]` + the dump's `time labels: relative=D origin=..
note=[..] x_origin_set=.. ticks=[..] first_rel=..` (stderr,
- `[timelabels-probe] <action> relative=D distinct=D window=.. origin=.. note=[..] ticks=[..]
first_value=.. first_rel=.. range=[..]` + the dump's `time labels: relative=D distinct=D
origin=.. note=[..] x_origin_set=.. ticks=[..] first_rel=..` (stderr,
`UDPSCOPE_PROBE_TIMELABELS_MS`) — the x label form across a zoom, driven through
`apply_time_window()`: **under the figure's x link group the group owns the window, so a
row-level `set_time_window()` does nothing** (a probe that changes the window must use the
app's path). Absolute at 2 s and at 500 ms, `relative=1` below a second, and the caption
must be non-empty exactly then. The probe *races the paint* — it reads the axis on a timer,
app's path). **`distinct` must be 1 on every line, and that is the assertion** — it says no two
major labels on the axis are the same string, which is the one thing the relative form exists to
buy and the only reading that holds whatever the stream's uptime is. `relative=D` deliberately
is *not* fixed per window: the form is decided by the tick step against the label resolution at
the axis' **magnitude** (see the invariant below), so the same 50 ms window reads absolute at
2 s of uptime (`2.13s, 2.14s, 2.15s`) and relative at 17 s (`100µs, 10.1ms, 20.1ms`, with the
caption) — both correct, and a probe pinned to one of them would assert the stream's clock
rather than the app. The **caption must be non-empty exactly when `relative=1`**. Run it after
`UDPSCOPE_ARM_TRIGGER_MS` as well, which is the case the span rule got wrong: a triggered 50 ms
window must read `relative=0 note=[] ticks=[-20ms,-10ms,0,10ms]` — a `t0` caption on an axis
that is already edge-relative re-anchors the ticks away from the trigger, which is the one
position the axis exists to show. The probe *races the paint* — it reads the axis on a timer,
and the ticks it sees are the previous layout's — so its `first_rel` (the first major minus
the axis' origin) is a fraction of a step; the dump runs after the last paint and is the
race-free reading. Either way the assertion that matters is the *sum*: `first_value` must
equal `origin + first_rel` as a *tick label* plus its caption, i.e. the labels and the
caption must share one anchor. `x_origin_set` is read back out of the plot; it must be 0 in
the absolute form.
the absolute form. Note the last two steps both report `window=0.05`: `RowSet::set_time_window()`
floors the window there, so the 20 ms step is clamped.
- `[fit-probe] <action> pinned=D window=.. event_span=.. x=[..] span=.. y=[..]` (stderr,
`UDPSCOPE_PROBE_FIT_MS`) — the two fits, through the **menu handlers** rather than the bare
`RowSet` calls, because the notice and the control are the handler's half. A fit is the **undo
@@ -597,10 +608,12 @@ Two lines of the self-check output are assertions in disguise:
lines and the disarm probe's `axis=`/`snap=` need a fast source to say anything —
`tools/mock_udpstreamer.py --rate 1300000 --batch 20000` — since at the default 10 kS/s a
whole window fits in the raw path and the publish is too short to lose a race.
- `trace ink: N paints checked, C collapsed, S staircase, H short, worst cover P%` plus a
- `trace ink: N paints checked, C collapsed, S staircase, H short, worst cover P%, worst
drawn-vs-quoted D px over R raw paints` plus a
`[ink] paint N <flags> mode=.. axis=[..] n=.. idx=[..) spp=.. level=.. pts=.. kept=..
ink=[..] cover=..% x0=.. x_at=[..] bounds=[..] rel=[..] dx=.. kept_fixed_x0=.. drops=[..]
back=..` line per anomalous frame (stderr) — **`collapsed` and `staircase` must both be 0**.
ink=[..] cover=..% x0=.. x_at=[..] bounds=[..] rel=[..] dx=.. drops=[..]
back=..` line per anomalous frame (stderr) — **`collapsed` and `staircase` must both be 0**,
and **`drawn-vs-quoted` must stay far under a pixel** (0.0000 is what a clean run reads).
This is the only check that can see a frame whose *waveform* vanished: `staged_segments`
counts geometry including the grid and the instruments, and `lod_traces` counts a trace whose
points are then collapsed, so both read healthy through the flicker. The probe runs on every
@@ -610,11 +623,22 @@ Two lines of the self-check output are assertions in disguise:
`collapsed` is fewer than two distinct positions left (nothing to draw at all), `staircase`
is the filter dropping over three quarters of the points, `short` is ink reaching under a
quarter of the axis (reported only once the data spans the window, since a filling ring is
*meant* to leave its left side empty). `kept_fixed_x0` is the same filter with `x0()` hoisted
out of the loop: it exceeding `kept` means `x0()` moved *during* the walk. **`back` ≈ one ring
span is the signature** of the torn `x0()` read (see the cached-first-stamp invariant); so is
an `ink=` range disjoint from `axis=`, or an `idx=` covering the whole ring with `cover` far
over 100%. It is a race, so it needs load and a reason for long frames —
*meant* to leave its left side empty). `drawn-vs-quoted` is the other reading, and it is the
one that names a *defect of position* rather than of presence: at a raw level a gathered pair
**is** sample `first + k`, so the line differences where a sample is drawn
(`axis.min + gather[2k]`, the renderer's own reconstruction) against the x a readout quotes
for it (`x_at()`, exact f64) and reports the worst of them in pixels. That is the
gather-origin invariant below read from the screen's side; it was over a hundred pixels at a
deep zoom before the origin became the caller's. It is measured on the raw levels only — above
them a gathered point is a bucket's envelope and has no single sample to be off from — so `R`
is legitimately below `N`. **`back` ≈ one ring span is the signature** of the torn `x0()` read
(see the cached-first-stamp invariant); so is an `ink=` range disjoint from `axis=`, or an
`idx=` covering the whole ring with `cover` far over 100%. There was a `kept_fixed_x0` field
beside `kept` — the same filter with `x0()` hoisted out of the loop, since the renderer used to
re-read `x0()` per point and a ring-backed one advances with the producer. The origin is a
local of the renderer's now, so that failure mode is structurally impossible and the field is
gone; the `[ink]` walk and the renderer still agree because both add `axis.min`. It is a race,
so it needs load and a reason for long frames —
`tools/mock_udpstreamer.py --rate 1300000 --batch 2000 --clock-jitter 0.4` with the
measurements *and* cursors panes up:
`UDPSCOPE_ENABLE_ALL_MS=1000 UDPSCOPE_PROBE_MEASURES_MS=2000 UDPSCOPE_PROBE_CURSORS_MS=3500
@@ -935,15 +959,32 @@ far the largest file.
`gather()`, `x_at()`, `neighbours()` and `x_bounds()` use the real time ring.
Nothing may walk that grid to find *which* samples a range covers — the renderer did,
and it lost the two end segments (see the margin invariant above). `gather()` emits x
relative to `x0()` because absolute epoch seconds do not survive `f32`.
- **`gather()` emits x relative to `x0()` in *every* series here**, `EventSeries`
included, and the renderer adds `x0()` back. An event's stamps are small (edge
relative, `[-pre, +post]`), so emitting them absolute does not overflow anything and
does not look like a bug — it just draws the whole capture `x0()` = one pre-trigger
window to the left of the axis. That was the "half the plot is empty / the marker sits
at the end of the data / nothing is drawn but the crosshair reads values" defect: the
crosshair reads `x_at()`, which was right all along. The probe's `data=` vs `drawn=`
is the guard.
relative to an origin because absolute epoch seconds do not survive `f32`.
- **`gather()` emits x relative to an origin *the caller names*, in every series here**,
`EventSeries` included, and the consumer adds that same origin back in `f64`. Two separate
rules live here and both were learned from a defect.
(1) **It must be relative at all.** An event's stamps are small (edge relative,
`[-pre, +post]`), so emitting them absolute does not overflow anything and does not look
like a bug — it just draws the whole capture `x0()` = one pre-trigger window to the left of
the axis. That was the "half the plot is empty / the marker sits at the end of the data /
nothing is drawn but the crosshair reads values" defect: the crosshair reads `x_at()`, which
was right all along. `[trig-probe]`'s `data=` vs `drawn=` is the guard.
(2) **The origin is the *view's* minimum, not `x0()`.** `x0()` is the start of the
**retained window**, so the f32 had to resolve 2^-24 of the whole ring however little of it
was on screen: 1.9 µs over 20 s of retained data, which at a view 10 µs wide across a 716 px
axis is over a hundred pixels between where a sample is drawn and the x `x_at()` quotes for
it — and it halved on every zoom out, so it read as "zooming in makes the crosshair select a
point beside the data". `Renderer::draw_traces` passes `x_range.min`, making the error a fixed
4e-5 of a pixel at any zoom. The parameter sits **before** `out_xy` deliberately, so a call
site that was not thought about is a compile error rather than silent drift. A caller that has
no view in mind may still pass `x0()`, and `[trig-probe]`'s `drawn=`/`data=` does exactly that
on purpose: it reads the series' *whole* span, which is the widest relative value a gather can
be asked to stage. The two guards are upstream's
`a_gather_origin_at_the_view_keeps_a_deep_zoom_on_the_sample_it_quotes` and the
`trace ink:` line's `drawn-vs-quoted` figure.
**A probe that recomputes what the renderer gathers must pass the same origin the renderer
does** (`[ink]` and `[zoom-probe]` both pass the axis minimum), or its reconstruction stops
being the renderer's and its pixel readings mean nothing.
- **Every series bound to a trace must carry a real `MinMaxPyramid`**, including an
immutable one. `MinMaxPyramid::level_for()` descends to level 0 — raw samples — on a
pyramid with no levels, whatever `n` is, so a series that answers `pyramid()` with an
@@ -999,14 +1040,19 @@ far the largest file.
sample 0 is the ring's oldest, the very slot the producer is about to overwrite. `time_of()`
checks the bounds and then copies, and when the producer laps the slot in between the stamp it
returns is **one whole ring-span too new**. Everything else on a `ChannelBuffer` is read at
paint time with no lock, and `x0()` is the origin the renderer reconstructs every trace point
from (`gather[i] + series.x0()`), read both inside its increasing-suffix filter and again at
`stage_xy()` — so one torn read of that one slot produced three pictures, all of them "the
paint time with no lock, and `x0()` *was* the origin the renderer reconstructed every trace
point from (`gather[i] + series.x0()`), read both inside its increasing-suffix filter and again
at `stage_xy()` — so one torn read of that one slot produced three pictures, all of them "the
waveform is gone while the grid and the axes stay": `neighbours()` tests against `x_at(0)` and
refused the whole visible range (the renderer then gathered the *entire* ring at a coarse level
and drew it off the axis — `idx=[0..8388608) level=7 cover=320%`); `x0()` changing *during* the
filter loop dropped three quarters of the points; and `stage_xy()` shifted the finished polyline
a ring-span off its axis. That is the "with measurements and cursors active the plot flickers as
a ring-span off its axis. The gather origin is the *view's* minimum now (see the `gather()`
invariant), a local of the renderer's, so the second and third of those are structurally
impossible — but **the first is not**, `x_at(0)` is still what `neighbours()` tests the visible
range against, and that is the one that gathered the whole ring. The cache is load-bearing for
that reason; do not read the slot at paint time because two of its three symptoms are gone.
That is the "with measurements and cursors active the plot flickers as
if there were no data, at no constant frequency" defect. It is **live only** (a snapshot is
immutable, so `x0()` is constant for `Held`/`Paused`), it is a race against packet arrival so it
is irregular, and the measurements pane makes it both likelier and larger: the pane holds
@@ -1406,6 +1452,41 @@ far the largest file.
reasons (an event pin) call `sync_window_display()` alone, which only restates the control and
never moves the plot — calling `apply_time_window()` there would clamp a legitimately short
capture up to the requested window.
- **The x labels go relative on the tick *step against the label resolution at the axis'
magnitude*, never on the span.** `RowSet::update_time_labels()`'s question is only ever "can
this step be written at this magnitude": `TickFormat::Time` carries three significant figures
below a minute, **whole seconds** through `m:ss`/`h:mm:ss` up to a day, and three figures of
days above — so the resolution is a step function of the magnitude, which `time_label_resolution()`
states and nothing else may re-derive. The old test was `span < 1.0` and it was wrong in both
directions: a 2 s window at twenty minutes of uptime reads `20:00` on every tick and stayed
absolute, and a **triggered** sub-second window — whose axis is already `[-25ms, +25ms]` about
the edge — went relative and so re-anchored its ticks *away from the trigger*, with a `t0`
caption that had nothing to explain. Three details hold it up. The step is read back from the
majors the **last layout** produced, which is not a feedback loop (a tick's value depends on the
range and the axis' pixel width, never on the label form) but *is* absent before the first
layout — and with no ticks the form must be **left alone**, because a step guessed from the span
would be latched by the hysteresis below. The hysteresis is a 1.05 band on *leaving* the relative
form. And *entering* it needs a ULP of slack (`1 - 1e-9`): a step differenced from two tick
values and a resolution from `pow(10, n)` differ in their last bits when they are the same
number, and `step == res` is a readable axis (`1.73s`, `1.74s`, `1.75s` — one in the last digit
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
+55 -9
View File
@@ -74,15 +74,36 @@ so the times on the axis do not change when the pause begins. Resuming drops the
re-frames on the newest data, so the seconds that passed while paused are *there*, not a hole —
which is what pausing used to leave, because it stopped the producer outright.
**Sub-second windows read relative.** The x ticks are absolute until the window's span goes
under a second (with a band to 1.05 s, so the form cannot flicker while a zoom is nudged), and
relative below it: the ticks are then generated on a grid anchored at the window's left edge —
so they read `0`, `5ms`, `10ms` instead of `12.345671s`, `12.345672s`, which three significant
digits cannot even tell apart — and a caption names that anchor in the axes' top-left corner:
`t0 = 12.3456 s`. The labels scale through the prefixed units the SI formatter uses — `500ms`,
`500us`, `5us`, `5ns` — so a microsecond window reads in microseconds instead of `0.005ms`. Adding the two is the tick's absolute time. 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 —
**The x ticks read relative when, and only when, they could not be written absolutely.** A
`TickFormat::Time` label carries three significant digits, so the test is the tick **step**
against the label's own **resolution at the axis' magnitude**: `12.345671s` and `12.345672s` are
the same string, and there the ticks are re-anchored on a grid at the window's left edge — so
they read `0`, `5ms`, `10ms` — with a caption naming that anchor, `t0 = 12.3456 s`. Adding the
two is the tick's absolute time. The labels scale through the prefixed units the SI formatter
uses — `500ms`, `500us`, `5us`, `5ns` — so a microsecond window reads in microseconds instead of
`0.005ms`.
The rule used to be the *span* alone (relative under a second, with a band to 1.05 s), and a
span says nothing about whether a label can be written: it was wrong in **both** directions. A
2 s window at twenty minutes of uptime is labelled `20:00` on every tick — `m:ss` carries whole
seconds — and was left absolute and unreadable. And a **triggered** sub-second window, whose
axis is already `[-25ms, +25ms]` about the edge, reads `-20ms`, `-10ms`, `0`, `10ms` perfectly
well, yet was re-anchored anyway: the one position a triggered axis exists to show was displaced
by a `t0` caption that had nothing to explain. The step is read back from the majors the last
layout produced (a function of the range and the axis width, not of the label form, so no
feedback loop), the magnitude is the larger of the two endpoints, and the comparison carries a
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 **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
@@ -1173,6 +1194,31 @@ those are two places. The library's hover crosshair snaps the same way and draws
since a snapped mark lands among those dots and at their size would read as one more sample
instead of as the answer to the question the hover asks.
**A zoomed-in trace is drawn on the x its readout quotes, which needed the gather's origin to
become the caller's.** Separately from the half-sample offset above — which is a property of
*which datum* is quoted — there was an offset in *where the polyline was drawn*, and it grew
with the zoom. `plot::data::Series::gather()` stages x as `f32`, which cannot hold an absolute
epoch time, so it emits a value relative to an origin the consumer adds back in `f64`. That
origin was fixed at `x0()`, the start of the **retained window**: the f32 then had to resolve
2^-24 of the whole ring however little of it was on screen — 1.9 µs over 20 s of retained data
— while a readout reads `x_at()`, exact `f64`. At a view 10 µs wide across a 716 px axis
(14 ns a pixel) that is over a hundred pixels between the drawn sample and the x reported for
it, and it halves with every zoom *out*, which is why it read as "zooming in makes the
crosshair pick the wrong point".
The origin is now an argument, and `Renderer::draw_traces` passes the **visible range's
minimum** — so the quantisation is 2^-24 of the span on screen, a fixed 4e-5 of a pixel, at
every zoom. The fix is upstream (`deps/plotter`), where the parameter was deliberately placed
*before* the output buffer so that every one of the library's and this app's call sites was a
compile error rather than a silent change of behaviour. One simplification fell out of it:
`stage_xy()` took a gather origin and a draw origin, those are now always the same value, and
the linear case hands the gathered buffer straight to the line batch with no per-point copy.
Two guards: the library's
`a_gather_origin_at_the_view_keeps_a_deep_zoom_on_the_sample_it_quotes`, which measures the
error in pixels both ways (under 0.05 px with the view's minimum, over 10 px with `x0()`), and
this app's `trace ink:` line, which now reports `worst drawn-vs-quoted P px` over the raw-level
paints of a whole run.
The readouts are a **table**: a header naming the columns, then one row per instrument —
`| who | A | B | delta |`. The first row is the two times and their difference, and the
rows below it are the rulers (one per row, in `horizontal`/`both`) or the traces (in
+1 -1
+5 -5
View File
@@ -189,7 +189,7 @@ void ChannelBuffer::raw(u64 begin, u64 end, f32* out) const {
for (u64 i = count; i < end - begin; ++i) out[i] = 0.0f;
}
void ChannelBuffer::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
void ChannelBuffer::gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
plot::data::GatherMode mode) const {
const u64 n = pyramid_.size();
const u64 b = std::min(begin, n);
@@ -197,7 +197,7 @@ void ChannelBuffer::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_
if (b >= e) return;
const u64 origin = pyramid_.window_base();
const f64 base = x0(); // x is emitted relative to this (see the header)
const f64 base = x_origin; // x is emitted relative to this (see the header)
const auto emit_raw = [&](u64 from, u64 to) {
for (u64 i = from; i < to; ++i) {
@@ -302,13 +302,13 @@ u64 SlotSeries::snap_index(f64 x) const {
return buffer_ != nullptr ? buffer_->snap_index(x) : 0;
}
void SlotSeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
void SlotSeries::gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
plot::data::GatherMode mode) const {
if (event_ != nullptr) {
event_->gather(level, begin, end, out_xy, mode);
event_->gather(level, begin, end, x_origin, out_xy, mode);
return;
}
if (buffer_ != nullptr) buffer_->gather(level, begin, end, out_xy, mode);
if (buffer_ != nullptr) buffer_->gather(level, begin, end, x_origin, out_xy, mode);
}
void SlotSeries::copy_raw(u64 begin, u64 end, f32* out) const {
+9 -6
View File
@@ -80,9 +80,11 @@ class ChannelBuffer final : public plot::data::Series {
plot::Range x_bounds() const override;
plot::Range y_bounds() const override;
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
/// x is emitted **relative to `x0()`** (the library's contract): staging an
/// absolute epoch time as f32 would quantise it far coarser than a pixel.
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
/// x is emitted **relative to `x_origin`** (the library's contract): staging an
/// absolute epoch time as f32 would quantise it far coarser than a pixel, and
/// staging it relative to the whole *retained window* quantises it coarser than a
/// pixel of a zoomed view -- see `plot::data::Series::gather()`.
void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override;
/// `copy_raw`, published so the retargetable trace series can forward the
@@ -109,8 +111,9 @@ class ChannelBuffer final : public plot::data::Series {
/// slot cannot be overwritten under the read. Everything else on this object is read at
/// *paint* time with no lock, and this one slot is the ring's oldest: the one the producer is
/// about to overwrite. Reading it there returned a stamp a whole ring-span too new whenever
/// the producer lapped it between the bounds check and the copy -- and `x0()` is the origin
/// the renderer reconstructs every trace point from (`gather[i] + series.x0()`), so one such
/// the producer lapped it between the bounds check and the copy -- and `x0()` is what
/// `neighbours()` tests the visible range against, as well as the origin the renderer
/// reconstructed every trace point from before the caller named one, so one such
/// read moved the entire polyline a ring-span off its axis (an invisible trace) or, caught
/// inside the renderer's increasing-suffix filter, made it drop most of the points. That is
/// the "live plot flickers as if there were no data" defect. The cached value is also the
@@ -163,7 +166,7 @@ class SlotSeries final : public plot::data::Series {
plot::Range x_bounds() const override;
plot::Range y_bounds() const override;
const plot::data::MinMaxPyramid& pyramid() const override;
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override;
bool streaming() const noexcept override { return event_ == nullptr && buffer_ != nullptr; }
+12 -12
View File
@@ -108,7 +108,7 @@ class EventSeries final : public plot::data::Series {
/// Indices need no rebasing: the pyramid is built from sample 0 and nothing is ever
/// dropped from it, so `window_base()` is 0 and a window index *is* the absolute one
/// (unlike the ring, whose window slides).
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
plot::data::GatherMode mode =
plot::data::GatherMode::Envelope) const override {
if (channel_ >= snap_->values.size()) return;
@@ -118,17 +118,17 @@ class EventSeries final : public plot::data::Series {
const u64 b = std::min(begin, n);
const u64 e = std::min(std::max(end, b), n);
if (b >= e) return;
// x **relative to x0()**, which is the library's gather contract: the renderer
// adds `x0()` back before mapping to the screen. Emitting the absolute stamp made
// it add x0 twice, so the whole capture was drawn one `x0()` -- a full
// pre-trigger window -- to the left of the axis it had just been measured
// against. The event's own stamps are small (edge-relative, so [-pre, +post]),
// which is why that shift looked like a plausible picture: with a 50/50 split the
// right half of the plot was simply empty, zooming out showed the trace ending at
// the x = 0 marker, and zooming in past the shift showed nothing at all while the
// crosshair -- which reads `x_at()`, never `gather()` -- went on reporting the
// right values.
const f64 base = x0();
// x **relative to `x_origin`**, which is the library's gather contract: the
// renderer adds that same origin back before mapping to the screen. Emitting the
// absolute stamp made it add an origin twice, so the whole capture was drawn one
// `x0()` -- a full pre-trigger window -- to the left of the axis it had just been
// measured against. The event's own stamps are small (edge-relative, so
// [-pre, +post]), which is why that shift looked like a plausible picture: with a
// 50/50 split the right half of the plot was simply empty, zooming out showed the
// trace ending at the x = 0 marker, and zooming in past the shift showed nothing
// at all while the crosshair -- which reads `x_at()`, never `gather()` -- went on
// reporting the right values.
const f64 base = x_origin;
const auto rel = [&](u64 i) {
return static_cast<f32>(stamps[static_cast<std::size_t>(i)] - base);
};
+74 -32
View File
@@ -30,10 +30,23 @@ 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
/// the last digit of a label at that magnitude is worth. `format_time` writes three significant
/// figures below a minute (of the SI-prefixed mantissa under a second, of the seconds above it),
/// whole seconds through `m:ss`/`h:mm:ss` up to a day, and three figures of days above. So the
/// resolution is a step function of the *magnitude* and not of the span -- which is the whole
/// reason a relative axis is ever needed, and the reason a span threshold alone gets it wrong in
/// both directions.
double time_label_resolution(double t) {
t = std::fabs(t);
if (!std::isfinite(t) || !(t > 0.0)) return 0.0;
if (t >= 86400.0) return 86400.0 * std::pow(10.0, std::floor(std::log10(t / 86400.0)) - 2.0);
if (t >= 60.0) return 1.0;
return std::pow(10.0, std::floor(std::log10(t)) - 2.0);
}
constexpr double kParked = -1.0e12; ///< hides an instrument without removing it
/// The measurement marks' stroke: thinner than the chrome that frames the plot (a trace is 1.5,
/// the axis lines and the trigger marker `Style::line_width_px`), and broken, so a readout over
@@ -155,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.
@@ -509,12 +517,48 @@ bool RowSet::update_time_labels() {
const double span = view.span();
if (!(span > 0.0) || !std::isfinite(span)) return false;
// Sub-second windows read relative: `TickFormat::Time` writes three significant digits, so
// `12.345671s` and `12.345672s` are the same string and a 1 ms step around a multi-second
// time cannot be labelled at all. The band is the switch's hysteresis -- entering below a
// second, leaving only above 1.05 -- so a span sitting on the boundary cannot flicker
// between two forms while a zoom is nudged.
const bool want_relative = relative_x_ ? span < 1.05 : span < 1.0;
// A relative axis is the answer to one problem only: a tick step the label form cannot write.
// `TickFormat::Time` carries three significant digits, so a 1 ms step around a 12 s stamp
// writes `12.3s` on every tick -- there the ticks have to be re-anchored and the anchor named.
// The test is therefore the *step against the resolution at the axis' own magnitude*, never the
// span: the two part company in both directions, and both cases are real here. A 2 s window at
// twenty minutes of uptime is labelled `20:00` throughout and needs the anchor although its
// span is wide; a 50 ms trigger window spanning [-25ms, +25ms] is labelled `-20ms .. +20ms`
// perfectly well and needs nothing, although its span is narrow. That second case is what made
// a triggered sub-second view carry a `t0` caption *and* re-anchor its ticks away from the
// edge, which is the one position a triggered axis exists to show.
// The 1.05 band is the switch's hysteresis, so a step sitting on the boundary cannot flicker
// between two forms while a zoom is nudged, and the `1 - 1e-9` is a slack the *entry* needs for
// its own reason: a step read back from two tick values and a resolution from `pow(10, n)`
// differ in their last bits even when they are the same number, and `step == res` is a readable
// axis (`1.73s, 1.74s, 1.75s` -- one in the last digit is exactly enough), so a decision taken
// on those bits picked a different form from one frame to the next.
const double mag = std::max(std::fabs(view.min), std::fabs(view.max));
const double res = time_label_resolution(mag);
// The step the labels are actually written at: the majors the *last* layout produced. They are
// a function of the range and the axis' pixel width, not of the label form, so reading them
// back here is no feedback loop.
double step = 0.0;
{
const plot::Axis& ax = first.plot->x_axis(first.axis_x);
double prev = 0.0;
bool have = false;
for (const plot::Tick& t : ax.ticks) {
if (!t.major) continue;
if (have) {
step = t.value - prev;
break;
}
prev = t.value;
have = true;
}
}
// No ticks yet -- the frames before the first layout. The form is a property of the labels, so
// with no labels there is nothing to decide, and keeping the current one is right where
// guessing a step from the span is not: a guess that entered the relative form would be
// *latched* there by the hysteresis above.
const bool want_relative =
step > 0.0 ? step < res * (relative_x_ ? 1.05 : 1.0 - 1.0e-9) : relative_x_;
const bool changed = want_relative != relative_x_;
relative_x_ = want_relative;
@@ -530,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;
}
-5
View File
@@ -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<plot::u32, kMeasureMarks> measure_h{};
+102 -68
View File
@@ -418,6 +418,14 @@ struct App {
int dbg_ink_staircase = 0; ///< the filter dropped over three quarters of the points
int dbg_ink_short = 0; ///< the ink reached under a quarter of the axis' width
double dbg_ink_worst_cover = 1.0;
/// The worst distance, in pixels, between where a sample is *drawn* and the x a readout
/// quotes for the same sample. `gather()` stages x as an f32 relative to an origin the
/// renderer names, while `Plot::crosshair_point()` reads `x_at()` in exact f64, so this is
/// the quantisation of that f32 expressed in the unit the user sees it in. Measured on the
/// raw levels only, where a gathered point *is* a sample; above them a point is a bucket's
/// envelope and has no single sample to be off from.
double dbg_ink_worst_offset_px = 0.0;
int dbg_ink_raw_checked = 0; ///< frames the figure above was measured over
int dbg_ink_reports = 0;
/// Frames the badge spent in each `BadgePhase`. Both `lit` and `dim` have to be non-zero
/// over a run that acquired anything -- that is what "it blinks" means, and a badge
@@ -1093,21 +1101,19 @@ void check_frame_ink(App& app) {
const plot::u32 level = spp >= 64.0 ? s->pyramid().level_for(n, spp) : 0u;
std::vector<plot::f32>& xy = app.measure_ink_scratch;
xy.clear();
s->gather(level, first, last, xy);
// The same origin the renderer names (`x_range.min`), so the reconstruction below is its
// reconstruction: see `plot::data::Series::gather()` for why the caller owns it.
s->gather(level, first, last, axis.min, xy);
const std::size_t pts = xy.size() / 2;
std::size_t kept = 0;
double lo = 0.0;
double hi = 0.0;
// Where the filter's drops fall, and how far back each one steps. The renderer reconstructs
// every point's absolute x as `gather[i] + series.x0()` **inside** this loop, so an `x0()`
// that is not constant for the loop's duration shifts the comparison under itself -- and on a
// ring-backed series `x0()` is `time_of(window_base)`, which *clamps to the oldest readable
// slot* and therefore advances with the producer. The two readings that separate that from a
// genuinely out-of-order gather are the drops' *positions* (spread through the points, or
// bunched at the front where the clamp lives) and `back` (one sample's spacing, or a packet's
// worth). So the loop is run twice: once the renderer's way, and once with `x0()` read a
// single time.
// Where the filter's drops fall, and how far back each one steps. The origin is a constant
// for the walk's duration *by construction* now -- the renderer reconstructs
// `gather[i] + x_range.min`, a local of its own, where it used to re-read `series.x0()` on
// every point and a ring-backed `x0()` advances with the producer. So a drop here is a
// genuinely out-of-order gather, and `back` says by how much.
std::size_t drop_first = 0;
std::size_t drop_last = 0;
double worst_back = 0.0;
@@ -1115,7 +1121,7 @@ void check_frame_ink(App& app) {
{
double next = std::numeric_limits<double>::infinity();
for (std::size_t k = pts; k > 0; --k) {
const double x = double(xy[2 * (k - 1)]) + s->x0();
const double x = double(xy[2 * (k - 1)]) + axis.min;
if (!(x < next)) {
worst_back = std::max(worst_back, x - next);
if (!any_drop) drop_last = k - 1;
@@ -1129,18 +1135,24 @@ void check_frame_ink(App& app) {
++kept;
}
}
std::size_t kept_fixed = 0;
{
const double base = s->x0(); // one read, held for the whole walk
double next = std::numeric_limits<double>::infinity();
for (std::size_t k = pts; k > 0; --k) {
const double x = double(xy[2 * (k - 1)]) + base;
if (!(x < next)) continue;
next = x;
++kept_fixed;
}
}
++app.dbg_ink_checked;
// Drawn against quoted, in pixels. At level 0 a gathered pair *is* sample `first + k`, so
// this is the one place the two numbers the user compares by eye -- the polyline's position
// and the crosshair's readout -- can be differenced. The origin is what decides it: the f32
// resolves 2^-24 of whatever span it is relative to, so a view narrower than that fraction
// of the *retained window* drew a sample visibly away from the x quoted for it, which is
// what "when zooming there is a visible offset between the real data point and the crosshair
// selected one" was.
if (level == 0 && pts > 0) {
const double px_per_x = axes_w / axis.span();
double worst = 0.0;
for (std::size_t k = 0; k < pts; ++k) {
const double drawn = axis.min + double(xy[2 * k]);
worst = std::max(worst, std::abs(drawn - s->x_at(first + k)) * px_per_x);
}
app.dbg_ink_worst_offset_px = std::max(app.dbg_ink_worst_offset_px, worst);
++app.dbg_ink_raw_checked;
}
const double cover = kept >= 2 ? (hi - lo) / axis.span() : 0.0;
app.dbg_ink_worst_cover = std::min(app.dbg_ink_worst_cover, cover);
const bool collapse = kept < 2;
@@ -1155,22 +1167,23 @@ void check_frame_ink(App& app) {
if ((collapse || staircase || short_ink) && app.dbg_ink_reports < 40) {
++app.dbg_ink_reports;
// The series' own answers beside the reconstruction, because the two disagreeing is itself
// a reading: `rel` is what `gather()` emitted (relative to the `x0()` *it* read) and `x0`
// is the `x0()` read here one call later, so a trace whose ink sits a whole ring-span off
// its axis separates into "the stamps moved under us" and "the window is the wrong one".
// a reading: `rel` is what `gather()` emitted (relative to `axis.min`, the origin it was
// given) while `x0`/`x_at`/`bounds` are the window the series says it holds, so a trace
// whose ink sits a whole ring-span off its axis separates into "the stamps moved under us"
// and "the window is the wrong one".
const plot::Range xb = s->x_bounds();
std::fprintf(stderr,
"[ink] paint %d %s%s%s mode=%s axis=[%.6f..%.6f] n=%llu idx=[%llu..%llu) "
"spp=%.1f level=%u pts=%zu kept=%zu ink=[%.6f..%.6f] cover=%.1f%% "
"x0=%.6f x_at=[%.6f..%.6f] bounds=[%.6f..%.6f] rel=[%.6f..%.6f] dx=%.3g "
"kept_fixed_x0=%zu drops=[%zu..%zu] back=%.3g\n",
"drops=[%zu..%zu] back=%.3g\n",
app.dbg_paint_n, collapse ? "collapse " : "", staircase ? "staircase " : "",
short_ink ? "short " : "", display_mode_name(app.dbg_display), axis.min, axis.max,
(unsigned long long)n, (unsigned long long)first, (unsigned long long)last, spp,
level, pts, kept, lo, hi, cover * 100.0, s->x0(), s->x_at(0), s->x_at(n - 1),
xb.min, xb.max, pts > 0 ? double(xy[0]) : 0.0,
pts > 0 ? double(xy[2 * (pts - 1)]) : 0.0, s->dx(), kept_fixed, drop_first,
drop_last, worst_back);
pts > 0 ? double(xy[2 * (pts - 1)]) : 0.0, s->dx(), drop_first, drop_last,
worst_back);
std::fflush(stderr);
}
}
@@ -4681,24 +4694,29 @@ void report_event_zoom(App& a, const char* what) {
const double spp = double(last - first) / axes_w;
const plot::u32 level = spp >= 64.0 ? s->pyramid().level_for(n, spp) : 0u;
std::vector<plot::f32> xy;
s->gather(level, first, last, xy);
// `axis.min` is the origin the renderer names, so every reconstruction below is its own --
// and at the deep end of this sweep that choice is the whole point: relative to the retained
// window instead, the f32's ULP would be a large fraction of these spans.
s->gather(level, first, last, axis.min, xy);
double ink_lo = 0.0, ink_hi = 0.0;
if (xy.size() >= 2) {
ink_lo = s->x0() + double(xy[0]);
ink_hi = s->x0() + double(xy[xy.size() - 2]);
ink_lo = axis.min + double(xy[0]);
ink_hi = axis.min + double(xy[xy.size() - 2]);
}
// The renderer's own increasing-suffix filter, counted rather than applied: it drops every
// point that is not strictly older than the one kept after it, which for a *run* of equal x
// keeps the last of the run. The stamps here are f32, so once the sample spacing falls below
// the f32 step at the stream's magnitude the gathered x has such runs -- and `kept` is how
// many distinct positions the polyline is left with. A `kept` far below `pts` is a staircase,
// and a `kept` of a handful is a trace with nothing to draw.
// the f32 step at the *gather origin's* magnitude the gathered x has such runs -- and `kept`
// is how many distinct positions the polyline is left with. A `kept` far below `pts` is a
// staircase, and a `kept` of a handful is a trace with nothing to draw. This is the other
// reading the origin change buys: the step is now 2^-24 of the span on screen, so a sweep
// that used to grind `kept` down as it zoomed keeps every point.
std::size_t kept = 0;
double worst_back = 0.0; // the largest backwards step, in data units: non-monotonic gather
{
double next = std::numeric_limits<double>::infinity();
for (std::size_t k = xy.size() / 2; k > 0; --k) {
const double x = double(xy[2 * (k - 1)]) + s->x0();
const double x = double(xy[2 * (k - 1)]) + axis.min;
if (!(x < next)) {
worst_back = std::max(worst_back, x - next);
continue;
@@ -6247,23 +6265,27 @@ gboolean trig_trace_probe(gpointer user) {
if (found) break;
}
// Where that slot's samples are *drawn*, reconstructed exactly as the renderer
// does it: `Series::gather()` emits x relative to `x0()`, and the renderer adds
// `x0()` back before mapping to the screen. So `x0() + gathered` must equal
// `x_at()`, and `drawn=` must equal `data=` to the f32's precision.
// Where that slot's samples are *drawn*, reconstructed the way the renderer does it:
// `Series::gather()` emits x relative to an origin **the caller names**, and the
// consumer adds that same origin back before mapping to the screen. So
// `origin + gathered` must equal `x_at()`, and `drawn=` must equal `data=` to the
// f32's precision. The origin named here is `x0()`, deliberately -- the renderer
// passes the visible range's minimum, but this line reads the series' *whole* span,
// which is the widest relative value a gather can be asked to stage and so the worst
// case for the f32.
//
// This is the one defect in this area the picture alone shows and no other probe
// line does: a series that emits absolute x instead makes the renderer add `x0()`
// twice, so the trace lands a whole `x0()` away from the axis it was measured
// line does: a series that emits absolute x instead makes the consumer add the origin
// twice, so the trace lands a whole origin away from the axis it was measured
// against -- off screen entirely once the view is narrower than that shift, while
// the crosshair (which reads `x_at()`) goes on reporting correct values.
double drawn_lo = 0.0, drawn_hi = 0.0, data_lo = 0.0, data_hi = 0.0;
if (shown != nullptr && shown->size() > 0) {
const u64 n = shown->size();
std::vector<f32> xy;
shown->gather(0u, 0u, 1u, xy);
shown->gather(0u, 0u, 1u, shown->x0(), xy);
const std::size_t head = xy.size();
shown->gather(0u, n - 1, n, xy);
shown->gather(0u, n - 1, n, shown->x0(), xy);
if (head >= 2 && xy.size() >= head + 2) {
drawn_lo = shown->x0() + double(xy[0]);
drawn_hi = shown->x0() + double(xy[head]);
@@ -7246,18 +7268,25 @@ gboolean timelabels_probe_step(gpointer user) {
const RowSet::Row& last = a->rows->row(a->rows->row_count() - 1);
std::string ticks;
std::string note;
// Whether *every* major label on the axis is a different string. That, and not a span
// threshold, is what the relative form exists to buy: two adjacent ticks reading `12.3s` are
// not labels. It is therefore the one assertion that holds whatever the stream's uptime is --
// which decides where the switch actually falls, since the label resolution is a function of
// the axis' magnitude.
bool distinct = true;
if (last.plot != nullptr) {
int n = 0;
std::vector<std::string> seen;
for (const plot::Tick& t : last.plot->x_axis(last.axis_x).ticks) {
if (!t.major) continue;
if (n++ != 0) ticks += ",";
ticks += t.label;
if (n >= 4) break;
}
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;
if (std::find(seen.begin(), seen.end(), t.label) != seen.end()) distinct = false;
seen.emplace_back(t.label);
if (n++ < 4) {
if (n != 1) ticks += ",";
ticks += t.label;
}
}
note = last.plot->x_note();
}
double first_value = 0.0;
plot::Range shown{};
@@ -7271,9 +7300,10 @@ gboolean timelabels_probe_step(gpointer user) {
shown = last.plot->x_range(last.axis_x);
}
std::fprintf(stderr,
"[timelabels-probe] %-12s relative=%d window=%.6g origin=%.6f note=[%s] "
"ticks=[%s] first_value=%.6f first_rel=%.6f range=[%.6f..%.6f]\n",
what, a->rows->time_labels_relative() ? 1 : 0, a->rows->time_window(),
"[timelabels-probe] %-12s relative=%d distinct=%d window=%.6g origin=%.6f "
"note=[%s] ticks=[%s] first_value=%.6f first_rel=%.6f range=[%.6f..%.6f]\n",
what, a->rows->time_labels_relative() ? 1 : 0, distinct ? 1 : 0,
a->rows->time_window(),
a->rows->time_labels_origin(), note.c_str(), ticks.c_str(), first_value,
first_value - (last.plot != nullptr ? last.plot->x_axis_origin(last.axis_x) : 0.0),
shown.min, shown.max);
@@ -8057,9 +8087,10 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
app.dbg_notrace_frames, app.dbg_notrace_after_bound, app.dbg_notrace_last);
std::printf("[udpscope] worst side/pane min width: %d px\n", app.dbg_min_w_worst);
std::printf("[udpscope] trace ink: %d paints checked, %d collapsed, %d staircase, %d short, "
"worst cover %.1f%%\n",
"worst cover %.1f%%, worst drawn-vs-quoted %.4f px over %d raw paints\n",
app.dbg_ink_checked, app.dbg_ink_collapse, app.dbg_ink_staircase,
app.dbg_ink_short, app.dbg_ink_worst_cover * 100.0);
app.dbg_ink_short, app.dbg_ink_worst_cover * 100.0, app.dbg_ink_worst_offset_px,
app.dbg_ink_raw_checked);
std::printf("[udpscope] blank view frames: %d of %d\n", app.dbg_blank_frames,
app.dbg_axes_frames);
std::printf("[udpscope] trigger events: %llu total, %llu at the disarm (no event after it "
@@ -8306,24 +8337,27 @@ 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;
bool distinct = true;
if (rlt.plot != nullptr) {
int shown = 0;
std::vector<std::string> seen;
for (const plot::Tick& t : rlt.plot->x_axis(rlt.axis_x).ticks) {
if (!t.major) continue;
if (shown++ != 0) ticks += ",";
ticks += t.label;
if (shown >= 4) break;
if (std::find(seen.begin(), seen.end(), t.label) != seen.end()) distinct = false;
seen.emplace_back(t.label);
if (shown++ < 4) {
if (shown != 1) ticks += ",";
ticks += t.label;
}
}
}
// The labels against the anchor they were built with: the dump runs after the last
@@ -8340,10 +8374,10 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
}
}
}
std::printf("[udpscope] time labels: relative=%d origin=%.6f note=[%s] x_origin_set=%.6f "
"ticks=[%s] first_rel=%.9f\n",
rows.time_labels_relative() ? 1 : 0, rows.time_labels_origin(), note.c_str(),
origin_set, ticks.c_str(), first_value - origin_set);
std::printf("[udpscope] time labels: relative=%d distinct=%d origin=%.6f note=[%s] "
"x_origin_set=%.6f ticks=[%s] first_rel=%.9f\n",
rows.time_labels_relative() ? 1 : 0, distinct ? 1 : 0, rows.time_labels_origin(),
note.c_str(), origin_set, ticks.c_str(), first_value - origin_set);
}
{
std::lock_guard<std::mutex> lock(model.mutex());