Skip to main content

merman_render/flowchart/
layout.rs

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