Commit Graph
23 Commits
Author SHA1 Message Date
Martino Ferrari 133cd680ce feat(plot): set_annotation(), so a readout annotation can be moved
A host whose annotation is a readout -- the point where a cursor crosses a
trace -- has to move it every frame, and `add_annotation()` only appends:
the id would change every frame and the annotation count grow without bound.
The setter keeps the id, ignores a stale one exactly as the cursor setters
do, and pairs with parking (`visible = false`) since there is no
`remove_annotation`.

Tested by `an_annotation_can_be_moved_by_id` in tests/test_plot.cpp.
2026-10-02 15:00:50 +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 ed3a8ce645 licence: MIT LICENSE and SPDX identifiers in every first-party file
The repository had no licence at all, so every source was all-rights-reserved
by default. Add the verbatim MIT text at LICENSE (Copyright (c) 2026 Martino
Ferrari) and a two-line SPDX header to each of the 113 first-party sources --
48 include/plot, 26 src, 10 adapters, 5 examples, 21 tests, 3 tools -- so the
grant travels with each file rather than relying on the root document alone.

Scripts keep their shebang on line 1; the SPDX lines follow it. Genuinely
vendored code is untouched: third_party/stb_truetype.h carries no header of
ours. src/render/embedded_mono_font.cpp is generated, so tools/fonts/gen_atlas.py
emit() now writes the two lines into its header comment and the file was
regenerated -- gen_atlas.py --check passes and the regeneration adds only those
comment lines to the previously committed file.

README's provenance section now states MIT and links LICENSE.

No code, test or public API change.
2026-09-28 22:13:40 +02:00
Martino Ferrari d2ba67921e fix(input): cursors follow the pointer while dragging and snap on drop 2026-09-28 20:46:14 +02:00
Martino Ferrari a14f333dbe fix(plot,render,glfw): a hue-neutral crosshair, an em-dash placeholder, one layout per figure event, and a usable readout switch 2026-09-28 19:38:30 +02:00
Martino Ferrari 8e27e22ebe test(plot): pin the crosshair's lookup, readout, placement, sync and clip 2026-09-28 19:07:22 +02:00
Martino Ferrari 63f9655ae1 feat(plot): horizontal rulers alongside the vertical cursors 2026-09-28 17:18:43 +02:00
Martino Ferrari 7cc06f07f5 fix(input): refuse non-finite region bounds, keep narrow regions movable, republish hot handles 2026-09-28 14:08:11 +02:00
Martino Ferrari 4e0e0ae0b3 test(plot): region removal re-indexes the ids and the statistics cache 2026-09-28 13:53:26 +02:00
Martino Ferrari f423cf6f99 test(input): region, cursor and band-edge direct-manipulation cases (spec §5) 2026-09-28 13:42:26 +02:00
Martino Ferrari 7d2349555d fix(plot): a degenerate range cannot strand a live axis, and the demo's follow check fails the run 2026-09-28 13:14:50 +02:00
Martino Ferrari 77e861f79e feat(input): live x follow with relative zoom and mouse panning 2026-09-28 13:01:48 +02:00
Martino Ferrari eaae07d8e0 feat(render): independent major and minor grid classes tied to the tick set 2026-09-28 12:42:22 +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 2d51b978d7 feat(plot): per-trace legend with measured layout and swatches 2026-09-28 11:56:06 +02:00
Martino Ferrari daeb31b1ea feat(plot): vertical y-axis label via a rotated text run 2026-09-28 11:14:36 +02:00
Martino Ferrari 5ca3587325 feat(render): anti-aliased embedded Adwaita Mono atlas, generated and committed 2026-09-28 10:40:23 +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 1f5aa11de2 fix(render): floor the axes so chrome cannot erase it, one title row per titled axis
Two findings on the pass-1 fix:

- The reserved strips were clamped only to the viewport, so a title wider than a
  narrow panel took the rest of it and the axes collapsed: draw_traces() returned
  on a zero-width rect (no trace drawn, nothing reported) and a zero-height one
  flattened every trace onto a single row. The chrome is now capped at half the
  viewport -- the label strip is served first, the title strip takes what is left
  of that allowance -- an over-long title is clipped to its strip by a walk that
  both measures and stages it, and draw_traces() tests the height as well as the
  width.
- Every titled y axis was staged at the same centred position inside one strip, so
  a Left/Right pair of titles overprinted. Each titled axis now owns a text line:
  the x strip grows one line per titled x axis (x_title_height), and the y runs
  stack down the axes-tall strip, which needs no extra width for it.

Tests: `narrow_panel_with_a_long_y_title_still_draws` and
`short_panel_with_an_x_title_still_draws` pin the floor (the axes keeps half the
panel and the trace still covers the columns, with the title clipped into its
strip), `titled_axis_pairs_get_their_own_rows` pins one text row per titled axis
in both directions, and `layout_stacks_one_title_line_per_titled_axis` pins the
strip sizing. All fail against the pass-1 sources.
2026-09-27 23:33:06 +02:00
Martino Ferrari 8f45108559 fix(render): bind identity into the epoch, titles into reserved strips, one layout per frame
Four review findings on the Task 7 renderer:

- InstanceStore::epoch() is derived from gl::Buffer::generation() -- advanced by
  every allocation, deletion and move -- plus the slot the instances were written
  to, so the orphan fallback of grow_persistent() can no longer leave the vertex
  array's attribute pointers bound to a deleted buffer: the epoch reports the new
  identity and upload() re-points them before the draw.
- PlotLayout reserves y_title_area/x_title_area outside axes (sized from the text
  metrics) and draw_titles() draws in them, so a title can no longer land on the
  data, whatever the envelope does.
- draw() computes plot.layout() once and hands it to the helpers, instead of six
  times per frame, each of which rebuilt the ticks and the grid.
- A refused fence is no longer dropped: mark_drawn() falls back to the orphan path
  and reports the failure rather than rotating through an unsynchronised slot.

New smoke cases force both refusals through the GL entry points (glMapBufferRange,
glFenceSync) and assert the pixels plus the live transfer mode; the title case
asserts the title colour is absent from the bands inside axes and present in both
strips. All three fail against the pre-fix sources.
2026-09-27 23:19:31 +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 93fc06f050 feat(plot): nice-number ticks, axes, grid, traces, cursors, measurement queries 2026-09-27 22:21:22 +02:00