Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fresh-layout-edges.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mermaid': patch
---

Fix leaf edges from directed subgraphs.
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,39 @@ const getAnchorId = (id) => {
return id;
};

const hasInternalEdge = (graph, clusterId) => {
const clusterDescendants = descendants.get(clusterId) || [];
return graph.edges().some((edge) => {
const vIn = clusterDescendants.includes(edge.v) || isDescendant(edge.v, clusterId);
const wIn = clusterDescendants.includes(edge.w) || isDescendant(edge.w, clusterId);
return vIn && wIn;
});
};

const hasRebasedClusterEdge = (graph, clusterId) => {
return graph.edges().some((edge) => {
const edgeData = graph.edge(edge);
return edgeData?.fromCluster === clusterId || edgeData?.toCluster === clusterId;
});
};

const shouldExtractExplicitDirCluster = (graph, id) => {
const children = graph.children(id);
if (!clusterDb.get(id)?.clusterData?.explicitDir || !children || children.length === 0) {
return false;
}

// If the cluster only contains disconnected leaves with edges to siblings,
// keeping the leaves in the parent graph lets dagre align those edges to the
// actual nodes. Extracting in that case rebases the edges onto the cluster
// boundary and loses the leaf-level constraints.
return (
hasInternalEdge(graph, id) ||
hasRebasedClusterEdge(graph, id) ||
children.some((child) => graph.children(child).length > 0)
);
};

export const adjustClustersAndEdges = (graph, depth) => {
if (!graph || depth > 10) {
log.debug('Opting out, no graph ');
Expand Down Expand Up @@ -362,12 +395,8 @@ export const extractor = (graph, depth) => {
);
if (!clusterDb.has(node)) {
log.debug('Not a cluster', node, depth);
} else if (
clusterDb.get(node)?.clusterData?.explicitDir &&
graph.children(node) &&
graph.children(node).length > 0
) {
// Cluster with an explicit direction keyword — always create a subgraph,
} else if (shouldExtractExplicitDirCluster(graph, node)) {
// Cluster with an explicit direction keyword that needs an isolated layout,
// even when it has external connections (fixes issue #4648).
log.debug('Cluster with explicit dir, creating subgraph for children', node, depth);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as graphlibJson from 'dagre-d3-es/src/graphlib/json.js';
import * as graphlib from 'dagre-d3-es/src/graphlib/index.js';
import {
clear,
validate,
adjustClustersAndEdges,
extractDescendants,
Expand All @@ -11,6 +12,7 @@ import { setLogLevel, log } from '../../../logger.js';
describe('Graphlib decorations', () => {
let g;
beforeEach(function () {
clear();
setLogLevel(1);
g = new graphlib.Graph({
multigraph: true,
Expand Down Expand Up @@ -473,7 +475,7 @@ flowchart TB
expect(g.node('B2').graph.nodes()).toEqual(['i2']);
});

it('GLB-DIR4: cross-boundary edge should be rebound in outer graph, not copied into clusterGraph', function () {
it('GLB-DIR4: explicit dir cluster with only leaf cross-boundary edges stays in the parent graph', function () {
/*
subgraph C2 [direction LR] ← uses a fresh cluster id to avoid stale module state
D1
Expand All @@ -488,18 +490,100 @@ flowchart TB

adjustClustersAndEdges(g);

// C2 should be a clusterNode (Branch 1 fired)
expect(g.node('C2').clusterNode).toBe(true);
expect(g.node('C2').clusterNode).toBeUndefined();
expect(g.parent('D1')).toBe('C2');
expect(g.edges().some((e) => e.v === 'D1' && e.w === 'D2')).toBe(true);
});

it('GLB-DIR5: leaf edges between sibling subgraphs remain leaf edges inside an extracted parent', function () {
/*
subgraph L1
subgraph sources [direction LR]
A
B
end
subgraph outputs
OA
OB
end
A --> OA
B --> OB
end
*/
g.setNode('L1', { data: 'outer' });
g.setNode('sources', { data: 'sources', dir: 'LR', explicitDir: true });
g.setNode('outputs', { data: 'outputs' });
g.setNode('A', { data: 'A' });
g.setNode('B', { data: 'B' });
g.setNode('OA', { data: 'OA' });
g.setNode('OB', { data: 'OB' });
g.setParent('sources', 'L1');
g.setParent('outputs', 'L1');
g.setParent('A', 'sources');
g.setParent('B', 'sources');
g.setParent('OA', 'outputs');
g.setParent('OB', 'outputs');
g.setEdge('A', 'OA', { data: 'A to OA' }, 'edge-a');
g.setEdge('B', 'OB', { data: 'B to OB' }, 'edge-b');

adjustClustersAndEdges(g);

const L1Graph = g.node('L1').graph;
expect(L1Graph.node('sources').clusterNode).toBeUndefined();
expect(L1Graph.parent('A')).toBe('sources');
expect(L1Graph.parent('B')).toBe('sources');
expect(L1Graph.edges().some((e) => e.v === 'A' && e.w === 'OA')).toBe(true);
expect(L1Graph.edges().some((e) => e.v === 'B' && e.w === 'OB')).toBe(true);
});

it('GLB-DIR6: direct outgoing cluster edges still extract after rebasing', function () {
/*
subgraph C3 [direction LR]
E1
end
E1 --> E0
C3 --> E2
*/
g.setNode('E0', { data: 0 });
g.setNode('E1', { data: 1 });
g.setNode('E2', { data: 2 });
g.setNode('C3', { data: 3, dir: 'LR', explicitDir: true });
g.setParent('E1', 'C3');
g.setEdge('E1', 'E0', { data: 'leaf edge' }, 'leaf-out');
g.setEdge('C3', 'E2', { data: 'cluster edge' }, 'cluster-out');

adjustClustersAndEdges(g);

expect(g.node('C3').clusterNode).toBe(true);
expect(g.node('C3').graph.nodes()).toEqual(['E1']);
const rebound = g.edges().find((e) => e.v === 'C3' && e.w === 'E2');
expect(rebound).toBeDefined();
expect(g.edge(rebound).fromCluster).toBe('C3');
});

it('GLB-DIR7: direct incoming cluster edges still extract after rebasing', function () {
/*
subgraph C4 [direction LR]
F1
end
F0 --> F1
F2 --> C4
*/
g.setNode('F0', { data: 0 });
g.setNode('F1', { data: 1 });
g.setNode('F2', { data: 2 });
g.setNode('C4', { data: 3, dir: 'LR', explicitDir: true });
g.setParent('F1', 'C4');
g.setEdge('F0', 'F1', { data: 'leaf edge' }, 'leaf-in');
g.setEdge('F2', 'C4', { data: 'cluster edge' }, 'cluster-in');

// The clusterGraph should contain D1 but have NO edges
// (the cross-boundary edge must NOT be copied inside)
const C2Graph = g.node('C2').graph;
expect(C2Graph.nodes()).toContain('D1');
expect(C2Graph.edges().length).toBe(0);
adjustClustersAndEdges(g);

// The outer graph should have a rebound edge from the cluster root (C2) to D2
const rebound = g.edges().some((e) => e.v === 'C2' && e.w === 'D2');
expect(rebound).toBe(true);
expect(g.node('C4').clusterNode).toBe(true);
expect(g.node('C4').graph.nodes()).toEqual(['F1']);
const rebound = g.edges().find((e) => e.v === 'F2' && e.w === 'C4');
expect(rebound).toBeDefined();
expect(g.edge(rebound).toCluster).toBe('C4');
});
});
});
Expand Down
Loading