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