# 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 **interpolated**, not interval statistics (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`.