use alloc::{vec, vec::Vec};
#[cfg(not(feature = "std"))]
#[allow(unused_imports)]
use num_traits::Float;
use num_traits::{AsPrimitive, ToPrimitive};
use rand::{RngExt, SeedableRng, rngs::SmallRng};
use super::{
ForceAtlas2Config, ForceAtlas2Error,
forces::{
apply_attraction, apply_attraction_anti_collision, apply_gravity, apply_lin_repulsion,
apply_lin_repulsion_anti_collision,
},
quadtree::QuadTree,
speed::{SpeedState, accumulate_totals, per_node_factor, update_global_speed},
};
use crate::traits::{Finite, SparseValuedMatrix2D, algorithms::modularity::approx_eq};
#[derive(Debug, Clone)]
pub(super) struct LayoutState {
pub(super) edges: Vec<(usize, usize, f64)>,
pub(super) masses: Vec<f64>,
pub(super) positions: Vec<[f64; 2]>,
pub(super) sizes: Option<Vec<f64>>,
}
impl LayoutState {
pub(super) fn from_matrix<M>(
matrix: &M,
config: &ForceAtlas2Config,
) -> Result<Self, ForceAtlas2Error>
where
M: SparseValuedMatrix2D,
M::RowIndex: AsPrimitive<usize>,
M::ColumnIndex: AsPrimitive<usize>,
M::Value: ToPrimitive + Finite,
{
let rows = matrix.number_of_rows().as_();
let columns = matrix.number_of_columns().as_();
if rows != columns {
return Err(ForceAtlas2Error::NonSquareMatrix { rows, columns });
}
if rows == 0 {
return Err(ForceAtlas2Error::EmptyGraph);
}
let mut adjacency: Vec<Vec<(usize, f64)>> = vec![Vec::new(); rows];
for row_id in matrix.row_indices() {
let source = row_id.as_();
for (column_id, weight) in
matrix.sparse_row(row_id).zip(matrix.sparse_row_values(row_id))
{
let destination = column_id.as_();
if !weight.is_finite() {
return Err(ForceAtlas2Error::NonFiniteWeight {
source_id: source,
destination_id: destination,
});
}
let weight = weight.to_f64().ok_or(ForceAtlas2Error::UnrepresentableWeight {
source_id: source,
destination_id: destination,
})?;
if !weight.is_finite() {
return Err(ForceAtlas2Error::NonFiniteWeight {
source_id: source,
destination_id: destination,
});
}
if weight < 0.0 {
return Err(ForceAtlas2Error::NegativeWeight {
source_id: source,
destination_id: destination,
});
}
if source != destination {
adjacency[source].push((destination, weight));
}
}
}
for row in &mut adjacency {
row.sort_unstable_by_key(|(destination, _)| *destination);
}
for (source, neighbors) in adjacency.iter().enumerate() {
for (destination, weight) in neighbors {
if !has_matching_edge(&adjacency[*destination], source, *weight) {
return Err(ForceAtlas2Error::NonSymmetricEdge {
source_id: source,
destination_id: *destination,
});
}
}
}
#[allow(clippy::cast_precision_loss)]
let masses: Vec<f64> = adjacency.iter().map(|row| (row.len() + 1) as f64).collect();
let mut edges = Vec::new();
for (source, neighbors) in adjacency.iter().enumerate() {
for (destination, weight) in neighbors {
if source < *destination {
edges.push((source, *destination, *weight));
}
}
}
let positions = initial_positions(rows, config)?;
let sizes = validated_sizes(rows, config)?;
Ok(Self { edges, masses, positions, sizes })
}
pub(super) fn run_with_progress(
&mut self,
config: &ForceAtlas2Config,
on_progress: &mut dyn FnMut(usize, usize),
) -> (f64, f64) {
let n = self.masses.len();
let mut forces = vec![[0.0_f64; 2]; n];
let mut old_forces = vec![[0.0_f64; 2]; n];
let mut speed_state = SpeedState::default();
let mut totals = (0.0, 0.0);
let effective_weights = effective_weights(&self.edges, config.edge_weight_influence);
#[allow(clippy::cast_precision_loss)]
let attraction_coefficient =
if config.dissuade_hubs { self.masses.iter().sum::<f64>() / n as f64 } else { 1.0 };
for iteration in 0..config.iterations {
core::mem::swap(&mut old_forces, &mut forces);
forces.fill([0.0; 2]);
self.accumulate_repulsion(config, &mut forces);
for ((pos, mass), force) in self.positions.iter().zip(&self.masses).zip(&mut forces) {
apply_gravity(*pos, *mass, config.gravity, config.strong_gravity, force);
}
self.accumulate_attraction(
config,
&effective_weights,
attraction_coefficient,
&mut forces,
);
totals = accumulate_totals(&self.masses, &old_forces, &forces);
update_global_speed(&mut speed_state, totals.0, totals.1, n, config.jitter_tolerance);
self.update_positions(speed_state.speed, &old_forces, &forces);
on_progress(iteration + 1, config.iterations);
}
totals
}
fn accumulate_repulsion(&self, config: &ForceAtlas2Config, forces: &mut [[f64; 2]]) {
let n = self.masses.len();
if config.barnes_hut {
let tree = QuadTree::build(&self.positions, &self.masses);
for ((node, pos), (mass, force)) in
self.positions.iter().enumerate().zip(self.masses.iter().zip(forces))
{
tree.apply_repulsion(
node,
*pos,
*mass,
config.scaling_ratio,
config.barnes_hut_theta,
self.sizes.as_deref(),
force,
);
}
} else if let Some(sizes) = &self.sizes {
for i in 1..n {
let (left, right) = forces.split_at_mut(i);
let force_i = &mut right[0];
for j in 0..i {
apply_lin_repulsion_anti_collision(
self.positions[i],
self.positions[j],
self.masses[i],
self.masses[j],
sizes[i],
sizes[j],
config.scaling_ratio,
force_i,
&mut left[j],
);
}
}
} else {
for i in 1..n {
let (left, right) = forces.split_at_mut(i);
let force_i = &mut right[0];
for ((pos_j, mass_j), force_j) in
self.positions[..i].iter().zip(&self.masses[..i]).zip(left.iter_mut())
{
apply_lin_repulsion(
self.positions[i],
*pos_j,
self.masses[i],
*mass_j,
config.scaling_ratio,
force_i,
force_j,
);
}
}
}
}
fn accumulate_attraction(
&self,
config: &ForceAtlas2Config,
effective_weights: &[f64],
attraction_coefficient: f64,
forces: &mut [[f64; 2]],
) {
for (&(source, target, _), &e) in self.edges.iter().zip(effective_weights) {
let (left, right) = forces.split_at_mut(target);
if let Some(sizes) = &self.sizes {
apply_attraction_anti_collision(
config.lin_log,
config.dissuade_hubs,
self.positions[source],
self.positions[target],
e,
attraction_coefficient,
self.masses[source],
sizes[source],
sizes[target],
&mut left[source],
&mut right[0],
);
} else {
apply_attraction(
config.lin_log,
config.dissuade_hubs,
self.positions[source],
self.positions[target],
e,
attraction_coefficient,
self.masses[source],
&mut left[source],
&mut right[0],
);
}
}
}
fn update_positions(&mut self, speed: f64, old_forces: &[[f64; 2]], forces: &[[f64; 2]]) {
let anti_collision = self.sizes.is_some();
for ((position, mass), (old_force, force)) in
self.positions.iter_mut().zip(&self.masses).zip(old_forces.iter().zip(forces))
{
let diff_x = old_force[0] - force[0];
let diff_y = old_force[1] - force[1];
let local_swinging = (diff_x * diff_x + diff_y * diff_y).sqrt();
let mut factor = per_node_factor(speed, *mass, local_swinging);
if anti_collision {
factor *= 0.1;
let df = (force[0] * force[0] + force[1] * force[1]).sqrt();
if df <= 0.0 {
continue;
}
factor = (factor * df).min(10.0) / df;
}
let new_x = position[0] + force[0] * factor;
let new_y = position[1] + force[1] * factor;
if new_x.is_finite() && new_y.is_finite() {
*position = [new_x, new_y];
}
}
}
}
#[allow(clippy::float_cmp)]
fn effective_weights(edges: &[(usize, usize, f64)], delta: f64) -> Vec<f64> {
if delta == 0.0 {
vec![1.0; edges.len()]
} else if delta == 1.0 {
edges.iter().map(|&(_, _, weight)| weight).collect()
} else {
edges.iter().map(|&(_, _, weight)| weight.powf(delta)).collect()
}
}
fn validated_sizes(
n: usize,
config: &ForceAtlas2Config,
) -> Result<Option<Vec<f64>>, ForceAtlas2Error> {
let Some(provided) = &config.node_sizes else {
return Ok(None);
};
if provided.len() != n {
return Err(ForceAtlas2Error::NodeSizesLengthMismatch {
expected: n,
actual: provided.len(),
});
}
for (index, size) in provided.iter().enumerate() {
if !size.is_finite() || *size < 0.0 {
return Err(ForceAtlas2Error::InvalidNodeSize { index });
}
}
Ok(Some(provided.clone()))
}
fn initial_positions(
n: usize,
config: &ForceAtlas2Config,
) -> Result<Vec<[f64; 2]>, ForceAtlas2Error> {
if let Some(provided) = &config.initial_positions {
if provided.len() != n {
return Err(ForceAtlas2Error::InitialPositionsLengthMismatch {
expected: n,
actual: provided.len(),
});
}
for (index, position) in provided.iter().enumerate() {
if !position[0].is_finite() || !position[1].is_finite() {
return Err(ForceAtlas2Error::NonFiniteInitialPosition { index });
}
}
return Ok(provided.clone());
}
let mut rng = SmallRng::seed_from_u64(config.seed);
Ok((0..n).map(|_| [rng.random_range(-0.5..0.5), rng.random_range(-0.5..0.5)]).collect())
}
fn has_matching_edge(row: &[(usize, f64)], destination: usize, weight: f64) -> bool {
row.binary_search_by_key(&destination, |(col, _)| *col)
.is_ok_and(|idx| approx_eq(weight, row[idx].1))
}