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 isolated: false,
227 pos: None,
228 },
229 Node {
230 id: NodeId::new("N02"),
231 kind: NodeKind::Experiment,
232 label: Some("Exp".into()),
233 support_level: None,
234 source_refs: vec![],
235 description: None,
236 fields: NodeFields::Experiment { result: None },
237 evidence_notes: vec![],
238 isolated: false,
239 pos: None,
240 },
241 Node {
242 id: NodeId::new("N03"),
243 kind: NodeKind::Decision,
244 label: Some("Dec".into()),
245 support_level: None,
246 source_refs: vec![],
247 description: None,
248 fields: NodeFields::Decision {
249 choice: None,
250 alternatives: vec![],
251 rationale: None,
252 },
253 evidence_notes: vec![],
254 isolated: false,
255 pos: None,
256 },
257 ],
258 links: vec![
259 Link {
260 from: NodeId::new("N01"),
261 to: NodeId::new("N02"),
262 kind: LinkKind::Child,
263 },
264 Link {
265 from: NodeId::new("N01"),
266 to: NodeId::new("N03"),
267 kind: LinkKind::Child,
268 },
269 ],
270 bindings: vec![],
271 claims: vec![],
272 bounds: None,
273 }
274 }
275
276 #[test]
277 fn all_positions_finite() {
278 let m = simple_manifest();
279 let result = layout(&m, &LayoutOptions::default());
280 for np in &result.positions {
281 assert!(np.pos.x.is_finite(), "NaN/inf x for {}", np.id);
282 assert!(np.pos.y.is_finite(), "NaN/inf y for {}", np.id);
283 }
284 }
285
286 #[test]
287 fn ranks_monotonic_along_child_edges() {
288 let m = simple_manifest();
289 let result = layout(&m, &LayoutOptions::default());
290 let pos_map: std::collections::HashMap<&str, &Point> = result
291 .positions
292 .iter()
293 .map(|np| (np.id.as_str(), &np.pos))
294 .collect();
295 for link in &m.links {
296 if link.kind == LinkKind::Child {
297 let from_y = pos_map[link.from.as_str()].y;
298 let to_y = pos_map[link.to.as_str()].y;
299 assert!(
300 from_y < to_y,
301 "rank not monotonic: {} (y={}) -> {} (y={})",
302 link.from,
303 from_y,
304 link.to,
305 to_y
306 );
307 }
308 }
309 }
310
311 #[test]
312 fn tie_break_stable_across_input_order() {
313 let m1 = simple_manifest();
314 let mut m2 = simple_manifest();
315 m2.nodes.reverse(); let r1 = layout(&m1, &LayoutOptions::default());
317 let r2 = layout(&m2, &LayoutOptions::default());
318 let mut p1: Vec<_> = r1.positions.clone();
320 let mut p2: Vec<_> = r2.positions.clone();
321 p1.sort_by(|a, b| a.id.cmp(&b.id));
322 p2.sort_by(|a, b| a.id.cmp(&b.id));
323 assert_eq!(p1, p2);
324 assert_eq!(r1.bounds, r2.bounds);
325 }
326
327 #[test]
328 fn empty_manifest_produces_zero_bounds() {
329 let m = Manifest {
330 nodes: vec![],
331 links: vec![],
332 bindings: vec![],
333 claims: vec![],
334 bounds: None,
335 };
336 let result = layout(&m, &LayoutOptions::default());
337 assert!(result.positions.is_empty());
338 assert_eq!(result.bounds.width, 0.0);
339 assert_eq!(result.bounds.height, 0.0);
340 }
341
342 #[test]
343 fn single_node_has_finite_pos_and_enclosing_bounds() {
344 let m = Manifest {
345 nodes: vec![Node {
346 id: NodeId::new("N01"),
347 kind: NodeKind::Question,
348 label: None,
349 support_level: None,
350 source_refs: vec![],
351 description: None,
352 fields: NodeFields::Question,
353 evidence_notes: vec![],
354 isolated: false,
355 pos: None,
356 }],
357 links: vec![],
358 bindings: vec![],
359 claims: vec![],
360 bounds: None,
361 };
362 let opts = LayoutOptions::default();
363 let result = layout(&m, &opts);
364 assert_eq!(result.positions.len(), 1);
365 assert!(result.positions[0].pos.x.is_finite());
366 assert!(result.positions[0].pos.y.is_finite());
367 assert!(result.bounds.width >= opts.node_width);
368 assert!(result.bounds.height >= opts.node_height);
369 }
370
371 #[test]
372 fn bounds_enclose_all_node_rects() {
373 let m = simple_manifest();
374 let opts = LayoutOptions::default();
375 let result = layout(&m, &opts);
376 let half_w = opts.node_width / 2.0;
377 let half_h = opts.node_height / 2.0;
378 for np in &result.positions {
379 assert!(np.pos.x - half_w >= result.bounds.x - 1e-9);
380 assert!(np.pos.y - half_h >= result.bounds.y - 1e-9);
381 assert!(np.pos.x + half_w <= result.bounds.x + result.bounds.width + 1e-9);
382 assert!(np.pos.y + half_h <= result.bounds.y + result.bounds.height + 1e-9);
383 }
384 }
385
386 #[test]
387 fn canonicalize_normalizes_negative_zero() {
388 assert_eq!(canonicalize(-0.0), 0.0);
389 assert_eq!(canonicalize(-0.0).to_bits(), 0.0_f64.to_bits());
390 }
391
392 #[test]
393 fn canonicalize_rounds_to_six_decimals() {
394 let v = 1.23456789;
395 assert_eq!(canonicalize(v), 1.234568);
396 }
397
398 #[test]
399 fn layout_twice_identical() {
400 let m = simple_manifest();
401 let opts = LayoutOptions::default();
402 let r1 = layout(&m, &opts);
403 let r2 = layout(&m, &opts);
404 let j1 = serde_json::to_string(&r1).unwrap();
405 let j2 = serde_json::to_string(&r2).unwrap();
406 assert_eq!(j1, j2);
407 }
408}