graph-explorer-style 0.1.0

Scene model, animation and declarative styling for graph-explorer.
Documentation
//! Animation data model and pure math: easing, interpolation, pulse, and the
//! declarative `AnimationSpec` (+ imperative `PartialHaloStyle` override).

use serde::Deserialize;
use crate::Rgba;

/// Timing curve for transitions. Governs the layout tween / crossfade (the
/// camera glide uses its own built-in ease in `graph-explorer-render`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum Easing {
    Linear,
    #[default]
    EaseInOut,
    EaseOut,
}

impl Easing {
    /// Map progress `t` (clamped to `[0,1]`) through the curve.
    pub fn apply(self, t: f32) -> f32 {
        let t = t.clamp(0.0, 1.0);
        match self {
            Easing::Linear => t,
            Easing::EaseOut => 1.0 - (1.0 - t) * (1.0 - t),
            Easing::EaseInOut => {
                if t < 0.5 { 2.0 * t * t } else { 1.0 - (-2.0 * t + 2.0).powi(2) / 2.0 }
            }
        }
    }
}

pub fn lerp(a: f32, b: f32, t: f32) -> f32 { a + (b - a) * t }
pub fn lerp2(a: [f32; 2], b: [f32; 2], t: f32) -> [f32; 2] {
    [lerp(a[0], b[0], t), lerp(a[1], b[1], t)]
}
pub fn lerp4(a: [f32; 4], b: [f32; 4], t: f32) -> [f32; 4] {
    [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t), lerp(a[3], b[3], t)]
}

/// A `[0,1]` breathing factor: 0 at phase 0, 1 at half period, 0 at full
/// period. `period_ms <= 0` yields 0 (no pulse / frozen).
pub fn pulse(phase_ms: f32, period_ms: f32) -> f32 {
    if period_ms <= 0.0 { return 0.0; }
    let x = (phase_ms / period_ms) * std::f32::consts::TAU;
    0.5 - 0.5 * x.cos()
}

/// Which selection a halo marks. `Primary` = the current/focus node (full
/// strength); `Secondary` = the highlighted radial candidate (lighter).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum HaloEmphasis { Primary, Secondary }

#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct HaloSpec {
    pub enabled: bool,
    pub color: Rgba,
    /// Base halo radius as a multiple of the node radius.
    pub base_scale: f32,
    /// Extra world-units added to the halo radius beyond `base_scale`.
    pub gap: f32,
    pub pulse_period_ms: f32,
    /// Pulse amplitude as a fraction of the node radius.
    pub pulse_amplitude: f32,
    /// Glow falloff softness, `0` hard .. `1` very soft.
    pub softness: f32,
    /// Alpha multiplier applied to `Secondary` halos.
    pub secondary_alpha: f32,
    /// Pulse-amplitude multiplier applied to `Secondary` halos.
    pub secondary_amplitude: f32,
}

impl Default for HaloSpec {
    fn default() -> Self {
        Self {
            enabled: true,
            // Cyan-white default: reads clearly against the gold focus node,
            // where a gold halo used to vanish. Host-overridable via set_animation.
            color: Rgba([0.45, 0.85, 1.0, 0.85]),
            base_scale: 2.1,
            gap: 8.0,
            pulse_period_ms: 1800.0,
            pulse_amplitude: 0.25,
            softness: 0.5,
            secondary_alpha: 0.5,
            secondary_amplitude: 0.6,
        }
    }
}

#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct TransitionSpec {
    pub duration_ms: f32,
    pub easing: Easing,
    /// When true, the camera glides to frame the selection on navigation.
    pub camera_follow: bool,
    /// When true, node positions interpolate on selection change.
    pub layout_tween: bool,
}

impl Default for TransitionSpec {
    fn default() -> Self {
        Self { duration_ms: 260.0, easing: Easing::EaseInOut, camera_follow: true, layout_tween: true }
    }
}

#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct AnimationSpec {
    pub halo: HaloSpec,
    pub transition: TransitionSpec,
    /// Honors `prefers-reduced-motion`: collapses transitions to a snap and
    /// freezes the pulse. The host sets this from `matchMedia`.
    pub reduced_motion: bool,
}

impl AnimationSpec {
    /// Transition duration honoring `reduced_motion` (0 => snap).
    pub fn effective_duration(&self) -> f32 {
        if self.reduced_motion { 0.0 } else { self.transition.duration_ms }
    }
    /// Whether the halo pulse advances (frozen under `reduced_motion`).
    pub fn pulse_animates(&self) -> bool { !self.reduced_motion }
}

/// Imperative per-frame halo override (from `set_halo_fn`), merged over the
/// declarative halo params — mirrors `PartialNodeStyle`.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PartialHaloStyle {
    #[serde(default)] pub radius: Option<f32>,
    #[serde(default)] pub color: Option<Rgba>,
    #[serde(default)] pub opacity: Option<f32>,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn easing_hits_endpoints_and_is_monotonic() {
        for e in [Easing::Linear, Easing::EaseInOut, Easing::EaseOut] {
            assert!((e.apply(0.0) - 0.0).abs() < 1e-6, "{e:?} @0");
            assert!((e.apply(1.0) - 1.0).abs() < 1e-6, "{e:?} @1");
            let mut prev = -1.0;
            for i in 0..=10 {
                let v = e.apply(i as f32 / 10.0);
                assert!(v >= prev - 1e-6, "{e:?} not monotonic at {i}");
                prev = v;
            }
        }
        assert_eq!(Easing::Linear.apply(-1.0), 0.0);
        assert_eq!(Easing::Linear.apply(2.0), 1.0);
    }

    #[test]
    fn lerps_interpolate() {
        assert_eq!(lerp(0.0, 10.0, 0.5), 5.0);
        assert_eq!(lerp2([0.0, 0.0], [10.0, 20.0], 0.5), [5.0, 10.0]);
        assert_eq!(lerp4([0.0; 4], [1.0; 4], 0.25), [0.25; 4]);
    }

    #[test]
    fn pulse_is_bounded_and_periodic() {
        assert!(pulse(0.0, 1000.0).abs() < 1e-4);
        assert!((pulse(500.0, 1000.0) - 1.0).abs() < 1e-4);
        assert!(pulse(1000.0, 1000.0).abs() < 1e-4);
        for i in 0..50 {
            let v = pulse(i as f32 * 37.0, 1000.0);
            assert!((0.0..=1.0).contains(&v), "pulse out of range: {v}");
        }
        assert_eq!(pulse(123.0, 0.0), 0.0);
    }

    #[test]
    fn animation_spec_defaults_are_sane() {
        let a = AnimationSpec::default();
        assert!(a.halo.enabled);
        assert_eq!(a.transition.duration_ms, 260.0);
        assert_eq!(a.transition.easing, Easing::EaseInOut);
        assert!(a.transition.camera_follow);
        assert!(a.transition.layout_tween);
        assert!(!a.reduced_motion);
        assert_eq!(a.halo.pulse_period_ms, 1800.0);
    }

    #[test]
    fn animation_spec_parses_partial_json_and_keeps_defaults() {
        let a: AnimationSpec =
            serde_json::from_str(r#"{ "transition": { "camera_follow": false } }"#).unwrap();
        assert!(!a.transition.camera_follow);
        assert!(a.transition.layout_tween);
        assert!(a.halo.enabled);
    }

    #[test]
    fn animation_spec_rejects_unknown_fields() {
        assert!(serde_json::from_str::<AnimationSpec>(r#"{ "wobble": true }"#).is_err());
        assert!(serde_json::from_str::<AnimationSpec>(r#"{ "halo": { "nope": 1 } }"#).is_err());
    }

    #[test]
    fn reduced_motion_collapses_duration_and_freezes_pulse() {
        let mut a = AnimationSpec::default();
        assert_eq!(a.effective_duration(), 260.0);
        assert!(a.pulse_animates());
        a.reduced_motion = true;
        assert_eq!(a.effective_duration(), 0.0);
        assert!(!a.pulse_animates());
    }

    #[test]
    fn partial_halo_style_parses_and_denies_unknown() {
        let p: PartialHaloStyle =
            serde_json::from_str(r#"{ "radius": 30, "opacity": 0.5 }"#).unwrap();
        assert_eq!(p.radius, Some(30.0));
        assert_eq!(p.opacity, Some(0.5));
        assert!(p.color.is_none());
        assert!(serde_json::from_str::<PartialHaloStyle>(r#"{ "glow": 1 }"#).is_err());
    }
}