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.clone(),
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.clone(),
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                    bg_colour: None,
264                    bg_r: 0.0,
265                },
266            )?;
267        }
268    }
269
270    // Minor ticks between majors.
271    if theme.axis_minor_ticks && axis_ticks.visible {
272        for pos in minor_breaks(&breaks) {
273            let px = along_x(coord, pos, plot_area);
274            backend.draw_line(
275                &[(px, edge_y), (px, edge_y + dir * tick_len * 0.5)],
276                &LineStyle {
277                    color: axis_ticks.color,
278                    width: axis_ticks.width,
279                    alpha: 1.0,
280                    linetype: axis_ticks.linetype.clone(),
281                    ..Default::default()
282                },
283            )?;
284        }
285    }
286
287    // Axis title
288    let title = scale.name();
289    if !title.is_empty() && theme.axis_title_x.visible {
290        let hjust = theme.axis_title_x.hjust.clamp(0.0, 1.0);
291        let center_x = plot_area.x + hjust * plot_area.width;
292        let anchor = if hjust <= 0.02 {
293            TextAnchor::Start
294        } else if hjust >= 0.98 {
295            TextAnchor::End
296        } else {
297            TextAnchor::Middle
298        };
299        let title_y = edge_y
300            + dir * (tick_len + theme.axis_text_x.size + 8.0 + theme.axis_title_x.size / 2.0);
301        let family = if theme.axis_title_x.family.is_empty() {
302            None
303        } else {
304            Some(theme.axis_title_x.family.clone())
305        };
306        backend.draw_text(
307            title,
308            (center_x, title_y),
309            &TextStyle {
310                color: theme.axis_title_x.color,
311                size: theme.axis_title_x.size,
312                anchor,
313                angle: 0.0,
314                family,
315                face: theme.axis_title_x.face,
316                bg_colour: None,
317                bg_r: 0.0,
318            },
319        )?;
320    }
321
322    Ok(())
323}
324
325/// Draw the Y axis: ticks, labels, and title.
326pub fn draw_y_axis(
327    scale: &dyn Scale,
328    coord: &dyn Coord,
329    theme: &Theme,
330    plot_area: &Rect,
331    backend: &mut dyn DrawBackend,
332) -> Result<(), RenderError> {
333    let breaks = scale.breaks();
334    let tick_len = theme.axis_ticks_length;
335    let axis_line = theme.get_axis_line_y();
336    let axis_ticks = theme.get_axis_ticks_y();
337
338    // Axis line
339    if axis_line.visible {
340        let top = (plot_area.x, plot_area.y);
341        let bottom = (plot_area.x, plot_area.y + plot_area.height);
342        backend.draw_line(
343            &[top, bottom],
344            &LineStyle {
345                color: axis_line.color,
346                width: axis_line.width,
347                alpha: 1.0,
348                linetype: axis_line.linetype.clone(),
349                ..Default::default()
350            },
351        )?;
352    }
353
354    // Ticks and labels
355    for (pos, label) in &breaks {
356        let py = along_y(coord, *pos, plot_area);
357
358        if axis_ticks.visible {
359            backend.draw_line(
360                &[(plot_area.x - tick_len, py), (plot_area.x, py)],
361                &LineStyle {
362                    color: axis_ticks.color,
363                    width: axis_ticks.width,
364                    alpha: 1.0,
365                    linetype: axis_ticks.linetype.clone(),
366                    ..Default::default()
367                },
368            )?;
369        }
370
371        if theme.axis_text_y.visible {
372            let family = if theme.axis_text_y.family.is_empty() {
373                None
374            } else {
375                Some(theme.axis_text_y.family.clone())
376            };
377            backend.draw_text(
378                label,
379                (plot_area.x - tick_len - theme.legend_spacing, py),
380                &TextStyle {
381                    color: theme.axis_text_y.color,
382                    size: theme.axis_text_y.size,
383                    anchor: TextAnchor::End,
384                    angle: theme.axis_text_y.angle,
385                    family,
386                    face: theme.axis_text_y.face,
387                    bg_colour: None,
388                    bg_r: 0.0,
389                },
390            )?;
391        }
392    }
393
394    // Minor ticks between majors.
395    if theme.axis_minor_ticks && axis_ticks.visible {
396        for pos in minor_breaks(&breaks) {
397            let py = along_y(coord, pos, plot_area);
398            backend.draw_line(
399                &[(plot_area.x - tick_len * 0.5, py), (plot_area.x, py)],
400                &LineStyle {
401                    color: axis_ticks.color,
402                    width: axis_ticks.width,
403                    alpha: 1.0,
404                    linetype: axis_ticks.linetype.clone(),
405                    ..Default::default()
406                },
407            )?;
408        }
409    }
410
411    // Axis title
412    let title = scale.name();
413    if !title.is_empty() && theme.axis_title_y.visible {
414        let title_x = plot_area.x - tick_len - theme.axis_text_y.size * 3.5 - theme.legend_spacing;
415        let center_y = plot_area.y + plot_area.height / 2.0;
416        let family = if theme.axis_title_y.family.is_empty() {
417            None
418        } else {
419            Some(theme.axis_title_y.family.clone())
420        };
421        backend.draw_text(
422            title,
423            (title_x, center_y),
424            &TextStyle {
425                color: theme.axis_title_y.color,
426                size: theme.axis_title_y.size,
427                anchor: TextAnchor::Middle,
428                angle: 270.0,
429                family,
430                face: theme.axis_title_y.face,
431                bg_colour: None,
432                bg_r: 0.0,
433            },
434        )?;
435    }
436
437    Ok(())
438}
439
440/// Draw a secondary Y axis on the right side.
441pub fn draw_sec_y_axis(
442    primary_scale: &dyn Scale,
443    sec_axis: &crate::scale::sec_axis::SecAxis,
444    coord: &dyn Coord,
445    theme: &Theme,
446    plot_area: &Rect,
447    backend: &mut dyn DrawBackend,
448) -> Result<(), RenderError> {
449    let breaks = primary_scale.breaks();
450    let tick_len = theme.axis_ticks_length;
451    let axis_line = theme.get_axis_line_y();
452    let axis_ticks = theme.get_axis_ticks_y();
453
454    let right_x = plot_area.x + plot_area.width;
455
456    // Axis line on right side
457    if axis_line.visible {
458        backend.draw_line(
459            &[
460                (right_x, plot_area.y),
461                (right_x, plot_area.y + plot_area.height),
462            ],
463            &LineStyle {
464                color: axis_line.color,
465                width: axis_line.width,
466                alpha: 1.0,
467                linetype: axis_line.linetype.clone(),
468                ..Default::default()
469            },
470        )?;
471    }
472
473    // Ticks and labels at primary break positions, but with transformed labels
474    for (pos, label) in &breaks {
475        let py = along_y(coord, *pos, plot_area);
476
477        if axis_ticks.visible {
478            backend.draw_line(
479                &[(right_x, py), (right_x + tick_len, py)],
480                &LineStyle {
481                    color: axis_ticks.color,
482                    width: axis_ticks.width,
483                    alpha: 1.0,
484                    linetype: axis_ticks.linetype.clone(),
485                    ..Default::default()
486                },
487            )?;
488        }
489
490        if theme.axis_text_y.visible {
491            // Parse the primary label back to a number, transform it
492            let locale = primary_scale.locale();
493            let parsed = match locale {
494                Some(l) => l.parse_number(label),
495                None => label.parse::<f64>().ok(),
496            };
497            let sec_label = if let Some(v) = parsed {
498                let transformed = sec_axis.transform_value(v);
499                crate::scale::util::format_number_opt(transformed, locale)
500            } else {
501                label.clone()
502            };
503
504            let family = if theme.axis_text_y.family.is_empty() {
505                None
506            } else {
507                Some(theme.axis_text_y.family.clone())
508            };
509            backend.draw_text(
510                &sec_label,
511                (right_x + tick_len + theme.legend_spacing, py),
512                &TextStyle {
513                    color: theme.axis_text_y.color,
514                    size: theme.axis_text_y.size,
515                    anchor: TextAnchor::Start,
516                    angle: theme.axis_text_y.angle,
517                    family,
518                    face: theme.axis_text_y.face,
519                    bg_colour: None,
520                    bg_r: 0.0,
521                },
522            )?;
523        }
524    }
525
526    // Secondary axis title
527    if !sec_axis.name.is_empty() && theme.axis_title_y.visible {
528        let title_x = right_x + tick_len + theme.axis_text_y.size * 3.5 + theme.legend_spacing;
529        let center_y = plot_area.y + plot_area.height / 2.0;
530        let family = if theme.axis_title_y.family.is_empty() {
531            None
532        } else {
533            Some(theme.axis_title_y.family.clone())
534        };
535        backend.draw_text(
536            &sec_axis.name,
537            (title_x, center_y),
538            &TextStyle {
539                color: theme.axis_title_y.color,
540                size: theme.axis_title_y.size,
541                anchor: TextAnchor::Middle,
542                angle: 90.0,
543                family,
544                face: theme.axis_title_y.face,
545                bg_colour: None,
546                bg_r: 0.0,
547            },
548        )?;
549    }
550
551    Ok(())
552}
553
554/// Compute minor break positions as midpoints between major breaks.
555fn minor_breaks(major: &[(f64, String)]) -> Vec<f64> {
556    if major.len() < 2 {
557        return vec![];
558    }
559    let mut minors = Vec::with_capacity(major.len() - 1);
560    for pair in major.windows(2) {
561        minors.push((pair[0].0 + pair[1].0) / 2.0);
562    }
563    minors
564}
565
566/// Draw gridlines for both axes.
567pub fn draw_gridlines(
568    x_scale: &dyn Scale,
569    y_scale: &dyn Scale,
570    coord: &dyn Coord,
571    theme: &Theme,
572    plot_area: &Rect,
573    backend: &mut dyn DrawBackend,
574) -> Result<(), RenderError> {
575    let major_x = theme.get_panel_grid_major_x();
576    let major_y = theme.get_panel_grid_major_y();
577    let minor_x = theme.get_panel_grid_minor_x();
578    let minor_y = theme.get_panel_grid_minor_y();
579
580    let x_breaks = x_scale.breaks();
581    let y_breaks = y_scale.breaks();
582
583    // Minor X gridlines (vertical) — drawn first so majors paint over them
584    if minor_x.visible {
585        for pos in minor_breaks(&x_breaks) {
586            let px = along_x(coord, pos, plot_area);
587            backend.draw_line(
588                &[(px, plot_area.y), (px, plot_area.y + plot_area.height)],
589                &LineStyle {
590                    color: minor_x.color,
591                    width: minor_x.width,
592                    alpha: 1.0,
593                    linetype: minor_x.linetype.clone(),
594                    ..Default::default()
595                },
596            )?;
597        }
598    }
599
600    // Minor Y gridlines (horizontal)
601    if minor_y.visible {
602        for pos in minor_breaks(&y_breaks) {
603            let py = along_y(coord, pos, plot_area);
604            backend.draw_line(
605                &[(plot_area.x, py), (plot_area.x + plot_area.width, py)],
606                &LineStyle {
607                    color: minor_y.color,
608                    width: minor_y.width,
609                    alpha: 1.0,
610                    linetype: minor_y.linetype.clone(),
611                    ..Default::default()
612                },
613            )?;
614        }
615    }
616
617    // Major X gridlines (vertical)
618    if major_x.visible {
619        for (pos, _) in &x_breaks {
620            let px = along_x(coord, *pos, plot_area);
621            backend.draw_line(
622                &[(px, plot_area.y), (px, plot_area.y + plot_area.height)],
623                &LineStyle {
624                    color: major_x.color,
625                    width: major_x.width,
626                    alpha: 1.0,
627                    linetype: major_x.linetype.clone(),
628                    ..Default::default()
629                },
630            )?;
631        }
632    }
633
634    // Major Y gridlines (horizontal)
635    if major_y.visible {
636        for (pos, _) in &y_breaks {
637            let py = along_y(coord, *pos, plot_area);
638            backend.draw_line(
639                &[(plot_area.x, py), (plot_area.x + plot_area.width, py)],
640                &LineStyle {
641                    color: major_y.color,
642                    width: major_y.width,
643                    alpha: 1.0,
644                    linetype: major_y.linetype.clone(),
645                    ..Default::default()
646                },
647            )?;
648        }
649    }
650
651    Ok(())
652}
653
654#[cfg(test)]
655mod tests {
656    use super::*;
657    use crate::aes::Aesthetic;
658    use crate::coord::cartesian::CoordCartesian;
659    use crate::data::Value;
660    use crate::render::svg_backend::SvgBackend;
661    use crate::theme::presets::theme_gray;
662
663    /// A scale with hand-picked break positions/labels, so a test can place
664    /// one off the canvas without fighting a real break algorithm.
665    struct FixedBreaksScale(Vec<(f64, String)>);
666
667    impl Scale for FixedBreaksScale {
668        fn aesthetic(&self) -> Aesthetic {
669            Aesthetic::X
670        }
671        fn train(&mut self, _values: &[Value]) {}
672        fn map(&self, _value: &Value) -> f64 {
673            0.0
674        }
675        fn breaks(&self) -> Vec<(f64, String)> {
676            self.0.clone()
677        }
678        fn name(&self) -> &str {
679            ""
680        }
681        fn set_name(&mut self, _name: &str) {}
682        fn clone_box(&self) -> Box<dyn Scale> {
683            Box::new(FixedBreaksScale(self.0.clone()))
684        }
685    }
686
687    fn backend(w: u32, h: u32, plot_area: Rect) -> SvgBackend {
688        SvgBackend::new(w, h, plot_area)
689    }
690
691    #[test]
692    fn a_tick_outside_the_svg_is_dropped() {
693        // Normalized position 5.0 is nowhere near [0, 1]; through
694        // `CoordCartesian` that lands far outside the 200×100 canvas.
695        let scale = FixedBreaksScale(vec![
696            (0.2, "kept".to_string()),
697            (5.0, "off_canvas".to_string()),
698        ]);
699        let coord = CoordCartesian::new();
700        let theme = theme_gray();
701        let plot_area = Rect {
702            x: 10.0,
703            y: 10.0,
704            width: 180.0,
705            height: 70.0,
706        };
707        let canvas = Rect {
708            x: 0.0,
709            y: 0.0,
710            width: 200.0,
711            height: 100.0,
712        };
713        let mut backend = backend(200, 100, plot_area.clone());
714        draw_x_axis(&scale, &coord, &theme, &plot_area, &canvas, &mut backend).unwrap();
715        let svg = backend.finish();
716        assert!(svg.contains(">kept<"), "on-canvas label dropped: {svg}");
717        assert!(
718            !svg.contains(">off_canvas<"),
719            "off-canvas label was drawn: {svg}"
720        );
721    }
722
723    #[test]
724    fn clip_draws_the_label_exactly_at_the_tick_even_past_the_edge() {
725        // A very wide label at the rightmost tick, Clip mode: no shift.
726        let scale = FixedBreaksScale(vec![
727            (0.0, "a".to_string()),
728            (1.0, "2021-01-01".to_string()),
729        ]);
730        let coord = CoordCartesian::new();
731        let mut theme = theme_gray();
732        theme.axis_text_x_fit = crate::theme::AxisLabelFit::Clip;
733        let plot_area = Rect {
734            x: 0.0,
735            y: 0.0,
736            width: 60.0,
737            height: 50.0,
738        };
739        let canvas = plot_area.clone();
740        let mut backend = backend(60, 50, plot_area.clone());
741        draw_x_axis(&scale, &coord, &theme, &plot_area, &canvas, &mut backend).unwrap();
742        let svg = backend.finish();
743        // Clip: the label sits exactly on the tick (x="60.00"), unmoved,
744        // even though its bbox clearly overflows a 60px canvas.
745        assert!(
746            svg.contains("x=\"60.00\" y=\"") && svg.contains(">2021-01-01<"),
747            "clip mode should not shift the label: {svg}"
748        );
749    }
750
751    #[test]
752    fn shift_moves_the_same_label_inside_the_canvas() {
753        let scale = FixedBreaksScale(vec![
754            (0.0, "a".to_string()),
755            (1.0, "2021-01-01".to_string()),
756        ]);
757        let coord = CoordCartesian::new();
758        let mut theme = theme_gray();
759        theme.axis_text_x_fit = crate::theme::AxisLabelFit::Shift;
760        let plot_area = Rect {
761            x: 0.0,
762            y: 0.0,
763            width: 60.0,
764            height: 50.0,
765        };
766        let canvas = plot_area.clone();
767        let mut backend = backend(60, 50, plot_area.clone());
768        draw_x_axis(&scale, &coord, &theme, &plot_area, &canvas, &mut backend).unwrap();
769        let svg = backend.finish();
770        assert!(
771            !svg.contains("x=\"60.00\" y=\""),
772            "shift mode should move the label off the bare tick: {svg}"
773        );
774        let w = crate::render::text::text_width("2021-01-01", theme.axis_text_x.size);
775        // The shifted label's right edge must land inside the canvas.
776        let needle = ">2021-01-01<";
777        let tag_end = svg.find(needle).unwrap();
778        let tag_start = svg[..tag_end].rfind("<text ").unwrap();
779        let tag = &svg[tag_start..tag_end];
780        let x_attr = tag.rsplit("x=\"").next().unwrap();
781        let x: f64 = x_attr.split('"').next().unwrap().parse().unwrap();
782        // `x` round-trips through a `"{:.2}"`-formatted SVG attribute, so
783        // allow a hair of slack for that rounding.
784        assert!(
785            x + w / 2.0 <= 60.0 - 1.0 + 0.01,
786            "right edge still overflows: x={x} w={w}"
787        );
788    }
789}