docs: spec and plan for data clipping, horizontal rulers and the crosshair readout
This commit is contained in:
1 parent
1b153b2ec3
commit
b0f9fbbb35
2 files changed
+202
No files matched your search
@@ -0,0 +1,139 @@
|
||||
# `plot` — data clipping, horizontal rulers, crosshair readout
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Stop data drawing outside the plot area (a reported defect), add horizontal rulers to match the vertical cursors, and add a crosshair whose per-signal hover readout is synced across a figure's rows.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-09-28-plot-interaction-and-fonts-design.md` sections 13, 14, 15. Read the relevant section before each task.
|
||||
|
||||
**Priority order:** Task 1 is a defect reported from use, so it goes first. Task 2 is small and symmetric with machinery that already exists. Task 3 is the largest and depends on Task 1 for its clipping.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- `plot_core` and every public header under `include/plot/` stay free of third-party code.
|
||||
- Public headers MUST be self-contained; the configure-time umbrella and self-containment checks must keep passing (optional-target headers are exempt via the existing narrow list).
|
||||
- C++20; our targets stay clean under `-Wall -Wextra -Wpedantic -Wconversion -Wshadow`, never suppressed per-file.
|
||||
- No exceptions across the public API boundary: `bool`/`Status` plus out-params. No `Result<T>`.
|
||||
- **Data-space geometry never scales**; chrome sizes are logical via `Style::device()`. Pointer coordinates reaching the controller are device pixels.
|
||||
- Vertex data stays in DATA space; per-frame cost stays O(visible pixels + produced samples). Clipping is a GL state change, never CPU geometry work.
|
||||
- The renderer clears only its viewport region via scissor and hands back host GL state; `renderer.hpp`'s documented residue list must stay accurate, including any scissor state a frame leaves behind.
|
||||
- Exact interval queries go through `Series::reduce`/`sum`/`sumsq`. A crosshair readout is *interpolated*, not exact, and says so.
|
||||
- OpenGL 3.3 core baseline.
|
||||
- The 229+ existing test cases keep passing (234 with the optional font target ON); any signature change is called out.
|
||||
- Every task ends with `cmake --preset linux-debug && cmake --build build/debug && ctest --test-dir build/debug --output-on-failure` green and warning-clean, and commits with the message in its own section.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Clip data to the plot area
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/render/renderer.cpp`, `include/plot/render/renderer.hpp` (the residue list)
|
||||
- Test: `tests/test_render_smoke.cpp`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `PlotLayout::axes`, the existing viewport scissor in `begin_frame`, `Style::device()`.
|
||||
- Produces: no public API change; the frame's clip sequence and the documented residue.
|
||||
|
||||
- [ ] **Step 1: Reproduce it first**
|
||||
|
||||
Write the failing test before the fix, with a discriminating predicate: a trace whose y values lie far outside the axis range (e.g. ±50 on a [0,1] axis) must produce **no** trace-coloured pixel outside `axes`. Assert three things separately, so a partial fix is visible: no trace ink in the y label strip, none in the x label strip, and — with a two-row `Figure` — none inside the neighbouring row's rect. Confirm it fails against the current code, and record the overflow direction and magnitude (how far outside the data lands) in the report; that number is the bug's size.
|
||||
|
||||
- [ ] **Step 2: Scissor the interior passes**
|
||||
|
||||
In `draw()`, wrap the passes that draw inside the axes — grid, regions, traces, cursors, the measurement band — in a scissor set to `layout().axes`, converted to framebuffer coordinates (GL's origin is bottom-left; `Rect` is top-left) and intersected with the viewport so a hostile rect cannot escape the frame. Release it back to the viewport scissor before the tick-label and axis-title passes, which live outside `axes` by design and would otherwise be erased. Keep the sequence in one place (a small `set_clip`/`clear_clip` pair) rather than scattering `glScissor` calls, and add the scissor state to `renderer.hpp`'s residue list — including that it ends the frame restored to the viewport, not left at the axes rect.
|
||||
|
||||
- [ ] **Step 3: Check the neighbours of the fix**
|
||||
|
||||
The same reasoning applies to geometry the fix now clips: confirm a region whose bounds are outside the visible x range is clipped rather than drawn across the label strip; confirm a cursor at an out-of-range x is clipped; confirm the legend and its panel (inside `axes`) are unaffected; and confirm the crosshair-free case still fills the axes completely, so the clip is the axes rect and not something smaller.
|
||||
|
||||
- [ ] **Step 4: Verify and commit**
|
||||
|
||||
Report the test's pre-fix overflow magnitudes and the post-fix zero. Also run the GLFW demo headlessly with a signal deliberately driven outside its y range and report that the label strips' ink counts are unchanged from a normal run (proving the strip is no longer contaminated).
|
||||
```bash
|
||||
git add -A && git commit -m "fix(render): clip grid, traces, regions and cursors to the plot area"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Horizontal rulers
|
||||
|
||||
**Files:**
|
||||
- Modify: `include/plot/plot/cursor.hpp`, `include/plot/plot/plot.hpp`, `src/plot/plot.cpp`, `src/input/controller.cpp`, `src/render/renderer.cpp`
|
||||
- Test: `tests/test_controller.cpp`, `tests/test_plot.cpp`, `tests/test_render_smoke.cpp`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the existing cursor grab/drag/hover path and its hit radius (`plot.style().device(...)`), the y tick set.
|
||||
- Produces: `plot::CursorOrientation{Vertical, Horizontal}`, `plot::HRulerSnap{Free, NearestTick}`, `Cursor::orientation`/`Cursor::y`/`Cursor::h_snap`, `Plot::add_h_ruler(f64 y, Color)`, `Plot::set_cursor_y(u32, f64)`.
|
||||
|
||||
- [ ] **Step 1: The type and the accessors**
|
||||
|
||||
Extend `Cursor` per spec §14 and add the horizontal constructor/setter beside the vertical ones. A vertical cursor's `y` is meaningless and a horizontal one's `x` is; say which field is live for each orientation in the header, and make the accessors respect it rather than leaving two fields a caller can set inconsistently.
|
||||
|
||||
- [ ] **Step 2: One drag path, two coordinates**
|
||||
|
||||
`cursor_at` accepts both orientations, and a grabbed cursor mutates the coordinate its orientation owns: `set_cursor_x` for a vertical one, `set_cursor_y` for a horizontal one. The hit radius is the same for both, so hover and grab agree, and a vertical cursor's hit test must not be widened by a horizontal one or vice versa — a point near both resolves to the nearest. `hovered_cursor`/`grabbed_cursor` report either orientation.
|
||||
|
||||
- [ ] **Step 3: Snap**
|
||||
|
||||
A vertical cursor keeps `CursorSnap::NearestSample`. A horizontal one snaps either freely or to the y **tick** set (`HRulerSnap::NearestTick`) — deliberately not to a sample, because the series is sampled in x and "nearest y sample" has no meaning. If the y ticks are stale when the snap is applied, refresh them; if that is not possible in the drag path, say so and snap to the nearest tick from the last layout instead, documenting the one-frame staleness.
|
||||
|
||||
- [ ] **Step 4: Paint it**
|
||||
|
||||
A horizontal ruler spans the axes width, in its own colour, with its label showing the y value; it must be clipped per Task 1 and never extend past the axes. The highlighted state while hovered or grabbed matches the vertical case.
|
||||
|
||||
- [ ] **Step 5: Tests**
|
||||
|
||||
- `horizontal_ruler_is_grabbed_and_dragged_along_y` — a press on it enters a cursor drag, motion changes its y and not its x, and the vertical cursors are untouched.
|
||||
- `vertical_and_horizontal_cursors_do_not_confuse_each_other` — a press nearest a vertical cursor moves that one even when a horizontal ruler is within the radius of the same point, and the symmetric case.
|
||||
- `horizontal_ruler_snaps_to_the_y_ticks_when_asked` — with `NearestTick` the stored y equals a y tick's value; with `Free` it does not.
|
||||
- `horizontal_ruler_label_reads_its_value` — pixel test: the ruler's ink is a single horizontal run inside `axes` at the mapped y, with no ink outside.
|
||||
- `horizontal_ruler_stays_inside_the_axes_at_the_edges` — a ruler dragged past the top or bottom is clamped to the axes rather than drawn over the strips.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
```bash
|
||||
git add -A && git commit -m "feat(plot): horizontal rulers alongside the vertical cursors"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Crosshair with a per-signal hover readout
|
||||
|
||||
**Files:**
|
||||
- Create: `include/plot/plot/crosshair.hpp`
|
||||
- Modify: `include/plot/plot/plot.hpp`, `src/plot/plot.cpp`, `include/plot/plot/figure.hpp`, `src/plot/figure.cpp`, `include/plot/input/controller.hpp`, `src/input/controller.cpp`, `src/render/renderer.cpp`, `include/plot/plot.hpp` (umbrella)
|
||||
- Test: `tests/test_plot.cpp`, `tests/test_figure.cpp`, `tests/test_controller.cpp`, `tests/test_render_smoke.cpp`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the series' sample access (`Series::x0()`/`dx()`/`size()` and the level-0 sample fetch), the legend's row measurement, Task 1's axes clip.
|
||||
- Produces: `plot::Crosshair{active, vertical, horizontal, show_readout, x, y}`; `Plot::set_crosshair(f64 x, bool)`, `Plot::set_crosshair(f64 x, f64 y, bool)`, `Plot::crosshair()`, `Plot::clear_crosshair()`; `CrosshairLayout` (the two line rects and the readout panel with per-row rects, trace ids and the interpolated values) exposed through `PlotLayout` like the legend and regions so the renderer consumes the plot's geometry; `Figure::set_crosshair(f64 x, bool)` applying one x to every row; the controller translating motion into it.
|
||||
|
||||
- [ ] **Step 1: State and the interpolated lookup**
|
||||
|
||||
Add `Crosshair` and the accessors, plus the lookup: the value of a trace at the crosshair's x is **linear interpolation between the two adjacent samples**, documented as interpolated and explicitly contrasted with the exact `Series::reduce`/`sum`/`sumsq` convention. It must be O(1) — index arithmetic plus at most two sample reads, never a scan — because it runs on every pointer move. Out-of-range x and an empty series yield an invalid row rather than a clamped lie; say which in the header.
|
||||
|
||||
- [ ] **Step 2: Geometry through `PlotLayout`**
|
||||
|
||||
Compute the line rects, the per-trace marker positions and the readout panel in the plot, with the panel placed near the pointer and flipped so it stays inside `axes`, sized from rows measured with the plot's `TextBackend` (reuse the legend's row measurement rather than growing a second). Expose it via `PlotLayout` as `LegendLayout` and the region layout already are. **An unnamed trace still gets a row** (the readout is about what is under the pointer, unlike the legend) — give it a stable placeholder label so the rows are identifiable without inventing a name.
|
||||
|
||||
- [ ] **Step 3: Paint it**
|
||||
|
||||
Lines in a `style.crosshair` colour distinct from `Style::cursor`, thin and distinguishable from the grid; a marker at each trace's intersection; the readout panel with a swatch and value per row. Everything clipped to `axes` per Task 1. Text goes on the text path so the line-then-text flush order holds.
|
||||
|
||||
- [ ] **Step 4: Wire the pointer and the figure**
|
||||
|
||||
The controller calls `set_crosshair` on motion inside `axes` and `clear_crosshair` when the pointer leaves or no button/tracking state warrants it; a wheel or drag keeps it active and re-anchored, since it follows the pointer. `Figure::set_crosshair(x, active)` applies the same data x to every row so the readouts are synced; a row whose series has no samples at that x shows no value row rather than a fabricated one.
|
||||
|
||||
- [ ] **Step 5: Tests**
|
||||
|
||||
- `crosshair_tracks_motion_and_clears_on_leave`.
|
||||
- `crosshair_value_is_interpolated_between_neighbours` — a hand-computed interpolation at a non-sample x, plus the two exact-sample endpoints and a one-sample series.
|
||||
- `crosshair_readout_lists_every_visible_trace_including_unnamed_ones`, and skips invisible ones.
|
||||
- `crosshair_panel_stays_inside_the_axes_at_all_four_edges` (pointer near each corner).
|
||||
- `figure_crosshair_applies_one_x_to_every_row` — rows at different sample rates report their own values for the same x, and a row with no data at that x reports none.
|
||||
- `crosshair_ink_stays_inside_the_axes` — pixel test, which depends on Task 1.
|
||||
- `crosshair_lookup_does_not_scan` — a counter or a large-series timing bound, so a future regression cannot reintroduce an O(n) walk on the pointer path.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
```bash
|
||||
git add -A && git commit -m "feat(plot): crosshair with an interpolated per-signal hover readout"
|
||||
```
|
||||
@@ -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`.
|
||||
Reference in new issue
Block a user