Skip to main content

rust_igraph/algorithms/layout/
align.rs

1//! Layout alignment via nematic tensor (ALGO-LO-016).
2//!
3//! Counterpart of `igraph_layout_align()` from
4//! `references/igraph/src/layout/align.c`.
5//!
6//! Centers the layout on the coordinate origin and rotates it so the
7//! principal axes of the edge-vector distribution align with the
8//! coordinate axes. Axes are reordered so the widest extent comes first.
9
10use crate::core::{Graph, IgraphError, IgraphResult};
11
12/// Align a graph layout with the coordinate axes.
13///
14/// Centers vertex positions on the origin and rotates them so that the
15/// layout's principal axes (computed from the nematic tensor of edge
16/// directions) coincide with the coordinate axes. After rotation, axes
17/// are reordered so the widest extent is in column 0.
18///
19/// The layout is modified **in place**. Each inner `Vec` represents one
20/// vertex's coordinates; all must have the same length (`dim ≥ 1`).
21///
22/// # Errors
23///
24/// Returns `InvalidArgument` if:
25/// - `layout.len()` does not equal `graph.vcount()`.
26/// - Any coordinate vector differs in length from the first.
27/// - `dim == 0` (zero-dimensional coordinates).
28///
29/// # Examples
30///
31/// ```
32/// use rust_igraph::{Graph, layout_align};
33///
34/// let mut g = Graph::with_vertices(3);
35/// g.add_edge(0, 1).unwrap();
36/// g.add_edge(1, 2).unwrap();
37///
38/// // Arbitrary 2D positions
39/// let mut layout = vec![vec![1.0, 2.0], vec![3.0, 4.0], vec![5.0, 6.0]];
40/// layout_align(&g, &mut layout).unwrap();
41///
42/// // After alignment, the center of mass is at the origin
43/// let cx: f64 = layout.iter().map(|p| p[0]).sum::<f64>() / 3.0;
44/// let cy: f64 = layout.iter().map(|p| p[1]).sum::<f64>() / 3.0;
45/// assert!((cx).abs() < 1e-10);
46/// assert!((cy).abs() < 1e-10);
47/// ```
48pub fn layout_align(graph: &Graph, layout: &mut [Vec<f64>]) -> IgraphResult<()> {
49    let vcount = graph.vcount() as usize;
50    let ecount = graph.ecount();
51
52    if layout.len() != vcount {
53        return Err(IgraphError::InvalidArgument(
54            "layout_align: number of points does not match vertex count".to_string(),
55        ));
56    }
57
58    if vcount == 0 {
59        return Ok(());
60    }
61
62    let dim = layout[0].len();
63    for (i, pos) in layout.iter().enumerate() {
64        if pos.len() != dim {
65            return Err(IgraphError::InvalidArgument(format!(
66                "layout_align: vertex {i} has {} coordinates, expected {dim}",
67                pos.len()
68            )));
69        }
70    }
71
72    if dim == 0 {
73        return Err(IgraphError::InvalidArgument(
74            "layout_align: coordinates must be at least one-dimensional".to_string(),
75        ));
76    }
77
78    // Step 1: Center the layout on the origin.
79    let mut center = vec![0.0_f64; dim];
80    for pos in layout.iter() {
81        for (j, &c) in pos.iter().enumerate() {
82            center[j] += c;
83        }
84    }
85    let inv_n = 1.0 / vcount as f64;
86    for c in &mut center {
87        *c *= inv_n;
88    }
89    for pos in layout.iter_mut() {
90        for (j, c) in pos.iter_mut().enumerate() {
91            *c -= center[j];
92        }
93    }
94
95    if dim == 1 {
96        return Ok(());
97    }
98
99    // Step 2: Compute M_ij = sum_{edges} v_i * v_j  (v = edge vector).
100    let mut m_mat = vec![vec![0.0_f64; dim]; dim];
101    let mut norm2_sum = 0.0_f64;
102
103    // Correction term for symmetry-breaking (first non-zero contribution).
104    let mut correction_saved = false;
105    let mut m_correction = vec![vec![0.0_f64; dim]; dim];
106    let mut norm2_sum_correction = 0.0_f64;
107
108    for eid in 0..ecount {
109        #[allow(clippy::cast_possible_truncation)]
110        let (from, to) = graph.edge(eid as u32)?;
111        if from == to {
112            continue;
113        }
114
115        let f = from as usize;
116        let t = to as usize;
117
118        for i in 0..dim {
119            let vi = layout[f][i] - layout[t][i];
120            for j in 0..dim {
121                let vj = layout[f][j] - layout[t][j];
122                let prod = vi * vj;
123                m_mat[i][j] += prod;
124                if i == j {
125                    norm2_sum += prod;
126                }
127            }
128        }
129
130        if !correction_saved && norm2_sum > 0.0 {
131            correction_saved = true;
132            norm2_sum_correction = norm2_sum;
133            for i in 0..dim {
134                for j in 0..dim {
135                    m_correction[i][j] = m_mat[i][j];
136                }
137            }
138        }
139    }
140
141    // If no edges (or all zero-length), use vertex positions instead.
142    if norm2_sum == 0.0 {
143        for vid in 0..vcount {
144            for i in 0..dim {
145                for j in 0..dim {
146                    let prod = layout[vid][i] * layout[vid][j];
147                    m_mat[i][j] += prod;
148                    if i == j {
149                        norm2_sum += prod;
150                    }
151                }
152            }
153
154            if !correction_saved && norm2_sum > 0.0 {
155                correction_saved = true;
156                norm2_sum_correction = norm2_sum;
157                for i in 0..dim {
158                    for j in 0..dim {
159                        m_correction[i][j] = m_mat[i][j];
160                    }
161                }
162            }
163        }
164    }
165
166    // All vertices at the origin — nothing to align.
167    if norm2_sum == 0.0 {
168        return Ok(());
169    }
170
171    // Step 3: Compute Q = M / norm2_sum - I/dim, find eigenvectors.
172    let mut retried = false;
173    let rotation;
174
175    loop {
176        // Q = M / norm2_sum - I/dim
177        let mut q = vec![vec![0.0_f64; dim]; dim];
178        let inv_norm2 = 1.0 / norm2_sum;
179        let inv_dim = 1.0 / dim as f64;
180        for i in 0..dim {
181            for j in 0..dim {
182                q[i][j] = m_mat[i][j] * inv_norm2;
183            }
184            q[i][i] -= inv_dim;
185        }
186
187        // Eigendecompose Q (symmetric dim×dim matrix).
188        let (eigenvalues, eigenvectors) = symmetric_eigen_jacobi(&q);
189
190        // Check if Q is close to zero (rotationally symmetric layout).
191        let matrix_norm = eigenvalues.iter().map(|&l| l.abs()).fold(0.0_f64, f64::max);
192
193        if matrix_norm > 1e-3 || retried {
194            rotation = eigenvectors;
195            break;
196        }
197
198        // Remove one contribution to break symmetry, then retry.
199        for i in 0..dim {
200            for j in 0..dim {
201                m_mat[i][j] -= m_correction[i][j];
202            }
203        }
204        norm2_sum -= norm2_sum_correction;
205
206        if norm2_sum <= 0.0 {
207            return Ok(());
208        }
209
210        retried = true;
211    }
212
213    // Step 4: Rotate layout: new_pos = layout * R (R columns = eigenvectors).
214    let mut temp = vec![vec![0.0_f64; dim]; vcount];
215    for v in 0..vcount {
216        for j in 0..dim {
217            let mut sum = 0.0_f64;
218            for k in 0..dim {
219                sum += layout[v][k] * rotation[k][j];
220            }
221            temp[v][j] = sum;
222        }
223    }
224
225    // Step 5: Reorder axes by extent (widest first).
226    let mut extents: Vec<(f64, usize)> = (0..dim)
227        .map(|j| {
228            let mut min = f64::INFINITY;
229            let mut max = f64::NEG_INFINITY;
230            for v in 0..vcount {
231                let c = temp[v][j];
232                if c < min {
233                    min = c;
234                }
235                if c > max {
236                    max = c;
237                }
238            }
239            (max - min, j)
240        })
241        .collect();
242    extents.sort_unstable_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
243
244    for v in 0..vcount {
245        for (new_j, &(_, old_j)) in extents.iter().enumerate() {
246            layout[v][new_j] = temp[v][old_j];
247        }
248    }
249
250    Ok(())
251}
252
253/// Jacobi eigenvalue algorithm for small symmetric matrices.
254///
255/// Returns `(eigenvalues, eigenvectors)` where `eigenvectors[i][j]` is
256/// the `j`-th component of the `i`-th eigenvector. Eigenvalues are
257/// sorted by ascending value. Eigenvectors form an orthonormal basis.
258fn symmetric_eigen_jacobi(a: &[Vec<f64>]) -> (Vec<f64>, Vec<Vec<f64>>) {
259    let n = a.len();
260
261    // Work copy — we'll reduce this to diagonal form.
262    let mut s: Vec<Vec<f64>> = a.to_vec();
263
264    // Accumulate rotations in V (starts as identity).
265    let mut v = vec![vec![0.0_f64; n]; n];
266    for i in 0..n {
267        v[i][i] = 1.0;
268    }
269
270    let max_sweeps = 100;
271
272    for _sweep in 0..max_sweeps {
273        // Find the largest off-diagonal element.
274        let mut max_off = 0.0_f64;
275        let mut p = 0;
276        let mut q = 1;
277        for i in 0..n {
278            for j in (i + 1)..n {
279                let val = s[i][j].abs();
280                if val > max_off {
281                    max_off = val;
282                    p = i;
283                    q = j;
284                }
285            }
286        }
287
288        if max_off < 1e-15 {
289            break;
290        }
291
292        // Compute rotation angle.
293        let (cos, sin) = if (s[p][p] - s[q][q]).abs() < 1e-15 {
294            let angle = std::f64::consts::FRAC_PI_4;
295            (angle.cos(), angle.sin())
296        } else {
297            let tau = (s[q][q] - s[p][p]) / (2.0 * s[p][q]);
298            let t = if tau >= 0.0 {
299                1.0 / (tau + (1.0 + tau * tau).sqrt())
300            } else {
301                -1.0 / (-tau + (1.0 + tau * tau).sqrt())
302            };
303            let c = 1.0 / (1.0 + t * t).sqrt();
304            (c, t * c)
305        };
306
307        // Apply Givens rotation to S.
308        let s_pp = s[p][p];
309        let s_qq = s[q][q];
310        let s_pq = s[p][q];
311
312        s[p][p] = cos * cos * s_pp - 2.0 * sin * cos * s_pq + sin * sin * s_qq;
313        s[q][q] = sin * sin * s_pp + 2.0 * sin * cos * s_pq + cos * cos * s_qq;
314        s[p][q] = 0.0;
315        s[q][p] = 0.0;
316
317        for i in 0..n {
318            if i == p || i == q {
319                continue;
320            }
321            let s_ip = s[i][p];
322            let s_iq = s[i][q];
323            s[i][p] = cos * s_ip - sin * s_iq;
324            s[p][i] = s[i][p];
325            s[i][q] = sin * s_ip + cos * s_iq;
326            s[q][i] = s[i][q];
327        }
328
329        // Update eigenvector matrix V.
330        for i in 0..n {
331            let v_ip = v[i][p];
332            let v_iq = v[i][q];
333            v[i][p] = cos * v_ip - sin * v_iq;
334            v[i][q] = sin * v_ip + cos * v_iq;
335        }
336    }
337
338    let mut eigenvalues: Vec<f64> = (0..n).map(|i| s[i][i]).collect();
339
340    // Sort eigenvalues ascending and permute eigenvectors accordingly.
341    let mut indices: Vec<usize> = (0..n).collect();
342    indices.sort_unstable_by(|&a, &b| {
343        eigenvalues[a]
344            .partial_cmp(&eigenvalues[b])
345            .unwrap_or(std::cmp::Ordering::Equal)
346    });
347
348    let sorted_eigenvalues: Vec<f64> = indices.iter().map(|&i| eigenvalues[i]).collect();
349    eigenvalues = sorted_eigenvalues;
350
351    // eigenvectors[i][j] = column i of V permuted
352    let mut sorted_v = vec![vec![0.0_f64; n]; n];
353    for row in 0..n {
354        for (new_col, &old_col) in indices.iter().enumerate() {
355            sorted_v[row][new_col] = v[row][old_col];
356        }
357    }
358
359    (eigenvalues, sorted_v)
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    fn approx_eq(a: f64, b: f64) -> bool {
367        (a - b).abs() < 1e-10
368    }
369
370    fn center_of_mass(layout: &[Vec<f64>]) -> Vec<f64> {
371        let n = layout.len();
372        if n == 0 {
373            return vec![];
374        }
375        let dim = layout[0].len();
376        let mut c = vec![0.0; dim];
377        for pos in layout {
378            for (j, &v) in pos.iter().enumerate() {
379                c[j] += v;
380            }
381        }
382        for v in &mut c {
383            *v /= n as f64;
384        }
385        c
386    }
387
388    #[test]
389    fn empty_graph() {
390        let g = Graph::with_vertices(0);
391        let mut layout: Vec<Vec<f64>> = vec![];
392        layout_align(&g, &mut layout).unwrap();
393        assert!(layout.is_empty());
394    }
395
396    #[test]
397    fn single_vertex() {
398        let g = Graph::with_vertices(1);
399        let mut layout = vec![vec![5.0, 3.0]];
400        layout_align(&g, &mut layout).unwrap();
401        // Centered on origin
402        assert!(approx_eq(layout[0][0], 0.0));
403        assert!(approx_eq(layout[0][1], 0.0));
404    }
405
406    #[test]
407    fn centering() {
408        let mut g = Graph::with_vertices(3);
409        g.add_edge(0, 1).unwrap();
410        g.add_edge(1, 2).unwrap();
411        let mut layout = vec![vec![10.0, 20.0], vec![12.0, 22.0], vec![14.0, 24.0]];
412        layout_align(&g, &mut layout).unwrap();
413
414        let c = center_of_mass(&layout);
415        assert!(approx_eq(c[0], 0.0));
416        assert!(approx_eq(c[1], 0.0));
417    }
418
419    #[test]
420    fn preserves_distances() {
421        let mut g = Graph::with_vertices(3);
422        g.add_edge(0, 1).unwrap();
423        g.add_edge(1, 2).unwrap();
424        let mut layout = vec![vec![1.0, 2.0], vec![3.0, 4.0], vec![5.0, 6.0]];
425
426        // Compute pairwise distances before
427        let dist = |a: &[f64], b: &[f64]| -> f64 {
428            a.iter()
429                .zip(b.iter())
430                .map(|(&x, &y)| (x - y) * (x - y))
431                .sum::<f64>()
432                .sqrt()
433        };
434        let d01_before = dist(&layout[0], &layout[1]);
435        let d12_before = dist(&layout[1], &layout[2]);
436
437        layout_align(&g, &mut layout).unwrap();
438
439        let d01_after = dist(&layout[0], &layout[1]);
440        let d12_after = dist(&layout[1], &layout[2]);
441
442        assert!((d01_before - d01_after).abs() < 1e-10);
443        assert!((d12_before - d12_after).abs() < 1e-10);
444    }
445
446    #[test]
447    fn widest_axis_first() {
448        let mut g = Graph::with_vertices(4);
449        g.add_edge(0, 1).unwrap();
450        g.add_edge(2, 3).unwrap();
451        // Layout with greater extent in Y than X
452        let mut layout = vec![
453            vec![0.0, 0.0],
454            vec![1.0, 0.0],
455            vec![0.0, 10.0],
456            vec![1.0, 10.0],
457        ];
458        layout_align(&g, &mut layout).unwrap();
459
460        // After alignment, axis 0 should have the widest extent
461        let extent_x = layout
462            .iter()
463            .map(|p| p[0])
464            .fold(f64::NEG_INFINITY, f64::max)
465            - layout.iter().map(|p| p[0]).fold(f64::INFINITY, f64::min);
466        let extent_y = layout
467            .iter()
468            .map(|p| p[1])
469            .fold(f64::NEG_INFINITY, f64::max)
470            - layout.iter().map(|p| p[1]).fold(f64::INFINITY, f64::min);
471
472        assert!(
473            extent_x >= extent_y - 1e-10,
474            "axis 0 extent ({extent_x}) should be >= axis 1 extent ({extent_y})"
475        );
476    }
477
478    #[test]
479    fn one_dimensional() {
480        let mut g = Graph::with_vertices(3);
481        g.add_edge(0, 1).unwrap();
482        g.add_edge(1, 2).unwrap();
483        let mut layout = vec![vec![1.0], vec![3.0], vec![5.0]];
484        layout_align(&g, &mut layout).unwrap();
485        let c = center_of_mass(&layout);
486        assert!(approx_eq(c[0], 0.0));
487    }
488
489    #[test]
490    fn three_dimensional() {
491        let mut g = Graph::with_vertices(4);
492        g.add_edge(0, 1).unwrap();
493        g.add_edge(1, 2).unwrap();
494        g.add_edge(2, 3).unwrap();
495        let mut layout = vec![
496            vec![1.0, 0.0, 0.0],
497            vec![0.0, 1.0, 0.0],
498            vec![0.0, 0.0, 1.0],
499            vec![1.0, 1.0, 1.0],
500        ];
501        layout_align(&g, &mut layout).unwrap();
502
503        let c = center_of_mass(&layout);
504        for &ci in &c {
505            assert!(approx_eq(ci, 0.0));
506        }
507    }
508
509    #[test]
510    fn no_edges_uses_vertices() {
511        let g = Graph::with_vertices(3);
512        // Vertices spread along diagonal
513        let mut layout = vec![vec![0.0, 0.0], vec![1.0, 1.0], vec![2.0, 2.0]];
514        layout_align(&g, &mut layout).unwrap();
515        let c = center_of_mass(&layout);
516        assert!(approx_eq(c[0], 0.0));
517        assert!(approx_eq(c[1], 0.0));
518    }
519
520    #[test]
521    fn all_at_origin() {
522        let mut g = Graph::with_vertices(3);
523        g.add_edge(0, 1).unwrap();
524        let mut layout = vec![vec![0.0, 0.0], vec![0.0, 0.0], vec![0.0, 0.0]];
525        layout_align(&g, &mut layout).unwrap();
526        for pos in &layout {
527            assert!(approx_eq(pos[0], 0.0));
528            assert!(approx_eq(pos[1], 0.0));
529        }
530    }
531
532    #[test]
533    fn self_loops_skipped() {
534        let mut g = Graph::with_vertices(2);
535        g.add_edge(0, 0).unwrap(); // self-loop
536        g.add_edge(0, 1).unwrap();
537        let mut layout = vec![vec![0.0, 0.0], vec![1.0, 1.0]];
538        layout_align(&g, &mut layout).unwrap();
539        let c = center_of_mass(&layout);
540        assert!(approx_eq(c[0], 0.0));
541        assert!(approx_eq(c[1], 0.0));
542    }
543
544    #[test]
545    fn directed_graph() {
546        let mut g = Graph::new(3, true).unwrap();
547        g.add_edge(0, 1).unwrap();
548        g.add_edge(1, 2).unwrap();
549        let mut layout = vec![vec![1.0, 2.0], vec![3.0, 4.0], vec![5.0, 6.0]];
550        layout_align(&g, &mut layout).unwrap();
551        let c = center_of_mass(&layout);
552        assert!(approx_eq(c[0], 0.0));
553        assert!(approx_eq(c[1], 0.0));
554    }
555
556    #[test]
557    fn mismatched_vertex_count() {
558        let g = Graph::with_vertices(3);
559        let mut layout = vec![vec![0.0, 0.0], vec![1.0, 1.0]];
560        assert!(layout_align(&g, &mut layout).is_err());
561    }
562
563    #[test]
564    fn inconsistent_dimensions() {
565        let g = Graph::with_vertices(2);
566        let mut layout = vec![vec![0.0, 0.0], vec![1.0]];
567        assert!(layout_align(&g, &mut layout).is_err());
568    }
569
570    #[test]
571    fn zero_dimensional() {
572        let g = Graph::with_vertices(2);
573        let mut layout = vec![vec![], vec![]];
574        assert!(layout_align(&g, &mut layout).is_err());
575    }
576
577    #[test]
578    fn jacobi_2x2() {
579        // [2  1]
580        // [1  3]
581        // eigenvalues: (5 ± sqrt(5)) / 2 ≈ 1.382, 3.618
582        let m = vec![vec![2.0, 1.0], vec![1.0, 3.0]];
583        let (vals, vecs) = symmetric_eigen_jacobi(&m);
584
585        let expected_0 = (5.0 - 5.0_f64.sqrt()) / 2.0;
586        let expected_1 = (5.0 + 5.0_f64.sqrt()) / 2.0;
587        assert!((vals[0] - expected_0).abs() < 1e-10);
588        assert!((vals[1] - expected_1).abs() < 1e-10);
589
590        // Eigenvectors should be orthonormal
591        let dot: f64 = vecs[0][0] * vecs[0][1] + vecs[1][0] * vecs[1][1];
592        assert!(dot.abs() < 1e-10);
593    }
594
595    #[test]
596    fn jacobi_identity() {
597        let m = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
598        let (vals, _) = symmetric_eigen_jacobi(&m);
599        assert!((vals[0] - 1.0).abs() < 1e-10);
600        assert!((vals[1] - 1.0).abs() < 1e-10);
601    }
602}