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::RenderError;
8use crate::scale::ScaleSet;
9use crate::stat::identity::StatIdentity;
10use crate::stat::Stat;
11use crate::theme::Theme;
12
13use super::{Geom, GeomParams};
14
15/// Text geometry — draws text labels at data positions.
16pub struct GeomText {
17    pub size: f64,
18    pub color: (u8, u8, u8),
19    pub alpha: f64,
20    /// Horizontal justification: 0.0 = left, 0.5 = center (default), 1.0 = right.
21    pub hjust: f64,
22    /// Vertical justification: 0.0 = bottom, 0.5 = middle (default), 1.0 = top.
23    pub vjust: f64,
24    /// Font family name (informational; actual rendering depends on backend).
25    pub fontfamily: String,
26    /// When true, skip drawing labels that overlap previously drawn labels.
27    pub check_overlap: bool,
28}
29
30impl GeomText {
31    pub fn with_hjust(mut self, hjust: f64) -> Self {
32        self.hjust = hjust;
33        self
34    }
35
36    pub fn with_vjust(mut self, vjust: f64) -> Self {
37        self.vjust = vjust;
38        self
39    }
40
41    pub fn with_fontfamily(mut self, family: &str) -> Self {
42        self.fontfamily = family.to_string();
43        self
44    }
45
46    pub fn with_check_overlap(mut self, check: bool) -> Self {
47        self.check_overlap = check;
48        self
49    }
50}
51
52impl Default for GeomText {
53    fn default() -> Self {
54        GeomText {
55            size: 10.0,
56            color: (0, 0, 0),
57            alpha: 1.0,
58            hjust: 0.5,
59            vjust: 0.5,
60            fontfamily: String::new(),
61            check_overlap: false,
62        }
63    }
64}
65
66impl Geom for GeomText {
67    fn draw(
68        &self,
69        data: &DataFrame,
70        coord: &dyn Coord,
71        scales: &ScaleSet,
72        _theme: &Theme,
73        backend: &mut dyn DrawBackend,
74    ) -> Result<(), RenderError> {
75        let x_col = data
76            .column("x")
77            .ok_or(RenderError::MissingAesthetic("x".into()))?;
78        let y_col = data
79            .column("y")
80            .ok_or(RenderError::MissingAesthetic("y".into()))?;
81        let label_col = data
82            .column("label")
83            .ok_or(RenderError::MissingAesthetic("label".into()))?;
84
85        let plot_area = backend.plot_area();
86        let x_scale = scales.get(&Aesthetic::X);
87        let y_scale = scales.get(&Aesthetic::Y);
88
89        let mut drawn_bboxes: Vec<(f64, f64, f64, f64)> = Vec::new();
90
91        for i in 0..data.nrows() {
92            let nx = x_scale.map(|s| s.map(&x_col[i])).unwrap_or(0.0);
93            let ny = y_scale.map(|s| s.map(&y_col[i])).unwrap_or(0.0);
94            let (px, py) = coord.transform((nx, ny), &plot_area);
95            // The backend centres text vertically (dominant-baseline=middle), so
96            // apply vjust as a baseline shift: 0 = bottom-anchored (text above the
97            // point), 0.5 = centred, 1 = top-anchored (text below). y grows down.
98            let py = py + (self.vjust - 0.5) * self.size;
99
100            let text = label_col[i].to_group_key();
101
102            if self.check_overlap {
103                let w = text.len() as f64 * self.size * 0.6;
104                let h = self.size;
105                let bbox = (px - w / 2.0, py - h / 2.0, px + w / 2.0, py + h / 2.0);
106                if bboxes_overlap(&bbox, &drawn_bboxes) {
107                    continue;
108                }
109                drawn_bboxes.push(bbox);
110            }
111
112            let anchor = hjust_to_anchor(self.hjust);
113            backend.draw_text(
114                &text,
115                (px, py),
116                &TextStyle {
117                    color: self.color,
118                    size: self.size,
119                    anchor,
120                    angle: 0.0,
121                    family: None,
122                    face: crate::render::backend::FontFace::Plain,
123                },
124            )?;
125        }
126
127        Ok(())
128    }
129
130    fn required_aes(&self) -> Vec<Aesthetic> {
131        vec![Aesthetic::X, Aesthetic::Y, Aesthetic::Label]
132    }
133
134    fn default_stat(&self) -> Box<dyn Stat> {
135        Box::new(StatIdentity)
136    }
137    fn default_position(&self) -> Box<dyn Position> {
138        Box::new(PositionIdentity)
139    }
140    fn default_params(&self) -> GeomParams {
141        GeomParams::default()
142    }
143    fn name(&self) -> &str {
144        "text"
145    }
146}
147
148/// Label geometry — like text but with a background rectangle.
149pub struct GeomLabel {
150    pub size: f64,
151    pub color: (u8, u8, u8),
152    pub fill: (u8, u8, u8),
153    pub alpha: f64,
154    pub padding: f64,
155    /// Horizontal justification: 0.0 = left, 0.5 = center (default), 1.0 = right.
156    pub hjust: f64,
157    /// Vertical justification: 0.0 = bottom, 0.5 = middle (default), 1.0 = top.
158    pub vjust: f64,
159    /// Font family name (informational; actual rendering depends on backend).
160    pub fontfamily: String,
161    /// When true, skip drawing labels that overlap previously drawn labels.
162    pub check_overlap: bool,
163}
164
165impl GeomLabel {
166    pub fn with_hjust(mut self, hjust: f64) -> Self {
167        self.hjust = hjust;
168        self
169    }
170
171    pub fn with_vjust(mut self, vjust: f64) -> Self {
172        self.vjust = vjust;
173        self
174    }
175
176    pub fn with_fontfamily(mut self, family: &str) -> Self {
177        self.fontfamily = family.to_string();
178        self
179    }
180
181    pub fn with_check_overlap(mut self, check: bool) -> Self {
182        self.check_overlap = check;
183        self
184    }
185}
186
187impl Default for GeomLabel {
188    fn default() -> Self {
189        GeomLabel {
190            size: 10.0,
191            color: (0, 0, 0),
192            fill: (255, 255, 255),
193            alpha: 0.8,
194            padding: 3.0,
195            hjust: 0.5,
196            vjust: 0.5,
197            fontfamily: String::new(),
198            check_overlap: false,
199        }
200    }
201}
202
203impl Geom for GeomLabel {
204    fn draw(
205        &self,
206        data: &DataFrame,
207        coord: &dyn Coord,
208        scales: &ScaleSet,
209        _theme: &Theme,
210        backend: &mut dyn DrawBackend,
211    ) -> Result<(), RenderError> {
212        let x_col = data
213            .column("x")
214            .ok_or(RenderError::MissingAesthetic("x".into()))?;
215        let y_col = data
216            .column("y")
217            .ok_or(RenderError::MissingAesthetic("y".into()))?;
218        let label_col = data
219            .column("label")
220            .ok_or(RenderError::MissingAesthetic("label".into()))?;
221
222        let plot_area = backend.plot_area();
223        let x_scale = scales.get(&Aesthetic::X);
224        let y_scale = scales.get(&Aesthetic::Y);
225
226        let mut drawn_bboxes: Vec<(f64, f64, f64, f64)> = Vec::new();
227
228        for i in 0..data.nrows() {
229            let nx = x_scale.map(|s| s.map(&x_col[i])).unwrap_or(0.0);
230            let ny = y_scale.map(|s| s.map(&y_col[i])).unwrap_or(0.0);
231            let (px, py) = coord.transform((nx, ny), &plot_area);
232            // The backend centres text vertically (dominant-baseline=middle), so
233            // apply vjust as a baseline shift: 0 = bottom-anchored (text above the
234            // point), 0.5 = centred, 1 = top-anchored (text below). y grows down.
235            let py = py + (self.vjust - 0.5) * self.size;
236
237            let text = label_col[i].to_group_key();
238            let approx_width = text.len() as f64 * self.size * 0.6;
239            let half_w = approx_width / 2.0 + self.padding;
240            let half_h = self.size / 2.0 + self.padding;
241
242            if self.check_overlap {
243                let bbox = (px - half_w, py - half_h, px + half_w, py + half_h);
244                if bboxes_overlap(&bbox, &drawn_bboxes) {
245                    continue;
246                }
247                drawn_bboxes.push(bbox);
248            }
249
250            // Background rect
251            backend.draw_rect(
252                (px - half_w, py - half_h),
253                (px + half_w, py + half_h),
254                &RectStyle {
255                    fill: Some(self.fill),
256                    stroke: Some(self.color),
257                    stroke_width: 0.5,
258                    alpha: self.alpha,
259                    clip: true,
260                },
261            )?;
262
263            // Text
264            let anchor = hjust_to_anchor(self.hjust);
265            backend.draw_text(
266                &text,
267                (px, py),
268                &TextStyle {
269                    color: self.color,
270                    size: self.size,
271                    anchor,
272                    angle: 0.0,
273                    family: None,
274                    face: crate::render::backend::FontFace::Plain,
275                },
276            )?;
277        }
278
279        Ok(())
280    }
281
282    fn required_aes(&self) -> Vec<Aesthetic> {
283        vec![Aesthetic::X, Aesthetic::Y, Aesthetic::Label]
284    }
285
286    fn default_stat(&self) -> Box<dyn Stat> {
287        Box::new(StatIdentity)
288    }
289    fn default_position(&self) -> Box<dyn Position> {
290        Box::new(PositionIdentity)
291    }
292    fn default_params(&self) -> GeomParams {
293        GeomParams::default()
294    }
295    fn name(&self) -> &str {
296        "label"
297    }
298}
299
300/// Map hjust (0.0 = left, 0.5 = center, 1.0 = right) to TextAnchor.
301fn hjust_to_anchor(hjust: f64) -> TextAnchor {
302    if hjust < 0.25 {
303        TextAnchor::Start
304    } else if hjust > 0.75 {
305        TextAnchor::End
306    } else {
307        TextAnchor::Middle
308    }
309}
310
311/// Check if a bbox overlaps any existing bbox.
312fn bboxes_overlap(candidate: &(f64, f64, f64, f64), existing: &[(f64, f64, f64, f64)]) -> bool {
313    for b in existing {
314        // Two rects overlap if they overlap on both axes
315        if candidate.0 < b.2 && candidate.2 > b.0 && candidate.1 < b.3 && candidate.3 > b.1 {
316            return true;
317        }
318    }
319    false
320}