Skip to main content

oximedia_graph/
layout.rs

1//! Graph layout algorithms.
2//!
3//! Provides force-directed, hierarchical, and grid layout algorithms
4//! for positioning graph nodes in 2D space.
5
6use std::collections::HashMap;
7
8/// A 2D position.
9#[allow(dead_code)]
10#[derive(Debug, Clone, Copy, PartialEq)]
11pub struct Position {
12    /// X coordinate.
13    pub x: f32,
14    /// Y coordinate.
15    pub y: f32,
16}
17
18impl Position {
19    /// Create a new position.
20    #[allow(dead_code)]
21    pub fn new(x: f32, y: f32) -> Self {
22        Self { x, y }
23    }
24
25    /// Euclidean distance to another position.
26    #[allow(dead_code)]
27    pub fn distance(&self, other: &Position) -> f32 {
28        let dx = self.x - other.x;
29        let dy = self.y - other.y;
30        (dx * dx + dy * dy).sqrt()
31    }
32}
33
34/// Node identifier for layout purposes.
35#[allow(dead_code)]
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
37pub struct LayoutNodeId(pub usize);
38
39/// An edge between two layout nodes.
40#[allow(dead_code)]
41#[derive(Debug, Clone, Copy)]
42pub struct LayoutEdge {
43    /// Source node.
44    pub from: LayoutNodeId,
45    /// Target node.
46    pub to: LayoutNodeId,
47}
48
49/// Layout algorithm selection.
50#[allow(dead_code)]
51#[derive(Debug, Clone, Copy, PartialEq)]
52pub enum LayoutAlgorithm {
53    /// Force-directed (Fruchterman-Reingold approximation).
54    ForceDirected {
55        /// Number of iterations.
56        iterations: usize,
57        /// Ideal spring length.
58        ideal_length: f32,
59    },
60    /// Hierarchical (Sugiyama-style layering).
61    Hierarchical {
62        /// Vertical spacing between layers.
63        layer_spacing: f32,
64        /// Horizontal spacing between nodes in same layer.
65        node_spacing: f32,
66    },
67    /// Grid layout.
68    Grid {
69        /// Number of columns.
70        columns: usize,
71        /// Horizontal cell size.
72        cell_width: f32,
73        /// Vertical cell size.
74        cell_height: f32,
75    },
76}
77
78impl Default for LayoutAlgorithm {
79    fn default() -> Self {
80        LayoutAlgorithm::Grid {
81            columns: 4,
82            cell_width: 150.0,
83            cell_height: 100.0,
84        }
85    }
86}
87
88/// Computes node positions using the selected layout algorithm.
89#[allow(dead_code)]
90pub struct GraphLayout {
91    algorithm: LayoutAlgorithm,
92}
93
94impl GraphLayout {
95    /// Create a new graph layout with the given algorithm.
96    #[allow(dead_code)]
97    pub fn new(algorithm: LayoutAlgorithm) -> Self {
98        Self { algorithm }
99    }
100
101    /// Compute positions for nodes given the edge list.
102    #[allow(dead_code)]
103    pub fn compute(
104        &self,
105        node_ids: &[LayoutNodeId],
106        edges: &[LayoutEdge],
107    ) -> HashMap<LayoutNodeId, Position> {
108        match self.algorithm {
109            LayoutAlgorithm::Grid {
110                columns,
111                cell_width,
112                cell_height,
113            } => self.grid_layout(node_ids, columns, cell_width, cell_height),
114            LayoutAlgorithm::Hierarchical {
115                layer_spacing,
116                node_spacing,
117            } => self.hierarchical_layout(node_ids, edges, layer_spacing, node_spacing),
118            LayoutAlgorithm::ForceDirected {
119                iterations,
120                ideal_length,
121            } => self.force_directed_layout(node_ids, edges, iterations, ideal_length),
122        }
123    }
124
125    /// Grid layout: place nodes in a grid.
126    #[allow(dead_code)]
127    fn grid_layout(
128        &self,
129        node_ids: &[LayoutNodeId],
130        columns: usize,
131        cell_width: f32,
132        cell_height: f32,
133    ) -> HashMap<LayoutNodeId, Position> {
134        let cols = columns.max(1);
135        let mut positions = HashMap::new();
136        for (i, &node_id) in node_ids.iter().enumerate() {
137            let col = i % cols;
138            let row = i / cols;
139            positions.insert(
140                node_id,
141                Position::new(col as f32 * cell_width, row as f32 * cell_height),
142            );
143        }
144        positions
145    }
146
147    /// Simple hierarchical layout based on in-degree (topological levels).
148    #[allow(dead_code)]
149    fn hierarchical_layout(
150        &self,
151        node_ids: &[LayoutNodeId],
152        edges: &[LayoutEdge],
153        layer_spacing: f32,
154        node_spacing: f32,
155    ) -> HashMap<LayoutNodeId, Position> {
156        // Compute in-degree for each node
157        let mut in_degree: HashMap<LayoutNodeId, usize> = HashMap::new();
158        for &id in node_ids {
159            in_degree.insert(id, 0);
160        }
161        for edge in edges {
162            *in_degree.entry(edge.to).or_insert(0) += 1;
163        }
164
165        // Group by in-degree as proxy for layer
166        let mut layers: HashMap<usize, Vec<LayoutNodeId>> = HashMap::new();
167        for (&node, &deg) in &in_degree {
168            layers.entry(deg).or_default().push(node);
169        }
170
171        let mut positions = HashMap::new();
172        for (layer_idx, (_, layer_nodes)) in layers.iter().enumerate() {
173            for (node_idx, &node_id) in layer_nodes.iter().enumerate() {
174                positions.insert(
175                    node_id,
176                    Position::new(
177                        node_idx as f32 * node_spacing,
178                        layer_idx as f32 * layer_spacing,
179                    ),
180                );
181            }
182        }
183        positions
184    }
185
186    /// Simplified force-directed layout (Fruchterman-Reingold approximation).
187    #[allow(dead_code)]
188    fn force_directed_layout(
189        &self,
190        node_ids: &[LayoutNodeId],
191        edges: &[LayoutEdge],
192        iterations: usize,
193        ideal_length: f32,
194    ) -> HashMap<LayoutNodeId, Position> {
195        if node_ids.is_empty() {
196            return HashMap::new();
197        }
198
199        // Initialize positions in a circle
200        let n = node_ids.len();
201        let mut positions: HashMap<LayoutNodeId, Position> = HashMap::new();
202        for (i, &id) in node_ids.iter().enumerate() {
203            let angle = 2.0 * std::f32::consts::PI * i as f32 / n as f32;
204            positions.insert(
205                id,
206                Position::new(angle.cos() * ideal_length, angle.sin() * ideal_length),
207            );
208        }
209
210        let k = ideal_length;
211        let k2 = k * k;
212
213        for _ in 0..iterations {
214            let mut displacements: HashMap<LayoutNodeId, (f32, f32)> = HashMap::new();
215            for &id in node_ids {
216                displacements.insert(id, (0.0, 0.0));
217            }
218
219            // Repulsive forces
220            for i in 0..n {
221                for j in (i + 1)..n {
222                    let u = node_ids[i];
223                    let v = node_ids[j];
224                    // SAFETY: positions is populated for every node in node_ids above
225                    let pu = match positions.get(&u) {
226                        Some(p) => *p,
227                        None => continue,
228                    };
229                    let pv = match positions.get(&v) {
230                        Some(p) => *p,
231                        None => continue,
232                    };
233                    let dx = pu.x - pv.x;
234                    let dy = pu.y - pv.y;
235                    let dist2 = (dx * dx + dy * dy).max(0.001);
236                    let dist = dist2.sqrt();
237                    let force = k2 / dist;
238                    let fx = (dx / dist) * force;
239                    let fy = (dy / dist) * force;
240                    if let Some(d) = displacements.get_mut(&u) {
241                        d.0 += fx;
242                        d.1 += fy;
243                    }
244                    if let Some(d) = displacements.get_mut(&v) {
245                        d.0 -= fx;
246                        d.1 -= fy;
247                    }
248                }
249            }
250
251            // Attractive forces along edges
252            for edge in edges {
253                let pu = match positions.get(&edge.from) {
254                    Some(p) => *p,
255                    None => continue,
256                };
257                let pv = match positions.get(&edge.to) {
258                    Some(p) => *p,
259                    None => continue,
260                };
261                let dx = pu.x - pv.x;
262                let dy = pu.y - pv.y;
263                let dist = (dx * dx + dy * dy).sqrt().max(0.001);
264                let force = dist * dist / k;
265                let fx = (dx / dist) * force;
266                let fy = (dy / dist) * force;
267                if let Some(d) = displacements.get_mut(&edge.from) {
268                    d.0 -= fx;
269                    d.1 -= fy;
270                }
271                if let Some(d) = displacements.get_mut(&edge.to) {
272                    d.0 += fx;
273                    d.1 += fy;
274                }
275            }
276
277            // Apply displacements with cooling (temperature = k / 10)
278            let temp = k / 10.0;
279            for &id in node_ids {
280                // SAFETY: displacements is populated for every node in node_ids above
281                let (dx, dy) = match displacements.get(&id) {
282                    Some(&d) => d,
283                    None => continue,
284                };
285                let disp_len = (dx * dx + dy * dy).sqrt().max(0.001);
286                let clamped = disp_len.min(temp);
287                if let Some(pos) = positions.get_mut(&id) {
288                    pos.x += (dx / disp_len) * clamped;
289                    pos.y += (dy / disp_len) * clamped;
290                }
291            }
292        }
293
294        positions
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    fn node_ids(n: usize) -> Vec<LayoutNodeId> {
303        (0..n).map(LayoutNodeId).collect()
304    }
305
306    #[test]
307    fn test_position_creation() {
308        let p = Position::new(1.0, 2.0);
309        assert_eq!(p.x, 1.0);
310        assert_eq!(p.y, 2.0);
311    }
312
313    #[test]
314    fn test_position_distance_zero() {
315        let p = Position::new(3.0, 4.0);
316        assert_eq!(p.distance(&p), 0.0);
317    }
318
319    #[test]
320    fn test_position_distance_nonzero() {
321        let a = Position::new(0.0, 0.0);
322        let b = Position::new(3.0, 4.0);
323        assert!((a.distance(&b) - 5.0).abs() < 1e-5);
324    }
325
326    #[test]
327    fn test_grid_layout_empty() {
328        let layout = GraphLayout::new(LayoutAlgorithm::Grid {
329            columns: 4,
330            cell_width: 100.0,
331            cell_height: 100.0,
332        });
333        let positions = layout.compute(&[], &[]);
334        assert!(positions.is_empty());
335    }
336
337    #[test]
338    fn test_grid_layout_count() {
339        let layout = GraphLayout::new(LayoutAlgorithm::Grid {
340            columns: 3,
341            cell_width: 100.0,
342            cell_height: 100.0,
343        });
344        let ids = node_ids(6);
345        let positions = layout.compute(&ids, &[]);
346        assert_eq!(positions.len(), 6);
347    }
348
349    #[test]
350    fn test_grid_layout_first_node_origin() {
351        let layout = GraphLayout::new(LayoutAlgorithm::Grid {
352            columns: 4,
353            cell_width: 100.0,
354            cell_height: 100.0,
355        });
356        let ids = node_ids(4);
357        let positions = layout.compute(&ids, &[]);
358        let p = positions[&LayoutNodeId(0)];
359        assert_eq!(p.x, 0.0);
360        assert_eq!(p.y, 0.0);
361    }
362
363    #[test]
364    fn test_grid_layout_second_row() {
365        let layout = GraphLayout::new(LayoutAlgorithm::Grid {
366            columns: 2,
367            cell_width: 100.0,
368            cell_height: 80.0,
369        });
370        let ids = node_ids(4);
371        let positions = layout.compute(&ids, &[]);
372        let p = positions[&LayoutNodeId(2)]; // row 1, col 0
373        assert_eq!(p.x, 0.0);
374        assert_eq!(p.y, 80.0);
375    }
376
377    #[test]
378    fn test_hierarchical_layout_count() {
379        let layout = GraphLayout::new(LayoutAlgorithm::Hierarchical {
380            layer_spacing: 80.0,
381            node_spacing: 120.0,
382        });
383        let ids = node_ids(4);
384        let edges = vec![LayoutEdge {
385            from: LayoutNodeId(0),
386            to: LayoutNodeId(1),
387        }];
388        let positions = layout.compute(&ids, &edges);
389        assert_eq!(positions.len(), 4);
390    }
391
392    #[test]
393    fn test_hierarchical_positions_finite() {
394        let layout = GraphLayout::new(LayoutAlgorithm::Hierarchical {
395            layer_spacing: 80.0,
396            node_spacing: 120.0,
397        });
398        let ids = node_ids(5);
399        let edges = vec![
400            LayoutEdge {
401                from: LayoutNodeId(0),
402                to: LayoutNodeId(2),
403            },
404            LayoutEdge {
405                from: LayoutNodeId(1),
406                to: LayoutNodeId(3),
407            },
408        ];
409        let positions = layout.compute(&ids, &edges);
410        for (_, pos) in &positions {
411            assert!(pos.x.is_finite());
412            assert!(pos.y.is_finite());
413        }
414    }
415
416    #[test]
417    fn test_force_directed_empty() {
418        let layout = GraphLayout::new(LayoutAlgorithm::ForceDirected {
419            iterations: 10,
420            ideal_length: 100.0,
421        });
422        let positions = layout.compute(&[], &[]);
423        assert!(positions.is_empty());
424    }
425
426    #[test]
427    fn test_force_directed_count() {
428        let layout = GraphLayout::new(LayoutAlgorithm::ForceDirected {
429            iterations: 5,
430            ideal_length: 100.0,
431        });
432        let ids = node_ids(4);
433        let edges = vec![LayoutEdge {
434            from: LayoutNodeId(0),
435            to: LayoutNodeId(1),
436        }];
437        let positions = layout.compute(&ids, &edges);
438        assert_eq!(positions.len(), 4);
439    }
440
441    #[test]
442    fn test_force_directed_positions_finite() {
443        let layout = GraphLayout::new(LayoutAlgorithm::ForceDirected {
444            iterations: 20,
445            ideal_length: 80.0,
446        });
447        let ids = node_ids(6);
448        let edges = vec![
449            LayoutEdge {
450                from: LayoutNodeId(0),
451                to: LayoutNodeId(1),
452            },
453            LayoutEdge {
454                from: LayoutNodeId(1),
455                to: LayoutNodeId(2),
456            },
457            LayoutEdge {
458                from: LayoutNodeId(2),
459                to: LayoutNodeId(3),
460            },
461        ];
462        let positions = layout.compute(&ids, &edges);
463        for (_, pos) in &positions {
464            assert!(pos.x.is_finite());
465            assert!(pos.y.is_finite());
466        }
467    }
468
469    #[test]
470    fn test_default_algorithm_is_grid() {
471        let algo = LayoutAlgorithm::default();
472        assert!(matches!(algo, LayoutAlgorithm::Grid { .. }));
473    }
474}