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) {
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 = node.padding.max(0.0);
164 let half_padding = padding / 2.0;
165
166 match node.shape.as_str() {
169 "" | "defaultMindmapNode" => (bbox_w + 8.0 * half_padding, bbox_h + 2.0 * half_padding),
171 "rect" => (bbox_w + 2.0 * padding, bbox_h + padding),
178 "rounded" => (bbox_w + 1.5 * padding, bbox_h + 1.5 * padding),
179 "mindmapCircle" => {
181 let d = bbox_w + 2.0 * padding;
182 (d, d)
183 }
184 "cloud" => (bbox_w + 2.0 * half_padding, bbox_h + 2.0 * half_padding),
186 "bang" => (bbox_w + 10.0 * half_padding, bbox_h + 8.0 * half_padding),
189 "hexagon" => {
192 let w = bbox_w + 2.5 * padding;
193 let h = bbox_h + padding;
194 (w * (7.0 / 6.0), h)
195 }
196 _ => (bbox_w + 8.0 * half_padding, bbox_h + 2.0 * half_padding),
197 }
198}
199
200fn compute_bounds(nodes: &[LayoutNode], edges: &[LayoutEdge]) -> Option<Bounds> {
201 let mut pts: Vec<(f64, f64)> = Vec::new();
202 for n in nodes {
203 let x0 = n.x - n.width / 2.0;
204 let y0 = n.y - n.height / 2.0;
205 let x1 = n.x + n.width / 2.0;
206 let y1 = n.y + n.height / 2.0;
207 pts.push((x0, y0));
208 pts.push((x1, y1));
209 }
210 for e in edges {
211 for p in &e.points {
212 pts.push((p.x, p.y));
213 }
214 }
215 Bounds::from_points(pts)
216}
217
218fn shift_nodes_to_positive_bounds(nodes: &mut [LayoutNode], content_min: f64) {
219 if nodes.is_empty() {
220 return;
221 }
222 let mut min_x = f64::INFINITY;
223 let mut min_y = f64::INFINITY;
224 for n in nodes.iter() {
225 min_x = min_x.min(n.x - n.width / 2.0);
226 min_y = min_y.min(n.y - n.height / 2.0);
227 }
228 if !(min_x.is_finite() && min_y.is_finite()) {
229 return;
230 }
231 let dx = content_min - min_x;
232 let dy = content_min - min_y;
233 for n in nodes.iter_mut() {
234 n.x += dx;
235 n.y += dy;
236 }
237}
238
239pub fn layout_mindmap_diagram(
240 model: &Value,
241 effective_config: &Value,
242 text_measurer: &dyn TextMeasurer,
243 use_manatee_layout: bool,
244) -> Result<MindmapDiagramLayout> {
245 let model: MindmapModel = from_value_ref(model)?;
246 layout_mindmap_diagram_model(&model, effective_config, text_measurer, use_manatee_layout)
247}
248
249pub fn layout_mindmap_diagram_typed(
250 model: &MindmapModel,
251 effective_config: &Value,
252 text_measurer: &dyn TextMeasurer,
253 use_manatee_layout: bool,
254) -> Result<MindmapDiagramLayout> {
255 layout_mindmap_diagram_model(model, effective_config, text_measurer, use_manatee_layout)
256}
257
258fn layout_mindmap_diagram_model(
259 model: &MindmapModel,
260 effective_config: &Value,
261 text_measurer: &dyn TextMeasurer,
262 use_manatee_layout: bool,
263) -> Result<MindmapDiagramLayout> {
264 let timing_enabled = std::env::var("MERMAN_MINDMAP_LAYOUT_TIMING")
265 .ok()
266 .as_deref()
267 == Some("1");
268 #[derive(Debug, Default, Clone)]
269 struct MindmapLayoutTimings {
270 total: std::time::Duration,
271 measure_nodes: std::time::Duration,
272 manatee: std::time::Duration,
273 build_edges: std::time::Duration,
274 bounds: std::time::Duration,
275 }
276 let mut timings = MindmapLayoutTimings::default();
277 let total_start = timing_enabled.then(std::time::Instant::now);
278
279 let text_style = mindmap_text_style(effective_config);
280 let max_node_width_px = config_f64(effective_config, &["mindmap", "maxNodeWidth"])
281 .unwrap_or(200.0)
282 .max(1.0);
283
284 let measure_nodes_start = timing_enabled.then(std::time::Instant::now);
285 let mut nodes_sorted: Vec<(i64, &MindmapNodeModel)> = model
286 .nodes
287 .iter()
288 .map(|n| (n.id.parse::<i64>().unwrap_or(i64::MAX), n))
289 .collect();
290 nodes_sorted.sort_by(|(na, a), (nb, b)| na.cmp(nb).then_with(|| a.id.cmp(&b.id)));
291
292 let mut nodes: Vec<LayoutNode> = Vec::with_capacity(model.nodes.len());
293 for (_id_num, n) in nodes_sorted {
294 let (width, height) =
295 mindmap_node_dimensions_px(n, text_measurer, &text_style, max_node_width_px);
296
297 nodes.push(LayoutNode {
298 id: n.id.clone(),
299 x: 0.0,
302 y: 0.0,
303 width: width.max(1.0),
304 height: height.max(1.0),
305 is_cluster: false,
306 label_width: None,
307 label_height: None,
308 });
309 }
310 if let Some(s) = measure_nodes_start {
311 timings.measure_nodes = s.elapsed();
312 }
313
314 let mut id_to_idx: rustc_hash::FxHashMap<&str, usize> =
315 rustc_hash::FxHashMap::with_capacity_and_hasher(nodes.len(), Default::default());
316 for (idx, n) in nodes.iter().enumerate() {
317 id_to_idx.insert(n.id.as_str(), idx);
318 }
319
320 let mut edge_indices: Vec<(usize, usize)> = Vec::with_capacity(model.edges.len());
321 for e in &model.edges {
322 let Some(&a) = id_to_idx.get(e.start.as_str()) else {
323 return Err(Error::InvalidModel {
324 message: format!("edge start node not found: {}", e.start),
325 });
326 };
327 let Some(&b) = id_to_idx.get(e.end.as_str()) else {
328 return Err(Error::InvalidModel {
329 message: format!("edge end node not found: {}", e.end),
330 });
331 };
332 edge_indices.push((a, b));
333 }
334
335 if use_manatee_layout {
336 let manatee_start = timing_enabled.then(std::time::Instant::now);
337 let indexed_nodes: Vec<manatee::algo::cose_bilkent::IndexedNode> = nodes
338 .iter()
339 .map(|n| manatee::algo::cose_bilkent::IndexedNode {
340 width: n.width,
341 height: n.height,
342 x: n.x,
343 y: n.y,
344 })
345 .collect();
346 let mut indexed_edges: Vec<manatee::algo::cose_bilkent::IndexedEdge> =
347 Vec::with_capacity(model.edges.len());
348 for (edge_idx, (a, b)) in edge_indices.iter().copied().enumerate() {
349 if a == b {
350 continue;
351 }
352 indexed_edges.push(manatee::algo::cose_bilkent::IndexedEdge { a, b });
353
354 let _ = edge_idx;
357 }
358
359 let positions = manatee::algo::cose_bilkent::layout_indexed(
360 &indexed_nodes,
361 &indexed_edges,
362 &Default::default(),
363 )
364 .map_err(|e| Error::InvalidModel {
365 message: format!("manatee layout failed: {e}"),
366 })?;
367
368 for (n, p) in nodes.iter_mut().zip(positions) {
369 n.x = p.x;
370 n.y = p.y;
371 }
372 if let Some(s) = manatee_start {
373 timings.manatee = s.elapsed();
374 }
375 }
376
377 if !use_manatee_layout {
384 shift_nodes_to_positive_bounds(&mut nodes, 15.0);
385 }
386
387 let build_edges_start = timing_enabled.then(std::time::Instant::now);
388 let mut edges: Vec<LayoutEdge> = Vec::new();
389 edges.reserve(model.edges.len());
390 for (e, (sidx, tidx)) in model.edges.iter().zip(edge_indices.iter().copied()) {
391 let (sx, sy) = (nodes[sidx].x, nodes[sidx].y);
392 let (tx, ty) = (nodes[tidx].x, nodes[tidx].y);
393 let points = vec![LayoutPoint { x: sx, y: sy }, LayoutPoint { x: tx, y: ty }];
394 edges.push(LayoutEdge {
395 id: e.id.clone(),
396 from: e.start.clone(),
397 to: e.end.clone(),
398 from_cluster: None,
399 to_cluster: None,
400 points,
401 label: None,
402 start_label_left: None,
403 start_label_right: None,
404 end_label_left: None,
405 end_label_right: None,
406 start_marker: None,
407 end_marker: None,
408 stroke_dasharray: None,
409 });
410 }
411 if let Some(s) = build_edges_start {
412 timings.build_edges = s.elapsed();
413 }
414
415 let bounds_start = timing_enabled.then(std::time::Instant::now);
416 let bounds = compute_bounds(&nodes, &edges);
417 if let Some(s) = bounds_start {
418 timings.bounds = s.elapsed();
419 }
420 if let Some(s) = total_start {
421 timings.total = s.elapsed();
422 eprintln!(
423 "[layout-timing] diagram=mindmap total={:?} measure_nodes={:?} manatee={:?} build_edges={:?} bounds={:?} nodes={} edges={}",
424 timings.total,
425 timings.measure_nodes,
426 timings.manatee,
427 timings.build_edges,
428 timings.bounds,
429 nodes.len(),
430 edges.len(),
431 );
432 }
433 Ok(MindmapDiagramLayout {
434 nodes,
435 edges,
436 bounds,
437 })
438}