Skip to main content

ggplot_rs/guide/
axis.rs

1use crate::coord::Coord;
2use crate::render::backend::{DrawBackend, LineStyle, 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: axis_line.linetype,
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: axis_ticks.linetype,
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                    face: theme.axis_text_x.face,
94                },
95            )?;
96        }
97    }
98
99    // Minor ticks between majors.
100    if theme.axis_minor_ticks && axis_ticks.visible {
101        for pos in minor_breaks(&breaks) {
102            let (px, _) = coord.transform((pos, 0.0), plot_area);
103            backend.draw_line(
104                &[(px, edge_y), (px, edge_y + dir * tick_len * 0.5)],
105                &LineStyle {
106                    color: axis_ticks.color,
107                    width: axis_ticks.width,
108                    alpha: 1.0,
109                    linetype: axis_ticks.linetype,
110                },
111            )?;
112        }
113    }
114
115    // Axis title
116    let title = scale.name();
117    if !title.is_empty() && theme.axis_title_x.visible {
118        let hjust = theme.axis_title_x.hjust.clamp(0.0, 1.0);
119        let center_x = plot_area.x + hjust * plot_area.width;
120        let anchor = if hjust <= 0.02 {
121            TextAnchor::Start
122        } else if hjust >= 0.98 {
123            TextAnchor::End
124        } else {
125            TextAnchor::Middle
126        };
127        let title_y = edge_y
128            + dir * (tick_len + theme.axis_text_x.size + 8.0 + theme.axis_title_x.size / 2.0);
129        let family = if theme.axis_title_x.family.is_empty() {
130            None
131        } else {
132            Some(theme.axis_title_x.family.clone())
133        };
134        backend.draw_text(
135            title,
136            (center_x, title_y),
137            &TextStyle {
138                color: theme.axis_title_x.color,
139                size: theme.axis_title_x.size,
140                anchor,
141                angle: 0.0,
142                family,
143                face: theme.axis_title_x.face,
144            },
145        )?;
146    }
147
148    Ok(())
149}
150
151/// Draw the Y axis: ticks, labels, and title.
152pub fn draw_y_axis(
153    scale: &dyn Scale,
154    coord: &dyn Coord,
155    theme: &Theme,
156    plot_area: &Rect,
157    backend: &mut dyn DrawBackend,
158) -> Result<(), RenderError> {
159    let breaks = scale.breaks();
160    let tick_len = theme.axis_ticks_length;
161    let axis_line = theme.get_axis_line_y();
162    let axis_ticks = theme.get_axis_ticks_y();
163
164    // Axis line
165    if axis_line.visible {
166        let top = (plot_area.x, plot_area.y);
167        let bottom = (plot_area.x, plot_area.y + plot_area.height);
168        backend.draw_line(
169            &[top, bottom],
170            &LineStyle {
171                color: axis_line.color,
172                width: axis_line.width,
173                alpha: 1.0,
174                linetype: axis_line.linetype,
175            },
176        )?;
177    }
178
179    // Ticks and labels
180    for (pos, label) in &breaks {
181        let (_, py) = coord.transform((0.0, *pos), plot_area);
182
183        if axis_ticks.visible {
184            backend.draw_line(
185                &[(plot_area.x - tick_len, py), (plot_area.x, py)],
186                &LineStyle {
187                    color: axis_ticks.color,
188                    width: axis_ticks.width,
189                    alpha: 1.0,
190                    linetype: axis_ticks.linetype,
191                },
192            )?;
193        }
194
195        if theme.axis_text_y.visible {
196            let family = if theme.axis_text_y.family.is_empty() {
197                None
198            } else {
199                Some(theme.axis_text_y.family.clone())
200            };
201            backend.draw_text(
202                label,
203                (plot_area.x - tick_len - theme.legend_spacing, py),
204                &TextStyle {
205                    color: theme.axis_text_y.color,
206                    size: theme.axis_text_y.size,
207                    anchor: TextAnchor::End,
208                    angle: theme.axis_text_y.angle,
209                    family,
210                    face: theme.axis_text_y.face,
211                },
212            )?;
213        }
214    }
215
216    // Minor ticks between majors.
217    if theme.axis_minor_ticks && axis_ticks.visible {
218        for pos in minor_breaks(&breaks) {
219            let (_, py) = coord.transform((0.0, pos), plot_area);
220            backend.draw_line(
221                &[(plot_area.x - tick_len * 0.5, py), (plot_area.x, py)],
222                &LineStyle {
223                    color: axis_ticks.color,
224                    width: axis_ticks.width,
225                    alpha: 1.0,
226                    linetype: axis_ticks.linetype,
227                },
228            )?;
229        }
230    }
231
232    // Axis title
233    let title = scale.name();
234    if !title.is_empty() && theme.axis_title_y.visible {
235        let title_x = plot_area.x - tick_len - theme.axis_text_y.size * 3.5 - theme.legend_spacing;
236        let center_y = plot_area.y + plot_area.height / 2.0;
237        let family = if theme.axis_title_y.family.is_empty() {
238            None
239        } else {
240            Some(theme.axis_title_y.family.clone())
241        };
242        backend.draw_text(
243            title,
244            (title_x, center_y),
245            &TextStyle {
246                color: theme.axis_title_y.color,
247                size: theme.axis_title_y.size,
248                anchor: TextAnchor::Middle,
249                angle: 270.0,
250                family,
251                face: theme.axis_title_y.face,
252            },
253        )?;
254    }
255
256    Ok(())
257}
258
259/// Draw a secondary Y axis on the right side.
260pub fn draw_sec_y_axis(
261    primary_scale: &dyn Scale,
262    sec_axis: &crate::scale::sec_axis::SecAxis,
263    coord: &dyn Coord,
264    theme: &Theme,
265    plot_area: &Rect,
266    backend: &mut dyn DrawBackend,
267) -> Result<(), RenderError> {
268    let breaks = primary_scale.breaks();
269    let tick_len = theme.axis_ticks_length;
270    let axis_line = theme.get_axis_line_y();
271    let axis_ticks = theme.get_axis_ticks_y();
272
273    let right_x = plot_area.x + plot_area.width;
274
275    // Axis line on right side
276    if axis_line.visible {
277        backend.draw_line(
278            &[
279                (right_x, plot_area.y),
280                (right_x, plot_area.y + plot_area.height),
281            ],
282            &LineStyle {
283                color: axis_line.color,
284                width: axis_line.width,
285                alpha: 1.0,
286                linetype: axis_line.linetype,
287            },
288        )?;
289    }
290
291    // Ticks and labels at primary break positions, but with transformed labels
292    for (pos, label) in &breaks {
293        let (_, py) = coord.transform((0.0, *pos), plot_area);
294
295        if axis_ticks.visible {
296            backend.draw_line(
297                &[(right_x, py), (right_x + tick_len, py)],
298                &LineStyle {
299                    color: axis_ticks.color,
300                    width: axis_ticks.width,
301                    alpha: 1.0,
302                    linetype: axis_ticks.linetype,
303                },
304            )?;
305        }
306
307        if theme.axis_text_y.visible {
308            // Parse the primary label back to a number, transform it
309            let sec_label = if let Ok(v) = label.parse::<f64>() {
310                let transformed = sec_axis.transform_value(v);
311                crate::scale::util::format_number(transformed)
312            } else {
313                label.clone()
314            };
315
316            let family = if theme.axis_text_y.family.is_empty() {
317                None
318            } else {
319                Some(theme.axis_text_y.family.clone())
320            };
321            backend.draw_text(
322                &sec_label,
323                (right_x + tick_len + theme.legend_spacing, py),
324                &TextStyle {
325                    color: theme.axis_text_y.color,
326                    size: theme.axis_text_y.size,
327                    anchor: TextAnchor::Start,
328                    angle: theme.axis_text_y.angle,
329                    family,
330                    face: theme.axis_text_y.face,
331                },
332            )?;
333        }
334    }
335
336    // Secondary axis title
337    if !sec_axis.name.is_empty() && theme.axis_title_y.visible {
338        let title_x = right_x + tick_len + theme.axis_text_y.size * 3.5 + theme.legend_spacing;
339        let center_y = plot_area.y + plot_area.height / 2.0;
340        let family = if theme.axis_title_y.family.is_empty() {
341            None
342        } else {
343            Some(theme.axis_title_y.family.clone())
344        };
345        backend.draw_text(
346            &sec_axis.name,
347            (title_x, center_y),
348            &TextStyle {
349                color: theme.axis_title_y.color,
350                size: theme.axis_title_y.size,
351                anchor: TextAnchor::Middle,
352                angle: 90.0,
353                family,
354                face: theme.axis_title_y.face,
355            },
356        )?;
357    }
358
359    Ok(())
360}
361
362/// Compute minor break positions as midpoints between major breaks.
363fn minor_breaks(major: &[(f64, String)]) -> Vec<f64> {
364    if major.len() < 2 {
365        return vec![];
366    }
367    let mut minors = Vec::with_capacity(major.len() - 1);
368    for pair in major.windows(2) {
369        minors.push((pair[0].0 + pair[1].0) / 2.0);
370    }
371    minors
372}
373
374/// Draw gridlines for both axes.
375pub fn draw_gridlines(
376    x_scale: &dyn Scale,
377    y_scale: &dyn Scale,
378    coord: &dyn Coord,
379    theme: &Theme,
380    plot_area: &Rect,
381    backend: &mut dyn DrawBackend,
382) -> Result<(), RenderError> {
383    let major_x = theme.get_panel_grid_major_x();
384    let major_y = theme.get_panel_grid_major_y();
385    let minor_x = theme.get_panel_grid_minor_x();
386    let minor_y = theme.get_panel_grid_minor_y();
387
388    let x_breaks = x_scale.breaks();
389    let y_breaks = y_scale.breaks();
390
391    // Minor X gridlines (vertical) — drawn first so majors paint over them
392    if minor_x.visible {
393        for pos in minor_breaks(&x_breaks) {
394            let (px, _) = coord.transform((pos, 0.0), plot_area);
395            backend.draw_line(
396                &[(px, plot_area.y), (px, plot_area.y + plot_area.height)],
397                &LineStyle {
398                    color: minor_x.color,
399                    width: minor_x.width,
400                    alpha: 1.0,
401                    linetype: minor_x.linetype,
402                },
403            )?;
404        }
405    }
406
407    // Minor Y gridlines (horizontal)
408    if minor_y.visible {
409        for pos in minor_breaks(&y_breaks) {
410            let (_, py) = coord.transform((0.0, pos), plot_area);
411            backend.draw_line(
412                &[(plot_area.x, py), (plot_area.x + plot_area.width, py)],
413                &LineStyle {
414                    color: minor_y.color,
415                    width: minor_y.width,
416                    alpha: 1.0,
417                    linetype: minor_y.linetype,
418                },
419            )?;
420        }
421    }
422
423    // Major X gridlines (vertical)
424    if major_x.visible {
425        for (pos, _) in &x_breaks {
426            let (px, _) = coord.transform((*pos, 0.0), plot_area);
427            backend.draw_line(
428                &[(px, plot_area.y), (px, plot_area.y + plot_area.height)],
429                &LineStyle {
430                    color: major_x.color,
431                    width: major_x.width,
432                    alpha: 1.0,
433                    linetype: major_x.linetype,
434                },
435            )?;
436        }
437    }
438
439    // Major Y gridlines (horizontal)
440    if major_y.visible {
441        for (pos, _) in &y_breaks {
442            let (_, py) = coord.transform((0.0, *pos), plot_area);
443            backend.draw_line(
444                &[(plot_area.x, py), (plot_area.x + plot_area.width, py)],
445                &LineStyle {
446                    color: major_y.color,
447                    width: major_y.width,
448                    alpha: 1.0,
449                    linetype: major_y.linetype,
450                },
451            )?;
452        }
453    }
454
455    Ok(())
456}