Skip to main content

dlin_core/render/
layout.rs

1use petgraph::Direction;
2use petgraph::stable_graph::NodeIndex;
3use petgraph::visit::EdgeRef;
4use std::collections::HashMap;
5
6use crate::graph::types::LineageGraph;
7
8/// Layout result: each node gets a (layer, position_within_layer) coordinate
9#[derive(Debug, Clone)]
10pub struct LayoutResult {
11    /// Map from NodeIndex to (layer, position)
12    pub positions: HashMap<NodeIndex, (usize, usize)>,
13    /// Number of layers
14    pub num_layers: usize,
15    /// Max nodes in any layer
16    pub max_layer_width: usize,
17    /// Nodes in each layer, ordered by position
18    pub layers: Vec<Vec<NodeIndex>>,
19}
20
21/// Perform simplified Sugiyama layout
22pub fn sugiyama_layout(graph: &LineageGraph) -> LayoutResult {
23    if graph.node_count() == 0 {
24        return LayoutResult {
25            positions: HashMap::new(),
26            num_layers: 0,
27            max_layer_width: 0,
28            layers: Vec::new(),
29        };
30    }
31
32    // Step 1: Assign layers using longest path from roots
33    let layers = assign_layers(graph);
34
35    // Step 2: Order nodes within layers to minimize crossings (barycenter method)
36    let ordered_layers = reduce_crossings(graph, &layers);
37
38    // Step 3: Build position map
39    let mut positions = HashMap::new();
40    let mut max_width = 0;
41
42    for (layer_idx, layer) in ordered_layers.iter().enumerate() {
43        max_width = max_width.max(layer.len());
44        for (pos, &node) in layer.iter().enumerate() {
45            positions.insert(node, (layer_idx, pos));
46        }
47    }
48
49    LayoutResult {
50        positions,
51        num_layers: ordered_layers.len(),
52        max_layer_width: max_width,
53        layers: ordered_layers,
54    }
55}
56
57/// Assign layers using longest path from roots (nodes with no incoming edges)
58fn assign_layers(graph: &LineageGraph) -> Vec<Vec<NodeIndex>> {
59    let mut layer_of: HashMap<NodeIndex, usize> = HashMap::new();
60
61    // Use topological order for longest-path layer assignment
62    if let Ok(topo) = petgraph::algo::toposort(graph, None) {
63        for node in &topo {
64            let predecessors: Vec<usize> = graph
65                .edges_directed(*node, Direction::Incoming)
66                .filter_map(|e| layer_of.get(&e.source()).copied())
67                .collect();
68
69            let layer = if predecessors.is_empty() {
70                0
71            } else {
72                predecessors.iter().max().unwrap() + 1
73            };
74            layer_of.insert(*node, layer);
75        }
76    } else {
77        // Fallback for cyclic graphs (shouldn't happen after filter)
78        for (i, node) in graph.node_indices().enumerate() {
79            layer_of.insert(node, i);
80        }
81    }
82
83    // Group by layer
84    let max_layer = layer_of.values().copied().max().unwrap_or(0);
85    let mut layers: Vec<Vec<NodeIndex>> = vec![Vec::new(); max_layer + 1];
86    for (node, layer) in &layer_of {
87        layers[*layer].push(*node);
88    }
89
90    // Remove empty layers
91    layers.retain(|l| !l.is_empty());
92
93    layers
94}
95
96/// Reduce edge crossings using barycenter heuristic
97fn reduce_crossings(
98    graph: &LineageGraph,
99    initial_layers: &[Vec<NodeIndex>],
100) -> Vec<Vec<NodeIndex>> {
101    let mut layers = initial_layers.to_vec();
102
103    // Run 3 passes of barycenter ordering
104    for _ in 0..3 {
105        // Forward pass
106        for i in 1..layers.len() {
107            let prev_layer = layers[i - 1].clone();
108            sort_by_barycenter(graph, &mut layers[i], &prev_layer, Direction::Incoming);
109        }
110
111        // Backward pass
112        for i in (0..layers.len().saturating_sub(1)).rev() {
113            let next_layer = layers[i + 1].clone();
114            sort_by_barycenter(graph, &mut layers[i], &next_layer, Direction::Outgoing);
115        }
116    }
117
118    layers
119}
120
121/// Sort nodes in a layer based on barycenter of connected nodes in adjacent layer
122fn sort_by_barycenter(
123    graph: &LineageGraph,
124    layer: &mut [NodeIndex],
125    adjacent: &[NodeIndex],
126    direction: Direction,
127) {
128    let adj_positions: HashMap<NodeIndex, usize> =
129        adjacent.iter().enumerate().map(|(i, &n)| (n, i)).collect();
130
131    let mut barycenters: HashMap<NodeIndex, f64> = HashMap::new();
132
133    for &node in layer.iter() {
134        let neighbors: Vec<usize> = graph
135            .edges_directed(node, direction)
136            .filter_map(|e| {
137                let other = match direction {
138                    Direction::Incoming => e.source(),
139                    Direction::Outgoing => e.target(),
140                };
141                adj_positions.get(&other).copied()
142            })
143            .collect();
144
145        let bc = if neighbors.is_empty() {
146            f64::MAX // Keep at current position
147        } else {
148            neighbors.iter().sum::<usize>() as f64 / neighbors.len() as f64
149        };
150        barycenters.insert(node, bc);
151    }
152
153    layer.sort_by(|a, b| {
154        let ba = barycenters.get(a).unwrap_or(&f64::MAX);
155        let bb = barycenters.get(b).unwrap_or(&f64::MAX);
156        ba.partial_cmp(bb).unwrap_or(std::cmp::Ordering::Equal)
157    });
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use crate::graph::types::*;
164
165    #[test]
166    fn test_empty_graph() {
167        let g = LineageGraph::new();
168        let layout = sugiyama_layout(&g);
169        assert_eq!(layout.num_layers, 0);
170    }
171
172    use crate::render::test_helpers::make_node;
173
174    #[test]
175    fn test_disconnected_node_in_layer() {
176        // Create a graph where one node is disconnected from others
177        // This exercises the f64::MAX barycenter fallback (line 146)
178        let mut g = LineageGraph::new();
179        let a = g.add_node(make_node("a", "a", NodeType::Source));
180        let b = g.add_node(make_node("b", "b", NodeType::Model));
181        let c = g.add_node(make_node("c", "c", NodeType::Model)); // disconnected
182        g.add_edge(a, b, EdgeData::direct(EdgeType::Source));
183        // c has no edges — it's a disconnected node
184        let _ = c; // used for graph construction
185
186        let layout = sugiyama_layout(&g);
187        // Should handle disconnected nodes without panicking
188        assert!(layout.num_layers >= 1);
189        assert!(layout.positions.contains_key(&a));
190        assert!(layout.positions.contains_key(&b));
191        assert!(layout.positions.contains_key(&c));
192    }
193
194    #[test]
195    fn test_linear_graph() {
196        let mut g = LineageGraph::new();
197        let a = g.add_node(make_node("a", "a", NodeType::Source));
198        let b = g.add_node(make_node("b", "b", NodeType::Model));
199        let c = g.add_node(make_node("c", "c", NodeType::Model));
200        g.add_edge(a, b, EdgeData::direct(EdgeType::Source));
201        g.add_edge(b, c, EdgeData::direct(EdgeType::Ref));
202
203        let layout = sugiyama_layout(&g);
204        assert_eq!(layout.num_layers, 3);
205
206        let (la, _) = layout.positions[&a];
207        let (lb, _) = layout.positions[&b];
208        let (lc, _) = layout.positions[&c];
209        assert!(la < lb);
210        assert!(lb < lc);
211    }
212
213    #[test]
214    fn test_cyclic_graph_fallback() {
215        // Covers lines 78-79: cyclic graph fallback in assign_layers
216        let mut g = LineageGraph::new();
217        let a = g.add_node(make_node("a", "a", NodeType::Model));
218        let b = g.add_node(make_node("b", "b", NodeType::Model));
219        // Create a cycle: a -> b -> a
220        g.add_edge(a, b, EdgeData::direct(EdgeType::Ref));
221        g.add_edge(b, a, EdgeData::direct(EdgeType::Ref));
222
223        let layout = sugiyama_layout(&g);
224        // Should not panic; each node gets its own layer in fallback
225        assert_eq!(layout.positions.len(), 2);
226        assert!(layout.positions.contains_key(&a));
227        assert!(layout.positions.contains_key(&b));
228    }
229}