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                let transformed = sec_axis.transform_value(v);
591                crate::scale::util::format_number_opt(transformed, locale)
592            } else {
593                label.clone()
594            };
595
596            let family = if theme.axis_text_y.family.is_empty() {
597                None
598            } else {
599                Some(theme.axis_text_y.family.clone())
600            };
601            backend.draw_text(
602                &sec_label,
603                (right_x + tick_len + theme.legend_spacing, py),
604                &TextStyle {
605                    color: theme.axis_text_y.color,
606                    size: theme.axis_text_y.size,
607                    anchor: TextAnchor::Start,
608                    angle: theme.axis_text_y.angle,
609                    family,
610                    face: theme.axis_text_y.face,
611                    bg_colour: None,
612                    bg_r: 0.0,
613                },
614            )?;
615        }
616    }
617
618    // Secondary axis title
619    if !sec_axis.name.is_empty() && theme.axis_title_y.visible {
620        // Clear the widest tick label (own percent ticks can exceed the
621        // 3.5-character default).
622        let max_ch = own_ticks
623            .as_ref()
624            .and_then(|t| t.iter().map(|(_, l)| l.chars().count()).max())
625            .unwrap_or(0);
626        let text_w =
627            (max_ch as f64 * theme.axis_text_y.size * 0.6).max(theme.axis_text_y.size * 3.5);
628        let title_x = right_x + tick_len + text_w + theme.legend_spacing;
629        let center_y = plot_area.y + plot_area.height / 2.0;
630        let family = if theme.axis_title_y.family.is_empty() {
631            None
632        } else {
633            Some(theme.axis_title_y.family.clone())
634        };
635        backend.draw_text(
636            &sec_axis.name,
637            (title_x, center_y),
638            &TextStyle {
639                color: theme.axis_title_y.color,
640                size: theme.axis_title_y.size,
641                anchor: TextAnchor::Middle,
642                angle: 90.0,
643                family,
644                face: theme.axis_title_y.face,
645                bg_colour: None,
646                bg_r: 0.0,
647            },
648        )?;
649    }
650
651    Ok(())
652}
653
654/// Compute minor break positions as midpoints between major breaks.
655fn minor_breaks(major: &[(f64, String)]) -> Vec<f64> {
656    if major.len() < 2 {
657        return vec![];
658    }
659    let mut minors = Vec::with_capacity(major.len() - 1);
660    for pair in major.windows(2) {
661        minors.push((pair[0].0 + pair[1].0) / 2.0);
662    }
663    minors
664}
665
666/// Draw gridlines for both axes.
667pub fn draw_gridlines(
668    x_scale: &dyn Scale,
669    y_scale: &dyn Scale,
670    coord: &dyn Coord,
671    theme: &Theme,
672    plot_area: &Rect,
673    backend: &mut dyn DrawBackend,
674) -> Result<(), RenderError> {
675    let major_x = theme.get_panel_grid_major_x();
676    let major_y = theme.get_panel_grid_major_y();
677    let minor_x = theme.get_panel_grid_minor_x();
678    let minor_y = theme.get_panel_grid_minor_y();
679
680    let x_breaks = x_scale.breaks();
681    let y_breaks = y_scale.breaks();
682
683    // Minor X gridlines (vertical) — drawn first so majors paint over them
684    if minor_x.visible {
685        for pos in minor_breaks(&x_breaks) {
686            let px = along_x(coord, pos, plot_area);
687            backend.draw_line(
688                &[(px, plot_area.y), (px, plot_area.y + plot_area.height)],
689                &LineStyle {
690                    color: minor_x.color,
691                    width: minor_x.width,
692                    alpha: 1.0,
693                    linetype: minor_x.linetype.clone(),
694                    ..Default::default()
695                },
696            )?;
697        }
698    }
699
700    // Minor Y gridlines (horizontal)
701    if minor_y.visible {
702        for pos in minor_breaks(&y_breaks) {
703            let py = along_y(coord, pos, plot_area);
704            backend.draw_line(
705                &[(plot_area.x, py), (plot_area.x + plot_area.width, py)],
706                &LineStyle {
707                    color: minor_y.color,
708                    width: minor_y.width,
709                    alpha: 1.0,
710                    linetype: minor_y.linetype.clone(),
711                    ..Default::default()
712                },
713            )?;
714        }
715    }
716
717    // Major X gridlines (vertical)
718    if major_x.visible {
719        for (pos, _) in &x_breaks {
720            let px = along_x(coord, *pos, plot_area);
721            backend.draw_line(
722                &[(px, plot_area.y), (px, plot_area.y + plot_area.height)],
723                &LineStyle {
724                    color: major_x.color,
725                    width: major_x.width,
726                    alpha: 1.0,
727                    linetype: major_x.linetype.clone(),
728                    ..Default::default()
729                },
730            )?;
731        }
732    }
733
734    // Major Y gridlines (horizontal)
735    if major_y.visible {
736        for (pos, _) in &y_breaks {
737            let py = along_y(coord, *pos, plot_area);
738            backend.draw_line(
739                &[(plot_area.x, py), (plot_area.x + plot_area.width, py)],
740                &LineStyle {
741                    color: major_y.color,
742                    width: major_y.width,
743                    alpha: 1.0,
744                    linetype: major_y.linetype.clone(),
745                    ..Default::default()
746                },
747            )?;
748        }
749    }
750
751    Ok(())
752}
753
754#[cfg(test)]
755mod tests {
756    use super::*;
757    use crate::aes::Aesthetic;
758    use crate::coord::cartesian::CoordCartesian;
759    use crate::data::Value;
760    use crate::render::svg_backend::SvgBackend;
761    use crate::theme::presets::theme_gray;
762
763    /// A scale with hand-picked break positions/labels, so a test can place
764    /// one off the canvas without fighting a real break algorithm.
765    struct FixedBreaksScale(Vec<(f64, String)>);
766
767    impl Scale for FixedBreaksScale {
768        fn aesthetic(&self) -> Aesthetic {
769            Aesthetic::X
770        }
771        fn train(&mut self, _values: &[Value]) {}
772        fn map(&self, _value: &Value) -> f64 {
773            0.0
774        }
775        fn breaks(&self) -> Vec<(f64, String)> {
776            self.0.clone()
777        }
778        fn name(&self) -> &str {
779            ""
780        }
781        fn set_name(&mut self, _name: &str) {}
782        fn clone_box(&self) -> Box<dyn Scale> {
783            Box::new(FixedBreaksScale(self.0.clone()))
784        }
785    }
786
787    fn backend(w: u32, h: u32, plot_area: Rect) -> SvgBackend {
788        SvgBackend::new(w, h, plot_area)
789    }
790
791    #[test]
792    fn a_tick_outside_the_svg_is_dropped() {
793        // Normalized position 5.0 is nowhere near [0, 1]; through
794        // `CoordCartesian` that lands far outside the 200×100 canvas.
795        let scale = FixedBreaksScale(vec![
796            (0.2, "kept".to_string()),
797            (5.0, "off_canvas".to_string()),
798        ]);
799        let coord = CoordCartesian::new();
800        let theme = theme_gray();
801        let plot_area = Rect {
802            x: 10.0,
803            y: 10.0,
804            width: 180.0,
805            height: 70.0,
806        };
807        let canvas = Rect {
808            x: 0.0,
809            y: 0.0,
810            width: 200.0,
811            height: 100.0,
812        };
813        let mut backend = backend(200, 100, plot_area.clone());
814        draw_x_axis(&scale, &coord, &theme, &plot_area, &canvas, &mut backend).unwrap();
815        let svg = backend.finish();
816        assert!(svg.contains(">kept<"), "on-canvas label dropped: {svg}");
817        assert!(
818            !svg.contains(">off_canvas<"),
819            "off-canvas label was drawn: {svg}"
820        );
821    }
822
823    #[test]
824    fn clip_draws_the_label_exactly_at_the_tick_even_past_the_edge() {
825        // A very wide label at the rightmost tick, Clip mode: no shift.
826        let scale = FixedBreaksScale(vec![
827            (0.0, "a".to_string()),
828            (1.0, "2021-01-01".to_string()),
829        ]);
830        let coord = CoordCartesian::new();
831        let mut theme = theme_gray();
832        theme.axis_text_x_fit = crate::theme::AxisLabelFit::Clip;
833        let plot_area = Rect {
834            x: 0.0,
835            y: 0.0,
836            width: 60.0,
837            height: 50.0,
838        };
839        let canvas = plot_area.clone();
840        let mut backend = backend(60, 50, plot_area.clone());
841        draw_x_axis(&scale, &coord, &theme, &plot_area, &canvas, &mut backend).unwrap();
842        let svg = backend.finish();
843        // Clip: the label sits exactly on the tick (x="60.00"), unmoved,
844        // even though its bbox clearly overflows a 60px canvas.
845        assert!(
846            svg.contains("x=\"60.00\" y=\"") && svg.contains(">2021-01-01<"),
847            "clip mode should not shift the label: {svg}"
848        );
849    }
850
851    #[test]
852    fn shift_moves_the_same_label_inside_the_canvas() {
853        let scale = FixedBreaksScale(vec![
854            (0.0, "a".to_string()),
855            (1.0, "2021-01-01".to_string()),
856        ]);
857        let coord = CoordCartesian::new();
858        let mut theme = theme_gray();
859        theme.axis_text_x_fit = crate::theme::AxisLabelFit::Shift;
860        let plot_area = Rect {
861            x: 0.0,
862            y: 0.0,
863            width: 60.0,
864            height: 50.0,
865        };
866        let canvas = plot_area.clone();
867        let mut backend = backend(60, 50, plot_area.clone());
868        draw_x_axis(&scale, &coord, &theme, &plot_area, &canvas, &mut backend).unwrap();
869        let svg = backend.finish();
870        assert!(
871            !svg.contains("x=\"60.00\" y=\""),
872            "shift mode should move the label off the bare tick: {svg}"
873        );
874        let w = crate::render::text::text_width("2021-01-01", theme.axis_text_x.size);
875        // The shifted label's right edge must land inside the canvas.
876        let needle = ">2021-01-01<";
877        let tag_end = svg.find(needle).unwrap();
878        let tag_start = svg[..tag_end].rfind("<text ").unwrap();
879        let tag = &svg[tag_start..tag_end];
880        let x_attr = tag.rsplit("x=\"").next().unwrap();
881        let x: f64 = x_attr.split('"').next().unwrap().parse().unwrap();
882        // `x` round-trips through a `"{:.2}"`-formatted SVG attribute, so
883        // allow a hair of slack for that rounding.
884        assert!(
885            x + w / 2.0 <= 60.0 - 1.0 + 0.01,
886            "right edge still overflows: x={x} w={w}"
887        );
888    }
889}