mod forces;
mod quadtree;
mod speed;
mod state;
use alloc::vec::Vec;
use num_traits::{AsPrimitive, ToPrimitive};
use state::LayoutState;
use crate::traits::{Finite, SparseValuedMatrix2D};
#[derive(Debug, Clone, PartialEq)]
pub struct ForceAtlas2Config {
pub iterations: usize,
pub scaling_ratio: f64,
pub gravity: f64,
pub strong_gravity: bool,
pub jitter_tolerance: f64,
pub edge_weight_influence: f64,
pub lin_log: bool,
pub dissuade_hubs: bool,
pub barnes_hut: bool,
pub barnes_hut_theta: f64,
pub node_sizes: Option<Vec<f64>>,
pub seed: u64,
pub initial_positions: Option<Vec<[f64; 2]>>,
}
impl Default for ForceAtlas2Config {
#[inline]
fn default() -> Self {
Self {
iterations: 100,
scaling_ratio: 2.0,
gravity: 1.0,
strong_gravity: false,
jitter_tolerance: 1.0,
edge_weight_influence: 1.0,
lin_log: false,
dissuade_hubs: false,
barnes_hut: false,
barnes_hut_theta: 1.2,
node_sizes: None,
seed: 42,
initial_positions: None,
}
}
}
fn validate_config(config: &ForceAtlas2Config) -> Result<(), ForceAtlas2Error> {
if !config.scaling_ratio.is_finite() || config.scaling_ratio <= 0.0 {
return Err(ForceAtlas2Error::InvalidScalingRatio);
}
if !config.gravity.is_finite() || config.gravity < 0.0 {
return Err(ForceAtlas2Error::InvalidGravity);
}
if !config.jitter_tolerance.is_finite() || config.jitter_tolerance <= 0.0 {
return Err(ForceAtlas2Error::InvalidJitterTolerance);
}
if !config.edge_weight_influence.is_finite() || config.edge_weight_influence < 0.0 {
return Err(ForceAtlas2Error::InvalidEdgeWeightInfluence);
}
if !config.barnes_hut_theta.is_finite() || config.barnes_hut_theta <= 0.0 {
return Err(ForceAtlas2Error::InvalidBarnesHutTheta);
}
Ok(())
}
#[derive(Debug, Clone, PartialEq)]
pub struct ForceAtlas2Result {
coordinates: Vec<f64>,
n: usize,
dims: usize,
final_swinging: f64,
final_traction: f64,
iterations_run: usize,
}
impl ForceAtlas2Result {
#[must_use]
#[inline]
pub fn point(&self, i: usize) -> &[f64] {
let start = i * self.dims;
&self.coordinates[start..start + self.dims]
}
#[must_use]
#[inline]
pub fn coordinates_flat(&self) -> &[f64] {
&self.coordinates
}
#[must_use]
#[inline]
pub fn dimensions(&self) -> usize {
self.dims
}
#[must_use]
#[inline]
pub fn num_points(&self) -> usize {
self.n
}
#[must_use]
#[inline]
pub fn final_swinging(&self) -> f64 {
self.final_swinging
}
#[must_use]
#[inline]
pub fn final_traction(&self) -> f64 {
self.final_traction
}
#[must_use]
#[inline]
pub fn iterations_run(&self) -> usize {
self.iterations_run
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ForceAtlas2Error {
#[error("The adjacency matrix must be square, but has {rows} rows and {columns} columns.")]
NonSquareMatrix {
rows: usize,
columns: usize,
},
#[error("The graph has no nodes.")]
EmptyGraph,
#[error(
"The matrix is not symmetric: edge ({source_id}, {destination_id}) has no matching reverse edge."
)]
NonSymmetricEdge {
source_id: usize,
destination_id: usize,
},
#[error(
"Found an edge weight on ({source_id}, {destination_id}) that cannot be represented as f64."
)]
UnrepresentableWeight {
source_id: usize,
destination_id: usize,
},
#[error("Found a non-finite edge weight on ({source_id}, {destination_id}).")]
NonFiniteWeight {
source_id: usize,
destination_id: usize,
},
#[error("Found a negative edge weight on ({source_id}, {destination_id}).")]
NegativeWeight {
source_id: usize,
destination_id: usize,
},
#[error("The scaling ratio must be finite and strictly positive.")]
InvalidScalingRatio,
#[error("The gravity constant must be finite and non-negative.")]
InvalidGravity,
#[error("The jitter tolerance must be finite and strictly positive.")]
InvalidJitterTolerance,
#[error("The edge weight influence must be finite and non-negative.")]
InvalidEdgeWeightInfluence,
#[error("The Barnes-Hut theta must be finite and strictly positive.")]
InvalidBarnesHutTheta,
#[error("Expected {expected} initial positions, but {actual} were provided.")]
InitialPositionsLengthMismatch {
expected: usize,
actual: usize,
},
#[error("The initial position of node {index} is not finite.")]
NonFiniteInitialPosition {
index: usize,
},
#[error("Expected {expected} node sizes, but {actual} were provided.")]
NodeSizesLengthMismatch {
expected: usize,
actual: usize,
},
#[error("The size of node {index} must be finite and non-negative.")]
InvalidNodeSize {
index: usize,
},
}
pub trait ForceAtlas2: SparseValuedMatrix2D + Sized
where
Self::RowIndex: AsPrimitive<usize>,
Self::ColumnIndex: AsPrimitive<usize>,
Self::Value: ToPrimitive + Finite,
{
fn force_atlas2(
&self,
config: &ForceAtlas2Config,
) -> Result<ForceAtlas2Result, ForceAtlas2Error> {
self.force_atlas2_with_progress(config, &mut |_, _| {})
}
fn force_atlas2_with_progress(
&self,
config: &ForceAtlas2Config,
on_progress: &mut dyn FnMut(usize, usize),
) -> Result<ForceAtlas2Result, ForceAtlas2Error> {
validate_config(config)?;
let mut state = LayoutState::from_matrix(self, config)?;
let n = state.masses.len();
let (final_swinging, final_traction) = state.run_with_progress(config, on_progress);
let mut coordinates = Vec::with_capacity(n * 2);
for position in &state.positions {
coordinates.extend_from_slice(position);
}
Ok(ForceAtlas2Result {
coordinates,
n,
dims: 2,
final_swinging,
final_traction,
iterations_run: config.iterations,
})
}
}
impl<M: SparseValuedMatrix2D> ForceAtlas2 for M
where
M::RowIndex: AsPrimitive<usize>,
M::ColumnIndex: AsPrimitive<usize>,
M::Value: ToPrimitive + Finite,
{
}
#[cfg(test)]
mod tests {
use alloc::vec::Vec;
use super::{ForceAtlas2Config, ForceAtlas2Error, state::LayoutState};
use crate::{impls::ValuedCSR2D, naive_structs::GenericEdgesBuilder, traits::EdgesBuilder};
type WeightedMatrix = ValuedCSR2D<usize, usize, usize, f64>;
fn build_matrix(node_count: usize, undirected_edges: &[(usize, usize, f64)]) -> WeightedMatrix {
let mut directed_edges = Vec::with_capacity(undirected_edges.len() * 2);
for &(source, destination, weight) in undirected_edges {
directed_edges.push((source, destination, weight));
if source != destination {
directed_edges.push((destination, source, weight));
}
}
directed_edges.sort_unstable_by(|(s1, d1, _), (s2, d2, _)| (s1, d1).cmp(&(s2, d2)));
GenericEdgesBuilder::<_, WeightedMatrix>::default()
.expected_number_of_edges(directed_edges.len())
.expected_shape((node_count, node_count))
.edges(directed_edges.into_iter())
.build()
.unwrap()
}
#[test]
fn masses_are_degree_plus_one() {
let matrix = build_matrix(4, &[(0, 1, 1.0), (1, 2, 1.0)]);
let config = ForceAtlas2Config::default();
let state = LayoutState::from_matrix(&matrix, &config).unwrap();
assert_eq!(state.masses, vec![2.0, 3.0, 2.0, 1.0]);
}
#[test]
fn self_loops_are_ignored() {
let matrix = build_matrix(2, &[(0, 0, 5.0), (0, 1, 1.0)]);
let config = ForceAtlas2Config::default();
let state = LayoutState::from_matrix(&matrix, &config).unwrap();
assert_eq!(state.masses, vec![2.0, 2.0]);
assert_eq!(state.edges, vec![(0, 1, 1.0)]);
}
#[test]
fn edges_are_upper_triangular() {
let matrix = build_matrix(3, &[(0, 1, 1.0), (0, 2, 2.0), (1, 2, 3.0)]);
let config = ForceAtlas2Config::default();
let state = LayoutState::from_matrix(&matrix, &config).unwrap();
assert_eq!(state.edges, vec![(0, 1, 1.0), (0, 2, 2.0), (1, 2, 3.0)]);
}
#[test]
fn zero_weight_edges_are_kept() {
let matrix = build_matrix(2, &[(0, 1, 0.0)]);
let config = ForceAtlas2Config::default();
let state = LayoutState::from_matrix(&matrix, &config).unwrap();
assert_eq!(state.masses, vec![2.0, 2.0]);
assert_eq!(state.edges, vec![(0, 1, 0.0)]);
}
#[test]
fn asymmetric_matrix_is_rejected() {
let directed_edges = vec![(0, 1, 1.0)];
let matrix: WeightedMatrix = GenericEdgesBuilder::<_, WeightedMatrix>::default()
.expected_number_of_edges(directed_edges.len())
.expected_shape((2, 2))
.edges(directed_edges.into_iter())
.build()
.unwrap();
let config = ForceAtlas2Config::default();
let error = LayoutState::from_matrix(&matrix, &config).unwrap_err();
assert_eq!(error, ForceAtlas2Error::NonSymmetricEdge { source_id: 0, destination_id: 1 });
}
}