use super::{force::*, LayoutAlgorithm, LayoutConfig};
use crate::debug::{log_distribution, DebugFlags};
use crate::graph::VowlGraph;
use crate::Result;
use nalgebra::Vector2;
use std::collections::HashMap;
pub struct ForceSimulation {
config: LayoutConfig,
alpha: f64,
iteration: usize,
debug_flags: DebugFlags,
}
impl ForceSimulation {
pub fn new() -> Self {
Self {
config: LayoutConfig::default(),
alpha: 1.0,
iteration: 0,
debug_flags: DebugFlags::default(),
}
}
pub fn with_config(config: LayoutConfig) -> Self {
let alpha = config.alpha;
Self {
config,
alpha,
iteration: 0,
debug_flags: DebugFlags::default(),
}
}
pub fn enable_debug(&mut self) {
self.debug_flags.enable_all();
}
pub fn set_debug_flags(&mut self, flags: DebugFlags) {
self.debug_flags = flags;
}
pub fn set_center(&mut self, x: f64, y: f64) {
self.config.center = (x, y);
}
pub fn set_link_distance(&mut self, distance: f64) {
self.config.link_distance = distance;
}
pub fn set_charge_strength(&mut self, strength: f64) {
self.config.charge_strength = strength;
}
fn initialize_positions(&self, graph: &mut VowlGraph) {
let radius = self.config.seed_radius;
let mut angle: f64 = 0.0;
let angle_step = std::f64::consts::TAU / graph.node_count() as f64;
#[cfg(target_arch = "wasm32")]
{
web_sys::console::log_1(
&format!(
"🔄 Initializing {} nodes in circular layout with radius {}",
graph.node_count(),
radius
)
.into(),
);
}
let node_ids: Vec<String> = graph.nodes().iter().map(|n| n.id.clone()).collect();
for node_id in node_ids {
if let Some(node_mut) = graph.get_node_mut(&node_id) {
let already_placed = node_mut.visual.x != 0.0 || node_mut.visual.y != 0.0;
if !(self.config.preserve_seeded_positions && already_placed) {
node_mut.visual.x = radius * angle.cos();
node_mut.visual.y = radius * angle.sin();
}
node_mut.visual.vx = 0.0;
node_mut.visual.vy = 0.0;
angle += angle_step;
}
}
if self.debug_flags.log_positions {
let positions: Vec<(f64, f64)> = graph
.nodes()
.iter()
.map(|n| (n.visual.x, n.visual.y))
.collect();
log_distribution("Initial Positions", &positions);
}
}
fn calculate_forces(&self, graph: &VowlGraph) -> HashMap<String, Vector2<f64>> {
let mut forces: HashMap<String, Vector2<f64>> = HashMap::new();
let nodes = graph.nodes();
let n = nodes.len();
for node in &nodes {
forces.insert(node.id.clone(), Vector2::zeros());
}
#[cfg(all(target_arch = "wasm32", feature = "simd"))]
{
if self.iteration == 0 {
web_sys::console::log_1(&"[SIMD] WASM SIMD128 force calculations enabled".into());
}
}
if self.config.use_barnes_hut && n > 50 {
use super::quadtree::QuadTree;
let quadtree = QuadTree::build(&nodes);
for node in &nodes {
let pos = Vector2::new(node.visual.x, node.visual.y);
let force = quadtree.calculate_force(
pos,
&node.id,
self.config.barnes_hut_theta,
self.config.charge_strength,
);
*forces.get_mut(&node.id).unwrap() += force;
}
} else {
let positions: Vec<(f64, f64)> =
nodes.iter().map(|n| (n.visual.x, n.visual.y)).collect();
let repulsion_forces =
calculate_batch_repulsion(&positions, self.config.charge_strength);
for (i, node) in nodes.iter().enumerate() {
*forces.get_mut(&node.id).unwrap() += repulsion_forces[i];
}
}
#[allow(unused_assignments, unused_variables)]
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_assignments))]
let mut attraction_count = 0;
for node in &nodes {
if let Ok(neighbors) = graph.neighbors(&node.id) {
for neighbor in neighbors {
let pos1 = Vector2::new(node.visual.x, node.visual.y);
let pos2 = Vector2::new(neighbor.visual.x, neighbor.visual.y);
let force = calculate_attraction(
pos1,
pos2,
self.config.link_distance,
self.config.link_strength,
);
*forces.get_mut(&node.id).unwrap() += force * self.alpha;
attraction_count += 1;
}
}
}
#[cfg(not(target_arch = "wasm32"))]
let _ = attraction_count;
#[cfg(target_arch = "wasm32")]
{
if self.iteration == 0 {
web_sys::console::log_1(
&format!(
"[Force] Applied {} attraction forces between {} nodes ({} edges in graph)",
attraction_count,
nodes.len(),
graph.edge_count()
)
.into(),
);
}
}
let positions: Vec<(f64, f64)> = nodes.iter().map(|n| (n.visual.x, n.visual.y)).collect();
let center_forces = calculate_batch_center_force(
&positions,
self.config.center,
self.config.center_strength,
);
for (i, node) in nodes.iter().enumerate() {
*forces.get_mut(&node.id).unwrap() += center_forces[i];
}
forces
}
fn apply_forces(&self, graph: &mut VowlGraph, forces: &HashMap<String, Vector2<f64>>) {
for (node_id, force) in forces {
if let Some(node) = graph.get_node_mut(node_id) {
if !node.visual.fixed {
node.visual.vx += force.x * self.alpha;
node.visual.vy += force.y * self.alpha;
node.visual.vx *= self.config.velocity_decay;
node.visual.vy *= self.config.velocity_decay;
node.visual.x += node.visual.vx;
node.visual.y += node.visual.vy;
}
}
}
}
}
impl LayoutAlgorithm for ForceSimulation {
fn initialize(&mut self, graph: &mut VowlGraph) -> Result<()> {
self.initialize_positions(graph);
self.alpha = self.config.alpha;
self.iteration = 0;
Ok(())
}
fn tick(&mut self, graph: &mut VowlGraph) -> Result<()> {
if self.is_finished() {
return Ok(());
}
if self.debug_flags.should_log(self.iteration) && self.debug_flags.log_positions {
let positions: Vec<(f64, f64)> = graph
.nodes()
.iter()
.map(|n| (n.visual.x, n.visual.y))
.collect();
log_distribution(&format!("Before Tick {}", self.iteration), &positions);
}
let forces = self.calculate_forces(graph);
self.apply_forces(graph, &forces);
self.alpha *= 1.0 - self.config.alpha_decay;
self.iteration += 1;
if self.debug_flags.should_log(self.iteration) {
#[cfg(target_arch = "wasm32")]
{
if self.debug_flags.log_positions {
let positions: Vec<(f64, f64)> = graph
.nodes()
.iter()
.map(|n| (n.visual.x, n.visual.y))
.collect();
log_distribution(&format!("After Tick {}", self.iteration), &positions);
}
web_sys::console::log_1(
&format!(
"⏱️ Iteration {}: alpha={:.4}, nodes={}",
self.iteration,
self.alpha,
graph.node_count()
)
.into(),
);
}
}
Ok(())
}
fn run(&mut self, graph: &mut VowlGraph, iterations: usize) -> Result<()> {
self.initialize(graph)?;
for _ in 0..iterations {
if self.is_finished() {
break;
}
self.tick(graph)?;
}
Ok(())
}
fn is_finished(&self) -> bool {
self.alpha < self.config.alpha_min
}
fn alpha(&self) -> f64 {
self.alpha
}
}
impl Default for ForceSimulation {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::{node::NodeBuilder, NodeType, VowlGraph};
fn create_test_graph() -> VowlGraph {
let mut graph = VowlGraph::new();
let node1 = NodeBuilder::new("node1")
.label("Node 1")
.node_type(NodeType::Class)
.build();
let node2 = NodeBuilder::new("node2")
.label("Node 2")
.node_type(NodeType::Class)
.build();
let node3 = NodeBuilder::new("node3")
.label("Node 3")
.node_type(NodeType::Class)
.build();
graph.add_node(node1).unwrap();
graph.add_node(node2).unwrap();
graph.add_node(node3).unwrap();
graph
}
#[test]
fn test_simulation_creation() {
let sim = ForceSimulation::new();
assert_eq!(sim.alpha, 1.0);
assert_eq!(sim.iteration, 0);
}
#[test]
fn test_simulation_with_config() {
let config = LayoutConfig {
alpha: 0.5,
link_distance: 50.0,
..Default::default()
};
let sim = ForceSimulation::with_config(config);
assert_eq!(sim.alpha, 0.5);
assert_eq!(sim.config.link_distance, 50.0);
}
#[test]
fn test_initialize_positions() {
let mut graph = create_test_graph();
let mut sim = ForceSimulation::new();
sim.initialize(&mut graph).unwrap();
for node in graph.nodes() {
assert!(
node.visual.x != 0.0 || node.visual.y != 0.0,
"Node should have initialized position"
);
}
}
#[test]
fn test_single_tick() {
let mut graph = create_test_graph();
let mut sim = ForceSimulation::new();
sim.initialize(&mut graph).unwrap();
let initial_alpha = sim.alpha();
sim.tick(&mut graph).unwrap();
assert!(sim.alpha() < initial_alpha);
}
#[test]
fn test_run_simulation() {
let mut graph = create_test_graph();
let mut sim = ForceSimulation::new();
let result = sim.run(&mut graph, 100);
assert!(result.is_ok());
assert!(sim.alpha() < 0.5);
}
#[test]
fn test_simulation_finishes() {
let mut graph = create_test_graph();
let mut sim = ForceSimulation::new();
sim.run(&mut graph, 1000).unwrap();
assert!(sim.is_finished());
}
#[test]
fn test_set_center() {
let mut sim = ForceSimulation::new();
sim.set_center(100.0, 200.0);
assert_eq!(sim.config.center, (100.0, 200.0));
}
#[test]
fn test_set_parameters() {
let mut sim = ForceSimulation::new();
sim.set_link_distance(50.0);
sim.set_charge_strength(-100.0);
assert_eq!(sim.config.link_distance, 50.0);
assert_eq!(sim.config.charge_strength, -100.0);
}
#[test]
fn initialize_reseeds_every_node_by_default() {
let mut graph = VowlGraph::new();
graph
.add_node(NodeBuilder::new("placed").position(7.0, 9.0).build())
.unwrap();
let mut sim = ForceSimulation::new();
sim.initialize(&mut graph).unwrap();
let node = graph.get_node("placed").unwrap();
assert_ne!((node.visual.x, node.visual.y), (7.0, 9.0));
let r = node.visual.x.hypot(node.visual.y);
assert!(
(r - LayoutConfig::default().seed_radius).abs() < 1e-6,
"seeded onto the circle: {r}"
);
}
#[test]
fn initialize_keeps_placed_nodes_when_preserving() {
let mut graph = VowlGraph::new();
graph
.add_node(NodeBuilder::new("placed").position(7.0, 9.0).build())
.unwrap();
graph
.add_node(NodeBuilder::new("at_origin").position(0.0, 0.0).build())
.unwrap();
let config = LayoutConfig {
preserve_seeded_positions: true,
..LayoutConfig::default()
};
let mut sim = ForceSimulation::with_config(config);
sim.initialize(&mut graph).unwrap();
let placed = graph.get_node("placed").unwrap();
assert_eq!((placed.visual.x, placed.visual.y), (7.0, 9.0));
let seeded = graph.get_node("at_origin").unwrap();
assert_ne!((seeded.visual.x, seeded.visual.y), (0.0, 0.0));
assert_eq!((placed.visual.vx, placed.visual.vy), (0.0, 0.0));
}
}