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
29const 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
45const 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
62const 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
74const 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 #[clap(value_parser)]
116 config: PathBuf,
117 #[clap(long)]
119 mission: Option<String>,
120 #[clap(long, action)]
122 list_missions: bool,
123 #[clap(long)]
125 open: bool,
126}
127
128fn main() -> std::io::Result<()> {
131 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 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 let mut svg_file = std::fs::File::create("output.svg")?;
171 svg_file.write_all(graph_svg.as_slice())?;
172 }
173 Ok(())
174}
175
176fn 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
195fn 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, §ion)?);
201 }
202
203 Ok(render_sections_to_svg(&layouts).into_bytes())
204}
205
206fn 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
241fn 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
397fn 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
452fn 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
500fn 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
536fn 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
546fn 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
571fn 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
636fn 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) = §ion.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) = §ion.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 §ion.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
954fn 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
985fn 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
1004fn 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
1204fn 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
1229fn 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
1243fn 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("&"),
1884 '<' => res.push_str("<"),
1885 '>' => res.push_str(">"),
1886 '"' => res.push_str("""),
1887 '\'' => res.push_str("'"),
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 §ion.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); type StraightEdgeEntry = (usize, Point, Point); 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); type DetourEdgeEntry = (usize, f64, f64, f64); 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}