graph-explorer-interaction 0.1.0

Navigation and the radial focus controller for graph-explorer.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
use graph_explorer_core::{Aggregate, Cursor, DataProvider, NeighborResult, Node, NodeId, QueryParams};

pub enum Candidate {
    Real(Node),
    More(Cursor),
    Aggregate(Aggregate),
    /// Placeholder shown while a fetch for this neighborhood is in flight.
    Loading,
}

pub enum DescendOutcome {
    Refocused(NodeId),
    LoadedMore,
    Subsearch(Aggregate),
    NoOp,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SelectionKind { Node, More, Aggregate, Loading }

impl SelectionKind {
    pub fn as_str(&self) -> &'static str {
        match self {
            SelectionKind::Node => "node",
            SelectionKind::More => "more",
            SelectionKind::Aggregate => "aggregate",
            SelectionKind::Loading => "loading",
        }
    }
}

/// A description of what is currently selected, for a host that wants to render
/// an affordance ("Enter to expand 32 Sorcery") without mirroring engine state
/// through a callback. Deliberately plain data — `graph-explorer-wasm` serializes it.
#[derive(Debug, Clone, PartialEq)]
pub struct SelectionInfo {
    pub kind: SelectionKind,
    pub id: String,
    pub label: Option<String>,
    pub count: Option<u64>,
}

pub struct FocusController {
    pub focus: NodeId,
    pub history: Vec<NodeId>,
    pub candidates: Vec<Candidate>,
    pub selection: usize,
    limit: usize,
}

impl FocusController {
    pub fn new(focus: NodeId, provider: &dyn DataProvider, limit: usize) -> Self {
        let mut c = Self { focus, history: Vec::new(), candidates: Vec::new(), selection: 0, limit };
        c.fetch(provider, None, false);
        c
    }

    fn build(&mut self, res: NeighborResult, append: bool) {
        if !append {
            self.candidates.clear();
            self.selection = 0;
        }
        for n in res.nodes {
            // Neighbors are undirected, so after descending A->B, A comes
            // back as one of B's neighbors. Skip the current focus and
            // anything already in history so we don't re-offer nodes the
            // user just came from (redundant UX, and it clobbers the
            // wasm `scene_positions` map since the same id would appear
            // both as history and as a fresh candidate).
            if n.id == self.focus || self.history.contains(&n.id) {
                continue;
            }
            self.candidates.push(Candidate::Real(n));
        }
        for mut a in res.aggregates {
            // Derived from the node we queried; never trusted from the wire.
            a.parent = self.focus.clone();
            self.candidates.push(Candidate::Aggregate(a));
        }
        if let Some(cur) = res.next {
            self.candidates.push(Candidate::More(cur));
        }
    }

    fn fetch(&mut self, provider: &dyn DataProvider, cursor: Option<Cursor>, append: bool) {
        let res = provider.neighbors(&self.focus, &QueryParams { limit: self.limit, cursor });
        if res.pending {
            // One marker, not `limit` of them — the real count isn't known yet.
            if !append { self.candidates.clear(); }
            self.candidates.push(Candidate::Loading);
            self.selection = 0;
            return;
        }
        self.build(res, append);
    }

    pub fn focus_on(&mut self, id: NodeId, provider: &dyn DataProvider) {
        self.focus = id;
        self.fetch(provider, None, false);
    }

    /// Whether the candidate list is currently just a pending placeholder — the
    /// neighborhood was requested but hadn't arrived when it was last fetched.
    pub fn is_pending(&self) -> bool {
        self.candidates.iter().any(|c| matches!(c, Candidate::Loading))
    }

    /// Re-run the fetch for the current focus without disturbing focus/history.
    /// Called after an async cache fill resolves a neighborhood that was pending
    /// when this controller first fetched it; the provider now returns real data.
    pub fn refetch(&mut self, provider: &dyn DataProvider) {
        self.fetch(provider, None, false);
    }

    pub fn select(&mut self, delta: i32) {
        if self.candidates.is_empty() {
            return;
        }
        let len = self.candidates.len() as i32;
        self.selection = (self.selection as i32 + delta).rem_euclid(len) as usize;
    }

    /// Move the selection to candidate `i`. Returns `false` — changing
    /// nothing — when `i` is out of range or the candidate is a `Loading`
    /// placeholder (inert by design, same as hit-testing). The id→index
    /// resolution lives in graph-explorer-wasm, which mints the scene ids; this
    /// method is deliberately just the bounds-checked cursor move.
    pub fn select_index(&mut self, i: usize) -> bool {
        match self.candidates.get(i) {
            Some(Candidate::Loading) | None => false,
            Some(_) => {
                self.selection = i;
                true
            }
        }
    }

    /// Read-only view for hosts that resolve ids/indices — graph-explorer-wasm's select_id.
    pub fn candidates(&self) -> &[Candidate] {
        &self.candidates
    }

    pub fn descend(&mut self, provider: &dyn DataProvider) -> DescendOutcome {
        if self.candidates.is_empty() {
            return DescendOutcome::NoOp;
        }
        match &self.candidates[self.selection] {
            Candidate::Real(n) => {
                let id = n.id.clone();
                self.history.push(self.focus.clone());
                self.focus_on(id.clone(), provider);
                DescendOutcome::Refocused(id)
            }
            Candidate::More(cur) => {
                let cur = cur.clone();
                self.candidates.remove(self.selection);
                self.fetch(provider, Some(cur), true);
                self.selection = self.selection.min(self.candidates.len().saturating_sub(1));
                DescendOutcome::LoadedMore
            }
            Candidate::Aggregate(a) => DescendOutcome::Subsearch(a.clone()),
            Candidate::Loading => DescendOutcome::NoOp,
        }
    }

    /// What is selected right now, or `None` when there are no candidates.
    pub fn selected(&self) -> Option<SelectionInfo> {
        let c = self.candidates.get(self.selection)?;
        Some(match c {
            Candidate::Real(n) => SelectionInfo {
                kind: SelectionKind::Node,
                id: n.id.clone(),
                label: n.label.clone(),
                count: None,
            },
            Candidate::More(cur) => SelectionInfo {
                kind: SelectionKind::More,
                id: format!("__more:{}", cur.0),
                label: None,
                count: None,
            },
            Candidate::Aggregate(a) => SelectionInfo {
                kind: SelectionKind::Aggregate,
                id: a.id.clone(),
                label: Some(a.display()),
                count: Some(a.count),
            },
            Candidate::Loading => SelectionInfo {
                kind: SelectionKind::Loading,
                id: "__loading".into(),
                label: None,
                count: None,
            },
        })
    }

    pub fn back(&mut self, provider: &dyn DataProvider) {
        if let Some(prev) = self.history.pop() {
            self.focus_on(prev, provider);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use graph_explorer_core::{Aggregate, Cursor, DataProvider, GroupBy, Graph, NeighborResult, Node, NodeId, QueryParams};

    /// Fake provider: focus "a" has 3 real neighbors paged 2-at-a-time;
    /// focus "b" returns one aggregate; everything else returns empty.
    struct Fake;
    impl DataProvider for Fake {
        fn load(&self) -> Graph { Graph::default() }
        fn neighbors(&self, focus: &NodeId, params: &QueryParams) -> NeighborResult {
            match focus.as_str() {
                "a" => {
                    let all = ["x", "y", "z"];
                    let off = params.cursor.as_ref().and_then(|c| c.0.parse::<usize>().ok()).unwrap_or(0);
                    let nodes: Vec<Node> = all.iter().skip(off).take(params.limit).map(|s| Node::new(*s)).collect();
                    let consumed = off + nodes.len();
                    let next = (consumed < all.len()).then(|| Cursor(consumed.to_string()));
                    NeighborResult { nodes, edges: vec![], aggregates: vec![], next, pending: false }
                }
                "b" => NeighborResult {
                    nodes: vec![],
                    edges: vec![],
                    aggregates: vec![Aggregate {
                        id: "grp".into(),
                        parent: String::new(),
                        group_by: GroupBy::Label,
                        value: "items".into(),
                        relationships: vec![],
                        count: 500,
                        query: QueryParams { limit: 10, cursor: Some(Cursor("grp:items:0".into())) },
                    }],
                    next: None,
                    pending: false,
                },
                _ => NeighborResult { nodes: vec![], edges: vec![], aggregates: vec![], next: None, pending: false },
            }
        }
    }

    fn ids(c: &FocusController) -> Vec<String> {
        c.candidates.iter().map(|cand| match cand {
            Candidate::Real(n) => n.id.clone(),
            Candidate::More(_) => "<more>".into(),
            Candidate::Aggregate(a) => format!("<agg:{}>", a.id),
            Candidate::Loading => "<loading>".into(),
        }).collect()
    }

    #[test]
    fn initial_candidates_have_a_more_when_paged() {
        let c = FocusController::new("a".into(), &Fake, 2);
        assert_eq!(ids(&c), vec!["x", "y", "<more>"]);
    }

    #[test]
    fn select_wraps() {
        let mut c = FocusController::new("a".into(), &Fake, 2); // 3 candidates
        assert_eq!(c.selection, 0);
        c.select(-1);
        assert_eq!(c.selection, 2);
        c.select(1);
        assert_eq!(c.selection, 0);
    }

    #[test]
    fn descend_more_appends_next_page() {
        let mut c = FocusController::new("a".into(), &Fake, 2); // [x, y, <more>]
        c.selection = 2; // the More
        let out = c.descend(&Fake);
        assert!(matches!(out, DescendOutcome::LoadedMore));
        assert_eq!(ids(&c), vec!["x", "y", "z"]); // z appended, no more More
    }

    #[test]
    fn descend_real_refocuses_and_pushes_history() {
        let mut c = FocusController::new("a".into(), &Fake, 10); // [x, y, z]
        c.selection = 0;
        let out = c.descend(&Fake);
        assert!(matches!(out, DescendOutcome::Refocused(ref id) if id == "x"));
        assert_eq!(c.focus, "x");
        assert_eq!(c.history, vec!["a".to_string()]);
        // "x" has no neighbors in Fake
        assert!(c.candidates.is_empty());
        // back returns to a
        c.back(&Fake);
        assert_eq!(c.focus, "a");
        assert!(c.history.is_empty());
    }

    #[test]
    fn descend_aggregate_emits_subsearch_without_changing_candidates() {
        let mut c = FocusController::new("b".into(), &Fake, 10); // [<agg:grp>]
        c.selection = 0;
        let before = ids(&c);
        let out = c.descend(&Fake);
        assert!(matches!(out, DescendOutcome::Subsearch(ref a) if a.id == "grp"));
        assert_eq!(ids(&c), before, "aggregate descend is a no-op on the candidate list (deferred)");
    }

    // focus "f": page 1 has one real node + a `next`; page 2 (cursor "1") is empty.
    struct FlakyProvider;
    impl DataProvider for FlakyProvider {
        fn load(&self) -> Graph { Graph::default() }
        fn neighbors(&self, _focus: &NodeId, params: &QueryParams) -> NeighborResult {
            let off = params.cursor.as_ref().and_then(|c| c.0.parse::<usize>().ok()).unwrap_or(0);
            if off == 0 {
                NeighborResult { nodes: vec![Node::new("only")], edges: vec![], aggregates: vec![], next: Some(Cursor("1".into())), pending: false }
            } else {
                NeighborResult { nodes: vec![], edges: vec![], aggregates: vec![], next: None, pending: false }
            }
        }
    }

    #[test]
    fn descend_more_with_empty_next_page_keeps_selection_valid() {
        let mut c = FocusController::new("f".into(), &FlakyProvider, 1); // [Real(only), More("1")]
        assert_eq!(c.candidates.len(), 2);
        c.selection = 1; // the More
        let out = c.descend(&FlakyProvider);
        assert!(matches!(out, DescendOutcome::LoadedMore));
        assert_eq!(c.candidates.len(), 1); // More removed, empty page appended nothing
        assert!(c.selection < c.candidates.len(), "selection must stay in bounds");
        // the previously-panicking follow-up descend must be safe now
        let _ = c.descend(&FlakyProvider); // selection 0 -> Real("only"), refocuses, no panic
    }

    /// focus "a" has neighbor "b"; focus "b" has neighbors ["a", "c"].
    /// Neighbors are undirected, so after descending a->b, "a" would
    /// naively reappear as one of b's candidates. It must be filtered out
    /// because it's now in history.
    struct Undirected;
    impl DataProvider for Undirected {
        fn load(&self) -> Graph { Graph::default() }
        fn neighbors(&self, focus: &NodeId, _params: &QueryParams) -> NeighborResult {
            let nodes = match focus.as_str() {
                "a" => vec![Node::new("b")],
                "b" => vec![Node::new("a"), Node::new("c")],
                _ => vec![],
            };
            NeighborResult { nodes, edges: vec![], aggregates: vec![], next: None, pending: false }
        }
    }

    #[test]
    fn descend_excludes_focus_and_history_from_candidates() {
        let mut c = FocusController::new("a".into(), &Undirected, 8); // [b]
        assert_eq!(ids(&c), vec!["b"]);
        c.selection = 0;
        let out = c.descend(&Undirected);
        assert!(matches!(out, DescendOutcome::Refocused(ref id) if id == "b"));
        assert_eq!(c.focus, "b");
        assert_eq!(c.history, vec!["a".to_string()]);
        // "a" is excluded (it's now history); only "c" remains.
        assert_eq!(ids(&c), vec!["c"]);
    }

    #[test]
    fn descend_on_empty_candidates_is_noop() {
        let mut c = FocusController::new("nobody".into(), &Fake, 5); // Fake returns empty for unknown focus
        assert!(c.candidates.is_empty());
        assert!(matches!(c.descend(&Fake), DescendOutcome::NoOp));
    }

    /// Provider that always reports "still loading" (nothing cached yet).
    struct PendingProvider;
    impl DataProvider for PendingProvider {
        fn load(&self) -> Graph { Graph::default() }
        fn neighbors(&self, _f: &NodeId, _p: &QueryParams) -> NeighborResult {
            NeighborResult { nodes: vec![], edges: vec![], aggregates: vec![], next: None, pending: true }
        }
    }

    #[test]
    fn pending_result_yields_a_single_loading_candidate() {
        let fc = FocusController::new("a".into(), &PendingProvider, 8);
        assert_eq!(fc.candidates.len(), 1, "one marker, not `limit` of them");
        assert!(matches!(fc.candidates[0], Candidate::Loading));
    }

    #[test]
    fn descending_a_loading_candidate_does_not_move_focus() {
        let mut fc = FocusController::new("a".into(), &PendingProvider, 8);
        let before = fc.focus.clone();
        let _ = fc.descend(&PendingProvider);
        assert_eq!(fc.focus, before, "focus must not move into a placeholder");
    }

    /// Provider that reports "pending" once, then real data — models an async
    /// cache that fills between the initial fetch and a later refetch.
    struct FillsOnSecondCall {
        calls: std::cell::Cell<u32>,
    }
    impl DataProvider for FillsOnSecondCall {
        fn load(&self) -> Graph { Graph::default() }
        fn neighbors(&self, _f: &NodeId, _p: &QueryParams) -> NeighborResult {
            let n = self.calls.get();
            self.calls.set(n + 1);
            if n == 0 {
                NeighborResult { nodes: vec![], edges: vec![], aggregates: vec![], next: None, pending: true }
            } else {
                NeighborResult {
                    nodes: vec![Node { id: "real".into(), label: None, attrs: Default::default() }],
                    edges: vec![], aggregates: vec![], next: None, pending: false,
                }
            }
        }
    }

    #[test]
    fn refetch_resolves_a_pending_neighborhood() {
        let p = FillsOnSecondCall { calls: std::cell::Cell::new(0) };
        let mut fc = FocusController::new("a".into(), &p, 8);
        assert!(fc.is_pending(), "first fetch is a placeholder");
        assert!(matches!(fc.candidates[0], Candidate::Loading));

        fc.refetch(&p); // cache has now 'filled'
        assert!(!fc.is_pending(), "refetch replaces the placeholder");
        assert_eq!(fc.candidates.len(), 1);
        assert!(matches!(&fc.candidates[0], Candidate::Real(n) if n.id == "real"));
        assert_eq!(fc.focus, "a", "refetch keeps the same focus");
    }

    #[test]
    fn fetch_stamps_the_aggregate_parent_from_the_focus() {
        let c = FocusController::new("b".into(), &Fake, 10);
        let agg = c.candidates.iter().find_map(|x| match x {
            Candidate::Aggregate(a) => Some(a),
            _ => None,
        }).expect("Fake returns one aggregate for focus b");
        assert_eq!(agg.parent, "b", "stamped from the queried node, not the wire");
    }

    #[test]
    fn selected_describes_a_real_candidate() {
        let c = FocusController::new("a".into(), &Fake, 10);
        let s = c.selected().expect("a has candidates");
        assert_eq!(s.kind, SelectionKind::Node);
        assert_eq!(s.id, "x");
        assert_eq!(s.count, None);
    }

    #[test]
    fn selected_describes_an_aggregate_with_its_count() {
        let mut c = FocusController::new("b".into(), &Fake, 10);
        c.selection = c.candidates.iter().position(|x| matches!(x, Candidate::Aggregate(_))).unwrap();
        let s = c.selected().unwrap();
        assert_eq!(s.kind, SelectionKind::Aggregate);
        assert_eq!(s.count, Some(500));
        assert_eq!(s.label.as_deref(), Some("500 items"), "composed by display(), not transmitted");
    }

    #[test]
    fn selected_is_none_when_there_are_no_candidates() {
        let c = FocusController::new("empty".into(), &Fake, 10);
        assert!(c.selected().is_none());
    }

    #[test]
    fn select_index_moves_to_an_in_range_candidate() {
        let mut c = FocusController::new("a".into(), &Fake, 10); // [x, y, z]
        assert!(c.select_index(1));
        let s = c.selected().expect("candidate 1 exists");
        assert_eq!(s.id, "y");
    }

    #[test]
    fn select_index_rejects_out_of_range_and_keeps_selection() {
        let mut c = FocusController::new("a".into(), &Fake, 10); // [x, y, z]
        assert_eq!(c.selection, 0);
        assert!(!c.select_index(999));
        assert_eq!(c.selection, 0);
        assert_eq!(c.selected().unwrap().id, "x", "selection unchanged");
    }

    #[test]
    fn select_index_rejects_loading_placeholders() {
        let mut c = FocusController::new("a".into(), &PendingProvider, 8); // [Loading]
        assert_eq!(c.selection, 0);
        assert!(!c.select_index(0));
        assert_eq!(c.selection, 0);
        assert!(matches!(c.candidates[c.selection], Candidate::Loading), "selection unchanged");
    }

    #[test]
    fn descend_on_an_aggregate_still_changes_nothing_and_reports_it() {
        // The deferral is the whole point: the engine hands the aggregate back
        // and lets the host decide what expansion means.
        let mut c = FocusController::new("b".into(), &Fake, 10);
        c.selection = c.candidates.iter().position(|x| matches!(x, Candidate::Aggregate(_))).unwrap();
        let before: Vec<String> = ids(&c);
        match c.descend(&Fake) {
            DescendOutcome::Subsearch(a) => {
                assert_eq!(a.parent, "b", "the outcome carries a usable parent");
                assert!(a.query.cursor.is_some(), "and a cursor the host can fetch with");
            }
            _ => panic!("expected Subsearch"),
        }
        assert_eq!(ids(&c), before, "candidates untouched");
    }
}