Skip to main content

graph_explorer_style/
anim.rs

1//! Animation data model and pure math: easing, interpolation, pulse, and the
2//! declarative `AnimationSpec` (+ imperative `PartialHaloStyle` override).
3
4use serde::Deserialize;
5use crate::Rgba;
6
7/// Timing curve for transitions. Governs the layout tween / crossfade (the
8/// camera glide uses its own built-in ease in `graph-explorer-render`).
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
10#[serde(rename_all = "kebab-case")]
11pub enum Easing {
12    Linear,
13    #[default]
14    EaseInOut,
15    EaseOut,
16}
17
18impl Easing {
19    /// Map progress `t` (clamped to `[0,1]`) through the curve.
20    pub fn apply(self, t: f32) -> f32 {
21        let t = t.clamp(0.0, 1.0);
22        match self {
23            Easing::Linear => t,
24            Easing::EaseOut => 1.0 - (1.0 - t) * (1.0 - t),
25            Easing::EaseInOut => {
26                if t < 0.5 { 2.0 * t * t } else { 1.0 - (-2.0 * t + 2.0).powi(2) / 2.0 }
27            }
28        }
29    }
30}
31
32pub fn lerp(a: f32, b: f32, t: f32) -> f32 { a + (b - a) * t }
33pub fn lerp2(a: [f32; 2], b: [f32; 2], t: f32) -> [f32; 2] {
34    [lerp(a[0], b[0], t), lerp(a[1], b[1], t)]
35}
36pub fn lerp4(a: [f32; 4], b: [f32; 4], t: f32) -> [f32; 4] {
37    [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t), lerp(a[3], b[3], t)]
38}
39
40/// A `[0,1]` breathing factor: 0 at phase 0, 1 at half period, 0 at full
41/// period. `period_ms <= 0` yields 0 (no pulse / frozen).
42pub fn pulse(phase_ms: f32, period_ms: f32) -> f32 {
43    if period_ms <= 0.0 { return 0.0; }
44    let x = (phase_ms / period_ms) * std::f32::consts::TAU;
45    0.5 - 0.5 * x.cos()
46}
47
48/// Which selection a halo marks. `Primary` = the current/focus node (full
49/// strength); `Secondary` = the highlighted radial candidate (lighter).
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
51#[serde(rename_all = "lowercase")]
52pub enum HaloEmphasis { Primary, Secondary }
53
54#[derive(Debug, Clone, Deserialize)]
55#[serde(deny_unknown_fields, default)]
56pub struct HaloSpec {
57    pub enabled: bool,
58    pub color: Rgba,
59    /// Base halo radius as a multiple of the node radius.
60    pub base_scale: f32,
61    /// Extra world-units added to the halo radius beyond `base_scale`.
62    pub gap: f32,
63    pub pulse_period_ms: f32,
64    /// Pulse amplitude as a fraction of the node radius.
65    pub pulse_amplitude: f32,
66    /// Glow falloff softness, `0` hard .. `1` very soft.
67    pub softness: f32,
68    /// Alpha multiplier applied to `Secondary` halos.
69    pub secondary_alpha: f32,
70    /// Pulse-amplitude multiplier applied to `Secondary` halos.
71    pub secondary_amplitude: f32,
72}
73
74impl Default for HaloSpec {
75    fn default() -> Self {
76        Self {
77            enabled: true,
78            // Cyan-white default: reads clearly against the gold focus node,
79            // where a gold halo used to vanish. Host-overridable via set_animation.
80            color: Rgba([0.45, 0.85, 1.0, 0.85]),
81            base_scale: 2.1,
82            gap: 8.0,
83            pulse_period_ms: 1800.0,
84            pulse_amplitude: 0.25,
85            softness: 0.5,
86            secondary_alpha: 0.5,
87            secondary_amplitude: 0.6,
88        }
89    }
90}
91
92#[derive(Debug, Clone, Deserialize)]
93#[serde(deny_unknown_fields, default)]
94pub struct TransitionSpec {
95    pub duration_ms: f32,
96    pub easing: Easing,
97    /// When true, the camera glides to frame the selection on navigation.
98    pub camera_follow: bool,
99    /// When true, node positions interpolate on selection change.
100    pub layout_tween: bool,
101}
102
103impl Default for TransitionSpec {
104    fn default() -> Self {
105        Self { duration_ms: 260.0, easing: Easing::EaseInOut, camera_follow: true, layout_tween: true }
106    }
107}
108
109#[derive(Debug, Clone, Default, Deserialize)]
110#[serde(deny_unknown_fields, default)]
111pub struct AnimationSpec {
112    pub halo: HaloSpec,
113    pub transition: TransitionSpec,
114    /// Honors `prefers-reduced-motion`: collapses transitions to a snap and
115    /// freezes the pulse. The host sets this from `matchMedia`.
116    pub reduced_motion: bool,
117}
118
119impl AnimationSpec {
120    /// Transition duration honoring `reduced_motion` (0 => snap).
121    pub fn effective_duration(&self) -> f32 {
122        if self.reduced_motion { 0.0 } else { self.transition.duration_ms }
123    }
124    /// Whether the halo pulse advances (frozen under `reduced_motion`).
125    pub fn pulse_animates(&self) -> bool { !self.reduced_motion }
126}
127
128/// Imperative per-frame halo override (from `set_halo_fn`), merged over the
129/// declarative halo params — mirrors `PartialNodeStyle`.
130#[derive(Debug, Clone, Default, Deserialize)]
131#[serde(deny_unknown_fields)]
132pub struct PartialHaloStyle {
133    #[serde(default)] pub radius: Option<f32>,
134    #[serde(default)] pub color: Option<Rgba>,
135    #[serde(default)] pub opacity: Option<f32>,
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn easing_hits_endpoints_and_is_monotonic() {
144        for e in [Easing::Linear, Easing::EaseInOut, Easing::EaseOut] {
145            assert!((e.apply(0.0) - 0.0).abs() < 1e-6, "{e:?} @0");
146            assert!((e.apply(1.0) - 1.0).abs() < 1e-6, "{e:?} @1");
147            let mut prev = -1.0;
148            for i in 0..=10 {
149                let v = e.apply(i as f32 / 10.0);
150                assert!(v >= prev - 1e-6, "{e:?} not monotonic at {i}");
151                prev = v;
152            }
153        }
154        assert_eq!(Easing::Linear.apply(-1.0), 0.0);
155        assert_eq!(Easing::Linear.apply(2.0), 1.0);
156    }
157
158    #[test]
159    fn lerps_interpolate() {
160        assert_eq!(lerp(0.0, 10.0, 0.5), 5.0);
161        assert_eq!(lerp2([0.0, 0.0], [10.0, 20.0], 0.5), [5.0, 10.0]);
162        assert_eq!(lerp4([0.0; 4], [1.0; 4], 0.25), [0.25; 4]);
163    }
164
165    #[test]
166    fn pulse_is_bounded_and_periodic() {
167        assert!(pulse(0.0, 1000.0).abs() < 1e-4);
168        assert!((pulse(500.0, 1000.0) - 1.0).abs() < 1e-4);
169        assert!(pulse(1000.0, 1000.0).abs() < 1e-4);
170        for i in 0..50 {
171            let v = pulse(i as f32 * 37.0, 1000.0);
172            assert!((0.0..=1.0).contains(&v), "pulse out of range: {v}");
173        }
174        assert_eq!(pulse(123.0, 0.0), 0.0);
175    }
176
177    #[test]
178    fn animation_spec_defaults_are_sane() {
179        let a = AnimationSpec::default();
180        assert!(a.halo.enabled);
181        assert_eq!(a.transition.duration_ms, 260.0);
182        assert_eq!(a.transition.easing, Easing::EaseInOut);
183        assert!(a.transition.camera_follow);
184        assert!(a.transition.layout_tween);
185        assert!(!a.reduced_motion);
186        assert_eq!(a.halo.pulse_period_ms, 1800.0);
187    }
188
189    #[test]
190    fn animation_spec_parses_partial_json_and_keeps_defaults() {
191        let a: AnimationSpec =
192            serde_json::from_str(r#"{ "transition": { "camera_follow": false } }"#).unwrap();
193        assert!(!a.transition.camera_follow);
194        assert!(a.transition.layout_tween);
195        assert!(a.halo.enabled);
196    }
197
198    #[test]
199    fn animation_spec_rejects_unknown_fields() {
200        assert!(serde_json::from_str::<AnimationSpec>(r#"{ "wobble": true }"#).is_err());
201        assert!(serde_json::from_str::<AnimationSpec>(r#"{ "halo": { "nope": 1 } }"#).is_err());
202    }
203
204    #[test]
205    fn reduced_motion_collapses_duration_and_freezes_pulse() {
206        let mut a = AnimationSpec::default();
207        assert_eq!(a.effective_duration(), 260.0);
208        assert!(a.pulse_animates());
209        a.reduced_motion = true;
210        assert_eq!(a.effective_duration(), 0.0);
211        assert!(!a.pulse_animates());
212    }
213
214    #[test]
215    fn partial_halo_style_parses_and_denies_unknown() {
216        let p: PartialHaloStyle =
217            serde_json::from_str(r#"{ "radius": 30, "opacity": 0.5 }"#).unwrap();
218        assert_eq!(p.radius, Some(30.0));
219        assert_eq!(p.opacity, Some(0.5));
220        assert!(p.color.is_none());
221        assert!(serde_json::from_str::<PartialHaloStyle>(r#"{ "glow": 1 }"#).is_err());
222    }
223}