Skip to main content

merman_render/flowchart/
layout.rs

1use crate::config::config_f64;
2use crate::math::MathRenderer;
3use crate::model::{
4    FlowchartV2Layout, LayoutCluster, LayoutEdge, LayoutLabel, LayoutNode, LayoutPoint,
5};
6use crate::text::{TextMeasurer, TextStyle, WrapMode};
7use crate::{Error, Result};
8use dugong::graphlib::{Graph, GraphOptions};
9use dugong::{EdgeLabel, GraphLabel, LabelPos, NodeLabel, RankDir};
10use merman_core::MermaidConfig;
11use serde_json::Value;
12use std::collections::{HashMap, HashSet};
13
14use super::label::compute_bounds;
15use super::node::{NodeLayoutDimensionsRequest, node_layout_dimensions};
16use super::{FlowEdge, FlowSubgraph, FlowchartV2Model};
17use super::{
18    FlowchartLabelMetricsRequest, flowchart_effective_html_labels,
19    flowchart_effective_node_html_labels, flowchart_effective_text_style_for_classes,
20    flowchart_effective_text_style_for_node_classes, flowchart_html_label_measurement_base_style,
21    flowchart_label_metrics_for_layout, flowchart_node_has_span_css_height_parity,
22};
23
24fn config_string(cfg: &Value, path: &[&str]) -> Option<String> {
25    let mut cur = cfg;
26    for key in path {
27        cur = cur.get(*key)?;
28    }
29    cur.as_str().map(|s| s.to_string())
30}
31
32fn flowchart_svg_plain_computed_width_px(
33    measurer: &dyn TextMeasurer,
34    plain: &str,
35    style: &TextStyle,
36    max_width_px: Option<f64>,
37) -> f64 {
38    let wrapped_lines =
39        crate::text::wrap_svg_text_lines_by_measurement(measurer, plain, style, max_width_px, true);
40    let mut width: f64 = 0.0;
41    for line in wrapped_lines {
42        width = width.max(measurer.measure_svg_text_computed_length_px(line.trim_end(), style));
43    }
44    crate::text::round_to_1_64_px(width)
45}
46
47fn rank_dir_from_flow(direction: &str) -> RankDir {
48    match direction.trim().to_uppercase().as_str() {
49        "TB" | "TD" => RankDir::TB,
50        "BT" => RankDir::BT,
51        "LR" => RankDir::LR,
52        "RL" => RankDir::RL,
53        _ => RankDir::TB,
54    }
55}
56
57fn normalize_dir(s: &str) -> String {
58    s.trim().to_uppercase()
59}
60
61fn toggled_dir(parent: &str) -> String {
62    let parent = normalize_dir(parent);
63    if parent == "TB" || parent == "TD" {
64        "LR".to_string()
65    } else {
66        "TB".to_string()
67    }
68}
69
70fn flow_dir_from_rankdir(rankdir: RankDir) -> &'static str {
71    match rankdir {
72        RankDir::TB => "TB",
73        RankDir::BT => "BT",
74        RankDir::LR => "LR",
75        RankDir::RL => "RL",
76    }
77}
78
79fn effective_cluster_dir(sg: &FlowSubgraph, parent_dir: &str, inherit_dir: bool) -> String {
80    if let Some(dir) = sg.dir.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
81        return normalize_dir(dir);
82    }
83    if inherit_dir {
84        return normalize_dir(parent_dir);
85    }
86    toggled_dir(parent_dir)
87}
88
89fn compute_effective_dir_by_id(
90    subgraphs_by_id: &HashMap<String, FlowSubgraph>,
91    g: &Graph<NodeLabel, EdgeLabel, GraphLabel>,
92    diagram_dir: &str,
93    inherit_dir: bool,
94) -> HashMap<String, String> {
95    fn compute_one(
96        id: &str,
97        subgraphs_by_id: &HashMap<String, FlowSubgraph>,
98        g: &Graph<NodeLabel, EdgeLabel, GraphLabel>,
99        diagram_dir: &str,
100        inherit_dir: bool,
101        visiting: &mut std::collections::HashSet<String>,
102        memo: &mut HashMap<String, String>,
103    ) -> String {
104        if let Some(dir) = memo.get(id) {
105            return dir.clone();
106        }
107        if !visiting.insert(id.to_string()) {
108            let dir = toggled_dir(diagram_dir);
109            memo.insert(id.to_string(), dir.clone());
110            return dir;
111        }
112
113        let parent_dir = g
114            .parent(id)
115            .and_then(|p| subgraphs_by_id.contains_key(p).then_some(p.to_string()))
116            .map(|p| {
117                compute_one(
118                    &p,
119                    subgraphs_by_id,
120                    g,
121                    diagram_dir,
122                    inherit_dir,
123                    visiting,
124                    memo,
125                )
126            })
127            .unwrap_or_else(|| normalize_dir(diagram_dir));
128
129        let dir = subgraphs_by_id
130            .get(id)
131            .map(|sg| effective_cluster_dir(sg, &parent_dir, inherit_dir))
132            .unwrap_or_else(|| toggled_dir(&parent_dir));
133
134        memo.insert(id.to_string(), dir.clone());
135        let _ = visiting.remove(id);
136        dir
137    }
138
139    let mut memo: HashMap<String, String> = HashMap::new();
140    let mut visiting: std::collections::HashSet<String> = std::collections::HashSet::new();
141    for id in subgraphs_by_id.keys() {
142        let _ = compute_one(
143            id,
144            subgraphs_by_id,
145            g,
146            diagram_dir,
147            inherit_dir,
148            &mut visiting,
149            &mut memo,
150        );
151    }
152    memo
153}
154
155fn dir_to_rankdir(dir: &str) -> RankDir {
156    match normalize_dir(dir).as_str() {
157        "TB" | "TD" => RankDir::TB,
158        "BT" => RankDir::BT,
159        "LR" => RankDir::LR,
160        "RL" => RankDir::RL,
161        _ => RankDir::TB,
162    }
163}
164
165fn edge_label_is_non_empty(edge: &FlowEdge) -> bool {
166    edge.label
167        .as_deref()
168        .map(|s| !s.trim().is_empty())
169        .unwrap_or(false)
170}
171
172fn lowest_common_parent(
173    g: &Graph<NodeLabel, EdgeLabel, GraphLabel>,
174    a: &str,
175    b: &str,
176) -> Option<String> {
177    if !g.options().compound {
178        return None;
179    }
180
181    let mut ancestors: std::collections::HashSet<String> = std::collections::HashSet::new();
182    let mut cur = g.parent(a);
183    while let Some(p) = cur {
184        ancestors.insert(p.to_string());
185        cur = g.parent(p);
186    }
187
188    let mut cur = g.parent(b);
189    while let Some(p) = cur {
190        if ancestors.contains(p) {
191            return Some(p.to_string());
192        }
193        cur = g.parent(p);
194    }
195
196    None
197}
198
199fn extract_descendants(id: &str, g: &Graph<NodeLabel, EdgeLabel, GraphLabel>) -> Vec<String> {
200    let mut out: Vec<String> = Vec::new();
201    let mut visited: HashSet<String> = HashSet::new();
202    let mut stack: Vec<String> = g.children(id).iter().map(|s| s.to_string()).collect();
203    while let Some(node) = stack.pop() {
204        if !visited.insert(node.clone()) {
205            continue;
206        }
207        for child in g.children(&node) {
208            stack.push(child.to_string());
209        }
210        out.push(node);
211    }
212    out
213}
214
215fn edge_in_cluster(
216    edge: &dugong::graphlib::EdgeKey,
217    cluster_id: &str,
218    descendants: &std::collections::HashMap<String, Vec<String>>,
219) -> bool {
220    if edge.v == cluster_id || edge.w == cluster_id {
221        return false;
222    }
223    let Some(cluster_desc) = descendants.get(cluster_id) else {
224        return false;
225    };
226    cluster_desc.contains(&edge.v) || cluster_desc.contains(&edge.w)
227}
228
229#[derive(Debug, Clone)]
230struct FlowchartClusterDbEntry {
231    anchor_id: String,
232    external_connections: bool,
233}
234
235fn flowchart_find_common_edges(
236    graph: &Graph<NodeLabel, EdgeLabel, GraphLabel>,
237    id1: &str,
238    id2: &str,
239) -> Vec<(String, String)> {
240    let edges1: Vec<(String, String)> = graph
241        .edge_keys()
242        .into_iter()
243        .filter(|edge| edge.v == id1 || edge.w == id1)
244        .map(|edge| (edge.v, edge.w))
245        .collect();
246    let edges2: Vec<(String, String)> = graph
247        .edge_keys()
248        .into_iter()
249        .filter(|edge| edge.v == id2 || edge.w == id2)
250        .map(|edge| (edge.v, edge.w))
251        .collect();
252
253    let edges1_prim: Vec<(String, String)> = edges1
254        .into_iter()
255        .map(|(v, w)| {
256            (
257                if v == id1 { id2.to_string() } else { v },
258                // Mermaid's `findCommonEdges(...)` has an asymmetry here: it maps the `w` side
259                // back to `id1` rather than `id2` (Mermaid@11.12.2).
260                if w == id1 { id1.to_string() } else { w },
261            )
262        })
263        .collect();
264
265    let mut out = Vec::new();
266    for e1 in edges1_prim {
267        if edges2.contains(&e1) {
268            out.push(e1);
269        }
270    }
271    out
272}
273
274fn flowchart_find_non_cluster_child(
275    id: &str,
276    graph: &Graph<NodeLabel, EdgeLabel, GraphLabel>,
277    cluster_id: &str,
278) -> Option<String> {
279    let children = graph.children(id);
280    if children.is_empty() {
281        return Some(id.to_string());
282    }
283    let mut reserve: Option<String> = None;
284    let mut visited: HashSet<String> = HashSet::new();
285    let mut stack: Vec<String> = children.iter().rev().map(|s| s.to_string()).collect();
286    while let Some(node) = stack.pop() {
287        if !visited.insert(node.clone()) {
288            continue;
289        }
290        let children = graph.children(&node);
291        if !children.is_empty() {
292            for child in children.iter().rev() {
293                stack.push(child.to_string());
294            }
295            continue;
296        }
297        let common_edges = flowchart_find_common_edges(graph, cluster_id, &node);
298        if !common_edges.is_empty() {
299            reserve = Some(node);
300        } else {
301            return Some(node);
302        }
303    }
304    reserve
305}
306
307fn adjust_flowchart_clusters_and_edges(
308    graph: &mut Graph<NodeLabel, EdgeLabel, GraphLabel>,
309) -> std::collections::HashMap<String, bool> {
310    use serde_json::Value;
311
312    fn is_descendant(
313        node_id: &str,
314        cluster_id: &str,
315        descendants: &std::collections::HashMap<String, Vec<String>>,
316    ) -> bool {
317        descendants
318            .get(cluster_id)
319            .is_some_and(|ds| ds.iter().any(|n| n == node_id))
320    }
321
322    let mut descendants: std::collections::HashMap<String, Vec<String>> =
323        std::collections::HashMap::new();
324    let mut cluster_db: std::collections::HashMap<String, FlowchartClusterDbEntry> =
325        std::collections::HashMap::new();
326
327    for id in graph.node_ids() {
328        if graph.children(&id).is_empty() {
329            continue;
330        }
331        descendants.insert(id.clone(), extract_descendants(&id, graph));
332        let anchor_id =
333            flowchart_find_non_cluster_child(&id, graph, &id).unwrap_or_else(|| id.clone());
334        cluster_db.insert(
335            id,
336            FlowchartClusterDbEntry {
337                anchor_id,
338                external_connections: false,
339            },
340        );
341    }
342
343    for id in cluster_db.keys().cloned().collect::<Vec<_>>() {
344        if graph.children(&id).is_empty() {
345            continue;
346        }
347        let mut has_external = false;
348        for e in graph.edges() {
349            let d1 = is_descendant(e.v.as_str(), id.as_str(), &descendants);
350            let d2 = is_descendant(e.w.as_str(), id.as_str(), &descendants);
351            if d1 ^ d2 {
352                has_external = true;
353                break;
354            }
355        }
356        if let Some(entry) = cluster_db.get_mut(&id) {
357            entry.external_connections = has_external;
358        }
359    }
360
361    for id in cluster_db.keys().cloned().collect::<Vec<_>>() {
362        let Some(non_cluster_child) = cluster_db.get(&id).map(|e| e.anchor_id.clone()) else {
363            continue;
364        };
365        let parent = graph.parent(&non_cluster_child);
366        if parent.is_some_and(|p| p != id.as_str())
367            && parent.is_some_and(|p| cluster_db.contains_key(p))
368            && parent.is_some_and(|p| !cluster_db.get(p).is_some_and(|e| e.external_connections))
369        {
370            if let Some(p) = parent {
371                if let Some(entry) = cluster_db.get_mut(&id) {
372                    entry.anchor_id = p.to_string();
373                }
374            }
375        }
376    }
377
378    fn get_anchor_id(
379        id: &str,
380        cluster_db: &std::collections::HashMap<String, FlowchartClusterDbEntry>,
381    ) -> String {
382        let Some(entry) = cluster_db.get(id) else {
383            return id.to_string();
384        };
385        if !entry.external_connections {
386            return id.to_string();
387        }
388        entry.anchor_id.clone()
389    }
390
391    let edge_keys = graph.edge_keys();
392    for ek in edge_keys {
393        if !cluster_db.contains_key(&ek.v) && !cluster_db.contains_key(&ek.w) {
394            continue;
395        }
396
397        let Some(mut edge_label) = graph.edge_by_key(&ek).cloned() else {
398            continue;
399        };
400
401        let v = get_anchor_id(&ek.v, &cluster_db);
402        let w = get_anchor_id(&ek.w, &cluster_db);
403
404        // Match Mermaid `adjustClustersAndEdges`: edges that touch cluster nodes are removed and
405        // re-inserted even when their endpoints do not change. This affects edge iteration order
406        // and therefore cycle-breaking determinism in Dagre's acyclic pass.
407        let _ = graph.remove_edge_key(&ek);
408
409        if v != ek.v {
410            if let Some(parent) = graph.parent(&v) {
411                if let Some(entry) = cluster_db.get_mut(parent) {
412                    entry.external_connections = true;
413                }
414            }
415            edge_label
416                .extras
417                .insert("fromCluster".to_string(), Value::String(ek.v.clone()));
418        }
419
420        if w != ek.w {
421            if let Some(parent) = graph.parent(&w) {
422                if let Some(entry) = cluster_db.get_mut(parent) {
423                    entry.external_connections = true;
424                }
425            }
426            edge_label
427                .extras
428                .insert("toCluster".to_string(), Value::String(ek.w.clone()));
429        }
430
431        graph.set_edge_named(v, w, ek.name, Some(edge_label));
432    }
433
434    cluster_db
435        .into_iter()
436        .map(|(id, entry)| (id, entry.external_connections))
437        .collect()
438}
439
440fn copy_cluster(
441    cluster_id: &str,
442    graph: &mut Graph<NodeLabel, EdgeLabel, GraphLabel>,
443    new_graph: &mut Graph<NodeLabel, EdgeLabel, GraphLabel>,
444    root_id: &str,
445    descendants: &std::collections::HashMap<String, Vec<String>>,
446) {
447    let mut nodes: Vec<String> = graph
448        .children(cluster_id)
449        .iter()
450        .map(|s| s.to_string())
451        .collect();
452    if cluster_id != root_id {
453        nodes.push(cluster_id.to_string());
454    }
455
456    for node in nodes {
457        if !graph.has_node(&node) {
458            continue;
459        }
460
461        if !graph.children(&node).is_empty() {
462            copy_cluster(&node, graph, new_graph, root_id, descendants);
463        } else {
464            let data = graph.node(&node).cloned().unwrap_or_default();
465            new_graph.set_node(node.clone(), data);
466
467            if let Some(parent) = graph.parent(&node) {
468                if parent != root_id {
469                    new_graph.set_parent(node.clone(), parent.to_string());
470                }
471            }
472            if cluster_id != root_id && node != cluster_id {
473                new_graph.set_parent(node.clone(), cluster_id.to_string());
474            }
475
476            // Copy edges for this extracted cluster.
477            //
478            // Mermaid's implementation calls `graph.edges(node)` (note: on dagre-d3-es Graphlib,
479            // `edges()` ignores the argument and returns *all* edges). Because the source graph is
480            // mutated as nodes are removed, this makes edge insertion order sensitive to the leaf
481            // traversal order, which in turn can affect deterministic tie-breaking in Dagre's
482            // acyclic/ranking steps.
483            //
484            // Reference:
485            // - `packages/mermaid/src/rendering-util/layout-algorithms/dagre/mermaid-graphlib.js`
486            for ek in graph.edge_keys() {
487                if !edge_in_cluster(&ek, root_id, descendants) {
488                    continue;
489                }
490                if new_graph.has_edge(&ek.v, &ek.w, ek.name.as_deref()) {
491                    continue;
492                }
493                let Some(lbl) = graph.edge_by_key(&ek).cloned() else {
494                    continue;
495                };
496                new_graph.set_edge_named(ek.v, ek.w, ek.name, Some(lbl));
497            }
498        }
499
500        let _ = graph.remove_node(&node);
501    }
502}
503
504fn extract_clusters_recursively(
505    graph: &mut Graph<NodeLabel, EdgeLabel, GraphLabel>,
506    subgraphs_by_id: &std::collections::HashMap<String, FlowSubgraph>,
507    external_connections_by_id: &std::collections::HashMap<String, bool>,
508    extracted: &mut std::collections::HashMap<String, Graph<NodeLabel, EdgeLabel, GraphLabel>>,
509    _depth: usize,
510) {
511    struct ExtractFrame {
512        id: String,
513        graph: Graph<NodeLabel, EdgeLabel, GraphLabel>,
514        expanded: bool,
515    }
516
517    fn extract_one_level(
518        graph: &mut Graph<NodeLabel, EdgeLabel, GraphLabel>,
519        subgraphs_by_id: &std::collections::HashMap<String, FlowSubgraph>,
520        external_connections_by_id: &std::collections::HashMap<String, bool>,
521    ) -> Vec<(String, Graph<NodeLabel, EdgeLabel, GraphLabel>)> {
522        let node_ids = graph.node_ids();
523        let mut descendants: std::collections::HashMap<String, Vec<String>> =
524            std::collections::HashMap::new();
525        for id in &node_ids {
526            if graph.children(id).is_empty() {
527                continue;
528            }
529            descendants.insert(id.clone(), extract_descendants(id, graph));
530        }
531
532        let mut extracted_here: Vec<(String, Graph<NodeLabel, EdgeLabel, GraphLabel>)> = Vec::new();
533
534        let candidates: Vec<String> = node_ids
535            .into_iter()
536            .filter(|id| graph.has_node(id))
537            .filter(|id| !graph.children(id).is_empty())
538            // Mermaid's extractor does not recompute external connections after
539            // `adjustClustersAndEdges` rewrites cluster endpoints to anchor children. It uses the
540            // global `clusterDb.externalConnections` flag computed before those rewrites, then
541            // recurses into extracted graphs with the same `clusterDb`.
542            //
543            // Reference:
544            // - `packages/mermaid/src/rendering-util/layout-algorithms/dagre/mermaid-graphlib.js`
545            .filter(|id| {
546                external_connections_by_id
547                    .get(id.as_str())
548                    .is_some_and(|external| !external)
549            })
550            .collect();
551
552        for id in candidates {
553            if !graph.has_node(&id) {
554                continue;
555            }
556            if graph.children(&id).is_empty() {
557                continue;
558            }
559
560            let mut cluster_graph: Graph<NodeLabel, EdgeLabel, GraphLabel> =
561                Graph::new(GraphOptions {
562                    multigraph: true,
563                    compound: true,
564                    directed: true,
565                });
566
567            // Mermaid's `extractor(...)` uses:
568            // - `clusterData.dir` when explicitly set for the subgraph
569            // - otherwise: toggle relative to the current graph's rankdir (TB<->LR)
570            let dir = subgraphs_by_id
571                .get(&id)
572                .and_then(|sg| sg.dir.as_deref())
573                .map(str::trim)
574                .filter(|s| !s.is_empty())
575                .map(normalize_dir)
576                .unwrap_or_else(|| toggled_dir(flow_dir_from_rankdir(graph.graph().rankdir)));
577
578            cluster_graph.set_graph(GraphLabel {
579                rankdir: dir_to_rankdir(&dir),
580                // Mermaid's cluster extractor initializes subgraphs with a fixed dagre config
581                // (nodesep/ranksep=50, marginx/marginy=8). Before each recursive render Mermaid then
582                // overrides `nodesep` to the parent graph value and `ranksep` to `parent.ranksep + 25`.
583                //
584                // We model that in headless mode by keeping the extractor defaults here, then applying
585                // the per-depth override inside `layout_graph_with_recursive_clusters(...)` right
586                // before laying out each extracted graph.
587                //
588                // Reference:
589                // - `packages/mermaid/src/rendering-util/layout-algorithms/dagre/mermaid-graphlib.js`
590                // - `packages/mermaid/src/rendering-util/layout-algorithms/dagre/index.js`
591                nodesep: 50.0,
592                ranksep: 50.0,
593                marginx: 8.0,
594                marginy: 8.0,
595                acyclicer: None,
596                ..Default::default()
597            });
598
599            copy_cluster(&id, graph, &mut cluster_graph, &id, &descendants);
600            extracted_here.push((id, cluster_graph));
601        }
602
603        extracted_here
604    }
605
606    let extracted_here = extract_one_level(graph, subgraphs_by_id, external_connections_by_id);
607    let mut stack: Vec<ExtractFrame> = extracted_here
608        .into_iter()
609        .rev()
610        .map(|(id, graph)| ExtractFrame {
611            id,
612            graph,
613            expanded: false,
614        })
615        .collect();
616
617    while let Some(mut frame) = stack.pop() {
618        if frame.expanded {
619            extracted.insert(frame.id, frame.graph);
620            continue;
621        }
622
623        let children = extract_one_level(
624            &mut frame.graph,
625            subgraphs_by_id,
626            external_connections_by_id,
627        );
628        frame.expanded = true;
629        stack.push(frame);
630        stack.extend(children.into_iter().rev().map(|(id, graph)| ExtractFrame {
631            id,
632            graph,
633            expanded: false,
634        }));
635    }
636}
637
638pub fn layout_flowchart_v2(
639    semantic: &Value,
640    effective_config: &MermaidConfig,
641    measurer: &dyn TextMeasurer,
642    math_renderer: Option<&(dyn MathRenderer + Send + Sync)>,
643) -> Result<FlowchartV2Layout> {
644    let timing_enabled = std::env::var("MERMAN_FLOWCHART_LAYOUT_TIMING")
645        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
646        .unwrap_or(false);
647    let total_start = timing_enabled.then(std::time::Instant::now);
648
649    let deserialize_start = timing_enabled.then(std::time::Instant::now);
650    let model: FlowchartV2Model = crate::json::from_value_ref(semantic)?;
651    let deserialize = deserialize_start.map(|s| s.elapsed()).unwrap_or_default();
652
653    layout_flowchart_v2_with_model(
654        &model,
655        effective_config,
656        measurer,
657        math_renderer,
658        timing_enabled,
659        total_start,
660        deserialize,
661    )
662}
663
664pub fn layout_flowchart_v2_typed(
665    model: &FlowchartV2Model,
666    effective_config: &MermaidConfig,
667    measurer: &dyn TextMeasurer,
668    math_renderer: Option<&(dyn MathRenderer + Send + Sync)>,
669) -> Result<FlowchartV2Layout> {
670    let timing_enabled = std::env::var("MERMAN_FLOWCHART_LAYOUT_TIMING")
671        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
672        .unwrap_or(false);
673    let total_start = timing_enabled.then(std::time::Instant::now);
674
675    layout_flowchart_v2_with_model(
676        model,
677        effective_config,
678        measurer,
679        math_renderer,
680        timing_enabled,
681        total_start,
682        std::time::Duration::default(),
683    )
684}
685
686fn layout_flowchart_v2_with_model(
687    model: &FlowchartV2Model,
688    effective_config: &MermaidConfig,
689    measurer: &dyn TextMeasurer,
690    math_renderer: Option<&(dyn MathRenderer + Send + Sync)>,
691    timing_enabled: bool,
692    total_start: Option<std::time::Instant>,
693    deserialize: std::time::Duration,
694) -> Result<FlowchartV2Layout> {
695    #[derive(Debug, Default, Clone)]
696    struct FlowchartLayoutTimings {
697        total: std::time::Duration,
698        deserialize: std::time::Duration,
699        expand_self_loops: std::time::Duration,
700        build_graph: std::time::Duration,
701        extract_clusters: std::time::Duration,
702        dom_order: std::time::Duration,
703        layout_recursive: std::time::Duration,
704        dagre_calls: u32,
705        dagre_total: std::time::Duration,
706        place_graph: std::time::Duration,
707        build_output: std::time::Duration,
708    }
709
710    let mut timings = FlowchartLayoutTimings {
711        deserialize,
712        ..Default::default()
713    };
714
715    let effective_config_value = effective_config.as_value();
716
717    // Mermaid's dagre adapter expands self-loop edges into a chain of two special label nodes plus
718    // three edges. This avoids `v == w` edges in Dagre and is required for SVG parity (Mermaid
719    // uses `*-cyclic-special-*` ids when rendering self-loops).
720    let expand_self_loops_start = timing_enabled.then(std::time::Instant::now);
721    let self_loop_count = model.edges.iter().filter(|e| e.from == e.to).count();
722    let mut render_edges: Vec<std::borrow::Cow<'_, FlowEdge>> =
723        Vec::with_capacity(model.edges.len() + self_loop_count * 3);
724    let mut self_loop_label_node_ids: Vec<String> = Vec::new();
725    let mut self_loop_label_node_id_set: std::collections::HashSet<String> =
726        std::collections::HashSet::new();
727    for e in &model.edges {
728        if e.from != e.to {
729            render_edges.push(std::borrow::Cow::Borrowed(e));
730            continue;
731        }
732
733        let helper_edges = super::flowchart_self_loop_helper_edges(
734            e,
735            super::FlowchartSelfLoopEdgeOptions::layout(),
736        );
737        if self_loop_label_node_id_set.insert(helper_edges.special_id_1.clone()) {
738            self_loop_label_node_ids.push(helper_edges.special_id_1.clone());
739        }
740        if self_loop_label_node_id_set.insert(helper_edges.special_id_2.clone()) {
741            self_loop_label_node_ids.push(helper_edges.special_id_2.clone());
742        }
743
744        // Mermaid clears the label text on the end segments, but keeps the label (if any) on the
745        // mid edge (`edgeMid` is a structuredClone of the original edge without label changes).
746        render_edges.push(std::borrow::Cow::Owned(helper_edges.edge1));
747        render_edges.push(std::borrow::Cow::Owned(helper_edges.edge_mid));
748        render_edges.push(std::borrow::Cow::Owned(helper_edges.edge2));
749    }
750    if let Some(s) = expand_self_loops_start {
751        timings.expand_self_loops = s.elapsed();
752    }
753
754    let build_graph_start = timing_enabled.then(std::time::Instant::now);
755
756    let nodesep = config_f64(effective_config_value, &["flowchart", "nodeSpacing"]).unwrap_or(50.0);
757    let ranksep = config_f64(effective_config_value, &["flowchart", "rankSpacing"]).unwrap_or(50.0);
758    // Mermaid's default config sets `flowchart.padding` to 15.
759    let node_padding =
760        config_f64(effective_config_value, &["flowchart", "padding"]).unwrap_or(15.0);
761    // Used by a few flowchart-v2 shapes (notably `forkJoin.ts`) to inflate Dagre node dimensions.
762    // Mermaid default config sets `state.padding` to 8.
763    let state_padding = config_f64(effective_config_value, &["state", "padding"]).unwrap_or(8.0);
764    let wrapping_width =
765        config_f64(effective_config_value, &["flowchart", "wrappingWidth"]).unwrap_or(200.0);
766    // Mermaid measures edge labels via `createText(...)` without overriding the default
767    // wrapping width (200px), independent of `flowchart.wrappingWidth`.
768    let edge_label_wrapping_width = 200.0;
769    // Mermaid@11.12.2 renders subgraph titles via the `createText(...)` path and applies a default
770    // wrapping width of 200px (even when `labelType=text` and `htmlLabels=false`), which results
771    // in `<tspan>`-wrapped titles for long words. Match that behavior in headless metrics.
772    let cluster_title_wrapping_width = 200.0;
773    // Mermaid flowchart-v2 has split htmlLabels semantics: node labels follow the root
774    // `htmlLabels` toggle, while edge labels, subgraph titles, CSS selectors, and browser-style
775    // styled-label height quirks follow `flowchart.htmlLabels` and fall back to the root toggle.
776    let node_html_labels = flowchart_effective_node_html_labels(effective_config_value);
777    let flowchart_html_labels = flowchart_effective_html_labels(effective_config_value);
778    let edge_html_labels = flowchart_html_labels;
779    let cluster_html_labels = edge_html_labels;
780    let node_html_label_css_parity = node_html_labels && flowchart_html_labels;
781    let node_wrap_mode = if node_html_labels {
782        WrapMode::HtmlLike
783    } else {
784        WrapMode::SvgLike
785    };
786    let cluster_wrap_mode = if cluster_html_labels {
787        WrapMode::HtmlLike
788    } else {
789        WrapMode::SvgLike
790    };
791    let edge_wrap_mode = if edge_html_labels {
792        WrapMode::HtmlLike
793    } else {
794        WrapMode::SvgLike
795    };
796    // Mermaid FlowDB encodes subgraph nodes with a fixed `padding: 8` in `data4Layout.nodes`.
797    // That value is separate from `flowchart.padding` (node padding) and `nodeSpacing`/`rankSpacing`.
798    let cluster_padding = 8.0;
799    let title_margin_top = config_f64(
800        effective_config_value,
801        &["flowchart", "subGraphTitleMargin", "top"],
802    )
803    .unwrap_or(0.0);
804    let title_margin_bottom = config_f64(
805        effective_config_value,
806        &["flowchart", "subGraphTitleMargin", "bottom"],
807    )
808    .unwrap_or(0.0);
809    let title_total_margin = title_margin_top + title_margin_bottom;
810    let y_shift = title_total_margin / 2.0;
811    let inherit_dir = effective_config_value
812        .get("flowchart")
813        .and_then(|v| v.get("inheritDir"))
814        .and_then(Value::as_bool)
815        .unwrap_or(false);
816
817    fn normalize_css_font_family(font_family: &str) -> String {
818        let s = font_family.trim().trim_end_matches(';').trim();
819        if s.is_empty() {
820            return String::new();
821        }
822
823        let mut parts: Vec<String> = Vec::new();
824        let mut cur = String::new();
825        let mut in_single = false;
826        let mut in_double = false;
827
828        for ch in s.chars() {
829            match ch {
830                '\'' if !in_double => {
831                    in_single = !in_single;
832                    cur.push(ch);
833                }
834                '"' if !in_single => {
835                    in_double = !in_double;
836                    cur.push(ch);
837                }
838                ',' if !in_single && !in_double => {
839                    let p = cur.trim();
840                    if !p.is_empty() {
841                        parts.push(p.to_string());
842                    }
843                    cur.clear();
844                }
845                _ => cur.push(ch),
846            }
847        }
848
849        let p = cur.trim();
850        if !p.is_empty() {
851            parts.push(p.to_string());
852        }
853        parts.join(",")
854    }
855
856    fn parse_font_size_px(v: &serde_json::Value) -> Option<f64> {
857        if let Some(n) = v.as_f64() {
858            return Some(n);
859        }
860        if let Some(n) = v.as_i64() {
861            return Some(n as f64);
862        }
863        if let Some(n) = v.as_u64() {
864            return Some(n as f64);
865        }
866        let s = v.as_str()?.trim();
867        if s.is_empty() {
868            return None;
869        }
870        let mut num = String::new();
871        for (idx, ch) in s.chars().enumerate() {
872            if ch.is_ascii_digit() {
873                num.push(ch);
874                continue;
875            }
876            if idx == 0 && (ch == '-' || ch == '+') {
877                num.push(ch);
878                continue;
879            }
880            break;
881        }
882        if num.trim().is_empty() {
883            return None;
884        }
885        num.parse::<f64>().ok()
886    }
887
888    let default_theme_font_family = "\"trebuchet ms\",verdana,arial,sans-serif".to_string();
889    let theme_font_family =
890        config_string(effective_config_value, &["themeVariables", "fontFamily"])
891            .map(|s| normalize_css_font_family(&s));
892    let top_font_family = config_string(effective_config_value, &["fontFamily"])
893        .map(|s| normalize_css_font_family(&s));
894    let font_family = Some(match (top_font_family, theme_font_family) {
895        (Some(top), Some(theme)) if theme == default_theme_font_family => top,
896        (_, Some(theme)) => theme,
897        (Some(top), None) => top,
898        (None, None) => default_theme_font_family,
899    });
900    let font_size = effective_config_value
901        .get("themeVariables")
902        .and_then(|tv| tv.get("fontSize"))
903        .and_then(parse_font_size_px)
904        .unwrap_or(16.0);
905    let font_weight = config_string(effective_config_value, &["fontWeight"]);
906    let text_style = TextStyle {
907        font_family,
908        font_size,
909        font_weight,
910    };
911    let html_label_text_style =
912        flowchart_html_label_measurement_base_style(&text_style, effective_config_value);
913    let node_label_base_style = if node_wrap_mode == WrapMode::HtmlLike {
914        &html_label_text_style
915    } else {
916        &text_style
917    };
918    let cluster_label_base_style = if cluster_wrap_mode == WrapMode::HtmlLike {
919        &html_label_text_style
920    } else {
921        &text_style
922    };
923    let edge_label_base_style = if edge_wrap_mode == WrapMode::HtmlLike {
924        &html_label_text_style
925    } else {
926        &text_style
927    };
928
929    let diagram_direction = normalize_dir(model.direction.as_deref().unwrap_or("TB"));
930    let has_subgraphs = !model.subgraphs.is_empty();
931    let mut subgraphs_by_id: std::collections::HashMap<String, FlowSubgraph> =
932        std::collections::HashMap::new();
933    for sg in &model.subgraphs {
934        subgraphs_by_id.insert(sg.id.clone(), sg.clone());
935    }
936    let subgraph_ids: std::collections::HashSet<&str> =
937        model.subgraphs.iter().map(|sg| sg.id.as_str()).collect();
938    let subgraph_id_set: std::collections::HashSet<String> =
939        model.subgraphs.iter().map(|sg| sg.id.clone()).collect();
940    let mut g: Graph<NodeLabel, EdgeLabel, GraphLabel> = Graph::new(GraphOptions {
941        multigraph: true,
942        // Mermaid's Dagre adapter always enables `compound: true`, even if there are no explicit
943        // subgraphs. This also allows `nestingGraph.run` to connect components during ranking.
944        compound: true,
945        directed: true,
946    });
947    g.set_graph(GraphLabel {
948        rankdir: rank_dir_from_flow(&diagram_direction),
949        nodesep,
950        ranksep,
951        marginx: 8.0,
952        marginy: 8.0,
953        acyclicer: None,
954        ..Default::default()
955    });
956
957    let mut empty_subgraph_ids: Vec<String> = Vec::new();
958    let mut cluster_node_labels: std::collections::HashMap<String, NodeLabel> =
959        std::collections::HashMap::new();
960    for sg in &model.subgraphs {
961        if sg.nodes.is_empty() {
962            // Mermaid renders empty subgraphs as regular nodes. Keep the semantic `subgraph`
963            // definition around for styling/title, but size + lay it out as a leaf node.
964            empty_subgraph_ids.push(sg.id.clone());
965            continue;
966        }
967        // Mermaid does not pre-size compound (subgraph) nodes based on title metrics for Dagre
968        // layout. Their dimensions are computed from children (border nodes) and then adjusted at
969        // render time for title width and configured margins.
970        cluster_node_labels.insert(sg.id.clone(), NodeLabel::default());
971    }
972
973    let mut leaf_node_labels: std::collections::HashMap<String, NodeLabel> =
974        std::collections::HashMap::new();
975    let mut leaf_label_metrics_by_id: HashMap<String, (f64, f64)> = HashMap::new();
976    leaf_label_metrics_by_id.reserve(model.nodes.len() + empty_subgraph_ids.len());
977    for n in &model.nodes {
978        // Mermaid treats the subgraph id as the "group node" id (a cluster can be referenced in
979        // edges). Avoid introducing a separate leaf node that would collide with the cluster node
980        // of the same id.
981        if subgraph_ids.contains(n.id.as_str()) {
982            continue;
983        }
984        let raw_label = n.label.as_deref().unwrap_or(&n.id);
985        let label_type = n.label_type.as_deref().unwrap_or("text");
986        let node_text_style = flowchart_effective_text_style_for_node_classes(
987            node_label_base_style,
988            &model.class_defs,
989            &n.classes,
990            &n.styles,
991        );
992        let mut metrics = flowchart_label_metrics_for_layout(FlowchartLabelMetricsRequest {
993            measurer,
994            raw_label,
995            label_type,
996            style: node_text_style.as_ref(),
997            max_width_px: Some(wrapping_width),
998            wrap_mode: node_wrap_mode,
999            config: effective_config,
1000            math_renderer,
1001            preserve_string_whitespace_height: node_html_label_css_parity,
1002        });
1003        let span_css_height_parity =
1004            flowchart_node_has_span_css_height_parity(&model.class_defs, &n.classes);
1005        if node_html_label_css_parity && span_css_height_parity {
1006            crate::text::flowchart_apply_mermaid_styled_node_height_parity(
1007                &mut metrics,
1008                node_text_style.as_ref(),
1009            );
1010        }
1011        if node_wrap_mode == WrapMode::SvgLike
1012            && label_type != "markdown"
1013            && !raw_label.contains('<')
1014            && !raw_label.contains('>')
1015            && matches!(
1016                n.layout_shape.as_deref().unwrap_or("squareRect"),
1017                "squareRect"
1018            )
1019        {
1020            let plain = crate::flowchart::flowchart_label_plain_text_for_layout(
1021                raw_label, label_type, false,
1022            );
1023            metrics.width = flowchart_svg_plain_computed_width_px(
1024                measurer,
1025                &plain,
1026                node_text_style.as_ref(),
1027                Some(wrapping_width),
1028            );
1029        }
1030        leaf_label_metrics_by_id.insert(n.id.clone(), (metrics.width, metrics.height));
1031        let (width, height) = node_layout_dimensions(NodeLayoutDimensionsRequest {
1032            layout_shape: n.layout_shape.as_deref(),
1033            layout_direction: &diagram_direction,
1034            metrics,
1035            padding: node_padding,
1036            state_padding,
1037            wrap_mode: node_wrap_mode,
1038            node_icon: n.icon.as_deref(),
1039            node_img: n.img.as_deref(),
1040            node_pos: n.pos.as_deref(),
1041            node_asset_width: n.asset_width,
1042            node_asset_height: n.asset_height,
1043        });
1044        leaf_node_labels.insert(
1045            n.id.clone(),
1046            NodeLabel {
1047                width,
1048                height,
1049                ..Default::default()
1050            },
1051        );
1052    }
1053    for sg in &model.subgraphs {
1054        if !sg.nodes.is_empty() {
1055            continue;
1056        }
1057        let label_type = sg.label_type.as_deref().unwrap_or("text");
1058        let sg_text_style = flowchart_effective_text_style_for_classes(
1059            cluster_label_base_style,
1060            &model.class_defs,
1061            &sg.classes,
1062            &[],
1063        );
1064        let metrics = flowchart_label_metrics_for_layout(FlowchartLabelMetricsRequest {
1065            measurer,
1066            raw_label: &sg.title,
1067            label_type,
1068            style: sg_text_style.as_ref(),
1069            max_width_px: Some(cluster_title_wrapping_width),
1070            wrap_mode: node_wrap_mode,
1071            config: effective_config,
1072            math_renderer,
1073            preserve_string_whitespace_height: node_html_label_css_parity,
1074        });
1075        leaf_label_metrics_by_id.insert(sg.id.clone(), (metrics.width, metrics.height));
1076        let (width, height) = node_layout_dimensions(NodeLayoutDimensionsRequest {
1077            layout_shape: Some("squareRect"),
1078            layout_direction: &diagram_direction,
1079            metrics,
1080            padding: cluster_padding,
1081            state_padding,
1082            wrap_mode: node_wrap_mode,
1083            node_icon: None,
1084            node_img: None,
1085            node_pos: None,
1086            node_asset_width: None,
1087            node_asset_height: None,
1088        });
1089        leaf_node_labels.insert(
1090            sg.id.clone(),
1091            NodeLabel {
1092                width,
1093                height,
1094                ..Default::default()
1095            },
1096        );
1097    }
1098
1099    // Mermaid constructs the Dagre graph by:
1100    // 1) inserting subgraph (cluster) nodes first (in reverse `subgraphs[]` order), then
1101    // 2) inserting vertex nodes (in FlowDB `Map` insertion order),
1102    // and setting `parentId` as each node is inserted.
1103    //
1104    // Matching this order matters because Graphlib insertion order can affect compound-graph
1105    // child ordering, anchor selection and deterministic tie-breaking in layout.
1106    let mut inserted: std::collections::HashSet<String> = std::collections::HashSet::new();
1107    let mut parent_assigned: std::collections::HashSet<String> = std::collections::HashSet::new();
1108    let insert_one = |id: &str,
1109                      g: &mut Graph<NodeLabel, EdgeLabel, GraphLabel>,
1110                      inserted: &mut std::collections::HashSet<String>| {
1111        if inserted.contains(id) {
1112            return;
1113        }
1114        if let Some(lbl) = cluster_node_labels.get(id).cloned() {
1115            g.set_node(id.to_string(), lbl);
1116            inserted.insert(id.to_string());
1117            return;
1118        }
1119        if let Some(lbl) = leaf_node_labels.get(id).cloned() {
1120            g.set_node(id.to_string(), lbl);
1121            inserted.insert(id.to_string());
1122        }
1123    };
1124
1125    if has_subgraphs {
1126        // Match Mermaid's `FlowDB.getData()` parent assignment: build `parentId` by iterating
1127        // subgraphs in reverse order and recording each membership.
1128        let mut parent_by_id: std::collections::HashMap<String, String> =
1129            std::collections::HashMap::new();
1130        for sg in model.subgraphs.iter().rev() {
1131            for child in &sg.nodes {
1132                parent_by_id.insert(child.clone(), sg.id.clone());
1133            }
1134        }
1135
1136        let insert_with_parent =
1137            |id: &str,
1138             g: &mut Graph<NodeLabel, EdgeLabel, GraphLabel>,
1139             inserted: &mut std::collections::HashSet<String>,
1140             parent_assigned: &mut std::collections::HashSet<String>| {
1141                insert_one(id, g, inserted);
1142                if !parent_assigned.insert(id.to_string()) {
1143                    return;
1144                }
1145                if let Some(p) = parent_by_id.get(id).cloned() {
1146                    g.set_parent(id.to_string(), p);
1147                }
1148            };
1149
1150        for sg in model.subgraphs.iter().rev() {
1151            insert_with_parent(sg.id.as_str(), &mut g, &mut inserted, &mut parent_assigned);
1152        }
1153        for n in &model.nodes {
1154            insert_with_parent(n.id.as_str(), &mut g, &mut inserted, &mut parent_assigned);
1155        }
1156        for id in &model.vertex_calls {
1157            insert_with_parent(id.as_str(), &mut g, &mut inserted, &mut parent_assigned);
1158        }
1159    } else {
1160        // No subgraphs: insertion order still matters for deterministic Dagre tie-breaking.
1161        for n in &model.nodes {
1162            insert_one(n.id.as_str(), &mut g, &mut inserted);
1163        }
1164        for id in &model.vertex_calls {
1165            insert_one(id.as_str(), &mut g, &mut inserted);
1166        }
1167    }
1168
1169    // Materialize self-loop helper label nodes and place them in the same parent cluster as the
1170    // base node (if any), matching Mermaid `@11.12.2` dagre layout adapter behavior.
1171    for id in &self_loop_label_node_ids {
1172        if !g.has_node(id) {
1173            g.set_node(
1174                id.clone(),
1175                NodeLabel {
1176                    // Mermaid initializes these labelRect nodes at 10x10, but then immediately
1177                    // runs `insertNode(...)` + `updateNodeBounds(...)` before Dagre layout. For an
1178                    // empty `labelRect`, the measured bbox collapses to ~0.1x0.1 and that is what
1179                    // Dagre actually sees for spacing. Match that here for layout parity.
1180                    width: 0.1_f32 as f64,
1181                    height: 0.1_f32 as f64,
1182                    ..Default::default()
1183                },
1184            );
1185        }
1186        let Some((base, _)) = id.split_once("---") else {
1187            continue;
1188        };
1189        if let Some(p) = g.parent(base) {
1190            g.set_parent(id.clone(), p.to_string());
1191        }
1192    }
1193
1194    let effective_dir_by_id = if has_subgraphs {
1195        compute_effective_dir_by_id(&subgraphs_by_id, &g, &diagram_direction, inherit_dir)
1196    } else {
1197        HashMap::new()
1198    };
1199
1200    // Map SVG edge ids to the multigraph key used by the Dagre layout graph. Most edges use their
1201    // `id` as the key, but Mermaid uses distinct keys for the self-loop special edges and we also
1202    // want deterministic ordering under our BTree-backed graph storage.
1203    let mut edge_key_by_id: HashMap<String, String> = HashMap::new();
1204    let mut edge_id_by_key: HashMap<String, String> = HashMap::new();
1205
1206    for e in &render_edges {
1207        // Mermaid uses distinct graphlib multigraph keys for self-loop helper edges.
1208        // Reference: `packages/mermaid/src/rendering-util/layout-algorithms/dagre/index.js`
1209        let edge_key = if let Some(prefix) = e.id.strip_suffix("-cyclic-special-1") {
1210            format!("{prefix}-cyclic-special-0")
1211        } else if let Some(prefix) = e.id.strip_suffix("-cyclic-special-mid") {
1212            format!("{prefix}-cyclic-special-1")
1213        } else if let Some(prefix) = e.id.strip_suffix("-cyclic-special-2") {
1214            // Mermaid contains this typo in the edge key (note the `<`):
1215            // `nodeId + '-cyc<lic-special-2'`
1216            format!("{prefix}-cyc<lic-special-2")
1217        } else {
1218            e.id.clone()
1219        };
1220        edge_key_by_id.insert(e.id.clone(), edge_key.clone());
1221        edge_id_by_key.insert(edge_key.clone(), e.id.clone());
1222
1223        let from = e.from.clone();
1224        let to = e.to.clone();
1225
1226        if edge_label_is_non_empty(e) {
1227            let label_text = e.label.as_deref().unwrap_or_default();
1228            let label_type = e.label_type.as_deref().unwrap_or("text");
1229            let edge_text_style = flowchart_effective_text_style_for_classes(
1230                edge_label_base_style,
1231                &model.class_defs,
1232                &e.classes,
1233                &e.style,
1234            );
1235            let metrics = if label_type == "markdown" && edge_wrap_mode != WrapMode::HtmlLike {
1236                crate::text::measure_wrapped_markdown_with_flowchart_bold_deltas(
1237                    measurer,
1238                    label_text,
1239                    edge_text_style.as_ref(),
1240                    Some(edge_label_wrapping_width),
1241                    edge_wrap_mode,
1242                )
1243            } else {
1244                flowchart_label_metrics_for_layout(FlowchartLabelMetricsRequest {
1245                    measurer,
1246                    raw_label: label_text,
1247                    label_type,
1248                    style: edge_text_style.as_ref(),
1249                    max_width_px: Some(edge_label_wrapping_width),
1250                    wrap_mode: edge_wrap_mode,
1251                    config: effective_config,
1252                    math_renderer,
1253                    preserve_string_whitespace_height: false,
1254                })
1255            };
1256            let (label_width, label_height) = if edge_html_labels {
1257                (metrics.width.max(1.0), metrics.height.max(1.0))
1258            } else {
1259                // Mermaid's SVG edge-labels include a padded background rect (+2px left/right and
1260                // +2px top/bottom).
1261                (
1262                    (metrics.width + 4.0).max(1.0),
1263                    (metrics.height + 4.0).max(1.0),
1264                )
1265            };
1266
1267            let minlen = e.length.max(1);
1268            let el = EdgeLabel {
1269                width: label_width,
1270                height: label_height,
1271                labelpos: LabelPos::C,
1272                // Dagre layout defaults `labeloffset` to 10 when unspecified.
1273                labeloffset: 10.0,
1274                minlen,
1275                weight: 1.0,
1276                ..Default::default()
1277            };
1278
1279            g.set_edge_named(from, to, Some(edge_key), Some(el));
1280        } else {
1281            let el = EdgeLabel {
1282                width: 0.0,
1283                height: 0.0,
1284                labelpos: LabelPos::C,
1285                // Dagre layout defaults `labeloffset` to 10 when unspecified.
1286                labeloffset: 10.0,
1287                minlen: e.length.max(1),
1288                weight: 1.0,
1289                ..Default::default()
1290            };
1291            g.set_edge_named(from, to, Some(edge_key), Some(el));
1292        }
1293    }
1294
1295    let external_connections_by_id = if has_subgraphs {
1296        adjust_flowchart_clusters_and_edges(&mut g)
1297    } else {
1298        std::collections::HashMap::new()
1299    };
1300
1301    let mut edge_endpoints_by_id: HashMap<String, (String, String)> = HashMap::new();
1302    for ek in g.edge_keys() {
1303        let Some(edge_key) = ek.name.as_deref() else {
1304            continue;
1305        };
1306        let edge_id = edge_id_by_key
1307            .get(edge_key)
1308            .cloned()
1309            .unwrap_or_else(|| edge_key.to_string());
1310        edge_endpoints_by_id.insert(edge_id, (ek.v.clone(), ek.w.clone()));
1311    }
1312
1313    if let Some(s) = build_graph_start {
1314        timings.build_graph = s.elapsed();
1315    }
1316
1317    let mut extracted_graphs: std::collections::HashMap<
1318        String,
1319        Graph<NodeLabel, EdgeLabel, GraphLabel>,
1320    > = std::collections::HashMap::new();
1321    if has_subgraphs {
1322        let extract_start = timing_enabled.then(std::time::Instant::now);
1323        extract_clusters_recursively(
1324            &mut g,
1325            &subgraphs_by_id,
1326            &external_connections_by_id,
1327            &mut extracted_graphs,
1328            0,
1329        );
1330        if let Some(s) = extract_start {
1331            timings.extract_clusters = s.elapsed();
1332        }
1333    }
1334
1335    // Mermaid's flowchart-v2 renderer inserts node DOM elements in `graph.nodes()` order before
1336    // running Dagre layout, including for recursively extracted cluster graphs. Capture that
1337    // insertion order per root so the headless SVG matches strict DOM expectations.
1338    let mut dom_node_order_by_root: std::collections::HashMap<String, Vec<String>> =
1339        std::collections::HashMap::new();
1340    let dom_order_start = timing_enabled.then(std::time::Instant::now);
1341    dom_node_order_by_root.insert(String::new(), g.node_ids());
1342    for (id, cg) in &extracted_graphs {
1343        dom_node_order_by_root.insert(id.clone(), cg.node_ids());
1344    }
1345    if let Some(s) = dom_order_start {
1346        timings.dom_order = s.elapsed();
1347    }
1348
1349    type Rect = merman_core::geom::Box2;
1350
1351    struct ClusterTitleMetricsContext<'a> {
1352        subgraphs_by_id: &'a std::collections::HashMap<String, FlowSubgraph>,
1353        measurer: &'a dyn TextMeasurer,
1354        text_style: &'a TextStyle,
1355        html_label_text_style: &'a TextStyle,
1356        title_wrapping_width: f64,
1357        wrap_mode: WrapMode,
1358        config: &'a MermaidConfig,
1359        math_renderer: Option<&'a (dyn MathRenderer + Send + Sync)>,
1360    }
1361
1362    fn cluster_title_metrics_for_layout(
1363        id: &str,
1364        ctx: &ClusterTitleMetricsContext<'_>,
1365    ) -> Option<(f64, f64)> {
1366        let sg = ctx.subgraphs_by_id.get(id)?;
1367        let label_type = sg.label_type.as_deref().unwrap_or("text");
1368        let metrics = flowchart_label_metrics_for_layout(FlowchartLabelMetricsRequest {
1369            measurer: ctx.measurer,
1370            raw_label: &sg.title,
1371            label_type,
1372            style: if ctx.wrap_mode == WrapMode::HtmlLike {
1373                ctx.html_label_text_style
1374            } else {
1375                ctx.text_style
1376            },
1377            max_width_px: Some(ctx.title_wrapping_width),
1378            wrap_mode: ctx.wrap_mode,
1379            config: ctx.config,
1380            math_renderer: ctx.math_renderer,
1381            preserve_string_whitespace_height: false,
1382        });
1383        Some((metrics.width.max(1.0), metrics.height.max(1.0)))
1384    }
1385
1386    fn extracted_graph_bbox_rect(
1387        g: &Graph<NodeLabel, EdgeLabel, GraphLabel>,
1388        title_total_margin: f64,
1389        extracted: &std::collections::HashMap<String, Graph<NodeLabel, EdgeLabel, GraphLabel>>,
1390        subgraph_id_set: &std::collections::HashSet<String>,
1391        title_metrics_ctx: &ClusterTitleMetricsContext<'_>,
1392        cluster_padding: f64,
1393    ) -> Option<Rect> {
1394        fn graph_content_rect(
1395            g: &Graph<NodeLabel, EdgeLabel, GraphLabel>,
1396            extracted: &std::collections::HashMap<String, Graph<NodeLabel, EdgeLabel, GraphLabel>>,
1397            subgraph_id_set: &std::collections::HashSet<String>,
1398            title_total_margin: f64,
1399            title_metrics_ctx: &ClusterTitleMetricsContext<'_>,
1400            cluster_padding: f64,
1401        ) -> Option<Rect> {
1402            let mut out: Option<Rect> = None;
1403            for id in g.node_ids() {
1404                let Some(n) = g.node(&id) else { continue };
1405                let (Some(x), Some(y)) = (n.x, n.y) else {
1406                    continue;
1407                };
1408                let mut width = n.width;
1409                let mut height = n.height;
1410                let is_cluster_node = extracted.contains_key(&id) && g.children(&id).is_empty();
1411                let is_non_recursive_cluster =
1412                    subgraph_id_set.contains(&id) && !g.children(&id).is_empty();
1413
1414                // Mermaid increases cluster node height by `subGraphTitleTotalMargin` *after* Dagre
1415                // layout (just before rendering), and `updateNodeBounds(...)` measures the DOM
1416                // bbox after that expansion. Mirror that here for non-recursive clusters.
1417                //
1418                // For leaf clusterNodes (recursively rendered clusters), the node's width/height
1419                // comes directly from `updateNodeBounds(...)`, so do not add margins again.
1420                if !is_cluster_node && is_non_recursive_cluster {
1421                    if title_total_margin > 0.0 {
1422                        height = (height + title_total_margin).max(1.0);
1423                    }
1424                    if let Some((title_w, title_h)) =
1425                        cluster_title_metrics_for_layout(&id, title_metrics_ctx)
1426                    {
1427                        width = width.max(title_w + cluster_padding);
1428                        height = height.max(title_h + title_total_margin);
1429                    }
1430                }
1431
1432                let r = Rect::from_center(x, y, width, height);
1433                if let Some(ref mut cur) = out {
1434                    cur.union(r);
1435                } else {
1436                    out = Some(r);
1437                }
1438            }
1439            for ek in g.edge_keys() {
1440                let Some(e) = g.edge_by_key(&ek) else {
1441                    continue;
1442                };
1443                let (Some(x), Some(y)) = (e.x, e.y) else {
1444                    continue;
1445                };
1446                if e.width <= 0.0 && e.height <= 0.0 {
1447                    continue;
1448                }
1449                let r = Rect::from_center(x, y, e.width, e.height);
1450                if let Some(ref mut cur) = out {
1451                    cur.union(r);
1452                } else {
1453                    out = Some(r);
1454                }
1455            }
1456            out
1457        }
1458
1459        graph_content_rect(
1460            g,
1461            extracted,
1462            subgraph_id_set,
1463            title_total_margin,
1464            title_metrics_ctx,
1465            cluster_padding,
1466        )
1467    }
1468
1469    fn apply_mermaid_subgraph_title_shifts(
1470        graph: &mut Graph<NodeLabel, EdgeLabel, GraphLabel>,
1471        extracted: &std::collections::HashMap<String, Graph<NodeLabel, EdgeLabel, GraphLabel>>,
1472        subgraph_id_set: &std::collections::HashSet<String>,
1473        y_shift: f64,
1474    ) {
1475        if y_shift.abs() < 1e-9 {
1476            return;
1477        }
1478
1479        // Mermaid v11.12.2 adjusts Y positions after Dagre layout:
1480        // - regular nodes: +subGraphTitleTotalMargin/2
1481        // - clusterNode nodes (recursively rendered clusters): +subGraphTitleTotalMargin
1482        // - pure cluster nodes (non-recursive clusters): no y-shift (but height grows elsewhere)
1483        for id in graph.node_ids() {
1484            // A cluster is only a Mermaid "clusterNode" placeholder if it is a leaf in the
1485            // current graph. Extracted graphs contain an injected parent cluster node with the
1486            // same id (and children), which must follow the pure-cluster path.
1487            let is_cluster_node = extracted.contains_key(&id) && graph.children(&id).is_empty();
1488            let delta_y = if is_cluster_node {
1489                y_shift * 2.0
1490            } else if subgraph_id_set.contains(&id) && !graph.children(&id).is_empty() {
1491                0.0
1492            } else {
1493                y_shift
1494            };
1495            if delta_y.abs() > 1e-9 {
1496                let Some(y) = graph.node(&id).and_then(|n| n.y) else {
1497                    continue;
1498                };
1499                if let Some(n) = graph.node_mut(&id) {
1500                    n.y = Some(y + delta_y);
1501                }
1502            }
1503        }
1504
1505        // Mermaid shifts all edge points and the edge label position by +subGraphTitleTotalMargin/2.
1506        for ek in graph.edge_keys() {
1507            let Some(e) = graph.edge_mut_by_key(&ek) else {
1508                continue;
1509            };
1510            if let Some(y) = e.y {
1511                e.y = Some(y + y_shift);
1512            }
1513            for p in &mut e.points {
1514                p.y += y_shift;
1515            }
1516        }
1517    }
1518
1519    struct RecursiveLayoutContext<'a> {
1520        extracted:
1521            &'a mut std::collections::HashMap<String, Graph<NodeLabel, EdgeLabel, GraphLabel>>,
1522        subgraph_id_set: &'a std::collections::HashSet<String>,
1523        y_shift: f64,
1524        cluster_node_labels: &'a std::collections::HashMap<String, NodeLabel>,
1525        title_total_margin: f64,
1526        title_metrics_ctx: &'a ClusterTitleMetricsContext<'a>,
1527        cluster_padding: f64,
1528        timings: &'a mut FlowchartLayoutTimings,
1529        timing_enabled: bool,
1530    }
1531
1532    fn layout_graph_with_recursive_clusters(
1533        graph: &mut Graph<NodeLabel, EdgeLabel, GraphLabel>,
1534        graph_cluster_id: Option<&str>,
1535        _depth: usize,
1536        ctx: &mut RecursiveLayoutContext<'_>,
1537    ) {
1538        #[derive(Clone)]
1539        struct LayoutFrame {
1540            cluster_id: Option<String>,
1541            expanded: bool,
1542        }
1543
1544        fn recursive_child_ids(
1545            graph: &Graph<NodeLabel, EdgeLabel, GraphLabel>,
1546            extracted: &std::collections::HashMap<String, Graph<NodeLabel, EdgeLabel, GraphLabel>>,
1547        ) -> Vec<String> {
1548            graph
1549                .node_ids()
1550                .into_iter()
1551                // Only recurse into extracted graphs for leaf cluster nodes ("clusterNode" in
1552                // Mermaid). Child graphs also get their parent cluster node injected, with
1553                // children, and must not recurse back into themselves.
1554                .filter(|id| graph.children(id).is_empty() && extracted.contains_key(id))
1555                .collect()
1556        }
1557
1558        fn inject_parent_cluster(
1559            graph: &mut Graph<NodeLabel, EdgeLabel, GraphLabel>,
1560            cluster_id: &str,
1561            ctx: &RecursiveLayoutContext<'_>,
1562        ) {
1563            if !graph.has_node(cluster_id) {
1564                let lbl = ctx
1565                    .cluster_node_labels
1566                    .get(cluster_id)
1567                    .cloned()
1568                    .unwrap_or_default();
1569                graph.set_node(cluster_id.to_string(), lbl);
1570            }
1571            let node_ids = graph.node_ids();
1572            for node_id in node_ids {
1573                if node_id == cluster_id {
1574                    continue;
1575                }
1576                if graph.parent(&node_id).is_none() {
1577                    graph.set_parent(node_id, cluster_id.to_string());
1578                }
1579            }
1580        }
1581
1582        fn update_child_cluster_bounds(
1583            graph: &mut Graph<NodeLabel, EdgeLabel, GraphLabel>,
1584            ctx: &RecursiveLayoutContext<'_>,
1585        ) {
1586            let ids = recursive_child_ids(graph, ctx.extracted);
1587            for id in ids {
1588                let Some(child) = ctx.extracted.get(&id) else {
1589                    continue;
1590                };
1591
1592                // In Mermaid, `updateNodeBounds(...)` measures the recursively rendered `<g
1593                // class="root">` group. In that render path, the child graph contains a node
1594                // matching the cluster id (inserted via `graph.setNode(parentCluster.id, ...)`),
1595                // whose computed compound bounds correspond to the cluster box measured in the DOM.
1596                if let Some(r) = extracted_graph_bbox_rect(
1597                    child,
1598                    ctx.title_total_margin,
1599                    ctx.extracted,
1600                    ctx.subgraph_id_set,
1601                    ctx.title_metrics_ctx,
1602                    ctx.cluster_padding,
1603                ) {
1604                    if let Some(n) = graph.node_mut(&id) {
1605                        n.width = r.width().max(1.0);
1606                        n.height = r.height().max(1.0);
1607                    }
1608                } else if let Some(n_child) = child.node(&id) {
1609                    if let Some(n) = graph.node_mut(&id) {
1610                        n.width = n_child.width.max(1.0);
1611                        n.height = n_child.height.max(1.0);
1612                    }
1613                }
1614            }
1615        }
1616
1617        fn layout_one_graph(
1618            graph: &mut Graph<NodeLabel, EdgeLabel, GraphLabel>,
1619            ctx: &mut RecursiveLayoutContext<'_>,
1620        ) {
1621            if ctx.timing_enabled {
1622                ctx.timings.dagre_calls += 1;
1623                let start = std::time::Instant::now();
1624                dugong::layout_dagreish(graph);
1625                ctx.timings.dagre_total += start.elapsed();
1626            } else {
1627                dugong::layout_dagreish(graph);
1628            }
1629            apply_mermaid_subgraph_title_shifts(
1630                graph,
1631                ctx.extracted,
1632                ctx.subgraph_id_set,
1633                ctx.y_shift,
1634            );
1635        }
1636
1637        let mut stack = vec![LayoutFrame {
1638            cluster_id: graph_cluster_id.map(str::to_string),
1639            expanded: false,
1640        }];
1641
1642        while let Some(frame) = stack.pop() {
1643            if !frame.expanded {
1644                let Some((child_ids, parent_nodesep, parent_ranksep)) = (match &frame.cluster_id {
1645                    Some(id) => ctx.extracted.get(id).map(|g| {
1646                        (
1647                            recursive_child_ids(g, ctx.extracted),
1648                            g.graph().nodesep,
1649                            g.graph().ranksep,
1650                        )
1651                    }),
1652                    None => Some((
1653                        recursive_child_ids(graph, ctx.extracted),
1654                        graph.graph().nodesep,
1655                        graph.graph().ranksep,
1656                    )),
1657                }) else {
1658                    continue;
1659                };
1660
1661                stack.push(LayoutFrame {
1662                    cluster_id: frame.cluster_id,
1663                    expanded: true,
1664                });
1665
1666                for child_id in child_ids.iter().rev() {
1667                    // Match Mermaid `recursiveRender` behavior: before laying out a recursively
1668                    // rendered cluster graph, override `nodesep` to the parent graph spacing and
1669                    // `ranksep` to `parent.ranksep + 25`. This compounds for nested recursive
1670                    // clusters.
1671                    if let Some(child) = ctx.extracted.get_mut(child_id) {
1672                        child.graph_mut().nodesep = parent_nodesep;
1673                        child.graph_mut().ranksep = parent_ranksep + 25.0;
1674                    }
1675                    stack.push(LayoutFrame {
1676                        cluster_id: Some(child_id.clone()),
1677                        expanded: false,
1678                    });
1679                }
1680                continue;
1681            }
1682
1683            if let Some(cluster_id) = frame.cluster_id {
1684                let Some(mut current) = ctx.extracted.remove(&cluster_id) else {
1685                    continue;
1686                };
1687                update_child_cluster_bounds(&mut current, ctx);
1688                inject_parent_cluster(&mut current, &cluster_id, ctx);
1689                layout_one_graph(&mut current, ctx);
1690                ctx.extracted.insert(cluster_id, current);
1691            } else {
1692                update_child_cluster_bounds(graph, ctx);
1693                layout_one_graph(graph, ctx);
1694            }
1695        }
1696    }
1697
1698    let layout_start = timing_enabled.then(std::time::Instant::now);
1699    {
1700        let title_metrics_ctx = ClusterTitleMetricsContext {
1701            subgraphs_by_id: &subgraphs_by_id,
1702            measurer,
1703            text_style: &text_style,
1704            html_label_text_style: &html_label_text_style,
1705            title_wrapping_width: cluster_title_wrapping_width,
1706            wrap_mode: cluster_wrap_mode,
1707            config: effective_config,
1708            math_renderer,
1709        };
1710        let mut recursive_layout_ctx = RecursiveLayoutContext {
1711            extracted: &mut extracted_graphs,
1712            subgraph_id_set: &subgraph_id_set,
1713            y_shift,
1714            cluster_node_labels: &cluster_node_labels,
1715            title_total_margin,
1716            title_metrics_ctx: &title_metrics_ctx,
1717            cluster_padding,
1718            timings: &mut timings,
1719            timing_enabled,
1720        };
1721        layout_graph_with_recursive_clusters(&mut g, None, 0, &mut recursive_layout_ctx);
1722    }
1723    if let Some(s) = layout_start {
1724        timings.layout_recursive = s.elapsed();
1725    }
1726
1727    let mut leaf_rects: std::collections::HashMap<String, Rect> = std::collections::HashMap::new();
1728    let mut base_pos: std::collections::HashMap<String, (f64, f64)> =
1729        std::collections::HashMap::new();
1730    let mut edge_override_points: std::collections::HashMap<String, Vec<LayoutPoint>> =
1731        std::collections::HashMap::new();
1732    let mut edge_override_label: std::collections::HashMap<String, Option<LayoutLabel>> =
1733        std::collections::HashMap::new();
1734    let mut edge_override_from_cluster: std::collections::HashMap<String, Option<String>> =
1735        std::collections::HashMap::new();
1736    let mut edge_override_to_cluster: std::collections::HashMap<String, Option<String>> =
1737        std::collections::HashMap::new();
1738    let mut leaf_node_ids: std::collections::HashSet<String> = model
1739        .nodes
1740        .iter()
1741        .filter(|n| !subgraph_ids.contains(n.id.as_str()))
1742        .map(|n| n.id.clone())
1743        .collect();
1744    for id in &self_loop_label_node_ids {
1745        leaf_node_ids.insert(id.clone());
1746    }
1747    for id in &empty_subgraph_ids {
1748        leaf_node_ids.insert(id.clone());
1749    }
1750
1751    struct PlaceGraphInputs<'a> {
1752        edge_id_by_key: &'a std::collections::HashMap<String, String>,
1753        extracted_graphs:
1754            &'a std::collections::HashMap<String, Graph<NodeLabel, EdgeLabel, GraphLabel>>,
1755        subgraph_ids: &'a std::collections::HashSet<&'a str>,
1756        leaf_node_ids: &'a std::collections::HashSet<String>,
1757    }
1758
1759    struct PlaceGraphOutputs<'a> {
1760        base_pos: &'a mut std::collections::HashMap<String, (f64, f64)>,
1761        leaf_rects: &'a mut std::collections::HashMap<String, Rect>,
1762        cluster_rects_from_graph: &'a mut std::collections::HashMap<String, Rect>,
1763        extracted_cluster_rects: &'a mut std::collections::HashMap<String, Rect>,
1764        extracted_cluster_base_widths: &'a mut std::collections::HashMap<String, f64>,
1765        edge_override_points: &'a mut std::collections::HashMap<String, Vec<LayoutPoint>>,
1766        edge_override_label: &'a mut std::collections::HashMap<String, Option<LayoutLabel>>,
1767        edge_override_from_cluster: &'a mut std::collections::HashMap<String, Option<String>>,
1768        edge_override_to_cluster: &'a mut std::collections::HashMap<String, Option<String>>,
1769    }
1770
1771    fn place_graph(
1772        graph: &Graph<NodeLabel, EdgeLabel, GraphLabel>,
1773        offset: (f64, f64),
1774        is_root: bool,
1775        inputs: &PlaceGraphInputs<'_>,
1776        out: &mut PlaceGraphOutputs<'_>,
1777    ) {
1778        for id in graph.node_ids() {
1779            let Some(n) = graph.node(&id) else { continue };
1780            let x = n.x.unwrap_or(0.0) + offset.0;
1781            let y = n.y.unwrap_or(0.0) + offset.1;
1782            if inputs.leaf_node_ids.contains(&id) {
1783                out.base_pos.insert(id.clone(), (x, y));
1784                out.leaf_rects
1785                    .insert(id.clone(), Rect::from_center(x, y, n.width, n.height));
1786                continue;
1787            }
1788        }
1789
1790        fn subtree_rect(
1791            graph: &Graph<NodeLabel, EdgeLabel, GraphLabel>,
1792            id: &str,
1793            visiting: &mut std::collections::HashSet<String>,
1794        ) -> Option<Rect> {
1795            if !visiting.insert(id.to_string()) {
1796                return None;
1797            }
1798            let mut out: Option<Rect> = None;
1799            for child in graph.children(id) {
1800                if let Some(n) = graph.node(child) {
1801                    if let (Some(x), Some(y)) = (n.x, n.y) {
1802                        let r = Rect::from_center(x, y, n.width, n.height);
1803                        if let Some(ref mut cur) = out {
1804                            cur.union(r);
1805                        } else {
1806                            out = Some(r);
1807                        }
1808                    }
1809                }
1810                if !graph.children(child).is_empty() {
1811                    if let Some(r) = subtree_rect(graph, child, visiting) {
1812                        if let Some(ref mut cur) = out {
1813                            cur.union(r);
1814                        } else {
1815                            out = Some(r);
1816                        }
1817                    }
1818                }
1819            }
1820            visiting.remove(id);
1821            out
1822        }
1823
1824        // Capture the layout-computed compound bounds for non-extracted clusters.
1825        //
1826        // Upstream Dagre computes compound-node geometry from border nodes and then removes the
1827        // border dummy nodes (`removeBorderNodes`). Our dugong parity pipeline mirrors that, so
1828        // prefer the compound node's own x/y/width/height when available.
1829        for id in graph.node_ids() {
1830            if !inputs.subgraph_ids.contains(id.as_str()) {
1831                continue;
1832            }
1833            if inputs.extracted_graphs.contains_key(&id) {
1834                continue;
1835            }
1836            if out.cluster_rects_from_graph.contains_key(&id) {
1837                continue;
1838            }
1839            if let Some(n) = graph.node(&id) {
1840                if let (Some(x), Some(y)) = (n.x, n.y) {
1841                    if n.width > 0.0 && n.height > 0.0 {
1842                        let mut r = Rect::from_center(x, y, n.width, n.height);
1843                        r.translate(offset.0, offset.1);
1844                        out.cluster_rects_from_graph.insert(id, r);
1845                        continue;
1846                    }
1847                }
1848            }
1849
1850            let mut visiting: std::collections::HashSet<String> = std::collections::HashSet::new();
1851            let Some(mut r) = subtree_rect(graph, &id, &mut visiting) else {
1852                continue;
1853            };
1854            r.translate(offset.0, offset.1);
1855            out.cluster_rects_from_graph.insert(id, r);
1856        }
1857
1858        for ek in graph.edge_keys() {
1859            let Some(edge_key) = ek.name.as_deref() else {
1860                continue;
1861            };
1862            let edge_id = inputs
1863                .edge_id_by_key
1864                .get(edge_key)
1865                .map(String::as_str)
1866                .unwrap_or(edge_key);
1867            let Some(lbl) = graph.edge_by_key(&ek) else {
1868                continue;
1869            };
1870
1871            if let (Some(x), Some(y)) = (lbl.x, lbl.y) {
1872                if lbl.width > 0.0 || lbl.height > 0.0 {
1873                    let lx = x + offset.0;
1874                    let ly = y + offset.1;
1875                    let leaf_id = format!("edge-label::{edge_id}");
1876                    out.base_pos.insert(leaf_id.clone(), (lx, ly));
1877                    out.leaf_rects
1878                        .insert(leaf_id, Rect::from_center(lx, ly, lbl.width, lbl.height));
1879                }
1880            }
1881
1882            if !is_root {
1883                let points = lbl
1884                    .points
1885                    .iter()
1886                    .map(|p| LayoutPoint {
1887                        x: p.x + offset.0,
1888                        y: p.y + offset.1,
1889                    })
1890                    .collect::<Vec<_>>();
1891                let label_pos = match (lbl.x, lbl.y) {
1892                    (Some(x), Some(y)) if lbl.width > 0.0 || lbl.height > 0.0 => {
1893                        Some(LayoutLabel {
1894                            x: x + offset.0,
1895                            y: y + offset.1,
1896                            width: lbl.width,
1897                            height: lbl.height,
1898                        })
1899                    }
1900                    _ => None,
1901                };
1902                out.edge_override_points.insert(edge_id.to_string(), points);
1903                out.edge_override_label
1904                    .insert(edge_id.to_string(), label_pos);
1905                let from_cluster = lbl
1906                    .extras
1907                    .get("fromCluster")
1908                    .and_then(|v| v.as_str().map(|s| s.to_string()));
1909                let to_cluster = lbl
1910                    .extras
1911                    .get("toCluster")
1912                    .and_then(|v| v.as_str().map(|s| s.to_string()));
1913                out.edge_override_from_cluster
1914                    .insert(edge_id.to_string(), from_cluster);
1915                out.edge_override_to_cluster
1916                    .insert(edge_id.to_string(), to_cluster);
1917            }
1918        }
1919
1920        for id in graph.node_ids() {
1921            // Only recurse into extracted graphs for leaf cluster nodes ("clusterNode" in Mermaid).
1922            // The recursively rendered graph itself also contains a node with the same id (the
1923            // parent cluster node injected before layout), which has children and must not recurse.
1924            if !graph.children(&id).is_empty() {
1925                continue;
1926            }
1927            let Some(child) = inputs.extracted_graphs.get(&id) else {
1928                continue;
1929            };
1930            let Some(n) = graph.node(&id) else {
1931                continue;
1932            };
1933            let (Some(px), Some(py)) = (n.x, n.y) else {
1934                continue;
1935            };
1936            let parent_x = px + offset.0;
1937            let parent_y = py + offset.1;
1938            let Some(cnode) = child.node(&id) else {
1939                continue;
1940            };
1941            let (Some(cx), Some(cy)) = (cnode.x, cnode.y) else {
1942                continue;
1943            };
1944            let child_offset = (parent_x - cx, parent_y - cy);
1945            // The extracted cluster's footprint in the parent graph is the clusterNode itself.
1946            // Our recursive layout step updates the parent graph's node `width/height` to match
1947            // Mermaid's `updateNodeBounds(...)` behavior (including any title margin). Avoid
1948            // adding `title_total_margin` again here.
1949            let r = Rect::from_center(parent_x, parent_y, n.width, n.height);
1950            out.extracted_cluster_rects.insert(id.clone(), r);
1951            out.extracted_cluster_base_widths
1952                .insert(id.clone(), cnode.width.max(1.0));
1953            place_graph(child, child_offset, false, inputs, out);
1954        }
1955    }
1956
1957    let mut cluster_rects_from_graph: std::collections::HashMap<String, Rect> =
1958        std::collections::HashMap::new();
1959    let mut extracted_cluster_rects: std::collections::HashMap<String, Rect> =
1960        std::collections::HashMap::new();
1961    let mut extracted_cluster_base_widths: std::collections::HashMap<String, f64> =
1962        std::collections::HashMap::new();
1963    let place_start = timing_enabled.then(std::time::Instant::now);
1964    {
1965        let place_graph_inputs = PlaceGraphInputs {
1966            edge_id_by_key: &edge_id_by_key,
1967            extracted_graphs: &extracted_graphs,
1968            subgraph_ids: &subgraph_ids,
1969            leaf_node_ids: &leaf_node_ids,
1970        };
1971        let mut place_graph_outputs = PlaceGraphOutputs {
1972            base_pos: &mut base_pos,
1973            leaf_rects: &mut leaf_rects,
1974            cluster_rects_from_graph: &mut cluster_rects_from_graph,
1975            extracted_cluster_rects: &mut extracted_cluster_rects,
1976            extracted_cluster_base_widths: &mut extracted_cluster_base_widths,
1977            edge_override_points: &mut edge_override_points,
1978            edge_override_label: &mut edge_override_label,
1979            edge_override_from_cluster: &mut edge_override_from_cluster,
1980            edge_override_to_cluster: &mut edge_override_to_cluster,
1981        };
1982        place_graph(
1983            &g,
1984            (0.0, 0.0),
1985            true,
1986            &place_graph_inputs,
1987            &mut place_graph_outputs,
1988        );
1989    }
1990    if let Some(s) = place_start {
1991        timings.place_graph = s.elapsed();
1992    }
1993
1994    let build_output_start = timing_enabled.then(std::time::Instant::now);
1995
1996    let mut extra_children: std::collections::HashMap<String, Vec<String>> =
1997        std::collections::HashMap::new();
1998    let labeled_edges: std::collections::HashSet<&str> = render_edges
1999        .iter()
2000        .filter(|e| edge_label_is_non_empty(e))
2001        .map(|e| e.id.as_str())
2002        .collect();
2003
2004    fn collect_extra_children(
2005        graph: &Graph<NodeLabel, EdgeLabel, GraphLabel>,
2006        edge_id_by_key: &std::collections::HashMap<String, String>,
2007        labeled_edges: &std::collections::HashSet<&str>,
2008        implicit_root: Option<&str>,
2009        out: &mut std::collections::HashMap<String, Vec<String>>,
2010    ) {
2011        for ek in graph.edge_keys() {
2012            let Some(edge_key) = ek.name.as_deref() else {
2013                continue;
2014            };
2015            let edge_id = edge_id_by_key
2016                .get(edge_key)
2017                .map(String::as_str)
2018                .unwrap_or(edge_key);
2019            if !labeled_edges.contains(edge_id) {
2020                continue;
2021            }
2022            // Mermaid's recursive cluster extractor removes the root cluster node from the
2023            // extracted graph. In that case, the "lowest common parent" of edges whose endpoints
2024            // belong to the extracted cluster becomes `None`, even though the label should still
2025            // participate in the extracted cluster's bounding box. Use `implicit_root` to map
2026            // those labels back to the extracted cluster id.
2027            let parent = lowest_common_parent(graph, &ek.v, &ek.w)
2028                .or_else(|| implicit_root.map(|s| s.to_string()));
2029            let Some(parent) = parent else {
2030                continue;
2031            };
2032            out.entry(parent)
2033                .or_default()
2034                .push(format!("edge-label::{edge_id}"));
2035        }
2036    }
2037
2038    collect_extra_children(
2039        &g,
2040        &edge_id_by_key,
2041        &labeled_edges,
2042        None,
2043        &mut extra_children,
2044    );
2045    for (cluster_id, cg) in &extracted_graphs {
2046        collect_extra_children(
2047            cg,
2048            &edge_id_by_key,
2049            &labeled_edges,
2050            Some(cluster_id.as_str()),
2051            &mut extra_children,
2052        );
2053    }
2054
2055    // Ensure Mermaid-style self-loop helper nodes participate in cluster bounding/packing.
2056    // These nodes are not part of the semantic `subgraph ... end` membership list, but are
2057    // parented into the same clusters as their base node.
2058    for id in &self_loop_label_node_ids {
2059        if let Some(p) = g.parent(id) {
2060            extra_children
2061                .entry(p.to_string())
2062                .or_default()
2063                .push(id.clone());
2064        }
2065    }
2066
2067    let mut out_nodes: Vec<LayoutNode> = Vec::new();
2068    for n in &model.nodes {
2069        if subgraph_ids.contains(n.id.as_str()) {
2070            continue;
2071        }
2072        let (x, y) = base_pos
2073            .get(&n.id)
2074            .copied()
2075            .ok_or_else(|| Error::InvalidModel {
2076                message: format!("missing positioned node {}", n.id),
2077            })?;
2078        let (width, height) = leaf_rects
2079            .get(&n.id)
2080            .map(|r| (r.width(), r.height()))
2081            .ok_or_else(|| Error::InvalidModel {
2082                message: format!("missing sized node {}", n.id),
2083            })?;
2084        out_nodes.push(LayoutNode {
2085            id: n.id.clone(),
2086            x,
2087            y,
2088            width,
2089            height,
2090            is_cluster: false,
2091            label_width: leaf_label_metrics_by_id.get(&n.id).map(|v| v.0),
2092            label_height: leaf_label_metrics_by_id.get(&n.id).map(|v| v.1),
2093        });
2094    }
2095    for id in &empty_subgraph_ids {
2096        let (x, y) = base_pos
2097            .get(id)
2098            .copied()
2099            .ok_or_else(|| Error::InvalidModel {
2100                message: format!("missing positioned node {id}"),
2101            })?;
2102        let (width, height) = leaf_rects
2103            .get(id)
2104            .map(|r| (r.width(), r.height()))
2105            .ok_or_else(|| Error::InvalidModel {
2106                message: format!("missing sized node {id}"),
2107            })?;
2108        out_nodes.push(LayoutNode {
2109            id: id.clone(),
2110            x,
2111            y,
2112            width,
2113            height,
2114            is_cluster: false,
2115            label_width: leaf_label_metrics_by_id.get(id).map(|v| v.0),
2116            label_height: leaf_label_metrics_by_id.get(id).map(|v| v.1),
2117        });
2118    }
2119    for id in &self_loop_label_node_ids {
2120        let (x, y) = base_pos
2121            .get(id)
2122            .copied()
2123            .ok_or_else(|| Error::InvalidModel {
2124                message: format!("missing positioned node {id}"),
2125            })?;
2126        let (width, height) = leaf_rects
2127            .get(id)
2128            .map(|r| (r.width(), r.height()))
2129            .ok_or_else(|| Error::InvalidModel {
2130                message: format!("missing sized node {id}"),
2131            })?;
2132        out_nodes.push(LayoutNode {
2133            id: id.clone(),
2134            x,
2135            y,
2136            width,
2137            height,
2138            is_cluster: false,
2139            label_width: None,
2140            label_height: None,
2141        });
2142    }
2143
2144    let mut clusters: Vec<LayoutCluster> = Vec::new();
2145
2146    let mut cluster_rects: std::collections::HashMap<String, Rect> =
2147        std::collections::HashMap::new();
2148    let mut cluster_base_widths: std::collections::HashMap<String, f64> =
2149        std::collections::HashMap::new();
2150    let mut visiting: std::collections::HashSet<String> = std::collections::HashSet::new();
2151
2152    struct ClusterRectContext<'a> {
2153        subgraphs_by_id: &'a std::collections::HashMap<String, FlowSubgraph>,
2154        leaf_rects: &'a std::collections::HashMap<String, Rect>,
2155        extra_children: &'a std::collections::HashMap<String, Vec<String>>,
2156        measurer: &'a dyn TextMeasurer,
2157        text_style: &'a TextStyle,
2158        html_label_text_style: &'a TextStyle,
2159        title_wrapping_width: f64,
2160        wrap_mode: WrapMode,
2161        config: &'a MermaidConfig,
2162        math_renderer: Option<&'a (dyn MathRenderer + Send + Sync)>,
2163        cluster_padding: f64,
2164        title_total_margin: f64,
2165    }
2166
2167    struct ClusterRectState<'a> {
2168        cluster_rects: &'a mut std::collections::HashMap<String, Rect>,
2169        cluster_base_widths: &'a mut std::collections::HashMap<String, f64>,
2170        visiting: &'a mut std::collections::HashSet<String>,
2171    }
2172
2173    fn compute_cluster_rect(
2174        id: &str,
2175        ctx: &ClusterRectContext<'_>,
2176        state: &mut ClusterRectState<'_>,
2177    ) -> Result<(Rect, f64)> {
2178        if let Some(r) = state.cluster_rects.get(id).copied() {
2179            let base_width = state
2180                .cluster_base_widths
2181                .get(id)
2182                .copied()
2183                .unwrap_or_else(|| r.width());
2184            return Ok((r, base_width));
2185        }
2186        if !state.visiting.insert(id.to_string()) {
2187            return Err(Error::InvalidModel {
2188                message: format!("cycle in subgraph membership involving {id}"),
2189            });
2190        }
2191
2192        let Some(sg) = ctx.subgraphs_by_id.get(id) else {
2193            return Err(Error::InvalidModel {
2194                message: format!("missing subgraph definition for {id}"),
2195            });
2196        };
2197
2198        let mut content: Option<Rect> = None;
2199        for member in &sg.nodes {
2200            let member_rect = if let Some(r) = ctx.leaf_rects.get(member).copied() {
2201                Some(r)
2202            } else if ctx.subgraphs_by_id.contains_key(member) {
2203                Some(compute_cluster_rect(member, ctx, state)?.0)
2204            } else {
2205                None
2206            };
2207
2208            if let Some(r) = member_rect {
2209                if let Some(ref mut cur) = content {
2210                    cur.union(r);
2211                } else {
2212                    content = Some(r);
2213                }
2214            }
2215        }
2216
2217        if let Some(extra) = ctx.extra_children.get(id) {
2218            for child in extra {
2219                if let Some(r) = ctx.leaf_rects.get(child).copied() {
2220                    if let Some(ref mut cur) = content {
2221                        cur.union(r);
2222                    } else {
2223                        content = Some(r);
2224                    }
2225                }
2226            }
2227        }
2228
2229        let label_type = sg.label_type.as_deref().unwrap_or("text");
2230        let title_width_limit = Some(ctx.title_wrapping_width);
2231        let title_metrics = flowchart_label_metrics_for_layout(FlowchartLabelMetricsRequest {
2232            measurer: ctx.measurer,
2233            raw_label: &sg.title,
2234            label_type,
2235            style: if ctx.wrap_mode == WrapMode::HtmlLike {
2236                ctx.html_label_text_style
2237            } else {
2238                ctx.text_style
2239            },
2240            max_width_px: title_width_limit,
2241            wrap_mode: ctx.wrap_mode,
2242            config: ctx.config,
2243            math_renderer: ctx.math_renderer,
2244            preserve_string_whitespace_height: false,
2245        });
2246        let mut rect = if let Some(r) = content {
2247            r
2248        } else {
2249            Rect::from_center(
2250                0.0,
2251                0.0,
2252                title_metrics.width.max(1.0),
2253                title_metrics.height.max(1.0),
2254            )
2255        };
2256
2257        // Expand to provide the cluster's internal padding.
2258        rect.pad(ctx.cluster_padding);
2259
2260        // Mermaid computes `node.diff` using the pre-widened layout node width, then may widen the
2261        // rect to fit the label bbox during rendering.
2262        let base_width = rect.width();
2263
2264        // Mermaid cluster "rect" rendering widens to fit the raw title bbox, plus a small
2265        // horizontal inset. Empirically (Mermaid@11.12.2 fixtures), this behaves like
2266        // `title_width + cluster_padding` when the title is wider than the content.
2267        let min_width = title_metrics.width.max(1.0) + ctx.cluster_padding;
2268        if rect.width() < min_width {
2269            let (cx, cy) = rect.center();
2270            rect = Rect::from_center(cx, cy, min_width, rect.height());
2271        }
2272
2273        // Extend height to reserve space for subgraph title margins (Mermaid does this after layout).
2274        if ctx.title_total_margin > 0.0 {
2275            let (cx, cy) = rect.center();
2276            rect = Rect::from_center(cx, cy, rect.width(), rect.height() + ctx.title_total_margin);
2277        }
2278
2279        // Keep the cluster tall enough to accommodate the title bbox if needed.
2280        let min_height = title_metrics.height.max(1.0) + ctx.title_total_margin;
2281        if rect.height() < min_height {
2282            let (cx, cy) = rect.center();
2283            rect = Rect::from_center(cx, cy, rect.width(), min_height);
2284        }
2285
2286        state.visiting.remove(id);
2287        state.cluster_rects.insert(id.to_string(), rect);
2288        state.cluster_base_widths.insert(id.to_string(), base_width);
2289        Ok((rect, base_width))
2290    }
2291
2292    struct ClusterTitleAdjustContext<'a> {
2293        measurer: &'a dyn TextMeasurer,
2294        text_style: &'a TextStyle,
2295        html_label_text_style: &'a TextStyle,
2296        title_wrapping_width: f64,
2297        wrap_mode: WrapMode,
2298        config: &'a MermaidConfig,
2299        math_renderer: Option<&'a (dyn MathRenderer + Send + Sync)>,
2300        title_total_margin: f64,
2301        cluster_padding: f64,
2302    }
2303
2304    fn adjust_cluster_rect_for_title(
2305        mut rect: Rect,
2306        title: &str,
2307        label_type: &str,
2308        add_title_total_margin: bool,
2309        ctx: &ClusterTitleAdjustContext<'_>,
2310    ) -> Rect {
2311        let title_width_limit = Some(ctx.title_wrapping_width);
2312        let title_metrics = flowchart_label_metrics_for_layout(FlowchartLabelMetricsRequest {
2313            measurer: ctx.measurer,
2314            raw_label: title,
2315            label_type,
2316            style: if ctx.wrap_mode == WrapMode::HtmlLike {
2317                ctx.html_label_text_style
2318            } else {
2319                ctx.text_style
2320            },
2321            max_width_px: title_width_limit,
2322            wrap_mode: ctx.wrap_mode,
2323            config: ctx.config,
2324            math_renderer: ctx.math_renderer,
2325            preserve_string_whitespace_height: false,
2326        });
2327        let title_w = title_metrics.width.max(1.0);
2328        let title_h = title_metrics.height.max(1.0);
2329
2330        // Mermaid cluster "rect" widens to fit the raw title bbox (no added padding),
2331        // even when the cluster bounds come from Dagre border nodes.
2332        let min_w = title_w + ctx.cluster_padding;
2333        if rect.width() < min_w {
2334            let (cx, cy) = rect.center();
2335            rect = Rect::from_center(cx, cy, min_w, rect.height());
2336        }
2337
2338        // Mermaid adds `subGraphTitleTotalMargin` to cluster height after layout.
2339        if add_title_total_margin && ctx.title_total_margin > 0.0 {
2340            let (cx, cy) = rect.center();
2341            rect = Rect::from_center(cx, cy, rect.width(), rect.height() + ctx.title_total_margin);
2342        }
2343
2344        // Keep the cluster tall enough for the title bbox (including title margins).
2345        let min_h = title_h + ctx.title_total_margin;
2346        if rect.height() < min_h {
2347            let (cx, cy) = rect.center();
2348            rect = Rect::from_center(cx, cy, rect.width(), min_h);
2349        }
2350
2351        rect
2352    }
2353
2354    let cluster_rect_ctx = ClusterRectContext {
2355        subgraphs_by_id: &subgraphs_by_id,
2356        leaf_rects: &leaf_rects,
2357        extra_children: &extra_children,
2358        measurer,
2359        text_style: &text_style,
2360        html_label_text_style: &html_label_text_style,
2361        title_wrapping_width: cluster_title_wrapping_width,
2362        wrap_mode: cluster_wrap_mode,
2363        config: effective_config,
2364        math_renderer,
2365        cluster_padding,
2366        title_total_margin,
2367    };
2368    let mut cluster_rect_state = ClusterRectState {
2369        cluster_rects: &mut cluster_rects,
2370        cluster_base_widths: &mut cluster_base_widths,
2371        visiting: &mut visiting,
2372    };
2373    let title_adjust_ctx = ClusterTitleAdjustContext {
2374        measurer,
2375        text_style: &text_style,
2376        html_label_text_style: &html_label_text_style,
2377        title_wrapping_width: cluster_title_wrapping_width,
2378        wrap_mode: cluster_wrap_mode,
2379        config: effective_config,
2380        math_renderer,
2381        title_total_margin,
2382        cluster_padding,
2383    };
2384
2385    for sg in &model.subgraphs {
2386        if sg.nodes.is_empty() {
2387            continue;
2388        }
2389
2390        let (rect, base_width) = if extracted_graphs.contains_key(&sg.id) {
2391            // For extracted (recursive) clusters, match Mermaid's `updateNodeBounds(...)` intent by
2392            // taking the rendered child-graph content bbox (including border nodes) as the cluster
2393            // node's bounds.
2394            let rect = extracted_cluster_rects
2395                .get(&sg.id)
2396                .copied()
2397                .unwrap_or_else(|| {
2398                    compute_cluster_rect(&sg.id, &cluster_rect_ctx, &mut cluster_rect_state)
2399                        .map(|v| v.0)
2400                        .unwrap_or_else(|_| Rect::from_center(0.0, 0.0, 1.0, 1.0))
2401                });
2402            let base_width = extracted_cluster_base_widths
2403                .get(&sg.id)
2404                .copied()
2405                .unwrap_or_else(|| rect.width());
2406            let rect = adjust_cluster_rect_for_title(
2407                rect,
2408                &sg.title,
2409                sg.label_type.as_deref().unwrap_or("text"),
2410                false,
2411                &title_adjust_ctx,
2412            );
2413            (rect, base_width)
2414        } else if let Some(r) = cluster_rects_from_graph.get(&sg.id).copied() {
2415            let base_width = r.width();
2416            let rect = adjust_cluster_rect_for_title(
2417                r,
2418                &sg.title,
2419                sg.label_type.as_deref().unwrap_or("text"),
2420                true,
2421                &title_adjust_ctx,
2422            );
2423            (rect, base_width)
2424        } else {
2425            compute_cluster_rect(&sg.id, &cluster_rect_ctx, &mut cluster_rect_state)?
2426        };
2427        let (cx, cy) = rect.center();
2428
2429        let label_type = sg.label_type.as_deref().unwrap_or("text");
2430        let title_width_limit = Some(cluster_title_wrapping_width);
2431        let mut title_metrics = flowchart_label_metrics_for_layout(FlowchartLabelMetricsRequest {
2432            measurer,
2433            raw_label: &sg.title,
2434            label_type,
2435            style: if cluster_wrap_mode == WrapMode::HtmlLike {
2436                &html_label_text_style
2437            } else {
2438                &text_style
2439            },
2440            max_width_px: title_width_limit,
2441            wrap_mode: cluster_wrap_mode,
2442            config: effective_config,
2443            math_renderer,
2444            preserve_string_whitespace_height: false,
2445        });
2446        if cluster_wrap_mode == crate::text::WrapMode::SvgLike && label_type != "markdown" {
2447            // Mermaid's flowchart cluster titles rendered as plain SVG `<text>` are measured via
2448            // `getComputedTextLength()` on the emitted wrapped lines rather than the raw title.
2449            // Using the raw unwrapped line makes long-word SVG-like subgraph titles pull root
2450            // bounds far to the left even though Mermaid emits a wrapped `<tspan>` stack.
2451            let plain = crate::flowchart::flowchart_label_plain_text_for_layout(
2452                &sg.title, label_type, false,
2453            );
2454            title_metrics.width = flowchart_svg_plain_computed_width_px(
2455                measurer,
2456                &plain,
2457                &text_style,
2458                title_width_limit,
2459            );
2460        } else if cluster_wrap_mode == crate::text::WrapMode::SvgLike && label_type == "markdown" {
2461            // Cluster titles with markdown emphasis are rendered as SVG `<text>/<tspan>` runs and
2462            // measured via browser `getBBox()` in upstream Mermaid. For italic `<em>` titles, the
2463            // 1/64px-snapped markdown width can be just enough to shift the left-aligned label
2464            // transform across a strict-XML rounding boundary; use a tighter lattice width probe.
2465            let has_emphasis = crate::text::mermaid_markdown_to_wrapped_word_lines(
2466                measurer,
2467                &sg.title,
2468                &text_style,
2469                title_width_limit,
2470                cluster_wrap_mode,
2471            )
2472            .iter()
2473            .any(|line| {
2474                line.iter()
2475                    .any(|(_, ty)| *ty == crate::text::MermaidMarkdownWordType::Em)
2476            });
2477            if has_emphasis {
2478                title_metrics.width = crate::text::measure_markdown_svg_like_precise_width_px(
2479                    measurer,
2480                    &sg.title,
2481                    &text_style,
2482                    title_width_limit,
2483                );
2484            }
2485        }
2486        let title_label = LayoutLabel {
2487            x: cx,
2488            y: cy - rect.height() / 2.0 + title_margin_top + title_metrics.height / 2.0,
2489            width: title_metrics.width,
2490            height: title_metrics.height,
2491        };
2492
2493        // `dagre-wrapper/clusters.js` (shape `rect`) sets `padding = 0 * node.padding`.
2494        // The cluster label is positioned at `node.x - bbox.width/2`, and `node.diff` is:
2495        // - `(bbox.width - node.width)/2 - node.padding/2` when the box widens to fit the title
2496        // - otherwise `-node.padding/2`.
2497        let title_w = title_metrics.width.max(1.0);
2498        let diff = if base_width <= title_w {
2499            (title_w - base_width) / 2.0 - cluster_padding / 2.0
2500        } else {
2501            -cluster_padding / 2.0
2502        };
2503        let offset_y = title_metrics.height - cluster_padding / 2.0;
2504
2505        let effective_dir = effective_dir_by_id
2506            .get(&sg.id)
2507            .cloned()
2508            .unwrap_or_else(|| effective_cluster_dir(sg, &diagram_direction, inherit_dir));
2509
2510        clusters.push(LayoutCluster {
2511            id: sg.id.clone(),
2512            x: cx,
2513            y: cy,
2514            width: rect.width(),
2515            height: rect.height(),
2516            diff,
2517            offset_y,
2518            title: sg.title.clone(),
2519            title_label,
2520            requested_dir: sg.dir.as_ref().map(|s| normalize_dir(s)),
2521            effective_dir,
2522            padding: cluster_padding,
2523            title_margin_top,
2524            title_margin_bottom,
2525        });
2526
2527        out_nodes.push(LayoutNode {
2528            id: sg.id.clone(),
2529            x: cx,
2530            // Mermaid does not shift pure cluster nodes by `subGraphTitleTotalMargin / 2`.
2531            y: cy,
2532            width: rect.width(),
2533            height: rect.height(),
2534            is_cluster: true,
2535            label_width: None,
2536            label_height: None,
2537        });
2538    }
2539    clusters.sort_by(|a, b| a.id.cmp(&b.id));
2540
2541    let mut out_edges: Vec<LayoutEdge> = Vec::new();
2542    for e in &render_edges {
2543        let (points, label_pos, mut from_cluster, mut to_cluster) =
2544            if let Some(points) = edge_override_points.get(&e.id) {
2545                let from_cluster = edge_override_from_cluster
2546                    .get(&e.id)
2547                    .cloned()
2548                    .unwrap_or(None);
2549                let to_cluster = edge_override_to_cluster.get(&e.id).cloned().unwrap_or(None);
2550                (
2551                    points.clone(),
2552                    edge_override_label.get(&e.id).cloned().unwrap_or(None),
2553                    from_cluster,
2554                    to_cluster,
2555                )
2556            } else {
2557                let (v, w) = edge_endpoints_by_id
2558                    .get(&e.id)
2559                    .cloned()
2560                    .unwrap_or_else(|| (e.from.clone(), e.to.clone()));
2561                let edge_key = edge_key_by_id
2562                    .get(&e.id)
2563                    .map(String::as_str)
2564                    .unwrap_or(e.id.as_str());
2565                let Some(label) = g.edge(&v, &w, Some(edge_key)) else {
2566                    return Err(Error::InvalidModel {
2567                        message: format!("missing layout edge {}", e.id),
2568                    });
2569                };
2570                let from_cluster = label
2571                    .extras
2572                    .get("fromCluster")
2573                    .and_then(|v| v.as_str().map(|s| s.to_string()));
2574                let to_cluster = label
2575                    .extras
2576                    .get("toCluster")
2577                    .and_then(|v| v.as_str().map(|s| s.to_string()));
2578                let points = label
2579                    .points
2580                    .iter()
2581                    .map(|p| LayoutPoint { x: p.x, y: p.y })
2582                    .collect::<Vec<_>>();
2583                let label_pos = match (label.x, label.y) {
2584                    (Some(x), Some(y)) if label.width > 0.0 || label.height > 0.0 => {
2585                        Some(LayoutLabel {
2586                            x,
2587                            y,
2588                            width: label.width,
2589                            height: label.height,
2590                        })
2591                    }
2592                    _ => None,
2593                };
2594                (points, label_pos, from_cluster, to_cluster)
2595            };
2596
2597        // Match Mermaid's dagre adapter: self-loop special edges on group nodes are annotated with
2598        // `fromCluster` / `toCluster` so downstream renderers can clip routes to the cluster
2599        // boundary.
2600        if subgraph_ids.contains(e.from.as_str()) && e.id.ends_with("-cyclic-special-1") {
2601            from_cluster = Some(e.from.clone());
2602        }
2603        if subgraph_ids.contains(e.to.as_str()) && e.id.ends_with("-cyclic-special-2") {
2604            to_cluster = Some(e.to.clone());
2605        }
2606
2607        out_edges.push(LayoutEdge {
2608            id: e.id.clone(),
2609            from: e.from.clone(),
2610            to: e.to.clone(),
2611            from_cluster,
2612            to_cluster,
2613            points,
2614            label: label_pos,
2615            start_label_left: None,
2616            start_label_right: None,
2617            end_label_left: None,
2618            end_label_right: None,
2619            start_marker: None,
2620            end_marker: None,
2621            stroke_dasharray: None,
2622        });
2623    }
2624
2625    // Mermaid's flowchart renderer uses shape-specific intersection functions for edge endpoints
2626    // (e.g. diamond nodes). Our Dagre-ish layout currently treats all nodes as rectangles, so the
2627    // first/last points can land on the bounding box rather than the actual polygon boundary.
2628    //
2629    // Adjust the first/last edge points to match Mermaid's shape intersection behavior for the
2630    // shapes that materially differ from rectangles.
2631    let mut node_shape_by_id: HashMap<&str, &str> = HashMap::new();
2632    for n in &model.nodes {
2633        if let Some(s) = n.layout_shape.as_deref() {
2634            node_shape_by_id.insert(n.id.as_str(), s);
2635        }
2636    }
2637    let mut layout_node_by_id: HashMap<&str, &LayoutNode> = HashMap::new();
2638    for n in &out_nodes {
2639        layout_node_by_id.insert(n.id.as_str(), n);
2640    }
2641
2642    fn diamond_intersection(node: &LayoutNode, toward: &LayoutPoint) -> Option<LayoutPoint> {
2643        let vx = toward.x - node.x;
2644        let vy = toward.y - node.y;
2645        if !(vx.is_finite() && vy.is_finite()) {
2646            return None;
2647        }
2648        if vx.abs() <= 1e-12 && vy.abs() <= 1e-12 {
2649            return None;
2650        }
2651        let hw = (node.width / 2.0).max(1e-9);
2652        let hh = (node.height / 2.0).max(1e-9);
2653        let denom = vx.abs() / hw + vy.abs() / hh;
2654        if !(denom.is_finite() && denom > 0.0) {
2655            return None;
2656        }
2657        let t = 1.0 / denom;
2658        Some(LayoutPoint {
2659            x: node.x + vx * t,
2660            y: node.y + vy * t,
2661        })
2662    }
2663
2664    for e in &mut out_edges {
2665        if e.points.len() < 2 {
2666            continue;
2667        }
2668
2669        if let Some(node) = layout_node_by_id.get(e.from.as_str()) {
2670            if !node.is_cluster {
2671                let shape = node_shape_by_id
2672                    .get(e.from.as_str())
2673                    .copied()
2674                    .unwrap_or("squareRect");
2675                if matches!(shape, "diamond" | "question" | "diam") {
2676                    if let Some(p) = diamond_intersection(node, &e.points[1]) {
2677                        e.points[0] = p;
2678                    }
2679                }
2680            }
2681        }
2682        if let Some(node) = layout_node_by_id.get(e.to.as_str()) {
2683            if !node.is_cluster {
2684                let shape = node_shape_by_id
2685                    .get(e.to.as_str())
2686                    .copied()
2687                    .unwrap_or("squareRect");
2688                if matches!(shape, "diamond" | "question" | "diam") {
2689                    let n = e.points.len();
2690                    if let Some(p) = diamond_intersection(node, &e.points[n - 2]) {
2691                        e.points[n - 1] = p;
2692                    }
2693                }
2694            }
2695        }
2696    }
2697
2698    let bounds = compute_bounds(&out_nodes, &out_edges);
2699
2700    if let Some(s) = build_output_start {
2701        timings.build_output = s.elapsed();
2702    }
2703    if let Some(s) = total_start {
2704        timings.total = s.elapsed();
2705        let dagre_overhead = timings
2706            .layout_recursive
2707            .checked_sub(timings.dagre_total)
2708            .unwrap_or_default();
2709        eprintln!(
2710            "[layout-timing] diagram=flowchart-v2 total={:?} deserialize={:?} expand_self_loops={:?} build_graph={:?} extract_clusters={:?} dom_order={:?} layout_recursive={:?} dagre_calls={} dagre_total={:?} dagre_overhead={:?} place_graph={:?} build_output={:?}",
2711            timings.total,
2712            timings.deserialize,
2713            timings.expand_self_loops,
2714            timings.build_graph,
2715            timings.extract_clusters,
2716            timings.dom_order,
2717            timings.layout_recursive,
2718            timings.dagre_calls,
2719            timings.dagre_total,
2720            dagre_overhead,
2721            timings.place_graph,
2722            timings.build_output,
2723        );
2724    }
2725
2726    Ok(FlowchartV2Layout {
2727        nodes: out_nodes,
2728        edges: out_edges,
2729        clusters,
2730        bounds,
2731        dom_node_order_by_root,
2732    })
2733}
2734
2735#[cfg(test)]
2736mod tests {
2737    use super::*;
2738    use std::sync::mpsc;
2739    use std::time::Duration;
2740
2741    #[test]
2742    fn extract_descendants_handles_deeply_nested_subgraphs() {
2743        const DEPTH: usize = 100_000;
2744        let (tx, rx) = mpsc::channel();
2745        std::thread::Builder::new()
2746            .stack_size(512 * 1024)
2747            .spawn(move || {
2748                let mut g = Graph::new(GraphOptions {
2749                    compound: true,
2750                    ..Default::default()
2751                });
2752                for i in (0..DEPTH).rev() {
2753                    g.set_parent(format!("n{}", i + 1), format!("n{i}"));
2754                }
2755                let _ = tx.send(extract_descendants("n0", &g));
2756            })
2757            .unwrap();
2758        let descendants = rx
2759            .recv_timeout(Duration::from_secs(2))
2760            .expect("extract_descendants overflowed the stack on deep nesting");
2761        assert_eq!(descendants.len(), DEPTH);
2762    }
2763}