cu29_rendercfg/
rendercfg.rs

1mod config;
2use clap::Parser;
3use config::{ConfigGraphs, PortLookup, build_render_topology, read_configuration};
4pub use cu29_traits::*;
5use hashbrown::HashMap;
6use hashbrown::hash_map::Entry;
7use layout::adt::dag::NodeHandle;
8use layout::core::base::Orientation;
9use layout::core::color::Color;
10use layout::core::format::{RenderBackend, Visible};
11use layout::core::geometry::{Point, get_size_for_str, pad_shape_scalar};
12use layout::core::style::{LineStyleKind, StyleAttr};
13use layout::std_shapes::shapes::{Arrow, Element, LineEndKind, RecordDef, ShapeKind};
14use layout::topo::layout::VisualGraph;
15use std::cmp::Ordering;
16use std::io::Write;
17use std::path::PathBuf;
18use std::process::Command;
19use svg::Document;
20use svg::node::Node;
21use svg::node::Text as TextNode;
22use svg::node::element::path::Data;
23use svg::node::element::{
24    Circle, Definitions, Element as SvgElement, Group, Image, Line, Marker, Path as SvgPath,
25    Polygon, Rectangle, Text, TextPath,
26};
27use tempfile::Builder;
28
29// Typography and text formatting.
30const FONT_FAMILY: &str = "'Noto Sans', sans-serif";
31const MONO_FONT_FAMILY: &str = "'Noto Sans Mono'";
32const FONT_SIZE: usize = 12;
33const TYPE_FONT_SIZE: usize = FONT_SIZE * 7 / 10;
34const PORT_HEADER_FONT_SIZE: usize = FONT_SIZE * 4 / 6;
35const PORT_VALUE_FONT_SIZE: usize = FONT_SIZE * 4 / 6;
36const CONFIG_FONT_SIZE: usize = PORT_VALUE_FONT_SIZE - 1;
37const EDGE_FONT_SIZE: usize = 7;
38const TYPE_WRAP_WIDTH: usize = 24;
39const CONFIG_WRAP_WIDTH: usize = 32;
40const MODULE_TRUNC_MARKER: &str = "…";
41const MODULE_SEPARATOR: &str = "⠶";
42const PLACEHOLDER_TEXT: &str = "\u{2014}";
43const COPPER_LOGO_SVG: &str = include_str!("../assets/cu29.svg");
44
45// Color palette and fills.
46const BORDER_COLOR: &str = "#999999";
47const HEADER_BG: &str = "#f4f4f4";
48const DIM_GRAY: &str = "dimgray";
49const LIGHT_GRAY: &str = "lightgray";
50const CLUSTER_COLOR: &str = "#bbbbbb";
51const BRIDGE_HEADER_BG: &str = "#f7d7e4";
52const SOURCE_HEADER_BG: &str = "#ddefc7";
53const SINK_HEADER_BG: &str = "#cce0ff";
54const TASK_HEADER_BG: &str = "#fde7c2";
55const COPPER_LINK_COLOR: &str = "#0000E0";
56const EDGE_COLOR_PALETTE: [&str; 10] = [
57    "#1F77B4", "#FF7F0E", "#2CA02C", "#D62728", "#9467BD", "#8C564B", "#E377C2", "#7F7F7F",
58    "#BCBD22", "#17BECF",
59];
60const EDGE_COLOR_ORDER: [usize; 10] = [0, 2, 1, 9, 7, 8, 3, 5, 6, 4];
61
62// Layout spacing and sizing.
63const GRAPH_MARGIN: f64 = 20.0;
64const CLUSTER_MARGIN: f64 = 20.0;
65const SECTION_SPACING: f64 = 60.0;
66const BOX_SHAPE_PADDING: f64 = 10.0;
67const CELL_PADDING: f64 = 6.0;
68const CELL_LINE_SPACING: f64 = 2.0;
69const VALUE_BORDER_WIDTH: f64 = 0.6;
70const OUTER_BORDER_WIDTH: f64 = 1.3;
71const LAYOUT_SCALE_X: f64 = 1.8;
72const LAYOUT_SCALE_Y: f64 = 1.2;
73
74// Edge routing and label placement.
75const EDGE_LABEL_FIT_RATIO: f64 = 0.8;
76const EDGE_LABEL_OFFSET: f64 = 8.0;
77const EDGE_LABEL_LIGHTEN: f64 = 0.35;
78const EDGE_LABEL_HALO_WIDTH: f64 = 3.0;
79const DETOUR_LABEL_CLEARANCE: f64 = 6.0;
80const BACK_EDGE_STACK_SPACING: f64 = 16.0;
81const BACK_EDGE_NODE_GAP: f64 = 12.0;
82const BACK_EDGE_DUP_SPACING: f64 = 6.0;
83const BACK_EDGE_SPAN_EPS: f64 = 4.0;
84const INTERMEDIATE_X_EPS: f64 = 6.0;
85const EDGE_STUB_LEN: f64 = 32.0;
86const EDGE_STUB_MIN: f64 = 18.0;
87const EDGE_PORT_HANDLE: f64 = 12.0;
88const PORT_DOT_RADIUS: f64 = 2.6;
89const PORT_LINE_GAP: f64 = 2.8;
90const LEGEND_TITLE_SIZE: usize = 11;
91const LEGEND_FONT_SIZE: usize = 10;
92const LEGEND_SWATCH_SIZE: f64 = 10.0;
93const LEGEND_PADDING: f64 = 8.0;
94const LEGEND_CORNER_RADIUS: f64 = 6.0;
95const LEGEND_ROW_GAP: f64 = 6.0;
96const LEGEND_LINK_GAP: f64 = 3.0;
97const LEGEND_WITH_LOGO_GAP: f64 = 4.0;
98const LEGEND_VERSION_GAP: f64 = 0.0;
99const LEGEND_SECTION_GAP: f64 = 8.0;
100const LEGEND_BOTTOM_PADDING: f64 = 6.0;
101const LEGEND_LOGO_SIZE: f64 = 16.0;
102const LEGEND_TEXT_WIDTH_FACTOR: f64 = 0.52;
103const COPPER_GITHUB_URL: &str = "https://github.com/copper-project/copper-rs";
104const LEGEND_ITEMS: [(&str, &str); 4] = [
105    ("Source", SOURCE_HEADER_BG),
106    ("Task", TASK_HEADER_BG),
107    ("Sink", SINK_HEADER_BG),
108    ("Bridge", BRIDGE_HEADER_BG),
109];
110
111#[derive(Parser)]
112#[clap(author, version, about, long_about = None)]
113struct Args {
114    /// Config file name
115    #[clap(value_parser)]
116    config: PathBuf,
117    /// Mission id to render (omit to render every mission)
118    #[clap(long)]
119    mission: Option<String>,
120    /// List missions contained in the configuration and exit
121    #[clap(long, action)]
122    list_missions: bool,
123    /// Open the SVG in the default system viewer
124    #[clap(long)]
125    open: bool,
126}
127
128/// Render the configuration file to an SVG and optionally opens it with inkscape.
129/// CLI entrypoint that parses args, renders SVG, and optionally opens it.
130fn main() -> std::io::Result<()> {
131    // Parse command line arguments
132    let args = Args::parse();
133
134    let config = read_configuration(args.config.to_str().unwrap())
135        .expect("Failed to read configuration file");
136
137    if args.list_missions {
138        print_mission_list(&config);
139        return Ok(());
140    }
141
142    let mission = match validate_mission_arg(&config, args.mission.as_deref()) {
143        Ok(mission) => mission,
144        Err(err) => {
145            eprintln!("{err}");
146            std::process::exit(1);
147        }
148    };
149
150    let graph_svg = match render_config_svg(&config, mission.as_deref()) {
151        Ok(svg) => svg,
152        Err(err) => {
153            eprintln!("{err}");
154            std::process::exit(1);
155        }
156    };
157
158    if args.open {
159        // Create a temporary file to store the SVG
160        let mut temp_file = Builder::new().suffix(".svg").tempfile()?;
161        temp_file.write_all(graph_svg.as_slice())?;
162        let temp_path = temp_file
163            .into_temp_path()
164            .keep()
165            .map_err(std::io::Error::other)?;
166
167        open_svg(&temp_path)?;
168    } else {
169        // Write the SVG content to a file
170        let mut svg_file = std::fs::File::create("output.svg")?;
171        svg_file.write_all(graph_svg.as_slice())?;
172    }
173    Ok(())
174}
175
176/// Hide platform-specific open commands behind a single helper.
177fn open_svg(path: &std::path::Path) -> std::io::Result<()> {
178    if cfg!(target_os = "windows") {
179        Command::new("cmd")
180            .args(["/C", "start", ""])
181            .arg(path)
182            .status()?;
183        return Ok(());
184    }
185
186    let program = if cfg!(target_os = "macos") {
187        "open"
188    } else {
189        "xdg-open"
190    };
191    Command::new(program).arg(path).status()?;
192    Ok(())
193}
194
195/// Run the full render pipeline and return SVG bytes for the CLI.
196fn render_config_svg(config: &config::CuConfig, mission_id: Option<&str>) -> CuResult<Vec<u8>> {
197    let sections = build_sections(config, mission_id)?;
198    let mut layouts = Vec::new();
199    for section in sections {
200        layouts.push(build_section_layout(config, &section)?);
201    }
202
203    Ok(render_sections_to_svg(&layouts).into_bytes())
204}
205
206/// Normalize mission selection into a list of sections to render.
207fn build_sections<'a>(
208    config: &'a config::CuConfig,
209    mission_id: Option<&str>,
210) -> CuResult<Vec<SectionRef<'a>>> {
211    let sections = match (&config.graphs, mission_id) {
212        (ConfigGraphs::Simple(graph), _) => vec![SectionRef {
213            label: Some("Default".to_string()),
214            graph,
215        }],
216        (ConfigGraphs::Missions(graphs), Some(id)) => {
217            let graph = graphs
218                .get(id)
219                .ok_or_else(|| CuError::from(format!("Mission {id} not found")))?;
220            vec![SectionRef {
221                label: Some(id.to_string()),
222                graph,
223            }]
224        }
225        (ConfigGraphs::Missions(graphs), None) => {
226            let mut missions: Vec<_> = graphs.iter().collect();
227            missions.sort_by(|a, b| a.0.cmp(b.0));
228            missions
229                .into_iter()
230                .map(|(label, graph)| SectionRef {
231                    label: Some(label.clone()),
232                    graph,
233                })
234                .collect()
235        }
236    };
237
238    Ok(sections)
239}
240
241/// Convert a config graph into positioned nodes, edges, and port anchors.
242fn build_section_layout(
243    config: &config::CuConfig,
244    section: &SectionRef<'_>,
245) -> CuResult<SectionLayout> {
246    let mut topology = build_render_topology(section.graph, &config.bridges);
247    topology.sort_connections();
248
249    let graph_orientation = Orientation::LeftToRight;
250    let node_orientation = graph_orientation.flip();
251    let mut graph = VisualGraph::new(graph_orientation);
252    let mut node_handles = HashMap::new();
253    let mut port_lookups = HashMap::new();
254    let mut nodes = Vec::new();
255
256    for node in &topology.nodes {
257        let node_idx = section
258            .graph
259            .get_node_id_by_name(node.id.as_str())
260            .ok_or_else(|| CuError::from(format!("Node '{}' missing from graph", node.id)))?;
261        let node_weight = section
262            .graph
263            .get_node(node_idx)
264            .ok_or_else(|| CuError::from(format!("Node '{}' missing weight", node.id)))?;
265
266        let is_src = section
267            .graph
268            .get_dst_edges(node_idx)
269            .unwrap_or_default()
270            .is_empty();
271        let is_sink = section
272            .graph
273            .get_src_edges(node_idx)
274            .unwrap_or_default()
275            .is_empty();
276
277        let header_fill = match node.flavor {
278            config::Flavor::Bridge => BRIDGE_HEADER_BG,
279            config::Flavor::Task if is_src => SOURCE_HEADER_BG,
280            config::Flavor::Task if is_sink => SINK_HEADER_BG,
281            _ => TASK_HEADER_BG,
282        };
283
284        let (table, port_lookup) = build_node_table(node, node_weight, header_fill);
285        let record = table_to_record(&table);
286        let shape = ShapeKind::Record(record);
287        let look = StyleAttr::new(
288            Color::fast(BORDER_COLOR),
289            1,
290            Some(Color::fast("white")),
291            0,
292            FONT_SIZE,
293        );
294        let size = record_size(&table, node_orientation);
295        let element = Element::create(shape, look, node_orientation, size);
296        let handle = graph.add_node(element);
297
298        node_handles.insert(node.id.clone(), handle);
299        port_lookups.insert(node.id.clone(), port_lookup);
300        nodes.push(NodeRender { handle, table });
301    }
302
303    let mut edges = Vec::new();
304    let mut edge_groups: HashMap<EdgeGroupKey, usize> = HashMap::new();
305    let mut next_color_slot = 0usize;
306    let edge_look = StyleAttr::new(Color::fast("black"), 1, None, 0, EDGE_FONT_SIZE);
307    for cnx in &topology.connections {
308        let src_handle = node_handles
309            .get(&cnx.src)
310            .ok_or_else(|| CuError::from(format!("Unknown node '{}'", cnx.src)))?;
311        let dst_handle = node_handles
312            .get(&cnx.dst)
313            .ok_or_else(|| CuError::from(format!("Unknown node '{}'", cnx.dst)))?;
314        let src_port = port_lookups
315            .get(&cnx.src)
316            .and_then(|lookup| lookup.resolve_output(cnx.src_port.as_deref()))
317            .map(|port| port.to_string());
318        let dst_port = port_lookups
319            .get(&cnx.dst)
320            .and_then(|lookup| lookup.resolve_input(cnx.dst_port.as_deref()))
321            .map(|port| port.to_string());
322
323        let arrow = Arrow::new(
324            LineEndKind::None,
325            LineEndKind::Arrow,
326            LineStyleKind::Normal,
327            "",
328            &edge_look,
329            &src_port,
330            &dst_port,
331        );
332        graph.add_edge(arrow.clone(), *src_handle, *dst_handle);
333        let group_key = EdgeGroupKey {
334            src: *src_handle,
335            src_port: src_port.clone(),
336            msg: cnx.msg.clone(),
337        };
338        let (color_idx, show_label) = match edge_groups.entry(group_key) {
339            Entry::Occupied(entry) => (*entry.get(), false),
340            Entry::Vacant(entry) => {
341                let color_idx = edge_cycle_color_index(&mut next_color_slot);
342                entry.insert(color_idx);
343                (color_idx, true)
344            }
345        };
346        edges.push(RenderEdge {
347            src: *src_handle,
348            dst: *dst_handle,
349            arrow,
350            label: if show_label {
351                cnx.msg.clone()
352            } else {
353                String::new()
354            },
355            color_idx,
356            src_port,
357            dst_port,
358        });
359    }
360
361    let mut null_backend = NullBackend;
362    graph.do_it(false, false, false, &mut null_backend);
363    scale_layout_positions(&mut graph);
364
365    let mut min = Point::new(f64::INFINITY, f64::INFINITY);
366    let mut max = Point::new(f64::NEG_INFINITY, f64::NEG_INFINITY);
367    for node in &nodes {
368        let pos = graph.element(node.handle).position();
369        let (top_left, bottom_right) = pos.bbox(false);
370        min.x = min.x.min(top_left.x);
371        min.y = min.y.min(top_left.y);
372        max.x = max.x.max(bottom_right.x);
373        max.y = max.y.max(bottom_right.y);
374    }
375    if !min.x.is_finite() || !min.y.is_finite() {
376        min = Point::new(0.0, 0.0);
377        max = Point::new(0.0, 0.0);
378    }
379
380    let mut port_anchors = HashMap::new();
381    for node in &nodes {
382        let element = graph.element(node.handle);
383        let anchors = collect_port_anchors(node, element);
384        port_anchors.insert(node.handle, anchors);
385    }
386
387    Ok(SectionLayout {
388        label: section.label.clone(),
389        graph,
390        nodes,
391        edges,
392        bounds: (min, max),
393        port_anchors,
394    })
395}
396
397/// Build the record table for a node and capture port ids for routing.
398fn build_node_table(
399    node: &config::RenderNode,
400    node_weight: &config::Node,
401    header_fill: &str,
402) -> (TableNode, PortLookup) {
403    let mut rows = Vec::new();
404
405    let header_lines = vec![
406        CellLine::new(node.id.clone(), "black", true, FONT_SIZE),
407        CellLine::code(
408            wrap_type_label(&strip_type_params(&node.type_name), TYPE_WRAP_WIDTH),
409            DIM_GRAY,
410            false,
411            TYPE_FONT_SIZE,
412        ),
413    ];
414    rows.push(TableNode::Cell(
415        TableCell::new(header_lines)
416            .with_background(header_fill)
417            .with_align(TextAlign::Center),
418    ));
419
420    let mut port_lookup = PortLookup::default();
421    let max_ports = node.inputs.len().max(node.outputs.len());
422    let inputs = build_port_column(
423        "Inputs",
424        &node.inputs,
425        "in",
426        &mut port_lookup.inputs,
427        &mut port_lookup.default_input,
428        max_ports,
429        TextAlign::Left,
430    );
431    let outputs = build_port_column(
432        "Outputs",
433        &node.outputs,
434        "out",
435        &mut port_lookup.outputs,
436        &mut port_lookup.default_output,
437        max_ports,
438        TextAlign::Right,
439    );
440    rows.push(TableNode::Array(vec![inputs, outputs]));
441
442    if let Some(config) = node_weight.get_instance_config() {
443        let config_rows = build_config_rows(config);
444        if !config_rows.is_empty() {
445            rows.extend(config_rows);
446        }
447    }
448
449    (TableNode::Array(rows), port_lookup)
450}
451
452/// Keep input/output rows aligned and generate stable port identifiers.
453fn build_port_column(
454    title: &str,
455    names: &[String],
456    prefix: &str,
457    lookup: &mut HashMap<String, String>,
458    default_port: &mut Option<String>,
459    target_len: usize,
460    align: TextAlign,
461) -> TableNode {
462    let mut rows = Vec::new();
463    rows.push(TableNode::Cell(
464        TableCell::single_line_sized(title, "black", false, PORT_HEADER_FONT_SIZE)
465            .with_background(HEADER_BG)
466            .with_align(align),
467    ));
468
469    let desired_rows = target_len.max(1);
470    for idx in 0..desired_rows {
471        if let Some(name) = names.get(idx) {
472            let port_id = format!("{prefix}_{idx}");
473            lookup.insert(name.clone(), port_id.clone());
474            if default_port.is_none() {
475                *default_port = Some(port_id.clone());
476            }
477            rows.push(TableNode::Cell(
478                TableCell::single_line_sized(name, "black", false, PORT_VALUE_FONT_SIZE)
479                    .with_port(port_id)
480                    .with_border_width(VALUE_BORDER_WIDTH)
481                    .with_align(align),
482            ));
483        } else {
484            rows.push(TableNode::Cell(
485                TableCell::single_line_sized(
486                    PLACEHOLDER_TEXT,
487                    LIGHT_GRAY,
488                    false,
489                    PORT_VALUE_FONT_SIZE,
490                )
491                .with_border_width(VALUE_BORDER_WIDTH)
492                .with_align(align),
493            ));
494        }
495    }
496
497    TableNode::Array(rows)
498}
499
500/// Render config entries in a stable order for readability and diffs.
501fn build_config_rows(config: &config::ComponentConfig) -> Vec<TableNode> {
502    if config.0.is_empty() {
503        return Vec::new();
504    }
505
506    let mut entries: Vec<_> = config.0.iter().collect();
507    entries.sort_by(|a, b| a.0.cmp(b.0));
508
509    let header = TableNode::Cell(
510        TableCell::single_line_sized("Config", "black", false, PORT_HEADER_FONT_SIZE)
511            .with_background(HEADER_BG),
512    );
513
514    let mut key_lines = Vec::new();
515    let mut value_lines = Vec::new();
516    for (key, value) in entries {
517        let value_str = wrap_text(&format!("{value}"), CONFIG_WRAP_WIDTH);
518        let value_parts: Vec<_> = value_str.split('\n').collect();
519        for (idx, part) in value_parts.iter().enumerate() {
520            let key_text = if idx == 0 { key.as_str() } else { "" };
521            key_lines.push(CellLine::code(key_text, DIM_GRAY, true, CONFIG_FONT_SIZE));
522            value_lines.push(CellLine::code(*part, DIM_GRAY, false, CONFIG_FONT_SIZE));
523        }
524    }
525
526    let keys_cell = TableCell::new(key_lines).with_border_width(VALUE_BORDER_WIDTH);
527    let values_cell = TableCell::new(value_lines).with_border_width(VALUE_BORDER_WIDTH);
528    let body = TableNode::Array(vec![
529        TableNode::Cell(keys_cell),
530        TableNode::Cell(values_cell),
531    ]);
532
533    vec![header, body]
534}
535
536/// Adapt our table tree into the layout-rs record format.
537fn table_to_record(node: &TableNode) -> RecordDef {
538    match node {
539        TableNode::Cell(cell) => RecordDef::Text(cell.label(), cell.port.clone()),
540        TableNode::Array(children) => {
541            RecordDef::Array(children.iter().map(table_to_record).collect())
542        }
543    }
544}
545
546/// Estimate record size before layout so edges and clusters can be sized.
547fn record_size(node: &TableNode, dir: Orientation) -> Point {
548    match node {
549        TableNode::Cell(cell) => pad_shape_scalar(cell_text_size(cell), BOX_SHAPE_PADDING),
550        TableNode::Array(children) => {
551            if children.is_empty() {
552                return Point::new(1.0, 1.0);
553            }
554            let mut x: f64 = 0.0;
555            let mut y: f64 = 0.0;
556            for child in children {
557                let sz = record_size(child, dir.flip());
558                if dir.is_left_right() {
559                    x += sz.x;
560                    y = y.max(sz.y);
561                } else {
562                    x = x.max(sz.x);
563                    y += sz.y;
564                }
565            }
566            Point::new(x, y)
567        }
568    }
569}
570
571/// Walk table cells to compute positions and collect port anchors.
572fn visit_table(
573    node: &TableNode,
574    dir: Orientation,
575    loc: Point,
576    size: Point,
577    visitor: &mut dyn TableVisitor,
578) {
579    match node {
580        TableNode::Cell(cell) => {
581            visitor.handle_cell(cell, loc, size);
582        }
583        TableNode::Array(children) => {
584            if children.is_empty() {
585                return;
586            }
587
588            let mut sizes = Vec::new();
589            let mut sum = Point::new(0.0, 0.0);
590
591            for child in children {
592                let child_size = record_size(child, dir.flip());
593                sizes.push(child_size);
594                if dir.is_left_right() {
595                    sum.x += child_size.x;
596                    sum.y = sum.y.max(child_size.y);
597                } else {
598                    sum.x = sum.x.max(child_size.x);
599                    sum.y += child_size.y;
600                }
601            }
602
603            for child_size in &mut sizes {
604                if dir.is_left_right() {
605                    if sum.x > 0.0 {
606                        *child_size = Point::new(size.x * child_size.x / sum.x, size.y);
607                    } else {
608                        *child_size = Point::new(1.0, size.y);
609                    }
610                } else if sum.y > 0.0 {
611                    *child_size = Point::new(size.x, size.y * child_size.y / sum.y);
612                } else {
613                    *child_size = Point::new(size.x, 1.0);
614                }
615            }
616
617            if dir.is_left_right() {
618                let mut start_x = loc.x - size.x / 2.0;
619                for (idx, child) in children.iter().enumerate() {
620                    let child_loc = Point::new(start_x + sizes[idx].x / 2.0, loc.y);
621                    visit_table(child, dir.flip(), child_loc, sizes[idx], visitor);
622                    start_x += sizes[idx].x;
623                }
624            } else {
625                let mut start_y = loc.y - size.y / 2.0;
626                for (idx, child) in children.iter().enumerate() {
627                    let child_loc = Point::new(loc.x, start_y + sizes[idx].y / 2.0);
628                    visit_table(child, dir.flip(), child_loc, sizes[idx], visitor);
629                    start_y += sizes[idx].y;
630                }
631            }
632        }
633    }
634}
635
636/// Render each section and merge them into a single SVG canvas.
637fn render_sections_to_svg(sections: &[SectionLayout]) -> String {
638    let mut svg = SvgWriter::new();
639    let mut cursor_y = GRAPH_MARGIN;
640    let mut last_section_bottom = GRAPH_MARGIN;
641    let mut last_section_right = GRAPH_MARGIN;
642
643    for section in sections {
644        let cluster_margin = if section.label.is_some() {
645            CLUSTER_MARGIN
646        } else {
647            0.0
648        };
649        let (min, max) = section.bounds;
650        let label_padding = if section.label.is_some() {
651            FONT_SIZE as f64
652        } else {
653            0.0
654        };
655        let node_bounds = collect_node_bounds(section);
656        let mut expanded_bounds = (min, max);
657        let mut edge_paths: Vec<Vec<BezierSegment>> = Vec::with_capacity(section.edges.len());
658        let mut edge_points: Vec<(Point, Point)> = Vec::with_capacity(section.edges.len());
659        let mut edge_is_self: Vec<bool> = Vec::with_capacity(section.edges.len());
660        let mut edge_is_detour: Vec<bool> = Vec::with_capacity(section.edges.len());
661        let mut detour_above = vec![false; section.edges.len()];
662        let mut detour_base_y = vec![0.0; section.edges.len()];
663        let mut back_plans_above: Vec<BackEdgePlan> = Vec::new();
664        let mut back_plans_below: Vec<BackEdgePlan> = Vec::new();
665
666        for (idx, edge) in section.edges.iter().enumerate() {
667            let src_point = resolve_anchor(section, edge.src, edge.src_port.as_ref());
668            let dst_point = resolve_anchor(section, edge.dst, edge.dst_port.as_ref());
669            let span_min_x = src_point.x.min(dst_point.x);
670            let span_max_x = src_point.x.max(dst_point.x);
671            let is_self = edge.src == edge.dst;
672            let has_intermediate = !is_self
673                && span_has_intermediate(&node_bounds, span_min_x, span_max_x, edge.src, edge.dst);
674            let is_reverse = src_point.x > dst_point.x;
675            let is_detour = !is_self && (is_reverse || has_intermediate);
676            edge_points.push((src_point, dst_point));
677            edge_is_self.push(is_self);
678            edge_is_detour.push(is_detour);
679
680            if is_detour {
681                let span = (src_point.x - dst_point.x).abs();
682                let above = !is_reverse;
683                let base_y = if above {
684                    min_top_for_span(&node_bounds, span_min_x, span_max_x) - BACK_EDGE_NODE_GAP
685                } else {
686                    max_bottom_for_span(&node_bounds, span_min_x, span_max_x) + BACK_EDGE_NODE_GAP
687                };
688                detour_above[idx] = above;
689                detour_base_y[idx] = base_y;
690                let plan = BackEdgePlan { idx, span };
691                if above {
692                    back_plans_above.push(plan);
693                } else {
694                    back_plans_below.push(plan);
695                }
696            }
697        }
698
699        let mut back_offsets = vec![0.0; section.edges.len()];
700        assign_back_edge_offsets(&back_plans_below, &mut back_offsets);
701        assign_back_edge_offsets(&back_plans_above, &mut back_offsets);
702        let mut detour_lane_y = vec![0.0; section.edges.len()];
703        for idx in 0..section.edges.len() {
704            if edge_is_detour[idx] {
705                detour_lane_y[idx] = if detour_above[idx] {
706                    detour_base_y[idx] - back_offsets[idx]
707                } else {
708                    detour_base_y[idx] + back_offsets[idx]
709                };
710            }
711        }
712        let detour_slots =
713            build_detour_label_slots(&edge_points, &edge_is_detour, &detour_above, &detour_lane_y);
714
715        for (idx, edge) in section.edges.iter().enumerate() {
716            let (src_point, dst_point) = edge_points[idx];
717            let (fallback_start_dir, fallback_end_dir) = fallback_port_dirs(src_point, dst_point);
718            let start_dir = port_dir(edge.src_port.as_ref()).unwrap_or(fallback_start_dir);
719            let end_dir = port_dir_incoming(edge.dst_port.as_ref()).unwrap_or(fallback_end_dir);
720            let path = if edge.src == edge.dst {
721                let pos = section.graph.element(edge.src).position();
722                let bbox = pos.bbox(false);
723                build_loop_path(src_point, dst_point, bbox, start_dir, end_dir)
724            } else if edge_is_detour[idx] {
725                build_back_edge_path(src_point, dst_point, detour_lane_y[idx], start_dir, end_dir)
726            } else {
727                build_edge_path(src_point, dst_point, start_dir, end_dir)
728            };
729
730            for segment in &path {
731                expand_bounds(&mut expanded_bounds, segment.start);
732                expand_bounds(&mut expanded_bounds, segment.c1);
733                expand_bounds(&mut expanded_bounds, segment.c2);
734                expand_bounds(&mut expanded_bounds, segment.end);
735            }
736
737            edge_paths.push(path);
738        }
739
740        let section_min = Point::new(
741            expanded_bounds.0.x - cluster_margin,
742            expanded_bounds.0.y - cluster_margin,
743        );
744        let section_max = Point::new(
745            expanded_bounds.1.x + cluster_margin,
746            expanded_bounds.1.y + cluster_margin + label_padding,
747        );
748        let offset = Point::new(GRAPH_MARGIN - section_min.x, cursor_y - section_min.y);
749        let content_offset = offset.add(Point::new(0.0, label_padding));
750        let cluster_top_left = section_min.add(offset);
751        let cluster_bottom_right = section_max.add(offset);
752        last_section_bottom = last_section_bottom.max(cluster_bottom_right.y);
753        last_section_right = last_section_right.max(cluster_bottom_right.x);
754        let label_bounds_min = Point::new(
755            cluster_top_left.x + 4.0,
756            cluster_top_left.y + label_padding + 4.0,
757        );
758        let label_bounds_max =
759            Point::new(cluster_bottom_right.x - 4.0, cluster_bottom_right.y - 4.0);
760
761        if let Some(label) = &section.label {
762            draw_cluster(&mut svg, section_min, section_max, label, offset);
763        }
764
765        let mut blocked_boxes: Vec<(Point, Point)> = node_bounds
766            .iter()
767            .map(|b| {
768                (
769                    Point::new(b.left, b.top)
770                        .add(content_offset)
771                        .sub(Point::new(4.0, 4.0)),
772                    Point::new(b.right, b.bottom)
773                        .add(content_offset)
774                        .add(Point::new(4.0, 4.0)),
775                )
776            })
777            .collect();
778        if let Some(label) = &section.label {
779            let label_text = format!("Mission: {label}");
780            let label_size = get_size_for_str(&label_text, FONT_SIZE);
781            let label_pos = Point::new(
782                section_min.x + offset.x + CELL_PADDING,
783                section_min.y + offset.y + FONT_SIZE as f64,
784            );
785            blocked_boxes.push((
786                Point::new(label_pos.x, label_pos.y - label_size.y / 2.0).sub(Point::new(2.0, 2.0)),
787                Point::new(label_pos.x + label_size.x, label_pos.y + label_size.y / 2.0)
788                    .add(Point::new(2.0, 2.0)),
789            ));
790        }
791
792        let straight_slots =
793            build_straight_label_slots(&edge_points, &edge_is_detour, &edge_is_self);
794
795        for ((idx, edge), path) in section.edges.iter().enumerate().zip(edge_paths.iter()) {
796            let path = path
797                .iter()
798                .map(|seg| BezierSegment {
799                    start: seg.start.add(content_offset),
800                    c1: seg.c1.add(content_offset),
801                    c2: seg.c2.add(content_offset),
802                    end: seg.end.add(content_offset),
803                })
804                .collect::<Vec<_>>();
805            let dashed = matches!(
806                edge.arrow.line_style,
807                LineStyleKind::Dashed | LineStyleKind::Dotted
808            );
809            let start = matches!(edge.arrow.start, LineEndKind::Arrow);
810            let end = matches!(edge.arrow.end, LineEndKind::Arrow);
811            let line_color = EDGE_COLOR_PALETTE[edge.color_idx];
812            let label = if edge.label.is_empty() {
813                None
814            } else {
815                let (text, font_size) = if edge_is_self[idx] {
816                    fit_edge_label(&edge.label, &path, EDGE_FONT_SIZE)
817                } else if let Some(slot) = straight_slots.get(&idx) {
818                    let mut max_width = slot.width;
819                    if slot.group_count <= 1 {
820                        let path_width = approximate_path_length(&path);
821                        max_width = max_width.max(path_width);
822                    }
823                    fit_label_to_width(&edge.label, max_width, EDGE_FONT_SIZE)
824                } else if let Some(slot) = detour_slots.get(&idx) {
825                    let mut max_width = slot.width;
826                    if slot.group_count <= 1 {
827                        if let Some((_, _, lane_len)) = find_horizontal_lane_span(&path) {
828                            max_width = max_width.max(lane_len);
829                        } else if slot.group_width > 0.0 {
830                            max_width = max_width.max(slot.group_width * 0.9);
831                        }
832                    }
833                    fit_label_to_width(&edge.label, max_width, EDGE_FONT_SIZE)
834                } else if edge_is_detour[idx] {
835                    let (lane_left, lane_right) =
836                        detour_lane_bounds_from_points(edge_points[idx].0, edge_points[idx].1);
837                    fit_label_to_width(
838                        &edge.label,
839                        (lane_right - lane_left).max(1.0),
840                        EDGE_FONT_SIZE,
841                    )
842                } else {
843                    fit_edge_label(&edge.label, &path, EDGE_FONT_SIZE)
844                };
845                let label_color = lighten_hex(line_color, EDGE_LABEL_LIGHTEN);
846                let mut label =
847                    ArrowLabel::new(text, &label_color, font_size, true, FontFamily::Mono);
848                let label_pos = if edge_is_self[idx] {
849                    let node_center = section
850                        .graph
851                        .element(edge.src)
852                        .position()
853                        .center()
854                        .add(content_offset);
855                    if let Some((center_x, lane_y, _)) = find_horizontal_lane_span(&path) {
856                        let above = lane_y < node_center.y;
857                        place_detour_label(
858                            &label.text,
859                            label.font_size,
860                            center_x,
861                            lane_y,
862                            above,
863                            &blocked_boxes,
864                        )
865                    } else {
866                        place_self_loop_label(
867                            &label.text,
868                            label.font_size,
869                            &path,
870                            node_center,
871                            &blocked_boxes,
872                        )
873                    }
874                } else if edge_is_detour[idx] {
875                    let mut label_pos = None;
876                    if let Some(slot) = detour_slots.get(&idx) {
877                        label_pos = Some(place_detour_label(
878                            &label.text,
879                            label.font_size,
880                            slot.center_x + content_offset.x,
881                            slot.lane_y + content_offset.y,
882                            slot.above,
883                            &blocked_boxes,
884                        ));
885                    }
886                    if label_pos.is_none() {
887                        label_pos = Some(place_detour_label(
888                            &label.text,
889                            label.font_size,
890                            (edge_points[idx].0.x + edge_points[idx].1.x) / 2.0 + content_offset.x,
891                            detour_lane_y[idx] + content_offset.y,
892                            detour_above[idx],
893                            &blocked_boxes,
894                        ));
895                    }
896                    if let Some(pos) = label_pos {
897                        pos
898                    } else {
899                        let dir = direction_unit(edge_points[idx].0, edge_points[idx].1);
900                        place_label_with_offset(
901                            &label.text,
902                            label.font_size,
903                            edge_points[idx].0.add(content_offset),
904                            dir,
905                            EDGE_LABEL_OFFSET,
906                            &blocked_boxes,
907                        )
908                    }
909                } else if let Some(slot) = straight_slots.get(&idx) {
910                    let mut normal = slot.normal;
911                    if normal.y > 0.0 {
912                        normal = Point::new(-normal.x, -normal.y);
913                    }
914                    place_label_with_offset(
915                        &label.text,
916                        label.font_size,
917                        slot.center.add(content_offset),
918                        normal,
919                        slot.stack_offset,
920                        &blocked_boxes,
921                    )
922                } else {
923                    place_edge_label(&label.text, label.font_size, &path, &blocked_boxes)
924                };
925                let clamped = clamp_label_position(
926                    label_pos,
927                    &label.text,
928                    label.font_size,
929                    label_bounds_min,
930                    label_bounds_max,
931                );
932                label = label.with_position(clamped);
933                Some(label)
934            };
935
936            let edge_look = colored_edge_style(&edge.arrow.look, line_color);
937            svg.draw_arrow(&path, dashed, (start, end), &edge_look, label.as_ref());
938        }
939
940        for node in &section.nodes {
941            let element = section.graph.element(node.handle);
942            draw_node_table(&mut svg, node, element, content_offset);
943        }
944
945        cursor_y += (section_max.y - section_min.y) + SECTION_SPACING;
946    }
947
948    let legend_top = last_section_bottom + GRAPH_MARGIN;
949    let _legend_height = draw_legend(&mut svg, legend_top, last_section_right);
950
951    svg.finalize()
952}
953
954/// Draw table cells manually since the layout engine only positions shapes.
955fn draw_node_table(svg: &mut SvgWriter, node: &NodeRender, element: &Element, offset: Point) {
956    let pos = element.position();
957    let center = pos.center().add(offset);
958    let size = pos.size(false);
959    let top_left = Point::new(center.x - size.x / 2.0, center.y - size.y / 2.0);
960
961    svg.draw_rect(top_left, size, None, 0.0, Some("white"), 0.0);
962
963    let mut renderer = TableRenderer {
964        svg,
965        node_left_x: top_left.x,
966        node_right_x: top_left.x + size.x,
967    };
968    visit_table(
969        &node.table,
970        element.orientation,
971        center,
972        size,
973        &mut renderer,
974    );
975    svg.draw_rect(
976        top_left,
977        size,
978        Some(BORDER_COLOR),
979        OUTER_BORDER_WIDTH,
980        None,
981        0.0,
982    );
983}
984
985/// Visually group mission sections with a labeled bounding box.
986fn draw_cluster(svg: &mut SvgWriter, min: Point, max: Point, label: &str, offset: Point) {
987    let top_left = min.add(offset);
988    let size = max.sub(min);
989    svg.draw_rect(top_left, size, Some(CLUSTER_COLOR), 1.0, None, 10.0);
990
991    let label_text = format!("Mission: {label}");
992    let label_pos = Point::new(top_left.x + CELL_PADDING, top_left.y + FONT_SIZE as f64);
993    svg.draw_text(
994        label_pos,
995        &label_text,
996        FONT_SIZE,
997        DIM_GRAY,
998        true,
999        "start",
1000        FontFamily::Sans,
1001    );
1002}
1003
1004/// Render a legend cartridge for task colors and the copper-rs credit line.
1005fn draw_legend(svg: &mut SvgWriter, top_y: f64, content_right: f64) -> f64 {
1006    let metrics = measure_legend();
1007    let legend_x = (content_right - metrics.width).max(GRAPH_MARGIN);
1008    let top_left = Point::new(legend_x, top_y);
1009
1010    svg.draw_rect(
1011        top_left,
1012        Point::new(metrics.width, metrics.height),
1013        Some(BORDER_COLOR),
1014        0.6,
1015        Some("white"),
1016        LEGEND_CORNER_RADIUS,
1017    );
1018
1019    let title_pos = Point::new(
1020        top_left.x + LEGEND_PADDING,
1021        top_left.y + LEGEND_PADDING + LEGEND_TITLE_SIZE as f64 / 2.0,
1022    );
1023    svg.draw_text(
1024        title_pos,
1025        "Legend",
1026        LEGEND_TITLE_SIZE,
1027        DIM_GRAY,
1028        true,
1029        "start",
1030        FontFamily::Sans,
1031    );
1032
1033    let mut cursor_y = top_left.y + LEGEND_PADDING + LEGEND_TITLE_SIZE as f64 + LEGEND_ROW_GAP;
1034    let item_height = LEGEND_SWATCH_SIZE.max(LEGEND_FONT_SIZE as f64);
1035    for (label, color) in LEGEND_ITEMS {
1036        let center_y = cursor_y + item_height / 2.0;
1037        let swatch_top = center_y - LEGEND_SWATCH_SIZE / 2.0;
1038        let swatch_left = top_left.x + LEGEND_PADDING;
1039        svg.draw_rect(
1040            Point::new(swatch_left, swatch_top),
1041            Point::new(LEGEND_SWATCH_SIZE, LEGEND_SWATCH_SIZE),
1042            Some(BORDER_COLOR),
1043            0.6,
1044            Some(color),
1045            2.0,
1046        );
1047        let text_x = swatch_left + LEGEND_SWATCH_SIZE + 4.0;
1048        svg.draw_text(
1049            Point::new(text_x, center_y),
1050            label,
1051            LEGEND_FONT_SIZE,
1052            "black",
1053            false,
1054            "start",
1055            FontFamily::Sans,
1056        );
1057        cursor_y += item_height + LEGEND_ROW_GAP;
1058    }
1059
1060    cursor_y += LEGEND_SECTION_GAP;
1061    let divider_y = cursor_y - LEGEND_ROW_GAP / 2.0;
1062    svg.draw_line(
1063        Point::new(top_left.x + LEGEND_PADDING, divider_y),
1064        Point::new(top_left.x + metrics.width - LEGEND_PADDING, divider_y),
1065        "#e0e0e0",
1066        0.5,
1067    );
1068
1069    let credit_height = draw_created_with(
1070        svg,
1071        Point::new(top_left.x + LEGEND_PADDING, cursor_y),
1072        top_left.x + metrics.width - LEGEND_PADDING,
1073    );
1074    cursor_y += credit_height;
1075
1076    cursor_y - top_left.y + LEGEND_BOTTOM_PADDING
1077}
1078
1079fn draw_created_with(svg: &mut SvgWriter, top_left: Point, right_edge: f64) -> f64 {
1080    let left_text = "Created with";
1081    let link_text = "Copper-rs";
1082    let version_text = format!("v{}", env!("CARGO_PKG_VERSION"));
1083    let left_width = legend_text_width(left_text, LEGEND_FONT_SIZE);
1084    let link_width = legend_text_width(link_text, LEGEND_FONT_SIZE);
1085    let version_width = legend_text_width(version_text.as_str(), LEGEND_FONT_SIZE);
1086    let height = LEGEND_LOGO_SIZE.max(LEGEND_FONT_SIZE as f64);
1087    let center_y = top_left.y + height / 2.0;
1088    let version_text_x = right_edge;
1089    let link_text_x = version_text_x - version_width - LEGEND_VERSION_GAP;
1090    let link_start_x = link_text_x - link_width;
1091    let logo_left = link_start_x - LEGEND_LINK_GAP - LEGEND_LOGO_SIZE;
1092    let logo_top = center_y - LEGEND_LOGO_SIZE / 2.0;
1093    let left_text_anchor = logo_left - LEGEND_WITH_LOGO_GAP;
1094
1095    svg.draw_text(
1096        Point::new(left_text_anchor, center_y),
1097        left_text,
1098        LEGEND_FONT_SIZE,
1099        DIM_GRAY,
1100        false,
1101        "end",
1102        FontFamily::Sans,
1103    );
1104
1105    let logo_uri = svg_data_uri(COPPER_LOGO_SVG);
1106    let image = Image::new()
1107        .set("x", logo_left)
1108        .set("y", logo_top)
1109        .set("width", LEGEND_LOGO_SIZE)
1110        .set("height", LEGEND_LOGO_SIZE)
1111        .set("href", logo_uri.clone())
1112        .set("xlink:href", logo_uri);
1113    let mut text_node = build_text_node(
1114        Point::new(link_text_x, center_y),
1115        link_text,
1116        LEGEND_FONT_SIZE,
1117        COPPER_LINK_COLOR,
1118        false,
1119        "end",
1120        FontFamily::Sans,
1121    );
1122    text_node.assign("text-decoration", "underline");
1123    text_node.assign("text-underline-offset", "1");
1124    text_node.assign("text-decoration-thickness", "0.6");
1125
1126    let mut link = SvgElement::new("a");
1127    link.assign("href", COPPER_GITHUB_URL);
1128    link.assign("target", "_blank");
1129    link.assign("rel", "noopener noreferrer");
1130    link.append(image);
1131    link.append(text_node);
1132    svg.append_node(link);
1133
1134    svg.draw_text(
1135        Point::new(version_text_x, center_y),
1136        version_text.as_str(),
1137        LEGEND_FONT_SIZE,
1138        DIM_GRAY,
1139        false,
1140        "end",
1141        FontFamily::Sans,
1142    );
1143
1144    let left_text_start = left_text_anchor - left_width;
1145    let total_width = right_edge - left_text_start;
1146    svg.grow_window(
1147        Point::new(left_text_start, top_left.y),
1148        Point::new(total_width, height),
1149    );
1150
1151    height
1152}
1153
1154struct LegendMetrics {
1155    width: f64,
1156    height: f64,
1157}
1158
1159fn measure_legend() -> LegendMetrics {
1160    let title_width = get_size_for_str("Legend", LEGEND_TITLE_SIZE).x;
1161    let mut max_line_width = title_width;
1162
1163    for (label, _) in LEGEND_ITEMS {
1164        let label_width = get_size_for_str(label, LEGEND_FONT_SIZE).x;
1165        let line_width = LEGEND_SWATCH_SIZE + 4.0 + label_width;
1166        max_line_width = max_line_width.max(line_width);
1167    }
1168
1169    let credit_left = "Created with";
1170    let credit_link = "Copper-rs";
1171    let credit_version = format!("v{}", env!("CARGO_PKG_VERSION"));
1172    let credit_width = legend_text_width(credit_left, LEGEND_FONT_SIZE)
1173        + LEGEND_WITH_LOGO_GAP
1174        + LEGEND_LOGO_SIZE
1175        + LEGEND_LINK_GAP
1176        + legend_text_width(credit_link, LEGEND_FONT_SIZE)
1177        + LEGEND_VERSION_GAP
1178        + legend_text_width(credit_version.as_str(), LEGEND_FONT_SIZE);
1179    max_line_width = max_line_width.max(credit_width);
1180
1181    let item_height = LEGEND_SWATCH_SIZE.max(LEGEND_FONT_SIZE as f64);
1182    let items_count = LEGEND_ITEMS.len() as f64;
1183    let items_height = if items_count > 0.0 {
1184        items_count * item_height + (items_count - 1.0) * LEGEND_ROW_GAP
1185    } else {
1186        0.0
1187    };
1188    let credit_height = LEGEND_LOGO_SIZE.max(LEGEND_FONT_SIZE as f64);
1189    let height = LEGEND_PADDING
1190        + LEGEND_BOTTOM_PADDING
1191        + LEGEND_TITLE_SIZE as f64
1192        + LEGEND_ROW_GAP
1193        + items_height
1194        + LEGEND_ROW_GAP
1195        + LEGEND_SECTION_GAP
1196        + credit_height;
1197
1198    LegendMetrics {
1199        width: LEGEND_PADDING * 2.0 + max_line_width,
1200        height,
1201    }
1202}
1203
1204/// Fail fast on invalid mission ids and provide a readable list.
1205fn validate_mission_arg(
1206    config: &config::CuConfig,
1207    requested: Option<&str>,
1208) -> CuResult<Option<String>> {
1209    match (&config.graphs, requested) {
1210        (ConfigGraphs::Simple(_), None) => Ok(None),
1211        (ConfigGraphs::Simple(_), Some("default")) => Ok(None),
1212        (ConfigGraphs::Simple(_), Some(id)) => Err(CuError::from(format!(
1213            "Config is not mission-based; remove --mission (received '{id}')"
1214        ))),
1215        (ConfigGraphs::Missions(graphs), Some(id)) => {
1216            if graphs.contains_key(id) {
1217                Ok(Some(id.to_string()))
1218            } else {
1219                Err(CuError::from(format!(
1220                    "Mission '{id}' not found. Available missions: {}",
1221                    format_mission_list(graphs)
1222                )))
1223            }
1224        }
1225        (ConfigGraphs::Missions(_), None) => Ok(None),
1226    }
1227}
1228
1229/// Support a CLI mode that prints mission names and exits.
1230fn print_mission_list(config: &config::CuConfig) {
1231    match &config.graphs {
1232        ConfigGraphs::Simple(_) => println!("default"),
1233        ConfigGraphs::Missions(graphs) => {
1234            let mut missions: Vec<_> = graphs.keys().cloned().collect();
1235            missions.sort();
1236            for mission in missions {
1237                println!("{mission}");
1238            }
1239        }
1240    }
1241}
1242
1243/// Keep mission lists stable for consistent error messages.
1244fn format_mission_list(graphs: &HashMap<String, config::CuGraph>) -> String {
1245    let mut missions: Vec<_> = graphs.keys().cloned().collect();
1246    missions.sort();
1247    missions.join(", ")
1248}
1249
1250struct SectionRef<'a> {
1251    label: Option<String>,
1252    graph: &'a config::CuGraph,
1253}
1254
1255struct SectionLayout {
1256    label: Option<String>,
1257    graph: VisualGraph,
1258    nodes: Vec<NodeRender>,
1259    edges: Vec<RenderEdge>,
1260    bounds: (Point, Point),
1261    port_anchors: HashMap<NodeHandle, HashMap<String, Point>>,
1262}
1263
1264struct NodeRender {
1265    handle: NodeHandle,
1266    table: TableNode,
1267}
1268
1269struct RenderEdge {
1270    src: NodeHandle,
1271    dst: NodeHandle,
1272    arrow: Arrow,
1273    label: String,
1274    color_idx: usize,
1275    src_port: Option<String>,
1276    dst_port: Option<String>,
1277}
1278
1279#[derive(Clone, Hash, PartialEq, Eq)]
1280struct EdgeGroupKey {
1281    src: NodeHandle,
1282    src_port: Option<String>,
1283    msg: String,
1284}
1285
1286#[derive(Clone)]
1287enum TableNode {
1288    Cell(TableCell),
1289    Array(Vec<TableNode>),
1290}
1291
1292#[derive(Clone)]
1293struct TableCell {
1294    lines: Vec<CellLine>,
1295    port: Option<String>,
1296    background: Option<String>,
1297    border_width: f64,
1298    align: TextAlign,
1299}
1300
1301impl TableCell {
1302    fn new(lines: Vec<CellLine>) -> Self {
1303        Self {
1304            lines,
1305            port: None,
1306            background: None,
1307            border_width: 1.0,
1308            align: TextAlign::Left,
1309        }
1310    }
1311
1312    fn single_line_sized(
1313        text: impl Into<String>,
1314        color: &str,
1315        bold: bool,
1316        font_size: usize,
1317    ) -> Self {
1318        Self::new(vec![CellLine::new(text, color, bold, font_size)])
1319    }
1320
1321    fn with_port(mut self, port: String) -> Self {
1322        self.port = Some(port);
1323        self
1324    }
1325
1326    fn with_background(mut self, color: &str) -> Self {
1327        self.background = Some(color.to_string());
1328        self
1329    }
1330
1331    fn with_border_width(mut self, width: f64) -> Self {
1332        self.border_width = width;
1333        self
1334    }
1335
1336    fn with_align(mut self, align: TextAlign) -> Self {
1337        self.align = align;
1338        self
1339    }
1340
1341    fn label(&self) -> String {
1342        self.lines
1343            .iter()
1344            .map(|line| line.text.as_str())
1345            .collect::<Vec<_>>()
1346            .join("\n")
1347    }
1348}
1349
1350#[derive(Clone, Copy)]
1351enum TextAlign {
1352    Left,
1353    Center,
1354    Right,
1355}
1356
1357#[derive(Clone)]
1358struct CellLine {
1359    text: String,
1360    color: String,
1361    bold: bool,
1362    font_size: usize,
1363    font_family: FontFamily,
1364}
1365
1366impl CellLine {
1367    fn new(text: impl Into<String>, color: &str, bold: bool, font_size: usize) -> Self {
1368        Self {
1369            text: text.into(),
1370            color: color.to_string(),
1371            bold,
1372            font_size,
1373            font_family: FontFamily::Sans,
1374        }
1375    }
1376
1377    fn code(text: impl Into<String>, color: &str, bold: bool, font_size: usize) -> Self {
1378        let mut line = Self::new(text, color, bold, font_size);
1379        line.font_family = FontFamily::Mono;
1380        line
1381    }
1382}
1383
1384#[derive(Clone, Copy)]
1385enum FontFamily {
1386    Sans,
1387    Mono,
1388}
1389
1390impl FontFamily {
1391    fn as_css(self) -> &'static str {
1392        match self {
1393            FontFamily::Sans => FONT_FAMILY,
1394            FontFamily::Mono => MONO_FONT_FAMILY,
1395        }
1396    }
1397}
1398
1399trait TableVisitor {
1400    fn handle_cell(&mut self, cell: &TableCell, loc: Point, size: Point);
1401}
1402
1403struct TableRenderer<'a> {
1404    svg: &'a mut SvgWriter,
1405    node_left_x: f64,
1406    node_right_x: f64,
1407}
1408
1409impl TableVisitor for TableRenderer<'_> {
1410    fn handle_cell(&mut self, cell: &TableCell, loc: Point, size: Point) {
1411        let top_left = Point::new(loc.x - size.x / 2.0, loc.y - size.y / 2.0);
1412
1413        if let Some(bg) = &cell.background {
1414            self.svg.draw_rect(top_left, size, None, 0.0, Some(bg), 0.0);
1415        }
1416        self.svg.draw_rect(
1417            top_left,
1418            size,
1419            Some(BORDER_COLOR),
1420            cell.border_width,
1421            None,
1422            0.0,
1423        );
1424
1425        if let Some(port) = &cell.port {
1426            let is_output = port.starts_with("out_");
1427            let dot_x = if is_output {
1428                self.node_right_x
1429            } else {
1430                self.node_left_x
1431            };
1432            self.svg
1433                .draw_circle_overlay(Point::new(dot_x, loc.y), PORT_DOT_RADIUS, BORDER_COLOR);
1434        }
1435
1436        if cell.lines.is_empty() {
1437            return;
1438        }
1439
1440        let total_height = cell_text_height(cell);
1441        let mut current_y = loc.y - total_height / 2.0;
1442        let (text_x, anchor) = match cell.align {
1443            TextAlign::Left => (loc.x - size.x / 2.0 + CELL_PADDING, "start"),
1444            TextAlign::Center => (loc.x, "middle"),
1445            TextAlign::Right => (loc.x + size.x / 2.0 - CELL_PADDING, "end"),
1446        };
1447
1448        for (idx, line) in cell.lines.iter().enumerate() {
1449            let line_height = line.font_size as f64;
1450            let y = current_y + line_height / 2.0;
1451            self.svg.draw_text(
1452                Point::new(text_x, y),
1453                &line.text,
1454                line.font_size,
1455                &line.color,
1456                line.bold,
1457                anchor,
1458                line.font_family,
1459            );
1460            current_y += line_height;
1461            if idx + 1 < cell.lines.len() {
1462                current_y += CELL_LINE_SPACING;
1463            }
1464        }
1465    }
1466}
1467
1468struct ArrowLabel {
1469    text: String,
1470    color: String,
1471    font_size: usize,
1472    bold: bool,
1473    font_family: FontFamily,
1474    position: Option<Point>,
1475}
1476
1477struct StraightLabelSlot {
1478    center: Point,
1479    width: f64,
1480    normal: Point,
1481    stack_offset: f64,
1482    group_count: usize,
1483}
1484
1485struct DetourLabelSlot {
1486    center_x: f64,
1487    width: f64,
1488    lane_y: f64,
1489    above: bool,
1490    group_count: usize,
1491    group_width: f64,
1492}
1493
1494struct BezierSegment {
1495    start: Point,
1496    c1: Point,
1497    c2: Point,
1498    end: Point,
1499}
1500
1501impl ArrowLabel {
1502    fn new(
1503        text: String,
1504        color: &str,
1505        font_size: usize,
1506        bold: bool,
1507        font_family: FontFamily,
1508    ) -> Self {
1509        Self {
1510            text,
1511            color: color.to_string(),
1512            font_size,
1513            bold,
1514            font_family,
1515            position: None,
1516        }
1517    }
1518
1519    fn with_position(mut self, position: Point) -> Self {
1520        self.position = Some(position);
1521        self
1522    }
1523}
1524
1525struct NullBackend;
1526
1527impl RenderBackend for NullBackend {
1528    fn draw_rect(
1529        &mut self,
1530        _xy: Point,
1531        _size: Point,
1532        _look: &StyleAttr,
1533        _properties: Option<String>,
1534        _clip: Option<layout::core::format::ClipHandle>,
1535    ) {
1536    }
1537
1538    fn draw_line(
1539        &mut self,
1540        _start: Point,
1541        _stop: Point,
1542        _look: &StyleAttr,
1543        _properties: Option<String>,
1544    ) {
1545    }
1546
1547    fn draw_circle(
1548        &mut self,
1549        _xy: Point,
1550        _size: Point,
1551        _look: &StyleAttr,
1552        _properties: Option<String>,
1553    ) {
1554    }
1555
1556    fn draw_text(&mut self, _xy: Point, _text: &str, _look: &StyleAttr) {}
1557
1558    fn draw_arrow(
1559        &mut self,
1560        _path: &[(Point, Point)],
1561        _dashed: bool,
1562        _head: (bool, bool),
1563        _look: &StyleAttr,
1564        _properties: Option<String>,
1565        _text: &str,
1566    ) {
1567    }
1568
1569    fn create_clip(&mut self, _xy: Point, _size: Point, _rounded_px: usize) -> usize {
1570        0
1571    }
1572}
1573
1574struct SvgWriter {
1575    content: Group,
1576    overlay: Group,
1577    defs: Definitions,
1578    view_size: Point,
1579    counter: usize,
1580}
1581
1582impl SvgWriter {
1583    fn new() -> Self {
1584        let mut defs = Definitions::new();
1585        let start_marker = Marker::new()
1586            .set("id", "startarrow")
1587            .set("markerWidth", 10)
1588            .set("markerHeight", 7)
1589            .set("refX", 2)
1590            .set("refY", 3.5)
1591            .set("orient", "auto")
1592            .add(
1593                Polygon::new()
1594                    .set("points", "10 0, 10 7, 0 3.5")
1595                    .set("fill", "context-stroke"),
1596            );
1597        let end_marker = Marker::new()
1598            .set("id", "endarrow")
1599            .set("markerWidth", 10)
1600            .set("markerHeight", 7)
1601            .set("refX", 8)
1602            .set("refY", 3.5)
1603            .set("orient", "auto")
1604            .add(
1605                Polygon::new()
1606                    .set("points", "0 0, 10 3.5, 0 7")
1607                    .set("fill", "context-stroke"),
1608            );
1609        defs.append(start_marker);
1610        defs.append(end_marker);
1611
1612        Self {
1613            content: Group::new(),
1614            overlay: Group::new(),
1615            defs,
1616            view_size: Point::new(0.0, 0.0),
1617            counter: 0,
1618        }
1619    }
1620
1621    fn grow_window(&mut self, point: Point, size: Point) {
1622        self.view_size.x = self.view_size.x.max(point.x + size.x);
1623        self.view_size.y = self.view_size.y.max(point.y + size.y);
1624    }
1625
1626    fn draw_rect(
1627        &mut self,
1628        top_left: Point,
1629        size: Point,
1630        stroke: Option<&str>,
1631        stroke_width: f64,
1632        fill: Option<&str>,
1633        rounded: f64,
1634    ) {
1635        self.grow_window(top_left, size);
1636
1637        let stroke_color = stroke.unwrap_or("none");
1638        let fill_color = fill.unwrap_or("none");
1639        let width = if stroke.is_some() { stroke_width } else { 0.0 };
1640        let mut rect = Rectangle::new()
1641            .set("x", top_left.x)
1642            .set("y", top_left.y)
1643            .set("width", size.x)
1644            .set("height", size.y)
1645            .set("fill", fill_color)
1646            .set("stroke", stroke_color)
1647            .set("stroke-width", width);
1648        if rounded > 0.0 {
1649            rect = rect.set("rx", rounded).set("ry", rounded);
1650        }
1651        self.content.append(rect);
1652    }
1653
1654    fn draw_circle_overlay(&mut self, center: Point, radius: f64, fill: &str) {
1655        let circle = Circle::new()
1656            .set("cx", center.x)
1657            .set("cy", center.y)
1658            .set("r", radius)
1659            .set("fill", fill);
1660        self.overlay.append(circle);
1661
1662        let top_left = Point::new(center.x - radius, center.y - radius);
1663        let size = Point::new(radius * 2.0, radius * 2.0);
1664        self.grow_window(top_left, size);
1665    }
1666
1667    fn draw_line(&mut self, start: Point, end: Point, color: &str, width: f64) {
1668        let line = Line::new()
1669            .set("x1", start.x)
1670            .set("y1", start.y)
1671            .set("x2", end.x)
1672            .set("y2", end.y)
1673            .set("stroke", color)
1674            .set("stroke-width", width);
1675        self.content.append(line);
1676
1677        let top_left = Point::new(start.x.min(end.x), start.y.min(end.y));
1678        let size = Point::new((start.x - end.x).abs(), (start.y - end.y).abs());
1679        self.grow_window(top_left, size);
1680    }
1681
1682    fn append_node<T>(&mut self, node: T)
1683    where
1684        T: Into<Box<dyn Node>>,
1685    {
1686        self.content.append(node);
1687    }
1688
1689    #[allow(clippy::too_many_arguments)]
1690    fn draw_text(
1691        &mut self,
1692        pos: Point,
1693        text: &str,
1694        font_size: usize,
1695        color: &str,
1696        bold: bool,
1697        anchor: &str,
1698        family: FontFamily,
1699    ) {
1700        if text.is_empty() {
1701            return;
1702        }
1703
1704        let escaped = escape_xml(text);
1705        let weight = if bold { "bold" } else { "normal" };
1706        let mut node = Text::new()
1707            .set("x", pos.x)
1708            .set("y", pos.y)
1709            .set("text-anchor", anchor)
1710            .set("dominant-baseline", "middle")
1711            .set("font-family", family.as_css())
1712            .set("font-size", format!("{font_size}px"))
1713            .set("fill", color)
1714            .set("font-weight", weight);
1715        node.append(TextNode::new(escaped));
1716        self.content.append(node);
1717
1718        let size = get_size_for_str(text, font_size);
1719        let top_left = Point::new(pos.x, pos.y - size.y / 2.0);
1720        self.grow_window(top_left, size);
1721    }
1722
1723    #[allow(clippy::too_many_arguments)]
1724    fn draw_text_overlay(
1725        &mut self,
1726        pos: Point,
1727        text: &str,
1728        font_size: usize,
1729        color: &str,
1730        bold: bool,
1731        anchor: &str,
1732        family: FontFamily,
1733    ) {
1734        if text.is_empty() {
1735            return;
1736        }
1737
1738        let escaped = escape_xml(text);
1739        let weight = if bold { "bold" } else { "normal" };
1740        let mut node = Text::new()
1741            .set("x", pos.x)
1742            .set("y", pos.y)
1743            .set("text-anchor", anchor)
1744            .set("dominant-baseline", "middle")
1745            .set("font-family", family.as_css())
1746            .set("font-size", format!("{font_size}px"))
1747            .set("fill", color)
1748            .set("font-weight", weight)
1749            .set("stroke", "white")
1750            .set("stroke-width", EDGE_LABEL_HALO_WIDTH)
1751            .set("paint-order", "stroke")
1752            .set("stroke-linejoin", "round");
1753        node.append(TextNode::new(escaped));
1754        self.overlay.append(node);
1755
1756        let size = get_size_for_str(text, font_size);
1757        let top_left = Point::new(pos.x, pos.y - size.y / 2.0);
1758        self.grow_window(top_left, size);
1759    }
1760
1761    fn draw_arrow(
1762        &mut self,
1763        path: &[BezierSegment],
1764        dashed: bool,
1765        head: (bool, bool),
1766        look: &StyleAttr,
1767        label: Option<&ArrowLabel>,
1768    ) {
1769        if path.is_empty() {
1770            return;
1771        }
1772
1773        for segment in path {
1774            self.grow_window(segment.start, Point::new(0.0, 0.0));
1775            self.grow_window(segment.c1, Point::new(0.0, 0.0));
1776            self.grow_window(segment.c2, Point::new(0.0, 0.0));
1777            self.grow_window(segment.end, Point::new(0.0, 0.0));
1778        }
1779
1780        let stroke_color = look.line_color.to_web_color();
1781        let stroke_color = normalize_web_color(&stroke_color);
1782
1783        let path_data = build_path_data(path);
1784        let path_id = format!("arrow{}", self.counter);
1785        let mut path_el = SvgPath::new()
1786            .set("id", path_id.clone())
1787            .set("d", path_data)
1788            .set("stroke", stroke_color)
1789            .set("stroke-width", look.line_width)
1790            .set("fill", "none");
1791        if dashed {
1792            path_el = path_el.set("stroke-dasharray", "5,5");
1793        }
1794        if head.0 {
1795            path_el = path_el.set("marker-start", "url(#startarrow)");
1796        }
1797        if head.1 {
1798            path_el = path_el.set("marker-end", "url(#endarrow)");
1799        }
1800        self.content.append(path_el);
1801
1802        if let Some(label) = label {
1803            if label.text.is_empty() {
1804                self.counter += 1;
1805                return;
1806            }
1807            if let Some(pos) = label.position {
1808                self.draw_text_overlay(
1809                    pos,
1810                    &label.text,
1811                    label.font_size,
1812                    &label.color,
1813                    label.bold,
1814                    "middle",
1815                    label.font_family,
1816                );
1817            } else {
1818                let label_path_id = format!("{}_label", path_id);
1819                let start = path[0].start;
1820                let end = path[path.len() - 1].end;
1821                let label_path_data = build_explicit_path_data(path, start.x > end.x);
1822                let label_path_el = SvgPath::new()
1823                    .set("id", label_path_id.clone())
1824                    .set("d", label_path_data)
1825                    .set("fill", "none")
1826                    .set("stroke", "none");
1827                self.overlay.append(label_path_el);
1828
1829                let escaped = escape_xml(&label.text);
1830                let weight = if label.bold { "bold" } else { "normal" };
1831                let mut text_path = TextPath::new()
1832                    .set("href", format!("#{label_path_id}"))
1833                    .set("startOffset", "50%")
1834                    .set("text-anchor", "middle")
1835                    .set("dy", EDGE_LABEL_OFFSET)
1836                    .set("font-family", label.font_family.as_css())
1837                    .set("font-size", format!("{}px", label.font_size))
1838                    .set("fill", label.color.clone())
1839                    .set("font-weight", weight)
1840                    .set("stroke", "white")
1841                    .set("stroke-width", EDGE_LABEL_HALO_WIDTH)
1842                    .set("paint-order", "stroke")
1843                    .set("stroke-linejoin", "round");
1844                text_path.append(TextNode::new(escaped));
1845                let mut text_node = Text::new();
1846                text_node.append(text_path);
1847                self.overlay.append(text_node);
1848            }
1849        }
1850
1851        self.counter += 1;
1852    }
1853
1854    fn finalize(self) -> String {
1855        let width = if self.view_size.x < 1.0 {
1856            1.0
1857        } else {
1858            self.view_size.x + GRAPH_MARGIN
1859        };
1860        let height = if self.view_size.y < 1.0 {
1861            1.0
1862        } else {
1863            self.view_size.y + GRAPH_MARGIN
1864        };
1865
1866        Document::new()
1867            .set("width", width)
1868            .set("height", height)
1869            .set("viewBox", (0, 0, width, height))
1870            .set("xmlns", "http://www.w3.org/2000/svg")
1871            .set("xmlns:xlink", "http://www.w3.org/1999/xlink")
1872            .add(self.defs)
1873            .add(self.content)
1874            .add(self.overlay)
1875            .to_string()
1876    }
1877}
1878
1879fn escape_xml(input: &str) -> String {
1880    let mut res = String::new();
1881    for ch in input.chars() {
1882        match ch {
1883            '&' => res.push_str("&amp;"),
1884            '<' => res.push_str("&lt;"),
1885            '>' => res.push_str("&gt;"),
1886            '"' => res.push_str("&quot;"),
1887            '\'' => res.push_str("&apos;"),
1888            _ => res.push(ch),
1889        }
1890    }
1891    res
1892}
1893
1894fn build_text_node(
1895    pos: Point,
1896    text: &str,
1897    font_size: usize,
1898    color: &str,
1899    bold: bool,
1900    anchor: &str,
1901    family: FontFamily,
1902) -> Text {
1903    let escaped = escape_xml(text);
1904    let weight = if bold { "bold" } else { "normal" };
1905    let mut node = Text::new()
1906        .set("x", pos.x)
1907        .set("y", pos.y)
1908        .set("text-anchor", anchor)
1909        .set("dominant-baseline", "middle")
1910        .set("font-family", family.as_css())
1911        .set("font-size", format!("{font_size}px"))
1912        .set("fill", color)
1913        .set("font-weight", weight);
1914    node.append(TextNode::new(escaped));
1915    node
1916}
1917
1918fn svg_data_uri(svg: &str) -> String {
1919    format!(
1920        "data:image/svg+xml;base64,{}",
1921        base64_encode(svg.as_bytes())
1922    )
1923}
1924
1925fn base64_encode(input: &[u8]) -> String {
1926    const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1927    let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
1928    let mut i = 0;
1929    while i < input.len() {
1930        let b0 = input[i];
1931        let b1 = if i + 1 < input.len() { input[i + 1] } else { 0 };
1932        let b2 = if i + 2 < input.len() { input[i + 2] } else { 0 };
1933        let triple = ((b0 as u32) << 16) | ((b1 as u32) << 8) | (b2 as u32);
1934        let idx0 = ((triple >> 18) & 0x3F) as usize;
1935        let idx1 = ((triple >> 12) & 0x3F) as usize;
1936        let idx2 = ((triple >> 6) & 0x3F) as usize;
1937        let idx3 = (triple & 0x3F) as usize;
1938        out.push(TABLE[idx0] as char);
1939        out.push(TABLE[idx1] as char);
1940        if i + 1 < input.len() {
1941            out.push(TABLE[idx2] as char);
1942        } else {
1943            out.push('=');
1944        }
1945        if i + 2 < input.len() {
1946            out.push(TABLE[idx3] as char);
1947        } else {
1948            out.push('=');
1949        }
1950        i += 3;
1951    }
1952    out
1953}
1954
1955fn legend_text_width(text: &str, font_size: usize) -> f64 {
1956    text.chars().count() as f64 * font_size as f64 * LEGEND_TEXT_WIDTH_FACTOR
1957}
1958
1959fn normalize_web_color(color: &str) -> String {
1960    if color.len() == 9 && color.starts_with('#') {
1961        return format!("#{}", &color[1..7]);
1962    }
1963    color.to_string()
1964}
1965
1966fn colored_edge_style(base: &StyleAttr, color: &str) -> StyleAttr {
1967    StyleAttr::new(Color::fast(color), base.line_width, None, 0, EDGE_FONT_SIZE)
1968}
1969
1970fn edge_cycle_color_index(slot: &mut usize) -> usize {
1971    let idx = EDGE_COLOR_ORDER[*slot % EDGE_COLOR_ORDER.len()];
1972    *slot += 1;
1973    idx
1974}
1975
1976fn lighten_hex(color: &str, amount: f64) -> String {
1977    let Some(hex) = color.strip_prefix('#') else {
1978        return color.to_string();
1979    };
1980    if hex.len() != 6 {
1981        return color.to_string();
1982    }
1983    let parse = |idx| u8::from_str_radix(&hex[idx..idx + 2], 16).ok();
1984    let (Some(r), Some(g), Some(b)) = (parse(0), parse(2), parse(4)) else {
1985        return color.to_string();
1986    };
1987    let blend = |c| ((c as f64) + (255.0 - c as f64) * amount).round() as u8;
1988    format!("#{:02X}{:02X}{:02X}", blend(r), blend(g), blend(b))
1989}
1990
1991fn wrap_text(text: &str, max_width: usize) -> String {
1992    if text.len() <= max_width {
1993        return text.to_string();
1994    }
1995    let mut out = String::new();
1996    let mut line_len = 0;
1997    for word in text.split_whitespace() {
1998        let next_len = if line_len == 0 {
1999            word.len()
2000        } else {
2001            line_len + 1 + word.len()
2002        };
2003        if next_len > max_width && line_len > 0 {
2004            out.push('\n');
2005            out.push_str(word);
2006            line_len = word.len();
2007        } else {
2008            if line_len > 0 {
2009                out.push(' ');
2010            }
2011            out.push_str(word);
2012            line_len = next_len;
2013        }
2014    }
2015    out
2016}
2017
2018fn wrap_type_label(label: &str, max_width: usize) -> String {
2019    if label.len() <= max_width {
2020        return label.to_string();
2021    }
2022
2023    let tokens = split_type_tokens(label);
2024    let mut lines: Vec<String> = Vec::new();
2025    let mut current = String::new();
2026    let mut current_len = 0usize;
2027
2028    for token in tokens {
2029        if token.is_empty() {
2030            continue;
2031        }
2032
2033        let chunks = split_long_token(&token, max_width);
2034        for chunk in chunks {
2035            if current_len + chunk.len() > max_width && !current.is_empty() {
2036                lines.push(current);
2037                current = String::new();
2038                current_len = 0;
2039            }
2040
2041            current.push_str(&chunk);
2042            current_len += chunk.len();
2043
2044            if chunk == "," || chunk == "<" || chunk == ">" {
2045                lines.push(current);
2046                current = String::new();
2047                current_len = 0;
2048            }
2049        }
2050    }
2051
2052    if !current.is_empty() {
2053        lines.push(current);
2054    }
2055
2056    if lines.is_empty() {
2057        return label.to_string();
2058    }
2059    lines.join("\n")
2060}
2061
2062fn split_long_token(token: &str, max_width: usize) -> Vec<String> {
2063    if token.len() <= max_width || token == "::" {
2064        return vec![token.to_string()];
2065    }
2066
2067    let mut out = Vec::new();
2068    let mut start = 0;
2069    let chars: Vec<char> = token.chars().collect();
2070    while start < chars.len() {
2071        let end = (start + max_width).min(chars.len());
2072        out.push(chars[start..end].iter().collect());
2073        start = end;
2074    }
2075    out
2076}
2077
2078fn cell_text_size(cell: &TableCell) -> Point {
2079    let mut max_width: f64 = 0.0;
2080    if cell.lines.is_empty() {
2081        return Point::new(1.0, 1.0);
2082    }
2083    for line in &cell.lines {
2084        let size = get_size_for_str(&line.text, line.font_size);
2085        max_width = max_width.max(size.x);
2086    }
2087    Point::new(max_width, cell_text_height(cell).max(1.0))
2088}
2089
2090fn cell_text_height(cell: &TableCell) -> f64 {
2091    if cell.lines.is_empty() {
2092        return 1.0;
2093    }
2094    let base: f64 = cell.lines.iter().map(|line| line.font_size as f64).sum();
2095    let spacing = CELL_LINE_SPACING * (cell.lines.len().saturating_sub(1) as f64);
2096    base + spacing
2097}
2098
2099fn collect_port_anchors(node: &NodeRender, element: &Element) -> HashMap<String, Point> {
2100    let pos = element.position();
2101    let center = pos.center();
2102    let size = pos.size(false);
2103    let left_x = center.x - size.x / 2.0;
2104    let right_x = center.x + size.x / 2.0;
2105
2106    let mut anchors = HashMap::new();
2107    let mut collector = PortAnchorCollector {
2108        anchors: &mut anchors,
2109        node_left_x: left_x,
2110        node_right_x: right_x,
2111    };
2112    visit_table(
2113        &node.table,
2114        element.orientation,
2115        center,
2116        size,
2117        &mut collector,
2118    );
2119    anchors
2120}
2121
2122struct PortAnchorCollector<'a> {
2123    anchors: &'a mut HashMap<String, Point>,
2124    node_left_x: f64,
2125    node_right_x: f64,
2126}
2127
2128impl TableVisitor for PortAnchorCollector<'_> {
2129    fn handle_cell(&mut self, cell: &TableCell, loc: Point, _size: Point) {
2130        let Some(port) = &cell.port else {
2131            return;
2132        };
2133
2134        let is_output = port.starts_with("out_");
2135        let port_offset = PORT_LINE_GAP + PORT_DOT_RADIUS;
2136        let x = if is_output {
2137            self.node_right_x + port_offset
2138        } else {
2139            self.node_left_x - port_offset
2140        };
2141        self.anchors.insert(port.clone(), Point::new(x, loc.y));
2142    }
2143}
2144
2145fn resolve_anchor(section: &SectionLayout, node: NodeHandle, port: Option<&String>) -> Point {
2146    if let Some(port) = port
2147        && let Some(anchors) = section.port_anchors.get(&node)
2148        && let Some(point) = anchors.get(port)
2149    {
2150        return *point;
2151    }
2152
2153    section.graph.element(node).position().center()
2154}
2155
2156#[derive(Clone, Copy)]
2157struct BackEdgePlan {
2158    idx: usize,
2159    span: f64,
2160}
2161
2162struct NodeBounds {
2163    handle: NodeHandle,
2164    left: f64,
2165    right: f64,
2166    top: f64,
2167    bottom: f64,
2168    center_x: f64,
2169}
2170
2171fn collect_node_bounds(section: &SectionLayout) -> Vec<NodeBounds> {
2172    let mut bounds = Vec::with_capacity(section.nodes.len());
2173    for node in &section.nodes {
2174        let pos = section.graph.element(node.handle).position();
2175        let (top_left, bottom_right) = pos.bbox(false);
2176        bounds.push(NodeBounds {
2177            handle: node.handle,
2178            left: top_left.x,
2179            right: bottom_right.x,
2180            top: top_left.y,
2181            bottom: bottom_right.y,
2182            center_x: (top_left.x + bottom_right.x) / 2.0,
2183        });
2184    }
2185    bounds
2186}
2187
2188fn max_bottom_for_span(bounds: &[NodeBounds], min_x: f64, max_x: f64) -> f64 {
2189    bounds
2190        .iter()
2191        .filter(|b| b.right >= min_x && b.left <= max_x)
2192        .map(|b| b.bottom)
2193        .fold(f64::NEG_INFINITY, f64::max)
2194}
2195
2196fn min_top_for_span(bounds: &[NodeBounds], min_x: f64, max_x: f64) -> f64 {
2197    bounds
2198        .iter()
2199        .filter(|b| b.right >= min_x && b.left <= max_x)
2200        .map(|b| b.top)
2201        .fold(f64::INFINITY, f64::min)
2202}
2203
2204fn span_has_intermediate(
2205    bounds: &[NodeBounds],
2206    min_x: f64,
2207    max_x: f64,
2208    src: NodeHandle,
2209    dst: NodeHandle,
2210) -> bool {
2211    bounds.iter().any(|b| {
2212        b.handle != src
2213            && b.handle != dst
2214            && b.center_x > min_x + INTERMEDIATE_X_EPS
2215            && b.center_x < max_x - INTERMEDIATE_X_EPS
2216    })
2217}
2218
2219fn assign_back_edge_offsets(plans: &[BackEdgePlan], offsets: &mut [f64]) {
2220    let mut plans = plans.to_vec();
2221    plans.sort_by(|a, b| a.span.partial_cmp(&b.span).unwrap_or(Ordering::Equal));
2222
2223    let mut layer = 0usize;
2224    let mut last_span: Option<f64> = None;
2225    let mut layer_counts: HashMap<usize, usize> = HashMap::new();
2226
2227    for plan in plans {
2228        if let Some(prev_span) = last_span
2229            && (plan.span - prev_span).abs() > BACK_EDGE_SPAN_EPS
2230        {
2231            layer += 1;
2232        }
2233        last_span = Some(plan.span);
2234
2235        let dup = layer_counts.entry(layer).or_insert(0);
2236        offsets[plan.idx] =
2237            layer as f64 * BACK_EDGE_STACK_SPACING + *dup as f64 * BACK_EDGE_DUP_SPACING;
2238        *dup += 1;
2239    }
2240}
2241
2242fn expand_bounds(bounds: &mut (Point, Point), point: Point) {
2243    bounds.0.x = bounds.0.x.min(point.x);
2244    bounds.0.y = bounds.0.y.min(point.y);
2245    bounds.1.x = bounds.1.x.max(point.x);
2246    bounds.1.y = bounds.1.y.max(point.y);
2247}
2248
2249fn port_dir(port: Option<&String>) -> Option<f64> {
2250    port.and_then(|name| {
2251        if name.starts_with("out_") {
2252            Some(1.0)
2253        } else if name.starts_with("in_") {
2254            Some(-1.0)
2255        } else {
2256            None
2257        }
2258    })
2259}
2260
2261fn port_dir_incoming(port: Option<&String>) -> Option<f64> {
2262    port_dir(port).map(|dir| -dir)
2263}
2264
2265fn fallback_port_dirs(start: Point, end: Point) -> (f64, f64) {
2266    let dir = if end.x >= start.x { 1.0 } else { -1.0 };
2267    (dir, dir)
2268}
2269
2270fn lerp_point(a: Point, b: Point, t: f64) -> Point {
2271    Point::new(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t)
2272}
2273
2274fn straight_segment(start: Point, end: Point) -> BezierSegment {
2275    BezierSegment {
2276        start,
2277        c1: lerp_point(start, end, 1.0 / 3.0),
2278        c2: lerp_point(start, end, 2.0 / 3.0),
2279        end,
2280    }
2281}
2282
2283fn edge_stub_len(start: Point, end: Point) -> f64 {
2284    let dx = (end.x - start.x).abs();
2285    if dx <= 0.0 {
2286        return 0.0;
2287    }
2288    let max_stub = dx * 0.45;
2289    let mut stub = EDGE_STUB_LEN.min(max_stub);
2290    let min_stub = EDGE_STUB_MIN.min(max_stub);
2291    if stub < min_stub {
2292        stub = min_stub;
2293    }
2294    stub
2295}
2296
2297fn edge_port_handle(start: Point, end: Point) -> f64 {
2298    let dx = (end.x - start.x).abs();
2299    let mut handle = EDGE_PORT_HANDLE.min(dx * 0.2);
2300    if handle < 6.0 {
2301        handle = 6.0;
2302    }
2303    handle
2304}
2305
2306fn build_edge_path(start: Point, end: Point, start_dir: f64, end_dir: f64) -> Vec<BezierSegment> {
2307    let dir = if end.x >= start.x { 1.0 } else { -1.0 };
2308    let stub = edge_stub_len(start, end);
2309    if stub <= 1.0 {
2310        let dx = (end.x - start.x).abs().max(40.0);
2311        let ctrl1 = Point::new(start.x + dir * dx * 0.5, start.y);
2312        let ctrl2 = Point::new(end.x - dir * dx * 0.5, end.y);
2313        return vec![BezierSegment {
2314            start,
2315            c1: ctrl1,
2316            c2: ctrl2,
2317            end,
2318        }];
2319    }
2320
2321    let start_stub = Point::new(start.x + start_dir * stub, start.y);
2322    let end_stub = Point::new(end.x - end_dir * stub, end.y);
2323    let inner_dir = if end_stub.x >= start_stub.x {
2324        1.0
2325    } else {
2326        -1.0
2327    };
2328    let curve_dx = ((end_stub.x - start_stub.x).abs() * 0.35).max(10.0);
2329
2330    let seg1 = straight_segment(start, start_stub);
2331    let seg2 = BezierSegment {
2332        start: start_stub,
2333        c1: Point::new(start_stub.x + inner_dir * curve_dx, start_stub.y),
2334        c2: Point::new(end_stub.x - inner_dir * curve_dx, end_stub.y),
2335        end: end_stub,
2336    };
2337    let seg3 = straight_segment(end_stub, end);
2338    vec![seg1, seg2, seg3]
2339}
2340
2341fn build_back_edge_path(
2342    start: Point,
2343    end: Point,
2344    lane_y: f64,
2345    start_dir: f64,
2346    end_dir: f64,
2347) -> Vec<BezierSegment> {
2348    build_lane_path(start, end, lane_y, start_dir, end_dir)
2349}
2350
2351fn build_loop_path(
2352    start: Point,
2353    end: Point,
2354    bbox: (Point, Point),
2355    start_dir: f64,
2356    end_dir: f64,
2357) -> Vec<BezierSegment> {
2358    let height = bbox.1.y - bbox.0.y;
2359    let loop_dy = height * 0.8 + 30.0;
2360
2361    let center_y = (bbox.0.y + bbox.1.y) / 2.0;
2362
2363    let dir_y = if (start.y + end.y) / 2.0 < center_y {
2364        -1.0
2365    } else {
2366        1.0
2367    };
2368    let lane_y = center_y + dir_y * loop_dy;
2369    build_back_edge_path(start, end, lane_y, start_dir, end_dir)
2370}
2371
2372fn build_lane_path(
2373    start: Point,
2374    end: Point,
2375    lane_y: f64,
2376    start_dir: f64,
2377    end_dir: f64,
2378) -> Vec<BezierSegment> {
2379    let base_stub = edge_port_handle(start, end);
2380    let dy_start = (lane_y - start.y).abs();
2381    let dy_end = (lane_y - end.y).abs();
2382    let max_stub = (end.x - start.x).abs().max(40.0) * 0.45;
2383    let start_stub = (base_stub + dy_start * 0.6).min(max_stub.max(base_stub));
2384    let end_stub = (base_stub + dy_end * 0.6).min(max_stub.max(base_stub));
2385    let mut start_corner = Point::new(start.x, lane_y);
2386    let mut end_corner = Point::new(end.x, lane_y);
2387    let lane_dir = if (end_corner.x - start_corner.x).abs() < 1.0 {
2388        if end_dir.abs() > 0.0 {
2389            end_dir
2390        } else {
2391            start_dir
2392        }
2393    } else if end_corner.x >= start_corner.x {
2394        1.0
2395    } else {
2396        -1.0
2397    };
2398    let span = (end_corner.x - start_corner.x).abs();
2399    if start.x < end.x && span > 1.0 {
2400        let min_span = 60.0;
2401        let mut shrink = (span * 0.2).min(80.0);
2402        let max_shrink = ((span - min_span).max(0.0)) / 2.0;
2403        if shrink > max_shrink {
2404            shrink = max_shrink;
2405        }
2406        start_corner.x += lane_dir * shrink;
2407        end_corner.x -= lane_dir * shrink;
2408    }
2409    let entry_dir = -lane_dir;
2410    let handle_scale = if start.x < end.x { 0.6 } else { 1.0 };
2411    let entry_handle = (start_stub * handle_scale).max(6.0);
2412    let exit_handle = (end_stub * handle_scale).max(6.0);
2413    let seg1 = BezierSegment {
2414        start,
2415        c1: Point::new(start.x + start_dir * entry_handle, start.y),
2416        c2: Point::new(start_corner.x + entry_dir * entry_handle, lane_y),
2417        end: start_corner,
2418    };
2419    let seg2 = straight_segment(start_corner, end_corner);
2420    let seg3 = BezierSegment {
2421        start: end_corner,
2422        c1: Point::new(end_corner.x + lane_dir * exit_handle, lane_y),
2423        c2: Point::new(end.x - end_dir * exit_handle, end.y),
2424        end,
2425    };
2426    vec![seg1, seg2, seg3]
2427}
2428
2429fn build_path_data(path: &[BezierSegment]) -> Data {
2430    if path.is_empty() {
2431        return Data::new();
2432    }
2433
2434    let first = &path[0];
2435    let mut data = Data::new()
2436        .move_to((first.start.x, first.start.y))
2437        .cubic_curve_to((
2438            first.c1.x,
2439            first.c1.y,
2440            first.c2.x,
2441            first.c2.y,
2442            first.end.x,
2443            first.end.y,
2444        ));
2445    for segment in path.iter().skip(1) {
2446        data = data.cubic_curve_to((
2447            segment.c1.x,
2448            segment.c1.y,
2449            segment.c2.x,
2450            segment.c2.y,
2451            segment.end.x,
2452            segment.end.y,
2453        ));
2454    }
2455    data
2456}
2457
2458fn build_explicit_path_data(path: &[BezierSegment], reverse: bool) -> Data {
2459    if path.is_empty() {
2460        return Data::new();
2461    }
2462
2463    if !reverse {
2464        return build_path_data(path);
2465    }
2466
2467    let mut iter = path.iter().rev();
2468    let Some(first) = iter.next() else {
2469        return Data::new();
2470    };
2471    let mut data = Data::new()
2472        .move_to((first.end.x, first.end.y))
2473        .cubic_curve_to((
2474            first.c2.x,
2475            first.c2.y,
2476            first.c1.x,
2477            first.c1.y,
2478            first.start.x,
2479            first.start.y,
2480        ));
2481    for segment in iter {
2482        data = data.cubic_curve_to((
2483            segment.c2.x,
2484            segment.c2.y,
2485            segment.c1.x,
2486            segment.c1.y,
2487            segment.start.x,
2488            segment.start.y,
2489        ));
2490    }
2491    data
2492}
2493
2494fn place_edge_label(
2495    text: &str,
2496    font_size: usize,
2497    path: &[BezierSegment],
2498    blocked: &[(Point, Point)],
2499) -> Point {
2500    let (mid, dir) = path_label_anchor(path);
2501    let mut normal = Point::new(-dir.y, dir.x);
2502    if normal.x == 0.0 && normal.y == 0.0 {
2503        normal = Point::new(0.0, -1.0);
2504    }
2505    if normal.y > 0.0 {
2506        normal = Point::new(-normal.x, -normal.y);
2507    }
2508    place_label_with_normal(text, font_size, mid, normal, blocked)
2509}
2510
2511fn place_self_loop_label(
2512    text: &str,
2513    font_size: usize,
2514    path: &[BezierSegment],
2515    node_center: Point,
2516    blocked: &[(Point, Point)],
2517) -> Point {
2518    if path.is_empty() {
2519        return node_center;
2520    }
2521    let mut best = &path[0];
2522    let mut best_len = 0.0;
2523    for seg in path {
2524        let len = segment_length(seg);
2525        if len > best_len {
2526            best_len = len;
2527            best = seg;
2528        }
2529    }
2530    let mid = segment_point(best, 0.5);
2531    let mut normal = Point::new(mid.x - node_center.x, mid.y - node_center.y);
2532    let norm = (normal.x * normal.x + normal.y * normal.y).sqrt();
2533    if norm > 0.0 {
2534        normal = Point::new(normal.x / norm, normal.y / norm);
2535    } else {
2536        normal = Point::new(0.0, 1.0);
2537    }
2538    place_label_with_offset(text, font_size, mid, normal, 0.0, blocked)
2539}
2540
2541fn find_horizontal_lane_span(path: &[BezierSegment]) -> Option<(f64, f64, f64)> {
2542    let mut best: Option<(f64, f64)> = None;
2543    let mut best_dx = 0.0;
2544    let tol = 0.5;
2545
2546    for seg in path {
2547        let dy = (seg.end.y - seg.start.y).abs();
2548        if dy > tol {
2549            continue;
2550        }
2551        if (seg.c1.y - seg.start.y).abs() > tol || (seg.c2.y - seg.start.y).abs() > tol {
2552            continue;
2553        }
2554        let dx = (seg.end.x - seg.start.x).abs();
2555        if dx <= best_dx {
2556            continue;
2557        }
2558        best_dx = dx;
2559        best = Some((
2560            (seg.start.x + seg.end.x) / 2.0,
2561            (seg.start.y + seg.end.y) / 2.0,
2562        ));
2563    }
2564
2565    best.map(|(x, y)| (x, y, best_dx))
2566}
2567
2568fn place_detour_label(
2569    text: &str,
2570    font_size: usize,
2571    center_x: f64,
2572    lane_y: f64,
2573    above: bool,
2574    blocked: &[(Point, Point)],
2575) -> Point {
2576    let mid = Point::new(center_x, lane_y);
2577    let normal = if above {
2578        Point::new(0.0, -1.0)
2579    } else {
2580        Point::new(0.0, 1.0)
2581    };
2582    let extra = (font_size as f64 * 0.6).max(DETOUR_LABEL_CLEARANCE);
2583    place_label_with_offset(text, font_size, mid, normal, extra, blocked)
2584}
2585
2586fn place_label_with_normal(
2587    text: &str,
2588    font_size: usize,
2589    mid: Point,
2590    normal: Point,
2591    blocked: &[(Point, Point)],
2592) -> Point {
2593    place_label_with_offset(text, font_size, mid, normal, 0.0, blocked)
2594}
2595
2596fn place_label_with_offset(
2597    text: &str,
2598    font_size: usize,
2599    mid: Point,
2600    normal: Point,
2601    offset: f64,
2602    blocked: &[(Point, Point)],
2603) -> Point {
2604    let size = get_size_for_str(text, font_size);
2605    let mut normal = normal;
2606    if normal.x == 0.0 && normal.y == 0.0 {
2607        normal = Point::new(0.0, -1.0);
2608    }
2609    let base_offset = EDGE_LABEL_OFFSET + offset;
2610    let step = font_size as f64 + 6.0;
2611    let mut last = Point::new(
2612        mid.x + normal.x * base_offset,
2613        mid.y + normal.y * base_offset,
2614    );
2615    for attempt in 0..6 {
2616        let offset = base_offset + attempt as f64 * step;
2617        let pos = Point::new(mid.x + normal.x * offset, mid.y + normal.y * offset);
2618        let bbox = label_bbox(pos, size, 2.0);
2619        if !blocked.iter().any(|b| rects_overlap(*b, bbox)) {
2620            return pos;
2621        }
2622        last = pos;
2623    }
2624    last
2625}
2626
2627fn label_bbox(center: Point, size: Point, pad: f64) -> (Point, Point) {
2628    let half_w = size.x / 2.0 + pad;
2629    let half_h = size.y / 2.0 + pad;
2630    (
2631        Point::new(center.x - half_w, center.y - half_h),
2632        Point::new(center.x + half_w, center.y + half_h),
2633    )
2634}
2635
2636fn rects_overlap(a: (Point, Point), b: (Point, Point)) -> bool {
2637    a.1.x >= b.0.x && b.1.x >= a.0.x && a.1.y >= b.0.y && b.1.y >= a.0.y
2638}
2639
2640fn clamp_label_position(pos: Point, text: &str, font_size: usize, min: Point, max: Point) -> Point {
2641    let size = get_size_for_str(text, font_size);
2642    let half_w = size.x / 2.0 + 2.0;
2643    let half_h = size.y / 2.0 + 2.0;
2644    let min_x = min.x + half_w;
2645    let max_x = max.x - half_w;
2646    let min_y = min.y + half_h;
2647    let max_y = max.y - half_h;
2648
2649    Point::new(pos.x.clamp(min_x, max_x), pos.y.clamp(min_y, max_y))
2650}
2651
2652fn segment_length(seg: &BezierSegment) -> f64 {
2653    seg.start.distance_to(seg.c1) + seg.c1.distance_to(seg.c2) + seg.c2.distance_to(seg.end)
2654}
2655
2656fn segment_point(seg: &BezierSegment, t: f64) -> Point {
2657    let u = 1.0 - t;
2658    let tt = t * t;
2659    let uu = u * u;
2660    let uuu = uu * u;
2661    let ttt = tt * t;
2662
2663    let mut p = Point::new(0.0, 0.0);
2664    p.x = uuu * seg.start.x + 3.0 * uu * t * seg.c1.x + 3.0 * u * tt * seg.c2.x + ttt * seg.end.x;
2665    p.y = uuu * seg.start.y + 3.0 * uu * t * seg.c1.y + 3.0 * u * tt * seg.c2.y + ttt * seg.end.y;
2666    p
2667}
2668
2669fn detour_lane_bounds_from_points(start: Point, end: Point) -> (f64, f64) {
2670    let dx_total = (start.x - end.x).abs().max(40.0);
2671    let max_dx = (dx_total / 2.0 - 10.0).max(20.0);
2672    let curve_dx = (dx_total * 0.25).min(max_dx);
2673    let left = start.x.min(end.x) + curve_dx;
2674    let right = start.x.max(end.x) - curve_dx;
2675    (left, right)
2676}
2677
2678fn build_straight_label_slots(
2679    edge_points: &[(Point, Point)],
2680    edge_is_detour: &[bool],
2681    edge_is_self: &[bool],
2682) -> HashMap<usize, StraightLabelSlot> {
2683    type StraightGroupKey = (i64, i64); // (start_x_bucket, start_y_bucket)
2684    type StraightEdgeEntry = (usize, Point, Point); // (edge_idx, start, end)
2685
2686    let mut groups: HashMap<StraightGroupKey, Vec<StraightEdgeEntry>> = HashMap::new();
2687    for (idx, (start, end)) in edge_points.iter().enumerate() {
2688        if edge_is_detour[idx] || edge_is_self[idx] {
2689            continue;
2690        }
2691        let key = (
2692            (start.x / 10.0).round() as i64,
2693            (start.y / 10.0).round() as i64,
2694        );
2695        groups.entry(key).or_default().push((idx, *start, *end));
2696    }
2697
2698    let mut slots = HashMap::new();
2699    for (_key, mut edges) in groups {
2700        edges.sort_by(|a, b| {
2701            a.1.y
2702                .partial_cmp(&b.1.y)
2703                .unwrap_or(Ordering::Equal)
2704                .then_with(|| a.2.y.partial_cmp(&b.2.y).unwrap_or(Ordering::Equal))
2705        });
2706
2707        let group_count = edges.len();
2708        for (slot_idx, (edge_idx, start, end)) in edges.into_iter().enumerate() {
2709            let center_x = (start.x + end.x) / 2.0;
2710            let center_y = (start.y + end.y) / 2.0;
2711            let span = (end.x - start.x).abs().max(1.0);
2712            let width = span * EDGE_LABEL_FIT_RATIO;
2713            let normal = if end.x >= start.x {
2714                Point::new(0.0, -1.0)
2715            } else {
2716                Point::new(0.0, 1.0)
2717            };
2718            slots.insert(
2719                edge_idx,
2720                StraightLabelSlot {
2721                    center: Point::new(center_x, center_y),
2722                    width,
2723                    normal,
2724                    stack_offset: slot_idx as f64 * (EDGE_FONT_SIZE as f64 + 4.0),
2725                    group_count,
2726                },
2727            );
2728        }
2729    }
2730
2731    slots
2732}
2733
2734fn build_detour_label_slots(
2735    edge_points: &[(Point, Point)],
2736    edge_is_detour: &[bool],
2737    detour_above: &[bool],
2738    detour_lane_y: &[f64],
2739) -> HashMap<usize, DetourLabelSlot> {
2740    type DetourLaneKey = (i64, i64, bool); // (left_bucket, right_bucket, above)
2741    type DetourEdgeEntry = (usize, f64, f64, f64); // (edge_idx, lane_left, lane_right, start_x)
2742
2743    let mut groups: HashMap<DetourLaneKey, Vec<DetourEdgeEntry>> = HashMap::new();
2744    for (idx, (start, end)) in edge_points.iter().enumerate() {
2745        if !edge_is_detour[idx] {
2746            continue;
2747        }
2748        let (left, right) = detour_lane_bounds_from_points(*start, *end);
2749        let key = (
2750            (left / 10.0).round() as i64,
2751            (right / 10.0).round() as i64,
2752            detour_above[idx],
2753        );
2754        groups
2755            .entry(key)
2756            .or_default()
2757            .push((idx, left, right, start.x));
2758    }
2759
2760    let mut slots = HashMap::new();
2761    for (_key, mut edges) in groups {
2762        edges.sort_by(|a, b| a.3.partial_cmp(&b.3).unwrap_or(Ordering::Equal));
2763        let mut left = f64::INFINITY;
2764        let mut right = f64::NEG_INFINITY;
2765        for (_, lane_left, lane_right, _) in &edges {
2766            left = left.min(*lane_left);
2767            right = right.max(*lane_right);
2768        }
2769        let width = (right - left).max(1.0);
2770        let count = edges.len();
2771        let slot_width = width / count as f64;
2772
2773        for (slot_idx, (edge_idx, _, _, _)) in edges.into_iter().enumerate() {
2774            let center_x = left + (slot_idx as f64 + 0.5) * slot_width;
2775            slots.insert(
2776                edge_idx,
2777                DetourLabelSlot {
2778                    center_x,
2779                    width: slot_width * 0.9,
2780                    lane_y: detour_lane_y[edge_idx],
2781                    above: detour_above[edge_idx],
2782                    group_count: count,
2783                    group_width: width,
2784                },
2785            );
2786        }
2787    }
2788
2789    slots
2790}
2791
2792fn fit_label_to_width(label: &str, max_width: f64, base_size: usize) -> (String, usize) {
2793    if max_width <= 0.0 {
2794        return (String::new(), base_size);
2795    }
2796    let mut candidate = shorten_module_path(label, max_width, base_size);
2797    let width = get_size_for_str(&candidate, base_size).x;
2798    if width <= max_width {
2799        return (candidate, base_size);
2800    }
2801
2802    let mut max_chars = (max_width / base_size as f64).floor() as usize;
2803    if max_chars == 0 {
2804        max_chars = 1;
2805    }
2806    candidate = truncate_label_left(&candidate, max_chars);
2807    (candidate, base_size)
2808}
2809
2810fn path_label_anchor(path: &[BezierSegment]) -> (Point, Point) {
2811    if path.is_empty() {
2812        return (Point::new(0.0, 0.0), Point::new(1.0, 0.0));
2813    }
2814
2815    let mut best_score = 0.0;
2816    let mut best_mid = path[0].start;
2817    let mut best_dir = Point::new(1.0, 0.0);
2818    for seg in path {
2819        let a = seg.start;
2820        let b = seg.end;
2821        let dx = b.x - a.x;
2822        let dy = b.y - a.y;
2823        let len = (dx * dx + dy * dy).sqrt();
2824        if len <= 0.0 {
2825            continue;
2826        }
2827        let horiz_bonus = if dx.abs() >= dy.abs() { 50.0 } else { 0.0 };
2828        let score = len + horiz_bonus;
2829        if score > best_score {
2830            best_score = score;
2831            let t = 0.5;
2832            best_mid = Point::new(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t);
2833            best_dir = Point::new(dx / len, dy / len);
2834        }
2835    }
2836
2837    (best_mid, best_dir)
2838}
2839
2840fn fit_edge_label(label: &str, path: &[BezierSegment], base_size: usize) -> (String, usize) {
2841    if label.is_empty() || path.is_empty() {
2842        return (label.to_string(), base_size);
2843    }
2844    let approx_len = approximate_path_length(path);
2845    let available = approx_len * EDGE_LABEL_FIT_RATIO;
2846    if available <= 0.0 {
2847        return (label.to_string(), base_size);
2848    }
2849
2850    fit_label_to_width(label, available, base_size)
2851}
2852
2853fn direction_unit(start: Point, end: Point) -> Point {
2854    let dx = end.x - start.x;
2855    let dy = end.y - start.y;
2856    let len = (dx * dx + dy * dy).sqrt();
2857    if len <= 0.0 {
2858        Point::new(1.0, 0.0)
2859    } else {
2860        Point::new(dx / len, dy / len)
2861    }
2862}
2863
2864fn approximate_path_length(path: &[BezierSegment]) -> f64 {
2865    let mut length = 0.0;
2866    for seg in path {
2867        length += seg.start.distance_to(seg.c1);
2868        length += seg.c1.distance_to(seg.c2);
2869        length += seg.c2.distance_to(seg.end);
2870    }
2871    length
2872}
2873
2874fn split_type_tokens(label: &str) -> Vec<String> {
2875    let mut tokens = Vec::new();
2876    let mut buf = String::new();
2877    let chars: Vec<char> = label.chars().collect();
2878    let mut idx = 0;
2879    while idx < chars.len() {
2880        let ch = chars[idx];
2881        if ch == ':' && idx + 1 < chars.len() && chars[idx + 1] == ':' {
2882            if !buf.is_empty() {
2883                tokens.push(buf.clone());
2884                buf.clear();
2885            }
2886            tokens.push("::".to_string());
2887            idx += 2;
2888            continue;
2889        }
2890
2891        if ch == '<' || ch == '>' || ch == ',' {
2892            if !buf.is_empty() {
2893                tokens.push(buf.clone());
2894                buf.clear();
2895            }
2896            tokens.push(ch.to_string());
2897            idx += 1;
2898            continue;
2899        }
2900
2901        if ch.is_whitespace() {
2902            if !buf.is_empty() {
2903                tokens.push(buf.clone());
2904                buf.clear();
2905            }
2906            idx += 1;
2907            continue;
2908        }
2909
2910        buf.push(ch);
2911        idx += 1;
2912    }
2913
2914    if !buf.is_empty() {
2915        tokens.push(buf);
2916    }
2917
2918    tokens
2919}
2920
2921fn shorten_module_path(label: &str, max_width: f64, font_size: usize) -> String {
2922    let segments: Vec<&str> = label.split("::").collect();
2923    if segments.len() <= 1 {
2924        return label.to_string();
2925    }
2926
2927    for keep in (1..=segments.len()).rev() {
2928        let slice = &segments[segments.len() - keep..];
2929        let mut candidate = slice.join(MODULE_SEPARATOR);
2930        if keep < segments.len() {
2931            candidate = format!("{MODULE_TRUNC_MARKER}{MODULE_SEPARATOR}{candidate}");
2932        }
2933        if get_size_for_str(&candidate, font_size).x <= max_width {
2934            return candidate;
2935        }
2936    }
2937
2938    format!(
2939        "{MODULE_TRUNC_MARKER}{MODULE_SEPARATOR}{}",
2940        segments.last().unwrap_or(&label)
2941    )
2942}
2943
2944fn truncate_label_left(label: &str, max_chars: usize) -> String {
2945    if max_chars == 0 {
2946        return String::new();
2947    }
2948    let count = label.chars().count();
2949    if count <= max_chars {
2950        return label.to_string();
2951    }
2952    let keep = max_chars.saturating_sub(1);
2953    let tail: String = label
2954        .chars()
2955        .rev()
2956        .take(keep)
2957        .collect::<Vec<_>>()
2958        .into_iter()
2959        .rev()
2960        .collect();
2961    format!("{MODULE_TRUNC_MARKER}{tail}")
2962}
2963
2964fn strip_type_params(label: &str) -> String {
2965    let mut depth = 0usize;
2966    let mut out = String::new();
2967    for ch in label.chars() {
2968        match ch {
2969            '<' => {
2970                depth += 1;
2971            }
2972            '>' => {
2973                depth = depth.saturating_sub(1);
2974            }
2975            _ => {
2976                if depth == 0 {
2977                    out.push(ch);
2978                }
2979            }
2980        }
2981    }
2982    out
2983}
2984
2985fn scale_layout_positions(graph: &mut VisualGraph) {
2986    for handle in graph.iter_nodes() {
2987        let center = graph.element(handle).position().center();
2988        let scaled = Point::new(center.x * LAYOUT_SCALE_X, center.y * LAYOUT_SCALE_Y);
2989        graph.element_mut(handle).position_mut().move_to(scaled);
2990    }
2991}