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
90/// A plot's pan/zoom state: the visible window of each axis. Persisted per
91/// node and readable with
92/// [`UiState::plot_view`](crate::state::UiState::plot_view) — e.g. for the
93/// virtual-data pull, where the app loads or resamples its source to cover
94/// the window the user has scrolled into (see `docs/PLOT2D_PLAN.md`,
95/// decision 5).
96///
97/// All projection is parameterised by the axis [`Scale`]s and the plot's
98/// **data rect** (the inner area the marks draw into, excluding axes and
99/// legend), so the same view projects correctly as the layout resizes.
100#[derive(Clone, Copy, Debug, PartialEq)]
101pub struct PlotView {
102    /// The visible window of the horizontal axis.
103    pub x: AxisView,
104    /// The visible window of the vertical axis.
105    pub y: AxisView,
106}
107
108impl PlotView {
109    /// A view with the given per-axis windows.
110    pub fn new(x: AxisView, y: AxisView) -> Self {
111        Self { x, y }
112    }
113
114    /// A view framing the data bounds `((x_min, x_max), (y_min, y_max))`
115    /// with a fractional `pad` of headroom added to each axis (e.g. `0.05`
116    /// for 5%). Degenerate (zero-width) bounds are nudged to a unit window so
117    /// the view is always usable.
118    pub fn fit(x: (f64, f64), y: (f64, f64), pad: f64) -> Self {
119        Self {
120            x: pad_axis(x, pad),
121            y: pad_axis(y, pad),
122        }
123    }
124
125    /// Project a data-space point to a pixel within `rect`. The vertical
126    /// axis is screen-inverted (data grows upward, pixels grow downward).
127    pub fn project(self, p: (f64, f64), x: Scale, y: Scale, rect: Rect) -> (f32, f32) {
128        let fx = self.x.fraction(p.0, x);
129        let fy = self.y.fraction(p.1, y);
130        (
131            rect.x + (fx as f32) * rect.w,
132            rect.y + ((1.0 - fy) as f32) * rect.h,
133        )
134    }
135
136    /// Inverse of [`project`](Self::project): map a pixel within `rect` back
137    /// to a data-space point (e.g. for a crosshair readout).
138    pub fn unproject(self, s: (f32, f32), x: Scale, y: Scale, rect: Rect) -> (f64, f64) {
139        let tx = if rect.w != 0.0 {
140            ((s.0 - rect.x) / rect.w) as f64
141        } else {
142            0.0
143        };
144        let ty = if rect.h != 0.0 {
145            1.0 - ((s.1 - rect.y) / rect.h) as f64
146        } else {
147            0.0
148        };
149        (self.x.at_fraction(tx, x), self.y.at_fraction(ty, y))
150    }
151
152    /// Pan the view by a pixel delta within `rect` — the drag gesture.
153    /// Dragging the content right (`dpx > 0`) reveals earlier data on the
154    /// left; dragging it down (`dpy > 0`) reveals larger values at the top.
155    pub fn pan_pixels(self, d: (f32, f32), x: Scale, y: Scale, rect: Rect) -> Self {
156        let frac_x = if rect.w != 0.0 {
157            -(d.0 as f64) / rect.w as f64
158        } else {
159            0.0
160        };
161        let frac_y = if rect.h != 0.0 {
162            (d.1 as f64) / rect.h as f64
163        } else {
164            0.0
165        };
166        Self {
167            x: self.x.panned(frac_x, x),
168            y: self.y.panned(frac_y, y),
169        }
170    }
171
172    /// Zoom about a pixel `anchor` within `rect`, keeping the data under the
173    /// cursor fixed — the wheel/pinch gesture. `factor` is per-axis (`< 1.0`
174    /// zooms in); pass equal factors for uniform zoom, or `1.0` on one axis
175    /// to lock it.
176    pub fn zoom_about(
177        self,
178        factor: (f64, f64),
179        anchor: (f32, f32),
180        x: Scale,
181        y: Scale,
182        rect: Rect,
183    ) -> Self {
184        let tx = if rect.w != 0.0 {
185            ((anchor.0 - rect.x) / rect.w) as f64
186        } else {
187            0.5
188        };
189        let ty = if rect.h != 0.0 {
190            1.0 - ((anchor.1 - rect.y) / rect.h) as f64
191        } else {
192            0.5
193        };
194        Self {
195            x: self.x.zoomed(factor.0, tx, x),
196            y: self.y.zoomed(factor.1, ty, y),
197        }
198    }
199
200    /// Replace the vertical window — used by `Y::autoscale`, which refits the
201    /// value axis to the data visible in the current horizontal window each
202    /// frame.
203    pub fn with_y(self, y: AxisView) -> Self {
204        Self { y, ..self }
205    }
206
207    /// Replace the horizontal window — used by `X::autoscale`, which refits
208    /// the time axis to the full data extent each frame while the user
209    /// hasn't taken manual X control (the streaming follow-the-data case).
210    pub fn with_x(self, x: AxisView) -> Self {
211        Self { x, ..self }
212    }
213}
214
215/// Frame a `(min, max)` data span with fractional `pad` of headroom, nudging
216/// a degenerate span to a unit window.
217fn pad_axis((min, max): (f64, f64), pad: f64) -> AxisView {
218    if !min.is_finite() || !max.is_finite() || max <= min {
219        // Degenerate: center a unit window on the (finite) value.
220        let c = if min.is_finite() { min } else { 0.0 };
221        return AxisView::new(c - 0.5, c + 0.5);
222    }
223    let m = (max - min) * pad;
224    AxisView::new(min - m, max + m)
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    const RECT: Rect = Rect {
232        x: 0.0,
233        y: 0.0,
234        w: 200.0,
235        h: 100.0,
236    };
237
238    fn lin_view() -> PlotView {
239        PlotView::new(AxisView::new(0.0, 100.0), AxisView::new(0.0, 50.0))
240    }
241
242    #[test]
243    fn project_corners_and_center() {
244        let v = lin_view();
245        let (xs, ys) = (Scale::linear(), Scale::linear());
246        // data (0,0) → bottom-left pixel
247        assert_eq!(v.project((0.0, 0.0), xs, ys, RECT), (0.0, 100.0));
248        // data (100,50) → top-right pixel
249        assert_eq!(v.project((100.0, 50.0), xs, ys, RECT), (200.0, 0.0));
250        // center
251        assert_eq!(v.project((50.0, 25.0), xs, ys, RECT), (100.0, 50.0));
252    }
253
254    #[test]
255    fn project_unproject_roundtrip() {
256        let v = lin_view();
257        let (xs, ys) = (Scale::linear(), Scale::linear());
258        let p = (37.5, 12.25);
259        let s = v.project(p, xs, ys, RECT);
260        let back = v.unproject(s, xs, ys, RECT);
261        assert!((back.0 - p.0).abs() < 1e-4, "x {back:?}");
262        assert!((back.1 - p.1).abs() < 1e-4, "y {back:?}");
263    }
264
265    #[test]
266    fn zoom_keeps_anchor_data_fixed() {
267        let v = lin_view();
268        let (xs, ys) = (Scale::linear(), Scale::linear());
269        let anchor = (150.0, 25.0); // some pixel
270        let before = v.unproject(anchor, xs, ys, RECT);
271        let zoomed = v.zoom_about((0.5, 0.5), anchor, xs, ys, RECT);
272        let after = zoomed.unproject(anchor, xs, ys, RECT);
273        assert!((before.0 - after.0).abs() < 1e-6, "x {before:?} {after:?}");
274        assert!((before.1 - after.1).abs() < 1e-6, "y {before:?} {after:?}");
275        // zooming in shrinks the windows
276        assert!(zoomed.x.max - zoomed.x.min < 100.0);
277    }
278
279    #[test]
280    fn pan_shifts_window_opposite_to_drag_x() {
281        let v = lin_view();
282        let (xs, ys) = (Scale::linear(), Scale::linear());
283        // drag content right by a full rect width → window moves left by its
284        // full span
285        let panned = v.pan_pixels((200.0, 0.0), xs, ys, RECT);
286        assert!((panned.x.min - -100.0).abs() < 1e-6);
287        assert!((panned.x.max - 0.0).abs() < 1e-6);
288    }
289
290    #[test]
291    fn pan_down_reveals_higher_values_at_top() {
292        let v = lin_view();
293        let (xs, ys) = (Scale::linear(), Scale::linear());
294        // drag content down by full height → window moves up by full span
295        let panned = v.pan_pixels((0.0, 100.0), xs, ys, RECT);
296        assert!((panned.y.min - 50.0).abs() < 1e-6);
297        assert!((panned.y.max - 100.0).abs() < 1e-6);
298    }
299
300    #[test]
301    fn log_zoom_anchor_fixed() {
302        let v = PlotView::new(AxisView::new(1.0, 1000.0), AxisView::new(0.0, 1.0));
303        let (xs, ys) = (Scale::log(), Scale::linear());
304        let anchor = (50.0, 50.0);
305        let before = v.unproject(anchor, xs, ys, RECT);
306        let zoomed = v.zoom_about((0.5, 1.0), anchor, xs, ys, RECT);
307        let after = zoomed.unproject(anchor, xs, ys, RECT);
308        assert!(
309            (before.0 - after.0).abs() / before.0 < 1e-6,
310            "{before:?} {after:?}"
311        );
312    }
313
314    #[test]
315    fn fit_adds_padding_and_handles_degenerate() {
316        let v = PlotView::fit((0.0, 100.0), (5.0, 5.0), 0.1);
317        assert!((v.x.min - -10.0).abs() < 1e-9);
318        assert!((v.x.max - 110.0).abs() < 1e-9);
319        // degenerate y → unit window centered on 5.0
320        assert_eq!(v.y, AxisView::new(4.5, 5.5));
321    }
322}