Skip to main content

cu29_rendercfg/
rendercfg.rs

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