Skip to main content

piw/
render.rs

1//! Port of `src/render/graph-render.ts`: renders the workflow DAG onto a
2//! `CharCanvas`. Statuses derive from the steps visible up to the selected
3//! step, taken transitions highlight, switch branches carry case labels, and
4//! loop edges route through a right-hand gutter. Output is pinned to the
5//! TypeScript renderer through the golden fixtures.
6
7use crate::bundle::types::{
8    DefinitionSnapshot, EdgeDef, NodeOutcome, RunState, RunStatus, StepRecord,
9};
10use crate::canvas::{CanvasStyle, CharCanvas};
11use crate::format::{format_duration, parse_timestamp_ms, sanitize_text};
12use crate::layout::{layout_graph, GraphCell, GraphEdge, GraphLayout, GraphSegment};
13use serde_json::Value;
14use std::collections::HashSet;
15
16/// Everything the graph needs from a loaded bundle.
17pub struct GraphView<'a> {
18    pub state: &'a RunState,
19    pub snapshot: Option<&'a DefinitionSnapshot>,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum NodeStatus {
24    Completed,
25    Failed,
26    TimedOut,
27    Active,
28    ReplayFocus,
29    Waiting,
30    Queued,
31    Cancelled,
32}
33
34impl NodeStatus {
35    fn glyph(self) -> char {
36        match self {
37            NodeStatus::Completed => '✓',
38            NodeStatus::Failed => '✗',
39            NodeStatus::TimedOut => '×',
40            NodeStatus::Active => '◐',
41            NodeStatus::ReplayFocus => '◆',
42            NodeStatus::Waiting => '⏸',
43            NodeStatus::Cancelled => '~',
44            NodeStatus::Queued => '·',
45        }
46    }
47
48    fn label(self) -> &'static str {
49        match self {
50            NodeStatus::Completed => "completed",
51            NodeStatus::Failed => "failed",
52            NodeStatus::TimedOut => "timed out",
53            NodeStatus::Active => "running",
54            NodeStatus::ReplayFocus => "replay focus",
55            NodeStatus::Waiting => "waiting",
56            NodeStatus::Cancelled => "cancelled",
57            NodeStatus::Queued => "queued",
58        }
59    }
60
61    fn style(self) -> CanvasStyle {
62        match self {
63            NodeStatus::Completed => CanvasStyle::Ok,
64            NodeStatus::Failed => CanvasStyle::Fail,
65            NodeStatus::TimedOut => CanvasStyle::TimedOut,
66            NodeStatus::Active => CanvasStyle::Active,
67            NodeStatus::ReplayFocus => CanvasStyle::Replay,
68            NodeStatus::Waiting => CanvasStyle::Warn,
69            NodeStatus::Cancelled => CanvasStyle::Cancelled,
70            NodeStatus::Queued => CanvasStyle::NodeDim,
71        }
72    }
73
74    fn border_style(self) -> CanvasStyle {
75        match self {
76            NodeStatus::Completed => CanvasStyle::NodeBorderOk,
77            NodeStatus::Failed => CanvasStyle::NodeBorderFail,
78            NodeStatus::TimedOut => CanvasStyle::NodeBorderTimedOut,
79            NodeStatus::Active => CanvasStyle::NodeBorderActive,
80            NodeStatus::ReplayFocus => CanvasStyle::NodeBorderReplay,
81            NodeStatus::Waiting => CanvasStyle::NodeBorderWarn,
82            NodeStatus::Cancelled => CanvasStyle::NodeBorderCancelled,
83            NodeStatus::Queued => CanvasStyle::NodeBorderDim,
84        }
85    }
86
87    fn is_focused(self) -> bool {
88        matches!(self, NodeStatus::Active | NodeStatus::ReplayFocus)
89    }
90}
91
92const CELL_GAP: i64 = 6;
93const GUTTER_GAP: i64 = 2;
94const GRAPH_SIDE_MARGIN: i64 = 2;
95const CARD_MIN_CONTENT_WIDTH: i64 = 28;
96const CARD_DYNAMIC_RESERVE: &str = "↻ 100  ◷ 9999d 23h 59m 59s";
97
98fn node_type_glyph(node_type: &str, action_execution: Option<&str>) -> char {
99    match (node_type, action_execution) {
100        ("agent", _) => '●',
101        ("compute", _) => 'ƒ',
102        ("notify", _) => '!',
103        ("action", Some("shell")) => '$',
104        ("action", _) => '*',
105        ("checkpoint", _) => '◆',
106        _ => '?',
107    }
108}
109
110fn node_type_style(node_type: &str, focused: bool) -> CanvasStyle {
111    match (node_type, focused) {
112        ("agent", false) => CanvasStyle::Agent,
113        ("agent", true) => CanvasStyle::AgentFocus,
114        ("compute", false) => CanvasStyle::Compute,
115        ("compute", true) => CanvasStyle::ComputeFocus,
116        ("notify", false) => CanvasStyle::Action,
117        ("notify", true) => CanvasStyle::ActionFocus,
118        ("action", false) => CanvasStyle::Action,
119        ("action", true) => CanvasStyle::ActionFocus,
120        ("checkpoint", false) => CanvasStyle::Checkpoint,
121        ("checkpoint", true) => CanvasStyle::CheckpointFocus,
122        (_, false) => CanvasStyle::NodeDim,
123        (_, true) => CanvasStyle::NodeFocusText,
124    }
125}
126
127fn node_type_badge(node_type: &str, action_execution: Option<&str>) -> String {
128    format!(
129        "{} {node_type}",
130        node_type_glyph(node_type, action_execution)
131    )
132}
133
134fn fit_text(text: &str, width: usize) -> String {
135    let chars: Vec<char> = text.chars().collect();
136    if chars.len() <= width {
137        return text.to_string();
138    }
139    if width <= 1 {
140        return chars.into_iter().take(width).collect();
141    }
142    format!("{}…", chars.into_iter().take(width - 1).collect::<String>())
143}
144
145fn centered_text(text: &str, width: usize) -> String {
146    let fitted = fit_text(text, width);
147    let left = width.saturating_sub(fitted.chars().count()) / 2;
148    format!("{}{fitted}", " ".repeat(left))
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum GraphNodeStyle {
153    Line,
154    Box,
155}
156
157#[derive(Debug, Clone, Copy)]
158struct CardMetrics {
159    width: i64,
160    height: i64,
161    branch_rows: usize,
162}
163
164fn cell_height(node_style: GraphNodeStyle, box_height: i64) -> i64 {
165    match node_style {
166        GraphNodeStyle::Box => box_height,
167        GraphNodeStyle::Line => 1,
168    }
169}
170
171/// JS `String.prototype.length` (UTF-16 code units), used for width math.
172fn js_len(text: &str) -> i64 {
173    text.encode_utf16().count() as i64
174}
175
176fn latest_visible_attempt<'a>(steps: &'a [StepRecord], node_id: &str) -> Option<&'a StepRecord> {
177    steps.iter().rev().find(|step| step.node_id == node_id)
178}
179
180fn derive_node_status(
181    view: &GraphView,
182    node_id: &str,
183    visible_steps: &[StepRecord],
184    at_latest_step: bool,
185) -> NodeStatus {
186    let state = view.state;
187    if at_latest_step && state.current_node.as_deref() == Some(node_id) {
188        return NodeStatus::Active;
189    }
190    if at_latest_step && state.waiting_on.as_deref() == Some(node_id) {
191        return NodeStatus::Waiting;
192    }
193    let Some(attempt) = latest_visible_attempt(visible_steps, node_id) else {
194        return NodeStatus::Queued;
195    };
196    // While scrubbing, the selected step is a replay cursor, not a live node.
197    if !at_latest_step && visible_steps.last().map(|step| step.node_id.as_str()) == Some(node_id) {
198        return NodeStatus::ReplayFocus;
199    }
200    match attempt.outcome {
201        NodeOutcome::Ok => NodeStatus::Completed,
202        NodeOutcome::TimedOut => NodeStatus::TimedOut,
203        NodeOutcome::Cancelled => NodeStatus::Cancelled,
204        NodeOutcome::Failed => NodeStatus::Failed,
205    }
206}
207
208fn node_branch_labels(view: &GraphView, node_id: &str) -> Vec<String> {
209    view.snapshot
210        .map(|snapshot| {
211            snapshot
212                .edges
213                .iter()
214                .flat_map(|edge| match edge {
215                    EdgeDef::Switch { from, switch } if from == node_id => switch
216                        .cases
217                        .keys()
218                        .map(|label| sanitize_text(label))
219                        .collect::<Vec<_>>(),
220                    _ => Vec::new(),
221                })
222                .collect()
223        })
224        .unwrap_or_default()
225}
226
227fn hierarchical_node_label(node_id: &str, node: Option<&Value>) -> String {
228    let Some(node) = node else {
229        return sanitize_text(node_id);
230    };
231    let Some(mount_path) = node.get("mountPath").and_then(Value::as_array) else {
232        return sanitize_text(node_id);
233    };
234    let path = mount_path
235        .iter()
236        .filter_map(Value::as_str)
237        .map(sanitize_text)
238        .collect::<Vec<_>>()
239        .join(" › ");
240    let Some(local_node_id) = node.get("localNodeId").and_then(Value::as_str) else {
241        return sanitize_text(node_id);
242    };
243    match node.get("includeTransition").and_then(Value::as_str) {
244        Some("entry") => format!("{path} · enter"),
245        Some("exit") => format!("{path} · {} exit", sanitize_text(local_node_id)),
246        _ => format!("{path} › {}", sanitize_text(local_node_id)),
247    }
248}
249
250fn card_metrics(view: &GraphView) -> CardMetrics {
251    let Some(snapshot) = view.snapshot else {
252        return CardMetrics {
253            width: CARD_MIN_CONTENT_WIDTH + 4,
254            height: 7,
255            branch_rows: 0,
256        };
257    };
258    let mut content_width = CARD_MIN_CONTENT_WIDTH.max(js_len(CARD_DYNAMIC_RESERVE));
259    let mut branch_rows = 0usize;
260    for status in [
261        NodeStatus::Completed,
262        NodeStatus::Failed,
263        NodeStatus::TimedOut,
264        NodeStatus::Active,
265        NodeStatus::ReplayFocus,
266        NodeStatus::Waiting,
267        NodeStatus::Queued,
268        NodeStatus::Cancelled,
269    ] {
270        content_width = content_width.max(js_len(&format!(
271            "{}  {} {}",
272            node_type_badge("checkpoint", None),
273            status.glyph(),
274            status.label()
275        )));
276    }
277    for (node_id, node) in &snapshot.nodes {
278        content_width = content_width.max(js_len(&hierarchical_node_label(node_id, Some(node))));
279        if let Some(node_type) = node.get("nodeType").and_then(Value::as_str) {
280            let action_execution = node.get("actionExecution").and_then(Value::as_str);
281            content_width =
282                content_width.max(js_len(&node_type_badge(node_type, action_execution)));
283        }
284        let labels = node_branch_labels(view, node_id);
285        branch_rows = branch_rows.max(labels.len());
286        for label in labels {
287            content_width = content_width.max(js_len(&format!("◇ {label}")));
288        }
289    }
290    CardMetrics {
291        width: content_width + 4,
292        height: 7 + branch_rows as i64,
293        branch_rows,
294    }
295}
296
297fn human_decision_detail(state: &RunState, node_id: &str, node: Option<&Value>) -> Option<String> {
298    let human = node?.get("humanDecision")?;
299    let choices = human.get("choices")?.as_object()?;
300    if state.waiting_on.as_deref() == Some(node_id) {
301        let audience = state
302            .final_output
303            .as_ref()
304            .and_then(|request| request.get("audience"))
305            .and_then(Value::as_str)
306            .or_else(|| human.get("audience").and_then(Value::as_str))
307            .unwrap_or("operator");
308        let labels = choices
309            .values()
310            .filter_map(|choice| choice.get("label").and_then(Value::as_str))
311            .map(sanitize_text)
312            .collect::<Vec<_>>()
313            .join(" / ");
314        let summary = state
315            .final_output
316            .as_ref()
317            .and_then(|request| request.pointer("/presentation/summary"))
318            .and_then(Value::as_str)
319            .map(sanitize_text);
320        let fingerprint = state
321            .final_output
322            .as_ref()
323            .and_then(|request| request.get("presentationDigest"))
324            .and_then(Value::as_str)
325            .and_then(|digest| digest.strip_prefix("sha256:"))
326            .map(|digest| digest.chars().take(12).collect::<String>());
327        return Some(format!(
328            "human decision · {}{} · {}{}",
329            sanitize_text(audience),
330            summary
331                .map(|value| format!(" · {value}"))
332                .unwrap_or_default(),
333            labels,
334            fingerprint
335                .map(|value| format!(" · {value}"))
336                .unwrap_or_default()
337        ));
338    }
339    state
340        .human_decision
341        .as_ref()
342        .filter(|decision| decision.get("nodeId").and_then(Value::as_str) == Some(node_id))
343        .and_then(|decision| decision.pointer("/response/choice"))
344        .and_then(Value::as_str)
345        .and_then(|choice| choices.get(choice))
346        .and_then(|choice| choice.get("label"))
347        .and_then(Value::as_str)
348        .map(|label| format!("human: {}", sanitize_text(label)))
349}
350
351struct RenderedCell {
352    cell: GraphCell,
353    text: String,
354    node_id: String,
355    node_type: String,
356    type_badge: String,
357    status: Option<NodeStatus>,
358    attempts: usize,
359    elapsed: String,
360    detail: String,
361    branch_lines: Vec<String>,
362    is_start: bool,
363    is_end: bool,
364    width: i64,
365}
366
367fn render_cell_text(
368    view: &GraphView,
369    cell: &GraphCell,
370    visible_steps: &[StepRecord],
371    at_latest_step: bool,
372    now_ms: i64,
373    node_style: GraphNodeStyle,
374    metrics: CardMetrics,
375) -> RenderedCell {
376    let GraphCell::Node { node_id } = cell else {
377        return RenderedCell {
378            cell: cell.clone(),
379            text: String::new(),
380            node_id: String::new(),
381            node_type: String::new(),
382            type_badge: String::new(),
383            status: None,
384            attempts: 0,
385            elapsed: String::new(),
386            detail: String::new(),
387            branch_lines: Vec::new(),
388            is_start: false,
389            is_end: false,
390            width: 1,
391        };
392    };
393    let state = view.state;
394    let status = derive_node_status(view, node_id, visible_steps, at_latest_step);
395    let node = view
396        .snapshot
397        .and_then(|snapshot| snapshot.nodes.get(node_id));
398    let node_type = view
399        .snapshot
400        .and_then(|snapshot| snapshot.node_type(node_id))
401        .unwrap_or("?");
402    let action_execution = view
403        .snapshot
404        .and_then(|snapshot| snapshot.node_action_execution(node_id));
405    let attempt = latest_visible_attempt(visible_steps, node_id);
406    let attempts = visible_steps
407        .iter()
408        .filter(|step| step.node_id == *node_id)
409        .count();
410    let labels = node_branch_labels(view, node_id);
411    let outgoing = view.snapshot.map_or(0, |snapshot| {
412        snapshot
413            .edges
414            .iter()
415            .filter_map(|edge| match edge {
416                EdgeDef::Simple { from, .. } if from == node_id => Some(1),
417                EdgeDef::Switch { from, switch } if from == node_id => Some(switch.cases.len()),
418                _ => None,
419            })
420            .sum::<usize>()
421    });
422    let is_start = view
423        .snapshot
424        .is_some_and(|snapshot| snapshot.start_at == *node_id);
425    let is_end = outgoing == 0;
426    let elapsed = if at_latest_step && state.current_node.as_deref() == Some(node_id.as_str()) {
427        let started_at = state
428            .current_node_started_at
429            .as_deref()
430            .and_then(parse_timestamp_ms)
431            .unwrap_or(now_ms);
432        format_duration(now_ms - started_at)
433    } else if let Some(attempt) = attempt {
434        let duration_ms = parse_timestamp_ms(&attempt.finished_at).unwrap_or(0)
435            - parse_timestamp_ms(&attempt.started_at).unwrap_or(0);
436        format_duration(duration_ms)
437    } else {
438        "—".to_string()
439    };
440    let detail = human_decision_detail(state, node_id, node).unwrap_or_else(|| {
441        if at_latest_step && state.current_node.as_deref() == Some(node_id.as_str()) {
442            state
443                .status_detail
444                .as_deref()
445                .map(sanitize_text)
446                .unwrap_or_default()
447        } else {
448            node.and_then(|node| {
449                node.get("statusDetail")
450                    .or_else(|| node.get("summary"))
451                    .and_then(Value::as_str)
452            })
453            .map(sanitize_text)
454            .unwrap_or_default()
455        }
456    });
457    let mut branch_lines: Vec<String> = labels
458        .into_iter()
459        .map(|label| format!("◇ {label}"))
460        .collect();
461    branch_lines.resize(metrics.branch_rows, String::new());
462    let count = if at_latest_step && state.current_node.as_deref() == Some(node_id.as_str()) {
463        attempts.max(1)
464    } else {
465        attempts
466    };
467    let timing = if attempt.is_some() || count > 0 {
468        format!(
469            "{count} attempt{} · {elapsed}",
470            if count == 1 { "" } else { "s" }
471        )
472    } else {
473        "not visited".to_string()
474    };
475    let display_node_id = hierarchical_node_label(node_id, node);
476    let text = format!("{display_node_id} [{node_type}] {timing}");
477    RenderedCell {
478        cell: cell.clone(),
479        text: text.clone(),
480        node_id: display_node_id,
481        node_type: node_type.to_string(),
482        type_badge: if node.is_some() {
483            node_type_badge(node_type, action_execution)
484        } else {
485            "? unknown".to_string()
486        },
487        status: Some(status),
488        attempts: count,
489        elapsed,
490        detail,
491        branch_lines,
492        is_start,
493        is_end,
494        width: match node_style {
495            GraphNodeStyle::Box => metrics.width,
496            GraphNodeStyle::Line => js_len(&text) + 2,
497        },
498    }
499}
500
501struct RankGeometry {
502    cells: Vec<RenderedCell>,
503    centers: Vec<i64>,
504}
505
506struct PlacedRank {
507    cells: Vec<RenderedCell>,
508    centers: Vec<i64>,
509    y: i64,
510}
511
512/// A strip segment with final pixel geometry and its assigned track row.
513struct GeomSegment {
514    edge_id: String,
515    label: Option<String>,
516    from_x: i64,
517    to_x: i64,
518    track: i64,
519    target_is_node: bool,
520}
521
522struct StripGeometry {
523    segments: Vec<GeomSegment>,
524    track_count: i64,
525    has_labels: bool,
526    /// True when every segment is an unlabeled vertical line.
527    straight: bool,
528}
529
530/// Transitions actually taken between the visible steps, as "from->to".
531fn taken_transitions(visible_steps: &[StepRecord]) -> HashSet<String> {
532    visible_steps
533        .windows(2)
534        .map(|pair| format!("{}->{}", pair[0].node_id, pair[1].node_id))
535        .collect()
536}
537
538#[derive(Debug, Clone, PartialEq, Eq)]
539pub struct NodeBounds {
540    pub node_id: String,
541    pub x: i64,
542    pub y: i64,
543    pub width: i64,
544    pub height: i64,
545}
546
547pub struct RenderedGraph {
548    pub canvas: CharCanvas,
549    pub node_bounds: Vec<NodeBounds>,
550}
551
552/// Render the graph pane and retain node bounds for camera targeting and hit
553/// testing. `selected_step_index` scrubs the replay position;
554/// `at_latest_step` says whether the caller is showing the live view.
555pub fn render_graph(
556    view: &GraphView,
557    selected_step_index: i64,
558    at_latest_step: bool,
559    now_ms: i64,
560    node_style: GraphNodeStyle,
561) -> Option<RenderedGraph> {
562    let snapshot = view.snapshot?;
563    let metrics = card_metrics(view);
564    let layout = layout_graph(snapshot);
565    let steps = &view.state.steps;
566    let bounded_index = selected_step_index.max(-1).min(steps.len() as i64 - 1);
567    let visible_steps = &steps[0..(bounded_index + 1) as usize];
568    let transitions = taken_transitions(visible_steps);
569    let active_pair = derive_pair_in_flight(view, visible_steps, at_latest_step);
570
571    let rendered: Vec<Vec<RenderedCell>> = layout
572        .ranks
573        .iter()
574        .map(|rank| {
575            rank.iter()
576                .map(|cell| {
577                    render_cell_text(
578                        view,
579                        cell,
580                        visible_steps,
581                        at_latest_step,
582                        now_ms,
583                        node_style,
584                        metrics,
585                    )
586                })
587                .collect()
588        })
589        .collect();
590
591    // Column positions: pack cells left to right per rank, then center every
592    // rank against the widest one so vertical edges stay near-vertical.
593    let rank_widths: Vec<i64> = rendered
594        .iter()
595        .map(|cells| {
596            cells.iter().map(|cell| cell.width).sum::<i64>()
597                + 0.max(cells.len() as i64 - 1) * CELL_GAP
598        })
599        .collect();
600    let graph_width = rank_widths.iter().copied().max().unwrap_or(0).max(0) + GRAPH_SIDE_MARGIN * 2;
601    let geometry: Vec<RankGeometry> = rendered
602        .into_iter()
603        .enumerate()
604        .map(|(rank_index, cells)| {
605            let mut centers = Vec::with_capacity(cells.len());
606            let mut x = (graph_width - rank_widths[rank_index]) / 2;
607            for cell in &cells {
608                // Single-cell ranks share the exact graph center so chains
609                // render as straight vertical lines instead of elbows.
610                centers.push(if cells.len() == 1 {
611                    graph_width / 2
612                } else {
613                    x + cell.width / 2
614                });
615                x += cell.width + CELL_GAP;
616            }
617            RankGeometry { cells, centers }
618        })
619        .collect();
620
621    // Horizontal edge geometry (exit/entry columns, pixel-space track rows)
622    // is fully decided before vertical placement, so row budgeting is exact.
623    let strips: Vec<StripGeometry> = (0..geometry.len())
624        .map(|rank_index| compute_strip_geometry(&layout, rank_index, &geometry))
625        .collect();
626
627    let lanes = BackEdgeLanes::new(&layout);
628    let mut placed: Vec<PlacedRank> = Vec::new();
629    // Entry lanes above the first rank need an arrow row of their own.
630    let top_lanes = lanes.above(0).len() as i64;
631    let mut y = if top_lanes > 0 { top_lanes + 1 } else { 0 };
632    let rank_count = geometry.len();
633    for (rank_index, rank) in geometry.into_iter().enumerate() {
634        placed.push(PlacedRank {
635            cells: rank.cells,
636            centers: rank.centers,
637            y,
638        });
639        y += cell_height(node_style, metrics.height)
640            + lanes.below(rank_index).len() as i64
641            + gap_rows(&strips[rank_index], rank_index, rank_count)
642            + lanes.above(rank_index + 1).len() as i64;
643    }
644
645    let node_bounds = placed
646        .iter()
647        .flat_map(|rank| {
648            rank.cells
649                .iter()
650                .zip(&rank.centers)
651                .filter_map(|(cell, center)| match &cell.cell {
652                    GraphCell::Node { node_id } => Some(NodeBounds {
653                        node_id: node_id.clone(),
654                        x: center - cell.width / 2,
655                        y: rank.y,
656                        width: cell.width,
657                        height: cell_height(node_style, metrics.height),
658                    }),
659                    GraphCell::Virtual { .. } => None,
660                })
661        })
662        .collect();
663
664    let mut canvas = CharCanvas::new();
665    draw_nodes(
666        &mut canvas,
667        &placed,
668        &layout,
669        &transitions,
670        node_style,
671        metrics.height,
672    );
673    let labels = draw_segments(
674        &mut canvas,
675        &placed,
676        &strips,
677        &layout,
678        &transitions,
679        active_pair.as_deref(),
680        graph_width,
681        node_style,
682        metrics.height,
683        &lanes,
684    );
685    draw_back_edges(
686        &mut canvas,
687        &placed,
688        &layout,
689        &transitions,
690        graph_width,
691        node_style,
692        metrics.height,
693        &lanes,
694    );
695    // Labels go on last, once every line is on the canvas: placement can then
696    // guarantee no later stroke crosses through a label.
697    for label in labels {
698        draw_segment_label(&mut canvas, &label);
699    }
700    Some(RenderedGraph {
701        canvas,
702        node_bounds,
703    })
704}
705
706/// Render only the graph canvas for callers that do not need hit regions.
707pub fn render_graph_canvas(
708    view: &GraphView,
709    selected_step_index: i64,
710    at_latest_step: bool,
711    now_ms: i64,
712    node_style: GraphNodeStyle,
713) -> Option<CharCanvas> {
714    render_graph(
715        view,
716        selected_step_index,
717        at_latest_step,
718        now_ms,
719        node_style,
720    )
721    .map(|rendered| rendered.canvas)
722}
723
724/// Render the graph to plain text lines (parity with the TS renderer with
725/// colors disabled). The TS reference infers "at latest" from the index, so
726/// this entry point does the same.
727pub fn render_graph_lines(
728    view: &GraphView,
729    selected_step_index: i64,
730    now_ms: i64,
731    node_style: GraphNodeStyle,
732) -> Vec<String> {
733    let at_latest_step = selected_step_index >= view.state.steps.len() as i64 - 1;
734    match render_graph_canvas(
735        view,
736        selected_step_index,
737        at_latest_step,
738        now_ms,
739        node_style,
740    ) {
741        Some(canvas) => canvas.render_plain(),
742        None => Vec::new(),
743    }
744}
745
746/// Back edges route through dedicated lane rows: one below their source rank
747/// and one above their target rank.
748struct BackEdgeLanes {
749    edges: Vec<GraphEdge>,
750    rank_of_node: std::collections::HashMap<String, usize>,
751}
752
753impl BackEdgeLanes {
754    fn new(layout: &GraphLayout) -> Self {
755        Self {
756            edges: layout
757                .edges
758                .iter()
759                .filter(|edge| edge.is_back_edge)
760                .cloned()
761                .collect(),
762            rank_of_node: layout.rank_of_node.clone(),
763        }
764    }
765
766    fn below(&self, rank: usize) -> Vec<&GraphEdge> {
767        self.edges
768            .iter()
769            .filter(|edge| self.rank_of_node.get(&edge.from) == Some(&rank))
770            .collect()
771    }
772
773    fn above(&self, rank: usize) -> Vec<&GraphEdge> {
774        self.edges
775            .iter()
776            .filter(|edge| self.rank_of_node.get(&edge.to) == Some(&rank))
777            .collect()
778    }
779}
780
781/// The transition currently in flight, drawn in the active style.
782fn derive_pair_in_flight(
783    view: &GraphView,
784    visible_steps: &[StepRecord],
785    at_latest_step: bool,
786) -> Option<String> {
787    let state = view.state;
788    if at_latest_step {
789        if state.status == RunStatus::Running {
790            if let (Some(current), Some(last)) = (
791                state.current_node.as_deref().filter(|id| !id.is_empty()),
792                visible_steps.last(),
793            ) {
794                return Some(format!("{}->{current}", last.node_id));
795            }
796        }
797        return None;
798    }
799    if visible_steps.len() >= 2 {
800        let previous = &visible_steps[visible_steps.len() - 2];
801        let last = &visible_steps[visible_steps.len() - 1];
802        return Some(format!("{}->{}", previous.node_id, last.node_id));
803    }
804    None
805}
806
807/// Rows between rank r's cell rows and rank r+1's cell rows.
808fn gap_rows(strip: &StripGeometry, rank: usize, rank_count: usize) -> i64 {
809    if strip.segments.is_empty() {
810        return if rank < rank_count - 1 { 1 } else { 0 };
811    }
812    // Straight unlabeled strips need no track rows: one line row, one arrow row.
813    if strip.straight {
814        return 2;
815    }
816    // Labelled strips reserve one extra row below the tracks so labels that
817    // do not fit on their horizontal run always have a collision-free home.
818    2 + strip.track_count + if strip.has_labels { 1 } else { 0 }
819}
820
821/// Resolve a strip (all segments between rank r and rank r+1) to final pixel
822/// geometry: exit and entry columns, and a horizontal track row per segment.
823fn compute_strip_geometry(
824    layout: &GraphLayout,
825    rank: usize,
826    geometry: &[RankGeometry],
827) -> StripGeometry {
828    let strip: Vec<&GraphSegment> = layout
829        .segments
830        .iter()
831        .filter(|segment| segment.rank == rank)
832        .collect();
833    let (Some(top), Some(bottom)) = (geometry.get(rank), geometry.get(rank + 1)) else {
834        return StripGeometry {
835            segments: Vec::new(),
836            track_count: 1,
837            has_labels: false,
838            straight: true,
839        };
840    };
841    if strip.is_empty() {
842        return StripGeometry {
843            segments: Vec::new(),
844            track_count: 1,
845            has_labels: false,
846            straight: true,
847        };
848    }
849    let exit_offsets = fan_offsets(&strip, FanSide::From, top, bottom);
850    let entry_offsets = fan_offsets(&strip, FanSide::To, top, bottom);
851    struct Resolved {
852        edge_id: String,
853        label: Option<String>,
854        from_x: i64,
855        to_x: i64,
856        target_is_node: bool,
857    }
858    let mut resolved: Vec<Resolved> = strip
859        .iter()
860        .map(|segment| {
861            let from_x = top.centers[segment.from_cell]
862                + exit_offsets.get(&segment.edge_id).copied().unwrap_or(0);
863            let mut to_x = bottom.centers[segment.to_cell]
864                + entry_offsets.get(&segment.edge_id).copied().unwrap_or(0);
865            let target_is_node = bottom.cells[segment.to_cell].cell.is_node();
866            // A one-column jog reads as noise; draw it straight into the
867            // target, whose rendered cell is wide enough to absorb the
868            // offset. Virtual cells are exactly one column wide, so they
869            // must never be snapped.
870            if target_is_node && (to_x - from_x).abs() <= 1 {
871                to_x = from_x;
872            }
873            Resolved {
874                edge_id: segment.edge_id.clone(),
875                label: segment.label.clone(),
876                from_x,
877                to_x,
878                target_is_node,
879            }
880        })
881        .collect();
882
883    // First-fit track assignment over pixel spans; straight unlabeled
884    // segments draw a plain vertical line and need no track row.
885    resolved.sort_by_key(|segment| segment.from_x);
886    let mut segments: Vec<GeomSegment> = Vec::new();
887    let mut track_ranges: Vec<Vec<(i64, i64)>> = Vec::new();
888    for segment in resolved {
889        let mut track = 0i64;
890        if segment.from_x != segment.to_x || segment.label.is_some() {
891            let span = (
892                segment.from_x.min(segment.to_x),
893                segment.from_x.max(segment.to_x),
894            );
895            let found = track_ranges.iter().position(|ranges| {
896                ranges
897                    .iter()
898                    .all(|&(start, end)| span.1 < start || span.0 > end)
899            });
900            track = match found {
901                Some(index) => index as i64,
902                None => {
903                    track_ranges.push(Vec::new());
904                    track_ranges.len() as i64 - 1
905                }
906            };
907            track_ranges[track as usize].push(span);
908        }
909        segments.push(GeomSegment {
910            edge_id: segment.edge_id,
911            label: segment.label,
912            from_x: segment.from_x,
913            to_x: segment.to_x,
914            track,
915            target_is_node: segment.target_is_node,
916        });
917    }
918    StripGeometry {
919        track_count: (track_ranges.len() as i64).max(1),
920        has_labels: segments.iter().any(|segment| segment.label.is_some()),
921        straight: segments
922            .iter()
923            .all(|segment| segment.from_x == segment.to_x && segment.label.is_none()),
924        segments,
925    }
926}
927
928#[derive(Clone, Copy, PartialEq)]
929enum FanSide {
930    From,
931    To,
932}
933
934/// Fan columns for edges sharing a cell: segment i (ordered by the far
935/// end's x) gets column center - 2*(n-1-i), clamped to the cell, never
936/// right of center.
937fn fan_offsets(
938    strip: &[&GraphSegment],
939    side: FanSide,
940    top: &RankGeometry,
941    bottom: &RankGeometry,
942) -> std::collections::HashMap<String, i64> {
943    let (own_rank, far_rank) = match side {
944        FanSide::From => (top, bottom),
945        FanSide::To => (bottom, top),
946    };
947    let own_cell = |segment: &GraphSegment| match side {
948        FanSide::From => segment.from_cell,
949        FanSide::To => segment.to_cell,
950    };
951    let far_cell = |segment: &GraphSegment| match side {
952        FanSide::From => segment.to_cell,
953        FanSide::To => segment.from_cell,
954    };
955    let mut offsets = std::collections::HashMap::new();
956    // Preserve insertion order of groups for determinism.
957    let mut group_order: Vec<usize> = Vec::new();
958    let mut groups: std::collections::HashMap<usize, Vec<&GraphSegment>> =
959        std::collections::HashMap::new();
960    for segment in strip {
961        // Virtual cells are one column wide and always have one edge per side.
962        if own_rank.cells[own_cell(segment)].cell.is_node() {
963            let key = own_cell(segment);
964            if !groups.contains_key(&key) {
965                group_order.push(key);
966            }
967            groups.entry(key).or_default().push(segment);
968        }
969    }
970    for cell_index in group_order {
971        let group = &groups[&cell_index];
972        if group.len() < 2 {
973            continue;
974        }
975        let cell = &own_rank.cells[cell_index];
976        let max_offset = 1.max(cell.width / 2 - 1);
977        let mut ordered: Vec<&&GraphSegment> = group.iter().collect();
978        ordered.sort_by_key(|segment| far_rank.centers[far_cell(segment)]);
979        let count = ordered.len() as i64;
980        for (index, segment) in ordered.into_iter().enumerate() {
981            let offset = -2 * (count - 1 - index as i64);
982            offsets.insert(segment.edge_id.clone(), offset.max(-max_offset));
983        }
984    }
985    offsets
986}
987
988struct BoxChars {
989    tl: char,
990    tr: char,
991    ml: char,
992    mr: char,
993    bl: char,
994    br: char,
995    h: char,
996    v: char,
997}
998
999const BOX_LIGHT: BoxChars = BoxChars {
1000    tl: '┌',
1001    tr: '┐',
1002    ml: '├',
1003    mr: '┤',
1004    bl: '└',
1005    br: '┘',
1006    h: '─',
1007    v: '│',
1008};
1009
1010const BOX_HEAVY: BoxChars = BoxChars {
1011    tl: '┏',
1012    tr: '┓',
1013    ml: '┣',
1014    mr: '┫',
1015    bl: '┗',
1016    br: '┛',
1017    h: '━',
1018    v: '┃',
1019};
1020
1021fn draw_nodes(
1022    canvas: &mut CharCanvas,
1023    placed: &[PlacedRank],
1024    layout: &GraphLayout,
1025    transitions: &HashSet<String>,
1026    node_style: GraphNodeStyle,
1027    box_height: i64,
1028) {
1029    for rank in placed {
1030        for (index, rendered) in rank.cells.iter().enumerate() {
1031            let center = rank.centers[index];
1032            match &rendered.cell {
1033                GraphCell::Virtual { edge_id } => {
1034                    let edge = layout
1035                        .edges
1036                        .iter()
1037                        .find(|candidate| candidate.edge_id == *edge_id);
1038                    let taken = edge.is_some_and(|edge| {
1039                        transitions.contains(&format!("{}->{}", edge.from, edge.to))
1040                    });
1041                    // Pass-through cells span the full cell height so the
1042                    // edge stays visually continuous across the rank row(s).
1043                    canvas.vline(
1044                        center,
1045                        rank.y,
1046                        rank.y + cell_height(node_style, box_height) - 1,
1047                        if taken {
1048                            CanvasStyle::Taken
1049                        } else {
1050                            CanvasStyle::Dim
1051                        },
1052                    );
1053                }
1054                GraphCell::Node { .. } => {
1055                    let status = rendered.status.unwrap_or(NodeStatus::Queued);
1056                    let start_x = center - rendered.width / 2;
1057                    if node_style == GraphNodeStyle::Box {
1058                        draw_node_box(canvas, start_x, rank.y, rendered, status);
1059                    } else {
1060                        if rendered.is_start {
1061                            canvas.put(start_x - 2, rank.y, '▶', status.border_style());
1062                        }
1063                        canvas.put(start_x, rank.y, status.glyph(), status.border_style());
1064                        canvas.text(
1065                            start_x + 2,
1066                            rank.y,
1067                            &rendered.text,
1068                            if status == NodeStatus::Queued {
1069                                CanvasStyle::Dim
1070                            } else {
1071                                CanvasStyle::Plain
1072                            },
1073                        );
1074                        if rendered.is_end {
1075                            canvas.put(
1076                                start_x + rendered.width + 1,
1077                                rank.y,
1078                                '■',
1079                                status.border_style(),
1080                            );
1081                        }
1082                    }
1083                }
1084            }
1085        }
1086    }
1087}
1088
1089/// A bordered node cell; the active node gets a heavy border.
1090fn draw_node_box(
1091    canvas: &mut CharCanvas,
1092    start_x: i64,
1093    y: i64,
1094    rendered: &RenderedCell,
1095    status: NodeStatus,
1096) {
1097    let chars = if status.is_focused() {
1098        &BOX_HEAVY
1099    } else {
1100        &BOX_LIGHT
1101    };
1102    let border_style = status.border_style();
1103    let status_style = status.style();
1104    let type_style = node_type_style(&rendered.node_type, status.is_focused());
1105    let branch_style = if status.is_focused() {
1106        CanvasStyle::BranchFocus
1107    } else {
1108        CanvasStyle::Branch
1109    };
1110    let content_style = if status.is_focused() {
1111        CanvasStyle::NodeFocusText
1112    } else if status == NodeStatus::Queued {
1113        CanvasStyle::NodeDim
1114    } else {
1115        CanvasStyle::NodeText
1116    };
1117    let height = 7 + rendered.branch_lines.len() as i64;
1118    let inner_width = (rendered.width - 2) as usize;
1119    let right_x = start_x + rendered.width - 1;
1120    canvas.fill_rect(
1121        start_x + 1,
1122        y + 1,
1123        rendered.width - 2,
1124        1,
1125        CanvasStyle::NodeHeader,
1126    );
1127    canvas.fill_rect(
1128        start_x + 1,
1129        y + 3,
1130        rendered.width - 2,
1131        height - 4,
1132        content_style,
1133    );
1134    let horizontal: String = std::iter::repeat_n(chars.h, inner_width).collect();
1135
1136    canvas.text(
1137        start_x,
1138        y,
1139        &format!("{}{horizontal}{}", chars.tl, chars.tr),
1140        border_style,
1141    );
1142    canvas.text(start_x, y + 1, &chars.v.to_string(), border_style);
1143    canvas.text(right_x, y + 1, &chars.v.to_string(), border_style);
1144    canvas.text(
1145        start_x + 1,
1146        y + 1,
1147        &centered_text(&rendered.node_id, inner_width),
1148        CanvasStyle::NodeHeader,
1149    );
1150    canvas.text(
1151        start_x,
1152        y + 2,
1153        &format!("{}{horizontal}{}", chars.ml, chars.mr),
1154        border_style,
1155    );
1156
1157    let type_badge = fit_text(&rendered.type_badge, inner_width - 2);
1158    let status_badge = fit_text(
1159        &format!("{} {}", status.glyph(), status.label()),
1160        inner_width - 2,
1161    );
1162    canvas.text(start_x, y + 3, &chars.v.to_string(), border_style);
1163    canvas.text(right_x, y + 3, &chars.v.to_string(), border_style);
1164    canvas.text(start_x + 2, y + 3, &type_badge, type_style);
1165    canvas.text(
1166        right_x - 1 - status_badge.chars().count() as i64,
1167        y + 3,
1168        &status_badge,
1169        status_style,
1170    );
1171
1172    let attempts = format!("↻ {}", rendered.attempts);
1173    let elapsed = format!("◷ {}", rendered.elapsed);
1174    canvas.text(start_x, y + 4, &chars.v.to_string(), border_style);
1175    canvas.text(right_x, y + 4, &chars.v.to_string(), border_style);
1176    canvas.text(start_x + 2, y + 4, &attempts, content_style);
1177    canvas.text(
1178        right_x - 1 - elapsed.chars().count() as i64,
1179        y + 4,
1180        &elapsed,
1181        content_style,
1182    );
1183
1184    for (index, branch) in rendered.branch_lines.iter().enumerate() {
1185        let row = y + 5 + index as i64;
1186        canvas.text(start_x, row, &chars.v.to_string(), border_style);
1187        canvas.text(right_x, row, &chars.v.to_string(), border_style);
1188        canvas.text(
1189            start_x + 2,
1190            row,
1191            &fit_text(branch, inner_width - 2),
1192            branch_style,
1193        );
1194    }
1195    let detail_row = y + 5 + rendered.branch_lines.len() as i64;
1196    canvas.text(start_x, detail_row, &chars.v.to_string(), border_style);
1197    canvas.text(right_x, detail_row, &chars.v.to_string(), border_style);
1198    if !rendered.detail.is_empty() {
1199        canvas.text(
1200            start_x + 2,
1201            detail_row,
1202            &fit_text(&format!("… {}", rendered.detail), inner_width - 2),
1203            content_style,
1204        );
1205    }
1206    canvas.text(
1207        start_x,
1208        y + height - 1,
1209        &format!("{}{horizontal}{}", chars.bl, chars.br),
1210        border_style,
1211    );
1212    if rendered.is_start {
1213        canvas.put(start_x - 2, y + 1, '▶', border_style);
1214    }
1215    if rendered.is_end {
1216        canvas.put(start_x + rendered.width + 1, y + 1, '■', border_style);
1217    }
1218}
1219
1220fn edge_style(
1221    pair_key: &str,
1222    transitions: &HashSet<String>,
1223    active_pair: Option<&str>,
1224) -> CanvasStyle {
1225    if active_pair == Some(pair_key) {
1226        return CanvasStyle::ActiveEdge;
1227    }
1228    if transitions.contains(pair_key) {
1229        return CanvasStyle::Taken;
1230    }
1231    CanvasStyle::Dim
1232}
1233
1234struct PendingLabel {
1235    text: String,
1236    style: CanvasStyle,
1237    from_x: i64,
1238    to_x: i64,
1239    track_y: i64,
1240    label_row: i64,
1241    graph_width: i64,
1242}
1243
1244#[allow(clippy::too_many_arguments)]
1245fn draw_segments(
1246    canvas: &mut CharCanvas,
1247    placed: &[PlacedRank],
1248    strips: &[StripGeometry],
1249    layout: &GraphLayout,
1250    transitions: &HashSet<String>,
1251    active_pair: Option<&str>,
1252    graph_width: i64,
1253    node_style: GraphNodeStyle,
1254    box_height: i64,
1255    lanes: &BackEdgeLanes,
1256) -> Vec<PendingLabel> {
1257    let mut labels = Vec::new();
1258    for rank in 0..placed.len().saturating_sub(1) {
1259        let strip = &strips[rank];
1260        if strip.segments.is_empty() {
1261            continue;
1262        }
1263        let top = &placed[rank];
1264        let bottom = &placed[rank + 1];
1265        // Forward lines start right below the source cell, cross any
1266        // back-edge lane rows (as ┼ crossings), run their strip tracks, then
1267        // cross the entry lanes to the arrow row directly above the target.
1268        let stub_top = top.y + cell_height(node_style, box_height);
1269        let strip_top = stub_top + lanes.below(rank).len() as i64;
1270        let arrow_y = bottom.y - 1;
1271        let strip_bottom = arrow_y - 1 - lanes.above(rank + 1).len() as i64;
1272        for segment in &strip.segments {
1273            let Some(edge) = layout
1274                .edges
1275                .iter()
1276                .find(|candidate| candidate.edge_id == segment.edge_id)
1277            else {
1278                continue;
1279            };
1280            let style = edge_style(
1281                &format!("{}->{}", edge.from, edge.to),
1282                transitions,
1283                active_pair,
1284            );
1285            let (from_x, to_x) = (segment.from_x, segment.to_x);
1286            let track_y = strip_top + segment.track;
1287            if from_x == to_x {
1288                canvas.vline(from_x, stub_top, arrow_y, style);
1289            } else {
1290                if track_y > stub_top {
1291                    canvas.vline(from_x, stub_top, track_y - 1, style);
1292                }
1293                canvas.put(
1294                    from_x,
1295                    track_y,
1296                    if to_x > from_x { '└' } else { '┘' },
1297                    style,
1298                );
1299                canvas.hline(track_y, from_x.min(to_x) + 1, from_x.max(to_x) - 1, style);
1300                canvas.put(to_x, track_y, if to_x > from_x { '┐' } else { '┌' }, style);
1301                if arrow_y > track_y {
1302                    canvas.vline(to_x, track_y + 1, arrow_y, style);
1303                }
1304            }
1305            if segment.target_is_node {
1306                canvas.put(to_x, arrow_y, '▼', style);
1307            }
1308            if let Some(label) = &segment.label {
1309                labels.push(PendingLabel {
1310                    text: label.clone(),
1311                    style,
1312                    from_x,
1313                    to_x,
1314                    track_y,
1315                    label_row: (strip_top + strip.track_count).min(strip_bottom),
1316                    graph_width,
1317                });
1318            }
1319        }
1320    }
1321    labels
1322}
1323
1324/// Place a branch label: first over the segment's own horizontal run, then
1325/// the strip's reserved label row beside the descending line (side facing
1326/// the graph center first), then beside the source corner.
1327fn draw_segment_label(canvas: &mut CharCanvas, label: &PendingLabel) {
1328    let padded = format!(" {} ", label.text);
1329    let padded_len = js_len(&padded);
1330    let text_len = js_len(&label.text);
1331    if label.from_x != label.to_x {
1332        let run_start = label.from_x.min(label.to_x) + 1;
1333        let run_end = label.from_x.max(label.to_x) - 1;
1334        let center = (run_start + run_end) / 2 - padded_len / 2;
1335        if run_end - run_start + 1 >= padded_len + 2
1336            && canvas.text_over_run(center, label.track_y, &padded, label.style)
1337        {
1338            return;
1339        }
1340    }
1341    let left = (label.to_x - text_len - 1, label.label_row);
1342    let right = (label.to_x + 2, label.label_row);
1343    let candidates = if label.to_x >= label.graph_width / 2 {
1344        [left, right]
1345    } else {
1346        [right, left]
1347    };
1348    for (x, y) in candidates {
1349        if canvas.text_if_empty(x, y, &label.text, label.style) {
1350            return;
1351        }
1352    }
1353    // Last resort: beside the source corner on the track row.
1354    canvas.text_if_empty(label.from_x + 2, label.track_y, &label.text, label.style);
1355}
1356
1357/// Each back edge leaves its source cell downward into its own lane row,
1358/// runs right to a private gutter column, climbs the gutter, and re-enters
1359/// through its target's entry lane and arrow row from above.
1360#[allow(clippy::too_many_arguments)]
1361fn draw_back_edges(
1362    canvas: &mut CharCanvas,
1363    placed: &[PlacedRank],
1364    layout: &GraphLayout,
1365    transitions: &HashSet<String>,
1366    graph_width: i64,
1367    node_style: GraphNodeStyle,
1368    box_height: i64,
1369    lanes: &BackEdgeLanes,
1370) {
1371    let mut gutter_x = graph_width + GUTTER_GAP;
1372    for edge in &lanes.edges {
1373        let (Some(&from_rank), Some(&to_rank)) = (
1374            layout.rank_of_node.get(&edge.from),
1375            layout.rank_of_node.get(&edge.to),
1376        ) else {
1377            continue;
1378        };
1379        let from = &placed[from_rank];
1380        let to = &placed[to_rank];
1381        let below = lanes.below(from_rank);
1382        let above = lanes.above(to_rank);
1383        let (Some(exit), Some(entry)) = (
1384            cell_anchor(from, &edge.from, &below, edge),
1385            cell_anchor(to, &edge.to, &above, edge),
1386        ) else {
1387            continue;
1388        };
1389        let style = if transitions.contains(&format!("{}->{}", edge.from, edge.to)) {
1390            CanvasStyle::Taken
1391        } else {
1392            CanvasStyle::Back
1393        };
1394        let exit_lane_y = from.y + cell_height(node_style, box_height) + exit.lane;
1395        let above_count = above.len() as i64;
1396        let arrow_y = to.y - 1;
1397        let entry_lane_y = arrow_y - above_count + entry.lane;
1398
1399        // Downward stub out of the source cell, then right along the exit lane.
1400        if exit_lane_y > from.y + cell_height(node_style, box_height) {
1401            canvas.vline(
1402                exit.x,
1403                from.y + cell_height(node_style, box_height),
1404                exit_lane_y - 1,
1405                style,
1406            );
1407        }
1408        canvas.put(exit.x, exit_lane_y, '└', style);
1409        canvas.hline(exit_lane_y, exit.x + 1, gutter_x - 1, style);
1410        canvas.put(gutter_x, exit_lane_y, '┘', style);
1411        // Up the gutter, then left along the entry lane into the target.
1412        canvas.put(gutter_x, entry_lane_y, '┐', style);
1413        if exit_lane_y - entry_lane_y > 1 {
1414            canvas.vline(gutter_x, entry_lane_y + 1, exit_lane_y - 1, style);
1415        }
1416        canvas.hline(entry_lane_y, entry.x + 1, gutter_x - 1, style);
1417        canvas.put(entry.x, entry_lane_y, '┌', style);
1418        if arrow_y - entry_lane_y > 1 {
1419            canvas.vline(entry.x, entry_lane_y + 1, arrow_y - 1, style);
1420        }
1421        canvas.put(entry.x, arrow_y, '▼', style);
1422        if let Some(label) = &edge.label {
1423            canvas.text(gutter_x + 2, entry_lane_y, label, style);
1424        }
1425        // Reserve horizontal room for this gutter and its label before the next.
1426        gutter_x += 2 + edge.label.as_deref().map_or(0, |label| js_len(label) + 1);
1427    }
1428}
1429
1430struct Anchor {
1431    x: i64,
1432    lane: i64,
1433}
1434
1435/// Where a back edge touches a node cell: offset right of center so the
1436/// stub can never collide with forward-edge lines at the center column,
1437/// clamped inside the cell.
1438fn cell_anchor(
1439    rank: &PlacedRank,
1440    node_id: &str,
1441    lane_edges: &[&GraphEdge],
1442    edge: &GraphEdge,
1443) -> Option<Anchor> {
1444    let index = rank
1445        .cells
1446        .iter()
1447        .position(|cell| matches!(&cell.cell, GraphCell::Node { node_id: id } if id == node_id))?;
1448    let lane = lane_edges
1449        .iter()
1450        .position(|candidate| candidate.edge_id == edge.edge_id)? as i64;
1451    let cell = &rank.cells[index];
1452    let center = rank.centers[index];
1453    let rightmost = center + cell.width / 2 - 1;
1454    Some(Anchor {
1455        x: (center + 2 + lane * 2).min(rightmost),
1456        lane,
1457    })
1458}