Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-core/src/fly.rs

//! **Click navigation, once for every graph pane** — 📌 Stay / 🎯 Focus, and the smooth
//! fly-to both modes' ⊙ fit and Focus use.
//!
//! * [`NavMode`]: what a click on a node does to the camera. **Stay** (the default): a
//!   click never moves the camera. **Focus**: the camera flies so the clicked node lands
//!   in the centre of the canvas at a readable zoom. A viewer's choice is remembered in
//!   the UI's own memory ([`NavMode::remembered`] / [`NavMode::remember`]) — one choice
//!   for every graph pane, 2D and 3D.
//! * [`FlightPath`]: van Wijk & Nuij's smooth zoom-and-pan ("Smooth and efficient
//!   zooming and panning", InfoVis 2003). A camera is a centre `c` (world, up to 3D) and
//!   a scale `w` (the visible world width in 2D, the orbit distance in 3D). The path
//!   moves both on ONE curve: a long jump pulls back mid-flight (so the user keeps their
//!   bearings) and comes back in; a short one is a near-straight glide.
//! * [`Flight`]: a path in time — ease-in-out, 400–600 ms scaled by the path's length,
//!   retargetable mid-flight from where the camera IS (a new click or ⊙ fit never
//!   jumps), cancelled by a drag.
//!
//! Pure (no egui in the math) so the path is unit-testable; a pane maps `(c, w)` to its
//! own camera (2D: `zoom = viewport_w / w`, `pan = −c·zoom`; 3D: `target = c`,
//! `distance = w`).

use serde::{Deserialize, Serialize};

/// What a click on a node does to the camera.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum NavMode {
    /// A click never moves the camera; a drill-in keeps the clicked node where it was.
    #[default]
    Stay,
    /// A click flies the clicked node to the centre of the canvas at a readable zoom.
    Focus,
}

impl NavMode {
    /// The toolbar label.
    #[must_use]
    pub fn label(self) -> &'static str {
        match self {
            NavMode::Stay => "📌 Stay",
            NavMode::Focus => "🎯 Focus",
        }
    }

    /// The hover text.
    #[must_use]
    pub fn hover(self) -> &'static str {
        match self {
            NavMode::Stay => "a click never moves the view",
            NavMode::Focus => "a click flies the node to the centre",
        }
    }

    fn key() -> egui::Id {
        egui::Id::new("facett-nav-mode")
    }

    /// This viewer's choice (UI-local memory, shared by every graph pane), `Stay` when
    /// they never chose.
    #[must_use]
    pub fn remembered(ctx: &egui::Context) -> NavMode {
        ctx.data_mut(|d| d.get_persisted::<NavMode>(Self::key())).unwrap_or_default()
    }

    /// Remember this viewer's choice.
    pub fn remember(self, ctx: &egui::Context) {
        ctx.data_mut(|d| d.insert_persisted(Self::key(), self));
    }

    /// The two-way toggle a pane puts in its toolbar (next to ⊙ fit). Returns the new
    /// mode when the viewer changed it (and remembers it).
    pub fn toggle(ui: &mut egui::Ui, current: NavMode) -> Option<NavMode> {
        let mut picked = None;
        for m in [NavMode::Stay, NavMode::Focus] {
            if ui.selectable_label(current == m, m.label()).on_hover_text(m.hover()).clicked() && current != m {
                picked = Some(m);
            }
        }
        if let Some(m) = picked {
            m.remember(ui.ctx());
        }
        picked
    }
}

/// van Wijk & Nuij's ρ: how much a long jump pulls back. √2 is the paper's "optimal";
/// the owner asked for "a little", so less.
pub const RHO: f64 = 1.0;

/// A flight takes this long at least …
pub const MIN_S: f64 = 0.40;
/// … and at most (seconds), scaled by the path length in between.
pub const MAX_S: f64 = 0.60;

/// **The smooth zoom-and-pan path** from `(c0, w0)` to `(c1, w1)`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FlightPath {
    c0: [f64; 3],
    c1: [f64; 3],
    w0: f64,
    w1: f64,
    u1: f64,
    r0: f64,
    /// The path length in van Wijk's `s` (dimensionless: ~ log-zoom steps).
    s: f64,
}

fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}

fn len(a: [f64; 3]) -> f64 {
    (a[0] * a[0] + a[1] * a[1] + a[2] * a[2]).sqrt()
}

impl FlightPath {
    /// The path between two cameras. Widths are floored at a tiny positive value.
    #[must_use]
    pub fn new(c0: [f64; 3], w0: f64, c1: [f64; 3], w1: f64) -> Self {
        let (w0, w1) = (w0.max(1e-9), w1.max(1e-9));
        let u1 = len(sub(c1, c0));
        let rho = RHO;
        if u1 < 1e-9 * w0.max(w1) {
            return Self { c0, c1, w0, w1, u1: 0.0, r0: 0.0, s: (w1 / w0).ln().abs() / rho };
        }
        let r = |w: f64, sign: f64| {
            let b = (w1 * w1 - w0 * w0 + sign * rho.powi(4) * u1 * u1) / (2.0 * w * rho * rho * u1);
            (-b + (b * b + 1.0).sqrt()).ln()
        };
        let (r0, r1) = (r(w0, 1.0), r(w1, -1.0));
        Self { c0, c1, w0, w1, u1, r0, s: ((r1 - r0) / rho).max(0.0) }
    }

    /// The path length (van Wijk's `S`).
    #[must_use]
    pub fn length(&self) -> f64 {
        self.s
    }

    /// The camera at fraction `f` (0..=1) of the path. Exact at both ends.
    #[must_use]
    pub fn at(&self, f: f64) -> ([f64; 3], f64) {
        if f <= 0.0 {
            return (self.c0, self.w0);
        }
        if f >= 1.0 {
            return (self.c1, self.w1);
        }
        let rho = RHO;
        let lerp = |k: f64| {
            let d = sub(self.c1, self.c0);
            [self.c0[0] + d[0] * k, self.c0[1] + d[1] * k, self.c0[2] + d[2] * k]
        };
        if self.u1 == 0.0 {
            // a pure zoom (and any sub-epsilon offset glides linearly)
            let w = self.w0 * ((self.w1 / self.w0).ln() * f).exp();
            return (lerp(f), w);
        }
        let s = self.s * f;
        let (c, sh) = (self.r0.cosh(), self.r0.sinh());
        let u = self.w0 / (rho * rho) * (c * (rho * s + self.r0).tanh() - sh);
        let w = self.w0 * c / (rho * s + self.r0).cosh();
        (lerp((u / self.u1).clamp(0.0, 1.0)), w)
    }
}

/// Cubic ease-in-out on `0..=1`.
#[must_use]
pub fn ease_in_out(t: f64) -> f64 {
    let t = t.clamp(0.0, 1.0);
    if t < 0.5 {
        4.0 * t * t * t
    } else {
        1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
    }
}

/// **A flight in time**: a [`FlightPath`] eased over 400–600 ms from `t0`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Flight {
    pub path: FlightPath,
    /// Start time (seconds, the UI's clock).
    pub t0: f64,
    /// Duration (seconds).
    pub dur: f64,
}

impl Flight {
    /// Fly from `(c0, w0)` to `(c1, w1)`, starting at `now`.
    #[must_use]
    pub fn new(now: f64, c0: [f64; 3], w0: f64, c1: [f64; 3], w1: f64) -> Self {
        let path = FlightPath::new(c0, w0, c1, w1);
        let dur = (MIN_S + 0.05 * path.length()).clamp(MIN_S, MAX_S);
        Self { path, t0: now, dur }
    }

    /// The camera at `now`.
    #[must_use]
    pub fn at(&self, now: f64) -> ([f64; 3], f64) {
        self.path.at(ease_in_out((now - self.t0) / self.dur))
    }

    /// Has it landed at `now`?
    #[must_use]
    pub fn done(&self, now: f64) -> bool {
        now - self.t0 >= self.dur - 1e-9
    }

    /// A new target mid-flight: a fresh flight from where the camera IS at `now` — the
    /// camera never jumps.
    #[must_use]
    pub fn retarget(&self, now: f64, c1: [f64; 3], w1: f64) -> Self {
        let (c, w) = self.at(now);
        Self::new(now, c, w, c1, w1)
    }

    /// The landing camera.
    #[must_use]
    pub fn target(&self) -> ([f64; 3], f64) {
        self.path.at(1.0)
    }
}

/// A 2D pan/zoom camera as a flight state: `screen = canvas_centre + pan + world·zoom`
/// ⇔ centre `c = −pan / zoom`, visible width `w = viewport_w / zoom`.
#[must_use]
pub fn from_pan_zoom(pan: [f32; 2], zoom: f32, viewport_w: f32) -> ([f64; 3], f64) {
    let z = f64::from(zoom.max(1e-6));
    ([-f64::from(pan[0]) / z, -f64::from(pan[1]) / z, 0.0], f64::from(viewport_w.max(1.0)) / z)
}

/// The inverse of [`from_pan_zoom`]: `(pan, zoom)`.
#[must_use]
pub fn to_pan_zoom(c: [f64; 3], w: f64, viewport_w: f32) -> ([f32; 2], f32) {
    let zoom = f64::from(viewport_w.max(1.0)) / w.max(1e-9);
    ([(-c[0] * zoom) as f32, (-c[1] * zoom) as f32], zoom as f32)
}

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

    fn sample(f: &Flight, fps: f64) -> Vec<([f64; 3], f64)> {
        let n = (f.dur * fps).ceil() as usize;
        (0..=n).map(|i| f.at(f.t0 + i as f64 / fps)).collect()
    }

    /// The flight ends EXACTLY at the target and starts exactly at the source.
    #[test]
    fn a_flight_lands_exactly() {
        let f = Flight::new(10.0, [0.0, 0.0, 0.0], 1000.0, [5000.0, -300.0, 0.0], 400.0);
        assert_eq!(f.at(10.0), ([0.0, 0.0, 0.0], 1000.0));
        assert_eq!(f.at(10.0 + f.dur), ([5000.0, -300.0, 0.0], 400.0));
        assert!(f.done(10.0 + f.dur) && !f.done(10.0 + f.dur * 0.5));
        assert!((MIN_S..=MAX_S).contains(&f.dur), "{}", f.dur);
    }

    /// **Continuous** at 60 fps, for a short glide up to a 20-screen jump: no frame moves
    /// the centre more than 25 % of the jump (MEASURED: 22 % at 20 screens in 0.6 s — the
    /// ease and the pull-back put the speed in the middle, where the view is zoomed out),
    /// nor more than half the visible width; no frame changes the scale by more than 1.4×.
    #[test]
    fn the_camera_path_is_continuous() {
        for (c1, w1) in [([5000.0, -300.0, 0.0], 400.0), ([40.0, 10.0, 0.0], 900.0), ([0.0, 0.0, 0.0], 50.0), ([2.0e4, 0.0, 0.0], 1000.0)] {
            let f = Flight::new(0.0, [0.0; 3], 1000.0, c1, w1);
            let d = len(c1);
            for p in sample(&f, 60.0).windows(2) {
                let (a, b) = (p[0], p[1]);
                let step = len(sub(b.0, a.0));
                if d > 0.0 {
                    assert!(step <= 0.25 * d, "centre step {step} of {d} to {c1:?}");
                }
                assert!(step <= 0.5 * a.1.max(b.1), "centre step {step} at width {}", a.1);
                assert!(b.1 / a.1 <= 1.4 && a.1 / b.1 <= 1.4, "scale jump {} → {}", a.1, b.1);
            }
        }
    }

    /// A long jump pulls back mid-flight (bearings); a short one barely does.
    #[test]
    fn a_long_jump_pulls_back_a_short_one_glides() {
        let long = Flight::new(0.0, [0.0; 3], 1000.0, [20_000.0, 0.0, 0.0], 1000.0);
        let peak = sample(&long, 60.0).iter().map(|s| s.1).fold(0.0, f64::max);
        assert!(peak > 2.0 * 1000.0, "pulls back: peak {peak}");
        let short = Flight::new(0.0, [0.0; 3], 1000.0, [150.0, 0.0, 0.0], 1000.0);
        let peak = sample(&short, 60.0).iter().map(|s| s.1).fold(0.0, f64::max);
        assert!(peak < 1.05 * 1000.0, "a short glide stays put: peak {peak}");
    }

    /// Retargeting starts from where the camera is: no jump.
    #[test]
    fn a_retarget_never_jumps() {
        let f = Flight::new(0.0, [0.0; 3], 1000.0, [5000.0, 0.0, 0.0], 400.0);
        let now = f.dur * 0.4;
        let here = f.at(now);
        let g = f.retarget(now, [-800.0, 900.0, 0.0], 700.0);
        assert_eq!(g.at(now), here);
        assert_eq!(g.target(), ([-800.0, 900.0, 0.0], 700.0));
    }

    /// The 2D mapping round-trips.
    #[test]
    fn pan_zoom_round_trips() {
        let (c, w) = from_pan_zoom([120.0, -40.0], 0.5, 1000.0);
        assert_eq!((c, w), ([-240.0, 80.0, 0.0], 2000.0));
        let (pan, zoom) = to_pan_zoom(c, w, 1000.0);
        assert!((pan[0] - 120.0).abs() < 1e-4 && (pan[1] + 40.0).abs() < 1e-4 && (zoom - 0.5).abs() < 1e-6);
    }
}