Skip to main content

model/layout/
fit.rs

1use super::{
2    ChartArea, Config, DataArea, LayoutError, Margins, Rect, Side, chart_title_contribution,
3    colorbar_parts, tick_contribution,
4};
5
6#[derive(Debug, Clone, PartialEq)]
7pub enum FitStrategy {
8    ProportionalScale,
9    PreserveTitles,
10    Absorb(Side),
11}
12
13// Per-side components used internally by fit/resize.
14//
15// Flex (scaled on fit/resize): `out_margin`, `chart_title` (Top only),
16// `colorbar_out_margin` (the colourbar's side only).
17// Fixed (never scaled): `major_tick_length`, `colorbar_fixed`.
18//
19// `label_offset_{x,y}` and `minor_tick_length` are visual-only and don't
20// contribute to margins, so they are absent here.
21//
22// The colourbar band is split rather than registered whole, and both halves
23// matter. Registering all of it as fixed would leave fit unable to reclaim the
24// bar's label space, so a tight target fails where it could have succeeded;
25// omitting it entirely would let fit and resize hand the data area a band that
26// `margins()` still charges, and the two would disagree about where the data
27// area is.
28
29#[derive(Clone)]
30struct SideComponents {
31    out_margin: f32,          // flex
32    chart_title: Option<f32>, // flex, Top only
33    major_tick_length: f32,   // fixed
34    /// The colourbar axis' `out_margin` — flex, and `Some` only on the side the
35    /// bar occupies (`None` elsewhere, so `write_side` knows not to write).
36    colorbar_out_margin: Option<f32>,
37    /// `gap_px + thickness_px + colourbar tick length` — fixed, like the axis
38    /// tick it sits next to. The strip does not grow with the window.
39    colorbar_fixed: f32,
40}
41
42fn read_side(cfg: &Config, side: &Side) -> SideComponents {
43    // Straight from the layout helper `margins()` uses, so "which side carries a
44    // band" is decided once for both.
45    let (colorbar_out_margin, colorbar_fixed) = match colorbar_parts(cfg, side) {
46        Some((flex, fixed)) => (Some(flex), fixed),
47        None => (None, 0.0),
48    };
49    let axis = super::axis_ref(cfg, side);
50    SideComponents {
51        out_margin: axis.out_margin,
52        chart_title: match side {
53            Side::Top => Some(chart_title_contribution(&cfg.chart_title)),
54            Side::Bottom | Side::Left | Side::Right => None,
55        },
56        major_tick_length: tick_contribution(axis),
57        colorbar_out_margin,
58        colorbar_fixed,
59    }
60}
61
62fn flex_total(c: &SideComponents) -> f32 {
63    c.out_margin + c.chart_title.unwrap_or(0.0) + c.colorbar_out_margin.unwrap_or(0.0)
64}
65
66fn side_total(c: &SideComponents) -> f32 {
67    flex_total(c) + c.major_tick_length + c.colorbar_fixed
68}
69
70// Write flex components back into Config. Tick lengths and the colourbar strip
71// itself are fixed and not touched.
72fn write_side(cfg: &mut Config, side: &Side, c: &SideComponents) {
73    if let (Some(value), Some(bar)) = (c.colorbar_out_margin, cfg.colorbar.as_mut()) {
74        bar.axis.out_margin = value;
75    }
76    super::axis_mut(cfg, side).out_margin = c.out_margin;
77    if let (Side::Top, Some(v)) = (side, c.chart_title) {
78        cfg.chart_title.top_margin = v;
79    }
80}
81
82fn scale_proportional(c: &SideComponents, target: f32) -> Result<SideComponents, LayoutError> {
83    // Tick and colourbar strip are fixed; scale only the flex part to reach
84    // the target total.
85    let flex_target = target - c.major_tick_length - c.colorbar_fixed;
86    if flex_target < 0.0 {
87        return Err(LayoutError::Infeasible);
88    }
89    let flex = flex_total(c);
90    if flex <= 0.0 {
91        if flex_target == 0.0 {
92            return Ok(c.clone());
93        }
94        return Err(LayoutError::Infeasible);
95    }
96    let r = flex_target / flex;
97    Ok(SideComponents {
98        out_margin: c.out_margin * r,
99        chart_title: c.chart_title.map(|v| v * r),
100        major_tick_length: c.major_tick_length,
101        colorbar_out_margin: c.colorbar_out_margin.map(|v| v * r),
102        colorbar_fixed: c.colorbar_fixed,
103    })
104}
105
106// PreserveTitles: keep chart_title fixed and adjust only out_margin (tick always
107// fixed). The colourbar's label space is preserved for the same reason titles
108// are — it holds text, and crushing it is what this strategy exists to avoid.
109fn preserve_titles(c: &SideComponents, target: f32) -> Result<SideComponents, LayoutError> {
110    let new_out = target - preserved_fixed(c);
111    if new_out < 0.0 {
112        return Err(LayoutError::Infeasible);
113    }
114    Ok(SideComponents {
115        out_margin: new_out,
116        ..c.clone()
117    })
118}
119
120/// Everything `PreserveTitles` / `Absorb` hold still: the fixed terms plus the
121/// text-bearing flex ones. The whole delta lands on the axis `out_margin`.
122fn preserved_fixed(c: &SideComponents) -> f32 {
123    c.major_tick_length
124        + c.chart_title.unwrap_or(0.0)
125        + c.colorbar_fixed
126        + c.colorbar_out_margin.unwrap_or(0.0)
127}
128
129// Absorb: push the entire delta into out_margin. chart_title, tick, and the
130// colourbar stay fixed.
131fn absorb_single(c: &SideComponents, target: f32) -> Result<SideComponents, LayoutError> {
132    let new_out = target - preserved_fixed(c);
133    if new_out < 0.0 {
134        return Err(LayoutError::Infeasible);
135    }
136    Ok(SideComponents {
137        out_margin: new_out,
138        ..c.clone()
139    })
140}
141
142// Used by resize_chart_area_scaled: scale flex by `r`, leave tick fixed.
143fn scale_flex(c: &SideComponents, r: f32) -> SideComponents {
144    SideComponents {
145        out_margin: c.out_margin * r,
146        chart_title: c.chart_title.map(|v| v * r),
147        major_tick_length: c.major_tick_length,
148        colorbar_out_margin: c.colorbar_out_margin.map(|v| v * r),
149        colorbar_fixed: c.colorbar_fixed,
150    }
151}
152
153// fit_to_data_area + strategies, resize_chart_area variants, set_side_margin / set_margins.
154
155impl Config {
156    pub fn fit_to_data_area(
157        &mut self,
158        target: DataArea,
159        strategy: FitStrategy,
160    ) -> Result<(), LayoutError> {
161        let ca = &self.chart_area;
162
163        if target.x < ca.x
164            || target.y < ca.y
165            || target.x + target.width > ca.x + ca.width
166            || target.y + target.height > ca.y + ca.height
167            || target.width == 0
168            || target.height == 0
169        {
170            return Err(LayoutError::TargetOutOfChartArea);
171        }
172
173        let need_top = (target.y - ca.y) as f32;
174        let need_bottom = ((ca.y + ca.height) - (target.y + target.height)) as f32;
175        let need_left = (target.x - ca.x) as f32;
176        let need_right = ((ca.x + ca.width) - (target.x + target.width)) as f32;
177
178        let sides = [
179            (Side::Top, need_top),
180            (Side::Bottom, need_bottom),
181            (Side::Left, need_left),
182            (Side::Right, need_right),
183        ];
184
185        let mut new_components: Vec<(Side, SideComponents)> = Vec::with_capacity(4);
186
187        match &strategy {
188            FitStrategy::ProportionalScale => {
189                for (side, target_total) in sides.iter() {
190                    let current = read_side(self, side);
191                    let scaled = scale_proportional(&current, *target_total)?;
192                    new_components.push((side.clone(), scaled));
193                }
194            }
195            FitStrategy::PreserveTitles => {
196                for (side, target_total) in sides.iter() {
197                    let current = read_side(self, side);
198                    let updated = preserve_titles(&current, *target_total)?;
199                    new_components.push((side.clone(), updated));
200                }
201            }
202            FitStrategy::Absorb(target_side) => {
203                for (side, target_total) in sides.iter() {
204                    let current = read_side(self, side);
205                    let current_total = side_total(&current);
206                    if side == target_side {
207                        let updated = absorb_single(&current, *target_total)?;
208                        new_components.push((side.clone(), updated));
209                    } else {
210                        if (current_total - *target_total).abs() > 1e-3 {
211                            return Err(LayoutError::Infeasible);
212                        }
213                        new_components.push((side.clone(), current));
214                    }
215                }
216            }
217        }
218
219        for (side, comps) in new_components.iter() {
220            write_side(self, side, comps);
221        }
222        Ok(())
223    }
224
225    pub fn resize_chart_area(&mut self, new_area: ChartArea) -> Result<(), LayoutError> {
226        let backup = self.chart_area.clone();
227        self.chart_area = new_area;
228        match self.data_area() {
229            Ok(_) => Ok(()),
230            Err(e) => {
231                self.chart_area = backup;
232                Err(e)
233            }
234        }
235    }
236
237    pub fn resize_chart_area_scaled(&mut self, new_area: ChartArea) -> Result<(), LayoutError> {
238        let old_w = self.chart_area.width as f32;
239        let old_h = self.chart_area.height as f32;
240        if old_w <= 0.0 || old_h <= 0.0 {
241            return Err(LayoutError::Infeasible);
242        }
243        let rx = new_area.width as f32 / old_w;
244        let ry = new_area.height as f32 / old_h;
245
246        // Top/Bottom scale by ry, Left/Right by rx.
247        // Tick is fixed; only flex (out_margin / chart_title) is scaled.
248        let scaled_sides: [(Side, SideComponents); 4] = [
249            (Side::Top, scale_flex(&read_side(self, &Side::Top), ry)),
250            (
251                Side::Bottom,
252                scale_flex(&read_side(self, &Side::Bottom), ry),
253            ),
254            (Side::Left, scale_flex(&read_side(self, &Side::Left), rx)),
255            (Side::Right, scale_flex(&read_side(self, &Side::Right), rx)),
256        ];
257
258        let mut trial = self.clone();
259        trial.chart_area = new_area.clone();
260        for (side, c) in scaled_sides.iter() {
261            write_side(&mut trial, side, c);
262        }
263        trial.data_area()?;
264
265        self.chart_area = new_area;
266        for (side, c) in scaled_sides.iter() {
267            write_side(self, side, c);
268        }
269        Ok(())
270    }
271
272    pub fn set_side_margin(&mut self, side: Side, value: f32) -> Result<(), LayoutError> {
273        if value < 0.0 {
274            return Err(LayoutError::Infeasible);
275        }
276        let current = read_side(self, &side);
277        let updated = scale_proportional(&current, value)?;
278
279        let mut trial = self.clone();
280        write_side(&mut trial, &side, &updated);
281        trial.data_area()?;
282
283        write_side(self, &side, &updated);
284        Ok(())
285    }
286
287    pub fn set_margins(
288        &mut self,
289        margins: Margins,
290        strategy: FitStrategy,
291    ) -> Result<(), LayoutError> {
292        let ca = &self.chart_area;
293        let need_h = margins.left + margins.right;
294        let need_v = margins.top + margins.bottom;
295        if need_h > ca.width as f32 {
296            return Err(LayoutError::OverflowHorizontal {
297                required: need_h,
298                available: ca.width,
299            });
300        }
301        if need_v > ca.height as f32 {
302            return Err(LayoutError::OverflowVertical {
303                required: need_v,
304                available: ca.height,
305            });
306        }
307        let x = ca.x + margins.left.floor() as u32;
308        let y = ca.y + margins.top.floor() as u32;
309        let w = ca.width - (margins.left.floor() as u32 + margins.right.floor() as u32);
310        let h = ca.height - (margins.top.floor() as u32 + margins.bottom.floor() as u32);
311        if w == 0 || h == 0 {
312            return Err(LayoutError::EmptyDataArea);
313        }
314        let target = DataArea(Rect {
315            x,
316            y,
317            width: w,
318            height: h,
319        });
320        self.fit_to_data_area(target, strategy)
321    }
322}
323
324// fit / resize / margins tests.
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329    use crate::default::{default_colorbar_options, default_config};
330
331    fn approx_eq(a: f32, b: f32, eps: f32) -> bool {
332        (a - b).abs() <= eps
333    }
334
335    fn config_with_colorbar(side: Side) -> Config {
336        let mut cfg = default_config();
337        let mut bar = default_colorbar_options();
338        bar.side = side;
339        cfg.colorbar = Some(bar);
340        cfg
341    }
342
343    // After a successful fit_to_data_area, data_area() must equal target (within rounding).
344    #[test]
345    fn fit_to_data_area_matches_target() {
346        let mut cfg = default_config();
347        let target = DataArea(Rect {
348            x: 150,
349            y: 150,
350            width: 700,
351            height: 500,
352        });
353        cfg.fit_to_data_area(target.clone(), FitStrategy::ProportionalScale)
354            .unwrap();
355        let da = cfg.data_area().unwrap();
356        assert_eq!(da.x, target.x);
357        assert_eq!(da.y, target.y);
358        assert_eq!(da.width, target.width);
359        assert_eq!(da.height, target.height);
360    }
361
362    // On error, state must be unchanged (transactional).
363    #[test]
364    fn fit_to_data_area_err_preserves_state() {
365        let mut cfg = default_config();
366        let before = cfg.clone();
367        let bad = DataArea(Rect {
368            x: 0,
369            y: 0,
370            width: 2000,
371            height: 2000,
372        });
373        let res = cfg.fit_to_data_area(bad, FitStrategy::ProportionalScale);
374        assert!(res.is_err());
375        assert_eq!(cfg, before);
376    }
377
378    #[test]
379    fn resize_chart_area_err_preserves_state() {
380        let mut cfg = default_config();
381        let before = cfg.clone();
382        let tiny = ChartArea(Rect {
383            x: 0,
384            y: 0,
385            width: 10,
386            height: 10,
387        });
388        let res = cfg.resize_chart_area(tiny);
389        assert!(res.is_err());
390        assert_eq!(cfg, before);
391    }
392
393    // resize_chart_area_scaled scales flex but keeps tick fixed,
394    // so total margin doesn't scale exactly by r — only the flex part does.
395    #[test]
396    fn resize_chart_area_scaled_scales_flex_not_tick() {
397        let mut cfg = default_config();
398        let out0_top = cfg.top_x.out_margin;
399        let tick0_top = cfg.top_x.major_tick_length;
400        let out0_left = cfg.left_y.out_margin;
401        let tick0_left = cfg.left_y.major_tick_length;
402        let chart_title0 = cfg.chart_title.top_margin;
403
404        cfg.resize_chart_area_scaled(ChartArea(Rect {
405            x: 0,
406            y: 0,
407            width: 2000,
408            height: 1600,
409        }))
410        .unwrap();
411
412        // Flex scales by 2x.
413        assert!(approx_eq(cfg.top_x.out_margin, out0_top * 2.0, 1e-3));
414        assert!(approx_eq(cfg.left_y.out_margin, out0_left * 2.0, 1e-3));
415        assert!(approx_eq(
416            cfg.chart_title.top_margin,
417            chart_title0 * 2.0,
418            1e-3
419        ));
420        // Tick stays fixed.
421        assert!(approx_eq(cfg.top_x.major_tick_length, tick0_top, 1e-3));
422        assert!(approx_eq(cfg.left_y.major_tick_length, tick0_left, 1e-3));
423    }
424
425    #[test]
426    fn set_side_margin_basic() {
427        let mut cfg = default_config();
428        cfg.set_side_margin(Side::Left, 100.0).unwrap();
429        let m = cfg.margins();
430        assert!(approx_eq(m.left, 100.0, 1e-3));
431    }
432
433    // set_side_margin must not change tick length.
434    #[test]
435    fn set_side_margin_keeps_tick_fixed() {
436        let mut cfg = default_config();
437        let tick0 = cfg.left_y.major_tick_length;
438        cfg.set_side_margin(Side::Left, 150.0).unwrap();
439        assert!(approx_eq(cfg.left_y.major_tick_length, tick0, 1e-3));
440    }
441
442    #[test]
443    fn set_margins_success() {
444        let mut cfg = default_config();
445        let m = Margins {
446            top: 100.0,
447            bottom: 80.0,
448            left: 120.0,
449            right: 80.0,
450        };
451        cfg.set_margins(m.clone(), FitStrategy::ProportionalScale)
452            .unwrap();
453        let got = cfg.margins();
454        assert!(approx_eq(got.top, m.top, 1.0));
455        assert!(approx_eq(got.bottom, m.bottom, 1.0));
456        assert!(approx_eq(got.left, m.left, 1.0));
457        assert!(approx_eq(got.right, m.right, 1.0));
458    }
459
460    // fit / resize must leave tick lengths (both major and minor) untouched.
461    #[test]
462    fn fit_keeps_tick_lengths_fixed() {
463        let mut cfg = default_config();
464        let maj0 = cfg.left_y.major_tick_length;
465        let min0 = cfg.left_y.minor_tick_length;
466
467        let target = DataArea(Rect {
468            x: 150,
469            y: 150,
470            width: 700,
471            height: 500,
472        });
473        cfg.fit_to_data_area(target, FitStrategy::ProportionalScale)
474            .unwrap();
475
476        assert!(approx_eq(cfg.left_y.major_tick_length, maj0, 1e-3));
477        assert!(approx_eq(cfg.left_y.minor_tick_length, min0, 1e-3));
478    }
479
480    #[test]
481    fn resize_keeps_tick_lengths_fixed() {
482        let mut cfg = default_config();
483        let maj0 = cfg.top_x.major_tick_length;
484        let min0 = cfg.top_x.minor_tick_length;
485
486        cfg.resize_chart_area_scaled(ChartArea(Rect {
487            x: 0,
488            y: 0,
489            width: 2000,
490            height: 1600,
491        }))
492        .unwrap();
493
494        assert!(approx_eq(cfg.top_x.major_tick_length, maj0, 1e-3));
495        assert!(approx_eq(cfg.top_x.minor_tick_length, min0, 1e-3));
496    }
497
498    // The invariant that ties this module to `margins()`: what fit thinks a
499    // side totals must be what layout charges it. A colourbar band registered
500    // in one and not the other would put the data area in two places.
501    #[test]
502    fn side_total_agrees_with_margins_on_every_side() {
503        for side in [Side::Top, Side::Bottom, Side::Left, Side::Right] {
504            let cfg = config_with_colorbar(side.clone());
505            let m = cfg.margins();
506            for probe in [Side::Top, Side::Bottom, Side::Left, Side::Right] {
507                let charged = match probe {
508                    Side::Top => m.top,
509                    Side::Bottom => m.bottom,
510                    Side::Left => m.left,
511                    Side::Right => m.right,
512                };
513                let seen = side_total(&read_side(&cfg, &probe));
514                assert!(
515                    approx_eq(seen, charged, 1e-3),
516                    "bar on {side:?}, probing {probe:?}: fit sees {seen}, margins charge {charged}"
517                );
518            }
519        }
520    }
521
522    // A fit must be able to reclaim the bar's label space (it is text space,
523    // like an axis' out_margin) but never the strip itself — a colourbar that
524    // got thinner with the window would misreport its own colours' extent.
525    #[test]
526    fn proportional_fit_scales_the_colorbar_label_margin_and_not_the_strip() {
527        let mut cfg = config_with_colorbar(Side::Right);
528        let bar0 = cfg.colorbar.clone().expect("colourbar");
529        let m = cfg.margins();
530        // Halve the right margin's flex share by asking for a wider data area.
531        let da = cfg.data_area().expect("data area");
532        let target = DataArea(Rect {
533            x: da.x,
534            y: da.y,
535            width: da.width + (m.right * 0.25) as u32,
536            height: da.height,
537        });
538        cfg.fit_to_data_area(target.clone(), FitStrategy::ProportionalScale)
539            .expect("fit");
540
541        let bar = cfg.colorbar.as_ref().expect("colourbar");
542        assert!(
543            bar.axis.out_margin < bar0.axis.out_margin,
544            "label margin should have shrunk: {} → {}",
545            bar0.axis.out_margin,
546            bar.axis.out_margin
547        );
548        assert!(approx_eq(bar.thickness_px, bar0.thickness_px, 1e-6));
549        assert!(approx_eq(bar.gap_px, bar0.gap_px, 1e-6));
550        assert!(approx_eq(
551            bar.axis.major_tick_length,
552            bar0.axis.major_tick_length,
553            1e-6
554        ));
555        // And the fit actually landed where it was asked to.
556        let after = cfg.data_area().expect("data area");
557        assert_eq!(after.width, target.width);
558    }
559
560    // PreserveTitles and Absorb hold text space still and push the whole delta
561    // into the axis margin — the colourbar's labels are text too.
562    #[test]
563    fn preserve_titles_and_absorb_leave_the_colorbar_alone() {
564        for strategy in [
565            FitStrategy::PreserveTitles,
566            FitStrategy::Absorb(Side::Right),
567        ] {
568            let mut cfg = config_with_colorbar(Side::Right);
569            let bar0 = cfg.colorbar.clone().expect("colourbar");
570            let out0 = cfg.right_y.out_margin;
571            let da = cfg.data_area().expect("data area");
572            // 4 px, not more: `right_y.out_margin` starts at 8 (that side's
573            // labels and title are off), and these two strategies may only
574            // spend the axis margin.
575            let target = DataArea(Rect {
576                x: da.x,
577                y: da.y,
578                width: da.width + 4,
579                height: da.height,
580            });
581            cfg.fit_to_data_area(target, strategy.clone())
582                .unwrap_or_else(|e| panic!("{strategy:?} fit: {e:?}"));
583
584            assert_eq!(cfg.colorbar.as_ref().expect("colourbar"), &bar0);
585            assert!(
586                cfg.right_y.out_margin < out0,
587                "{strategy:?}: the delta belongs to the axis margin"
588            );
589        }
590    }
591
592    // resize_chart_area_scaled scales flex per side; the colourbar's label
593    // margin is flex, its strip is not.
594    #[test]
595    fn scaled_resize_scales_the_colorbar_label_margin_only() {
596        let mut cfg = config_with_colorbar(Side::Right);
597        let bar0 = cfg.colorbar.clone().expect("colourbar");
598        let area = cfg.chart_area.clone();
599        cfg.resize_chart_area_scaled(ChartArea(Rect {
600            x: area.x,
601            y: area.y,
602            width: area.width * 2,
603            height: area.height,
604        }))
605        .expect("resize");
606
607        let bar = cfg.colorbar.as_ref().expect("colourbar");
608        assert!(approx_eq(
609            bar.axis.out_margin,
610            bar0.axis.out_margin * 2.0,
611            1e-3
612        ));
613        assert!(approx_eq(bar.thickness_px, bar0.thickness_px, 1e-6));
614        assert!(approx_eq(bar.gap_px, bar0.gap_px, 1e-6));
615    }
616
617    // A bar on one side must not be written to from another side's fit.
618    #[test]
619    fn fitting_a_side_without_a_bar_does_not_touch_it() {
620        let mut cfg = config_with_colorbar(Side::Right);
621        let bar0 = cfg.colorbar.clone().expect("colourbar");
622        cfg.set_side_margin(Side::Left, cfg.margins().left * 0.8)
623            .expect("set left margin");
624        assert_eq!(cfg.colorbar.as_ref().expect("colourbar"), &bar0);
625    }
626}