graph_explorer_layout/
lib.rs1mod radial;
2pub use radial::{RadialInput, RadialLayout, RadialPositions};
3
4mod quadtree;
5pub use quadtree::QuadTree;
6
7mod force_sim;
8pub use force_sim::{ForceSimulation, SimulationSpec};
9
10pub type Position = [f32; 2];
11
12#[cfg(test)]
13mod layout_quality_tests {
14 use crate::{ForceSimulation, SimulationSpec};
15 use graph_explorer_core::{Graph, Node, Edge};
16 use std::collections::HashMap;
17
18 fn clustered(n: usize) -> Graph {
20 let mut g = Graph::default();
21 let cluster = 50;
22 let hubs = n.div_ceil(cluster);
23 for c in 0..hubs {
24 let hub = format!("h{c}");
25 g.add_node(Node::new(hub.clone()));
26 if c > 0 { g.add_edge(Edge::new(format!("ih{c}"), format!("h{}", c - 1), hub.clone())); }
27 for k in 1..cluster {
28 if g.nodes().count() >= n { break; }
29 let id = format!("n{c}x{k}");
30 g.add_node(Node::new(id.clone()));
31 g.add_edge(Edge::new(format!("e{c}x{k}"), hub.clone(), id));
32 }
33 }
34 if hubs > 1 { g.add_edge(Edge::new("ring", format!("h{}", hubs - 1), "h0")); }
35 g
36 }
37
38 fn settled_positions(g: &Graph) -> HashMap<String, [f32; 2]> {
39 let mut s = ForceSimulation::new(SimulationSpec::default());
40 s.sync(g, &HashMap::new()); s.reheat_full();
42 s.settle(600);
43 assert!(!s.is_active(), "must converge within the settle bound");
44 s.positions().map(|(id, p)| (id.clone(), p)).collect()
45 }
46
47 #[test]
48 fn settle_from_phyllotaxis_yields_a_real_layout() {
49 let g = clustered(500);
50 let pos = settled_positions(&g);
51 assert_eq!(pos.len(), 500);
52 for p in pos.values() {
53 assert!(p[0].is_finite() && p[1].is_finite(), "NaN/inf position");
54 }
55 let (mut min, mut max) = ([f32::MAX; 2], [f32::MIN; 2]);
56 for p in pos.values() {
57 min[0] = min[0].min(p[0]); min[1] = min[1].min(p[1]);
58 max[0] = max[0].max(p[0]); max[1] = max[1].max(p[1]);
59 }
60 assert!(max[0] - min[0] > 100.0 && max[1] - min[1] > 100.0, "degenerate spread");
61 let g_edges: Vec<_> = g.edges().collect();
62 let mean_linked: f32 = g_edges.iter()
63 .map(|e| dist(pos[&e.source], pos[&e.target]))
64 .sum::<f32>() / g_edges.len() as f32;
65 let ids: Vec<_> = pos.keys().cloned().collect();
66 let mut sum = 0.0f32; let mut cnt = 0u32;
67 for i in (0..ids.len()).step_by(7) {
68 let j = (i * 31 + 17) % ids.len();
69 if i == j { continue; }
70 sum += dist(pos[&ids[i]], pos[&ids[j]]); cnt += 1;
71 }
72 let mean_sampled = sum / cnt as f32;
73 assert!(mean_linked < mean_sampled * 0.75,
74 "linked pairs ({mean_linked}) not meaningfully closer than sampled pairs ({mean_sampled})");
75 }
76
77 #[test]
78 fn settle_is_deterministic_for_identical_inputs() {
79 let g = clustered(300);
80 let a = settled_positions(&g);
81 let b = settled_positions(&g);
82 assert_eq!(a.len(), b.len());
83 for (id, pa) in &a {
84 let pb = b[id];
85 assert!((pa[0] - pb[0]).abs() < 1e-6 && (pa[1] - pb[1]).abs() < 1e-6,
86 "nondeterministic settle at {id}");
87 }
88 }
89
90 fn dist(a: [f32; 2], b: [f32; 2]) -> f32 { ((a[0]-b[0]).powi(2) + (a[1]-b[1]).powi(2)).sqrt() }
91}