Skip to main content

merman_render/
mindmap.rs

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    // Mermaid mindmap labels are rendered via HTML `<foreignObject>` and inherit the global font.
48    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    // Mermaid mindmap uses HTML `<foreignObject>` labels. Mermaid CLI baselines show that the
52    // HTML label contents do not reliably inherit SVG-root `font-size` rules; measurement matches
53    // a 16px default even when users override `themeVariables.fontSize`.
54    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    // Conservative: only fast-path labels that would render as plain text inside a `<p>...</p>`
64    // when passed through Mermaid's Markdown + sanitizer pipeline.
65    if text.contains('\n') || text.contains('\r') {
66        return false;
67    }
68    let trimmed = text.trim_start();
69    let bytes = trimmed.as_bytes();
70    // Line-leading markdown constructs that can change the HTML shape even without newlines.
71    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    // Ordered list: `1. item` / `1) item`
78    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    // Block/inline markdown triggers we don't want to replicate here.
90    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    // HTML passthrough / entity patterns: keep the full markdown path.
102    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    // Mermaid mindmap labels are rendered via HTML `<foreignObject>` and respect
116    // `mindmap.maxNodeWidth` (default 200px). When the raw label is wider than that, Mermaid
117    // switches the label container to a fixed 200px width and allows HTML-like wrapping (e.g.
118    // `white-space: break-spaces` in upstream SVG baselines).
119    //
120    // Mirror that by measuring with an explicit max width in HTML-like mode.
121    let max_node_width_px = max_node_width_px.max(1.0);
122
123    // Complex Markdown labels require the full DOM-like measurement path (bold/em deltas, inline
124    // HTML, sanitizer edge cases). Keep the existing two-pass approach for those.
125    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    // Mermaid mindmap labels can overflow the configured `maxNodeWidth` when they contain long
176    // unbreakable tokens. Upstream measures these via DOM in a way that resembles `scrollWidth`,
177    // so keep the larger of:
178    // - the wrapped layout width (clamped by `max-width`), and
179    // - the unwrapped overflow width (ignores `max-width`).
180    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    // Mermaid mindmap applies some shape-specific padding overrides during rendering (after
206    // `mindmapDb.getData()`), notably for rounded nodes.
207    //
208    // Our semantic snapshots keep the DB padding (e.g. doubled padding for `(text)`), but layout
209    // should follow the render-time effective padding so layout golden snapshots remain stable.
210    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    // Align with Mermaid shape sizing rules for mindmap nodes (via `labelHelper(...)` + shape
217    // handlers in `rendering-elements/shapes/*`).
218    let (w, h) = match node.shape.as_str() {
219        // `defaultMindmapNode.ts`: w = bbox.width + 8 * halfPadding; h = bbox.height + 2 * halfPadding
220        "" | "defaultMindmapNode" => (bbox_w + 8.0 * half_padding, bbox_h + 2.0 * half_padding),
221        // Mindmap node shapes use the standard `labelHelper(...)` label bbox, but mindmap DB
222        // adjusts `node.padding` depending on the delimiter type (e.g. `[` / `(` / `{{`).
223        //
224        // Upstream Mermaid@11.12.2 mindmap SVG baselines show:
225        // - rect (`[text]`): w = bbox.width + 2*padding, h = bbox.height + padding
226        // - rounded (`(text)`): w = bbox.width + 2*padding, h = bbox.height + 2*padding
227        "rect" => (bbox_w + 2.0 * padding, bbox_h + padding),
228        "rounded" => (bbox_w + 2.0 * padding, bbox_h + 2.0 * padding),
229        // `mindmapCircle.ts` -> `circle.ts`: radius = bbox.width/2 + padding (mindmap passes full padding)
230        "mindmapCircle" => {
231            let d = bbox_w + 2.0 * padding;
232            (d, d)
233        }
234        // `cloud.ts`: w = bbox.width + 2*halfPadding; h = bbox.height + 2*halfPadding
235        "cloud" => (bbox_w + 2.0 * half_padding, bbox_h + 2.0 * half_padding),
236        // `bang.ts`:
237        // - w = bbox.width + 10*halfPadding; h = bbox.height + 8*halfPadding
238        // - minWidth = bbox.width + 20; minHeight = bbox.height + 20
239        // - effectiveWidth/Height = max(w/h, minWidth/Height)
240        "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.ts`: h = bbox.height + padding; w = bbox.width + 2.5*padding; then expands by +w/6
248        // due to `halfWidth = w/2 + m` where `m = (w/2)/6`.
249        "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            // Mermaid mindmap uses Cytoscape COSE-Bilkent and initializes node positions at (0,0).
359            // We keep that behavior so `manatee` can reproduce upstream placements deterministically.
360            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            // Keep `edge_idx` referenced so unused warnings don't obscure failures if we ever
414            // enhance indexed validation error messages.
415            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    // Mermaid's COSE-Bilkent post-layout normalizes to a positive coordinate space via
437    // `transform(0,0)` (layout-base), yielding a content bbox that starts around (15,15) before
438    // the 10px viewport padding is applied (viewBox starts at 5,5).
439    //
440    // Do this regardless of layout backend so parity-root viewport comparisons remain stable.
441    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}