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