Files
plotter/docs/interaction.md
T
Martino Ferrari c960decaea feat(plot): the readout marker snaps to the sample it quotes, and the value mode is a choice
The mark was placed at the *pointer's* x with the quoted sample's y, so with the
nearest-sample readout it sat off the datum horizontally -- and further off the deeper
the zoom. `CrosshairRow` now carries the data `x` the value belongs to and the marker is
placed from it, so in `NearestSample` the mark lands on the sample the panel is quoting.
`Style::crosshair_marker_px` (9) and `crosshair_marker_width` (1.5) replace the
renderer's hard-coded 3 px arm and 1 px stroke: a snapped mark lands among a trace's own
sample dots (`marker_px`, 3) and at that size would read as one more sample rather than
as the answer to the question the hover asks.

`CrosshairValue` makes the previous behaviour reachable rather than deleted. The two
modes answer different questions -- `NearestSample` "which datum is under the pointer",
`Interpolated` "what is the signal at this instant" -- and only the caller knows which it
is asking, so the plot offers both with the sample as the default.
`crosshair_interpolate()` is restored for it.

`Plot::crosshair_point()` is the lookup with both coordinates, public because a host that
draws instruments of its own needs the same snap and a second rule for it would drift
from this one; `crosshair_value()` delegates to it.
2026-10-05 14:36:47 +02:00

241 lines
15 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.
- `set_crosshair_value_mode(CrosshairValue)` chooses what a readout *is* — see below.
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.
### What a readout is: the two value modes
`Crosshair::value_mode` (`set_crosshair_value_mode()`) answers two different questions, and
which one a caller is asking decides the mode:
| mode | the value | the marker | answers |
|---|---|---|---|
| `CrosshairValue::NearestSample` (default) | the nearer bracketing sample's y (a tie reads the earlier) | **snapped to that sample's own x** | *which datum is under the pointer?* |
| `CrosshairValue::Interpolated` | a linear blend of the two bracketing samples | at the crosshair's own x | *what is the signal at this instant?* |
The default is the sample, because a readout that quotes a number the data does not contain is
a second estimate laid over the first — and once the view is zoomed past the sample spacing the
polyline between two samples is a line the *renderer* drew, so reading a value off it reports
the drawing rather than the signal. A host whose x is a continuous quantity it is willing to
interpolate (a smooth analytic curve, a resampled series) states `Interpolated` and gets the
blend, exactly at the pointer. Either way the value is a **stored** sample or a blend of two of
them, never an LOD bucket's aggregate (see
[data-and-performance.md](data-and-performance.md)).
`CrosshairRow` carries both halves: `x` and `y` are the data coordinate the value belongs to —
the quoted sample's own in `NearestSample`, the crosshair's x in `Interpolated` — and `marker`
is that point in screen space. `Plot::crosshair_point(trace, x, out_x, out_y)` is the same
lookup for a host that draws instruments of its own; `Plot::crosshair_value()` is it with the x
discarded.
The mark is drawn `Style::crosshair_marker_px` across (9 logical px) at
`crosshair_marker_width`, both deliberately **larger than a trace's own sample dots**
(`marker_px`, 3): in `NearestSample` the mark lands *among* those dots, and at the same size it
would read as one more sample instead of as the one the panel is quoting. `0` across draws no
mark.
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`.