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 xm = super::support::XMapper::new(data, scales);
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 = xm.map(&x_col[i], i);
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: super::resolve_font_family(&self.fontfamily, theme),
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    fn apply_element_geom(&mut self, el: &crate::theme::elements::ElementGeom) {
147        if let (true, Some(ink)) = (self.color == (0, 0, 0), el.ink) {
148            self.color = ink;
149        }
150    }
151}
152
153/// Label geometry — like text but with a background rectangle.
154pub struct GeomLabel {
155    pub size: f64,
156    pub color: (u8, u8, u8),
157    pub fill: (u8, u8, u8),
158    pub alpha: f64,
159    pub padding: f64,
160    /// Horizontal justification: 0.0 = left, 0.5 = center (default), 1.0 = right.
161    pub hjust: f64,
162    /// Vertical justification: 0.0 = bottom, 0.5 = middle (default), 1.0 = top.
163    pub vjust: f64,
164    /// Font family name (informational; actual rendering depends on backend).
165    pub fontfamily: String,
166    /// When true, skip drawing labels that overlap previously drawn labels.
167    pub check_overlap: bool,
168}
169
170impl GeomLabel {
171    pub fn with_hjust(mut self, hjust: f64) -> Self {
172        self.hjust = hjust;
173        self
174    }
175
176    pub fn with_vjust(mut self, vjust: f64) -> Self {
177        self.vjust = vjust;
178        self
179    }
180
181    pub fn with_fontfamily(mut self, family: &str) -> Self {
182        self.fontfamily = family.to_string();
183        self
184    }
185
186    pub fn with_check_overlap(mut self, check: bool) -> Self {
187        self.check_overlap = check;
188        self
189    }
190}
191
192impl Default for GeomLabel {
193    fn default() -> Self {
194        GeomLabel {
195            size: 10.0,
196            color: (0, 0, 0),
197            fill: (255, 255, 255),
198            alpha: 0.8,
199            padding: 3.0,
200            hjust: 0.5,
201            vjust: 0.5,
202            fontfamily: String::new(),
203            check_overlap: false,
204        }
205    }
206}
207
208impl Geom for GeomLabel {
209    fn draw(
210        &self,
211        data: &DataFrame,
212        coord: &dyn Coord,
213        scales: &ScaleSet,
214        theme: &Theme,
215        backend: &mut dyn DrawBackend,
216    ) -> Result<(), RenderError> {
217        let x_col = data
218            .column("x")
219            .ok_or(RenderError::MissingAesthetic("x".into()))?;
220        let y_col = data
221            .column("y")
222            .ok_or(RenderError::MissingAesthetic("y".into()))?;
223        let label_col = data
224            .column("label")
225            .ok_or(RenderError::MissingAesthetic("label".into()))?;
226
227        let plot_area = backend.plot_area();
228        let xm = super::support::XMapper::new(data, scales);
229        let y_scale = scales.get(&Aesthetic::Y);
230
231        let mut drawn_bboxes: Vec<(f64, f64, f64, f64)> = Vec::new();
232
233        for i in 0..data.nrows() {
234            let nx = xm.map(&x_col[i], i);
235            let ny = y_scale.map(|s| s.map(&y_col[i])).unwrap_or(0.0);
236            let (px, py) = coord.transform((nx, ny), &plot_area);
237            // The backend centres text vertically (dominant-baseline=middle), so
238            // apply vjust as a baseline shift: 0 = bottom-anchored (text above the
239            // point), 0.5 = centred, 1 = top-anchored (text below). y grows down.
240            let py = py + (self.vjust - 0.5) * self.size;
241
242            let text = label_col[i].to_group_key();
243            let approx_width = text.len() as f64 * self.size * 0.6;
244            let half_w = approx_width / 2.0 + self.padding;
245            let half_h = self.size / 2.0 + self.padding;
246
247            if self.check_overlap {
248                let bbox = (px - half_w, py - half_h, px + half_w, py + half_h);
249                if bboxes_overlap(&bbox, &drawn_bboxes) {
250                    continue;
251                }
252                drawn_bboxes.push(bbox);
253            }
254
255            // Background rect
256            backend.draw_rect(
257                (px - half_w, py - half_h),
258                (px + half_w, py + half_h),
259                &RectStyle {
260                    fill: Some(self.fill),
261                    stroke: Some(self.color),
262                    stroke_width: 0.5,
263                    alpha: self.alpha,
264                    clip: true,
265                },
266            )?;
267
268            // Text
269            let anchor = hjust_to_anchor(self.hjust);
270            backend.draw_text(
271                &text,
272                (px, py),
273                &TextStyle {
274                    color: self.color,
275                    size: self.size,
276                    anchor,
277                    angle: 0.0,
278                    family: super::resolve_font_family(&self.fontfamily, theme),
279                    face: crate::render::backend::FontFace::Plain,
280                },
281            )?;
282        }
283
284        Ok(())
285    }
286
287    fn required_aes(&self) -> Vec<Aesthetic> {
288        vec![Aesthetic::X, Aesthetic::Y, Aesthetic::Label]
289    }
290
291    fn default_stat(&self) -> Box<dyn Stat> {
292        Box::new(StatIdentity)
293    }
294    fn default_position(&self) -> Box<dyn Position> {
295        Box::new(PositionIdentity)
296    }
297    fn default_params(&self) -> GeomParams {
298        GeomParams::default()
299    }
300    fn name(&self) -> &str {
301        "label"
302    }
303    fn apply_element_geom(&mut self, el: &crate::theme::elements::ElementGeom) {
304        if let (true, Some(ink)) = (self.color == (0, 0, 0), el.ink) {
305            self.color = ink;
306        }
307        if let (true, Some(paper)) = (self.fill == (255, 255, 255), el.paper) {
308            self.fill = paper;
309        }
310    }
311}
312
313/// Map hjust (0.0 = left, 0.5 = center, 1.0 = right) to TextAnchor.
314fn hjust_to_anchor(hjust: f64) -> TextAnchor {
315    if hjust < 0.25 {
316        TextAnchor::Start
317    } else if hjust > 0.75 {
318        TextAnchor::End
319    } else {
320        TextAnchor::Middle
321    }
322}
323
324/// Check if a bbox overlaps any existing bbox.
325fn bboxes_overlap(candidate: &(f64, f64, f64, f64), existing: &[(f64, f64, f64, f64)]) -> bool {
326    for b in existing {
327        // Two rects overlap if they overlap on both axes
328        if candidate.0 < b.2 && candidate.2 > b.0 && candidate.1 < b.3 && candidate.3 > b.1 {
329            return true;
330        }
331    }
332    false
333}