use rand::{RngExt, SeedableRng};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Position {
pub x: f64,
pub y: f64,
}
#[derive(Debug, Clone)]
pub struct Layout {
pub positions: Vec<Position>,
#[allow(dead_code)]
pub width: f64,
#[allow(dead_code)]
pub height: f64,
}
pub fn force_directed_layout(
node_count: usize,
edges: &[(usize, usize)],
width: f64,
height: f64,
iterations: usize,
) -> Layout {
const LAYOUT_SEED: u64 = 0x5EED;
layout_with_seed(node_count, edges, width, height, iterations, LAYOUT_SEED)
}
fn layout_with_seed(
node_count: usize,
edges: &[(usize, usize)],
width: f64,
height: f64,
iterations: usize,
seed: u64,
) -> Layout {
assert!(node_count > 0, "node_count must be > 0");
let k = (width * height / node_count as f64).sqrt();
let mut rng = rand::rngs::StdRng::seed_from_u64(seed);
let min_sep_sq = (k * 0.2).powi(2);
let mut positions: Vec<Position> = Vec::with_capacity(node_count);
'next_node: for _ in 0..node_count {
for _ in 0..(100 * node_count.max(1)) {
let candidate = Position {
x: rng.random::<f64>() * width,
y: rng.random::<f64>() * height,
};
let too_close = positions.iter().any(|p| {
let dx = p.x - candidate.x;
let dy = p.y - candidate.y;
dx * dx + dy * dy < min_sep_sq
});
if !too_close {
positions.push(candidate);
continue 'next_node;
}
}
let angle = rng.random::<f64>() * std::f64::consts::TAU;
let radius = (min_sep_sq.sqrt()) * (1.0 + rng.random::<f64>());
positions.push(Position {
x: (width / 2.0 + angle.cos() * radius).clamp(0.0, width),
y: (height / 2.0 + angle.sin() * radius).clamp(0.0, height),
});
}
let mut disp = vec![Position { x: 0.0, y: 0.0 }; node_count];
for iteration in 0..iterations {
let cooling = 1.0 - iteration as f64 / iterations.max(1) as f64;
let max_disp = k * cooling;
for d in &mut disp {
d.x = 0.0;
d.y = 0.0;
}
for i in 0..node_count {
for j in (i + 1)..node_count {
let dx = positions[j].x - positions[i].x;
let dy = positions[j].y - positions[i].y;
let dist = (dx * dx + dy * dy).sqrt().max(1.0);
let force = (k * k) / dist;
let fx = (dx / dist) * force;
let fy = (dy / dist) * force;
disp[i].x -= fx;
disp[i].y -= fy;
disp[j].x += fx;
disp[j].y += fy;
}
}
for &(src, tgt) in edges {
debug_assert!(
src < node_count && tgt < node_count,
"edge index out of bounds: ({src}, {tgt}) with {node_count} nodes"
);
let dx = positions[tgt].x - positions[src].x;
let dy = positions[tgt].y - positions[src].y;
let dist = (dx * dx + dy * dy).sqrt().max(1.0);
let force = (dist * dist) / k;
let fx = (dx / dist) * force;
let fy = (dy / dist) * force;
disp[src].x += fx;
disp[src].y += fy;
disp[tgt].x -= fx;
disp[tgt].y -= fy;
}
for i in 0..node_count {
let d = (disp[i].x * disp[i].x + disp[i].y * disp[i].y).sqrt();
if d > 0.0 {
let scale = max_disp.min(d) / d;
positions[i].x += disp[i].x * scale;
positions[i].y += disp[i].y * scale;
}
let padding = 5.0;
positions[i].x = positions[i].x.clamp(padding, width - padding);
positions[i].y = positions[i].y.clamp(padding, height - padding);
}
}
Layout {
positions,
width,
height,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn distance(a: &Position, b: &Position) -> f64 {
((a.x - b.x).powi(2) + (a.y - b.y).powi(2)).sqrt()
}
#[test]
fn test_layout_returns_correct_count() {
let edges = [(0, 1), (1, 2), (2, 3), (3, 4)];
let layout = force_directed_layout(5, &edges, 800.0, 600.0, 50);
assert_eq!(
layout.positions.len(),
5,
"should return exactly node_count positions"
);
}
#[test]
fn test_layout_positions_within_bounds() {
let edges: Vec<(usize, usize)> = (0..9).map(|i| (i, i + 1)).collect();
let w = 640.0;
let h = 480.0;
let layout = force_directed_layout(10, &edges, w, h, 100);
for (i, pos) in layout.positions.iter().enumerate() {
assert!(
pos.x >= 0.0 && pos.x <= w,
"node {i}: x={} out of bounds [0, {w}]",
pos.x
);
assert!(
pos.y >= 0.0 && pos.y <= h,
"node {i}: y={} out of bounds [0, {h}]",
pos.y
);
}
}
#[test]
fn test_layout_converges() {
let node_count = 20;
let edges: Vec<(usize, usize)> = (1..node_count).map(|i| (0, i)).collect();
let layout = force_directed_layout(node_count, &edges, 800.0, 600.0, 200);
for (i, pos) in layout.positions.iter().enumerate() {
assert!(
pos.x >= 0.0 && pos.x <= 800.0,
"node {i}: x={} out of bounds after 200 iterations",
pos.x
);
assert!(
pos.y >= 0.0 && pos.y <= 600.0,
"node {i}: y={} out of bounds after 200 iterations",
pos.y
);
}
let cx = layout.positions.iter().map(|p| p.x).sum::<f64>() / node_count as f64;
let cy = layout.positions.iter().map(|p| p.y).sum::<f64>() / node_count as f64;
let w = 800.0;
let h = 600.0;
assert!(
cx > w * 0.2 && cx < w * 0.8,
"centre of mass x={cx:.1} is far from canvas centre (should be ~{})",
w / 2.0
);
assert!(
cy > h * 0.2 && cy < h * 0.8,
"centre of mass y={cy:.1} is far from canvas centre (should be ~{})",
h / 2.0
);
}
#[test]
fn test_layout_no_overlap() {
let node_count = 15;
let edges: Vec<(usize, usize)> = (0..node_count - 1).map(|i| (i, i + 1)).collect();
let layout = force_directed_layout(node_count, &edges, 600.0, 600.0, 200);
let min_dist = (0..node_count)
.flat_map(|i| ((i + 1)..node_count).map(move |j| (i, j)))
.map(|(i, j)| distance(&layout.positions[i], &layout.positions[j]))
.fold(f64::MAX, f64::min);
assert!(
min_dist > 1e-6,
"two nodes are at essentially the same position (min_dist={min_dist:e})"
);
}
#[test]
fn test_layout_connected_nodes() {
let node_count = 12;
let mut edges: Vec<(usize, usize)> = (0..5).map(|i| (i, i + 1)).collect();
edges.extend((6..11).map(|i| (i, i + 1)));
edges.push((3, 9));
let layout = force_directed_layout(node_count, &edges, 800.0, 600.0, 150);
let mut connected_dists: Vec<f64> = Vec::new();
let mut unconnected_dists: Vec<f64> = Vec::new();
for i in 0..node_count {
for j in (i + 1)..node_count {
let d = distance(&layout.positions[i], &layout.positions[j]);
let is_connected = edges
.iter()
.any(|(a, b)| (*a == i && *b == j) || (*a == j && *b == i));
if is_connected {
connected_dists.push(d);
} else {
unconnected_dists.push(d);
}
}
}
let mean_connected = connected_dists.iter().sum::<f64>() / connected_dists.len() as f64;
let mean_unconnected =
unconnected_dists.iter().sum::<f64>() / unconnected_dists.len() as f64;
assert!(
mean_connected < mean_unconnected,
"connected nodes should be closer on average: \
mean_connected={mean_connected:.2}, mean_unconnected={mean_unconnected:.2}"
);
}
#[test]
fn test_layout_empty_edges() {
let layout = force_directed_layout(5, &[], 500.0, 500.0, 50);
assert_eq!(layout.positions.len(), 5);
for pos in &layout.positions {
assert!(pos.x >= 0.0 && pos.x <= 500.0);
assert!(pos.y >= 0.0 && pos.y <= 500.0);
}
}
#[test]
fn test_layout_single_node() {
let layout = force_directed_layout(1, &[], 100.0, 100.0, 10);
assert_eq!(layout.positions.len(), 1);
let pos = &layout.positions[0];
assert!(pos.x >= 0.0 && pos.x <= 100.0);
assert!(pos.y >= 0.0 && pos.y <= 100.0);
}
}