Skip to main content

model/layout/
geometry.rs

1use crate::config::{AxisOptions, BarAlign, ColorBarOptions, Config, LegendCorner, TickVisibility};
2use crate::text::{MeasureText, 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/// Room the default title band leaves around one default-size title line: the
221/// default `top_margin` minus that line's nominal height, its font size (the
222/// em box the renderer also gives a line without ink). Derived rather than
223/// restated, so fitted bands follow the defaults if they change.
224fn 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
229/// Grow `cfg.chart_title.top_margin` when the measured title is taller than
230/// its band.
231///
232/// [`chart_title_placement`] centres the title block in the band, so a block
233/// taller than the band starts above the chart area and its first line is
234/// clipped. Only in that case the band grows to the block's height plus the
235/// room the default band gives a single default-size line, rounded up to a
236/// whole pixel. A title that already fits is left exactly as it is: text is
237/// measured by its ink, so refitting every title would move the data area
238/// whenever the title's letters changed.
239///
240/// The band is never shrunk, so a margin the host made larger is kept.
241/// Nothing changes when the title is hidden or empty, when the measurement is
242/// not finite, when the margin is NaN (validation reports it), or when the
243/// taller band would leave no valid data area in a chart that had one.
244///
245/// This is the title helpers' half of the helper contract: `Chart::with_title`
246/// and the web example's `set_title` derive the band from the text they set.
247/// A `top_margin` injected directly through the SSoT is kept as given.
248/// Returns whether the band changed.
249pub 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/// The margin geometry an axis title is placed inside.
270///
271/// A struct rather than three loose `f32` parameters: they are all pixel
272/// lengths measured along the same direction, and a positional swap between
273/// them would move a title without failing to compile.
274#[derive(Debug, Clone, Copy, PartialEq)]
275pub struct TitleBand {
276    /// The axis' own outer margin — the band its title and tick labels share.
277    pub out_margin: f32,
278    /// The chart title band at the top of the chart area. Only `Side::Top` sits
279    /// below it; the other sides ignore this.
280    pub chart_title_margin: f32,
281    /// How far this side's axis band is pushed in from the chart-area edge by
282    /// whatever occupies the outside of that margin — today the colourbar band
283    /// ([`Config::colorbar_band`](crate::config::Config::colorbar_band)), zero on
284    /// the other three sides. Without it the placement assumes the axis band
285    /// touches the chart edge, and a title on the colourbar's side would be
286    /// drawn on top of the bar.
287    pub edge_inset: f32,
288}
289
290/// Where an axis title sits inside its side's margin.
291pub 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
346/// Place a colourbar title from the actual painted strip rectangle.
347///
348/// The title sits in `axis.out_margin`, immediately beyond the strip's major
349/// tick reach, and is centred along the strip's own long dimension. Therefore
350/// `length_frac`, `align`, `offset_{x,y}`, and resize all move the title through
351/// the same rectangle that the renderer paints and the hit map selects.
352pub 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
403/// Convert an axis-title local offset to its rotated screen-space offset.
404pub 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
412/// Convert a screen-space drag delta to an axis title's local offset frame.
413pub 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
450/// The colourbar strip's rectangle.
451///
452/// `side` alone decides the orientation: `Left` / `Right` give a vertical strip
453/// `thickness_px` wide, `Top` / `Bottom` a horizontal one `thickness_px` tall.
454/// The long dimension is `length_frac` of the data area's matching dimension,
455/// placed by `align`, so the bar lines up with the plot it describes. The
456/// strip's axis runs along its outer long edge — which is why its ends carry
457/// `axis.min` / `axis.max` rather than the data area's corners.
458///
459/// **The short dimension is measured inward from the chart-area edge, not
460/// outward from the data area.** The colourbar band is the *outermost* part of
461/// its side's margin: from the chart edge inward it is the strip's own label
462/// margin and tick, then the strip, then `gap_px`, and only then the chart
463/// axis' band next to the data area. Placing the strip immediately outside the
464/// data area instead would sit it on top of that axis' tick labels whenever
465/// they are visible on the same side — and axis labels are drawn from the data
466/// area outward, so they cannot move out of the way.
467///
468/// The two agree by construction: `gap_px` is what is left between the axis band
469/// and the strip precisely because `colorbar_parts` charged the same terms to
470/// that side's margin.
471///
472/// `offset_x` / `offset_y` shift the result and nothing else: they are
473/// margin-noncontributing, exactly like the legend's and the titles' offsets, so
474/// dragging the bar does not reflow the data area out from under the pointer.
475///
476/// Callers get a rect for a hidden bar too — the visibility decision belongs to
477/// the drawing code and to `colorbar_contribution`, not here.
478pub 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    // The strip's distance from the chart edge: its labels, then its ticks, then
491    // the strip itself.
492    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    // Written as one match over the four sides so a nested wildcard never stands
507    // in for a side.
508    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        // The chart title owns the very top of the chart area; the colourbar
524        // band is the outermost part of what is left, exactly as
525        // `top_total` sums them.
526        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
542/// Where a strip of `length` starts within `available`. `Start` is top / left,
543/// matching the screen direction the sides are named in.
544fn 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    /// 8 px per char on the widest line, 10 up / 3 down per line, 13 px line
686    /// pitch — the shape of the renderer's multi-line envelope.
687    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    // A three-line title (39 px) is taller than the default 32 px band, so
717    // centring it would start above the chart area. Fitting grows the band to
718    // the block plus the default padding, which the placement then centres.
719    #[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        // Already fitted: a second call is a no-op.
739        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        // A block that fits the band, even with less than the default padding
754        // around it, renders today without clipping and is left alone.
755        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        // Grow-only: a band the user enlarged is never shrunk to the text.
762        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        // A non-finite margin is left for validation to report.
780        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        // Growing would leave no data area in a chart that had one: the band
786        // stays as it was.
787        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    /// A config whose data area really is the chart area minus its margins, so
882    /// the band relationships the strip is placed by actually hold. The
883    /// synthetic `areas()` fixture above cannot: its data area is not derived
884    /// from any margin.
885    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    /// The band the strip sits in, taken apart. Every side must show the same
908    /// three-part structure outward from the data area: the chart axis' band,
909    /// then `gap_px`, then the strip — with the strip's own tick and label
910    /// margin between it and the chart-area edge.
911    #[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            // Between the data area and the strip: the axis' own band, then the
938            // gap. Nothing of the strip reaches into the axis' label space.
939            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            // Between the strip and the chart edge: the strip's tick and labels.
946            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    // Orientation, length, and alignment along the side.
954    #[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        // Centred along the data area.
961        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    // `align` places the strip along its side; `Start` is top / left in screen
971    // direction, matching how the sides are named.
972    #[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    // A full-length bar spans its side exactly, whatever the alignment.
991    #[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    // Nonsense dims produce a degenerate rect, not a panic and not a rect that
1007    // reaches back over the data area. `Config::validate` is what rejects them.
1008    #[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    // The axis title band is pushed in by whatever occupies the outside of its
1031    // side's margin. Without the inset a title on the colourbar's side would be
1032    // centred on top of the bar.
1033    #[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        // And the sides the bar is not on are unaffected, because their band is 0.
1057        for side in [Side::Top, Side::Bottom, Side::Left] {
1058            assert_eq!(cfg.colorbar_band(&side), 0.0);
1059        }
1060    }
1061}