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