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) {
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    // Align with Mermaid shape sizing rules for mindmap nodes (via `labelHelper(...)` + shape
167    // handlers in `rendering-elements/shapes/*`).
168    match node.shape.as_str() {
169        // `defaultMindmapNode.ts`: w = bbox.width + 8 * halfPadding; h = bbox.height + 2 * halfPadding
170        "" | "defaultMindmapNode" => (bbox_w + 8.0 * half_padding, bbox_h + 2.0 * half_padding),
171        // Mindmap node shapes use the standard `labelHelper(...)` label bbox, but mindmap DB
172        // adjusts `node.padding` depending on the delimiter type (e.g. `[` / `(` / `{{`).
173        //
174        // Upstream Mermaid@11.12.2 mindmap SVG baselines show:
175        // - rect (`[text]`): w = bbox.width + 2*padding, h = bbox.height + padding
176        // - rounded (`(text)`): w = bbox.width + 1.5*padding, h = bbox.height + 1.5*padding
177        "rect" => (bbox_w + 2.0 * padding, bbox_h + padding),
178        "rounded" => (bbox_w + 1.5 * padding, bbox_h + 1.5 * padding),
179        // `mindmapCircle.ts` -> `circle.ts`: radius = bbox.width/2 + padding (mindmap passes full padding)
180        "mindmapCircle" => {
181            let d = bbox_w + 2.0 * padding;
182            (d, d)
183        }
184        // `cloud.ts`: w = bbox.width + 2*halfPadding; h = bbox.height + 2*halfPadding
185        "cloud" => (bbox_w + 2.0 * half_padding, bbox_h + 2.0 * half_padding),
186        // `bang.ts`: effectiveWidth = bbox.width + 10*halfPadding (min bbox+20 is always smaller here)
187        //           effectiveHeight = bbox.height + 8*halfPadding (min bbox+20 is always smaller here)
188        "bang" => (bbox_w + 10.0 * half_padding, bbox_h + 8.0 * half_padding),
189        // `hexagon.ts`: h = bbox.height + padding; w = bbox.width + 2.5*padding; then expands by +w/6
190        // due to `halfWidth = w/2 + m` where `m = (w/2)/6`.
191        "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            // Mermaid mindmap uses Cytoscape COSE-Bilkent and initializes node positions at (0,0).
300            // We keep that behavior so `manatee` can reproduce upstream placements deterministically.
301            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            // Keep `edge_idx` referenced so unused warnings don't obscure failures if we ever
355            // enhance indexed validation error messages.
356            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    // Mermaid's COSE-Bilkent post-layout normalizes to a positive coordinate space via
378    // `transform(0,0)` (layout-base), yielding a content bbox that starts around (15,15) before
379    // the 10px viewport padding is applied (viewBox starts at 5,5).
380    //
381    // When we do NOT use the manatee COSE port, keep a compatibility translation so parity-root
382    // viewport comparisons remain stable.
383    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}