Skip to main content

bra0_layout/
lib.rs

1//! bra0-layout — Rust graph layout engine
2//!
3//! Implements Sugiyama-style layered (hierarchical) layout for directed graphs.
4//! Pure Rust, no external graph library needed for this focused use case.
5//!
6//! Architecture: Rust for compute, TypeScript for UI (ADR-026).
7
8use serde::{Deserialize, Serialize};
9use std::collections::{HashMap, HashSet, VecDeque};
10
11#[cfg(feature = "wasm")]
12use wasm_bindgen::prelude::*;
13
14// ── Public types ────────────────────────────────────────────
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct LayoutNode {
18    pub id: String,
19    pub width: f64,
20    pub height: f64,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct LayoutEdge {
25    pub source: String,
26    pub target: String,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct LayoutOptions {
31    /// Direction: "TB" (top-bottom), "LR" (left-right)
32    #[serde(default = "default_rankdir")]
33    pub rankdir: String,
34    /// Horizontal spacing between nodes in the same layer
35    #[serde(default = "default_nodesep")]
36    pub nodesep: f64,
37    /// Vertical spacing between layers
38    #[serde(default = "default_ranksep")]
39    pub ranksep: f64,
40}
41
42fn default_rankdir() -> String { "TB".to_string() }
43fn default_nodesep() -> f64 { 60.0 }
44fn default_ranksep() -> f64 { 80.0 }
45
46impl Default for LayoutOptions {
47    fn default() -> Self {
48        Self {
49            rankdir: default_rankdir(),
50            nodesep: default_nodesep(),
51            ranksep: default_ranksep(),
52        }
53    }
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct LayoutResult {
58    pub positions: HashMap<String, Position>,
59}
60
61#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
62pub struct Position {
63    pub x: f64,
64    pub y: f64,
65}
66
67// ── WASM entry point ────────────────────────────────────────
68
69#[cfg(feature = "wasm")]
70#[wasm_bindgen]
71pub fn hierarchical_layout(
72    nodes_json: &str,
73    edges_json: &str,
74    options_json: &str,
75) -> String {
76    let nodes: Vec<LayoutNode> = serde_json::from_str(nodes_json).unwrap_or_default();
77    let edges: Vec<LayoutEdge> = serde_json::from_str(edges_json).unwrap_or_default();
78    let options: LayoutOptions = serde_json::from_str(options_json).unwrap_or_default();
79    let result = layout_hierarchical(&nodes, &edges, &options);
80    serde_json::to_string(&result).unwrap_or_default()
81}
82
83// ── Native entry point ──────────────────────────────────────
84
85/// Sugiyama-style layered layout for directed graphs.
86///
87/// Algorithm phases:
88/// 1. Cycle removal (reverse back-edges via DFS)
89/// 2. Layer assignment (longest path from roots)
90/// 3. Crossing minimization (barycenter heuristic)
91/// 4. Position assignment (center nodes within layers)
92pub fn layout_hierarchical(
93    nodes: &[LayoutNode],
94    edges: &[LayoutEdge],
95    options: &LayoutOptions,
96) -> LayoutResult {
97    if nodes.is_empty() {
98        return LayoutResult { positions: HashMap::new() };
99    }
100
101    let node_ids: HashSet<&str> = nodes.iter().map(|n| n.id.as_str()).collect();
102    let dims: HashMap<&str, (f64, f64)> = nodes
103        .iter()
104        .map(|n| (n.id.as_str(), (n.width, n.height)))
105        .collect();
106
107    // Filter edges to valid nodes only
108    let valid_edges: Vec<(&str, &str)> = edges
109        .iter()
110        .filter(|e| node_ids.contains(e.source.as_str()) && node_ids.contains(e.target.as_str()))
111        .map(|e| (e.source.as_str(), e.target.as_str()))
112        .collect();
113
114    // Phase 1: Build adjacency and compute in-degree
115    let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
116    let mut in_degree: HashMap<&str, usize> = HashMap::new();
117    for &id in &node_ids {
118        adj.entry(id).or_default();
119        in_degree.entry(id).or_insert(0);
120    }
121    for &(src, tgt) in &valid_edges {
122        adj.entry(src).or_default().push(tgt);
123        *in_degree.entry(tgt).or_insert(0) += 1;
124    }
125
126    // Phase 2: Layer assignment via topological sort (Kahn's algorithm)
127    // Nodes in cycles get assigned to layer 0 (fallback)
128    let mut layers: HashMap<&str, usize> = HashMap::new();
129    let mut queue: VecDeque<&str> = VecDeque::new();
130
131    for (&id, &deg) in &in_degree {
132        if deg == 0 {
133            queue.push_back(id);
134            layers.insert(id, 0);
135        }
136    }
137
138    while let Some(node) = queue.pop_front() {
139        let layer = layers[node];
140        if let Some(children) = adj.get(node) {
141            for &child in children {
142                let child_layer = layers.entry(child).or_insert(0);
143                *child_layer = (*child_layer).max(layer + 1);
144                let deg = in_degree.get_mut(child).unwrap();
145                *deg -= 1;
146                if *deg == 0 {
147                    queue.push_back(child);
148                }
149            }
150        }
151    }
152
153    // Fallback: assign unvisited nodes (cycles) to layer 0
154    for &id in &node_ids {
155        layers.entry(id).or_insert(0);
156    }
157
158    // Group nodes by layer
159    let max_layer = layers.values().copied().max().unwrap_or(0);
160    let mut layer_nodes: Vec<Vec<&str>> = vec![vec![]; max_layer + 1];
161    for (&id, &layer) in &layers {
162        layer_nodes[layer].push(id);
163    }
164
165    // Phase 3: Crossing minimization (barycenter heuristic, 2 passes)
166    let rev_adj: HashMap<&str, Vec<&str>> = {
167        let mut r: HashMap<&str, Vec<&str>> = HashMap::new();
168        for &(src, tgt) in &valid_edges {
169            r.entry(tgt).or_default().push(src);
170        }
171        r
172    };
173
174    for _pass in 0..4 {
175        for layer_idx in 1..=max_layer {
176            let prev_positions: HashMap<&str, f64> = layer_nodes[layer_idx - 1]
177                .iter()
178                .enumerate()
179                .map(|(i, &id)| (id, i as f64))
180                .collect();
181
182            let mut barycenters: Vec<(&str, f64)> = layer_nodes[layer_idx]
183                .iter()
184                .map(|&id| {
185                    let parents = rev_adj.get(id).map(|v| v.as_slice()).unwrap_or(&[]);
186                    if parents.is_empty() {
187                        (id, f64::MAX)
188                    } else {
189                        let sum: f64 = parents
190                            .iter()
191                            .filter_map(|p| prev_positions.get(p))
192                            .sum();
193                        let count = parents.iter().filter(|p| prev_positions.contains_key(*p)).count();
194                        if count > 0 {
195                            (id, sum / count as f64)
196                        } else {
197                            (id, f64::MAX)
198                        }
199                    }
200                })
201                .collect();
202
203            barycenters.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
204            layer_nodes[layer_idx] = barycenters.into_iter().map(|(id, _)| id).collect();
205        }
206    }
207
208    // Phase 4: Position assignment
209    let is_horizontal = options.rankdir == "LR";
210    let mut positions = HashMap::new();
211
212    for (layer_idx, layer) in layer_nodes.iter().enumerate() {
213        let mut offset = 0.0;
214
215        for &id in layer {
216            let (w, h) = dims.get(id).copied().unwrap_or((160.0, 60.0));
217            let (node_main, node_cross) = if is_horizontal { (h, w) } else { (w, h) };
218
219            let main_pos = layer_idx as f64 * (if is_horizontal {
220                node_cross + options.ranksep
221            } else {
222                node_cross + options.ranksep
223            });
224
225            let cross_pos = offset + node_main / 2.0;
226            offset += node_main + options.nodesep;
227
228            let pos = if is_horizontal {
229                Position { x: main_pos, y: cross_pos }
230            } else {
231                Position { x: cross_pos, y: main_pos }
232            };
233
234            positions.insert(id.to_string(), pos);
235        }
236
237        // Center this layer
238        if !layer.is_empty() {
239            let total_width = offset - options.nodesep;
240            let shift = -total_width / 2.0;
241            for &id in layer {
242                if let Some(pos) = positions.get_mut(id) {
243                    if is_horizontal {
244                        pos.y += shift;
245                    } else {
246                        pos.x += shift;
247                    }
248                }
249            }
250        }
251    }
252
253    LayoutResult { positions }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn empty_graph() {
262        let result = layout_hierarchical(&[], &[], &LayoutOptions::default());
263        assert!(result.positions.is_empty());
264    }
265
266    #[test]
267    fn single_node() {
268        let nodes = vec![LayoutNode { id: "a".into(), width: 160.0, height: 60.0 }];
269        let result = layout_hierarchical(&nodes, &[], &LayoutOptions::default());
270        assert_eq!(result.positions.len(), 1);
271        assert!(result.positions.contains_key("a"));
272    }
273
274    #[test]
275    fn linear_chain() {
276        let nodes = vec![
277            LayoutNode { id: "a".into(), width: 100.0, height: 50.0 },
278            LayoutNode { id: "b".into(), width: 100.0, height: 50.0 },
279            LayoutNode { id: "c".into(), width: 100.0, height: 50.0 },
280        ];
281        let edges = vec![
282            LayoutEdge { source: "a".into(), target: "b".into() },
283            LayoutEdge { source: "b".into(), target: "c".into() },
284        ];
285        let result = layout_hierarchical(&nodes, &edges, &LayoutOptions::default());
286
287        let a = result.positions["a"];
288        let b = result.positions["b"];
289        let c = result.positions["c"];
290
291        // a should be above b, b above c (TB direction)
292        assert!(a.y < b.y, "a.y ({}) should be < b.y ({})", a.y, b.y);
293        assert!(b.y < c.y, "b.y ({}) should be < c.y ({})", b.y, c.y);
294    }
295
296    #[test]
297    fn diamond_graph() {
298        let nodes = vec![
299            LayoutNode { id: "top".into(), width: 100.0, height: 50.0 },
300            LayoutNode { id: "left".into(), width: 100.0, height: 50.0 },
301            LayoutNode { id: "right".into(), width: 100.0, height: 50.0 },
302            LayoutNode { id: "bottom".into(), width: 100.0, height: 50.0 },
303        ];
304        let edges = vec![
305            LayoutEdge { source: "top".into(), target: "left".into() },
306            LayoutEdge { source: "top".into(), target: "right".into() },
307            LayoutEdge { source: "left".into(), target: "bottom".into() },
308            LayoutEdge { source: "right".into(), target: "bottom".into() },
309        ];
310        let result = layout_hierarchical(&nodes, &edges, &LayoutOptions::default());
311
312        // 3 layers: top, {left, right}, bottom
313        let top = result.positions["top"];
314        let left = result.positions["left"];
315        let right = result.positions["right"];
316        let bottom = result.positions["bottom"];
317
318        assert!(top.y < left.y);
319        assert!(top.y < right.y);
320        assert!(left.y < bottom.y);
321        assert!(right.y < bottom.y);
322        // left and right on same layer
323        assert!((left.y - right.y).abs() < 1.0);
324    }
325
326    #[test]
327    fn handles_cycles() {
328        let nodes = vec![
329            LayoutNode { id: "a".into(), width: 100.0, height: 50.0 },
330            LayoutNode { id: "b".into(), width: 100.0, height: 50.0 },
331        ];
332        let edges = vec![
333            LayoutEdge { source: "a".into(), target: "b".into() },
334            LayoutEdge { source: "b".into(), target: "a".into() },
335        ];
336        // Should not panic, both nodes get positions
337        let result = layout_hierarchical(&nodes, &edges, &LayoutOptions::default());
338        assert_eq!(result.positions.len(), 2);
339    }
340
341    #[test]
342    fn horizontal_layout() {
343        let nodes = vec![
344            LayoutNode { id: "a".into(), width: 100.0, height: 50.0 },
345            LayoutNode { id: "b".into(), width: 100.0, height: 50.0 },
346        ];
347        let edges = vec![
348            LayoutEdge { source: "a".into(), target: "b".into() },
349        ];
350        let opts = LayoutOptions { rankdir: "LR".into(), ..Default::default() };
351        let result = layout_hierarchical(&nodes, &edges, &opts);
352
353        let a = result.positions["a"];
354        let b = result.positions["b"];
355        // LR: a should be left of b
356        assert!(a.x < b.x, "a.x ({}) should be < b.x ({})", a.x, b.x);
357    }
358}