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#[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 const MIN_VISIBLE_DATA_AREA_PX: u32 = 8;
54
55pub(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
71pub(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
99pub(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
127pub(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
146impl 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 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 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 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 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 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 if !(bar.length_frac > 0.0 && bar.length_frac <= 1.0) {
257 return Err(LayoutError::Infeasible);
258 }
259 }
260 Ok(())
261 }
262}
263
264fn 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#[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 assert!(small_chart(100, -90.0).data_area().unwrap().0.y < 100);
476 }
477
478 #[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 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 #[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 #[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 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 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 #[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 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}