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#[derive(Clone)]
30struct SideComponents {
31 out_margin: f32, chart_title: Option<f32>, major_tick_length: f32, colorbar_out_margin: Option<f32>,
37 colorbar_fixed: f32,
40}
41
42fn read_side(cfg: &Config, side: &Side) -> SideComponents {
43 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
70fn 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 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
106fn 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
120fn 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
129fn 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
142fn 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
153impl 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(¤t, *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(¤t, *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(¤t);
206 if side == target_side {
207 let updated = absorb_single(¤t, *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 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(¤t, 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#[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 #[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 #[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 #[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 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 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 #[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 #[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 #[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 #[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 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 let after = cfg.data_area().expect("data area");
557 assert_eq!(after.width, target.width);
558 }
559
560 #[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 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 #[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 #[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}