Files
plotter/docs/superpowers/plans/2026-09-28-plot-figure-align-crosshair-cursors.md
T

11 KiB

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.

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.

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).

git add -A && git commit -m "fix(input): cursors follow the pointer while dragging and snap on drop"