docs: spec and plan for figure y-gutter alignment, crosshair line and style, and cursor dragging

This commit is contained in:
Martino Ferrari committed 2026-09-28 19:57:19 +02:00
1 parent a14f333dbe
commit 663b57c679
2 files changed
+171

No files matched your search

@@ -0,0 +1,130 @@
# `plot` — figure alignment, crosshair line and style, cursor dragging
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task.
**Goal:** Fix four defects reported from use: figure rows whose y axes do not line up; the crosshair's missing horizontal line in a figure; a crosshair that is too heavy and solid to read as chrome; and cursor dragging that grabs poorly and hops between samples.
**Spec:** `docs/superpowers/specs/2026-09-28-plot-interaction-and-fonts-design.md` sections 16, 17, 18. Read the relevant section before each task.
## 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.
- **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). Dashing is a shader effect, not new geometry.
- Everything drawn over the axes is clipped to `axes` (§13); the renderer hands back host GL state and its documented residue list stays accurate.
- OpenGL 3.3 core baseline.
- The 256 existing test cases keep passing; 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. **Commit as soon as the tree builds** — several agents have died of turn exhaustion in this phase.
---
### Task 1: Figure rows share one y gutter
**Files:**
- Modify: `include/plot/plot/plot.hpp`, `src/plot/plot.cpp`, `include/plot/plot/figure.hpp`, `src/plot/figure.cpp`
- Test: `tests/test_figure.cpp`, `tests/test_render_smoke.cpp`
**Interfaces:**
- Consumes: `Plot::layout()`, `PlotLayout::y_label_area`/`y_title_area`/`axes`, `TextBackend` metrics.
- Produces: `Plot::set_min_y_gutter(f32)` / `Plot::min_y_gutter()` (a figure-level imposition consulted by `layout()`), and `FigureLayout` gaining whatever the rows need to agree.
- [ ] **Step 1: Reproduce the misalignment first**
With a `Figure` whose rows carry different magnitudes ("671m", "90", "2.51"), assert that every row's `axes.x` and `axes.w` are equal. It fails today; record the three actual `axes.x` values as the defect's size. Also assert the drawn consequence: a vertical feature at a given x lands at the same pixel column in every row.
- [ ] **Step 2: Impose a common gutter**
Lay each row out once to learn the strip width its own labels need, take the maximum across rows (and include the title columns, so §3's column rule still holds), then re-lay every row out with that width imposed. A `Plot` standing alone must keep sizing its own strip from its own data — so the imposition is the figure's decision, expressed as a figure-level override on the plot rather than a change to how a lone plot sizes itself. Say in the report which shape you chose and why.
- [ ] **Step 3: Keep the invariants**
Confirm: a single-plot host is unaffected (the override is not set and nothing changes); the `axes` floor still holds for a tiny viewport; a figure with one row behaves exactly as the plot does; `Figure::layout()` still lays each row out once per call, not repeatedly until it settles (a fixed-point loop here would be the tempting wrong answer — the gutter is a maximum, computable in one pass, so say why it needs no iteration); and the shared x axis still lands in the bottom row's strip.
- [ ] **Step 4: Verify and commit**
Report the three pre-fix `axes.x` values and the single post-fix value, plus the pixel column of a vertical feature in each row. Run the multiplot demo headlessly and report the rows' `axes.x`/`axes.w` from one frame.
```bash
git add -A && git commit -m "fix(plot): a figure gives every row the same y gutter so the axes line up"
```
---
### Task 2: Crosshair horizontal line in the hovered row, and a thinner dashed style
**Files:**
- Modify: `include/plot/render/style.hpp`, `include/plot/plot/crosshair.hpp`, `include/plot/plot/plot.hpp`, `src/plot/plot.cpp`, `include/plot/plot/figure.hpp`, `src/plot/figure.cpp`, `src/input/controller.cpp`, `src/render/renderer.cpp`, `src/render/shaders.cpp`
- Test: `tests/test_plot.cpp`, `tests/test_figure.cpp`, `tests/test_render_smoke.cpp`
**Interfaces:**
- Consumes: `Crosshair{vertical, horizontal, x, y}`, `kLineVS`'s `v_along`, the line pipeline's per-draw uniforms.
- Produces: `Style::crosshair_width`, `Style::crosshair_dash_on`, `Style::crosshair_dash_off` (all logical); an optional per-`Crosshair` style override; a dash uniform in the line shader; `Figure::set_crosshair(f64 x, u32 hovered_row, f64 y_in_row, bool active)` (or an equivalent that carries the hovered row and its local y).
- [ ] **Step 1: The horizontal line in the hovered row**
The figure's sync currently calls the single-coordinate `set_crosshair`, so no horizontal line is drawn anywhere — correct for a figure-wide y, which would be meaningless, but wrong for the user, who wants the line where it *does* mean something. Carry which row the pointer is in and that row's local y through the figure's crosshair sync, apply the shared x to every row and the y only to the hovered row. Assert: with the pointer in row 1, row 1 has a horizontal line and rows 0 and 2 do not, while all three have vertical lines at the same x; and no row ever shows another row's y. Also assert that moving the pointer from row 1 to row 2 moves the horizontal line and leaves the vertical lines alone.
- [ ] **Step 2: The dash, in the fragment shader**
`kLineVS` already computes `v_along` (distance travelled along the segment, in pixels). Add a per-draw uniform carrying the dash pattern in pixels and, in the fragment stage, discard (or zero the alpha of) fragments where `mod(v_along, on + off) >= on`. `off == 0` must mean solid, so the same pipeline serves every other line in the library unchanged. Keep vertices in data space; add no CPU geometry. Report the shader diff and confirm the other line users (grid, traces, axis lines, regions, cursors, the legend) are unaffected by the new uniform's default.
- [ ] **Step 3: Thinner and configurable**
Add the three logical style knobs with the spec's defaults (0.75 px, 4/4 dash), route the crosshair's stroke through `Style::device()`, and allow a per-`Crosshair` override so one plot can differ without touching global style. Confirm the dash phase is stable frame to frame for a stationary crosshair (a pattern that crawls would look like noise) and say how you anchored it.
- [ ] **Step 4: Tests**
- `figure_crosshair_draws_a_horizontal_line_only_in_the_hovered_row`.
- `crosshair_dash_is_configurable_and_zero_off_means_solid` — pixel test: a dashed crosshair leaves gaps along its length, a solid one does not, and the gap count changes with `crosshair_dash_off`.
- `crosshair_is_thinner_than_a_trace` — assert the default stroke is below the trace line width and that the ink width follows `crosshair_width` and `ui_scale`.
- `grid_traces_and_cursors_are_unaffected_by_the_dash_uniform` — a regression guard for the shared pipeline.
- `dash_phase_is_stable_across_frames` — render the same frame twice and assert byte-identical output.
- [ ] **Step 5: Verify and commit**
Run the GLFW and multiplot demos headlessly and report, from the frames: the crosshair's ink row count for the horizontal line (one in the hovered row, zero elsewhere), a dashes-versus-gaps count along the vertical line, and the stroke width in device pixels at `ui_scale` 1 and 2.
```bash
git add -A && git commit -m "feat(plot): the figure crosshair marks the hovered row, and the stroke is thin, dashed and configurable"
```
---
### Task 3: Cursor dragging follows the pointer and grabs reliably
**Files:**
- Modify: `include/plot/plot/cursor.hpp`, `include/plot/plot/plot.hpp`, `src/plot/plot.cpp`, `include/plot/input/controller.hpp`, `src/input/controller.cpp`
- Test: `tests/test_controller.cpp`, `tests/test_plot.cpp`
**Interfaces:**
- Consumes: `Cursor::snap`, `Plot::set_cursor_x`, the controller's drag path and `kCursorHitRadiusLogical`.
- Produces: snapping applied at the **end** of a drag rather than inside the setter, a documented free path for programmatic placement, and a hit-test order in which a cursor beats a nearby region handle.
- [ ] **Step 1: Diagnose it with numbers before changing anything**
Reproduce both halves of the report on a **sparse** row (few samples per pixel) and on a dense one:
- **Snapping:** drive a sequence of pointer positions across the axes and record the cursor's `x` at each step, plus the pixel distance it moved versus the pointer. On a sparse row the cursor should be seen hopping to sample positions rather than tracking the pointer; quantify the hop size in pixels. Use an isolated worktree at the current HEAD for the before-numbers.
- **Grabbing:** for a press at increasing distance from the marker, find the pixel distance at which the grab succeeds; and with a region overlapping the cursor, determine who wins. Report both numbers, then fix.
- [ ] **Step 2: Snap on drop, not during the drag**
Apply the snap when the drag ends: during the drag the cursor follows the pointer freely, and release commits the snapped position. Keep a documented free path on the setter for programmatic placement (and say whether `SnapMode::Free` remains meaningful). A host must be able to ask for the old behaviour if it wants it — so state what the default is and what the opt-out is.
- [ ] **Step 3: Make the grab reliable**
Raise the hit radius into the 6-8 logical px range with hover and grab sharing **one** value (no second constant), and give a cursor precedence over a region's edge or body within that radius, while a region still wins where no cursor is close. Confirm the region's own cases still pass and that a region with no cursor nearby remains fully draggable.
- [ ] **Step 4: Tests**
- `cursor_follows_the_pointer_freely_while_dragging` — on a sparse series the cursor's x tracks the pointer within a pixel at every move, not a sample position.
- `cursor_snaps_when_the_drag_ends` — after release the stored x equals the nearest sample's x, and it did not during the drag.
- `cursor_wins_the_grab_over_a_nearby_region_handle`, and `a_region_without_a_cursor_nearby_is_still_draggable`.
- `hover_and_grab_use_the_same_radius` — a press exactly at the hover radius grabs, which is the property that makes the affordance honest.
- [ ] **Step 5: Verify and commit**
Report the before/after per-move pixel deltas on the sparse row, the grab-success distance before and after, and the dense-row behaviour (which must be unchanged in substance — it was already smooth).
```bash
git add -A && git commit -m "fix(input): cursors follow the pointer while dragging and snap on drop"
```
@@ -324,3 +324,44 @@ void clear_crosshair();
**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. **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`. 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`.
---
## 16. Figure rows share one y gutter (defect)
Reported from use: **"multi plot example, the y axis are not vertically aligned."** The cause is that a `Figure` does nothing to align its rows: each row's `Plot` sizes its own `y_label_area` from its own widest tick label, so rows whose magnitudes differ ("671m" vs "90" vs "2.51") get different strip widths and their `axes` rects start at different x.
A figure must give its rows a **common left gutter**:
- Lay each row out once to learn the strip width its own labels need.
- Take the maximum across rows, plus the title columns, and re-lay every row out with that width imposed.
- All rows' `axes.x` and `axes.w` are then identical, so a vertical line through the figure is a real column of the same x and traces in different rows line up.
This is the figure's decision, not the plot's: a `Plot` standing alone keeps sizing its own strip from its own data. So the imposition is a figure-level override — for example `Plot::set_min_y_gutter(f32)` consulted by `layout()` — and it composes with the existing title columns (the gutter covers labels plus titles, so §3's column rule still holds). The equality of `axes.x`/`axes.w` across rows is the invariant, and it is asserted rather than eyeballed.
## 17. Crosshair: a horizontal line where it means something, and a configurable style
Two reported items.
**The missing horizontal line.** In a `Figure` the sync path calls the single-coordinate `set_crosshair(x, active)`, which by design draws only the vertical line — and the existing comment's reasoning is sound: rows carry different y scales, so one figure-wide `y` would be a value meaning something different in each row. The resolution is not to invent a figure-wide y but to draw the horizontal line **in the row under the pointer**, where a y is genuine. So the figure's sync learns which row the pointer is in and that row's local y, applies the shared x to every row (vertical lines everywhere, as now) and additionally the y to the hovered row only — one horizontal line, in the row being examined. No row ever shows a y belonging to another row's scale.
**The style.** The crosshair is a passive hover instrument drawn over the data, so its default must recede: a **thinner** stroke (~0.75 logical px rather than a trace's line width) and a **dashed** stroke, so it reads as chrome rather than as data. Both are configurable:
```cpp
// in Style, alongside the crosshair colour:
f32 crosshair_width = 0.75f; // logical px
f32 crosshair_dash_on = 4.0f; // logical px of ink
f32 crosshair_dash_off = 4.0f; // logical px of gap; 0 disables dashing (solid)
```
Dashing is a **fragment-shader** effect, not new geometry: `kLineVS` already passes `v_along` (distance travelled along the segment in pixels), so the fragment stage discards fragments where `mod(v_along, on + off) >= on`. Vertices stay in data space, no CPU work is added, and the pattern is a uniform — per-draw configurable and free to change per frame. A `Crosshair` may override the style's values so one plot differs from another without touching global style.
## 18. Cursor dragging is hard and snaps oddly (defect)
Reported from use: **"moving cursors horizontally is very difficult and they stick to data in a wired way."** Two mechanisms, both real:
**Snapping during the drag.** `set_cursor_x` applies `NearestSample` snapping on **every** assignment, so a drag does not follow the pointer — it hops between sample positions. On a dense row that quantum is sub-pixel and invisible; on a sparse row, or zoomed in, samples can be tens of pixels apart, so the cursor jumps and appears to stick to the data. The fix is the convention every editor uses: **during a drag the cursor follows the pointer freely, and the snap is applied when the drag ends**, so the drop commits the snapped position. Snapping therefore becomes a property applied at the end of a gesture rather than inside the setter, with the setter keeping a documented free path for programmatic placement. A host that wants snapped motion can still ask for it; the default becomes "smooth while moving, snapped when dropped".
**Grabbing.** The hit radius is a small logical constant (5 logical px) and a cursor can lose the grab to a region whose edge or body is nearby. Make the grab reliable: a cursor within the radius takes precedence over a region's edge or body at the same point (the region still wins where no cursor is close), and the radius is large enough to hit a 1 px marker comfortably — 6 to 8 logical px, with hover and grab using the same value so the affordance the host sees is the one it gets.
Both changes are measured, not asserted: the report states the pixel distance at which a grab succeeds, and the cursor's per-move x during a drag on a sparse row before and after.