Files
plotter/docs/superpowers/plans/2026-09-28-plot-clipping-rulers-crosshair.md
T

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

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

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

git add -A && git commit -m "feat(plot): crosshair with an interpolated per-signal hover readout"