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}
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    // Mermaid mindmap labels are rendered via HTML `<foreignObject>` and inherit the global font.
29    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    // Conservative: only fast-path labels that would render as plain text inside a `<p>...</p>`
44    // when passed through Mermaid's Markdown + sanitizer pipeline.
45    if text.contains('\n') || text.contains('\r') {
46        return false;
47    }
48    let trimmed = text.trim_start();
49    let bytes = trimmed.as_bytes();
50    // Line-leading markdown constructs that can change the HTML shape even without newlines.
51    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    // Ordered list: `1. item` / `1) item`
58    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    // Block/inline markdown triggers we don't want to replicate here.
70    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    // HTML passthrough / entity patterns: keep the full markdown path.
82    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    // Mermaid mindmap labels are rendered via HTML `<foreignObject>` and respect
96    // `mindmap.maxNodeWidth` (default 200px). When the raw label is wider than that, Mermaid
97    // switches the label container to a fixed 200px width and allows HTML-like wrapping (e.g.
98    // `white-space: break-spaces` in upstream SVG baselines).
99    //
100    // Mirror that by measuring with an explicit max width in HTML-like mode.
101    let max_node_width_px = max_node_width_px.max(1.0);
102
103    // Complex Markdown labels require the full DOM-like measurement path (bold/em deltas, inline
104    // HTML, sanitizer edge cases). Keep the existing two-pass approach for those.
105    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    // Mermaid mindmap labels can overflow the configured `maxNodeWidth` when they contain long
134    // unbreakable tokens. Upstream measures these via DOM in a way that resembles `scrollWidth`,
135    // so keep the larger of:
136    // - the wrapped layout width (clamped by `max-width`), and
137    // - the unwrapped overflow width (ignores `max-width`).
138    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    // Mermaid mindmap applies some shape-specific padding overrides during rendering (after
164    // `mindmapDb.getData()`), notably for rounded nodes.
165    //
166    // Our semantic snapshots keep the DB padding (e.g. doubled padding for `(text)`), but layout
167    // should follow the render-time effective padding so layout golden snapshots remain stable.
168    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    // Align with Mermaid shape sizing rules for mindmap nodes (via `labelHelper(...)` + shape
175    // handlers in `rendering-elements/shapes/*`).
176    let (w, h) = match node.shape.as_str() {
177        // `defaultMindmapNode.ts`: w = bbox.width + 8 * halfPadding; h = bbox.height + 2 * halfPadding
178        "" | "defaultMindmapNode" => (bbox_w + 8.0 * half_padding, bbox_h + 2.0 * half_padding),
179        // Mindmap node shapes use the standard `labelHelper(...)` label bbox, but mindmap DB
180        // adjusts `node.padding` depending on the delimiter type (e.g. `[` / `(` / `{{`).
181        //
182        // Upstream Mermaid@11.12.2 mindmap SVG baselines show:
183        // - rect (`[text]`): w = bbox.width + 2*padding, h = bbox.height + padding
184        // - rounded (`(text)`): w = bbox.width + 2*padding, h = bbox.height + 2*padding
185        "rect" => (bbox_w + 2.0 * padding, bbox_h + padding),
186        "rounded" => (bbox_w + 2.0 * padding, bbox_h + 2.0 * padding),
187        // `mindmapCircle.ts` -> `circle.ts`: radius = bbox.width/2 + padding (mindmap passes full padding)
188        "mindmapCircle" => {
189            let d = bbox_w + 2.0 * padding;
190            (d, d)
191        }
192        // `cloud.ts`: w = bbox.width + 2*halfPadding; h = bbox.height + 2*halfPadding
193        "cloud" => (bbox_w + 2.0 * half_padding, bbox_h + 2.0 * half_padding),
194        // `bang.ts`:
195        // - w = bbox.width + 10*halfPadding; h = bbox.height + 8*halfPadding
196        // - minWidth = bbox.width + 20; minHeight = bbox.height + 20
197        // - effectiveWidth/Height = max(w/h, minWidth/Height)
198        "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.ts`: h = bbox.height + padding; w = bbox.width + 2.5*padding; then expands by +w/6
206        // due to `halfWidth = w/2 + m` where `m = (w/2)/6`.
207        "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            // Mermaid mindmap uses Cytoscape COSE-Bilkent and initializes node positions at (0,0).
318            // We keep that behavior so `manatee` can reproduce upstream placements deterministically.
319            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            // Keep `edge_idx` referenced so unused warnings don't obscure failures if we ever
373            // enhance indexed validation error messages.
374            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    // Mermaid's COSE-Bilkent post-layout normalizes to a positive coordinate space via
396    // `transform(0,0)` (layout-base), yielding a content bbox that starts around (15,15) before
397    // the 10px viewport padding is applied (viewBox starts at 5,5).
398    //
399    // Do this regardless of layout backend so parity-root viewport comparisons remain stable.
400    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}