feat(input): live x follow with relative zoom and mouse panning

This commit is contained in:
Martino Ferrari committed 2026-09-28 13:01:48 +02:00
1 parent eaae07d8e0
commit 77e861f79e
10 files changed
+971 -28

No files matched your search

+135 -5
View File
@@ -165,8 +165,29 @@ Range Plot::y_range(u32 axis) const { return y_axis(axis).range; }
void Plot::set_x_range(u32 axis, Range r) {
if (axis >= x_axes_.size()) return;
x_axes_[axis].range = r;
x_axes_[axis].auto_scale = false;
Axis& a = x_axes_[axis];
Range live{};
if (a.follow == XFollow::Live && live_x_bounds(axis, live) && r.span() > 0.0 &&
std::isfinite(r.min) && std::isfinite(r.max)) {
// Under Live the caller is stating a *span* and where the window sits relative
// to the live edge, not a pair of absolute positions: the next layout()
// recomputes the window from the series' newest x, so storing `r` verbatim
// would be a position that survives exactly until the next frame. The offset is
// what carries the position -- derived here from the live edge as it is *now*,
// which is what makes a zoom keep its anchor still and a pan shift by the
// drag's data delta -- and place_live_x_window() clamps it and stores the
// window that follows from it, so the state is consistent before the next
// layout rather than only after it.
a.range = r;
a.offset = live.max - r.max;
place_live_x_window(axis);
a.auto_scale = false; // as ever: a range a caller chose is not an auto-fit
return;
}
// Off, or Live with nothing streaming to follow: the range is stored verbatim,
// which is what every caller of this setter got before follow existed.
a.range = r;
a.auto_scale = false;
}
void Plot::set_y_range(u32 axis, Range r) {
@@ -183,6 +204,95 @@ void Plot::set_y_auto_scale(u32 axis, bool on) {
if (axis < y_axes_.size()) y_axes_[axis].auto_scale = on;
}
void Plot::set_x_follow(u32 axis, XFollow f) {
if (axis >= x_axes_.size()) return;
x_axes_[axis].follow = f;
// Arming a live axis places its window at once, so x_range() reports the window
// the next layout produces rather than the one the axis held while it was Off.
// Nothing else follows from it: the follow state is only ever changed here, and
// the range the axis already held is the span `Live` starts from (see the header).
place_live_x_window(axis);
}
XFollow Plot::x_follow(u32 axis) const {
return axis < x_axes_.size() ? x_axes_[axis].follow : XFollow::Off;
}
void Plot::set_x_offset(u32 axis, f64 units_behind_live) {
if (axis >= x_axes_.size()) return;
// No such offset: a NaN would place a window that is not a window, and the last
// usable one is a better answer than that for a value no gesture can undo.
if (!std::isfinite(units_behind_live)) return;
x_axes_[axis].offset = std::max(0.0, units_behind_live);
// The upper end of the clamp is what the traces still retain, which only they
// know: place_live_x_window() applies it, and with it brings the visible window
// along so x_range() and x_offset() agree before the next layout. A no-op on an
// axis that is not Live -- the value waits there for when following is armed.
place_live_x_window(axis);
}
f64 Plot::x_offset(u32 axis) const {
return axis < x_axes_.size() ? x_axes_[axis].offset : 0.0;
}
bool Plot::x_live_edge(u32 axis, f64& newest) const {
Range live{};
if (axis >= x_axes_.size() || !live_x_bounds(axis, live)) return false;
newest = live.max;
return true;
}
bool Plot::live_x_bounds(u32 axis, Range& out) const {
bool any = false;
for (const Trace& t : traces_) {
// Only a streaming series has a live edge. An array's bounds cover everything
// it holds and change only when the caller replaces the data, so `Live` on such
// a trace would be an anchor that never moves -- the axis is left to its stored
// range instead (see place_live_x_window).
if (t.axis_x != axis || !t.visible() || !t.series->streaming()) continue;
const Range b = t.series->x_bounds();
if (!std::isfinite(b.min) || !std::isfinite(b.max)) continue;
if (!any) {
out = b;
any = true;
} else {
// The union of what the traces retain: the oldest x any of them still holds
// and the newest any of them holds, so the window is clamped against the ring
// that reaches furthest back and anchored to the sample that is newest.
out.min = std::min(out.min, b.min);
out.max = std::max(out.max, b.max);
}
}
return any;
}
bool Plot::follows_live(u32 axis) const {
if (x_axes_[axis].follow != XFollow::Live) return false;
Range live{};
return live_x_bounds(axis, live);
}
bool Plot::place_live_x_window(u32 axis) {
Axis& a = x_axes_[axis];
if (a.follow != XFollow::Live) return false;
Range live{};
if (!live_x_bounds(axis, live)) return false;
// The span is the axis' own -- what the user zoomed to, or what the last fit
// produced -- and the follow window only moves it: the span is what makes this a
// scroll rather than a rescale. A span that is not a span is nothing to place a
// window from, so the range is left exactly as it is.
const f64 span = a.range.span();
if (!std::isfinite(span) || !(span > 0.0)) return false;
// The offset can outlive the window it names: a ring that dropped samples moves
// the oldest x forward under it, so it is clamped against what the traces hold
// *now* -- an offset past that would put the window's right edge behind the
// oldest sample left, i.e. over data that is gone.
const f64 retained = std::max(0.0, live.max - live.min);
a.offset = std::clamp(a.offset, 0.0, retained);
a.range = Range{live.max - a.offset - span, live.max - a.offset};
return true;
}
Transform Plot::x_transform(u32 axis) const {
const Axis& a = x_axis(axis);
const Rect r = layout_.axes;
@@ -235,7 +345,14 @@ void Plot::auto_fit_x() {
for (u32 i = 0; i < x_axes_.size(); ++i) {
// Fitting re-arms auto scaling, so the next layout keeps the axis framed. An
// axis with nothing bound to it has nothing to fit and nothing to re-arm.
if (fit_x_axis(i)) x_axes_[i].auto_scale = true;
if (!fit_x_axis(i)) continue;
x_axes_[i].auto_scale = true;
// Under Live a fit is also the way *back* to the live edge: the span becomes the
// one that was just fitted, and following means the window ends at the newest
// sample, so the offset returns to zero. Following stays armed -- a fit is not
// how a plot stops following, set_x_follow() is.
if (follows_live(i)) x_axes_[i].offset = 0.0;
place_live_x_window(i);
}
}
@@ -246,7 +363,11 @@ void Plot::auto_fit_y(u32 axis) {
void Plot::autoscale_axes() {
for (u32 i = 0; i < x_axes_.size(); ++i) {
if (x_axes_[i].auto_scale) fit_x_axis(i);
// A Live axis with a live edge is owned by the follow path, and auto scale is
// inert on it: a fit here would overwrite the span the follow window is built
// from -- the user's zoom -- on every frame, which is exactly the fight spec §12
// removes. auto_fit_x() is how a live axis is framed.
if (x_axes_[i].auto_scale && !follows_live(i)) fit_x_axis(i);
}
for (u32 i = 0; i < y_axes_.size(); ++i) {
if (y_axes_[i].auto_scale) fit_y_axis(i);
@@ -317,9 +438,18 @@ MeasureSpan Plot::measure_span(u32 id) const noexcept {
}
const PlotLayout& Plot::layout() {
// Live x windows are placed first, so everything below that derives from a range
// -- the ticks, the grid, the transforms the controller hit-tests through, and the
// range a caller reads back -- describes the live edge of *this* frame rather than
// the position the window had when the axis was last touched, and the renderer,
// the controller and a caller all see one window per frame. O(1) per axis and
// allocation-free, so a frame pays nothing for it.
for (u32 i = 0; i < x_axes_.size(); ++i) place_live_x_window(i);
// Axes still on auto scale refit here, once per layout, so a growing stream
// stays framed. A caller gesture clears auto_scale (set_x_range/set_y_range),
// which is precisely what stops this from fighting the range the user chose.
// which is precisely what stops this from fighting the range the user chose --
// and a live axis is not fitted at all, because the follow path above owns it.
autoscale_axes();
// The label padding is a logical size, so it is converted once, here: every strip