A user looked at the display and reported "the colour fading is not applied, it looks white or
full color, no opacity gradient" -- and the *numbers* were right: ink decayed, `step-time` was
1/e, `step-acquisitions` was 1 + 0.5 x 1. It was the mapping from a cell's value to a pixel.
**Neither the shader nor the blend was wrong** (the answer to the question that was asked): the
fragment write is already the weighted one (`vec4(u_color.rgb, u_color.a * ink)`) with the frame's
straight-alpha blend, and a cell at half ink over a grid line rendered 160 where the arithmetic
predicts 160. What was wrong was the **scale**:
- `full_scale` defaulted to **1.0**, so the *newest* acquisition already sat at the ceiling and
everything above it clamped to the same pixel -- a display of one sweep and a display of five
were one white. It is now **1/(1 - 0.5) = 2.0**, the steady state of the default per-acquisition
fade, so one sweep reads half ink and white is reached only where acquisitions agree. A caller
whose fade settles elsewhere states its own sum (udpscope's is `1/(1 - factor)`, in its commit).
- `gamma` defaulted to **0.45**, the value `Style::intensity_gamma` wants where the measure is a
*dwell fraction*; on a *count* it spends most of the ramp on the last sweep (a quarter of the
scale mapped to 0.54). It is now **1.0 -- linear in sweeps**, because half as many sweeps must
read as half the ink.
The case that would have caught this is a **framebuffer** reading, not the grid's, and
`a_phosphor_density_is_drawn_under_the_traces` now makes it: it fades the grid to a quarter
through the time rule (`dt = tau ln 4`, asserted at 0.25 off the ink) and reads the same pixel
back, so the ramp is asserted where the eye sees it -- **`empty=21 full=83 quarter=54`** (red
channel), against the 199 / 199 the ceiling and a single sweep both read at the old defaults. A
full cell must be measurably above the background and above a quarter cell, and a quarter cell
above the background: three assertions on pixels, none of which the grid's own numbers can make.
Suite: **396 cases, 0 failed**, 4/4 ctest entries.
`Plot::set_density()` gives a plot a **phosphor density**: a screen-sized intensity image behind
its traces, into which each acquisition is deposited and which fades by a time constant or by a
per-acquisition factor. It is the library's and not a host's because the library is the side that
knows a trace's device geometry -- a host would be re-rasterising traces the renderer has already
staged, at a second derivation of the same mapping.
It is an **image and not a set of retained traces**, and that is the whole of its cost: one `f32`
cell per device pixel of the axes rect (`DensityGrid`), a cell's value a *number of sweeps*, so a
display of a thousand acquisitions costs what one costs and there is no depth to get wrong.
Measured on a 640x360 viewport's axes: **573x313 = 179 349 cells, 717 396 bytes** of grid, and one
byte per cell uploaded on the frame the grid moved.
- `Plot::accumulate_density()` deposits the acquisition on screen: every visible trace's window is
mapped to sample indices and gathered exactly as `Renderer::draw_traces()` maps it, then walked
column by column -- so the deposit is bounded by the axes' columns and not by the samples behind
them -- and a cell takes **1.0 once per trace per sweep**, however many of that trace's own
samples crossed it. Counting the hits instead put a single dense sweep at the ceiling of the
brightness scale and hid the sweeps behind it, and it made a cell mean "how much the beam moved
here" rather than "how many acquisitions were here"; the dedupe is a stamp per grid *row* (one
`u64`, keyed by trace and column) so two sweeps agreeing on a cell still count twice. Dwell is a
different question the library already answers (`Style::intensity_grading`).
- `decay_density(dt_s)` fades by the clock, `clear_density()` and switching the density off drop
the image at once. `DensityConfig` carries `full_scale` and `gamma`, applied on the CPU when the
grid changed: the value a host reads back through `ink()`/`peak()` and the picture it sees are
one derivation, and a frame whose grid did not move skips the conversion and the upload entirely
(`FrameStats::density_cells`/`density_upload_bytes`, the latter the *frame's* total over every
row, which one row's assignment used to overwrite).
- The renderer draws it as one attribute-less quad over the axes rect (`kDensityVS`/`kDensityFS`,
`DensityProgram`), over the grid and the invalid-span bands and **under the traces**, inside the
axes' clip: it reserves no layout space and nothing on it can be picked, exactly as
`Plot::set_invalid_spans()` does. `Style::density` is its tint.
- The two fades are the two questions a persistence display can be asked -- "still visible a second
later" and "the N-th back is gone" -- so the choice is the caller's.
Six cases, all in the suite: four plot-level ones (the grid's resolution and memory, the
per-acquisition schedule **584 -> 876 -> 1022** ink with `peak_steady=2.000`, `exp(-dt/tau)` off
the ink, and the deposit's own geometry including a NaN as a gap) and one on the real driver
(`a_phosphor_density_is_drawn_under_the_traces`: `cells=179349 uploaded=179349` on the moved frame
and `uploaded=0` on the identical one after it, with the trace's stroke keeping its own colour over
the ink). The suite is **396 cases, 0 failed** and all four ctest entries pass.
The controller has always tracked its selection; nothing drew it, so a Ctrl or
Shift drag framed a range with no feedback until the release.
The preview is the area the release will change, not what the pointer swept:
Ctrl frames the x range only, so a band across the plot is what it will zoom,
where the sliver a straight horizontal drag sweeps is only the gesture. The
plot carries the rect a host hands it -- it learns nothing about pointers --
and the renderer draws a low-alpha fill under a light border over everything
else inside the axes.
Intensity grading was gated on the column reduction, which is gated on a
density whose only job is killing the moire beat. Dwell has nothing to do
with that, so narrowing a window past the threshold turned the whole
phosphor model off and a discontinuity went from a third of a flat top's
ink to all of it in one pixel of zoom.
stage_trace_slopes() grades each segment by its own slope instead, with a
measure that agrees with the dense path at the boundary, and reports
itself as FrameStats::slope_traces -- disjoint from column_traces, so a
host asserting graded_traces == column_traces reads the same as before.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
A pixel column reduced from a dense envelope is honest about where the signal
went and silent about how long it was there: its min and max are identical
whether its samples fill the span or pile at its two ends, so a ramp and a pulse
are one band. Style::intensity_grading grades it instead -- bright where the
signal sits, faint where it only passes through -- by asking the gather for a
level finer than the pixel grid and reading the distribution from those
sub-buckets. Off by default: a change of look, not a correction of one.
The measure is dwell per row, each segment depositing one unit spread over the
rows it spans; counting +1 per row instead grades a sine's fast middle as
brightly as its rails, which is the pattern inverted. The accumulator is one
column wide (a full w*h density image is 33 MB at 4K) with each span written as
two delta writes, and normalisation is against the column's own peak -- a
frame-wide scale fades the trace out exactly where it moves fastest. Alpha is
quantised into tiers, one LineBatch and one draw call each, because the line
pipeline's colour is a uniform and a per-segment alpha is inexpressible.
The finer ask is floored at level 1 whenever the flat path would have used the
pyramid: level_for() answers 0 -- raw samples -- as soon as a sub-column falls
under one bucket, so an unfloored ask turns a moderately dense frame into an
O(visible) gather (measured at 172 samples a pixel: raw=1154 lod=0 and 6.4 ms a
frame against raw=710 lod=453 and 2.3 ms). Grading is a way of drawing a
reduction and may not undo it.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
An LOD envelope staged as a polyline moirés. level_for() sizes a bucket at one to
two pixel columns and Envelope emits two points per bucket, so a column carries
three or four near-vertical hairlines at as many sub-pixel phases; under src-alpha
compositing its brightness is then a non-linear function of those phases, the
pattern repeats at the beat between the bucket pitch and the pixel pitch, and a pan
sweeps the beat so the banding crawls.
draw_traces now reduces such a window onto the pixel grid: one exactly vertical
segment per integer column of the axes, from the lowest to the highest screen y any
gathered point put in it, flushed in framebuffer pixels with the snap on. One phase
for the whole trace, so there is nothing left to beat -- and the geometry becomes
O(axes.w) rather than O(buckets).
Gated on the gathered density rather than on the level (the raw path beats the same
way), and on Envelope alone. A hole stays a hole, disjoint neighbours are joined so
a fast edge keeps it, and a span under the stroke is drawn as the stroke since the
shader caps nothing. It is the one carve-out in u_snap's data/chrome rule: a column
is a pixel column, and the y bounds it reports are untouched.
Measured by the new test as the number of distinct coverage levels in the ink --
4 against the polyline's 126, with 627 segments against 60 054. A count of
partially covered pixels was tried first and separates nothing (577 against 652):
a 1.5 px stroke's fringe always spills into the next column.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The mark was placed at the *pointer's* x with the quoted sample's y, so with the
nearest-sample readout it sat off the datum horizontally -- and further off the deeper
the zoom. `CrosshairRow` now carries the data `x` the value belongs to and the marker is
placed from it, so in `NearestSample` the mark lands on the sample the panel is quoting.
`Style::crosshair_marker_px` (9) and `crosshair_marker_width` (1.5) replace the
renderer's hard-coded 3 px arm and 1 px stroke: a snapped mark lands among a trace's own
sample dots (`marker_px`, 3) and at that size would read as one more sample rather than
as the answer to the question the hover asks.
`CrosshairValue` makes the previous behaviour reachable rather than deleted. The two
modes answer different questions -- `NearestSample` "which datum is under the pointer",
`Interpolated` "what is the signal at this instant" -- and only the caller knows which it
is asking, so the plot offers both with the sample as the default.
`crosshair_interpolate()` is restored for it.
`Plot::crosshair_point()` is the lookup with both coordinates, public because a host that
draws instruments of its own needs the same snap and a second rule for it would drift
from this one; `crosshair_value()` delegates to it.
The readout's y was a linear interpolation between the two samples adjacent to
the pointer, which answers "what is the signal at this instant" -- a number the
series does not contain. On a source whose samples are already averaged or
decimated at ingest that is a value nothing measured, and at a zoom past the
sample spacing it is visibly not the datum under the pointer.
`crosshair_span()`'s `t` becomes a selector rather than a blend factor: the
nearer end wins, a tie reading the earlier sample (the bias the span already
takes for a degenerate pair). `t` is measured on the samples' own x through
`neighbours()`/`x_at()`, so "nearer" is exact on a non-uniform clock too, and
the lookup still reads level 0, so the value is a stored sample and never an
LOD bucket's aggregate. `crosshair_interpolate()` had no other caller and is
gone.
`CrosshairRow::marker` is placed from the same y, so the dot follows the
reported sample: past the sample spacing it sits beside the polyline rather
than on it, deliberately -- one y feeding both keeps the mark and the number
from ever disagreeing.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
`Style::marker_below_samples` (50 by default, `0` for never) draws a `marker_px` dot on every
*raw* sample when the visible window holds at most that many, in the trace's own colour. Zoomed
in far enough to see individual samples, the polyline between them stops being the shape a reader
is looking at -- a handful of points across a whole panel ARE the picture -- and a dot makes each
one a place rather than a bend in a line. Level 0 only: above it the geometry is pyramid buckets,
which are not points anyone can find.
Test: a_sparse_window_marks_its_samples -- the same flat series at ten samples and at a hundred.
Ten gives exactly ten dots, evenly spaced (their centres are checked against the panel's own
step, so "they are the samples" is asserted rather than "something was painted"); a hundred is
past the threshold and gives none. The dots *group* into runs of columns (a marker is `marker_px`
wide), which is how the test reads one marker per sample -- counting columns read three.
Cursors took `Style::line_width_px` and were always solid, so a host had no way to place a
*readout mark* on the data (a measured level, a peak, a pulse's edge) without it reading as
another instrument framing the plot. `set_cursor_stroke(id, width, dash)` gives it its own, the
same shape as `set_cursor_color()`: per cursor, because one plot holds both kinds.
`width <= 0` means the style's default for that cursor (`handle_width_px` while hovered or
grabbed, `line_width_px` otherwise), and a hover can only make the stroke *thicker* -- the cue
that the pointer has hold of something must not be the thing that hides it. `dash.off <= 0` is
solid, the convention a trace's, the crosshair's and the grid's dash already share.
Test: a_cursor_stroke_can_be_thinner_and_dashed -- a default cursor and a thin dashed one in the
same frame. The dashed column alternates ink and gap (153/151 at the 4/4 the test asks for)
while the solid one has no gaps; the ink laid down over each stroke's whole run of columns is
less for the thin one. That last assertion is deliberately *not* a single-column peak: a cursor
is a data position and is not snapped, so the solid stroke's ink split across two columns and
read *lighter* than the thin one -- which is how this test first failed.
grid_major 0x2A3038 -> 0x39414C, still well under axis_line's 0x8A94A6, so the three-way
hierarchy the eye reads stays minor < major < axis. Already fully opaque, so "more
prominent" meant a brighter colour and nothing else.
The tests that classify grid pixels take their colours from the style, so they followed
the change; the one hardcoded mention (grid_pixel()'s doc comment) is updated, and the
minor's relationship to the major is now stated as an order of magnitude rather than the
fifth the old colours happened to give. 361 cases, 0 failed.
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.
The minor class is now three things the style states explicitly:
- grid_minor_width 1.0 -> 0.75, a sub-pixel logical width. At 1x that is expressed as
reduced coverage through the anti-aliased ramp; on a HiDPI display the line is
literally narrower.
- grid_minor_dash_on/_off (new, 3/3): dashed by default, off == 0 draws it solid, the
same convention as the crosshair's and a trace's dash. The dash is a fragment-shader
discard, so no geometry is added -- the vertices are the line either way.
- grid_minor carries alpha (0x1C2126 at 50%), and the line shader applies u_color.a,
so it blends into the background instead of sitting on it.
Together that is about a fifth of the ink a major line leaves along its length, which
is what gives the eye one grid to read instead of two. The major class is untouched:
it stays the structure.
Tests: a new pixel test (the_minor_grid_is_thinner_dashed_and_dimmer_than_the_major)
asserts a major column is inked top-to-bottom while a minor one alternates, and that
the minor's ink sits closer to the background than the major's. Three existing tests
counted the minor class as "most of eight rows" of its exact colour; two now take
their floor from the style (minor_line_ink: width x alpha x duty), and the width test
states the dash and alpha away because widths are its subject. 361 cases, 0 failed.
Breaking: Series::gather emits x relative to x0() and takes a GatherMode;
consumers reconstruct absolute x in f64 before mapping (series.hpp documents
the contract).
Bugs, each pinned by a fail-before regression test:
- render: f32 staging quantised epoch-time x into 128 px columns; every
data-space draw now stages to_space(x) - to_space(origin) with the origin
folded into the offset uniform in f64 (epoch/zero-origin twins rasterise
identically)
- ticks: major test was ULP-fragile and blanked epoch axes; majors are an
integer-index test and are never truncated by the minor cap
- gather: LOD envelope emitted buckets straddling [begin,end); ceil/floor
bucket edges + raw lead/trail spans
- live follow: log x axes held a linear span and zoomed every frame; span and
offset now live in axis space (plot + both figure link-group paths)
- input: a zero-notch wheel disarmed auto_scale on every axis; dy==0 pans x,
factor == 1 / inf refused in wheel and drag zoom
- render: the single text flush painted legend names over the crosshair
readout panel; two batches, under flushed before the panel
- data: NaN dropped from min/max unless it seeded the fold; non-finite
samples are now excluded from every aggregate and from cached bounds
Robustness: viewport normalisation (no clamp UB), non-finite range refusal,
pyramid level cap at 57 (no shift/div UB through public gather), rounded ring
capacity documented + pre-sized, GL blend func and UNPACK_ALIGNMENT
save/restore, Renderer::release_gl() teardown, u64 buffer epochs, annotation
captions clamped, button-aware GLFW row capture, cached figure row rects (no
per-event layout), ruler-pair measurement refused, span-nonfinite guards in
axis_ends.
Features: XY series (sorted x, binary-search neighbours/snap, LOD over sample
index); StreamSeries::freeze(); ArraySeries::push(); per-trace dash and
markers (scatter at width 0) with auto or literal stride; GatherMode
Envelope/Nearest/Mean via bucket_sum/sumsq; Style::line_aa_px; per-glyph text
colour (readout values in trace colour); wheel-dx x pan; arrow-key pan;
Escape cancels a drag and restores captured ranges; modifier-masked box zoom
(Shift both, Ctrl x, Alt y); double-click reset via PointerEvent::time_s;
ImGui key repeat unified; pinch gestures on Qt and GTK; pixelDelta wheel path
and DevicePixelRatioChange handling; GNUInstallDirs, SOVERSION,
SameMinorVersion, generated plot/version.hpp, relocatable plot.pc, licence
install, FILES_MATCHING, GitHub Actions CI (debug/coverage/asan).
Suite 283 -> 350 cases, 0 failed; coverage 93.9% (3730/3971) over 24 files,
floor 80/90 held; ASAN preset suite green; all four demos run headless (the
GTK scripted-resize check needs a window manager and fails identically on the
parent commit); readme_main.cpp committed and compiles with the README
command; pkg-config and find_package consumption verified from a relocated
install prefix.
`remove_region()` now clears or decrements the published `hot_.region` with the
cache it re-indexes, so a frame after a removal highlights the region the handle
actually names. The new case covers both branches (a handle above the hole
follows it down, the removed region's handle is cleared with its zone), and the
coverage document is regenerated by the command that measured it: 95.2% of src/,
every file at or above 80%.
Final whole-branch review fixes.
1 (Important). Plot::measurement() cached on (series pointer, sample count)
only, so a saturated StreamSeries -- whose size() stays pinned while x0()
advances every acquire() -- froze its readout on the first saturated frame
until a cursor moved. The cache key now carries the series' x0() (new
MeasureSlot::last_x0), so a sliding window recomputes and a static series
still does not: asserted with a new macro-gated counter hook
(plot/detail/measure_count.hpp, PLOT_COUNT_MEASURE_RECOMPUTES, wired like the
pyramid and ring counters, absent from the release library). New case
saturated_stream_measurement_tracks_the_sliding_window: saturated ring,
stationary cursors, ramp in absolute x -- zero recomputes for repeated reads
with nothing moving, one per acquire while it slides.
2-3, 9. Spec: 4.3 described a GL_LINE_STRIP path that does not exist (there is
one rasteriser -- instanced anti-aliased quads -- and dense_threshold_spp
selects the gather level, not a primitive); 8 named a PLOT_GL_CHECK() macro
that exists nowhere, and stated no exception policy. The table, the field's
comment and 8 now describe the real gather-level threshold, the real
glGetError() drain plus the stores' drop-to-orphan fallbacks, and the real
policy: library failures return Status/bool, allocating entry points propagate
std::bad_alloc.
4. The frame handed back neither GL_UNPACK_ALIGNMENT (init() sets 1 for the
atlas) nor the clear colour (queried once with a new glGetFloatv entry).
end_frame() restores both and renderer.hpp lists them in the residue
contract.
5. draw() kept a host TextBackend for layout while drawing with its own font.
The renderer now installs its font unconditionally -- strips and labels measure
and draw with one backend -- with a debug-build diagnostic if a different one
was set, and Plot::set_text_backend() is documented as the no-renderer path.
New case renderer_measures_the_labels_it_draws asserts layout, measuring and
the drawn label's ink agree.
6-10. draw()'s style overwrite stated at the call site; the load-sensitive
wall-clock bound in test_measure.cpp replaced by a counter assertion (same
interval, same fold count over a 512x length ratio); dump_frame_ppm drops
noexcept and says why it may allocate; error.txt ignored (not this project's,
not generated here) rather than deleted.
Verified: debug and release build clean (-Wall -Wextra -Wpedantic -Wconversion
-Wshadow), 4/4 ctest entries pass, 130 cases pass (was 127), umbrella and
header self-containment checks pass, and no counter symbol in the release
archive.
Findings from the review of the sliding-window change:
- A level >= 1 gather emitted only complete, wholly-retained buckets, so the
geometry stopped at the last bucket boundary inside the range: the newest
samples of a live window -- up to `span - 1` of them, about a pixel at the
level level_for() picks -- were not drawn, on the right edge, which is the
part of a scrolling trace that gets watched, and the oldest retained samples
fell outside the geometry too (the level-1 gather of a sliding window started
51 samples in). The two partial edge buckets are now gathered as raw samples
through copy_raw() and bracketed around the envelope -- the same exactness
split reduce/sum/sumsq already use -- so the emitted geometry spans the
requested range exactly. The pyramid still stores no partial bucket, so append
stays amortised O(1); at 1 MS/s the demo stages 511 extra sub-pixel segments
and still draws at 1.05 ms/frame, and the screenshot's trace now reaches one
column further at each end.
- `drop_before`'s doc claimed a no-op "for an empty pyramid": false, and
backwards -- the restart branch is exactly what an empty pyramid hits, which is
what StreamSeries::acquire() depends on when the ring saturated before the
first acquire. The precondition is `absolute_sample <= window_base()`, and the
restart is documented as what it is.
- The pyramid's memory figure was 3x optimistic: an entry is 24 bytes (MinMax
plus the exact sum/sumsq doubles), so level 0 costs 0.375n bytes and the whole
pyramid ~0.75n -- about 19% of 4-byte raw samples, not the 6% a min/max-only
index would cost. Corrected in the header, spec 4.2 and 4.5, with the trade
stated (the doubles buy O(log n) sum/mean/RMS/integral from whole buckets),
and the plan's Task 3 sketch no longer describes per-level prefix sums, a
level() span or an unaligned-end widening that does not exist.
Tests: `gather_at_a_lod_level_reaches_both_ends_of_the_window` pins that the
first and last emitted points are the oldest and newest retained samples -- with
spike values no bucket envelope can produce -- at level 1, at the level a dense
view picks, and for a static series whose end is unaligned. It fails against the
previous commit (first point at the first whole bucket), and so does the sliding
gather mapping check, which now accounts for the raw edge pairs.
Frame cost was O(capacity x levels) once the ring saturated: acquire() saw the
window base move (it always moves once the ring wraps), cleared the pyramid and
re-appended all 1,048,576 retained samples, and append() walked every live level
with no early stop -- 13,647,936 bucket merges and 73.8 ms per frame at a
1M-sample window, scaling with the window, which is what capped the 1 MS/s demo
at 32-58 ms/frame while it was still filling.
Index the pyramid in absolute bucket space instead. Level l bucket k covers the
samples [k * (kBucket << l), (k + 1) * (kBucket << l)), so a bucket's span never
depends on where the window starts and the window can slide over the aggregate
instead of forcing it to be rebuilt. Each level keeps its complete buckets in a
deque plus the absolute base of its first entry; append() folds a sample into the
single in-progress level-0 bucket and, only when that bucket completes, cascades
upward, stopping at the first level that gains no new entry; a new drop_before()
slides the window by discarding leading buckets and remembers where the window
begins. acquire() is now: drop to the new base, then append exactly what was
produced -- 256 merges and 0.034 ms per frame for 8192 new samples, independent
of the window, and memory bounded by the window (32,767 entries after 50M samples
through a 1M ring, the same as after 10M). A producer that laps the consumer by
more than capacity() between two acquires restarts the window rather than
splicing a gap.
Interface: `level(l) -> std::span<const MinMax>` cannot survive a ring and is
replaced by level_base/level_size/bucket in absolute bucket coordinates, plus
window_base/window_end/drop_before. The *_buckets queries stay in window
coordinates (index 0 = oldest retained sample), so Series keeps its window
semantics for callers; its exactness split now aligns in absolute space
(window_base() + the ceil/floor) because the bucket grid is absolute. The sums
move from per-level prefix sums to per-bucket ones -- the only representation a
sliding front can keep exact -- and StreamSeries::y_bounds() becomes
reduce(0, size()), exact in O(log n + kBucket) per frame instead of a scan of the
window. The pyramid owns the published window now, so there is no second copy of
base/end to keep in step, and gather() emits only buckets that are both complete
and wholly retained.
Tests: test-only merge counters (mirroring PLOT_PYRAMID_COUNT_COVERS, absent from
every non-test build) pin an acquire proportional to production and independent
of capacity, an append cost amortised over 16 live levels, and bounded memory;
brute-force checks pin absolute bucket aggregates across slides, sliding-window
reduce/sum/sumsq over unaligned ranges, and the level-1 gather mapping. The demo
now draws 946 samples/px at 1.03 ms/frame (967 fps) where the saturated window
cost 54.96 ms/frame (18 fps).
Every pixel-to-data conversion in the controller now maps through the
same rectangle Plot::x_transform/y_transform are built from:
- pan() derives the scale from layout().axes.w/.h, so a drag moves the
grabbed data point by exactly the drag distance (the viewport model
stranded it behind the pointer by the label-strip fraction).
- apply_box_zoom() clamps the selection to layout().axes and sets each
range from the transforms (x_transform().to_data_x(l/r), y_transform()
.to_data_y(bottom/top)); the selection's bottom edge is the y minimum.
- end_drag() takes the release event and refreshes the selection from its
coordinates before applying, so a host that reports only press and
release still frames the box instead of degrading it to a click.
- axis_zoom() uses a per-pixel sensitivity (200 px == one decade) rather
than reusing the wheel's per-notch constant, so a pixel drag is no
longer treated as a wheel notch.
Tests now assert the grab-and-drag invariant through the transform (the
old pixel-fraction assertions certified the viewport defect), add a
press+release-only box zoom, and pin the axis-zoom magnitude.