improved measuraments ,cursors, zooming and user interactions
This commit is contained in:
1 parent
0eceaf537b
commit
02620ca946
12 files changed
+1488
-171
No files matched your search
@@ -60,6 +60,34 @@ single consumer), never through the lock.
|
||||
|
||||
## Measurements
|
||||
|
||||
**Pause freezes the picture, not the acquisition.** The producer keeps writing the live ring
|
||||
while paused — the same thing it does when the trigger is off — and the plot binds a *snapshot*
|
||||
of the window that was on screen at the pause, with the axes pinned there. That is the same
|
||||
immutable-series path a held trigger event uses (`DisplayMode::Paused` sits beside `Held`); the
|
||||
difference is the anchoring: an event's stamps are edge-relative, a pause's are the stream's own,
|
||||
so the times on the axis do not change when the pause begins. Resuming drops the snapshot and
|
||||
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 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
|
||||
by the app: the window's edge is derived from the newest sample during the layout, so a value
|
||||
the app wrote a frame earlier is a frame of scroll stale — at 1 MS/s more than a millisecond
|
||||
window shows. One consequence worth knowing: the zero tick can sit up to one tick step to the
|
||||
*left* of the window's edge in the frame it was drawn, because the grid is anchored to the
|
||||
edge the ticks were generated for; the caption always names the anchor the labels were built
|
||||
with, so the addition stays exact.
|
||||
|
||||
**Measurements** (overflow menu) reveals a pane with a `[+]` and one row per measurement:
|
||||
`[badge] <type> <value> [edit]`. `[+]` opens a dialog with the signal, the type, the options
|
||||
that type actually uses, and "show on plot"; `edit` opens the same dialog on that
|
||||
@@ -313,15 +341,120 @@ that apart from a ring that has only just started filling: the first frames afte
|
||||
signal is enabled hold a couple of hundred samples, a 2 s window was clamped to the
|
||||
~0.02 s they covered, and nothing there ever grows a span back. The view then stayed
|
||||
pinned to the newest 20 ms of a 6.5 s buffer for the rest of the session. It now clamps
|
||||
against the larger of the retained span and the requested window, so a partly filled
|
||||
window simply has an empty left-hand side.
|
||||
against the **requested window** itself — not the retained span, which was the second
|
||||
attempt and the second bug: while the ring is still filling the two are the same, but once
|
||||
it holds more than the window the bound became the whole ring, and a zoom-out walked the
|
||||
view past the window into data the window was never set to show. A partly filled window
|
||||
simply has an empty left-hand side.
|
||||
|
||||
**The trigger marker is a readout, not a handle.** It is a cursor, and a cursor is grabbable by
|
||||
default — so parking it outside the axis, which is how it is hidden when no event is on screen, did
|
||||
not stop a *drag*: the controller holds a marker for the gesture's life and follows the pointer
|
||||
between frames. It takes `set_cursor_selectable(false)` like the measurement marks, and the dump
|
||||
reports `readout instruments locked: 1`.
|
||||
|
||||
**A pinned view is navigable within its data, and in live view too.** Three places could leave the
|
||||
window showing data the scope never had, and all three are now bounded by *the data* rather than by
|
||||
the window that was requested: the **pause** pinned on the window it asked for (the snapshot is a
|
||||
cut clamped to the ring, so it can be shorter), the **held event** pinned on the snapshot's recorded
|
||||
`pre_s`/`post_s`, which is again the request — a capture shorter than it exceeds it — and a
|
||||
**following group**, whose scroll offset was bounded by the retained span alone. That last one is
|
||||
the live case: with the loose bound, scrolling back put the window's *right* edge on the oldest
|
||||
sample, which leaves the whole span to its left over nothing — an empty margin exactly as wide as
|
||||
the view. The group's bound has two cases because it has to: while the members retain at least a
|
||||
window's worth, the whole window is kept on the data; when the window is *wider* than what is
|
||||
retained (a ring still filling, a trimmed one) no placement avoids a margin, and the least-empty
|
||||
one — right edge on the newest sample — is kept, which is also the behaviour every existing test
|
||||
expects. A `Capturing` view is deliberately not clamped: its post side is empty until the capture
|
||||
completes, which is the feature.
|
||||
|
||||
**The pause pins on its snapshot, not on the window it asked for.** The pause freezes the visible
|
||||
window, but that window is *cut* out of the ring and the cut is clamped to what the ring holds — so
|
||||
a window reaching back past the oldest retained sample produces a shorter snapshot. The axis was
|
||||
pinned to the request, which left the shortfall as an **empty left margin** (a run showed
|
||||
`frozen_x=[0.000000..0.561537]` against an axis of `[-1.44..0.56]`), and zooming in moved the view
|
||||
deeper into the empty part, which is what made it look like zooming *caused* it. The pin is the
|
||||
snapshot's own stamps now, so the frozen picture fills the frame and a zoom stays on its data; the
|
||||
event pin already worked this way, and the pause agrees with it. `[pause-probe]` prints `axis=`
|
||||
beside `frozen_x=`, and while paused the axis must lie *inside* `frozen_x` — equal at the pause, a
|
||||
subset after a zoom-in.
|
||||
|
||||
**A pinned window is not a navigation surface.** In trigger mode the x window is the event —
|
||||
`[-pre, +post]` while a capture fills, the snapshot's own span once it is held — and a gesture
|
||||
must not move it. Nothing was pulling a drag back: `clamp_to_data()` is the live-view half of
|
||||
the rule above and is deliberately skipped while a capture or an event is displayed, and the
|
||||
library's own pan bound lives in the *following* path (a `Live` group's offset is clamped
|
||||
against what its members retain), so a group with the follow off had no bound at all. A drag or
|
||||
a wheel zoom therefore walked the frozen capture off its axis, leaving an empty plot over data
|
||||
the scope never had. The pin is now re-stated every frame while it is pinned, in the same
|
||||
branch that decides `clamp_to_data()` is not to run, which makes a pan or a zoom in trigger mode
|
||||
(and on a paused view, whose frozen window is held the same way) a no-op. Leaving trigger mode,
|
||||
or resuming, is what makes the view navigable again. `UDPSCOPE_PROBE_PAN_MS` is the guard: it
|
||||
writes the range a gesture writes and reports `held=1` when the axis ignored it.
|
||||
|
||||
**Four measurements cost a third of the run's frames.** A measurement is a statistic of the
|
||||
visible window, so it was recomputed whenever that window moved — which in a live view is every
|
||||
frame, at O(visible samples) *per measurement*. Adding a `top` measurement to a 1.3 MS/s stream
|
||||
whose window held 4.7M samples took the frame tick's mean from **0.41 ms to 13.66 ms** and the
|
||||
run from 631 frames to 407. Three separate costs, found by instrumenting the pane rather than by
|
||||
reading the frame mean (`measure cost: N recomputes, X ms total, Y ms each` in the dump, next to
|
||||
the values):
|
||||
|
||||
1. *The pane was recomputed per frame.* It is a readout, so it now follows the rule the status
|
||||
line's own fields already had: at most every 100 ms while the window scrolls, one pass the
|
||||
moment it settles, one per mode change — and the interval follows the *work*, stretching to
|
||||
400 ms when a pass costs more than 5 ms, so what stays bounded is the duty cycle rather than
|
||||
the period. (Getting the "settled" case backwards is what made it *worse* first: 457
|
||||
recomputes in one run, because a *pinned* window then recomputed on every frame.)
|
||||
2. *Both window vectors were copied per measurement.* Four measurements over a 4.7M-sample
|
||||
window is 300 MB of memcpy per frame — memory-bandwidth bound. The loop holds the cached
|
||||
window by reference now, and it is materialized in bounded chunks instead of through two
|
||||
whole-window `float` temporaries.
|
||||
3. *Every type rescanned the window.* Average, min, max and the two new types are all derived
|
||||
from one shared pass — both extremes with their first positions, the sum and the count — so
|
||||
several measurements on one signal share a single scan and adding a type adds no pass.
|
||||
|
||||
Measured on the same stream and window: tick mean **1.39 ms** with the pane holding four
|
||||
measurements against **0.73 ms** without (595 frames against 610), the pass itself 11 ms every
|
||||
400 ms — and the 82 ms worst tick is the *control's* too, i.e. the publish stall, not this.
|
||||
|
||||
**"Show on plot" draws full-width rulers, and they must not blink.** A level measurement draws an
|
||||
`h_ruler` across the whole plot and a time one a full-height cursor, so a pane holding four
|
||||
measurements can put six lines over the data — that is the feature, and the lines move with the
|
||||
window. Because the pane is now refreshed on a cadence, the marks were re-placed from a result
|
||||
that could be a pass old; worse, a pass landing on an empty window (a rebuild, a capture
|
||||
boundary, a mode change) emitted no marks at all, so the rulers vanished and returned a frame
|
||||
later. The fallback is by *kind*: an invalid pass keeps the last valid pass' **level** marks — a
|
||||
level is a property of the signal and outlives the window it was measured in — and drops its
|
||||
**time** marks, which are positions inside the window that result described. `marks=0` with
|
||||
measurements on and data arriving is now the assertion (`[measure-trace]` in a probe run), and it
|
||||
never occurs.
|
||||
|
||||
Their stroke is the app's own setting, not the library default: **0.75 px and dashed 4/4**
|
||||
(`set_cursor_stroke()`), so a measurement's mark reads as an annotation over the data rather than
|
||||
as another instrument framing the plot. The trigger marker keeps the solid `line_width_px` stroke
|
||||
on purpose — it marks an event the scope acted on, which is a different claim from a measured
|
||||
level. The library's own test is `a_cursor_stroke_can_be_thinner_and_dashed`.
|
||||
|
||||
Two related guards came out of the same hunt: `clamp_to_data()` compares against the window with
|
||||
a relative tolerance (clamping to *exactly* `window_s_` can leave the group's span an ULP over,
|
||||
and a clamp that fires every frame is a re-frame loop — the dump counts it as `clamp fires:`), and
|
||||
the sample-window walk it does is unchanged.
|
||||
|
||||
**Amplitude and peak to peak** are the two new types: `max - min`, and half of it. Deliberately
|
||||
`(max - min) / 2` rather than `max(|value|)` — the latter is a distance from zero, which for a
|
||||
signal riding on an offset is a different and less useful number. Both come out of the shared
|
||||
pass, so they are free, and both read exactly what a unit sine should: `amplitude=1`,
|
||||
`peak to peak=2`.
|
||||
|
||||
The second is subtler and needs a saturated ring. A `ChannelBuffer` sample is a (value,
|
||||
stamp) pair split across two rings of equal capacity, and the published window is pinned
|
||||
by `acquire()` while both rings keep sliding under it — so the oldest samples of the
|
||||
window point at time slots the producer has since overwritten. Those used to read back as
|
||||
`0`, a perfectly plausible coordinate, which landed in `x_bounds()` and from there in
|
||||
`dx()` and `x0()`. That is the renderer's entire index mapping: it converts the visible x
|
||||
`dx()` and `x0()` — the window's extent, which the axis, the measurements and the LOD level are
|
||||
all read from. (The renderer itself no longer walks that grid to find its sample window; see
|
||||
the margins note below.) It converts the visible x
|
||||
range into indices with `(x - x0()) / dx()`, so *one* unreadable slot inflated `dx()` by
|
||||
the ratio of elapsed stream time to the retained window, scaled every index up with it,
|
||||
and the gather began well inside the visible range. The stamp is now clamped to the oldest
|
||||
@@ -557,8 +690,10 @@ the fix is a submodule commit and a pin move (see CLAUDE.md):
|
||||
cursors, region edges, the crosshair — because the rounding moves a line by up to
|
||||
half a pixel, and a cursor that reports a value may not be moved to look tidy.
|
||||
Diagonals are untouched either way. Measured on one scanline of 620 px: 22
|
||||
distinct intensities before, 10 after, with the grid landing exactly on its own
|
||||
`grid_major`/`grid_minor` colours instead of a spread of partial blends.
|
||||
distinct intensities before, 10 after, with the labelled grid landing exactly on its
|
||||
own `grid_major` colour instead of a spread of partial blends. (That measurement
|
||||
predates the minor grid's own styling below: the *minor* class now blends on
|
||||
purpose, so it no longer lands on its exact colour — the majors still do.)
|
||||
|
||||
A third, found the same way, is in the tick generator rather than the shaders:
|
||||
|
||||
@@ -575,8 +710,33 @@ A third, found the same way, is in the tick generator rather than the shaders:
|
||||
— where the same residue read `3.19e-13ms`. A range whose ticks are *legitimately*
|
||||
that small keeps them.
|
||||
|
||||
**The minor grid is texture, the labelled grid is structure.** The subdivisions used to be
|
||||
drawn exactly like the labelled lines — same width, same opacity — so a plot dense enough to
|
||||
need them read as two competing sets of ticks. They are now three things the style states
|
||||
explicitly: **thinner** (`grid_minor_width = 0.75`, a sub-pixel logical width, which at 1x is
|
||||
expressed as reduced coverage and on a HiDPI display as a literally narrower line),
|
||||
**dashed** (`grid_minor_dash_on`/`_off = 3/3`, a fragment-shader discard, so the vertices are
|
||||
unchanged), and **blended into the background** (`grid_minor` carries 50% alpha, and the line
|
||||
shader applies it). The labelled grid is also brighter than it was (`grid_major` `0x2A3038` ->
|
||||
`0x39414C`), still well under the axis line's `0x8A94A6`, so the three-way hierarchy the eye reads
|
||||
is minor < major < axis. Together the defaults leave a minor line an order of magnitude less ink
|
||||
than a major one, which is what gives the eye one grid to read instead of two. Set `grid_minor_dash_off = 0` for the old
|
||||
solid subdivision, and `grid_minor_width`/`grid_minor` for the rest. The library test is
|
||||
`the_minor_grid_is_thinner_dashed_and_dimmer_than_the_major`.
|
||||
|
||||
A fourth lives in the renderer rather than the atlas: the visible x range is mapped onto
|
||||
sample indices through the **series' own x**, not the `(x - x0()) / dx()` grid. `dx()` is
|
||||
documented as a *nominal* spacing and on a ring-backed series — whose x is a streamed
|
||||
timestamp — it is only the mean of the retained window, so the grid walk landed the draw
|
||||
window inside the visible range and dropped the two segments that enter and leave the frame.
|
||||
The symptom is the one zooming in shows first: the trace stops short of the left and right
|
||||
margins and those edges go empty. `Series::neighbours()` answers where the bracketing samples
|
||||
are, over the real x, and a uniform series answers the same question through the same call —
|
||||
so it is one path, and it costs what the crosshair's own lookup costs.
|
||||
|
||||
Each carries a test upstream (`the_atlas_is_filtered_not_point_sampled`,
|
||||
`grid_hairlines_land_on_whole_pixels`, `the_tick_at_the_origin_reads_zero`), and each
|
||||
`grid_hairlines_land_on_whole_pixels`, `the_tick_at_the_origin_reads_zero`,
|
||||
`a_sparse_window_between_samples_still_inks_both_margins`), and each
|
||||
was verified to fail with its own fix reverted.
|
||||
|
||||
**Cursors start hidden** and are switched on from the overflow menu; **Reset cursors**
|
||||
@@ -596,8 +756,10 @@ chooses what the instruments are:
|
||||
|
||||
The vertical pair is one stream time shared by every row, so the markers line up down
|
||||
the column and dragging one on any row moves them all — the app reads the position back
|
||||
out of the plot each frame, because a drag writes the *plot* and that is the only place
|
||||
it is visible. A ruler is per row: each row has its own y axis, so two rows' rulers are
|
||||
out of the plot **while a gesture is on that row**, because a drag writes the *plot* and that is
|
||||
the only place it is visible. Outside a gesture the plot is not read at all: `cursor.x` is the
|
||||
snapped value then, which is what the app wrote itself a frame earlier, and adopting that back
|
||||
is what made a dragged cursor spring to where it had been. A ruler is per row: each row has its own y axis, so two rows' rulers are
|
||||
two different values, and the pane names the row.
|
||||
|
||||
`mix` needs no rulers: it reports the value the trace actually has at each marker, read
|
||||
@@ -833,6 +995,12 @@ now — they are listed so they are not mistaken for anything else:
|
||||
hundred samples every fifth of a second made every frame span microseconds while
|
||||
the frames were 100 ms apart).
|
||||
|
||||
**Every launch is its own instance.** The GTK application is created `G_APPLICATION_NON_UNIQUE`:
|
||||
a scope is the kind of program a user runs twice side by side — one instance per source, or two
|
||||
views of the same one — and without the flag `GApplication`'s D-Bus single-instance rule turned
|
||||
every later launch into an activation on the first, so the new process exited at once and only
|
||||
one instance ever lived.
|
||||
|
||||
**One source, one client, over unicast.** A UDPStreamer over unicast keeps a single
|
||||
session: a second `udpscope`'s CONNECT displaces the first, the displaced client sees its
|
||||
`silence_timeout_s` elapse, reconnects and displaces the other, and the two take turns
|
||||
|
||||
Reference in new issue
Block a user