opentui-markdown-render 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE +27 -0
- package/README.md +223 -2
- package/bin/otmd.tsx +99 -0
- package/native/Cargo.lock +3708 -0
- package/native/Cargo.toml +28 -0
- package/native/build.rs +3 -0
- package/native/src/lib.rs +880 -0
- package/package.json +67 -4
- package/prebuilds/linux-x64-gnu/opentui_markdown_render.node +4 -0
- package/src/app.tsx +180 -0
- package/src/cli.ts +63 -0
- package/src/config/index.ts +46 -0
- package/src/config/load.ts +337 -0
- package/src/config/resolve.ts +36 -0
- package/src/config/theme.ts +110 -0
- package/src/config/types.ts +105 -0
- package/src/core/document.ts +44 -0
- package/src/core/frame.ts +144 -0
- package/src/core/image.ts +9 -0
- package/src/core/index.ts +6 -0
- package/src/core/layout.ts +27 -0
- package/src/core/math.ts +170 -0
- package/src/core/parse.ts +208 -0
- package/src/core/tables.ts +58 -0
- package/src/graphics.ts +85 -0
- package/src/headings.ts +77 -0
- package/src/index.ts +107 -0
- package/src/math/index.ts +86 -0
- package/src/math/native.ts +39 -0
- package/src/math/renderer.ts +84 -0
- package/src/math/validate.ts +44 -0
- package/src/media.tsx +267 -0
- package/src/merman/class/dagre-layout.ts +171 -0
- package/src/merman/class/diagram.ts +8 -0
- package/src/merman/class/drawing.ts +343 -0
- package/src/merman/class/labels.ts +38 -0
- package/src/merman/class/layout.ts +531 -0
- package/src/merman/class/parser.ts +308 -0
- package/src/merman/class/render-grid.ts +18 -0
- package/src/merman/class/style.ts +27 -0
- package/src/merman/class/types.ts +57 -0
- package/src/merman/core/canvas.ts +228 -0
- package/src/merman/core/color/style.ts +56 -0
- package/src/merman/core/drawing.ts +307 -0
- package/src/merman/core/geometry.ts +347 -0
- package/src/merman/core/mermaid.ts +38 -0
- package/src/merman/core/render-grid.ts +51 -0
- package/src/merman/core/spatial.ts +243 -0
- package/src/merman/core/text-lines.ts +32 -0
- package/src/merman/core/text.ts +44 -0
- package/src/merman/detect.ts +19 -0
- package/src/merman/diagnostics.ts +16 -0
- package/src/merman/diagram.ts +268 -0
- package/src/merman/flowchart/drawing.ts +433 -0
- package/src/merman/flowchart/labels.ts +144 -0
- package/src/merman/flowchart/layout.ts +1057 -0
- package/src/merman/flowchart/options.ts +12 -0
- package/src/merman/flowchart/parser.ts +477 -0
- package/src/merman/flowchart/render.ts +10 -0
- package/src/merman/flowchart/routing.ts +1429 -0
- package/src/merman/flowchart/style.ts +79 -0
- package/src/merman/flowchart/types.ts +63 -0
- package/src/merman/gantt/diagram.ts +8 -0
- package/src/merman/gantt/drawing.ts +193 -0
- package/src/merman/gantt/parser.ts +198 -0
- package/src/merman/gantt/render-grid.ts +17 -0
- package/src/merman/gantt/style.ts +36 -0
- package/src/merman/gantt/types.ts +45 -0
- package/src/merman/gitgraph/diagram.ts +8 -0
- package/src/merman/gitgraph/drawing.ts +234 -0
- package/src/merman/gitgraph/parser.ts +234 -0
- package/src/merman/gitgraph/render-grid.ts +17 -0
- package/src/merman/gitgraph/style.ts +37 -0
- package/src/merman/gitgraph/types.ts +36 -0
- package/src/merman/palette.ts +60 -0
- package/src/merman/sequence/diagram.ts +8 -0
- package/src/merman/sequence/drawing.ts +288 -0
- package/src/merman/sequence/options.ts +8 -0
- package/src/merman/sequence/parser.ts +303 -0
- package/src/merman/sequence/placement.ts +692 -0
- package/src/merman/sequence/render-grid.ts +22 -0
- package/src/merman/sequence/style.ts +81 -0
- package/src/merman/sequence/types.ts +74 -0
- package/src/merman/state/diagram.ts +8 -0
- package/src/merman/state/drawing.ts +363 -0
- package/src/merman/state/endpoint.ts +15 -0
- package/src/merman/state/layout.ts +1134 -0
- package/src/merman/state/note.ts +59 -0
- package/src/merman/state/options.ts +11 -0
- package/src/merman/state/parser.ts +245 -0
- package/src/merman/state/render-grid.ts +22 -0
- package/src/merman/state/routing.ts +1522 -0
- package/src/merman/state/search.ts +103 -0
- package/src/merman/state/style.ts +52 -0
- package/src/merman/state/types.ts +63 -0
- package/src/merman/state/visible-model.ts +77 -0
- package/src/merman/timeline/diagram.ts +8 -0
- package/src/merman/timeline/drawing.ts +109 -0
- package/src/merman/timeline/parser.ts +119 -0
- package/src/merman/timeline/render-grid.ts +17 -0
- package/src/merman/timeline/style.ts +36 -0
- package/src/merman/timeline/types.ts +31 -0
- package/src/merman.ts +3 -0
- package/src/statusline.tsx +183 -0
- package/src/theme/colors.ts +11 -0
- package/src/theme/defaults.ts +190 -0
- package/src/theme/index.ts +7 -0
- package/src/theme/load.ts +39 -0
- package/src/theme/resolve.ts +167 -0
- package/src/theme/syntax.ts +96 -0
- package/src/theme/types.ts +151 -0
- package/src/view/code.tsx +27 -0
- package/src/view/document-slots.ts +111 -0
- package/src/view/document-table.tsx +98 -0
- package/src/view/document.tsx +195 -0
- package/src/view/frame.tsx +83 -0
- package/src/view/inline-flow.tsx +113 -0
- package/src/view/inline-layout.ts +149 -0
- package/src/view/inline-math.ts +42 -0
- package/src/view/inline-style.ts +103 -0
- package/src/view/math.tsx +85 -0
- package/src/view/mermaid.tsx +128 -0
- package/src/view/table.ts +100 -0
- package/themes/dark.json +32 -0
- package/themes/example.jsonc +98 -0
- package/themes/light.json +32 -0
- package/themes/solarized-dark.json +32 -0
- package/themes/solarized-light.json +32 -0
|
@@ -0,0 +1,1057 @@
|
|
|
1
|
+
import {
|
|
2
|
+
diagramBoundsFromBounds,
|
|
3
|
+
diagramBoundsFromPoints,
|
|
4
|
+
segmentBetween,
|
|
5
|
+
segmentSpan,
|
|
6
|
+
translateDiagramBounds,
|
|
7
|
+
} from "../core/geometry.js"
|
|
8
|
+
import { diagramTextWidth, measureDiagramTextBox, splitDiagramLines } from "../core/text.js"
|
|
9
|
+
import {
|
|
10
|
+
flowchartEdgeLabelLayout,
|
|
11
|
+
flowchartHorizontalLabelRankGap,
|
|
12
|
+
flowchartLabelWidth,
|
|
13
|
+
flowchartRouteLabelLayout,
|
|
14
|
+
flowchartVerticalBranchLabelGap,
|
|
15
|
+
} from "./labels.js"
|
|
16
|
+
import type { FlowchartDiagramRenderOptions } from "./options.js"
|
|
17
|
+
import { avoidFlowchartFrameBorders, routeFlowchartEdges } from "./routing.js"
|
|
18
|
+
import type {
|
|
19
|
+
FlowchartDiagram,
|
|
20
|
+
FlowchartDirection,
|
|
21
|
+
FlowchartEdge,
|
|
22
|
+
FlowchartEdgeRoute,
|
|
23
|
+
FlowchartNode,
|
|
24
|
+
FlowchartNodeBounds,
|
|
25
|
+
FlowchartNodeSize,
|
|
26
|
+
FlowchartPoint,
|
|
27
|
+
FlowchartSubgraphBounds,
|
|
28
|
+
} from "./types.js"
|
|
29
|
+
|
|
30
|
+
export const DEFAULT_MIN_NODE_GAP = 5
|
|
31
|
+
export const DEFAULT_MIN_BRANCH_LABEL_GAP = 12
|
|
32
|
+
const DEFAULT_MAX_UNLABELED_RANK_WIDTH = 120
|
|
33
|
+
export const DEFAULT_MIN_RANK_GAP = 7
|
|
34
|
+
export const DEFAULT_MIN_VERTICAL_RANK_GAP = 4
|
|
35
|
+
export const COMPACT_MIN_RANK_GAP = 4
|
|
36
|
+
export const COMPACT_MIN_VERTICAL_RANK_GAP = 2
|
|
37
|
+
const SUBGRAPH_PADDING_X = 2
|
|
38
|
+
const SUBGRAPH_PADDING_TOP = 1
|
|
39
|
+
const SUBGRAPH_PADDING_BOTTOM = 1
|
|
40
|
+
|
|
41
|
+
export interface FlowchartLayout {
|
|
42
|
+
diagram: FlowchartDiagram
|
|
43
|
+
bounds: Map<string, FlowchartNodeBounds>
|
|
44
|
+
routes: FlowchartEdgeRoute[]
|
|
45
|
+
subgraphBounds: Map<string, FlowchartSubgraphBounds>
|
|
46
|
+
width: number
|
|
47
|
+
height: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type FlowchartBounds = Pick<FlowchartSubgraphBounds, "left" | "top" | "width" | "height" | "centerX" | "centerY">
|
|
51
|
+
|
|
52
|
+
function horizontalRankGaps(
|
|
53
|
+
diagram: FlowchartDiagram,
|
|
54
|
+
normalizedRanks: ReadonlyMap<string, number>,
|
|
55
|
+
rankKeys: readonly number[],
|
|
56
|
+
fallback: number,
|
|
57
|
+
): number[] {
|
|
58
|
+
const gaps = Array.from({ length: Math.max(0, rankKeys.length - 1) }, () => fallback)
|
|
59
|
+
const rankIndexes = new Map(rankKeys.map((rank, index) => [rank, index]))
|
|
60
|
+
|
|
61
|
+
for (const edge of diagram.edges) {
|
|
62
|
+
if (!edge.label) continue
|
|
63
|
+
const fromIndex = rankIndexes.get(normalizedRanks.get(edge.from) ?? -1)
|
|
64
|
+
const toIndex = rankIndexes.get(normalizedRanks.get(edge.to) ?? -1)
|
|
65
|
+
if (fromIndex === undefined || toIndex === undefined || fromIndex === toIndex) continue
|
|
66
|
+
|
|
67
|
+
const labelGap = flowchartHorizontalLabelRankGap(flowchartLabelWidth(edge.label, visualLength))
|
|
68
|
+
for (let index = Math.min(fromIndex, toIndex); index < Math.max(fromIndex, toIndex); index++) {
|
|
69
|
+
gaps[index] = Math.max(gaps[index]!, labelGap)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return gaps
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function verticalRankGaps(
|
|
77
|
+
diagram: FlowchartDiagram,
|
|
78
|
+
normalizedRanks: ReadonlyMap<string, number>,
|
|
79
|
+
rankKeys: readonly number[],
|
|
80
|
+
fallback: number,
|
|
81
|
+
): number[] {
|
|
82
|
+
const gaps = Array.from({ length: Math.max(0, rankKeys.length - 1) }, () => fallback)
|
|
83
|
+
const rankIndexes = new Map(rankKeys.map((rank, index) => [rank, index]))
|
|
84
|
+
|
|
85
|
+
for (const edge of diagram.edges) {
|
|
86
|
+
if (!edge.label) continue
|
|
87
|
+
const fromIndex = rankIndexes.get(normalizedRanks.get(edge.from) ?? -1)
|
|
88
|
+
const toIndex = rankIndexes.get(normalizedRanks.get(edge.to) ?? -1)
|
|
89
|
+
if (fromIndex === undefined || toIndex === undefined || fromIndex === toIndex) continue
|
|
90
|
+
|
|
91
|
+
const labelHeight = splitDiagramLines(edge.label).length
|
|
92
|
+
for (let index = Math.min(fromIndex, toIndex); index < Math.max(fromIndex, toIndex); index++) {
|
|
93
|
+
gaps[index] = Math.max(gaps[index]!, labelHeight + 2)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return gaps
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isHorizontalDirection(direction: FlowchartDirection): boolean {
|
|
101
|
+
return direction === "LR" || direction === "RL"
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function visualLength(value: string): number {
|
|
105
|
+
return diagramTextWidth(value)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function normalizePositiveInt(value: number | undefined, fallback: number): number {
|
|
109
|
+
if (value === undefined || !Number.isFinite(value)) return fallback
|
|
110
|
+
return Math.max(1, Math.trunc(value))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function nodeSize(node: FlowchartNode): FlowchartNodeSize {
|
|
114
|
+
const { lines, width } = measureDiagramTextBox(node.label, { paddingX: 2 })
|
|
115
|
+
const innerWidth = width - 4
|
|
116
|
+
if (node.shape === "decision") {
|
|
117
|
+
const width = innerWidth + 6
|
|
118
|
+
return {
|
|
119
|
+
width: width % 2 === 0 ? width + 1 : width,
|
|
120
|
+
height: Math.max(5, lines.length + 4),
|
|
121
|
+
lines,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (node.shape === "circle") return { width: Math.max(9, innerWidth + 6), height: Math.max(5, lines.length + 4), lines }
|
|
125
|
+
if (node.shape === "hexagon") return { width: Math.max(9, innerWidth + 6), height: Math.max(5, lines.length + 4), lines }
|
|
126
|
+
if (node.shape === "parallelogram") return { width: innerWidth + 6, height: lines.length + 2, lines }
|
|
127
|
+
if (node.shape === "database") return { width: innerWidth + 4, height: lines.length + 4, lines }
|
|
128
|
+
if (node.shape === "subroutine") return { width: innerWidth + 6, height: lines.length + 2, lines }
|
|
129
|
+
return { width: innerWidth + 4, height: lines.length + 2, lines }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function rankNodes(diagram: FlowchartDiagram): Map<string, number> {
|
|
133
|
+
const ranks = new Map<string, number>()
|
|
134
|
+
const outgoing = new Map<string, string[]>()
|
|
135
|
+
const incoming = new Set<string>()
|
|
136
|
+
const incomingCounts = new Map(diagram.nodes.map((node) => [node.id, 0]))
|
|
137
|
+
|
|
138
|
+
const nodeIds = new Set(diagram.nodes.map((node) => node.id))
|
|
139
|
+
const rankEdges = diagram.edges.flatMap((edge) => {
|
|
140
|
+
if (!edge.orderOnly) return [edge]
|
|
141
|
+
const fromIds = nodeIds.has(edge.from) ? [edge.from] : [...collectSubgraphNodeIds(diagram, edge.from)]
|
|
142
|
+
const toIds = nodeIds.has(edge.to) ? [edge.to] : [...collectSubgraphNodeIds(diagram, edge.to)]
|
|
143
|
+
return fromIds.flatMap((from) => toIds.map((to) => ({ ...edge, from, to })))
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
for (const edge of rankEdges) {
|
|
147
|
+
const list = outgoing.get(edge.from) ?? []
|
|
148
|
+
list.push(edge.to)
|
|
149
|
+
outgoing.set(edge.from, list)
|
|
150
|
+
incoming.add(edge.to)
|
|
151
|
+
incomingCounts.set(edge.to, (incomingCounts.get(edge.to) ?? 0) + 1)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const starts = diagram.nodes.filter((node) => !incoming.has(node.id))
|
|
155
|
+
if (starts.length === 0 && diagram.nodes[0]) starts.push(diagram.nodes[0])
|
|
156
|
+
|
|
157
|
+
const queue = starts.map((node) => node.id)
|
|
158
|
+
for (const node of starts) ranks.set(node.id, 0)
|
|
159
|
+
|
|
160
|
+
for (let index = 0; index < queue.length; index++) {
|
|
161
|
+
const id = queue[index]!
|
|
162
|
+
const rank = ranks.get(id) ?? 0
|
|
163
|
+
for (const to of outgoing.get(id) ?? []) {
|
|
164
|
+
const nextRank = rank + 1
|
|
165
|
+
if ((ranks.get(to) ?? Number.POSITIVE_INFINITY) <= nextRank) continue
|
|
166
|
+
ranks.set(to, nextRank)
|
|
167
|
+
queue.push(to)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
for (const node of diagram.nodes) {
|
|
172
|
+
if (!ranks.has(node.id)) ranks.set(node.id, ranks.size)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const acyclicRanks = new Map<string, number>()
|
|
176
|
+
const acyclicQueue = diagram.nodes.filter((node) => incomingCounts.get(node.id) === 0).map((node) => node.id)
|
|
177
|
+
for (const id of acyclicQueue) acyclicRanks.set(id, 0)
|
|
178
|
+
for (let index = 0; index < acyclicQueue.length; index++) {
|
|
179
|
+
const id = acyclicQueue[index]!
|
|
180
|
+
const rank = acyclicRanks.get(id) ?? 0
|
|
181
|
+
for (const to of outgoing.get(id) ?? []) {
|
|
182
|
+
acyclicRanks.set(to, Math.max(acyclicRanks.get(to) ?? 0, rank + 1))
|
|
183
|
+
const remainingIncoming = (incomingCounts.get(to) ?? 0) - 1
|
|
184
|
+
incomingCounts.set(to, remainingIncoming)
|
|
185
|
+
if (remainingIncoming === 0) acyclicQueue.push(to)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
for (const [id, rank] of acyclicRanks) ranks.set(id, rank)
|
|
189
|
+
|
|
190
|
+
return ranks
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function translateBounds(bounds: FlowchartBounds, dx: number, dy: number): void {
|
|
194
|
+
translateDiagramBounds(bounds, dx, dy)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function translateRoutes(routes: readonly FlowchartEdgeRoute[], dx: number, dy: number): void {
|
|
198
|
+
for (const route of routes) {
|
|
199
|
+
for (const point of route.points) {
|
|
200
|
+
point.x += dx
|
|
201
|
+
point.y += dy
|
|
202
|
+
}
|
|
203
|
+
if (route.labelPoint) {
|
|
204
|
+
route.labelPoint.x += dx
|
|
205
|
+
route.labelPoint.y += dy
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function freezeRouteLabelPoints(routes: readonly FlowchartEdgeRoute[]): void {
|
|
211
|
+
for (const route of routes) {
|
|
212
|
+
if (!route.edge.label || route.labelPoint) continue
|
|
213
|
+
route.labelPoint = flowchartEdgeLabelLayout(route.points, route.edge.label, visualLength, route.labelAxis).point
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function boundsFromChildren(children: readonly FlowchartBounds[]): FlowchartBounds | undefined {
|
|
218
|
+
return diagramBoundsFromBounds(children)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function subgraphBoundFromChildren(
|
|
222
|
+
id: string,
|
|
223
|
+
label: string,
|
|
224
|
+
children: readonly FlowchartBounds[],
|
|
225
|
+
): FlowchartSubgraphBounds {
|
|
226
|
+
const labelLines = splitDiagramLines(label)
|
|
227
|
+
const labelHeight = labelLines.length
|
|
228
|
+
let left = Math.min(...children.map((child) => child.left)) - SUBGRAPH_PADDING_X
|
|
229
|
+
const top = Math.min(...children.map((child) => child.top)) - Math.max(SUBGRAPH_PADDING_TOP, labelHeight)
|
|
230
|
+
let right = Math.max(...children.map((child) => child.left + child.width)) + SUBGRAPH_PADDING_X
|
|
231
|
+
const bottom =
|
|
232
|
+
Math.max(...children.map((child) => child.top + child.height)) + Math.max(SUBGRAPH_PADDING_BOTTOM, labelHeight)
|
|
233
|
+
const minWidth = Math.max(...labelLines.map(visualLength)) + 5
|
|
234
|
+
|
|
235
|
+
if (right - left < minWidth) {
|
|
236
|
+
const extra = minWidth - (right - left)
|
|
237
|
+
left -= Math.floor(extra / 2)
|
|
238
|
+
right += Math.ceil(extra / 2)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const width = right - left
|
|
242
|
+
const height = Math.max(3, bottom - top)
|
|
243
|
+
return {
|
|
244
|
+
id,
|
|
245
|
+
label,
|
|
246
|
+
left,
|
|
247
|
+
top,
|
|
248
|
+
width,
|
|
249
|
+
height,
|
|
250
|
+
centerX: left + Math.floor(width / 2),
|
|
251
|
+
centerY: top + Math.floor(height / 2),
|
|
252
|
+
labelSide: "top",
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function spansOverlap(leftStart: number, leftEnd: number, rightStart: number, rightEnd: number): boolean {
|
|
257
|
+
return leftStart <= rightEnd && rightStart <= leftEnd
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function labelSlot(bounds: FlowchartSubgraphBounds, side: FlowchartSubgraphBounds["labelSide"]): FlowchartBounds {
|
|
261
|
+
const lines = splitDiagramLines(bounds.label)
|
|
262
|
+
const left = bounds.left + 2
|
|
263
|
+
const height = lines.length
|
|
264
|
+
const top = side === "top" ? bounds.top : bounds.top + bounds.height - height
|
|
265
|
+
const width = Math.max(...lines.map((line) => visualLength(` ${line} `)))
|
|
266
|
+
return { left, top, width, height, centerX: left + Math.floor(width / 2), centerY: top + Math.floor(height / 2) }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function segmentOverlapsSlot(
|
|
270
|
+
from: { x: number; y: number },
|
|
271
|
+
to: { x: number; y: number },
|
|
272
|
+
slot: FlowchartBounds,
|
|
273
|
+
): boolean {
|
|
274
|
+
const segment = segmentBetween(from, to)
|
|
275
|
+
if (!segment) return false
|
|
276
|
+
|
|
277
|
+
const slotRight = slot.left + slot.width - 1
|
|
278
|
+
const slotBottom = slot.top + slot.height - 1
|
|
279
|
+
const span = segmentSpan(segment)
|
|
280
|
+
if (segment.axis === "x") {
|
|
281
|
+
return (
|
|
282
|
+
segment.from.y >= slot.top &&
|
|
283
|
+
segment.from.y <= slotBottom &&
|
|
284
|
+
spansOverlap(span.start, span.end, slot.left, slotRight)
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
return (
|
|
288
|
+
segment.from.x >= slot.left &&
|
|
289
|
+
segment.from.x <= slotRight &&
|
|
290
|
+
spansOverlap(span.start, span.end, slot.top, slotBottom)
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function routeOverlapsSlot(route: FlowchartEdgeRoute, slot: FlowchartBounds): boolean {
|
|
295
|
+
for (let index = 1; index < route.points.length; index++) {
|
|
296
|
+
if (segmentOverlapsSlot(route.points[index - 1]!, route.points[index]!, slot)) return true
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const routeLabelBounds = labelBounds(route)
|
|
300
|
+
if (
|
|
301
|
+
!routeLabelBounds ||
|
|
302
|
+
!spansOverlap(
|
|
303
|
+
routeLabelBounds.top,
|
|
304
|
+
routeLabelBounds.top + routeLabelBounds.height - 1,
|
|
305
|
+
slot.top,
|
|
306
|
+
slot.top + slot.height - 1,
|
|
307
|
+
)
|
|
308
|
+
) {
|
|
309
|
+
return false
|
|
310
|
+
}
|
|
311
|
+
return spansOverlap(
|
|
312
|
+
routeLabelBounds.left,
|
|
313
|
+
routeLabelBounds.left + routeLabelBounds.width - 1,
|
|
314
|
+
slot.left,
|
|
315
|
+
slot.left + slot.width - 1,
|
|
316
|
+
)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function chooseSubgraphLabelSide(
|
|
320
|
+
bounds: FlowchartSubgraphBounds,
|
|
321
|
+
routes: readonly FlowchartEdgeRoute[],
|
|
322
|
+
): FlowchartSubgraphBounds["labelSide"] {
|
|
323
|
+
const topSlot = labelSlot(bounds, "top")
|
|
324
|
+
if (!routes.some((route) => routeOverlapsSlot(route, topSlot))) return "top"
|
|
325
|
+
|
|
326
|
+
const bottomSlot = labelSlot(bounds, "bottom")
|
|
327
|
+
return routes.some((route) => routeOverlapsSlot(route, bottomSlot)) ? "top" : "bottom"
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function pathBounds(points: readonly { x: number; y: number }[]): FlowchartBounds | undefined {
|
|
331
|
+
return diagramBoundsFromPoints(points)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function labelBounds(route: FlowchartEdgeRoute): FlowchartBounds | undefined {
|
|
335
|
+
if (!route.edge.label) return undefined
|
|
336
|
+
const label = flowchartRouteLabelLayout(route, visualLength)
|
|
337
|
+
const { point, width, height } = label
|
|
338
|
+
return {
|
|
339
|
+
left: point.x,
|
|
340
|
+
top: point.y,
|
|
341
|
+
width,
|
|
342
|
+
height,
|
|
343
|
+
centerX: point.x + Math.floor(width / 2),
|
|
344
|
+
centerY: point.y + Math.floor(height / 2),
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function subgraphRouteBounds(subgraphNodeIds: Set<string>, routes: readonly FlowchartEdgeRoute[]): FlowchartBounds[] {
|
|
349
|
+
return routeRenderBounds(
|
|
350
|
+
routes.filter((route) => subgraphNodeIds.has(route.edge.from) && subgraphNodeIds.has(route.edge.to)),
|
|
351
|
+
)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function routeRenderBounds(routes: readonly FlowchartEdgeRoute[]): FlowchartBounds[] {
|
|
355
|
+
const bounds: FlowchartBounds[] = []
|
|
356
|
+
for (const route of routes) {
|
|
357
|
+
const routeBounds = pathBounds(route.points)
|
|
358
|
+
if (routeBounds) bounds.push(routeBounds)
|
|
359
|
+
const routeLabelBounds = labelBounds(route)
|
|
360
|
+
if (routeLabelBounds) bounds.push(routeLabelBounds)
|
|
361
|
+
}
|
|
362
|
+
return bounds
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function layoutRankedNodes(
|
|
366
|
+
diagram: FlowchartDiagram,
|
|
367
|
+
direction: FlowchartDirection,
|
|
368
|
+
sizes: ReadonlyMap<string, FlowchartNodeSize>,
|
|
369
|
+
minNodeGap: number,
|
|
370
|
+
requestedMinRankGap: number,
|
|
371
|
+
targetWidth?: number,
|
|
372
|
+
): { bounds: Map<string, FlowchartNodeBounds>; wrapped: boolean } {
|
|
373
|
+
const horizontal = isHorizontalDirection(direction)
|
|
374
|
+
const ranks = rankNodes(diagram)
|
|
375
|
+
const maxRank = Math.max(0, ...ranks.values())
|
|
376
|
+
const ranksByIndex = new Map<number, FlowchartNode[]>()
|
|
377
|
+
const normalizedRanks = new Map<string, number>()
|
|
378
|
+
|
|
379
|
+
for (const node of diagram.nodes) {
|
|
380
|
+
const rank = ranks.get(node.id) ?? 0
|
|
381
|
+
const normalizedRank = direction === "RL" || direction === "BT" ? maxRank - rank : rank
|
|
382
|
+
normalizedRanks.set(node.id, normalizedRank)
|
|
383
|
+
const nodes = ranksByIndex.get(normalizedRank) ?? []
|
|
384
|
+
nodes.push(node)
|
|
385
|
+
ranksByIndex.set(normalizedRank, nodes)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const spaciousNodeGap = Math.max(minNodeGap, DEFAULT_MIN_BRANCH_LABEL_GAP)
|
|
389
|
+
const widestUnlabeledRank = Math.max(
|
|
390
|
+
0,
|
|
391
|
+
...[...ranksByIndex.values()].map(
|
|
392
|
+
(nodes) =>
|
|
393
|
+
nodes.reduce((total, node) => total + sizes.get(node.id)!.width, 0) +
|
|
394
|
+
Math.max(0, nodes.length - 1) * spaciousNodeGap,
|
|
395
|
+
),
|
|
396
|
+
)
|
|
397
|
+
const verticalNodeGap = (rank: number): number => {
|
|
398
|
+
const widestLabel = Math.max(
|
|
399
|
+
0,
|
|
400
|
+
...diagram.edges
|
|
401
|
+
.filter(
|
|
402
|
+
(edge) => edge.label && (normalizedRanks.get(edge.from) === rank || normalizedRanks.get(edge.to) === rank),
|
|
403
|
+
)
|
|
404
|
+
.map((edge) => flowchartLabelWidth(edge.label, visualLength)),
|
|
405
|
+
)
|
|
406
|
+
if (widestLabel > 0) return Math.max(spaciousNodeGap, flowchartVerticalBranchLabelGap(widestLabel))
|
|
407
|
+
return widestUnlabeledRank > DEFAULT_MAX_UNLABELED_RANK_WIDTH ? minNodeGap : spaciousNodeGap
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const rankKeys = [...ranksByIndex.keys()].sort((a, b) => a - b)
|
|
411
|
+
const horizontalGaps = horizontal ? horizontalRankGaps(diagram, normalizedRanks, rankKeys, requestedMinRankGap) : []
|
|
412
|
+
const verticalGaps = horizontal ? [] : verticalRankGaps(diagram, normalizedRanks, rankKeys, requestedMinRankGap)
|
|
413
|
+
const bounds = new Map<string, FlowchartNodeBounds>()
|
|
414
|
+
let wrapped = false
|
|
415
|
+
|
|
416
|
+
if (horizontal) {
|
|
417
|
+
const columnWidths = rankKeys.map((rank) =>
|
|
418
|
+
Math.max(...ranksByIndex.get(rank)!.map((node) => sizes.get(node.id)!.width)),
|
|
419
|
+
)
|
|
420
|
+
const columnHeights = rankKeys.map((rank) => {
|
|
421
|
+
const nodes = ranksByIndex.get(rank)!
|
|
422
|
+
return (
|
|
423
|
+
nodes.reduce((total, node) => total + sizes.get(node.id)!.height, 0) +
|
|
424
|
+
Math.max(0, nodes.length - 1) * minNodeGap
|
|
425
|
+
)
|
|
426
|
+
})
|
|
427
|
+
const canvasHeight = Math.max(1, ...columnHeights)
|
|
428
|
+
const centerBaseline = Math.floor(canvasHeight / 2)
|
|
429
|
+
let x = 0
|
|
430
|
+
for (let rankIndex = 0; rankIndex < rankKeys.length; rankIndex++) {
|
|
431
|
+
const rank = rankKeys[rankIndex]!
|
|
432
|
+
const nodes = ranksByIndex.get(rank)!
|
|
433
|
+
const columnWidth = columnWidths[rankIndex]!
|
|
434
|
+
let y = centerBaseline - Math.floor(columnHeights[rankIndex]! / 2)
|
|
435
|
+
for (const node of nodes) {
|
|
436
|
+
const size = sizes.get(node.id)!
|
|
437
|
+
const left = x + Math.floor((columnWidth - size.width) / 2)
|
|
438
|
+
bounds.set(node.id, {
|
|
439
|
+
id: node.id,
|
|
440
|
+
...size,
|
|
441
|
+
left,
|
|
442
|
+
top: y,
|
|
443
|
+
centerX: left + Math.floor(size.width / 2),
|
|
444
|
+
centerY: y + Math.floor(size.height / 2),
|
|
445
|
+
})
|
|
446
|
+
y += size.height + minNodeGap
|
|
447
|
+
}
|
|
448
|
+
x += columnWidth + (horizontalGaps[rankIndex] ?? 0)
|
|
449
|
+
}
|
|
450
|
+
} else {
|
|
451
|
+
const rankBands = rankKeys.map((rank) => {
|
|
452
|
+
const nodes = ranksByIndex.get(rank)!
|
|
453
|
+
const roomyNodeGap = verticalNodeGap(rank)
|
|
454
|
+
const naturalWidth =
|
|
455
|
+
nodes.reduce((total, node) => total + sizes.get(node.id)!.width, 0) +
|
|
456
|
+
Math.max(0, nodes.length - 1) * roomyNodeGap
|
|
457
|
+
const labeledEdges = diagram.edges.filter(
|
|
458
|
+
(edge) => edge.label && (normalizedRanks.get(edge.from) === rank || normalizedRanks.get(edge.to) === rank),
|
|
459
|
+
)
|
|
460
|
+
const needsLabelLanes =
|
|
461
|
+
labeledEdges.length > 1 &&
|
|
462
|
+
labeledEdges.some((edge) => {
|
|
463
|
+
const targets = new Set(
|
|
464
|
+
labeledEdges.filter((candidate) => candidate.from === edge.from).map((candidate) => candidate.to),
|
|
465
|
+
)
|
|
466
|
+
const sources = new Set(
|
|
467
|
+
labeledEdges.filter((candidate) => candidate.to === edge.to).map((candidate) => candidate.from),
|
|
468
|
+
)
|
|
469
|
+
const grouped = (ids: readonly string[]) =>
|
|
470
|
+
!diagram.subgraphs?.length ||
|
|
471
|
+
diagram.subgraphs.some((subgraph) => ids.every((id) => subgraph.nodeIds.includes(id)))
|
|
472
|
+
return (
|
|
473
|
+
(targets.size > 1 && grouped([edge.from, ...targets])) ||
|
|
474
|
+
(sources.size > 1 && grouped([edge.to, ...sources]))
|
|
475
|
+
)
|
|
476
|
+
})
|
|
477
|
+
const nodeGap =
|
|
478
|
+
targetWidth !== undefined && naturalWidth > targetWidth && (!needsLabelLanes || !diagram.subgraphs?.length)
|
|
479
|
+
? minNodeGap
|
|
480
|
+
: roomyNodeGap
|
|
481
|
+
const bands: { nodes: FlowchartNode[]; width: number; height: number }[] = []
|
|
482
|
+
for (const node of nodes) {
|
|
483
|
+
const size = sizes.get(node.id)!
|
|
484
|
+
const current = bands.at(-1)
|
|
485
|
+
const width = current ? current.width + nodeGap + size.width : size.width
|
|
486
|
+
if (current && targetWidth !== undefined && width > targetWidth) {
|
|
487
|
+
wrapped = true
|
|
488
|
+
bands.push({ nodes: [node], width: size.width, height: size.height })
|
|
489
|
+
continue
|
|
490
|
+
}
|
|
491
|
+
if (!current) {
|
|
492
|
+
bands.push({ nodes: [node], width: size.width, height: size.height })
|
|
493
|
+
continue
|
|
494
|
+
}
|
|
495
|
+
current.nodes.push(node)
|
|
496
|
+
current.width = width
|
|
497
|
+
current.height = Math.max(current.height, size.height)
|
|
498
|
+
}
|
|
499
|
+
return { bands, nodeGap }
|
|
500
|
+
})
|
|
501
|
+
const canvasWidth = Math.max(1, ...rankBands.flatMap((rank) => rank.bands.map((band) => band.width)))
|
|
502
|
+
let y = 0
|
|
503
|
+
for (let rankIndex = 0; rankIndex < rankKeys.length; rankIndex++) {
|
|
504
|
+
const rank = rankBands[rankIndex]!
|
|
505
|
+
for (const [bandIndex, band] of rank.bands.entries()) {
|
|
506
|
+
let x = Math.floor((canvasWidth - band.width) / 2)
|
|
507
|
+
for (const node of band.nodes) {
|
|
508
|
+
const size = sizes.get(node.id)!
|
|
509
|
+
const top = y + Math.floor((band.height - size.height) / 2)
|
|
510
|
+
bounds.set(node.id, {
|
|
511
|
+
id: node.id,
|
|
512
|
+
...size,
|
|
513
|
+
left: x,
|
|
514
|
+
top,
|
|
515
|
+
centerX: x + Math.floor(size.width / 2),
|
|
516
|
+
centerY: top + Math.floor(size.height / 2),
|
|
517
|
+
})
|
|
518
|
+
x += size.width + rank.nodeGap
|
|
519
|
+
}
|
|
520
|
+
y += band.height + (bandIndex < rank.bands.length - 1 ? minNodeGap : 0)
|
|
521
|
+
}
|
|
522
|
+
y += verticalGaps[rankIndex] ?? 0
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return { bounds, wrapped }
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function layoutLocalSubgraphDirections(
|
|
530
|
+
diagram: FlowchartDiagram,
|
|
531
|
+
nodeBounds: Map<string, FlowchartNodeBounds>,
|
|
532
|
+
minNodeGap: number,
|
|
533
|
+
requestedMinRankGap: number,
|
|
534
|
+
targetWidth?: number,
|
|
535
|
+
): boolean {
|
|
536
|
+
let wrapped = false
|
|
537
|
+
const subgraphs = diagram.subgraphs ?? []
|
|
538
|
+
const subgraphById = new Map(subgraphs.map((subgraph) => [subgraph.id, subgraph]))
|
|
539
|
+
for (const subgraph of [...subgraphs].reverse()) {
|
|
540
|
+
let parent = subgraph.parentId ? subgraphById.get(subgraph.parentId) : undefined
|
|
541
|
+
while (parent && !parent.direction) parent = parent.parentId ? subgraphById.get(parent.parentId) : undefined
|
|
542
|
+
const direction =
|
|
543
|
+
subgraph.direction ??
|
|
544
|
+
(parent && subgraphs.filter((child) => child.parentId === parent.id).length > 1 ? parent.direction : undefined)
|
|
545
|
+
if (!direction || direction === diagram.direction) continue
|
|
546
|
+
const childSubgraphs = subgraphs.filter((child) => child.parentId === subgraph.id)
|
|
547
|
+
const coveredNodeIds = new Set(childSubgraphs.flatMap((child) => [...collectSubgraphNodeIds(diagram, child.id)]))
|
|
548
|
+
const items = [
|
|
549
|
+
...childSubgraphs.flatMap((child) => {
|
|
550
|
+
const nodeIds = [...collectSubgraphNodeIds(diagram, child.id)]
|
|
551
|
+
const content = boundsFromChildren(nodeIds.flatMap((id) => nodeBounds.get(id) ?? []))
|
|
552
|
+
const bounds = content ? subgraphBoundFromChildren(child.id, child.label, [content]) : undefined
|
|
553
|
+
return bounds ? [{ id: `subgraph:${child.id}`, nodeIds, bounds, childId: child.id }] : []
|
|
554
|
+
}),
|
|
555
|
+
...subgraph.nodeIds.flatMap((id) => {
|
|
556
|
+
if (coveredNodeIds.has(id)) return []
|
|
557
|
+
const bounds = nodeBounds.get(id)
|
|
558
|
+
return bounds ? [{ id, nodeIds: [id], bounds, childId: undefined }] : []
|
|
559
|
+
}),
|
|
560
|
+
]
|
|
561
|
+
if (items.length === 0) continue
|
|
562
|
+
|
|
563
|
+
const currentBounds = boundsFromChildren(items.map((item) => item.bounds))
|
|
564
|
+
if (!currentBounds) continue
|
|
565
|
+
|
|
566
|
+
const itemByEndpoint = new Map<string, string>()
|
|
567
|
+
for (const item of items) {
|
|
568
|
+
for (const nodeId of item.nodeIds) itemByEndpoint.set(nodeId, item.id)
|
|
569
|
+
if (item.childId) itemByEndpoint.set(item.childId, item.id)
|
|
570
|
+
}
|
|
571
|
+
const nodes = items.map((item): FlowchartNode => ({ id: item.id, label: item.id, shape: "box" }))
|
|
572
|
+
const localDiagram: FlowchartDiagram = {
|
|
573
|
+
direction,
|
|
574
|
+
nodes,
|
|
575
|
+
edges: diagram.edges.flatMap((edge) => {
|
|
576
|
+
const from = itemByEndpoint.get(edge.from)
|
|
577
|
+
const to = itemByEndpoint.get(edge.to)
|
|
578
|
+
return from && to && from !== to ? [{ ...edge, from, to }] : []
|
|
579
|
+
}),
|
|
580
|
+
subgraphs: [],
|
|
581
|
+
}
|
|
582
|
+
const itemSizes = new Map(
|
|
583
|
+
items.map((item) => [item.id, { width: item.bounds.width, height: item.bounds.height, lines: [item.id] }]),
|
|
584
|
+
)
|
|
585
|
+
const localLayout = layoutRankedNodes(
|
|
586
|
+
localDiagram,
|
|
587
|
+
direction,
|
|
588
|
+
itemSizes,
|
|
589
|
+
Math.max(minNodeGap, SUBGRAPH_PADDING_X * 2 + 1),
|
|
590
|
+
requestedMinRankGap,
|
|
591
|
+
targetWidth,
|
|
592
|
+
)
|
|
593
|
+
const localBounds = localLayout.bounds
|
|
594
|
+
wrapped ||= localLayout.wrapped
|
|
595
|
+
const localExtent = boundsFromChildren([...localBounds.values()])
|
|
596
|
+
if (!localExtent) continue
|
|
597
|
+
|
|
598
|
+
const targetLeft = currentBounds.left + Math.floor((currentBounds.width - localExtent.width) / 2)
|
|
599
|
+
const targetTop = currentBounds.top + Math.floor((currentBounds.height - localExtent.height) / 2)
|
|
600
|
+
const dx = targetLeft - localExtent.left
|
|
601
|
+
const dy = targetTop - localExtent.top
|
|
602
|
+
|
|
603
|
+
const translations = new Map<string, { dx: number; dy: number }>()
|
|
604
|
+
for (const item of items) {
|
|
605
|
+
const bound = localBounds.get(item.id)!
|
|
606
|
+
const itemDx = bound.left + dx - item.bounds.left
|
|
607
|
+
const itemDy = bound.top + dy - item.bounds.top
|
|
608
|
+
for (const nodeId of item.nodeIds) translations.set(nodeId, { dx: itemDx, dy: itemDy })
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
let groupOffset = { x: 0, y: 0 }
|
|
612
|
+
if (targetWidth !== undefined) {
|
|
613
|
+
const localNodeIds = new Set(translations.keys())
|
|
614
|
+
const external = [...nodeBounds.entries()].filter(([id]) => !localNodeIds.has(id)).map(([, bound]) => bound)
|
|
615
|
+
const overlaps = (offset: FlowchartPoint) =>
|
|
616
|
+
[...translations].some(([id, translation]) => {
|
|
617
|
+
const bound = nodeBounds.get(id)!
|
|
618
|
+
const left = bound.left + translation.dx + offset.x
|
|
619
|
+
const top = bound.top + translation.dy + offset.y
|
|
620
|
+
return external.some(
|
|
621
|
+
(other) =>
|
|
622
|
+
left < other.left + other.width + minNodeGap &&
|
|
623
|
+
left + bound.width + minNodeGap > other.left &&
|
|
624
|
+
top < other.top + other.height + minNodeGap &&
|
|
625
|
+
top + bound.height + minNodeGap > other.top,
|
|
626
|
+
)
|
|
627
|
+
})
|
|
628
|
+
if (overlaps(groupOffset)) {
|
|
629
|
+
const vertical = !isHorizontalDirection(diagram.direction)
|
|
630
|
+
const sign = diagram.direction === "RL" || diagram.direction === "BT" ? -1 : 1
|
|
631
|
+
let found = false
|
|
632
|
+
search: for (let distance = 1; distance < 1_000; distance++) {
|
|
633
|
+
const candidates = vertical
|
|
634
|
+
? [
|
|
635
|
+
{ x: 0, y: sign * distance },
|
|
636
|
+
{ x: 0, y: -sign * distance },
|
|
637
|
+
{ x: distance, y: 0 },
|
|
638
|
+
{ x: -distance, y: 0 },
|
|
639
|
+
]
|
|
640
|
+
: [
|
|
641
|
+
{ x: sign * distance, y: 0 },
|
|
642
|
+
{ x: -sign * distance, y: 0 },
|
|
643
|
+
{ x: 0, y: distance },
|
|
644
|
+
{ x: 0, y: -distance },
|
|
645
|
+
]
|
|
646
|
+
for (const candidate of candidates) {
|
|
647
|
+
if (overlaps(candidate)) continue
|
|
648
|
+
groupOffset = candidate
|
|
649
|
+
found = true
|
|
650
|
+
break search
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
if (!found) throw new Error(`Subgraph ${subgraph.id} has no collision-free responsive position`)
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
for (const [nodeId, translation] of translations) {
|
|
658
|
+
const nodeBound = nodeBounds.get(nodeId)
|
|
659
|
+
if (nodeBound) translateBounds(nodeBound, translation.dx + groupOffset.x, translation.dy + groupOffset.y)
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
return wrapped
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function edgeDirection(diagram: FlowchartDiagram, edge: FlowchartEdge): FlowchartDirection {
|
|
666
|
+
for (const subgraph of [...(diagram.subgraphs ?? [])].reverse()) {
|
|
667
|
+
if (!subgraph.direction) continue
|
|
668
|
+
if (subgraph.nodeIds.includes(edge.from) && subgraph.nodeIds.includes(edge.to)) return subgraph.direction
|
|
669
|
+
}
|
|
670
|
+
return diagram.direction
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function collectSubgraphNodeIds(diagram: FlowchartDiagram, subgraphId: string): Set<string> {
|
|
674
|
+
const nodeIds = new Set<string>()
|
|
675
|
+
for (const subgraph of diagram.subgraphs ?? []) {
|
|
676
|
+
if (subgraph.id !== subgraphId && subgraph.parentId !== subgraphId) continue
|
|
677
|
+
for (const nodeId of subgraph.nodeIds) nodeIds.add(nodeId)
|
|
678
|
+
if (subgraph.parentId === subgraphId) {
|
|
679
|
+
for (const nodeId of collectSubgraphNodeIds(diagram, subgraph.id)) nodeIds.add(nodeId)
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
return nodeIds
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function rankGraphComponents(
|
|
686
|
+
ids: readonly string[],
|
|
687
|
+
outgoing: ReadonlyMap<string, ReadonlySet<string>>,
|
|
688
|
+
): Map<string, number> {
|
|
689
|
+
const reachable = new Map<string, Set<string>>()
|
|
690
|
+
for (const id of ids) {
|
|
691
|
+
const seen = new Set<string>()
|
|
692
|
+
const queue = [id]
|
|
693
|
+
for (let index = 0; index < queue.length; index++) {
|
|
694
|
+
const current = queue[index]!
|
|
695
|
+
if (seen.has(current)) continue
|
|
696
|
+
seen.add(current)
|
|
697
|
+
queue.push(...(outgoing.get(current) ?? []))
|
|
698
|
+
}
|
|
699
|
+
reachable.set(id, seen)
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const componentById = new Map<string, number>()
|
|
703
|
+
const components: string[][] = []
|
|
704
|
+
for (const id of ids) {
|
|
705
|
+
if (componentById.has(id)) continue
|
|
706
|
+
const component = ids.filter(
|
|
707
|
+
(candidate) =>
|
|
708
|
+
!componentById.has(candidate) && reachable.get(id)!.has(candidate) && reachable.get(candidate)!.has(id),
|
|
709
|
+
)
|
|
710
|
+
const componentIndex = components.length
|
|
711
|
+
components.push(component)
|
|
712
|
+
for (const member of component) componentById.set(member, componentIndex)
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const componentOutgoing = new Map(components.map((_, index) => [index, new Set<number>()]))
|
|
716
|
+
const incoming = new Map(components.map((_, index) => [index, 0]))
|
|
717
|
+
for (const [from, targets] of outgoing) {
|
|
718
|
+
const fromComponent = componentById.get(from)!
|
|
719
|
+
for (const to of targets) {
|
|
720
|
+
const toComponent = componentById.get(to)!
|
|
721
|
+
if (fromComponent === toComponent || componentOutgoing.get(fromComponent)!.has(toComponent)) continue
|
|
722
|
+
componentOutgoing.get(fromComponent)!.add(toComponent)
|
|
723
|
+
incoming.set(toComponent, incoming.get(toComponent)! + 1)
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const componentRanks = new Map<number, number>()
|
|
728
|
+
const queue = components.map((_, index) => index).filter((index) => incoming.get(index) === 0)
|
|
729
|
+
for (const component of queue) componentRanks.set(component, 0)
|
|
730
|
+
for (let index = 0; index < queue.length; index++) {
|
|
731
|
+
const component = queue[index]!
|
|
732
|
+
for (const to of componentOutgoing.get(component)!) {
|
|
733
|
+
componentRanks.set(to, Math.max(componentRanks.get(to) ?? 0, componentRanks.get(component)! + 1))
|
|
734
|
+
incoming.set(to, incoming.get(to)! - 1)
|
|
735
|
+
if (incoming.get(to) === 0) queue.push(to)
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
return new Map(ids.map((id) => [id, componentRanks.get(componentById.get(id)!) ?? 0]))
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function separateTopLevelItems(
|
|
743
|
+
diagram: FlowchartDiagram,
|
|
744
|
+
nodeBounds: Map<string, FlowchartNodeBounds>,
|
|
745
|
+
subgraphBounds: ReadonlyMap<string, FlowchartSubgraphBounds>,
|
|
746
|
+
gap: number,
|
|
747
|
+
targetWidth?: number,
|
|
748
|
+
): boolean {
|
|
749
|
+
const hasLocalDirection = (diagram.subgraphs ?? []).some(
|
|
750
|
+
(subgraph) => subgraph.direction && subgraph.direction !== diagram.direction,
|
|
751
|
+
)
|
|
752
|
+
const coveredNodeIds = new Set<string>()
|
|
753
|
+
const items: {
|
|
754
|
+
id: string
|
|
755
|
+
bounds: FlowchartBounds
|
|
756
|
+
nodeIds: Set<string>
|
|
757
|
+
rank: number
|
|
758
|
+
}[] = []
|
|
759
|
+
const itemByEndpoint = new Map<string, string>()
|
|
760
|
+
const subgraphs = diagram.subgraphs ?? []
|
|
761
|
+
const topLevelIds = new Set(subgraphs.filter((subgraph) => !subgraph.parentId).map((subgraph) => subgraph.id))
|
|
762
|
+
const subgraphById = new Map(subgraphs.map((subgraph) => [subgraph.id, subgraph]))
|
|
763
|
+
const topLevelSubgraphId = (id: string): string => {
|
|
764
|
+
let current = subgraphById.get(id)
|
|
765
|
+
while (current?.parentId) current = subgraphById.get(current.parentId)
|
|
766
|
+
return current?.id ?? id
|
|
767
|
+
}
|
|
768
|
+
for (const subgraph of subgraphs) {
|
|
769
|
+
if (subgraph.parentId) continue
|
|
770
|
+
const bounds = subgraphBounds.get(subgraph.id)
|
|
771
|
+
const nodeIds = collectSubgraphNodeIds(diagram, subgraph.id)
|
|
772
|
+
if (!bounds || nodeIds.size === 0) continue
|
|
773
|
+
items.push({ id: subgraph.id, bounds, nodeIds, rank: 0 })
|
|
774
|
+
itemByEndpoint.set(subgraph.id, subgraph.id)
|
|
775
|
+
for (const nodeId of nodeIds) {
|
|
776
|
+
coveredNodeIds.add(nodeId)
|
|
777
|
+
itemByEndpoint.set(nodeId, subgraph.id)
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
for (const subgraph of subgraphs) itemByEndpoint.set(subgraph.id, topLevelSubgraphId(subgraph.id))
|
|
781
|
+
|
|
782
|
+
for (const node of diagram.nodes) {
|
|
783
|
+
if (coveredNodeIds.has(node.id)) continue
|
|
784
|
+
const bounds = nodeBounds.get(node.id)
|
|
785
|
+
if (!bounds) continue
|
|
786
|
+
items.push({ id: node.id, bounds, nodeIds: new Set([node.id]), rank: 0 })
|
|
787
|
+
itemByEndpoint.set(node.id, node.id)
|
|
788
|
+
}
|
|
789
|
+
if (items.length < 2) return false
|
|
790
|
+
|
|
791
|
+
const horizontal = isHorizontalDirection(diagram.direction)
|
|
792
|
+
const moveItem = (item: (typeof items)[number], dx: number, dy: number): void => {
|
|
793
|
+
for (const nodeId of item.nodeIds) {
|
|
794
|
+
const bounds = nodeBounds.get(nodeId)
|
|
795
|
+
if (bounds) translateBounds(bounds, dx, dy)
|
|
796
|
+
}
|
|
797
|
+
if (!topLevelIds.has(item.id)) return
|
|
798
|
+
for (const subgraph of subgraphs) {
|
|
799
|
+
if (topLevelSubgraphId(subgraph.id) !== item.id) continue
|
|
800
|
+
const bounds = subgraphBounds.get(subgraph.id)
|
|
801
|
+
if (bounds) translateBounds(bounds, dx, dy)
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
if (hasLocalDirection) {
|
|
805
|
+
const outgoing = new Map(items.map((item) => [item.id, new Set<string>()]))
|
|
806
|
+
for (const edge of diagram.edges) {
|
|
807
|
+
const from = itemByEndpoint.get(edge.from)
|
|
808
|
+
const to = itemByEndpoint.get(edge.to)
|
|
809
|
+
if (from && to && from !== to) outgoing.get(from)?.add(to)
|
|
810
|
+
}
|
|
811
|
+
const ranks = rankGraphComponents(
|
|
812
|
+
items.map((item) => item.id),
|
|
813
|
+
outgoing,
|
|
814
|
+
)
|
|
815
|
+
items.sort(
|
|
816
|
+
(a, b) =>
|
|
817
|
+
ranks.get(a.id)! - ranks.get(b.id)! ||
|
|
818
|
+
(horizontal ? a.bounds.centerX - b.bounds.centerX : a.bounds.centerY - b.bounds.centerY),
|
|
819
|
+
)
|
|
820
|
+
let cursor: number | undefined
|
|
821
|
+
let moved = false
|
|
822
|
+
for (const item of items) {
|
|
823
|
+
const start = horizontal ? item.bounds.left : item.bounds.top
|
|
824
|
+
const size = horizontal ? item.bounds.width : item.bounds.height
|
|
825
|
+
if (cursor === undefined) {
|
|
826
|
+
cursor = start + size + gap
|
|
827
|
+
continue
|
|
828
|
+
}
|
|
829
|
+
const shift = cursor - start
|
|
830
|
+
moved ||= shift !== 0
|
|
831
|
+
moveItem(item, horizontal ? shift : 0, horizontal ? 0 : shift)
|
|
832
|
+
cursor = start + shift + size + gap
|
|
833
|
+
}
|
|
834
|
+
return moved
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const rankedItems = items.filter((item) => topLevelIds.has(item.id))
|
|
838
|
+
if (rankedItems.length < 2) return false
|
|
839
|
+
|
|
840
|
+
const itemById = new Map(rankedItems.map((item) => [item.id, item]))
|
|
841
|
+
const outgoing = new Map(rankedItems.map((item) => [item.id, new Set<string>()]))
|
|
842
|
+
for (const edge of diagram.edges) {
|
|
843
|
+
const from = itemByEndpoint.get(edge.from)
|
|
844
|
+
const to = itemByEndpoint.get(edge.to)
|
|
845
|
+
if (!from || !to || from === to || !itemById.has(from) || !itemById.has(to) || outgoing.get(from)!.has(to)) continue
|
|
846
|
+
outgoing.get(from)!.add(to)
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
const ranks = rankGraphComponents(
|
|
850
|
+
rankedItems.map((item) => item.id),
|
|
851
|
+
outgoing,
|
|
852
|
+
)
|
|
853
|
+
for (const item of rankedItems) item.rank = ranks.get(item.id)!
|
|
854
|
+
|
|
855
|
+
const reversed = diagram.direction === "RL" || diagram.direction === "BT"
|
|
856
|
+
const primaryStart = (item: (typeof items)[number]): number => {
|
|
857
|
+
const start = horizontal ? item.bounds.left : item.bounds.top
|
|
858
|
+
const size = horizontal ? item.bounds.width : item.bounds.height
|
|
859
|
+
return reversed ? -(start + size) : start
|
|
860
|
+
}
|
|
861
|
+
const itemsByRank = Map.groupBy(rankedItems, (item) => item.rank)
|
|
862
|
+
const rankKeys = [...itemsByRank.keys()].sort((a, b) => a - b)
|
|
863
|
+
let cursor: number | undefined
|
|
864
|
+
let moved = false
|
|
865
|
+
for (const rank of rankKeys) {
|
|
866
|
+
const rankItems = itemsByRank.get(rank)!
|
|
867
|
+
const start = Math.min(...rankItems.map(primaryStart))
|
|
868
|
+
const end = Math.max(
|
|
869
|
+
...rankItems.map((item) => primaryStart(item) + (horizontal ? item.bounds.width : item.bounds.height)),
|
|
870
|
+
)
|
|
871
|
+
if (cursor === undefined) {
|
|
872
|
+
cursor = end + gap
|
|
873
|
+
continue
|
|
874
|
+
}
|
|
875
|
+
const shift = Math.max(0, cursor - start)
|
|
876
|
+
if (shift > 0) {
|
|
877
|
+
moved = true
|
|
878
|
+
for (const item of rankItems) {
|
|
879
|
+
const offset = reversed ? -shift : shift
|
|
880
|
+
moveItem(item, horizontal ? offset : 0, horizontal ? 0 : offset)
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
cursor = end + shift + gap
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
for (const rank of rankKeys) {
|
|
887
|
+
const rankItems = itemsByRank
|
|
888
|
+
.get(rank)!
|
|
889
|
+
.toSorted((a, b) => (horizontal ? a.bounds.top - b.bounds.top : a.bounds.left - b.bounds.left))
|
|
890
|
+
let crossCursor: number | undefined
|
|
891
|
+
for (const item of rankItems) {
|
|
892
|
+
const start = horizontal ? item.bounds.top : item.bounds.left
|
|
893
|
+
const size = horizontal ? item.bounds.height : item.bounds.width
|
|
894
|
+
if (crossCursor === undefined) {
|
|
895
|
+
crossCursor = start + size + gap
|
|
896
|
+
continue
|
|
897
|
+
}
|
|
898
|
+
const shift = Math.max(0, crossCursor - start)
|
|
899
|
+
if (shift > 0) {
|
|
900
|
+
moved = true
|
|
901
|
+
moveItem(item, horizontal ? 0 : shift, horizontal ? shift : 0)
|
|
902
|
+
}
|
|
903
|
+
crossCursor = start + shift + size + gap
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
if (targetWidth !== undefined && !horizontal) {
|
|
907
|
+
const intersects = (left: (typeof items)[number], right: (typeof items)[number]): boolean =>
|
|
908
|
+
[...left.nodeIds].some((leftId) => {
|
|
909
|
+
const leftBounds = nodeBounds.get(leftId)!
|
|
910
|
+
return [...right.nodeIds].some((rightId) => {
|
|
911
|
+
const rightBounds = nodeBounds.get(rightId)!
|
|
912
|
+
return (
|
|
913
|
+
leftBounds.left <= rightBounds.left + rightBounds.width - 1 &&
|
|
914
|
+
leftBounds.left + leftBounds.width - 1 >= rightBounds.left &&
|
|
915
|
+
leftBounds.top <= rightBounds.top + rightBounds.height - 1 &&
|
|
916
|
+
leftBounds.top + leftBounds.height - 1 >= rightBounds.top
|
|
917
|
+
)
|
|
918
|
+
})
|
|
919
|
+
})
|
|
920
|
+
for (let rightIndex = 1; rightIndex < items.length; rightIndex++) {
|
|
921
|
+
const right = items[rightIndex]!
|
|
922
|
+
for (let leftIndex = 0; leftIndex < rightIndex; leftIndex++) {
|
|
923
|
+
const left = items[leftIndex]!
|
|
924
|
+
if (!intersects(left, right)) continue
|
|
925
|
+
const leftBounds = boundsFromChildren([...left.nodeIds].map((id) => nodeBounds.get(id)!))!
|
|
926
|
+
const rightBounds = boundsFromChildren([...right.nodeIds].map((id) => nodeBounds.get(id)!))!
|
|
927
|
+
const shift = reversed
|
|
928
|
+
? leftBounds.top - gap - (rightBounds.top + rightBounds.height)
|
|
929
|
+
: leftBounds.top + leftBounds.height + gap - rightBounds.top
|
|
930
|
+
moved ||= shift !== 0
|
|
931
|
+
moveItem(right, 0, shift)
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
return moved
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function layoutSubgraphs(
|
|
939
|
+
diagram: FlowchartDiagram,
|
|
940
|
+
nodeBounds: Map<string, FlowchartNodeBounds>,
|
|
941
|
+
routes: readonly FlowchartEdgeRoute[],
|
|
942
|
+
): Map<string, FlowchartSubgraphBounds> {
|
|
943
|
+
const subgraphBounds = new Map<string, FlowchartSubgraphBounds>()
|
|
944
|
+
const subgraphs = diagram.subgraphs ?? []
|
|
945
|
+
|
|
946
|
+
for (const subgraph of [...subgraphs].reverse()) {
|
|
947
|
+
const children: FlowchartBounds[] = []
|
|
948
|
+
for (const nodeId of subgraph.nodeIds) {
|
|
949
|
+
const bound = nodeBounds.get(nodeId)
|
|
950
|
+
if (bound) children.push(bound)
|
|
951
|
+
}
|
|
952
|
+
children.push(...subgraphRouteBounds(new Set(subgraph.nodeIds), routes))
|
|
953
|
+
for (const childSubgraph of subgraphs) {
|
|
954
|
+
if (childSubgraph.parentId !== subgraph.id) continue
|
|
955
|
+
const bound = subgraphBounds.get(childSubgraph.id)
|
|
956
|
+
if (bound) children.push(bound)
|
|
957
|
+
}
|
|
958
|
+
if (children.length > 0) {
|
|
959
|
+
const bound = subgraphBoundFromChildren(subgraph.id, subgraph.label, children)
|
|
960
|
+
bound.labelSide = chooseSubgraphLabelSide(bound, routes)
|
|
961
|
+
subgraphBounds.set(subgraph.id, bound)
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
return subgraphBounds
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
function layoutFlowchartWithDirection(
|
|
969
|
+
sourceDiagram: FlowchartDiagram,
|
|
970
|
+
options: FlowchartDiagramRenderOptions,
|
|
971
|
+
direction: FlowchartDirection,
|
|
972
|
+
responsiveFallback = false,
|
|
973
|
+
): FlowchartLayout {
|
|
974
|
+
const diagram = direction === sourceDiagram.direction ? sourceDiagram : { ...sourceDiagram, direction }
|
|
975
|
+
const horizontal = isHorizontalDirection(direction)
|
|
976
|
+
const minNodeGap = normalizePositiveInt(options.minNodeGap, DEFAULT_MIN_NODE_GAP)
|
|
977
|
+
const requestedMinRankGap = normalizePositiveInt(
|
|
978
|
+
options.minRankGap,
|
|
979
|
+
options.compact
|
|
980
|
+
? horizontal
|
|
981
|
+
? COMPACT_MIN_RANK_GAP
|
|
982
|
+
: COMPACT_MIN_VERTICAL_RANK_GAP
|
|
983
|
+
: horizontal
|
|
984
|
+
? DEFAULT_MIN_RANK_GAP
|
|
985
|
+
: DEFAULT_MIN_VERTICAL_RANK_GAP,
|
|
986
|
+
)
|
|
987
|
+
const sizes = new Map(diagram.nodes.map((node) => [node.id, nodeSize(node)]))
|
|
988
|
+
const targetWidth =
|
|
989
|
+
!horizontal && options.layoutMaxWidth !== undefined && Number.isFinite(options.layoutMaxWidth)
|
|
990
|
+
? Math.max(1, Math.trunc(options.layoutMaxWidth))
|
|
991
|
+
: undefined
|
|
992
|
+
const ranked = layoutRankedNodes(diagram, direction, sizes, minNodeGap, requestedMinRankGap, targetWidth)
|
|
993
|
+
const bounds = ranked.bounds
|
|
994
|
+
const responsive = layoutLocalSubgraphDirections(diagram, bounds, minNodeGap, requestedMinRankGap, targetWidth)
|
|
995
|
+
const directionAligned = responsiveFallback || responsive || ranked.wrapped
|
|
996
|
+
const route = (frames?: ReadonlyMap<string, FlowchartSubgraphBounds>) =>
|
|
997
|
+
routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge), frames, targetWidth, directionAligned)
|
|
998
|
+
|
|
999
|
+
const subgraphs = diagram.subgraphs ?? []
|
|
1000
|
+
let subgraphBounds = new Map<string, FlowchartSubgraphBounds>()
|
|
1001
|
+
let routes: FlowchartEdgeRoute[]
|
|
1002
|
+
if (subgraphs.length === 0) {
|
|
1003
|
+
routes = route()
|
|
1004
|
+
} else {
|
|
1005
|
+
routes = route()
|
|
1006
|
+
subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
|
|
1007
|
+
const moved = separateTopLevelItems(
|
|
1008
|
+
diagram,
|
|
1009
|
+
bounds,
|
|
1010
|
+
subgraphBounds,
|
|
1011
|
+
Math.max(1, Math.floor(requestedMinRankGap / 2)),
|
|
1012
|
+
targetWidth,
|
|
1013
|
+
)
|
|
1014
|
+
if (moved) {
|
|
1015
|
+
routes = route()
|
|
1016
|
+
subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
|
|
1017
|
+
}
|
|
1018
|
+
routes = route(subgraphBounds)
|
|
1019
|
+
subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
|
|
1020
|
+
// Frame-aware routes can expand a group after initial separation. Move the expanded frame rigidly
|
|
1021
|
+
// before rerouting so external edges cannot grow it back across neighboring top-level items.
|
|
1022
|
+
const expanded = separateTopLevelItems(
|
|
1023
|
+
diagram,
|
|
1024
|
+
bounds,
|
|
1025
|
+
subgraphBounds,
|
|
1026
|
+
Math.max(1, Math.floor(requestedMinRankGap / 2)),
|
|
1027
|
+
targetWidth,
|
|
1028
|
+
)
|
|
1029
|
+
if (expanded) routes = route(subgraphBounds)
|
|
1030
|
+
avoidFlowchartFrameBorders(routes, bounds, subgraphBounds)
|
|
1031
|
+
}
|
|
1032
|
+
freezeRouteLabelPoints(routes)
|
|
1033
|
+
const allBounds = [...bounds.values(), ...subgraphBounds.values(), ...routeRenderBounds(routes)]
|
|
1034
|
+
const dx = Math.max(0, -Math.min(0, ...allBounds.map((bound) => bound.left)))
|
|
1035
|
+
const dy = Math.max(0, -Math.min(0, ...allBounds.map((bound) => bound.top)))
|
|
1036
|
+
if (dx > 0 || dy > 0) {
|
|
1037
|
+
for (const bound of allBounds) translateBounds(bound, dx, dy)
|
|
1038
|
+
translateRoutes(routes, dx, dy)
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
const maxX = Math.max(0, ...allBounds.map((bound) => bound.left + bound.width))
|
|
1042
|
+
const maxY = Math.max(0, ...allBounds.map((bound) => bound.top + bound.height))
|
|
1043
|
+
return { diagram, bounds, routes, subgraphBounds, width: maxX + 4, height: maxY + 4 }
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
export function layoutFlowchartDiagram(
|
|
1047
|
+
sourceDiagram: FlowchartDiagram,
|
|
1048
|
+
options: FlowchartDiagramRenderOptions = {},
|
|
1049
|
+
): FlowchartLayout {
|
|
1050
|
+
const direction = options.direction ?? sourceDiagram.direction
|
|
1051
|
+
const layout = layoutFlowchartWithDirection(sourceDiagram, options, direction)
|
|
1052
|
+
const maxWidth = options.layoutMaxWidth
|
|
1053
|
+
if (!isHorizontalDirection(direction) || maxWidth === undefined || !Number.isFinite(maxWidth)) return layout
|
|
1054
|
+
if (layout.width <= Math.max(1, Math.trunc(maxWidth))) return layout
|
|
1055
|
+
|
|
1056
|
+
return layoutFlowchartWithDirection(sourceDiagram, options, direction === "RL" ? "BT" : "TD", true)
|
|
1057
|
+
}
|