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