feat(input): live x follow with relative zoom and mouse panning
This commit is contained in:
1 parent
eaae07d8e0
commit
77e861f79e
10 files changed
+971
-28
No files matched your search
+135
-5
@@ -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
|
||||
|
||||
Reference in new issue
Block a user