/// Force-directed layout algorithm for graph visualization. /// /// Uses physics simulation (repulsive + attractive forces) to compute /// node positions in 2D space suitable for React Flow visualization. use serde::{Deserialize, Serialize}; use super::bfs_graph_traversal::{GraphData, TraversalNode, TraversalEdge}; /// 2D position (X, Y coordinates) #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] pub struct Position { pub x: f32, pub y: f32, } /// Force simulation parameters #[derive(Debug, Clone)] pub struct LayoutConfig { pub iterations: usize, // Number of solver iterations (10-100) pub charge: f32, // Repulsive force strength (-500 to -1000) pub link_distance: f32, // Ideal edge length (50-150) pub alpha_decay: f32, // Cooling rate (0.02-0.10) pub width: f32, // Canvas width (default 800) pub height: f32, // Canvas height (default 600) } impl Default for LayoutConfig { fn default() -> Self { Self { iterations: 50, charge: -800.0, link_distance: 100.0, alpha_decay: 0.05, width: 800.0, height: 600.0, } } } /// Layout result with computed positions #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LayoutResult { pub positions: std::collections::HashMap, pub iterations_completed: usize, pub layout_time_ms: u64, } /// Velocity for each node in simulation #[derive(Debug, Clone, Copy)] struct Velocity { vx: f32, vy: f32, } /// Force-directed layout engine pub struct ForceDirectedLayout; impl ForceDirectedLayout { /// Compute layout for graph pub fn layout(graph: &GraphData, config: &LayoutConfig) -> LayoutResult { let start_time = std::time::Instant::now(); // Initialize positions randomly in canvas let mut positions = Self::initialize_positions(&graph.nodes, config); let mut velocities: std::collections::HashMap = graph.nodes .iter() .map(|n| (n.id.clone(), Velocity { vx: 0.0, vy: 0.0 })) .collect(); // Simulation parameters let mut alpha = 1.0; let alpha_target = 0.001; // Iterate until convergence for iteration in 0..config.iterations { // Apply forces for node in &graph.nodes { let mut fx = 0.0; let mut fy = 0.0; let pos = positions.get(&node.id).unwrap(); // 1. Repulsive forces (all pairs) for other_node in &graph.nodes { if node.id == other_node.id { continue; } let other_pos = positions.get(&other_node.id).unwrap(); let (dfx, dfy) = Self::repulsive_force( *pos, *other_pos, config.charge, ); fx += dfx; fy += dfy; } // 2. Attractive forces (linked nodes) for edge in &graph.edges { if edge.source_id == node.id { let target_pos = positions.get(&edge.target_id).unwrap(); let (dfx, dfy) = Self::attractive_force( *pos, *target_pos, config.link_distance, ); fx += dfx; fy += dfy; } } // Update velocity (with damping) let vel = velocities.get_mut(&node.id).unwrap(); vel.vx += fx * alpha; vel.vy += fy * alpha; vel.vx *= 0.95; // Damping vel.vy *= 0.95; } // Update positions for node in &graph.nodes { let vel = velocities.get(&node.id).unwrap(); let pos = positions.get_mut(&node.id).unwrap(); pos.x += vel.vx; pos.y += vel.vy; // Boundary constraints pos.x = pos.x.max(0.0).min(config.width); pos.y = pos.y.max(0.0).min(config.height); } // Cool down (reduce step size) alpha *= (alpha_target / alpha).powf(config.alpha_decay); // Early exit if converged if alpha < alpha_target { return LayoutResult { positions, iterations_completed: iteration + 1, layout_time_ms: start_time.elapsed().as_millis() as u64, }; } } LayoutResult { positions, iterations_completed: config.iterations, layout_time_ms: start_time.elapsed().as_millis() as u64, } } /// Initialize random positions fn initialize_positions( nodes: &[TraversalNode], config: &LayoutConfig, ) -> std::collections::HashMap { use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; let mut positions = std::collections::HashMap::new(); for node in nodes { // Pseudo-random based on node ID (deterministic) let mut hasher = DefaultHasher::new(); node.id.hash(&mut hasher); let hash = hasher.finish(); let x = (hash as f32 % config.width).abs(); let y = ((hash >> 32) as f32 % config.height).abs(); positions.insert(node.id.clone(), Position { x, y }); } positions } /// Coulomb repulsion force fn repulsive_force(p1: Position, p2: Position, charge: f32) -> (f32, f32) { let dx = p2.x - p1.x; let dy = p2.y - p1.y; let dist_sq = dx * dx + dy * dy + 1.0; // Add 1 to avoid singularity let dist = dist_sq.sqrt(); let force = charge / dist_sq; let fx = (force * dx / dist); let fy = (force * dy / dist); (-fx, -fy) // Negative = repulsive } /// Hooke's law attractive force fn attractive_force(p1: Position, p2: Position, link_distance: f32) -> (f32, f32) { let dx = p2.x - p1.x; let dy = p2.y - p1.y; let dist = (dx * dx + dy * dy).sqrt().max(0.1); let displacement = dist - link_distance; let force = 0.1 * displacement; // Spring constant let fx = (force * dx / dist); let fy = (force * dy / dist); (fx, fy) // Positive = attractive } } #[cfg(test)] mod tests { use super::*; #[test] fn test_layout_config_defaults() { let config = LayoutConfig::default(); assert_eq!(config.iterations, 50); assert_eq!(config.width, 800.0); assert_eq!(config.height, 600.0); } #[test] fn test_position_creation() { let pos = Position { x: 100.0, y: 200.0 }; assert_eq!(pos.x, 100.0); assert_eq!(pos.y, 200.0); } #[test] fn test_repulsive_force() { let p1 = Position { x: 0.0, y: 0.0 }; let p2 = Position { x: 10.0, y: 0.0 }; let (fx, fy) = ForceDirectedLayout::repulsive_force(p1, p2, -800.0); // Should push p1 away from p2 (negative x) assert!(fx < 0.0); assert_eq!(fy, 0.0); // No y component } #[test] fn test_attractive_force() { let p1 = Position { x: 0.0, y: 0.0 }; let p2 = Position { x: 100.0, y: 0.0 }; let (fx, fy) = ForceDirectedLayout::attractive_force(p1, p2, 50.0); // Distance is 100, ideal is 50, so pull p1 towards p2 (positive x) assert!(fx > 0.0); assert_eq!(fy, 0.0); } #[test] fn test_layout_result_creation() { let mut positions = std::collections::HashMap::new(); positions.insert("n1".to_string(), Position { x: 10.0, y: 20.0 }); let result = LayoutResult { positions, iterations_completed: 25, layout_time_ms: 150, }; assert_eq!(result.iterations_completed, 25); assert_eq!(result.layout_time_ms, 150); } }