Skip to main content

merman_render/flowchart/
layout.rs

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