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