Skip to main content

orbital_charts/shared/motion/
path_draw.rs

1//! SVG path draw animation using motion tokens.
2
3use leptos::prelude::*;
4use orbital_motion::{MotionCurve, MotionDuration};
5
6/// Animated SVG stroke path with dash-offset draw on enter.
7#[component]
8pub fn PathDrawMotion(
9    /// SVG path `d` attribute.
10    #[prop(into)]
11    d: Signal<String>,
12    /// Stroke color.
13    #[prop(into)]
14    stroke: Signal<String>,
15    /// CSS class for the path element.
16    #[prop(into, default = "orb-line-stroke".into())]
17    class: String,
18    /// When true, skip draw animation.
19    #[prop(default = false)]
20    skip_animation: bool,
21    /// Animation duration token.
22    #[prop(default = MotionDuration::Normal)]
23    duration: MotionDuration,
24    /// Animation curve token.
25    #[prop(default = MotionCurve::DecelerateMax)]
26    curve: MotionCurve,
27    /// Stable key to re-trigger animation on data change.
28    #[prop(into)]
29    draw_key: Signal<String>,
30    /// Stroke width.
31    #[prop(default = 2.0)]
32    stroke_width: f64,
33) -> impl IntoView {
34    let drawn = RwSignal::new(skip_animation);
35    let path_length = Memo::new(move |_| estimate_path_length(&d.get()));
36
37    Effect::new(move |_| {
38        let _ = draw_key.get();
39        if skip_animation {
40            drawn.set(true);
41            return;
42        }
43        drawn.set(false);
44        schedule_draw_complete(move || drawn.set(true));
45    });
46
47    let dash_style = move || {
48        let len = path_length.get().max(1.0);
49        let offset = if drawn.get() { 0.0 } else { len };
50        format!(
51            "stroke-dasharray: {len}; stroke-dashoffset: {offset}; transition: stroke-dashoffset {} {}; fill: none; stroke-width: {stroke_width}; stroke: {};",
52            duration.ms(),
53            curve.css_var(),
54            stroke.get(),
55        )
56    };
57
58    view! {
59        <path
60            class=class
61            d=move || d.get()
62            style=dash_style
63            fill="none"
64        />
65    }
66}
67
68fn schedule_draw_complete(f: impl FnOnce() + 'static) {
69    #[cfg(feature = "hydrate")]
70    {
71        use wasm_bindgen::closure::Closure;
72        use wasm_bindgen::JsCast;
73
74        if let Some(win) = web_sys::window() {
75            let cb = Closure::once(f);
76            let _ = win.set_timeout_with_callback_and_timeout_and_arguments_0(
77                cb.as_ref().unchecked_ref(),
78                16,
79            );
80            cb.forget();
81        }
82    }
83    #[cfg(not(feature = "hydrate"))]
84    let _ = f;
85    #[cfg(not(feature = "hydrate"))]
86    f();
87}
88
89fn estimate_path_length(d: &str) -> f64 {
90    let nums: Vec<f64> = d
91        .split(|c: char| !c.is_ascii_digit() && c != '.' && c != '-')
92        .filter_map(|s| s.parse().ok())
93        .collect();
94    if nums.len() < 4 {
95        return 100.0;
96    }
97    let mut len = 0.0;
98    for w in nums.windows(2) {
99        len += (w[1] - w[0]).abs();
100    }
101    len.max(50.0)
102}