Skip to main content

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}