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