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/// Whether a point layer's marks are inked.
16///
17/// Points over a line often exist mainly as per-point hover and click
18/// targets (each mark carries its `<title>` and `data-*` attributes). On a
19/// dense series they swamp the line, so they can be drawn without ink: the
20/// mark stays in the SVG with `fill-opacity="0"`, which browsers still
21/// hit-test (`pointer-events: visiblePainted` counts a painted fill of any
22/// opacity), so hover and clicks keep working.
23#[derive(Debug, Clone, Copy, PartialEq, Default)]
24pub enum PointVisibility {
25    /// Every point is drawn (the default).
26    #[default]
27    Visible,
28    /// No point is inked.
29    Hidden,
30    /// Visible while the panel's distinct x positions are at least
31    /// `min_spacing_px` apart on average, hidden when denser. Decided per
32    /// panel, so a facet with a short series keeps its dots.
33    Auto { min_spacing_px: f64 },
34}
35
36impl PointVisibility {
37    /// `Auto` with a 3 px average spacing: a 7-day series at 15-minute
38    /// resolution (672 points) is hidden on any plot narrower than ~2000 px,
39    /// a 12-point monthly series stays visible down to ~36 px.
40    pub const AUTO: PointVisibility = PointVisibility::Auto {
41        min_spacing_px: 3.0,
42    };
43}
44
45/// Scatter plot points.
46pub struct GeomPoint {
47    pub size: f64,
48    pub color: (u8, u8, u8),
49    pub alpha: f64,
50    /// Whether the marks are inked; see [`PointVisibility`].
51    pub visibility: PointVisibility,
52    /// When `visibility` hides the points: keep them as transparent hover
53    /// and click targets (`true`, the default), or omit them from the
54    /// output entirely (`false`, for static output where nothing hovers).
55    pub hidden_targets: bool,
56}
57
58impl Default for GeomPoint {
59    fn default() -> Self {
60        GeomPoint {
61            size: 3.0,
62            color: (0, 0, 0),
63            alpha: 1.0,
64            visibility: PointVisibility::Visible,
65            hidden_targets: true,
66        }
67    }
68}
69
70impl Geom for GeomPoint {
71    fn draw(
72        &self,
73        data: &DataFrame,
74        coord: &dyn Coord,
75        scales: &ScaleSet,
76        _theme: &Theme,
77        backend: &mut dyn DrawBackend,
78    ) -> Result<(), RenderError> {
79        draw_points(
80            data,
81            coord,
82            scales,
83            backend,
84            PointDefaults {
85                size: self.size,
86                color: self.color,
87                alpha: self.alpha,
88                shape: PointShape::Circle,
89                visibility: self.visibility,
90                hidden_targets: self.hidden_targets,
91            },
92        )
93    }
94
95    fn required_aes(&self) -> Vec<Aesthetic> {
96        vec![Aesthetic::X, Aesthetic::Y]
97    }
98
99    fn default_stat(&self) -> Box<dyn Stat> {
100        Box::new(StatIdentity)
101    }
102
103    fn default_position(&self) -> Box<dyn Position> {
104        Box::new(PositionIdentity)
105    }
106
107    fn default_params(&self) -> GeomParams {
108        GeomParams::default()
109    }
110
111    fn name(&self) -> &str {
112        "point"
113    }
114
115    fn consumes_tooltips(&self) -> bool {
116        true
117    }
118
119    fn apply_element_geom(&mut self, el: &crate::theme::elements::ElementGeom) {
120        if let (true, Some(ink)) = (self.color == (0, 0, 0), el.ink) {
121            self.color = ink;
122        }
123        // #413: `element_geom(pointsize =)` for the built-in default only.
124        let d = GeomPoint::default();
125        if let (true, Some(sz)) = (self.size == d.size, el.pointsize) {
126            self.size = sz;
127        }
128    }
129
130    fn set_series_color(&mut self, color: (u8, u8, u8)) {
131        self.color = color;
132    }
133}
134
135/// Unmapped defaults for [`draw_points`].
136pub(crate) struct PointDefaults {
137    pub size: f64,
138    pub color: (u8, u8, u8),
139    pub alpha: f64,
140    pub shape: PointShape,
141    pub visibility: PointVisibility,
142    pub hidden_targets: bool,
143}
144
145/// `true` when the panel's distinct x positions sit closer than
146/// `min_spacing_px` on average. The span is measured between the
147/// transformed first and last x (so a flipped coord measures along y).
148fn too_dense(
149    data: &DataFrame,
150    x_col: &[crate::data::Value],
151    xm: &super::support::XMapper<'_>,
152    coord: &dyn Coord,
153    plot_area: &crate::render::Rect,
154    min_spacing_px: f64,
155) -> bool {
156    let mut xs: Vec<f64> = (0..data.nrows())
157        .filter(|&i| !matches!(x_col[i], crate::data::Value::Na))
158        .map(|i| xm.map(&x_col[i], i))
159        .filter(|v| v.is_finite())
160        .collect();
161    xs.sort_by(f64::total_cmp);
162    xs.dedup_by(|a, b| (*a - *b).abs() < 1e-12);
163    let (Some(&lo), Some(&hi)) = (xs.first(), xs.last()) else {
164        return false;
165    };
166    if xs.len() < 2 {
167        return false;
168    }
169    let (ax, ay) = coord.transform((lo, 0.5), plot_area);
170    let (bx, by) = coord.transform((hi, 0.5), plot_area);
171    let span = ((bx - ax).powi(2) + (by - ay).powi(2)).sqrt();
172    span / ((xs.len() - 1) as f64) < min_spacing_px
173}
174
175/// Draw one point per row (shared by `geom_point` and censor marks):
176/// mapped `color`/`size`/`alpha`/`shape` win over `defaults`; the x position
177/// honours a dodge offset; every point carries hover metadata.
178pub(crate) fn draw_points(
179    data: &DataFrame,
180    coord: &dyn Coord,
181    scales: &ScaleSet,
182    backend: &mut dyn DrawBackend,
183    defaults: PointDefaults,
184) -> Result<(), RenderError> {
185    let x_col = data
186        .column("x")
187        .ok_or(RenderError::MissingAesthetic("x".into()))?;
188    let y_col = data
189        .column("y")
190        .ok_or(RenderError::MissingAesthetic("y".into()))?;
191    let color_col = data.column("color");
192    let size_col = data.column("size");
193    let alpha_col = data.column("alpha");
194    let shape_col = data.column("shape");
195    let label_col = data.column("label");
196
197    let plot_area = backend.plot_area();
198    let xm = super::support::XMapper::new(data, scales);
199    let y_scale = scales.get(&Aesthetic::Y);
200
201    let hidden = match defaults.visibility {
202        PointVisibility::Visible => false,
203        PointVisibility::Hidden => true,
204        PointVisibility::Auto { min_spacing_px } => {
205            too_dense(data, x_col, &xm, coord, &plot_area, min_spacing_px)
206        }
207    };
208    if hidden && !defaults.hidden_targets {
209        return Ok(());
210    }
211
212    for i in 0..data.nrows() {
213        // Drop missing values (ggplot2 removes rows with NA x/y). Without this
214        // an NA maps to 0.0 and paints a spurious point at the panel baseline.
215        if matches!(x_col[i], crate::data::Value::Na) || matches!(y_col[i], crate::data::Value::Na)
216        {
217            continue;
218        }
219        let nx = xm.map(&x_col[i], i);
220        let ny = y_scale.map(|s| s.map(&y_col[i])).unwrap_or(0.0);
221        let (px, py) = coord.transform((nx, ny), &plot_area);
222
223        // Hover tooltip: an explicit `label` with its y value ("series: 22"),
224        // else "(x, y)" prefixed by group.
225        let tip = match label_col
226            .map(|c| super::tip_value(&c[i]))
227            .filter(|s| !s.is_empty())
228        {
229            Some(l) => Some(format!("{l}: {}", super::tip_value(&y_col[i]))),
230            None => {
231                let xy = format!(
232                    "({}, {})",
233                    super::tip_value(&x_col[i]),
234                    super::tip_value(&y_col[i])
235                );
236                match color_col
237                    .map(|c| super::tip_value(&c[i]))
238                    .filter(|s| !s.is_empty())
239                {
240                    Some(gr) => Some(format!("{gr}: {xy}")),
241                    None => Some(xy),
242                }
243            }
244        };
245        super::set_mark(
246            backend,
247            tip,
248            Some(super::tip_value(&x_col[i])),
249            super::series_key(data, i),
250            super::measured_value(data, i),
251        );
252        super::set_mark_formatted(
253            backend,
254            scales,
255            Some(&x_col[i]),
256            &Aesthetic::Y,
257            super::measured_raw(data, i),
258        );
259        super::set_row_extras(backend, data, i);
260
261        let (r, g, b) = if let Some(cc) = color_col {
262            scales
263                .map_color(&Aesthetic::Color, &cc[i])
264                .unwrap_or(defaults.color)
265        } else {
266            defaults.color
267        };
268
269        let alpha = alpha_col
270            .and_then(|c| scales.map_alpha(&c[i]))
271            .or_else(|| alpha_col.and_then(|c| c[i].as_f64()))
272            .unwrap_or(defaults.alpha);
273        let alpha = if hidden { 0.0 } else { alpha };
274
275        let size = size_col
276            .and_then(|c| scales.map_size(&c[i]))
277            .or_else(|| size_col.and_then(|c| c[i].as_f64()))
278            .unwrap_or(defaults.size);
279
280        let shape = shape_col
281            .and_then(|c| scales.map_shape(&c[i]))
282            .unwrap_or(defaults.shape);
283
284        backend.draw_shape(
285            (px, py),
286            size,
287            &PointStyle {
288                color: (r, g, b),
289                alpha,
290                filled: true,
291                shape,
292            },
293        )?;
294    }
295    super::clear_mark(backend);
296    super::clear_row_extras(backend);
297
298    Ok(())
299}
300
301#[cfg(test)]
302mod interactive_aes_tests {
303    use crate::data::Value;
304    use crate::prelude::*;
305
306    /// Review finding #1 (CRITICAL): a raw data column that merely happens
307    /// to be named like a `data-*` attribute — never mapped through
308    /// `Aes::data_attr` — must never reach the SVG as an attribute, since an
309    /// adversarial column name (e.g. a SQL alias) could inject arbitrary
310    /// attribute syntax.
311    #[test]
312    fn a_raw_column_named_like_a_data_attr_is_never_emitted_as_one() {
313        let evil = "data-a onmouseover=alert(1) b";
314        let svg = GGPlot::new(vec![
315            ("x".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
316            ("y".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
317            (
318                evil.into(),
319                vec![Value::Str("v".into()), Value::Str("v".into())],
320            ),
321        ])
322        .aes(Aes::new().x("x").y("y")) // no data_attr mapping at all
323        .geom_point()
324        .render_svg_native_with_size(200, 200)
325        .unwrap();
326
327        assert!(!svg.contains("onmouseover"));
328        assert!(!svg.contains(evil));
329        // Also: a column that merely *looks* like a validly-named data-attr
330        // (no injection, but still unmapped) must not leak either, so
331        // "layers without these aes are byte-identical" actually holds.
332        let svg2 = GGPlot::new(vec![
333            ("x".into(), vec![Value::Float(0.0)]),
334            ("y".into(), vec![Value::Float(0.0)]),
335            ("data-row".into(), vec![Value::Str("42".into())]),
336        ])
337        .aes(Aes::new().x("x").y("y"))
338        .geom_point()
339        .render_svg_native_with_size(200, 200)
340        .unwrap();
341        assert!(!svg2.contains("data-row"));
342    }
343
344    #[test]
345    fn missing_tooltip_or_attr_on_a_later_row_does_not_inherit_an_earlier_rows() {
346        let svg = GGPlot::new(vec![
347            ("x".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
348            ("y".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
349            ("tip".into(), vec![Value::Str("first".into()), Value::Na]),
350            ("extra".into(), vec![Value::Str("A".into()), Value::Na]),
351        ])
352        .aes(
353            Aes::new()
354                .x("x")
355                .y("y")
356                .tooltip("tip")
357                .data_attr("extra", "extra"),
358        )
359        .geom_point()
360        .render_svg_native_with_size(200, 200)
361        .unwrap();
362
363        // Exactly one <title> (row 1's) and one data-extra (row 1's) — row 2
364        // carries neither.
365        assert_eq!(svg.matches("<title>first</title>").count(), 1);
366        assert_eq!(svg.matches("data-extra=\"A\"").count(), 1);
367        // Row 2's circle (the second <circle ...>) has no data-extra and no
368        // <title> at all.
369        let circles: Vec<&str> = svg.split("<circle").skip(1).collect();
370        assert_eq!(circles.len(), 2);
371        let second = circles[1].split("/>").next().unwrap();
372        assert!(!second.contains("data-extra"));
373        assert!(!circles[1].contains("<title>"));
374    }
375
376    #[test]
377    fn tooltip_does_not_leak_into_a_later_layer_or_the_legend() {
378        let svg = GGPlot::new(vec![
379            ("x".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
380            ("y".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
381            (
382                "g".into(),
383                vec![Value::Str("a".into()), Value::Str("b".into())],
384            ),
385            (
386                "tip".into(),
387                vec![Value::Str("T1".into()), Value::Str("T2".into())],
388            ),
389        ])
390        .aes(Aes::new().x("x").y("y").color("g").tooltip("tip"))
391        .geom_point()
392        .annotate_rect(0.2, 0.3, 0.2, 0.3)
393        .render_svg_native_with_size(300, 300)
394        .unwrap();
395
396        // Only the two mapped point tooltips exist; the legend keys/
397        // background and the annotation rect carry none of them.
398        assert_eq!(svg.matches("<title>").count(), 2);
399        assert!(svg.contains("<title>T1</title>"));
400        assert!(svg.contains("<title>T2</title>"));
401    }
402
403    /// Review R-1 (blocking): an unmapped raw column literally named
404    /// `"tooltip"` must not drive tooltips — the same class of bug fixed
405    /// for `data_attr`, now also fixed for `Aes::tooltip`'s own plain name.
406    #[test]
407    fn a_raw_column_named_tooltip_with_no_mapping_sets_no_tooltips() {
408        let with_raw_tooltip_col = GGPlot::new(vec![
409            ("x".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
410            ("y".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
411            (
412                "tooltip".into(),
413                vec![Value::Str("sneaky 1".into()), Value::Str("sneaky 2".into())],
414            ),
415        ])
416        .aes(Aes::new().x("x").y("y")) // no Aes::tooltip mapping
417        .geom_point()
418        .render_svg_native_with_size(200, 200)
419        .unwrap();
420        assert!(!with_raw_tooltip_col.contains("sneaky"));
421
422        // Same dataset, without the raw column, for a baseline title count.
423        let without = GGPlot::new(vec![
424            ("x".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
425            ("y".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
426        ])
427        .aes(Aes::new().x("x").y("y"))
428        .geom_point()
429        .render_svg_native_with_size(200, 200)
430        .unwrap();
431        // geom_point's own default title is "(x, y)", present either way —
432        // the raw "tooltip" column changed nothing about it.
433        let title_count = |s: &str| s.matches("<title>").count();
434        assert_eq!(title_count(&with_raw_tooltip_col), title_count(&without));
435    }
436
437    /// Review R-3 (low): a raw column that tries to *forge* the internal
438    /// `data_attr`/`tooltip` storage name directly (NUL byte included —
439    /// `GGPlot::new` accepts any `String`) is dropped by `resolve_mappings`,
440    /// not carried through and misread as a validated attribute.
441    #[test]
442    fn a_raw_column_forging_the_internal_prefix_is_dropped() {
443        let forged_data_attr = format!("{}data-spoof", crate::aes::DATA_ATTR_INTERNAL_PREFIX);
444        let forged_tooltip = crate::aes::TOOLTIP_INTERNAL_NAME.to_string();
445        let svg = GGPlot::new(vec![
446            ("x".into(), vec![Value::Float(0.0)]),
447            ("y".into(), vec![Value::Float(0.0)]),
448            (forged_data_attr, vec![Value::Str("evil".into())]),
449            (forged_tooltip, vec![Value::Str("evil tip".into())]),
450        ])
451        .aes(Aes::new().x("x").y("y"))
452        .geom_point()
453        .render_svg_native_with_size(200, 200)
454        .unwrap();
455        assert!(!svg.contains("data-spoof"));
456        assert!(!svg.contains("evil"));
457    }
458}
459
460#[cfg(test)]
461mod visibility_tests {
462    use super::PointVisibility;
463    use crate::data::Value;
464    use crate::prelude::*;
465
466    fn series(n: usize) -> GGPlot {
467        GGPlot::new(vec![
468            ("x".into(), (0..n).map(|i| Value::Float(i as f64)).collect()),
469            (
470                "y".into(),
471                (0..n).map(|i| Value::Float((i % 7) as f64)).collect(),
472            ),
473        ])
474        .aes(Aes::new().x("x").y("y"))
475        .geom_line()
476    }
477
478    fn points(svg: &str) -> Vec<&str> {
479        svg.split("<circle").skip(1).collect()
480    }
481
482    fn render(n: usize, visibility: PointVisibility, hidden_targets: bool) -> String {
483        series(n)
484            .geom_point_with(GeomPoint {
485                visibility,
486                hidden_targets,
487                ..Default::default()
488            })
489            .render_svg_native_with_size(400, 300)
490            .unwrap()
491    }
492
493    #[test]
494    fn auto_hides_a_dense_series_but_keeps_hover_targets() {
495        let svg = render(672, PointVisibility::AUTO, true);
496        let pts = points(&svg);
497        assert_eq!(pts.len(), 672);
498        assert!(pts.iter().all(|c| c.contains("fill-opacity=\"0.000\"")));
499        // Each target still carries its tooltip.
500        assert_eq!(svg.matches("<title>").count(), 672);
501    }
502
503    #[test]
504    fn auto_keeps_a_sparse_series_visible() {
505        let svg = render(12, PointVisibility::AUTO, true);
506        let pts = points(&svg);
507        assert_eq!(pts.len(), 12);
508        assert!(pts.iter().all(|c| !c.contains("fill-opacity")));
509    }
510
511    #[test]
512    fn auto_counts_distinct_x_not_rows() {
513        // Two series sharing 40 x positions: ~8 px apart on a 400 px plot.
514        let n = 40;
515        let svg = GGPlot::new(vec![
516            (
517                "x".into(),
518                (0..2 * n).map(|i| Value::Float((i % n) as f64)).collect(),
519            ),
520            (
521                "y".into(),
522                (0..2 * n).map(|i| Value::Float(i as f64)).collect(),
523            ),
524            (
525                "g".into(),
526                (0..2 * n)
527                    .map(|i| Value::Str(if i < n { "a" } else { "b" }.into()))
528                    .collect(),
529            ),
530        ])
531        .aes(Aes::new().x("x").y("y").color("g"))
532        .geom_point_with(GeomPoint {
533            visibility: PointVisibility::AUTO,
534            ..Default::default()
535        })
536        .render_svg_native_with_size(400, 300)
537        .unwrap();
538        assert!(points(&svg).iter().all(|c| !c.contains("fill-opacity")));
539    }
540
541    #[test]
542    fn hidden_and_visible_override_the_rule_both_ways() {
543        let hidden = render(12, PointVisibility::Hidden, true);
544        assert!(points(&hidden)
545            .iter()
546            .all(|c| c.contains("fill-opacity=\"0.000\"")));
547        let visible = render(672, PointVisibility::Visible, true);
548        assert!(points(&visible).iter().all(|c| !c.contains("fill-opacity")));
549    }
550
551    #[test]
552    fn hidden_points_can_be_omitted_entirely() {
553        assert!(points(&render(672, PointVisibility::AUTO, false)).is_empty());
554        assert!(points(&render(12, PointVisibility::Hidden, false)).is_empty());
555        // A sparse series under Auto is still drawn.
556        assert_eq!(points(&render(12, PointVisibility::AUTO, false)).len(), 12);
557    }
558}