Skip to main content

graph_explorer_layout/
radial.rs

1use crate::Position;
2
3/// Shape of the current focus session (counts + order are all the layout needs).
4pub struct RadialInput {
5    pub history_len: usize,   // ordered oldest..newest
6    pub candidate_count: usize,
7}
8
9pub struct RadialPositions {
10    pub focus: Position,
11    pub history: Vec<Position>,   // aligned to input history order (oldest..newest)
12    pub candidates: Vec<Position>,// aligned to candidate order
13}
14
15pub struct RadialLayout {
16    pub hist_x: f32,     // left column x (negative)
17    pub hist_gap: f32,   // vertical gap between history nodes
18    pub cand_radius: f32,// arc radius for candidates
19    pub cand_spread: f32,// fraction of PI the fan spans (0..1)
20}
21
22impl Default for RadialLayout {
23    fn default() -> Self {
24        Self { hist_x: -3.0, hist_gap: 1.4, cand_radius: 3.0, cand_spread: 0.9 }
25    }
26}
27
28impl RadialLayout {
29    pub fn compute(&self, input: &RadialInput) -> RadialPositions {
30        let focus = [0.0, 0.0];
31
32        let n = input.history_len;
33        let history: Vec<Position> = (0..n)
34            .map(|i| {
35                // i: oldest..newest; recency distance from focus = (n-1-i)
36                let recency = (n - 1 - i) as f32;
37                [self.hist_x, recency * self.hist_gap]
38            })
39            .collect();
40
41        let m = input.candidate_count;
42        let candidates: Vec<Position> = (0..m)
43            .map(|j| {
44                let t = if m <= 1 { 0.5 } else { j as f32 / (m as f32 - 1.0) }; // 0..1
45                let angle = (t - 0.5) * std::f32::consts::PI * self.cand_spread; // symmetric fan
46                [self.cand_radius * angle.cos(), self.cand_radius * angle.sin()]
47            })
48            .collect();
49
50        RadialPositions { focus, history, candidates }
51    }
52}
53
54#[cfg(test)]
55mod tests {
56    use super::*;
57
58    #[test]
59    fn focus_at_origin() {
60        let out = RadialLayout::default().compute(&RadialInput { history_len: 0, candidate_count: 0 });
61        assert_eq!(out.focus, [0.0, 0.0]);
62        assert!(out.history.is_empty());
63        assert!(out.candidates.is_empty());
64    }
65
66    #[test]
67    fn history_is_a_left_column_newest_nearest() {
68        let out = RadialLayout::default().compute(&RadialInput { history_len: 3, candidate_count: 0 });
69        assert_eq!(out.history.len(), 3);
70        // all to the left of focus
71        assert!(out.history.iter().all(|p| p[0] < 0.0));
72        // input order is oldest..newest; newest (last) should be nearest the focus row (smallest |y|)
73        let oldest_y = out.history[0][1].abs();
74        let newest_y = out.history[2][1].abs();
75        assert!(newest_y < oldest_y, "newest should sit nearer the focus than oldest");
76    }
77
78    #[test]
79    fn candidates_fan_to_the_right() {
80        let out = RadialLayout::default().compute(&RadialInput { history_len: 0, candidate_count: 4 });
81        assert_eq!(out.candidates.len(), 4);
82        assert!(out.candidates.iter().all(|p| p[0] > 0.0), "candidates must be on the right");
83    }
84}