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 set_series_color(&mut self, color: (u8, u8, u8)) {
222        self.color = color;
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use crate::data::Value;
229    use crate::geom::arrow::arrow;
230    use crate::prelude::*;
231
232    fn plot() -> GGPlot {
233        GGPlot::new(vec![
234            ("x".into(), vec![Value::Float(0.0), Value::Float(0.2)]),
235            ("y".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
236            ("xend".into(), vec![Value::Float(1.0), Value::Float(0.8)]),
237            ("yend".into(), vec![Value::Float(0.5), Value::Float(0.3)]),
238        ])
239        .aes(Aes::new().x("x").y("y").xend("xend").yend("yend"))
240    }
241
242    #[test]
243    fn curve_emits_a_native_quadratic_path() {
244        let svg = plot()
245            .geom_curve_with(GeomCurve::default())
246            .render_svg_native_with_size(200, 200)
247            .unwrap();
248        assert!(svg.contains("<path"));
249        assert!(svg.contains(" Q"));
250        assert!(!svg.contains("NaN"));
251    }
252
253    #[test]
254    fn curve_arrow_closed_emits_tagged_polygons() {
255        let svg = plot()
256            .geom_curve_with(GeomCurve::default().arrow(arrow().closed()))
257            .render_svg_native_with_size(200, 200)
258            .unwrap();
259        assert_eq!(svg.matches("data-part=\"arrow\"").count(), 2);
260    }
261
262    #[test]
263    fn lineend_round_gives_stroke_linecap_round_on_the_path() {
264        let svg = plot()
265            .geom_curve_with(GeomCurve::default().lineend(crate::render::backend::LineEnd::Round))
266            .render_svg_native_with_size(200, 200)
267            .unwrap();
268        assert!(svg.contains("stroke-linecap=\"round\""));
269    }
270
271    #[test]
272    fn control_point_matches_the_documented_formula() {
273        let p0 = (0.0, 0.0);
274        let p2 = (10.0, 0.0);
275        let ctrl = GeomCurve::control_point(p0, p2, 0.2);
276        // mid = (5, 0); dx = 10, dy = 0 -> ctrl = (5 - 0*0.2, 0 + 10*0.2) = (5, 2)
277        assert!((ctrl.0 - 5.0).abs() < 1e-9);
278        assert!((ctrl.1 - 2.0).abs() < 1e-9);
279    }
280
281    /// `.color()`/`.alpha()`/`.linewidth()`/`.curvature()`/`.ncp()` builders
282    /// actually change the rendered output, not just the struct's fields.
283    #[test]
284    fn builders_change_the_rendered_curve() {
285        let flat = plot()
286            .geom_curve_with(GeomCurve::default().curvature(0.0))
287            .render_svg_native_with_size(200, 200)
288            .unwrap();
289        let bent = plot()
290            .geom_curve_with(GeomCurve::default().curvature(0.8))
291            .render_svg_native_with_size(200, 200)
292            .unwrap();
293        assert_ne!(flat, bent, "curvature should change the control point");
294
295        let styled = plot()
296            .geom_curve_with(
297                GeomCurve::default()
298                    .color((10, 20, 30))
299                    .alpha(0.4)
300                    .linewidth(5.0)
301                    // `ncp` is ignored by SVG rendering (documented), but
302                    // the builder itself must still work.
303                    .ncp(12),
304            )
305            .render_svg_native_with_size(200, 200)
306            .unwrap();
307        assert!(styled.contains("stroke=\"#0A141E\""));
308        assert!(styled.contains("stroke-opacity=\"0.400\""));
309        assert!(styled.contains("stroke-width=\"5.00\""));
310    }
311
312    // ─── B1-7: interactive aesthetics ──────────────────────────────────
313
314    fn plot_with_extras() -> GGPlot {
315        GGPlot::new(vec![
316            ("x".into(), vec![Value::Float(0.0), Value::Float(0.2)]),
317            ("y".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
318            ("xend".into(), vec![Value::Float(1.0), Value::Float(0.8)]),
319            ("yend".into(), vec![Value::Float(0.5), Value::Float(0.3)]),
320            (
321                "orig".into(),
322                vec![Value::Str("Berlin".into()), Value::Str("Paris & Co".into())],
323            ),
324            (
325                "tip".into(),
326                vec![
327                    Value::Str("A tooltip".into()),
328                    Value::Str("<script>".into()),
329                ],
330            ),
331        ])
332        .aes(
333            Aes::new()
334                .x("x")
335                .y("y")
336                .xend("xend")
337                .yend("yend")
338                .data_attr("orig", "orig")
339                .tooltip("tip"),
340        )
341    }
342
343    #[test]
344    fn data_attr_and_tooltip_both_emit_and_arrow_heads_carry_the_attr_but_no_title() {
345        let svg = plot_with_extras()
346            .geom_curve_with(GeomCurve::default().arrow(crate::geom::arrow::arrow().closed()))
347            .render_svg_native_with_size(200, 200)
348            .unwrap();
349
350        assert!(svg.contains("data-orig=\"Berlin\""));
351        // `&` is escaped.
352        assert!(svg.contains("data-orig=\"Paris &amp; Co\""));
353        assert!(svg.contains("<title>A tooltip</title>"));
354        // `<script>` is escaped, never a literal second element.
355        assert!(svg.contains("&lt;script&gt;"));
356        assert!(!svg.contains("<script>"));
357
358        // Arrow heads are decorative marks: they carry the row's data-orig
359        // (set before the arrow is drawn) but no `<title>`. Each is a
360        // self-closing `<polygon .../>` element, so bound the check to it.
361        for part in svg.split("<polygon").skip(1) {
362            let element = part.split("/>").next().unwrap_or("");
363            if element.contains("data-part=\"arrow\"") {
364                assert!(element.contains("data-orig="));
365                assert!(!element.contains("<title>"));
366            }
367        }
368    }
369
370    #[test]
371    fn data_attr_x_gives_a_single_data_x_not_two() {
372        // GeomPoint sets the built-in `data-x` via `set_mark_axis`;
373        // `data_attr("x", "xlabel")` maps to the same attribute name and
374        // should replace it, not duplicate it.
375        let svg = GGPlot::new(vec![
376            ("x".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
377            ("y".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
378            (
379                "xlabel".into(),
380                vec![Value::Str("first".into()), Value::Str("second".into())],
381            ),
382        ])
383        .aes(Aes::new().x("x").y("y").data_attr("x", "xlabel"))
384        .geom_point()
385        .render_svg_native_with_size(200, 200)
386        .unwrap();
387
388        assert!(svg.contains("data-x=\"first\""));
389        assert!(svg.contains("data-x=\"second\""));
390        for tag_open in svg.split('<').skip(1) {
391            let attrs_part = tag_open.split('>').next().unwrap_or("");
392            let count = attrs_part.matches("data-x=\"").count();
393            assert!(count <= 1, "data-x duplicated in: <{tag_open}");
394        }
395    }
396
397    #[test]
398    fn layers_without_interactive_aes_are_byte_identical() {
399        let plain = |p: GGPlot| {
400            p.geom_curve_with(GeomCurve::default())
401                .render_svg_native_with_size(200, 200)
402                .unwrap()
403        };
404        let a = plain(plot());
405        let b = plain(plot());
406        assert_eq!(a, b);
407        assert!(!a.contains("data-orig"));
408    }
409
410    /// Review finding #3: `data_attr("part", ...)` must never produce a
411    /// second `data-part` alongside the arrowhead's own — a duplicate
412    /// attribute is an XML parse error, and the duplicate would shadow
413    /// `data-part="arrow"`, breaking the `[data-part="arrow"]` selector.
414    #[test]
415    fn data_attr_named_part_is_rejected_so_arrowheads_never_get_a_duplicate_data_part() {
416        let plot = GGPlot::new(vec![
417            ("x".into(), vec![Value::Float(0.0), Value::Float(0.2)]),
418            ("y".into(), vec![Value::Float(0.0), Value::Float(1.0)]),
419            ("xend".into(), vec![Value::Float(1.0), Value::Float(0.8)]),
420            ("yend".into(), vec![Value::Float(0.5), Value::Float(0.3)]),
421            (
422                "p".into(),
423                vec![Value::Str("mine".into()), Value::Str("mine".into())],
424            ),
425        ])
426        .aes(
427            Aes::new()
428                .x("x")
429                .y("y")
430                .xend("xend")
431                .yend("yend")
432                .data_attr("part", "p"),
433        );
434        // The invalid/reserved name is rejected with a build warning, not
435        // silently accepted.
436        assert!(plot
437            .warnings
438            .iter()
439            .any(|w| w.contains("part") && w.contains("reserved")));
440
441        let svg = plot
442            .geom_curve_with(GeomCurve::default().arrow(crate::geom::arrow::arrow().closed()))
443            .render_svg_native_with_size(200, 200)
444            .unwrap();
445
446        // Exactly one `data-part` per arrowhead element, always "arrow".
447        for part in svg.split("<polygon").skip(1) {
448            let element = part.split("/>").next().unwrap_or("");
449            if element.contains("data-part") {
450                assert_eq!(element.matches("data-part=").count(), 1);
451                assert!(element.contains("data-part=\"arrow\""));
452            }
453        }
454        assert!(!svg.contains("data-part=\"mine\""));
455    }
456}