12 KiB
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_coreand every public header underinclude/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/Statusplus out-params. NoResult<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-failuregreen 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 inbegin_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).
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— withNearestTickthe stored y equals a y tick's value; withFreeit does not. -
horizontal_ruler_label_reads_its_value— pixel test: the ruler's ink is a single horizontal run insideaxesat 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
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 throughPlotLayoutlike 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
git add -A && git commit -m "feat(plot): crosshair with an interpolated per-signal hover readout"