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.
15 KiB
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 ofx, 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 aty. 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).
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 byexp(-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_scaleis 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 noset_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.