damascene_core/plot/view.rs
1//! The plot's pan/zoom view state: a visible data-space window per axis,
2//! plus the projection, pan, and zoom algebra that maps between data space
3//! and the pixels of the plot's data rect.
4//!
5//! [`PlotView`] is to a `plot` what
6//! [`ViewportView`](crate::viewport::ViewportView) is to a `viewport`:
7//! per-node interaction state, persisted across rebuilds in
8//! [`UiState`](crate::state::UiState) and keyed by the plot's `.key(...)`.
9//! The difference — and the reason a plot does **not** reuse the
10//! `viewport()` widget — is that a plot zooms each axis **independently and
11//! non-uniformly**: you can stretch the time axis while the value axis
12//! autoscales, and a log axis zooms affine-in-`log` space. So `PlotView`
13//! carries a separate [`AxisView`] per axis and does all of its panning and
14//! zooming in **scale space** (through the axis [`Scale`]), exactly the
15//! coordinate in which those operations stay affine (see
16//! `docs/PLOT2D_PLAN.md`, decisions 2 and 4).
17
18#![warn(missing_docs)]
19
20use crate::Rect;
21use crate::plot::scale::Scale;
22
23/// The visible window of one axis, in **data space** (the units the app
24/// measures in). For a [`Scale::Time`](crate::plot::Scale::Time) axis these
25/// are epoch seconds; for [`Scale::Log`](crate::plot::Scale::Log) they are
26/// the raw positive values, not their logarithms.
27#[derive(Clone, Copy, Debug, PartialEq)]
28pub struct AxisView {
29 /// The window's lower bound, in data space.
30 pub min: f64,
31 /// The window's upper bound, in data space.
32 pub max: f64,
33}
34
35impl AxisView {
36 /// A window spanning `min..=max`.
37 pub fn new(min: f64, max: f64) -> Self {
38 Self { min, max }
39 }
40
41 /// The window in scale space: `(forward(min), forward(max))`.
42 fn forward(self, scale: Scale) -> (f64, f64) {
43 (scale.forward(self.min), scale.forward(self.max))
44 }
45
46 /// Zoom about a scale-space fraction `t` of the window (`0.0` = `min`
47 /// edge, `1.0` = `max` edge), scaling the window width by `factor`
48 /// (`< 1.0` zooms in, `> 1.0` zooms out) while keeping the data under
49 /// `t` fixed.
50 fn zoomed(self, factor: f64, t: f64, scale: Scale) -> Self {
51 let (fa, fb) = self.forward(scale);
52 let anchor = fa + t * (fb - fa);
53 Self {
54 min: scale.inverse(anchor + (fa - anchor) * factor),
55 max: scale.inverse(anchor + (fb - anchor) * factor),
56 }
57 }
58
59 /// Pan by `frac` of the window width, measured in scale space. Positive
60 /// `frac` moves the window toward larger values.
61 fn panned(self, frac: f64, scale: Scale) -> Self {
62 let (fa, fb) = self.forward(scale);
63 let d = frac * (fb - fa);
64 Self {
65 min: scale.inverse(fa + d),
66 max: scale.inverse(fb + d),
67 }
68 }
69
70 /// Map a data value to a `0.0..=1.0` scale-space fraction of the window
71 /// (`0.0` at `min`, `1.0` at `max`). Returns the unclamped fraction so
72 /// out-of-window values land outside `[0, 1]`.
73 fn fraction(self, v: f64, scale: Scale) -> f64 {
74 let (fa, fb) = self.forward(scale);
75 if fb == fa {
76 0.0
77 } else {
78 (scale.forward(v) - fa) / (fb - fa)
79 }
80 }
81
82 /// Inverse of [`fraction`](Self::fraction): the data value at scale-space
83 /// fraction `t`.
84 fn at_fraction(self, t: f64, scale: Scale) -> f64 {
85 let (fa, fb) = self.forward(scale);
86 scale.inverse(fa + t * (fb - fa))
87 }
88
89 /// A window framing the data span `(min, max)` with a fractional `pad`
90 /// of headroom added in **scale space** — so a linear axis pads by
91 /// value, and a log axis pads by *ratio* (a padded log window never
92 /// crosses zero, which would explode the warp; see issue #124). A
93 /// degenerate span is nudged to a unit scale-space window so the view
94 /// is always usable.
95 pub fn fit((min, max): (f64, f64), pad: f64, scale: Scale) -> Self {
96 let (fa, fb) = (scale.forward(min), scale.forward(max));
97 if !min.is_finite() || !max.is_finite() || fb <= fa {
98 // Degenerate: center a unit scale-space window on the (finite)
99 // value.
100 let c = if min.is_finite() { fa } else { 0.0 };
101 return Self::new(scale.inverse(c - 0.5), scale.inverse(c + 0.5));
102 }
103 let m = (fb - fa) * pad;
104 Self::new(scale.inverse(fa - m), scale.inverse(fb + m))
105 }
106}
107
108/// A plot's pan/zoom state: the visible window of each axis. Persisted per
109/// node and readable with
110/// [`UiState::plot_view`](crate::state::UiState::plot_view) — e.g. for the
111/// virtual-data pull, where the app loads or resamples its source to cover
112/// the window the user has scrolled into (see `docs/PLOT2D_PLAN.md`,
113/// decision 5).
114///
115/// All projection is parameterised by the axis [`Scale`]s and the plot's
116/// **data rect** (the inner area the marks draw into, excluding axes and
117/// legend), so the same view projects correctly as the layout resizes.
118#[derive(Clone, Copy, Debug, PartialEq)]
119pub struct PlotView {
120 /// The visible window of the horizontal axis.
121 pub x: AxisView,
122 /// The visible window of the vertical axis.
123 pub y: AxisView,
124}
125
126impl PlotView {
127 /// A view with the given per-axis windows.
128 pub fn new(x: AxisView, y: AxisView) -> Self {
129 Self { x, y }
130 }
131
132 /// A view framing the data bounds `((x_min, x_max), (y_min, y_max))`
133 /// with a fractional `pad` of headroom added to each axis (e.g. `0.05`
134 /// for 5%), **in scale space** — see [`AxisView::fit`]. Degenerate
135 /// (zero-width) bounds are nudged to a unit window so the view is always
136 /// usable.
137 pub fn fit(x: (f64, f64), y: (f64, f64), pad: f64, xs: Scale, ys: Scale) -> Self {
138 Self {
139 x: AxisView::fit(x, pad, xs),
140 y: AxisView::fit(y, pad, ys),
141 }
142 }
143
144 /// Project a data-space point to a pixel within `rect`. The vertical
145 /// axis is screen-inverted (data grows upward, pixels grow downward).
146 pub fn project(self, p: (f64, f64), x: Scale, y: Scale, rect: Rect) -> (f32, f32) {
147 let fx = self.x.fraction(p.0, x);
148 let fy = self.y.fraction(p.1, y);
149 (
150 rect.x + (fx as f32) * rect.w,
151 rect.y + ((1.0 - fy) as f32) * rect.h,
152 )
153 }
154
155 /// Inverse of [`project`](Self::project): map a pixel within `rect` back
156 /// to a data-space point (e.g. for a crosshair readout).
157 pub fn unproject(self, s: (f32, f32), x: Scale, y: Scale, rect: Rect) -> (f64, f64) {
158 let tx = if rect.w != 0.0 {
159 ((s.0 - rect.x) / rect.w) as f64
160 } else {
161 0.0
162 };
163 let ty = if rect.h != 0.0 {
164 1.0 - ((s.1 - rect.y) / rect.h) as f64
165 } else {
166 0.0
167 };
168 (self.x.at_fraction(tx, x), self.y.at_fraction(ty, y))
169 }
170
171 /// Pan the view by a pixel delta within `rect` — the drag gesture.
172 /// Dragging the content right (`dpx > 0`) reveals earlier data on the
173 /// left; dragging it down (`dpy > 0`) reveals larger values at the top.
174 pub fn pan_pixels(self, d: (f32, f32), x: Scale, y: Scale, rect: Rect) -> Self {
175 let frac_x = if rect.w != 0.0 {
176 -(d.0 as f64) / rect.w as f64
177 } else {
178 0.0
179 };
180 let frac_y = if rect.h != 0.0 {
181 (d.1 as f64) / rect.h as f64
182 } else {
183 0.0
184 };
185 Self {
186 x: self.x.panned(frac_x, x),
187 y: self.y.panned(frac_y, y),
188 }
189 }
190
191 /// Zoom about a pixel `anchor` within `rect`, keeping the data under the
192 /// cursor fixed — the wheel/pinch gesture. `factor` is per-axis (`< 1.0`
193 /// zooms in); pass equal factors for uniform zoom, or `1.0` on one axis
194 /// to lock it.
195 pub fn zoom_about(
196 self,
197 factor: (f64, f64),
198 anchor: (f32, f32),
199 x: Scale,
200 y: Scale,
201 rect: Rect,
202 ) -> Self {
203 let tx = if rect.w != 0.0 {
204 ((anchor.0 - rect.x) / rect.w) as f64
205 } else {
206 0.5
207 };
208 let ty = if rect.h != 0.0 {
209 1.0 - ((anchor.1 - rect.y) / rect.h) as f64
210 } else {
211 0.5
212 };
213 Self {
214 x: self.x.zoomed(factor.0, tx, x),
215 y: self.y.zoomed(factor.1, ty, y),
216 }
217 }
218
219 /// Replace the vertical window — used by `Y::autoscale`, which refits the
220 /// value axis to the data visible in the current horizontal window each
221 /// frame.
222 pub fn with_y(self, y: AxisView) -> Self {
223 Self { y, ..self }
224 }
225
226 /// Replace the horizontal window — used by `X::autoscale`, which refits
227 /// the time axis to the full data extent each frame while the user
228 /// hasn't taken manual X control (the streaming follow-the-data case).
229 pub fn with_x(self, x: AxisView) -> Self {
230 Self { x, ..self }
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 const RECT: Rect = Rect {
239 x: 0.0,
240 y: 0.0,
241 w: 200.0,
242 h: 100.0,
243 };
244
245 fn lin_view() -> PlotView {
246 PlotView::new(AxisView::new(0.0, 100.0), AxisView::new(0.0, 50.0))
247 }
248
249 #[test]
250 fn project_corners_and_center() {
251 let v = lin_view();
252 let (xs, ys) = (Scale::linear(), Scale::linear());
253 // data (0,0) → bottom-left pixel
254 assert_eq!(v.project((0.0, 0.0), xs, ys, RECT), (0.0, 100.0));
255 // data (100,50) → top-right pixel
256 assert_eq!(v.project((100.0, 50.0), xs, ys, RECT), (200.0, 0.0));
257 // center
258 assert_eq!(v.project((50.0, 25.0), xs, ys, RECT), (100.0, 50.0));
259 }
260
261 #[test]
262 fn project_unproject_roundtrip() {
263 let v = lin_view();
264 let (xs, ys) = (Scale::linear(), Scale::linear());
265 let p = (37.5, 12.25);
266 let s = v.project(p, xs, ys, RECT);
267 let back = v.unproject(s, xs, ys, RECT);
268 assert!((back.0 - p.0).abs() < 1e-4, "x {back:?}");
269 assert!((back.1 - p.1).abs() < 1e-4, "y {back:?}");
270 }
271
272 #[test]
273 fn zoom_keeps_anchor_data_fixed() {
274 let v = lin_view();
275 let (xs, ys) = (Scale::linear(), Scale::linear());
276 let anchor = (150.0, 25.0); // some pixel
277 let before = v.unproject(anchor, xs, ys, RECT);
278 let zoomed = v.zoom_about((0.5, 0.5), anchor, xs, ys, RECT);
279 let after = zoomed.unproject(anchor, xs, ys, RECT);
280 assert!((before.0 - after.0).abs() < 1e-6, "x {before:?} {after:?}");
281 assert!((before.1 - after.1).abs() < 1e-6, "y {before:?} {after:?}");
282 // zooming in shrinks the windows
283 assert!(zoomed.x.max - zoomed.x.min < 100.0);
284 }
285
286 #[test]
287 fn pan_shifts_window_opposite_to_drag_x() {
288 let v = lin_view();
289 let (xs, ys) = (Scale::linear(), Scale::linear());
290 // drag content right by a full rect width → window moves left by its
291 // full span
292 let panned = v.pan_pixels((200.0, 0.0), xs, ys, RECT);
293 assert!((panned.x.min - -100.0).abs() < 1e-6);
294 assert!((panned.x.max - 0.0).abs() < 1e-6);
295 }
296
297 #[test]
298 fn pan_down_reveals_higher_values_at_top() {
299 let v = lin_view();
300 let (xs, ys) = (Scale::linear(), Scale::linear());
301 // drag content down by full height → window moves up by full span
302 let panned = v.pan_pixels((0.0, 100.0), xs, ys, RECT);
303 assert!((panned.y.min - 50.0).abs() < 1e-6);
304 assert!((panned.y.max - 100.0).abs() < 1e-6);
305 }
306
307 #[test]
308 fn log_zoom_anchor_fixed() {
309 let v = PlotView::new(AxisView::new(1.0, 1000.0), AxisView::new(0.0, 1.0));
310 let (xs, ys) = (Scale::log(), Scale::linear());
311 let anchor = (50.0, 50.0);
312 let before = v.unproject(anchor, xs, ys, RECT);
313 let zoomed = v.zoom_about((0.5, 1.0), anchor, xs, ys, RECT);
314 let after = zoomed.unproject(anchor, xs, ys, RECT);
315 assert!(
316 (before.0 - after.0).abs() / before.0 < 1e-6,
317 "{before:?} {after:?}"
318 );
319 }
320
321 #[test]
322 fn fit_adds_padding_and_handles_degenerate() {
323 let lin = Scale::linear();
324 let v = PlotView::fit((0.0, 100.0), (5.0, 5.0), 0.1, lin, lin);
325 assert!((v.x.min - -10.0).abs() < 1e-9);
326 assert!((v.x.max - 110.0).abs() < 1e-9);
327 // degenerate y → unit window centered on 5.0
328 assert_eq!(v.y, AxisView::new(4.5, 5.5));
329 }
330
331 /// The #124 regression: fitting a log axis pads by ratio in scale space,
332 /// so the window stays strictly positive no matter how many decades the
333 /// data spans (a linear pad of 5% of 65535 would push `min` to −3275 and
334 /// the clamped warp would crush every mark onto the top edge).
335 #[test]
336 fn fit_log_pads_by_ratio_and_stays_positive() {
337 let v = AxisView::fit((1.0, 65536.0), 0.05, Scale::log());
338 assert!(v.min > 0.0, "log fit must stay positive: {v:?}");
339 assert!(v.min < 1.0 && v.max > 65536.0, "pads outward: {v:?}");
340 // 5% of the 4.816-decade span on each side, as a ratio.
341 let decades = 65536.0f64.log10();
342 assert!((v.min.log10() - -0.05 * decades).abs() < 1e-9);
343 assert!((v.max.log10() - 1.05 * decades).abs() < 1e-9);
344 }
345
346 #[test]
347 fn fit_log_degenerate_nudges_in_scale_space() {
348 // A single value nudges to ±half a decade, never through zero.
349 let v = AxisView::fit((100.0, 100.0), 0.05, Scale::log());
350 assert!((v.min - 100.0 / 10.0f64.sqrt()).abs() < 1e-9);
351 assert!((v.max - 100.0 * 10.0f64.sqrt()).abs() < 1e-9);
352 }
353}