docs: spec and plan for data clipping, horizontal rulers and the crosshair readout

This commit is contained in:
Martino Ferrari committed 2026-09-28 16:32:18 +02:00
1 parent 1b153b2ec3
commit b0f9fbbb35
2 files changed
+202

No files matched your search

@@ -261,3 +261,66 @@ With `Live`, the visible window is `[newest - offset - span, newest - offset]`,
- **Static plots are unaffected.** `XFollow::Off` is the default, and a plot whose series is not streaming behaves exactly as it does today, including `auto_scale` on the y axis.
The y axis keeps its existing `auto_scale`; this section governs the x view of a streaming series.
---
## 13. Clip the data to the plot area (defect)
**This is a bug, reported from use: "you can see signals outside the plot area."** `Renderer::begin_frame` scissors the *viewport*, and traces are drawn through the axes transform, so any sample whose y lies outside the visible range maps outside `axes` and is drawn anyway — over the y label strip, the y title strip, and in a `Figure` straight into the neighbouring row. The same applies to grid lines at a tick outside the range, region fills and their bounds, cursors and the measurement band.
The fix is a scissor to `layout().axes`, in framebuffer pixels and intersected with the viewport, around every pass that draws *inside* the axes: grid, regions, traces, cursors, and the measurement band. The passes that draw *outside* it by design — tick labels, axis titles — must run with the axes clip released, or they would be erased. So the frame alternates: viewport clip → axes clip for the interior passes → back to the viewport clip for the label and title passes.
This is a state change, not a geometry change: no vertex data moves, no CPU clipping is introduced, and the data-space invariant is untouched.
Verification is a pixel test with a discriminating predicate: a trace whose values lie far outside the y range must produce **no** trace-coloured pixel outside `axes` — specifically none in the label or title strips — and, for a two-row `Figure`, none inside the neighbouring row's rect. It must fail against the current code.
## 14. Horizontal rulers
Cursors are vertical only: `Cursor` carries an `x` and the drag path moves it along x. Reading a value off the y axis needs the same instrument turned 90°.
`Cursor` gains an orientation and a second coordinate, and the existing grab/drag/hover path handles both:
```cpp
enum class CursorOrientation { Vertical, Horizontal };
struct Cursor {
CursorOrientation orientation = CursorOrientation::Vertical;
f64 x = 0.0; // meaningful for Vertical
f64 y = 0.0; // meaningful for Horizontal
CursorSnap snap = CursorSnap::NearestSample; // Vertical
HRulerSnap h_snap = HRulerSnap::Free; // Horizontal: Free | NearestTick
Color color = Color::hex(0xFFB020);
std::string label;
u32 axis_x = 0, axis_y = 0;
bool visible = true;
};
```
Horizontal rulers span the axes width, are grabbable and draggable along y with the same hit radius as a vertical cursor, and snap either freely or to the y tick set (`NearestTick` — a y sample has no meaning, since the series is sampled in x). The plot gains `add_h_ruler(f64 y, Color)` / `set_cursor_y(u32, f64)` alongside the vertical pair, and `hovered_cursor`/`grabbed_cursor` report both orientations. A vertical and a horizontal cursor are indistinguishable to the drag machinery except in which coordinate they mutate, so there is one hit-test path, not two.
## 15. Crosshair with a per-signal hover readout
Following the pointer inside `axes` should show the data under it: a crosshair at the pointer's x (and optionally its y), a marker on each visible trace at that x, and a readout panel listing each trace's name and value there — the same information a cursor gives, but continuous with the mouse and available per signal.
```cpp
struct Crosshair {
bool active = false;
bool vertical = true, horizontal = true; // which lines to draw
bool show_readout = true;
f64 x = 0.0, y = 0.0; // data coordinates
};
// on Plot:
void set_crosshair(f64 x, bool active); // vertical line + per-trace readout
void set_crosshair(f64 x, f64 y, bool active);
const Crosshair& crosshair() const;
void clear_crosshair();
```
**Value lookup.** The readout's y for a trace is obtained by **linear interpolation between the two samples adjacent to the crosshair's x**, and is documented as interpolated rather than exact — the opposite convention from `Series::reduce`/`sum`/`sumsq`, which are exact over a sample range. The distinction matters, so it is stated on both. A sample lookup uses `snap`-style index arithmetic, is O(1), and never scans: the crosshair updates on every pointer move and must stay cheap.
**Synced across plots.** The crosshair's x is a *data* coordinate, so one hover can drive every row: `Figure::set_crosshair(f64 x, bool active)` applies the same x to every row, and each row's readout lists its own traces at that x. That is what "synced if multiple plots available" means — one pointer position, one time, N rows of values. A figure with rows at different sample rates still shares the x, because the x is the shared axis.
**Painting.** Lines in a `style.crosshair` colour (default distinct from `cursor`), thin and distinguishable from the grid; a small marker at each trace's intersection; the readout panel placed inside `axes`, offset from the pointer, flipped to stay within the axes rect near an edge, with a swatch and value per visible named trace, reusing the legend's row measurement rather than growing a second one. An unnamed trace still contributes a value row (it has a colour); the legend's rule of skipping unnamed traces does not apply here, since the readout is about what is under the pointer, not about identity. Clip everything to `axes` per §13, and place the panel so it cannot fall outside.
**Pointer leaving the axes clears it**, and a middle-drag or a wheel keeps it active but re-anchored — the crosshair follows the pointer, so any interaction that does not move the pointer leaves it where the pointer is.
The controller owns translating motion into `set_crosshair`; the plot owns the data; the renderer owns only painting. Tests: the crosshair x tracks motion and clears on leave; the interpolated value matches a hand-computed interpolation at a non-sample x; the readout lists one row per visible trace including unnamed ones; a `Figure` applies one x to every row; no crosshair ink outside `axes`.