Files
plotter/docs/interaction.md
T

13 KiB
Raw Blame History

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 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); 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.