Skip to main content

model/layout/
mod.rs

1use crate::config::{AxisOptions, ChartTitleOptions, Config};
2
3mod fit;
4mod geometry;
5mod nudge;
6mod rect;
7
8pub use fit::FitStrategy;
9pub use geometry::{
10    LABEL_GAP, LEGEND_INSET, TitleBand, TitlePlacement, axis_anchor, axis_offset,
11    axis_title_offset_to_screen, axis_title_placement, axis_visibility_extent,
12    axis_visibility_rect, chart_title_placement, colorbar_rect, colorbar_title_placement,
13    fit_chart_title_margin, label_origin, label_rect, legend_rect, point_on_rect_side,
14    rect_axis_visibility_rect, screen_offset_to_axis_title,
15};
16pub use nudge::{Element, NudgeReject, NudgeResult};
17pub use rect::{ChartArea, DataArea, Rect, RectF};
18
19// Support types.
20
21#[derive(Debug, Clone, PartialEq)]
22pub struct Margins {
23    pub top: f32,
24    pub bottom: f32,
25    pub left: f32,
26    pub right: f32,
27}
28
29/// A chart edge. Serializable because `ColorBarOptions::side` is part of
30/// `Config`; the layout-internal uses (`FitStrategy::Absorb`, nudge) predate
31/// that and are unaffected.
32#[derive(Debug, Clone, PartialEq)]
33#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
34pub enum Side {
35    Top,
36    Bottom,
37    Left,
38    Right,
39}
40
41#[derive(Debug, Clone, PartialEq)]
42pub enum LayoutError {
43    OverflowHorizontal { required: f32, available: u32 },
44    OverflowVertical { required: f32, available: u32 },
45    EmptyDataArea,
46    TargetOutOfChartArea,
47    Infeasible,
48}
49
50/// How much of the data area, in pixels along each axis, must stay inside the
51/// chart area when margins push it past the chart edges, so its selection box
52/// and resize handles remain reachable.
53pub const MIN_VISIBLE_DATA_AREA_PX: u32 = 8;
54
55// Margin contribution helpers (used by sub-modules).
56//
57// Every contribution is counted regardless of visibility. `label_offset_{x,y}`
58// is a **visual-only offset** that never moves margins (so axis-label nudges
59// shift the label without disturbing layout); the space to hold the label is
60// always reserved by `out_margin`. `major_tick_length` contributes and is
61// **fixed** across fit/resize so tick marks don't grow with the window.
62
63pub(super) fn tick_contribution(axis: &AxisOptions) -> f32 {
64    axis.major_tick_length
65}
66
67pub(super) fn chart_title_contribution(title: &ChartTitleOptions) -> f32 {
68    title.top_margin
69}
70
71/// The colourbar band on one side, split into `(flex, fixed)`; `None` on the
72/// three sides the bar is not on, and when it is hidden.
73///
74/// `flex` is the strip's axis `out_margin` — label space, scaled by fit/resize
75/// exactly as a chart axis' is. `fixed` is the gap, the strip, and the strip's
76/// tick length: a colourbar that got thinner with the window would misreport
77/// the extent of its own colours.
78///
79/// The hidden case is the one place this departs from "contributions count
80/// regardless of visibility": an axis' `visible` flags hide a *part* (line,
81/// ticks, labels) whose space the rest still needs, while
82/// `ColorBarOptions::visible = false` means nothing is drawn there at all, and a
83/// reserved band with nothing in it is just a hole in the chart.
84///
85/// This is the **only** place that decides which side carries a band. Both
86/// `colorbar_contribution` (what `margins()` charges) and `fit`'s per-side
87/// components read it, so the two cannot disagree about a side and leave the
88/// data area in two places at once.
89pub(super) fn colorbar_parts(cfg: &Config, side: &Side) -> Option<(f32, f32)> {
90    match cfg.colorbar.as_ref() {
91        Some(bar) if bar.visible && bar.side == *side => Some((
92            bar.axis.out_margin,
93            bar.gap_px + bar.thickness_px + tick_contribution(&bar.axis),
94        )),
95        _ => None,
96    }
97}
98
99/// The colourbar band's total contribution to one side's margin.
100pub(super) fn colorbar_contribution(cfg: &Config, side: &Side) -> f32 {
101    colorbar_parts(cfg, side).map_or(0.0, |(flex, fixed)| flex + fixed)
102}
103
104fn top_total(cfg: &Config) -> f32 {
105    chart_title_contribution(&cfg.chart_title)
106        + cfg.top_x.out_margin
107        + tick_contribution(&cfg.top_x)
108        + colorbar_contribution(cfg, &Side::Top)
109}
110
111fn bottom_total(cfg: &Config) -> f32 {
112    cfg.bottom_x.out_margin
113        + tick_contribution(&cfg.bottom_x)
114        + colorbar_contribution(cfg, &Side::Bottom)
115}
116
117fn left_total(cfg: &Config) -> f32 {
118    cfg.left_y.out_margin + tick_contribution(&cfg.left_y) + colorbar_contribution(cfg, &Side::Left)
119}
120
121fn right_total(cfg: &Config) -> f32 {
122    cfg.right_y.out_margin
123        + tick_contribution(&cfg.right_y)
124        + colorbar_contribution(cfg, &Side::Right)
125}
126
127// Side → axis ref / mut accessors.
128pub(super) fn axis_ref<'a>(cfg: &'a Config, side: &Side) -> &'a AxisOptions {
129    match side {
130        Side::Top => &cfg.top_x,
131        Side::Bottom => &cfg.bottom_x,
132        Side::Left => &cfg.left_y,
133        Side::Right => &cfg.right_y,
134    }
135}
136
137pub(super) fn axis_mut<'a>(cfg: &'a mut Config, side: &Side) -> &'a mut AxisOptions {
138    match side {
139        Side::Top => &mut cfg.top_x,
140        Side::Bottom => &mut cfg.bottom_x,
141        Side::Left => &mut cfg.left_y,
142        Side::Right => &mut cfg.right_y,
143    }
144}
145
146// Lookup / validation.
147
148impl Config {
149    pub fn margins(&self) -> Margins {
150        Margins {
151            top: top_total(self),
152            bottom: bottom_total(self),
153            left: left_total(self),
154            right: right_total(self),
155        }
156    }
157
158    pub fn data_area(&self) -> Result<DataArea, LayoutError> {
159        let ca = &self.chart_area;
160        let m = self.margins();
161
162        // A negative margin is just a coordinate: it moves that edge of the data
163        // area out past the chart edge, which is allowed. A NaN or infinite one
164        // is not a coordinate at all.
165        if [m.top, m.bottom, m.left, m.right]
166            .iter()
167            .any(|v| !v.is_finite())
168        {
169            return Err(LayoutError::Infeasible);
170        }
171
172        let h_need = m.left + m.right;
173        if h_need > ca.width as f32 {
174            return Err(LayoutError::OverflowHorizontal {
175                required: h_need,
176                available: ca.width,
177            });
178        }
179        let v_need = m.top + m.bottom;
180        if v_need > ca.height as f32 {
181            return Err(LayoutError::OverflowVertical {
182                required: v_need,
183                available: ca.height,
184            });
185        }
186
187        let x = ca.x as f32 + m.left;
188        let y = ca.y as f32 + m.top;
189        let w = ca.width as f32 - h_need;
190        let h = ca.height as f32 - v_need;
191
192        if w <= 0.0 || h <= 0.0 {
193            return Err(LayoutError::EmptyDataArea);
194        }
195
196        // `Rect` is unsigned, so the data area cannot start above or left of the
197        // canvas origin, nor end past `u32::MAX`: the cast would saturate and
198        // silently move the opposite edge instead.
199        let max = f64::from(u32::MAX);
200        if x < 0.0
201            || y < 0.0
202            || f64::from(x) + f64::from(w) > max
203            || f64::from(y) + f64::from(h) > max
204        {
205            return Err(LayoutError::TargetOutOfChartArea);
206        }
207        let rect = Rect {
208            x: x.floor() as u32,
209            y: y.floor() as u32,
210            width: w.floor() as u32,
211            height: h.floor() as u32,
212        };
213        // The data area may run past the chart edges, but it must keep a
214        // grabbable strip on screen: once it is entirely outside, it can no
215        // longer be selected or dragged back. A data area smaller than the
216        // strip only has to stay wholly inside.
217        let keeps_visible_edge = |start: u32, len: u32, ca_start: u32, ca_len: u32| {
218            let lo = i64::from(start.max(ca_start));
219            let hi =
220                (i64::from(start) + i64::from(len)).min(i64::from(ca_start) + i64::from(ca_len));
221            hi - lo >= i64::from(MIN_VISIBLE_DATA_AREA_PX.min(len).min(ca_len))
222        };
223        if !keeps_visible_edge(rect.x, rect.width, ca.x, ca.width)
224            || !keeps_visible_edge(rect.y, rect.height, ca.y, ca.height)
225        {
226            return Err(LayoutError::TargetOutOfChartArea);
227        }
228        Ok(DataArea(rect))
229    }
230
231    /// How far this side's axis band is pushed in from the chart-area edge by
232    /// the colourbar band. Zero on the three sides the bar is not on, and when
233    /// it is hidden.
234    ///
235    /// Public because everything that positions chrome against the chart edge —
236    /// the axis title's placement, its hit box, its nudge anchor — needs the
237    /// same number, and computing it separately in each is how they drift apart.
238    pub fn colorbar_band(&self, side: &Side) -> f32 {
239        colorbar_contribution(self, side)
240    }
241
242    pub fn validate(&self) -> Result<(), LayoutError> {
243        let _ = self.data_area()?;
244        for axis in [&self.top_x, &self.bottom_x, &self.left_y, &self.right_y] {
245            axis_dims_feasible(axis)?;
246        }
247        if let Some(bar) = self.colorbar.as_ref() {
248            // The z axis is held to the same dimensional rules as the four
249            // chart axes — it contributes to a margin the same way.
250            axis_dims_feasible(&bar.axis)?;
251            if bar.thickness_px < 0.0 || bar.gap_px < 0.0 || bar.border_width < 0.0 {
252                return Err(LayoutError::Infeasible);
253            }
254            // A strip of zero length has no pixel to hold a colour, and one
255            // longer than its side runs past the axis it is labelled against.
256            if !(bar.length_frac > 0.0 && bar.length_frac <= 1.0) {
257                return Err(LayoutError::Infeasible);
258            }
259        }
260        Ok(())
261    }
262}
263
264/// The dimensional rules every `AxisOptions` must satisfy, chart axis or
265/// colourbar. Range direction is checked here; whether a *logarithmic* range is
266/// positive is a renderer-side rule (`validate_renderer_config`), which is
267/// where the reason string lives.
268fn axis_dims_feasible(axis: &AxisOptions) -> Result<(), LayoutError> {
269    if axis.max <= axis.min {
270        return Err(LayoutError::Infeasible);
271    }
272    if axis.out_margin < 0.0
273        || axis.major_tick_length < 0.0
274        || axis.minor_tick_length < 0.0
275        || axis.line_width < 0.0
276    {
277        return Err(LayoutError::Infeasible);
278    }
279    Ok(())
280}
281
282// Invariant tests (data_area / margins / validate).
283
284#[cfg(test)]
285mod tests {
286    use super::{Side, colorbar_contribution};
287    use crate::config::Config;
288    use crate::default::{default_colorbar_options, default_config};
289    use crate::layout::{ChartArea, LayoutError, Rect};
290
291    fn config_with_colorbar(side: Side) -> Config {
292        let mut cfg = default_config();
293        let mut bar = default_colorbar_options();
294        bar.side = side;
295        cfg.colorbar = Some(bar);
296        cfg
297    }
298
299    fn band(cfg: &Config) -> f32 {
300        let bar = cfg.colorbar.as_ref().expect("colourbar");
301        bar.gap_px + bar.thickness_px + bar.axis.out_margin + bar.axis.major_tick_length
302    }
303
304    // data_area() must be fully contained inside chart_area on success.
305    #[test]
306    fn data_area_contained_in_chart_area() {
307        let cfg = default_config();
308        let da = cfg.data_area().unwrap();
309        let ca = &cfg.chart_area;
310        assert!(da.x >= ca.x);
311        assert!(da.y >= ca.y);
312        assert!(da.x + da.width <= ca.x + ca.width);
313        assert!(da.y + da.height <= ca.y + ca.height);
314    }
315
316    // All margin-contribution fields must be non-negative.
317    #[test]
318    fn margins_non_negative() {
319        let cfg = default_config();
320        let m = cfg.margins();
321        assert!(m.top >= 0.0);
322        assert!(m.bottom >= 0.0);
323        assert!(m.left >= 0.0);
324        assert!(m.right >= 0.0);
325    }
326
327    // The band lands on the bar's side and nowhere else: the data area loses
328    // exactly that much width, and the other three margins do not move.
329    #[test]
330    fn the_colorbar_band_is_charged_to_its_own_side_only() {
331        for side in [Side::Top, Side::Bottom, Side::Left, Side::Right] {
332            let base = default_config();
333            let cfg = config_with_colorbar(side.clone());
334            let (before, after) = (base.margins(), cfg.margins());
335            let expected = band(&cfg);
336
337            let (grew, unchanged) = match side {
338                Side::Top => (
339                    after.top - before.top,
340                    [
341                        after.bottom - before.bottom,
342                        after.left - before.left,
343                        after.right - before.right,
344                    ],
345                ),
346                Side::Bottom => (
347                    after.bottom - before.bottom,
348                    [
349                        after.top - before.top,
350                        after.left - before.left,
351                        after.right - before.right,
352                    ],
353                ),
354                Side::Left => (
355                    after.left - before.left,
356                    [
357                        after.top - before.top,
358                        after.bottom - before.bottom,
359                        after.right - before.right,
360                    ],
361                ),
362                Side::Right => (
363                    after.right - before.right,
364                    [
365                        after.top - before.top,
366                        after.bottom - before.bottom,
367                        after.left - before.left,
368                    ],
369                ),
370            };
371            assert!(
372                (grew - expected).abs() < 1e-3,
373                "{side:?}: margin grew by {grew}, band is {expected}"
374            );
375            for delta in unchanged {
376                assert!(
377                    delta.abs() < 1e-6,
378                    "{side:?}: another side moved by {delta}"
379                );
380            }
381
382            let da = cfg.data_area().expect("data area");
383            let base_da = base.data_area().expect("data area");
384            match side {
385                Side::Left | Side::Right => {
386                    assert!(da.width < base_da.width);
387                    assert_eq!(da.height, base_da.height);
388                }
389                Side::Top | Side::Bottom => {
390                    assert!(da.height < base_da.height);
391                    assert_eq!(da.width, base_da.width);
392                }
393            }
394        }
395    }
396
397    // `visible: false` gives the band back. Unlike an axis' visibility flags —
398    // which hide a part whose space the rest still needs — there is nothing
399    // left to reserve space for.
400    #[test]
401    fn a_hidden_colorbar_reserves_nothing() {
402        let mut cfg = config_with_colorbar(Side::Right);
403        cfg.colorbar.as_mut().expect("colourbar").visible = false;
404        assert_eq!(cfg.margins(), default_config().margins());
405        assert_eq!(colorbar_contribution(&cfg, &Side::Right), 0.0);
406    }
407
408    // Nothing about the colourbar may make a chart with no z dimension differ
409    // from one written before the field existed.
410    #[test]
411    fn a_config_without_a_colorbar_is_unaffected() {
412        let cfg = default_config();
413        assert!(cfg.colorbar.is_none());
414        for side in [Side::Top, Side::Bottom, Side::Left, Side::Right] {
415            assert_eq!(colorbar_contribution(&cfg, &side), 0.0);
416        }
417    }
418
419    // The z axis is held to the same dimensional rules as the four chart axes,
420    // and the strip's own dims have to be drawable.
421    #[test]
422    fn validate_rejects_a_degenerate_colorbar() {
423        let good = config_with_colorbar(Side::Right);
424        assert!(good.validate().is_ok());
425
426        let mut reversed = good.clone();
427        reversed.colorbar.as_mut().unwrap().axis.max = reversed.colorbar.as_ref().unwrap().axis.min;
428        assert_eq!(reversed.validate(), Err(LayoutError::Infeasible));
429
430        let mut negative_margin = good.clone();
431        negative_margin.colorbar.as_mut().unwrap().axis.out_margin = -1.0;
432        assert_eq!(negative_margin.validate(), Err(LayoutError::Infeasible));
433
434        for frac in [0.0, -0.5, 1.5, f32::NAN] {
435            let mut bad = good.clone();
436            bad.colorbar.as_mut().unwrap().length_frac = frac;
437            assert_eq!(
438                bad.validate(),
439                Err(LayoutError::Infeasible),
440                "length_frac {frac} must be rejected"
441            );
442        }
443    }
444
445    // Issue #6: a negative title margin is a coordinate, not an error. It may
446    // push the data area past the chart edge, as long as the unsigned rect can
447    // hold it and a grabbable strip stays on screen.
448    fn small_chart(y: u32, top_margin: f32) -> Config {
449        let mut cfg = config_with_colorbar(Side::Right);
450        cfg.chart_area = ChartArea(Rect {
451            x: 0,
452            y,
453            width: 320,
454            height: 240,
455        });
456        cfg.chart_title.top_margin = top_margin;
457        cfg
458    }
459
460    fn bottom_edge(cfg: &Config) -> f32 {
461        cfg.chart_area.y as f32 + cfg.chart_area.height as f32 - cfg.margins().bottom
462    }
463
464    #[test]
465    fn a_negative_title_margin_is_a_coordinate() {
466        for (y, margin) in [(0, -5.0), (100, -60.0), (100, -90.0)] {
467            let cfg = small_chart(y, margin);
468            assert!(cfg.validate().is_ok(), "y {y}, title margin {margin}");
469            let da = cfg.data_area().expect("data area").0;
470            // The top edge follows the margin; the bottom edge does not move.
471            assert_eq!(da.y as f32, (y as f32 + cfg.margins().top).floor());
472            assert_eq!((da.y + da.height) as f32, bottom_edge(&cfg).floor());
473        }
474        // Running past the chart's top edge is allowed when the canvas has room.
475        assert!(small_chart(100, -90.0).data_area().unwrap().0.y < 100);
476    }
477
478    // `Rect` is unsigned: a data area starting above the canvas origin used to
479    // saturate to y = 0 and keep its height, moving the bottom edge down.
480    #[test]
481    fn a_data_area_above_the_canvas_origin_is_refused_not_shifted() {
482        let cfg = small_chart(0, -60.0);
483        assert!(cfg.margins().top < 0.0);
484        assert_eq!(cfg.data_area(), Err(LayoutError::TargetOutOfChartArea));
485        assert!(cfg.validate().is_err());
486        // The issue's reproduction.
487        assert_eq!(
488            small_chart(0, -1e38).data_area(),
489            Err(LayoutError::TargetOutOfChartArea)
490        );
491    }
492
493    #[test]
494    fn a_non_finite_margin_is_rejected() {
495        for margin in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
496            let cfg = small_chart(100, margin);
497            assert_eq!(cfg.data_area(), Err(LayoutError::Infeasible), "{margin}");
498            assert!(cfg.validate().is_err());
499        }
500    }
501
502    // Past `u32::MAX` the unsigned rect cannot hold the far edge.
503    #[test]
504    fn a_data_area_past_u32_max_is_refused() {
505        let mut cfg = small_chart(0, 32.0);
506        cfg.bottom_x.out_margin = -1e38;
507        assert_eq!(cfg.data_area(), Err(LayoutError::TargetOutOfChartArea));
508    }
509
510    // The data area must keep MIN_VISIBLE_DATA_AREA_PX inside the chart area,
511    // so it can still be selected and dragged back.
512    #[test]
513    fn the_data_area_keeps_a_visible_strip() {
514        use super::MIN_VISIBLE_DATA_AREA_PX as MIN;
515        let base = small_chart(100, 32.0);
516        let ca_bottom = (base.chart_area.y + base.chart_area.height) as f32;
517        let top = base.margins().top;
518        // Choose the title margin so the data area starts `inside` px above the
519        // chart's bottom edge.
520        let with_strip = |inside: f32| {
521            let mut cfg = base.clone();
522            let shift = ca_bottom - inside - (cfg.chart_area.y as f32 + top);
523            cfg.chart_title.top_margin += shift;
524            cfg.bottom_x.out_margin -= shift;
525            cfg
526        };
527        assert!(with_strip(MIN as f32).data_area().is_ok());
528        assert_eq!(
529            with_strip(MIN as f32 - 1.0).data_area(),
530            Err(LayoutError::TargetOutOfChartArea)
531        );
532        assert_eq!(
533            with_strip(-20.0).data_area(),
534            Err(LayoutError::TargetOutOfChartArea)
535        );
536        // A data area smaller than the strip only has to stay inside.
537        let mut tiny = base.clone();
538        let m = tiny.margins();
539        tiny.bottom_x.out_margin += tiny.chart_area.height as f32 - m.top - m.bottom - 3.0;
540        let da = tiny.data_area().expect("tiny data area").0;
541        assert!(da.height < MIN);
542    }
543
544    // The selection box of every chrome element is derived from `data_area()`,
545    // so an interaction that left it erroring would drop the selection. Drive
546    // random edge drags, data-area moves, margin edits and resizes from random
547    // valid layouts: the layout must stay valid after each step, whether the
548    // step was applied or refused, and the display copy must never trip the
549    // origin or visible-strip guard.
550    #[test]
551    fn interactions_from_a_valid_layout_never_lose_the_data_area() {
552        use crate::layout::{Element, FitStrategy};
553
554        let mut seed: u64 = 0x9E37_79B9_7F4A_7C15;
555        let mut next = move || {
556            seed ^= seed << 13;
557            seed ^= seed >> 7;
558            seed ^= seed << 17;
559            (seed >> 11) as f32 / (1u64 << 53) as f32
560        };
561        let sides = [Side::Top, Side::Bottom, Side::Left, Side::Right];
562        let mut checked = 0usize;
563
564        for _ in 0..1500 {
565            let mut cfg = default_config();
566            cfg.chart_area = ChartArea(Rect {
567                x: (next() * 5000.0) as u32,
568                y: (next() * 5000.0) as u32,
569                width: 40 + (next() * 4000.0) as u32,
570                height: 40 + (next() * 4000.0) as u32,
571            });
572            cfg.chart_title.top_margin = next() * 120.0;
573            for side in &sides {
574                super::axis_mut(&mut cfg, side).out_margin = next() * 90.0;
575            }
576            if next() < 0.5 {
577                let mut bar = default_colorbar_options();
578                bar.side = sides[(next() * 4.0) as usize % 4].clone();
579                cfg.colorbar = Some(bar);
580            }
581            if cfg.validate().is_err() {
582                continue;
583            }
584
585            for _ in 0..150 {
586                let side = sides[(next() * 4.0) as usize % 4].clone();
587                let (dx, dy) = ((next() - 0.5) * 120.0, (next() - 0.5) * 120.0);
588                let before = cfg.clone();
589                match (next() * 6.0) as u32 {
590                    0 | 1 => {
591                        let _ = cfg.nudge(Element::DataAreaEdge(side), dx, dy);
592                    }
593                    2 => {
594                        let _ = cfg.nudge(Element::DataArea, dx, dy);
595                    }
596                    3 => {
597                        let _ = cfg.set_side_margin(side, next() * 300.0);
598                    }
599                    4 => {
600                        let ca = cfg.chart_area.0;
601                        let _ = cfg.resize_chart_area_scaled(ChartArea(Rect {
602                            x: ca.x,
603                            y: ca.y,
604                            width: 20 + (next() * 4000.0) as u32,
605                            height: 20 + (next() * 4000.0) as u32,
606                        }));
607                    }
608                    _ => {
609                        let m = cfg.margins();
610                        let _ = cfg.set_margins(
611                            super::Margins {
612                                top: m.top + dy,
613                                ..m
614                            },
615                            FitStrategy::ProportionalScale,
616                        );
617                    }
618                }
619                assert!(
620                    cfg.validate().is_ok(),
621                    "an interaction left an invalid layout: {:?} -> {:?}",
622                    before.margins(),
623                    cfg.margins()
624                );
625                // The display copy (high-DPI scale) must stay valid too.
626                let scale = 0.5 + next() * 3.5;
627                let shown = cfg.scaled(scale);
628                match shown.data_area() {
629                    Ok(_)
630                    | Err(LayoutError::OverflowHorizontal { .. })
631                    | Err(LayoutError::OverflowVertical { .. })
632                    | Err(LayoutError::EmptyDataArea) => {}
633                    Err(e) => panic!("display scale {scale} hit a new rejection: {e:?}"),
634                }
635                checked += 1;
636            }
637        }
638        assert!(checked > 100_000, "only {checked} steps exercised");
639    }
640}