Skip to main content

ggplot_rs/geom/
arrow.rs

1//! Arrowheads for segment/curve/path/line, modelled on R grid's `arrow()`.
2//!
3//! `Arrow` only describes *where* and *how* a head should be drawn; the
4//! geometry is computed in pixel space from an already-projected polyline by
5//! [`heads`], and drawn by [`draw_heads`]. Geoms call these two functions —
6//! see `geom::segment`/`geom::curve`/`geom::path`/`geom::line` (B1-3).
7
8use crate::render::backend::{DrawBackend, LineEnd, LineStyle, Linetype, RectStyle};
9use crate::render::RenderError;
10
11/// Which end(s) of a path get an arrowhead.
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13pub enum ArrowEnds {
14    /// Head at the end of the path (the default — like R's `arrow()`).
15    Last,
16    /// Head at the start of the path.
17    First,
18    /// A head at both ends.
19    Both,
20}
21
22/// Arrowhead shape.
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub enum ArrowType {
25    /// Two open strokes meeting at the tip (not filled).
26    Open,
27    /// A filled triangle.
28    Closed,
29}
30
31/// Describes an arrowhead to draw at the end(s) of a line-like geom,
32/// following R grid's `arrow()`. `position` and `length_per_linewidth` are
33/// ggplot-rs extensions, not part of grid's API.
34#[derive(Clone, Copy, Debug, PartialEq)]
35pub struct Arrow {
36    /// Half-angle of the arrowhead, in degrees. The full opening angle
37    /// (between the two wings) is `2 * angle`.
38    pub angle: f64,
39    /// Wing length in pixels.
40    pub length: f64,
41    /// Which end(s) of the path get a head. Default [`ArrowEnds::Last`].
42    pub ends: ArrowEnds,
43    /// Open (stroked wings) or closed (filled triangle). Default
44    /// [`ArrowType::Open`].
45    pub kind: ArrowType,
46    /// Where along the path's arc length the head sits, as a fraction in
47    /// `[0, 1]` measured from the path's start (`1.0`, the default, is the
48    /// very end for `ArrowEnds::Last`, the very start for `ArrowEnds::First`).
49    /// Extension: not part of grid's `arrow()`.
50    pub position: f64,
51    /// When set above 0, the effective wing length is
52    /// `max(length, length_per_linewidth * linewidth)`, so arrowheads scale
53    /// up with a thick line. Extension: not part of grid's `arrow()`.
54    pub length_per_linewidth: f64,
55}
56
57impl Default for Arrow {
58    fn default() -> Self {
59        Arrow {
60            angle: 30.0,
61            length: 18.0,
62            ends: ArrowEnds::Last,
63            kind: ArrowType::Open,
64            position: 1.0,
65            length_per_linewidth: 0.0,
66        }
67    }
68}
69
70impl Arrow {
71    /// Half-angle of the wings, in degrees. Default `30.0`.
72    pub fn angle(mut self, degrees: f64) -> Self {
73        self.angle = degrees;
74        self
75    }
76
77    /// Wing length in pixels. Default `18.0`.
78    pub fn length(mut self, px: f64) -> Self {
79        self.length = px;
80        self
81    }
82
83    /// Which end(s) of the path get a head.
84    pub fn ends(mut self, ends: ArrowEnds) -> Self {
85        self.ends = ends;
86        self
87    }
88
89    /// Open (stroked wings) or closed (filled triangle).
90    pub fn kind(mut self, kind: ArrowType) -> Self {
91        self.kind = kind;
92        self
93    }
94
95    /// Shorthand for `.kind(ArrowType::Closed)`.
96    pub fn closed(mut self) -> Self {
97        self.kind = ArrowType::Closed;
98        self
99    }
100
101    /// Shorthand for `.kind(ArrowType::Open)`.
102    pub fn open(mut self) -> Self {
103        self.kind = ArrowType::Open;
104        self
105    }
106
107    /// Where along the path's arc length the head sits — see the
108    /// [`position`](Self::position) field doc.
109    pub fn position(mut self, fraction: f64) -> Self {
110        self.position = fraction;
111        self
112    }
113
114    /// Scale the effective wing length with the stroke width — see the
115    /// [`length_per_linewidth`](Self::length_per_linewidth) field doc.
116    pub fn length_per_linewidth(mut self, k: f64) -> Self {
117        self.length_per_linewidth = k;
118        self
119    }
120}
121
122/// Build an [`Arrow`] with the default settings (open, 30° half-angle, 18px
123/// wings, a head at the end): `arrow()`, or `arrow().closed().angle(20.0)`
124/// for a filled, narrower head.
125pub fn arrow() -> Arrow {
126    Arrow::default()
127}
128
129/// One computed arrowhead, in the same pixel space as `path_px`.
130#[derive(Clone, Copy, Debug, PartialEq)]
131pub(crate) struct ArrowHead {
132    pub tip: (f64, f64),
133    pub left: (f64, f64),
134    pub right: (f64, f64),
135}
136
137fn rotate((x, y): (f64, f64), radians: f64) -> (f64, f64) {
138    let (s, c) = radians.sin_cos();
139    (x * c - y * s, x * s + y * c)
140}
141
142/// Compute the arrowhead(s) for a polyline already in pixel space.
143///
144/// Returns no heads for a path with fewer than two points, any non-finite
145/// point, or zero total arc length (a degenerate, single-point path).
146pub(crate) fn heads(path_px: &[(f64, f64)], arrow: &Arrow, linewidth: f64) -> Vec<ArrowHead> {
147    if path_px.len() < 2
148        || path_px
149            .iter()
150            .any(|(x, y)| !x.is_finite() || !y.is_finite())
151    {
152        return Vec::new();
153    }
154    let mut cum = vec![0.0f64; path_px.len()];
155    for i in 1..path_px.len() {
156        let (x0, y0) = path_px[i - 1];
157        let (x1, y1) = path_px[i];
158        cum[i] = cum[i - 1] + ((x1 - x0).powi(2) + (y1 - y0).powi(2)).sqrt();
159    }
160    let total = cum[path_px.len() - 1];
161    if total <= 0.0 || !total.is_finite() {
162        return Vec::new();
163    }
164
165    let effective_length = arrow.length.max(arrow.length_per_linewidth * linewidth);
166
167    // Point and forward-pointing unit tangent at arc-length fraction `frac`.
168    let point_at = |frac: f64| -> ((f64, f64), (f64, f64)) {
169        let target = frac.clamp(0.0, 1.0) * total;
170        let mut idx = 1;
171        while idx < cum.len() - 1 && cum[idx] < target {
172            idx += 1;
173        }
174        let seg_start = cum[idx - 1];
175        let seg_end = cum[idx];
176        let seg_len = seg_end - seg_start;
177        let t = if seg_len > 0.0 {
178            ((target - seg_start) / seg_len).clamp(0.0, 1.0)
179        } else {
180            0.0
181        };
182        let (x0, y0) = path_px[idx - 1];
183        let (x1, y1) = path_px[idx];
184        let px = x0 + (x1 - x0) * t;
185        let py = y0 + (y1 - y0) * t;
186        let dx = x1 - x0;
187        let dy = y1 - y0;
188        let len = (dx * dx + dy * dy).sqrt();
189        let unit = if len > 0.0 {
190            (dx / len, dy / len)
191        } else {
192            (0.0, 0.0)
193        };
194        ((px, py), unit)
195    };
196
197    let make_head = |frac: f64, forward: bool| -> Option<ArrowHead> {
198        let (tip, tangent) = point_at(frac);
199        let dir = if forward {
200            tangent
201        } else {
202            (-tangent.0, -tangent.1)
203        };
204        if dir.0 == 0.0 && dir.1 == 0.0 {
205            return None;
206        }
207        let back = (-dir.0, -dir.1);
208        let angle = arrow.angle.to_radians();
209        let l = rotate(back, angle);
210        let r = rotate(back, -angle);
211        Some(ArrowHead {
212            tip,
213            left: (
214                tip.0 + effective_length * l.0,
215                tip.1 + effective_length * l.1,
216            ),
217            right: (
218                tip.0 + effective_length * r.0,
219                tip.1 + effective_length * r.1,
220            ),
221        })
222    };
223
224    let mut out = Vec::new();
225    match arrow.ends {
226        ArrowEnds::Last => out.extend(make_head(arrow.position, true)),
227        ArrowEnds::First => out.extend(make_head(1.0 - arrow.position, false)),
228        ArrowEnds::Both => {
229            out.extend(make_head(arrow.position, true));
230            out.extend(make_head(1.0 - arrow.position, false));
231        }
232    }
233    out
234}
235
236/// Draw computed arrowheads, marking them as the `"arrow"` mark part (no
237/// `<title>`, `data-part="arrow"`, `aria-hidden="true"` on the SVG backend —
238/// see [`DrawBackend::set_mark_part`]). `Closed` draws a filled triangle with
239/// fill equal to the stroke colour; `Open` draws the two wings as a single
240/// 3-point open polyline.
241pub(crate) fn draw_heads(
242    backend: &mut dyn DrawBackend,
243    heads: &[ArrowHead],
244    arrow: &Arrow,
245    color: (u8, u8, u8),
246    alpha: f64,
247    linewidth: f64,
248) -> Result<(), RenderError> {
249    for h in heads {
250        backend.set_mark_part(Some("arrow"));
251        let result = match arrow.kind {
252            ArrowType::Closed => backend.draw_polygon(
253                &[h.tip, h.left, h.right],
254                &RectStyle {
255                    fill: Some(color),
256                    stroke: Some(color),
257                    stroke_width: linewidth,
258                    alpha,
259                    clip: true,
260                    corner_radius: 0.0,
261                },
262            ),
263            ArrowType::Open => backend.draw_line(
264                &[h.left, h.tip, h.right],
265                &LineStyle {
266                    color,
267                    alpha,
268                    width: linewidth,
269                    linetype: Linetype::Solid,
270                    lineend: LineEnd::Round,
271                },
272            ),
273        };
274        backend.set_mark_part(None);
275        result?;
276    }
277    Ok(())
278}
279
280/// Where a straight line from a rectangle's `center` toward `toward` leaves
281/// the rectangle — i.e. the point on the rect's boundary a segment/curve
282/// endpoint should sit at so an [`arrow()`] drawn on it lands on the box's
283/// edge instead of overlapping into the box (too short) or stopping short in
284/// open space (too far).
285///
286/// `half_w`/`half_h` are the box's half-width/half-height in px; `toward` is
287/// the point the line is headed to — typically the *other* box's centre, or
288/// its own [`rect_edge_point`] result for a tighter node-link layout. This
289/// is the same "which axis clips first" test as scaling a line from the
290/// centre until it first crosses a vertical or horizontal edge: whichever of
291/// the two scale factors is smaller wins. `toward == center` (a zero-length
292/// direction) returns `center` itself rather than dividing by zero.
293///
294/// # Combining with `geom_segment`/`geom_curve` for a node-link diagram
295///
296/// Draw boxes with [`crate::geom::rect::GeomRect`]/[`crate::geom::tile::GeomTile`]
297/// (optionally `.with_radius(_)` for rounded corners, #176), then compute
298/// each edge's `(x, y)`/`(xend, yend)` with this helper before building the
299/// segment data — `geom_segment`/`geom_curve` draw straight to whatever
300/// endpoints the data gives them; they have no notion of "box" themselves:
301///
302/// ```
303/// use ggplot_rs::geom::arrow::rect_edge_point;
304///
305/// // Two boxes, by centre + half-extents.
306/// let (a_center, a_half) = ((0.0, 0.0), (40.0, 20.0));
307/// let (b_center, b_half) = ((160.0, 0.0), (40.0, 20.0));
308///
309/// // The segment's data points: each box's edge facing the other box,
310/// // not the raw centres — so the line (and its arrowhead) starts and
311/// // ends exactly at the boxes' boundaries.
312/// let start = rect_edge_point(a_center, a_half.0, a_half.1, b_center);
313/// let end = rect_edge_point(b_center, b_half.0, b_half.1, a_center);
314/// // ... feed `start`/`end` as this row's (x, y)/(xend, yend), then
315/// // `GGPlot::geom_segment_with(GeomSegment::default().arrow(arrow()))`.
316/// assert!((start.0 - 40.0).abs() < 1e-9); // right edge of box A
317/// assert!((end.0 - 120.0).abs() < 1e-9); // left edge of box B
318/// ```
319pub fn rect_edge_point(
320    center: (f64, f64),
321    half_w: f64,
322    half_h: f64,
323    toward: (f64, f64),
324) -> (f64, f64) {
325    let (dx, dy) = (toward.0 - center.0, toward.1 - center.1);
326    let sx = if dx != 0.0 {
327        half_w / dx.abs()
328    } else {
329        f64::INFINITY
330    };
331    let sy = if dy != 0.0 {
332        half_h / dy.abs()
333    } else {
334        f64::INFINITY
335    };
336    let k = sx.min(sy);
337    if k.is_finite() {
338        (center.0 + dx * k, center.1 + dy * k)
339    } else {
340        center
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    fn angle_between(a: (f64, f64), b: (f64, f64)) -> f64 {
349        let dot = a.0 * b.0 + a.1 * b.1;
350        let na = (a.0 * a.0 + a.1 * a.1).sqrt();
351        let nb = (b.0 * b.0 + b.1 * b.1).sqrt();
352        (dot / (na * nb)).clamp(-1.0, 1.0).acos()
353    }
354
355    #[test]
356    fn tip_lands_on_the_endpoint_at_position_1() {
357        let path = [(0.0, 0.0), (10.0, 0.0)];
358        let hs = heads(&path, &Arrow::default(), 1.0);
359        assert_eq!(hs.len(), 1);
360        assert!((hs[0].tip.0 - 10.0).abs() < 1e-9);
361        assert!((hs[0].tip.1 - 0.0).abs() < 1e-9);
362    }
363
364    #[test]
365    fn opening_angle_is_twice_the_half_angle() {
366        let path = [(0.0, 0.0), (10.0, 0.0)];
367        let a = Arrow::default().angle(30.0);
368        let hs = heads(&path, &a, 1.0);
369        assert_eq!(hs.len(), 1);
370        let h = hs[0];
371        let v_left = (h.left.0 - h.tip.0, h.left.1 - h.tip.1);
372        let v_right = (h.right.0 - h.tip.0, h.right.1 - h.tip.1);
373        let opening = angle_between(v_left, v_right).to_degrees();
374        assert!((opening - 60.0).abs() < 1e-6, "opening = {opening}");
375    }
376
377    #[test]
378    fn both_ends_give_two_heads() {
379        let path = [(0.0, 0.0), (10.0, 0.0)];
380        let a = Arrow::default().ends(ArrowEnds::Both);
381        let hs = heads(&path, &a, 1.0);
382        assert_eq!(hs.len(), 2);
383    }
384
385    #[test]
386    fn position_half_gives_the_midpoint() {
387        let path = [(0.0, 0.0), (10.0, 0.0)];
388        let a = Arrow::default().position(0.5);
389        let hs = heads(&path, &a, 1.0);
390        assert_eq!(hs.len(), 1);
391        assert!((hs[0].tip.0 - 5.0).abs() < 1e-9);
392    }
393
394    #[test]
395    fn length_per_linewidth_scales_the_wings() {
396        let path = [(0.0, 0.0), (10.0, 0.0)];
397        let a = Arrow::default().length(5.0).length_per_linewidth(2.2);
398        let hs = heads(&path, &a, 10.0); // effective length = max(5, 2.2*10) = 22
399        assert_eq!(hs.len(), 1);
400        let h = hs[0];
401        let d = ((h.left.0 - h.tip.0).powi(2) + (h.left.1 - h.tip.1).powi(2)).sqrt();
402        assert!((d - 22.0).abs() < 1e-6, "wing length = {d}");
403    }
404
405    #[test]
406    fn zero_length_path_gives_no_heads() {
407        let path = [(5.0, 5.0), (5.0, 5.0)];
408        assert!(heads(&path, &Arrow::default(), 1.0).is_empty());
409    }
410
411    #[test]
412    fn non_finite_path_gives_no_heads() {
413        let path = [(f64::NAN, 0.0), (1.0, 1.0)];
414        assert!(heads(&path, &Arrow::default(), 1.0).is_empty());
415        let path2 = [(0.0, 0.0), (f64::INFINITY, 1.0)];
416        assert!(heads(&path2, &Arrow::default(), 1.0).is_empty());
417    }
418
419    #[test]
420    fn first_end_places_tip_at_the_start() {
421        let path = [(0.0, 0.0), (10.0, 0.0)];
422        let a = Arrow::default().ends(ArrowEnds::First);
423        let hs = heads(&path, &a, 1.0);
424        assert_eq!(hs.len(), 1);
425        assert!((hs[0].tip.0 - 0.0).abs() < 1e-9);
426    }
427
428    #[test]
429    fn kind_and_open_builders_set_the_arrow_type() {
430        let a = Arrow::default().kind(ArrowType::Closed);
431        assert_eq!(a.kind, ArrowType::Closed);
432        // `.open()` is the shorthand back to the (already-default) Open kind.
433        let a = a.open();
434        assert_eq!(a.kind, ArrowType::Open);
435    }
436
437    /// A zero-length segment in the *middle* of an otherwise normal path
438    /// (not a wholly degenerate path) must not produce a NaN/garbage tip —
439    /// `point_at`'s `seg_len <= 0` fallback is exercised here.
440    #[test]
441    fn a_zero_length_segment_mid_path_does_not_produce_a_degenerate_head() {
442        let path = [(0.0, 0.0), (0.0, 0.0), (10.0, 0.0)];
443        let hs = heads(&path, &Arrow::default(), 1.0);
444        assert_eq!(hs.len(), 1);
445        assert!(hs[0].tip.0.is_finite() && hs[0].tip.1.is_finite());
446        // position 1.0 (default) still lands on the true endpoint.
447        assert!((hs[0].tip.0 - 10.0).abs() < 1e-9);
448    }
449
450    /// Positioning the head exactly on a degenerate (zero-length) leading
451    /// segment gives a zero tangent — `make_head` must recognise it and
452    /// produce no head, not a NaN direction.
453    #[test]
454    fn position_on_a_degenerate_segment_gives_no_head() {
455        let path = [(0.0, 0.0), (0.0, 0.0), (10.0, 0.0)];
456        let a = Arrow::default().position(0.0);
457        assert!(heads(&path, &a, 1.0).is_empty());
458    }
459
460    #[test]
461    fn rect_edge_point_exits_through_the_right_edge() {
462        // Target due east and further away than the box is wide: the box's
463        // vertical edge clips first.
464        let p = rect_edge_point((0.0, 0.0), 40.0, 20.0, (200.0, 0.0));
465        assert!((p.0 - 40.0).abs() < 1e-9);
466        assert!((p.1 - 0.0).abs() < 1e-9);
467    }
468
469    #[test]
470    fn rect_edge_point_exits_through_the_top_edge() {
471        // Target due north (screen "up", smaller y): the horizontal edge
472        // clips first since the box is wider than tall.
473        let p = rect_edge_point((0.0, 0.0), 40.0, 20.0, (0.0, -200.0));
474        assert!((p.0 - 0.0).abs() < 1e-9);
475        assert!((p.1 - -20.0).abs() < 1e-9);
476    }
477
478    #[test]
479    fn rect_edge_point_picks_the_axis_that_clips_first_on_a_diagonal() {
480        // A target just inside the box's own corner cone exits through the
481        // vertical edge (dx dominates): same "which scale factor is
482        // smaller" test as the consumer's exit().
483        let p = rect_edge_point((0.0, 0.0), 10.0, 10.0, (100.0, 20.0));
484        assert!(
485            (p.0 - 10.0).abs() < 1e-9,
486            "expected the right edge, got {p:?}"
487        );
488        assert!(p.1.abs() <= 10.0 + 1e-9);
489    }
490
491    #[test]
492    fn rect_edge_point_toward_the_centre_itself_returns_the_centre() {
493        let p = rect_edge_point((5.0, 5.0), 10.0, 10.0, (5.0, 5.0));
494        assert_eq!(p, (5.0, 5.0));
495    }
496
497    #[test]
498    fn rect_edge_point_is_symmetric_for_two_equal_boxes_facing_each_other() {
499        let (a, b) = ((0.0, 0.0), (160.0, 0.0));
500        let start = rect_edge_point(a, 40.0, 20.0, b);
501        let end = rect_edge_point(b, 40.0, 20.0, a);
502        assert!((start.0 - 40.0).abs() < 1e-9);
503        assert!((end.0 - 120.0).abs() < 1e-9);
504        assert!((end.0 - start.0 - 80.0).abs() < 1e-9); // the gap between boxes
505    }
506}