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    label_origin, label_rect, legend_rect, point_on_rect_side, rect_axis_visibility_rect,
14    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// Margin contribution helpers (used by sub-modules).
51//
52// Every contribution is counted regardless of visibility. `label_offset_{x,y}`
53// is a **visual-only offset** that never moves margins (so axis-label nudges
54// shift the label without disturbing layout); the space to hold the label is
55// always reserved by `out_margin`. `major_tick_length` contributes and is
56// **fixed** across fit/resize so tick marks don't grow with the window.
57
58pub(super) fn tick_contribution(axis: &AxisOptions) -> f32 {
59    axis.major_tick_length
60}
61
62pub(super) fn chart_title_contribution(title: &ChartTitleOptions) -> f32 {
63    title.top_margin
64}
65
66/// The colourbar band on one side, split into `(flex, fixed)`; `None` on the
67/// three sides the bar is not on, and when it is hidden.
68///
69/// `flex` is the strip's axis `out_margin` — label space, scaled by fit/resize
70/// exactly as a chart axis' is. `fixed` is the gap, the strip, and the strip's
71/// tick length: a colourbar that got thinner with the window would misreport
72/// the extent of its own colours.
73///
74/// The hidden case is the one place this departs from "contributions count
75/// regardless of visibility": an axis' `visible` flags hide a *part* (line,
76/// ticks, labels) whose space the rest still needs, while
77/// `ColorBarOptions::visible = false` means nothing is drawn there at all, and a
78/// reserved band with nothing in it is just a hole in the chart.
79///
80/// This is the **only** place that decides which side carries a band. Both
81/// `colorbar_contribution` (what `margins()` charges) and `fit`'s per-side
82/// components read it, so the two cannot disagree about a side and leave the
83/// data area in two places at once.
84pub(super) fn colorbar_parts(cfg: &Config, side: &Side) -> Option<(f32, f32)> {
85    match cfg.colorbar.as_ref() {
86        Some(bar) if bar.visible && bar.side == *side => Some((
87            bar.axis.out_margin,
88            bar.gap_px + bar.thickness_px + tick_contribution(&bar.axis),
89        )),
90        _ => None,
91    }
92}
93
94/// The colourbar band's total contribution to one side's margin.
95pub(super) fn colorbar_contribution(cfg: &Config, side: &Side) -> f32 {
96    colorbar_parts(cfg, side).map_or(0.0, |(flex, fixed)| flex + fixed)
97}
98
99fn top_total(cfg: &Config) -> f32 {
100    chart_title_contribution(&cfg.chart_title)
101        + cfg.top_x.out_margin
102        + tick_contribution(&cfg.top_x)
103        + colorbar_contribution(cfg, &Side::Top)
104}
105
106fn bottom_total(cfg: &Config) -> f32 {
107    cfg.bottom_x.out_margin
108        + tick_contribution(&cfg.bottom_x)
109        + colorbar_contribution(cfg, &Side::Bottom)
110}
111
112fn left_total(cfg: &Config) -> f32 {
113    cfg.left_y.out_margin + tick_contribution(&cfg.left_y) + colorbar_contribution(cfg, &Side::Left)
114}
115
116fn right_total(cfg: &Config) -> f32 {
117    cfg.right_y.out_margin
118        + tick_contribution(&cfg.right_y)
119        + colorbar_contribution(cfg, &Side::Right)
120}
121
122// Side → axis ref / mut accessors.
123pub(super) fn axis_ref<'a>(cfg: &'a Config, side: &Side) -> &'a AxisOptions {
124    match side {
125        Side::Top => &cfg.top_x,
126        Side::Bottom => &cfg.bottom_x,
127        Side::Left => &cfg.left_y,
128        Side::Right => &cfg.right_y,
129    }
130}
131
132pub(super) fn axis_mut<'a>(cfg: &'a mut Config, side: &Side) -> &'a mut AxisOptions {
133    match side {
134        Side::Top => &mut cfg.top_x,
135        Side::Bottom => &mut cfg.bottom_x,
136        Side::Left => &mut cfg.left_y,
137        Side::Right => &mut cfg.right_y,
138    }
139}
140
141// Lookup / validation.
142
143impl Config {
144    pub fn margins(&self) -> Margins {
145        Margins {
146            top: top_total(self),
147            bottom: bottom_total(self),
148            left: left_total(self),
149            right: right_total(self),
150        }
151    }
152
153    pub fn data_area(&self) -> Result<DataArea, LayoutError> {
154        let ca = &self.chart_area;
155        let m = self.margins();
156
157        let h_need = m.left + m.right;
158        if h_need > ca.width as f32 {
159            return Err(LayoutError::OverflowHorizontal {
160                required: h_need,
161                available: ca.width,
162            });
163        }
164        let v_need = m.top + m.bottom;
165        if v_need > ca.height as f32 {
166            return Err(LayoutError::OverflowVertical {
167                required: v_need,
168                available: ca.height,
169            });
170        }
171
172        let x = ca.x as f32 + m.left;
173        let y = ca.y as f32 + m.top;
174        let w = ca.width as f32 - h_need;
175        let h = ca.height as f32 - v_need;
176
177        if w <= 0.0 || h <= 0.0 {
178            return Err(LayoutError::EmptyDataArea);
179        }
180
181        Ok(DataArea(Rect {
182            x: x.floor() as u32,
183            y: y.floor() as u32,
184            width: w.floor() as u32,
185            height: h.floor() as u32,
186        }))
187    }
188
189    /// How far this side's axis band is pushed in from the chart-area edge by
190    /// the colourbar band. Zero on the three sides the bar is not on, and when
191    /// it is hidden.
192    ///
193    /// Public because everything that positions chrome against the chart edge —
194    /// the axis title's placement, its hit box, its nudge anchor — needs the
195    /// same number, and computing it separately in each is how they drift apart.
196    pub fn colorbar_band(&self, side: &Side) -> f32 {
197        colorbar_contribution(self, side)
198    }
199
200    pub fn validate(&self) -> Result<(), LayoutError> {
201        let _ = self.data_area()?;
202        for axis in [&self.top_x, &self.bottom_x, &self.left_y, &self.right_y] {
203            axis_dims_feasible(axis)?;
204        }
205        if let Some(bar) = self.colorbar.as_ref() {
206            // The z axis is held to the same dimensional rules as the four
207            // chart axes — it contributes to a margin the same way.
208            axis_dims_feasible(&bar.axis)?;
209            if bar.thickness_px < 0.0 || bar.gap_px < 0.0 || bar.border_width < 0.0 {
210                return Err(LayoutError::Infeasible);
211            }
212            // A strip of zero length has no pixel to hold a colour, and one
213            // longer than its side runs past the axis it is labelled against.
214            if !(bar.length_frac > 0.0 && bar.length_frac <= 1.0) {
215                return Err(LayoutError::Infeasible);
216            }
217        }
218        Ok(())
219    }
220}
221
222/// The dimensional rules every `AxisOptions` must satisfy, chart axis or
223/// colourbar. Range direction is checked here; whether a *logarithmic* range is
224/// positive is a renderer-side rule (`validate_renderer_config`), which is
225/// where the reason string lives.
226fn axis_dims_feasible(axis: &AxisOptions) -> Result<(), LayoutError> {
227    if axis.max <= axis.min {
228        return Err(LayoutError::Infeasible);
229    }
230    if axis.out_margin < 0.0
231        || axis.major_tick_length < 0.0
232        || axis.minor_tick_length < 0.0
233        || axis.line_width < 0.0
234    {
235        return Err(LayoutError::Infeasible);
236    }
237    Ok(())
238}
239
240// Invariant tests (data_area / margins / validate).
241
242#[cfg(test)]
243mod tests {
244    use super::{Side, colorbar_contribution};
245    use crate::config::Config;
246    use crate::default::{default_colorbar_options, default_config};
247    use crate::layout::LayoutError;
248
249    fn config_with_colorbar(side: Side) -> Config {
250        let mut cfg = default_config();
251        let mut bar = default_colorbar_options();
252        bar.side = side;
253        cfg.colorbar = Some(bar);
254        cfg
255    }
256
257    fn band(cfg: &Config) -> f32 {
258        let bar = cfg.colorbar.as_ref().expect("colourbar");
259        bar.gap_px + bar.thickness_px + bar.axis.out_margin + bar.axis.major_tick_length
260    }
261
262    // data_area() must be fully contained inside chart_area on success.
263    #[test]
264    fn data_area_contained_in_chart_area() {
265        let cfg = default_config();
266        let da = cfg.data_area().unwrap();
267        let ca = &cfg.chart_area;
268        assert!(da.x >= ca.x);
269        assert!(da.y >= ca.y);
270        assert!(da.x + da.width <= ca.x + ca.width);
271        assert!(da.y + da.height <= ca.y + ca.height);
272    }
273
274    // All margin-contribution fields must be non-negative.
275    #[test]
276    fn margins_non_negative() {
277        let cfg = default_config();
278        let m = cfg.margins();
279        assert!(m.top >= 0.0);
280        assert!(m.bottom >= 0.0);
281        assert!(m.left >= 0.0);
282        assert!(m.right >= 0.0);
283    }
284
285    // The band lands on the bar's side and nowhere else: the data area loses
286    // exactly that much width, and the other three margins do not move.
287    #[test]
288    fn the_colorbar_band_is_charged_to_its_own_side_only() {
289        for side in [Side::Top, Side::Bottom, Side::Left, Side::Right] {
290            let base = default_config();
291            let cfg = config_with_colorbar(side.clone());
292            let (before, after) = (base.margins(), cfg.margins());
293            let expected = band(&cfg);
294
295            let (grew, unchanged) = match side {
296                Side::Top => (
297                    after.top - before.top,
298                    [
299                        after.bottom - before.bottom,
300                        after.left - before.left,
301                        after.right - before.right,
302                    ],
303                ),
304                Side::Bottom => (
305                    after.bottom - before.bottom,
306                    [
307                        after.top - before.top,
308                        after.left - before.left,
309                        after.right - before.right,
310                    ],
311                ),
312                Side::Left => (
313                    after.left - before.left,
314                    [
315                        after.top - before.top,
316                        after.bottom - before.bottom,
317                        after.right - before.right,
318                    ],
319                ),
320                Side::Right => (
321                    after.right - before.right,
322                    [
323                        after.top - before.top,
324                        after.bottom - before.bottom,
325                        after.left - before.left,
326                    ],
327                ),
328            };
329            assert!(
330                (grew - expected).abs() < 1e-3,
331                "{side:?}: margin grew by {grew}, band is {expected}"
332            );
333            for delta in unchanged {
334                assert!(
335                    delta.abs() < 1e-6,
336                    "{side:?}: another side moved by {delta}"
337                );
338            }
339
340            let da = cfg.data_area().expect("data area");
341            let base_da = base.data_area().expect("data area");
342            match side {
343                Side::Left | Side::Right => {
344                    assert!(da.width < base_da.width);
345                    assert_eq!(da.height, base_da.height);
346                }
347                Side::Top | Side::Bottom => {
348                    assert!(da.height < base_da.height);
349                    assert_eq!(da.width, base_da.width);
350                }
351            }
352        }
353    }
354
355    // `visible: false` gives the band back. Unlike an axis' visibility flags —
356    // which hide a part whose space the rest still needs — there is nothing
357    // left to reserve space for.
358    #[test]
359    fn a_hidden_colorbar_reserves_nothing() {
360        let mut cfg = config_with_colorbar(Side::Right);
361        cfg.colorbar.as_mut().expect("colourbar").visible = false;
362        assert_eq!(cfg.margins(), default_config().margins());
363        assert_eq!(colorbar_contribution(&cfg, &Side::Right), 0.0);
364    }
365
366    // Nothing about the colourbar may make a chart with no z dimension differ
367    // from one written before the field existed.
368    #[test]
369    fn a_config_without_a_colorbar_is_unaffected() {
370        let cfg = default_config();
371        assert!(cfg.colorbar.is_none());
372        for side in [Side::Top, Side::Bottom, Side::Left, Side::Right] {
373            assert_eq!(colorbar_contribution(&cfg, &side), 0.0);
374        }
375    }
376
377    // The z axis is held to the same dimensional rules as the four chart axes,
378    // and the strip's own dims have to be drawable.
379    #[test]
380    fn validate_rejects_a_degenerate_colorbar() {
381        let good = config_with_colorbar(Side::Right);
382        assert!(good.validate().is_ok());
383
384        let mut reversed = good.clone();
385        reversed.colorbar.as_mut().unwrap().axis.max = reversed.colorbar.as_ref().unwrap().axis.min;
386        assert_eq!(reversed.validate(), Err(LayoutError::Infeasible));
387
388        let mut negative_margin = good.clone();
389        negative_margin.colorbar.as_mut().unwrap().axis.out_margin = -1.0;
390        assert_eq!(negative_margin.validate(), Err(LayoutError::Infeasible));
391
392        for frac in [0.0, -0.5, 1.5, f32::NAN] {
393            let mut bad = good.clone();
394            bad.colorbar.as_mut().unwrap().length_frac = frac;
395            assert_eq!(
396                bad.validate(),
397                Err(LayoutError::Infeasible),
398                "length_frac {frac} must be rejected"
399            );
400        }
401    }
402}