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        // #482: below rotated labels (auto-rotated to -45°/270°, or an
377        // explicit angle) the title clears their real extent — the same
378        // rise/fall the layout reserves for them — not one line of text.
379        let angle = theme.axis_text_x.angle;
380        let labels_h = if theme.axis_text_x.visible && angle.abs() > 10.0 {
381            let breaks = scale.breaks();
382            let (rise, fall) = crate::render::text::rotated_label_rise_fall(
383                breaks.iter().map(|(_, l)| l.as_str()),
384                theme.axis_text_x.size,
385                angle,
386                crate::render::text::rotated_anchor_frac(angle, dir),
387            );
388            (rise + fall).max(theme.axis_text_x.size)
389        } else {
390            theme.axis_text_x.size
391        };
392        let title_y = edge_y + dir * (tick_len + labels_h + 8.0 + theme.axis_title_x.size / 2.0);
393        let family = if theme.axis_title_x.family.is_empty() {
394            None
395        } else {
396            Some(theme.axis_title_x.family.clone())
397        };
398        backend.set_element_class(Some("gg-axis-title gg-axis-title-x"));
399        crate::render::rich::draw(
400            backend,
401            theme.rich_text,
402            title,
403            (center_x, title_y),
404            &TextStyle {
405                color: theme.axis_title_x.color,
406                size: theme.axis_title_x.size,
407                anchor,
408                angle: 0.0,
409                family,
410                face: theme.axis_title_x.face,
411                bg_colour: None,
412                bg_r: 0.0,
413            },
414        )?;
415    }
416
417    Ok(())
418}
419
420/// Draw the Y axis: ticks, labels, and title.
421pub fn draw_y_axis(
422    scale: &dyn Scale,
423    coord: &dyn Coord,
424    theme: &Theme,
425    plot_area: &Rect,
426    backend: &mut dyn DrawBackend,
427) -> Result<(), RenderError> {
428    draw_y_axis_with(scale, coord, theme, plot_area, true, backend)
429}
430
431/// [`draw_y_axis`], optionally without its title — a faceted grid draws one
432/// title for all panels instead (#348).
433pub(crate) fn draw_y_axis_with(
434    scale: &dyn Scale,
435    coord: &dyn Coord,
436    theme: &Theme,
437    plot_area: &Rect,
438    with_title: bool,
439    backend: &mut dyn DrawBackend,
440) -> Result<(), RenderError> {
441    let breaks = scale.breaks();
442    let tick_len = theme.ticks_len_y();
443    let axis_line = theme.axis_line_at(crate::theme::AxisSide::Left);
444    let axis_ticks = theme.axis_ticks_at(crate::theme::AxisSide::Left);
445
446    // Axis line
447    if axis_line.visible {
448        let top = (plot_area.x, plot_area.y);
449        let bottom = (plot_area.x, plot_area.y + plot_area.height);
450        backend.set_element_class(Some("gg-axis gg-axis-y gg-axis-line"));
451        backend.draw_line(
452            &[top, bottom],
453            &LineStyle {
454                color: axis_line.color,
455                width: axis_line.width,
456                alpha: 1.0,
457                linetype: axis_line.linetype.clone(),
458                ..Default::default()
459            },
460        )?;
461    }
462
463    // Ticks and labels
464    for (pos, label) in &breaks {
465        let py = along_y(coord, *pos, plot_area);
466
467        if axis_ticks.visible {
468            backend.set_element_class(Some("gg-axis gg-axis-y gg-axis-tick"));
469            backend.draw_line(
470                &[(plot_area.x - tick_len, py), (plot_area.x, py)],
471                &LineStyle {
472                    color: axis_ticks.color,
473                    width: axis_ticks.width,
474                    alpha: 1.0,
475                    linetype: axis_ticks.linetype.clone(),
476                    ..Default::default()
477                },
478            )?;
479        }
480
481        if theme.axis_text_y.visible {
482            let family = if theme.axis_text_y.family.is_empty() {
483                None
484            } else {
485                Some(theme.axis_text_y.family.clone())
486            };
487            backend.set_element_class(Some("gg-axis gg-axis-y gg-axis-text"));
488            backend.draw_text(
489                label,
490                (plot_area.x - tick_len - theme.legend_spacing, py),
491                &TextStyle {
492                    color: theme.axis_text_y.color,
493                    size: theme.axis_text_y.size,
494                    anchor: TextAnchor::End,
495                    angle: theme.axis_text_y.angle,
496                    family,
497                    face: theme.axis_text_y.face,
498                    bg_colour: None,
499                    bg_r: 0.0,
500                },
501            )?;
502        }
503    }
504
505    // Minor ticks between majors.
506    if theme.axis_minor_ticks && axis_ticks.visible {
507        for pos in minor_breaks(&breaks) {
508            let py = along_y(coord, pos, plot_area);
509            backend.set_element_class(Some("gg-axis gg-axis-y gg-axis-tick"));
510            backend.draw_line(
511                &[(plot_area.x - tick_len * 0.5, py), (plot_area.x, py)],
512                &LineStyle {
513                    color: axis_ticks.color,
514                    width: axis_ticks.width,
515                    alpha: 1.0,
516                    linetype: axis_ticks.linetype.clone(),
517                    ..Default::default()
518                },
519            )?;
520        }
521    }
522
523    // `guide_axis_logticks()` (#409).
524    if axis_ticks.visible {
525        for (pos, kind) in scale.log_ticks() {
526            let py = along_y(coord, pos, plot_area);
527            let len = tick_len * [2.25, 1.5, 0.75][kind as usize];
528            backend.set_element_class(Some("gg-axis gg-axis-y gg-axis-tick"));
529            backend.draw_line(
530                &[(plot_area.x - len, py), (plot_area.x, py)],
531                &LineStyle {
532                    color: axis_ticks.color,
533                    width: axis_ticks.width,
534                    alpha: 1.0,
535                    linetype: axis_ticks.linetype.clone(),
536                    ..Default::default()
537                },
538            )?;
539        }
540    }
541
542    if with_title {
543        let reserve = y_label_reserve(theme, &breaks);
544        draw_y_axis_title(scale, theme, plot_area, reserve, backend)?;
545    }
546
547    Ok(())
548}
549
550/// Gap between the y tick labels and the y axis title (#348).
551const Y_TITLE_GAP: f64 = 4.0;
552
553/// Width the layout reserves for the y tick labels with these `breaks`
554/// (estimated from their length; never below the theme default).
555pub(crate) fn y_label_reserve(theme: &Theme, breaks: &[(f64, String)]) -> f64 {
556    let max_ch = breaks
557        .iter()
558        .map(|(_, l)| l.chars().count())
559        .max()
560        .unwrap_or(0);
561    let measured = max_ch as f64 * theme.axis_text_y.size * 0.6 + 4.0;
562    let default = theme.axis_text_y.size * 3.5 + 4.0;
563    measured.max(default)
564}
565
566/// The y axis title, left of `area`'s tick labels (`label_reserve` wide)
567/// and centred on its height.
568pub(crate) fn draw_y_axis_title(
569    scale: &dyn Scale,
570    theme: &Theme,
571    area: &Rect,
572    label_reserve: f64,
573    backend: &mut dyn DrawBackend,
574) -> Result<(), RenderError> {
575    let title = scale.name();
576    if !title.is_empty() && theme.axis_title_y.visible {
577        // The rotated (270°) title's glyphs span roughly [x − 0.75·size,
578        // x + 0.25·size]; keep its right edge a fixed gap (ggplot2's
579        // 2.75pt title margin) left of the tick-label column the layout
580        // reserved — never overlapping short labels — and on the canvas.
581        let size = theme.axis_title_y.size;
582        let right = area.x - theme.ticks_len_y() - label_reserve - Y_TITLE_GAP;
583        let min_x = backend.total_area().x + 0.75 * size;
584        let title_x = (right - 0.25 * size).max(min_x);
585        let center_y = area.y + area.height / 2.0;
586        let family = if theme.axis_title_y.family.is_empty() {
587            None
588        } else {
589            Some(theme.axis_title_y.family.clone())
590        };
591        backend.set_element_class(Some("gg-axis-title gg-axis-title-y"));
592        crate::render::rich::draw(
593            backend,
594            theme.rich_text,
595            title,
596            (title_x, center_y),
597            &TextStyle {
598                color: theme.axis_title_y.color,
599                size: theme.axis_title_y.size,
600                anchor: TextAnchor::Middle,
601                angle: 270.0,
602                family,
603                face: theme.axis_title_y.face,
604                bg_colour: None,
605                bg_r: 0.0,
606            },
607        )?;
608    }
609
610    Ok(())
611}
612
613/// Draw a secondary Y axis on the right side.
614pub fn draw_sec_y_axis(
615    primary_scale: &dyn Scale,
616    sec_axis: &crate::scale::sec_axis::SecAxis,
617    coord: &dyn Coord,
618    theme: &Theme,
619    plot_area: &Rect,
620    backend: &mut dyn DrawBackend,
621) -> Result<(), RenderError> {
622    // The right axis's labels follow `axis_text_y_right` when set (#413).
623    let right_theme;
624    let theme = match &theme.axis_text_y_right {
625        Some(el) => {
626            right_theme = Theme {
627                axis_text_y: el.clone(),
628                ..theme.clone()
629            };
630            &right_theme
631        }
632        None => theme,
633    };
634    let own_ticks = sec_axis.own_ticks(primary_scale);
635    let breaks = own_ticks.clone().unwrap_or_else(|| primary_scale.breaks());
636    let tick_len = theme.ticks_len_y();
637    let axis_line = theme.axis_line_at(crate::theme::AxisSide::Right);
638    let axis_ticks = theme.axis_ticks_at(crate::theme::AxisSide::Right);
639
640    let right_x = plot_area.x + plot_area.width;
641
642    // Axis line on right side
643    if axis_line.visible {
644        backend.set_element_class(Some("gg-axis gg-axis-y gg-axis-line"));
645        backend.draw_line(
646            &[
647                (right_x, plot_area.y),
648                (right_x, plot_area.y + plot_area.height),
649            ],
650            &LineStyle {
651                color: axis_line.color,
652                width: axis_line.width,
653                alpha: 1.0,
654                linetype: axis_line.linetype.clone(),
655                ..Default::default()
656            },
657        )?;
658    }
659
660    // Ticks and labels at primary break positions, but with transformed labels
661    for (pos, label) in &breaks {
662        let py = along_y(coord, *pos, plot_area);
663
664        if axis_ticks.visible {
665            backend.set_element_class(Some("gg-axis gg-axis-y gg-axis-tick"));
666            backend.draw_line(
667                &[(right_x, py), (right_x + tick_len, py)],
668                &LineStyle {
669                    color: axis_ticks.color,
670                    width: axis_ticks.width,
671                    alpha: 1.0,
672                    linetype: axis_ticks.linetype.clone(),
673                    ..Default::default()
674                },
675            )?;
676        }
677
678        if theme.axis_text_y.visible {
679            // Parse the primary label back to a number, transform it
680            let locale = primary_scale.locale();
681            let parsed = match locale {
682                Some(l) => l.parse_number(label),
683                None => label.parse::<f64>().ok(),
684            };
685            let sec_label = if own_ticks.is_some() {
686                label.clone()
687            } else if let Some(v) = parsed {
688                sec_axis.label(sec_axis.transform_value(v), locale)
689            } else {
690                label.clone()
691            };
692
693            let family = if theme.axis_text_y.family.is_empty() {
694                None
695            } else {
696                Some(theme.axis_text_y.family.clone())
697            };
698            backend.set_element_class(Some("gg-axis gg-axis-y gg-axis-text"));
699            backend.draw_text(
700                &sec_label,
701                (right_x + tick_len + theme.legend_spacing, py),
702                &TextStyle {
703                    color: theme.axis_text_y.color,
704                    size: theme.axis_text_y.size,
705                    anchor: TextAnchor::Start,
706                    angle: theme.axis_text_y.angle,
707                    family,
708                    face: theme.axis_text_y.face,
709                    bg_colour: None,
710                    bg_r: 0.0,
711                },
712            )?;
713        }
714    }
715
716    // Secondary axis title
717    if !sec_axis.name.is_empty() && theme.axis_title_y.visible {
718        // Clear the widest tick label (own percent ticks can exceed the
719        // 3.5-character default).
720        let max_ch = own_ticks
721            .as_ref()
722            .and_then(|t| t.iter().map(|(_, l)| l.chars().count()).max())
723            .unwrap_or(0);
724        let text_w =
725            (max_ch as f64 * theme.axis_text_y.size * 0.6).max(theme.axis_text_y.size * 3.5);
726        let title_x = right_x + tick_len + text_w + theme.legend_spacing;
727        let center_y = plot_area.y + plot_area.height / 2.0;
728        let family = if theme.axis_title_y.family.is_empty() {
729            None
730        } else {
731            Some(theme.axis_title_y.family.clone())
732        };
733        backend.set_element_class(Some("gg-axis-title gg-axis-title-y"));
734        crate::render::rich::draw(
735            backend,
736            theme.rich_text,
737            &sec_axis.name,
738            (title_x, center_y),
739            &TextStyle {
740                color: theme.axis_title_y.color,
741                size: theme.axis_title_y.size,
742                anchor: TextAnchor::Middle,
743                angle: 90.0,
744                family,
745                face: theme.axis_title_y.face,
746                bg_colour: None,
747                bg_r: 0.0,
748            },
749        )?;
750    }
751
752    Ok(())
753}
754
755/// The labels a secondary axis draws at `breaks` (its own ticks, or the
756/// primary labels run through the transform).
757fn sec_labels(
758    primary_scale: &dyn Scale,
759    sec_axis: &crate::scale::sec_axis::SecAxis,
760    breaks: &[(f64, String)],
761    own: bool,
762) -> Vec<String> {
763    let locale = primary_scale.locale();
764    breaks
765        .iter()
766        .map(|(_, label)| {
767            if own {
768                return label.clone();
769            }
770            let parsed = match locale {
771                Some(l) => l.parse_number(label),
772                None => label.parse::<f64>().ok(),
773            };
774            match parsed {
775                Some(v) => sec_axis.label(sec_axis.transform_value(v), locale),
776                None => label.clone(),
777            }
778        })
779        .collect()
780}
781
782/// The height a secondary x axis takes above the panel: ticks, one line of
783/// labels and its title (#409). Shared by the layout reservation and
784/// [`draw_sec_x_axis`].
785pub(crate) fn sec_x_axis_height(theme: &Theme, sec_axis: &crate::scale::sec_axis::SecAxis) -> f64 {
786    theme.ticks_len_x()
787        + if theme.axis_text_x.visible {
788            theme.axis_text_x.size + 4.0
789        } else {
790            0.0
791        }
792        + if !sec_axis.name.is_empty() && theme.axis_title_x.visible {
793            theme.axis_title_x.size + 6.0
794        } else {
795            0.0
796        }
797}
798
799/// Draw a secondary x axis along the top of the panel (ggplot2's
800/// `scale_x_continuous(sec.axis = …)` / `dup_axis()`, #409).
801pub fn draw_sec_x_axis(
802    primary_scale: &dyn Scale,
803    sec_axis: &crate::scale::sec_axis::SecAxis,
804    coord: &dyn Coord,
805    theme: &Theme,
806    plot_area: &Rect,
807    backend: &mut dyn DrawBackend,
808) -> Result<(), RenderError> {
809    let own_ticks = sec_axis.own_ticks(primary_scale);
810    let breaks = own_ticks.clone().unwrap_or_else(|| primary_scale.breaks());
811    let labels = sec_labels(primary_scale, sec_axis, &breaks, own_ticks.is_some());
812    let tick_len = theme.ticks_len_x();
813    let axis_line = theme.axis_line_at(crate::theme::AxisSide::Top);
814    let axis_ticks = theme.axis_ticks_at(crate::theme::AxisSide::Top);
815    let top_y = plot_area.y;
816    let line = |el: &crate::theme::elements::ElementLine| LineStyle {
817        color: el.color,
818        width: el.width,
819        alpha: 1.0,
820        linetype: el.linetype.clone(),
821        ..Default::default()
822    };
823    if axis_line.visible {
824        backend.set_element_class(Some("gg-axis gg-axis-x gg-axis-line"));
825        backend.draw_line(
826            &[(plot_area.x, top_y), (plot_area.x + plot_area.width, top_y)],
827            &line(axis_line),
828        )?;
829    }
830    let family = |f: &str| (!f.is_empty()).then(|| f.to_string());
831    for ((pos, _), label) in breaks.iter().zip(&labels) {
832        let px = along_x(coord, *pos, plot_area);
833        if axis_ticks.visible {
834            backend.set_element_class(Some("gg-axis gg-axis-x gg-axis-tick"));
835            backend.draw_line(&[(px, top_y), (px, top_y - tick_len)], &line(axis_ticks))?;
836        }
837        if theme.axis_text_x.visible {
838            backend.set_element_class(Some("gg-axis gg-axis-x gg-axis-text"));
839            backend.draw_text(
840                label,
841                (px, top_y - tick_len - 2.0 - theme.axis_text_x.size / 2.0),
842                &TextStyle {
843                    color: theme.axis_text_x.color,
844                    size: theme.axis_text_x.size,
845                    anchor: TextAnchor::Middle,
846                    angle: 0.0,
847                    family: family(&theme.axis_text_x.family),
848                    face: theme.axis_text_x.face,
849                    bg_colour: None,
850                    bg_r: 0.0,
851                },
852            )?;
853        }
854    }
855    if !sec_axis.name.is_empty() && theme.axis_title_x.visible {
856        let y = top_y - sec_x_axis_height(theme, sec_axis) + theme.axis_title_x.size / 2.0;
857        backend.set_element_class(Some("gg-axis-title gg-axis-title-x"));
858        crate::render::rich::draw(
859            backend,
860            theme.rich_text,
861            &sec_axis.name,
862            (plot_area.x + plot_area.width / 2.0, y),
863            &TextStyle {
864                color: theme.axis_title_x.color,
865                size: theme.axis_title_x.size,
866                anchor: TextAnchor::Middle,
867                angle: 0.0,
868                family: family(&theme.axis_title_x.family),
869                face: theme.axis_title_x.face,
870                bg_colour: None,
871                bg_r: 0.0,
872            },
873        )?;
874    }
875    Ok(())
876}
877
878/// Compute minor break positions as midpoints between major breaks.
879fn minor_breaks(major: &[(f64, String)]) -> Vec<f64> {
880    if major.len() < 2 {
881        return vec![];
882    }
883    let mut minors = Vec::with_capacity(major.len() - 1);
884    for pair in major.windows(2) {
885        minors.push((pair[0].0 + pair[1].0) / 2.0);
886    }
887    minors
888}
889
890/// Draw gridlines for both axes.
891pub fn draw_gridlines(
892    x_scale: &dyn Scale,
893    y_scale: &dyn Scale,
894    coord: &dyn Coord,
895    theme: &Theme,
896    plot_area: &Rect,
897    backend: &mut dyn DrawBackend,
898) -> Result<(), RenderError> {
899    let major_x = theme.get_panel_grid_major_x();
900    let major_y = theme.get_panel_grid_major_y();
901    let minor_x = theme.get_panel_grid_minor_x();
902    let minor_y = theme.get_panel_grid_minor_y();
903
904    let x_breaks = x_scale.breaks();
905    let y_breaks = y_scale.breaks();
906
907    // Minor X gridlines (vertical) — drawn first so majors paint over them
908    if minor_x.visible {
909        for pos in minor_breaks(&x_breaks) {
910            let px = along_x(coord, pos, plot_area);
911            backend.set_element_class(Some("gg-grid gg-grid-minor gg-grid-x"));
912            backend.draw_line(
913                &[(px, plot_area.y), (px, plot_area.y + plot_area.height)],
914                &LineStyle {
915                    color: minor_x.color,
916                    width: minor_x.width,
917                    alpha: 1.0,
918                    linetype: minor_x.linetype.clone(),
919                    ..Default::default()
920                },
921            )?;
922        }
923    }
924
925    // Minor Y gridlines (horizontal)
926    if minor_y.visible {
927        for pos in minor_breaks(&y_breaks) {
928            let py = along_y(coord, pos, plot_area);
929            backend.set_element_class(Some("gg-grid gg-grid-minor gg-grid-y"));
930            backend.draw_line(
931                &[(plot_area.x, py), (plot_area.x + plot_area.width, py)],
932                &LineStyle {
933                    color: minor_y.color,
934                    width: minor_y.width,
935                    alpha: 1.0,
936                    linetype: minor_y.linetype.clone(),
937                    ..Default::default()
938                },
939            )?;
940        }
941    }
942
943    // Major X gridlines (vertical)
944    if major_x.visible {
945        for (pos, _) in &x_breaks {
946            let px = along_x(coord, *pos, plot_area);
947            backend.set_element_class(Some("gg-grid gg-grid-major gg-grid-x"));
948            backend.draw_line(
949                &[(px, plot_area.y), (px, plot_area.y + plot_area.height)],
950                &LineStyle {
951                    color: major_x.color,
952                    width: major_x.width,
953                    alpha: 1.0,
954                    linetype: major_x.linetype.clone(),
955                    ..Default::default()
956                },
957            )?;
958        }
959    }
960
961    // Major Y gridlines (horizontal)
962    if major_y.visible {
963        for (pos, _) in &y_breaks {
964            let py = along_y(coord, *pos, plot_area);
965            backend.set_element_class(Some("gg-grid gg-grid-major gg-grid-y"));
966            backend.draw_line(
967                &[(plot_area.x, py), (plot_area.x + plot_area.width, py)],
968                &LineStyle {
969                    color: major_y.color,
970                    width: major_y.width,
971                    alpha: 1.0,
972                    linetype: major_y.linetype.clone(),
973                    ..Default::default()
974                },
975            )?;
976        }
977    }
978
979    Ok(())
980}
981
982#[cfg(test)]
983mod tests {
984    use super::*;
985    use crate::aes::Aesthetic;
986    use crate::coord::cartesian::CoordCartesian;
987    use crate::data::Value;
988    use crate::render::svg_backend::SvgBackend;
989    use crate::theme::presets::theme_gray;
990
991    /// A scale with hand-picked break positions/labels, so a test can place
992    /// one off the canvas without fighting a real break algorithm.
993    struct FixedBreaksScale(Vec<(f64, String)>);
994
995    impl Scale for FixedBreaksScale {
996        fn aesthetic(&self) -> Aesthetic {
997            Aesthetic::X
998        }
999        fn train(&mut self, _values: &[Value]) {}
1000        fn map(&self, _value: &Value) -> f64 {
1001            0.0
1002        }
1003        fn breaks(&self) -> Vec<(f64, String)> {
1004            self.0.clone()
1005        }
1006        fn name(&self) -> &str {
1007            ""
1008        }
1009        fn set_name(&mut self, _name: &str) {}
1010        fn clone_box(&self) -> Box<dyn Scale> {
1011            Box::new(FixedBreaksScale(self.0.clone()))
1012        }
1013    }
1014
1015    fn backend(w: u32, h: u32, plot_area: Rect) -> SvgBackend {
1016        SvgBackend::new(w, h, plot_area)
1017    }
1018
1019    #[test]
1020    fn a_tick_outside_the_svg_is_dropped() {
1021        // Normalized position 5.0 is nowhere near [0, 1]; through
1022        // `CoordCartesian` that lands far outside the 200×100 canvas.
1023        let scale = FixedBreaksScale(vec![
1024            (0.2, "kept".to_string()),
1025            (5.0, "off_canvas".to_string()),
1026        ]);
1027        let coord = CoordCartesian::new();
1028        let theme = theme_gray();
1029        let plot_area = Rect {
1030            x: 10.0,
1031            y: 10.0,
1032            width: 180.0,
1033            height: 70.0,
1034        };
1035        let canvas = Rect {
1036            x: 0.0,
1037            y: 0.0,
1038            width: 200.0,
1039            height: 100.0,
1040        };
1041        let mut backend = backend(200, 100, plot_area.clone());
1042        draw_x_axis(&scale, &coord, &theme, &plot_area, &canvas, &mut backend).unwrap();
1043        let svg = backend.finish();
1044        assert!(svg.contains(">kept<"), "on-canvas label dropped: {svg}");
1045        assert!(
1046            !svg.contains(">off_canvas<"),
1047            "off-canvas label was drawn: {svg}"
1048        );
1049    }
1050
1051    #[test]
1052    fn clip_draws_the_label_exactly_at_the_tick_even_past_the_edge() {
1053        // A very wide label at the rightmost tick, Clip mode: no shift.
1054        let scale = FixedBreaksScale(vec![
1055            (0.0, "a".to_string()),
1056            (1.0, "2021-01-01".to_string()),
1057        ]);
1058        let coord = CoordCartesian::new();
1059        let mut theme = theme_gray();
1060        theme.axis_text_x_fit = crate::theme::AxisLabelFit::Clip;
1061        let plot_area = Rect {
1062            x: 0.0,
1063            y: 0.0,
1064            width: 60.0,
1065            height: 50.0,
1066        };
1067        let canvas = plot_area.clone();
1068        let mut backend = backend(60, 50, plot_area.clone());
1069        draw_x_axis(&scale, &coord, &theme, &plot_area, &canvas, &mut backend).unwrap();
1070        let svg = backend.finish();
1071        // Clip: the label sits exactly on the tick (x="60.00"), unmoved,
1072        // even though its bbox clearly overflows a 60px canvas.
1073        assert!(
1074            svg.contains("x=\"60.00\" y=\"") && svg.contains(">2021-01-01<"),
1075            "clip mode should not shift the label: {svg}"
1076        );
1077    }
1078
1079    #[test]
1080    fn shift_moves_the_same_label_inside_the_canvas() {
1081        let scale = FixedBreaksScale(vec![
1082            (0.0, "a".to_string()),
1083            (1.0, "2021-01-01".to_string()),
1084        ]);
1085        let coord = CoordCartesian::new();
1086        let mut theme = theme_gray();
1087        theme.axis_text_x_fit = crate::theme::AxisLabelFit::Shift;
1088        let plot_area = Rect {
1089            x: 0.0,
1090            y: 0.0,
1091            width: 60.0,
1092            height: 50.0,
1093        };
1094        let canvas = plot_area.clone();
1095        let mut backend = backend(60, 50, plot_area.clone());
1096        draw_x_axis(&scale, &coord, &theme, &plot_area, &canvas, &mut backend).unwrap();
1097        let svg = backend.finish();
1098        assert!(
1099            !svg.contains("x=\"60.00\" y=\""),
1100            "shift mode should move the label off the bare tick: {svg}"
1101        );
1102        let w = crate::render::text::text_width("2021-01-01", theme.axis_text_x.size);
1103        // The shifted label's right edge must land inside the canvas.
1104        let needle = ">2021-01-01<";
1105        let tag_end = svg.find(needle).unwrap();
1106        let tag_start = svg[..tag_end].rfind("<text ").unwrap();
1107        let tag = &svg[tag_start..tag_end];
1108        let x_attr = tag.rsplit("x=\"").next().unwrap();
1109        let x: f64 = x_attr.split('"').next().unwrap().parse().unwrap();
1110        // `x` round-trips through a `"{:.2}"`-formatted SVG attribute, so
1111        // allow a hair of slack for that rounding.
1112        assert!(
1113            x + w / 2.0 <= 60.0 - 1.0 + 0.01,
1114            "right edge still overflows: x={x} w={w}"
1115        );
1116    }
1117}