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