Skip to main content

ggplot_rs/guide/
axis.rs

1use crate::coord::Coord;
2use crate::render::backend::{DrawBackend, LineStyle, Linetype, TextAnchor, TextStyle};
3use crate::render::{Rect, RenderError};
4use crate::scale::Scale;
5use crate::theme::Theme;
6
7/// Draw the X axis: ticks, labels, and title.
8pub fn draw_x_axis(
9    scale: &dyn Scale,
10    coord: &dyn Coord,
11    theme: &Theme,
12    plot_area: &Rect,
13    backend: &mut dyn DrawBackend,
14) -> Result<(), RenderError> {
15    let breaks = scale.breaks();
16    let tick_len = theme.axis_ticks_length;
17    let axis_line = theme.get_axis_line_x();
18    let axis_ticks = theme.get_axis_ticks_x();
19
20    // Axis position: bottom (default) or top. `dir` points away from the panel.
21    let top = scale.axis_position_opposite();
22    let edge_y = if top {
23        plot_area.y
24    } else {
25        plot_area.y + plot_area.height
26    };
27    let dir = if top { -1.0 } else { 1.0 };
28
29    // Axis line
30    if axis_line.visible {
31        backend.draw_line(
32            &[
33                (plot_area.x, edge_y),
34                (plot_area.x + plot_area.width, edge_y),
35            ],
36            &LineStyle {
37                color: axis_line.color,
38                width: axis_line.width,
39                alpha: 1.0,
40                linetype: Linetype::Solid,
41            },
42        )?;
43    }
44
45    // Ticks and labels
46    let dodge = theme.axis_text_x_dodge.max(1);
47    for (i, (pos, label)) in breaks.iter().enumerate() {
48        let (px, _py) = coord.transform((*pos, 0.0), plot_area);
49
50        if axis_ticks.visible {
51            backend.draw_line(
52                &[(px, edge_y), (px, edge_y + dir * tick_len)],
53                &LineStyle {
54                    color: axis_ticks.color,
55                    width: axis_ticks.width,
56                    alpha: 1.0,
57                    linetype: Linetype::Solid,
58                },
59            )?;
60        }
61
62        if theme.axis_text_x.visible {
63            let family = if theme.axis_text_x.family.is_empty() {
64                None
65            } else {
66                Some(theme.axis_text_x.family.clone())
67            };
68            // Adjust anchor for rotated labels
69            let anchor = if theme.axis_text_x.angle.abs() > 10.0 {
70                TextAnchor::End
71            } else {
72                TextAnchor::Middle
73            };
74            // Stagger labels across `dodge` rows to avoid overlap.
75            let row_offset = (i % dodge) as f64 * (theme.axis_text_x.size + 2.0);
76            backend.draw_text(
77                label,
78                (
79                    px,
80                    edge_y
81                        + dir
82                            * (tick_len
83                                + theme.legend_spacing / 2.0
84                                + theme.axis_text_x.size / 2.0
85                                + row_offset),
86                ),
87                &TextStyle {
88                    color: theme.axis_text_x.color,
89                    size: theme.axis_text_x.size,
90                    anchor,
91                    angle: theme.axis_text_x.angle,
92                    family,
93                },
94            )?;
95        }
96    }
97
98    // Axis title
99    let title = scale.name();
100    if !title.is_empty() && theme.axis_title_x.visible {
101        let center_x = plot_area.x + plot_area.width / 2.0;
102        let title_y = edge_y
103            + dir * (tick_len + theme.axis_text_x.size + 8.0 + theme.axis_title_x.size / 2.0);
104        let family = if theme.axis_title_x.family.is_empty() {
105            None
106        } else {
107            Some(theme.axis_title_x.family.clone())
108        };
109        backend.draw_text(
110            title,
111            (center_x, title_y),
112            &TextStyle {
113                color: theme.axis_title_x.color,
114                size: theme.axis_title_x.size,
115                anchor: TextAnchor::Middle,
116                angle: 0.0,
117                family,
118            },
119        )?;
120    }
121
122    Ok(())
123}
124
125/// Draw the Y axis: ticks, labels, and title.
126pub fn draw_y_axis(
127    scale: &dyn Scale,
128    coord: &dyn Coord,
129    theme: &Theme,
130    plot_area: &Rect,
131    backend: &mut dyn DrawBackend,
132) -> Result<(), RenderError> {
133    let breaks = scale.breaks();
134    let tick_len = theme.axis_ticks_length;
135    let axis_line = theme.get_axis_line_y();
136    let axis_ticks = theme.get_axis_ticks_y();
137
138    // Axis line
139    if axis_line.visible {
140        let top = (plot_area.x, plot_area.y);
141        let bottom = (plot_area.x, plot_area.y + plot_area.height);
142        backend.draw_line(
143            &[top, bottom],
144            &LineStyle {
145                color: axis_line.color,
146                width: axis_line.width,
147                alpha: 1.0,
148                linetype: Linetype::Solid,
149            },
150        )?;
151    }
152
153    // Ticks and labels
154    for (pos, label) in &breaks {
155        let (_, py) = coord.transform((0.0, *pos), plot_area);
156
157        if axis_ticks.visible {
158            backend.draw_line(
159                &[(plot_area.x - tick_len, py), (plot_area.x, py)],
160                &LineStyle {
161                    color: axis_ticks.color,
162                    width: axis_ticks.width,
163                    alpha: 1.0,
164                    linetype: Linetype::Solid,
165                },
166            )?;
167        }
168
169        if theme.axis_text_y.visible {
170            let family = if theme.axis_text_y.family.is_empty() {
171                None
172            } else {
173                Some(theme.axis_text_y.family.clone())
174            };
175            backend.draw_text(
176                label,
177                (plot_area.x - tick_len - theme.legend_spacing, py),
178                &TextStyle {
179                    color: theme.axis_text_y.color,
180                    size: theme.axis_text_y.size,
181                    anchor: TextAnchor::End,
182                    angle: theme.axis_text_y.angle,
183                    family,
184                },
185            )?;
186        }
187    }
188
189    // Axis title
190    let title = scale.name();
191    if !title.is_empty() && theme.axis_title_y.visible {
192        let title_x = plot_area.x - tick_len - theme.axis_text_y.size * 3.5 - theme.legend_spacing;
193        let center_y = plot_area.y + plot_area.height / 2.0;
194        let family = if theme.axis_title_y.family.is_empty() {
195            None
196        } else {
197            Some(theme.axis_title_y.family.clone())
198        };
199        backend.draw_text(
200            title,
201            (title_x, center_y),
202            &TextStyle {
203                color: theme.axis_title_y.color,
204                size: theme.axis_title_y.size,
205                anchor: TextAnchor::Middle,
206                angle: 270.0,
207                family,
208            },
209        )?;
210    }
211
212    Ok(())
213}
214
215/// Draw a secondary Y axis on the right side.
216pub fn draw_sec_y_axis(
217    primary_scale: &dyn Scale,
218    sec_axis: &crate::scale::sec_axis::SecAxis,
219    coord: &dyn Coord,
220    theme: &Theme,
221    plot_area: &Rect,
222    backend: &mut dyn DrawBackend,
223) -> Result<(), RenderError> {
224    let breaks = primary_scale.breaks();
225    let tick_len = theme.axis_ticks_length;
226    let axis_line = theme.get_axis_line_y();
227    let axis_ticks = theme.get_axis_ticks_y();
228
229    let right_x = plot_area.x + plot_area.width;
230
231    // Axis line on right side
232    if axis_line.visible {
233        backend.draw_line(
234            &[
235                (right_x, plot_area.y),
236                (right_x, plot_area.y + plot_area.height),
237            ],
238            &LineStyle {
239                color: axis_line.color,
240                width: axis_line.width,
241                alpha: 1.0,
242                linetype: Linetype::Solid,
243            },
244        )?;
245    }
246
247    // Ticks and labels at primary break positions, but with transformed labels
248    for (pos, label) in &breaks {
249        let (_, py) = coord.transform((0.0, *pos), plot_area);
250
251        if axis_ticks.visible {
252            backend.draw_line(
253                &[(right_x, py), (right_x + tick_len, py)],
254                &LineStyle {
255                    color: axis_ticks.color,
256                    width: axis_ticks.width,
257                    alpha: 1.0,
258                    linetype: Linetype::Solid,
259                },
260            )?;
261        }
262
263        if theme.axis_text_y.visible {
264            // Parse the primary label back to a number, transform it
265            let sec_label = if let Ok(v) = label.parse::<f64>() {
266                let transformed = sec_axis.transform_value(v);
267                crate::scale::util::format_number(transformed)
268            } else {
269                label.clone()
270            };
271
272            let family = if theme.axis_text_y.family.is_empty() {
273                None
274            } else {
275                Some(theme.axis_text_y.family.clone())
276            };
277            backend.draw_text(
278                &sec_label,
279                (right_x + tick_len + theme.legend_spacing, py),
280                &TextStyle {
281                    color: theme.axis_text_y.color,
282                    size: theme.axis_text_y.size,
283                    anchor: TextAnchor::Start,
284                    angle: theme.axis_text_y.angle,
285                    family,
286                },
287            )?;
288        }
289    }
290
291    // Secondary axis title
292    if !sec_axis.name.is_empty() && theme.axis_title_y.visible {
293        let title_x = right_x + tick_len + theme.axis_text_y.size * 3.5 + theme.legend_spacing;
294        let center_y = plot_area.y + plot_area.height / 2.0;
295        let family = if theme.axis_title_y.family.is_empty() {
296            None
297        } else {
298            Some(theme.axis_title_y.family.clone())
299        };
300        backend.draw_text(
301            &sec_axis.name,
302            (title_x, center_y),
303            &TextStyle {
304                color: theme.axis_title_y.color,
305                size: theme.axis_title_y.size,
306                anchor: TextAnchor::Middle,
307                angle: 90.0,
308                family,
309            },
310        )?;
311    }
312
313    Ok(())
314}
315
316/// Compute minor break positions as midpoints between major breaks.
317fn minor_breaks(major: &[(f64, String)]) -> Vec<f64> {
318    if major.len() < 2 {
319        return vec![];
320    }
321    let mut minors = Vec::with_capacity(major.len() - 1);
322    for pair in major.windows(2) {
323        minors.push((pair[0].0 + pair[1].0) / 2.0);
324    }
325    minors
326}
327
328/// Draw gridlines for both axes.
329pub fn draw_gridlines(
330    x_scale: &dyn Scale,
331    y_scale: &dyn Scale,
332    coord: &dyn Coord,
333    theme: &Theme,
334    plot_area: &Rect,
335    backend: &mut dyn DrawBackend,
336) -> Result<(), RenderError> {
337    let major_x = theme.get_panel_grid_major_x();
338    let major_y = theme.get_panel_grid_major_y();
339    let minor_x = theme.get_panel_grid_minor_x();
340    let minor_y = theme.get_panel_grid_minor_y();
341
342    let x_breaks = x_scale.breaks();
343    let y_breaks = y_scale.breaks();
344
345    // Minor X gridlines (vertical) — drawn first so majors paint over them
346    if minor_x.visible {
347        for pos in minor_breaks(&x_breaks) {
348            let (px, _) = coord.transform((pos, 0.0), plot_area);
349            backend.draw_line(
350                &[(px, plot_area.y), (px, plot_area.y + plot_area.height)],
351                &LineStyle {
352                    color: minor_x.color,
353                    width: minor_x.width,
354                    alpha: 1.0,
355                    linetype: Linetype::Solid,
356                },
357            )?;
358        }
359    }
360
361    // Minor Y gridlines (horizontal)
362    if minor_y.visible {
363        for pos in minor_breaks(&y_breaks) {
364            let (_, py) = coord.transform((0.0, pos), plot_area);
365            backend.draw_line(
366                &[(plot_area.x, py), (plot_area.x + plot_area.width, py)],
367                &LineStyle {
368                    color: minor_y.color,
369                    width: minor_y.width,
370                    alpha: 1.0,
371                    linetype: Linetype::Solid,
372                },
373            )?;
374        }
375    }
376
377    // Major X gridlines (vertical)
378    if major_x.visible {
379        for (pos, _) in &x_breaks {
380            let (px, _) = coord.transform((*pos, 0.0), plot_area);
381            backend.draw_line(
382                &[(px, plot_area.y), (px, plot_area.y + plot_area.height)],
383                &LineStyle {
384                    color: major_x.color,
385                    width: major_x.width,
386                    alpha: 1.0,
387                    linetype: Linetype::Solid,
388                },
389            )?;
390        }
391    }
392
393    // Major Y gridlines (horizontal)
394    if major_y.visible {
395        for (pos, _) in &y_breaks {
396            let (_, py) = coord.transform((0.0, *pos), plot_area);
397            backend.draw_line(
398                &[(plot_area.x, py), (plot_area.x + plot_area.width, py)],
399                &LineStyle {
400                    color: major_y.color,
401                    width: major_y.width,
402                    alpha: 1.0,
403                    linetype: Linetype::Solid,
404                },
405            )?;
406        }
407    }
408
409    Ok(())
410}