Skip to main content

ggplot_rs/geom/
support.rs

1//! Shared drawing helpers for geoms: row grouping, dodge-aware x mapping and
2//! polyline clipping to the panel (the native SVG backend has no clip paths,
3//! so curves that can leave the panel are clipped geometrically).
4
5use crate::aes::Aesthetic;
6use crate::data::{DataFrame, Value};
7use crate::position::DODGE_OFFSET_COL;
8use crate::scale::{Scale, ScaleSet};
9
10/// Split row indices into drawing groups keyed on `group`, `color`, `fill`
11/// and `linetype` (whichever exist), in first-appearance order. A frame
12/// without any of these columns is one group.
13pub(crate) fn row_groups(data: &DataFrame) -> Vec<Vec<usize>> {
14    let cols: Vec<&[Value]> = ["group", "color", "fill", "linetype"]
15        .iter()
16        .filter_map(|c| data.column(c))
17        .collect();
18    let n = data.nrows();
19    if cols.is_empty() {
20        return if n == 0 {
21            vec![]
22        } else {
23            vec![(0..n).collect()]
24        };
25    }
26    let mut groups: indexmap::IndexMap<Vec<crate::data::GroupKey<'_>>, Vec<usize>> =
27        indexmap::IndexMap::new();
28    for i in 0..n {
29        let key = cols.iter().map(|c| c[i].group_key()).collect();
30        groups.entry(key).or_default().push(i);
31    }
32    groups.into_values().collect()
33}
34
35/// Maps a row's x to a normalized panel position, honouring a dodge offset
36/// that a position adjustment stored for a *discrete* x axis (in category
37/// units; one category = `1 / n_levels` of the panel).
38pub(crate) struct XMapper<'a> {
39    scale: Option<&'a dyn Scale>,
40    offsets: Option<&'a [Value]>,
41    counts: Option<&'a [Value]>,
42    band: f64,
43}
44
45impl<'a> XMapper<'a> {
46    pub(crate) fn new(data: &'a DataFrame, scales: &'a ScaleSet) -> Self {
47        let scale = scales.get(&Aesthetic::X);
48        let offsets = data.column(DODGE_OFFSET_COL);
49        let band = match (scale, offsets) {
50            (Some(s), Some(_)) if s.is_discrete() => 1.0 / s.breaks().len().max(1) as f64,
51            _ => 0.0,
52        };
53        XMapper {
54            scale,
55            offsets,
56            counts: data.column(crate::position::DODGE_N_COL),
57            band,
58        }
59    }
60
61    /// How many groups share row `i`'s slot on a dodged *discrete* axis
62    /// (1 otherwise): slot-filling geoms divide their element width by it.
63    pub(crate) fn slot_n(&self, i: usize) -> f64 {
64        if self.band == 0.0 {
65            return 1.0;
66        }
67        self.counts
68            .and_then(|c| c.get(i))
69            .and_then(|v| v.as_f64())
70            .filter(|n| n.is_finite() && *n >= 1.0)
71            .unwrap_or(1.0)
72    }
73
74    /// Normalized x of `value` at row `i` (the dodge offset of row `i` added).
75    pub(crate) fn map(&self, value: &Value, i: usize) -> f64 {
76        let base = self.scale.map(|s| s.map(value)).unwrap_or(0.0);
77        let off = self
78            .offsets
79            .and_then(|o| o.get(i))
80            .and_then(|v| v.as_f64())
81            .filter(|v| v.is_finite())
82            .unwrap_or(0.0);
83        base + off * self.band
84    }
85}
86
87/// Hover metadata for an interval mark (errorbar / linerange / pointrange):
88/// `data-x` = the x key, `data-series`, `data-value` = the point estimate
89/// `y` when present, else `"ymin ymax"`; tooltip `"<series>: <x>: y [lo, hi]"`.
90pub(crate) fn mark_interval(
91    backend: &mut dyn crate::render::backend::DrawBackend,
92    scales: &crate::scale::ScaleSet,
93    data: &DataFrame,
94    i: usize,
95    x: &Value,
96    lo: &Value,
97    hi: &Value,
98) {
99    let series = super::series_key(data, i);
100    let y = data.column("y").and_then(|c| c.get(i));
101    let range = format!("[{}, {}]", super::tip_value(lo), super::tip_value(hi));
102    let body = match y {
103        Some(y) => format!("{}: {} {range}", super::tip_value(x), super::tip_value(y)),
104        None => format!("{}: {range}", super::tip_value(x)),
105    };
106    let tip = Some(match &series {
107        Some(s) => format!("{s}: {body}"),
108        None => body,
109    });
110    let value = match y {
111        Some(y) => super::raw_value(y),
112        None => super::raw_value(lo)
113            .zip(super::raw_value(hi))
114            .map(|(a, b)| format!("{a} {b}")),
115    };
116    super::set_mark(backend, tip, Some(super::tip_value(x)), series, value);
117    // A [lo, hi] range (no single `y`) isn't a single raw value
118    // `Scale::format_value` can take, so `data-fvalue` only applies when a
119    // plain `y` exists.
120    super::set_mark_formatted(backend, scales, Some(x), &crate::aes::Aesthetic::Y, y);
121}
122
123/// Map a numeric position through a position scale. A numeric value on a
124/// *discrete* scale is read as ggplot2 does — category `k` sits at `k`
125/// (1-based), so `1.5` falls between the first two categories.
126pub(crate) fn map_position(scale: Option<&dyn Scale>, value: &Value) -> Option<f64> {
127    let s = scale?;
128    if s.is_discrete() {
129        if let Some(f) = value.as_f64() {
130            let n = s.breaks().len().max(1) as f64;
131            return Some((f - 0.5) / n);
132        }
133    }
134    Some(s.map(value))
135}
136
137/// Clip the segment `a → b` to the unit square (Liang–Barsky). Returns the
138/// visible part, or `None` when the segment lies entirely outside.
139pub(crate) fn clip_segment_unit(a: (f64, f64), b: (f64, f64)) -> Option<((f64, f64), (f64, f64))> {
140    let (dx, dy) = (b.0 - a.0, b.1 - a.1);
141    let mut t0: f64 = 0.0;
142    let mut t1: f64 = 1.0;
143    for (p, q) in [(-dx, a.0), (dx, 1.0 - a.0), (-dy, a.1), (dy, 1.0 - a.1)] {
144        if p.abs() < 1e-15 {
145            if q < 0.0 {
146                return None;
147            }
148        } else {
149            let r = q / p;
150            if p < 0.0 {
151                t0 = t0.max(r);
152            } else {
153                t1 = t1.min(r);
154            }
155        }
156    }
157    if t0 > t1 || !(t0.is_finite() && t1.is_finite()) {
158        return None;
159    }
160    Some((
161        (a.0 + t0 * dx, a.1 + t0 * dy),
162        (a.0 + t1 * dx, a.1 + t1 * dy),
163    ))
164}
165
166/// Clip a polyline in normalized panel space to the unit square, returning
167/// the visible runs (each with ≥ 2 points). Non-finite vertices break the
168/// line.
169pub(crate) fn clip_polyline_unit(pts: &[(f64, f64)]) -> Vec<Vec<(f64, f64)>> {
170    let mut runs: Vec<Vec<(f64, f64)>> = Vec::new();
171    let mut cur: Vec<(f64, f64)> = Vec::new();
172    for w in pts.windows(2) {
173        let (a, b) = (w[0], w[1]);
174        let finite = a.0.is_finite() && a.1.is_finite() && b.0.is_finite() && b.1.is_finite();
175        match clip_segment_unit(a, b).filter(|_| finite) {
176            Some((p, q)) => {
177                let joins = cur
178                    .last()
179                    .is_some_and(|l| (l.0 - p.0).abs() < 1e-12 && (l.1 - p.1).abs() < 1e-12);
180                if !joins {
181                    if cur.len() >= 2 {
182                        runs.push(std::mem::take(&mut cur));
183                    }
184                    cur.clear();
185                    cur.push(p);
186                }
187                cur.push(q);
188            }
189            None => {
190                if cur.len() >= 2 {
191                    runs.push(std::mem::take(&mut cur));
192                }
193                cur.clear();
194            }
195        }
196    }
197    if cur.len() >= 2 {
198        runs.push(cur);
199    }
200    runs
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn clip_inside_segment_is_unchanged() {
209        let (p, q) = clip_segment_unit((0.1, 0.2), (0.8, 0.9)).unwrap();
210        for (got, want) in [(p.0, 0.1), (p.1, 0.2), (q.0, 0.8), (q.1, 0.9)] {
211            assert!((got - want).abs() < 1e-12);
212        }
213    }
214
215    #[test]
216    fn clip_crossing_segment() {
217        let (p, q) = clip_segment_unit((-1.0, 0.5), (2.0, 0.5)).unwrap();
218        assert!((p.0 - 0.0).abs() < 1e-12 && (q.0 - 1.0).abs() < 1e-12);
219        assert!(clip_segment_unit((-1.0, 2.0), (2.0, 2.0)).is_none());
220    }
221
222    #[test]
223    fn clip_polyline_splits_runs() {
224        // In, out (above), in again → two runs.
225        let pts = [(0.1, 0.5), (0.3, 0.5), (0.5, 1.5), (0.7, 0.5), (0.9, 0.5)];
226        let runs = clip_polyline_unit(&pts);
227        assert_eq!(runs.len(), 2);
228        for r in &runs {
229            assert!(r
230                .iter()
231                .all(|(x, y)| (0.0..=1.0).contains(x) && (0.0..=1.0).contains(y)));
232        }
233    }
234
235    #[test]
236    fn clip_polyline_breaks_on_nan() {
237        let pts = [
238            (0.1, 0.1),
239            (0.2, 0.2),
240            (f64::NAN, 0.3),
241            (0.4, 0.4),
242            (0.5, 0.5),
243        ];
244        let runs = clip_polyline_unit(&pts);
245        assert_eq!(runs.len(), 2);
246    }
247
248    #[test]
249    fn row_groups_by_colour() {
250        let mut df = DataFrame::new();
251        df.add_column(
252            "color".into(),
253            vec!["a", "b", "a"]
254                .into_iter()
255                .map(|s| Value::Str(s.into()))
256                .collect(),
257        );
258        assert_eq!(row_groups(&df), vec![vec![0, 2], vec![1]]);
259        let mut plain = DataFrame::new();
260        plain.add_column("x".into(), vec![Value::Float(1.0); 3]);
261        assert_eq!(row_groups(&plain), vec![vec![0, 1, 2]]);
262    }
263}
264
265/// How a line/area/ribbon joins consecutive points (Highcharts' `spline`
266/// series, d3-shape's curves).
267#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
268pub enum Interpolate {
269    /// Straight segments.
270    #[default]
271    Linear,
272    /// Monotone cubic in x (d3's `curveMonotoneX`): a smooth curve through
273    /// every point that never overshoots between them. Points must be
274    /// ordered by x.
275    MonotoneCubic,
276    /// Uniform Catmull–Rom spline through every point (may overshoot).
277    CatmullRom,
278}
279
280/// Samples per original segment when densifying a curve.
281const CURVE_STEPS: usize = 16;
282
283/// Densify `points` (normalized panel coords) along `method`. Returns the
284/// new points and, for each, the index of the input segment it lies on (so
285/// per-vertex attributes such as linewidth can follow). Non-finite points
286/// are kept as breaks: each finite run is interpolated on its own.
287pub(crate) fn interpolate(
288    points: &[(f64, f64)],
289    method: Interpolate,
290) -> (Vec<(f64, f64)>, Vec<usize>) {
291    if method == Interpolate::Linear || points.len() < 3 {
292        return (points.to_vec(), (0..points.len()).collect());
293    }
294    let mut out = Vec::with_capacity(points.len() * CURVE_STEPS);
295    let mut origin = Vec::with_capacity(points.len() * CURVE_STEPS);
296    let finite = |p: &(f64, f64)| p.0.is_finite() && p.1.is_finite();
297    let mut start = 0;
298    while start < points.len() {
299        if !finite(&points[start]) {
300            out.push(points[start]);
301            origin.push(start);
302            start += 1;
303            continue;
304        }
305        let mut end = start;
306        while end < points.len() && finite(&points[end]) {
307            end += 1;
308        }
309        let run = &points[start..end];
310        let curve = match method {
311            Interpolate::MonotoneCubic => monotone_run(run),
312            _ => catmull_rom_run(run),
313        };
314        for (p, k) in curve {
315            out.push(p);
316            origin.push(start + k);
317        }
318        start = end;
319    }
320    (out, origin)
321}
322
323/// d3-shape's `slope3`: the monotone tangent at the middle of three points.
324fn slope3(p0: (f64, f64), p1: (f64, f64), p2: (f64, f64)) -> f64 {
325    let (h0, h1) = (p1.0 - p0.0, p2.0 - p1.0);
326    if h0 <= 0.0 || h1 <= 0.0 {
327        return 0.0;
328    }
329    let (s0, s1) = ((p1.1 - p0.1) / h0, (p2.1 - p1.1) / h1);
330    let p = (s0 * h1 + s1 * h0) / (h0 + h1);
331    (s0.signum() + s1.signum()) * s0.abs().min(s1.abs()).min(0.5 * p.abs())
332}
333
334fn monotone_run(run: &[(f64, f64)]) -> Vec<((f64, f64), usize)> {
335    let n = run.len();
336    if n < 3 {
337        return run.iter().enumerate().map(|(k, &p)| (p, k)).collect();
338    }
339    let mut m = vec![0.0; n];
340    for k in 1..n - 1 {
341        m[k] = slope3(run[k - 1], run[k], run[k + 1]);
342    }
343    // d3's slope2 at the ends: the secant pulled toward the inner tangent.
344    let end_slope = |a: (f64, f64), b: (f64, f64), t: f64| {
345        let h = b.0 - a.0;
346        if h > 0.0 {
347            (3.0 * (b.1 - a.1) / h - t) / 2.0
348        } else {
349            t
350        }
351    };
352    m[0] = end_slope(run[0], run[1], m[1]);
353    m[n - 1] = end_slope(run[n - 2], run[n - 1], m[n - 2]);
354    let mut out = vec![(run[0], 0)];
355    for k in 0..n - 1 {
356        let ((x0, y0), (x1, y1)) = (run[k], run[k + 1]);
357        let h = x1 - x0;
358        if h <= 0.0 {
359            out.push((run[k + 1], k));
360            continue;
361        }
362        for s in 1..=CURVE_STEPS {
363            let t = s as f64 / CURVE_STEPS as f64;
364            let (t2, t3) = (t * t, t * t * t);
365            let y = (2.0 * t3 - 3.0 * t2 + 1.0) * y0
366                + (t3 - 2.0 * t2 + t) * h * m[k]
367                + (-2.0 * t3 + 3.0 * t2) * y1
368                + (t3 - t2) * h * m[k + 1];
369            out.push(((x0 + h * t, y), k));
370        }
371    }
372    out
373}
374
375fn catmull_rom_run(run: &[(f64, f64)]) -> Vec<((f64, f64), usize)> {
376    let n = run.len();
377    if n < 3 {
378        return run.iter().enumerate().map(|(k, &p)| (p, k)).collect();
379    }
380    let at = |i: isize| run[i.clamp(0, n as isize - 1) as usize];
381    let mut out = vec![(run[0], 0)];
382    for k in 0..n - 1 {
383        let (p0, p1, p2, p3) = (
384            at(k as isize - 1),
385            at(k as isize),
386            at(k as isize + 1),
387            at(k as isize + 2),
388        );
389        for s in 1..=CURVE_STEPS {
390            let t = s as f64 / CURVE_STEPS as f64;
391            let (t2, t3) = (t * t, t * t * t);
392            let c = |a: f64, b: f64, c: f64, d: f64| {
393                0.5 * (2.0 * b
394                    + (-a + c) * t
395                    + (2.0 * a - 5.0 * b + 4.0 * c - d) * t2
396                    + (-a + 3.0 * b - 3.0 * c + d) * t3)
397            };
398            out.push(((c(p0.0, p1.0, p2.0, p3.0), c(p0.1, p1.1, p2.1, p3.1)), k));
399        }
400    }
401    out
402}
403
404#[cfg(test)]
405mod interpolate_tests {
406    use super::*;
407
408    #[test]
409    fn monotone_passes_through_points_without_overshoot() {
410        let pts = [(0.0, 0.0), (1.0, 1.0), (2.0, 1.0), (3.0, 5.0), (4.0, 0.0)];
411        let (out, origin) = interpolate(&pts, Interpolate::MonotoneCubic);
412        assert_eq!(out.len(), 1 + 4 * CURVE_STEPS);
413        assert_eq!(origin.len(), out.len());
414        for p in pts {
415            assert!(out
416                .iter()
417                .any(|q| (q.0 - p.0).abs() < 1e-12 && (q.1 - p.1).abs() < 1e-12));
418        }
419        // Between each pair the curve stays within the pair's y range.
420        for (q, &k) in out.iter().zip(&origin) {
421            let (a, b) = (pts[k].1, pts[k + 1].1);
422            assert!(
423                q.1 >= a.min(b) - 1e-9 && q.1 <= a.max(b) + 1e-9,
424                "{q:?} on {k}"
425            );
426        }
427    }
428
429    #[test]
430    fn catmull_rom_passes_through_points_and_breaks_on_nan() {
431        let pts = [
432            (0.0, 0.0),
433            (1.0, 2.0),
434            (2.0, 0.0),
435            (f64::NAN, f64::NAN),
436            (3.0, 1.0),
437            (4.0, 3.0),
438            (5.0, 2.0),
439        ];
440        let (out, origin) = interpolate(&pts, Interpolate::CatmullRom);
441        assert_eq!(out.iter().filter(|p| p.0.is_nan()).count(), 1);
442        assert!(out
443            .iter()
444            .any(|q| (q.0 - 1.0).abs() < 1e-12 && (q.1 - 2.0).abs() < 1e-12));
445        assert_eq!(origin[out.iter().position(|p| p.0.is_nan()).unwrap()], 3);
446        let (lin, _) = interpolate(&pts, Interpolate::Linear);
447        assert_eq!(lin.len(), pts.len());
448    }
449}