1use crate::json::from_value_ref;
2use crate::model::{Bounds, LayoutEdge, LayoutNode, LayoutPoint, MindmapDiagramLayout};
3use crate::text::WrapMode;
4use crate::text::{TextMeasurer, TextStyle};
5use crate::{Error, Result};
6use merman_core::MAX_DIAGRAM_NESTING_DEPTH;
7use serde_json::Value;
8
9fn config_f64(cfg: &Value, path: &[&str]) -> Option<f64> {
10 let mut v = cfg;
11 for p in path {
12 v = v.get(*p)?;
13 }
14 v.as_f64()
15 .or_else(|| v.as_i64().map(|n| n as f64))
16 .or_else(|| v.as_u64().map(|n| n as f64))
17}
18
19fn config_string(cfg: &Value, path: &[&str]) -> Option<String> {
20 let mut v = cfg;
21 for p in path {
22 v = v.get(*p)?;
23 }
24 v.as_str().map(|s| s.to_string())
25}
26
27fn parse_css_px_to_f64(text: &str) -> Option<f64> {
28 let trimmed = text.trim();
29 let raw = trimmed.strip_suffix("px").unwrap_or(trimmed).trim();
30 raw.parse::<f64>().ok().filter(|value| value.is_finite())
31}
32
33pub(crate) fn mindmap_max_node_width_px(effective_config: &Value) -> f64 {
34 config_f64(effective_config, &["mindmap", "maxNodeWidth"])
35 .or_else(|| {
36 config_string(effective_config, &["mindmap", "maxNodeWidth"])
37 .and_then(|value| parse_css_px_to_f64(&value))
38 })
39 .unwrap_or(200.0)
40 .max(1.0)
41}
42
43type MindmapModel = merman_core::diagrams::mindmap::MindmapDiagramRenderModel;
44type MindmapNodeModel = merman_core::diagrams::mindmap::MindmapDiagramRenderNode;
45
46fn mindmap_text_style(effective_config: &Value) -> TextStyle {
47 let font_family = config_string(effective_config, &["fontFamily"])
49 .or_else(|| config_string(effective_config, &["themeVariables", "fontFamily"]))
50 .or_else(|| Some("\"trebuchet ms\", verdana, arial, sans-serif".to_string()));
51 let font_size = 16.0;
55 TextStyle {
56 font_family,
57 font_size,
58 font_weight: None,
59 }
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_label_bbox_px(
109 text: &str,
110 label_type: &str,
111 measurer: &dyn TextMeasurer,
112 style: &TextStyle,
113 max_node_width_px: f64,
114) -> (f64, f64) {
115 let max_node_width_px = max_node_width_px.max(1.0);
122
123 if label_type == "markdown" && !is_simple_markdown_label(text) {
126 if text.contains("![") {
127 let wrapped = crate::text::measure_markdown_with_flowchart_bold_deltas(
128 measurer,
129 text,
130 style,
131 Some(max_node_width_px),
132 WrapMode::HtmlLike,
133 );
134 let unwrapped = crate::text::measure_markdown_with_flowchart_bold_deltas(
135 measurer,
136 text,
137 style,
138 None,
139 WrapMode::HtmlLike,
140 );
141 return (
142 wrapped.width.max(unwrapped.width).max(0.0),
143 wrapped.height.max(0.0),
144 );
145 }
146
147 let html = crate::text::mermaid_markdown_to_xhtml_label_fragment(text, true);
148 let wrapped = crate::text::measure_html_with_flowchart_bold_deltas(
149 measurer,
150 &html,
151 style,
152 Some(max_node_width_px),
153 WrapMode::HtmlLike,
154 );
155 let unwrapped = crate::text::measure_html_with_flowchart_bold_deltas(
156 measurer,
157 &html,
158 style,
159 None,
160 WrapMode::HtmlLike,
161 );
162 return (
163 wrapped.width.max(unwrapped.width).max(0.0),
164 wrapped.height.max(0.0),
165 );
166 }
167
168 let (wrapped, raw_width_px) = measurer.measure_wrapped_with_raw_width(
169 text,
170 style,
171 Some(max_node_width_px),
172 WrapMode::HtmlLike,
173 );
174
175 let overflow_width_px = raw_width_px.unwrap_or_else(|| {
181 measurer
182 .measure_wrapped(text, style, None, WrapMode::HtmlLike)
183 .width
184 });
185
186 (
187 wrapped.width.max(overflow_width_px).max(0.0),
188 wrapped.height.max(0.0),
189 )
190}
191
192fn mindmap_node_dimensions_px(
193 node: &MindmapNodeModel,
194 measurer: &dyn TextMeasurer,
195 style: &TextStyle,
196 max_node_width_px: f64,
197) -> (f64, f64, f64, f64) {
198 let (bbox_w, bbox_h) = mindmap_label_bbox_px(
199 &node.label,
200 &node.label_type,
201 measurer,
202 style,
203 max_node_width_px,
204 );
205 let padding = match node.shape.as_str() {
211 "rounded" => 15.0,
212 _ => node.padding.max(0.0),
213 };
214 let half_padding = padding / 2.0;
215
216 let (w, h) = match node.shape.as_str() {
219 "" | "defaultMindmapNode" => (bbox_w + 8.0 * half_padding, bbox_h + 2.0 * half_padding),
221 "rect" => (bbox_w + 2.0 * padding, bbox_h + padding),
228 "rounded" => (bbox_w + 2.0 * padding, bbox_h + 2.0 * padding),
229 "mindmapCircle" => {
231 let d = bbox_w + 2.0 * padding;
232 (d, d)
233 }
234 "cloud" => (bbox_w + 2.0 * half_padding, bbox_h + 2.0 * half_padding),
236 "bang" => {
241 let w = bbox_w + 10.0 * half_padding;
242 let h = bbox_h + 8.0 * half_padding;
243 let min_w = bbox_w + 20.0;
244 let min_h = bbox_h + 20.0;
245 (w.max(min_w), h.max(min_h))
246 }
247 "hexagon" => {
250 let w = bbox_w + 2.5 * padding;
251 let h = bbox_h + padding;
252 (w * (7.0 / 6.0), h)
253 }
254 _ => (bbox_w + 8.0 * half_padding, bbox_h + 2.0 * half_padding),
255 };
256
257 (w, h, bbox_w, bbox_h)
258}
259
260fn compute_bounds(nodes: &[LayoutNode], edges: &[LayoutEdge]) -> Option<Bounds> {
261 let mut pts: Vec<(f64, f64)> = Vec::new();
262 for n in nodes {
263 let x0 = n.x - n.width / 2.0;
264 let y0 = n.y - n.height / 2.0;
265 let x1 = n.x + n.width / 2.0;
266 let y1 = n.y + n.height / 2.0;
267 pts.push((x0, y0));
268 pts.push((x1, y1));
269 }
270 for e in edges {
271 for p in &e.points {
272 pts.push((p.x, p.y));
273 }
274 }
275 Bounds::from_points(pts)
276}
277
278fn shift_nodes_to_positive_bounds(nodes: &mut [LayoutNode], content_min: f64) {
279 if nodes.is_empty() {
280 return;
281 }
282 let mut min_x = f64::INFINITY;
283 let mut min_y = f64::INFINITY;
284 for n in nodes.iter() {
285 min_x = min_x.min(n.x - n.width / 2.0);
286 min_y = min_y.min(n.y - n.height / 2.0);
287 }
288 if !(min_x.is_finite() && min_y.is_finite()) {
289 return;
290 }
291 let dx = content_min - min_x;
292 let dy = content_min - min_y;
293 for n in nodes.iter_mut() {
294 n.x += dx;
295 n.y += dy;
296 }
297}
298
299pub fn layout_mindmap_diagram(
300 model: &Value,
301 effective_config: &Value,
302 text_measurer: &dyn TextMeasurer,
303 use_manatee_layout: bool,
304) -> Result<MindmapDiagramLayout> {
305 let model: MindmapModel = from_value_ref(model)?;
306 layout_mindmap_diagram_model(&model, effective_config, text_measurer, use_manatee_layout)
307}
308
309pub fn layout_mindmap_diagram_typed(
310 model: &MindmapModel,
311 effective_config: &Value,
312 text_measurer: &dyn TextMeasurer,
313 use_manatee_layout: bool,
314) -> Result<MindmapDiagramLayout> {
315 layout_mindmap_diagram_model(model, effective_config, text_measurer, use_manatee_layout)
316}
317
318fn layout_mindmap_diagram_model(
319 model: &MindmapModel,
320 effective_config: &Value,
321 text_measurer: &dyn TextMeasurer,
322 use_manatee_layout: bool,
323) -> Result<MindmapDiagramLayout> {
324 validate_mindmap_model_depth(model)?;
325 let timing_enabled = std::env::var("MERMAN_MINDMAP_LAYOUT_TIMING")
326 .ok()
327 .as_deref()
328 == Some("1");
329 #[derive(Debug, Default, Clone)]
330 struct MindmapLayoutTimings {
331 total: std::time::Duration,
332 measure_nodes: std::time::Duration,
333 manatee: std::time::Duration,
334 build_edges: std::time::Duration,
335 bounds: std::time::Duration,
336 }
337 let mut timings = MindmapLayoutTimings::default();
338 let total_start = timing_enabled.then(std::time::Instant::now);
339
340 let text_style = mindmap_text_style(effective_config);
341 let max_node_width_px = mindmap_max_node_width_px(effective_config);
342
343 let measure_nodes_start = timing_enabled.then(std::time::Instant::now);
344 let mut nodes_sorted: Vec<(i64, &MindmapNodeModel)> = model
345 .nodes
346 .iter()
347 .map(|n| (n.id.parse::<i64>().unwrap_or(i64::MAX), n))
348 .collect();
349 nodes_sorted.sort_by(|(na, a), (nb, b)| na.cmp(nb).then_with(|| a.id.cmp(&b.id)));
350
351 let mut nodes: Vec<LayoutNode> = Vec::with_capacity(model.nodes.len());
352 for (_id_num, n) in nodes_sorted {
353 let (width, height, label_width, label_height) =
354 mindmap_node_dimensions_px(n, text_measurer, &text_style, max_node_width_px);
355
356 nodes.push(LayoutNode {
357 id: n.id.clone(),
358 x: 0.0,
361 y: 0.0,
362 width: width.max(1.0),
363 height: height.max(1.0),
364 is_cluster: false,
365 label_width: Some(label_width.max(0.0)),
366 label_height: Some(label_height.max(0.0)),
367 });
368 }
369 if let Some(s) = measure_nodes_start {
370 timings.measure_nodes = s.elapsed();
371 }
372
373 let mut id_to_idx: rustc_hash::FxHashMap<&str, usize> =
374 rustc_hash::FxHashMap::with_capacity_and_hasher(nodes.len(), Default::default());
375 for (idx, n) in nodes.iter().enumerate() {
376 id_to_idx.insert(n.id.as_str(), idx);
377 }
378
379 let mut edge_indices: Vec<(usize, usize)> = Vec::with_capacity(model.edges.len());
380 for e in &model.edges {
381 let Some(&a) = id_to_idx.get(e.start.as_str()) else {
382 return Err(Error::InvalidModel {
383 message: format!("edge start node not found: {}", e.start),
384 });
385 };
386 let Some(&b) = id_to_idx.get(e.end.as_str()) else {
387 return Err(Error::InvalidModel {
388 message: format!("edge end node not found: {}", e.end),
389 });
390 };
391 edge_indices.push((a, b));
392 }
393
394 if use_manatee_layout {
395 let manatee_start = timing_enabled.then(std::time::Instant::now);
396 let indexed_nodes: Vec<manatee::algo::cose_bilkent::IndexedNode> = nodes
397 .iter()
398 .map(|n| manatee::algo::cose_bilkent::IndexedNode {
399 width: n.width,
400 height: n.height,
401 x: n.x,
402 y: n.y,
403 })
404 .collect();
405 let mut indexed_edges: Vec<manatee::algo::cose_bilkent::IndexedEdge> =
406 Vec::with_capacity(model.edges.len());
407 for (edge_idx, (a, b)) in edge_indices.iter().copied().enumerate() {
408 if a == b {
409 continue;
410 }
411 indexed_edges.push(manatee::algo::cose_bilkent::IndexedEdge { a, b });
412
413 let _ = edge_idx;
416 }
417
418 let positions = manatee::algo::cose_bilkent::layout_indexed(
419 &indexed_nodes,
420 &indexed_edges,
421 &Default::default(),
422 )
423 .map_err(|e| Error::InvalidModel {
424 message: format!("manatee layout failed: {e}"),
425 })?;
426
427 for (n, p) in nodes.iter_mut().zip(positions) {
428 n.x = p.x;
429 n.y = p.y;
430 }
431 if let Some(s) = manatee_start {
432 timings.manatee = s.elapsed();
433 }
434 }
435
436 shift_nodes_to_positive_bounds(&mut nodes, 15.0);
442
443 let build_edges_start = timing_enabled.then(std::time::Instant::now);
444 let mut edges: Vec<LayoutEdge> = Vec::new();
445 edges.reserve(model.edges.len());
446 for (e, (sidx, tidx)) in model.edges.iter().zip(edge_indices.iter().copied()) {
447 let (sx, sy) = (nodes[sidx].x, nodes[sidx].y);
448 let (tx, ty) = (nodes[tidx].x, nodes[tidx].y);
449 let points = vec![LayoutPoint { x: sx, y: sy }, LayoutPoint { x: tx, y: ty }];
450 edges.push(LayoutEdge {
451 id: e.id.clone(),
452 from: e.start.clone(),
453 to: e.end.clone(),
454 from_cluster: None,
455 to_cluster: None,
456 points,
457 label: None,
458 start_label_left: None,
459 start_label_right: None,
460 end_label_left: None,
461 end_label_right: None,
462 start_marker: None,
463 end_marker: None,
464 stroke_dasharray: None,
465 });
466 }
467 if let Some(s) = build_edges_start {
468 timings.build_edges = s.elapsed();
469 }
470
471 let bounds_start = timing_enabled.then(std::time::Instant::now);
472 let bounds = compute_bounds(&nodes, &edges);
473 if let Some(s) = bounds_start {
474 timings.bounds = s.elapsed();
475 }
476 if let Some(s) = total_start {
477 timings.total = s.elapsed();
478 eprintln!(
479 "[layout-timing] diagram=mindmap total={:?} measure_nodes={:?} manatee={:?} build_edges={:?} bounds={:?} nodes={} edges={}",
480 timings.total,
481 timings.measure_nodes,
482 timings.manatee,
483 timings.build_edges,
484 timings.bounds,
485 nodes.len(),
486 edges.len(),
487 );
488 }
489 Ok(MindmapDiagramLayout {
490 nodes,
491 edges,
492 bounds,
493 })
494}
495
496fn validate_mindmap_model_depth(model: &MindmapModel) -> Result<()> {
497 for node in &model.nodes {
498 if usize::try_from(node.level).is_ok_and(|depth| depth > MAX_DIAGRAM_NESTING_DEPTH) {
499 return Err(Error::InvalidModel {
500 message: format!(
501 "mindmap nesting depth exceeds maximum of {MAX_DIAGRAM_NESTING_DEPTH}"
502 ),
503 });
504 }
505 }
506 Ok(())
507}
508
509#[cfg(test)]
510mod tests {
511 #[test]
512 fn mindmap_max_node_width_accepts_number_and_px_string() {
513 let numeric = serde_json::json!({
514 "mindmap": {
515 "maxNodeWidth": 320
516 }
517 });
518 assert_eq!(super::mindmap_max_node_width_px(&numeric), 320.0);
519
520 let px_string = serde_json::json!({
521 "mindmap": {
522 "maxNodeWidth": "280px"
523 }
524 });
525 assert_eq!(super::mindmap_max_node_width_px(&px_string), 280.0);
526
527 let plain_string = serde_json::json!({
528 "mindmap": {
529 "maxNodeWidth": "240"
530 }
531 });
532 assert_eq!(super::mindmap_max_node_width_px(&plain_string), 240.0);
533
534 let fallback = serde_json::json!({});
535 assert_eq!(super::mindmap_max_node_width_px(&fallback), 200.0);
536 }
537}