Files
plotter/docs/interaction.md
T
Martino FerrariandClaude Opus 4.6 c028f48fa0 fix(plot): a crosshair readout quotes a sample, never a value between two
The readout's y was a linear interpolation between the two samples adjacent to
the pointer, which answers "what is the signal at this instant" -- a number the
series does not contain. On a source whose samples are already averaged or
decimated at ingest that is a value nothing measured, and at a zoom past the
sample spacing it is visibly not the datum under the pointer.

`crosshair_span()`'s `t` becomes a selector rather than a blend factor: the
nearer end wins, a tie reading the earlier sample (the bias the span already
takes for a degenerate pair). `t` is measured on the samples' own x through
`neighbours()`/`x_at()`, so "nearer" is exact on a non-uniform clock too, and
the lookup still reads level 0, so the value is a stored sample and never an
LOD bucket's aggregate. `crosshair_interpolate()` had no other caller and is
gone.

`CrosshairRow::marker` is placed from the same y, so the dot follows the
reported sample: past the sample spacing it sits beside the polyline rather
than on it, deliberately -- one y feeding both keeps the mark and the number
from ever disagreeing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-05 13:42:57 +02:00

211 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Interaction
Everything in this page is driven by `input::Controller` (`include/plot/input/controller.hpp`,
`src/input/controller.cpp`), which turns host events into gestures knowing nothing about Qt,
GTK, GLFW or ImGui. It is pure CPU and needs no GL context and no font — everything it reads
comes from the `Plot`'s layout, ranges and transforms. It owns no viewport of its own:
`Controller::set_viewport(Rect)` forwards to `Plot::set_viewport()` and refreshes the layout,
so the two cannot disagree.
The plot's own state — cursors, regions, the measurement, the legend, the crosshair — is
owned by `Plot`; the controller only moves it and publishes what is hot.
## Cursors
A cursor is a draggable marker at a data coordinate (`Cursor`, `plot/cursor.hpp`). There are
two orientations, one type:
| orientation | created by | live coordinate | live snap | dead fields |
|---|---|---|---|---|
| `CursorOrientation::Vertical` | `Plot::add_cursor(f64 x, Color)` | `x` on `axis_x` | `SnapMode` | `y`, `h_snap`, `axis_y` |
| `CursorOrientation::Horizontal` | `Plot::add_h_ruler(f64 y, Color)` | `y` on `axis_y` | `HRulerSnap` | `x`, `snap`, `axis_x` |
A vertical cursor is a marker at `x` spanning the axes' **height**; a horizontal ruler is at
`y` spanning their **width**. Dead fields are *ignored*, not honoured: `set_cursor_y()` on a
vertical cursor and `set_cursor_x()` on a ruler change nothing, and neither does the other
orientation's snap setter.
Snaps. `SnapMode::NearestSample` pulls a marker onto the nearest sample of the first visible
trace on its x axis; `SnapMode::Free` leaves it. `HRulerSnap::NearestTick` pulls a ruler onto
the nearest tick of its y axis' tick set (the *last* `layout()`'s set — ticks are generated
there and nowhere else); `HRulerSnap::Free` leaves it.
Grab radius. A pointer within **7 logical pixels** (`kCursorHitRadiusLogical`) of a cursor's
line grabs it. The nearest of a vertical and a horizontal cursor wins; neither orientation
widens the other's reach. The distance is logical, converted through `Style::device()` at the
plot's scale, so the aim is the same physical distance on any display.
Snap on drop. `set_cursor_x()`/`set_cursor_y()` take a `CursorPlacement`: `Snapped` (the
default) applies the cursor's snap rule immediately — the programmatic path; `Free` stores
the value verbatim and leaves the snap to `Plot::snap_cursor()`. The controller uses `Free`
while dragging, so the marker tracks the pointer under the hand and the snap is committed
once, on release. A dragged ruler is additionally clamped to its own y axis' range, because a
pointer past the top or bottom of the axes has no data under it.
## Regions
A region is a shaded x interval with exact statistics (`Region`, `plot/region.hpp`). Create
with `Plot::add_region(const Region&)`; `Region::snap` is `RegionSnap::NearestSample` or
`Free`. The bounds are **ordered**: `set_region(id, x0, x1)` stores min/max, so a drag that
crosses one bound over the other **swaps** them rather than inverting the region. A pair of
bounds that is not both finite is refused (nothing changes).
Three grab zones (`RegionGrab`): `Left` (the bound at `x0`), `Right` (the bound at `x1`) and
`Body` (the interior). The controller resolves them nearest-first against the geometry
`Plot::layout()` measured — **a bound within the grab radius beats the body**, and the nearer
of the two bounds wins; with no bound in range, the topmost region whose interior holds the
pointer is the body. That geometry is the same `RegionLayout` the renderer paints, so what is
drawn and what can be grabbed cannot drift apart.
The bound band is the same 7-logical-pixel radius, cut back so a narrow region keeps a
middle a body drag can reach (`min(radius, (width − 2)/2)`, with a minimum interior of
**2 logical pixels**, `kMinRegionBodyLogical`). A region narrower than **3 logical pixels**
(`kMinRegionEdgeGrabLogical`) is grabbed by its body instead, widened to that same minimum —
otherwise its two bounds would be one target and it could never be moved again. `Body` drag
translates both bounds by one delta, preserving the width exactly; a bound drag moves one end
and holds the other. A snapping region is put on the sample grid once, on release
(`snap_region()`), not on every motion. `remove_region(id)` drops the region and shifts the
ids above it down.
## The measurement band
`Plot::add_measure(cursor_a, cursor_b, trace = 0)` measures the interval between two cursors
over the series of `trace`. The renderer draws the shaded band the readout belongs to from
`Plot::measure_span(id)` (which reports the cursor pair), and the statistics come from
`Plot::measurement(id)`: `dx`, `dy`, `slope`, `mean`, `rms`, `ymin`, `ymax` and the
left-Riemann `integral`. The band's edges *are* those cursors, so it is resized by dragging
either of them. A measurement is re-evaluated only when a cursor moved, when it was added, or
when the series underneath changed — never once per frame; `const` because recomputing fills
a cache.
## The legend
The legend is on by default (`Legend::visible = true`) and free until a trace is named: with
no named visible trace — or no text backend to measure a name with — `Plot::layout()` produces
an empty layout and nothing is drawn. A trace earns an entry by being given a `name`
(`TraceDesc::name`/`Trace::name`). Configure it with `Plot::set_legend(const Legend&)`;
`Legend::place` anchors the panel to a corner of the axes (`TopLeft`, `TopRight`,
`BottomLeft`, `BottomRight`) and `Legend::margin` is both the anchor's inset and the budget
the panel has to fit in, so a panel that does not fit drops entries and ends with `...` rather
than overhanging. See [styling.md](styling.md) for the knobs and their defaults.
## The crosshair
The crosshair is a **hover** instrument, not a cursor: it follows the pointer and exists only
while the pointer is over the data area (`Crosshair`, `plot/crosshair.hpp`). There is at most
one per plot.
- `Plot::set_crosshair(f64 x, bool active)` places a **vertical-only** crosshair: the column
of `x`, a marker on every visible trace at that x, and the readout panel beside it. This is
the form for a caller that has a coordinate and not a pointer.
- `Plot::set_crosshair(f64 x, f64 y, bool active)` adds the **horizontal** line at `y`. This is
the form the controller uses, because a pointer over the axes has both coordinates.
- `Plot::clear_crosshair()` hides it, leaving it where it was. The controller calls this when
the pointer leaves the axes; a press, wheel or key that moves the data under the pointer
*re-anchors* the crosshair rather than clearing it.
- `set_crosshair_readout(bool)` switches the panel off — or back on — without moving the
crosshair. `set_crosshair_style(const CrosshairStyle&)` overrides this plot's stroke and
dash.
In a `Figure`, the crosshair is applied to **every row** (one shared `x`): each row's readout
lists its own traces at that instant. `Figure::set_crosshair(x, active)` is vertical-only
across the figure, and `Figure::set_crosshair(x, hovered_row, y_in_row, active)` adds the
horizontal line **only in the row the pointer is in** — a figure's rows share x and not y, so
a figure-wide horizontal line would be a value that means something different in each row.
Readout values are the **nearest sample's**, neither interpolated between two samples nor an
interval statistic (see [data-and-performance.md](data-and-performance.md)); a row whose trace
has no sample at the crosshair's x is listed with the em dash `"—"` and no marker, rather than a
fabricated value.
## The gesture table (exactly as implemented)
A press opens the first of these that applies, in this order (from `Controller::begin_drag`
and `Controller::begin_handle_drag`):
| gesture | drag mode | effect |
|---|---|---|
| **shift + left drag** | `BoxZoom` | both ranges from the selection rect, applied on release |
| **middle drag on the x label strip** | `AxisZoomX` | x-only zoom |
| **middle drag on the y label strip** | `AxisZoomY` | y-only zoom |
| middle drag anywhere else | — | nothing |
| **left or right drag on a handle** | `MoveCursor` / `MoveRegion` / `ResizeRegionLeft` / `ResizeRegionRight` | move the grabbed handle |
| **left or right drag elsewhere** | `Pan` | translate both ranges |
| **wheel** | — | zoom about the pointer (shift: x only, ctrl: y only; neither or both: both axes) |
| **`+` / `-`** | — | zoom about the plot centre |
| **`f`** (or `F`) | — | `auto_fit_x()` / `auto_fit_y()` every axis |
Precedence for a press that is not shift+left and not middle: **cursor marker → region bound
→ region body → pan**. Shift is checked first of all, so a selection box is what shift+left
means even when it starts over a handle. The right button **pans and grabs** exactly as the
left does — both are buttons a host drives drags with, so neither is singled out for handles.
`Ctrl` is not needed for a cursor move and does not change what a press on one does.
Values behind the gestures:
- one wheel notch (or one `+`/`-` press) changes the span by `exp(-0.15)` (~14%,
`kZoomPerNotch`); positive = zoom in;
- a middle-drag axis zoom is one decade per **200 logical pixels**
(`kZoomPerPixel = ln10 / 200`), with the wheel's sign convention (dragging down zooms in);
- a box-zoom selection under **4 logical pixels** on either side (`kMinBoxLogical`) is a
click and leaves the ranges alone.
Events are sanitised before they act: a non-finite coordinate or delta becomes the last known
position and no movement, but the buttons still flow, so a drag whose release arrives with a
garbage position still ends. A wheel amount that is not a number is refused outright (a
zeroed notch would still run the range setters, which clear auto scale). A gesture that sets
a range also clears that axis' `auto_scale` — one place, in `Plot`'s range setters.
What the pointer is on is published to the plot (`Plot::set_hot_handles()`) after every event
that can move the geometry under the pointer, so the renderer draws hovered/grab highlights
without knowing what a pointer is. `hovered_cursor()`, `grabbed_cursor()`, `hovered_region()`,
`grabbed_region()` and `region_grab(u32)` expose the same answers to a host (a resize cursor, a
tooltip, a status line).
## Live follow
An x axis has a follow state, `XFollow::Off` (the default) or `XFollow::Live`
(`Plot::set_x_follow(axis, f)`). `Live` makes the visible window a view from the live edge:
every `layout()` places it at `[newest − offset − span, newest − offset]`, where `newest` is
the newest x any visible streaming trace bound to the axis holds (`Plot::x_live_edge()`).
`Off` means the range is exactly what a caller set or an auto-fit produced.
**`set_x_range()` on a live axis is not an absolute window.** A range handed to
`Plot::set_x_range(axis, r)` while the axis is `Live` *and* has a live edge is read as the
**span** the window should have and the **offset** it implies — `offset = newest − r.max`,
clamped to what the traces still retain — and the visible window follows from those at the
live edge of the moment. `XFollow::Off` stores the range verbatim. The point of the
reinterpretation is that every gesture (pan, wheel, box zoom, axis zoom) reaches the x range
through this one setter, so they all keep following for free.
The offset is clamped to `[0, retained span]` (`Plot::set_x_offset()`, read back with
`Plot::x_offset()`): the upper limit is what the traces still hold, so the window's right
edge never goes behind the oldest retained sample (and the view is left showing the dropped
data rather than inventing samples that are gone); the lower limit is why dragging toward the
live edge stops instead of turning the window inside out. A non-finite offset is refused. The
clamp is applied again by `layout()` every frame.
Consequences while an axis is `Live` *and* has a live edge:
- `auto_scale` is **inert** on that axis: `layout()` does not fit it, because a fit would
overwrite the span (the user's zoom) on every frame. The follow state wins; `auto_fit_x()`
is how such an axis is framed (it fits, sets the span to the retained window's, and returns
the offset to 0 without turning following off).
- Nothing but `set_x_follow()` disarms following. No gesture and no `set_x_range()` reaches
the follow state.
- **Zoom is relative (multiplicative)**: the wheel multiplies the span, so notches compound,
and the anchor is kept still by adjusting the offset. **Pan** shifts the offset by the
drag's data delta and leaves the span alone, so a user can walk back through the retained
window and stops at the live edge.
Arming `Live` on an axis with a live edge places its window at the live edge at once, from the
span it already had. Turning `Live` off leaves the axis where it is: the last live window
becomes an ordinary stored range.
**Figures.** In a `Figure`, the follow state belongs to the link **group**, not the row: a
group pushes one window to many rows, so the state has to be the group's or the same range
would mean a different window in each member. Use `Figure::add_x_link()`, `link_x(row, group)`,
`set_x_link_range(group, r)` and `set_x_link_follow(group, XFollow::Live)`; a group derives
its window once per frame from the newest x across all its members' traces. `link_x()` refuses
a member whose own axis is `Live`.