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