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}
15
16fn config_string(cfg: &Value, path: &[&str]) -> Option<String> {
17 let mut v = cfg;
18 for p in path {
19 v = v.get(*p)?;
20 }
21 v.as_str().map(|s| s.to_string())
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 = config_f64(effective_config, &["fontSize"])
33 .unwrap_or(16.0)
34 .max(1.0);
35 TextStyle {
36 font_family,
37 font_size,
38 font_weight: None,
39 }
40}
41
42fn is_simple_markdown_label(text: &str) -> bool {
43 if text.contains('\n') || text.contains('\r') {
46 return false;
47 }
48 let trimmed = text.trim_start();
49 let bytes = trimmed.as_bytes();
50 if bytes.first().is_some_and(|b| matches!(b, b'#' | b'>')) {
52 return false;
53 }
54 if bytes.starts_with(b"- ") || bytes.starts_with(b"+ ") || bytes.starts_with(b"---") {
55 return false;
56 }
57 let mut i = 0usize;
59 while i < bytes.len() && bytes[i].is_ascii_digit() {
60 i += 1;
61 }
62 if i > 0
63 && i + 1 < bytes.len()
64 && (bytes[i] == b'.' || bytes[i] == b')')
65 && bytes[i + 1] == b' '
66 {
67 return false;
68 }
69 if text.contains('*')
71 || text.contains('_')
72 || text.contains('`')
73 || text.contains('~')
74 || text.contains('[')
75 || text.contains(']')
76 || text.contains('!')
77 || text.contains('\\')
78 {
79 return false;
80 }
81 if text.contains('<') || text.contains('>') || text.contains('&') {
83 return false;
84 }
85 true
86}
87
88fn mindmap_label_bbox_px(
89 text: &str,
90 label_type: &str,
91 measurer: &dyn TextMeasurer,
92 style: &TextStyle,
93 max_node_width_px: f64,
94) -> (f64, f64) {
95 let max_node_width_px = max_node_width_px.max(1.0);
102
103 if label_type == "markdown" && !is_simple_markdown_label(text) {
106 let wrapped = crate::text::measure_markdown_with_flowchart_bold_deltas(
107 measurer,
108 text,
109 style,
110 Some(max_node_width_px),
111 WrapMode::HtmlLike,
112 );
113 let unwrapped = crate::text::measure_markdown_with_flowchart_bold_deltas(
114 measurer,
115 text,
116 style,
117 None,
118 WrapMode::HtmlLike,
119 );
120 return (
121 wrapped.width.max(unwrapped.width).max(0.0),
122 wrapped.height.max(0.0),
123 );
124 }
125
126 let (wrapped, raw_width_px) = measurer.measure_wrapped_with_raw_width(
127 text,
128 style,
129 Some(max_node_width_px),
130 WrapMode::HtmlLike,
131 );
132
133 let overflow_width_px = raw_width_px.unwrap_or_else(|| {
139 measurer
140 .measure_wrapped(text, style, None, WrapMode::HtmlLike)
141 .width
142 });
143
144 (
145 wrapped.width.max(overflow_width_px).max(0.0),
146 wrapped.height.max(0.0),
147 )
148}
149
150fn mindmap_node_dimensions_px(
151 node: &MindmapNodeModel,
152 measurer: &dyn TextMeasurer,
153 style: &TextStyle,
154 max_node_width_px: f64,
155) -> (f64, f64, f64, f64) {
156 let (bbox_w, bbox_h) = mindmap_label_bbox_px(
157 &node.label,
158 &node.label_type,
159 measurer,
160 style,
161 max_node_width_px,
162 );
163 let padding = match node.shape.as_str() {
169 "rounded" => 15.0,
170 _ => node.padding.max(0.0),
171 };
172 let half_padding = padding / 2.0;
173
174 let (w, h) = match node.shape.as_str() {
177 "" | "defaultMindmapNode" => (bbox_w + 8.0 * half_padding, bbox_h + 2.0 * half_padding),
179 "rect" => (bbox_w + 2.0 * padding, bbox_h + padding),
186 "rounded" => (bbox_w + 2.0 * padding, bbox_h + 2.0 * padding),
187 "mindmapCircle" => {
189 let d = bbox_w + 2.0 * padding;
190 (d, d)
191 }
192 "cloud" => (bbox_w + 2.0 * half_padding, bbox_h + 2.0 * half_padding),
194 "bang" => {
199 let w = bbox_w + 10.0 * half_padding;
200 let h = bbox_h + 8.0 * half_padding;
201 let min_w = bbox_w + 20.0;
202 let min_h = bbox_h + 20.0;
203 (w.max(min_w), h.max(min_h))
204 }
205 "hexagon" => {
208 let w = bbox_w + 2.5 * padding;
209 let h = bbox_h + padding;
210 (w * (7.0 / 6.0), h)
211 }
212 _ => (bbox_w + 8.0 * half_padding, bbox_h + 2.0 * half_padding),
213 };
214
215 (w, h, bbox_w, bbox_h)
216}
217
218fn compute_bounds(nodes: &[LayoutNode], edges: &[LayoutEdge]) -> Option<Bounds> {
219 let mut pts: Vec<(f64, f64)> = Vec::new();
220 for n in nodes {
221 let x0 = n.x - n.width / 2.0;
222 let y0 = n.y - n.height / 2.0;
223 let x1 = n.x + n.width / 2.0;
224 let y1 = n.y + n.height / 2.0;
225 pts.push((x0, y0));
226 pts.push((x1, y1));
227 }
228 for e in edges {
229 for p in &e.points {
230 pts.push((p.x, p.y));
231 }
232 }
233 Bounds::from_points(pts)
234}
235
236fn shift_nodes_to_positive_bounds(nodes: &mut [LayoutNode], content_min: f64) {
237 if nodes.is_empty() {
238 return;
239 }
240 let mut min_x = f64::INFINITY;
241 let mut min_y = f64::INFINITY;
242 for n in nodes.iter() {
243 min_x = min_x.min(n.x - n.width / 2.0);
244 min_y = min_y.min(n.y - n.height / 2.0);
245 }
246 if !(min_x.is_finite() && min_y.is_finite()) {
247 return;
248 }
249 let dx = content_min - min_x;
250 let dy = content_min - min_y;
251 for n in nodes.iter_mut() {
252 n.x += dx;
253 n.y += dy;
254 }
255}
256
257pub fn layout_mindmap_diagram(
258 model: &Value,
259 effective_config: &Value,
260 text_measurer: &dyn TextMeasurer,
261 use_manatee_layout: bool,
262) -> Result<MindmapDiagramLayout> {
263 let model: MindmapModel = from_value_ref(model)?;
264 layout_mindmap_diagram_model(&model, effective_config, text_measurer, use_manatee_layout)
265}
266
267pub fn layout_mindmap_diagram_typed(
268 model: &MindmapModel,
269 effective_config: &Value,
270 text_measurer: &dyn TextMeasurer,
271 use_manatee_layout: bool,
272) -> Result<MindmapDiagramLayout> {
273 layout_mindmap_diagram_model(model, effective_config, text_measurer, use_manatee_layout)
274}
275
276fn layout_mindmap_diagram_model(
277 model: &MindmapModel,
278 effective_config: &Value,
279 text_measurer: &dyn TextMeasurer,
280 use_manatee_layout: bool,
281) -> Result<MindmapDiagramLayout> {
282 let timing_enabled = std::env::var("MERMAN_MINDMAP_LAYOUT_TIMING")
283 .ok()
284 .as_deref()
285 == Some("1");
286 #[derive(Debug, Default, Clone)]
287 struct MindmapLayoutTimings {
288 total: std::time::Duration,
289 measure_nodes: std::time::Duration,
290 manatee: std::time::Duration,
291 build_edges: std::time::Duration,
292 bounds: std::time::Duration,
293 }
294 let mut timings = MindmapLayoutTimings::default();
295 let total_start = timing_enabled.then(std::time::Instant::now);
296
297 let text_style = mindmap_text_style(effective_config);
298 let max_node_width_px = config_f64(effective_config, &["mindmap", "maxNodeWidth"])
299 .unwrap_or(200.0)
300 .max(1.0);
301
302 let measure_nodes_start = timing_enabled.then(std::time::Instant::now);
303 let mut nodes_sorted: Vec<(i64, &MindmapNodeModel)> = model
304 .nodes
305 .iter()
306 .map(|n| (n.id.parse::<i64>().unwrap_or(i64::MAX), n))
307 .collect();
308 nodes_sorted.sort_by(|(na, a), (nb, b)| na.cmp(nb).then_with(|| a.id.cmp(&b.id)));
309
310 let mut nodes: Vec<LayoutNode> = Vec::with_capacity(model.nodes.len());
311 for (_id_num, n) in nodes_sorted {
312 let (width, height, label_width, label_height) =
313 mindmap_node_dimensions_px(n, text_measurer, &text_style, max_node_width_px);
314
315 nodes.push(LayoutNode {
316 id: n.id.clone(),
317 x: 0.0,
320 y: 0.0,
321 width: width.max(1.0),
322 height: height.max(1.0),
323 is_cluster: false,
324 label_width: Some(label_width.max(0.0)),
325 label_height: Some(label_height.max(0.0)),
326 });
327 }
328 if let Some(s) = measure_nodes_start {
329 timings.measure_nodes = s.elapsed();
330 }
331
332 let mut id_to_idx: rustc_hash::FxHashMap<&str, usize> =
333 rustc_hash::FxHashMap::with_capacity_and_hasher(nodes.len(), Default::default());
334 for (idx, n) in nodes.iter().enumerate() {
335 id_to_idx.insert(n.id.as_str(), idx);
336 }
337
338 let mut edge_indices: Vec<(usize, usize)> = Vec::with_capacity(model.edges.len());
339 for e in &model.edges {
340 let Some(&a) = id_to_idx.get(e.start.as_str()) else {
341 return Err(Error::InvalidModel {
342 message: format!("edge start node not found: {}", e.start),
343 });
344 };
345 let Some(&b) = id_to_idx.get(e.end.as_str()) else {
346 return Err(Error::InvalidModel {
347 message: format!("edge end node not found: {}", e.end),
348 });
349 };
350 edge_indices.push((a, b));
351 }
352
353 if use_manatee_layout {
354 let manatee_start = timing_enabled.then(std::time::Instant::now);
355 let indexed_nodes: Vec<manatee::algo::cose_bilkent::IndexedNode> = nodes
356 .iter()
357 .map(|n| manatee::algo::cose_bilkent::IndexedNode {
358 width: n.width,
359 height: n.height,
360 x: n.x,
361 y: n.y,
362 })
363 .collect();
364 let mut indexed_edges: Vec<manatee::algo::cose_bilkent::IndexedEdge> =
365 Vec::with_capacity(model.edges.len());
366 for (edge_idx, (a, b)) in edge_indices.iter().copied().enumerate() {
367 if a == b {
368 continue;
369 }
370 indexed_edges.push(manatee::algo::cose_bilkent::IndexedEdge { a, b });
371
372 let _ = edge_idx;
375 }
376
377 let positions = manatee::algo::cose_bilkent::layout_indexed(
378 &indexed_nodes,
379 &indexed_edges,
380 &Default::default(),
381 )
382 .map_err(|e| Error::InvalidModel {
383 message: format!("manatee layout failed: {e}"),
384 })?;
385
386 for (n, p) in nodes.iter_mut().zip(positions) {
387 n.x = p.x;
388 n.y = p.y;
389 }
390 if let Some(s) = manatee_start {
391 timings.manatee = s.elapsed();
392 }
393 }
394
395 shift_nodes_to_positive_bounds(&mut nodes, 15.0);
401
402 let build_edges_start = timing_enabled.then(std::time::Instant::now);
403 let mut edges: Vec<LayoutEdge> = Vec::new();
404 edges.reserve(model.edges.len());
405 for (e, (sidx, tidx)) in model.edges.iter().zip(edge_indices.iter().copied()) {
406 let (sx, sy) = (nodes[sidx].x, nodes[sidx].y);
407 let (tx, ty) = (nodes[tidx].x, nodes[tidx].y);
408 let points = vec![LayoutPoint { x: sx, y: sy }, LayoutPoint { x: tx, y: ty }];
409 edges.push(LayoutEdge {
410 id: e.id.clone(),
411 from: e.start.clone(),
412 to: e.end.clone(),
413 from_cluster: None,
414 to_cluster: None,
415 points,
416 label: None,
417 start_label_left: None,
418 start_label_right: None,
419 end_label_left: None,
420 end_label_right: None,
421 start_marker: None,
422 end_marker: None,
423 stroke_dasharray: None,
424 });
425 }
426 if let Some(s) = build_edges_start {
427 timings.build_edges = s.elapsed();
428 }
429
430 let bounds_start = timing_enabled.then(std::time::Instant::now);
431 let bounds = compute_bounds(&nodes, &edges);
432 if let Some(s) = bounds_start {
433 timings.bounds = s.elapsed();
434 }
435 if let Some(s) = total_start {
436 timings.total = s.elapsed();
437 eprintln!(
438 "[layout-timing] diagram=mindmap total={:?} measure_nodes={:?} manatee={:?} build_edges={:?} bounds={:?} nodes={} edges={}",
439 timings.total,
440 timings.measure_nodes,
441 timings.manatee,
442 timings.build_edges,
443 timings.bounds,
444 nodes.len(),
445 edges.len(),
446 );
447 }
448 Ok(MindmapDiagramLayout {
449 nodes,
450 edges,
451 bounds,
452 })
453}