graph-explorer-interaction 0.1.0

Navigation and the radial focus controller for graph-explorer.
Documentation
use graph_explorer_core::{NavState, NodeId};

mod focus;
pub use focus::{Candidate, DescendOutcome, FocusController, SelectionInfo, SelectionKind};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode { Normal, Edit, Insert, Command }

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction { Left, Down, Up, Right }

impl Direction {
    /// Unit vector in graph space (+y is up).
    fn vec(self) -> [f32; 2] {
        match self {
            Direction::Left => [-1.0, 0.0],
            Direction::Right => [1.0, 0.0],
            Direction::Up => [0.0, 1.0],
            Direction::Down => [0.0, -1.0],
        }
    }
}

/// Commands the interaction layer understands. Serialized to/from strings at the JS boundary.
#[derive(Debug, Clone, PartialEq)]
pub enum Command {
    Leap(Direction),
    Sibling(i32),
    Back,
    ZoomIn,
    ZoomOut,
    Pan(f32, f32),
}

/// Candidates in `dir`, sorted closest-to-axis first (tie-break by distance).
fn candidates_in_dir(current: [f32; 2], neighbors: &[(NodeId, [f32; 2])], dir: Direction) -> Vec<NodeId> {
    let d = dir.vec();
    let mut scored: Vec<(f32, f32, NodeId)> = neighbors
        .iter()
        .filter_map(|(id, p)| {
            let vx = p[0] - current[0];
            let vy = p[1] - current[1];
            let len = (vx * vx + vy * vy).sqrt();
            if len < 1e-6 { return None; }
            let dot = (vx * d[0] + vy * d[1]) / len; // cos(angle to axis) in [-1,1]
            if dot <= 0.0 { return None; }           // strictly within 90° of the axis
            let deviation = 1.0 - dot;               // 0 == perfectly on-axis
            Some((deviation, len, id.clone()))
        })
        .collect();
    scored.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap().then(a.1.partial_cmp(&b.1).unwrap()));
    scored.into_iter().map(|(_, _, id)| id).collect()
}

/// Pure resolver: pick the `cycle`-th candidate in `dir` (wrapping).
pub fn leap_target(current: [f32; 2], neighbors: &[(NodeId, [f32; 2])], dir: Direction, cycle: usize) -> Option<NodeId> {
    let c = candidates_in_dir(current, neighbors, dir);
    if c.is_empty() { return None; }
    Some(c[cycle % c.len()].clone())
}

/// Tracks the last directional leap so `n`/`N` can slide between siblings.
struct LeapContext {
    candidates: Vec<NodeId>,
    index: usize,
}

pub struct Navigator {
    nav: NavState,
    pending: Option<LeapContext>,
    pub mode: Mode,
}

impl Navigator {
    pub fn new(start: NodeId) -> Self {
        let nav = NavState { current: Some(start), ..Default::default() };
        Self { nav, pending: None, mode: Mode::Normal }
    }

    pub fn current(&self) -> &str {
        self.nav.current.as_deref().unwrap_or("")
    }

    /// Directional leap. `positions` maps id->graph position; `neighbors` returns
    /// (id, position) pairs for a node's edge-connected neighbors.
    pub fn leap(
        &mut self,
        dir: Direction,
        positions: &impl Fn(&str) -> [f32; 2],
        neighbors: &impl Fn(&str) -> Vec<(NodeId, [f32; 2])>,
    ) {
        let cur_id = self.current().to_string();
        let cur_pos = positions(&cur_id);
        let ns = neighbors(&cur_id);
        let candidates = candidates_in_dir(cur_pos, &ns, dir);
        let Some(target) = candidates.first().cloned() else { return };
        self.nav.focus(target);
        self.pending = Some(LeapContext { candidates, index: 0 });
    }

    /// Slide to the next (+1) / previous (-1) sibling of the last leap.
    pub fn sibling(
        &mut self,
        delta: i32,
        positions: &impl Fn(&str) -> [f32; 2],
        neighbors: &impl Fn(&str) -> Vec<(NodeId, [f32; 2])>,
    ) {
        let Some(ctx) = self.pending.take() else { return };
        let len = ctx.candidates.len();
        if len == 0 { return; }
        let new_index = ((ctx.index as i32 + delta).rem_euclid(len as i32)) as usize;
        // reserved for future candidate re-validation against live positions
        let _ = positions; let _ = neighbors;
        self.nav.current = Some(ctx.candidates[new_index].clone());
        self.pending = Some(LeapContext { index: new_index, ..ctx });
    }

    /// Any non-sibling action clears the sibling context.
    pub fn clear_pending(&mut self) { self.pending = None; }

    /// Force the current node (e.g. continuity when switching from Focus mode).
    /// Does not push history — this is a teleport, not a `focus()`-style leap.
    pub fn set_current(&mut self, id: NodeId) {
        self.nav.current = Some(id);
        self.clear_pending();
    }

    pub fn back(&mut self) { self.clear_pending(); self.nav.back(); }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ViewMode {
    Traditional,
    Focus,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FocusCommand {
    SelectPrev,
    SelectNext,
    Descend,
    Back,
    Overview,
    ZoomIn,
    ZoomOut,
}

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

    fn n(id: &str, x: f32, y: f32) -> (String, [f32; 2]) { (id.to_string(), [x, y]) }

    #[test]
    fn picks_the_neighbor_in_the_pressed_direction() {
        let cur = [0.0, 0.0];
        let ns = vec![n("right", 10.0, 0.0), n("up", 0.0, 10.0), n("left", -10.0, 0.0)];
        assert_eq!(leap_target(cur, &ns, Direction::Right, 0), Some("right".into()));
        assert_eq!(leap_target(cur, &ns, Direction::Up, 0), Some("up".into()));
        assert_eq!(leap_target(cur, &ns, Direction::Left, 0), Some("left".into()));
    }

    #[test]
    fn closest_to_axis_wins_then_cycle_reaches_the_sibling() {
        let cur = [0.0, 0.0];
        // both are "rightish"; dead_right is on-axis, up_right is off-axis
        let ns = vec![n("up_right", 10.0, 6.0), n("dead_right", 10.0, 0.0)];
        assert_eq!(leap_target(cur, &ns, Direction::Right, 0), Some("dead_right".into()));
        assert_eq!(leap_target(cur, &ns, Direction::Right, 1), Some("up_right".into()));
        // cycle wraps
        assert_eq!(leap_target(cur, &ns, Direction::Right, 2), Some("dead_right".into()));
    }

    #[test]
    fn ignores_neighbors_outside_the_direction() {
        let cur = [0.0, 0.0];
        let ns = vec![n("left", -10.0, 0.0)];
        assert_eq!(leap_target(cur, &ns, Direction::Right, 0), None);
    }

    #[test]
    fn navigator_commits_and_tracks_context_for_siblings() {
        // graph-space positions; a is current, b and c are both to the right
        let mut nav = Navigator::new("a".into());
        let neighbors = |_id: &str| vec![n("b", 10.0, 1.0), n("c", 10.0, 8.0)];
        let positions = |id: &str| match id { "a" => [0.0,0.0], "b" => [10.0,1.0], "c" => [10.0,8.0], _ => [0.0,0.0] };

        // Leap right -> closest-to-axis is b
        nav.leap(Direction::Right, &positions, &neighbors);
        assert_eq!(nav.current(), "b");
        // Next sibling -> c (undoes b, commits c from a)
        nav.sibling(1, &positions, &neighbors);
        assert_eq!(nav.current(), "c");
        // Prev sibling -> back to b
        nav.sibling(-1, &positions, &neighbors);
        assert_eq!(nav.current(), "b");
    }

}