The controller has always tracked its selection; nothing drew it, so a Ctrl or Shift drag framed a range with no feedback until the release. The preview is the area the release will change, not what the pointer swept: Ctrl frames the x range only, so a band across the plot is what it will zoom, where the sliver a straight horizontal drag sweeps is only the gesture. The plot carries the rect a host hands it -- it learns nothing about pointers -- and the renderer draws a low-alpha fill under a light border over everything else inside the axes.
251 lines
16 KiB
Markdown
251 lines
16 KiB
Markdown
# 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).
|
||
|
||
The frame an open box zoom is drawing is published the same way and for the same reason
|
||
(`Plot::set_selection_rect()`), and the plot still learns nothing about pointers: the host reads the
|
||
rect off the controller -- `Controller::selection_preview()` -- on every frame of the drag and takes it
|
||
off the plot when the gesture ends. The preview is deliberately not what the pointer swept. Shift
|
||
frames both axes and its preview is the swept box; Ctrl frames x only, so its preview is widened to the
|
||
full height of the axes, and Alt's to the full width. The swept sliver is the *gesture*; the widened
|
||
rect is what the *release* will apply, which is the one worth showing while it is still only a
|
||
gesture. Nothing is previewed until the drag has swept every axis the release will act on: an empty
|
||
rect widened is a band the width of the plot, which would claim a range that does not exist yet.
|
||
|
||
## 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`.
|