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#[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#[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
50pub(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
66pub(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
94pub(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
122pub(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
141impl 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 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 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 if !(bar.length_frac > 0.0 && bar.length_frac <= 1.0) {
215 return Err(LayoutError::Infeasible);
216 }
217 }
218 Ok(())
219 }
220}
221
222fn 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#[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 #[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 #[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 #[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 #[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 #[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 #[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}