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