Skip to main content

oximedia_graph/
visualization.rs

1//! Graph visualization: layout computation and ASCII art rendering.
2//!
3//! Provides a BFS-based left-to-right layer layout algorithm and a simple
4//! ASCII art renderer for quick textual inspection of a processing graph.
5
6// ── NodePosition ─────────────────────────────────────────────────────────────
7
8/// A 2-D position for a node on the visualization canvas.
9#[derive(Debug, Clone, PartialEq)]
10pub struct NodePosition {
11    /// Horizontal coordinate (pixels or arbitrary units).
12    pub x: f32,
13    /// Vertical coordinate (pixels or arbitrary units).
14    pub y: f32,
15}
16
17impl NodePosition {
18    /// Creates a new position.
19    pub fn new(x: f32, y: f32) -> Self {
20        Self { x, y }
21    }
22
23    /// Returns the Euclidean distance from `self` to `other`.
24    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// ── BoundingBox ──────────────────────────────────────────────────────────────
32
33/// An axis-aligned bounding rectangle.
34#[derive(Debug, Clone, PartialEq)]
35pub struct BoundingBox {
36    /// Left edge.
37    pub min_x: f32,
38    /// Top edge.
39    pub min_y: f32,
40    /// Right edge.
41    pub max_x: f32,
42    /// Bottom edge.
43    pub max_y: f32,
44}
45
46impl BoundingBox {
47    /// Creates a new bounding box.
48    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    /// Width of the bounding box.
58    pub fn width(&self) -> f32 {
59        self.max_x - self.min_x
60    }
61
62    /// Height of the bounding box.
63    pub fn height(&self) -> f32 {
64        self.max_y - self.min_y
65    }
66
67    /// Centre point of the bounding box.
68    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    /// Returns `true` if `pos` lies within (or on the boundary of) this box.
76    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// ── GraphLayout ──────────────────────────────────────────────────────────────
82
83/// A mapping of node IDs to 2-D canvas positions.
84#[derive(Debug, Default)]
85pub struct GraphLayout {
86    /// (node_id, position) pairs.
87    pub positions: Vec<(u64, NodePosition)>,
88}
89
90impl GraphLayout {
91    /// Creates an empty layout.
92    pub fn new() -> Self {
93        Self::default()
94    }
95
96    /// Returns the position of `node_id`, or `None` if not yet placed.
97    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    /// Sets or updates the position for `node_id`.
105    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    /// Computes a column-based left-to-right layout using BFS layering.
114    ///
115    /// `nodes` is the ordered list of all node IDs; `edges` is a list of
116    /// `(from_id, to_id)` pairs.  Nodes in the same BFS depth layer are
117    /// stacked vertically, spaced 100 units apart.  Layers are spaced 200
118    /// units horizontally.
119    pub fn auto_layout_left_right(nodes: &[u64], edges: &[(u64, u64)]) -> Self {
120        use std::collections::{HashMap, VecDeque};
121
122        // Build adjacency and in-degree maps.
123        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        // BFS from zero-in-degree nodes, assigning each node a column (layer).
135        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        // Use BFS order (deterministic via sort).
143        let mut seed: Vec<u64> = queue.drain(..).collect();
144        seed.sort_unstable();
145        queue.extend(seed);
146
147        // Restart properly.
148        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        // Assign positions: group nodes by layer, stack vertically.
167        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        // Sort for determinism.
185        positions.sort_by(|a, b| a.0.cmp(&b.0));
186
187        Self { positions }
188    }
189}
190
191// ── GraphRenderer ─────────────────────────────────────────────────────────────
192
193/// A simple canvas-based renderer that produces ASCII art.
194pub struct GraphRenderer {
195    /// Canvas width in character columns.
196    pub canvas_width: u32,
197    /// Canvas height in character rows.
198    pub canvas_height: u32,
199}
200
201impl GraphRenderer {
202    /// Creates a new renderer with the given canvas dimensions.
203    pub fn new(canvas_width: u32, canvas_height: u32) -> Self {
204        Self {
205            canvas_width,
206            canvas_height,
207        }
208    }
209
210    /// Renders the layout as an ASCII art string.
211    ///
212    /// Each node is represented as `[name]` placed at its grid position
213    /// (scaled to fit the canvas).  The returned string contains
214    /// `canvas_height` lines separated by `'\n'`.
215    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        // Determine coordinate ranges for normalisation.
221        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        // Build a grid of spaces.
233        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// ─────────────────────────────────────────────────────────────────────────────
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    // ── NodePosition ──────────────────────────────────────────────────────────
262
263    #[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    // ── BoundingBox ───────────────────────────────────────────────────────────
277
278    #[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    // ── GraphLayout ───────────────────────────────────────────────────────────
312
313    #[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        // Only one entry for the node.
329        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        // node 1 (source) -> node 2 -> node 3 (sink)
355        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    // ── GraphRenderer ─────────────────────────────────────────────────────────
375
376    #[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        // canvas_height lines joined by '\n' gives canvas_height - 1 newlines,
402        // so split gives canvas_height parts.
403        assert_eq!(output.lines().count(), 10);
404    }
405}