1use dagre_dgl_rs::{EdgeLabel, Graph, GraphLabel, NodeLabel as DagreNodeLabel};
8
9use std::collections::BTreeMap;
10
11use crate::manifest::{Link, Manifest, Node, NodeId};
12
13#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
15pub struct Point {
16 pub x: f64,
17 pub y: f64,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
22pub struct Rect {
23 pub x: f64,
24 pub y: f64,
25 pub width: f64,
26 pub height: f64,
27}
28
29#[derive(Debug, Clone)]
31pub struct LayoutOptions {
32 pub node_width: f64,
34 pub node_height: f64,
36 pub node_sep: f64,
38 pub rank_sep: f64,
40}
41
42impl Default for LayoutOptions {
43 fn default() -> Self {
44 Self {
45 node_width: 180.0,
46 node_height: 60.0,
47 node_sep: 50.0,
48 rank_sep: 50.0,
49 }
50 }
51}
52
53#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
55pub struct LayoutResult {
56 pub positions: Vec<NodePosition>,
57 pub bounds: Rect,
58}
59
60#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
62pub struct NodePosition {
63 pub id: NodeId,
64 pub pos: Point,
65}
66
67pub fn layout(manifest: &Manifest, opts: &LayoutOptions) -> LayoutResult {
77 if manifest.nodes.is_empty() {
78 return LayoutResult {
79 positions: Vec::new(),
80 bounds: Rect {
81 x: 0.0,
82 y: 0.0,
83 width: 0.0,
84 height: 0.0,
85 },
86 };
87 }
88
89 let mut g = Graph::default();
90 g.set_graph(GraphLabel {
91 rankdir: Some("TB".to_string()),
92 nodesep: Some(opts.node_sep),
93 ranksep: Some(opts.rank_sep),
94 ..Default::default()
95 });
96
97 let mut sorted_ids: Vec<&NodeId> = manifest.nodes.iter().map(|n| &n.id).collect();
99 sorted_ids.sort();
100 for id in &sorted_ids {
101 g.set_node(
102 id.as_str(),
103 DagreNodeLabel {
104 width: opts.node_width,
105 height: opts.node_height,
106 ..Default::default()
107 },
108 );
109 }
110
111 debug_assert!(
113 !has_cycle(&manifest.nodes, &manifest.links),
114 "cycle reached layout — parse should have rejected this"
115 );
116 for link in &manifest.links {
117 g.set_edge(
118 link.from.as_str(),
119 link.to.as_str(),
120 EdgeLabel::default(),
121 None,
122 );
123 }
124
125 dagre_dgl_rs::layout(&mut g);
126
127 let mut positions = Vec::with_capacity(manifest.nodes.len());
129 let mut min_x = f64::INFINITY;
130 let mut min_y = f64::INFINITY;
131 let mut max_x = f64::NEG_INFINITY;
132 let mut max_y = f64::NEG_INFINITY;
133
134 for node in &manifest.nodes {
135 let nl = g.node(node.id.as_str());
136 let x = canonicalize(nl.x.unwrap_or(0.0));
137 let y = canonicalize(nl.y.unwrap_or(0.0));
138 positions.push(NodePosition {
139 id: node.id.clone(),
140 pos: Point { x, y },
141 });
142
143 let half_w = opts.node_width / 2.0;
144 let half_h = opts.node_height / 2.0;
145 min_x = min_x.min(x - half_w);
146 min_y = min_y.min(y - half_h);
147 max_x = max_x.max(x + half_w);
148 max_y = max_y.max(y + half_h);
149 }
150
151 let bounds = Rect {
152 x: canonicalize(min_x),
153 y: canonicalize(min_y),
154 width: canonicalize(max_x - min_x),
155 height: canonicalize(max_y - min_y),
156 };
157
158 LayoutResult { positions, bounds }
159}
160
161fn canonicalize(v: f64) -> f64 {
163 let rounded = (v * 1_000_000.0).round() / 1_000_000.0;
164 if rounded == 0.0 { 0.0 } else { rounded }
165}
166
167fn has_cycle(nodes: &[Node], links: &[Link]) -> bool {
169 let mut adj: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
170 for link in links {
171 adj.entry(link.from.as_str())
172 .or_default()
173 .push(link.to.as_str());
174 }
175 let mut color: BTreeMap<&str, u8> = BTreeMap::new();
176 for node in nodes {
177 if color.get(node.id.as_str()).copied().unwrap_or(0) == 0
178 && visit_cycle(node.id.as_str(), &adj, &mut color)
179 {
180 return true;
181 }
182 }
183 false
184}
185
186fn visit_cycle<'a>(
187 u: &'a str,
188 adj: &BTreeMap<&'a str, Vec<&'a str>>,
189 color: &mut BTreeMap<&'a str, u8>,
190) -> bool {
191 color.insert(u, 1);
192 if let Some(neighbors) = adj.get(u) {
193 for &v in neighbors {
194 match color.get(v).copied().unwrap_or(0) {
195 0 => {
196 if visit_cycle(v, adj, color) {
197 return true;
198 }
199 }
200 1 => return true,
201 _ => {}
202 }
203 }
204 }
205 color.insert(u, 2);
206 false
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212 use crate::manifest::{Link, LinkKind, Manifest, Node, NodeFields, NodeId, NodeKind};
213
214 fn simple_manifest() -> Manifest {
215 Manifest {
216 nodes: vec![
217 Node {
218 id: NodeId::new("N01"),
219 kind: NodeKind::Question,
220 label: Some("Q?".into()),
221 support_level: None,
222 source_refs: vec![],
223 description: None,
224 fields: NodeFields::Question,
225 evidence_notes: vec![],
226 pos: None,
227 },
228 Node {
229 id: NodeId::new("N02"),
230 kind: NodeKind::Experiment,
231 label: Some("Exp".into()),
232 support_level: None,
233 source_refs: vec![],
234 description: None,
235 fields: NodeFields::Experiment { result: None },
236 evidence_notes: vec![],
237 pos: None,
238 },
239 Node {
240 id: NodeId::new("N03"),
241 kind: NodeKind::Decision,
242 label: Some("Dec".into()),
243 support_level: None,
244 source_refs: vec![],
245 description: None,
246 fields: NodeFields::Decision {
247 choice: None,
248 alternatives: vec![],
249 rationale: None,
250 },
251 evidence_notes: vec![],
252 pos: None,
253 },
254 ],
255 links: vec![
256 Link {
257 from: NodeId::new("N01"),
258 to: NodeId::new("N02"),
259 kind: LinkKind::Child,
260 },
261 Link {
262 from: NodeId::new("N01"),
263 to: NodeId::new("N03"),
264 kind: LinkKind::Child,
265 },
266 ],
267 bindings: vec![],
268 claims: vec![],
269 bounds: None,
270 }
271 }
272
273 #[test]
274 fn all_positions_finite() {
275 let m = simple_manifest();
276 let result = layout(&m, &LayoutOptions::default());
277 for np in &result.positions {
278 assert!(np.pos.x.is_finite(), "NaN/inf x for {}", np.id);
279 assert!(np.pos.y.is_finite(), "NaN/inf y for {}", np.id);
280 }
281 }
282
283 #[test]
284 fn ranks_monotonic_along_child_edges() {
285 let m = simple_manifest();
286 let result = layout(&m, &LayoutOptions::default());
287 let pos_map: std::collections::HashMap<&str, &Point> = result
288 .positions
289 .iter()
290 .map(|np| (np.id.as_str(), &np.pos))
291 .collect();
292 for link in &m.links {
293 if link.kind == LinkKind::Child {
294 let from_y = pos_map[link.from.as_str()].y;
295 let to_y = pos_map[link.to.as_str()].y;
296 assert!(
297 from_y < to_y,
298 "rank not monotonic: {} (y={}) -> {} (y={})",
299 link.from,
300 from_y,
301 link.to,
302 to_y
303 );
304 }
305 }
306 }
307
308 #[test]
309 fn tie_break_stable_across_input_order() {
310 let m1 = simple_manifest();
311 let mut m2 = simple_manifest();
312 m2.nodes.reverse(); let r1 = layout(&m1, &LayoutOptions::default());
314 let r2 = layout(&m2, &LayoutOptions::default());
315 let mut p1: Vec<_> = r1.positions.clone();
317 let mut p2: Vec<_> = r2.positions.clone();
318 p1.sort_by(|a, b| a.id.cmp(&b.id));
319 p2.sort_by(|a, b| a.id.cmp(&b.id));
320 assert_eq!(p1, p2);
321 assert_eq!(r1.bounds, r2.bounds);
322 }
323
324 #[test]
325 fn empty_manifest_produces_zero_bounds() {
326 let m = Manifest {
327 nodes: vec![],
328 links: vec![],
329 bindings: vec![],
330 claims: vec![],
331 bounds: None,
332 };
333 let result = layout(&m, &LayoutOptions::default());
334 assert!(result.positions.is_empty());
335 assert_eq!(result.bounds.width, 0.0);
336 assert_eq!(result.bounds.height, 0.0);
337 }
338
339 #[test]
340 fn single_node_has_finite_pos_and_enclosing_bounds() {
341 let m = Manifest {
342 nodes: vec![Node {
343 id: NodeId::new("N01"),
344 kind: NodeKind::Question,
345 label: None,
346 support_level: None,
347 source_refs: vec![],
348 description: None,
349 fields: NodeFields::Question,
350 evidence_notes: vec![],
351 pos: None,
352 }],
353 links: vec![],
354 bindings: vec![],
355 claims: vec![],
356 bounds: None,
357 };
358 let opts = LayoutOptions::default();
359 let result = layout(&m, &opts);
360 assert_eq!(result.positions.len(), 1);
361 assert!(result.positions[0].pos.x.is_finite());
362 assert!(result.positions[0].pos.y.is_finite());
363 assert!(result.bounds.width >= opts.node_width);
364 assert!(result.bounds.height >= opts.node_height);
365 }
366
367 #[test]
368 fn bounds_enclose_all_node_rects() {
369 let m = simple_manifest();
370 let opts = LayoutOptions::default();
371 let result = layout(&m, &opts);
372 let half_w = opts.node_width / 2.0;
373 let half_h = opts.node_height / 2.0;
374 for np in &result.positions {
375 assert!(np.pos.x - half_w >= result.bounds.x - 1e-9);
376 assert!(np.pos.y - half_h >= result.bounds.y - 1e-9);
377 assert!(np.pos.x + half_w <= result.bounds.x + result.bounds.width + 1e-9);
378 assert!(np.pos.y + half_h <= result.bounds.y + result.bounds.height + 1e-9);
379 }
380 }
381
382 #[test]
383 fn canonicalize_normalizes_negative_zero() {
384 assert_eq!(canonicalize(-0.0), 0.0);
385 assert_eq!(canonicalize(-0.0).to_bits(), 0.0_f64.to_bits());
386 }
387
388 #[test]
389 fn canonicalize_rounds_to_six_decimals() {
390 let v = 1.23456789;
391 assert_eq!(canonicalize(v), 1.234568);
392 }
393
394 #[test]
395 fn layout_twice_identical() {
396 let m = simple_manifest();
397 let opts = LayoutOptions::default();
398 let r1 = layout(&m, &opts);
399 let r2 = layout(&m, &opts);
400 let j1 = serde_json::to_string(&r1).unwrap();
401 let j2 = serde_json::to_string(&r2).unwrap();
402 assert_eq!(j1, j2);
403 }
404}