Skip to main content

ggplot_rs/geom/
text.rs

1use crate::aes::Aesthetic;
2use crate::coord::Coord;
3use crate::data::DataFrame;
4use crate::position::identity::PositionIdentity;
5use crate::position::Position;
6use crate::render::backend::{DrawBackend, RectStyle, TextAnchor, TextStyle};
7use crate::render::text::{wrap_text, wrapped_block_width};
8use crate::render::RenderError;
9use crate::scale::ScaleSet;
10use crate::stat::identity::StatIdentity;
11use crate::stat::Stat;
12use crate::theme::Theme;
13
14use super::{Geom, GeomParams};
15
16/// Text geometry — draws text labels at data positions.
17pub struct GeomText {
18    pub size: f64,
19    pub color: (u8, u8, u8),
20    pub alpha: f64,
21    /// Horizontal justification: 0.0 = left, 0.5 = center (default), 1.0 = right.
22    pub hjust: f64,
23    /// Vertical justification: 0.0 = bottom, 0.5 = middle (default), 1.0 = top.
24    pub vjust: f64,
25    /// Font family name (informational; actual rendering depends on backend).
26    pub fontfamily: String,
27    /// When true, skip drawing labels that overlap previously drawn labels.
28    pub check_overlap: bool,
29    /// Word-wrap width in px (ggtext / `str_wrap` behaviour), measured with
30    /// the theme's text metrics ([`crate::render::text::text_width`]).
31    /// `None` (the default) never wraps — unchanged from pre-#176 behaviour.
32    pub wrap_width: Option<f64>,
33    /// Line height as a multiple of `size`, applied between wrapped lines
34    /// (CSS-style; ggplot2's `lineheight` is the same idea). Only matters
35    /// when `wrap_width` is set and the label wraps to more than one line.
36    pub line_height: f64,
37}
38
39impl GeomText {
40    pub fn with_hjust(mut self, hjust: f64) -> Self {
41        self.hjust = hjust;
42        self
43    }
44
45    pub fn with_vjust(mut self, vjust: f64) -> Self {
46        self.vjust = vjust;
47        self
48    }
49
50    pub fn with_fontfamily(mut self, family: &str) -> Self {
51        self.fontfamily = family.to_string();
52        self
53    }
54
55    pub fn with_check_overlap(mut self, check: bool) -> Self {
56        self.check_overlap = check;
57        self
58    }
59
60    /// Word-wrap the label to lines no wider than `width_px` (#176).
61    pub fn with_wrap(mut self, width_px: f64) -> Self {
62        self.wrap_width = Some(width_px);
63        self
64    }
65
66    /// Line height as a multiple of `size` for wrapped text (default `1.2`).
67    pub fn with_line_height(mut self, line_height: f64) -> Self {
68        self.line_height = line_height;
69        self
70    }
71}
72
73impl Default for GeomText {
74    fn default() -> Self {
75        GeomText {
76            size: 10.0,
77            color: (0, 0, 0),
78            alpha: 1.0,
79            hjust: 0.5,
80            vjust: 0.5,
81            fontfamily: String::new(),
82            check_overlap: false,
83            wrap_width: None,
84            line_height: 1.2,
85        }
86    }
87}
88
89impl Geom for GeomText {
90    fn draw(
91        &self,
92        data: &DataFrame,
93        coord: &dyn Coord,
94        scales: &ScaleSet,
95        theme: &Theme,
96        backend: &mut dyn DrawBackend,
97    ) -> Result<(), RenderError> {
98        let x_col = data
99            .column("x")
100            .ok_or(RenderError::MissingAesthetic("x".into()))?;
101        let y_col = data
102            .column("y")
103            .ok_or(RenderError::MissingAesthetic("y".into()))?;
104        let label_col = data
105            .column("label")
106            .ok_or(RenderError::MissingAesthetic("label".into()))?;
107
108        let plot_area = backend.plot_area();
109        let xm = super::support::XMapper::new(data, scales);
110        let y_scale = scales.get(&Aesthetic::Y);
111
112        let mut drawn_bboxes: Vec<(f64, f64, f64, f64)> = Vec::new();
113
114        for i in 0..data.nrows() {
115            // #221: `Aes::class`/`group_wrap`/`data_attr` per row.
116            super::set_row_extras(backend, data, i);
117            // Text geoms don't consume tooltips (the build warns if one is
118            // mapped) — keep the label box `<title>`-free as before.
119            backend.set_tooltip(None);
120            let nx = xm.map(&x_col[i], i);
121            let ny = y_scale.map(|s| s.map(&y_col[i])).unwrap_or(0.0);
122            let (px, py) = coord.transform((nx, ny), &plot_area);
123
124            let text = label_col[i].to_group_key();
125            let lines = match self.wrap_width {
126                Some(w) => wrap_text(&text, w, self.size),
127                None => vec![text],
128            };
129            let lh = self.size * self.line_height;
130            let n = lines.len() as f64;
131            // The backend centres text vertically (dominant-baseline=middle), so
132            // apply vjust as a baseline shift over the whole wrapped block: 0 =
133            // bottom-anchored (block above the point), 0.5 = centred, 1 =
134            // top-anchored (block below). y grows down. An unwrapped single line
135            // (the pre-#176 case, `lines.len() == 1`) keeps the exact pre-#176
136            // `(vjust - 0.5) * size` shift — `line_height` only matters once a
137            // label actually wraps, so an un-wrapped plot's output is unchanged.
138            let block_h = if n <= 1.0 { self.size } else { lh * n };
139            let py_block = py + (self.vjust - 0.5) * block_h;
140
141            if self.check_overlap {
142                let w = wrapped_block_width(&lines, self.size);
143                let bbox = (
144                    px - w / 2.0,
145                    py_block - block_h / 2.0,
146                    px + w / 2.0,
147                    py_block + block_h / 2.0,
148                );
149                if bboxes_overlap(&bbox, &drawn_bboxes) {
150                    continue;
151                }
152                drawn_bboxes.push(bbox);
153            }
154
155            let anchor = hjust_to_anchor(self.hjust);
156            let family = super::resolve_font_family(&self.fontfamily, theme);
157            let top = py_block - block_h / 2.0;
158            for (li, line) in lines.iter().enumerate() {
159                // n == 1 draws at py_block directly (byte-identical to pre-#176
160                // single-line output); n > 1 lays lines out by `lh` within the
161                // block computed above.
162                let line_y = if n <= 1.0 {
163                    py_block
164                } else {
165                    top + lh * (li as f64 + 0.5)
166                };
167                backend.draw_text(
168                    line,
169                    (px, line_y),
170                    &TextStyle {
171                        color: self.color,
172                        size: self.size,
173                        anchor: anchor.clone(),
174                        angle: 0.0,
175                        family: family.clone(),
176                        face: crate::render::backend::FontFace::Plain,
177                    },
178                )?;
179            }
180        }
181        super::clear_row_extras(backend);
182
183        Ok(())
184    }
185
186    fn required_aes(&self) -> Vec<Aesthetic> {
187        vec![Aesthetic::X, Aesthetic::Y, Aesthetic::Label]
188    }
189
190    fn default_stat(&self) -> Box<dyn Stat> {
191        Box::new(StatIdentity)
192    }
193    fn default_position(&self) -> Box<dyn Position> {
194        Box::new(PositionIdentity)
195    }
196    fn default_params(&self) -> GeomParams {
197        GeomParams::default()
198    }
199    fn name(&self) -> &str {
200        "text"
201    }
202    fn apply_element_geom(&mut self, el: &crate::theme::elements::ElementGeom) {
203        if let (true, Some(ink)) = (self.color == (0, 0, 0), el.ink) {
204            self.color = ink;
205        }
206    }
207}
208
209/// Label geometry — like text but with a background rectangle.
210pub struct GeomLabel {
211    pub size: f64,
212    pub color: (u8, u8, u8),
213    pub fill: (u8, u8, u8),
214    pub alpha: f64,
215    pub padding: f64,
216    /// Horizontal justification: 0.0 = left, 0.5 = center (default), 1.0 = right.
217    pub hjust: f64,
218    /// Vertical justification: 0.0 = bottom, 0.5 = middle (default), 1.0 = top.
219    pub vjust: f64,
220    /// Font family name (informational; actual rendering depends on backend).
221    pub fontfamily: String,
222    /// When true, skip drawing labels that overlap previously drawn labels.
223    pub check_overlap: bool,
224    /// Word-wrap width in px (ggtext / `str_wrap` behaviour), measured with
225    /// the theme's text metrics ([`crate::render::text::text_width`]). The
226    /// background box grows to fit however many lines result, plus
227    /// `padding`. `None` (the default) never wraps — unchanged from
228    /// pre-#176 behaviour.
229    pub wrap_width: Option<f64>,
230    /// Line height as a multiple of `size`, applied between wrapped lines.
231    /// Only matters when `wrap_width` is set and the label wraps to more
232    /// than one line.
233    pub line_height: f64,
234    /// Corner radius in px for the background box (ggplot2
235    /// `geom_label(label.r)`, #176). `0.0` (the default) is a plain
236    /// rectangle, unchanged from pre-#176 behaviour.
237    pub radius: f64,
238}
239
240impl GeomLabel {
241    pub fn with_hjust(mut self, hjust: f64) -> Self {
242        self.hjust = hjust;
243        self
244    }
245
246    pub fn with_vjust(mut self, vjust: f64) -> Self {
247        self.vjust = vjust;
248        self
249    }
250
251    pub fn with_fontfamily(mut self, family: &str) -> Self {
252        self.fontfamily = family.to_string();
253        self
254    }
255
256    pub fn with_check_overlap(mut self, check: bool) -> Self {
257        self.check_overlap = check;
258        self
259    }
260
261    /// Word-wrap the label to lines no wider than `width_px`, growing the
262    /// background box to fit (#176).
263    pub fn with_wrap(mut self, width_px: f64) -> Self {
264        self.wrap_width = Some(width_px);
265        self
266    }
267
268    /// Line height as a multiple of `size` for wrapped text (default `1.2`).
269    pub fn with_line_height(mut self, line_height: f64) -> Self {
270        self.line_height = line_height;
271        self
272    }
273
274    /// Corner radius in px for the background box (ggplot2 `label.r`, #176).
275    pub fn with_radius(mut self, radius_px: f64) -> Self {
276        self.radius = radius_px;
277        self
278    }
279}
280
281impl Default for GeomLabel {
282    fn default() -> Self {
283        GeomLabel {
284            size: 10.0,
285            color: (0, 0, 0),
286            fill: (255, 255, 255),
287            alpha: 0.8,
288            padding: 3.0,
289            hjust: 0.5,
290            vjust: 0.5,
291            fontfamily: String::new(),
292            check_overlap: false,
293            wrap_width: None,
294            line_height: 1.2,
295            radius: 0.0,
296        }
297    }
298}
299
300impl Geom for GeomLabel {
301    fn draw(
302        &self,
303        data: &DataFrame,
304        coord: &dyn Coord,
305        scales: &ScaleSet,
306        theme: &Theme,
307        backend: &mut dyn DrawBackend,
308    ) -> Result<(), RenderError> {
309        let x_col = data
310            .column("x")
311            .ok_or(RenderError::MissingAesthetic("x".into()))?;
312        let y_col = data
313            .column("y")
314            .ok_or(RenderError::MissingAesthetic("y".into()))?;
315        let label_col = data
316            .column("label")
317            .ok_or(RenderError::MissingAesthetic("label".into()))?;
318
319        let plot_area = backend.plot_area();
320        let xm = super::support::XMapper::new(data, scales);
321        let y_scale = scales.get(&Aesthetic::Y);
322
323        let mut drawn_bboxes: Vec<(f64, f64, f64, f64)> = Vec::new();
324
325        for i in 0..data.nrows() {
326            // #221: `Aes::class`/`group_wrap`/`data_attr` per row.
327            super::set_row_extras(backend, data, i);
328            // Text geoms don't consume tooltips (the build warns if one is
329            // mapped) — keep the label box `<title>`-free as before.
330            backend.set_tooltip(None);
331            let nx = xm.map(&x_col[i], i);
332            let ny = y_scale.map(|s| s.map(&y_col[i])).unwrap_or(0.0);
333            let (px, py) = coord.transform((nx, ny), &plot_area);
334
335            let text = label_col[i].to_group_key();
336            // Unwrapped (pre-#176, the default) keeps the exact old box width:
337            // a crude char-count estimate, not the real `text_width` metric
338            // `with_wrap` uses — changing it would shift every existing
339            // geom_label()'s box width with no opt-in. Wrapping is new, so it
340            // has no old behaviour to preserve: it sizes the box to fit the
341            // wrapped lines using the real metric.
342            let (lines, half_w) = match self.wrap_width {
343                Some(w) => {
344                    let lines = wrap_text(&text, w, self.size);
345                    let half_w = wrapped_block_width(&lines, self.size) / 2.0 + self.padding;
346                    (lines, half_w)
347                }
348                None => {
349                    let half_w = text.len() as f64 * self.size * 0.6 / 2.0 + self.padding;
350                    (vec![text], half_w)
351                }
352            };
353            let lh = self.size * self.line_height;
354            let n = lines.len() as f64;
355            // See GeomText::draw for why n == 1 keeps the exact pre-#176
356            // `size`-based box height and `(vjust - 0.5) * size` shift.
357            let block_h = if n <= 1.0 { self.size } else { lh * n };
358            let py_block = py + (self.vjust - 0.5) * block_h;
359            let half_h = block_h / 2.0 + self.padding;
360
361            if self.check_overlap {
362                let bbox = (
363                    px - half_w,
364                    py_block - half_h,
365                    px + half_w,
366                    py_block + half_h,
367                );
368                if bboxes_overlap(&bbox, &drawn_bboxes) {
369                    continue;
370                }
371                drawn_bboxes.push(bbox);
372            }
373
374            // Background rect, sized to fit the wrapped lines + padding, with
375            // its corners rounded by `radius` (#176's `label.r`).
376            backend.draw_rect(
377                (px - half_w, py_block - half_h),
378                (px + half_w, py_block + half_h),
379                &RectStyle {
380                    fill: Some(self.fill),
381                    stroke: Some(self.color),
382                    stroke_width: 0.5,
383                    alpha: self.alpha,
384                    clip: true,
385                    corner_radius: self.radius,
386                },
387            )?;
388
389            // Text, one draw_text call per wrapped line.
390            let anchor = hjust_to_anchor(self.hjust);
391            let family = super::resolve_font_family(&self.fontfamily, theme);
392            let top = py_block - block_h / 2.0;
393            for (li, line) in lines.iter().enumerate() {
394                let line_y = if n <= 1.0 {
395                    py_block
396                } else {
397                    top + lh * (li as f64 + 0.5)
398                };
399                backend.draw_text(
400                    line,
401                    (px, line_y),
402                    &TextStyle {
403                        color: self.color,
404                        size: self.size,
405                        anchor: anchor.clone(),
406                        angle: 0.0,
407                        family: family.clone(),
408                        face: crate::render::backend::FontFace::Plain,
409                    },
410                )?;
411            }
412        }
413        super::clear_row_extras(backend);
414
415        Ok(())
416    }
417
418    fn required_aes(&self) -> Vec<Aesthetic> {
419        vec![Aesthetic::X, Aesthetic::Y, Aesthetic::Label]
420    }
421
422    fn default_stat(&self) -> Box<dyn Stat> {
423        Box::new(StatIdentity)
424    }
425    fn default_position(&self) -> Box<dyn Position> {
426        Box::new(PositionIdentity)
427    }
428    fn default_params(&self) -> GeomParams {
429        GeomParams::default()
430    }
431    fn name(&self) -> &str {
432        "label"
433    }
434    fn apply_element_geom(&mut self, el: &crate::theme::elements::ElementGeom) {
435        if let (true, Some(ink)) = (self.color == (0, 0, 0), el.ink) {
436            self.color = ink;
437        }
438        if let (true, Some(paper)) = (self.fill == (255, 255, 255), el.paper) {
439            self.fill = paper;
440        }
441    }
442}
443
444/// Map hjust (0.0 = left, 0.5 = center, 1.0 = right) to TextAnchor.
445fn hjust_to_anchor(hjust: f64) -> TextAnchor {
446    if hjust < 0.25 {
447        TextAnchor::Start
448    } else if hjust > 0.75 {
449        TextAnchor::End
450    } else {
451        TextAnchor::Middle
452    }
453}
454
455/// Check if a bbox overlaps any existing bbox.
456fn bboxes_overlap(candidate: &(f64, f64, f64, f64), existing: &[(f64, f64, f64, f64)]) -> bool {
457    for b in existing {
458        // Two rects overlap if they overlap on both axes
459        if candidate.0 < b.2 && candidate.2 > b.0 && candidate.1 < b.3 && candidate.3 > b.1 {
460            return true;
461        }
462    }
463    false
464}