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