Skip to main content

merman_render/flowchart/
layout.rs

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