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/// For rotated bottom-axis tick labels, the extra vertical *rise* (above
69/// the un-rotated anchor point) and *fall* (below it) the rotated bounding
70/// boxes need, maxed over `labels` (sipemu/ggplot-rs#162). A label
71/// end-anchored at a point just below the panel and rotated toward the
72/// panel (e.g. the usual 45° "reads up-and-to-the-right" tilt) swings its
73/// far end — `rise` — back up by roughly `width · sin(angle)`; a label
74/// rotated away from the panel (e.g. 270°, hanging straight down) has no
75/// rise at all, only `fall`.
76///
77/// Both `GGPlot::layout_built` (to reserve enough panel-bottom margin that
78/// `fall` stays on-canvas) and `draw_x_axis` (to place the rotation anchor
79/// `rise` further from the axis line than the flat-label offset, so the
80/// rotated label never swings back over the panel) need the *same*
81/// numbers — this is their one shared source, so the two can never drift
82/// apart.
83pub(crate) fn rotated_label_rise_fall<'a>(
84 labels: impl Iterator<Item = &'a str>,
85 size: f64,
86 angle: f64,
87 anchor: f64,
88) -> (f64, f64) {
89 let mut rise = 0.0_f64;
90 let mut fall = 0.0_f64;
91 for label in labels {
92 let b = label_bbox(label, size, 0.0, 0.0, angle, anchor);
93 rise = rise.max(-b[1]);
94 fall = fall.max(b[3]);
95 }
96 (rise.max(0.0), fall.max(0.0))
97}
98
99/// Which end of a rotated axis-tick label should pin to the tick
100/// (sipemu/ggplot-rs#163): `1.0` (`TextAnchor::End`, the label's *last*
101/// character at the tick) or `0.0` (`TextAnchor::Start`, its *first*
102/// character at the tick) — whichever keeps the label's *far* end swinging
103/// away from the panel instead of into it.
104///
105/// `dir` is `draw_x_axis`'s own tick direction: `+1.0` for a bottom axis
106/// (the panel sits above the axis line), `-1.0` for a top axis (the panel
107/// sits below it). The right anchor depends on *both* the rotation's
108/// direction and which side the panel is on — not the angle alone: a
109/// counter-clockwise (SVG-negative) angle swings its far end away from the
110/// panel when its *last* character is pinned, when the panel is above
111/// (bottom axis); a clockwise (SVG-positive) angle needs the opposite —
112/// its *first* character pinned — for the same effect. Both flip for a top
113/// axis, where "away from the panel" points the other way. Concretely:
114/// `End` exactly when `dir * sin(angle) < 0`.
115///
116/// Picking the wrong one for a given sign pushes the whole label away from
117/// the tick instead (0.22.1's bug, on a bottom axis): the tick-anchored
118/// end stays near the tick but the free end swings into the previous
119/// category's space (or, with the direction only half-fixed, the anchor
120/// itself gets pushed far from the tick to clear the panel on a top axis)
121/// instead of the near-axis end simply staying at the tick either way.
122///
123/// Only meaningful when the caller already knows the label is rotated
124/// (`angle.abs() > 10.0`); the flat case uses `0.5` (`TextAnchor::Middle`)
125/// unconditionally, never this function.
126pub(crate) fn rotated_anchor_frac(angle: f64, dir: f64) -> f64 {
127 if dir * angle.to_radians().sin() < 0.0 {
128 1.0
129 } else {
130 0.0
131 }
132}
133
134/// ggplot2's `check.overlap` draw order (its internal `label_priority`):
135/// always keep the first and last breaks, then recursively bisect the
136/// remaining range (midpoint first, then its two halves), so thinning drops
137/// the *most crowded middle* first rather than every other label. R parity
138/// (ggplot2 4.0.3): `axis_label_priority(5)` is `1 5 3 2 4`;
139/// `axis_label_priority(10)` is `1 10 5 3 2 4 7 6 8 9` (1-indexed).
140pub(crate) fn axis_label_priority(n: usize) -> Vec<usize> {
141 if n == 0 {
142 return Vec::new();
143 }
144 if n == 1 {
145 return vec![1];
146 }
147 let mut out = vec![1, n];
148 bisect(2, n - 1, &mut out);
149 out
150}
151
152/// 1-indexed inclusive range `[lo, hi]`: midpoint first (floor division),
153/// then the left half, then the right half.
154fn bisect(lo: usize, hi: usize, out: &mut Vec<usize>) {
155 if lo > hi {
156 return;
157 }
158 let mid = (lo + hi) / 2;
159 out.push(mid);
160 if mid > lo {
161 bisect(lo, mid - 1, out);
162 }
163 bisect(mid + 1, hi, out);
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 #[test]
171 fn priority_matches_r_ggplot2_4_0_3() {
172 assert_eq!(axis_label_priority(5), vec![1, 5, 3, 2, 4]);
173 assert_eq!(axis_label_priority(10), vec![1, 10, 5, 3, 2, 4, 7, 6, 8, 9]);
174 }
175
176 #[test]
177 fn priority_edge_cases() {
178 assert_eq!(axis_label_priority(0), Vec::<usize>::new());
179 assert_eq!(axis_label_priority(1), vec![1]);
180 assert_eq!(axis_label_priority(2), vec![1, 2]);
181 }
182
183 #[test]
184 fn text_width_matches_consumer_em_table() {
185 assert_eq!(text_width("", 10.0), 0.0);
186 assert!((text_width("i", 10.0) - 2.7).abs() < 1e-9);
187 assert!((text_width("M", 10.0) - 8.6).abs() < 1e-9);
188 }
189
190 #[test]
191 fn label_bbox_unrotated_middle_anchor() {
192 let b = label_bbox("ab", 10.0, 100.0, 50.0, 0.0, 0.5);
193 let w = text_width("ab", 10.0);
194 assert!((b[0] - (100.0 - w / 2.0)).abs() < 1e-9);
195 assert!((b[2] - (100.0 + w / 2.0)).abs() < 1e-9);
196 assert!((b[1] - (50.0 - 6.0)).abs() < 1e-9);
197 assert!((b[3] - (50.0 + 6.0)).abs() < 1e-9);
198 }
199}