1#[derive(Debug, Clone, PartialEq)]
10pub struct NodePosition {
11 pub x: f32,
13 pub y: f32,
15}
16
17impl NodePosition {
18 pub fn new(x: f32, y: f32) -> Self {
20 Self { x, y }
21 }
22
23 pub fn distance(&self, other: &Self) -> f32 {
25 let dx = self.x - other.x;
26 let dy = self.y - other.y;
27 (dx * dx + dy * dy).sqrt()
28 }
29}
30
31#[derive(Debug, Clone, PartialEq)]
35pub struct BoundingBox {
36 pub min_x: f32,
38 pub min_y: f32,
40 pub max_x: f32,
42 pub max_y: f32,
44}
45
46impl BoundingBox {
47 pub fn new(min_x: f32, min_y: f32, max_x: f32, max_y: f32) -> Self {
49 Self {
50 min_x,
51 min_y,
52 max_x,
53 max_y,
54 }
55 }
56
57 pub fn width(&self) -> f32 {
59 self.max_x - self.min_x
60 }
61
62 pub fn height(&self) -> f32 {
64 self.max_y - self.min_y
65 }
66
67 pub fn center(&self) -> NodePosition {
69 NodePosition::new(
70 (self.min_x + self.max_x) * 0.5,
71 (self.min_y + self.max_y) * 0.5,
72 )
73 }
74
75 pub fn contains(&self, pos: &NodePosition) -> bool {
77 pos.x >= self.min_x && pos.x <= self.max_x && pos.y >= self.min_y && pos.y <= self.max_y
78 }
79}
80
81#[derive(Debug, Default)]
85pub struct GraphLayout {
86 pub positions: Vec<(u64, NodePosition)>,
88}
89
90impl GraphLayout {
91 pub fn new() -> Self {
93 Self::default()
94 }
95
96 pub fn get_position(&self, node_id: u64) -> Option<&NodePosition> {
98 self.positions
99 .iter()
100 .find(|(id, _)| *id == node_id)
101 .map(|(_, pos)| pos)
102 }
103
104 pub fn set_position(&mut self, node_id: u64, pos: NodePosition) {
106 if let Some(entry) = self.positions.iter_mut().find(|(id, _)| *id == node_id) {
107 entry.1 = pos;
108 } else {
109 self.positions.push((node_id, pos));
110 }
111 }
112
113 pub fn auto_layout_left_right(nodes: &[u64], edges: &[(u64, u64)]) -> Self {
120 use std::collections::{HashMap, VecDeque};
121
122 let mut in_degree: HashMap<u64, usize> = nodes.iter().map(|&id| (id, 0)).collect();
124 let mut successors: HashMap<u64, Vec<u64>> =
125 nodes.iter().map(|&id| (id, Vec::new())).collect();
126
127 for &(from, to) in edges {
128 if in_degree.contains_key(&from) && in_degree.contains_key(&to) {
129 *in_degree.entry(to).or_insert(0) += 1;
130 successors.entry(from).or_default().push(to);
131 }
132 }
133
134 let mut layer: HashMap<u64, usize> = HashMap::new();
136 let mut queue: VecDeque<u64> = in_degree
137 .iter()
138 .filter(|(_, &d)| d == 0)
139 .map(|(&id, _)| id)
140 .collect();
141
142 let mut seed: Vec<u64> = queue.drain(..).collect();
144 seed.sort_unstable();
145 queue.extend(seed);
146
147 let mut visited: std::collections::HashSet<u64> = std::collections::HashSet::new();
149 while let Some(id) = queue.pop_front() {
150 if visited.contains(&id) {
151 continue;
152 }
153 visited.insert(id);
154 let current_layer = *layer.entry(id).or_insert(0);
155 let mut nexts: Vec<u64> = successors.get(&id).cloned().unwrap_or_default();
156 nexts.sort_unstable();
157 for next in nexts {
158 let next_layer = layer.entry(next).or_insert(0);
159 if *next_layer <= current_layer {
160 *next_layer = current_layer + 1;
161 }
162 queue.push_back(next);
163 }
164 }
165
166 let mut layer_counts: HashMap<usize, usize> = HashMap::new();
168 let mut positions: Vec<(u64, NodePosition)> = nodes
169 .iter()
170 .map(|&id| {
171 let col = *layer.get(&id).unwrap_or(&0);
172 let row = {
173 let cnt = layer_counts.entry(col).or_insert(0);
174 let r = *cnt;
175 *cnt += 1;
176 r
177 };
178 let x = col as f32 * 200.0;
179 let y = row as f32 * 100.0;
180 (id, NodePosition::new(x, y))
181 })
182 .collect();
183
184 positions.sort_by(|a, b| a.0.cmp(&b.0));
186
187 Self { positions }
188 }
189}
190
191pub struct GraphRenderer {
195 pub canvas_width: u32,
197 pub canvas_height: u32,
199}
200
201impl GraphRenderer {
202 pub fn new(canvas_width: u32, canvas_height: u32) -> Self {
204 Self {
205 canvas_width,
206 canvas_height,
207 }
208 }
209
210 pub fn render_ascii(&self, layout: &GraphLayout, nodes: &[(u64, String)]) -> String {
216 if nodes.is_empty() || layout.positions.is_empty() {
217 return String::new();
218 }
219
220 let (min_x, max_x, min_y, max_y) = layout.positions.iter().fold(
222 (f32::MAX, f32::MIN, f32::MAX, f32::MIN),
223 |(ax, bx, ay, by), (_, p)| (ax.min(p.x), bx.max(p.x), ay.min(p.y), by.max(p.y)),
224 );
225
226 let range_x = (max_x - min_x).max(1.0);
227 let range_y = (max_y - min_y).max(1.0);
228
229 let cols = self.canvas_width as usize;
230 let rows = self.canvas_height as usize;
231
232 let mut grid: Vec<Vec<char>> = vec![vec![' '; cols]; rows];
234
235 for (id, name) in nodes {
236 if let Some(pos) = layout.get_position(*id) {
237 let col = ((pos.x - min_x) / range_x * (cols.saturating_sub(10)) as f32) as usize;
238 let row = ((pos.y - min_y) / range_y * (rows.saturating_sub(3)) as f32) as usize;
239 let label = format!("[{name}]");
240 for (i, ch) in label.chars().enumerate() {
241 let c = col + i;
242 if row < rows && c < cols {
243 grid[row][c] = ch;
244 }
245 }
246 }
247 }
248
249 grid.iter()
250 .map(|row| row.iter().collect::<String>())
251 .collect::<Vec<_>>()
252 .join("\n")
253 }
254}
255
256#[cfg(test)]
258mod tests {
259 use super::*;
260
261 #[test]
264 fn distance_to_same_point_is_zero() {
265 let p = NodePosition::new(3.0, 4.0);
266 assert!((p.distance(&p)).abs() < 1e-6);
267 }
268
269 #[test]
270 fn distance_3_4_5_triangle() {
271 let a = NodePosition::new(0.0, 0.0);
272 let b = NodePosition::new(3.0, 4.0);
273 assert!((a.distance(&b) - 5.0).abs() < 1e-5);
274 }
275
276 #[test]
279 fn bounding_box_width_and_height() {
280 let bb = BoundingBox::new(0.0, 0.0, 100.0, 50.0);
281 assert!((bb.width() - 100.0).abs() < 1e-6);
282 assert!((bb.height() - 50.0).abs() < 1e-6);
283 }
284
285 #[test]
286 fn bounding_box_center() {
287 let bb = BoundingBox::new(0.0, 0.0, 200.0, 100.0);
288 let c = bb.center();
289 assert!((c.x - 100.0).abs() < 1e-6);
290 assert!((c.y - 50.0).abs() < 1e-6);
291 }
292
293 #[test]
294 fn bounding_box_contains_interior_point() {
295 let bb = BoundingBox::new(0.0, 0.0, 10.0, 10.0);
296 assert!(bb.contains(&NodePosition::new(5.0, 5.0)));
297 }
298
299 #[test]
300 fn bounding_box_excludes_exterior_point() {
301 let bb = BoundingBox::new(0.0, 0.0, 10.0, 10.0);
302 assert!(!bb.contains(&NodePosition::new(11.0, 5.0)));
303 }
304
305 #[test]
306 fn bounding_box_contains_boundary_point() {
307 let bb = BoundingBox::new(0.0, 0.0, 10.0, 10.0);
308 assert!(bb.contains(&NodePosition::new(10.0, 10.0)));
309 }
310
311 #[test]
314 fn layout_set_and_get_position() {
315 let mut layout = GraphLayout::new();
316 layout.set_position(1, NodePosition::new(0.0, 0.0));
317 let pos = layout.get_position(1).expect("get_position should succeed");
318 assert!((pos.x - 0.0).abs() < 1e-6);
319 }
320
321 #[test]
322 fn layout_update_position() {
323 let mut layout = GraphLayout::new();
324 layout.set_position(2, NodePosition::new(0.0, 0.0));
325 layout.set_position(2, NodePosition::new(100.0, 200.0));
326 let pos = layout.get_position(2).expect("get_position should succeed");
327 assert!((pos.x - 100.0).abs() < 1e-6);
328 assert_eq!(
330 layout.positions.iter().filter(|(id, _)| *id == 2).count(),
331 1
332 );
333 }
334
335 #[test]
336 fn layout_get_missing_returns_none() {
337 let layout = GraphLayout::new();
338 assert!(layout.get_position(99).is_none());
339 }
340
341 #[test]
342 fn auto_layout_places_all_nodes() {
343 let nodes = vec![1u64, 2, 3];
344 let edges = vec![(1u64, 2u64), (2, 3)];
345 let layout = GraphLayout::auto_layout_left_right(&nodes, &edges);
346 assert_eq!(layout.positions.len(), 3);
347 for &id in &nodes {
348 assert!(layout.get_position(id).is_some(), "missing node {id}");
349 }
350 }
351
352 #[test]
353 fn auto_layout_source_is_leftmost() {
354 let nodes = vec![1u64, 2, 3];
356 let edges = vec![(1u64, 2u64), (2, 3)];
357 let layout = GraphLayout::auto_layout_left_right(&nodes, &edges);
358 let x1 = layout
359 .get_position(1)
360 .expect("get_position should succeed")
361 .x;
362 let x2 = layout
363 .get_position(2)
364 .expect("get_position should succeed")
365 .x;
366 let x3 = layout
367 .get_position(3)
368 .expect("get_position should succeed")
369 .x;
370 assert!(x1 <= x2, "source should be left of middle");
371 assert!(x2 <= x3, "middle should be left of sink");
372 }
373
374 #[test]
377 fn render_ascii_empty_graph_returns_empty() {
378 let renderer = GraphRenderer::new(80, 24);
379 let layout = GraphLayout::new();
380 let result = renderer.render_ascii(&layout, &[]);
381 assert!(result.is_empty());
382 }
383
384 #[test]
385 fn render_ascii_contains_node_name() {
386 let mut layout = GraphLayout::new();
387 layout.set_position(1, NodePosition::new(0.0, 0.0));
388 let renderer = GraphRenderer::new(80, 24);
389 let nodes = vec![(1u64, "source".to_string())];
390 let output = renderer.render_ascii(&layout, &nodes);
391 assert!(output.contains("source"), "expected 'source' in:\n{output}");
392 }
393
394 #[test]
395 fn render_ascii_has_correct_line_count() {
396 let mut layout = GraphLayout::new();
397 layout.set_position(1, NodePosition::new(0.0, 0.0));
398 let renderer = GraphRenderer::new(40, 10);
399 let nodes = vec![(1u64, "n".to_string())];
400 let output = renderer.render_ascii(&layout, &nodes);
401 assert_eq!(output.lines().count(), 10);
404 }
405}