Skip to main content

ggplot_rs/geom/
curve.rs

1use crate::aes::Aesthetic;
2use crate::coord::Coord;
3use crate::data::DataFrame;
4use crate::geom::arrow::{draw_heads, heads, Arrow};
5use crate::position::identity::PositionIdentity;
6use crate::position::Position;
7use crate::render::backend::{DrawBackend, LineEnd, LineStyle, Linetype};
8use crate::render::RenderError;
9use crate::scale::ScaleSet;
10use crate::stat::identity::StatIdentity;
11use crate::stat::Stat;
12use crate::theme::Theme;
13
14use super::{Geom, GeomParams};
15
16/// Curve geometry — curved segment via a quadratic Bézier.
17pub struct GeomCurve {
18    pub color: (u8, u8, u8),
19    pub width: f64,
20    pub alpha: f64,
21    /// Curvature of the control point, in pixel space: the control point
22    /// sits at `mid + (-dy, dx) * curvature` (positive is right-handed).
23    pub curvature: f64,
24    /// Ignored: SVG draws a true quadratic Bézier (one control point), so
25    /// there is no subdivision count to tune. Kept for API compatibility.
26    pub ncp: usize,
27    /// An optional arrowhead drawn at the curve's end(s).
28    pub arrow: Option<Arrow>,
29    /// Line cap style (R grid's `lineend`).
30    pub lineend: LineEnd,
31}
32
33impl Default for GeomCurve {
34    fn default() -> Self {
35        GeomCurve {
36            color: (0, 0, 0),
37            width: 1.0,
38            alpha: 1.0,
39            curvature: 0.5,
40            ncp: 5,
41            arrow: None,
42            lineend: LineEnd::Butt,
43        }
44    }
45}
46
47impl GeomCurve {
48    /// Fixed stroke colour, used when no `color` aesthetic is mapped.
49    pub fn color(mut self, color: (u8, u8, u8)) -> Self {
50        self.color = color;
51        self
52    }
53
54    /// Sets `width` (the stroke width in pixels).
55    pub fn linewidth(mut self, width: f64) -> Self {
56        self.width = width;
57        self
58    }
59
60    /// Fixed opacity, used when no `alpha` aesthetic is mapped.
61    pub fn alpha(mut self, alpha: f64) -> Self {
62        self.alpha = alpha;
63        self
64    }
65
66    /// See the [`curvature`](Self::curvature) field doc.
67    pub fn curvature(mut self, curvature: f64) -> Self {
68        self.curvature = curvature;
69        self
70    }
71
72    /// See the [`ncp`](Self::ncp) field doc: ignored by SVG rendering.
73    pub fn ncp(mut self, ncp: usize) -> Self {
74        self.ncp = ncp;
75        self
76    }
77
78    /// Draw an arrowhead at the end(s) — see [`arrow()`](crate::geom::arrow::arrow).
79    pub fn arrow(mut self, arrow: Arrow) -> Self {
80        self.arrow = Some(arrow);
81        self
82    }
83
84    /// Line cap style (R grid's `lineend`).
85    pub fn lineend(mut self, lineend: LineEnd) -> Self {
86        self.lineend = lineend;
87        self
88    }
89
90    /// The single quadratic-Bézier control point for `p0 -> p2`, in pixel
91    /// space: `mid + (-dy, dx) * curvature`.
92    fn control_point(p0: (f64, f64), p2: (f64, f64), curvature: f64) -> (f64, f64) {
93        let mx = (p0.0 + p2.0) / 2.0;
94        let my = (p0.1 + p2.1) / 2.0;
95        let dx = p2.0 - p0.0;
96        let dy = p2.1 - p0.1;
97        (mx - dy * curvature, my + dx * curvature)
98    }
99
100    /// Flatten the quadratic Bézier to `n` points — used only to feed the
101    /// arrowhead geometry ([`crate::geom::arrow::heads`]) an accurate
102    /// polyline; the actual stroke is drawn with
103    /// [`DrawBackend::draw_quadratic`], a true curve on the SVG backend.
104    fn flatten(p0: (f64, f64), ctrl: (f64, f64), p2: (f64, f64), n: usize) -> Vec<(f64, f64)> {
105        (0..n)
106            .map(|i| {
107                let t = i as f64 / (n - 1) as f64;
108                let u = 1.0 - t;
109                let x = u * u * p0.0 + 2.0 * u * t * ctrl.0 + t * t * p2.0;
110                let y = u * u * p0.1 + 2.0 * u * t * ctrl.1 + t * t * p2.1;
111                (x, y)
112            })
113            .collect()
114    }
115}
116
117impl Geom for GeomCurve {
118    fn draw(
119        &self,
120        data: &DataFrame,
121        coord: &dyn Coord,
122        scales: &ScaleSet,
123        _theme: &Theme,
124        backend: &mut dyn DrawBackend,
125    ) -> Result<(), RenderError> {
126        let x_col = data
127            .column("x")
128            .ok_or(RenderError::MissingAesthetic("x".into()))?;
129        let y_col = data
130            .column("y")
131            .ok_or(RenderError::MissingAesthetic("y".into()))?;
132        let xend_col = data
133            .column("xend")
134            .ok_or(RenderError::MissingAesthetic("xend".into()))?;
135        let yend_col = data
136            .column("yend")
137            .ok_or(RenderError::MissingAesthetic("yend".into()))?;
138        let color_col = data.column("color");
139        let linetype_col = data.column("linetype");
140        let alpha_col = data.column("alpha");
141        let linewidth_col = data.column("linewidth");
142
143        let plot_area = backend.plot_area();
144        let x_scale = scales.get(&Aesthetic::X);
145        let y_scale = scales.get(&Aesthetic::Y);
146
147        for i in 0..data.nrows() {
148            let nx1 = x_scale.map(|s| s.map(&x_col[i])).unwrap_or(0.0);
149            let ny1 = y_scale.map(|s| s.map(&y_col[i])).unwrap_or(0.0);
150            let nx2 = x_scale.map(|s| s.map(&xend_col[i])).unwrap_or(0.0);
151            let ny2 = y_scale.map(|s| s.map(&yend_col[i])).unwrap_or(0.0);
152
153            let p0 = coord.transform((nx1, ny1), &plot_area);
154            let p2 = coord.transform((nx2, ny2), &plot_area);
155            let ctrl = Self::control_point(p0, p2, self.curvature);
156
157            let line_color = color_col
158                .and_then(|cc| scales.map_color(&Aesthetic::Color, &cc[i]))
159                .unwrap_or(self.color);
160            let lt = linetype_col
161                .and_then(|lc| scales.map_linetype(&lc[i]))
162                .unwrap_or(Linetype::Solid);
163            let alpha = alpha_col
164                .and_then(|ac| scales.map_alpha(&ac[i]).or_else(|| ac[i].as_f64()))
165                .filter(|a| a.is_finite())
166                .unwrap_or(self.alpha);
167            let width = linewidth_col
168                .and_then(|lc| scales.map_linewidth(&lc[i]))
169                .unwrap_or(self.width);
170            super::set_row_extras(backend, data, i);
171
172            backend.draw_quadratic(
173                p0,
174                ctrl,
175                p2,
176                &LineStyle {
177                    color: line_color,
178                    alpha,
179                    width,
180                    linetype: lt,
181                    lineend: self.lineend,
182                },
183            )?;
184
185            if let Some(arrow) = &self.arrow {
186                let path = Self::flatten(p0, ctrl, p2, 64);
187                let hs = heads(&path, arrow, width);
188                draw_heads(backend, &hs, arrow, line_color, alpha, width)?;
189            }
190        }
191        super::clear_mark(backend);
192        super::clear_row_extras(backend);
193
194        Ok(())
195    }
196
197    fn required_aes(&self) -> Vec<Aesthetic> {
198        vec![Aesthetic::X, Aesthetic::Y, Aesthetic::Xend, Aesthetic::Yend]
199    }
200
201    fn default_stat(&self) -> Box<dyn Stat> {
202        Box::new(StatIdentity)
203    }
204
205    fn default_position(&self) -> Box<dyn Position> {
206        Box::new(PositionIdentity)
207    }
208
209    fn default_params(&self) -> GeomParams {
210        GeomParams::default()
211    }
212
213    fn name(&self) -> &str {
214        "curve"
215    }
216
217    fn consumes_tooltips(&self) -> bool {
218        true
219    }
220
221    fn apply_element_geom(&mut self, el: &crate::theme::elements::ElementGeom) {
222        if let (true, Some(ink)) = (self.color == (0, 0, 0), el.ink) {
223            self.color = ink;
224        }
225    }
226
227    fn set_series_color(&mut self, color: (u8, u8, u8)) {
228        self.color = color;
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use crate::data::Value;
235    use crate::geom::arrow::arrow;
236    use crate::prelude::*;
237
238    fn plot() -> GGPlot {
239        GGPlot::new(vec![
240            ("x".into(), vec![Value::Float(0.0), Value::Float(0.2)]),
241            ("y".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
242            ("xend".into(), vec![Value::Float(1.0), Value::Float(0.8)]),
243            ("yend".into(), vec![Value::Float(0.5), Value::Float(0.3)]),
244        ])
245        .aes(Aes::new().x("x").y("y").xend("xend").yend("yend"))
246    }
247
248    #[test]
249    fn curve_emits_a_native_quadratic_path() {
250        let svg = plot()
251            .geom_curve_with(GeomCurve::default())
252            .render_svg_native_with_size(200, 200)
253            .unwrap();
254        assert!(svg.contains("<path"));
255        assert!(svg.contains(" Q"));
256        assert!(!svg.contains("NaN"));
257    }
258
259    #[test]
260    fn curve_arrow_closed_emits_tagged_polygons() {
261        let svg = plot()
262            .geom_curve_with(GeomCurve::default().arrow(arrow().closed()))
263            .render_svg_native_with_size(200, 200)
264            .unwrap();
265        assert_eq!(svg.matches("data-part=\"arrow\"").count(), 2);
266    }
267
268    #[test]
269    fn lineend_round_gives_stroke_linecap_round_on_the_path() {
270        let svg = plot()
271            .geom_curve_with(GeomCurve::default().lineend(crate::render::backend::LineEnd::Round))
272            .render_svg_native_with_size(200, 200)
273            .unwrap();
274        assert!(svg.contains("stroke-linecap=\"round\""));
275    }
276
277    #[test]
278    fn control_point_matches_the_documented_formula() {
279        let p0 = (0.0, 0.0);
280        let p2 = (10.0, 0.0);
281        let ctrl = GeomCurve::control_point(p0, p2, 0.2);
282        // mid = (5, 0); dx = 10, dy = 0 -> ctrl = (5 - 0*0.2, 0 + 10*0.2) = (5, 2)
283        assert!((ctrl.0 - 5.0).abs() < 1e-9);
284        assert!((ctrl.1 - 2.0).abs() < 1e-9);
285    }
286
287    /// `.color()`/`.alpha()`/`.linewidth()`/`.curvature()`/`.ncp()` builders
288    /// actually change the rendered output, not just the struct's fields.
289    #[test]
290    fn builders_change_the_rendered_curve() {
291        let flat = plot()
292            .geom_curve_with(GeomCurve::default().curvature(0.0))
293            .render_svg_native_with_size(200, 200)
294            .unwrap();
295        let bent = plot()
296            .geom_curve_with(GeomCurve::default().curvature(0.8))
297            .render_svg_native_with_size(200, 200)
298            .unwrap();
299        assert_ne!(flat, bent, "curvature should change the control point");
300
301        let styled = plot()
302            .geom_curve_with(
303                GeomCurve::default()
304                    .color((10, 20, 30))
305                    .alpha(0.4)
306                    .linewidth(5.0)
307                    // `ncp` is ignored by SVG rendering (documented), but
308                    // the builder itself must still work.
309                    .ncp(12),
310            )
311            .render_svg_native_with_size(200, 200)
312            .unwrap();
313        assert!(styled.contains("stroke=\"#0A141E\""));
314        assert!(styled.contains("stroke-opacity=\"0.400\""));
315        assert!(styled.contains("stroke-width=\"5.00\""));
316    }
317
318    // ─── B1-7: interactive aesthetics ──────────────────────────────────
319
320    fn plot_with_extras() -> GGPlot {
321        GGPlot::new(vec![
322            ("x".into(), vec![Value::Float(0.0), Value::Float(0.2)]),
323            ("y".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
324            ("xend".into(), vec![Value::Float(1.0), Value::Float(0.8)]),
325            ("yend".into(), vec![Value::Float(0.5), Value::Float(0.3)]),
326            (
327                "orig".into(),
328                vec![Value::Str("Berlin".into()), Value::Str("Paris & Co".into())],
329            ),
330            (
331                "tip".into(),
332                vec![
333                    Value::Str("A tooltip".into()),
334                    Value::Str("<script>".into()),
335                ],
336            ),
337        ])
338        .aes(
339            Aes::new()
340                .x("x")
341                .y("y")
342                .xend("xend")
343                .yend("yend")
344                .data_attr("orig", "orig")
345                .tooltip("tip"),
346        )
347    }
348
349    #[test]
350    fn data_attr_and_tooltip_both_emit_and_arrow_heads_carry_the_attr_but_no_title() {
351        let svg = plot_with_extras()
352            .geom_curve_with(GeomCurve::default().arrow(crate::geom::arrow::arrow().closed()))
353            .render_svg_native_with_size(200, 200)
354            .unwrap();
355
356        assert!(svg.contains("data-orig=\"Berlin\""));
357        // `&` is escaped.
358        assert!(svg.contains("data-orig=\"Paris &amp; Co\""));
359        assert!(svg.contains("<title>A tooltip</title>"));
360        // `<script>` is escaped, never a literal second element.
361        assert!(svg.contains("&lt;script&gt;"));
362        assert!(!svg.contains("<script>"));
363
364        // Arrow heads are decorative marks: they carry the row's data-orig
365        // (set before the arrow is drawn) but no `<title>`. Each is a
366        // self-closing `<polygon .../>` element, so bound the check to it.
367        for part in svg.split("<polygon").skip(1) {
368            let element = part.split("/>").next().unwrap_or("");
369            if element.contains("data-part=\"arrow\"") {
370                assert!(element.contains("data-orig="));
371                assert!(!element.contains("<title>"));
372            }
373        }
374    }
375
376    #[test]
377    fn data_attr_x_gives_a_single_data_x_not_two() {
378        // GeomPoint sets the built-in `data-x` via `set_mark_axis`;
379        // `data_attr("x", "xlabel")` maps to the same attribute name and
380        // should replace it, not duplicate it.
381        let svg = GGPlot::new(vec![
382            ("x".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
383            ("y".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
384            (
385                "xlabel".into(),
386                vec![Value::Str("first".into()), Value::Str("second".into())],
387            ),
388        ])
389        .aes(Aes::new().x("x").y("y").data_attr("x", "xlabel"))
390        .geom_point()
391        .render_svg_native_with_size(200, 200)
392        .unwrap();
393
394        assert!(svg.contains("data-x=\"first\""));
395        assert!(svg.contains("data-x=\"second\""));
396        for tag_open in svg.split('<').skip(1) {
397            let attrs_part = tag_open.split('>').next().unwrap_or("");
398            let count = attrs_part.matches("data-x=\"").count();
399            assert!(count <= 1, "data-x duplicated in: <{tag_open}");
400        }
401    }
402
403    #[test]
404    fn layers_without_interactive_aes_are_byte_identical() {
405        let plain = |p: GGPlot| {
406            p.geom_curve_with(GeomCurve::default())
407                .render_svg_native_with_size(200, 200)
408                .unwrap()
409        };
410        let a = plain(plot());
411        let b = plain(plot());
412        assert_eq!(a, b);
413        assert!(!a.contains("data-orig"));
414    }
415
416    /// Review finding #3: `data_attr("part", ...)` must never produce a
417    /// second `data-part` alongside the arrowhead's own — a duplicate
418    /// attribute is an XML parse error, and the duplicate would shadow
419    /// `data-part="arrow"`, breaking the `[data-part="arrow"]` selector.
420    #[test]
421    fn data_attr_named_part_is_rejected_so_arrowheads_never_get_a_duplicate_data_part() {
422        let plot = GGPlot::new(vec![
423            ("x".into(), vec![Value::Float(0.0), Value::Float(0.2)]),
424            ("y".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
425            ("xend".into(), vec![Value::Float(1.0), Value::Float(0.8)]),
426            ("yend".into(), vec![Value::Float(0.5), Value::Float(0.3)]),
427            (
428                "p".into(),
429                vec![Value::Str("mine".into()), Value::Str("mine".into())],
430            ),
431        ])
432        .aes(
433            Aes::new()
434                .x("x")
435                .y("y")
436                .xend("xend")
437                .yend("yend")
438                .data_attr("part", "p"),
439        );
440        // The invalid/reserved name is rejected with a build warning, not
441        // silently accepted.
442        assert!(plot
443            .warnings
444            .iter()
445            .any(|w| w.contains("part") && w.contains("reserved")));
446
447        let svg = plot
448            .geom_curve_with(GeomCurve::default().arrow(crate::geom::arrow::arrow().closed()))
449            .render_svg_native_with_size(200, 200)
450            .unwrap();
451
452        // Exactly one `data-part` per arrowhead element, always "arrow".
453        for part in svg.split("<polygon").skip(1) {
454            let element = part.split("/>").next().unwrap_or("");
455            if element.contains("data-part") {
456                assert_eq!(element.matches("data-part=").count(), 1);
457                assert!(element.contains("data-part=\"arrow\""));
458            }
459        }
460        assert!(!svg.contains("data-part=\"mine\""));
461    }
462}