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