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