Skip to main content

ggplot_rs/guide/
axis.rs

1use crate::coord::Coord;
2use crate::render::backend::{DrawBackend, LineStyle, TextAnchor, TextStyle};
3use crate::render::text::{axis_label_priority, label_bbox};
4use crate::render::{Rect, RenderError};
5use crate::scale::Scale;
6use crate::theme::{AxisLabelFit, Theme};
7
8/// Horizontal pixel for a break at normalized `pos` along the *horizontal* axis.
9/// `coord_flip` swaps which normalized component drives horizontal position, so
10/// route through the coord with the component that lands on x either way.
11fn along_x(coord: &dyn Coord, pos: f64, area: &Rect) -> f64 {
12    let p = if coord.is_flipped() {
13        (0.0, pos)
14    } else {
15        (pos, 0.0)
16    };
17    coord.transform(p, area).0
18}
19
20/// Vertical pixel for a break at normalized `pos` along the *vertical* axis.
21fn along_y(coord: &dyn Coord, pos: f64, area: &Rect) -> f64 {
22    let p = if coord.is_flipped() {
23        (pos, 0.0)
24    } else {
25        (0.0, pos)
26    };
27    coord.transform(p, area).1
28}
29
30/// Draw the X axis: ticks, labels, and title.
31///
32/// `canvas` is the full SVG viewport (not just the panel): B3-1's outer-
33/// label fit drops a label whose tick has scrolled off it, and shifts a
34/// label bbox that crosses it back inside, unless
35/// `theme.axis_text_x_fit` is [`AxisLabelFit::Clip`] (the 0.21 behaviour —
36/// no correction). Ticks and gridlines are drawn regardless.
37pub fn draw_x_axis(
38    scale: &dyn Scale,
39    coord: &dyn Coord,
40    theme: &Theme,
41    plot_area: &Rect,
42    canvas: &Rect,
43    backend: &mut dyn DrawBackend,
44) -> Result<(), RenderError> {
45    let breaks = scale.breaks();
46    let tick_len = theme.axis_ticks_length;
47    let axis_line = theme.get_axis_line_x();
48    let axis_ticks = theme.get_axis_ticks_x();
49
50    // Axis position: bottom (default) or top. `dir` points away from the panel.
51    let top = scale.axis_position_opposite();
52    let edge_y = if top {
53        plot_area.y
54    } else {
55        plot_area.y + plot_area.height
56    };
57    let dir = if top { -1.0 } else { 1.0 };
58
59    // Axis line
60    if axis_line.visible {
61        backend.draw_line(
62            &[
63                (plot_area.x, edge_y),
64                (plot_area.x + plot_area.width, edge_y),
65            ],
66            &LineStyle {
67                color: axis_line.color,
68                width: axis_line.width,
69                alpha: 1.0,
70                linetype: axis_line.linetype,
71                ..Default::default()
72            },
73        )?;
74    }
75
76    // Precompute, per break, whether/where its label is drawn — but keep a
77    // single loop below in the *original* per-break (tick, label) order, so
78    // a plot whose labels all fit and don't overlap renders byte-identical
79    // SVG to 0.21 (same element order), even though the decision logic ran.
80    let label_draw: Vec<Option<f64>> = if theme.axis_text_x.visible {
81        let angle = theme.axis_text_x.angle;
82        let rotated = angle.abs() > 10.0;
83        let anchor_frac = if rotated {
84            crate::render::text::rotated_anchor_frac(angle, dir)
85        } else {
86            0.5
87        };
88        let fit = theme.axis_text_x_fit;
89        let canvas_lo = canvas.x;
90        let canvas_hi = canvas.x + canvas.width;
91        const EDGE: f64 = 1.0;
92        let size = theme.axis_text_x.size;
93
94        // (index, px, label) for breaks whose tick is on-canvas. A label
95        // whose tick has scrolled off the canvas names nothing that is
96        // shown — unconditional, even under `Clip`.
97        let onscreen: Vec<(usize, f64, &str)> = breaks
98            .iter()
99            .enumerate()
100            .map(|(i, (pos, label))| (i, along_x(coord, *pos, plot_area), label.as_str()))
101            .filter(|(_, px, _)| (canvas_lo..=canvas_hi).contains(px))
102            .collect();
103
104        // ggplot2's `check.overlap`: draw in priority order (first, last,
105        // then recursive midpoint bisection — D5), skipping any label that
106        // would overlap one already kept.
107        let mut keep = vec![true; onscreen.len()];
108        if theme.axis_text_x_check_overlap && onscreen.len() > 1 {
109            let n = onscreen.len();
110            let mut kept: Vec<(f64, f64, f64)> = Vec::with_capacity(n); // (x0, x2, px)
111            keep = vec![false; n];
112            for p in axis_label_priority(n) {
113                let idx = p - 1; // 1-indexed in the ggplot2 sense
114                let (_, px, label) = onscreen[idx];
115                let bbox = label_bbox(label, size, px, 0.0, angle, anchor_frac);
116                let overlaps = kept.iter().any(|&(kx0, kx2, kpx)| {
117                    if !rotated {
118                        bbox[0] < kx2 && bbox[2] > kx0
119                    } else {
120                        // Parallel rotated labels on one baseline: the
121                        // perpendicular distance across the text direction
122                        // must leave room for a line of text.
123                        (px - kpx).abs() * angle.to_radians().sin().abs() < size
124                    }
125                });
126                if !overlaps {
127                    keep[idx] = true;
128                    kept.push((bbox[0], bbox[2], px));
129                }
130            }
131        }
132
133        let mut draw = vec![None; breaks.len()];
134        for (k, &(i, px, label)) in onscreen.iter().enumerate() {
135            if !keep[k] {
136                continue;
137            }
138            // Shift a bbox that crosses the canvas edge back inside (moving
139            // the rotate centre too), unless `fit` is `Clip` (0.21 behaviour).
140            let lx = if fit == AxisLabelFit::Clip {
141                px
142            } else {
143                // `ly` doesn't affect the x-bbox; 0.0 is a placeholder.
144                let bbox = label_bbox(label, size, px, 0.0, angle, anchor_frac);
145                let room_hi = canvas_hi - EDGE;
146                let room_lo = canvas_lo + EDGE;
147                if bbox[0] < room_lo {
148                    px + (room_lo - bbox[0])
149                } else if bbox[2] > room_hi {
150                    px - (bbox[2] - room_hi).min(bbox[0] - room_lo)
151                } else {
152                    px
153                }
154            };
155            draw[i] = Some(lx);
156        }
157        draw
158    } else {
159        Vec::new()
160    };
161
162    // Ticks and labels, interleaved in break order (ticks always stay).
163    let dodge = theme.axis_text_x_dodge.max(1);
164    let family = if theme.axis_text_x.family.is_empty() {
165        None
166    } else {
167        Some(theme.axis_text_x.family.clone())
168    };
169    let rotated = theme.axis_text_x.angle.abs() > 10.0;
170    // Which end of the text pins to the tick, so the near-axis end stays
171    // there instead of the whole label sliding away (sipemu/ggplot-rs#163)
172    // — depends on both the rotation's direction and which side of the
173    // axis line the panel is on (`dir`); see `rotated_anchor_frac`.
174    let draw_anchor_frac = if rotated {
175        crate::render::text::rotated_anchor_frac(theme.axis_text_x.angle, dir)
176    } else {
177        0.5
178    };
179    let anchor = if !rotated {
180        TextAnchor::Middle
181    } else if draw_anchor_frac >= 1.0 {
182        TextAnchor::End
183    } else {
184        TextAnchor::Start
185    };
186    // A rotated label's far end swings back *toward* the panel on one side
187    // and away from it on the other (sipemu/ggplot-rs#162) — toward the
188    // panel is `rise` for a bottom axis (`dir > 0`, the panel sits above
189    // the axis line) and `fall` for a top axis (`dir < 0`, the panel sits
190    // below it). Reserving only `rotated_label_rise_fall`'s sum (as
191    // `layout_built` does, for the *other* side's on-canvas extent) isn't
192    // enough on its own: this offset must actually place the rotation
193    // anchor that far from the axis line, or a long enough label swings
194    // right back over the panel no matter how much height was reserved.
195    let rotation_margin = if rotated {
196        let (rise, fall) = crate::render::text::rotated_label_rise_fall(
197            breaks
198                .iter()
199                .filter(|(_, l)| !l.is_empty())
200                .map(|(_, l)| l.as_str()),
201            theme.axis_text_x.size,
202            theme.axis_text_x.angle,
203            draw_anchor_frac,
204        );
205        if dir > 0.0 {
206            rise
207        } else {
208            fall
209        }
210    } else {
211        0.0
212    };
213    for (i, (pos, label)) in breaks.iter().enumerate() {
214        let px = along_x(coord, *pos, plot_area);
215
216        if axis_ticks.visible {
217            backend.draw_line(
218                &[(px, edge_y), (px, edge_y + dir * tick_len)],
219                &LineStyle {
220                    color: axis_ticks.color,
221                    width: axis_ticks.width,
222                    alpha: 1.0,
223                    linetype: axis_ticks.linetype,
224                    ..Default::default()
225                },
226            )?;
227        }
228
229        if let Some(Some(lx)) = label_draw.get(i) {
230            // Stagger labels across `dodge` rows to avoid overlap.
231            let row_offset = (i % dodge) as f64 * (theme.axis_text_x.size + 2.0);
232            let ly = edge_y
233                + dir
234                    * (tick_len
235                        + theme.legend_spacing / 2.0
236                        + theme.axis_text_x.size / 2.0
237                        + row_offset
238                        + rotation_margin);
239            backend.draw_text(
240                label,
241                (*lx, ly),
242                &TextStyle {
243                    color: theme.axis_text_x.color,
244                    size: theme.axis_text_x.size,
245                    anchor: anchor.clone(),
246                    angle: theme.axis_text_x.angle,
247                    family: family.clone(),
248                    face: theme.axis_text_x.face,
249                },
250            )?;
251        }
252    }
253
254    // Minor ticks between majors.
255    if theme.axis_minor_ticks && axis_ticks.visible {
256        for pos in minor_breaks(&breaks) {
257            let px = along_x(coord, pos, plot_area);
258            backend.draw_line(
259                &[(px, edge_y), (px, edge_y + dir * tick_len * 0.5)],
260                &LineStyle {
261                    color: axis_ticks.color,
262                    width: axis_ticks.width,
263                    alpha: 1.0,
264                    linetype: axis_ticks.linetype,
265                    ..Default::default()
266                },
267            )?;
268        }
269    }
270
271    // Axis title
272    let title = scale.name();
273    if !title.is_empty() && theme.axis_title_x.visible {
274        let hjust = theme.axis_title_x.hjust.clamp(0.0, 1.0);
275        let center_x = plot_area.x + hjust * plot_area.width;
276        let anchor = if hjust <= 0.02 {
277            TextAnchor::Start
278        } else if hjust >= 0.98 {
279            TextAnchor::End
280        } else {
281            TextAnchor::Middle
282        };
283        let title_y = edge_y
284            + dir * (tick_len + theme.axis_text_x.size + 8.0 + theme.axis_title_x.size / 2.0);
285        let family = if theme.axis_title_x.family.is_empty() {
286            None
287        } else {
288            Some(theme.axis_title_x.family.clone())
289        };
290        backend.draw_text(
291            title,
292            (center_x, title_y),
293            &TextStyle {
294                color: theme.axis_title_x.color,
295                size: theme.axis_title_x.size,
296                anchor,
297                angle: 0.0,
298                family,
299                face: theme.axis_title_x.face,
300            },
301        )?;
302    }
303
304    Ok(())
305}
306
307/// Draw the Y axis: ticks, labels, and title.
308pub fn draw_y_axis(
309    scale: &dyn Scale,
310    coord: &dyn Coord,
311    theme: &Theme,
312    plot_area: &Rect,
313    backend: &mut dyn DrawBackend,
314) -> Result<(), RenderError> {
315    let breaks = scale.breaks();
316    let tick_len = theme.axis_ticks_length;
317    let axis_line = theme.get_axis_line_y();
318    let axis_ticks = theme.get_axis_ticks_y();
319
320    // Axis line
321    if axis_line.visible {
322        let top = (plot_area.x, plot_area.y);
323        let bottom = (plot_area.x, plot_area.y + plot_area.height);
324        backend.draw_line(
325            &[top, bottom],
326            &LineStyle {
327                color: axis_line.color,
328                width: axis_line.width,
329                alpha: 1.0,
330                linetype: axis_line.linetype,
331                ..Default::default()
332            },
333        )?;
334    }
335
336    // Ticks and labels
337    for (pos, label) in &breaks {
338        let py = along_y(coord, *pos, plot_area);
339
340        if axis_ticks.visible {
341            backend.draw_line(
342                &[(plot_area.x - tick_len, py), (plot_area.x, py)],
343                &LineStyle {
344                    color: axis_ticks.color,
345                    width: axis_ticks.width,
346                    alpha: 1.0,
347                    linetype: axis_ticks.linetype,
348                    ..Default::default()
349                },
350            )?;
351        }
352
353        if theme.axis_text_y.visible {
354            let family = if theme.axis_text_y.family.is_empty() {
355                None
356            } else {
357                Some(theme.axis_text_y.family.clone())
358            };
359            backend.draw_text(
360                label,
361                (plot_area.x - tick_len - theme.legend_spacing, py),
362                &TextStyle {
363                    color: theme.axis_text_y.color,
364                    size: theme.axis_text_y.size,
365                    anchor: TextAnchor::End,
366                    angle: theme.axis_text_y.angle,
367                    family,
368                    face: theme.axis_text_y.face,
369                },
370            )?;
371        }
372    }
373
374    // Minor ticks between majors.
375    if theme.axis_minor_ticks && axis_ticks.visible {
376        for pos in minor_breaks(&breaks) {
377            let py = along_y(coord, pos, plot_area);
378            backend.draw_line(
379                &[(plot_area.x - tick_len * 0.5, py), (plot_area.x, py)],
380                &LineStyle {
381                    color: axis_ticks.color,
382                    width: axis_ticks.width,
383                    alpha: 1.0,
384                    linetype: axis_ticks.linetype,
385                    ..Default::default()
386                },
387            )?;
388        }
389    }
390
391    // Axis title
392    let title = scale.name();
393    if !title.is_empty() && theme.axis_title_y.visible {
394        let title_x = plot_area.x - tick_len - theme.axis_text_y.size * 3.5 - theme.legend_spacing;
395        let center_y = plot_area.y + plot_area.height / 2.0;
396        let family = if theme.axis_title_y.family.is_empty() {
397            None
398        } else {
399            Some(theme.axis_title_y.family.clone())
400        };
401        backend.draw_text(
402            title,
403            (title_x, center_y),
404            &TextStyle {
405                color: theme.axis_title_y.color,
406                size: theme.axis_title_y.size,
407                anchor: TextAnchor::Middle,
408                angle: 270.0,
409                family,
410                face: theme.axis_title_y.face,
411            },
412        )?;
413    }
414
415    Ok(())
416}
417
418/// Draw a secondary Y axis on the right side.
419pub fn draw_sec_y_axis(
420    primary_scale: &dyn Scale,
421    sec_axis: &crate::scale::sec_axis::SecAxis,
422    coord: &dyn Coord,
423    theme: &Theme,
424    plot_area: &Rect,
425    backend: &mut dyn DrawBackend,
426) -> Result<(), RenderError> {
427    let breaks = primary_scale.breaks();
428    let tick_len = theme.axis_ticks_length;
429    let axis_line = theme.get_axis_line_y();
430    let axis_ticks = theme.get_axis_ticks_y();
431
432    let right_x = plot_area.x + plot_area.width;
433
434    // Axis line on right side
435    if axis_line.visible {
436        backend.draw_line(
437            &[
438                (right_x, plot_area.y),
439                (right_x, plot_area.y + plot_area.height),
440            ],
441            &LineStyle {
442                color: axis_line.color,
443                width: axis_line.width,
444                alpha: 1.0,
445                linetype: axis_line.linetype,
446                ..Default::default()
447            },
448        )?;
449    }
450
451    // Ticks and labels at primary break positions, but with transformed labels
452    for (pos, label) in &breaks {
453        let py = along_y(coord, *pos, plot_area);
454
455        if axis_ticks.visible {
456            backend.draw_line(
457                &[(right_x, py), (right_x + tick_len, py)],
458                &LineStyle {
459                    color: axis_ticks.color,
460                    width: axis_ticks.width,
461                    alpha: 1.0,
462                    linetype: axis_ticks.linetype,
463                    ..Default::default()
464                },
465            )?;
466        }
467
468        if theme.axis_text_y.visible {
469            // Parse the primary label back to a number, transform it
470            let locale = primary_scale.locale();
471            let parsed = match locale {
472                Some(l) => l.parse_number(label),
473                None => label.parse::<f64>().ok(),
474            };
475            let sec_label = if let Some(v) = parsed {
476                let transformed = sec_axis.transform_value(v);
477                crate::scale::util::format_number_opt(transformed, locale)
478            } else {
479                label.clone()
480            };
481
482            let family = if theme.axis_text_y.family.is_empty() {
483                None
484            } else {
485                Some(theme.axis_text_y.family.clone())
486            };
487            backend.draw_text(
488                &sec_label,
489                (right_x + tick_len + theme.legend_spacing, py),
490                &TextStyle {
491                    color: theme.axis_text_y.color,
492                    size: theme.axis_text_y.size,
493                    anchor: TextAnchor::Start,
494                    angle: theme.axis_text_y.angle,
495                    family,
496                    face: theme.axis_text_y.face,
497                },
498            )?;
499        }
500    }
501
502    // Secondary axis title
503    if !sec_axis.name.is_empty() && theme.axis_title_y.visible {
504        let title_x = right_x + tick_len + theme.axis_text_y.size * 3.5 + theme.legend_spacing;
505        let center_y = plot_area.y + plot_area.height / 2.0;
506        let family = if theme.axis_title_y.family.is_empty() {
507            None
508        } else {
509            Some(theme.axis_title_y.family.clone())
510        };
511        backend.draw_text(
512            &sec_axis.name,
513            (title_x, center_y),
514            &TextStyle {
515                color: theme.axis_title_y.color,
516                size: theme.axis_title_y.size,
517                anchor: TextAnchor::Middle,
518                angle: 90.0,
519                family,
520                face: theme.axis_title_y.face,
521            },
522        )?;
523    }
524
525    Ok(())
526}
527
528/// Compute minor break positions as midpoints between major breaks.
529fn minor_breaks(major: &[(f64, String)]) -> Vec<f64> {
530    if major.len() < 2 {
531        return vec![];
532    }
533    let mut minors = Vec::with_capacity(major.len() - 1);
534    for pair in major.windows(2) {
535        minors.push((pair[0].0 + pair[1].0) / 2.0);
536    }
537    minors
538}
539
540/// Draw gridlines for both axes.
541pub fn draw_gridlines(
542    x_scale: &dyn Scale,
543    y_scale: &dyn Scale,
544    coord: &dyn Coord,
545    theme: &Theme,
546    plot_area: &Rect,
547    backend: &mut dyn DrawBackend,
548) -> Result<(), RenderError> {
549    let major_x = theme.get_panel_grid_major_x();
550    let major_y = theme.get_panel_grid_major_y();
551    let minor_x = theme.get_panel_grid_minor_x();
552    let minor_y = theme.get_panel_grid_minor_y();
553
554    let x_breaks = x_scale.breaks();
555    let y_breaks = y_scale.breaks();
556
557    // Minor X gridlines (vertical) — drawn first so majors paint over them
558    if minor_x.visible {
559        for pos in minor_breaks(&x_breaks) {
560            let px = along_x(coord, pos, plot_area);
561            backend.draw_line(
562                &[(px, plot_area.y), (px, plot_area.y + plot_area.height)],
563                &LineStyle {
564                    color: minor_x.color,
565                    width: minor_x.width,
566                    alpha: 1.0,
567                    linetype: minor_x.linetype,
568                    ..Default::default()
569                },
570            )?;
571        }
572    }
573
574    // Minor Y gridlines (horizontal)
575    if minor_y.visible {
576        for pos in minor_breaks(&y_breaks) {
577            let py = along_y(coord, pos, plot_area);
578            backend.draw_line(
579                &[(plot_area.x, py), (plot_area.x + plot_area.width, py)],
580                &LineStyle {
581                    color: minor_y.color,
582                    width: minor_y.width,
583                    alpha: 1.0,
584                    linetype: minor_y.linetype,
585                    ..Default::default()
586                },
587            )?;
588        }
589    }
590
591    // Major X gridlines (vertical)
592    if major_x.visible {
593        for (pos, _) in &x_breaks {
594            let px = along_x(coord, *pos, plot_area);
595            backend.draw_line(
596                &[(px, plot_area.y), (px, plot_area.y + plot_area.height)],
597                &LineStyle {
598                    color: major_x.color,
599                    width: major_x.width,
600                    alpha: 1.0,
601                    linetype: major_x.linetype,
602                    ..Default::default()
603                },
604            )?;
605        }
606    }
607
608    // Major Y gridlines (horizontal)
609    if major_y.visible {
610        for (pos, _) in &y_breaks {
611            let py = along_y(coord, *pos, plot_area);
612            backend.draw_line(
613                &[(plot_area.x, py), (plot_area.x + plot_area.width, py)],
614                &LineStyle {
615                    color: major_y.color,
616                    width: major_y.width,
617                    alpha: 1.0,
618                    linetype: major_y.linetype,
619                    ..Default::default()
620                },
621            )?;
622        }
623    }
624
625    Ok(())
626}
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631    use crate::aes::Aesthetic;
632    use crate::coord::cartesian::CoordCartesian;
633    use crate::data::Value;
634    use crate::render::svg_backend::SvgBackend;
635    use crate::theme::presets::theme_gray;
636
637    /// A scale with hand-picked break positions/labels, so a test can place
638    /// one off the canvas without fighting a real break algorithm.
639    struct FixedBreaksScale(Vec<(f64, String)>);
640
641    impl Scale for FixedBreaksScale {
642        fn aesthetic(&self) -> Aesthetic {
643            Aesthetic::X
644        }
645        fn train(&mut self, _values: &[Value]) {}
646        fn map(&self, _value: &Value) -> f64 {
647            0.0
648        }
649        fn breaks(&self) -> Vec<(f64, String)> {
650            self.0.clone()
651        }
652        fn name(&self) -> &str {
653            ""
654        }
655        fn set_name(&mut self, _name: &str) {}
656        fn clone_box(&self) -> Box<dyn Scale> {
657            Box::new(FixedBreaksScale(self.0.clone()))
658        }
659    }
660
661    fn backend(w: u32, h: u32, plot_area: Rect) -> SvgBackend {
662        SvgBackend::new(w, h, plot_area)
663    }
664
665    #[test]
666    fn a_tick_outside_the_svg_is_dropped() {
667        // Normalized position 5.0 is nowhere near [0, 1]; through
668        // `CoordCartesian` that lands far outside the 200×100 canvas.
669        let scale = FixedBreaksScale(vec![
670            (0.2, "kept".to_string()),
671            (5.0, "off_canvas".to_string()),
672        ]);
673        let coord = CoordCartesian::new();
674        let theme = theme_gray();
675        let plot_area = Rect {
676            x: 10.0,
677            y: 10.0,
678            width: 180.0,
679            height: 70.0,
680        };
681        let canvas = Rect {
682            x: 0.0,
683            y: 0.0,
684            width: 200.0,
685            height: 100.0,
686        };
687        let mut backend = backend(200, 100, plot_area.clone());
688        draw_x_axis(&scale, &coord, &theme, &plot_area, &canvas, &mut backend).unwrap();
689        let svg = backend.finish();
690        assert!(svg.contains(">kept<"), "on-canvas label dropped: {svg}");
691        assert!(
692            !svg.contains(">off_canvas<"),
693            "off-canvas label was drawn: {svg}"
694        );
695    }
696
697    #[test]
698    fn clip_draws_the_label_exactly_at_the_tick_even_past_the_edge() {
699        // A very wide label at the rightmost tick, Clip mode: no shift.
700        let scale = FixedBreaksScale(vec![
701            (0.0, "a".to_string()),
702            (1.0, "2021-01-01".to_string()),
703        ]);
704        let coord = CoordCartesian::new();
705        let mut theme = theme_gray();
706        theme.axis_text_x_fit = crate::theme::AxisLabelFit::Clip;
707        let plot_area = Rect {
708            x: 0.0,
709            y: 0.0,
710            width: 60.0,
711            height: 50.0,
712        };
713        let canvas = plot_area.clone();
714        let mut backend = backend(60, 50, plot_area.clone());
715        draw_x_axis(&scale, &coord, &theme, &plot_area, &canvas, &mut backend).unwrap();
716        let svg = backend.finish();
717        // Clip: the label sits exactly on the tick (x="60.00"), unmoved,
718        // even though its bbox clearly overflows a 60px canvas.
719        assert!(
720            svg.contains("x=\"60.00\" y=\"") && svg.contains(">2021-01-01<"),
721            "clip mode should not shift the label: {svg}"
722        );
723    }
724
725    #[test]
726    fn shift_moves_the_same_label_inside_the_canvas() {
727        let scale = FixedBreaksScale(vec![
728            (0.0, "a".to_string()),
729            (1.0, "2021-01-01".to_string()),
730        ]);
731        let coord = CoordCartesian::new();
732        let mut theme = theme_gray();
733        theme.axis_text_x_fit = crate::theme::AxisLabelFit::Shift;
734        let plot_area = Rect {
735            x: 0.0,
736            y: 0.0,
737            width: 60.0,
738            height: 50.0,
739        };
740        let canvas = plot_area.clone();
741        let mut backend = backend(60, 50, plot_area.clone());
742        draw_x_axis(&scale, &coord, &theme, &plot_area, &canvas, &mut backend).unwrap();
743        let svg = backend.finish();
744        assert!(
745            !svg.contains("x=\"60.00\" y=\""),
746            "shift mode should move the label off the bare tick: {svg}"
747        );
748        let w = crate::render::text::text_width("2021-01-01", theme.axis_text_x.size);
749        // The shifted label's right edge must land inside the canvas.
750        let needle = ">2021-01-01<";
751        let tag_end = svg.find(needle).unwrap();
752        let tag_start = svg[..tag_end].rfind("<text ").unwrap();
753        let tag = &svg[tag_start..tag_end];
754        let x_attr = tag.rsplit("x=\"").next().unwrap();
755        let x: f64 = x_attr.split('"').next().unwrap().parse().unwrap();
756        // `x` round-trips through a `"{:.2}"`-formatted SVG attribute, so
757        // allow a hair of slack for that rounding.
758        assert!(
759            x + w / 2.0 <= 60.0 - 1.0 + 0.01,
760            "right edge still overflows: x={x} w={w}"
761        );
762    }
763}