1use crate::config::{AxisOptions, BarAlign, ColorBarOptions, Config, LegendCorner, TickVisibility};
2use crate::text::{MeasureText, TextExtents};
3
4use super::{ChartArea, DataArea, RectF, Side};
5
6pub const LABEL_GAP: f32 = 4.0;
8
9pub const LEGEND_INSET: f32 = 6.0;
11
12pub fn axis_anchor(side: Side, data_area: &DataArea) -> ((f32, f32), (f32, f32)) {
15 let x0 = data_area.x as f32 + 0.5;
16 let y0 = data_area.y as f32 + 0.5;
17 let x1 = (data_area.x + data_area.width) as f32 - 0.5;
18 let y1 = (data_area.y + data_area.height) as f32 - 0.5;
19 match side {
20 Side::Top => ((x0, y0), (x1, y0)),
21 Side::Bottom => ((x0, y1), (x1, y1)),
22 Side::Left => ((x0, y0), (x0, y1)),
23 Side::Right => ((x1, y0), (x1, y1)),
24 }
25}
26
27pub fn axis_offset(side: Side, line_offset: f32) -> (f32, f32) {
29 match side {
30 Side::Left | Side::Right => (line_offset, 0.0),
31 Side::Top | Side::Bottom => (0.0, line_offset),
32 }
33}
34
35pub fn axis_visibility_extent(axis: &AxisOptions) -> (f32, f32) {
37 let tick = axis.major_tick_length;
38 let (inward, outward) = match axis.tick {
39 TickVisibility::None => (0.0, 0.0),
40 TickVisibility::Outside => (0.0, tick),
41 TickVisibility::Inside => (tick, 0.0),
42 TickVisibility::Both => (tick, tick),
43 };
44 let half_line = if axis.line_visible {
45 axis.line_width.max(1.0) * 0.5
46 } else {
47 0.0
48 };
49 (inward.max(half_line), outward.max(half_line))
50}
51
52pub fn axis_visibility_rect(side: Side, data_area: &DataArea, axis: &AxisOptions) -> RectF {
54 let (inward, outward) = axis_visibility_extent(axis);
55 let rect = match side.clone() {
56 Side::Top => RectF {
57 x: data_area.x as f32,
58 y: data_area.y as f32 - outward,
59 width: data_area.width as f32,
60 height: inward + outward,
61 },
62 Side::Bottom => RectF {
63 x: data_area.x as f32,
64 y: (data_area.y + data_area.height) as f32 - inward,
65 width: data_area.width as f32,
66 height: inward + outward,
67 },
68 Side::Left => RectF {
69 x: data_area.x as f32 - outward,
70 y: data_area.y as f32,
71 width: inward + outward,
72 height: data_area.height as f32,
73 },
74 Side::Right => RectF {
75 x: (data_area.x + data_area.width) as f32 - inward,
76 y: data_area.y as f32,
77 width: inward + outward,
78 height: data_area.height as f32,
79 },
80 };
81 let (dx, dy) = axis_offset(side, axis.line_offset);
82 rect.translated(dx, dy)
83}
84
85pub fn rect_axis_visibility_rect(side: Side, rect: &RectF, axis: &AxisOptions) -> RectF {
90 let (inward, outward) = axis_visibility_extent(axis);
91 let bounds = match side.clone() {
92 Side::Top => RectF {
93 x: rect.x,
94 y: rect.y - outward,
95 width: rect.width,
96 height: inward + outward,
97 },
98 Side::Bottom => RectF {
99 x: rect.x,
100 y: rect.y + rect.height - inward,
101 width: rect.width,
102 height: inward + outward,
103 },
104 Side::Left => RectF {
105 x: rect.x - outward,
106 y: rect.y,
107 width: inward + outward,
108 height: rect.height,
109 },
110 Side::Right => RectF {
111 x: rect.x + rect.width - inward,
112 y: rect.y,
113 width: inward + outward,
114 height: rect.height,
115 },
116 };
117 let (dx, dy) = axis_offset(side, axis.line_offset);
118 bounds.translated(dx, dy)
119}
120
121pub fn point_on_rect_side(t: f32, side: &Side, rect: &RectF) -> (f32, f32) {
126 match side {
127 Side::Top => (rect.x + t * rect.width, rect.y),
128 Side::Bottom => (rect.x + t * rect.width, rect.y + rect.height),
129 Side::Left => (rect.x, rect.y + rect.height - t * rect.height),
130 Side::Right => (rect.x + rect.width, rect.y + rect.height - t * rect.height),
131 }
132}
133
134pub fn label_origin(
136 side: Side,
137 tick_position: (f32, f32),
138 outward: f32,
139 offset: (f32, f32),
140 extents: TextExtents,
141) -> (f32, f32) {
142 let (x, y) = match side {
143 Side::Top => (
144 tick_position.0 - extents.width * 0.5,
145 tick_position.1 - outward - LABEL_GAP - extents.descent,
146 ),
147 Side::Bottom => (
148 tick_position.0 - extents.width * 0.5,
149 tick_position.1 + outward + LABEL_GAP + extents.ascent,
150 ),
151 Side::Left => (
152 tick_position.0 - outward - LABEL_GAP - extents.width,
153 tick_position.1 + (extents.ascent - extents.descent) * 0.5,
154 ),
155 Side::Right => (
156 tick_position.0 + outward + LABEL_GAP,
157 tick_position.1 + (extents.ascent - extents.descent) * 0.5,
158 ),
159 };
160 (x + offset.0, y + offset.1)
161}
162
163pub fn label_rect(origin: (f32, f32), extents: TextExtents) -> RectF {
165 RectF {
166 x: origin.0,
167 y: origin.1 - extents.ascent,
168 width: extents.width,
169 height: extents.height(),
170 }
171}
172
173#[derive(Debug, Clone, Copy, PartialEq)]
175pub struct TitlePlacement {
176 pub origin: (f32, f32),
177 pub rotation_center: (f32, f32),
178 pub rotation_degrees: f32,
179}
180
181impl TitlePlacement {
182 pub fn rect(self, extents: TextExtents) -> RectF {
184 let local = label_rect(self.origin, extents);
185 match self.rotation_degrees {
186 -90.0 => RectF {
187 x: self.rotation_center.0 + (local.y - self.rotation_center.1),
188 y: self.rotation_center.1 - (local.x + local.width - self.rotation_center.0),
189 width: local.height,
190 height: local.width,
191 },
192 90.0 => RectF {
193 x: self.rotation_center.0 - (local.y + local.height - self.rotation_center.1),
194 y: self.rotation_center.1 + (local.x - self.rotation_center.0),
195 width: local.height,
196 height: local.width,
197 },
198 _ => local,
199 }
200 }
201}
202
203pub fn chart_title_placement(
204 chart_area: &ChartArea,
205 top_margin: f32,
206 offset: (f32, f32),
207 extents: TextExtents,
208) -> TitlePlacement {
209 let origin = (
210 chart_area.x as f32 + chart_area.width as f32 * 0.5 - extents.width * 0.5 + offset.0,
211 chart_area.y as f32 + (top_margin - extents.height()) * 0.5 + extents.ascent + offset.1,
212 );
213 TitlePlacement {
214 origin,
215 rotation_center: origin,
216 rotation_degrees: 0.0,
217 }
218}
219
220fn chart_title_band_padding() -> f32 {
225 let defaults = crate::default::default_chart_title_options();
226 (defaults.top_margin - defaults.text.font_size).max(0.0)
227}
228
229pub fn fit_chart_title_margin(cfg: &mut Config, measure: &dyn MeasureText) -> bool {
250 let title = &cfg.chart_title;
251 if !title.visible || title.text.segments.is_empty() {
252 return false;
253 }
254 let height = measure.measure_rich(&title.text).height();
255 let current = title.top_margin;
256 if !height.is_finite() || current.is_nan() || height <= current {
257 return false;
258 }
259 let required = (height + chart_title_band_padding()).ceil();
260 let had_data_area = cfg.data_area().is_ok();
261 cfg.chart_title.top_margin = required;
262 if had_data_area && cfg.data_area().is_err() {
263 cfg.chart_title.top_margin = current;
264 return false;
265 }
266 true
267}
268
269#[derive(Debug, Clone, Copy, PartialEq)]
275pub struct TitleBand {
276 pub out_margin: f32,
278 pub chart_title_margin: f32,
281 pub edge_inset: f32,
288}
289
290pub fn axis_title_placement(
292 side: Side,
293 chart_area: &ChartArea,
294 data_area: &DataArea,
295 band: TitleBand,
296 offset: (f32, f32),
297 extents: TextExtents,
298) -> TitlePlacement {
299 let TitleBand {
300 out_margin,
301 chart_title_margin,
302 edge_inset,
303 } = band;
304 match side {
305 Side::Top | Side::Bottom => {
306 let band_top = if matches!(side, Side::Top) {
307 chart_area.y as f32 + chart_title_margin + edge_inset
308 } else {
309 (chart_area.y + chart_area.height) as f32 - out_margin - edge_inset
310 };
311 let origin = (
312 data_area.x as f32 + data_area.width as f32 * 0.5 - extents.width * 0.5 + offset.0,
313 band_top + (out_margin - extents.height()) * 0.5 + extents.ascent + offset.1,
314 );
315 TitlePlacement {
316 origin,
317 rotation_center: origin,
318 rotation_degrees: 0.0,
319 }
320 }
321 Side::Left | Side::Right => {
322 let center = (
323 if matches!(side, Side::Left) {
324 chart_area.x as f32 + edge_inset + out_margin * 0.5
325 } else {
326 (chart_area.x + chart_area.width) as f32 - edge_inset - out_margin * 0.5
327 },
328 data_area.y as f32 + data_area.height as f32 * 0.5,
329 );
330 TitlePlacement {
331 origin: (
332 center.0 - extents.width * 0.5 + offset.0,
333 center.1 + (extents.ascent - extents.descent) * 0.5 + offset.1,
334 ),
335 rotation_center: center,
336 rotation_degrees: if matches!(side, Side::Left) {
337 -90.0
338 } else {
339 90.0
340 },
341 }
342 }
343 }
344}
345
346pub fn colorbar_title_placement(
353 side: Side,
354 strip: &RectF,
355 axis: &AxisOptions,
356 offset: (f32, f32),
357 extents: TextExtents,
358) -> TitlePlacement {
359 let tick = axis.major_tick_length.max(0.0);
360 let out_margin = axis.out_margin.max(0.0);
361 match side {
362 Side::Top | Side::Bottom => {
363 let band_top = if matches!(side, Side::Top) {
364 strip.y - tick - out_margin
365 } else {
366 strip.y + strip.height + tick
367 };
368 let origin = (
369 strip.x + strip.width * 0.5 - extents.width * 0.5 + offset.0,
370 band_top + (out_margin - extents.height()) * 0.5 + extents.ascent + offset.1,
371 );
372 TitlePlacement {
373 origin,
374 rotation_center: origin,
375 rotation_degrees: 0.0,
376 }
377 }
378 Side::Left | Side::Right => {
379 let center = (
380 if matches!(side, Side::Left) {
381 strip.x - tick - out_margin * 0.5
382 } else {
383 strip.x + strip.width + tick + out_margin * 0.5
384 },
385 strip.y + strip.height * 0.5,
386 );
387 TitlePlacement {
388 origin: (
389 center.0 - extents.width * 0.5 + offset.0,
390 center.1 + (extents.ascent - extents.descent) * 0.5 + offset.1,
391 ),
392 rotation_center: center,
393 rotation_degrees: if matches!(side, Side::Left) {
394 -90.0
395 } else {
396 90.0
397 },
398 }
399 }
400 }
401}
402
403pub fn axis_title_offset_to_screen(side: Side, offset: (f32, f32)) -> (f32, f32) {
405 match side {
406 Side::Left => (offset.1, -offset.0),
407 Side::Right => (-offset.1, offset.0),
408 Side::Top | Side::Bottom => offset,
409 }
410}
411
412pub fn screen_offset_to_axis_title(side: Side, offset: (f32, f32)) -> (f32, f32) {
414 match side {
415 Side::Left => (-offset.1, offset.0),
416 Side::Right => (offset.1, -offset.0),
417 Side::Top | Side::Bottom => offset,
418 }
419}
420
421pub fn legend_rect(
422 data_area: &DataArea,
423 corner: LegendCorner,
424 padding: f32,
425 offset: (f32, f32),
426 extents: TextExtents,
427) -> RectF {
428 let width = extents.width + padding * 2.0;
429 let height = extents.height() + padding * 2.0;
430 let x = match corner {
431 LegendCorner::TopLeft | LegendCorner::BottomLeft => data_area.x as f32 + LEGEND_INSET,
432 LegendCorner::TopRight | LegendCorner::BottomRight => {
433 (data_area.x + data_area.width) as f32 - width - LEGEND_INSET
434 }
435 };
436 let y = match corner {
437 LegendCorner::TopLeft | LegendCorner::TopRight => data_area.y as f32 + LEGEND_INSET,
438 LegendCorner::BottomLeft | LegendCorner::BottomRight => {
439 (data_area.y + data_area.height) as f32 - height - LEGEND_INSET
440 }
441 };
442 RectF {
443 x: x + offset.0,
444 y: y + offset.1,
445 width,
446 height,
447 }
448}
449
450pub fn colorbar_rect(
479 chart_area: &ChartArea,
480 data_area: &DataArea,
481 chart_title_margin: f32,
482 bar: &ColorBarOptions,
483) -> RectF {
484 let frac = if bar.length_frac.is_finite() {
485 bar.length_frac.clamp(0.0, 1.0)
486 } else {
487 0.0
488 };
489 let thickness = bar.thickness_px.max(0.0);
490 let outer = bar.axis.out_margin.max(0.0) + bar.axis.major_tick_length.max(0.0) + thickness;
493 let (dx, dy, dw, dh) = (
494 data_area.x as f32,
495 data_area.y as f32,
496 data_area.width as f32,
497 data_area.height as f32,
498 );
499 let (cx, cy, cw, ch) = (
500 chart_area.x as f32,
501 chart_area.y as f32,
502 chart_area.width as f32,
503 chart_area.height as f32,
504 );
505
506 let vertical_length = dh * frac;
509 let horizontal_length = dw * frac;
510 let anchored = match bar.side {
511 Side::Left => RectF {
512 x: cx + outer - thickness,
513 y: dy + align_offset(dh, vertical_length, &bar.align),
514 width: thickness,
515 height: vertical_length,
516 },
517 Side::Right => RectF {
518 x: cx + cw - outer,
519 y: dy + align_offset(dh, vertical_length, &bar.align),
520 width: thickness,
521 height: vertical_length,
522 },
523 Side::Top => RectF {
527 x: dx + align_offset(dw, horizontal_length, &bar.align),
528 y: cy + chart_title_margin + outer - thickness,
529 width: horizontal_length,
530 height: thickness,
531 },
532 Side::Bottom => RectF {
533 x: dx + align_offset(dw, horizontal_length, &bar.align),
534 y: cy + ch - outer,
535 width: horizontal_length,
536 height: thickness,
537 },
538 };
539 anchored.translated(bar.offset_x, bar.offset_y)
540}
541
542fn align_offset(available: f32, length: f32, align: &BarAlign) -> f32 {
545 match align {
546 BarAlign::Start => 0.0,
547 BarAlign::Center => (available - length) * 0.5,
548 BarAlign::End => available - length,
549 }
550}
551
552#[cfg(test)]
553mod tests {
554 use super::*;
555 use crate::default::default_config;
556 use crate::layout::Rect;
557
558 fn areas() -> (ChartArea, DataArea) {
559 (
560 ChartArea(Rect {
561 x: 10,
562 y: 20,
563 width: 300,
564 height: 200,
565 }),
566 DataArea(Rect {
567 x: 50,
568 y: 60,
569 width: 200,
570 height: 100,
571 }),
572 )
573 }
574
575 fn band(out_margin: f32, chart_title_margin: f32, edge_inset: f32) -> TitleBand {
576 TitleBand {
577 out_margin,
578 chart_title_margin,
579 edge_inset,
580 }
581 }
582
583 fn extents() -> TextExtents {
584 TextExtents {
585 width: 20.0,
586 ascent: 7.0,
587 descent: 3.0,
588 }
589 }
590
591 #[test]
592 fn axis_anchors_and_visibility_preserve_half_pixels() {
593 let (_, data) = areas();
594 assert_eq!(axis_anchor(Side::Top, &data), ((50.5, 60.5), (249.5, 60.5)));
595 assert_eq!(
596 axis_anchor(Side::Right, &data),
597 ((249.5, 60.5), (249.5, 159.5))
598 );
599
600 let mut axis = default_config().left_y;
601 axis.tick = TickVisibility::Both;
602 axis.major_tick_length = 5.0;
603 axis.line_width = 1.0;
604 axis.line_offset = 2.0;
605 assert_eq!(axis_visibility_extent(&axis), (5.0, 5.0));
606 assert_eq!(
607 axis_visibility_rect(Side::Left, &data, &axis),
608 RectF {
609 x: 47.0,
610 y: 60.0,
611 width: 10.0,
612 height: 100.0,
613 }
614 );
615 }
616
617 #[test]
618 fn label_origin_and_rect_cover_all_sides_and_offsets() {
619 let tick = (100.0, 200.0);
620 let offset = (2.0, 4.0);
621 let m = extents();
622 let expected = [
623 (Side::Top, (92.0, 192.0)),
624 (Side::Bottom, (92.0, 220.0)),
625 (Side::Left, (73.0, 206.0)),
626 (Side::Right, (111.0, 206.0)),
627 ];
628 for (side, origin) in expected {
629 let actual = label_origin(side, tick, 5.0, offset, m);
630 assert_eq!(actual, origin);
631 assert_eq!(label_rect(actual, m).height, 10.0);
632 }
633 }
634
635 #[test]
636 fn title_placement_keeps_rotation_signs_and_offset_frames() {
637 let (chart, data) = areas();
638 let m = extents();
639 let left = axis_title_placement(
640 Side::Left,
641 &chart,
642 &data,
643 band(40.0, 32.0, 0.0),
644 (2.0, 3.0),
645 m,
646 );
647 let right = axis_title_placement(
648 Side::Right,
649 &chart,
650 &data,
651 band(40.0, 32.0, 0.0),
652 (2.0, 3.0),
653 m,
654 );
655 assert_eq!(left.rotation_degrees, -90.0);
656 assert_eq!(right.rotation_degrees, 90.0);
657 assert_eq!(
658 left.rect(m),
659 RectF {
660 x: 28.0,
661 y: 98.0,
662 width: 10.0,
663 height: 20.0
664 }
665 );
666 assert_eq!(
667 right.rect(m),
668 RectF {
669 x: 282.0,
670 y: 102.0,
671 width: 10.0,
672 height: 20.0
673 }
674 );
675
676 for side in [Side::Top, Side::Bottom, Side::Left, Side::Right] {
677 let local = (7.0, -4.0);
678 assert_eq!(
679 screen_offset_to_axis_title(side.clone(), axis_title_offset_to_screen(side, local)),
680 local
681 );
682 }
683 }
684
685 struct LineMeasure;
688 impl MeasureText for LineMeasure {
689 fn measure_rich(&self, rt: &crate::text::RichText) -> TextExtents {
690 let text: String = rt.segments.iter().map(|s| s.text).collect();
691 let lines = text.split('\n').count();
692 let widest = text.split('\n').map(|l| l.chars().count()).max();
693 TextExtents {
694 width: widest.unwrap_or(0) as f32 * 8.0,
695 ascent: 10.0,
696 descent: 3.0 + (lines - 1) as f32 * 13.0,
697 }
698 }
699 }
700
701 fn titled(text: &str) -> Config {
702 let mut cfg = default_config();
703 cfg.chart_title.text.segments = crate::text::rich_segments_from_text(text);
704 cfg
705 }
706
707 #[test]
708 fn the_title_band_padding_comes_from_the_defaults() {
709 let defaults = crate::default::default_chart_title_options();
710 assert_eq!(
711 chart_title_band_padding(),
712 defaults.top_margin - defaults.text.font_size
713 );
714 }
715
716 #[test]
720 fn fitting_grows_the_band_to_hold_a_multi_line_title() {
721 let mut cfg = titled("one\ntwo\nthree");
722 let m = LineMeasure.measure_rich(&cfg.chart_title.text);
723 assert!(m.height() > cfg.chart_title.top_margin);
724
725 assert!(fit_chart_title_margin(&mut cfg, &LineMeasure));
726 let band = cfg.chart_title.top_margin;
727 assert_eq!(band, (m.height() + chart_title_band_padding()).ceil());
728
729 let ca = &cfg.chart_area;
730 let rect = chart_title_placement(ca, band, (0.0, 0.0), m).rect(m);
731 assert!(rect.y >= ca.y as f32, "first line inside the chart area");
732 assert!(
733 rect.y + rect.height <= ca.y as f32 + band,
734 "block inside the band"
735 );
736 assert!(cfg.data_area().is_ok());
737
738 assert!(!fit_chart_title_margin(&mut cfg, &LineMeasure));
740 assert_eq!(cfg.chart_title.top_margin, band);
741 }
742
743 #[test]
744 fn fitting_leaves_a_single_default_line_and_a_larger_band_alone() {
745 let mut cfg = titled("Title");
746 assert!(!fit_chart_title_margin(&mut cfg, &LineMeasure));
747 assert_eq!(
748 cfg,
749 titled("Title"),
750 "the default band already fits one line"
751 );
752
753 let mut snug = titled("Title");
756 snug.chart_title.top_margin = 14.0;
757 assert!(LineMeasure.measure_rich(&snug.chart_title.text).height() <= 14.0);
758 assert!(!fit_chart_title_margin(&mut snug, &LineMeasure));
759 assert_eq!(snug.chart_title.top_margin, 14.0);
760
761 let mut roomy = titled("one\ntwo\nthree");
763 roomy.chart_title.top_margin = 120.0;
764 assert!(!fit_chart_title_margin(&mut roomy, &LineMeasure));
765 assert_eq!(roomy.chart_title.top_margin, 120.0);
766 }
767
768 #[test]
769 fn fitting_skips_hidden_empty_and_unusable_titles() {
770 let mut empty = default_config();
771 assert!(!fit_chart_title_margin(&mut empty, &LineMeasure));
772 assert_eq!(empty, default_config());
773
774 let mut hidden = titled("one\ntwo\nthree");
775 hidden.chart_title.visible = false;
776 assert!(!fit_chart_title_margin(&mut hidden, &LineMeasure));
777 assert_eq!(hidden.chart_title.top_margin, 32.0);
778
779 let mut nan = titled("one\ntwo\nthree");
781 nan.chart_title.top_margin = f32::NAN;
782 assert!(!fit_chart_title_margin(&mut nan, &LineMeasure));
783 assert!(nan.chart_title.top_margin.is_nan());
784
785 let mut short = titled(&["line"; 40].join("\n"));
788 short.chart_area.0.height = 300;
789 assert!(short.data_area().is_ok());
790 assert!(!fit_chart_title_margin(&mut short, &LineMeasure));
791 assert_eq!(short.chart_title.top_margin, 32.0);
792 assert!(short.data_area().is_ok());
793 }
794
795 #[test]
796 fn colorbar_title_tracks_the_actual_strip_on_every_side() {
797 let strip = RectF {
798 x: 70.0,
799 y: 80.0,
800 width: 40.0,
801 height: 100.0,
802 };
803 let moved = strip.translated(13.0, -9.0);
804 let axis = crate::default::default_axis_options_colorbar();
805 let m = extents();
806
807 for side in [Side::Top, Side::Bottom, Side::Left, Side::Right] {
808 let before =
809 colorbar_title_placement(side.clone(), &strip, &axis, (0.0, 0.0), m).rect(m);
810 let after = colorbar_title_placement(side, &moved, &axis, (0.0, 0.0), m).rect(m);
811 assert_eq!(after, before.translated(13.0, -9.0));
812 }
813
814 let right = colorbar_title_placement(Side::Right, &strip, &axis, (0.0, 0.0), m).rect(m);
815 assert_eq!(right.y + right.height * 0.5, strip.y + strip.height * 0.5);
816
817 let top = colorbar_title_placement(Side::Top, &strip, &axis, (0.0, 0.0), m).rect(m);
818 assert_eq!(top.x + top.width * 0.5, strip.x + strip.width * 0.5);
819 }
820
821 #[test]
822 fn rect_axis_visibility_uses_tick_direction_and_line_offset() {
823 let strip = RectF {
824 x: 10.0,
825 y: 20.0,
826 width: 40.0,
827 height: 100.0,
828 };
829 let mut axis = crate::default::default_axis_options_colorbar();
830 axis.line_visible = false;
831 axis.major_tick_length = 5.0;
832 axis.line_offset = 3.0;
833
834 axis.tick = TickVisibility::Outside;
835 assert_eq!(
836 rect_axis_visibility_rect(Side::Right, &strip, &axis),
837 RectF {
838 x: 53.0,
839 y: 20.0,
840 width: 5.0,
841 height: 100.0,
842 }
843 );
844
845 axis.tick = TickVisibility::Inside;
846 assert_eq!(
847 rect_axis_visibility_rect(Side::Right, &strip, &axis),
848 RectF {
849 x: 48.0,
850 y: 20.0,
851 width: 5.0,
852 height: 100.0,
853 }
854 );
855 }
856
857 #[test]
858 fn legend_rect_covers_all_corners_and_offsets() {
859 let (_, data) = areas();
860 let m = extents();
861 let expected = [
862 (LegendCorner::TopLeft, (57.0, 68.0)),
863 (LegendCorner::TopRight, (215.0, 68.0)),
864 (LegendCorner::BottomLeft, (57.0, 136.0)),
865 (LegendCorner::BottomRight, (215.0, 136.0)),
866 ];
867 for (corner, (x, y)) in expected {
868 let rect = legend_rect(&data, corner, 5.0, (1.0, 2.0), m);
869 assert_eq!(
870 rect,
871 RectF {
872 x,
873 y,
874 width: 30.0,
875 height: 20.0
876 }
877 );
878 }
879 }
880
881 fn colorbar_config(side: Side) -> (crate::config::Config, DataArea) {
886 let mut cfg = crate::default::default_config();
887 let mut bar = crate::default::default_colorbar_options();
888 bar.side = side;
889 bar.thickness_px = 20.0;
890 bar.gap_px = 10.0;
891 bar.length_frac = 0.5;
892 bar.align = BarAlign::Center;
893 cfg.colorbar = Some(bar);
894 let da = cfg.data_area().expect("data area");
895 (cfg, da)
896 }
897
898 fn rect_of(cfg: &crate::config::Config, da: &DataArea) -> RectF {
899 colorbar_rect(
900 &cfg.chart_area,
901 da,
902 cfg.chart_title.top_margin,
903 cfg.colorbar.as_ref().expect("colourbar"),
904 )
905 }
906
907 #[test]
912 fn the_colorbar_strip_sits_outside_the_axis_band_not_on_top_of_it() {
913 for side in [Side::Top, Side::Bottom, Side::Left, Side::Right] {
914 let (cfg, da) = colorbar_config(side.clone());
915 let bar = cfg.colorbar.as_ref().expect("colourbar");
916 let rect = rect_of(&cfg, &da);
917 let axis = crate::layout::axis_ref(&cfg, &side);
918 let axis_band = axis.out_margin + axis.major_tick_length;
919 let outer_band = bar.axis.out_margin + bar.axis.major_tick_length;
920 let ca = &cfg.chart_area;
921
922 let (from_data_area, to_chart_edge) = match side {
923 Side::Left => (da.x as f32 - (rect.x + rect.width), rect.x - ca.x as f32),
924 Side::Right => (
925 rect.x - (da.x + da.width) as f32,
926 (ca.x + ca.width) as f32 - (rect.x + rect.width),
927 ),
928 Side::Top => (
929 da.y as f32 - (rect.y + rect.height),
930 rect.y - (ca.y as f32 + cfg.chart_title.top_margin),
931 ),
932 Side::Bottom => (
933 rect.y - (da.y + da.height) as f32,
934 (ca.y + ca.height) as f32 - (rect.y + rect.height),
935 ),
936 };
937 assert!(
940 (from_data_area - (axis_band + bar.gap_px)).abs() <= 1.0,
941 "{side:?}: {from_data_area} px from the data area, expected axis band \
942 {axis_band} + gap {}",
943 bar.gap_px
944 );
945 assert!(
947 (to_chart_edge - outer_band).abs() <= 1.0,
948 "{side:?}: {to_chart_edge} px to the chart edge, expected {outer_band}"
949 );
950 }
951 }
952
953 #[test]
955 fn colorbar_orientation_and_length_follow_side_and_length_frac() {
956 let (cfg, da) = colorbar_config(Side::Right);
957 let vertical = rect_of(&cfg, &da);
958 assert_eq!(vertical.width, 20.0);
959 assert_eq!(vertical.height, da.height as f32 * 0.5);
960 assert_eq!(vertical.y, da.y as f32 + da.height as f32 * 0.25);
962
963 let (cfg, da) = colorbar_config(Side::Bottom);
964 let horizontal = rect_of(&cfg, &da);
965 assert_eq!(horizontal.height, 20.0);
966 assert_eq!(horizontal.width, da.width as f32 * 0.5);
967 assert_eq!(horizontal.x, da.x as f32 + da.width as f32 * 0.25);
968 }
969
970 #[test]
973 fn colorbar_align_places_the_strip_along_its_side() {
974 let (mut cfg, da) = colorbar_config(Side::Right);
975 let quarter = da.height as f32 * 0.5;
976
977 cfg.colorbar.as_mut().unwrap().align = BarAlign::Start;
978 assert_eq!(rect_of(&cfg, &da).y, da.y as f32);
979 cfg.colorbar.as_mut().unwrap().align = BarAlign::End;
980 assert_eq!(rect_of(&cfg, &da).y, (da.y + da.height) as f32 - quarter);
981
982 let (mut cfg, da) = colorbar_config(Side::Bottom);
983 let half = da.width as f32 * 0.5;
984 cfg.colorbar.as_mut().unwrap().align = BarAlign::Start;
985 assert_eq!(rect_of(&cfg, &da).x, da.x as f32);
986 cfg.colorbar.as_mut().unwrap().align = BarAlign::End;
987 assert_eq!(rect_of(&cfg, &da).x, (da.x + da.width) as f32 - half);
988 }
989
990 #[test]
992 fn a_full_length_colorbar_spans_its_side() {
993 for align in [BarAlign::Start, BarAlign::Center, BarAlign::End] {
994 let (mut cfg, da) = colorbar_config(Side::Right);
995 {
996 let bar = cfg.colorbar.as_mut().unwrap();
997 bar.length_frac = 1.0;
998 bar.align = align.clone();
999 }
1000 let rect = rect_of(&cfg, &da);
1001 assert_eq!(rect.y, da.y as f32, "{align:?}");
1002 assert_eq!(rect.height, da.height as f32, "{align:?}");
1003 }
1004 }
1005
1006 #[test]
1009 fn colorbar_rect_survives_values_validate_would_reject() {
1010 let (mut cfg, da) = colorbar_config(Side::Right);
1011 for frac in [0.0, -1.0, f32::NAN, 2.0] {
1012 cfg.colorbar.as_mut().unwrap().length_frac = frac;
1013 let rect = rect_of(&cfg, &da);
1014 assert!(
1015 rect.height >= 0.0,
1016 "frac {frac} gave height {}",
1017 rect.height
1018 );
1019 assert!(
1020 rect.height <= da.height as f32,
1021 "frac {frac} overran its side"
1022 );
1023 }
1024 cfg.colorbar.as_mut().unwrap().length_frac = 0.5;
1025 cfg.colorbar.as_mut().unwrap().thickness_px = -5.0;
1026 let rect = rect_of(&cfg, &da);
1027 assert_eq!(rect.width, 0.0);
1028 }
1029
1030 #[test]
1034 fn axis_title_placement_is_pushed_in_by_the_colorbar_band() {
1035 let (cfg, da) = colorbar_config(Side::Right);
1036 let colorbar_band = cfg.colorbar_band(&Side::Right);
1037 assert!(colorbar_band > 0.0);
1038
1039 let placement = |inset: f32| {
1040 axis_title_placement(
1041 Side::Right,
1042 &cfg.chart_area,
1043 &da,
1044 band(cfg.right_y.out_margin, cfg.chart_title.top_margin, inset),
1045 (0.0, 0.0),
1046 extents(),
1047 )
1048 };
1049 let with_band = placement(colorbar_band);
1050 let without = placement(0.0);
1051 assert!(
1052 (without.rotation_center.0 - with_band.rotation_center.0 - colorbar_band).abs() < 1e-3,
1053 "the inset must move the title inward by exactly the band"
1054 );
1055
1056 for side in [Side::Top, Side::Bottom, Side::Left] {
1058 assert_eq!(cfg.colorbar_band(&side), 0.0);
1059 }
1060 }
1061}