ascii_dag/render/
ascii.rs

1//! ASCII rendering implementation for DAG visualization.
2
3use crate::graph::{DAG, RenderMode};
4use alloc::{string::String, vec, vec::Vec};
5use core::fmt::Write;
6
7// Box drawing characters (Unicode)
8pub(crate) const V_LINE: char = '│';
9pub(crate) const H_LINE: char = '─';
10pub(crate) const ARROW_DOWN: char = '↓';
11pub(crate) const ARROW_RIGHT: char = '→';
12pub(crate) const CYCLE_ARROW: char = '⇄'; // For cycle detection
13
14// Convergence/divergence
15pub(crate) const CORNER_DR: char = '└'; // Down-Right corner
16pub(crate) const CORNER_DL: char = '┘'; // Down-Left corner
17pub(crate) const TEE_DOWN: char = '┬'; // T pointing down
18pub(crate) const TEE_UP: char = '┴'; // T pointing up
19pub(crate) const CORNER_UR: char = '┌'; // Up-Right corner
20pub(crate) const CORNER_UL: char = '┐'; // Up-Left corner
21
22impl<'a> DAG<'a> {
23    /// Render the DAG to an ASCII string.
24    ///
25    /// # Examples
26    ///
27    /// ```
28    /// use ascii_dag::graph::DAG;
29    ///
30    /// let dag = DAG::from_edges(
31    ///     &[(1, "Start"), (2, "End")],
32    ///     &[(1, 2)]
33    /// );
34    ///
35    /// let output = dag.render();
36    /// println!("{}", output);
37    /// ```
38    pub fn render(&self) -> String {
39        let mut buf = String::with_capacity(self.estimate_size());
40        self.render_to(&mut buf);
41        buf
42    }
43
44    /// Render the DAG into a provided buffer (zero-allocation).
45    ///
46    /// # Examples
47    ///
48    /// ```
49    /// use ascii_dag::graph::DAG;
50    ///
51    /// let dag = DAG::from_edges(
52    ///     &[(1, "A")],
53    ///     &[]
54    /// );
55    ///
56    /// let mut buffer = String::new();
57    /// dag.render_to(&mut buffer);
58    /// assert!(!buffer.is_empty());
59    /// ```
60    pub fn render_to(&self, output: &mut String) {
61        if self.nodes.is_empty() {
62            output.push_str("Empty DAG");
63            return;
64        }
65
66        // Check for cycles and render them specially
67        if self.has_cycle() {
68            self.render_cycle(output);
69            return;
70        }
71
72        // Determine actual render mode
73        let mode = match self.render_mode {
74            RenderMode::Auto => {
75                if self.is_simple_chain() {
76                    RenderMode::Horizontal
77                } else {
78                    RenderMode::Vertical
79                }
80            }
81            other => other,
82        };
83
84        match mode {
85            RenderMode::Horizontal => self.render_horizontal(output),
86            RenderMode::Vertical | RenderMode::Auto => self.render_vertical(output),
87        }
88    }
89
90    /// Render a graph with cycles (not a valid DAG, but useful for error visualization).
91    fn render_cycle(&self, output: &mut String) {
92        writeln!(output, "⚠️  CYCLE DETECTED - Not a valid DAG").ok();
93        writeln!(output).ok();
94
95        // Find the cycle using DFS
96        if let Some(cycle_nodes) = self.find_cycle_path() {
97            writeln!(output, "Cyclic dependency chain:").ok();
98
99            for (i, node_id) in cycle_nodes.iter().enumerate() {
100                if let Some(&(id, label)) = self.nodes.iter().find(|(nid, _)| nid == node_id) {
101                    self.write_node(output, id, label);
102
103                    if i < cycle_nodes.len() - 1 {
104                        write!(output, " → ").ok();
105                    } else {
106                        // Last node, show it cycles back
107                        if let Some(&(first_id, first_label)) =
108                            self.nodes.iter().find(|(nid, _)| nid == &cycle_nodes[0])
109                        {
110                            write!(output, " {} ", CYCLE_ARROW).ok();
111                            self.write_node(output, first_id, first_label);
112                        }
113                    }
114                }
115            }
116            writeln!(output).ok();
117            writeln!(output).ok();
118            writeln!(
119                output,
120                "This creates an infinite loop in error dependencies."
121            )
122            .ok();
123        } else {
124            writeln!(output, "Complex cycle detected in graph.").ok();
125        }
126    }
127
128    /// Check if this is a simple chain (A → B → C, no branching).
129    fn is_simple_chain(&self) -> bool {
130        if self.nodes.is_empty() {
131            return false;
132        }
133
134        // If we have multiple disconnected subgraphs, it's not a simple chain
135        let subgraphs = self.find_subgraphs();
136        if subgraphs.len() > 1 {
137            return false;
138        }
139
140        // Check if every node has at most 1 parent and 1 child
141        for &(node_id, _) in &self.nodes {
142            let parents = self.get_parents(node_id);
143            let children = self.get_children(node_id);
144
145            if parents.len() > 1 || children.len() > 1 {
146                return false;
147            }
148        }
149
150        true
151    }
152
153    /// Render in horizontal mode: [A] → [B] → [C]
154    fn render_horizontal(&self, output: &mut String) {
155        // Find the root (node with no parents)
156        let roots: Vec<_> = self
157            .nodes
158            .iter()
159            .filter(|(id, _)| self.get_parents(*id).is_empty())
160            .collect();
161
162        if roots.is_empty() {
163            output.push_str("(no root)");
164            return;
165        }
166
167        // Follow the chain from root
168        let mut current_id = roots[0].0;
169        let mut visited = Vec::new();
170
171        loop {
172            visited.push(current_id);
173
174            // Find node and format with appropriate brackets
175            if let Some(&(id, label)) = self.nodes.iter().find(|(nid, _)| *nid == current_id) {
176                self.write_node(output, id, label);
177            }
178
179            // Get children
180            let children = self.get_children(current_id);
181
182            if children.is_empty() {
183                break;
184            }
185
186            // Draw arrow
187            write!(output, " {} ", ARROW_RIGHT).ok();
188
189            // Move to next
190            current_id = children[0];
191
192            // Avoid infinite loops
193            if visited.contains(&current_id) {
194                break;
195            }
196        }
197
198        writeln!(output).ok();
199    }
200
201    /// Render in vertical mode (Sugiyama layout).
202    fn render_vertical(&self, output: &mut String) {
203        // Detect if we have multiple disconnected subgraphs
204        let subgraphs = self.find_subgraphs();
205
206        if subgraphs.len() > 1 {
207            // Render each subgraph separately
208            for (i, subgraph_nodes) in subgraphs.iter().enumerate() {
209                if i > 0 {
210                    writeln!(output).ok();
211                }
212                self.render_subgraph(output, subgraph_nodes);
213            }
214            return;
215        }
216
217        // Single connected graph - 4-Pass Sugiyama-inspired layout
218        let level_data = self.calculate_levels();
219        let max_level = level_data.iter().map(|(_, l)| *l).max().unwrap_or(0);
220
221        // Group nodes by level
222        let mut levels: Vec<Vec<usize>> = vec![Vec::new(); max_level + 1];
223        for (idx, level) in &level_data {
224            levels[*level].push(*idx);
225        }
226
227        // === PASS 1: Crossing Reduction (Median Heuristic) ===
228        self.reduce_crossings(&mut levels, max_level);
229
230        // === PASS 2: Character-Level Coordinate Assignment ===
231        let node_x_coords = self.assign_x_coordinates(&mut levels, max_level);
232
233        // === PASS 3: Calculate Canvas Width and Centering ===
234        let (level_widths, max_canvas_width) =
235            self.calculate_canvas_dimensions(&levels, &node_x_coords);
236
237        // === PASS 4: Render with Manhattan Routing ===
238        for (current_level, level_nodes) in levels.iter().enumerate() {
239            if level_nodes.is_empty() {
240                continue;
241            }
242
243            // Calculate centering offset for this level
244            let level_width = level_widths[current_level];
245            let level_offset = if max_canvas_width > level_width {
246                (max_canvas_width - level_width) / 2
247            } else {
248                0
249            };
250
251            // Find minimum x-coordinate in this level
252            let min_x = level_nodes
253                .iter()
254                .map(|&idx| node_x_coords[idx])
255                .min()
256                .unwrap_or(0);
257
258            // Render nodes at their assigned x-coordinates
259            let mut current_col = 0;
260            for &idx in level_nodes {
261                let node_x = node_x_coords[idx] - min_x + level_offset;
262
263                // Add spacing to reach this node's position
264                while current_col < node_x {
265                    output.push(' ');
266                    current_col += 1;
267                }
268
269                let (id, label) = self.nodes[idx];
270                // Write directly to avoid intermediate allocation
271                self.write_node(output, id, label);
272                current_col += self.get_node_width(idx); // Use cached width
273            }
274            writeln!(output).ok();
275
276            // Draw connections if not last level
277            if current_level < max_level {
278                let next_level_width = level_widths[current_level + 1];
279                let next_level_offset = if max_canvas_width > next_level_width {
280                    (max_canvas_width - next_level_width) / 2
281                } else {
282                    0
283                };
284
285                self.draw_connections_sugiyama(
286                    output,
287                    level_nodes,
288                    &levels[current_level + 1],
289                    &node_x_coords,
290                    min_x,
291                    level_offset,
292                    next_level_offset,
293                );
294            }
295        }
296    }
297
298    /// PASS 4: Draw connections with Manhattan routing.
299    fn draw_connections_sugiyama(
300        &self,
301        output: &mut String,
302        current_nodes: &[usize],
303        next_nodes: &[usize],
304        x_coords: &[usize],
305        current_min_x: usize,
306        current_offset: usize,
307        next_offset: usize,
308    ) {
309        if current_nodes.is_empty() || next_nodes.is_empty() {
310            return;
311        }
312
313        // Calculate center positions
314        let current_centers: Vec<(usize, usize)> = current_nodes
315            .iter()
316            .map(|&idx| {
317                let width = self.get_node_width(idx);
318                let center = x_coords[idx] - current_min_x + current_offset + width / 2;
319                (idx, center)
320            })
321            .collect();
322
323        let next_min_x = next_nodes
324            .iter()
325            .map(|&idx| x_coords[idx])
326            .min()
327            .unwrap_or(0);
328        let next_centers: Vec<(usize, usize)> = next_nodes
329            .iter()
330            .map(|&idx| {
331                let width = self.get_node_width(idx);
332                let center = x_coords[idx] - next_min_x + next_offset + width / 2;
333                (idx, center)
334            })
335            .collect();
336
337        // Find connections
338        let mut connections: Vec<(usize, usize)> = Vec::new();
339        for &(curr_idx, from_pos) in &current_centers {
340            let node_id = self.nodes[curr_idx].0;
341            for child_id in self.get_children(node_id) {
342                if let Some(&(_, to_pos)) = next_centers
343                    .iter()
344                    .find(|(idx, _)| self.nodes[*idx].0 == child_id)
345                {
346                    connections.push((from_pos, to_pos));
347                }
348            }
349        }
350
351        if connections.is_empty() {
352            return;
353        }
354
355        // Group by target/source for convergence/divergence detection
356        let mut target_groups: Vec<(usize, Vec<usize>)> = Vec::new();
357        for &(from, to) in &connections {
358            match target_groups.binary_search_by_key(&to, |(k, _)| *k) {
359                Ok(idx) => target_groups[idx].1.push(from),
360                Err(idx) => target_groups.insert(idx, (to, vec![from])),
361            }
362        }
363
364        let mut source_groups: Vec<(usize, Vec<usize>)> = Vec::new();
365        for &(from, to) in &connections {
366            match source_groups.binary_search_by_key(&from, |(k, _)| *k) {
367                Ok(idx) => source_groups[idx].1.push(to),
368                Err(idx) => source_groups.insert(idx, (from, vec![to])),
369            }
370        }
371
372        let has_convergence = target_groups.iter().any(|(_, v)| v.len() > 1);
373        let has_divergence = source_groups.iter().any(|(_, v)| v.len() > 1);
374
375        // Find the range we need to draw - always start from 0 since nodes are positioned from 0
376        let min_pos = 0;
377        let max_pos = connections
378            .iter()
379            .flat_map(|(f, t)| [*f, *t])
380            .max()
381            .unwrap_or(0);
382
383        // Draw based on pattern
384        if has_convergence && !has_divergence {
385            self.draw_convergence_manhattan(output, &target_groups, min_pos, max_pos);
386        } else if has_divergence && !has_convergence {
387            self.draw_divergence_manhattan(output, &source_groups, min_pos, max_pos);
388        } else {
389            self.draw_simple_manhattan(output, &connections, min_pos, max_pos);
390        }
391    }
392
393    fn draw_convergence_manhattan(
394        &self,
395        output: &mut String,
396        target_groups: &[(usize, Vec<usize>)],
397        min_pos: usize,
398        max_pos: usize,
399    ) {
400        let all_sources: Vec<usize> = target_groups
401            .iter()
402            .flat_map(|(_, sources)| sources.iter().copied())
403            .collect();
404
405        // Line 1: Vertical drops
406        for i in min_pos..=max_pos {
407            output.push(if all_sources.contains(&i) {
408                V_LINE
409            } else {
410                ' '
411            });
412        }
413        writeln!(output).ok();
414
415        // Line 2: Horizontal convergence └──┴──┘
416        for i in min_pos..=max_pos {
417            let mut ch = ' ';
418            for (_, sources) in target_groups.iter() {
419                if sources.len() <= 1 {
420                    continue;
421                }
422                let min_src = *sources.iter().min().unwrap();
423                let max_src = *sources.iter().max().unwrap();
424                if i == min_src {
425                    ch = CORNER_DR;
426                } else if i == max_src {
427                    ch = CORNER_DL;
428                } else if sources.contains(&i) {
429                    ch = TEE_UP;
430                } else if i > min_src && i < max_src {
431                    ch = H_LINE;
432                }
433            }
434            output.push(ch);
435        }
436        writeln!(output).ok();
437
438        // Line 3: Arrows down
439        for i in min_pos..=max_pos {
440            output.push(if target_groups.iter().any(|(t, _)| *t == i) {
441                ARROW_DOWN
442            } else {
443                ' '
444            });
445        }
446        writeln!(output).ok();
447    }
448
449    fn draw_divergence_manhattan(
450        &self,
451        output: &mut String,
452        source_groups: &[(usize, Vec<usize>)],
453        min_pos: usize,
454        max_pos: usize,
455    ) {
456        let all_sources: Vec<usize> = source_groups.iter().map(|(s, _)| *s).collect();
457
458        // Line 1: Vertical from sources
459        for i in min_pos..=max_pos {
460            output.push(if all_sources.contains(&i) {
461                V_LINE
462            } else {
463                ' '
464            });
465        }
466        writeln!(output).ok();
467
468        // Line 2: Horizontal divergence ┌──┬──┐
469        for i in min_pos..=max_pos {
470            let mut ch = ' ';
471            for (_, targets) in source_groups.iter() {
472                if targets.len() <= 1 {
473                    continue;
474                }
475                let min_tgt = *targets.iter().min().unwrap();
476                let max_tgt = *targets.iter().max().unwrap();
477                if i == min_tgt {
478                    ch = CORNER_UR;
479                } else if i == max_tgt {
480                    ch = CORNER_UL;
481                } else if targets.contains(&i) {
482                    ch = TEE_DOWN;
483                } else if i > min_tgt && i < max_tgt {
484                    ch = H_LINE;
485                }
486            }
487            output.push(ch);
488        }
489        writeln!(output).ok();
490
491        // Line 3: Arrows down
492        let all_targets: Vec<usize> = source_groups
493            .iter()
494            .flat_map(|(_, t)| t.iter().copied())
495            .collect();
496        for i in min_pos..=max_pos {
497            output.push(if all_targets.contains(&i) {
498                ARROW_DOWN
499            } else {
500                ' '
501            });
502        }
503        writeln!(output).ok();
504    }
505
506    fn draw_simple_manhattan(
507        &self,
508        output: &mut String,
509        connections: &[(usize, usize)],
510        min_pos: usize,
511        max_pos: usize,
512    ) {
513        // Line 1: Vertical
514        for i in min_pos..=max_pos {
515            output.push(if connections.iter().any(|(f, _)| *f == i) {
516                V_LINE
517            } else {
518                ' '
519            });
520        }
521        writeln!(output).ok();
522
523        // Line 2: Arrows
524        for i in min_pos..=max_pos {
525            output.push(if connections.iter().any(|(f, _)| *f == i) {
526                ARROW_DOWN
527            } else {
528                ' '
529            });
530        }
531        writeln!(output).ok();
532    }
533
534    /// Render a specific subgraph.
535    pub(crate) fn render_subgraph(&self, output: &mut String, subgraph_indices: &[usize]) {
536        // Build a mini-DAG with just these nodes
537        let _subgraph_node_ids: Vec<usize> = subgraph_indices
538            .iter()
539            .map(|&idx| self.nodes[idx].0)
540            .collect();
541
542        // Calculate levels for this subgraph
543        let level_data = self.calculate_levels_for_subgraph(subgraph_indices);
544        let max_level = level_data.iter().map(|(_, l)| *l).max().unwrap_or(0);
545
546        // Group nodes by level
547        let mut levels: Vec<Vec<usize>> = vec![Vec::new(); max_level + 1];
548        for (idx, level) in level_data {
549            levels[level].push(idx);
550        }
551
552        // Check if it's a simple chain - render horizontally
553        if self.is_subgraph_simple_chain(subgraph_indices) {
554            // Render horizontally
555            let roots: Vec<_> = subgraph_indices
556                .iter()
557                .filter(|&&idx| {
558                    let node_id = self.nodes[idx].0;
559                    self.get_parents(node_id).is_empty()
560                })
561                .collect();
562
563            if let Some(&&root_idx) = roots.first() {
564                let mut current_id = self.nodes[root_idx].0;
565                let mut visited = Vec::new();
566
567                loop {
568                    visited.push(current_id);
569
570                    if let Some(&(id, label)) =
571                        self.nodes.iter().find(|(nid, _)| *nid == current_id)
572                    {
573                        self.write_node(output, id, label);
574                    }
575
576                    let children = self.get_children(current_id);
577
578                    if children.is_empty() {
579                        break;
580                    }
581
582                    write!(output, " {} ", ARROW_RIGHT).ok();
583                    current_id = children[0];
584
585                    if visited.contains(&current_id) {
586                        break;
587                    }
588                }
589
590                writeln!(output).ok();
591            }
592            return;
593        }
594
595        // Render vertically for complex subgraphs
596        for (current_level, node_indices) in levels.iter().enumerate() {
597            if node_indices.is_empty() {
598                continue;
599            }
600
601            // Draw nodes with appropriate formatting
602            for (pos, &idx) in node_indices.iter().enumerate() {
603                let (id, label) = self.nodes[idx];
604                self.write_node(output, id, label);
605
606                if pos < node_indices.len() - 1 {
607                    output.push_str("   ");
608                }
609            }
610            writeln!(output).ok();
611
612            // Draw connections if not last level
613            if current_level < max_level {
614                self.draw_vertical_connections(output, node_indices, &levels[current_level + 1]);
615            }
616        }
617    }
618
619    fn draw_vertical_connections(
620        &self,
621        output: &mut String,
622        current_nodes: &[usize],
623        next_nodes: &[usize],
624    ) {
625        if current_nodes.is_empty() || next_nodes.is_empty() {
626            return;
627        }
628
629        // Calculate center positions for each node in current level
630        let mut current_positions = Vec::new();
631        let mut pos = 0;
632        for &idx in current_nodes {
633            let label_len = self.get_node_width(idx);
634            let center = pos + label_len / 2;
635            current_positions.push((idx, center, pos, pos + label_len));
636            pos += label_len + 3; // +3 for spacing
637        }
638
639        // Calculate center positions for each node in next level
640        let mut next_positions = Vec::new();
641        let mut pos = 0;
642        for &idx in next_nodes {
643            let label_len = self.get_node_width(idx);
644            let center = pos + label_len / 2;
645            next_positions.push((idx, center));
646            pos += label_len + 3; // +3 for spacing
647        }
648
649        // Find connections
650        let mut connections: Vec<(usize, usize, usize)> = Vec::new(); // (from_idx, from_pos, to_pos)
651
652        for &(current_idx, from_pos, _, _) in &current_positions {
653            let node_id = self.nodes[current_idx].0;
654            let children = self.get_children(node_id);
655
656            for child_id in children {
657                if let Some(&(_, to_pos)) = next_positions
658                    .iter()
659                    .find(|(idx, _)| self.nodes[*idx].0 == child_id)
660                {
661                    connections.push((current_idx, from_pos, to_pos));
662                }
663            }
664        }
665
666        if connections.is_empty() {
667            return;
668        }
669
670        // Group connections by target to find convergence patterns
671        // Using sorted Vec with binary search for O(log n) lookup
672        let mut target_groups: Vec<(usize, Vec<(usize, usize, usize)>)> = Vec::new();
673
674        for &conn in &connections {
675            // Binary search to find existing group or insertion point
676            match target_groups.binary_search_by_key(&conn.2, |(k, _)| *k) {
677                Ok(idx) => target_groups[idx].1.push(conn),
678                Err(idx) => target_groups.insert(idx, (conn.2, vec![conn])),
679            }
680        }
681
682        // Check if we have any convergence (multiple sources to one target)
683        let has_any_convergence = target_groups.iter().any(|(_, v)| v.len() > 1);
684
685        // Group connections by source to find divergence patterns
686        let mut source_groups: Vec<(usize, Vec<(usize, usize, usize)>)> = Vec::new();
687
688        for &conn in &connections {
689            match source_groups.binary_search_by_key(&conn.0, |(k, _)| *k) {
690                Ok(idx) => source_groups[idx].1.push(conn),
691                Err(idx) => source_groups.insert(idx, (conn.0, vec![conn])),
692            }
693        }
694
695        // Check if we have any divergence (one source to multiple targets)
696        let has_any_divergence = source_groups.iter().any(|(_, v)| v.len() > 1);
697
698        // Choose rendering strategy based on pattern complexity
699        if has_any_convergence && !has_any_divergence {
700            // Pure convergence pattern(s)
701            self.draw_multiple_convergences(output, &target_groups);
702        } else if has_any_divergence && !has_any_convergence {
703            // Pure divergence pattern(s)
704            self.draw_multiple_divergences(output, &source_groups);
705        } else if has_any_convergence && has_any_divergence {
706            // Mixed pattern - draw simple connections
707            self.draw_simple_verticals(output, &connections);
708        } else {
709            // Simple 1-to-1 connections
710            self.draw_simple_verticals(output, &connections);
711        }
712    }
713
714    fn draw_multiple_convergences(
715        &self,
716        output: &mut String,
717        target_groups: &[(usize, Vec<(usize, usize, usize)>)],
718    ) {
719        // Find all unique source and target positions
720        let all_connections: Vec<_> = target_groups
721            .iter()
722            .flat_map(|(_, v)| v.iter().copied())
723            .collect();
724        let min_pos = all_connections
725            .iter()
726            .map(|(_, from, to)| (*from).min(*to))
727            .min()
728            .unwrap_or(0);
729        let max_pos = all_connections
730            .iter()
731            .map(|(_, from, to)| (*from).max(*to))
732            .max()
733            .unwrap_or(0);
734
735        // Line 1: Vertical drops from sources
736        for i in min_pos..=max_pos {
737            if all_connections.iter().any(|(_, from, _)| *from == i) {
738                output.push(V_LINE);
739            } else {
740                output.push(' ');
741            }
742        }
743        writeln!(output).ok();
744
745        // Line 2: Draw convergence lines for each target
746        for i in min_pos..=max_pos {
747            let mut char_at_pos = ' ';
748
749            for (_, conns) in target_groups.iter() {
750                if conns.len() <= 1 {
751                    continue;
752                }
753
754                let sources: Vec<_> = conns.iter().map(|(_, from, _)| from).collect();
755                let min_source = **sources.iter().min().unwrap();
756                let max_source = **sources.iter().max().unwrap();
757
758                if i == min_source {
759                    char_at_pos = CORNER_DR; // └
760                } else if i == max_source {
761                    char_at_pos = CORNER_DL; // ┘
762                } else if sources.contains(&&i) {
763                    char_at_pos = TEE_UP; // ┴
764                } else if i > min_source && i < max_source {
765                    if char_at_pos == ' ' {
766                        char_at_pos = H_LINE; // ─
767                    }
768                }
769            }
770
771            output.push(char_at_pos);
772        }
773        writeln!(output).ok();
774
775        // Line 3: Arrows pointing down to targets
776        for i in min_pos..=max_pos {
777            if target_groups.iter().any(|(target_pos, _)| *target_pos == i) {
778                output.push(ARROW_DOWN);
779            } else {
780                output.push(' ');
781            }
782        }
783        writeln!(output).ok();
784    }
785
786    fn draw_multiple_divergences(
787        &self,
788        output: &mut String,
789        source_groups: &[(usize, Vec<(usize, usize, usize)>)],
790    ) {
791        let all_connections: Vec<_> = source_groups
792            .iter()
793            .flat_map(|(_, v)| v.iter().copied())
794            .collect();
795        let min_pos = all_connections
796            .iter()
797            .map(|(_, from, to)| (*from).min(*to))
798            .min()
799            .unwrap_or(0);
800        let max_pos = all_connections
801            .iter()
802            .map(|(_, from, to)| (*from).max(*to))
803            .max()
804            .unwrap_or(0);
805
806        // Line 1: Vertical lines from sources (using from_pos, not source_pos key)
807        for i in 0..=max_pos {
808            if i < min_pos {
809                output.push(' ');
810            } else if all_connections.iter().any(|(_, from, _)| *from == i) {
811                output.push(V_LINE);
812            } else {
813                output.push(' ');
814            }
815        }
816        writeln!(output).ok();
817
818        // Line 2: Draw divergence lines
819        for i in 0..=max_pos {
820            let mut char_at_pos = ' ';
821
822            if i >= min_pos {
823                for (_, conns) in source_groups.iter() {
824                    if conns.len() <= 1 {
825                        continue;
826                    }
827
828                    let targets: Vec<_> = conns.iter().map(|(_, _, to)| to).collect();
829                    let min_target = **targets.iter().min().unwrap();
830                    let max_target = **targets.iter().max().unwrap();
831
832                    if i == min_target {
833                        char_at_pos = CORNER_UR; // ┌
834                    } else if i == max_target {
835                        char_at_pos = CORNER_UL; // ┐
836                    } else if targets.contains(&&i) {
837                        char_at_pos = TEE_DOWN; // ┬
838                    } else if i > min_target && i < max_target {
839                        if char_at_pos == ' ' {
840                            char_at_pos = H_LINE; // ─
841                        }
842                    }
843                }
844            }
845
846            output.push(char_at_pos);
847        }
848        writeln!(output).ok();
849
850        // Line 3: Arrows pointing down
851        for i in 0..=max_pos {
852            if i < min_pos {
853                output.push(' ');
854            } else if all_connections.iter().any(|(_, _, to)| *to == i) {
855                output.push(ARROW_DOWN);
856            } else {
857                output.push(' ');
858            }
859        }
860        writeln!(output).ok();
861    }
862
863    fn draw_simple_verticals(&self, output: &mut String, connections: &[(usize, usize, usize)]) {
864        let max_pos = connections
865            .iter()
866            .map(|(_, from, to)| (*from).max(*to))
867            .max()
868            .unwrap_or(0);
869
870        // Line 1: Vertical lines
871        for i in 0..=max_pos {
872            if connections.iter().any(|(_, from, _)| *from == i) {
873                output.push(V_LINE);
874            } else {
875                output.push(' ');
876            }
877        }
878        writeln!(output).ok();
879
880        // Line 2: Arrows
881        for i in 0..=max_pos {
882            if connections.iter().any(|(_, from, _)| *from == i) {
883                output.push(ARROW_DOWN);
884            } else {
885                output.push(' ');
886            }
887        }
888        writeln!(output).ok();
889    }
890}