Skip to main content

cu29_rendercfg/
rendercfg.rs

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
32// Typography and text formatting.
33const 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
49// Color palette and fills.
50const 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
73// Layout spacing and sizing.
74const 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
87// Edge routing and label placement.
88const 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    /// Config file name
170    #[clap(value_parser)]
171    config: PathBuf,
172    /// Log statistics JSON file to enrich the DAG
173    #[clap(long)]
174    logstats: Option<PathBuf>,
175    /// Mission id to render (omit to render every mission)
176    #[clap(long)]
177    mission: Option<String>,
178    /// List missions contained in the configuration and exit
179    #[clap(long, action)]
180    list_missions: bool,
181    /// Open the SVG in the default system viewer
182    #[clap(long)]
183    open: bool,
184}
185
186/// Render the configuration file to an SVG and optionally opens it with inkscape.
187/// CLI entrypoint that parses args, renders SVG, and optionally opens it.
188fn main() -> std::io::Result<()> {
189    // Parse command line arguments
190    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        // Create a temporary file to store the SVG
229        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        // Write the SVG content to a file
239        let mut svg_file = std::fs::File::create("output.svg")?;
240        svg_file.write_all(graph_svg.as_slice())?;
241    }
242    Ok(())
243}
244
245/// Hide platform-specific open commands behind a single helper.
246fn 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
264/// Run the full render pipeline and return SVG bytes for the CLI.
265fn 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            &section,
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
345/// Normalize mission selection into a list of sections to render.
346fn 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
383/// Convert a config graph into positioned nodes, edges, and port anchors.
384fn 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
892/// Build the record table for a node and capture port ids for routing.
893fn 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
947/// Keep input/output rows aligned and generate stable port identifiers.
948fn 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
995/// Render config entries in a stable order for readability and diffs.
996fn 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
1031/// Adapt our table tree into the layout-rs record format.
1032fn 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
1041/// Estimate record size before layout so edges and clusters can be sized.
1042fn 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
1066/// Walk table cells to compute positions and collect port anchors.
1067fn 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
1268/// Render each section and merge them into a single SVG canvas.
1269fn 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(&section.nodes, &section.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) = &section.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) = &section.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 &section.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
1632/// Draw table cells manually since the layout engine only positions shapes.
1633fn 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
1690/// Visually group mission sections with a labeled bounding box.
1691fn 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
1709/// Render a legend cartridge for task colors and the copper-rs credit line.
1710fn 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
1971/// Fail fast on invalid mission ids and provide a readable list.
1972fn 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
1996/// Support a CLI mode that prints mission names and exits.
1997fn 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
2010/// Keep mission lists stable for consistent error messages.
2011fn 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); // (start_x_bucket, start_y_bucket)
3587    type StraightEdgeEntry = (usize, Point, Point); // (edge_idx, start, end)
3588
3589    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); // (left_bucket, right_bucket, above)
3644    type DetourEdgeEntry = (usize, f64, f64, f64); // (edge_idx, lane_left, lane_right, start_x)
3645
3646    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}