Skip to main content

ggplot_rs/guide/
axis.rs

1use crate::coord::Coord;
2use crate::render::backend::{DrawBackend, LineStyle, TextAnchor, TextStyle};
3use crate::render::text::{axis_label_priority, label_bbox};
4use crate::render::{Rect, RenderError};
5use crate::scale::Scale;
6use crate::theme::{AxisLabelFit, Theme};
7
8/// Horizontal pixel for a break at normalized `pos` along the *horizontal* axis.
9/// `coord_flip` swaps which normalized component drives horizontal position, so
10/// route through the coord with the component that lands on x either way.
11fn along_x(coord: &dyn Coord, pos: f64, area: &Rect) -> f64 {
12    let p = if coord.is_flipped() {
13        (0.0, pos)
14    } else {
15        (pos, 0.0)
16    };
17    coord.transform(p, area).0
18}
19
20/// Vertical pixel for a break at normalized `pos` along the *vertical* axis.
21fn along_y(coord: &dyn Coord, pos: f64, area: &Rect) -> f64 {
22    let p = if coord.is_flipped() {
23        (pos, 0.0)
24    } else {
25        (0.0, pos)
26    };
27    coord.transform(p, area).1
28}
29
30/// Draw the X axis: ticks, labels, and title.
31///
32/// `canvas` is the full SVG viewport (not just the panel): B3-1's outer-
33/// label fit drops a label whose tick has scrolled off it, and shifts a
34/// label bbox that crosses it back inside, unless
35/// `theme.axis_text_x_fit` is [`AxisLabelFit::Clip`] (the 0.21 behaviour —
36/// no correction). Ticks and gridlines are drawn regardless.
37pub fn draw_x_axis(
38    scale: &dyn Scale,
39    coord: &dyn Coord,
40    theme: &Theme,
41    plot_area: &Rect,
42    canvas: &Rect,
43    backend: &mut dyn DrawBackend,
44) -> Result<(), RenderError> {
45    let breaks = scale.breaks();
46    let tick_len = theme.axis_ticks_length;
47    let axis_line = theme.get_axis_line_x();
48    let axis_ticks = theme.get_axis_ticks_x();
49
50    // Axis position: bottom (default) or top. `dir` points away from the panel.
51    let top = scale.axis_position_opposite();
52    let edge_y = if top {
53        plot_area.y
54    } else {
55        plot_area.y + plot_area.height
56    };
57    let dir = if top { -1.0 } else { 1.0 };
58
59    // Axis line
60    if axis_line.visible {
61        backend.draw_line(
62            &[
63                (plot_area.x, edge_y),
64                (plot_area.x + plot_area.width, edge_y),
65            ],
66            &LineStyle {
67                color: axis_line.color,
68                width: axis_line.width,
69                alpha: 1.0,
70                linetype: axis_line.linetype,
71                ..Default::default()
72            },
73        )?;
74    }
75
76    // Precompute, per break, whether/where its label is drawn — but keep a
77    // single loop below in the *original* per-break (tick, label) order, so
78    // a plot whose labels all fit and don't overlap renders byte-identical
79    // SVG to 0.21 (same element order), even though the decision logic ran.
80    let label_draw: Vec<Option<f64>> = if theme.axis_text_x.visible {
81        let angle = theme.axis_text_x.angle;
82        let rotated = angle.abs() > 10.0;
83        let anchor_frac = if rotated { 1.0 } else { 0.5 };
84        let fit = theme.axis_text_x_fit;
85        let canvas_lo = canvas.x;
86        let canvas_hi = canvas.x + canvas.width;
87        const EDGE: f64 = 1.0;
88        let size = theme.axis_text_x.size;
89
90        // (index, px, label) for breaks whose tick is on-canvas. A label
91        // whose tick has scrolled off the canvas names nothing that is
92        // shown — unconditional, even under `Clip`.
93        let onscreen: Vec<(usize, f64, &str)> = breaks
94            .iter()
95            .enumerate()
96            .map(|(i, (pos, label))| (i, along_x(coord, *pos, plot_area), label.as_str()))
97            .filter(|(_, px, _)| (canvas_lo..=canvas_hi).contains(px))
98            .collect();
99
100        // ggplot2's `check.overlap`: draw in priority order (first, last,
101        // then recursive midpoint bisection — D5), skipping any label that
102        // would overlap one already kept.
103        let mut keep = vec![true; onscreen.len()];
104        if theme.axis_text_x_check_overlap && onscreen.len() > 1 {
105            let n = onscreen.len();
106            let mut kept: Vec<(f64, f64, f64)> = Vec::with_capacity(n); // (x0, x2, px)
107            keep = vec![false; n];
108            for p in axis_label_priority(n) {
109                let idx = p - 1; // 1-indexed in the ggplot2 sense
110                let (_, px, label) = onscreen[idx];
111                let bbox = label_bbox(label, size, px, 0.0, angle, anchor_frac);
112                let overlaps = kept.iter().any(|&(kx0, kx2, kpx)| {
113                    if !rotated {
114                        bbox[0] < kx2 && bbox[2] > kx0
115                    } else {
116                        // Parallel rotated labels on one baseline: the
117                        // perpendicular distance across the text direction
118                        // must leave room for a line of text.
119                        (px - kpx).abs() * angle.to_radians().sin().abs() < size
120                    }
121                });
122                if !overlaps {
123                    keep[idx] = true;
124                    kept.push((bbox[0], bbox[2], px));
125                }
126            }
127        }
128
129        let mut draw = vec![None; breaks.len()];
130        for (k, &(i, px, label)) in onscreen.iter().enumerate() {
131            if !keep[k] {
132                continue;
133            }
134            // Shift a bbox that crosses the canvas edge back inside (moving
135            // the rotate centre too), unless `fit` is `Clip` (0.21 behaviour).
136            let lx = if fit == AxisLabelFit::Clip {
137                px
138            } else {
139                // `ly` doesn't affect the x-bbox; 0.0 is a placeholder.
140                let bbox = label_bbox(label, size, px, 0.0, angle, anchor_frac);
141                let room_hi = canvas_hi - EDGE;
142                let room_lo = canvas_lo + EDGE;
143                if bbox[0] < room_lo {
144                    px + (room_lo - bbox[0])
145                } else if bbox[2] > room_hi {
146                    px - (bbox[2] - room_hi).min(bbox[0] - room_lo)
147                } else {
148                    px
149                }
150            };
151            draw[i] = Some(lx);
152        }
153        draw
154    } else {
155        Vec::new()
156    };
157
158    // Ticks and labels, interleaved in break order (ticks always stay).
159    let dodge = theme.axis_text_x_dodge.max(1);
160    let family = if theme.axis_text_x.family.is_empty() {
161        None
162    } else {
163        Some(theme.axis_text_x.family.clone())
164    };
165    let anchor = if theme.axis_text_x.angle.abs() > 10.0 {
166        TextAnchor::End
167    } else {
168        TextAnchor::Middle
169    };
170    for (i, (pos, label)) in breaks.iter().enumerate() {
171        let px = along_x(coord, *pos, plot_area);
172
173        if axis_ticks.visible {
174            backend.draw_line(
175                &[(px, edge_y), (px, edge_y + dir * tick_len)],
176                &LineStyle {
177                    color: axis_ticks.color,
178                    width: axis_ticks.width,
179                    alpha: 1.0,
180                    linetype: axis_ticks.linetype,
181                    ..Default::default()
182                },
183            )?;
184        }
185
186        if let Some(Some(lx)) = label_draw.get(i) {
187            // Stagger labels across `dodge` rows to avoid overlap.
188            let row_offset = (i % dodge) as f64 * (theme.axis_text_x.size + 2.0);
189            let ly = edge_y
190                + dir
191                    * (tick_len
192                        + theme.legend_spacing / 2.0
193                        + theme.axis_text_x.size / 2.0
194                        + row_offset);
195            backend.draw_text(
196                label,
197                (*lx, ly),
198                &TextStyle {
199                    color: theme.axis_text_x.color,
200                    size: theme.axis_text_x.size,
201                    anchor: anchor.clone(),
202                    angle: theme.axis_text_x.angle,
203                    family: family.clone(),
204                    face: theme.axis_text_x.face,
205                },
206            )?;
207        }
208    }
209
210    // Minor ticks between majors.
211    if theme.axis_minor_ticks && axis_ticks.visible {
212        for pos in minor_breaks(&breaks) {
213            let px = along_x(coord, pos, plot_area);
214            backend.draw_line(
215                &[(px, edge_y), (px, edge_y + dir * tick_len * 0.5)],
216                &LineStyle {
217                    color: axis_ticks.color,
218                    width: axis_ticks.width,
219                    alpha: 1.0,
220                    linetype: axis_ticks.linetype,
221                    ..Default::default()
222                },
223            )?;
224        }
225    }
226
227    // Axis title
228    let title = scale.name();
229    if !title.is_empty() && theme.axis_title_x.visible {
230        let hjust = theme.axis_title_x.hjust.clamp(0.0, 1.0);
231        let center_x = plot_area.x + hjust * plot_area.width;
232        let anchor = if hjust <= 0.02 {
233            TextAnchor::Start
234        } else if hjust >= 0.98 {
235            TextAnchor::End
236        } else {
237            TextAnchor::Middle
238        };
239        let title_y = edge_y
240            + dir * (tick_len + theme.axis_text_x.size + 8.0 + theme.axis_title_x.size / 2.0);
241        let family = if theme.axis_title_x.family.is_empty() {
242            None
243        } else {
244            Some(theme.axis_title_x.family.clone())
245        };
246        backend.draw_text(
247            title,
248            (center_x, title_y),
249            &TextStyle {
250                color: theme.axis_title_x.color,
251                size: theme.axis_title_x.size,
252                anchor,
253                angle: 0.0,
254                family,
255                face: theme.axis_title_x.face,
256            },
257        )?;
258    }
259
260    Ok(())
261}
262
263/// Draw the Y axis: ticks, labels, and title.
264pub fn draw_y_axis(
265    scale: &dyn Scale,
266    coord: &dyn Coord,
267    theme: &Theme,
268    plot_area: &Rect,
269    backend: &mut dyn DrawBackend,
270) -> Result<(), RenderError> {
271    let breaks = scale.breaks();
272    let tick_len = theme.axis_ticks_length;
273    let axis_line = theme.get_axis_line_y();
274    let axis_ticks = theme.get_axis_ticks_y();
275
276    // Axis line
277    if axis_line.visible {
278        let top = (plot_area.x, plot_area.y);
279        let bottom = (plot_area.x, plot_area.y + plot_area.height);
280        backend.draw_line(
281            &[top, bottom],
282            &LineStyle {
283                color: axis_line.color,
284                width: axis_line.width,
285                alpha: 1.0,
286                linetype: axis_line.linetype,
287                ..Default::default()
288            },
289        )?;
290    }
291
292    // Ticks and labels
293    for (pos, label) in &breaks {
294        let py = along_y(coord, *pos, plot_area);
295
296        if axis_ticks.visible {
297            backend.draw_line(
298                &[(plot_area.x - tick_len, py), (plot_area.x, py)],
299                &LineStyle {
300                    color: axis_ticks.color,
301                    width: axis_ticks.width,
302                    alpha: 1.0,
303                    linetype: axis_ticks.linetype,
304                    ..Default::default()
305                },
306            )?;
307        }
308
309        if theme.axis_text_y.visible {
310            let family = if theme.axis_text_y.family.is_empty() {
311                None
312            } else {
313                Some(theme.axis_text_y.family.clone())
314            };
315            backend.draw_text(
316                label,
317                (plot_area.x - tick_len - theme.legend_spacing, py),
318                &TextStyle {
319                    color: theme.axis_text_y.color,
320                    size: theme.axis_text_y.size,
321                    anchor: TextAnchor::End,
322                    angle: theme.axis_text_y.angle,
323                    family,
324                    face: theme.axis_text_y.face,
325                },
326            )?;
327        }
328    }
329
330    // Minor ticks between majors.
331    if theme.axis_minor_ticks && axis_ticks.visible {
332        for pos in minor_breaks(&breaks) {
333            let py = along_y(coord, pos, plot_area);
334            backend.draw_line(
335                &[(plot_area.x - tick_len * 0.5, py), (plot_area.x, py)],
336                &LineStyle {
337                    color: axis_ticks.color,
338                    width: axis_ticks.width,
339                    alpha: 1.0,
340                    linetype: axis_ticks.linetype,
341                    ..Default::default()
342                },
343            )?;
344        }
345    }
346
347    // Axis title
348    let title = scale.name();
349    if !title.is_empty() && theme.axis_title_y.visible {
350        let title_x = plot_area.x - tick_len - theme.axis_text_y.size * 3.5 - theme.legend_spacing;
351        let center_y = plot_area.y + plot_area.height / 2.0;
352        let family = if theme.axis_title_y.family.is_empty() {
353            None
354        } else {
355            Some(theme.axis_title_y.family.clone())
356        };
357        backend.draw_text(
358            title,
359            (title_x, center_y),
360            &TextStyle {
361                color: theme.axis_title_y.color,
362                size: theme.axis_title_y.size,
363                anchor: TextAnchor::Middle,
364                angle: 270.0,
365                family,
366                face: theme.axis_title_y.face,
367            },
368        )?;
369    }
370
371    Ok(())
372}
373
374/// Draw a secondary Y axis on the right side.
375pub fn draw_sec_y_axis(
376    primary_scale: &dyn Scale,
377    sec_axis: &crate::scale::sec_axis::SecAxis,
378    coord: &dyn Coord,
379    theme: &Theme,
380    plot_area: &Rect,
381    backend: &mut dyn DrawBackend,
382) -> Result<(), RenderError> {
383    let breaks = primary_scale.breaks();
384    let tick_len = theme.axis_ticks_length;
385    let axis_line = theme.get_axis_line_y();
386    let axis_ticks = theme.get_axis_ticks_y();
387
388    let right_x = plot_area.x + plot_area.width;
389
390    // Axis line on right side
391    if axis_line.visible {
392        backend.draw_line(
393            &[
394                (right_x, plot_area.y),
395                (right_x, plot_area.y + plot_area.height),
396            ],
397            &LineStyle {
398                color: axis_line.color,
399                width: axis_line.width,
400                alpha: 1.0,
401                linetype: axis_line.linetype,
402                ..Default::default()
403            },
404        )?;
405    }
406
407    // Ticks and labels at primary break positions, but with transformed labels
408    for (pos, label) in &breaks {
409        let py = along_y(coord, *pos, plot_area);
410
411        if axis_ticks.visible {
412            backend.draw_line(
413                &[(right_x, py), (right_x + tick_len, py)],
414                &LineStyle {
415                    color: axis_ticks.color,
416                    width: axis_ticks.width,
417                    alpha: 1.0,
418                    linetype: axis_ticks.linetype,
419                    ..Default::default()
420                },
421            )?;
422        }
423
424        if theme.axis_text_y.visible {
425            // Parse the primary label back to a number, transform it
426            let locale = primary_scale.locale();
427            let parsed = match locale {
428                Some(l) => l.parse_number(label),
429                None => label.parse::<f64>().ok(),
430            };
431            let sec_label = if let Some(v) = parsed {
432                let transformed = sec_axis.transform_value(v);
433                crate::scale::util::format_number_opt(transformed, locale)
434            } else {
435                label.clone()
436            };
437
438            let family = if theme.axis_text_y.family.is_empty() {
439                None
440            } else {
441                Some(theme.axis_text_y.family.clone())
442            };
443            backend.draw_text(
444                &sec_label,
445                (right_x + tick_len + theme.legend_spacing, py),
446                &TextStyle {
447                    color: theme.axis_text_y.color,
448                    size: theme.axis_text_y.size,
449                    anchor: TextAnchor::Start,
450                    angle: theme.axis_text_y.angle,
451                    family,
452                    face: theme.axis_text_y.face,
453                },
454            )?;
455        }
456    }
457
458    // Secondary axis title
459    if !sec_axis.name.is_empty() && theme.axis_title_y.visible {
460        let title_x = right_x + tick_len + theme.axis_text_y.size * 3.5 + theme.legend_spacing;
461        let center_y = plot_area.y + plot_area.height / 2.0;
462        let family = if theme.axis_title_y.family.is_empty() {
463            None
464        } else {
465            Some(theme.axis_title_y.family.clone())
466        };
467        backend.draw_text(
468            &sec_axis.name,
469            (title_x, center_y),
470            &TextStyle {
471                color: theme.axis_title_y.color,
472                size: theme.axis_title_y.size,
473                anchor: TextAnchor::Middle,
474                angle: 90.0,
475                family,
476                face: theme.axis_title_y.face,
477            },
478        )?;
479    }
480
481    Ok(())
482}
483
484/// Compute minor break positions as midpoints between major breaks.
485fn minor_breaks(major: &[(f64, String)]) -> Vec<f64> {
486    if major.len() < 2 {
487        return vec![];
488    }
489    let mut minors = Vec::with_capacity(major.len() - 1);
490    for pair in major.windows(2) {
491        minors.push((pair[0].0 + pair[1].0) / 2.0);
492    }
493    minors
494}
495
496/// Draw gridlines for both axes.
497pub fn draw_gridlines(
498    x_scale: &dyn Scale,
499    y_scale: &dyn Scale,
500    coord: &dyn Coord,
501    theme: &Theme,
502    plot_area: &Rect,
503    backend: &mut dyn DrawBackend,
504) -> Result<(), RenderError> {
505    let major_x = theme.get_panel_grid_major_x();
506    let major_y = theme.get_panel_grid_major_y();
507    let minor_x = theme.get_panel_grid_minor_x();
508    let minor_y = theme.get_panel_grid_minor_y();
509
510    let x_breaks = x_scale.breaks();
511    let y_breaks = y_scale.breaks();
512
513    // Minor X gridlines (vertical) — drawn first so majors paint over them
514    if minor_x.visible {
515        for pos in minor_breaks(&x_breaks) {
516            let px = along_x(coord, pos, plot_area);
517            backend.draw_line(
518                &[(px, plot_area.y), (px, plot_area.y + plot_area.height)],
519                &LineStyle {
520                    color: minor_x.color,
521                    width: minor_x.width,
522                    alpha: 1.0,
523                    linetype: minor_x.linetype,
524                    ..Default::default()
525                },
526            )?;
527        }
528    }
529
530    // Minor Y gridlines (horizontal)
531    if minor_y.visible {
532        for pos in minor_breaks(&y_breaks) {
533            let py = along_y(coord, pos, plot_area);
534            backend.draw_line(
535                &[(plot_area.x, py), (plot_area.x + plot_area.width, py)],
536                &LineStyle {
537                    color: minor_y.color,
538                    width: minor_y.width,
539                    alpha: 1.0,
540                    linetype: minor_y.linetype,
541                    ..Default::default()
542                },
543            )?;
544        }
545    }
546
547    // Major X gridlines (vertical)
548    if major_x.visible {
549        for (pos, _) in &x_breaks {
550            let px = along_x(coord, *pos, plot_area);
551            backend.draw_line(
552                &[(px, plot_area.y), (px, plot_area.y + plot_area.height)],
553                &LineStyle {
554                    color: major_x.color,
555                    width: major_x.width,
556                    alpha: 1.0,
557                    linetype: major_x.linetype,
558                    ..Default::default()
559                },
560            )?;
561        }
562    }
563
564    // Major Y gridlines (horizontal)
565    if major_y.visible {
566        for (pos, _) in &y_breaks {
567            let py = along_y(coord, *pos, plot_area);
568            backend.draw_line(
569                &[(plot_area.x, py), (plot_area.x + plot_area.width, py)],
570                &LineStyle {
571                    color: major_y.color,
572                    width: major_y.width,
573                    alpha: 1.0,
574                    linetype: major_y.linetype,
575                    ..Default::default()
576                },
577            )?;
578        }
579    }
580
581    Ok(())
582}
583
584#[cfg(test)]
585mod tests {
586    use super::*;
587    use crate::aes::Aesthetic;
588    use crate::coord::cartesian::CoordCartesian;
589    use crate::data::Value;
590    use crate::render::svg_backend::SvgBackend;
591    use crate::theme::presets::theme_gray;
592
593    /// A scale with hand-picked break positions/labels, so a test can place
594    /// one off the canvas without fighting a real break algorithm.
595    struct FixedBreaksScale(Vec<(f64, String)>);
596
597    impl Scale for FixedBreaksScale {
598        fn aesthetic(&self) -> Aesthetic {
599            Aesthetic::X
600        }
601        fn train(&mut self, _values: &[Value]) {}
602        fn map(&self, _value: &Value) -> f64 {
603            0.0
604        }
605        fn breaks(&self) -> Vec<(f64, String)> {
606            self.0.clone()
607        }
608        fn name(&self) -> &str {
609            ""
610        }
611        fn set_name(&mut self, _name: &str) {}
612        fn clone_box(&self) -> Box<dyn Scale> {
613            Box::new(FixedBreaksScale(self.0.clone()))
614        }
615    }
616
617    fn backend(w: u32, h: u32, plot_area: Rect) -> SvgBackend {
618        SvgBackend::new(w, h, plot_area)
619    }
620
621    #[test]
622    fn a_tick_outside_the_svg_is_dropped() {
623        // Normalized position 5.0 is nowhere near [0, 1]; through
624        // `CoordCartesian` that lands far outside the 200×100 canvas.
625        let scale = FixedBreaksScale(vec![
626            (0.2, "kept".to_string()),
627            (5.0, "off_canvas".to_string()),
628        ]);
629        let coord = CoordCartesian::new();
630        let theme = theme_gray();
631        let plot_area = Rect {
632            x: 10.0,
633            y: 10.0,
634            width: 180.0,
635            height: 70.0,
636        };
637        let canvas = Rect {
638            x: 0.0,
639            y: 0.0,
640            width: 200.0,
641            height: 100.0,
642        };
643        let mut backend = backend(200, 100, plot_area.clone());
644        draw_x_axis(&scale, &coord, &theme, &plot_area, &canvas, &mut backend).unwrap();
645        let svg = backend.finish();
646        assert!(svg.contains(">kept<"), "on-canvas label dropped: {svg}");
647        assert!(
648            !svg.contains(">off_canvas<"),
649            "off-canvas label was drawn: {svg}"
650        );
651    }
652
653    #[test]
654    fn clip_draws_the_label_exactly_at_the_tick_even_past_the_edge() {
655        // A very wide label at the rightmost tick, Clip mode: no shift.
656        let scale = FixedBreaksScale(vec![
657            (0.0, "a".to_string()),
658            (1.0, "2021-01-01".to_string()),
659        ]);
660        let coord = CoordCartesian::new();
661        let mut theme = theme_gray();
662        theme.axis_text_x_fit = crate::theme::AxisLabelFit::Clip;
663        let plot_area = Rect {
664            x: 0.0,
665            y: 0.0,
666            width: 60.0,
667            height: 50.0,
668        };
669        let canvas = plot_area.clone();
670        let mut backend = backend(60, 50, plot_area.clone());
671        draw_x_axis(&scale, &coord, &theme, &plot_area, &canvas, &mut backend).unwrap();
672        let svg = backend.finish();
673        // Clip: the label sits exactly on the tick (x="60.00"), unmoved,
674        // even though its bbox clearly overflows a 60px canvas.
675        assert!(
676            svg.contains("x=\"60.00\" y=\"") && svg.contains(">2021-01-01<"),
677            "clip mode should not shift the label: {svg}"
678        );
679    }
680
681    #[test]
682    fn shift_moves_the_same_label_inside_the_canvas() {
683        let scale = FixedBreaksScale(vec![
684            (0.0, "a".to_string()),
685            (1.0, "2021-01-01".to_string()),
686        ]);
687        let coord = CoordCartesian::new();
688        let mut theme = theme_gray();
689        theme.axis_text_x_fit = crate::theme::AxisLabelFit::Shift;
690        let plot_area = Rect {
691            x: 0.0,
692            y: 0.0,
693            width: 60.0,
694            height: 50.0,
695        };
696        let canvas = plot_area.clone();
697        let mut backend = backend(60, 50, plot_area.clone());
698        draw_x_axis(&scale, &coord, &theme, &plot_area, &canvas, &mut backend).unwrap();
699        let svg = backend.finish();
700        assert!(
701            !svg.contains("x=\"60.00\" y=\""),
702            "shift mode should move the label off the bare tick: {svg}"
703        );
704        let w = crate::render::text::text_width("2021-01-01", theme.axis_text_x.size);
705        // The shifted label's right edge must land inside the canvas.
706        let needle = ">2021-01-01<";
707        let tag_end = svg.find(needle).unwrap();
708        let tag_start = svg[..tag_end].rfind("<text ").unwrap();
709        let tag = &svg[tag_start..tag_end];
710        let x_attr = tag.rsplit("x=\"").next().unwrap();
711        let x: f64 = x_attr.split('"').next().unwrap().parse().unwrap();
712        // `x` round-trips through a `"{:.2}"`-formatted SVG attribute, so
713        // allow a hair of slack for that rounding.
714        assert!(
715            x + w / 2.0 <= 60.0 - 1.0 + 0.01,
716            "right edge still overflows: x={x} w={w}"
717        );
718    }
719}