140 lines
12 KiB
Markdown
140 lines
12 KiB
Markdown
# `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"
|
|
```
|