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);
}
}