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