Skip to main content

graph_explorer_interaction/
lib.rs

1use graph_explorer_core::{NavState, NodeId};
2
3mod focus;
4pub use focus::{Candidate, DescendOutcome, FocusController, SelectionInfo, SelectionKind};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum Mode { Normal, Edit, Insert, Command }
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum Direction { Left, Down, Up, Right }
11
12impl Direction {
13    /// Unit vector in graph space (+y is up).
14    fn vec(self) -> [f32; 2] {
15        match self {
16            Direction::Left => [-1.0, 0.0],
17            Direction::Right => [1.0, 0.0],
18            Direction::Up => [0.0, 1.0],
19            Direction::Down => [0.0, -1.0],
20        }
21    }
22}
23
24/// Commands the interaction layer understands. Serialized to/from strings at the JS boundary.
25#[derive(Debug, Clone, PartialEq)]
26pub enum Command {
27    Leap(Direction),
28    Sibling(i32),
29    Back,
30    ZoomIn,
31    ZoomOut,
32    Pan(f32, f32),
33}
34
35/// Candidates in `dir`, sorted closest-to-axis first (tie-break by distance).
36fn candidates_in_dir(current: [f32; 2], neighbors: &[(NodeId, [f32; 2])], dir: Direction) -> Vec<NodeId> {
37    let d = dir.vec();
38    let mut scored: Vec<(f32, f32, NodeId)> = neighbors
39        .iter()
40        .filter_map(|(id, p)| {
41            let vx = p[0] - current[0];
42            let vy = p[1] - current[1];
43            let len = (vx * vx + vy * vy).sqrt();
44            if len < 1e-6 { return None; }
45            let dot = (vx * d[0] + vy * d[1]) / len; // cos(angle to axis) in [-1,1]
46            if dot <= 0.0 { return None; }           // strictly within 90° of the axis
47            let deviation = 1.0 - dot;               // 0 == perfectly on-axis
48            Some((deviation, len, id.clone()))
49        })
50        .collect();
51    scored.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap().then(a.1.partial_cmp(&b.1).unwrap()));
52    scored.into_iter().map(|(_, _, id)| id).collect()
53}
54
55/// Pure resolver: pick the `cycle`-th candidate in `dir` (wrapping).
56pub fn leap_target(current: [f32; 2], neighbors: &[(NodeId, [f32; 2])], dir: Direction, cycle: usize) -> Option<NodeId> {
57    let c = candidates_in_dir(current, neighbors, dir);
58    if c.is_empty() { return None; }
59    Some(c[cycle % c.len()].clone())
60}
61
62/// Tracks the last directional leap so `n`/`N` can slide between siblings.
63struct LeapContext {
64    candidates: Vec<NodeId>,
65    index: usize,
66}
67
68pub struct Navigator {
69    nav: NavState,
70    pending: Option<LeapContext>,
71    pub mode: Mode,
72}
73
74impl Navigator {
75    pub fn new(start: NodeId) -> Self {
76        let nav = NavState { current: Some(start), ..Default::default() };
77        Self { nav, pending: None, mode: Mode::Normal }
78    }
79
80    pub fn current(&self) -> &str {
81        self.nav.current.as_deref().unwrap_or("")
82    }
83
84    /// Directional leap. `positions` maps id->graph position; `neighbors` returns
85    /// (id, position) pairs for a node's edge-connected neighbors.
86    pub fn leap(
87        &mut self,
88        dir: Direction,
89        positions: &impl Fn(&str) -> [f32; 2],
90        neighbors: &impl Fn(&str) -> Vec<(NodeId, [f32; 2])>,
91    ) {
92        let cur_id = self.current().to_string();
93        let cur_pos = positions(&cur_id);
94        let ns = neighbors(&cur_id);
95        let candidates = candidates_in_dir(cur_pos, &ns, dir);
96        let Some(target) = candidates.first().cloned() else { return };
97        self.nav.focus(target);
98        self.pending = Some(LeapContext { candidates, index: 0 });
99    }
100
101    /// Slide to the next (+1) / previous (-1) sibling of the last leap.
102    pub fn sibling(
103        &mut self,
104        delta: i32,
105        positions: &impl Fn(&str) -> [f32; 2],
106        neighbors: &impl Fn(&str) -> Vec<(NodeId, [f32; 2])>,
107    ) {
108        let Some(ctx) = self.pending.take() else { return };
109        let len = ctx.candidates.len();
110        if len == 0 { return; }
111        let new_index = ((ctx.index as i32 + delta).rem_euclid(len as i32)) as usize;
112        // reserved for future candidate re-validation against live positions
113        let _ = positions; let _ = neighbors;
114        self.nav.current = Some(ctx.candidates[new_index].clone());
115        self.pending = Some(LeapContext { index: new_index, ..ctx });
116    }
117
118    /// Any non-sibling action clears the sibling context.
119    pub fn clear_pending(&mut self) { self.pending = None; }
120
121    /// Force the current node (e.g. continuity when switching from Focus mode).
122    /// Does not push history — this is a teleport, not a `focus()`-style leap.
123    pub fn set_current(&mut self, id: NodeId) {
124        self.nav.current = Some(id);
125        self.clear_pending();
126    }
127
128    pub fn back(&mut self) { self.clear_pending(); self.nav.back(); }
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub enum ViewMode {
133    Traditional,
134    Focus,
135}
136
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum FocusCommand {
139    SelectPrev,
140    SelectNext,
141    Descend,
142    Back,
143    Overview,
144    ZoomIn,
145    ZoomOut,
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    fn n(id: &str, x: f32, y: f32) -> (String, [f32; 2]) { (id.to_string(), [x, y]) }
153
154    #[test]
155    fn picks_the_neighbor_in_the_pressed_direction() {
156        let cur = [0.0, 0.0];
157        let ns = vec![n("right", 10.0, 0.0), n("up", 0.0, 10.0), n("left", -10.0, 0.0)];
158        assert_eq!(leap_target(cur, &ns, Direction::Right, 0), Some("right".into()));
159        assert_eq!(leap_target(cur, &ns, Direction::Up, 0), Some("up".into()));
160        assert_eq!(leap_target(cur, &ns, Direction::Left, 0), Some("left".into()));
161    }
162
163    #[test]
164    fn closest_to_axis_wins_then_cycle_reaches_the_sibling() {
165        let cur = [0.0, 0.0];
166        // both are "rightish"; dead_right is on-axis, up_right is off-axis
167        let ns = vec![n("up_right", 10.0, 6.0), n("dead_right", 10.0, 0.0)];
168        assert_eq!(leap_target(cur, &ns, Direction::Right, 0), Some("dead_right".into()));
169        assert_eq!(leap_target(cur, &ns, Direction::Right, 1), Some("up_right".into()));
170        // cycle wraps
171        assert_eq!(leap_target(cur, &ns, Direction::Right, 2), Some("dead_right".into()));
172    }
173
174    #[test]
175    fn ignores_neighbors_outside_the_direction() {
176        let cur = [0.0, 0.0];
177        let ns = vec![n("left", -10.0, 0.0)];
178        assert_eq!(leap_target(cur, &ns, Direction::Right, 0), None);
179    }
180
181    #[test]
182    fn navigator_commits_and_tracks_context_for_siblings() {
183        // graph-space positions; a is current, b and c are both to the right
184        let mut nav = Navigator::new("a".into());
185        let neighbors = |_id: &str| vec![n("b", 10.0, 1.0), n("c", 10.0, 8.0)];
186        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] };
187
188        // Leap right -> closest-to-axis is b
189        nav.leap(Direction::Right, &positions, &neighbors);
190        assert_eq!(nav.current(), "b");
191        // Next sibling -> c (undoes b, commits c from a)
192        nav.sibling(1, &positions, &neighbors);
193        assert_eq!(nav.current(), "c");
194        // Prev sibling -> back to b
195        nav.sibling(-1, &positions, &neighbors);
196        assert_eq!(nav.current(), "b");
197    }
198
199}