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