Skip to main content

rust_igraph/algorithms/layout/
graphopt.rs

1//! GraphOpt force-directed layout (ALGO-LO-009).
2//!
3//! Port of the graphopt layout algorithm by Michael Schmuhl.
4//! Uses physical analogies: Coulomb repulsion between all vertex pairs
5//! and Hooke spring attraction along edges, iterated until equilibrium.
6//!
7//! Reference: <https://web.archive.org/web/20220611030748/http://www.schmuhl.org/graphopt/>
8
9use crate::core::{Graph, IgraphError, IgraphResult};
10
11/// Parameters for the GraphOpt layout.
12#[derive(Debug, Clone)]
13pub struct GraphoptParams {
14    /// Number of iterations. Default: 500.
15    pub niter: u32,
16    /// Charge of each vertex (Coulomb repulsion). Default: 0.001.
17    pub node_charge: f64,
18    /// Mass of each vertex (inertia). Default: 30.0.
19    pub node_mass: f64,
20    /// Rest length of springs (edges). Default: 0.0.
21    pub spring_length: f64,
22    /// Spring constant (Hooke's law). Default: 1.0.
23    pub spring_constant: f64,
24    /// Maximum displacement per axis per iteration. Default: 5.0.
25    pub max_sa_movement: f64,
26}
27
28impl Default for GraphoptParams {
29    fn default() -> Self {
30        Self {
31            niter: 500,
32            node_charge: 0.001,
33            node_mass: 30.0,
34            spring_length: 0.0,
35            spring_constant: 1.0,
36            max_sa_movement: 5.0,
37        }
38    }
39}
40
41/// Compute the GraphOpt force-directed layout.
42///
43/// Vertices repel each other via Coulomb force and are attracted along
44/// edges via Hooke spring force. The system is iterated for `niter`
45/// steps; each step accumulates forces, divides by mass, clamps
46/// displacement, and moves vertices.
47///
48/// Edge directions are ignored (treated as undirected).
49///
50/// # Arguments
51///
52/// * `graph` — input graph.
53/// * `seed` — optional initial positions. If `None`, random positions
54///   are generated.
55/// * `params` — algorithm parameters.
56///
57/// Returns `[x, y]` positions for each vertex.
58///
59/// # Errors
60///
61/// Returns `InvalidArgument` if `node_mass` is zero (division by zero)
62/// or if `seed` length doesn't match vertex count.
63///
64/// # Examples
65///
66/// ```
67/// use rust_igraph::{Graph, layout_graphopt, GraphoptParams};
68///
69/// let mut g = Graph::with_vertices(5);
70/// g.add_edge(0, 1).unwrap();
71/// g.add_edge(1, 2).unwrap();
72/// g.add_edge(2, 3).unwrap();
73/// g.add_edge(3, 4).unwrap();
74/// g.add_edge(4, 0).unwrap();
75///
76/// let params = GraphoptParams::default();
77/// let pos = layout_graphopt(&g, None, &params).unwrap();
78/// assert_eq!(pos.len(), 5);
79/// assert!(pos.iter().all(|p| p[0].is_finite() && p[1].is_finite()));
80/// ```
81pub fn layout_graphopt(
82    graph: &Graph,
83    seed: Option<&[[f64; 2]]>,
84    params: &GraphoptParams,
85) -> IgraphResult<Vec<[f64; 2]>> {
86    let n = graph.vcount() as usize;
87    if n == 0 {
88        return Ok(Vec::new());
89    }
90
91    if params.node_mass == 0.0 {
92        return Err(IgraphError::InvalidArgument(
93            "node_mass must be non-zero".into(),
94        ));
95    }
96    if let Some(s) = seed {
97        if s.len() != n {
98            return Err(IgraphError::InvalidArgument(format!(
99                "seed length ({}) must equal vertex count ({})",
100                s.len(),
101                n
102            )));
103        }
104    }
105
106    let no_edges = graph.ecount();
107    let apply_electric = params.node_charge != 0.0;
108
109    // Coulomb's constant
110    const COULOMBS_CONSTANT: f64 = 8_987_500_000.0;
111
112    // Build edge list
113    let mut edges: Vec<(usize, usize)> = Vec::with_capacity(no_edges);
114    for eid in 0..no_edges as u32 {
115        if let Ok((src, tgt)) = graph.edge(eid) {
116            edges.push((src as usize, tgt as usize));
117        }
118    }
119
120    // Initialize positions
121    let mut pos = if let Some(s) = seed {
122        s.to_vec()
123    } else {
124        let mut rng = SplitMix64::new(42);
125        (0..n)
126            .map(|_| [rng.next_uniform() - 0.5, rng.next_uniform() - 0.5])
127            .collect()
128    };
129
130    let mut forces_x = vec![0.0_f64; n];
131    let mut forces_y = vec![0.0_f64; n];
132
133    for _iter in 0..params.niter {
134        // Clear forces
135        for fx in forces_x.iter_mut() {
136            *fx = 0.0;
137        }
138        for fy in forces_y.iter_mut() {
139            *fy = 0.0;
140        }
141
142        // Electrical repulsion (all pairs)
143        if apply_electric {
144            for this in 0..n {
145                for other in (this + 1)..n {
146                    let dx = pos[this][0] - pos[other][0];
147                    let dy = pos[this][1] - pos[other][1];
148                    let distance = (dx * dx + dy * dy).sqrt();
149
150                    if distance == 0.0 || distance >= 500.0 {
151                        continue;
152                    }
153
154                    let directed_force = COULOMBS_CONSTANT
155                        * (params.node_charge * params.node_charge)
156                        / (distance * distance);
157
158                    let fx = directed_force * dx.abs() / distance;
159                    let fy = directed_force * dy.abs() / distance;
160
161                    // Force on this_node away from other_node
162                    let sign_x = if pos[other][0] < pos[this][0] {
163                        1.0
164                    } else {
165                        -1.0
166                    };
167                    let sign_y = if pos[other][1] < pos[this][1] {
168                        1.0
169                    } else {
170                        -1.0
171                    };
172
173                    forces_x[this] -= sign_x * fx;
174                    forces_y[this] -= sign_y * fy;
175                    forces_x[other] += sign_x * fx;
176                    forces_y[other] += sign_y * fy;
177                }
178            }
179        }
180
181        // Spring forces along edges
182        for &(src, tgt) in &edges {
183            let dx = pos[src][0] - pos[tgt][0];
184            let dy = pos[src][1] - pos[tgt][1];
185            let distance = (dx * dx + dy * dy).sqrt();
186
187            if distance == 0.0 {
188                continue;
189            }
190
191            let displacement = (distance - params.spring_length).abs();
192            let directed_force = -params.spring_constant * displacement;
193
194            // Determine spring axial forces
195            if distance == params.spring_length {
196                continue;
197            }
198
199            let fx_abs = directed_force.abs() * dx.abs() / distance;
200            let fy_abs = directed_force.abs() * dy.abs() / distance;
201
202            // Base direction: force on src away from tgt (like electric)
203            let sign_x = if pos[tgt][0] < pos[src][0] { 1.0 } else { -1.0 };
204            let sign_y = if pos[tgt][1] < pos[src][1] { 1.0 } else { -1.0 };
205
206            // If spring is too long, pull toward; if too short, push away
207            let spring_sign = if distance > params.spring_length {
208                1.0
209            } else {
210                -1.0
211            };
212
213            // Half force to each node (spring is shared)
214            let hfx = 0.5 * spring_sign * sign_x * fx_abs;
215            let hfy = 0.5 * spring_sign * sign_y * fy_abs;
216
217            // src is pulled toward tgt (negative of repulsion direction)
218            forces_x[src] += hfx;
219            forces_y[src] += hfy;
220            forces_x[tgt] -= hfx;
221            forces_y[tgt] -= hfy;
222        }
223
224        // Move nodes
225        for v in 0..n {
226            let mut x_move = forces_x[v] / params.node_mass;
227            let mut y_move = forces_y[v] / params.node_mass;
228
229            x_move = x_move.clamp(-params.max_sa_movement, params.max_sa_movement);
230            y_move = y_move.clamp(-params.max_sa_movement, params.max_sa_movement);
231
232            pos[v][0] += x_move;
233            pos[v][1] += y_move;
234        }
235    }
236
237    Ok(pos)
238}
239
240// ═══════════════════════════════════════════════════════════════════
241// Internal RNG
242// ═══════════════════════════════════════════════════════════════════
243
244struct SplitMix64 {
245    state: u64,
246}
247
248impl SplitMix64 {
249    fn new(seed: u64) -> Self {
250        Self { state: seed }
251    }
252
253    fn next_u64(&mut self) -> u64 {
254        self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
255        let mut z = self.state;
256        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
257        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
258        z ^ (z >> 31)
259    }
260
261    fn next_uniform(&mut self) -> f64 {
262        (self.next_u64() >> 11) as f64 / ((1u64 << 53) as f64)
263    }
264}
265
266// ═══════════════════════════════════════════════════════════════════
267// Tests
268// ═══════════════════════════════════════════════════════════════════
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn test_graphopt_empty() {
276        let g = Graph::with_vertices(0);
277        let params = GraphoptParams::default();
278        let pos = layout_graphopt(&g, None, &params).unwrap();
279        assert!(pos.is_empty());
280    }
281
282    #[test]
283    fn test_graphopt_single() {
284        let g = Graph::with_vertices(1);
285        let params = GraphoptParams::default();
286        let pos = layout_graphopt(&g, None, &params).unwrap();
287        assert_eq!(pos.len(), 1);
288        assert!(pos[0][0].is_finite());
289    }
290
291    #[test]
292    fn test_graphopt_triangle() {
293        let mut g = Graph::with_vertices(3);
294        g.add_edge(0, 1).unwrap();
295        g.add_edge(1, 2).unwrap();
296        g.add_edge(2, 0).unwrap();
297        let params = GraphoptParams {
298            niter: 100,
299            ..GraphoptParams::default()
300        };
301        let pos = layout_graphopt(&g, None, &params).unwrap();
302        assert_eq!(pos.len(), 3);
303        for p in &pos {
304            assert!(p[0].is_finite() && p[1].is_finite());
305        }
306    }
307
308    #[test]
309    fn test_graphopt_path() {
310        let mut g = Graph::with_vertices(5);
311        for i in 0..4 {
312            g.add_edge(i, i + 1).unwrap();
313        }
314        let params = GraphoptParams {
315            niter: 50,
316            ..GraphoptParams::default()
317        };
318        let pos = layout_graphopt(&g, None, &params).unwrap();
319        assert_eq!(pos.len(), 5);
320        for p in &pos {
321            assert!(p[0].is_finite() && p[1].is_finite());
322        }
323    }
324
325    #[test]
326    fn test_graphopt_with_seed() {
327        let mut g = Graph::with_vertices(3);
328        g.add_edge(0, 1).unwrap();
329        g.add_edge(1, 2).unwrap();
330        let seed = vec![[0.0, 0.0], [1.0, 0.0], [0.5, 0.866]];
331        let params = GraphoptParams {
332            niter: 50,
333            ..GraphoptParams::default()
334        };
335        let pos = layout_graphopt(&g, Some(&seed), &params).unwrap();
336        assert_eq!(pos.len(), 3);
337    }
338
339    #[test]
340    fn test_graphopt_seed_wrong_length() {
341        let g = Graph::with_vertices(3);
342        let seed = vec![[0.0, 0.0]];
343        let params = GraphoptParams::default();
344        assert!(layout_graphopt(&g, Some(&seed), &params).is_err());
345    }
346
347    #[test]
348    fn test_graphopt_zero_mass() {
349        let g = Graph::with_vertices(3);
350        let params = GraphoptParams {
351            node_mass: 0.0,
352            ..GraphoptParams::default()
353        };
354        assert!(layout_graphopt(&g, None, &params).is_err());
355    }
356
357    #[test]
358    fn test_graphopt_no_charge() {
359        let mut g = Graph::with_vertices(4);
360        g.add_edge(0, 1).unwrap();
361        g.add_edge(1, 2).unwrap();
362        g.add_edge(2, 3).unwrap();
363        g.add_edge(3, 0).unwrap();
364        let params = GraphoptParams {
365            niter: 50,
366            node_charge: 0.0,
367            ..GraphoptParams::default()
368        };
369        let pos = layout_graphopt(&g, None, &params).unwrap();
370        assert_eq!(pos.len(), 4);
371        for p in &pos {
372            assert!(p[0].is_finite() && p[1].is_finite());
373        }
374    }
375
376    #[test]
377    fn test_graphopt_deterministic() {
378        let mut g = Graph::with_vertices(4);
379        g.add_edge(0, 1).unwrap();
380        g.add_edge(1, 2).unwrap();
381        g.add_edge(2, 3).unwrap();
382        g.add_edge(3, 0).unwrap();
383        let params = GraphoptParams {
384            niter: 50,
385            ..GraphoptParams::default()
386        };
387        let pos1 = layout_graphopt(&g, None, &params).unwrap();
388        let pos2 = layout_graphopt(&g, None, &params).unwrap();
389        for i in 0..4 {
390            assert!((pos1[i][0] - pos2[i][0]).abs() < 1e-10);
391            assert!((pos1[i][1] - pos2[i][1]).abs() < 1e-10);
392        }
393    }
394
395    #[test]
396    fn test_graphopt_no_edges() {
397        let g = Graph::with_vertices(4);
398        let params = GraphoptParams {
399            niter: 50,
400            ..GraphoptParams::default()
401        };
402        let pos = layout_graphopt(&g, None, &params).unwrap();
403        assert_eq!(pos.len(), 4);
404        for p in &pos {
405            assert!(p[0].is_finite() && p[1].is_finite());
406        }
407    }
408
409    #[test]
410    fn test_graphopt_vertices_spread() {
411        let mut g = Graph::with_vertices(4);
412        g.add_edge(0, 1).unwrap();
413        g.add_edge(2, 3).unwrap();
414        let params = GraphoptParams {
415            niter: 100,
416            ..GraphoptParams::default()
417        };
418        let pos = layout_graphopt(&g, None, &params).unwrap();
419        // Vertices shouldn't all collapse to the same point
420        let mut all_same = true;
421        for i in 1..4 {
422            if (pos[i][0] - pos[0][0]).abs() > 1e-6 || (pos[i][1] - pos[0][1]).abs() > 1e-6 {
423                all_same = false;
424                break;
425            }
426        }
427        assert!(!all_same, "all vertices collapsed to the same point");
428    }
429}