diff --git a/docs/superpowers/plans/2026-09-28-plot-clipping-rulers-crosshair.md b/docs/superpowers/plans/2026-09-28-plot-clipping-rulers-crosshair.md new file mode 100644 index 0000000..4c1bc70 --- /dev/null +++ b/docs/superpowers/plans/2026-09-28-plot-clipping-rulers-crosshair.md @@ -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`. +- **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" +``` diff --git a/docs/superpowers/specs/2026-09-28-plot-interaction-and-fonts-design.md b/docs/superpowers/specs/2026-09-28-plot-interaction-and-fonts-design.md index 9f7f4aa..b80deaf 100644 --- a/docs/superpowers/specs/2026-09-28-plot-interaction-and-fonts-design.md +++ b/docs/superpowers/specs/2026-09-28-plot-interaction-and-fonts-design.md @@ -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`.