234 lines
7.2 KiB
Rust
234 lines
7.2 KiB
Rust
//! Graph layout algorithms for brain topology visualization.
|
|
|
|
use ruv_neural_core::brain::Parcellation;
|
|
use ruv_neural_core::graph::BrainGraph;
|
|
|
|
/// Force-directed layout for brain graph visualization.
|
|
///
|
|
/// Uses the Fruchterman-Reingold algorithm to position nodes such that
|
|
/// connected nodes are attracted and all nodes repel each other.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ForceDirectedLayout {
|
|
/// Number of layout iterations.
|
|
pub iterations: usize,
|
|
/// Repulsion constant between all node pairs.
|
|
pub repulsion: f64,
|
|
/// Attraction constant along edges.
|
|
pub attraction: f64,
|
|
/// Velocity damping factor per iteration.
|
|
pub damping: f64,
|
|
}
|
|
|
|
impl Default for ForceDirectedLayout {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl ForceDirectedLayout {
|
|
/// Create a new layout with default parameters.
|
|
pub fn new() -> Self {
|
|
Self {
|
|
iterations: 100,
|
|
repulsion: 1000.0,
|
|
attraction: 0.01,
|
|
damping: 0.95,
|
|
}
|
|
}
|
|
|
|
/// Compute 3D positions for each node using force-directed placement.
|
|
///
|
|
/// 1. Initialize positions deterministically (grid-based).
|
|
/// 2. Iterate: compute repulsive forces between all pairs, attractive forces along edges.
|
|
/// 3. Apply displacement with damping.
|
|
pub fn compute(&self, graph: &BrainGraph) -> Vec<[f64; 3]> {
|
|
let n = graph.num_nodes;
|
|
if n == 0 {
|
|
return Vec::new();
|
|
}
|
|
|
|
// Initialize positions on a simple 3D grid
|
|
let mut positions: Vec<[f64; 3]> = (0..n)
|
|
.map(|i| {
|
|
let fi = i as f64;
|
|
let cols = (n as f64).sqrt().ceil() as usize;
|
|
let cols_f = cols as f64;
|
|
let x = (fi % cols_f) * 10.0;
|
|
let y = ((fi / cols_f).floor()) * 10.0;
|
|
let z = ((fi / (cols_f * cols_f)).floor()) * 10.0;
|
|
[x, y, z]
|
|
})
|
|
.collect();
|
|
|
|
let mut velocities = vec![[0.0_f64; 3]; n];
|
|
|
|
for _iter in 0..self.iterations {
|
|
let mut forces = vec![[0.0_f64; 3]; n];
|
|
|
|
// Repulsive forces between all pairs
|
|
for i in 0..n {
|
|
for j in (i + 1)..n {
|
|
let dx = positions[i][0] - positions[j][0];
|
|
let dy = positions[i][1] - positions[j][1];
|
|
let dz = positions[i][2] - positions[j][2];
|
|
let dist_sq = dx * dx + dy * dy + dz * dz;
|
|
let dist = dist_sq.sqrt().max(0.01);
|
|
|
|
let force = self.repulsion / dist_sq.max(0.01);
|
|
let fx = force * dx / dist;
|
|
let fy = force * dy / dist;
|
|
let fz = force * dz / dist;
|
|
|
|
forces[i][0] += fx;
|
|
forces[i][1] += fy;
|
|
forces[i][2] += fz;
|
|
forces[j][0] -= fx;
|
|
forces[j][1] -= fy;
|
|
forces[j][2] -= fz;
|
|
}
|
|
}
|
|
|
|
// Attractive forces along edges
|
|
for edge in &graph.edges {
|
|
if edge.source >= n || edge.target >= n {
|
|
continue;
|
|
}
|
|
let s = edge.source;
|
|
let t = edge.target;
|
|
let dx = positions[t][0] - positions[s][0];
|
|
let dy = positions[t][1] - positions[s][1];
|
|
let dz = positions[t][2] - positions[s][2];
|
|
let dist = (dx * dx + dy * dy + dz * dz).sqrt().max(0.01);
|
|
|
|
let force = self.attraction * edge.weight * dist;
|
|
let fx = force * dx / dist;
|
|
let fy = force * dy / dist;
|
|
let fz = force * dz / dist;
|
|
|
|
forces[s][0] += fx;
|
|
forces[s][1] += fy;
|
|
forces[s][2] += fz;
|
|
forces[t][0] -= fx;
|
|
forces[t][1] -= fy;
|
|
forces[t][2] -= fz;
|
|
}
|
|
|
|
// Apply forces with damping
|
|
for i in 0..n {
|
|
for d in 0..3 {
|
|
velocities[i][d] = (velocities[i][d] + forces[i][d]) * self.damping;
|
|
positions[i][d] += velocities[i][d];
|
|
}
|
|
}
|
|
}
|
|
|
|
positions
|
|
}
|
|
}
|
|
|
|
/// Anatomical layout using MNI coordinates from brain parcellation.
|
|
pub struct AnatomicalLayout;
|
|
|
|
impl AnatomicalLayout {
|
|
/// Compute positions from parcellation MNI centroids.
|
|
pub fn compute(parcellation: &Parcellation) -> Vec<[f64; 3]> {
|
|
parcellation.regions.iter().map(|r| r.centroid).collect()
|
|
}
|
|
}
|
|
|
|
/// Compute a circular 2D layout for a given number of nodes.
|
|
///
|
|
/// Nodes are placed evenly around a unit circle.
|
|
pub fn circular_layout(num_nodes: usize) -> Vec<[f64; 2]> {
|
|
if num_nodes == 0 {
|
|
return Vec::new();
|
|
}
|
|
(0..num_nodes)
|
|
.map(|i| {
|
|
let angle = 2.0 * std::f64::consts::PI * (i as f64) / (num_nodes as f64);
|
|
[angle.cos(), angle.sin()]
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use ruv_neural_core::brain::Atlas;
|
|
use ruv_neural_core::graph::{BrainEdge, BrainGraph};
|
|
use ruv_neural_core::signal::FrequencyBand;
|
|
|
|
fn make_test_graph(num_nodes: usize) -> BrainGraph {
|
|
let mut edges = Vec::new();
|
|
for i in 0..num_nodes {
|
|
for j in (i + 1)..num_nodes {
|
|
if (i + j) % 3 == 0 {
|
|
edges.push(BrainEdge {
|
|
source: i,
|
|
target: j,
|
|
weight: 0.5,
|
|
metric: ruv_neural_core::graph::ConnectivityMetric::Coherence,
|
|
frequency_band: FrequencyBand::Alpha,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
BrainGraph {
|
|
num_nodes,
|
|
edges,
|
|
timestamp: 0.0,
|
|
window_duration_s: 1.0,
|
|
atlas: Atlas::Custom(num_nodes),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn force_directed_positions_within_bounds() {
|
|
let graph = make_test_graph(8);
|
|
let layout = ForceDirectedLayout::new();
|
|
let positions = layout.compute(&graph);
|
|
|
|
assert_eq!(positions.len(), 8);
|
|
for pos in &positions {
|
|
for &coord in pos {
|
|
assert!(coord.is_finite(), "position coordinate must be finite");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn force_directed_empty_graph() {
|
|
let graph = BrainGraph {
|
|
num_nodes: 0,
|
|
edges: Vec::new(),
|
|
timestamp: 0.0,
|
|
window_duration_s: 1.0,
|
|
atlas: Atlas::Custom(0),
|
|
};
|
|
let layout = ForceDirectedLayout::new();
|
|
let positions = layout.compute(&graph);
|
|
assert!(positions.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn circular_layout_correct_count() {
|
|
let positions = circular_layout(10);
|
|
assert_eq!(positions.len(), 10);
|
|
}
|
|
|
|
#[test]
|
|
fn circular_layout_on_unit_circle() {
|
|
let positions = circular_layout(4);
|
|
for pos in &positions {
|
|
let r = (pos[0] * pos[0] + pos[1] * pos[1]).sqrt();
|
|
assert!((r - 1.0).abs() < 1e-10, "point should be on unit circle");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn circular_layout_empty() {
|
|
let positions = circular_layout(0);
|
|
assert!(positions.is_empty());
|
|
}
|
|
}
|