Skip to main content

cu29_rendercfg/
rendercfg.rs

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