1mod config;
2use clap::Parser;
3use config::{ConfigGraphs, PortLookup, build_render_topology, read_configuration};
4pub use cu29_traits::*;
5use hashbrown::HashMap;
6use hashbrown::hash_map::Entry;
7use layout::adt::dag::NodeHandle;
8use layout::core::base::Orientation;
9use layout::core::color::Color;
10use layout::core::format::{RenderBackend, Visible};
11use layout::core::geometry::{Point, get_size_for_str, pad_shape_scalar};
12use layout::core::style::{LineStyleKind, StyleAttr};
13use layout::std_shapes::shapes::{Arrow, Element, LineEndKind, RecordDef, ShapeKind};
14use layout::topo::layout::VisualGraph;
15use serde::Deserialize;
16use std::cmp::Ordering;
17use std::collections::{BTreeSet, HashSet};
18use std::fs;
19use std::io::Write;
20use std::path::{Path, PathBuf};
21use std::process::Command;
22use svg::Document;
23use svg::node::Node;
24use svg::node::Text as SvgTextNode;
25use svg::node::element::path::Data;
26use svg::node::element::{
27 Circle, Definitions, Element as SvgElement, Group, Image, Line, Marker, Path as SvgPath,
28 Polygon, Rectangle, Text, TextPath, Title,
29};
30use tempfile::Builder;
31
32const FONT_FAMILY: &str = "'Noto Sans', sans-serif";
34const MONO_FONT_FAMILY: &str = "'Noto Sans Mono'";
35const FONT_SIZE: usize = 12;
36const TYPE_FONT_SIZE: usize = FONT_SIZE * 7 / 10;
37const PORT_HEADER_FONT_SIZE: usize = FONT_SIZE * 4 / 6;
38const PORT_VALUE_FONT_SIZE: usize = FONT_SIZE * 4 / 6;
39const CONFIG_FONT_SIZE: usize = PORT_VALUE_FONT_SIZE - 1;
40const EDGE_FONT_SIZE: usize = 7;
41const TYPE_WRAP_WIDTH: usize = 24;
42const CONFIG_WRAP_WIDTH: usize = 32;
43const MODULE_TRUNC_MARKER: &str = "…";
44const MODULE_SEPARATOR: &str = "⠶";
45const PLACEHOLDER_TEXT: &str = "\u{2014}";
46const COPPER_LOGO_SVG: &str = include_str!("../assets/cu29.svg");
47const LOGSTATS_SCHEMA_VERSION: u32 = 1;
48
49const BORDER_COLOR: &str = "#999999";
51const BACKGROUND_COLOR: &str = "#ffffff";
52const HEADER_BG: &str = "#f4f4f4";
53const DIM_GRAY: &str = "dimgray";
54const LIGHT_GRAY: &str = "lightgray";
55const CLUSTER_COLOR: &str = "#bbbbbb";
56const BRIDGE_HEADER_BG: &str = "#f7d7e4";
57const SOURCE_HEADER_BG: &str = "#ddefc7";
58const SINK_HEADER_BG: &str = "#cce0ff";
59const TASK_HEADER_BG: &str = "#fde7c2";
60const RESOURCE_TITLE_BG: &str = "#eef1f6";
61const RESOURCE_EXCLUSIVE_BG: &str = "#e3f4e7";
62const RESOURCE_SHARED_BG: &str = "#fff0d9";
63const RESOURCE_UNUSED_BG: &str = "#f1f1f1";
64const RESOURCE_UNUSED_TEXT: &str = "#8d8d8d";
65const PERF_TITLE_BG: &str = "#eaf2ff";
66const COPPER_LINK_COLOR: &str = "#0000E0";
67const EDGE_COLOR_PALETTE: [&str; 10] = [
68 "#1F77B4", "#FF7F0E", "#2CA02C", "#D62728", "#9467BD", "#8C564B", "#E377C2", "#7F7F7F",
69 "#BCBD22", "#17BECF",
70];
71const EDGE_COLOR_ORDER: [usize; 10] = [0, 2, 1, 9, 7, 8, 3, 5, 6, 4];
72
73const GRAPH_MARGIN: f64 = 20.0;
75const CLUSTER_MARGIN: f64 = 20.0;
76const SECTION_SPACING: f64 = 60.0;
77const RESOURCE_TABLE_MARGIN: f64 = 18.0;
78const RESOURCE_TABLE_GAP: f64 = 12.0;
79const BOX_SHAPE_PADDING: f64 = 10.0;
80const CELL_PADDING: f64 = 6.0;
81const CELL_LINE_SPACING: f64 = 2.0;
82const VALUE_BORDER_WIDTH: f64 = 0.6;
83const OUTER_BORDER_WIDTH: f64 = 1.3;
84const LAYOUT_SCALE_X: f64 = 1.8;
85const LAYOUT_SCALE_Y: f64 = 1.2;
86
87const EDGE_LABEL_FIT_RATIO: f64 = 0.8;
89const EDGE_LABEL_OFFSET: f64 = 8.0;
90const EDGE_LABEL_LIGHTEN: f64 = 0.35;
91const EDGE_LABEL_HALO_WIDTH: f64 = 3.0;
92const EDGE_HITBOX_STROKE_WIDTH: usize = 12;
93const EDGE_HITBOX_OPACITY: f64 = 0.01;
94const EDGE_HOVER_POINT_RADIUS: f64 = 2.4;
95const EDGE_HOVER_POINT_STROKE_WIDTH: f64 = 1.0;
96const EDGE_TOOLTIP_CSS: &str = r#"
97.edge-hover .edge-tooltip {
98 opacity: 0;
99 pointer-events: none;
100 transition: opacity 120ms ease-out;
101}
102.edge-hover:hover .edge-tooltip {
103 opacity: 1;
104}
105.edge-hover .edge-hover-point {
106 opacity: 0.65;
107 pointer-events: none;
108 transition: opacity 120ms ease-out;
109}
110.edge-hover:hover .edge-hover-point {
111 opacity: 0.95;
112}
113"#;
114const DETOUR_LABEL_CLEARANCE: f64 = 6.0;
115const BACK_EDGE_STACK_SPACING: f64 = 16.0;
116const BACK_EDGE_NODE_GAP: f64 = 12.0;
117const BACK_EDGE_DUP_SPACING: f64 = 6.0;
118const BACK_EDGE_SPAN_EPS: f64 = 4.0;
119const INTERMEDIATE_X_EPS: f64 = 6.0;
120const EDGE_STUB_LEN: f64 = 32.0;
121const EDGE_STUB_MIN: f64 = 18.0;
122const EDGE_PORT_HANDLE: f64 = 12.0;
123const TOOLTIP_FONT_SIZE: usize = 9;
124const TOOLTIP_PADDING: f64 = 6.0;
125const TOOLTIP_LINE_GAP: f64 = 2.0;
126const TOOLTIP_RADIUS: f64 = 3.0;
127const TOOLTIP_OFFSET_X: f64 = 12.0;
128const TOOLTIP_OFFSET_Y: f64 = 12.0;
129const TOOLTIP_BORDER_WIDTH: f64 = 1.0;
130const TOOLTIP_BG: &str = "#fff7d1";
131const TOOLTIP_BORDER: &str = "#d9c37f";
132const TOOLTIP_TEXT: &str = "#111111";
133const PORT_DOT_RADIUS: f64 = 2.6;
134const PORT_LINE_GAP: f64 = 2.8;
135const LEGEND_TITLE_SIZE: usize = 11;
136const LEGEND_FONT_SIZE: usize = 10;
137const LEGEND_SWATCH_SIZE: f64 = 10.0;
138const LEGEND_PADDING: f64 = 8.0;
139const LEGEND_CORNER_RADIUS: f64 = 6.0;
140const LEGEND_ROW_GAP: f64 = 6.0;
141const LEGEND_LINK_GAP: f64 = 3.0;
142const LEGEND_WITH_LOGO_GAP: f64 = 4.0;
143const LEGEND_VERSION_GAP: f64 = 0.0;
144const LEGEND_SECTION_GAP: f64 = 8.0;
145const LEGEND_BOTTOM_PADDING: f64 = 6.0;
146const LEGEND_LOGO_SIZE: f64 = 16.0;
147const LEGEND_TEXT_WIDTH_FACTOR: f64 = 0.52;
148const COPPER_GITHUB_URL: &str = "https://github.com/copper-project/copper-rs";
149const LEGEND_ITEMS: [(&str, &str); 4] = [
150 ("Source", SOURCE_HEADER_BG),
151 ("Task", TASK_HEADER_BG),
152 ("Sink", SINK_HEADER_BG),
153 ("Bridge", BRIDGE_HEADER_BG),
154];
155const RESOURCE_LEGEND_TITLE: &str = "Resources";
156const RESOURCE_LEGEND_ITEMS: [(&str, &str); 3] = [
157 ("Exclusive", RESOURCE_EXCLUSIVE_BG),
158 ("Shared", RESOURCE_SHARED_BG),
159 ("Unused", RESOURCE_UNUSED_BG),
160];
161const LINUX_RESOURCE_SLOT_NAMES: [&str; 15] = [
162 "serial0", "serial1", "serial2", "serial3", "serial4", "serial5", "i2c0", "i2c1", "i2c2",
163 "gpio0", "gpio1", "gpio2", "gpio3", "gpio4", "gpio5",
164];
165
166#[derive(Parser)]
167#[clap(author, version, about, long_about = None)]
168struct Args {
169 #[clap(value_parser)]
171 config: PathBuf,
172 #[clap(long)]
174 logstats: Option<PathBuf>,
175 #[clap(long)]
177 mission: Option<String>,
178 #[clap(long, action)]
180 list_missions: bool,
181 #[clap(long)]
183 open: bool,
184}
185
186fn main() -> std::io::Result<()> {
189 let args = Args::parse();
191
192 let config = read_configuration(args.config.to_str().unwrap())
193 .expect("Failed to read configuration file");
194
195 if args.list_missions {
196 print_mission_list(&config);
197 return Ok(());
198 }
199
200 let mission = match validate_mission_arg(&config, args.mission.as_deref()) {
201 Ok(mission) => mission,
202 Err(err) => {
203 eprintln!("{err}");
204 std::process::exit(1);
205 }
206 };
207
208 let logstats = match args.logstats.as_deref() {
209 Some(path) => match load_logstats(path, &config, args.mission.as_deref()) {
210 Ok(stats) => Some(stats),
211 Err(err) => {
212 eprintln!("{err}");
213 std::process::exit(1);
214 }
215 },
216 None => None,
217 };
218
219 let graph_svg = match render_config_svg(&config, mission.as_deref(), logstats.as_ref()) {
220 Ok(svg) => svg,
221 Err(err) => {
222 eprintln!("{err}");
223 std::process::exit(1);
224 }
225 };
226
227 if args.open {
228 let mut temp_file = Builder::new().suffix(".svg").tempfile()?;
230 temp_file.write_all(graph_svg.as_slice())?;
231 let temp_path = temp_file
232 .into_temp_path()
233 .keep()
234 .map_err(std::io::Error::other)?;
235
236 open_svg(&temp_path)?;
237 } else {
238 let mut svg_file = std::fs::File::create("output.svg")?;
240 svg_file.write_all(graph_svg.as_slice())?;
241 }
242 Ok(())
243}
244
245fn open_svg(path: &std::path::Path) -> std::io::Result<()> {
247 if cfg!(target_os = "windows") {
248 Command::new("cmd")
249 .args(["/C", "start", ""])
250 .arg(path)
251 .status()?;
252 return Ok(());
253 }
254
255 let program = if cfg!(target_os = "macos") {
256 "open"
257 } else {
258 "xdg-open"
259 };
260 Command::new(program).arg(path).status()?;
261 Ok(())
262}
263
264fn render_config_svg(
266 config: &config::CuConfig,
267 mission_id: Option<&str>,
268 logstats: Option<&LogStatsIndex>,
269) -> CuResult<Vec<u8>> {
270 let sections = build_sections(config, mission_id)?;
271 let resource_catalog = collect_resource_catalog(config)?;
272 let mut layouts = Vec::new();
273 let mut logstats_applied = false;
274 for section in sections {
275 let section_logstats =
276 logstats.filter(|stats| stats.applies_to(section.mission_id.as_deref()));
277 if section_logstats.is_some() {
278 logstats_applied = true;
279 }
280 layouts.push(build_section_layout(
281 config,
282 §ion,
283 &resource_catalog,
284 section_logstats,
285 )?);
286 }
287 if logstats.is_some() && !logstats_applied {
288 eprintln!("Warning: logstats did not match any rendered mission");
289 }
290
291 Ok(render_sections_to_svg(&layouts).into_bytes())
292}
293
294fn load_logstats(
295 path: &Path,
296 config: &config::CuConfig,
297 expected_mission: Option<&str>,
298) -> CuResult<LogStatsIndex> {
299 let contents = fs::read_to_string(path)
300 .map_err(|e| CuError::new_with_cause("Failed to read logstats file", e))?;
301 let logstats: LogStats = serde_json::from_str(&contents)
302 .map_err(|e| CuError::new_with_cause("Failed to parse logstats JSON", e))?;
303
304 if logstats.schema_version != LOGSTATS_SCHEMA_VERSION {
305 eprintln!(
306 "Warning: logstats schema version {} does not match renderer {}",
307 logstats.schema_version, LOGSTATS_SCHEMA_VERSION
308 );
309 }
310
311 if let Ok(signature) = build_graph_signature(config, logstats.mission.as_deref()) {
312 if signature != logstats.config_signature {
313 eprintln!(
314 "Warning: logstats signature mismatch (expected {}, got {})",
315 signature, logstats.config_signature
316 );
317 }
318 } else {
319 eprintln!("Warning: unable to validate logstats signature");
320 }
321
322 if expected_mission.is_some()
323 && mission_key(expected_mission) != mission_key(logstats.mission.as_deref())
324 {
325 eprintln!(
326 "Warning: logstats mission '{}' does not match requested mission '{}'",
327 logstats.mission.as_deref().unwrap_or("default"),
328 expected_mission.unwrap_or("default")
329 );
330 }
331
332 let edge_map = logstats
333 .edges
334 .into_iter()
335 .map(|edge| (EdgeStatsKey::from_edge(&edge), edge))
336 .collect();
337
338 Ok(LogStatsIndex {
339 mission: logstats.mission,
340 edges: edge_map,
341 perf: logstats.perf,
342 })
343}
344
345fn build_sections<'a>(
347 config: &'a config::CuConfig,
348 mission_id: Option<&str>,
349) -> CuResult<Vec<SectionRef<'a>>> {
350 let sections = match (&config.graphs, mission_id) {
351 (ConfigGraphs::Simple(graph), _) => vec![SectionRef {
352 label: Some("Default".to_string()),
353 mission_id: None,
354 graph,
355 }],
356 (ConfigGraphs::Missions(graphs), Some(id)) => {
357 let graph = graphs
358 .get(id)
359 .ok_or_else(|| CuError::from(format!("Mission {id} not found")))?;
360 vec![SectionRef {
361 label: Some(id.to_string()),
362 mission_id: Some(id.to_string()),
363 graph,
364 }]
365 }
366 (ConfigGraphs::Missions(graphs), None) => {
367 let mut missions: Vec<_> = graphs.iter().collect();
368 missions.sort_by(|a, b| a.0.cmp(b.0));
369 missions
370 .into_iter()
371 .map(|(label, graph)| SectionRef {
372 label: Some(label.clone()),
373 mission_id: Some(label.clone()),
374 graph,
375 })
376 .collect()
377 }
378 };
379
380 Ok(sections)
381}
382
383fn build_section_layout(
385 config: &config::CuConfig,
386 section: &SectionRef<'_>,
387 resource_catalog: &HashMap<String, BTreeSet<String>>,
388 logstats: Option<&LogStatsIndex>,
389) -> CuResult<SectionLayout> {
390 let mut topology = build_render_topology(section.graph, &config.bridges);
391 topology.sort_connections();
392
393 let graph_orientation = Orientation::LeftToRight;
394 let node_orientation = graph_orientation.flip();
395 let mut graph = VisualGraph::new(graph_orientation);
396 let mut node_handles = HashMap::new();
397 let mut port_lookups = HashMap::new();
398 let mut nodes = Vec::new();
399
400 for node in &topology.nodes {
401 let node_idx = section
402 .graph
403 .get_node_id_by_name(node.id.as_str())
404 .ok_or_else(|| CuError::from(format!("Node '{}' missing from graph", node.id)))?;
405 let node_weight = section
406 .graph
407 .get_node(node_idx)
408 .ok_or_else(|| CuError::from(format!("Node '{}' missing weight", node.id)))?;
409
410 let is_src = section
411 .graph
412 .get_dst_edges(node_idx)
413 .unwrap_or_default()
414 .is_empty();
415 let is_sink = section
416 .graph
417 .get_src_edges(node_idx)
418 .unwrap_or_default()
419 .is_empty();
420
421 let header_fill = match node.flavor {
422 config::Flavor::Bridge => BRIDGE_HEADER_BG,
423 config::Flavor::Task if is_src => SOURCE_HEADER_BG,
424 config::Flavor::Task if is_sink => SINK_HEADER_BG,
425 _ => TASK_HEADER_BG,
426 };
427
428 let (table, port_lookup) = build_node_table(node, node_weight, header_fill);
429 let record = table_to_record(&table);
430 let shape = ShapeKind::Record(record);
431 let look = StyleAttr::new(
432 Color::fast(BORDER_COLOR),
433 1,
434 Some(Color::fast("white")),
435 0,
436 FONT_SIZE,
437 );
438 let size = record_size(&table, node_orientation);
439 let element = Element::create(shape, look, node_orientation, size);
440 let handle = graph.add_node(element);
441
442 node_handles.insert(node.id.clone(), handle);
443 port_lookups.insert(node.id.clone(), port_lookup);
444 nodes.push(NodeRender { handle, table });
445 }
446
447 let mut edges = Vec::new();
448 let mut edge_groups: HashMap<EdgeGroupKey, usize> = HashMap::new();
449 let mut next_color_slot = 0usize;
450 let edge_look = StyleAttr::new(Color::fast("black"), 1, None, 0, EDGE_FONT_SIZE);
451 for cnx in &topology.connections {
452 let src_handle = node_handles
453 .get(&cnx.src)
454 .ok_or_else(|| CuError::from(format!("Unknown node '{}'", cnx.src)))?;
455 let dst_handle = node_handles
456 .get(&cnx.dst)
457 .ok_or_else(|| CuError::from(format!("Unknown node '{}'", cnx.dst)))?;
458 let src_port = port_lookups
459 .get(&cnx.src)
460 .and_then(|lookup| lookup.resolve_output(cnx.src_port.as_deref()))
461 .map(|port| port.to_string());
462 let dst_port = port_lookups
463 .get(&cnx.dst)
464 .and_then(|lookup| lookup.resolve_input(cnx.dst_port.as_deref()))
465 .map(|port| port.to_string());
466
467 let arrow = Arrow::new(
468 LineEndKind::None,
469 LineEndKind::Arrow,
470 LineStyleKind::Normal,
471 "",
472 &edge_look,
473 &src_port,
474 &dst_port,
475 );
476 graph.add_edge(arrow.clone(), *src_handle, *dst_handle);
477 let edge_stats = logstats.and_then(|stats| stats.edge_stats_for(cnx));
478 let group_key = EdgeGroupKey {
479 src: *src_handle,
480 src_port: src_port.clone(),
481 msg: cnx.msg.clone(),
482 };
483 let (color_idx, show_label) = match edge_groups.entry(group_key) {
484 Entry::Occupied(entry) => (*entry.get(), false),
485 Entry::Vacant(entry) => {
486 let color_idx = edge_cycle_color_index(&mut next_color_slot);
487 entry.insert(color_idx);
488 (color_idx, true)
489 }
490 };
491 edges.push(RenderEdge {
492 src: *src_handle,
493 dst: *dst_handle,
494 arrow,
495 label: if show_label {
496 cnx.msg.clone()
497 } else {
498 String::new()
499 },
500 color_idx,
501 src_port,
502 dst_port,
503 stats: edge_stats,
504 });
505 }
506
507 let mut null_backend = NullBackend;
508 graph.do_it(false, false, false, &mut null_backend);
509 scale_layout_positions(&mut graph);
510
511 let node_bounds = collect_node_bounds(&nodes, &graph);
512 reorder_auto_input_rows(&mut nodes, &topology, &node_handles, &node_bounds, &graph);
513
514 let mut min = Point::new(f64::INFINITY, f64::INFINITY);
515 let mut max = Point::new(f64::NEG_INFINITY, f64::NEG_INFINITY);
516 for node in &nodes {
517 let pos = graph.element(node.handle).position();
518 let (top_left, bottom_right) = pos.bbox(false);
519 min.x = min.x.min(top_left.x);
520 min.y = min.y.min(top_left.y);
521 max.x = max.x.max(bottom_right.x);
522 max.y = max.y.max(bottom_right.y);
523 }
524 if !min.x.is_finite() || !min.y.is_finite() {
525 min = Point::new(0.0, 0.0);
526 max = Point::new(0.0, 0.0);
527 }
528
529 let mut port_anchors = HashMap::new();
530 for node in &nodes {
531 let element = graph.element(node.handle);
532 let anchors = collect_port_anchors(node, element);
533 port_anchors.insert(node.handle, anchors);
534 }
535
536 let resource_tables = build_resource_tables(config, section, resource_catalog)?;
537 let perf_table = logstats.map(|stats| build_perf_table(&stats.perf));
538
539 Ok(SectionLayout {
540 label: section.label.clone(),
541 graph,
542 nodes,
543 edges,
544 bounds: (min, max),
545 port_anchors,
546 resource_tables,
547 perf_table,
548 })
549}
550
551fn collect_resource_catalog(
552 config: &config::CuConfig,
553) -> CuResult<HashMap<String, BTreeSet<String>>> {
554 let bundle_ids: HashSet<String> = config
555 .resources
556 .iter()
557 .map(|bundle| bundle.id.clone())
558 .collect();
559 let mut catalog: HashMap<String, BTreeSet<String>> = HashMap::new();
560
561 let mut collect_graph = |graph: &config::CuGraph| -> CuResult<()> {
562 for (_, node) in graph.get_all_nodes() {
563 let Some(resources) = node.get_resources() else {
564 continue;
565 };
566 for path in resources.values() {
567 let (bundle_id, resource_name) = parse_resource_path(path)?;
568 if !bundle_ids.contains(&bundle_id) {
569 return Err(CuError::from(format!(
570 "Resource '{}' references unknown bundle '{}'",
571 path, bundle_id
572 )));
573 }
574 catalog.entry(bundle_id).or_default().insert(resource_name);
575 }
576 }
577 Ok(())
578 };
579
580 match &config.graphs {
581 ConfigGraphs::Simple(graph) => collect_graph(graph)?,
582 ConfigGraphs::Missions(graphs) => {
583 for graph in graphs.values() {
584 collect_graph(graph)?;
585 }
586 }
587 }
588
589 for bundle in &config.resources {
590 let Some(resource_names) = provider_resource_slots(bundle.provider.as_str()) else {
591 continue;
592 };
593 let bundle_resources = catalog.entry(bundle.id.clone()).or_default();
594 for resource_name in resource_names {
595 bundle_resources.insert((*resource_name).to_string());
596 }
597 }
598
599 Ok(catalog)
600}
601
602fn build_resource_tables(
603 config: &config::CuConfig,
604 section: &SectionRef<'_>,
605 resource_catalog: &HashMap<String, BTreeSet<String>>,
606) -> CuResult<Vec<ResourceTable>> {
607 let owners_by_bundle = collect_graph_resource_owners(section.graph)?;
608 let mission_id = section.mission_id.as_deref();
609 let mut tables = Vec::new();
610
611 for bundle in &config.resources {
612 if !bundle_applies(&bundle.missions, mission_id) {
613 continue;
614 }
615 let resources = resource_catalog
616 .get(&bundle.id)
617 .map(|set| set.iter().cloned().collect::<Vec<_>>())
618 .unwrap_or_default();
619 let table = build_resource_table(bundle, &resources, owners_by_bundle.get(&bundle.id));
620 let size = record_size(&table, Orientation::TopToBottom);
621 tables.push(ResourceTable { table, size });
622 }
623
624 Ok(tables)
625}
626
627fn build_resource_table(
628 bundle: &config::ResourceBundleConfig,
629 resources: &[String],
630 owners_by_resource: Option<&HashMap<String, Vec<ResourceOwner>>>,
631) -> TableNode {
632 let mut rows = Vec::new();
633 let provider_label = wrap_type_label(
634 &strip_type_params(bundle.provider.as_str()),
635 TYPE_WRAP_WIDTH,
636 );
637 let header_lines = vec![
638 CellLine::new(format!("Bundle: {}", bundle.id), "black", true, FONT_SIZE),
639 CellLine::code(provider_label, DIM_GRAY, false, TYPE_FONT_SIZE),
640 ];
641 rows.push(TableNode::Cell(
642 TableCell::new(header_lines)
643 .with_background(RESOURCE_TITLE_BG)
644 .with_align(TextAlign::Center),
645 ));
646
647 let mut resource_column = Vec::new();
648 let mut users_column = Vec::new();
649 resource_column.push(TableNode::Cell(
650 TableCell::single_line_sized("Resource", "black", false, PORT_HEADER_FONT_SIZE)
651 .with_background(HEADER_BG)
652 .with_align(TextAlign::Left),
653 ));
654 users_column.push(TableNode::Cell(
655 TableCell::single_line_sized("Used by", "black", false, PORT_HEADER_FONT_SIZE)
656 .with_background(HEADER_BG)
657 .with_align(TextAlign::Left),
658 ));
659
660 if resources.is_empty() {
661 let resource_cell =
662 TableCell::single_line_sized(PLACEHOLDER_TEXT, LIGHT_GRAY, false, PORT_VALUE_FONT_SIZE)
663 .with_background(RESOURCE_UNUSED_BG)
664 .with_border_width(VALUE_BORDER_WIDTH)
665 .with_align(TextAlign::Left);
666 let owners_cell = TableCell::single_line_sized(
667 "unused",
668 RESOURCE_UNUSED_TEXT,
669 false,
670 PORT_VALUE_FONT_SIZE,
671 )
672 .with_border_width(VALUE_BORDER_WIDTH)
673 .with_align(TextAlign::Left);
674 resource_column.push(TableNode::Cell(resource_cell));
675 users_column.push(TableNode::Cell(owners_cell));
676 } else {
677 for resource in resources {
678 let owners = owners_by_resource
679 .and_then(|map| map.get(resource))
680 .cloned()
681 .unwrap_or_default();
682 let usage = resource_usage(&owners);
683 let resource_label = format!("{}.{}", bundle.id, resource);
684 let resource_cell = TableCell::new(vec![CellLine::code(
685 resource_label,
686 "black",
687 false,
688 PORT_VALUE_FONT_SIZE,
689 )])
690 .with_background(resource_usage_color(usage))
691 .with_border_width(VALUE_BORDER_WIDTH)
692 .with_align(TextAlign::Left);
693 let owners_cell = TableCell::new(format_resource_owners(&owners, usage))
694 .with_border_width(VALUE_BORDER_WIDTH)
695 .with_align(TextAlign::Left);
696 resource_column.push(TableNode::Cell(resource_cell));
697 users_column.push(TableNode::Cell(owners_cell));
698 }
699 }
700
701 rows.push(TableNode::Array(vec![
702 TableNode::Array(resource_column),
703 TableNode::Array(users_column),
704 ]));
705
706 TableNode::Array(rows)
707}
708
709fn build_perf_table(perf: &PerfStats) -> ResourceTable {
710 let header_lines = vec![CellLine::new("Log Performance", "black", true, FONT_SIZE)];
711 let mut rows = Vec::new();
712 rows.push(TableNode::Cell(
713 TableCell::new(header_lines)
714 .with_background(PERF_TITLE_BG)
715 .with_align(TextAlign::Center),
716 ));
717
718 let mut metric_column = Vec::new();
719 let mut value_column = Vec::new();
720 metric_column.push(TableNode::Cell(
721 TableCell::single_line_sized("Metric", "black", false, PORT_HEADER_FONT_SIZE)
722 .with_background(HEADER_BG)
723 .with_align(TextAlign::Left),
724 ));
725 value_column.push(TableNode::Cell(
726 TableCell::single_line_sized("Value", "black", false, PORT_HEADER_FONT_SIZE)
727 .with_background(HEADER_BG)
728 .with_align(TextAlign::Left),
729 ));
730
731 let sample_text = format!("{}/{}", perf.valid_time_samples, perf.samples);
732 let metrics = [
733 ("Samples (valid/total)", sample_text),
734 (
735 "End-to-end mean",
736 format_duration_ns_f64(perf.end_to_end.mean_ns),
737 ),
738 (
739 "End-to-end min",
740 format_duration_ns_u64(perf.end_to_end.min_ns),
741 ),
742 (
743 "End-to-end max",
744 format_duration_ns_u64(perf.end_to_end.max_ns),
745 ),
746 (
747 "End-to-end sigma",
748 format_duration_ns_f64(perf.end_to_end.stddev_ns),
749 ),
750 ("Jitter mean", format_duration_ns_f64(perf.jitter.mean_ns)),
751 (
752 "Jitter sigma",
753 format_duration_ns_f64(perf.jitter.stddev_ns),
754 ),
755 ];
756
757 for (label, value) in metrics {
758 metric_column.push(TableNode::Cell(
759 TableCell::single_line_sized(label, "black", false, PORT_VALUE_FONT_SIZE)
760 .with_border_width(VALUE_BORDER_WIDTH)
761 .with_align(TextAlign::Left),
762 ));
763 value_column.push(TableNode::Cell(
764 TableCell::single_line_sized(&value, "black", false, PORT_VALUE_FONT_SIZE)
765 .with_border_width(VALUE_BORDER_WIDTH)
766 .with_align(TextAlign::Left),
767 ));
768 }
769
770 rows.push(TableNode::Array(vec![
771 TableNode::Array(metric_column),
772 TableNode::Array(value_column),
773 ]));
774
775 let table = TableNode::Array(rows);
776 let size = record_size(&table, Orientation::TopToBottom);
777 ResourceTable { table, size }
778}
779
780fn collect_graph_resource_owners(
781 graph: &config::CuGraph,
782) -> CuResult<HashMap<String, HashMap<String, Vec<ResourceOwner>>>> {
783 let mut owners: HashMap<String, HashMap<String, Vec<ResourceOwner>>> = HashMap::new();
784 for (_, node) in graph.get_all_nodes() {
785 let Some(resources) = node.get_resources() else {
786 continue;
787 };
788 let owner = ResourceOwner {
789 name: node.get_id(),
790 flavor: node.get_flavor(),
791 };
792 for path in resources.values() {
793 let (bundle_id, resource_name) = parse_resource_path(path)?;
794 owners
795 .entry(bundle_id)
796 .or_default()
797 .entry(resource_name)
798 .or_default()
799 .push(owner.clone());
800 }
801 }
802
803 for bundle in owners.values_mut() {
804 for list in bundle.values_mut() {
805 dedup_owners(list);
806 }
807 }
808
809 Ok(owners)
810}
811
812fn dedup_owners(owners: &mut Vec<ResourceOwner>) {
813 owners.sort_by(|a, b| {
814 flavor_rank(a.flavor)
815 .cmp(&flavor_rank(b.flavor))
816 .then_with(|| a.name.cmp(&b.name))
817 });
818 owners.dedup_by(|a, b| a.flavor == b.flavor && a.name == b.name);
819}
820
821fn flavor_rank(flavor: config::Flavor) -> u8 {
822 match flavor {
823 config::Flavor::Task => 0,
824 config::Flavor::Bridge => 1,
825 }
826}
827
828fn resource_usage(owners: &[ResourceOwner]) -> ResourceUsage {
829 match owners.len() {
830 0 => ResourceUsage::Unused,
831 1 => ResourceUsage::Exclusive,
832 _ => ResourceUsage::Shared,
833 }
834}
835
836fn resource_usage_color(usage: ResourceUsage) -> &'static str {
837 match usage {
838 ResourceUsage::Exclusive => RESOURCE_EXCLUSIVE_BG,
839 ResourceUsage::Shared => RESOURCE_SHARED_BG,
840 ResourceUsage::Unused => RESOURCE_UNUSED_BG,
841 }
842}
843
844fn format_resource_owners(owners: &[ResourceOwner], usage: ResourceUsage) -> Vec<CellLine> {
845 if owners.is_empty() && matches!(usage, ResourceUsage::Unused) {
846 return vec![CellLine::new(
847 "unused",
848 RESOURCE_UNUSED_TEXT,
849 false,
850 PORT_VALUE_FONT_SIZE,
851 )];
852 }
853
854 owners
855 .iter()
856 .map(|owner| {
857 let (label, color) = match owner.flavor {
858 config::Flavor::Task => (format!("task: {}", owner.name), "black"),
859 config::Flavor::Bridge => (format!("bridge: {}", owner.name), DIM_GRAY),
860 };
861 CellLine::code(label, color, false, PORT_VALUE_FONT_SIZE)
862 })
863 .collect()
864}
865
866fn bundle_applies(missions: &Option<Vec<String>>, mission_id: Option<&str>) -> bool {
867 match mission_id {
868 None => true,
869 Some(id) => missions
870 .as_ref()
871 .map(|list| list.iter().any(|m| m == id))
872 .unwrap_or(true),
873 }
874}
875
876fn parse_resource_path(path: &str) -> CuResult<(String, String)> {
877 let (bundle_id, name) = path.split_once('.').ok_or_else(|| {
878 CuError::from(format!(
879 "Resource '{path}' is missing a bundle prefix (expected bundle.resource)"
880 ))
881 })?;
882
883 if bundle_id.is_empty() || name.is_empty() {
884 return Err(CuError::from(format!(
885 "Resource '{path}' must use the 'bundle.resource' format"
886 )));
887 }
888
889 Ok((bundle_id.to_string(), name.to_string()))
890}
891
892fn build_node_table(
894 node: &config::RenderNode,
895 node_weight: &config::Node,
896 header_fill: &str,
897) -> (TableNode, PortLookup) {
898 let mut rows = Vec::new();
899
900 let header_lines = vec![
901 CellLine::new(node.id.clone(), "black", true, FONT_SIZE),
902 CellLine::code(
903 wrap_type_label(&strip_type_params(&node.type_name), TYPE_WRAP_WIDTH),
904 DIM_GRAY,
905 false,
906 TYPE_FONT_SIZE,
907 ),
908 ];
909 rows.push(TableNode::Cell(
910 TableCell::new(header_lines)
911 .with_background(header_fill)
912 .with_align(TextAlign::Center),
913 ));
914
915 let mut port_lookup = PortLookup::default();
916 let max_ports = node.inputs.len().max(node.outputs.len());
917 let inputs = build_port_column(
918 "Inputs",
919 &node.inputs,
920 "in",
921 &mut port_lookup.inputs,
922 &mut port_lookup.default_input,
923 max_ports,
924 TextAlign::Left,
925 );
926 let outputs = build_port_column(
927 "Outputs",
928 &node.outputs,
929 "out",
930 &mut port_lookup.outputs,
931 &mut port_lookup.default_output,
932 max_ports,
933 TextAlign::Right,
934 );
935 rows.push(TableNode::Array(vec![inputs, outputs]));
936
937 if let Some(config) = node_weight.get_instance_config() {
938 let config_rows = build_config_rows(config);
939 if !config_rows.is_empty() {
940 rows.extend(config_rows);
941 }
942 }
943
944 (TableNode::Array(rows), port_lookup)
945}
946
947fn build_port_column(
949 title: &str,
950 names: &[String],
951 prefix: &str,
952 lookup: &mut HashMap<String, String>,
953 default_port: &mut Option<String>,
954 target_len: usize,
955 align: TextAlign,
956) -> TableNode {
957 let mut rows = Vec::new();
958 rows.push(TableNode::Cell(
959 TableCell::single_line_sized(title, "black", false, PORT_HEADER_FONT_SIZE)
960 .with_background(HEADER_BG)
961 .with_align(align),
962 ));
963
964 let desired_rows = target_len.max(1);
965 for idx in 0..desired_rows {
966 if let Some(name) = names.get(idx) {
967 let port_id = format!("{prefix}_{idx}");
968 lookup.insert(name.clone(), port_id.clone());
969 if default_port.is_none() {
970 *default_port = Some(port_id.clone());
971 }
972 rows.push(TableNode::Cell(
973 TableCell::single_line_sized(name, "black", false, PORT_VALUE_FONT_SIZE)
974 .with_port(port_id)
975 .with_border_width(VALUE_BORDER_WIDTH)
976 .with_align(align),
977 ));
978 } else {
979 rows.push(TableNode::Cell(
980 TableCell::single_line_sized(
981 PLACEHOLDER_TEXT,
982 LIGHT_GRAY,
983 false,
984 PORT_VALUE_FONT_SIZE,
985 )
986 .with_border_width(VALUE_BORDER_WIDTH)
987 .with_align(align),
988 ));
989 }
990 }
991
992 TableNode::Array(rows)
993}
994
995fn build_config_rows(config: &config::ComponentConfig) -> Vec<TableNode> {
997 if config.0.is_empty() {
998 return Vec::new();
999 }
1000
1001 let mut entries: Vec<_> = config.0.iter().collect();
1002 entries.sort_by(|a, b| a.0.cmp(b.0));
1003
1004 let header = TableNode::Cell(
1005 TableCell::single_line_sized("Config", "black", false, PORT_HEADER_FONT_SIZE)
1006 .with_background(HEADER_BG),
1007 );
1008
1009 let mut key_lines = Vec::new();
1010 let mut value_lines = Vec::new();
1011 for (key, value) in entries {
1012 let value_str = wrap_text(&format!("{value}"), CONFIG_WRAP_WIDTH);
1013 let value_parts: Vec<_> = value_str.split('\n').collect();
1014 for (idx, part) in value_parts.iter().enumerate() {
1015 let key_text = if idx == 0 { key.as_str() } else { "" };
1016 key_lines.push(CellLine::code(key_text, DIM_GRAY, true, CONFIG_FONT_SIZE));
1017 value_lines.push(CellLine::code(*part, DIM_GRAY, false, CONFIG_FONT_SIZE));
1018 }
1019 }
1020
1021 let keys_cell = TableCell::new(key_lines).with_border_width(VALUE_BORDER_WIDTH);
1022 let values_cell = TableCell::new(value_lines).with_border_width(VALUE_BORDER_WIDTH);
1023 let body = TableNode::Array(vec![
1024 TableNode::Cell(keys_cell),
1025 TableNode::Cell(values_cell),
1026 ]);
1027
1028 vec![header, body]
1029}
1030
1031fn table_to_record(node: &TableNode) -> RecordDef {
1033 match node {
1034 TableNode::Cell(cell) => RecordDef::Text(cell.label(), cell.port.clone()),
1035 TableNode::Array(children) => {
1036 RecordDef::Array(children.iter().map(table_to_record).collect())
1037 }
1038 }
1039}
1040
1041fn record_size(node: &TableNode, dir: Orientation) -> Point {
1043 match node {
1044 TableNode::Cell(cell) => pad_shape_scalar(cell_text_size(cell), BOX_SHAPE_PADDING),
1045 TableNode::Array(children) => {
1046 if children.is_empty() {
1047 return Point::new(1.0, 1.0);
1048 }
1049 let mut x: f64 = 0.0;
1050 let mut y: f64 = 0.0;
1051 for child in children {
1052 let sz = record_size(child, dir.flip());
1053 if dir.is_left_right() {
1054 x += sz.x;
1055 y = y.max(sz.y);
1056 } else {
1057 x = x.max(sz.x);
1058 y += sz.y;
1059 }
1060 }
1061 Point::new(x, y)
1062 }
1063 }
1064}
1065
1066fn visit_table(
1068 node: &TableNode,
1069 dir: Orientation,
1070 loc: Point,
1071 size: Point,
1072 visitor: &mut dyn TableVisitor,
1073) {
1074 match node {
1075 TableNode::Cell(cell) => {
1076 visitor.handle_cell(cell, loc, size);
1077 }
1078 TableNode::Array(children) => {
1079 if children.is_empty() {
1080 return;
1081 }
1082
1083 let mut sizes = Vec::new();
1084 let mut sum = Point::new(0.0, 0.0);
1085
1086 for child in children {
1087 let child_size = record_size(child, dir.flip());
1088 sizes.push(child_size);
1089 if dir.is_left_right() {
1090 sum.x += child_size.x;
1091 sum.y = sum.y.max(child_size.y);
1092 } else {
1093 sum.x = sum.x.max(child_size.x);
1094 sum.y += child_size.y;
1095 }
1096 }
1097
1098 for child_size in &mut sizes {
1099 if dir.is_left_right() {
1100 if sum.x > 0.0 {
1101 *child_size = Point::new(size.x * child_size.x / sum.x, size.y);
1102 } else {
1103 *child_size = Point::new(1.0, size.y);
1104 }
1105 } else if sum.y > 0.0 {
1106 *child_size = Point::new(size.x, size.y * child_size.y / sum.y);
1107 } else {
1108 *child_size = Point::new(size.x, 1.0);
1109 }
1110 }
1111
1112 if dir.is_left_right() {
1113 let mut start_x = loc.x - size.x / 2.0;
1114 for (idx, child) in children.iter().enumerate() {
1115 let child_loc = Point::new(start_x + sizes[idx].x / 2.0, loc.y);
1116 visit_table(child, dir.flip(), child_loc, sizes[idx], visitor);
1117 start_x += sizes[idx].x;
1118 }
1119 } else {
1120 let mut start_y = loc.y - size.y / 2.0;
1121 for (idx, child) in children.iter().enumerate() {
1122 let child_loc = Point::new(loc.x, start_y + sizes[idx].y / 2.0);
1123 visit_table(child, dir.flip(), child_loc, sizes[idx], visitor);
1124 start_y += sizes[idx].y;
1125 }
1126 }
1127 }
1128 }
1129}
1130
1131fn reorder_auto_input_rows(
1132 nodes: &mut [NodeRender],
1133 topology: &config::RenderTopology,
1134 node_handles: &HashMap<String, NodeHandle>,
1135 node_bounds: &[NodeBounds],
1136 graph: &VisualGraph,
1137) {
1138 let mut inputs_by_id = HashMap::new();
1139 for node in &topology.nodes {
1140 inputs_by_id.insert(node.id.clone(), node.inputs.clone());
1141 }
1142
1143 let mut order_info_by_dst: HashMap<String, HashMap<String, (usize, f64)>> = HashMap::new();
1144 for cnx in &topology.connections {
1145 let Some(dst_port) = cnx.dst_port.as_ref() else {
1146 continue;
1147 };
1148 let (Some(src_handle), Some(dst_handle)) =
1149 (node_handles.get(&cnx.src), node_handles.get(&cnx.dst))
1150 else {
1151 continue;
1152 };
1153 let src_pos = graph.element(*src_handle).position().center();
1154 let dst_pos = graph.element(*dst_handle).position().center();
1155 let span_min_x = src_pos.x.min(dst_pos.x);
1156 let span_max_x = src_pos.x.max(dst_pos.x);
1157 let is_self = src_handle == dst_handle;
1158 let has_intermediate = !is_self
1159 && span_has_intermediate(
1160 node_bounds,
1161 span_min_x,
1162 span_max_x,
1163 *src_handle,
1164 *dst_handle,
1165 );
1166 let is_reverse = src_pos.x > dst_pos.x;
1167 let is_detour = !is_self && (is_reverse || has_intermediate);
1168 let detour_above = is_detour && !is_reverse;
1169 let group_rank = if detour_above { 0 } else { 1 };
1170 order_info_by_dst
1171 .entry(cnx.dst.clone())
1172 .or_default()
1173 .insert(dst_port.clone(), (group_rank, src_pos.y));
1174 }
1175
1176 let mut handle_to_id = HashMap::new();
1177 for (id, handle) in node_handles {
1178 handle_to_id.insert(*handle, id.clone());
1179 }
1180
1181 for node in nodes {
1182 let Some(node_id) = handle_to_id.get(&node.handle) else {
1183 continue;
1184 };
1185 let Some(inputs) = inputs_by_id.get(node_id) else {
1186 continue;
1187 };
1188 if inputs.len() <= 1 {
1189 continue;
1190 }
1191 let Some(order_info) = order_info_by_dst.get(node_id) else {
1192 continue;
1193 };
1194 if order_info.len() < 2 {
1195 continue;
1196 }
1197
1198 let mut indexed: Vec<_> = inputs
1199 .iter()
1200 .enumerate()
1201 .map(|(idx, label)| {
1202 let (group_rank, src_y) = order_info.get(label).copied().unwrap_or((2, 0.0));
1203 (group_rank, src_y, idx, label.clone())
1204 })
1205 .collect();
1206 indexed.sort_by(|a, b| {
1207 a.0.cmp(&b.0)
1208 .then_with(|| a.1.partial_cmp(&b.1).unwrap_or(Ordering::Equal))
1209 .then_with(|| a.2.cmp(&b.2))
1210 });
1211
1212 let mut order = HashMap::new();
1213 for (pos, (_, _, _, label)) in indexed.into_iter().enumerate() {
1214 order.insert(label, pos);
1215 }
1216 reorder_input_rows(&mut node.table, &order);
1217 }
1218}
1219
1220fn reorder_input_rows(table: &mut TableNode, order: &HashMap<String, usize>) {
1221 let TableNode::Array(rows) = table else {
1222 return;
1223 };
1224 if rows.len() < 2 {
1225 return;
1226 }
1227 let TableNode::Array(columns) = &mut rows[1] else {
1228 return;
1229 };
1230 if columns.is_empty() {
1231 return;
1232 }
1233 let TableNode::Array(input_rows) = &mut columns[0] else {
1234 return;
1235 };
1236 if input_rows.len() <= 2 {
1237 return;
1238 }
1239
1240 let header = input_rows[0].clone();
1241 let mut inputs = Vec::new();
1242 let mut placeholders = Vec::new();
1243 for row in input_rows.iter().skip(1) {
1244 match row {
1245 TableNode::Cell(cell) if cell.port.is_some() => {
1246 let label = cell.label();
1247 let key = *order.get(&label).unwrap_or(&usize::MAX);
1248 inputs.push((key, row.clone()));
1249 }
1250 _ => placeholders.push(row.clone()),
1251 }
1252 }
1253 if inputs.len() <= 1 {
1254 return;
1255 }
1256 inputs.sort_by(|a, b| a.0.cmp(&b.0));
1257 let mut new_rows = Vec::with_capacity(input_rows.len());
1258 new_rows.push(header);
1259 for (_, row) in inputs {
1260 new_rows.push(row);
1261 }
1262 for row in placeholders {
1263 new_rows.push(row);
1264 }
1265 *input_rows = new_rows;
1266}
1267
1268fn render_sections_to_svg(sections: &[SectionLayout]) -> String {
1270 let mut svg = SvgWriter::new();
1271 let mut cursor_y = GRAPH_MARGIN;
1272 let mut last_section_bottom = GRAPH_MARGIN;
1273 let mut last_section_right = GRAPH_MARGIN;
1274
1275 for section in sections {
1276 let cluster_margin = if section.label.is_some() {
1277 CLUSTER_MARGIN
1278 } else {
1279 0.0
1280 };
1281 let (min, max) = section.bounds;
1282 let label_padding = if section.label.is_some() {
1283 FONT_SIZE as f64
1284 } else {
1285 0.0
1286 };
1287 let node_bounds = collect_node_bounds(§ion.nodes, §ion.graph);
1288 let mut expanded_bounds = (min, max);
1289 let mut edge_paths: Vec<Vec<BezierSegment>> = Vec::with_capacity(section.edges.len());
1290 let mut edge_points: Vec<(Point, Point)> = Vec::with_capacity(section.edges.len());
1291 let mut edge_is_self: Vec<bool> = Vec::with_capacity(section.edges.len());
1292 let mut edge_is_detour: Vec<bool> = Vec::with_capacity(section.edges.len());
1293 let mut detour_above = vec![false; section.edges.len()];
1294 let mut detour_base_y = vec![0.0; section.edges.len()];
1295 let mut back_plans_above: Vec<BackEdgePlan> = Vec::new();
1296 let mut back_plans_below: Vec<BackEdgePlan> = Vec::new();
1297
1298 for (idx, edge) in section.edges.iter().enumerate() {
1299 let src_point = resolve_anchor(section, edge.src, edge.src_port.as_ref());
1300 let dst_point = resolve_anchor(section, edge.dst, edge.dst_port.as_ref());
1301 let span_min_x = src_point.x.min(dst_point.x);
1302 let span_max_x = src_point.x.max(dst_point.x);
1303 let is_self = edge.src == edge.dst;
1304 let has_intermediate = !is_self
1305 && span_has_intermediate(&node_bounds, span_min_x, span_max_x, edge.src, edge.dst);
1306 let is_reverse = src_point.x > dst_point.x;
1307 let is_detour = !is_self && (is_reverse || has_intermediate);
1308 edge_points.push((src_point, dst_point));
1309 edge_is_self.push(is_self);
1310 edge_is_detour.push(is_detour);
1311
1312 if is_detour {
1313 let span = (src_point.x - dst_point.x).abs();
1314 let above = !is_reverse;
1315 let base_y = if above {
1316 min_top_for_span(&node_bounds, span_min_x, span_max_x) - BACK_EDGE_NODE_GAP
1317 } else {
1318 max_bottom_for_span(&node_bounds, span_min_x, span_max_x) + BACK_EDGE_NODE_GAP
1319 };
1320 detour_above[idx] = above;
1321 detour_base_y[idx] = base_y;
1322 let plan = BackEdgePlan {
1323 idx,
1324 span,
1325 order_y: dst_point.y,
1326 };
1327 if above {
1328 back_plans_above.push(plan);
1329 } else {
1330 back_plans_below.push(plan);
1331 }
1332 }
1333 }
1334
1335 let mut back_offsets = vec![0.0; section.edges.len()];
1336 assign_back_edge_offsets(&back_plans_below, &mut back_offsets);
1337 assign_back_edge_offsets(&back_plans_above, &mut back_offsets);
1338 let mut detour_lane_y = vec![0.0; section.edges.len()];
1339 for idx in 0..section.edges.len() {
1340 if edge_is_detour[idx] {
1341 detour_lane_y[idx] = if detour_above[idx] {
1342 detour_base_y[idx] - back_offsets[idx]
1343 } else {
1344 detour_base_y[idx] + back_offsets[idx]
1345 };
1346 }
1347 }
1348 let detour_slots =
1349 build_detour_label_slots(&edge_points, &edge_is_detour, &detour_above, &detour_lane_y);
1350
1351 for (idx, edge) in section.edges.iter().enumerate() {
1352 let (src_point, dst_point) = edge_points[idx];
1353 let (fallback_start_dir, fallback_end_dir) = fallback_port_dirs(src_point, dst_point);
1354 let start_dir = port_dir(edge.src_port.as_ref()).unwrap_or(fallback_start_dir);
1355 let end_dir = port_dir_incoming(edge.dst_port.as_ref()).unwrap_or(fallback_end_dir);
1356 let path = if edge.src == edge.dst {
1357 let pos = section.graph.element(edge.src).position();
1358 let bbox = pos.bbox(false);
1359 build_loop_path(src_point, dst_point, bbox, start_dir, end_dir)
1360 } else if edge_is_detour[idx] {
1361 build_back_edge_path(src_point, dst_point, detour_lane_y[idx], start_dir, end_dir)
1362 } else {
1363 build_edge_path(src_point, dst_point, start_dir, end_dir)
1364 };
1365
1366 for segment in &path {
1367 expand_bounds(&mut expanded_bounds, segment.start);
1368 expand_bounds(&mut expanded_bounds, segment.c1);
1369 expand_bounds(&mut expanded_bounds, segment.c2);
1370 expand_bounds(&mut expanded_bounds, segment.end);
1371 }
1372
1373 edge_paths.push(path);
1374 }
1375
1376 let mut info_table_positions: Vec<(Point, &ResourceTable)> = Vec::new();
1377 if section.perf_table.is_some() || !section.resource_tables.is_empty() {
1378 let content_left = expanded_bounds.0.x;
1379 let content_bottom = expanded_bounds.1.y;
1380 let mut max_table_width: f64 = 0.0;
1381 let mut cursor_table_y = content_bottom + RESOURCE_TABLE_MARGIN;
1382 for table in section
1383 .perf_table
1384 .iter()
1385 .chain(section.resource_tables.iter())
1386 {
1387 let top_left = Point::new(content_left, cursor_table_y);
1388 info_table_positions.push((top_left, table));
1389 cursor_table_y += table.size.y + RESOURCE_TABLE_GAP;
1390 max_table_width = max_table_width.max(table.size.x);
1391 }
1392 let tables_bottom = cursor_table_y - RESOURCE_TABLE_GAP;
1393 expanded_bounds.1.y = expanded_bounds.1.y.max(tables_bottom);
1394 expanded_bounds.1.x = expanded_bounds.1.x.max(content_left + max_table_width);
1395 }
1396
1397 let section_min = Point::new(
1398 expanded_bounds.0.x - cluster_margin,
1399 expanded_bounds.0.y - cluster_margin,
1400 );
1401 let section_max = Point::new(
1402 expanded_bounds.1.x + cluster_margin,
1403 expanded_bounds.1.y + cluster_margin + label_padding,
1404 );
1405 let offset = Point::new(GRAPH_MARGIN - section_min.x, cursor_y - section_min.y);
1406 let content_offset = offset.add(Point::new(0.0, label_padding));
1407 let cluster_top_left = section_min.add(offset);
1408 let cluster_bottom_right = section_max.add(offset);
1409 last_section_bottom = last_section_bottom.max(cluster_bottom_right.y);
1410 last_section_right = last_section_right.max(cluster_bottom_right.x);
1411 let label_bounds_min = Point::new(
1412 cluster_top_left.x + 4.0,
1413 cluster_top_left.y + label_padding + 4.0,
1414 );
1415 let label_bounds_max =
1416 Point::new(cluster_bottom_right.x - 4.0, cluster_bottom_right.y - 4.0);
1417
1418 if let Some(label) = §ion.label {
1419 draw_cluster(&mut svg, section_min, section_max, label, offset);
1420 }
1421
1422 let mut blocked_boxes: Vec<(Point, Point)> = node_bounds
1423 .iter()
1424 .map(|b| {
1425 (
1426 Point::new(b.left, b.top)
1427 .add(content_offset)
1428 .sub(Point::new(4.0, 4.0)),
1429 Point::new(b.right, b.bottom)
1430 .add(content_offset)
1431 .add(Point::new(4.0, 4.0)),
1432 )
1433 })
1434 .collect();
1435 if let Some(label) = §ion.label {
1436 let label_text = format!("Mission: {label}");
1437 let label_size = get_size_for_str(&label_text, FONT_SIZE);
1438 let label_pos = Point::new(
1439 section_min.x + offset.x + CELL_PADDING,
1440 section_min.y + offset.y + FONT_SIZE as f64,
1441 );
1442 blocked_boxes.push((
1443 Point::new(label_pos.x, label_pos.y - label_size.y / 2.0).sub(Point::new(2.0, 2.0)),
1444 Point::new(label_pos.x + label_size.x, label_pos.y + label_size.y / 2.0)
1445 .add(Point::new(2.0, 2.0)),
1446 ));
1447 }
1448
1449 for (top_left, table) in &info_table_positions {
1450 let top_left = top_left.add(content_offset);
1451 let bottom_right = Point::new(top_left.x + table.size.x, top_left.y + table.size.y);
1452 blocked_boxes.push((
1453 top_left.sub(Point::new(4.0, 4.0)),
1454 bottom_right.add(Point::new(4.0, 4.0)),
1455 ));
1456 }
1457
1458 let straight_slots =
1459 build_straight_label_slots(&edge_points, &edge_is_detour, &edge_is_self);
1460
1461 for ((idx, edge), path) in section.edges.iter().enumerate().zip(edge_paths.iter()) {
1462 let path = path
1463 .iter()
1464 .map(|seg| BezierSegment {
1465 start: seg.start.add(content_offset),
1466 c1: seg.c1.add(content_offset),
1467 c2: seg.c2.add(content_offset),
1468 end: seg.end.add(content_offset),
1469 })
1470 .collect::<Vec<_>>();
1471 let dashed = matches!(
1472 edge.arrow.line_style,
1473 LineStyleKind::Dashed | LineStyleKind::Dotted
1474 );
1475 let start = matches!(edge.arrow.start, LineEndKind::Arrow);
1476 let end = matches!(edge.arrow.end, LineEndKind::Arrow);
1477 let line_color = EDGE_COLOR_PALETTE[edge.color_idx];
1478 let label = if edge.label.is_empty() {
1479 None
1480 } else {
1481 let (text, font_size) = if edge_is_self[idx] {
1482 fit_edge_label(&edge.label, &path, EDGE_FONT_SIZE)
1483 } else if let Some(slot) = straight_slots.get(&idx) {
1484 let mut max_width = slot.width;
1485 if slot.group_count <= 1 {
1486 let path_width = approximate_path_length(&path);
1487 max_width = max_width.max(path_width);
1488 }
1489 fit_label_to_width(&edge.label, max_width, EDGE_FONT_SIZE)
1490 } else if let Some(slot) = detour_slots.get(&idx) {
1491 let mut max_width = slot.width;
1492 if slot.group_count <= 1 {
1493 if let Some((_, _, lane_len)) = find_horizontal_lane_span(&path) {
1494 max_width = max_width.max(lane_len);
1495 } else if slot.group_width > 0.0 {
1496 max_width = max_width.max(slot.group_width * 0.9);
1497 }
1498 }
1499 fit_label_to_width(&edge.label, max_width, EDGE_FONT_SIZE)
1500 } else if edge_is_detour[idx] {
1501 let (lane_left, lane_right) =
1502 detour_lane_bounds_from_points(edge_points[idx].0, edge_points[idx].1);
1503 fit_label_to_width(
1504 &edge.label,
1505 (lane_right - lane_left).max(1.0),
1506 EDGE_FONT_SIZE,
1507 )
1508 } else {
1509 fit_edge_label(&edge.label, &path, EDGE_FONT_SIZE)
1510 };
1511 let label_color = lighten_hex(line_color, EDGE_LABEL_LIGHTEN);
1512 let mut label =
1513 ArrowLabel::new(text, &label_color, font_size, true, FontFamily::Mono);
1514 let label_pos = if edge_is_self[idx] {
1515 let node_center = section
1516 .graph
1517 .element(edge.src)
1518 .position()
1519 .center()
1520 .add(content_offset);
1521 if let Some((center_x, lane_y, _)) = find_horizontal_lane_span(&path) {
1522 let above = lane_y < node_center.y;
1523 place_detour_label(
1524 &label.text,
1525 label.font_size,
1526 center_x,
1527 lane_y,
1528 above,
1529 &blocked_boxes,
1530 )
1531 } else {
1532 place_self_loop_label(
1533 &label.text,
1534 label.font_size,
1535 &path,
1536 node_center,
1537 &blocked_boxes,
1538 )
1539 }
1540 } else if edge_is_detour[idx] {
1541 let mut label_pos = None;
1542 if let Some(slot) = detour_slots.get(&idx) {
1543 label_pos = Some(place_detour_label(
1544 &label.text,
1545 label.font_size,
1546 slot.center_x + content_offset.x,
1547 slot.lane_y + content_offset.y,
1548 slot.above,
1549 &blocked_boxes,
1550 ));
1551 }
1552 if label_pos.is_none() {
1553 label_pos = Some(place_detour_label(
1554 &label.text,
1555 label.font_size,
1556 (edge_points[idx].0.x + edge_points[idx].1.x) / 2.0 + content_offset.x,
1557 detour_lane_y[idx] + content_offset.y,
1558 detour_above[idx],
1559 &blocked_boxes,
1560 ));
1561 }
1562 if let Some(pos) = label_pos {
1563 pos
1564 } else {
1565 let dir = direction_unit(edge_points[idx].0, edge_points[idx].1);
1566 place_label_with_offset(
1567 &label.text,
1568 label.font_size,
1569 edge_points[idx].0.add(content_offset),
1570 dir,
1571 EDGE_LABEL_OFFSET,
1572 &blocked_boxes,
1573 )
1574 }
1575 } else if let Some(slot) = straight_slots.get(&idx) {
1576 let mut normal = slot.normal;
1577 if normal.y > 0.0 {
1578 normal = Point::new(-normal.x, -normal.y);
1579 }
1580 place_label_with_offset(
1581 &label.text,
1582 label.font_size,
1583 slot.center.add(content_offset),
1584 normal,
1585 slot.stack_offset,
1586 &blocked_boxes,
1587 )
1588 } else {
1589 place_edge_label(&label.text, label.font_size, &path, &blocked_boxes)
1590 };
1591 let clamped = clamp_label_position(
1592 label_pos,
1593 &label.text,
1594 label.font_size,
1595 label_bounds_min,
1596 label_bounds_max,
1597 );
1598 label = label.with_position(clamped);
1599 Some(label)
1600 };
1601
1602 let edge_look = colored_edge_style(&edge.arrow.look, line_color);
1603 let tooltip = edge.stats.as_ref().map(format_edge_tooltip);
1604 svg.draw_arrow(
1605 &path,
1606 dashed,
1607 (start, end),
1608 &edge_look,
1609 label.as_ref(),
1610 tooltip.as_deref(),
1611 );
1612 }
1613
1614 for node in §ion.nodes {
1615 let element = section.graph.element(node.handle);
1616 draw_node_table(&mut svg, node, element, content_offset);
1617 }
1618
1619 for (top_left, table) in &info_table_positions {
1620 draw_resource_table(&mut svg, table, top_left.add(content_offset));
1621 }
1622
1623 cursor_y += (section_max.y - section_min.y) + SECTION_SPACING;
1624 }
1625
1626 let legend_top = last_section_bottom + GRAPH_MARGIN;
1627 let _legend_height = draw_legend(&mut svg, legend_top, last_section_right);
1628
1629 svg.finalize()
1630}
1631
1632fn draw_node_table(svg: &mut SvgWriter, node: &NodeRender, element: &Element, offset: Point) {
1634 let pos = element.position();
1635 let center = pos.center().add(offset);
1636 let size = pos.size(false);
1637 let top_left = Point::new(center.x - size.x / 2.0, center.y - size.y / 2.0);
1638
1639 svg.draw_rect(top_left, size, None, 0.0, Some("white"), 0.0);
1640
1641 let mut renderer = TableRenderer {
1642 svg,
1643 node_left_x: top_left.x,
1644 node_right_x: top_left.x + size.x,
1645 };
1646 visit_table(
1647 &node.table,
1648 element.orientation,
1649 center,
1650 size,
1651 &mut renderer,
1652 );
1653 svg.draw_rect(
1654 top_left,
1655 size,
1656 Some(BORDER_COLOR),
1657 OUTER_BORDER_WIDTH,
1658 None,
1659 0.0,
1660 );
1661}
1662
1663fn draw_resource_table(svg: &mut SvgWriter, table: &ResourceTable, top_left: Point) {
1664 let size = table.size;
1665 let center = Point::new(top_left.x + size.x / 2.0, top_left.y + size.y / 2.0);
1666 svg.draw_rect(top_left, size, None, 0.0, Some("white"), 0.0);
1667
1668 let mut renderer = TableRenderer {
1669 svg,
1670 node_left_x: top_left.x,
1671 node_right_x: top_left.x + size.x,
1672 };
1673 visit_table(
1674 &table.table,
1675 Orientation::TopToBottom,
1676 center,
1677 size,
1678 &mut renderer,
1679 );
1680 svg.draw_rect(
1681 top_left,
1682 size,
1683 Some(BORDER_COLOR),
1684 OUTER_BORDER_WIDTH,
1685 None,
1686 0.0,
1687 );
1688}
1689
1690fn draw_cluster(svg: &mut SvgWriter, min: Point, max: Point, label: &str, offset: Point) {
1692 let top_left = min.add(offset);
1693 let size = max.sub(min);
1694 svg.draw_rect(top_left, size, Some(CLUSTER_COLOR), 1.0, None, 10.0);
1695
1696 let label_text = format!("Mission: {label}");
1697 let label_pos = Point::new(top_left.x + CELL_PADDING, top_left.y + FONT_SIZE as f64);
1698 svg.draw_text(
1699 label_pos,
1700 &label_text,
1701 FONT_SIZE,
1702 DIM_GRAY,
1703 true,
1704 "start",
1705 FontFamily::Sans,
1706 );
1707}
1708
1709fn draw_legend(svg: &mut SvgWriter, top_y: f64, content_right: f64) -> f64 {
1711 let metrics = measure_legend();
1712 let legend_x = (content_right - metrics.width).max(GRAPH_MARGIN);
1713 let top_left = Point::new(legend_x, top_y);
1714
1715 svg.draw_rect(
1716 top_left,
1717 Point::new(metrics.width, metrics.height),
1718 Some(BORDER_COLOR),
1719 0.6,
1720 Some("white"),
1721 LEGEND_CORNER_RADIUS,
1722 );
1723
1724 let title_pos = Point::new(
1725 top_left.x + LEGEND_PADDING,
1726 top_left.y + LEGEND_PADDING + LEGEND_TITLE_SIZE as f64 / 2.0,
1727 );
1728 svg.draw_text(
1729 title_pos,
1730 "Legend",
1731 LEGEND_TITLE_SIZE,
1732 DIM_GRAY,
1733 true,
1734 "start",
1735 FontFamily::Sans,
1736 );
1737
1738 let mut cursor_y = top_left.y + LEGEND_PADDING + LEGEND_TITLE_SIZE as f64 + LEGEND_ROW_GAP;
1739 let item_height = LEGEND_SWATCH_SIZE.max(LEGEND_FONT_SIZE as f64);
1740 for (label, color) in LEGEND_ITEMS {
1741 let center_y = cursor_y + item_height / 2.0;
1742 let swatch_top = center_y - LEGEND_SWATCH_SIZE / 2.0;
1743 let swatch_left = top_left.x + LEGEND_PADDING;
1744 svg.draw_rect(
1745 Point::new(swatch_left, swatch_top),
1746 Point::new(LEGEND_SWATCH_SIZE, LEGEND_SWATCH_SIZE),
1747 Some(BORDER_COLOR),
1748 0.6,
1749 Some(color),
1750 2.0,
1751 );
1752 let text_x = swatch_left + LEGEND_SWATCH_SIZE + 4.0;
1753 svg.draw_text(
1754 Point::new(text_x, center_y),
1755 label,
1756 LEGEND_FONT_SIZE,
1757 "black",
1758 false,
1759 "start",
1760 FontFamily::Sans,
1761 );
1762 cursor_y += item_height + LEGEND_ROW_GAP;
1763 }
1764
1765 if !RESOURCE_LEGEND_ITEMS.is_empty() {
1766 cursor_y += LEGEND_SECTION_GAP;
1767 let title_y = cursor_y + LEGEND_FONT_SIZE as f64 / 2.0;
1768 svg.draw_text(
1769 Point::new(top_left.x + LEGEND_PADDING, title_y),
1770 RESOURCE_LEGEND_TITLE,
1771 LEGEND_FONT_SIZE,
1772 DIM_GRAY,
1773 true,
1774 "start",
1775 FontFamily::Sans,
1776 );
1777 cursor_y += LEGEND_FONT_SIZE as f64 + LEGEND_ROW_GAP;
1778
1779 for (label, color) in RESOURCE_LEGEND_ITEMS {
1780 let center_y = cursor_y + item_height / 2.0;
1781 let swatch_top = center_y - LEGEND_SWATCH_SIZE / 2.0;
1782 let swatch_left = top_left.x + LEGEND_PADDING;
1783 svg.draw_rect(
1784 Point::new(swatch_left, swatch_top),
1785 Point::new(LEGEND_SWATCH_SIZE, LEGEND_SWATCH_SIZE),
1786 Some(BORDER_COLOR),
1787 0.6,
1788 Some(color),
1789 2.0,
1790 );
1791 let text_x = swatch_left + LEGEND_SWATCH_SIZE + 4.0;
1792 svg.draw_text(
1793 Point::new(text_x, center_y),
1794 label,
1795 LEGEND_FONT_SIZE,
1796 "black",
1797 false,
1798 "start",
1799 FontFamily::Sans,
1800 );
1801 cursor_y += item_height + LEGEND_ROW_GAP;
1802 }
1803 }
1804
1805 cursor_y += LEGEND_SECTION_GAP;
1806 let divider_y = cursor_y - LEGEND_ROW_GAP / 2.0;
1807 svg.draw_line(
1808 Point::new(top_left.x + LEGEND_PADDING, divider_y),
1809 Point::new(top_left.x + metrics.width - LEGEND_PADDING, divider_y),
1810 "#e0e0e0",
1811 0.5,
1812 );
1813
1814 let credit_height = draw_created_with(
1815 svg,
1816 Point::new(top_left.x + LEGEND_PADDING, cursor_y),
1817 top_left.x + metrics.width - LEGEND_PADDING,
1818 );
1819 cursor_y += credit_height;
1820
1821 cursor_y - top_left.y + LEGEND_BOTTOM_PADDING
1822}
1823
1824fn draw_created_with(svg: &mut SvgWriter, top_left: Point, right_edge: f64) -> f64 {
1825 let left_text = "Created with";
1826 let link_text = "Copper-rs";
1827 let version_text = format!("v{}", env!("CARGO_PKG_VERSION"));
1828 let left_width = legend_text_width(left_text, LEGEND_FONT_SIZE);
1829 let link_width = legend_text_width(link_text, LEGEND_FONT_SIZE);
1830 let version_width = legend_text_width(version_text.as_str(), LEGEND_FONT_SIZE);
1831 let height = LEGEND_LOGO_SIZE.max(LEGEND_FONT_SIZE as f64);
1832 let center_y = top_left.y + height / 2.0;
1833 let version_text_x = right_edge;
1834 let link_text_x = version_text_x - version_width - LEGEND_VERSION_GAP;
1835 let link_start_x = link_text_x - link_width;
1836 let logo_left = link_start_x - LEGEND_LINK_GAP - LEGEND_LOGO_SIZE;
1837 let logo_top = center_y - LEGEND_LOGO_SIZE / 2.0;
1838 let left_text_anchor = logo_left - LEGEND_WITH_LOGO_GAP;
1839
1840 svg.draw_text(
1841 Point::new(left_text_anchor, center_y),
1842 left_text,
1843 LEGEND_FONT_SIZE,
1844 DIM_GRAY,
1845 false,
1846 "end",
1847 FontFamily::Sans,
1848 );
1849
1850 let logo_uri = svg_data_uri(COPPER_LOGO_SVG);
1851 let image = Image::new()
1852 .set("x", logo_left)
1853 .set("y", logo_top)
1854 .set("width", LEGEND_LOGO_SIZE)
1855 .set("height", LEGEND_LOGO_SIZE)
1856 .set("href", logo_uri.clone())
1857 .set("xlink:href", logo_uri);
1858 let mut text_node = build_text_node(
1859 Point::new(link_text_x, center_y),
1860 link_text,
1861 LEGEND_FONT_SIZE,
1862 COPPER_LINK_COLOR,
1863 false,
1864 "end",
1865 FontFamily::Sans,
1866 );
1867 text_node.assign("text-decoration", "underline");
1868 text_node.assign("text-underline-offset", "1");
1869 text_node.assign("text-decoration-thickness", "0.6");
1870
1871 let mut link = SvgElement::new("a");
1872 link.assign("href", COPPER_GITHUB_URL);
1873 link.assign("target", "_blank");
1874 link.assign("rel", "noopener noreferrer");
1875 link.append(image);
1876 link.append(text_node);
1877 svg.append_node(link);
1878
1879 svg.draw_text(
1880 Point::new(version_text_x, center_y),
1881 version_text.as_str(),
1882 LEGEND_FONT_SIZE,
1883 DIM_GRAY,
1884 false,
1885 "end",
1886 FontFamily::Sans,
1887 );
1888
1889 let left_text_start = left_text_anchor - left_width;
1890 let total_width = right_edge - left_text_start;
1891 svg.grow_window(
1892 Point::new(left_text_start, top_left.y),
1893 Point::new(total_width, height),
1894 );
1895
1896 height
1897}
1898
1899struct LegendMetrics {
1900 width: f64,
1901 height: f64,
1902}
1903
1904fn measure_legend() -> LegendMetrics {
1905 let title_width = get_size_for_str("Legend", LEGEND_TITLE_SIZE).x;
1906 let mut max_line_width = title_width;
1907
1908 for (label, _) in LEGEND_ITEMS {
1909 let label_width = get_size_for_str(label, LEGEND_FONT_SIZE).x;
1910 let line_width = LEGEND_SWATCH_SIZE + 4.0 + label_width;
1911 max_line_width = max_line_width.max(line_width);
1912 }
1913
1914 if !RESOURCE_LEGEND_ITEMS.is_empty() {
1915 let section_width = get_size_for_str(RESOURCE_LEGEND_TITLE, LEGEND_FONT_SIZE).x;
1916 max_line_width = max_line_width.max(section_width);
1917 for (label, _) in RESOURCE_LEGEND_ITEMS {
1918 let label_width = get_size_for_str(label, LEGEND_FONT_SIZE).x;
1919 let line_width = LEGEND_SWATCH_SIZE + 4.0 + label_width;
1920 max_line_width = max_line_width.max(line_width);
1921 }
1922 }
1923
1924 let credit_left = "Created with";
1925 let credit_link = "Copper-rs";
1926 let credit_version = format!("v{}", env!("CARGO_PKG_VERSION"));
1927 let credit_width = legend_text_width(credit_left, LEGEND_FONT_SIZE)
1928 + LEGEND_WITH_LOGO_GAP
1929 + LEGEND_LOGO_SIZE
1930 + LEGEND_LINK_GAP
1931 + legend_text_width(credit_link, LEGEND_FONT_SIZE)
1932 + LEGEND_VERSION_GAP
1933 + legend_text_width(credit_version.as_str(), LEGEND_FONT_SIZE);
1934 max_line_width = max_line_width.max(credit_width);
1935
1936 let item_height = LEGEND_SWATCH_SIZE.max(LEGEND_FONT_SIZE as f64);
1937 let items_count = LEGEND_ITEMS.len() as f64;
1938 let items_height = if items_count > 0.0 {
1939 items_count * item_height + (items_count - 1.0) * LEGEND_ROW_GAP
1940 } else {
1941 0.0
1942 };
1943 let resource_count = RESOURCE_LEGEND_ITEMS.len() as f64;
1944 let resource_height = if resource_count > 0.0 {
1945 resource_count * item_height + (resource_count - 1.0) * LEGEND_ROW_GAP
1946 } else {
1947 0.0
1948 };
1949 let resource_section_height = if resource_count > 0.0 {
1950 LEGEND_SECTION_GAP + LEGEND_FONT_SIZE as f64 + LEGEND_ROW_GAP + resource_height
1951 } else {
1952 0.0
1953 };
1954 let credit_height = LEGEND_LOGO_SIZE.max(LEGEND_FONT_SIZE as f64);
1955 let height = LEGEND_PADDING
1956 + LEGEND_BOTTOM_PADDING
1957 + LEGEND_TITLE_SIZE as f64
1958 + LEGEND_ROW_GAP
1959 + items_height
1960 + LEGEND_ROW_GAP
1961 + resource_section_height
1962 + LEGEND_SECTION_GAP
1963 + credit_height;
1964
1965 LegendMetrics {
1966 width: LEGEND_PADDING * 2.0 + max_line_width,
1967 height,
1968 }
1969}
1970
1971fn validate_mission_arg(
1973 config: &config::CuConfig,
1974 requested: Option<&str>,
1975) -> CuResult<Option<String>> {
1976 match (&config.graphs, requested) {
1977 (ConfigGraphs::Simple(_), None) => Ok(None),
1978 (ConfigGraphs::Simple(_), Some("default")) => Ok(None),
1979 (ConfigGraphs::Simple(_), Some(id)) => Err(CuError::from(format!(
1980 "Config is not mission-based; remove --mission (received '{id}')"
1981 ))),
1982 (ConfigGraphs::Missions(graphs), Some(id)) => {
1983 if graphs.contains_key(id) {
1984 Ok(Some(id.to_string()))
1985 } else {
1986 Err(CuError::from(format!(
1987 "Mission '{id}' not found. Available missions: {}",
1988 format_mission_list(graphs)
1989 )))
1990 }
1991 }
1992 (ConfigGraphs::Missions(_), None) => Ok(None),
1993 }
1994}
1995
1996fn print_mission_list(config: &config::CuConfig) {
1998 match &config.graphs {
1999 ConfigGraphs::Simple(_) => println!("default"),
2000 ConfigGraphs::Missions(graphs) => {
2001 let mut missions: Vec<_> = graphs.keys().cloned().collect();
2002 missions.sort();
2003 for mission in missions {
2004 println!("{mission}");
2005 }
2006 }
2007 }
2008}
2009
2010fn format_mission_list(graphs: &HashMap<String, config::CuGraph>) -> String {
2012 let mut missions: Vec<_> = graphs.keys().cloned().collect();
2013 missions.sort();
2014 missions.join(", ")
2015}
2016
2017struct SectionRef<'a> {
2018 label: Option<String>,
2019 mission_id: Option<String>,
2020 graph: &'a config::CuGraph,
2021}
2022
2023struct SectionLayout {
2024 label: Option<String>,
2025 graph: VisualGraph,
2026 nodes: Vec<NodeRender>,
2027 edges: Vec<RenderEdge>,
2028 bounds: (Point, Point),
2029 port_anchors: HashMap<NodeHandle, HashMap<String, Point>>,
2030 resource_tables: Vec<ResourceTable>,
2031 perf_table: Option<ResourceTable>,
2032}
2033
2034struct NodeRender {
2035 handle: NodeHandle,
2036 table: TableNode,
2037}
2038
2039struct ResourceTable {
2040 table: TableNode,
2041 size: Point,
2042}
2043
2044#[derive(Clone)]
2045struct ResourceOwner {
2046 name: String,
2047 flavor: config::Flavor,
2048}
2049
2050#[derive(Clone, Copy)]
2051enum ResourceUsage {
2052 Exclusive,
2053 Shared,
2054 Unused,
2055}
2056
2057#[derive(Clone, Deserialize)]
2058struct LogStats {
2059 schema_version: u32,
2060 config_signature: String,
2061 mission: Option<String>,
2062 edges: Vec<EdgeLogStats>,
2063 perf: PerfStats,
2064}
2065
2066#[derive(Clone, Deserialize)]
2067struct EdgeLogStats {
2068 src: String,
2069 src_channel: Option<String>,
2070 dst: String,
2071 dst_channel: Option<String>,
2072 msg: String,
2073 samples: u64,
2074 none_samples: u64,
2075 valid_time_samples: u64,
2076 total_raw_bytes: u64,
2077 avg_raw_bytes: Option<f64>,
2078 rate_hz: Option<f64>,
2079 throughput_bytes_per_sec: Option<f64>,
2080}
2081
2082#[derive(Clone, Deserialize)]
2083struct PerfStats {
2084 samples: u64,
2085 valid_time_samples: u64,
2086 end_to_end: DurationStats,
2087 jitter: DurationStats,
2088}
2089
2090#[derive(Clone, Deserialize)]
2091struct DurationStats {
2092 min_ns: Option<u64>,
2093 max_ns: Option<u64>,
2094 mean_ns: Option<f64>,
2095 stddev_ns: Option<f64>,
2096}
2097
2098struct LogStatsIndex {
2099 mission: Option<String>,
2100 edges: HashMap<EdgeStatsKey, EdgeLogStats>,
2101 perf: PerfStats,
2102}
2103
2104impl LogStatsIndex {
2105 fn applies_to(&self, mission_id: Option<&str>) -> bool {
2106 mission_key(self.mission.as_deref()) == mission_key(mission_id)
2107 }
2108
2109 fn edge_stats_for(&self, cnx: &config::RenderConnection) -> Option<EdgeLogStats> {
2110 let key = EdgeStatsKey::from_connection(cnx);
2111 self.edge_stats_for_key(&key)
2112 }
2113
2114 fn edge_stats_for_key(&self, key: &EdgeStatsKey) -> Option<EdgeLogStats> {
2115 self.edges
2116 .get(key)
2117 .or_else(|| {
2118 if key.src_channel.is_some() || key.dst_channel.is_some() {
2119 self.edges.get(&key.without_channels())
2120 } else {
2121 None
2122 }
2123 })
2124 .cloned()
2125 }
2126}
2127
2128struct RenderEdge {
2129 src: NodeHandle,
2130 dst: NodeHandle,
2131 arrow: Arrow,
2132 label: String,
2133 color_idx: usize,
2134 src_port: Option<String>,
2135 dst_port: Option<String>,
2136 stats: Option<EdgeLogStats>,
2137}
2138
2139#[derive(Clone, Hash, PartialEq, Eq)]
2140struct EdgeGroupKey {
2141 src: NodeHandle,
2142 src_port: Option<String>,
2143 msg: String,
2144}
2145
2146#[derive(Clone, Hash, PartialEq, Eq)]
2147struct EdgeStatsKey {
2148 src: String,
2149 src_channel: Option<String>,
2150 dst: String,
2151 dst_channel: Option<String>,
2152 msg: String,
2153}
2154
2155impl EdgeStatsKey {
2156 fn from_edge(edge: &EdgeLogStats) -> Self {
2157 Self {
2158 src: edge.src.clone(),
2159 src_channel: edge.src_channel.clone(),
2160 dst: edge.dst.clone(),
2161 dst_channel: edge.dst_channel.clone(),
2162 msg: edge.msg.clone(),
2163 }
2164 }
2165
2166 fn from_connection(cnx: &config::RenderConnection) -> Self {
2167 Self {
2168 src: cnx.src.clone(),
2169 src_channel: cnx.src_channel.clone(),
2170 dst: cnx.dst.clone(),
2171 dst_channel: cnx.dst_channel.clone(),
2172 msg: cnx.msg.clone(),
2173 }
2174 }
2175
2176 fn without_channels(&self) -> Self {
2177 Self {
2178 src: self.src.clone(),
2179 src_channel: None,
2180 dst: self.dst.clone(),
2181 dst_channel: None,
2182 msg: self.msg.clone(),
2183 }
2184 }
2185}
2186
2187#[derive(Clone)]
2188enum TableNode {
2189 Cell(TableCell),
2190 Array(Vec<TableNode>),
2191}
2192
2193#[derive(Clone)]
2194struct TableCell {
2195 lines: Vec<CellLine>,
2196 port: Option<String>,
2197 background: Option<String>,
2198 border_width: f64,
2199 align: TextAlign,
2200}
2201
2202impl TableCell {
2203 fn new(lines: Vec<CellLine>) -> Self {
2204 Self {
2205 lines,
2206 port: None,
2207 background: None,
2208 border_width: 1.0,
2209 align: TextAlign::Left,
2210 }
2211 }
2212
2213 fn single_line_sized(
2214 text: impl Into<String>,
2215 color: &str,
2216 bold: bool,
2217 font_size: usize,
2218 ) -> Self {
2219 Self::new(vec![CellLine::new(text, color, bold, font_size)])
2220 }
2221
2222 fn with_port(mut self, port: String) -> Self {
2223 self.port = Some(port);
2224 self
2225 }
2226
2227 fn with_background(mut self, color: &str) -> Self {
2228 self.background = Some(color.to_string());
2229 self
2230 }
2231
2232 fn with_border_width(mut self, width: f64) -> Self {
2233 self.border_width = width;
2234 self
2235 }
2236
2237 fn with_align(mut self, align: TextAlign) -> Self {
2238 self.align = align;
2239 self
2240 }
2241
2242 fn label(&self) -> String {
2243 self.lines
2244 .iter()
2245 .map(|line| line.text.as_str())
2246 .collect::<Vec<_>>()
2247 .join("\n")
2248 }
2249}
2250
2251#[derive(Clone, Copy)]
2252enum TextAlign {
2253 Left,
2254 Center,
2255 Right,
2256}
2257
2258#[derive(Clone)]
2259struct CellLine {
2260 text: String,
2261 color: String,
2262 bold: bool,
2263 font_size: usize,
2264 font_family: FontFamily,
2265}
2266
2267impl CellLine {
2268 fn new(text: impl Into<String>, color: &str, bold: bool, font_size: usize) -> Self {
2269 Self {
2270 text: text.into(),
2271 color: color.to_string(),
2272 bold,
2273 font_size,
2274 font_family: FontFamily::Sans,
2275 }
2276 }
2277
2278 fn code(text: impl Into<String>, color: &str, bold: bool, font_size: usize) -> Self {
2279 let mut line = Self::new(text, color, bold, font_size);
2280 line.font_family = FontFamily::Mono;
2281 line
2282 }
2283}
2284
2285#[derive(Clone, Copy)]
2286enum FontFamily {
2287 Sans,
2288 Mono,
2289}
2290
2291impl FontFamily {
2292 fn as_css(self) -> &'static str {
2293 match self {
2294 FontFamily::Sans => FONT_FAMILY,
2295 FontFamily::Mono => MONO_FONT_FAMILY,
2296 }
2297 }
2298}
2299
2300trait TableVisitor {
2301 fn handle_cell(&mut self, cell: &TableCell, loc: Point, size: Point);
2302}
2303
2304struct TableRenderer<'a> {
2305 svg: &'a mut SvgWriter,
2306 node_left_x: f64,
2307 node_right_x: f64,
2308}
2309
2310impl TableVisitor for TableRenderer<'_> {
2311 fn handle_cell(&mut self, cell: &TableCell, loc: Point, size: Point) {
2312 let top_left = Point::new(loc.x - size.x / 2.0, loc.y - size.y / 2.0);
2313
2314 if let Some(bg) = &cell.background {
2315 self.svg.draw_rect(top_left, size, None, 0.0, Some(bg), 0.0);
2316 }
2317 self.svg.draw_rect(
2318 top_left,
2319 size,
2320 Some(BORDER_COLOR),
2321 cell.border_width,
2322 None,
2323 0.0,
2324 );
2325
2326 if let Some(port) = &cell.port {
2327 let is_output = port.starts_with("out_");
2328 let dot_x = if is_output {
2329 self.node_right_x
2330 } else {
2331 self.node_left_x
2332 };
2333 self.svg
2334 .draw_circle_overlay(Point::new(dot_x, loc.y), PORT_DOT_RADIUS, BORDER_COLOR);
2335 }
2336
2337 if cell.lines.is_empty() {
2338 return;
2339 }
2340
2341 let total_height = cell_text_height(cell);
2342 let mut current_y = loc.y - total_height / 2.0;
2343 let (text_x, anchor) = match cell.align {
2344 TextAlign::Left => (loc.x - size.x / 2.0 + CELL_PADDING, "start"),
2345 TextAlign::Center => (loc.x, "middle"),
2346 TextAlign::Right => (loc.x + size.x / 2.0 - CELL_PADDING, "end"),
2347 };
2348
2349 for (idx, line) in cell.lines.iter().enumerate() {
2350 let line_height = line.font_size as f64;
2351 let y = current_y + line_height / 2.0;
2352 self.svg.draw_text(
2353 Point::new(text_x, y),
2354 &line.text,
2355 line.font_size,
2356 &line.color,
2357 line.bold,
2358 anchor,
2359 line.font_family,
2360 );
2361 current_y += line_height;
2362 if idx + 1 < cell.lines.len() {
2363 current_y += CELL_LINE_SPACING;
2364 }
2365 }
2366 }
2367}
2368
2369struct ArrowLabel {
2370 text: String,
2371 color: String,
2372 font_size: usize,
2373 bold: bool,
2374 font_family: FontFamily,
2375 position: Option<Point>,
2376}
2377
2378struct StraightLabelSlot {
2379 center: Point,
2380 width: f64,
2381 normal: Point,
2382 stack_offset: f64,
2383 group_count: usize,
2384}
2385
2386struct DetourLabelSlot {
2387 center_x: f64,
2388 width: f64,
2389 lane_y: f64,
2390 above: bool,
2391 group_count: usize,
2392 group_width: f64,
2393}
2394
2395struct BezierSegment {
2396 start: Point,
2397 c1: Point,
2398 c2: Point,
2399 end: Point,
2400}
2401
2402impl ArrowLabel {
2403 fn new(
2404 text: String,
2405 color: &str,
2406 font_size: usize,
2407 bold: bool,
2408 font_family: FontFamily,
2409 ) -> Self {
2410 Self {
2411 text,
2412 color: color.to_string(),
2413 font_size,
2414 bold,
2415 font_family,
2416 position: None,
2417 }
2418 }
2419
2420 fn with_position(mut self, position: Point) -> Self {
2421 self.position = Some(position);
2422 self
2423 }
2424}
2425
2426struct NullBackend;
2427
2428impl RenderBackend for NullBackend {
2429 fn draw_rect(
2430 &mut self,
2431 _xy: Point,
2432 _size: Point,
2433 _look: &StyleAttr,
2434 _properties: Option<String>,
2435 _clip: Option<layout::core::format::ClipHandle>,
2436 ) {
2437 }
2438
2439 fn draw_line(
2440 &mut self,
2441 _start: Point,
2442 _stop: Point,
2443 _look: &StyleAttr,
2444 _properties: Option<String>,
2445 ) {
2446 }
2447
2448 fn draw_circle(
2449 &mut self,
2450 _xy: Point,
2451 _size: Point,
2452 _look: &StyleAttr,
2453 _properties: Option<String>,
2454 ) {
2455 }
2456
2457 fn draw_text(&mut self, _xy: Point, _text: &str, _look: &StyleAttr) {}
2458
2459 fn draw_arrow(
2460 &mut self,
2461 _path: &[(Point, Point)],
2462 _dashed: bool,
2463 _head: (bool, bool),
2464 _look: &StyleAttr,
2465 _properties: Option<String>,
2466 _text: &str,
2467 ) {
2468 }
2469
2470 fn create_clip(&mut self, _xy: Point, _size: Point, _rounded_px: usize) -> usize {
2471 0
2472 }
2473}
2474
2475struct SvgWriter {
2476 content: Group,
2477 overlay: Group,
2478 defs: Definitions,
2479 view_size: Point,
2480 counter: usize,
2481}
2482
2483impl SvgWriter {
2484 fn new() -> Self {
2485 let mut defs = Definitions::new();
2486 let start_marker = Marker::new()
2487 .set("id", "startarrow")
2488 .set("markerWidth", 10)
2489 .set("markerHeight", 7)
2490 .set("refX", 2)
2491 .set("refY", 3.5)
2492 .set("orient", "auto")
2493 .add(
2494 Polygon::new()
2495 .set("points", "10 0, 10 7, 0 3.5")
2496 .set("fill", "context-stroke"),
2497 );
2498 let end_marker = Marker::new()
2499 .set("id", "endarrow")
2500 .set("markerWidth", 10)
2501 .set("markerHeight", 7)
2502 .set("refX", 8)
2503 .set("refY", 3.5)
2504 .set("orient", "auto")
2505 .add(
2506 Polygon::new()
2507 .set("points", "0 0, 10 3.5, 0 7")
2508 .set("fill", "context-stroke"),
2509 );
2510 defs.append(start_marker);
2511 defs.append(end_marker);
2512 let mut style = SvgElement::new("style");
2513 style.assign("type", "text/css");
2514 style.append(SvgTextNode::new(EDGE_TOOLTIP_CSS));
2515 defs.append(style);
2516
2517 Self {
2518 content: Group::new(),
2519 overlay: Group::new(),
2520 defs,
2521 view_size: Point::new(0.0, 0.0),
2522 counter: 0,
2523 }
2524 }
2525
2526 fn grow_window(&mut self, point: Point, size: Point) {
2527 self.view_size.x = self.view_size.x.max(point.x + size.x);
2528 self.view_size.y = self.view_size.y.max(point.y + size.y);
2529 }
2530
2531 fn draw_rect(
2532 &mut self,
2533 top_left: Point,
2534 size: Point,
2535 stroke: Option<&str>,
2536 stroke_width: f64,
2537 fill: Option<&str>,
2538 rounded: f64,
2539 ) {
2540 self.grow_window(top_left, size);
2541
2542 let stroke_color = stroke.unwrap_or("none");
2543 let fill_color = fill.unwrap_or("none");
2544 let width = if stroke.is_some() { stroke_width } else { 0.0 };
2545 let mut rect = Rectangle::new()
2546 .set("x", top_left.x)
2547 .set("y", top_left.y)
2548 .set("width", size.x)
2549 .set("height", size.y)
2550 .set("fill", fill_color)
2551 .set("stroke", stroke_color)
2552 .set("stroke-width", width);
2553 if rounded > 0.0 {
2554 rect = rect.set("rx", rounded).set("ry", rounded);
2555 }
2556 self.content.append(rect);
2557 }
2558
2559 fn draw_circle_overlay(&mut self, center: Point, radius: f64, fill: &str) {
2560 let circle = Circle::new()
2561 .set("cx", center.x)
2562 .set("cy", center.y)
2563 .set("r", radius)
2564 .set("fill", fill);
2565 self.overlay.append(circle);
2566
2567 let top_left = Point::new(center.x - radius, center.y - radius);
2568 let size = Point::new(radius * 2.0, radius * 2.0);
2569 self.grow_window(top_left, size);
2570 }
2571
2572 fn draw_line(&mut self, start: Point, end: Point, color: &str, width: f64) {
2573 let line = Line::new()
2574 .set("x1", start.x)
2575 .set("y1", start.y)
2576 .set("x2", end.x)
2577 .set("y2", end.y)
2578 .set("stroke", color)
2579 .set("stroke-width", width);
2580 self.content.append(line);
2581
2582 let top_left = Point::new(start.x.min(end.x), start.y.min(end.y));
2583 let size = Point::new((start.x - end.x).abs(), (start.y - end.y).abs());
2584 self.grow_window(top_left, size);
2585 }
2586
2587 fn append_node<T>(&mut self, node: T)
2588 where
2589 T: Into<Box<dyn Node>>,
2590 {
2591 self.content.append(node);
2592 }
2593
2594 #[allow(clippy::too_many_arguments)]
2595 fn draw_text(
2596 &mut self,
2597 pos: Point,
2598 text: &str,
2599 font_size: usize,
2600 color: &str,
2601 bold: bool,
2602 anchor: &str,
2603 family: FontFamily,
2604 ) {
2605 if text.is_empty() {
2606 return;
2607 }
2608
2609 let weight = if bold { "bold" } else { "normal" };
2610 let node = Text::new(text)
2611 .set("x", pos.x)
2612 .set("y", pos.y)
2613 .set("text-anchor", anchor)
2614 .set("dominant-baseline", "middle")
2615 .set("font-family", family.as_css())
2616 .set("font-size", format!("{font_size}px"))
2617 .set("fill", color)
2618 .set("font-weight", weight);
2619 self.content.append(node);
2620
2621 let size = get_size_for_str(text, font_size);
2622 let top_left = Point::new(pos.x, pos.y - size.y / 2.0);
2623 self.grow_window(top_left, size);
2624 }
2625
2626 #[allow(clippy::too_many_arguments)]
2627 fn draw_text_overlay(
2628 &mut self,
2629 pos: Point,
2630 text: &str,
2631 font_size: usize,
2632 color: &str,
2633 bold: bool,
2634 anchor: &str,
2635 family: FontFamily,
2636 ) {
2637 if text.is_empty() {
2638 return;
2639 }
2640
2641 let weight = if bold { "bold" } else { "normal" };
2642 let node = Text::new(text)
2643 .set("x", pos.x)
2644 .set("y", pos.y)
2645 .set("text-anchor", anchor)
2646 .set("dominant-baseline", "middle")
2647 .set("font-family", family.as_css())
2648 .set("font-size", format!("{font_size}px"))
2649 .set("fill", color)
2650 .set("font-weight", weight)
2651 .set("stroke", "white")
2652 .set("stroke-width", EDGE_LABEL_HALO_WIDTH)
2653 .set("paint-order", "stroke")
2654 .set("stroke-linejoin", "round");
2655 self.overlay.append(node);
2656
2657 let size = get_size_for_str(text, font_size);
2658 let top_left = Point::new(pos.x, pos.y - size.y / 2.0);
2659 self.grow_window(top_left, size);
2660 }
2661
2662 fn draw_arrow(
2663 &mut self,
2664 path: &[BezierSegment],
2665 dashed: bool,
2666 head: (bool, bool),
2667 look: &StyleAttr,
2668 label: Option<&ArrowLabel>,
2669 tooltip: Option<&str>,
2670 ) {
2671 if path.is_empty() {
2672 return;
2673 }
2674
2675 for segment in path {
2676 self.grow_window(segment.start, Point::new(0.0, 0.0));
2677 self.grow_window(segment.c1, Point::new(0.0, 0.0));
2678 self.grow_window(segment.c2, Point::new(0.0, 0.0));
2679 self.grow_window(segment.end, Point::new(0.0, 0.0));
2680 }
2681
2682 let stroke_color = look.line_color.to_web_color();
2683 let stroke_color = normalize_web_color(&stroke_color);
2684
2685 let path_data = build_path_data(path);
2686 let path_id = format!("arrow{}", self.counter);
2687 let mut path_el = SvgPath::new()
2688 .set("id", path_id.clone())
2689 .set("d", path_data)
2690 .set("stroke", stroke_color.clone())
2691 .set("stroke-width", look.line_width)
2692 .set("fill", "none");
2693 if dashed {
2694 path_el = path_el.set("stroke-dasharray", "5,5");
2695 }
2696 if head.0 {
2697 path_el = path_el.set("marker-start", "url(#startarrow)");
2698 }
2699 if head.1 {
2700 path_el = path_el.set("marker-end", "url(#endarrow)");
2701 }
2702 self.content.append(path_el);
2703
2704 if let Some(label) = label {
2705 if label.text.is_empty() {
2706 self.counter += 1;
2707 return;
2708 }
2709 if let Some(pos) = label.position {
2710 self.draw_text_overlay(
2711 pos,
2712 &label.text,
2713 label.font_size,
2714 &label.color,
2715 label.bold,
2716 "middle",
2717 label.font_family,
2718 );
2719 } else {
2720 let label_path_id = format!("{}_label", path_id);
2721 let start = path[0].start;
2722 let end = path[path.len() - 1].end;
2723 let label_path_data = build_explicit_path_data(path, start.x > end.x);
2724 let label_path_el = SvgPath::new()
2725 .set("id", label_path_id.clone())
2726 .set("d", label_path_data)
2727 .set("fill", "none")
2728 .set("stroke", "none");
2729 self.overlay.append(label_path_el);
2730
2731 let weight = if label.bold { "bold" } else { "normal" };
2732 let text_path = TextPath::new(label.text.as_str())
2733 .set("href", format!("#{label_path_id}"))
2734 .set("startOffset", "50%")
2735 .set("text-anchor", "middle")
2736 .set("dy", EDGE_LABEL_OFFSET)
2737 .set("font-family", label.font_family.as_css())
2738 .set("font-size", format!("{}px", label.font_size))
2739 .set("fill", label.color.clone())
2740 .set("font-weight", weight)
2741 .set("stroke", "white")
2742 .set("stroke-width", EDGE_LABEL_HALO_WIDTH)
2743 .set("paint-order", "stroke")
2744 .set("stroke-linejoin", "round");
2745 let mut text_node = SvgElement::new("text");
2746 text_node.append(text_path);
2747 self.overlay.append(text_node);
2748 }
2749 }
2750
2751 if let Some(tooltip) = tooltip {
2752 let (hover_group, tooltip_top_left, tooltip_size) =
2753 build_edge_hover_overlay(path, tooltip, &stroke_color, look.line_width);
2754 self.grow_window(tooltip_top_left, tooltip_size);
2755 self.overlay.append(hover_group);
2756 }
2757
2758 self.counter += 1;
2759 }
2760
2761 fn finalize(self) -> String {
2762 let width = if self.view_size.x < 1.0 {
2763 1.0
2764 } else {
2765 self.view_size.x + GRAPH_MARGIN
2766 };
2767 let height = if self.view_size.y < 1.0 {
2768 1.0
2769 } else {
2770 self.view_size.y + GRAPH_MARGIN
2771 };
2772
2773 let background = Rectangle::new()
2774 .set("x", 0)
2775 .set("y", 0)
2776 .set("width", width)
2777 .set("height", height)
2778 .set("fill", BACKGROUND_COLOR);
2779
2780 Document::new()
2781 .set("width", width)
2782 .set("height", height)
2783 .set("viewBox", (0, 0, width, height))
2784 .set("xmlns", "http://www.w3.org/2000/svg")
2785 .set("xmlns:xlink", "http://www.w3.org/1999/xlink")
2786 .add(self.defs)
2787 .add(background)
2788 .add(self.content)
2789 .add(self.overlay)
2790 .to_string()
2791 }
2792}
2793
2794fn build_text_node(
2795 pos: Point,
2796 text: &str,
2797 font_size: usize,
2798 color: &str,
2799 bold: bool,
2800 anchor: &str,
2801 family: FontFamily,
2802) -> Text {
2803 let weight = if bold { "bold" } else { "normal" };
2804 Text::new(text)
2805 .set("x", pos.x)
2806 .set("y", pos.y)
2807 .set("text-anchor", anchor)
2808 .set("dominant-baseline", "middle")
2809 .set("font-family", family.as_css())
2810 .set("font-size", format!("{font_size}px"))
2811 .set("fill", color)
2812 .set("font-weight", weight)
2813}
2814
2815fn svg_data_uri(svg: &str) -> String {
2816 format!(
2817 "data:image/svg+xml;base64,{}",
2818 base64_encode(svg.as_bytes())
2819 )
2820}
2821
2822fn base64_encode(input: &[u8]) -> String {
2823 const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
2824 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
2825 let mut i = 0;
2826 while i < input.len() {
2827 let b0 = input[i];
2828 let b1 = if i + 1 < input.len() { input[i + 1] } else { 0 };
2829 let b2 = if i + 2 < input.len() { input[i + 2] } else { 0 };
2830 let triple = ((b0 as u32) << 16) | ((b1 as u32) << 8) | (b2 as u32);
2831 let idx0 = ((triple >> 18) & 0x3F) as usize;
2832 let idx1 = ((triple >> 12) & 0x3F) as usize;
2833 let idx2 = ((triple >> 6) & 0x3F) as usize;
2834 let idx3 = (triple & 0x3F) as usize;
2835 out.push(TABLE[idx0] as char);
2836 out.push(TABLE[idx1] as char);
2837 if i + 1 < input.len() {
2838 out.push(TABLE[idx2] as char);
2839 } else {
2840 out.push('=');
2841 }
2842 if i + 2 < input.len() {
2843 out.push(TABLE[idx3] as char);
2844 } else {
2845 out.push('=');
2846 }
2847 i += 3;
2848 }
2849 out
2850}
2851
2852fn legend_text_width(text: &str, font_size: usize) -> f64 {
2853 text.chars().count() as f64 * font_size as f64 * LEGEND_TEXT_WIDTH_FACTOR
2854}
2855
2856fn normalize_web_color(color: &str) -> String {
2857 if color.len() == 9 && color.starts_with('#') {
2858 return format!("#{}", &color[1..7]);
2859 }
2860 color.to_string()
2861}
2862
2863fn colored_edge_style(base: &StyleAttr, color: &str) -> StyleAttr {
2864 StyleAttr::new(Color::fast(color), base.line_width, None, 0, EDGE_FONT_SIZE)
2865}
2866
2867fn edge_cycle_color_index(slot: &mut usize) -> usize {
2868 let idx = EDGE_COLOR_ORDER[*slot % EDGE_COLOR_ORDER.len()];
2869 *slot += 1;
2870 idx
2871}
2872
2873fn lighten_hex(color: &str, amount: f64) -> String {
2874 let Some(hex) = color.strip_prefix('#') else {
2875 return color.to_string();
2876 };
2877 if hex.len() != 6 {
2878 return color.to_string();
2879 }
2880 let parse = |idx| u8::from_str_radix(&hex[idx..idx + 2], 16).ok();
2881 let (Some(r), Some(g), Some(b)) = (parse(0), parse(2), parse(4)) else {
2882 return color.to_string();
2883 };
2884 let blend = |c| ((c as f64) + (255.0 - c as f64) * amount).round() as u8;
2885 format!("#{:02X}{:02X}{:02X}", blend(r), blend(g), blend(b))
2886}
2887
2888fn wrap_text(text: &str, max_width: usize) -> String {
2889 if text.len() <= max_width {
2890 return text.to_string();
2891 }
2892 let mut out = String::new();
2893 let mut line_len = 0;
2894 for word in text.split_whitespace() {
2895 let next_len = if line_len == 0 {
2896 word.len()
2897 } else {
2898 line_len + 1 + word.len()
2899 };
2900 if next_len > max_width && line_len > 0 {
2901 out.push('\n');
2902 out.push_str(word);
2903 line_len = word.len();
2904 } else {
2905 if line_len > 0 {
2906 out.push(' ');
2907 }
2908 out.push_str(word);
2909 line_len = next_len;
2910 }
2911 }
2912 out
2913}
2914
2915fn wrap_type_label(label: &str, max_width: usize) -> String {
2916 if label.len() <= max_width {
2917 return label.to_string();
2918 }
2919
2920 let tokens = split_type_tokens(label);
2921 let mut lines: Vec<String> = Vec::new();
2922 let mut current = String::new();
2923 let mut current_len = 0usize;
2924
2925 for token in tokens {
2926 if token.is_empty() {
2927 continue;
2928 }
2929
2930 let chunks = split_long_token(&token, max_width);
2931 for chunk in chunks {
2932 if current_len + chunk.len() > max_width && !current.is_empty() {
2933 lines.push(current);
2934 current = String::new();
2935 current_len = 0;
2936 }
2937
2938 current.push_str(&chunk);
2939 current_len += chunk.len();
2940
2941 if chunk == "," || chunk == "<" || chunk == ">" {
2942 lines.push(current);
2943 current = String::new();
2944 current_len = 0;
2945 }
2946 }
2947 }
2948
2949 if !current.is_empty() {
2950 lines.push(current);
2951 }
2952
2953 if lines.is_empty() {
2954 return label.to_string();
2955 }
2956 lines.join("\n")
2957}
2958
2959fn split_long_token(token: &str, max_width: usize) -> Vec<String> {
2960 if token.len() <= max_width || token == "::" {
2961 return vec![token.to_string()];
2962 }
2963
2964 let mut out = Vec::new();
2965 let mut start = 0;
2966 let chars: Vec<char> = token.chars().collect();
2967 while start < chars.len() {
2968 let end = (start + max_width).min(chars.len());
2969 out.push(chars[start..end].iter().collect());
2970 start = end;
2971 }
2972 out
2973}
2974
2975fn cell_text_size(cell: &TableCell) -> Point {
2976 let mut max_width: f64 = 0.0;
2977 if cell.lines.is_empty() {
2978 return Point::new(1.0, 1.0);
2979 }
2980 for line in &cell.lines {
2981 let size = get_size_for_str(&line.text, line.font_size);
2982 max_width = max_width.max(size.x);
2983 }
2984 Point::new(max_width, cell_text_height(cell).max(1.0))
2985}
2986
2987fn cell_text_height(cell: &TableCell) -> f64 {
2988 if cell.lines.is_empty() {
2989 return 1.0;
2990 }
2991 let base: f64 = cell.lines.iter().map(|line| line.font_size as f64).sum();
2992 let spacing = CELL_LINE_SPACING * (cell.lines.len().saturating_sub(1) as f64);
2993 base + spacing
2994}
2995
2996fn collect_port_anchors(node: &NodeRender, element: &Element) -> HashMap<String, Point> {
2997 let pos = element.position();
2998 let center = pos.center();
2999 let size = pos.size(false);
3000 let left_x = center.x - size.x / 2.0;
3001 let right_x = center.x + size.x / 2.0;
3002
3003 let mut anchors = HashMap::new();
3004 let mut collector = PortAnchorCollector {
3005 anchors: &mut anchors,
3006 node_left_x: left_x,
3007 node_right_x: right_x,
3008 };
3009 visit_table(
3010 &node.table,
3011 element.orientation,
3012 center,
3013 size,
3014 &mut collector,
3015 );
3016 anchors
3017}
3018
3019struct PortAnchorCollector<'a> {
3020 anchors: &'a mut HashMap<String, Point>,
3021 node_left_x: f64,
3022 node_right_x: f64,
3023}
3024
3025impl TableVisitor for PortAnchorCollector<'_> {
3026 fn handle_cell(&mut self, cell: &TableCell, loc: Point, _size: Point) {
3027 let Some(port) = &cell.port else {
3028 return;
3029 };
3030
3031 let is_output = port.starts_with("out_");
3032 let port_offset = PORT_LINE_GAP + PORT_DOT_RADIUS;
3033 let x = if is_output {
3034 self.node_right_x + port_offset
3035 } else {
3036 self.node_left_x - port_offset
3037 };
3038 self.anchors.insert(port.clone(), Point::new(x, loc.y));
3039 }
3040}
3041
3042fn resolve_anchor(section: &SectionLayout, node: NodeHandle, port: Option<&String>) -> Point {
3043 if let Some(port) = port
3044 && let Some(anchors) = section.port_anchors.get(&node)
3045 && let Some(point) = anchors.get(port)
3046 {
3047 return *point;
3048 }
3049
3050 section.graph.element(node).position().center()
3051}
3052
3053#[derive(Clone, Copy)]
3054struct BackEdgePlan {
3055 idx: usize,
3056 span: f64,
3057 order_y: f64,
3058}
3059
3060struct NodeBounds {
3061 handle: NodeHandle,
3062 left: f64,
3063 right: f64,
3064 top: f64,
3065 bottom: f64,
3066 center_x: f64,
3067}
3068
3069fn collect_node_bounds(nodes: &[NodeRender], graph: &VisualGraph) -> Vec<NodeBounds> {
3070 let mut bounds = Vec::with_capacity(nodes.len());
3071 for node in nodes {
3072 let pos = graph.element(node.handle).position();
3073 let (top_left, bottom_right) = pos.bbox(false);
3074 bounds.push(NodeBounds {
3075 handle: node.handle,
3076 left: top_left.x,
3077 right: bottom_right.x,
3078 top: top_left.y,
3079 bottom: bottom_right.y,
3080 center_x: (top_left.x + bottom_right.x) / 2.0,
3081 });
3082 }
3083 bounds
3084}
3085
3086fn max_bottom_for_span(bounds: &[NodeBounds], min_x: f64, max_x: f64) -> f64 {
3087 bounds
3088 .iter()
3089 .filter(|b| b.right >= min_x && b.left <= max_x)
3090 .map(|b| b.bottom)
3091 .fold(f64::NEG_INFINITY, f64::max)
3092}
3093
3094fn min_top_for_span(bounds: &[NodeBounds], min_x: f64, max_x: f64) -> f64 {
3095 bounds
3096 .iter()
3097 .filter(|b| b.right >= min_x && b.left <= max_x)
3098 .map(|b| b.top)
3099 .fold(f64::INFINITY, f64::min)
3100}
3101
3102fn span_has_intermediate(
3103 bounds: &[NodeBounds],
3104 min_x: f64,
3105 max_x: f64,
3106 src: NodeHandle,
3107 dst: NodeHandle,
3108) -> bool {
3109 bounds.iter().any(|b| {
3110 b.handle != src
3111 && b.handle != dst
3112 && b.center_x > min_x + INTERMEDIATE_X_EPS
3113 && b.center_x < max_x - INTERMEDIATE_X_EPS
3114 })
3115}
3116
3117fn assign_back_edge_offsets(plans: &[BackEdgePlan], offsets: &mut [f64]) {
3118 let mut plans = plans.to_vec();
3119 plans.sort_by(|a, b| {
3120 a.span
3121 .partial_cmp(&b.span)
3122 .unwrap_or(Ordering::Equal)
3123 .then_with(|| a.order_y.partial_cmp(&b.order_y).unwrap_or(Ordering::Equal))
3124 });
3125
3126 let mut layer = 0usize;
3127 let mut last_span: Option<f64> = None;
3128 let mut layer_counts: HashMap<usize, usize> = HashMap::new();
3129
3130 for plan in plans {
3131 if let Some(prev_span) = last_span
3132 && (plan.span - prev_span).abs() > BACK_EDGE_SPAN_EPS
3133 {
3134 layer += 1;
3135 }
3136 last_span = Some(plan.span);
3137
3138 let dup = layer_counts.entry(layer).or_insert(0);
3139 offsets[plan.idx] =
3140 layer as f64 * BACK_EDGE_STACK_SPACING + *dup as f64 * BACK_EDGE_DUP_SPACING;
3141 *dup += 1;
3142 }
3143}
3144
3145fn expand_bounds(bounds: &mut (Point, Point), point: Point) {
3146 bounds.0.x = bounds.0.x.min(point.x);
3147 bounds.0.y = bounds.0.y.min(point.y);
3148 bounds.1.x = bounds.1.x.max(point.x);
3149 bounds.1.y = bounds.1.y.max(point.y);
3150}
3151
3152fn port_dir(port: Option<&String>) -> Option<f64> {
3153 port.and_then(|name| {
3154 if name.starts_with("out_") {
3155 Some(1.0)
3156 } else if name.starts_with("in_") {
3157 Some(-1.0)
3158 } else {
3159 None
3160 }
3161 })
3162}
3163
3164fn port_dir_incoming(port: Option<&String>) -> Option<f64> {
3165 port_dir(port).map(|dir| -dir)
3166}
3167
3168fn fallback_port_dirs(start: Point, end: Point) -> (f64, f64) {
3169 let dir = if end.x >= start.x { 1.0 } else { -1.0 };
3170 (dir, dir)
3171}
3172
3173fn lerp_point(a: Point, b: Point, t: f64) -> Point {
3174 Point::new(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t)
3175}
3176
3177fn straight_segment(start: Point, end: Point) -> BezierSegment {
3178 BezierSegment {
3179 start,
3180 c1: lerp_point(start, end, 1.0 / 3.0),
3181 c2: lerp_point(start, end, 2.0 / 3.0),
3182 end,
3183 }
3184}
3185
3186fn edge_stub_len(start: Point, end: Point) -> f64 {
3187 let dx = (end.x - start.x).abs();
3188 if dx <= 0.0 {
3189 return 0.0;
3190 }
3191 let max_stub = dx * 0.45;
3192 let mut stub = EDGE_STUB_LEN.min(max_stub);
3193 let min_stub = EDGE_STUB_MIN.min(max_stub);
3194 if stub < min_stub {
3195 stub = min_stub;
3196 }
3197 stub
3198}
3199
3200fn edge_port_handle(start: Point, end: Point) -> f64 {
3201 let dx = (end.x - start.x).abs();
3202 let mut handle = EDGE_PORT_HANDLE.min(dx * 0.2);
3203 if handle < 6.0 {
3204 handle = 6.0;
3205 }
3206 handle
3207}
3208
3209fn build_edge_path(start: Point, end: Point, start_dir: f64, end_dir: f64) -> Vec<BezierSegment> {
3210 let dir = if end.x >= start.x { 1.0 } else { -1.0 };
3211 let stub = edge_stub_len(start, end);
3212 if stub <= 1.0 {
3213 let dx = (end.x - start.x).abs().max(40.0);
3214 let ctrl1 = Point::new(start.x + dir * dx * 0.5, start.y);
3215 let ctrl2 = Point::new(end.x - dir * dx * 0.5, end.y);
3216 return vec![BezierSegment {
3217 start,
3218 c1: ctrl1,
3219 c2: ctrl2,
3220 end,
3221 }];
3222 }
3223
3224 let start_stub = Point::new(start.x + start_dir * stub, start.y);
3225 let end_stub = Point::new(end.x - end_dir * stub, end.y);
3226 let inner_dir = if end_stub.x >= start_stub.x {
3227 1.0
3228 } else {
3229 -1.0
3230 };
3231 let curve_dx = ((end_stub.x - start_stub.x).abs() * 0.35).max(10.0);
3232
3233 let seg1 = straight_segment(start, start_stub);
3234 let seg2 = BezierSegment {
3235 start: start_stub,
3236 c1: Point::new(start_stub.x + inner_dir * curve_dx, start_stub.y),
3237 c2: Point::new(end_stub.x - inner_dir * curve_dx, end_stub.y),
3238 end: end_stub,
3239 };
3240 let seg3 = straight_segment(end_stub, end);
3241 vec![seg1, seg2, seg3]
3242}
3243
3244fn build_back_edge_path(
3245 start: Point,
3246 end: Point,
3247 lane_y: f64,
3248 start_dir: f64,
3249 end_dir: f64,
3250) -> Vec<BezierSegment> {
3251 build_lane_path(start, end, lane_y, start_dir, end_dir)
3252}
3253
3254fn build_loop_path(
3255 start: Point,
3256 end: Point,
3257 bbox: (Point, Point),
3258 start_dir: f64,
3259 end_dir: f64,
3260) -> Vec<BezierSegment> {
3261 let height = bbox.1.y - bbox.0.y;
3262 let loop_dy = height * 0.8 + 30.0;
3263
3264 let center_y = (bbox.0.y + bbox.1.y) / 2.0;
3265
3266 let dir_y = if (start.y + end.y) / 2.0 < center_y {
3267 -1.0
3268 } else {
3269 1.0
3270 };
3271 let lane_y = center_y + dir_y * loop_dy;
3272 build_back_edge_path(start, end, lane_y, start_dir, end_dir)
3273}
3274
3275fn build_lane_path(
3276 start: Point,
3277 end: Point,
3278 lane_y: f64,
3279 start_dir: f64,
3280 end_dir: f64,
3281) -> Vec<BezierSegment> {
3282 let base_stub = edge_port_handle(start, end);
3283 let dy_start = (lane_y - start.y).abs();
3284 let dy_end = (lane_y - end.y).abs();
3285 let max_stub = (end.x - start.x).abs().max(40.0) * 0.45;
3286 let start_stub = (base_stub + dy_start * 0.6).min(max_stub.max(base_stub));
3287 let end_stub = (base_stub + dy_end * 0.6).min(max_stub.max(base_stub));
3288 let mut start_corner = Point::new(start.x, lane_y);
3289 let mut end_corner = Point::new(end.x, lane_y);
3290 let lane_dir = if (end_corner.x - start_corner.x).abs() < 1.0 {
3291 if end_dir.abs() > 0.0 {
3292 end_dir
3293 } else {
3294 start_dir
3295 }
3296 } else if end_corner.x >= start_corner.x {
3297 1.0
3298 } else {
3299 -1.0
3300 };
3301 let span = (end_corner.x - start_corner.x).abs();
3302 if start.x < end.x && span > 1.0 {
3303 let min_span = 60.0;
3304 let mut shrink = (span * 0.2).min(80.0);
3305 let max_shrink = ((span - min_span).max(0.0)) / 2.0;
3306 if shrink > max_shrink {
3307 shrink = max_shrink;
3308 }
3309 start_corner.x += lane_dir * shrink;
3310 end_corner.x -= lane_dir * shrink;
3311 }
3312 let entry_dir = -lane_dir;
3313 let handle_scale = if start.x < end.x { 0.6 } else { 1.0 };
3314 let entry_handle = (start_stub * handle_scale).max(6.0);
3315 let exit_handle = (end_stub * handle_scale).max(6.0);
3316 let seg1 = BezierSegment {
3317 start,
3318 c1: Point::new(start.x + start_dir * entry_handle, start.y),
3319 c2: Point::new(start_corner.x + entry_dir * entry_handle, lane_y),
3320 end: start_corner,
3321 };
3322 let seg2 = straight_segment(start_corner, end_corner);
3323 let seg3 = BezierSegment {
3324 start: end_corner,
3325 c1: Point::new(end_corner.x + lane_dir * exit_handle, lane_y),
3326 c2: Point::new(end.x - end_dir * exit_handle, end.y),
3327 end,
3328 };
3329 vec![seg1, seg2, seg3]
3330}
3331
3332fn build_path_data(path: &[BezierSegment]) -> Data {
3333 if path.is_empty() {
3334 return Data::new();
3335 }
3336
3337 let first = &path[0];
3338 let mut data = Data::new()
3339 .move_to((first.start.x, first.start.y))
3340 .cubic_curve_to((
3341 first.c1.x,
3342 first.c1.y,
3343 first.c2.x,
3344 first.c2.y,
3345 first.end.x,
3346 first.end.y,
3347 ));
3348 for segment in path.iter().skip(1) {
3349 data = data.cubic_curve_to((
3350 segment.c1.x,
3351 segment.c1.y,
3352 segment.c2.x,
3353 segment.c2.y,
3354 segment.end.x,
3355 segment.end.y,
3356 ));
3357 }
3358 data
3359}
3360
3361fn build_explicit_path_data(path: &[BezierSegment], reverse: bool) -> Data {
3362 if path.is_empty() {
3363 return Data::new();
3364 }
3365
3366 if !reverse {
3367 return build_path_data(path);
3368 }
3369
3370 let mut iter = path.iter().rev();
3371 let Some(first) = iter.next() else {
3372 return Data::new();
3373 };
3374 let mut data = Data::new()
3375 .move_to((first.end.x, first.end.y))
3376 .cubic_curve_to((
3377 first.c2.x,
3378 first.c2.y,
3379 first.c1.x,
3380 first.c1.y,
3381 first.start.x,
3382 first.start.y,
3383 ));
3384 for segment in iter {
3385 data = data.cubic_curve_to((
3386 segment.c2.x,
3387 segment.c2.y,
3388 segment.c1.x,
3389 segment.c1.y,
3390 segment.start.x,
3391 segment.start.y,
3392 ));
3393 }
3394 data
3395}
3396
3397fn place_edge_label(
3398 text: &str,
3399 font_size: usize,
3400 path: &[BezierSegment],
3401 blocked: &[(Point, Point)],
3402) -> Point {
3403 let (mid, dir) = path_label_anchor(path);
3404 let mut normal = Point::new(-dir.y, dir.x);
3405 if normal.x == 0.0 && normal.y == 0.0 {
3406 normal = Point::new(0.0, -1.0);
3407 }
3408 if normal.y > 0.0 {
3409 normal = Point::new(-normal.x, -normal.y);
3410 }
3411 place_label_with_normal(text, font_size, mid, normal, blocked)
3412}
3413
3414fn place_self_loop_label(
3415 text: &str,
3416 font_size: usize,
3417 path: &[BezierSegment],
3418 node_center: Point,
3419 blocked: &[(Point, Point)],
3420) -> Point {
3421 if path.is_empty() {
3422 return node_center;
3423 }
3424 let mut best = &path[0];
3425 let mut best_len = 0.0;
3426 for seg in path {
3427 let len = segment_length(seg);
3428 if len > best_len {
3429 best_len = len;
3430 best = seg;
3431 }
3432 }
3433 let mid = segment_point(best, 0.5);
3434 let mut normal = Point::new(mid.x - node_center.x, mid.y - node_center.y);
3435 let norm = (normal.x * normal.x + normal.y * normal.y).sqrt();
3436 if norm > 0.0 {
3437 normal = Point::new(normal.x / norm, normal.y / norm);
3438 } else {
3439 normal = Point::new(0.0, 1.0);
3440 }
3441 place_label_with_offset(text, font_size, mid, normal, 0.0, blocked)
3442}
3443
3444fn find_horizontal_lane_span(path: &[BezierSegment]) -> Option<(f64, f64, f64)> {
3445 let mut best: Option<(f64, f64)> = None;
3446 let mut best_dx = 0.0;
3447 let tol = 0.5;
3448
3449 for seg in path {
3450 let dy = (seg.end.y - seg.start.y).abs();
3451 if dy > tol {
3452 continue;
3453 }
3454 if (seg.c1.y - seg.start.y).abs() > tol || (seg.c2.y - seg.start.y).abs() > tol {
3455 continue;
3456 }
3457 let dx = (seg.end.x - seg.start.x).abs();
3458 if dx <= best_dx {
3459 continue;
3460 }
3461 best_dx = dx;
3462 best = Some((
3463 (seg.start.x + seg.end.x) / 2.0,
3464 (seg.start.y + seg.end.y) / 2.0,
3465 ));
3466 }
3467
3468 best.map(|(x, y)| (x, y, best_dx))
3469}
3470
3471fn place_detour_label(
3472 text: &str,
3473 font_size: usize,
3474 center_x: f64,
3475 lane_y: f64,
3476 above: bool,
3477 blocked: &[(Point, Point)],
3478) -> Point {
3479 let mid = Point::new(center_x, lane_y);
3480 let normal = if above {
3481 Point::new(0.0, -1.0)
3482 } else {
3483 Point::new(0.0, 1.0)
3484 };
3485 let extra = (font_size as f64 * 0.6).max(DETOUR_LABEL_CLEARANCE);
3486 place_label_with_offset(text, font_size, mid, normal, extra, blocked)
3487}
3488
3489fn place_label_with_normal(
3490 text: &str,
3491 font_size: usize,
3492 mid: Point,
3493 normal: Point,
3494 blocked: &[(Point, Point)],
3495) -> Point {
3496 place_label_with_offset(text, font_size, mid, normal, 0.0, blocked)
3497}
3498
3499fn place_label_with_offset(
3500 text: &str,
3501 font_size: usize,
3502 mid: Point,
3503 normal: Point,
3504 offset: f64,
3505 blocked: &[(Point, Point)],
3506) -> Point {
3507 let size = get_size_for_str(text, font_size);
3508 let mut normal = normal;
3509 if normal.x == 0.0 && normal.y == 0.0 {
3510 normal = Point::new(0.0, -1.0);
3511 }
3512 let base_offset = EDGE_LABEL_OFFSET + offset;
3513 let step = font_size as f64 + 6.0;
3514 let mut last = Point::new(
3515 mid.x + normal.x * base_offset,
3516 mid.y + normal.y * base_offset,
3517 );
3518 for attempt in 0..6 {
3519 let offset = base_offset + attempt as f64 * step;
3520 let pos = Point::new(mid.x + normal.x * offset, mid.y + normal.y * offset);
3521 let bbox = label_bbox(pos, size, 2.0);
3522 if !blocked.iter().any(|b| rects_overlap(*b, bbox)) {
3523 return pos;
3524 }
3525 last = pos;
3526 }
3527 last
3528}
3529
3530fn label_bbox(center: Point, size: Point, pad: f64) -> (Point, Point) {
3531 let half_w = size.x / 2.0 + pad;
3532 let half_h = size.y / 2.0 + pad;
3533 (
3534 Point::new(center.x - half_w, center.y - half_h),
3535 Point::new(center.x + half_w, center.y + half_h),
3536 )
3537}
3538
3539fn rects_overlap(a: (Point, Point), b: (Point, Point)) -> bool {
3540 a.1.x >= b.0.x && b.1.x >= a.0.x && a.1.y >= b.0.y && b.1.y >= a.0.y
3541}
3542
3543fn clamp_label_position(pos: Point, text: &str, font_size: usize, min: Point, max: Point) -> Point {
3544 let size = get_size_for_str(text, font_size);
3545 let half_w = size.x / 2.0 + 2.0;
3546 let half_h = size.y / 2.0 + 2.0;
3547 let min_x = min.x + half_w;
3548 let max_x = max.x - half_w;
3549 let min_y = min.y + half_h;
3550 let max_y = max.y - half_h;
3551
3552 Point::new(pos.x.clamp(min_x, max_x), pos.y.clamp(min_y, max_y))
3553}
3554
3555fn segment_length(seg: &BezierSegment) -> f64 {
3556 seg.start.distance_to(seg.c1) + seg.c1.distance_to(seg.c2) + seg.c2.distance_to(seg.end)
3557}
3558
3559fn segment_point(seg: &BezierSegment, t: f64) -> Point {
3560 let u = 1.0 - t;
3561 let tt = t * t;
3562 let uu = u * u;
3563 let uuu = uu * u;
3564 let ttt = tt * t;
3565
3566 let mut p = Point::new(0.0, 0.0);
3567 p.x = uuu * seg.start.x + 3.0 * uu * t * seg.c1.x + 3.0 * u * tt * seg.c2.x + ttt * seg.end.x;
3568 p.y = uuu * seg.start.y + 3.0 * uu * t * seg.c1.y + 3.0 * u * tt * seg.c2.y + ttt * seg.end.y;
3569 p
3570}
3571
3572fn detour_lane_bounds_from_points(start: Point, end: Point) -> (f64, f64) {
3573 let dx_total = (start.x - end.x).abs().max(40.0);
3574 let max_dx = (dx_total / 2.0 - 10.0).max(20.0);
3575 let curve_dx = (dx_total * 0.25).min(max_dx);
3576 let left = start.x.min(end.x) + curve_dx;
3577 let right = start.x.max(end.x) - curve_dx;
3578 (left, right)
3579}
3580
3581fn build_straight_label_slots(
3582 edge_points: &[(Point, Point)],
3583 edge_is_detour: &[bool],
3584 edge_is_self: &[bool],
3585) -> HashMap<usize, StraightLabelSlot> {
3586 type StraightGroupKey = (i64, i64); type StraightEdgeEntry = (usize, Point, Point); let mut groups: HashMap<StraightGroupKey, Vec<StraightEdgeEntry>> = HashMap::new();
3590 for (idx, (start, end)) in edge_points.iter().enumerate() {
3591 if edge_is_detour[idx] || edge_is_self[idx] {
3592 continue;
3593 }
3594 let key = (
3595 (start.x / 10.0).round() as i64,
3596 (start.y / 10.0).round() as i64,
3597 );
3598 groups.entry(key).or_default().push((idx, *start, *end));
3599 }
3600
3601 let mut slots = HashMap::new();
3602 for (_key, mut edges) in groups {
3603 edges.sort_by(|a, b| {
3604 a.1.y
3605 .partial_cmp(&b.1.y)
3606 .unwrap_or(Ordering::Equal)
3607 .then_with(|| a.2.y.partial_cmp(&b.2.y).unwrap_or(Ordering::Equal))
3608 });
3609
3610 let group_count = edges.len();
3611 for (slot_idx, (edge_idx, start, end)) in edges.into_iter().enumerate() {
3612 let center_x = (start.x + end.x) / 2.0;
3613 let center_y = (start.y + end.y) / 2.0;
3614 let span = (end.x - start.x).abs().max(1.0);
3615 let width = span * EDGE_LABEL_FIT_RATIO;
3616 let normal = if end.x >= start.x {
3617 Point::new(0.0, -1.0)
3618 } else {
3619 Point::new(0.0, 1.0)
3620 };
3621 slots.insert(
3622 edge_idx,
3623 StraightLabelSlot {
3624 center: Point::new(center_x, center_y),
3625 width,
3626 normal,
3627 stack_offset: slot_idx as f64 * (EDGE_FONT_SIZE as f64 + 4.0),
3628 group_count,
3629 },
3630 );
3631 }
3632 }
3633
3634 slots
3635}
3636
3637fn build_detour_label_slots(
3638 edge_points: &[(Point, Point)],
3639 edge_is_detour: &[bool],
3640 detour_above: &[bool],
3641 detour_lane_y: &[f64],
3642) -> HashMap<usize, DetourLabelSlot> {
3643 type DetourLaneKey = (i64, i64, bool); type DetourEdgeEntry = (usize, f64, f64, f64); let mut groups: HashMap<DetourLaneKey, Vec<DetourEdgeEntry>> = HashMap::new();
3647 for (idx, (start, end)) in edge_points.iter().enumerate() {
3648 if !edge_is_detour[idx] {
3649 continue;
3650 }
3651 let (left, right) = detour_lane_bounds_from_points(*start, *end);
3652 let key = (
3653 (left / 10.0).round() as i64,
3654 (right / 10.0).round() as i64,
3655 detour_above[idx],
3656 );
3657 groups
3658 .entry(key)
3659 .or_default()
3660 .push((idx, left, right, start.x));
3661 }
3662
3663 let mut slots = HashMap::new();
3664 for (_key, mut edges) in groups {
3665 edges.sort_by(|a, b| a.3.partial_cmp(&b.3).unwrap_or(Ordering::Equal));
3666 let mut left = f64::INFINITY;
3667 let mut right = f64::NEG_INFINITY;
3668 for (_, lane_left, lane_right, _) in &edges {
3669 left = left.min(*lane_left);
3670 right = right.max(*lane_right);
3671 }
3672 let width = (right - left).max(1.0);
3673 let count = edges.len();
3674 let slot_width = width / count as f64;
3675
3676 for (slot_idx, (edge_idx, _, _, _)) in edges.into_iter().enumerate() {
3677 let center_x = left + (slot_idx as f64 + 0.5) * slot_width;
3678 slots.insert(
3679 edge_idx,
3680 DetourLabelSlot {
3681 center_x,
3682 width: slot_width * 0.9,
3683 lane_y: detour_lane_y[edge_idx],
3684 above: detour_above[edge_idx],
3685 group_count: count,
3686 group_width: width,
3687 },
3688 );
3689 }
3690 }
3691
3692 slots
3693}
3694
3695fn fit_label_to_width(label: &str, max_width: f64, base_size: usize) -> (String, usize) {
3696 if max_width <= 0.0 {
3697 return (String::new(), base_size);
3698 }
3699 let mut candidate = shorten_module_path(label, max_width, base_size);
3700 let width = get_size_for_str(&candidate, base_size).x;
3701 if width <= max_width {
3702 return (candidate, base_size);
3703 }
3704
3705 let mut max_chars = (max_width / base_size as f64).floor() as usize;
3706 if max_chars == 0 {
3707 max_chars = 1;
3708 }
3709 candidate = truncate_label_left(&candidate, max_chars);
3710 (candidate, base_size)
3711}
3712
3713fn path_label_anchor(path: &[BezierSegment]) -> (Point, Point) {
3714 if path.is_empty() {
3715 return (Point::new(0.0, 0.0), Point::new(1.0, 0.0));
3716 }
3717
3718 let mut best_score = 0.0;
3719 let mut best_mid = path[0].start;
3720 let mut best_dir = Point::new(1.0, 0.0);
3721 for seg in path {
3722 let a = seg.start;
3723 let b = seg.end;
3724 let dx = b.x - a.x;
3725 let dy = b.y - a.y;
3726 let len = (dx * dx + dy * dy).sqrt();
3727 if len <= 0.0 {
3728 continue;
3729 }
3730 let horiz_bonus = if dx.abs() >= dy.abs() { 50.0 } else { 0.0 };
3731 let score = len + horiz_bonus;
3732 if score > best_score {
3733 best_score = score;
3734 let t = 0.5;
3735 best_mid = Point::new(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t);
3736 best_dir = Point::new(dx / len, dy / len);
3737 }
3738 }
3739
3740 (best_mid, best_dir)
3741}
3742
3743fn fit_edge_label(label: &str, path: &[BezierSegment], base_size: usize) -> (String, usize) {
3744 if label.is_empty() || path.is_empty() {
3745 return (label.to_string(), base_size);
3746 }
3747 let approx_len = approximate_path_length(path);
3748 let available = approx_len * EDGE_LABEL_FIT_RATIO;
3749 if available <= 0.0 {
3750 return (label.to_string(), base_size);
3751 }
3752
3753 fit_label_to_width(label, available, base_size)
3754}
3755
3756fn direction_unit(start: Point, end: Point) -> Point {
3757 let dx = end.x - start.x;
3758 let dy = end.y - start.y;
3759 let len = (dx * dx + dy * dy).sqrt();
3760 if len <= 0.0 {
3761 Point::new(1.0, 0.0)
3762 } else {
3763 Point::new(dx / len, dy / len)
3764 }
3765}
3766
3767fn approximate_path_length(path: &[BezierSegment]) -> f64 {
3768 let mut length = 0.0;
3769 for seg in path {
3770 length += seg.start.distance_to(seg.c1);
3771 length += seg.c1.distance_to(seg.c2);
3772 length += seg.c2.distance_to(seg.end);
3773 }
3774 length
3775}
3776
3777fn split_type_tokens(label: &str) -> Vec<String> {
3778 let mut tokens = Vec::new();
3779 let mut buf = String::new();
3780 let chars: Vec<char> = label.chars().collect();
3781 let mut idx = 0;
3782 while idx < chars.len() {
3783 let ch = chars[idx];
3784 if ch == ':' && idx + 1 < chars.len() && chars[idx + 1] == ':' {
3785 if !buf.is_empty() {
3786 tokens.push(buf.clone());
3787 buf.clear();
3788 }
3789 tokens.push("::".to_string());
3790 idx += 2;
3791 continue;
3792 }
3793
3794 if ch == '<' || ch == '>' || ch == ',' {
3795 if !buf.is_empty() {
3796 tokens.push(buf.clone());
3797 buf.clear();
3798 }
3799 tokens.push(ch.to_string());
3800 idx += 1;
3801 continue;
3802 }
3803
3804 if ch.is_whitespace() {
3805 if !buf.is_empty() {
3806 tokens.push(buf.clone());
3807 buf.clear();
3808 }
3809 idx += 1;
3810 continue;
3811 }
3812
3813 buf.push(ch);
3814 idx += 1;
3815 }
3816
3817 if !buf.is_empty() {
3818 tokens.push(buf);
3819 }
3820
3821 tokens
3822}
3823
3824fn shorten_module_path(label: &str, max_width: f64, font_size: usize) -> String {
3825 let segments: Vec<&str> = label.split("::").collect();
3826 if segments.len() <= 1 {
3827 return label.to_string();
3828 }
3829
3830 for keep in (1..=segments.len()).rev() {
3831 let slice = &segments[segments.len() - keep..];
3832 let mut candidate = slice.join(MODULE_SEPARATOR);
3833 if keep < segments.len() {
3834 candidate = format!("{MODULE_TRUNC_MARKER}{MODULE_SEPARATOR}{candidate}");
3835 }
3836 if get_size_for_str(&candidate, font_size).x <= max_width {
3837 return candidate;
3838 }
3839 }
3840
3841 format!(
3842 "{MODULE_TRUNC_MARKER}{MODULE_SEPARATOR}{}",
3843 segments.last().unwrap_or(&label)
3844 )
3845}
3846
3847fn truncate_label_left(label: &str, max_chars: usize) -> String {
3848 if max_chars == 0 {
3849 return String::new();
3850 }
3851 let count = label.chars().count();
3852 if count <= max_chars {
3853 return label.to_string();
3854 }
3855 let keep = max_chars.saturating_sub(1);
3856 let tail: String = label
3857 .chars()
3858 .rev()
3859 .take(keep)
3860 .collect::<Vec<_>>()
3861 .into_iter()
3862 .rev()
3863 .collect();
3864 format!("{MODULE_TRUNC_MARKER}{tail}")
3865}
3866
3867fn strip_type_params(label: &str) -> String {
3868 let mut depth = 0usize;
3869 let mut out = String::new();
3870 for ch in label.chars() {
3871 match ch {
3872 '<' => {
3873 depth += 1;
3874 }
3875 '>' => {
3876 depth = depth.saturating_sub(1);
3877 }
3878 _ => {
3879 if depth == 0 {
3880 out.push(ch);
3881 }
3882 }
3883 }
3884 }
3885 out
3886}
3887
3888fn provider_resource_slots(provider: &str) -> Option<&'static [&'static str]> {
3889 let provider = strip_type_params(provider);
3890 let compact: String = provider.chars().filter(|ch| !ch.is_whitespace()).collect();
3891 let segments: Vec<&str> = compact
3892 .split("::")
3893 .filter(|segment| !segment.is_empty())
3894 .collect();
3895 let is_linux_bundle = segments.last().copied() == Some("LinuxResources")
3896 && segments.contains(&"cu_linux_resources");
3897 if is_linux_bundle {
3898 Some(&LINUX_RESOURCE_SLOT_NAMES)
3899 } else {
3900 None
3901 }
3902}
3903
3904fn scale_layout_positions(graph: &mut VisualGraph) {
3905 for handle in graph.iter_nodes() {
3906 let center = graph.element(handle).position().center();
3907 let scaled = Point::new(center.x * LAYOUT_SCALE_X, center.y * LAYOUT_SCALE_Y);
3908 graph.element_mut(handle).position_mut().move_to(scaled);
3909 }
3910}
3911
3912fn build_edge_hover_overlay(
3913 path: &[BezierSegment],
3914 tooltip: &str,
3915 stroke_color: &str,
3916 line_width: usize,
3917) -> (Group, Point, Point) {
3918 let hitbox_width = line_width.max(EDGE_HITBOX_STROKE_WIDTH);
3919 let mut hover_group = Group::new().set("class", "edge-hover");
3920 let mut hitbox_el = SvgPath::new()
3921 .set("d", build_path_data(path))
3922 .set("stroke", stroke_color)
3923 .set("stroke-opacity", EDGE_HITBOX_OPACITY)
3924 .set("stroke-width", hitbox_width)
3925 .set("fill", "none")
3926 .set("pointer-events", "stroke")
3927 .set("cursor", "help");
3928 hitbox_el.append(Title::new(tooltip));
3929 hover_group.append(hitbox_el);
3930
3931 let anchor = tooltip_anchor_for_path(path);
3932 let hover_point = Circle::new()
3933 .set("class", "edge-hover-point")
3934 .set("cx", anchor.x)
3935 .set("cy", anchor.y)
3936 .set("r", EDGE_HOVER_POINT_RADIUS)
3937 .set("fill", BACKGROUND_COLOR)
3938 .set("stroke", stroke_color)
3939 .set("stroke-width", EDGE_HOVER_POINT_STROKE_WIDTH);
3940 hover_group.append(hover_point);
3941
3942 let (tooltip_group, tooltip_top_left, tooltip_size) = build_edge_tooltip_group(path, tooltip);
3943 hover_group.append(tooltip_group);
3944
3945 (hover_group, tooltip_top_left, tooltip_size)
3946}
3947
3948fn build_edge_tooltip_group(path: &[BezierSegment], tooltip: &str) -> (Group, Point, Point) {
3949 let lines: Vec<&str> = if tooltip.is_empty() {
3950 vec![""]
3951 } else {
3952 tooltip.lines().collect()
3953 };
3954 let line_height = tooltip_line_height();
3955 let mut max_width: f64 = 0.0;
3956 for line in &lines {
3957 let size = get_size_for_str(line, TOOLTIP_FONT_SIZE);
3958 max_width = max_width.max(size.x);
3959 }
3960 let content_height = line_height * (lines.len() as f64);
3961 let box_width = max_width + TOOLTIP_PADDING * 2.0;
3962 let box_height = content_height + TOOLTIP_PADDING * 2.0;
3963 let anchor = tooltip_anchor_for_path(path);
3964 let top_left = Point::new(
3965 anchor.x + TOOLTIP_OFFSET_X,
3966 anchor.y - TOOLTIP_OFFSET_Y - box_height,
3967 );
3968
3969 let mut group = Group::new()
3970 .set("class", "edge-tooltip")
3971 .set("pointer-events", "none");
3972 let rect = Rectangle::new()
3973 .set("x", top_left.x)
3974 .set("y", top_left.y)
3975 .set("width", box_width)
3976 .set("height", box_height)
3977 .set("rx", TOOLTIP_RADIUS)
3978 .set("ry", TOOLTIP_RADIUS)
3979 .set("fill", TOOLTIP_BG)
3980 .set("stroke", TOOLTIP_BORDER)
3981 .set("stroke-width", TOOLTIP_BORDER_WIDTH);
3982 group.append(rect);
3983
3984 let text_x = top_left.x + TOOLTIP_PADDING;
3985 let mut text_y = top_left.y + TOOLTIP_PADDING;
3986 for line in lines {
3987 let text = Text::new(line)
3988 .set("x", text_x)
3989 .set("y", text_y)
3990 .set("dominant-baseline", "hanging")
3991 .set("font-family", MONO_FONT_FAMILY)
3992 .set("font-size", format!("{TOOLTIP_FONT_SIZE}px"))
3993 .set("fill", TOOLTIP_TEXT);
3994 group.append(text);
3995 text_y += line_height;
3996 }
3997
3998 (group, top_left, Point::new(box_width, box_height))
3999}
4000
4001fn tooltip_line_height() -> f64 {
4002 TOOLTIP_FONT_SIZE as f64 + TOOLTIP_LINE_GAP
4003}
4004
4005fn tooltip_anchor_for_path(path: &[BezierSegment]) -> Point {
4006 let mut min = Point::new(f64::INFINITY, f64::INFINITY);
4007 let mut max = Point::new(f64::NEG_INFINITY, f64::NEG_INFINITY);
4008 for segment in path {
4009 for point in [segment.start, segment.c1, segment.c2, segment.end] {
4010 min.x = min.x.min(point.x);
4011 min.y = min.y.min(point.y);
4012 max.x = max.x.max(point.x);
4013 max.y = max.y.max(point.y);
4014 }
4015 }
4016 if !min.x.is_finite() || !min.y.is_finite() {
4017 return Point::new(0.0, 0.0);
4018 }
4019 Point::new((min.x + max.x) / 2.0, (min.y + max.y) / 2.0)
4020}
4021
4022fn format_edge_tooltip(stats: &EdgeLogStats) -> String {
4023 [
4024 format!("Message: {}", stats.msg),
4025 format!(
4026 "Message size (avg): {}",
4027 format_bytes_opt(stats.avg_raw_bytes)
4028 ),
4029 format!(
4030 "Rate: {}",
4031 format_rate_bytes_per_sec(stats.throughput_bytes_per_sec)
4032 ),
4033 format!(
4034 "None: {}",
4035 format_none_ratio(stats.none_samples, stats.samples)
4036 ),
4037 format!("Message rate: {}", format_rate_hz(stats.rate_hz)),
4038 format!(
4039 "Total bytes: {}",
4040 format_bytes(stats.total_raw_bytes as f64)
4041 ),
4042 format!(
4043 "Time samples: {}/{}",
4044 stats.valid_time_samples, stats.samples
4045 ),
4046 ]
4047 .join("\n")
4048}
4049
4050fn format_none_ratio(none_samples: u64, samples: u64) -> String {
4051 if samples == 0 {
4052 return "n/a".to_string();
4053 }
4054 let ratio = (none_samples as f64) / (samples as f64) * 100.0;
4055 format!("{ratio:.1}% ({none_samples}/{samples})")
4056}
4057
4058fn format_rate_hz(rate: Option<f64>) -> String {
4059 rate.map_or_else(|| "n/a".to_string(), |value| format!("{value:.2} Hz"))
4060}
4061
4062fn format_rate_bytes_per_sec(value: Option<f64>) -> String {
4063 value.map_or_else(|| "n/a".to_string(), format_rate_units)
4064}
4065
4066fn format_rate_units(bytes: f64) -> String {
4067 const UNITS: [&str; 4] = ["B/s", "KB/s", "MB/s", "GB/s"];
4068 let mut value = bytes;
4069 let mut unit_idx = 0;
4070 while value >= 1000.0 && unit_idx < UNITS.len() - 1 {
4071 value /= 1000.0;
4072 unit_idx += 1;
4073 }
4074 let formatted = if unit_idx == 0 {
4075 format!("{value:.0}")
4076 } else if value < 10.0 {
4077 format!("{value:.2}")
4078 } else if value < 100.0 {
4079 format!("{value:.1}")
4080 } else {
4081 format!("{value:.0}")
4082 };
4083 format!("{formatted} {}", UNITS[unit_idx])
4084}
4085
4086fn format_bytes_opt(value: Option<f64>) -> String {
4087 value.map_or_else(|| "n/a".to_string(), format_bytes)
4088}
4089
4090fn format_bytes(bytes: f64) -> String {
4091 const UNITS: [&str; 4] = ["B", "KiB", "MiB", "GiB"];
4092 let mut value = bytes;
4093 let mut unit_idx = 0;
4094 while value >= 1024.0 && unit_idx < UNITS.len() - 1 {
4095 value /= 1024.0;
4096 unit_idx += 1;
4097 }
4098 if unit_idx == 0 {
4099 format!("{value:.0} {}", UNITS[unit_idx])
4100 } else {
4101 format!("{value:.2} {}", UNITS[unit_idx])
4102 }
4103}
4104
4105fn format_duration_ns_f64(value: Option<f64>) -> String {
4106 value.map_or_else(|| "n/a".to_string(), format_duration_ns)
4107}
4108
4109fn format_duration_ns_u64(value: Option<u64>) -> String {
4110 value.map_or_else(
4111 || "n/a".to_string(),
4112 |nanos| format_duration_ns(nanos as f64),
4113 )
4114}
4115
4116fn format_duration_ns(nanos: f64) -> String {
4117 if nanos >= 1_000_000_000.0 {
4118 format!("{:.3} s", nanos / 1_000_000_000.0)
4119 } else if nanos >= 1_000_000.0 {
4120 format!("{:.3} ms", nanos / 1_000_000.0)
4121 } else if nanos >= 1_000.0 {
4122 format!("{:.3} us", nanos / 1_000.0)
4123 } else {
4124 format!("{nanos:.0} ns")
4125 }
4126}
4127
4128fn mission_key(mission: Option<&str>) -> &str {
4129 match mission {
4130 Some(value) if value != "default" => value,
4131 _ => "default",
4132 }
4133}
4134
4135fn build_graph_signature(config: &config::CuConfig, mission: Option<&str>) -> CuResult<String> {
4136 let graph = config.get_graph(mission)?;
4137 let mut parts = Vec::new();
4138 parts.push(format!("mission={}", mission.unwrap_or("default")));
4139
4140 let mut nodes: Vec<_> = graph.get_all_nodes();
4141 nodes.sort_by(|a, b| a.1.get_id().cmp(&b.1.get_id()));
4142 for (_, node) in nodes {
4143 parts.push(format!(
4144 "node|{}|{}|{}",
4145 node.get_id(),
4146 node.get_type(),
4147 flavor_label(node.get_flavor())
4148 ));
4149 }
4150
4151 let mut edges: Vec<String> = graph
4152 .edges()
4153 .map(|cnx| {
4154 format!(
4155 "edge|{}|{}|{}",
4156 format_endpoint(cnx.src.as_str(), cnx.src_channel.as_deref()),
4157 format_endpoint(cnx.dst.as_str(), cnx.dst_channel.as_deref()),
4158 cnx.msg
4159 )
4160 })
4161 .collect();
4162 edges.sort();
4163 parts.extend(edges);
4164
4165 let joined = parts.join("\n");
4166 Ok(format!("fnv1a64:{:016x}", fnv1a64(joined.as_bytes())))
4167}
4168
4169fn format_endpoint(node: &str, channel: Option<&str>) -> String {
4170 match channel {
4171 Some(ch) => format!("{node}/{ch}"),
4172 None => node.to_string(),
4173 }
4174}
4175
4176fn flavor_label(flavor: config::Flavor) -> &'static str {
4177 match flavor {
4178 config::Flavor::Task => "task",
4179 config::Flavor::Bridge => "bridge",
4180 }
4181}
4182
4183fn fnv1a64(data: &[u8]) -> u64 {
4184 const OFFSET_BASIS: u64 = 0xcbf29ce484222325;
4185 const PRIME: u64 = 0x100000001b3;
4186 let mut hash = OFFSET_BASIS;
4187 for byte in data {
4188 hash ^= u64::from(*byte);
4189 hash = hash.wrapping_mul(PRIME);
4190 }
4191 hash
4192}
4193
4194#[cfg(test)]
4195mod tests {
4196 use super::*;
4197
4198 #[test]
4199 fn tooltip_formats_missing_values() {
4200 let stats = EdgeLogStats {
4201 src: "a".to_string(),
4202 src_channel: None,
4203 dst: "b".to_string(),
4204 dst_channel: None,
4205 msg: "Msg".to_string(),
4206 samples: 0,
4207 none_samples: 0,
4208 valid_time_samples: 0,
4209 total_raw_bytes: 0,
4210 avg_raw_bytes: None,
4211 rate_hz: None,
4212 throughput_bytes_per_sec: None,
4213 };
4214 let tooltip = format_edge_tooltip(&stats);
4215 assert!(tooltip.contains("Message size (avg): n/a"));
4216 assert!(tooltip.contains("Rate: n/a"));
4217 assert!(tooltip.contains("None: n/a"));
4218 }
4219
4220 #[test]
4221 fn provider_slot_matching_handles_type_params() {
4222 assert_eq!(
4223 provider_resource_slots("cu_linux_resources::LinuxResources"),
4224 Some(&LINUX_RESOURCE_SLOT_NAMES[..])
4225 );
4226 assert_eq!(
4227 provider_resource_slots(" crate::x::cu_linux_resources::LinuxResources < Foo<Bar> > "),
4228 Some(&LINUX_RESOURCE_SLOT_NAMES[..])
4229 );
4230 assert!(provider_resource_slots("board::MicoAirH743").is_none());
4231 }
4232
4233 #[test]
4234 fn linux_bundle_catalog_includes_known_slots_without_bindings() {
4235 let config = config::CuConfig {
4236 monitors: Vec::new(),
4237 logging: None,
4238 runtime: None,
4239 resources: vec![config::ResourceBundleConfig {
4240 id: "linux".to_string(),
4241 provider: "cu_linux_resources::LinuxResources".to_string(),
4242 config: None,
4243 missions: None,
4244 }],
4245 bridges: Vec::new(),
4246 graphs: ConfigGraphs::Simple(config::CuGraph::default()),
4247 };
4248
4249 let catalog = collect_resource_catalog(&config).expect("catalog should build");
4250 let linux_slots = catalog
4251 .get("linux")
4252 .expect("linux bundle should expose slot catalog");
4253 assert_eq!(linux_slots.len(), LINUX_RESOURCE_SLOT_NAMES.len());
4254 for slot in LINUX_RESOURCE_SLOT_NAMES {
4255 assert!(linux_slots.contains(slot), "missing slot {slot}");
4256 }
4257 }
4258}