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.
This commit is contained in:
Martino Ferrari committed 2026-09-30 17:32:06 +02:00
1 parent f132a5ec92
commit 38ef459897
77 files changed
+5905 -688

No files matched your search

+80 -4
View File
@@ -3,6 +3,8 @@
#pragma once
#include <vector>
#include <plot/core/rect.hpp>
#include <plot/core/types.hpp>
#include <plot/input/events.hpp>
@@ -47,14 +49,21 @@ using plot::RegionGrab;
/// | gesture | mode | effect |
/// |---|---|---|
/// | shift + left drag | `BoxZoom` | both ranges from the selection rect |
/// | ctrl + left drag | `BoxZoom` | x range only from the selection rect |
/// | alt + left drag | `BoxZoom` | y range only from the selection rect |
/// | left drag on a handle | `MoveCursor` / `MoveRegion` / `ResizeRegion*` | move the handle the press grabbed |
/// | left drag elsewhere | `Pan` | translate both ranges |
/// | right drag | `Pan` | same, for hosts that reserve left |
/// | middle drag on the x label strip | `AxisZoomX` | x-only zoom |
/// | middle drag on the y label strip | `AxisZoomY` | y-only zoom |
/// | wheel (shift: x only, ctrl: y only) | -- | zoom about the pointer |
/// | wheel `dx` (no `dy`) | -- | pan x, 5% of the span per notch |
/// | `+` / `-` | -- | zoom about the plot centre |
/// | `f` | -- | auto-fit every axis |
/// | Left/Right | -- | pan every x axis, 10% of its span |
/// | Up/Down | -- | pan every y axis, 10% of its span |
/// | Escape | -- | cancel the active drag: the ranges captured at `begin_drag` are restored, a box zoom applies nothing |
/// | double click (same button, 0.35 s, 4 px, `PointerEvent::time_s`) | -- | re-fit every axis; the second press begins no drag |
///
/// The right button pans *and* grabs: both are buttons a host drives drags with, so
/// neither one is singled out for handles. Middle keeps its own role on the label
@@ -74,9 +83,10 @@ using plot::RegionGrab;
/// Cursors and regions are *grabbable*: a press on one enters a drag that moves it,
/// with no modifier, and a press anywhere else pans exactly as it always has -- so
/// the new gesture is a superset of the old one and every host that drives a drag
/// keeps panning. `Ctrl` is no longer needed for a cursor move and does not change
/// what a press on one does; it only means the same thing it always did (a press
/// away from a handle pans, with or without it).
/// keeps panning. `Ctrl` and `Alt` no longer fall through to that press: they are
/// the modifier box zoom's masks now (see the gesture table -- Ctrl frames the x
/// range only, Alt the y range only, from the selection, over whatever was under
/// the press), which is why a *plain* press is the one that grabs a handle or pans.
///
/// One hit test runs for both, in this order, and that precedence is the whole rule:
///
@@ -203,9 +213,18 @@ class Controller {
/// zeroed: a zero-notch zoom changes no range but still goes through the range
/// setters, which clear auto scale -- so a sanitised amount would quietly disarm
/// auto-fit. An unusable amount leaves the plot exactly as it was; an unusable
/// *position* only loses its anchor, falling back to the last known one.
/// *position* only loses its anchor, falling back to the last known one. The
/// same refusal covers `dx`, which pans when `dy` is zero.
void on_wheel(const WheelEvent& e);
void on_key(const KeyEvent& e);
/// A pinch frame from a touch host (see `GestureEvent`): `scale` zooms about
/// `(x, y)` exactly like a wheel notch pair would -- the same setters, the
/// same live-axis behaviour, the same guards -- and `(dx, dy)` pans by the
/// focal point's movement. A frame with `scale == 1` and no movement is
/// refused outright, for the same reason a zero-notch wheel is: the setters
/// would clear `auto_scale` for no visible change. An amount that is not a
/// number is refused rather than sanitised.
void on_gesture(const GestureEvent& e);
DragMode drag_mode() const noexcept { return mode_; }
/// The current box-zoom selection in pixels; meaningful while BoxZoom.
@@ -237,6 +256,35 @@ class Controller {
void update_drag(const PointerEvent& e);
void end_drag(const PointerEvent& e);
/// The ranges (and, for x, the live offsets and auto-scale flags) every axis held
/// when a drag began -- what Escape restores. Captured at `begin_drag`, before the
/// gesture can move anything.
void capture_ranges();
/// Escape: the active drag is cancelled -- the ranges captured at `begin_drag`
/// are restored, a box zoom applies nothing, and the grab state is cleared. The
/// restore goes through the plot's own setters, so a live axis is restored as the
/// span and offset it held (the setters' native reading) rather than as positions
/// that the next frame's follow would overwrite anyway.
void cancel_drag();
/// Re-fits every axis (`auto_fit_x()` plus one `auto_fit_y()` per y axis), which
/// re-arms auto scale with the fit: the double-click reset.
void fit_all();
/// True when `e` is the second press of a double-click: the same button, within
/// 0.35 s (both timestamps > 0 -- a host that leaves `time_s` at 0 never produces
/// one) and within 4 logical px of the recorded press. Records `e` as the press a
/// third one would be measured against either way; a detected double-click clears
/// the record, so a third press starts a fresh gesture rather than re-firing.
bool double_click(const PointerEvent& e);
/// Pans every x axis by `fraction` of its own span, in axis space (log-aware:
/// a log axis translates multiplicatively). A positive fraction scrolls toward
/// larger x. The wheel's `dx` path and the Left/Right arrows share it.
void shift_x_axes(f64 fraction);
/// Same for every y axis: a positive fraction scrolls toward larger y (the Up
/// arrow). Screen y is inverted, so this is *not* the sign `pan()` derives from a
/// drag's `dy` -- an arrow says where to look, a drag says where the data goes.
void shift_y_axes(f64 fraction);
void pan(const PointerEvent& e);
void box_zoom_update(const PointerEvent& e);
void apply_box_zoom();
@@ -317,6 +365,34 @@ class Controller {
Rect selection_{};
f32 origin_x_ = 0.0f;
f32 origin_y_ = 0.0f;
/// The box-zoom mask, resolved once at `begin_drag` from the modifier table
/// (Shift = both, Ctrl = x-only, Alt = y-only, any other combo = both): which
/// axes the selection's release frames. `apply_box_zoom()` reads these for the
/// per-axis application *and* for the click threshold -- a masked-out dimension
/// of a thin selection does not make the gesture a click.
bool box_zoom_x_ = true;
bool box_zoom_y_ = true;
/// The range state a drag began with, per axis in order: what `cancel_drag()`
/// restores. Empty while no drag is open.
struct SavedX {
Range range{};
f64 offset = 0.0;
bool auto_scale = false;
};
struct SavedY {
Range range{};
bool auto_scale = false;
};
std::vector<SavedX> saved_x_;
std::vector<SavedY> saved_y_;
/// The last press seen, for the double-click reset: the button, the host
/// timestamp, and the position it was made at. `has_click_` is false until the
/// first press arrives.
bool has_click_ = false;
int click_button_ = -1;
f64 click_time_s_ = 0.0;
f32 click_x_ = 0.0f;
f32 click_y_ = 0.0f;
u64 drag_cursor_ = kInvalid;
/// The region drag's state. `drag_region_` is the region and `drag_zone_` the zone
/// the press resolved, kept for the whole gesture so the highlight a grab started