Skip to main content

batuta/tui/
graph_layout.rs

1//! Graph Layout Algorithms
2//!
3//! Layout algorithms for graph visualization in the TUI.
4//!
5//! ## Academic References
6//!
7//! - Fruchterman & Reingold (1991) - Force-directed layout
8//! - Kamada & Kawai (1989) - Spring-based layout
9//! - Sugiyama et al. (1981) - Hierarchical DAG layout
10
11use std::collections::HashMap;
12
13use super::graph::{Graph, Position};
14
15/// Convert usize to f32 for layout math. Graph node counts are small
16/// enough that f32 precision (24-bit mantissa) is always sufficient.
17#[inline]
18fn f(n: usize) -> f32 {
19    debug_assert!(n <= (1 << 24), "layout value {n} exceeds f32 mantissa precision");
20    n as f32
21}
22
23/// Layout algorithm selection
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub enum LayoutAlgorithm {
26    /// Simple grid layout (O(n))
27    #[default]
28    Grid,
29    /// Force-directed Fruchterman-Reingold (O(n²) per iteration)
30    ForceDirected,
31    /// Hierarchical Sugiyama for DAGs (O(nm log m))
32    Hierarchical,
33    /// Radial layout for trees (O(n + m))
34    Radial,
35    /// Circular layout - nodes in a circle (O(n)) - Cytoscape pattern
36    Circular,
37    /// Concentric layout - nodes in rings by metric (O(n)) - Cytoscape/Gephi pattern
38    Concentric,
39}
40
41/// Layout configuration
42#[derive(Debug, Clone)]
43pub struct LayoutConfig {
44    /// Algorithm to use
45    pub algorithm: LayoutAlgorithm,
46    /// Width of layout area
47    pub width: f32,
48    /// Height of layout area
49    pub height: f32,
50    /// Iterations for iterative algorithms
51    pub iterations: usize,
52    /// Optimal edge length for force-directed
53    pub optimal_distance: f32,
54    /// Cooling factor for force-directed
55    pub cooling: f32,
56}
57
58impl Default for LayoutConfig {
59    fn default() -> Self {
60        Self {
61            algorithm: LayoutAlgorithm::Grid,
62            width: 80.0,
63            height: 24.0,
64            iterations: 50,
65            optimal_distance: 3.0,
66            cooling: 0.95,
67        }
68    }
69}
70
71/// Layout engine
72pub struct LayoutEngine;
73
74impl LayoutEngine {
75    /// Compute layout for graph
76    pub fn compute<N, E>(graph: &mut Graph<N, E>, config: &LayoutConfig) {
77        match config.algorithm {
78            LayoutAlgorithm::Grid => Self::grid_layout(graph, config),
79            LayoutAlgorithm::ForceDirected => Self::force_directed_layout(graph, config),
80            LayoutAlgorithm::Hierarchical => Self::hierarchical_layout(graph, config),
81            LayoutAlgorithm::Circular => Self::circular_layout(graph, config),
82            LayoutAlgorithm::Concentric => Self::concentric_layout(graph, config),
83            LayoutAlgorithm::Radial => Self::radial_layout(graph, config),
84        }
85    }
86
87    /// Grid layout - O(n)
88    fn grid_layout<N, E>(graph: &mut Graph<N, E>, config: &LayoutConfig) {
89        let n = graph.node_count();
90        if n == 0 {
91            return;
92        }
93
94        let cols = f(n).sqrt().ceil() as usize;
95        let rows = n.div_ceil(cols);
96
97        let cell_width = config.width / f(cols);
98        let cell_height = config.height / f(rows);
99
100        for (i, node) in graph.nodes_mut().enumerate() {
101            let col = i % cols;
102            let row = i / cols;
103            node.position =
104                Position::new((f(col) + 0.5) * cell_width, (f(row) + 0.5) * cell_height);
105        }
106    }
107
108    /// Force-directed layout - Fruchterman-Reingold
109    /// O(n²) per iteration, with software fallback for non-SIMD (Jidoka per peer review #4)
110    fn force_directed_layout<N, E>(graph: &mut Graph<N, E>, config: &LayoutConfig) {
111        let n = graph.node_count();
112        if n == 0 {
113            return;
114        }
115
116        // Initialize with grid layout
117        Self::grid_layout(graph, config);
118
119        let k = config.optimal_distance;
120        let k_squared = k * k;
121        let mut temperature = config.width.min(config.height) / 10.0;
122
123        // Collect node IDs for iteration
124        let node_ids: Vec<String> = graph.nodes.keys().cloned().collect();
125
126        for _ in 0..config.iterations {
127            // Compute forces
128            let mut forces: HashMap<String, (f32, f32)> = HashMap::new();
129            for id in &node_ids {
130                forces.insert(id.clone(), (0.0, 0.0));
131            }
132
133            // Repulsive forces between all pairs
134            for i in 0..node_ids.len() {
135                for j in (i + 1)..node_ids.len() {
136                    let id_i = &node_ids[i];
137                    let id_j = &node_ids[j];
138
139                    let pos_i = graph.nodes.get(id_i).map(|n| n.position).unwrap_or_default();
140                    let pos_j = graph.nodes.get(id_j).map(|n| n.position).unwrap_or_default();
141
142                    let dx = pos_i.x - pos_j.x;
143                    let dy = pos_i.y - pos_j.y;
144                    let dist = (dx * dx + dy * dy).sqrt().max(0.01);
145
146                    // Repulsive force: k² / d
147                    let force = k_squared / dist;
148                    let fx = (dx / dist) * force;
149                    let fy = (dy / dist) * force;
150
151                    if let Some(f) = forces.get_mut(id_i) {
152                        f.0 += fx;
153                        f.1 += fy;
154                    }
155                    if let Some(f) = forces.get_mut(id_j) {
156                        f.0 -= fx;
157                        f.1 -= fy;
158                    }
159                }
160            }
161
162            // Attractive forces along edges
163            for edge in &graph.edges {
164                let pos_from = graph.nodes.get(&edge.from).map(|n| n.position).unwrap_or_default();
165                let pos_to = graph.nodes.get(&edge.to).map(|n| n.position).unwrap_or_default();
166
167                let dx = pos_to.x - pos_from.x;
168                let dy = pos_to.y - pos_from.y;
169                let dist = (dx * dx + dy * dy).sqrt().max(0.01);
170
171                // Attractive force: d² / k
172                let force = (dist * dist) / k;
173                let fx = (dx / dist) * force;
174                let fy = (dy / dist) * force;
175
176                if let Some(f) = forces.get_mut(&edge.from) {
177                    f.0 += fx;
178                    f.1 += fy;
179                }
180                if let Some(f) = forces.get_mut(&edge.to) {
181                    f.0 -= fx;
182                    f.1 -= fy;
183                }
184            }
185
186            // Apply forces with temperature limiting
187            for id in &node_ids {
188                if let (Some(node), Some(&(fx, fy))) = (graph.nodes.get_mut(id), forces.get(id)) {
189                    let mag = (fx * fx + fy * fy).sqrt().max(0.01);
190                    let capped_mag = mag.min(temperature);
191                    node.position.x += (fx / mag) * capped_mag;
192                    node.position.y += (fy / mag) * capped_mag;
193
194                    // Clamp to bounds
195                    node.position.x = node.position.x.clamp(1.0, config.width - 1.0);
196                    node.position.y = node.position.y.clamp(1.0, config.height - 1.0);
197                }
198            }
199
200            // Cool down
201            temperature *= config.cooling;
202        }
203    }
204
205    /// Hierarchical layout - simplified Sugiyama
206    fn hierarchical_layout<N, E>(graph: &mut Graph<N, E>, config: &LayoutConfig) {
207        let n = graph.node_count();
208        if n == 0 {
209            return;
210        }
211
212        // Compute in-degrees for layer assignment
213        let mut in_degree: HashMap<String, usize> = HashMap::new();
214        for id in graph.nodes.keys() {
215            in_degree.insert(id.clone(), 0);
216        }
217        for edge in &graph.edges {
218            *in_degree.entry(edge.to.clone()).or_default() += 1;
219        }
220
221        // Assign layers based on longest path
222        let mut layers: HashMap<String, usize> = HashMap::new();
223        let mut queue: Vec<String> =
224            in_degree.iter().filter(|(_, &d)| d == 0).map(|(id, _)| id.clone()).collect();
225
226        for id in &queue {
227            layers.insert(id.clone(), 0);
228        }
229
230        let mut max_layer = 0;
231        while let Some(id) = queue.pop() {
232            let current_layer = *layers.get(&id).unwrap_or(&0);
233            for neighbor in graph.neighbors(&id) {
234                let new_layer = current_layer + 1;
235                let entry = layers.entry(neighbor.to_string()).or_insert(0);
236                if new_layer > *entry {
237                    *entry = new_layer;
238                    max_layer = max_layer.max(new_layer);
239                }
240                queue.push(neighbor.to_string());
241            }
242        }
243
244        // Position nodes by layer
245        let layer_height = config.height / f(max_layer + 1);
246        let mut layer_counts: HashMap<usize, usize> = HashMap::new();
247        let mut layer_positions: HashMap<usize, usize> = HashMap::new();
248
249        // Count nodes per layer
250        for &layer in layers.values() {
251            *layer_counts.entry(layer).or_default() += 1;
252        }
253
254        // Position nodes
255        for node in graph.nodes_mut() {
256            let layer = *layers.get(&node.id).unwrap_or(&0);
257            let count = *layer_counts.get(&layer).unwrap_or(&1);
258            let pos_in_layer = *layer_positions.entry(layer).or_default();
259            layer_positions.insert(layer, pos_in_layer + 1);
260
261            let layer_width = config.width / f(count);
262            node.position = Position::new(
263                (f(pos_in_layer) + 0.5) * layer_width,
264                (f(layer) + 0.5) * layer_height,
265            );
266        }
267    }
268
269    /// Radial layout - for tree structures
270    fn radial_layout<N, E>(graph: &mut Graph<N, E>, config: &LayoutConfig) {
271        let n = graph.node_count();
272        if n == 0 {
273            return;
274        }
275
276        let center_x = config.width / 2.0;
277        let center_y = config.height / 2.0;
278        let radius = config.width.min(config.height) / 2.5;
279
280        // Find root (node with no incoming edges)
281        let mut in_degree: HashMap<String, usize> = HashMap::new();
282        for id in graph.nodes.keys() {
283            in_degree.insert(id.clone(), 0);
284        }
285        for edge in &graph.edges {
286            *in_degree.entry(edge.to.clone()).or_default() += 1;
287        }
288
289        let root = in_degree.iter().find(|(_, &d)| d == 0).map(|(id, _)| id.clone());
290
291        if let Some(root_id) = root {
292            // Place root at center
293            if let Some(node) = graph.nodes.get_mut(&root_id) {
294                node.position = Position::new(center_x, center_y);
295            }
296
297            // BFS to assign radial positions
298            let mut visited: HashMap<String, usize> = HashMap::new();
299            visited.insert(root_id.clone(), 0);
300            let root_id_clone = root_id.clone();
301            let mut queue = vec![root_id];
302            let mut depth_counts: HashMap<usize, usize> = HashMap::new();
303            let mut depth_positions: HashMap<usize, usize> = HashMap::new();
304
305            while let Some(id) = queue.pop() {
306                let depth = *visited.get(&id).unwrap_or(&0);
307                for neighbor in graph.neighbors(&id) {
308                    if !visited.contains_key(neighbor) {
309                        visited.insert(neighbor.to_string(), depth + 1);
310                        *depth_counts.entry(depth + 1).or_default() += 1;
311                        queue.push(neighbor.to_string());
312                    }
313                }
314            }
315
316            // Position nodes radially
317            let max_depth = visited.values().max().copied().unwrap_or(1);
318            let mut unvisited_idx = 0;
319            let unvisited_count = graph.node_count() - visited.len();
320
321            for node in graph.nodes_mut() {
322                if node.id == root_id_clone {
323                    continue;
324                }
325
326                if let Some(&depth) = visited.get(&node.id) {
327                    // Node was visited by BFS - position radially
328                    let count = *depth_counts.get(&depth).unwrap_or(&1);
329                    let pos = *depth_positions.entry(depth).or_default();
330                    depth_positions.insert(depth, pos + 1);
331
332                    let angle = 2.0 * std::f32::consts::PI * (f(pos) / f(count));
333                    let r = radius * (f(depth) / f(max_depth));
334
335                    node.position = Position::new(
336                        (center_x + r * angle.cos()).clamp(1.0, config.width - 1.0),
337                        (center_y + r * angle.sin()).clamp(1.0, config.height - 1.0),
338                    );
339                } else {
340                    // Unvisited/disconnected node - place on outer ring
341                    let angle =
342                        2.0 * std::f32::consts::PI * (f(unvisited_idx) / f(unvisited_count.max(1)));
343                    let r = radius * 1.2; // Slightly outside main graph
344                    unvisited_idx += 1;
345
346                    node.position = Position::new(
347                        (center_x + r * angle.cos()).clamp(1.0, config.width - 1.0),
348                        (center_y + r * angle.sin()).clamp(1.0, config.height - 1.0),
349                    );
350                }
351            }
352        } else {
353            // Fallback to grid if no root found
354            Self::grid_layout(graph, config);
355        }
356    }
357
358    /// Circular layout - nodes arranged in a circle (Cytoscape pattern)
359    fn circular_layout<N, E>(graph: &mut Graph<N, E>, config: &LayoutConfig) {
360        let n = graph.node_count();
361        if n == 0 {
362            return;
363        }
364
365        let center_x = config.width / 2.0;
366        let center_y = config.height / 2.0;
367        let radius = config.width.min(config.height) / 2.5;
368
369        for (i, node) in graph.nodes_mut().enumerate() {
370            let angle = 2.0 * std::f32::consts::PI * (f(i) / f(n));
371            node.position = Position::new(
372                (center_x + radius * angle.cos()).clamp(1.0, config.width - 1.0),
373                (center_y + radius * angle.sin()).clamp(1.0, config.height - 1.0),
374            );
375        }
376    }
377
378    /// Concentric layout - nodes in rings by importance/degree (Cytoscape/Gephi pattern)
379    fn concentric_layout<N, E>(graph: &mut Graph<N, E>, config: &LayoutConfig) {
380        let n = graph.node_count();
381        if n == 0 {
382            return;
383        }
384
385        let center_x = config.width / 2.0;
386        let center_y = config.height / 2.0;
387        let max_radius = config.width.min(config.height) / 2.5;
388
389        // Sort nodes by importance (highest importance = center)
390        let mut node_order: Vec<(String, f32)> =
391            graph.nodes().map(|n| (n.id.clone(), n.importance)).collect();
392        node_order.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
393
394        // Assign to concentric rings (higher importance = smaller ring)
395        let num_rings = 4.min(n);
396        let nodes_per_ring = n.div_ceil(num_rings);
397
398        for (idx, (node_id, _)) in node_order.iter().enumerate() {
399            let ring = idx / nodes_per_ring;
400            let pos_in_ring = idx % nodes_per_ring;
401            let nodes_in_this_ring =
402                if ring == num_rings - 1 { n - ring * nodes_per_ring } else { nodes_per_ring };
403
404            // Ring 0 = center, ring N = outer
405            let radius = if ring == 0 && nodes_in_this_ring == 1 {
406                0.0 // Single node at center
407            } else {
408                max_radius * (f(ring + 1) / f(num_rings))
409            };
410
411            let angle = 2.0 * std::f32::consts::PI * (f(pos_in_ring) / f(nodes_in_this_ring));
412
413            if let Some(node) = graph.nodes.get_mut(node_id) {
414                node.position = Position::new(
415                    (center_x + radius * angle.cos()).clamp(1.0, config.width - 1.0),
416                    (center_y + radius * angle.sin()).clamp(1.0, config.height - 1.0),
417                );
418            }
419        }
420    }
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426    use crate::tui::graph::Node;
427
428    #[test]
429    fn test_layout_algorithm_default() {
430        assert_eq!(LayoutAlgorithm::default(), LayoutAlgorithm::Grid);
431    }
432
433    #[test]
434    fn test_layout_config_default() {
435        let config = LayoutConfig::default();
436        assert_eq!(config.algorithm, LayoutAlgorithm::Grid);
437        assert_eq!(config.width, 80.0);
438        assert_eq!(config.height, 24.0);
439        assert_eq!(config.iterations, 50);
440    }
441
442    #[test]
443    fn test_grid_layout_empty() {
444        let mut graph: Graph<(), ()> = Graph::new();
445        let config = LayoutConfig::default();
446        LayoutEngine::compute(&mut graph, &config);
447        assert_eq!(graph.node_count(), 0);
448    }
449
450    #[test]
451    fn test_grid_layout_single() {
452        let mut graph: Graph<(), ()> = Graph::new();
453        graph.add_node(Node::new("a", ()));
454        let config = LayoutConfig::default();
455        LayoutEngine::compute(&mut graph, &config);
456
457        let node = graph.nodes.get("a").expect("key not found");
458        assert!(node.position.x > 0.0);
459        assert!(node.position.y > 0.0);
460    }
461
462    #[test]
463    fn test_circular_layout() {
464        let mut graph: Graph<(), ()> = Graph::new();
465        graph.add_node(Node::new("a", ()));
466        graph.add_node(Node::new("b", ()));
467        graph.add_node(Node::new("c", ()));
468
469        let config = LayoutConfig { algorithm: LayoutAlgorithm::Circular, ..Default::default() };
470        LayoutEngine::compute(&mut graph, &config);
471
472        // All nodes should be positioned
473        for node in graph.nodes() {
474            assert!(node.position.x > 0.0);
475            assert!(node.position.y > 0.0);
476        }
477    }
478
479    #[test]
480    fn test_force_directed_layout() {
481        use crate::tui::graph::Edge;
482        let mut graph: Graph<(), ()> = Graph::new();
483        graph.add_node(Node::new("a", ()));
484        graph.add_node(Node::new("b", ()));
485        graph.add_edge(Edge::new("a", "b", ()));
486
487        let config = LayoutConfig {
488            algorithm: LayoutAlgorithm::ForceDirected,
489            iterations: 5,
490            ..Default::default()
491        };
492        LayoutEngine::compute(&mut graph, &config);
493
494        // All nodes should be within bounds
495        for node in graph.nodes() {
496            assert!(node.position.x >= 1.0);
497            assert!(node.position.y >= 1.0);
498            assert!(node.position.x <= config.width - 1.0);
499            assert!(node.position.y <= config.height - 1.0);
500        }
501    }
502
503    #[test]
504    fn test_hierarchical_layout() {
505        use crate::tui::graph::Edge;
506        let mut graph: Graph<(), ()> = Graph::new();
507        graph.add_node(Node::new("root", ()));
508        graph.add_node(Node::new("child1", ()));
509        graph.add_node(Node::new("child2", ()));
510        graph.add_edge(Edge::new("root", "child1", ()));
511        graph.add_edge(Edge::new("root", "child2", ()));
512
513        let config =
514            LayoutConfig { algorithm: LayoutAlgorithm::Hierarchical, ..Default::default() };
515        LayoutEngine::compute(&mut graph, &config);
516
517        // All nodes should be positioned
518        for node in graph.nodes() {
519            assert!(node.position.x > 0.0);
520            assert!(node.position.y > 0.0);
521        }
522    }
523
524    #[test]
525    fn test_radial_layout() {
526        use crate::tui::graph::Edge;
527        let mut graph: Graph<(), ()> = Graph::new();
528        graph.add_node(Node::new("center", ()));
529        graph.add_node(Node::new("leaf1", ()));
530        graph.add_node(Node::new("leaf2", ()));
531        graph.add_edge(Edge::new("center", "leaf1", ()));
532        graph.add_edge(Edge::new("center", "leaf2", ()));
533
534        let config = LayoutConfig { algorithm: LayoutAlgorithm::Radial, ..Default::default() };
535        LayoutEngine::compute(&mut graph, &config);
536
537        // All nodes should be positioned
538        for node in graph.nodes() {
539            assert!(node.position.x > 0.0);
540            assert!(node.position.y > 0.0);
541        }
542    }
543
544    #[test]
545    fn test_concentric_layout() {
546        let mut graph: Graph<(), ()> = Graph::new();
547        let mut node1 = Node::new("important", ());
548        node1.importance = 1.0;
549        let mut node2 = Node::new("less", ());
550        node2.importance = 0.5;
551        let mut node3 = Node::new("least", ());
552        node3.importance = 0.1;
553        graph.add_node(node1);
554        graph.add_node(node2);
555        graph.add_node(node3);
556
557        let config = LayoutConfig { algorithm: LayoutAlgorithm::Concentric, ..Default::default() };
558        LayoutEngine::compute(&mut graph, &config);
559
560        // All nodes should be positioned
561        for node in graph.nodes() {
562            assert!(node.position.x > 0.0);
563            assert!(node.position.y > 0.0);
564        }
565    }
566
567    #[test]
568    fn test_radial_layout_no_root() {
569        use crate::tui::graph::Edge;
570        let mut graph: Graph<(), ()> = Graph::new();
571        graph.add_node(Node::new("a", ()));
572        graph.add_node(Node::new("b", ()));
573        // Create a cycle - no clear root
574        graph.add_edge(Edge::new("a", "b", ()));
575        graph.add_edge(Edge::new("b", "a", ()));
576
577        let config = LayoutConfig { algorithm: LayoutAlgorithm::Radial, ..Default::default() };
578        LayoutEngine::compute(&mut graph, &config);
579
580        // Should fallback to grid layout
581        for node in graph.nodes() {
582            assert!(node.position.x > 0.0);
583            assert!(node.position.y > 0.0);
584        }
585    }
586
587    #[test]
588    fn test_layout_algorithm_variants() {
589        assert_ne!(LayoutAlgorithm::Grid, LayoutAlgorithm::ForceDirected);
590        assert_ne!(LayoutAlgorithm::Hierarchical, LayoutAlgorithm::Radial);
591        assert_ne!(LayoutAlgorithm::Circular, LayoutAlgorithm::Concentric);
592    }
593
594    #[test]
595    fn test_layout_config_custom() {
596        let config = LayoutConfig {
597            algorithm: LayoutAlgorithm::ForceDirected,
598            width: 100.0,
599            height: 50.0,
600            iterations: 100,
601            optimal_distance: 5.0,
602            cooling: 0.9,
603        };
604        assert_eq!(config.iterations, 100);
605        assert_eq!(config.optimal_distance, 5.0);
606        assert_eq!(config.cooling, 0.9);
607    }
608
609    #[test]
610    fn test_grid_layout_multiple_rows() {
611        let mut graph: Graph<(), ()> = Graph::new();
612        for i in 0..6 {
613            graph.add_node(Node::new(&format!("n{}", i), ()));
614        }
615
616        let config = LayoutConfig::default();
617        LayoutEngine::compute(&mut graph, &config);
618
619        // All nodes should have different positions
620        let positions: Vec<_> = graph.nodes().map(|n| (n.position.x, n.position.y)).collect();
621        for i in 0..positions.len() {
622            for j in (i + 1)..positions.len() {
623                assert!(positions[i] != positions[j] || i == j);
624            }
625        }
626    }
627
628    #[test]
629    fn test_hierarchical_layout_empty() {
630        let mut graph: Graph<(), ()> = Graph::new();
631        let config =
632            LayoutConfig { algorithm: LayoutAlgorithm::Hierarchical, ..Default::default() };
633        LayoutEngine::compute(&mut graph, &config);
634        assert_eq!(graph.node_count(), 0);
635    }
636
637    #[test]
638    fn test_radial_layout_empty() {
639        let mut graph: Graph<(), ()> = Graph::new();
640        let config = LayoutConfig { algorithm: LayoutAlgorithm::Radial, ..Default::default() };
641        LayoutEngine::compute(&mut graph, &config);
642        assert_eq!(graph.node_count(), 0);
643    }
644
645    #[test]
646    fn test_force_directed_layout_empty() {
647        let mut graph: Graph<(), ()> = Graph::new();
648        let config =
649            LayoutConfig { algorithm: LayoutAlgorithm::ForceDirected, ..Default::default() };
650        LayoutEngine::compute(&mut graph, &config);
651        assert_eq!(graph.node_count(), 0);
652    }
653
654    #[test]
655    fn test_circular_layout_empty() {
656        let mut graph: Graph<(), ()> = Graph::new();
657        let config = LayoutConfig { algorithm: LayoutAlgorithm::Circular, ..Default::default() };
658        LayoutEngine::compute(&mut graph, &config);
659        assert_eq!(graph.node_count(), 0);
660    }
661
662    #[test]
663    fn test_concentric_layout_empty() {
664        let mut graph: Graph<(), ()> = Graph::new();
665        let config = LayoutConfig { algorithm: LayoutAlgorithm::Concentric, ..Default::default() };
666        LayoutEngine::compute(&mut graph, &config);
667        assert_eq!(graph.node_count(), 0);
668    }
669}