diff --git a/packages/merman/src/flowchart/flowchart.test.ts b/packages/merman/src/flowchart/flowchart.test.ts
index 323c75d270..a1602a1bec 100644
--- a/packages/merman/src/flowchart/flowchart.test.ts
+++ b/packages/merman/src/flowchart/flowchart.test.ts
@@ -198,13 +198,13 @@ describe("FlowchartDiagram", () => {
B --> A`)
expectDiagram(output).toEqualDiagram(`
- ╭──────────────╮
- │ │
- │ │
- ▼ │
- ╭───╮ ╭─┴─╮
- │ A ├─────────▶│ B │
- ╰───╯ ╰───╯
+ ╭───────────╮
+ │ │
+ │ │
+ ▼ │
+ ╭───╮ ╭─┴─╮
+ │ A ├──────▶│ B │
+ ╰───╯ ╰───╯
`)
})
@@ -599,7 +599,7 @@ flowchart LR
const output = renderFlowchartDiagram(content)
expect(diagram.edges).toEqual([{ from: "Build", to: "Ship", label: "", style: "thick" }])
- expect(output).toContain("━━━━━━━━━▶")
+ expect(output).toContain("━━━━━━▶")
})
test("parses and renders Mermaid dashed edges", () => {
@@ -611,7 +611,7 @@ flowchart LR
const output = renderFlowchartDiagram(content)
expect(diagram.edges).toEqual([{ from: "Build", to: "Ship", label: "", style: "dashed" }])
- expect(output).toContain("─────────▶")
+ expect(output).toContain("──────▶")
})
test("paints horizontal and vertical dashed routes with solid terminal cells", () => {
@@ -689,11 +689,11 @@ graph LR
`)
expectDiagram(output).toEqualDiagram(`
- ╭───────╮
- ╭────────╮ ╭─────╮ ├───────┤
- │ Client ├─────────▶│ API ├─────────▶│ Cache │
- ╰────────╯ ╰─────╯ ├───────┤
- ╰───────╯
+ ╭───────╮
+ ╭────────╮ ╭─────╮ ├───────┤
+ │ Client ├──────▶│ API ├──────▶│ Cache │
+ ╰────────╯ ╰─────╯ ├───────┤
+ ╰───────╯
`)
})
@@ -1062,6 +1062,131 @@ flowchart LR
}
})
+ test.each(["TD", "BT", "LR", "RL"] as const)(
+ "keeps parallel top-level subgraphs in the same rank for %s diagrams",
+ (direction) => {
+ const layout = layoutFlowchartDiagram(`flowchart ${direction}
+ subgraph source [Source]
+ A[A]
+ end
+ subgraph left [Left]
+ B[B]
+ end
+ subgraph right [Right]
+ C[C]
+ end
+ A --> B
+ A --> C`)
+ const source = layout.subgraphBounds.get("source")!
+ const left = layout.subgraphBounds.get("left")!
+ const right = layout.subgraphBounds.get("right")!
+ const leftNode = layout.bounds.get("B")!
+ const rightNode = layout.bounds.get("C")!
+ const horizontal = direction === "LR" || direction === "RL"
+ const reversed = direction === "BT" || direction === "RL"
+ const start = (bound: typeof source) => {
+ const value = horizontal ? bound.left : bound.top
+ const size = horizontal ? bound.width : bound.height
+ return reversed ? -(value + size) : value
+ }
+ const size = (bound: typeof source) => (horizontal ? bound.width : bound.height)
+
+ expect(horizontal ? leftNode.centerX : leftNode.centerY).toBe(horizontal ? rightNode.centerX : rightNode.centerY)
+ expect(Math.min(start(left), start(right))).toBeGreaterThanOrEqual(start(source) + size(source))
+ },
+ )
+
+ test.each(["TD", "BT", "LR", "RL"] as const)(
+ "separates oversized parallel subgraph labels across %s diagrams",
+ (direction) => {
+ const horizontal = direction === "LR" || direction === "RL"
+ const label = horizontal ? "one
two
three
four
five" : "A very very wide downstream subgraph title"
+ const layout = layoutFlowchartDiagram(`flowchart ${direction}
+ subgraph source [Source]
+ A[A]
+ end
+ subgraph left [${label}]
+ B[B]
+ end
+ subgraph right [Right]
+ C[C]
+ end
+ A --> B
+ A --> C`)
+ const left = layout.subgraphBounds.get("left")!
+ const right = layout.subgraphBounds.get("right")!
+ const overlap =
+ left.left < right.left + right.width &&
+ left.left + left.width > right.left &&
+ left.top < right.top + right.height &&
+ left.top + left.height > right.top
+
+ expect(overlap).toBe(false)
+ },
+ )
+
+ test.each(["TD", "BT", "LR", "RL"] as const)(
+ "ranks nested order-only subgraph dependencies for %s diagrams",
+ (direction) => {
+ const layout = layoutFlowchartDiagram(`flowchart ${direction}
+ subgraph first [First]
+ subgraph firstInner [First inner]
+ A[A]
+ end
+ end
+ subgraph second [Second]
+ subgraph secondInner [Second inner]
+ B[B]
+ end
+ end
+ firstInner ~~~ secondInner`)
+ const first = layout.subgraphBounds.get("first")!
+ const second = layout.subgraphBounds.get("second")!
+ const horizontal = direction === "LR" || direction === "RL"
+ const reversed = direction === "BT" || direction === "RL"
+ const start = (bound: typeof first) => {
+ const value = horizontal ? bound.left : bound.top
+ const size = horizontal ? bound.width : bound.height
+ return reversed ? -(value + size) : value
+ }
+ const size = horizontal ? first.width : first.height
+
+ expect(start(second)).toBeGreaterThanOrEqual(start(first) + size)
+ },
+ )
+
+ test.each(["TD", "BT", "LR", "RL"] as const)(
+ "ranks cyclic top-level subgraphs as one downstream component for %s diagrams",
+ (direction) => {
+ const layout = layoutFlowchartDiagram(`flowchart ${direction}
+ subgraph source [Source]
+ A[A]
+ end
+ subgraph first [First]
+ B[B]
+ end
+ subgraph second [Second]
+ C[C]
+ end
+ A --> B
+ B --> C
+ C --> B`)
+ const source = layout.subgraphBounds.get("source")!
+ const first = layout.subgraphBounds.get("first")!
+ const second = layout.subgraphBounds.get("second")!
+ const horizontal = direction === "LR" || direction === "RL"
+ const reversed = direction === "BT" || direction === "RL"
+ const start = (bound: typeof source) => {
+ const value = horizontal ? bound.left : bound.top
+ const size = horizontal ? bound.width : bound.height
+ return reversed ? -(value + size) : value
+ }
+ const size = (bound: typeof source) => (horizontal ? bound.width : bound.height)
+
+ expect(Math.min(start(first), start(second))).toBeGreaterThanOrEqual(start(source) + size(source))
+ },
+ )
+
test("moves subgraph labels away from crossing routes", () => {
const output = renderFlowchartDiagram(`
flowchart TD
diff --git a/packages/merman/src/flowchart/layout.ts b/packages/merman/src/flowchart/layout.ts
index 653ddbf8ac..5b3e262a92 100644
--- a/packages/merman/src/flowchart/layout.ts
+++ b/packages/merman/src/flowchart/layout.ts
@@ -27,7 +27,7 @@ import type {
export const DEFAULT_MIN_NODE_GAP = 5
export const DEFAULT_MIN_BRANCH_LABEL_GAP = 12
-export const DEFAULT_MIN_RANK_GAP = 10
+export const DEFAULT_MIN_RANK_GAP = 7
export const DEFAULT_MIN_VERTICAL_RANK_GAP = 4
export const COMPACT_MIN_RANK_GAP = 4
export const COMPACT_MIN_VERTICAL_RANK_GAP = 2
@@ -511,19 +511,79 @@ function collectSubgraphNodeIds(diagram: FlowchartDiagram, subgraphId: string):
return nodeIds
}
+function rankGraphComponents(ids: readonly string[], outgoing: ReadonlyMap>): Map {
+ const reachable = new Map>()
+ for (const id of ids) {
+ const seen = new Set()
+ const queue = [id]
+ for (let index = 0; index < queue.length; index++) {
+ const current = queue[index]!
+ if (seen.has(current)) continue
+ seen.add(current)
+ queue.push(...(outgoing.get(current) ?? []))
+ }
+ reachable.set(id, seen)
+ }
+
+ const componentById = new Map()
+ const components: string[][] = []
+ for (const id of ids) {
+ if (componentById.has(id)) continue
+ const component = ids.filter(
+ (candidate) => !componentById.has(candidate) && reachable.get(id)!.has(candidate) && reachable.get(candidate)!.has(id),
+ )
+ const componentIndex = components.length
+ components.push(component)
+ for (const member of component) componentById.set(member, componentIndex)
+ }
+
+ const componentOutgoing = new Map(components.map((_, index) => [index, new Set()]))
+ const incoming = new Map(components.map((_, index) => [index, 0]))
+ for (const [from, targets] of outgoing) {
+ const fromComponent = componentById.get(from)!
+ for (const to of targets) {
+ const toComponent = componentById.get(to)!
+ if (fromComponent === toComponent || componentOutgoing.get(fromComponent)!.has(toComponent)) continue
+ componentOutgoing.get(fromComponent)!.add(toComponent)
+ incoming.set(toComponent, incoming.get(toComponent)! + 1)
+ }
+ }
+
+ const componentRanks = new Map()
+ const queue = components.map((_, index) => index).filter((index) => incoming.get(index) === 0)
+ for (const component of queue) componentRanks.set(component, 0)
+ for (let index = 0; index < queue.length; index++) {
+ const component = queue[index]!
+ for (const to of componentOutgoing.get(component)!) {
+ componentRanks.set(to, Math.max(componentRanks.get(to) ?? 0, componentRanks.get(component)! + 1))
+ incoming.set(to, incoming.get(to)! - 1)
+ if (incoming.get(to) === 0) queue.push(to)
+ }
+ }
+
+ return new Map(ids.map((id) => [id, componentRanks.get(componentById.get(id)!) ?? 0]))
+}
+
function separateTopLevelItems(
diagram: FlowchartDiagram,
nodeBounds: Map,
subgraphBounds: ReadonlyMap,
gap: number,
-): void {
+): boolean {
const hasLocalDirection = (diagram.subgraphs ?? []).some(
(subgraph) => subgraph.direction && subgraph.direction !== diagram.direction,
)
const coveredNodeIds = new Set()
const items: { id: string; bounds: FlowchartBounds; nodeIds: Set; rank: number }[] = []
const itemByEndpoint = new Map()
- for (const subgraph of diagram.subgraphs ?? []) {
+ const subgraphs = diagram.subgraphs ?? []
+ const subgraphById = new Map(subgraphs.map((subgraph) => [subgraph.id, subgraph]))
+ const topLevelSubgraphId = (id: string): string => {
+ let current = subgraphById.get(id)
+ while (current?.parentId) current = subgraphById.get(current.parentId)
+ return current?.id ?? id
+ }
+ for (const subgraph of subgraphs) {
if (subgraph.parentId) continue
const bounds = subgraphBounds.get(subgraph.id)
const nodeIds = collectSubgraphNodeIds(diagram, subgraph.id)
@@ -535,6 +595,7 @@ function separateTopLevelItems(
itemByEndpoint.set(nodeId, subgraph.id)
}
}
+ for (const subgraph of subgraphs) itemByEndpoint.set(subgraph.id, topLevelSubgraphId(subgraph.id))
for (const node of diagram.nodes) {
if (coveredNodeIds.has(node.id)) continue
@@ -543,12 +604,19 @@ function separateTopLevelItems(
items.push({ id: node.id, bounds, nodeIds: new Set([node.id]), rank: 0 })
itemByEndpoint.set(node.id, node.id)
}
- if (items.length < 2) return
+ if (items.length < 2) return false
const horizontal = isHorizontalDirection(diagram.direction)
+ const moveItem = (item: (typeof items)[number], dx: number, dy: number): void => {
+ for (const nodeId of item.nodeIds) {
+ const bounds = nodeBounds.get(nodeId)
+ if (bounds) translateBounds(bounds, dx, dy)
+ }
+ }
if (hasLocalDirection) {
items.sort((a, b) => (horizontal ? a.bounds.left - b.bounds.left : a.bounds.top - b.bounds.top))
let cursor: number | undefined
+ let moved = false
for (const item of items) {
const start = horizontal ? item.bounds.left : item.bounds.top
const size = horizontal ? item.bounds.width : item.bounds.height
@@ -557,42 +625,31 @@ function separateTopLevelItems(
continue
}
const shift = cursor - start
- for (const nodeId of item.nodeIds) {
- const bounds = nodeBounds.get(nodeId)
- if (bounds) translateBounds(bounds, horizontal ? shift : 0, horizontal ? 0 : shift)
- }
+ moved ||= shift !== 0
+ moveItem(item, horizontal ? shift : 0, horizontal ? 0 : shift)
cursor = start + shift + size + gap
}
- return
+ return moved
}
- const topLevelIds = new Set(
- (diagram.subgraphs ?? []).filter((subgraph) => !subgraph.parentId).map((subgraph) => subgraph.id),
- )
+ const topLevelIds = new Set(subgraphs.filter((subgraph) => !subgraph.parentId).map((subgraph) => subgraph.id))
const rankedItems = items.filter((item) => topLevelIds.has(item.id))
- if (rankedItems.length < 2) return
+ if (rankedItems.length < 2) return false
const itemById = new Map(rankedItems.map((item) => [item.id, item]))
const outgoing = new Map(rankedItems.map((item) => [item.id, new Set()]))
- const incoming = new Map(rankedItems.map((item) => [item.id, 0]))
for (const edge of diagram.edges) {
const from = itemByEndpoint.get(edge.from)
const to = itemByEndpoint.get(edge.to)
if (!from || !to || from === to || !itemById.has(from) || !itemById.has(to) || outgoing.get(from)!.has(to)) continue
outgoing.get(from)!.add(to)
- incoming.set(to, incoming.get(to)! + 1)
}
- const queue = rankedItems.filter((item) => incoming.get(item.id) === 0)
- for (let index = 0; index < queue.length; index++) {
- const item = queue[index]!
- for (const to of outgoing.get(item.id)!) {
- const downstream = itemById.get(to)!
- downstream.rank = Math.max(downstream.rank, item.rank + 1)
- incoming.set(to, incoming.get(to)! - 1)
- if (incoming.get(to) === 0) queue.push(downstream)
- }
- }
+ const ranks = rankGraphComponents(
+ rankedItems.map((item) => item.id),
+ outgoing,
+ )
+ for (const item of rankedItems) item.rank = ranks.get(item.id)!
const reversed = diagram.direction === "RL" || diagram.direction === "BT"
const primaryStart = (item: (typeof items)[number]): number => {
@@ -600,28 +657,54 @@ function separateTopLevelItems(
const size = horizontal ? item.bounds.width : item.bounds.height
return reversed ? -(start + size) : start
}
- rankedItems.sort((a, b) => a.rank - b.rank || primaryStart(a) - primaryStart(b))
-
+ const itemsByRank = Map.groupBy(rankedItems, (item) => item.rank)
+ const rankKeys = [...itemsByRank.keys()].sort((a, b) => a - b)
let cursor: number | undefined
- for (const item of rankedItems) {
- const start = primaryStart(item)
- const size = horizontal ? item.bounds.width : item.bounds.height
+ let moved = false
+ for (const rank of rankKeys) {
+ const rankItems = itemsByRank.get(rank)!
+ const start = Math.min(...rankItems.map(primaryStart))
+ const end = Math.max(
+ ...rankItems.map((item) => primaryStart(item) + (horizontal ? item.bounds.width : item.bounds.height)),
+ )
if (cursor === undefined) {
- cursor = start + size + gap
+ cursor = end + gap
continue
}
const shift = Math.max(0, cursor - start)
if (shift > 0) {
- for (const nodeId of item.nodeIds) {
- const bounds = nodeBounds.get(nodeId)
- if (bounds) {
- const offset = reversed ? -shift : shift
- translateBounds(bounds, horizontal ? offset : 0, horizontal ? 0 : offset)
- }
+ moved = true
+ for (const item of rankItems) {
+ const offset = reversed ? -shift : shift
+ moveItem(item, horizontal ? offset : 0, horizontal ? 0 : offset)
}
}
- cursor = start + shift + size + gap
+ cursor = end + shift + gap
}
+
+ for (const rank of rankKeys) {
+ const rankItems = itemsByRank
+ .get(rank)!
+ .toSorted((a, b) =>
+ horizontal ? a.bounds.top - b.bounds.top : a.bounds.left - b.bounds.left,
+ )
+ let crossCursor: number | undefined
+ for (const item of rankItems) {
+ const start = horizontal ? item.bounds.top : item.bounds.left
+ const size = horizontal ? item.bounds.height : item.bounds.width
+ if (crossCursor === undefined) {
+ crossCursor = start + size + gap
+ continue
+ }
+ const shift = Math.max(0, crossCursor - start)
+ if (shift > 0) {
+ moved = true
+ moveItem(item, horizontal ? 0 : shift, horizontal ? shift : 0)
+ }
+ crossCursor = start + shift + size + gap
+ }
+ }
+ return moved
}
function layoutSubgraphs(
@@ -676,13 +759,27 @@ function layoutFlowchartWithDirection(
const bounds = layoutRankedNodes(diagram, direction, sizes, minNodeGap, requestedMinRankGap)
layoutLocalSubgraphDirections(diagram, bounds, sizes, minNodeGap, requestedMinRankGap)
- let routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge))
- let subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
- separateTopLevelItems(diagram, bounds, subgraphBounds, Math.max(1, Math.floor(requestedMinRankGap / 2)))
- routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge))
- subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
- routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge), subgraphBounds)
- subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
+ const subgraphs = diagram.subgraphs ?? []
+ let subgraphBounds = new Map()
+ let routes: FlowchartEdgeRoute[]
+ if (subgraphs.length === 0) {
+ routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge))
+ } else {
+ routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge))
+ subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
+ const moved = separateTopLevelItems(
+ diagram,
+ bounds,
+ subgraphBounds,
+ Math.max(1, Math.floor(requestedMinRankGap / 2)),
+ )
+ if (moved) {
+ routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge))
+ subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
+ }
+ routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge), subgraphBounds)
+ subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
+ }
const allBounds = [...bounds.values(), ...subgraphBounds.values(), ...routeRenderBounds(routes)]
const dx = Math.max(0, -Math.min(0, ...allBounds.map((bound) => bound.left)))
const dy = Math.max(0, -Math.min(0, ...allBounds.map((bound) => bound.top)))