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