Skip to main content

ggplot_rs/render/
text.rs

1//! Text-metric helpers for axis-label fit (B3-1): ggplot-rs owns the
2//! outer-label-overflow and `check.overlap` logic centrally, instead of a
3//! downstream consumer post-processing rendered SVG (the old approach had
4//! to re-parse `<text>` elements out of the markup).
5//!
6//! Ported verbatim from the consumer's `ticks.rs` em table and
7//! `TickLabel::bbox`, which is the only reason the measurements match what
8//! callers previously saw.
9
10/// Estimated width (px) of `text` set at font size `size`, in the house sans
11/// (Hanken Grotesk, or the system UI font it falls back to). This is a
12/// heuristic per-character average-width table, not real font metrics —
13/// good enough for layout decisions (fit, overlap, auto-wrap), not for
14/// pixel-perfect text extents.
15pub fn text_width(text: &str, size: f64) -> f64 {
16    let em: f64 = text
17        .chars()
18        .map(|ch| match ch {
19            ' ' => 0.27,
20            '.' | ',' | ':' | ';' | '\'' | '|' | '!' | 'i' | 'j' | 'l' | 'I' => 0.27,
21            'f' | 't' | 'r' | '(' | ')' | '[' | ']' | '-' | '/' => 0.36,
22            '0'..='9' | '$' | '€' | '£' | '+' | '=' | '<' | '>' | '#' | '*' | '?' => 0.57,
23            'm' | 'w' => 0.8,
24            'M' | 'W' | '%' | '@' => 0.86,
25            'a'..='z' => 0.53,
26            'A'..='Z' | '&' => 0.66,
27            _ => 0.62,
28        })
29        .sum();
30    em * size
31}
32
33/// Estimated bounding box `[x0, y0, x1, y1]` of a label anchored at
34/// `(x, y)`, set at font `size`, rotated `angle` degrees (SVG convention,
35/// clockwise), with `anchor` in `0.0` (text starts at `x`) `..=1.0` (text
36/// ends at `x`) — `0.5` centres it, matching `TextAnchor::{Start,Middle,
37/// End}`. Ported from the consumer's `TickLabel::bbox`.
38pub(crate) fn label_bbox(
39    text: &str,
40    size: f64,
41    x: f64,
42    y: f64,
43    angle: f64,
44    anchor: f64,
45) -> [f64; 4] {
46    let w = text_width(text, size);
47    let h = size * 1.2;
48    let x0 = -w * anchor;
49    let (s, c) = angle.to_radians().sin_cos();
50    let mut b = [
51        f64::INFINITY,
52        f64::INFINITY,
53        f64::NEG_INFINITY,
54        f64::NEG_INFINITY,
55    ];
56    for (dx, dy) in [
57        (x0, -h / 2.0),
58        (x0 + w, -h / 2.0),
59        (x0, h / 2.0),
60        (x0 + w, h / 2.0),
61    ] {
62        let (px, py) = (x + dx * c - dy * s, y + dx * s + dy * c);
63        b = [b[0].min(px), b[1].min(py), b[2].max(px), b[3].max(py)];
64    }
65    b
66}
67
68/// ggplot2's `check.overlap` draw order (its internal `label_priority`):
69/// always keep the first and last breaks, then recursively bisect the
70/// remaining range (midpoint first, then its two halves), so thinning drops
71/// the *most crowded middle* first rather than every other label. R parity
72/// (ggplot2 4.0.3): `axis_label_priority(5)` is `1 5 3 2 4`;
73/// `axis_label_priority(10)` is `1 10 5 3 2 4 7 6 8 9` (1-indexed).
74pub(crate) fn axis_label_priority(n: usize) -> Vec<usize> {
75    if n == 0 {
76        return Vec::new();
77    }
78    if n == 1 {
79        return vec![1];
80    }
81    let mut out = vec![1, n];
82    bisect(2, n - 1, &mut out);
83    out
84}
85
86/// 1-indexed inclusive range `[lo, hi]`: midpoint first (floor division),
87/// then the left half, then the right half.
88fn bisect(lo: usize, hi: usize, out: &mut Vec<usize>) {
89    if lo > hi {
90        return;
91    }
92    let mid = (lo + hi) / 2;
93    out.push(mid);
94    if mid > lo {
95        bisect(lo, mid - 1, out);
96    }
97    bisect(mid + 1, hi, out);
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn priority_matches_r_ggplot2_4_0_3() {
106        assert_eq!(axis_label_priority(5), vec![1, 5, 3, 2, 4]);
107        assert_eq!(axis_label_priority(10), vec![1, 10, 5, 3, 2, 4, 7, 6, 8, 9]);
108    }
109
110    #[test]
111    fn priority_edge_cases() {
112        assert_eq!(axis_label_priority(0), Vec::<usize>::new());
113        assert_eq!(axis_label_priority(1), vec![1]);
114        assert_eq!(axis_label_priority(2), vec![1, 2]);
115    }
116
117    #[test]
118    fn text_width_matches_consumer_em_table() {
119        assert_eq!(text_width("", 10.0), 0.0);
120        assert!((text_width("i", 10.0) - 2.7).abs() < 1e-9);
121        assert!((text_width("M", 10.0) - 8.6).abs() < 1e-9);
122    }
123
124    #[test]
125    fn label_bbox_unrotated_middle_anchor() {
126        let b = label_bbox("ab", 10.0, 100.0, 50.0, 0.0, 0.5);
127        let w = text_width("ab", 10.0);
128        assert!((b[0] - (100.0 - w / 2.0)).abs() < 1e-9);
129        assert!((b[2] - (100.0 + w / 2.0)).abs() < 1e-9);
130        assert!((b[1] - (50.0 - 6.0)).abs() < 1e-9);
131        assert!((b[3] - (50.0 + 6.0)).abs() < 1e-9);
132    }
133}