graph-explorer-layout 0.1.0

Barnes-Hut force simulation and radial layout for graph-explorer.
Documentation
use crate::Position;

/// Shape of the current focus session (counts + order are all the layout needs).
pub struct RadialInput {
    pub history_len: usize,   // ordered oldest..newest
    pub candidate_count: usize,
}

pub struct RadialPositions {
    pub focus: Position,
    pub history: Vec<Position>,   // aligned to input history order (oldest..newest)
    pub candidates: Vec<Position>,// aligned to candidate order
}

pub struct RadialLayout {
    pub hist_x: f32,     // left column x (negative)
    pub hist_gap: f32,   // vertical gap between history nodes
    pub cand_radius: f32,// arc radius for candidates
    pub cand_spread: f32,// fraction of PI the fan spans (0..1)
}

impl Default for RadialLayout {
    fn default() -> Self {
        Self { hist_x: -3.0, hist_gap: 1.4, cand_radius: 3.0, cand_spread: 0.9 }
    }
}

impl RadialLayout {
    pub fn compute(&self, input: &RadialInput) -> RadialPositions {
        let focus = [0.0, 0.0];

        let n = input.history_len;
        let history: Vec<Position> = (0..n)
            .map(|i| {
                // i: oldest..newest; recency distance from focus = (n-1-i)
                let recency = (n - 1 - i) as f32;
                [self.hist_x, recency * self.hist_gap]
            })
            .collect();

        let m = input.candidate_count;
        let candidates: Vec<Position> = (0..m)
            .map(|j| {
                let t = if m <= 1 { 0.5 } else { j as f32 / (m as f32 - 1.0) }; // 0..1
                let angle = (t - 0.5) * std::f32::consts::PI * self.cand_spread; // symmetric fan
                [self.cand_radius * angle.cos(), self.cand_radius * angle.sin()]
            })
            .collect();

        RadialPositions { focus, history, candidates }
    }
}

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

    #[test]
    fn focus_at_origin() {
        let out = RadialLayout::default().compute(&RadialInput { history_len: 0, candidate_count: 0 });
        assert_eq!(out.focus, [0.0, 0.0]);
        assert!(out.history.is_empty());
        assert!(out.candidates.is_empty());
    }

    #[test]
    fn history_is_a_left_column_newest_nearest() {
        let out = RadialLayout::default().compute(&RadialInput { history_len: 3, candidate_count: 0 });
        assert_eq!(out.history.len(), 3);
        // all to the left of focus
        assert!(out.history.iter().all(|p| p[0] < 0.0));
        // input order is oldest..newest; newest (last) should be nearest the focus row (smallest |y|)
        let oldest_y = out.history[0][1].abs();
        let newest_y = out.history[2][1].abs();
        assert!(newest_y < oldest_y, "newest should sit nearer the focus than oldest");
    }

    #[test]
    fn candidates_fan_to_the_right() {
        let out = RadialLayout::default().compute(&RadialInput { history_len: 0, candidate_count: 4 });
        assert_eq!(out.candidates.len(), 4);
        assert!(out.candidates.iter().all(|p| p[0] > 0.0), "candidates must be on the right");
    }
}