Skip to main content

ggplot_rs/geom/
point.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, PointShape, PointStyle};
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/// Scatter plot points.
16pub struct GeomPoint {
17    pub size: f64,
18    pub color: (u8, u8, u8),
19    pub alpha: f64,
20}
21
22impl Default for GeomPoint {
23    fn default() -> Self {
24        GeomPoint {
25            size: 3.0,
26            color: (0, 0, 0),
27            alpha: 1.0,
28        }
29    }
30}
31
32impl Geom for GeomPoint {
33    fn draw(
34        &self,
35        data: &DataFrame,
36        coord: &dyn Coord,
37        scales: &ScaleSet,
38        _theme: &Theme,
39        backend: &mut dyn DrawBackend,
40    ) -> Result<(), RenderError> {
41        draw_points(
42            data,
43            coord,
44            scales,
45            backend,
46            PointDefaults {
47                size: self.size,
48                color: self.color,
49                alpha: self.alpha,
50                shape: PointShape::Circle,
51            },
52        )
53    }
54
55    fn required_aes(&self) -> Vec<Aesthetic> {
56        vec![Aesthetic::X, Aesthetic::Y]
57    }
58
59    fn default_stat(&self) -> Box<dyn Stat> {
60        Box::new(StatIdentity)
61    }
62
63    fn default_position(&self) -> Box<dyn Position> {
64        Box::new(PositionIdentity)
65    }
66
67    fn default_params(&self) -> GeomParams {
68        GeomParams::default()
69    }
70
71    fn name(&self) -> &str {
72        "point"
73    }
74
75    fn consumes_tooltips(&self) -> bool {
76        true
77    }
78
79    fn apply_element_geom(&mut self, el: &crate::theme::elements::ElementGeom) {
80        if let (true, Some(ink)) = (self.color == (0, 0, 0), el.ink) {
81            self.color = ink;
82        }
83    }
84
85    fn set_series_color(&mut self, color: (u8, u8, u8)) {
86        self.color = color;
87    }
88}
89
90/// Unmapped defaults for [`draw_points`].
91pub(crate) struct PointDefaults {
92    pub size: f64,
93    pub color: (u8, u8, u8),
94    pub alpha: f64,
95    pub shape: PointShape,
96}
97
98/// Draw one point per row (shared by `geom_point` and censor marks):
99/// mapped `color`/`size`/`alpha`/`shape` win over `defaults`; the x position
100/// honours a dodge offset; every point carries hover metadata.
101pub(crate) fn draw_points(
102    data: &DataFrame,
103    coord: &dyn Coord,
104    scales: &ScaleSet,
105    backend: &mut dyn DrawBackend,
106    defaults: PointDefaults,
107) -> Result<(), RenderError> {
108    let x_col = data
109        .column("x")
110        .ok_or(RenderError::MissingAesthetic("x".into()))?;
111    let y_col = data
112        .column("y")
113        .ok_or(RenderError::MissingAesthetic("y".into()))?;
114    let color_col = data.column("color");
115    let size_col = data.column("size");
116    let alpha_col = data.column("alpha");
117    let shape_col = data.column("shape");
118    let label_col = data.column("label");
119
120    let plot_area = backend.plot_area();
121    let xm = super::support::XMapper::new(data, scales);
122    let y_scale = scales.get(&Aesthetic::Y);
123
124    for i in 0..data.nrows() {
125        // Drop missing values (ggplot2 removes rows with NA x/y). Without this
126        // an NA maps to 0.0 and paints a spurious point at the panel baseline.
127        if matches!(x_col[i], crate::data::Value::Na) || matches!(y_col[i], crate::data::Value::Na)
128        {
129            continue;
130        }
131        let nx = xm.map(&x_col[i], i);
132        let ny = y_scale.map(|s| s.map(&y_col[i])).unwrap_or(0.0);
133        let (px, py) = coord.transform((nx, ny), &plot_area);
134
135        // Hover tooltip: an explicit `label` with its y value ("series: 22"),
136        // else "(x, y)" prefixed by group.
137        let tip = match label_col
138            .map(|c| super::tip_value(&c[i]))
139            .filter(|s| !s.is_empty())
140        {
141            Some(l) => Some(format!("{l}: {}", super::tip_value(&y_col[i]))),
142            None => {
143                let xy = format!(
144                    "({}, {})",
145                    super::tip_value(&x_col[i]),
146                    super::tip_value(&y_col[i])
147                );
148                match color_col
149                    .map(|c| super::tip_value(&c[i]))
150                    .filter(|s| !s.is_empty())
151                {
152                    Some(gr) => Some(format!("{gr}: {xy}")),
153                    None => Some(xy),
154                }
155            }
156        };
157        super::set_mark(
158            backend,
159            tip,
160            Some(super::tip_value(&x_col[i])),
161            super::series_key(data, i),
162            super::measured_value(data, i),
163        );
164        super::set_mark_formatted(
165            backend,
166            scales,
167            Some(&x_col[i]),
168            &Aesthetic::Y,
169            super::measured_raw(data, i),
170        );
171        super::set_row_extras(backend, data, i);
172
173        let (r, g, b) = if let Some(cc) = color_col {
174            scales
175                .map_color(&Aesthetic::Color, &cc[i])
176                .unwrap_or(defaults.color)
177        } else {
178            defaults.color
179        };
180
181        let alpha = alpha_col
182            .and_then(|c| scales.map_alpha(&c[i]))
183            .or_else(|| alpha_col.and_then(|c| c[i].as_f64()))
184            .unwrap_or(defaults.alpha);
185
186        let size = size_col
187            .and_then(|c| scales.map_size(&c[i]))
188            .or_else(|| size_col.and_then(|c| c[i].as_f64()))
189            .unwrap_or(defaults.size);
190
191        let shape = shape_col
192            .and_then(|c| scales.map_shape(&c[i]))
193            .unwrap_or(defaults.shape);
194
195        backend.draw_shape(
196            (px, py),
197            size,
198            &PointStyle {
199                color: (r, g, b),
200                alpha,
201                filled: true,
202                shape,
203            },
204        )?;
205    }
206    super::clear_mark(backend);
207    super::clear_row_extras(backend);
208
209    Ok(())
210}
211
212#[cfg(test)]
213mod interactive_aes_tests {
214    use crate::data::Value;
215    use crate::prelude::*;
216
217    /// Review finding #1 (CRITICAL): a raw data column that merely happens
218    /// to be named like a `data-*` attribute — never mapped through
219    /// `Aes::data_attr` — must never reach the SVG as an attribute, since an
220    /// adversarial column name (e.g. a SQL alias) could inject arbitrary
221    /// attribute syntax.
222    #[test]
223    fn a_raw_column_named_like_a_data_attr_is_never_emitted_as_one() {
224        let evil = "data-a onmouseover=alert(1) b";
225        let svg = GGPlot::new(vec![
226            ("x".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
227            ("y".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
228            (
229                evil.into(),
230                vec![Value::Str("v".into()), Value::Str("v".into())],
231            ),
232        ])
233        .aes(Aes::new().x("x").y("y")) // no data_attr mapping at all
234        .geom_point()
235        .render_svg_native_with_size(200, 200)
236        .unwrap();
237
238        assert!(!svg.contains("onmouseover"));
239        assert!(!svg.contains(evil));
240        // Also: a column that merely *looks* like a validly-named data-attr
241        // (no injection, but still unmapped) must not leak either, so
242        // "layers without these aes are byte-identical" actually holds.
243        let svg2 = GGPlot::new(vec![
244            ("x".into(), vec![Value::Float(0.0)]),
245            ("y".into(), vec![Value::Float(0.0)]),
246            ("data-row".into(), vec![Value::Str("42".into())]),
247        ])
248        .aes(Aes::new().x("x").y("y"))
249        .geom_point()
250        .render_svg_native_with_size(200, 200)
251        .unwrap();
252        assert!(!svg2.contains("data-row"));
253    }
254
255    #[test]
256    fn missing_tooltip_or_attr_on_a_later_row_does_not_inherit_an_earlier_rows() {
257        let svg = GGPlot::new(vec![
258            ("x".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
259            ("y".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
260            ("tip".into(), vec![Value::Str("first".into()), Value::Na]),
261            ("extra".into(), vec![Value::Str("A".into()), Value::Na]),
262        ])
263        .aes(
264            Aes::new()
265                .x("x")
266                .y("y")
267                .tooltip("tip")
268                .data_attr("extra", "extra"),
269        )
270        .geom_point()
271        .render_svg_native_with_size(200, 200)
272        .unwrap();
273
274        // Exactly one <title> (row 1's) and one data-extra (row 1's) — row 2
275        // carries neither.
276        assert_eq!(svg.matches("<title>first</title>").count(), 1);
277        assert_eq!(svg.matches("data-extra=\"A\"").count(), 1);
278        // Row 2's circle (the second <circle ...>) has no data-extra and no
279        // <title> at all.
280        let circles: Vec<&str> = svg.split("<circle").skip(1).collect();
281        assert_eq!(circles.len(), 2);
282        let second = circles[1].split("/>").next().unwrap();
283        assert!(!second.contains("data-extra"));
284        assert!(!circles[1].contains("<title>"));
285    }
286
287    #[test]
288    fn tooltip_does_not_leak_into_a_later_layer_or_the_legend() {
289        let svg = GGPlot::new(vec![
290            ("x".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
291            ("y".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
292            (
293                "g".into(),
294                vec![Value::Str("a".into()), Value::Str("b".into())],
295            ),
296            (
297                "tip".into(),
298                vec![Value::Str("T1".into()), Value::Str("T2".into())],
299            ),
300        ])
301        .aes(Aes::new().x("x").y("y").color("g").tooltip("tip"))
302        .geom_point()
303        .annotate_rect(0.2, 0.3, 0.2, 0.3)
304        .render_svg_native_with_size(300, 300)
305        .unwrap();
306
307        // Only the two mapped point tooltips exist; the legend keys/
308        // background and the annotation rect carry none of them.
309        assert_eq!(svg.matches("<title>").count(), 2);
310        assert!(svg.contains("<title>T1</title>"));
311        assert!(svg.contains("<title>T2</title>"));
312    }
313
314    /// Review R-1 (blocking): an unmapped raw column literally named
315    /// `"tooltip"` must not drive tooltips — the same class of bug fixed
316    /// for `data_attr`, now also fixed for `Aes::tooltip`'s own plain name.
317    #[test]
318    fn a_raw_column_named_tooltip_with_no_mapping_sets_no_tooltips() {
319        let with_raw_tooltip_col = GGPlot::new(vec![
320            ("x".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
321            ("y".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
322            (
323                "tooltip".into(),
324                vec![Value::Str("sneaky 1".into()), Value::Str("sneaky 2".into())],
325            ),
326        ])
327        .aes(Aes::new().x("x").y("y")) // no Aes::tooltip mapping
328        .geom_point()
329        .render_svg_native_with_size(200, 200)
330        .unwrap();
331        assert!(!with_raw_tooltip_col.contains("sneaky"));
332
333        // Same dataset, without the raw column, for a baseline title count.
334        let without = GGPlot::new(vec![
335            ("x".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
336            ("y".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
337        ])
338        .aes(Aes::new().x("x").y("y"))
339        .geom_point()
340        .render_svg_native_with_size(200, 200)
341        .unwrap();
342        // geom_point's own default title is "(x, y)", present either way —
343        // the raw "tooltip" column changed nothing about it.
344        let title_count = |s: &str| s.matches("<title>").count();
345        assert_eq!(title_count(&with_raw_tooltip_col), title_count(&without));
346    }
347
348    /// Review R-3 (low): a raw column that tries to *forge* the internal
349    /// `data_attr`/`tooltip` storage name directly (NUL byte included —
350    /// `GGPlot::new` accepts any `String`) is dropped by `resolve_mappings`,
351    /// not carried through and misread as a validated attribute.
352    #[test]
353    fn a_raw_column_forging_the_internal_prefix_is_dropped() {
354        let forged_data_attr = format!("{}data-spoof", crate::aes::DATA_ATTR_INTERNAL_PREFIX);
355        let forged_tooltip = crate::aes::TOOLTIP_INTERNAL_NAME.to_string();
356        let svg = GGPlot::new(vec![
357            ("x".into(), vec![Value::Float(0.0)]),
358            ("y".into(), vec![Value::Float(0.0)]),
359            (forged_data_attr, vec![Value::Str("evil".into())]),
360            (forged_tooltip, vec![Value::Str("evil tip".into())]),
361        ])
362        .aes(Aes::new().x("x").y("y"))
363        .geom_point()
364        .render_svg_native_with_size(200, 200)
365        .unwrap();
366        assert!(!svg.contains("data-spoof"));
367        assert!(!svg.contains("evil"));
368    }
369}