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