Files
plotter/docs/superpowers/plans/2026-09-28-plot-dpi-grid-follow.md

13 KiB

plot — DPI-consistent sizing, grid classes, and live follow with zoom and mouse panning

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Make chrome render at a consistent physical size on any display DPI; split the grid into independently styled major and minor classes tied to the tick classes; and make wheel zoom and mouse panning work with live data following instead of destroying it.

Architecture: Three separable changes. A Style::ui_scale turns every style metric into a logical unit converted to device pixels at one point per consumer (§10). Grid classes gain per-class width and visibility (§11). The x axis gains an explicit XFollow state with a live-edge-relative window, so zoom multiplies the span and panning shifts an offset, neither of which disarms following (§12).

Spec: docs/superpowers/specs/2026-09-28-plot-interaction-and-fonts-design.md sections 10, 11, 12. Read the relevant section before each task.

Priority: these three come before the remaining tasks of 2026-09-28-plot-interaction-and-fonts.md (drag-and-drop regions, Figure, the demo/verification pass, the optional TrueType target). Task 1 changes the units every later visual task is written in, so it goes first; Task 2 is small and independent; Task 3 unblocks the interaction work.

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.
  • 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. Only chrome does. dense_threshold_spp stays a device-pixel ratio (it governs rasterisation density, not physical size); everything a user perceives as a size — font px, stroke widths, paddings, swatch and marker sizes, tick spacing — is logical.
  • The renderer clears only its viewport region via scissor and hands back host GL state; renderer.hpp's documented residue list stays accurate.
  • OpenGL 3.3 core baseline.
  • The 164 existing test cases keep passing (they are 171 from Task 1 on, which adds 7 of its own); any signature change is called out in the task report.
  • 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: UI scale — consistent physical size across DPI

Files:

  • Modify: include/plot/render/style.hpp, include/plot/plot/plot.hpp, src/plot/plot.cpp, src/render/renderer.cpp, include/plot/render/renderer.hpp, include/plot/input/controller.hpp (doc), src/input/controller.cpp
  • Modify the four adapters: adapters/glfw/glfw_host.cpp, adapters/imgui/imgui_plot.cpp, adapters/qt6/qt_plot_widget.cpp, adapters/gtk4/gtk_plot_area.cpp
  • Test: tests/test_render_smoke.cpp, tests/test_plot.cpp, tests/test_controller.cpp

Interfaces:

  • Consumes: Style, PlotLayout, Renderer::draw, the adapters' existing device-scale computations.

  • Produces: Style::ui_scale (device px per logical unit) and Style::device(f32 logical) -> f32; Plot::set_ui_scale(f32) / Plot::ui_scale(); Renderer::draw() propagating style().ui_scale onto the plot before layout.

  • Step 1: Classify every metric as logical or device, in the header

Go through Style field by field and mark each one in its comment as logical (scaled) or device (not). The load-bearing distinctions: axis_font_px, line_width_px, grid_*_width, axis_* paddings, tick_min_px, cursor/marker/swatch sizes → logical; dense_threshold_spp → device, because it is samples per rasterised pixel, not a physical size. Write the classification into the header before changing any behaviour, so the reviewer can check the code against a written rule rather than against taste.

  • Step 2: Route every consumer through device()

PlotLayout's strip and title sizing, the legend, region chrome and the label paddings go through style.device(...) (the plot reads ui_scale()). The renderer converts stroke widths, font px, AA fringe and marker sizes through the same call. dense_threshold_spp is used raw.

  • Step 3: Propagate from the host

Renderer::draw() sets plot.set_ui_scale(style_.ui_scale) before plot.layout(). Each adapter sets renderer.style().ui_scale from the scale it already computes — GLFW from framebuffer/window, Qt from devicePixelRatioF(), GTK from the ratio of the ::resize device size to the widget's logical size (do not use gtk_widget_get_scale_factor(), which is the ceiling under fractional scaling), ImGui from io.DisplayFramebufferScale.

  • Step 4: The controller's tolerances

Pointer coordinates reach the controller in device pixels, so its grab radius must be logical * ui_scale or grabbing becomes physically harder on a high-DPI display. The controller already holds the plot, so read plot.ui_scale(); do not add a parallel setter that can drift. Same for the box-zoom minimum-size click threshold.

  • Step 5: Tests

  • logical_metrics_are_dpi_independent — render the same plot at ui_scale 1.0 and 2.0 with a 2x framebuffer/viewport and assert the axis label's device ink extent doubles while its logical extent (device ÷ scale) is unchanged, and that the label strip's width in logical units is identical across the two.

  • dense_threshold_stays_a_device_ratio — the LOD level chosen for a fixed samples-per-device-pixel is identical at 1.0 and 2.0, proving the threshold was not scaled.

  • grab_radius_scales_with_dpi — a press exactly at the logical grab distance from a cursor grabs it at both scales.

  • ui_scale_defaults_to_one — nothing changes for existing callers.

  • Step 6: Verify and commit

Run the suite, then run the GLFW and Qt demos headlessly at ui_scale 1.0 and at QT_SCALE_FACTOR=1.25 / GDK_SCALE=2 where available, and report the axis-text pixel count and the label strip width in device pixels at each scale, plus the same figures normalised to logical units. The logical figures must be equal; the device ones must scale.

git add -A && git commit -m "feat(render): logical style metrics with a UI scale, so chrome has a consistent physical size"

Task 2: Major and minor grid as independent styled classes

Files:

  • Modify: include/plot/render/style.hpp, include/plot/plot/grid.hpp, src/plot/grid.cpp, src/render/renderer.cpp
  • Test: tests/test_plot.cpp, tests/test_render_smoke.cpp

Interfaces:

  • Consumes: the tick set (plot::Tick{f64 value, bool major, std::string label} and generate_ticks's include_minors), Style from Task 1 (ui_scale, device(), and the logical/device classification it wrote down, which is what makes the two new widths logical).

  • Produces: Style::grid_major_width, Style::grid_minor_width (logical), Style::grid_major_visible, Style::grid_minor_visible; grid segments tagged with their class.

  • Step 1: Per-class style

Add the four fields with the documented defaults (both visible, both 1.0 logical px). Widths go through Style::device(); colours are unchanged and still per class.

  • Step 2: One source for line positions, and one for visibility

Positions are already single-sourced: Grid::build() consumes the tick set and tags each GridLine with its major flag, and Axis::refresh(px, min_px, include_minors = true) already emits minors by default, so a labeled major tick and a major gridline coincide today. Keep that; do not introduce a second spacing derivation.

But there are two places to fix, both found by inspection before starting:

  • src/render/renderer.cpp:550 hardcodes the stroke width as 1.0f for both classes. Route style.device(style.grid_major_width) / grid_minor_width through per class.
  • include/plot/plot/grid.hpp:24 already carries bool show_minor = true, which is a second source of truth for visibility alongside the Style::grid_minor_visible you are adding. Two switches for one behaviour is the defect class this project keeps hitting. Do a clean cutover: Style::grid_*_visible is the only switch, Grid::show_minor is deleted, and every caller is migrated. Report any caller you find and why it had the flag.

Route the class through to the renderer so colour and width are selected per class, and confirm the grid is still built from the primary axis pair's transforms, as the existing comment at renderer.cpp:537-540 states.

  • Step 3: Visibility is a skip, not alpha zero

Check visibility before staging, so a disabled class stages no geometry at all.

  • Step 4: Tests

  • major_grid_lines_coincide_with_major_ticks — for several ranges, every major gridline's x equals some major tick's x and vice versa; same for minor.

  • disabling_the_minor_grid_removes_its_colour — with grid_minor_visible = false the minor grid colour has zero pixels and the major colour's count is unchanged.

  • disabling_the_major_grid_leaves_the_minor_grid — the symmetric case.

  • grid_widths_are_per_class_and_scale_with_dpi — a 2:1 width ratio produces a 2:1 stroke-pixel ratio; and both double in device pixels when ui_scale doubles while their logical widths are unchanged.

  • Step 5: Commit

git add -A && git commit -m "feat(render): independent major and minor grid classes tied to the tick set"

Task 3: Live follow with relative zoom and mouse panning

Files:

  • Modify: include/plot/plot/axis.hpp, include/plot/plot/plot.hpp, src/plot/plot.cpp, include/plot/input/controller.hpp, src/input/controller.cpp, src/render/renderer.cpp (only if the live edge needs exposing)
  • Test: tests/test_plot.cpp, tests/test_controller.cpp

Interfaces:

  • Consumes: Plot's axis ranges and transforms, data::Series::x_bounds(), the controller's existing wheel and drag paths.

  • Produces: plot::XFollow{Off, Live}; Plot::set_x_follow(u32, XFollow), Plot::x_follow(u32), Plot::set_x_offset(u32, f64), Plot::x_offset(u32); the live-window recompute; the controller's follow-preserving zoom and pan.

  • Step 1: The follow state and the live window

Add XFollow and the four accessors. set_x_offset clamps to [0, retained_window_span] (the retained window is what the series' x_bounds() reports for a streaming series, so the limit is honest about what is still in the ring). While x_follow(axis) == Live, the visible range is recomputed from the series' newest x every time layout is computed: [newest - offset - span, newest - offset], where span is the span the axis had. A plot with no trace on that axis, or a non-streaming series, keeps its stored range. XFollow::Off is the default and changes nothing.

  • Step 2: Relative zoom

The wheel path multiplies the span: span *= factor, never a fixed increment, so notches compound. Under Live, after scaling, adjust offset so the anchored data coordinate keeps its screen position: the anchor is the pointer's data x when the pointer is inside axes, and the live edge otherwise. Following stays armed. Under Off, the behaviour is exactly today's anchor-at-pointer zoom.

  • Step 3: Mouse panning under live

The left-drag pan path, under Live, shifts offset by the data delta of the drag and leaves the span alone, clamped at 0 so the view cannot pass the newest sample. Under Off the existing pan is unchanged. In both cases, do not clear the follow state — and take care that the current code's set_x_range call, which clears auto_scale, is not the path used under Live, or that Live re-arms itself after it.

  • Step 4: Returning to live

auto_fit under Live sets offset = 0 and fits the span to the retained window. It leaves Live armed. Document it on auto_fit_x.

  • Step 5: Tests

  • live_window_tracks_the_newest_sample — push samples through a StreamSeries, call the update path each frame, assert the range's right edge equals the newest x every frame.

  • wheel_zoom_keeps_following — zoom while Live, assert the span shrank, the right edge is still the newest sample, and x_follow is still Live. This is the regression the section exists for; it must fail against the current code, where the zoom clears auto_scale.

  • wheel_zoom_is_relative — two half-notches produce the same span as one full notch (within a tolerance), proving multiplication rather than addition.

  • mouse_pan_shifts_the_offset_and_preserves_the_span — drag, assert the span is unchanged, the offset moved by the data delta, and x_follow is still Live.

  • pan_cannot_pass_the_live_edge — a long drag toward the live edge clamps offset at 0 rather than inverting the range.

  • offset_survives_new_samples — with a non-zero offset, wait for more data and assert the window advanced by the same amount, keeping the same data in view.

  • auto_fit_returns_to_the_live_edge — from a non-zero offset, auto_fit sets offset 0, leaves Live armed, and fits the retained window.

  • a_static_plot_is_unaffected — XFollow::Off behaves exactly as before for zoom and pan.

  • Step 6: Commit

git add -A && git commit -m "feat(input): live x follow with relative zoom and mouse panning"