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