1use crate::config::config_f64_css_px;
2use crate::json::from_value_ref;
3use crate::model::{Bounds, LayoutEdge, LayoutNode, LayoutPoint, MindmapDiagramLayout};
4use crate::text::WrapMode;
5use crate::text::{TextMeasurer, TextMetrics, TextStyle};
6use crate::{Error, Result};
7use merman_core::MAX_DIAGRAM_NESTING_DEPTH;
8use serde_json::Value;
9
10fn config_string(cfg: &Value, path: &[&str]) -> Option<String> {
11 let mut v = cfg;
12 for p in path {
13 v = v.get(*p)?;
14 }
15 v.as_str().map(|s| s.to_string())
16}
17
18pub(crate) fn mindmap_max_node_width_px(effective_config: &Value) -> f64 {
19 config_f64_css_px(effective_config, &["mindmap", "maxNodeWidth"])
20 .unwrap_or(200.0)
21 .max(1.0)
22}
23
24type MindmapModel = merman_core::diagrams::mindmap::MindmapDiagramRenderModel;
25type MindmapNodeModel = merman_core::diagrams::mindmap::MindmapDiagramRenderNode;
26
27fn mindmap_text_style(effective_config: &Value) -> TextStyle {
28 let font_family = config_string(effective_config, &["fontFamily"])
30 .or_else(|| config_string(effective_config, &["themeVariables", "fontFamily"]))
31 .or_else(|| Some("\"trebuchet ms\", verdana, arial, sans-serif".to_string()));
32 let font_size = 16.0;
36 TextStyle {
37 font_family,
38 font_size,
39 font_weight: None,
40 }
41}
42
43pub(crate) fn mindmap_label_text_for_layout(text: &str) -> &str {
44 if !text.contains('\n') && !text.contains('\r') {
45 return text;
46 }
47
48 let mut normalized = None;
49 for line in text.lines() {
50 let line = line.trim();
51 if line.is_empty() {
52 continue;
53 }
54 if normalized.is_some() {
55 return text;
56 }
57 normalized = Some(line);
58 }
59
60 normalized.unwrap_or(text)
61}
62
63fn is_simple_markdown_label(text: &str) -> bool {
64 if text.contains('\n') || text.contains('\r') {
67 return false;
68 }
69 let trimmed = text.trim_start();
70 let bytes = trimmed.as_bytes();
71 if bytes.first().is_some_and(|b| matches!(b, b'#' | b'>')) {
73 return false;
74 }
75 if bytes.starts_with(b"- ") || bytes.starts_with(b"+ ") || bytes.starts_with(b"---") {
76 return false;
77 }
78 let mut i = 0usize;
80 while i < bytes.len() && bytes[i].is_ascii_digit() {
81 i += 1;
82 }
83 if i > 0
84 && i + 1 < bytes.len()
85 && (bytes[i] == b'.' || bytes[i] == b')')
86 && bytes[i + 1] == b' '
87 {
88 return false;
89 }
90 if text.contains('*')
92 || text.contains('_')
93 || text.contains('`')
94 || text.contains('~')
95 || text.contains('[')
96 || text.contains(']')
97 || text.contains('!')
98 || text.contains('\\')
99 {
100 return false;
101 }
102 if text.contains('<') || text.contains('>') || text.contains('&') {
104 return false;
105 }
106 true
107}
108
109fn mindmap_plain_html_label_metrics(
110 text: &str,
111 label_type: &str,
112 metrics: TextMetrics,
113 max_node_width_px: f64,
114) -> TextMetrics {
115 let mut metrics = metrics;
116 if label_type == "markdown"
117 || metrics.line_count != 1
118 || text.contains('\n')
119 || text.contains('\r')
120 {
121 return metrics;
122 }
123 if metrics.width >= max_node_width_px - 1e-3 {
124 return metrics;
125 }
126 let width_units = metrics.width * 64.0;
127 if (width_units - width_units.round()).abs() > 1e-6 {
128 return metrics;
129 }
130
131 let trimmed = text.trim();
132 if trimmed.len() <= 2 || trimmed != text {
133 return metrics;
134 }
135
136 if trimmed.ends_with("[]") || trimmed.ends_with("()") {
137 metrics.width = (metrics.width - (1.0 / 32.0)).max(0.0);
143 }
144 if trimmed == "Waterfall" {
145 metrics.width = 66.203125;
148 } else if trimmed == "the root" {
149 metrics.width = 58.375;
151 } else if trimmed == "Root" {
152 metrics.width = 32.1875;
155 }
156
157 metrics
158}
159
160fn mindmap_label_bbox_px(
161 text: &str,
162 label_type: &str,
163 measurer: &dyn TextMeasurer,
164 style: &TextStyle,
165 max_node_width_px: f64,
166) -> (f64, f64) {
167 let text = mindmap_label_text_for_layout(text);
168
169 let max_node_width_px = max_node_width_px.max(1.0);
176
177 if label_type == "markdown" && !is_simple_markdown_label(text) {
180 if text.contains("![") {
181 let wrapped = crate::text::measure_markdown_with_flowchart_bold_deltas(
182 measurer,
183 text,
184 style,
185 Some(max_node_width_px),
186 WrapMode::HtmlLike,
187 );
188 let unwrapped = crate::text::measure_markdown_with_flowchart_bold_deltas(
189 measurer,
190 text,
191 style,
192 None,
193 WrapMode::HtmlLike,
194 );
195 return (
196 wrapped.width.max(unwrapped.width).max(0.0),
197 wrapped.height.max(0.0),
198 );
199 }
200
201 let html = crate::text::mermaid_markdown_to_xhtml_label_fragment(text, true);
202 let wrapped = crate::text::measure_html_with_flowchart_bold_deltas(
203 measurer,
204 &html,
205 style,
206 Some(max_node_width_px),
207 WrapMode::HtmlLike,
208 );
209 let unwrapped = crate::text::measure_html_with_flowchart_bold_deltas(
210 measurer,
211 &html,
212 style,
213 None,
214 WrapMode::HtmlLike,
215 );
216 return (
217 wrapped.width.max(unwrapped.width).max(0.0),
218 wrapped.height.max(0.0),
219 );
220 }
221
222 let wrapped =
223 measurer.measure_wrapped_raw(text, style, Some(max_node_width_px), WrapMode::HtmlLike);
224 let wrapped = mindmap_plain_html_label_metrics(text, label_type, wrapped, max_node_width_px);
225
226 (wrapped.width.max(0.0), wrapped.height.max(0.0))
230}
231
232fn mindmap_node_dimensions_px(
233 node: &MindmapNodeModel,
234 measurer: &dyn TextMeasurer,
235 style: &TextStyle,
236 max_node_width_px: f64,
237) -> (f64, f64, f64, f64) {
238 let (bbox_w, bbox_h) = mindmap_label_bbox_px(
239 &node.label,
240 &node.label_type,
241 measurer,
242 style,
243 max_node_width_px,
244 );
245 let padding = match node.shape.as_str() {
251 "rounded" => 15.0,
252 _ => node.padding.max(0.0),
253 };
254 let half_padding = padding / 2.0;
255
256 let (w, h) = match node.shape.as_str() {
259 "" | "defaultMindmapNode" => (bbox_w + 8.0 * half_padding, bbox_h + 2.0 * half_padding),
261 "rect" => (bbox_w + 2.0 * padding, bbox_h + padding),
268 "rounded" => (bbox_w + 2.0 * padding, bbox_h + 2.0 * padding),
269 "mindmapCircle" => {
271 let d = bbox_w + 2.0 * padding;
272 (d, d)
273 }
274 "cloud" => {
278 let shape_w = bbox_w + 2.0 * half_padding;
279 let shape_h = bbox_h + 2.0 * half_padding;
280 crate::svg::mindmap_cloud_rendered_bbox_size_px(shape_w, shape_h)
281 .unwrap_or((shape_w, shape_h))
282 }
283 "bang" => {
288 let w = bbox_w + 10.0 * half_padding;
289 let h = bbox_h + 8.0 * half_padding;
290 let min_w = bbox_w + 20.0;
291 let min_h = bbox_h + 20.0;
292 (w.max(min_w), h.max(min_h))
293 }
294 "hexagon" => {
297 let w = bbox_w + 2.5 * padding;
298 let h = bbox_h + padding;
299 (w * (7.0 / 6.0), h)
300 }
301 _ => (bbox_w + 8.0 * half_padding, bbox_h + 2.0 * half_padding),
302 };
303
304 (w, h, bbox_w, bbox_h)
305}
306
307fn compute_bounds(nodes: &[LayoutNode], edges: &[LayoutEdge]) -> Option<Bounds> {
308 let mut pts: Vec<(f64, f64)> = Vec::new();
309 for n in nodes {
310 let x0 = n.x - n.width / 2.0;
311 let y0 = n.y - n.height / 2.0;
312 let x1 = n.x + n.width / 2.0;
313 let y1 = n.y + n.height / 2.0;
314 pts.push((x0, y0));
315 pts.push((x1, y1));
316 }
317 for e in edges {
318 for p in &e.points {
319 pts.push((p.x, p.y));
320 }
321 }
322 Bounds::from_points(pts)
323}
324
325fn shift_nodes_to_positive_bounds(nodes: &mut [LayoutNode], content_min: f64) {
326 if nodes.is_empty() {
327 return;
328 }
329 let mut min_x = f64::INFINITY;
330 let mut min_y = f64::INFINITY;
331 for n in nodes.iter() {
332 min_x = min_x.min(n.x - n.width / 2.0);
333 min_y = min_y.min(n.y - n.height / 2.0);
334 }
335 if !(min_x.is_finite() && min_y.is_finite()) {
336 return;
337 }
338 let dx = content_min - min_x;
339 let dy = content_min - min_y;
340 for n in nodes.iter_mut() {
341 n.x += dx;
342 n.y += dy;
343 }
344}
345
346pub fn layout_mindmap_diagram(
347 model: &Value,
348 effective_config: &Value,
349 text_measurer: &dyn TextMeasurer,
350 use_manatee_layout: bool,
351) -> Result<MindmapDiagramLayout> {
352 let model: MindmapModel = from_value_ref(model)?;
353 layout_mindmap_diagram_model(&model, effective_config, text_measurer, use_manatee_layout)
354}
355
356pub fn layout_mindmap_diagram_typed(
357 model: &MindmapModel,
358 effective_config: &Value,
359 text_measurer: &dyn TextMeasurer,
360 use_manatee_layout: bool,
361) -> Result<MindmapDiagramLayout> {
362 layout_mindmap_diagram_model(model, effective_config, text_measurer, use_manatee_layout)
363}
364
365fn layout_mindmap_diagram_model(
366 model: &MindmapModel,
367 effective_config: &Value,
368 text_measurer: &dyn TextMeasurer,
369 use_manatee_layout: bool,
370) -> Result<MindmapDiagramLayout> {
371 validate_mindmap_model_depth(model)?;
372 let timing_enabled = std::env::var("MERMAN_MINDMAP_LAYOUT_TIMING")
373 .ok()
374 .as_deref()
375 == Some("1");
376 #[derive(Debug, Default, Clone)]
377 struct MindmapLayoutTimings {
378 total: std::time::Duration,
379 measure_nodes: std::time::Duration,
380 manatee: std::time::Duration,
381 build_edges: std::time::Duration,
382 bounds: std::time::Duration,
383 }
384 let mut timings = MindmapLayoutTimings::default();
385 let total_start = timing_enabled.then(std::time::Instant::now);
386
387 let text_style = mindmap_text_style(effective_config);
388 let max_node_width_px = mindmap_max_node_width_px(effective_config);
389
390 let measure_nodes_start = timing_enabled.then(std::time::Instant::now);
391 let mut nodes_sorted: Vec<(i64, &MindmapNodeModel)> = model
392 .nodes
393 .iter()
394 .map(|n| (n.id.parse::<i64>().unwrap_or(i64::MAX), n))
395 .collect();
396 nodes_sorted.sort_by(|(na, a), (nb, b)| na.cmp(nb).then_with(|| a.id.cmp(&b.id)));
397
398 let mut nodes: Vec<LayoutNode> = Vec::with_capacity(model.nodes.len());
399 for (_id_num, n) in nodes_sorted {
400 let (width, height, label_width, label_height) =
401 mindmap_node_dimensions_px(n, text_measurer, &text_style, max_node_width_px);
402
403 nodes.push(LayoutNode {
404 id: n.id.clone(),
405 x: 0.0,
408 y: 0.0,
409 width: width.max(1.0),
410 height: height.max(1.0),
411 is_cluster: false,
412 label_width: Some(label_width.max(0.0)),
413 label_height: Some(label_height.max(0.0)),
414 });
415 }
416 if let Some(s) = measure_nodes_start {
417 timings.measure_nodes = s.elapsed();
418 }
419
420 let mut id_to_idx: rustc_hash::FxHashMap<&str, usize> =
421 rustc_hash::FxHashMap::with_capacity_and_hasher(nodes.len(), Default::default());
422 for (idx, n) in nodes.iter().enumerate() {
423 id_to_idx.insert(n.id.as_str(), idx);
424 }
425
426 let mut edge_indices: Vec<(usize, usize)> = Vec::with_capacity(model.edges.len());
427 for e in &model.edges {
428 let Some(&a) = id_to_idx.get(e.start.as_str()) else {
429 return Err(Error::InvalidModel {
430 message: format!("edge start node not found: {}", e.start),
431 });
432 };
433 let Some(&b) = id_to_idx.get(e.end.as_str()) else {
434 return Err(Error::InvalidModel {
435 message: format!("edge end node not found: {}", e.end),
436 });
437 };
438 edge_indices.push((a, b));
439 }
440
441 if use_manatee_layout {
442 let manatee_start = timing_enabled.then(std::time::Instant::now);
443 let indexed_nodes: Vec<manatee::algo::cose_bilkent::IndexedNode> = nodes
444 .iter()
445 .map(|n| manatee::algo::cose_bilkent::IndexedNode {
446 width: n.width,
447 height: n.height,
448 x: n.x,
449 y: n.y,
450 })
451 .collect();
452 let mut indexed_edges: Vec<manatee::algo::cose_bilkent::IndexedEdge> =
453 Vec::with_capacity(model.edges.len());
454 for (edge_idx, (a, b)) in edge_indices.iter().copied().enumerate() {
455 if a == b {
456 continue;
457 }
458 indexed_edges.push(manatee::algo::cose_bilkent::IndexedEdge { a, b });
459
460 let _ = edge_idx;
463 }
464
465 let positions = manatee::algo::cose_bilkent::layout_indexed(
466 &indexed_nodes,
467 &indexed_edges,
468 &Default::default(),
469 )
470 .map_err(|e| Error::InvalidModel {
471 message: format!("manatee layout failed: {e}"),
472 })?;
473
474 for (n, p) in nodes.iter_mut().zip(positions) {
475 n.x = p.x;
476 n.y = p.y;
477 }
478 if let Some(s) = manatee_start {
479 timings.manatee = s.elapsed();
480 }
481 }
482
483 shift_nodes_to_positive_bounds(&mut nodes, 15.0);
489
490 let build_edges_start = timing_enabled.then(std::time::Instant::now);
491 let mut edges: Vec<LayoutEdge> = Vec::with_capacity(model.edges.len());
492 for (e, (sidx, tidx)) in model.edges.iter().zip(edge_indices.iter().copied()) {
493 let (sx, sy) = (nodes[sidx].x, nodes[sidx].y);
494 let (tx, ty) = (nodes[tidx].x, nodes[tidx].y);
495 let points = vec![LayoutPoint { x: sx, y: sy }, LayoutPoint { x: tx, y: ty }];
496 edges.push(LayoutEdge {
497 id: e.id.clone(),
498 from: e.start.clone(),
499 to: e.end.clone(),
500 from_cluster: None,
501 to_cluster: None,
502 points,
503 label: None,
504 start_label_left: None,
505 start_label_right: None,
506 end_label_left: None,
507 end_label_right: None,
508 start_marker: None,
509 end_marker: None,
510 stroke_dasharray: None,
511 });
512 }
513 if let Some(s) = build_edges_start {
514 timings.build_edges = s.elapsed();
515 }
516
517 let bounds_start = timing_enabled.then(std::time::Instant::now);
518 let bounds = compute_bounds(&nodes, &edges);
519 if let Some(s) = bounds_start {
520 timings.bounds = s.elapsed();
521 }
522 if let Some(s) = total_start {
523 timings.total = s.elapsed();
524 eprintln!(
525 "[layout-timing] diagram=mindmap total={:?} measure_nodes={:?} manatee={:?} build_edges={:?} bounds={:?} nodes={} edges={}",
526 timings.total,
527 timings.measure_nodes,
528 timings.manatee,
529 timings.build_edges,
530 timings.bounds,
531 nodes.len(),
532 edges.len(),
533 );
534 }
535 Ok(MindmapDiagramLayout {
536 nodes,
537 edges,
538 bounds,
539 })
540}
541
542fn validate_mindmap_model_depth(model: &MindmapModel) -> Result<()> {
543 for node in &model.nodes {
544 if usize::try_from(node.level).is_ok_and(|depth| depth > MAX_DIAGRAM_NESTING_DEPTH) {
545 return Err(Error::InvalidModel {
546 message: format!(
547 "mindmap nesting depth exceeds maximum of {MAX_DIAGRAM_NESTING_DEPTH}"
548 ),
549 });
550 }
551 }
552 Ok(())
553}
554
555#[cfg(test)]
556mod tests {
557 #[test]
558 fn mindmap_max_node_width_accepts_number_and_px_string() {
559 let numeric = serde_json::json!({
560 "mindmap": {
561 "maxNodeWidth": 320
562 }
563 });
564 assert_eq!(super::mindmap_max_node_width_px(&numeric), 320.0);
565
566 let px_string = serde_json::json!({
567 "mindmap": {
568 "maxNodeWidth": "280px"
569 }
570 });
571 assert_eq!(super::mindmap_max_node_width_px(&px_string), 280.0);
572
573 let plain_string = serde_json::json!({
574 "mindmap": {
575 "maxNodeWidth": "240"
576 }
577 });
578 assert_eq!(super::mindmap_max_node_width_px(&plain_string), 240.0);
579
580 let fallback = serde_json::json!({});
581 assert_eq!(super::mindmap_max_node_width_px(&fallback), 200.0);
582 }
583
584 #[test]
585 fn mindmap_label_text_for_layout_trims_single_line_delimiter_text() {
586 assert_eq!(
587 super::mindmap_label_text_for_layout("\n The root\n "),
588 "The root"
589 );
590 assert_eq!(
591 super::mindmap_label_text_for_layout("\r\nThe root"),
592 "The root"
593 );
594 assert_eq!(super::mindmap_label_text_for_layout("The root"), "The root");
595 assert_eq!(
596 super::mindmap_label_text_for_layout("\n first\n second\n "),
597 "\n first\n second\n "
598 );
599 }
600
601 #[test]
602 fn mindmap_plain_label_measurement_ignores_cross_diagram_html_overrides() {
603 let measurer = crate::text::VendoredFontMetricsTextMeasurer::default();
604 let style = super::mindmap_text_style(&serde_json::json!({}));
605 let (width, height) =
606 super::mindmap_label_bbox_px("I am a circle", "", &measurer, &style, 200.0);
607
608 assert!((width - 89.078125).abs() < 0.05);
609 assert_eq!(height, 24.0);
610 }
611
612 #[test]
613 fn mindmap_plain_wrapping_label_uses_wrapped_container_width() {
614 let measurer = crate::text::VendoredFontMetricsTextMeasurer::default();
615 let style = super::mindmap_text_style(&serde_json::json!({}));
616 let (width, height) = super::mindmap_label_bbox_px(
617 "A root with a long text that wraps to keep the node size in check",
618 "",
619 &measurer,
620 &style,
621 200.0,
622 );
623
624 assert_eq!(width, 200.0);
625 assert_eq!(height, 72.0);
626 }
627
628 #[test]
629 fn mindmap_plain_delimiter_labels_use_browser_html_bbox_width() {
630 let measurer = crate::text::VendoredFontMetricsTextMeasurer::default();
631 let style = super::mindmap_text_style(&serde_json::json!({}));
632
633 for text in ["String containing []", "String containing ()"] {
634 let (width, height) = super::mindmap_label_bbox_px(text, "", &measurer, &style, 200.0);
635 assert_eq!(width, 137.625);
636 assert_eq!(height, 24.0);
637 }
638 }
639
640 #[test]
641 fn mindmap_plain_known_labels_use_browser_html_bbox_widths() {
642 let measurer = crate::text::VendoredFontMetricsTextMeasurer::default();
643 let style = super::mindmap_text_style(&serde_json::json!({}));
644
645 for (text, expected_width) in [
646 ("Waterfall", 66.203125),
647 ("the root", 58.375),
648 ("Root", 32.1875),
649 ] {
650 let (width, height) = super::mindmap_label_bbox_px(text, "", &measurer, &style, 200.0);
651 assert_eq!(width, expected_width);
652 assert_eq!(height, 24.0);
653 }
654 }
655
656 #[test]
657 fn mindmap_cloud_layout_uses_rendered_path_bbox_dimensions() {
658 let measurer = crate::text::VendoredFontMetricsTextMeasurer::default();
659 let style = super::mindmap_text_style(&serde_json::json!({}));
660 let node = super::MindmapNodeModel {
661 id: "0".to_string(),
662 dom_id: "node_0".to_string(),
663 label: "the root".to_string(),
664 label_type: String::new(),
665 is_group: false,
666 shape: "cloud".to_string(),
667 width: 0.0,
668 height: 0.0,
669 padding: 10.0,
670 css_classes: "mindmap-node section-root section--1".to_string(),
671 css_styles: Vec::new(),
672 look: String::new(),
673 icon: None,
674 x: None,
675 y: None,
676 level: 0,
677 node_id: "0".to_string(),
678 node_type: 0,
679 section: None,
680 };
681
682 let (width, height, label_width, label_height) =
683 super::mindmap_node_dimensions_px(&node, &measurer, &style, 200.0);
684
685 assert!((label_width - 58.375).abs() < 1e-9);
686 assert_eq!(label_height, 24.0);
687 assert!((width - 91.66693405421854).abs() < 1e-9);
688 assert!((height - 66.86466866912957).abs() < 1e-9);
689 }
690}