Skip to main content

model/layout/
geometry.rs

1use crate::config::{AxisOptions, BarAlign, ColorBarOptions, LegendCorner, TickVisibility};
2use crate::text::TextExtents;
3
4use super::{ChartArea, DataArea, RectF, Side};
5
6/// Minimum gap between a tick end and its label, in pixels.
7pub const LABEL_GAP: f32 = 4.0;
8
9/// Inset between a legend box and its data-area corner, in pixels.
10pub const LEGEND_INSET: f32 = 6.0;
11
12/// Axis-line endpoints on pixel centers. Top/left use the first inside pixel;
13/// bottom/right use the last inside pixel.
14pub 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
27/// Perpendicular screen translation for a detached axis.
28pub 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
35/// Visible reach on the inward and outward sides of an axis line.
36pub 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
52/// Axis line/tick interaction bounds, including detached-axis translation.
53pub 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
85/// Axis line/tick interaction bounds when the axis runs on one edge of an
86/// arbitrary rectangle. The colourbar uses this with its *painted strip*
87/// rectangle, so a shortened, aligned, or freely offset strip and its axis
88/// chrome cannot acquire different interaction geometry.
89pub 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
121/// Place a fraction on one side of a rectangle. Horizontal sides run
122/// left-to-right; vertical sides run bottom-to-top, matching numeric axes.
123/// The colourbar renderer, label hit geometry, and tests all use this one
124/// mapping rather than independently deciding which screen end is low.
125pub 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
134/// Baseline origin for one measured tick label.
135pub 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
163/// Pixel rectangle occupied by a label drawn at a baseline origin.
164pub 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/// Draw origin and optional rotation for a chart or axis title.
174#[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    /// Axis-aligned screen rectangle of the placed text envelope.
183    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
220/// The margin geometry an axis title is placed inside.
221///
222/// A struct rather than three loose `f32` parameters: they are all pixel
223/// lengths measured along the same direction, and a positional swap between
224/// them would move a title without failing to compile.
225#[derive(Debug, Clone, Copy, PartialEq)]
226pub struct TitleBand {
227    /// The axis' own outer margin — the band its title and tick labels share.
228    pub out_margin: f32,
229    /// The chart title band at the top of the chart area. Only `Side::Top` sits
230    /// below it; the other sides ignore this.
231    pub chart_title_margin: f32,
232    /// How far this side's axis band is pushed in from the chart-area edge by
233    /// whatever occupies the outside of that margin — today the colourbar band
234    /// ([`Config::colorbar_band`](crate::config::Config::colorbar_band)), zero on
235    /// the other three sides. Without it the placement assumes the axis band
236    /// touches the chart edge, and a title on the colourbar's side would be
237    /// drawn on top of the bar.
238    pub edge_inset: f32,
239}
240
241/// Where an axis title sits inside its side's margin.
242pub fn axis_title_placement(
243    side: Side,
244    chart_area: &ChartArea,
245    data_area: &DataArea,
246    band: TitleBand,
247    offset: (f32, f32),
248    extents: TextExtents,
249) -> TitlePlacement {
250    let TitleBand {
251        out_margin,
252        chart_title_margin,
253        edge_inset,
254    } = band;
255    match side {
256        Side::Top | Side::Bottom => {
257            let band_top = if matches!(side, Side::Top) {
258                chart_area.y as f32 + chart_title_margin + edge_inset
259            } else {
260                (chart_area.y + chart_area.height) as f32 - out_margin - edge_inset
261            };
262            let origin = (
263                data_area.x as f32 + data_area.width as f32 * 0.5 - extents.width * 0.5 + offset.0,
264                band_top + (out_margin - extents.height()) * 0.5 + extents.ascent + offset.1,
265            );
266            TitlePlacement {
267                origin,
268                rotation_center: origin,
269                rotation_degrees: 0.0,
270            }
271        }
272        Side::Left | Side::Right => {
273            let center = (
274                if matches!(side, Side::Left) {
275                    chart_area.x as f32 + edge_inset + out_margin * 0.5
276                } else {
277                    (chart_area.x + chart_area.width) as f32 - edge_inset - out_margin * 0.5
278                },
279                data_area.y as f32 + data_area.height as f32 * 0.5,
280            );
281            TitlePlacement {
282                origin: (
283                    center.0 - extents.width * 0.5 + offset.0,
284                    center.1 + (extents.ascent - extents.descent) * 0.5 + offset.1,
285                ),
286                rotation_center: center,
287                rotation_degrees: if matches!(side, Side::Left) {
288                    -90.0
289                } else {
290                    90.0
291                },
292            }
293        }
294    }
295}
296
297/// Place a colourbar title from the actual painted strip rectangle.
298///
299/// The title sits in `axis.out_margin`, immediately beyond the strip's major
300/// tick reach, and is centred along the strip's own long dimension. Therefore
301/// `length_frac`, `align`, `offset_{x,y}`, and resize all move the title through
302/// the same rectangle that the renderer paints and the hit map selects.
303pub fn colorbar_title_placement(
304    side: Side,
305    strip: &RectF,
306    axis: &AxisOptions,
307    offset: (f32, f32),
308    extents: TextExtents,
309) -> TitlePlacement {
310    let tick = axis.major_tick_length.max(0.0);
311    let out_margin = axis.out_margin.max(0.0);
312    match side {
313        Side::Top | Side::Bottom => {
314            let band_top = if matches!(side, Side::Top) {
315                strip.y - tick - out_margin
316            } else {
317                strip.y + strip.height + tick
318            };
319            let origin = (
320                strip.x + strip.width * 0.5 - extents.width * 0.5 + offset.0,
321                band_top + (out_margin - extents.height()) * 0.5 + extents.ascent + offset.1,
322            );
323            TitlePlacement {
324                origin,
325                rotation_center: origin,
326                rotation_degrees: 0.0,
327            }
328        }
329        Side::Left | Side::Right => {
330            let center = (
331                if matches!(side, Side::Left) {
332                    strip.x - tick - out_margin * 0.5
333                } else {
334                    strip.x + strip.width + tick + out_margin * 0.5
335                },
336                strip.y + strip.height * 0.5,
337            );
338            TitlePlacement {
339                origin: (
340                    center.0 - extents.width * 0.5 + offset.0,
341                    center.1 + (extents.ascent - extents.descent) * 0.5 + offset.1,
342                ),
343                rotation_center: center,
344                rotation_degrees: if matches!(side, Side::Left) {
345                    -90.0
346                } else {
347                    90.0
348                },
349            }
350        }
351    }
352}
353
354/// Convert an axis-title local offset to its rotated screen-space offset.
355pub fn axis_title_offset_to_screen(side: Side, offset: (f32, f32)) -> (f32, f32) {
356    match side {
357        Side::Left => (offset.1, -offset.0),
358        Side::Right => (-offset.1, offset.0),
359        Side::Top | Side::Bottom => offset,
360    }
361}
362
363/// Convert a screen-space drag delta to an axis title's local offset frame.
364pub fn screen_offset_to_axis_title(side: Side, offset: (f32, f32)) -> (f32, f32) {
365    match side {
366        Side::Left => (-offset.1, offset.0),
367        Side::Right => (offset.1, -offset.0),
368        Side::Top | Side::Bottom => offset,
369    }
370}
371
372pub fn legend_rect(
373    data_area: &DataArea,
374    corner: LegendCorner,
375    padding: f32,
376    offset: (f32, f32),
377    extents: TextExtents,
378) -> RectF {
379    let width = extents.width + padding * 2.0;
380    let height = extents.height() + padding * 2.0;
381    let x = match corner {
382        LegendCorner::TopLeft | LegendCorner::BottomLeft => data_area.x as f32 + LEGEND_INSET,
383        LegendCorner::TopRight | LegendCorner::BottomRight => {
384            (data_area.x + data_area.width) as f32 - width - LEGEND_INSET
385        }
386    };
387    let y = match corner {
388        LegendCorner::TopLeft | LegendCorner::TopRight => data_area.y as f32 + LEGEND_INSET,
389        LegendCorner::BottomLeft | LegendCorner::BottomRight => {
390            (data_area.y + data_area.height) as f32 - height - LEGEND_INSET
391        }
392    };
393    RectF {
394        x: x + offset.0,
395        y: y + offset.1,
396        width,
397        height,
398    }
399}
400
401/// The colourbar strip's rectangle.
402///
403/// `side` alone decides the orientation: `Left` / `Right` give a vertical strip
404/// `thickness_px` wide, `Top` / `Bottom` a horizontal one `thickness_px` tall.
405/// The long dimension is `length_frac` of the data area's matching dimension,
406/// placed by `align`, so the bar lines up with the plot it describes. The
407/// strip's axis runs along its outer long edge — which is why its ends carry
408/// `axis.min` / `axis.max` rather than the data area's corners.
409///
410/// **The short dimension is measured inward from the chart-area edge, not
411/// outward from the data area.** The colourbar band is the *outermost* part of
412/// its side's margin: from the chart edge inward it is the strip's own label
413/// margin and tick, then the strip, then `gap_px`, and only then the chart
414/// axis' band next to the data area. Placing the strip immediately outside the
415/// data area instead would sit it on top of that axis' tick labels whenever
416/// they are visible on the same side — and axis labels are drawn from the data
417/// area outward, so they cannot move out of the way.
418///
419/// The two agree by construction: `gap_px` is what is left between the axis band
420/// and the strip precisely because `colorbar_parts` charged the same terms to
421/// that side's margin.
422///
423/// `offset_x` / `offset_y` shift the result and nothing else: they are
424/// margin-noncontributing, exactly like the legend's and the titles' offsets, so
425/// dragging the bar does not reflow the data area out from under the pointer.
426///
427/// Callers get a rect for a hidden bar too — the visibility decision belongs to
428/// the drawing code and to `colorbar_contribution`, not here.
429pub fn colorbar_rect(
430    chart_area: &ChartArea,
431    data_area: &DataArea,
432    chart_title_margin: f32,
433    bar: &ColorBarOptions,
434) -> RectF {
435    let frac = if bar.length_frac.is_finite() {
436        bar.length_frac.clamp(0.0, 1.0)
437    } else {
438        0.0
439    };
440    let thickness = bar.thickness_px.max(0.0);
441    // The strip's distance from the chart edge: its labels, then its ticks, then
442    // the strip itself.
443    let outer = bar.axis.out_margin.max(0.0) + bar.axis.major_tick_length.max(0.0) + thickness;
444    let (dx, dy, dw, dh) = (
445        data_area.x as f32,
446        data_area.y as f32,
447        data_area.width as f32,
448        data_area.height as f32,
449    );
450    let (cx, cy, cw, ch) = (
451        chart_area.x as f32,
452        chart_area.y as f32,
453        chart_area.width as f32,
454        chart_area.height as f32,
455    );
456
457    // Written as one match over the four sides so a nested wildcard never stands
458    // in for a side.
459    let vertical_length = dh * frac;
460    let horizontal_length = dw * frac;
461    let anchored = match bar.side {
462        Side::Left => RectF {
463            x: cx + outer - thickness,
464            y: dy + align_offset(dh, vertical_length, &bar.align),
465            width: thickness,
466            height: vertical_length,
467        },
468        Side::Right => RectF {
469            x: cx + cw - outer,
470            y: dy + align_offset(dh, vertical_length, &bar.align),
471            width: thickness,
472            height: vertical_length,
473        },
474        // The chart title owns the very top of the chart area; the colourbar
475        // band is the outermost part of what is left, exactly as
476        // `top_total` sums them.
477        Side::Top => RectF {
478            x: dx + align_offset(dw, horizontal_length, &bar.align),
479            y: cy + chart_title_margin + outer - thickness,
480            width: horizontal_length,
481            height: thickness,
482        },
483        Side::Bottom => RectF {
484            x: dx + align_offset(dw, horizontal_length, &bar.align),
485            y: cy + ch - outer,
486            width: horizontal_length,
487            height: thickness,
488        },
489    };
490    anchored.translated(bar.offset_x, bar.offset_y)
491}
492
493/// Where a strip of `length` starts within `available`. `Start` is top / left,
494/// matching the screen direction the sides are named in.
495fn align_offset(available: f32, length: f32, align: &BarAlign) -> f32 {
496    match align {
497        BarAlign::Start => 0.0,
498        BarAlign::Center => (available - length) * 0.5,
499        BarAlign::End => available - length,
500    }
501}
502
503#[cfg(test)]
504mod tests {
505    use super::*;
506    use crate::default::default_config;
507    use crate::layout::Rect;
508
509    fn areas() -> (ChartArea, DataArea) {
510        (
511            ChartArea(Rect {
512                x: 10,
513                y: 20,
514                width: 300,
515                height: 200,
516            }),
517            DataArea(Rect {
518                x: 50,
519                y: 60,
520                width: 200,
521                height: 100,
522            }),
523        )
524    }
525
526    fn band(out_margin: f32, chart_title_margin: f32, edge_inset: f32) -> TitleBand {
527        TitleBand {
528            out_margin,
529            chart_title_margin,
530            edge_inset,
531        }
532    }
533
534    fn extents() -> TextExtents {
535        TextExtents {
536            width: 20.0,
537            ascent: 7.0,
538            descent: 3.0,
539        }
540    }
541
542    #[test]
543    fn axis_anchors_and_visibility_preserve_half_pixels() {
544        let (_, data) = areas();
545        assert_eq!(axis_anchor(Side::Top, &data), ((50.5, 60.5), (249.5, 60.5)));
546        assert_eq!(
547            axis_anchor(Side::Right, &data),
548            ((249.5, 60.5), (249.5, 159.5))
549        );
550
551        let mut axis = default_config().left_y;
552        axis.tick = TickVisibility::Both;
553        axis.major_tick_length = 5.0;
554        axis.line_width = 1.0;
555        axis.line_offset = 2.0;
556        assert_eq!(axis_visibility_extent(&axis), (5.0, 5.0));
557        assert_eq!(
558            axis_visibility_rect(Side::Left, &data, &axis),
559            RectF {
560                x: 47.0,
561                y: 60.0,
562                width: 10.0,
563                height: 100.0,
564            }
565        );
566    }
567
568    #[test]
569    fn label_origin_and_rect_cover_all_sides_and_offsets() {
570        let tick = (100.0, 200.0);
571        let offset = (2.0, 4.0);
572        let m = extents();
573        let expected = [
574            (Side::Top, (92.0, 192.0)),
575            (Side::Bottom, (92.0, 220.0)),
576            (Side::Left, (73.0, 206.0)),
577            (Side::Right, (111.0, 206.0)),
578        ];
579        for (side, origin) in expected {
580            let actual = label_origin(side, tick, 5.0, offset, m);
581            assert_eq!(actual, origin);
582            assert_eq!(label_rect(actual, m).height, 10.0);
583        }
584    }
585
586    #[test]
587    fn title_placement_keeps_rotation_signs_and_offset_frames() {
588        let (chart, data) = areas();
589        let m = extents();
590        let left = axis_title_placement(
591            Side::Left,
592            &chart,
593            &data,
594            band(40.0, 32.0, 0.0),
595            (2.0, 3.0),
596            m,
597        );
598        let right = axis_title_placement(
599            Side::Right,
600            &chart,
601            &data,
602            band(40.0, 32.0, 0.0),
603            (2.0, 3.0),
604            m,
605        );
606        assert_eq!(left.rotation_degrees, -90.0);
607        assert_eq!(right.rotation_degrees, 90.0);
608        assert_eq!(
609            left.rect(m),
610            RectF {
611                x: 28.0,
612                y: 98.0,
613                width: 10.0,
614                height: 20.0
615            }
616        );
617        assert_eq!(
618            right.rect(m),
619            RectF {
620                x: 282.0,
621                y: 102.0,
622                width: 10.0,
623                height: 20.0
624            }
625        );
626
627        for side in [Side::Top, Side::Bottom, Side::Left, Side::Right] {
628            let local = (7.0, -4.0);
629            assert_eq!(
630                screen_offset_to_axis_title(side.clone(), axis_title_offset_to_screen(side, local)),
631                local
632            );
633        }
634    }
635
636    #[test]
637    fn colorbar_title_tracks_the_actual_strip_on_every_side() {
638        let strip = RectF {
639            x: 70.0,
640            y: 80.0,
641            width: 40.0,
642            height: 100.0,
643        };
644        let moved = strip.translated(13.0, -9.0);
645        let axis = crate::default::default_axis_options_colorbar();
646        let m = extents();
647
648        for side in [Side::Top, Side::Bottom, Side::Left, Side::Right] {
649            let before =
650                colorbar_title_placement(side.clone(), &strip, &axis, (0.0, 0.0), m).rect(m);
651            let after = colorbar_title_placement(side, &moved, &axis, (0.0, 0.0), m).rect(m);
652            assert_eq!(after, before.translated(13.0, -9.0));
653        }
654
655        let right = colorbar_title_placement(Side::Right, &strip, &axis, (0.0, 0.0), m).rect(m);
656        assert_eq!(right.y + right.height * 0.5, strip.y + strip.height * 0.5);
657
658        let top = colorbar_title_placement(Side::Top, &strip, &axis, (0.0, 0.0), m).rect(m);
659        assert_eq!(top.x + top.width * 0.5, strip.x + strip.width * 0.5);
660    }
661
662    #[test]
663    fn rect_axis_visibility_uses_tick_direction_and_line_offset() {
664        let strip = RectF {
665            x: 10.0,
666            y: 20.0,
667            width: 40.0,
668            height: 100.0,
669        };
670        let mut axis = crate::default::default_axis_options_colorbar();
671        axis.line_visible = false;
672        axis.major_tick_length = 5.0;
673        axis.line_offset = 3.0;
674
675        axis.tick = TickVisibility::Outside;
676        assert_eq!(
677            rect_axis_visibility_rect(Side::Right, &strip, &axis),
678            RectF {
679                x: 53.0,
680                y: 20.0,
681                width: 5.0,
682                height: 100.0,
683            }
684        );
685
686        axis.tick = TickVisibility::Inside;
687        assert_eq!(
688            rect_axis_visibility_rect(Side::Right, &strip, &axis),
689            RectF {
690                x: 48.0,
691                y: 20.0,
692                width: 5.0,
693                height: 100.0,
694            }
695        );
696    }
697
698    #[test]
699    fn legend_rect_covers_all_corners_and_offsets() {
700        let (_, data) = areas();
701        let m = extents();
702        let expected = [
703            (LegendCorner::TopLeft, (57.0, 68.0)),
704            (LegendCorner::TopRight, (215.0, 68.0)),
705            (LegendCorner::BottomLeft, (57.0, 136.0)),
706            (LegendCorner::BottomRight, (215.0, 136.0)),
707        ];
708        for (corner, (x, y)) in expected {
709            let rect = legend_rect(&data, corner, 5.0, (1.0, 2.0), m);
710            assert_eq!(
711                rect,
712                RectF {
713                    x,
714                    y,
715                    width: 30.0,
716                    height: 20.0
717                }
718            );
719        }
720    }
721
722    /// A config whose data area really is the chart area minus its margins, so
723    /// the band relationships the strip is placed by actually hold. The
724    /// synthetic `areas()` fixture above cannot: its data area is not derived
725    /// from any margin.
726    fn colorbar_config(side: Side) -> (crate::config::Config, DataArea) {
727        let mut cfg = crate::default::default_config();
728        let mut bar = crate::default::default_colorbar_options();
729        bar.side = side;
730        bar.thickness_px = 20.0;
731        bar.gap_px = 10.0;
732        bar.length_frac = 0.5;
733        bar.align = BarAlign::Center;
734        cfg.colorbar = Some(bar);
735        let da = cfg.data_area().expect("data area");
736        (cfg, da)
737    }
738
739    fn rect_of(cfg: &crate::config::Config, da: &DataArea) -> RectF {
740        colorbar_rect(
741            &cfg.chart_area,
742            da,
743            cfg.chart_title.top_margin,
744            cfg.colorbar.as_ref().expect("colourbar"),
745        )
746    }
747
748    /// The band the strip sits in, taken apart. Every side must show the same
749    /// three-part structure outward from the data area: the chart axis' band,
750    /// then `gap_px`, then the strip — with the strip's own tick and label
751    /// margin between it and the chart-area edge.
752    #[test]
753    fn the_colorbar_strip_sits_outside_the_axis_band_not_on_top_of_it() {
754        for side in [Side::Top, Side::Bottom, Side::Left, Side::Right] {
755            let (cfg, da) = colorbar_config(side.clone());
756            let bar = cfg.colorbar.as_ref().expect("colourbar");
757            let rect = rect_of(&cfg, &da);
758            let axis = crate::layout::axis_ref(&cfg, &side);
759            let axis_band = axis.out_margin + axis.major_tick_length;
760            let outer_band = bar.axis.out_margin + bar.axis.major_tick_length;
761            let ca = &cfg.chart_area;
762
763            let (from_data_area, to_chart_edge) = match side {
764                Side::Left => (da.x as f32 - (rect.x + rect.width), rect.x - ca.x as f32),
765                Side::Right => (
766                    rect.x - (da.x + da.width) as f32,
767                    (ca.x + ca.width) as f32 - (rect.x + rect.width),
768                ),
769                Side::Top => (
770                    da.y as f32 - (rect.y + rect.height),
771                    rect.y - (ca.y as f32 + cfg.chart_title.top_margin),
772                ),
773                Side::Bottom => (
774                    rect.y - (da.y + da.height) as f32,
775                    (ca.y + ca.height) as f32 - (rect.y + rect.height),
776                ),
777            };
778            // Between the data area and the strip: the axis' own band, then the
779            // gap. Nothing of the strip reaches into the axis' label space.
780            assert!(
781                (from_data_area - (axis_band + bar.gap_px)).abs() <= 1.0,
782                "{side:?}: {from_data_area} px from the data area, expected axis band \
783                 {axis_band} + gap {}",
784                bar.gap_px
785            );
786            // Between the strip and the chart edge: the strip's tick and labels.
787            assert!(
788                (to_chart_edge - outer_band).abs() <= 1.0,
789                "{side:?}: {to_chart_edge} px to the chart edge, expected {outer_band}"
790            );
791        }
792    }
793
794    // Orientation, length, and alignment along the side.
795    #[test]
796    fn colorbar_orientation_and_length_follow_side_and_length_frac() {
797        let (cfg, da) = colorbar_config(Side::Right);
798        let vertical = rect_of(&cfg, &da);
799        assert_eq!(vertical.width, 20.0);
800        assert_eq!(vertical.height, da.height as f32 * 0.5);
801        // Centred along the data area.
802        assert_eq!(vertical.y, da.y as f32 + da.height as f32 * 0.25);
803
804        let (cfg, da) = colorbar_config(Side::Bottom);
805        let horizontal = rect_of(&cfg, &da);
806        assert_eq!(horizontal.height, 20.0);
807        assert_eq!(horizontal.width, da.width as f32 * 0.5);
808        assert_eq!(horizontal.x, da.x as f32 + da.width as f32 * 0.25);
809    }
810
811    // `align` places the strip along its side; `Start` is top / left in screen
812    // direction, matching how the sides are named.
813    #[test]
814    fn colorbar_align_places_the_strip_along_its_side() {
815        let (mut cfg, da) = colorbar_config(Side::Right);
816        let quarter = da.height as f32 * 0.5;
817
818        cfg.colorbar.as_mut().unwrap().align = BarAlign::Start;
819        assert_eq!(rect_of(&cfg, &da).y, da.y as f32);
820        cfg.colorbar.as_mut().unwrap().align = BarAlign::End;
821        assert_eq!(rect_of(&cfg, &da).y, (da.y + da.height) as f32 - quarter);
822
823        let (mut cfg, da) = colorbar_config(Side::Bottom);
824        let half = da.width as f32 * 0.5;
825        cfg.colorbar.as_mut().unwrap().align = BarAlign::Start;
826        assert_eq!(rect_of(&cfg, &da).x, da.x as f32);
827        cfg.colorbar.as_mut().unwrap().align = BarAlign::End;
828        assert_eq!(rect_of(&cfg, &da).x, (da.x + da.width) as f32 - half);
829    }
830
831    // A full-length bar spans its side exactly, whatever the alignment.
832    #[test]
833    fn a_full_length_colorbar_spans_its_side() {
834        for align in [BarAlign::Start, BarAlign::Center, BarAlign::End] {
835            let (mut cfg, da) = colorbar_config(Side::Right);
836            {
837                let bar = cfg.colorbar.as_mut().unwrap();
838                bar.length_frac = 1.0;
839                bar.align = align.clone();
840            }
841            let rect = rect_of(&cfg, &da);
842            assert_eq!(rect.y, da.y as f32, "{align:?}");
843            assert_eq!(rect.height, da.height as f32, "{align:?}");
844        }
845    }
846
847    // Nonsense dims produce a degenerate rect, not a panic and not a rect that
848    // reaches back over the data area. `Config::validate` is what rejects them.
849    #[test]
850    fn colorbar_rect_survives_values_validate_would_reject() {
851        let (mut cfg, da) = colorbar_config(Side::Right);
852        for frac in [0.0, -1.0, f32::NAN, 2.0] {
853            cfg.colorbar.as_mut().unwrap().length_frac = frac;
854            let rect = rect_of(&cfg, &da);
855            assert!(
856                rect.height >= 0.0,
857                "frac {frac} gave height {}",
858                rect.height
859            );
860            assert!(
861                rect.height <= da.height as f32,
862                "frac {frac} overran its side"
863            );
864        }
865        cfg.colorbar.as_mut().unwrap().length_frac = 0.5;
866        cfg.colorbar.as_mut().unwrap().thickness_px = -5.0;
867        let rect = rect_of(&cfg, &da);
868        assert_eq!(rect.width, 0.0);
869    }
870
871    // The axis title band is pushed in by whatever occupies the outside of its
872    // side's margin. Without the inset a title on the colourbar's side would be
873    // centred on top of the bar.
874    #[test]
875    fn axis_title_placement_is_pushed_in_by_the_colorbar_band() {
876        let (cfg, da) = colorbar_config(Side::Right);
877        let colorbar_band = cfg.colorbar_band(&Side::Right);
878        assert!(colorbar_band > 0.0);
879
880        let placement = |inset: f32| {
881            axis_title_placement(
882                Side::Right,
883                &cfg.chart_area,
884                &da,
885                band(cfg.right_y.out_margin, cfg.chart_title.top_margin, inset),
886                (0.0, 0.0),
887                extents(),
888            )
889        };
890        let with_band = placement(colorbar_band);
891        let without = placement(0.0);
892        assert!(
893            (without.rotation_center.0 - with_band.rotation_center.0 - colorbar_band).abs() < 1e-3,
894            "the inset must move the title inward by exactly the band"
895        );
896
897        // And the sides the bar is not on are unaffected, because their band is 0.
898        for side in [Side::Top, Side::Bottom, Side::Left] {
899            assert_eq!(cfg.colorbar_band(&side), 0.0);
900        }
901    }
902}