Commit Graph
25 Commits
Author SHA1 Message Date
Martino Ferrari fba8d985cf feat(cursor): a cursor's stroke is its own -- width and dash
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.
2026-10-03 15:22:01 +02:00
Martino Ferrari c4c0a49466 style(grid): brighter major grid
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.
2026-10-03 14:42:06 +02:00
Martino Ferrari 01e410bd74 style(grid): the minor grid is thinner, dashed and blended into the background
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.
2026-10-03 14:31:57 +02:00
Martino Ferrari 38ef459897 feat(plot)!: harden pipeline, XY series, dash/markers, touch, packaging
CI / debug (tests + all adapters) (push) Canceled after 0s
CI / coverage (floor + measured table) (push) Canceled after 0s
CI / asan (AddressSanitizer) (push) Canceled after 0s
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.
2026-09-30 17:32:06 +02:00
Martino Ferrari f132a5ec92 test(plot): pin the hot-handle re-index on region removal, refresh coverage
`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%.
2026-09-28 22:42:56 +02:00
Martino Ferrari 19c8d4368d docs(coverage): regenerate the measured table from the command that measures it 2026-09-28 22:00:57 +02:00
Martino Ferrari b7d0f901cc docs: a user guide covering getting started, architecture, embedding, data, interaction, styling and testing 2026-09-28 21:50:19 +02:00
Martino Ferrari e9985eeeaf test(coverage): a reproducible coverage preset, report and floor check 2026-09-28 21:23:22 +02:00
Martino Ferrari 41b79313a7 docs: plan for the user documentation and a coverage floor 2026-09-28 21:11:43 +02:00
Martino Ferrari 663b57c679 docs: spec and plan for figure y-gutter alignment, crosshair line and style, and cursor dragging 2026-09-28 19:57:19 +02:00
Martino Ferrari b0f9fbbb35 docs: spec and plan for data clipping, horizontal rulers and the crosshair readout 2026-09-28 16:32:18 +02:00
Martino Ferrari 69495a3799 fix(render): convert Trace::width through the UI scale, and fold the controller's tolerance conversion into Style::device() 2026-09-28 12:26:06 +02:00
Martino Ferrari 7500fc90d2 feat(render): logical style metrics with a UI scale, so chrome has a consistent physical size 2026-09-28 12:17:53 +02:00
Martino Ferrari 1f03631e98 docs: spec and plan for DPI-consistent sizing, grid classes, and live follow with zoom and panning 2026-09-28 11:56:26 +02:00
Martino Ferrari e1f6b61b7d docs: atlases are fixed-cell grids; state the constraint instead of discovering it 2026-09-28 10:10:41 +02:00
Martino Ferrari 90e17a0a11 docs: spec and plan for vertical y label, fonts, legend, draggable areas and synced multi-plot 2026-09-28 09:32:02 +02:00
Martino Ferrari a08b184481 fix(plot,render,gl): live streaming measurements, handed-back GL state
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.
2026-09-28 03:42:34 +02:00
Martino Ferrari fb2c2f5e4d fix(data): draw the partial edge buckets at LOD levels, correct the pyramid's memory and precondition docs
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.
2026-09-28 00:19:59 +02:00
Martino Ferrari 0ac5d47fa6 perf(data): absolute-space pyramid so acquire() is O(produced), not O(window)
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).
2026-09-28 00:05:15 +02:00
Martino Ferrari e0cdd95a3b fix(input): pan and box zoom through layout().axes, not the viewport
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.
2026-09-27 22:41:42 +02:00
Martino Ferrari 606e40a9f1 fix(plot): single-rounding clock labels, log-axis fits that refuse non-positive data 2026-09-27 22:28:56 +02:00
Martino Ferrari 8f438b049b docs: repoint plan call sites off the removed MinMaxPyramid::reduce 2026-09-27 21:45:19 +02:00
Martino Ferrari c93c98002b docs: exact range queries belong on Series, not on the bucket-grained pyramid 2026-09-27 21:20:22 +02:00
Martino Ferrari 8a7e9e97f8 feat: project skeleton, core types and math with transform tests 2026-09-27 20:54:19 +02:00
Martino Ferrari e4051bc6b5 docs: design spec and implementation plan for the plot library 2026-09-27 20:50:40 +02:00