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