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
96            let text = label_col[i].to_group_key();
97
98            if self.check_overlap {
99                let w = text.len() as f64 * self.size * 0.6;
100                let h = self.size;
101                let bbox = (px - w / 2.0, py - h / 2.0, px + w / 2.0, py + h / 2.0);
102                if bboxes_overlap(&bbox, &drawn_bboxes) {
103                    continue;
104                }
105                drawn_bboxes.push(bbox);
106            }
107
108            let anchor = hjust_to_anchor(self.hjust);
109            backend.draw_text(
110                &text,
111                (px, py),
112                &TextStyle {
113                    color: self.color,
114                    size: self.size,
115                    anchor,
116                    angle: 0.0,
117                    family: None,
118                    face: crate::render::backend::FontFace::Plain,
119                },
120            )?;
121        }
122
123        Ok(())
124    }
125
126    fn required_aes(&self) -> Vec<Aesthetic> {
127        vec![Aesthetic::X, Aesthetic::Y, Aesthetic::Label]
128    }
129
130    fn default_stat(&self) -> Box<dyn Stat> {
131        Box::new(StatIdentity)
132    }
133    fn default_position(&self) -> Box<dyn Position> {
134        Box::new(PositionIdentity)
135    }
136    fn default_params(&self) -> GeomParams {
137        GeomParams::default()
138    }
139    fn name(&self) -> &str {
140        "text"
141    }
142}
143
144/// Label geometry — like text but with a background rectangle.
145pub struct GeomLabel {
146    pub size: f64,
147    pub color: (u8, u8, u8),
148    pub fill: (u8, u8, u8),
149    pub alpha: f64,
150    pub padding: f64,
151    /// Horizontal justification: 0.0 = left, 0.5 = center (default), 1.0 = right.
152    pub hjust: f64,
153    /// Vertical justification: 0.0 = bottom, 0.5 = middle (default), 1.0 = top.
154    pub vjust: f64,
155    /// Font family name (informational; actual rendering depends on backend).
156    pub fontfamily: String,
157    /// When true, skip drawing labels that overlap previously drawn labels.
158    pub check_overlap: bool,
159}
160
161impl GeomLabel {
162    pub fn with_hjust(mut self, hjust: f64) -> Self {
163        self.hjust = hjust;
164        self
165    }
166
167    pub fn with_vjust(mut self, vjust: f64) -> Self {
168        self.vjust = vjust;
169        self
170    }
171
172    pub fn with_fontfamily(mut self, family: &str) -> Self {
173        self.fontfamily = family.to_string();
174        self
175    }
176
177    pub fn with_check_overlap(mut self, check: bool) -> Self {
178        self.check_overlap = check;
179        self
180    }
181}
182
183impl Default for GeomLabel {
184    fn default() -> Self {
185        GeomLabel {
186            size: 10.0,
187            color: (0, 0, 0),
188            fill: (255, 255, 255),
189            alpha: 0.8,
190            padding: 3.0,
191            hjust: 0.5,
192            vjust: 0.5,
193            fontfamily: String::new(),
194            check_overlap: false,
195        }
196    }
197}
198
199impl Geom for GeomLabel {
200    fn draw(
201        &self,
202        data: &DataFrame,
203        coord: &dyn Coord,
204        scales: &ScaleSet,
205        _theme: &Theme,
206        backend: &mut dyn DrawBackend,
207    ) -> Result<(), RenderError> {
208        let x_col = data
209            .column("x")
210            .ok_or(RenderError::MissingAesthetic("x".into()))?;
211        let y_col = data
212            .column("y")
213            .ok_or(RenderError::MissingAesthetic("y".into()))?;
214        let label_col = data
215            .column("label")
216            .ok_or(RenderError::MissingAesthetic("label".into()))?;
217
218        let plot_area = backend.plot_area();
219        let x_scale = scales.get(&Aesthetic::X);
220        let y_scale = scales.get(&Aesthetic::Y);
221
222        let mut drawn_bboxes: Vec<(f64, f64, f64, f64)> = Vec::new();
223
224        for i in 0..data.nrows() {
225            let nx = x_scale.map(|s| s.map(&x_col[i])).unwrap_or(0.0);
226            let ny = y_scale.map(|s| s.map(&y_col[i])).unwrap_or(0.0);
227            let (px, py) = coord.transform((nx, ny), &plot_area);
228
229            let text = label_col[i].to_group_key();
230            let approx_width = text.len() as f64 * self.size * 0.6;
231            let half_w = approx_width / 2.0 + self.padding;
232            let half_h = self.size / 2.0 + self.padding;
233
234            if self.check_overlap {
235                let bbox = (px - half_w, py - half_h, px + half_w, py + half_h);
236                if bboxes_overlap(&bbox, &drawn_bboxes) {
237                    continue;
238                }
239                drawn_bboxes.push(bbox);
240            }
241
242            // Background rect
243            backend.draw_rect(
244                (px - half_w, py - half_h),
245                (px + half_w, py + half_h),
246                &RectStyle {
247                    fill: Some(self.fill),
248                    stroke: Some(self.color),
249                    stroke_width: 0.5,
250                    alpha: self.alpha,
251                    clip: true,
252                },
253            )?;
254
255            // Text
256            let anchor = hjust_to_anchor(self.hjust);
257            backend.draw_text(
258                &text,
259                (px, py),
260                &TextStyle {
261                    color: self.color,
262                    size: self.size,
263                    anchor,
264                    angle: 0.0,
265                    family: None,
266                    face: crate::render::backend::FontFace::Plain,
267                },
268            )?;
269        }
270
271        Ok(())
272    }
273
274    fn required_aes(&self) -> Vec<Aesthetic> {
275        vec![Aesthetic::X, Aesthetic::Y, Aesthetic::Label]
276    }
277
278    fn default_stat(&self) -> Box<dyn Stat> {
279        Box::new(StatIdentity)
280    }
281    fn default_position(&self) -> Box<dyn Position> {
282        Box::new(PositionIdentity)
283    }
284    fn default_params(&self) -> GeomParams {
285        GeomParams::default()
286    }
287    fn name(&self) -> &str {
288        "label"
289    }
290}
291
292/// Map hjust (0.0 = left, 0.5 = center, 1.0 = right) to TextAnchor.
293fn hjust_to_anchor(hjust: f64) -> TextAnchor {
294    if hjust < 0.25 {
295        TextAnchor::Start
296    } else if hjust > 0.75 {
297        TextAnchor::End
298    } else {
299        TextAnchor::Middle
300    }
301}
302
303/// Check if a bbox overlaps any existing bbox.
304fn bboxes_overlap(candidate: &(f64, f64, f64, f64), existing: &[(f64, f64, f64, f64)]) -> bool {
305    for b in existing {
306        // Two rects overlap if they overlap on both axes
307        if candidate.0 < b.2 && candidate.2 > b.0 && candidate.1 < b.3 && candidate.3 > b.1 {
308            return true;
309        }
310    }
311    false
312}