Files
plotter/docs/superpowers/specs/2026-09-28-plot-interaction-and-fonts-design.md
T

31 KiB
Raw Blame History

plot — interaction, fonts, legend and multi-plot

Date: 2026-09-28 Status: design approved by the requester (font, external-font mechanism, and area semantics chosen explicitly) Predecessor: 2026-09-27-plot-library-design.md (the library itself; its invariants still bind)

Goal

Five changes to the shipped library, driven by using it:

  1. The y-axis label is laid out vertically, as every plotting package does it.
  2. The default font is replaced — the 8×8 1-bit bitmap reads badly — with an anti-aliased embedded Adwaita Mono, and the library gains runtime external-font support.
  3. Cursors and measurement areas are directly draggable (grab on the marker, no modifier), and droppable with snapping.
  4. A legend for the signals, and it must appear in the examples.
  5. A multi-plot example: several plots and several signals per plot, with the x axis synchronised across plots.

Non-goals

  • Arbitrary font features: no kerning, no shaping, no hinting, no subpixel positioning. Explicitly deferred.
  • Proportional (variable-width) text. An atlas is a fixed-cell grid: every glyph occupies one cell of the same size. That is what keeps the staged instance at 6 floats and the vertex shader free of a per-glyph size, and it is why a proportional font must draw its glyphs into a uniform cell instead of trimming the atlas rect. The constraint is structural — declared by the backend, asserted in debug builds — not a note, because violating it silently draws every glyph at the first staged glyph's size.
  • Text selection/editing, data export, annotations beyond what exists.
  • A general constraint/layout engine — Figure is a fixed vertical stack, not a docking system.

1. Text pipeline refactor (prerequisite for 1 and 2)

Today the split is wrong: TextBackend (include/plot/render/text.hpp) exposes only measure/height/ascent, while the renderer privately owns a hardcoded 128×56 1-bit atlas and kTextVS hardcodes kGlyphTexels = vec2(8.0). Two consequences: a non-bitmap font cannot be plugged in, and text cannot be rotated. Both are fixed by making the backend the atlas owner.

namespace plot::render {

/// One glyph's atlas rectangle and metrics, in font pixels.
struct Glyph {
  f32 u0 = 0, v0 = 0, u1 = 0, v1 = 0;  // normalised atlas rect
  f32 xoff = 0, yoff = 0;              // bearing from the pen, top-left origin
  f32 advance = 0;                     // pen movement along the baseline
};

class TextBackend {
 public:
  virtual ~TextBackend() = default;

  virtual f32 measure(const char* utf8) const = 0;
  virtual f32 height() const = 0;
  virtual f32 ascent() const = 0;

  /// Null for a codepoint with no glyph; the caller draws its fallback box.
  virtual const Glyph* glyph(u32 codepoint) const = 0;

  /// 8-bit single-channel coverage, row-major, `atlas_width() * atlas_height()`
  /// bytes. Uploaded as GL_R8. Null is legal and means "no text".
  virtual const u8* atlas_pixels() const = 0;
  virtual u32 atlas_width() const = 0;
  virtual u32 atlas_height() const = 0;

  /// Bumped whenever `atlas_pixels()` content changes, so the renderer knows to
  /// re-upload instead of trusting its cache. Must not change otherwise.
  virtual u32 revision() const = 0;
};

}  // namespace plot::render

The renderer uploads whatever atlas the backend exposes and re-uploads when revision() changes — the hardcoded atlas and the 8×8 texel assumption both go away.

Rotation is a property of the staged instance, not a new shader path. TextBatch's instance grows from {x, y, u, v} to {x, y, u, v, ax, ay} where (ax, ay) is the glyph's local +x axis expressed in pixels (default (1, 0)). The vertex shader builds the cell from that basis and its perpendicular, and maps UVs consistently, so a 90° run is just (0, -1). This supports arbitrary angles at 8 bytes per glyph, which is cheaper than a matrix and keeps one code path.

2. Font strategy

Embedded default. Adwaita Mono (SIL OFL 1.1 — derived from Inter and Iosevka; adwaita-fonts ships the licence). A greyscale, anti-aliased atlas is rasterised at authoring time by a committed generator script (tools/fonts/gen_atlas.py, using fontTools, already present) at a chosen pixel size, and the resulting byte table is committed as generated data. Consequences: no runtime dependency, no build-time dependency, no network, and the atlas is reviewable in a diff.

OFL compliance, explicitly:

  • The full OFL 1.1 text is committed under licences/.
  • The backend is named neutrally (EmbeddedMonoFont), not "Adwaita Mono", because OFL reserves the font name for modified redistributions.
  • This is a clean cutover, not an alias. The public class BitmapFont is renamed to EmbeddedMonoFont: every caller in src/, adapters/, examples/ and tests/ is migrated and the old name is deleted. No deprecated alias, no re-export.
  • Provenance (source font, version, generator invocation, size, and the coverage set) is stated in both the generated header and the script.

Coverage: ASCII 32..126, plus U+00B0, U+00B5, U+03A9 (the codepoints format_si emits) and U+2013/U+2014, plus a fallback box for everything else. The generator asserts every requested codepoint rasterised to non-empty ink, so a coverage regression fails generation rather than shipping blank glyphs.

Runtime external fonts are the second half of the request. A new optional target plot_font_ttf vendors stb_truetype.h (public domain, single header) under third_party/ and provides:

namespace plot::text {
/// Rasterises a TrueType/OpenType file into an atlas at one pixel size.
class TrueTypeFont final : public render::TextBackend {
 public:
  /// Reads `utf8_path` fully and bakes an atlas at `px` em. Returns false and
  /// fills `*st` on a missing file, an unparsable font, or a bake that produced
  /// no ink — matching the existing `bool` + out-param + `Status*` convention
  /// rather than introducing a new `Result<T>` type.
  static bool from_file(const char* utf8_path, f32 px, TrueTypeFont* out, Status* st = nullptr);
  /// Which codepoints to bake. Defaults to the same coverage as the embedded font.
  void set_coverage(std::span<const u32> codepoints);
};
}

third_party/ and stb_truetype are reachable only by this opt-in target; plot_core and every public header under include/plot/ remain third-party-free, and the negative check for that (the loader/nm audit and the self-containment build) still applies. The no third_party/ rule in the predecessor spec is hereby narrowed to the core, not the optional font target.

3. Vertical y-axis label

PlotLayout::y_title_area becomes a narrow vertical strip — one text line wide rather than tall — placed immediately outside y_label_area, and the y title is drawn bottom-to-top (rotated 90° CCW) inside it. y_title_width consequently means the strip's width (one line height), and the old "horizontal run stacked down a tall strip" model and the "one row per titled y axis" rule are replaced by "one column per titled y axis", so a Left/Right pair still cannot collide.

Plot continues to reserve nothing when no axis of that direction has a title. The invariant that a title is never drawn inside axes is unchanged and still tested.

4. Legend

Trace gains std::string name (empty means "no legend entry"). A new Legend config lives on the plot:

struct Legend {
  bool visible = true;            // auto-suppressed when no trace has a name
  LegendPlace place = LegendPlace::TopRight;   // TopLeft/TopRight/BottomLeft/BottomRight
  bool frame = true;              // background panel behind the entries
  f32 pad = 6.0f; f32 swatch_w = 14.0f; f32 swatch_h = 8.0f; f32 row_gap = 3.0f;
  f32 margin = 8.0f;              // inset from the axes edge
};

Layout: entries are measured with the same TextBackend the renderer draws with; the panel is swatch_w + gap + widest name + padding wide and rows * line + gaps tall, clamped inside axes; if it cannot fit, entries are dropped from the end and a … row is drawn. Each row draws a filled swatch in the trace colour (alpha 1.0, so a translucent trace is still legible) and the name in axis_text. A trace with an empty name is skipped, and a legend with no entries draws nothing at all — so the feature is free for callers who never name a trace.

5. Direct manipulation: cursors, regions and areas

Cursors become grabbable with no modifier: the controller hit-tests the cursor marker first, and a press within the existing cursor hit radius enters MoveCursor, showing the cursor in a highlighted colour while hovered or grabbed. The radius is not a new constant — it is the one the controller already uses, so hover and grab agree. Falling back: pressing elsewhere still pans, so the old behaviour is a superset. Snapping is unchanged (SnapMode::NearestSample on release/drag).

Areas — both forms, since both were requested:

enum class RegionSnap { Free, NearestSample };
struct Region {
  f64 x0 = 0.0, x1 = 0.0;         // ordered on the x axis
  RegionSnap snap = RegionSnap::NearestSample;
  Color fill = Color::hex(0xFFB020, 0.15f);
  Color edge = Color::hex(0xFFB020);
  std::string label;
  bool visible = true;
};

class RegionStats {                 // computed over the region's x interval
 public:
  f64 x0 = 0, x1 = 0, width = 0;
  f64 mean = 0, rms = 0, min = 0, max = 0, integral = 0;
  u64 count = 0;                    // samples in the interval
  bool valid = false;
};

Statistics are exact via Series::reduce/sum/sumsq, exactly as measurements are.

Plot gains add_region(Region) / region(u32) / set_region(u32, f64 x0, f64 x1) / region_stats(u32, u32 trace) / remove_region(u32). Region statistics reuse the exact Series interval queries, exactly as measurements do — no new scanning path.

Interaction: a region has three grab zones — the interior (move the whole interval, preserving width), the left edge, and the right edge — using that same cursor hit radius, resolved nearest-first so an edge always wins over the interior when both are in range. Dragging an edge moves one bound; x0 > x1 is never stored (bounds are ordered on assignment). A region narrower than ~3 px is treated as a click, not an edge grab, so it stays draggable once collapsed.

The cursor-bound measurement band also gets draggable edges, so the shaded band between two measurement cursors can be resized by dragging either edge — the same hit-test path, mapping the grab to the cursor that owns that edge.

New controller modes: MoveRegion, ResizeRegionLeft, ResizeRegionRight, plus RegionGrab reporting which zone is hot so a host can set a resize cursor.

6. Multi-plot with a synchronised x axis

class Figure {
 public:
  explicit Figure(u32 rows);
  Plot& plot(u32 row);

  /// Axis link groups. Plots in the same group share one x range: setting the
  /// range on any member (by API or by interaction) propagates to the others.
  /// Group 0 exists by default and every plot starts in it.
  u32 add_x_link();
  void link_x(u32 row, u32 group);
  void set_x_link_range(u32 group, Range);

  void set_viewport(Rect);        // the whole figure, framebuffer pixels
  const FigureLayout& layout() const;
};

The legend is per plot, not per figure (§4): each row legends its own traces, which is what a multi-signal row needs and what keeps Renderer::draw(Plot&) unchanged for single-plot hosts.

FigureLayout holds the per-row Rects (each row's PlotLayout inset by the figure's margins), one shared bottom x-axis strip, and the gap between rows. Row heights are equal by default; set_row_weights(span<const f32>) allows a taller main plot.

Rendering: Renderer::draw(Figure&) lays the rows out, then for each row sets Plot::set_viewport(row rect) and draws it exactly as draw(Plot&) does today, then draws one shared x axis under the last row. The x-axis ticks are drawn once, not per row; interior rows hide their bottom label strip to avoid duplicated labels (configurable). draw(Plot&) remains and is unchanged for single-plot hosts, so no existing caller breaks.

Propagation: a range change from the controller on row i is applied to the group and then pushed to every member before the next frame, so a pan or zoom anywhere moves all plots together. Propagation is idempotent and must terminate: a group member receiving a propagated range must not re-propagate it.

7. Invariants that must not regress

These are inherited from the predecessor spec and are non-negotiable; every task's review checks them:

  • plot_core and all public headers stay third-party-free (now: the optional plot_font_ttf target may use the vendored stb_truetype and nothing else may).
  • Vertex data stays in data space; pan/zoom remains a uniform update; the per-frame cost stays O(visible pixels + produced samples).
  • The renderer clears only its viewport region via scissor and hands back host GL state.
  • Exact interval queries: regions, legends and measurements use Series::reduce/sum/sumsq, never the pyramid's *_buckets forms.
  • 3.3-core baseline; anything above stays behind caps().
  • The 130 existing test cases keep passing unmodified except where a signature genuinely changes, and every signature change is called out in the task report.

8. Verification

Area How
Text refactor, font, rotation Pixel tests on the headless EGL context: a rotated run occupies a tall narrow box and its ink is where the basis says; the embedded atlas reports non-empty ink for every covered codepoint; a swapped-in second backend (a test double with a differently sized atlas) renders, proving revision()-driven re-upload works.
Vertical y label Pixel test that the title strip is narrow and tall, that no title ink appears inside axes, and that the ink is rotated (taller than wide for a run that is wider than tall when horizontal).
Legend Layout unit tests (panel size, clamping, overflow-drop) plus a pixel test asserting the swatch colour and the label text appear inside the panel, and that an unnamed plot draws no panel.
Drag and drop Controller unit tests: grab without modifier, edge-before-interior resolution, ordering invariant under inversion, sub-3px click handling, snap on release, and that a pan still happens when no handle is hot.
Multi-plot + x-link Layout unit tests for row rects and weights; link tests (set on any member propagates to all, idempotent, terminating); pixel test that two rows render distinct content and the shared x axis appears once.
Examples All four existing demos gain a legend and keep their headless PLOT_DEMO_EXIT_AFTER_MS/PLOT_DEMO_SCREENSHOT mode; a fifth demo shows the multi-plot synced figure. Every demo verified by headless run plus pixel classification of its own frame, and I eyeball the frames myself.

9. Risks

  • The text refactor touches every text pixel in the library. Mitigated by doing it first, alone, with the existing pixel tests as the regression net plus new rotation tests.
  • Rotation in the vertex shader is easy to get subtly wrong (mirrored glyphs, UV handedness). Mitigated by asserting absolute orientation with an asymmetric glyph, as the bitmap font already does.
  • Adwaita Mono's licence is OFL 1.1 with reserved font names; mitigated by a neutral backend name, a committed licence, and stated provenance.
  • Figure touches the renderer's frame contract. Mitigated by leaving draw(Plot&) untouched and adding draw(Figure&) alongside it, so the four shipped adapters keep working unchanged.
  • Snapshot/revision() staleness could render a stale atlas. Mitigated by making revision() monotone and testing a backend swap mid-run.

10. Consistent physical sizing: the UI scale

This reverses a decision recorded in the predecessor spec. That document settled on style metrics being device pixels and accepted the consequence that labels are relatively smaller on a high-DPI host. That is backwards for a library whose output should look the same everywhere: a 12 px axis label must be the same physical size on a 1x panel and a 2x panel, and a 1 px grid must not be a hairline on one and a slab on the other.

Style gains f32 ui_scale = 1.0f — device pixels per logical unit. Every visual metric in Style is henceforth a logical unit (axis_font_px, line widths, grid widths, paddings, swatch and marker sizes, tick minimum spacing), converted to device pixels by one helper per consumer: PlotLayout when it sizes label strips, title strips, the legend and region chrome, and the renderer when it converts widths and font px. Plot scales the font px it hands to the text pipeline, so a logical 12 px label is drawn at 12 * ui_scale device px.

Data-space geometry is untouched: only chrome scales. The pixel-space convention is otherwise unchanged — Rect is still device pixels with a top-left origin, so the renderer, the scissor clear and every adapter's event mapping stay as they are.

The host supplies the value and every adapter already knows it: GLFW from glfwGetFramebufferSize / glfwGetWindowSize, Qt from devicePixelRatioF(), GTK from the two sizes the ::resize signal reports, ImGui from io.DisplayFramebufferScale. Renderer::draw() propagates style().ui_scale onto the plot before laying out, so layout and drawing cannot disagree about the scale.

Invariant, and the test that pins it: with ui_scale = 2.0, every chrome element's device extent doubles while its logical extent is unchanged — the same axis label occupies twice the pixels but expresses the same size, and measure-derived strip widths scale with it.

11. Grid lines as two styled classes

Style carried separate colours for major and minor grid lines but neither width nor visibility, so a dense plot could not switch the minor grid off, and a fine grid could not be drawn thinner than a coarse one. Each class gains its own width and visibility, all in logical units scaled by §10:

Color grid_major = Color::hex(0x2A3038);
Color grid_minor = Color::hex(0x1C2126);
f32   grid_major_width = 1.0f;   // logical px
f32   grid_minor_width = 1.0f;
bool  grid_major_visible = true;
bool  grid_minor_visible = true;

The grid emits majors exactly at the major ticks and minors exactly at the minor ticks — one source, the tick set — so a major grid line coincides with a labelled tick by construction rather than by coincidence. Visibility is checked before staging, so a disabled class costs nothing to draw rather than being drawn transparent.

12. Live data: relative zoom and panning that keeps following

The streaming case is the library's headline use, and today any interaction destroys it: a wheel zoom or a drag calls set_x_range, which clears auto_scale, so a live plot stops following the newest sample and never resumes. That is wrong for a scrolling display, and it is why panning feels useless there. The fix is an explicit follow state on the x axis instead of an implicit consequence of auto_scale:

enum class XFollow { Off, Live };
// on Plot:
void set_x_follow(u32 axis, XFollow);
XFollow x_follow(u32 axis) const;
void set_x_offset(u32 axis, f64 units_behind_live);   // >= 0, clamped to the retained window
f64 x_offset(u32 axis) const;

With Live, the visible window is [newest - offset - span, newest - offset], recomputed from the series' newest x every frame, so new samples keep arriving at the right edge and the plot never stops following.

  • Relative zoom. A wheel zoom multiplies the span rather than adding to it, so successive notches compound and the zoom is scale-free. The anchor is the data coordinate under the pointer when the pointer is inside axes, and the live edge otherwise. Following is preserved either way, and the offset is adjusted so the anchored coordinate does not move on screen.
  • Mouse panning. A left-drag changes offset by the data delta and leaves the span alone — this is the panning a live plot needs, and what "add mouse panning" means here. Dragging toward the live edge stops at offset == 0; you cannot pan past the newest sample. Dragging away moves back through the retained window, so the limit is what the StreamSeries still holds.
  • Returning to live. auto_fit under Live sets offset = 0 and fits the span to the retained window. It does not leave Live.
  • Interactions never silently disable following. Pan and zoom keep XFollow::Live armed; only an explicit set_x_follow(axis, Off) turns it off. This is the behavioural change: today's interaction path disarms auto-follow as a side effect.
  • Static plots are unaffected. XFollow::Off is the default, and a plot whose series is not streaming behaves exactly as it does today, including auto_scale on the y axis.

The y axis keeps its existing auto_scale; this section governs the x view of a streaming series.


13. Clip the data to the plot area (defect)

This is a bug, reported from use: "you can see signals outside the plot area." Renderer::begin_frame scissors the viewport, and traces are drawn through the axes transform, so any sample whose y lies outside the visible range maps outside axes and is drawn anyway — over the y label strip, the y title strip, and in a Figure straight into the neighbouring row. The same applies to grid lines at a tick outside the range, region fills and their bounds, cursors and the measurement band.

The fix is a scissor to layout().axes, in framebuffer pixels and intersected with the viewport, around every pass that draws inside the axes: grid, regions, traces, cursors, and the measurement band. The passes that draw outside it by design — tick labels, axis titles — must run with the axes clip released, or they would be erased. So the frame alternates: viewport clip → axes clip for the interior passes → back to the viewport clip for the label and title passes.

This is a state change, not a geometry change: no vertex data moves, no CPU clipping is introduced, and the data-space invariant is untouched.

Verification is a pixel test with a discriminating predicate: a trace whose values lie far outside the y range must produce no trace-coloured pixel outside axes — specifically none in the label or title strips — and, for a two-row Figure, none inside the neighbouring row's rect. It must fail against the current code.

14. Horizontal rulers

Cursors are vertical only: Cursor carries an x and the drag path moves it along x. Reading a value off the y axis needs the same instrument turned 90°.

Cursor gains an orientation and a second coordinate, and the existing grab/drag/hover path handles both:

enum class CursorOrientation { Vertical, Horizontal };
struct Cursor {
  CursorOrientation orientation = CursorOrientation::Vertical;
  f64 x = 0.0;          // meaningful for Vertical
  f64 y = 0.0;          // meaningful for Horizontal
  CursorSnap snap = CursorSnap::NearestSample;   // Vertical
  HRulerSnap h_snap = HRulerSnap::Free;          // Horizontal: Free | NearestTick
  Color color = Color::hex(0xFFB020);
  std::string label;
  u32 axis_x = 0, axis_y = 0;
  bool visible = true;
};

Horizontal rulers span the axes width, are grabbable and draggable along y with the same hit radius as a vertical cursor, and snap either freely or to the y tick set (NearestTick — a y sample has no meaning, since the series is sampled in x). The plot gains add_h_ruler(f64 y, Color) / set_cursor_y(u32, f64) alongside the vertical pair, and hovered_cursor/grabbed_cursor report both orientations. A vertical and a horizontal cursor are indistinguishable to the drag machinery except in which coordinate they mutate, so there is one hit-test path, not two.

15. Crosshair with a per-signal hover readout

Following the pointer inside axes should show the data under it: a crosshair at the pointer's x (and optionally its y), a marker on each visible trace at that x, and a readout panel listing each trace's name and value there — the same information a cursor gives, but continuous with the mouse and available per signal.

struct Crosshair {
  bool active = false;
  bool vertical = true, horizontal = true;   // which lines to draw
  bool show_readout = true;
  f64 x = 0.0, y = 0.0;                      // data coordinates
};
// on Plot:
void set_crosshair(f64 x, bool active);      // vertical line + per-trace readout
void set_crosshair(f64 x, f64 y, bool active);
const Crosshair& crosshair() const;
void clear_crosshair();

Value lookup. The readout's y for a trace is obtained by linear interpolation between the two samples adjacent to the crosshair's x, and is documented as interpolated rather than exact — the opposite convention from Series::reduce/sum/sumsq, which are exact over a sample range. The distinction matters, so it is stated on both. A sample lookup uses snap-style index arithmetic, is O(1), and never scans: the crosshair updates on every pointer move and must stay cheap.

Synced across plots. The crosshair's x is a data coordinate, so one hover can drive every row: Figure::set_crosshair(f64 x, bool active) applies the same x to every row, and each row's readout lists its own traces at that x. That is what "synced if multiple plots available" means — one pointer position, one time, N rows of values. A figure with rows at different sample rates still shares the x, because the x is the shared axis.

Painting. Lines in a style.crosshair colour (default distinct from cursor), thin and distinguishable from the grid; a small marker at each trace's intersection; the readout panel placed inside axes, offset from the pointer, flipped to stay within the axes rect near an edge, with a swatch and value per visible named trace, reusing the legend's row measurement rather than growing a second one. An unnamed trace still contributes a value row (it has a colour); the legend's rule of skipping unnamed traces does not apply here, since the readout is about what is under the pointer, not about identity. Clip everything to axes per §13, and place the panel so it cannot fall outside.

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.


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:

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