Skip to main content

damascene_core/plot/
resolve.rs

1//! Resolving a [`PlotSpec`] against its data and laid-out rect: the
2//! data-space bounds of its marks, the auto-fit and Y-autoscale view, and
3//! the data rect (the inner area marks draw into, inside the axis gutters).
4//!
5//! These are the pure pieces the per-frame prepare pass
6//! ([`UiState::prepare_plots`](crate::state::UiState)) uses to seed and
7//! update each plot's [`PlotView`] before `draw_ops` reads it. Kept here,
8//! separate and unit-tested, rather than buried in the state walk.
9
10#![warn(missing_docs)]
11
12use crate::plot::scale::Scale;
13use crate::plot::series::SeriesBounds;
14use crate::plot::spec::{Mark, PlotSpec};
15use crate::plot::view::{AxisView, PlotView};
16use crate::tree::Rect;
17
18/// Fractional headroom added around the data when auto-fitting a view,
19/// measured in scale space (so a log axis pads by ratio — see
20/// [`AxisView::fit`]).
21pub const FIT_PADDING: f64 = 0.05;
22
23/// Axis-gutter insets that separate a plot node's rect from its data rect,
24/// in logical px: room for the X tick labels (bottom) and a small margin
25/// (top/right). The left gutter (Y tick labels) is sized adaptively by
26/// [`left_gutter`].
27const GUTTER_BOTTOM: f32 = 28.0;
28const MARGIN_TOP: f32 = 10.0;
29const MARGIN_RIGHT: f32 = 12.0;
30
31/// Floor for the adaptive left gutter — it never shrinks below this even when
32/// the Y labels are narrow (keeps a tidy axis margin).
33pub const GUTTER_LEFT_MIN: f32 = 40.0;
34/// Y tick label font size — mirrors the `size` used by `draw_ops`' tick
35/// chrome, so the measured gutter matches the labels actually drawn.
36const Y_TICK_LABEL_SIZE: f32 = 11.0;
37/// Gap reserved beyond the widest Y label: the label's right pad to the data
38/// rect plus a small left margin inside the node.
39const Y_LABEL_GAP: f32 = 12.0;
40/// Number of Y ticks targeted — mirrors `draw_ops`.
41const Y_TICK_TARGET: usize = 6;
42
43/// The left gutter needed to fit `view`'s Y tick labels without clipping,
44/// floored at [`GUTTER_LEFT_MIN`]. Measures the widest formatted Y tick label
45/// in the same font/size/count `draw_ops` draws them.
46pub fn left_gutter(spec: &PlotSpec, view: &PlotView) -> f32 {
47    let ys = spec.y.scale;
48    let mut widest = 0.0_f32;
49    for t in ys.ticks((view.y.min, view.y.max), Y_TICK_TARGET) {
50        let w = crate::text::metrics::line_width(
51            &t.label,
52            Y_TICK_LABEL_SIZE,
53            crate::tree::FontWeight::default(),
54            false,
55        );
56        widest = widest.max(w);
57    }
58    (widest + Y_LABEL_GAP).max(GUTTER_LEFT_MIN)
59}
60
61/// The data rect for a plot laid out at `node_rect`, inset by `gutter_left`
62/// (from [`left_gutter`]) and the fixed bottom/top/right gutters. Clamped to
63/// non-negative size.
64pub fn data_rect(node_rect: Rect, gutter_left: f32) -> Rect {
65    let x = node_rect.x + gutter_left;
66    let y = node_rect.y + MARGIN_TOP;
67    let w = (node_rect.w - gutter_left - MARGIN_RIGHT).max(0.0);
68    let h = (node_rect.h - MARGIN_TOP - GUTTER_BOTTOM).max(0.0);
69    Rect::new(x, y, w, h)
70}
71
72/// The union of every mark's series bounds — the full data extent of the
73/// plot, used to auto-fit the initial view.
74pub fn data_bounds(spec: &PlotSpec) -> SeriesBounds {
75    let mut bounds = SeriesBounds::default();
76    for mark in &spec.marks {
77        bounds = bounds.union(series_of(mark).bounds());
78    }
79    bounds
80}
81
82/// The series a mark reads from.
83fn series_of(mark: &Mark) -> &crate::plot::series::SeriesHandle {
84    match mark {
85        Mark::Line(m) => &m.series,
86        Mark::Scatter(m) => &m.series,
87    }
88}
89
90/// An auto-fit [`PlotView`] framing `bounds` with [`FIT_PADDING`] headroom
91/// added in scale space (per-axis, through `xs`/`ys`). Missing per-axis
92/// bounds fall back to a unit window (via [`PlotView::fit`]).
93pub fn autofit(bounds: SeriesBounds, xs: Scale, ys: Scale) -> PlotView {
94    PlotView::fit(
95        bounds.x.unwrap_or((0.0, 1.0)),
96        bounds.y.unwrap_or((0.0, 1.0)),
97        FIT_PADDING,
98        xs,
99        ys,
100    )
101}
102
103/// The `(min, max)` of `y` over every sample whose `x` lies within the
104/// horizontal window `x` — what `Y::autoscale` fits the value axis to each
105/// frame. `None` when no sample is in range.
106pub fn visible_y(spec: &PlotSpec, x: AxisView) -> Option<(f64, f64)> {
107    let (lo, hi) = (x.min.min(x.max), x.min.max(x.max));
108    let mut acc: Option<(f64, f64)> = None;
109    for mark in &spec.marks {
110        let (samples, _) = series_of(mark).snapshot();
111        for s in samples.iter() {
112            if s.x.is_finite() && s.y.is_finite() && s.x >= lo && s.x <= hi {
113                acc = Some(match acc {
114                    Some((ylo, yhi)) => (ylo.min(s.y), yhi.max(s.y)),
115                    None => (s.y, s.y),
116                });
117            }
118        }
119    }
120    acc
121}
122
123/// Pad a `(min, max)` value span into an [`AxisView`] with [`FIT_PADDING`]
124/// headroom added in scale space (through the axis `scale`), nudging a
125/// degenerate span to a unit scale-space window.
126pub fn pad_y(span: (f64, f64), scale: Scale) -> AxisView {
127    AxisView::fit(span, FIT_PADDING, scale)
128}
129
130/// Resolve the view for a plot this frame: start from the persisted view
131/// (or an auto-fit if there is none), refit the X axis to the full data
132/// extent when `autoscale_x` is set (so streaming appends stay in view),
133/// then refit the Y axis to the visible data when `autoscale_y` is set.
134/// The caller passes the *effective* autoscale state per axis —
135/// `spec.{x,y}_autoscale` unless the user has taken manual control of that
136/// axis (an X gesture, or box-zooming the value axis), which opts the plot
137/// out until reset. `scale` selection lives on the spec; this only moves
138/// the windows.
139pub fn resolve_view(
140    spec: &PlotSpec,
141    persisted: Option<PlotView>,
142    autoscale_x: bool,
143    autoscale_y: bool,
144) -> PlotView {
145    let bounds = data_bounds(spec);
146    let fit = autofit(bounds, spec.x.scale, spec.y.scale);
147    let mut view = persisted.unwrap_or(fit);
148    if autoscale_x && bounds.x.is_some() {
149        view = view.with_x(fit.x);
150    }
151    if autoscale_y && let Some(span) = visible_y(spec, view.x) {
152        view = view.with_y(pad_y(span, spec.y.scale));
153    }
154    view
155}
156
157/// The horizontal-axis [`Scale`] of a spec (a convenience for the prepare
158/// pass / metrics).
159pub fn x_scale(spec: &PlotSpec) -> Scale {
160    spec.x.scale
161}
162
163/// The vertical-axis [`Scale`] of a spec.
164pub fn y_scale(spec: &PlotSpec) -> Scale {
165    spec.y.scale
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use crate::plot::series::{Sample, SeriesHandle};
172    use crate::plot::spec::line;
173
174    fn spec_with(samples: Vec<Sample>) -> PlotSpec {
175        let h = SeriesHandle::new(samples);
176        PlotSpec::new().add_mark(line(&h))
177    }
178
179    #[test]
180    fn data_rect_insets_gutters() {
181        let g = 52.0;
182        let r = data_rect(Rect::new(0.0, 0.0, 200.0, 100.0), g);
183        assert_eq!(r.x, g);
184        assert_eq!(r.y, MARGIN_TOP);
185        assert_eq!(r.w, 200.0 - g - MARGIN_RIGHT);
186        assert_eq!(r.h, 100.0 - MARGIN_TOP - GUTTER_BOTTOM);
187    }
188
189    #[test]
190    fn data_rect_clamps_to_nonnegative() {
191        let r = data_rect(Rect::new(0.0, 0.0, 10.0, 10.0), 52.0);
192        assert_eq!(r.w, 0.0);
193        assert_eq!(r.h, 0.0);
194    }
195
196    #[test]
197    fn left_gutter_grows_for_wide_labels_and_floors() {
198        // A series with large Y values needs a wider gutter than one with
199        // small values; both are floored at the minimum.
200        let wide = spec_with(vec![Sample::new(0.0, 0.0), Sample::new(1.0, 1_000_000.0)]);
201        let narrow = spec_with(vec![Sample::new(0.0, 0.0), Sample::new(1.0, 9.0)]);
202        let view_w = resolve_view(&wide, None, true, true);
203        let view_n = resolve_view(&narrow, None, true, true);
204        let g_wide = left_gutter(&wide, &view_w);
205        let g_narrow = left_gutter(&narrow, &view_n);
206        assert!(
207            g_wide > g_narrow,
208            "wide labels widen the gutter: {g_wide} vs {g_narrow}"
209        );
210        assert!(
211            g_narrow >= GUTTER_LEFT_MIN,
212            "floored at the minimum: {g_narrow}"
213        );
214    }
215
216    #[test]
217    fn data_bounds_union_over_marks() {
218        let a = SeriesHandle::new(vec![Sample::new(0.0, 1.0), Sample::new(5.0, 3.0)]);
219        let b = SeriesHandle::new(vec![Sample::new(-2.0, 0.0), Sample::new(3.0, 9.0)]);
220        let spec = PlotSpec::new().line(&a).line(&b);
221        let bounds = data_bounds(&spec);
222        assert_eq!(bounds.x, Some((-2.0, 5.0)));
223        assert_eq!(bounds.y, Some((0.0, 9.0)));
224    }
225
226    #[test]
227    fn autofit_pads_the_window() {
228        let bounds = SeriesBounds {
229            x: Some((0.0, 100.0)),
230            y: Some((0.0, 10.0)),
231        };
232        let v = autofit(bounds, Scale::linear(), Scale::linear());
233        assert!(v.x.min < 0.0 && v.x.max > 100.0);
234        assert!(v.y.min < 0.0 && v.y.max > 10.0);
235    }
236
237    #[test]
238    fn visible_y_only_counts_in_window() {
239        let spec = spec_with(vec![
240            Sample::new(0.0, 1.0),
241            Sample::new(5.0, 100.0), // outside the x window below
242            Sample::new(1.0, 2.0),
243        ]);
244        let span = visible_y(&spec, AxisView::new(-0.5, 1.5)).unwrap();
245        assert_eq!(span, (1.0, 2.0)); // the 100.0 at x=5 is excluded
246    }
247
248    #[test]
249    fn resolve_view_autoscales_y_to_visible() {
250        let spec = spec_with(vec![Sample::new(0.0, 0.0), Sample::new(10.0, 1000.0)]);
251        // Persist a narrow x window around x=0; Y should fit ~0, not 1000.
252        let persisted = PlotView::new(AxisView::new(-1.0, 1.0), AxisView::new(-5.0, 5.0));
253        let v = resolve_view(&spec, Some(persisted), false, true);
254        assert!(v.y.max < 100.0, "y autoscaled to visible: {:?}", v.y);
255    }
256
257    #[test]
258    fn resolve_view_autofits_when_unpersisted() {
259        let spec = spec_with(vec![Sample::new(0.0, 0.0), Sample::new(4.0, 8.0)]);
260        let v = resolve_view(&spec, None, true, true);
261        assert!(v.x.min < 0.0 && v.x.max > 4.0);
262    }
263}
264
265#[cfg(test)]
266mod log_fit_tests {
267    use super::*;
268    use crate::plot::series::{Sample, SeriesHandle};
269    use crate::plot::spec::line;
270    use crate::tree::Rect;
271
272    /// Issue #124: a log-Y plot over data spanning many decades collapsed
273    /// onto the top edge with unusable ticks, because the fit padded 5% of
274    /// the *raw* span (pushing `y.min` to −3275 for data in 1..=65536) and
275    /// the clamped log warp then stretched the window to ~305 decades.
276    #[test]
277    fn log_y_autofit_keeps_marks_spread_and_ticks_sane() {
278        // The issue's shape: a line descending 65536 → 1 over x ∈ [0, 36000].
279        let samples: Vec<Sample> = (0..149)
280            .map(|i| {
281                let x = f64::from(i) * (36000.0 / 148.0);
282                let y = 65536.0 * (1.0f64 / 65536.0).powf(f64::from(i) / 148.0);
283                Sample::new(x, y)
284            })
285            .collect();
286        let h = SeriesHandle::new(samples);
287        let spec = PlotSpec::new()
288            .x(Scale::linear())
289            .y(Scale::log())
290            .add_mark(line(&h));
291
292        let view = resolve_view(&spec, None, true, true);
293        assert!(
294            view.y.min > 0.0,
295            "log-y window stays positive: {:?}",
296            view.y
297        );
298
299        // The data's extremes project across most of the rect, not onto a
300        // single edge.
301        let rect = Rect::new(0.0, 0.0, 400.0, 300.0);
302        let (xs, ys) = (Scale::linear(), Scale::log());
303        let top = view.project((0.0, 65536.0), xs, ys, rect).1;
304        let bottom = view.project((36000.0, 1.0), xs, ys, rect).1;
305        assert!(
306            (bottom - top).abs() > rect.h * 0.8,
307            "marks span the rect: {top} .. {bottom}"
308        );
309
310        // Ticks are the decades of the data, each with a distinct label.
311        let ticks = ys.ticks((view.y.min, view.y.max), 6);
312        let values: Vec<f64> = ticks.iter().map(|t| t.value).collect();
313        assert_eq!(values, vec![1.0, 10.0, 100.0, 1000.0, 10000.0, 100000.0]);
314        assert!(ticks.iter().all(|t| t.label != "0"), "labels: {ticks:?}");
315    }
316}
317
318#[cfg(test)]
319mod x_autoscale_tests {
320    use super::*;
321    use crate::plot::Sample;
322    use crate::plot::series::SeriesHandle;
323
324    fn spec_of(h: &SeriesHandle) -> PlotSpec {
325        PlotSpec::new().line(h)
326    }
327
328    /// The #116 regression: a persisted view must track a growing X extent
329    /// when autoscale-X is on — streaming appends stay in view.
330    #[test]
331    fn resolve_view_x_autoscale_tracks_growing_data() {
332        let h = SeriesHandle::new(vec![Sample::new(0.0, 0.0), Sample::new(1.0, 1.0)]);
333        let spec = spec_of(&h);
334        let first = resolve_view(&spec, None, true, true);
335        assert!(first.x.max < 2.0, "seeded around the initial extent");
336
337        // The stream advances well past the seeded window.
338        h.append(&[Sample::new(100.0, 5.0)]);
339        let next = resolve_view(&spec, Some(first), true, true);
340        assert!(
341            next.x.max > 100.0,
342            "x window follows the data: {:?}",
343            next.x
344        );
345    }
346
347    /// With autoscale-X off (or manual control taken), the persisted X
348    /// window holds regardless of data growth — today's sticky behavior.
349    #[test]
350    fn resolve_view_manual_x_holds_the_window() {
351        let h = SeriesHandle::new(vec![Sample::new(0.0, 0.0), Sample::new(1.0, 1.0)]);
352        let spec = spec_of(&h);
353        let first = resolve_view(&spec, None, false, true);
354        h.append(&[Sample::new(100.0, 5.0)]);
355        let next = resolve_view(&spec, Some(first), false, true);
356        assert_eq!(next.x, first.x, "manual x window is sticky");
357    }
358}