@zombie-mermaid/svg-renderer 3.2.0 → 4.0.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/dist/index.cjs +45 -41
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +34 -4
- package/dist/index.d.ts +34 -4
- package/dist/index.js +2310 -1115
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/c4-label-clearance-1290.test.ts +112 -0
- package/src/__tests__/c4-line-ends-1208.test.ts +52 -0
- package/src/__tests__/c4-mermaid-reference.test.ts +56 -17
- package/src/__tests__/c4-text-width-1210.test.ts +43 -0
- package/src/__tests__/class-component-order-1249.test.ts +40 -0
- package/src/__tests__/class-member-right-padding-1238.test.ts +43 -0
- package/src/__tests__/class-namespace-1196.test.ts +165 -0
- package/src/__tests__/class-namespace-sparse-elk-1196.test.ts +96 -0
- package/src/__tests__/class-visibility-markers-1237.test.ts +27 -0
- package/src/__tests__/er-attribute-right-padding-1262.test.ts +47 -0
- package/src/__tests__/er-default-direction-1250.test.ts +69 -0
- package/src/__tests__/er-edge-label-contrast-1244.test.ts +58 -0
- package/src/__tests__/sequence-label-clearance-1242.test.ts +162 -0
- package/src/__tests__/sequence-nested-activation-1241.test.ts +37 -0
- package/src/__tests__/xychart-x-label-overlap-1243.test.ts +62 -0
- package/src/c4/arial-widths.ts +59 -0
- package/src/c4/layout.ts +234 -44
- package/src/c4/metrics.ts +56 -30
- package/src/c4/renderer.ts +1 -1
- package/src/class/layout.ts +178 -13
- package/src/class/renderer.ts +31 -2
- package/src/er/layout.ts +20 -4
- package/src/er/renderer.ts +10 -1
- package/src/layout-engine/back-edges.ts +73 -0
- package/src/layout-engine/compound-flat.ts +916 -0
- package/src/layout-engine/elk-graph-builder.ts +5 -4
- package/src/layout-engine/from-elk.ts +7 -2
- package/src/layout-engine/inner-edges.ts +329 -0
- package/src/layout-engine/layout-hints.ts +27 -0
- package/src/layout-engine/to-elk.ts +170 -20
- package/src/layout-engine.ts +38 -1
- package/src/registry.ts +2 -2
- package/src/renderer.ts +26 -2
- package/src/sequence/layout.ts +72 -4
- package/src/sequence/renderer.ts +155 -15
- package/src/title-gaps.ts +146 -0
- package/src/xychart/layout.ts +31 -2
- package/src/xychart/renderer.ts +1 -0
|
@@ -0,0 +1,916 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layout for a flowchart with subgraphs, arranged the way mermaid.js arranges it.
|
|
3
|
+
*
|
|
4
|
+
* ELK lays a subgraph's contents out as a block before the nodes around it, so
|
|
5
|
+
* a node outside the subgraph can never sit beside a node deep inside it, and
|
|
6
|
+
* an edge from outside can't pull a member down to a later layer. mermaid.js
|
|
7
|
+
* (dagre) lays the whole graph out flat, ranks every node on its own, and draws
|
|
8
|
+
* each subgraph as a box around its members, with the nodes that don't belong
|
|
9
|
+
* to it placed beside the box. In the CI/CD sample, `Deploy Staging`,
|
|
10
|
+
* `QA Approved?` and `Production` form a column to the left of the pipeline's
|
|
11
|
+
* box, and `Fix & Retry` sits at the bottom of the box, level with `Production`.
|
|
12
|
+
*
|
|
13
|
+
* This does the same with ELK:
|
|
14
|
+
*
|
|
15
|
+
* 1. Lay the graph out flat (no subgraphs) to learn the layers and the order of
|
|
16
|
+
* the nodes within them.
|
|
17
|
+
* 2. Keep each subgraph's box clear of the nodes beside it. In a layer between
|
|
18
|
+
* a subgraph's first and last member where it has no member, add an
|
|
19
|
+
* invisible *spine* node, chained to the subgraph's members in the layers
|
|
20
|
+
* on either side. It stands for the box in that layer, so ELK keeps the
|
|
21
|
+
* other nodes of the layer out of the box's way.
|
|
22
|
+
* 3. Give ELK the nodes in a left-to-right order that keeps each subgraph's
|
|
23
|
+
* nodes together, with each outsider on the side of the subgraph its
|
|
24
|
+
* connections already put it on, and lay it out again.
|
|
25
|
+
* 4. Draw each subgraph's box around its members. If a box would still cover a
|
|
26
|
+
* node that isn't a member, widen that subgraph's spine nodes (they start
|
|
27
|
+
* narrow) and lay out again.
|
|
28
|
+
*
|
|
29
|
+
* The spine nodes and their edges are layout scaffolding: they are dropped from
|
|
30
|
+
* the result. If the boxes can't be made to clear the other nodes, `undefined`
|
|
31
|
+
* is returned and the caller uses the nested layout instead.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import type {
|
|
35
|
+
Direction,
|
|
36
|
+
MermaidEdge,
|
|
37
|
+
MermaidGraph,
|
|
38
|
+
MermaidNode,
|
|
39
|
+
MermaidSubgraph,
|
|
40
|
+
PositionedEdge,
|
|
41
|
+
Point,
|
|
42
|
+
PositionedGraph,
|
|
43
|
+
PositionedGroup,
|
|
44
|
+
PositionedNode,
|
|
45
|
+
} from '@zombie-mermaid/core'
|
|
46
|
+
import { measureMultilineText } from '@zombie-mermaid/core'
|
|
47
|
+
import type { FontSizes } from '../styles.ts'
|
|
48
|
+
import { ARROW_HEAD, FONT_WEIGHTS } from '../styles.ts'
|
|
49
|
+
import { DEFAULTS } from './constants.ts'
|
|
50
|
+
import { resolveEdgeStyle } from './from-elk.ts'
|
|
51
|
+
import { labelSpot, routeInnerEdge } from './inner-edges.ts'
|
|
52
|
+
import type { LayoutHints } from './layout-hints.ts'
|
|
53
|
+
import {
|
|
54
|
+
SUBGRAPH_PADDING,
|
|
55
|
+
hasAnyDirectionOverride,
|
|
56
|
+
isStateGraph,
|
|
57
|
+
} from './to-elk.ts'
|
|
58
|
+
|
|
59
|
+
/** Lays a graph out, with optional hints; the engine's `layoutGraphSync` bound to its options. */
|
|
60
|
+
export type LayoutFn = (
|
|
61
|
+
graph: MermaidGraph,
|
|
62
|
+
hints?: LayoutHints,
|
|
63
|
+
) => PositionedGraph
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Room two boxes (or a box and a node) that are too close ask for: `amount` px
|
|
67
|
+
* more along `axis`, opened at `after`, where the first of them ends.
|
|
68
|
+
*/
|
|
69
|
+
interface RoomRequest {
|
|
70
|
+
axis: 'x' | 'y'
|
|
71
|
+
amount: number
|
|
72
|
+
after: number
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Prefix of the ids of the spine nodes, which no real node may share. */
|
|
76
|
+
const SPINE_PREFIX = '__zm_spine__'
|
|
77
|
+
|
|
78
|
+
/** How narrow a spine node starts, in px; it is widened only as far as needed. */
|
|
79
|
+
const SPINE_MIN_WIDTH = 8
|
|
80
|
+
|
|
81
|
+
/** How many times the spine nodes are widened before giving up. */
|
|
82
|
+
const MAX_WIDENINGS = 6
|
|
83
|
+
|
|
84
|
+
/** How many times room is opened between boxes that sit too close. */
|
|
85
|
+
const MAX_ROOM_STEPS = 12
|
|
86
|
+
|
|
87
|
+
/** Slack added to the room two boxes ask for, in px. */
|
|
88
|
+
const SPACING_SLACK = 4
|
|
89
|
+
|
|
90
|
+
/** Slack added each time a spine node is widened, in px. */
|
|
91
|
+
const WIDEN_SLACK = 4
|
|
92
|
+
|
|
93
|
+
/** Px a neighbouring node moves per px a spine node is widened, until measured. */
|
|
94
|
+
const ASSUMED_SLOPE = 0.25
|
|
95
|
+
|
|
96
|
+
/** A measured slope below this is noise, and the assumed one is used. */
|
|
97
|
+
const MIN_SLOPE = 0.02
|
|
98
|
+
|
|
99
|
+
/** Space kept between a box and the nodes and boxes around it, in px. */
|
|
100
|
+
const BOX_CLEARANCE = 40
|
|
101
|
+
|
|
102
|
+
/** Left and right room the title text needs inside its box, in px. */
|
|
103
|
+
const TITLE_INSET = 12
|
|
104
|
+
|
|
105
|
+
type Box = { x: number; y: number; width: number; height: number }
|
|
106
|
+
|
|
107
|
+
const isVertical = (direction: Direction): boolean =>
|
|
108
|
+
direction === 'TD' || direction === 'TB' || direction === 'BT'
|
|
109
|
+
|
|
110
|
+
const isSpine = (id: string): boolean => id.startsWith(SPINE_PREFIX)
|
|
111
|
+
|
|
112
|
+
function flatten(subgraphs: MermaidSubgraph[]): MermaidSubgraph[] {
|
|
113
|
+
return subgraphs.flatMap((sg) => [sg, ...flatten(sg.children)])
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Every node id in `sg`, including those of its nested subgraphs. */
|
|
117
|
+
function membersOf(sg: MermaidSubgraph): Set<string> {
|
|
118
|
+
const out = new Set<string>(sg.nodeIds)
|
|
119
|
+
for (const child of sg.children) {
|
|
120
|
+
for (const id of membersOf(child)) out.add(id)
|
|
121
|
+
}
|
|
122
|
+
return out
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ============================================================================
|
|
126
|
+
// Whether to use it
|
|
127
|
+
// ============================================================================
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Whether `graph` can be laid out this way: it has subgraphs, and nothing in it
|
|
131
|
+
* needs the nested layout. A subgraph with its own direction is laid out as its
|
|
132
|
+
* own graph, which this doesn't do; state diagrams have their own handling of
|
|
133
|
+
* composite states; an edge to a subgraph (not a node) has no node to attach
|
|
134
|
+
* to, and the parser gives it a node with the subgraph's id; and a subgraph
|
|
135
|
+
* with no node in it has nothing to draw a box around.
|
|
136
|
+
*/
|
|
137
|
+
export function canLayOutFlat(graph: MermaidGraph): boolean {
|
|
138
|
+
if (graph.subgraphs.length === 0) return false
|
|
139
|
+
if (isStateGraph(graph) || hasAnyDirectionOverride(graph.subgraphs)) {
|
|
140
|
+
return false
|
|
141
|
+
}
|
|
142
|
+
for (const id of graph.nodes.keys()) if (isSpine(id)) return false
|
|
143
|
+
for (const edge of graph.edges) {
|
|
144
|
+
if (!graph.nodes.has(edge.source) || !graph.nodes.has(edge.target)) {
|
|
145
|
+
return false
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const clusters = flatten(graph.subgraphs)
|
|
149
|
+
// An edge to a subgraph comes out of the parser as an edge to a node that
|
|
150
|
+
// has the subgraph's id; the nested layout points it at the subgraph's box.
|
|
151
|
+
if (clusters.some((sg) => graph.nodes.has(sg.id))) return false
|
|
152
|
+
return clusters.every((sg) =>
|
|
153
|
+
[...membersOf(sg)].some((id) => graph.nodes.has(id)),
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ============================================================================
|
|
158
|
+
// Layers
|
|
159
|
+
// ============================================================================
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The layer of each node of a laid-out graph, counted from the start of the
|
|
163
|
+
* flow. The nodes of one layer overlap along the flow axis (their centres or
|
|
164
|
+
* their start edges line up, depending on the direction), and the next layer
|
|
165
|
+
* starts after the shortest of them ends.
|
|
166
|
+
*/
|
|
167
|
+
export function layerIndexes(
|
|
168
|
+
nodes: ReadonlyArray<Box & { id: string }>,
|
|
169
|
+
direction: Direction,
|
|
170
|
+
): Map<string, number> {
|
|
171
|
+
const vertical = isVertical(direction)
|
|
172
|
+
const span = (n: Box): [number, number] =>
|
|
173
|
+
vertical ? [n.y, n.y + n.height] : [n.x, n.x + n.width]
|
|
174
|
+
const sorted = [...nodes].sort((a, b) => {
|
|
175
|
+
const [a0, a1] = span(a)
|
|
176
|
+
const [b0, b1] = span(b)
|
|
177
|
+
return (a0 + a1) / 2 - (b0 + b1) / 2
|
|
178
|
+
})
|
|
179
|
+
const layers: Array<{ end: number; ids: string[] }> = []
|
|
180
|
+
for (const node of sorted) {
|
|
181
|
+
const [start, end] = span(node)
|
|
182
|
+
const current = layers[layers.length - 1]
|
|
183
|
+
if (current && start < current.end) {
|
|
184
|
+
current.end = Math.min(current.end, end)
|
|
185
|
+
current.ids.push(node.id)
|
|
186
|
+
} else {
|
|
187
|
+
layers.push({ end, ids: [node.id] })
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (direction === 'BT' || direction === 'RL') layers.reverse()
|
|
191
|
+
const out = new Map<string, number>()
|
|
192
|
+
layers.forEach((layer, i) => layer.ids.forEach((id) => out.set(id, i)))
|
|
193
|
+
return out
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ============================================================================
|
|
197
|
+
// Spine nodes
|
|
198
|
+
// ============================================================================
|
|
199
|
+
|
|
200
|
+
export interface SpinePlan {
|
|
201
|
+
/** Spine node id and the subgraph it stands for. */
|
|
202
|
+
ghosts: Array<{ id: string; subgraph: string }>
|
|
203
|
+
/** The invisible edges chaining each subgraph's members and spine nodes by layer. */
|
|
204
|
+
edges: MermaidEdge[]
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The spine nodes and the edges that chain them. For each subgraph, one node
|
|
209
|
+
* per layer between its first and last member where it has no member, and an
|
|
210
|
+
* edge from one representative per layer to the next, where either is a spine
|
|
211
|
+
* node. The representative is the subgraph's leftmost member in that layer, or
|
|
212
|
+
* its spine node. `crossOf` is a
|
|
213
|
+
* node's position across the flow, in the first layout.
|
|
214
|
+
*/
|
|
215
|
+
export function planSpines(
|
|
216
|
+
clusters: MermaidSubgraph[],
|
|
217
|
+
layer: ReadonlyMap<string, number>,
|
|
218
|
+
crossOf: (id: string) => number,
|
|
219
|
+
): SpinePlan {
|
|
220
|
+
const plan: SpinePlan = { ghosts: [], edges: [] }
|
|
221
|
+
for (const sg of clusters) {
|
|
222
|
+
const real = [...membersOf(sg)].filter((id) => layer.has(id))
|
|
223
|
+
if (real.length === 0) continue
|
|
224
|
+
const layers = real.map((id) => layer.get(id)!)
|
|
225
|
+
const first = Math.min(...layers)
|
|
226
|
+
const last = Math.max(...layers)
|
|
227
|
+
const representative = new Map<number, string>()
|
|
228
|
+
for (let l = first; l <= last; l++) {
|
|
229
|
+
const here = real
|
|
230
|
+
.filter((id) => layer.get(id) === l)
|
|
231
|
+
.sort((a, b) => crossOf(a) - crossOf(b))
|
|
232
|
+
if (here.length > 0) {
|
|
233
|
+
representative.set(l, here[0]!)
|
|
234
|
+
} else {
|
|
235
|
+
const id = `${SPINE_PREFIX}${sg.id}__${l}`
|
|
236
|
+
plan.ghosts.push({ id, subgraph: sg.id })
|
|
237
|
+
representative.set(l, id)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
for (let l = first; l < last; l++) {
|
|
241
|
+
const source = representative.get(l)!
|
|
242
|
+
const target = representative.get(l + 1)!
|
|
243
|
+
// Two real members in neighbouring layers need no chain: the layers they
|
|
244
|
+
// are in already keep them together.
|
|
245
|
+
if (!isSpine(source) && !isSpine(target)) continue
|
|
246
|
+
plan.edges.push({
|
|
247
|
+
source,
|
|
248
|
+
target,
|
|
249
|
+
style: 'invisible',
|
|
250
|
+
hasArrowStart: false,
|
|
251
|
+
hasArrowEnd: false,
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return plan
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ============================================================================
|
|
259
|
+
// Order
|
|
260
|
+
// ============================================================================
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Put sibling subgraphs that sit side by side in the order mermaid.js draws
|
|
264
|
+
* them: the reverse of their declaration order (mermaid.js walks its list of
|
|
265
|
+
* subgraphs backwards, so `subgraph a` then `subgraph b` come out with `b` on
|
|
266
|
+
* the left; see #444 and the same reversal in `mermaidToElk`). ELK chooses the
|
|
267
|
+
* order of two independent subgraphs by their edges, so the first layout may
|
|
268
|
+
* have them the other way round. The subgraphs of a group swap places: each
|
|
269
|
+
* takes the position of whichever came to occupy its slot, by moving the keys of
|
|
270
|
+
* its nodes (`keys`, positions across the flow, is changed in place). Only
|
|
271
|
+
* subgraphs whose layers overlap are side by side; the rest are left alone.
|
|
272
|
+
*/
|
|
273
|
+
export function orderSiblings(
|
|
274
|
+
subgraphs: readonly MermaidSubgraph[],
|
|
275
|
+
layer: ReadonlyMap<string, number>,
|
|
276
|
+
keys: Map<string, number>,
|
|
277
|
+
connected: (a: MermaidSubgraph, b: MermaidSubgraph) => boolean = () => false,
|
|
278
|
+
): void {
|
|
279
|
+
const span = (sg: MermaidSubgraph): [number, number] | undefined => {
|
|
280
|
+
const layers = [...membersOf(sg)]
|
|
281
|
+
.filter((id) => layer.has(id))
|
|
282
|
+
.map((id) => layer.get(id)!)
|
|
283
|
+
return layers.length > 0
|
|
284
|
+
? [Math.min(...layers), Math.max(...layers)]
|
|
285
|
+
: undefined
|
|
286
|
+
}
|
|
287
|
+
const mean = (sg: MermaidSubgraph): number => {
|
|
288
|
+
const at = [...membersOf(sg)]
|
|
289
|
+
.filter((id) => keys.has(id))
|
|
290
|
+
.map((id) => keys.get(id)!)
|
|
291
|
+
return at.reduce((a, b) => a + b, 0) / at.length
|
|
292
|
+
}
|
|
293
|
+
// Group the siblings whose layers overlap, directly or through one another.
|
|
294
|
+
const spans = subgraphs
|
|
295
|
+
.map((sg) => ({ sg, at: span(sg) }))
|
|
296
|
+
.filter((e): e is { sg: MermaidSubgraph; at: [number, number] } => !!e.at)
|
|
297
|
+
.sort((a, b) => a.at[0] - b.at[0])
|
|
298
|
+
const groups: MermaidSubgraph[][] = []
|
|
299
|
+
let reach = -Infinity
|
|
300
|
+
for (const { sg, at } of spans) {
|
|
301
|
+
const current = groups[groups.length - 1]
|
|
302
|
+
if (current && at[0] <= reach) {
|
|
303
|
+
current.push(sg)
|
|
304
|
+
reach = Math.max(reach, at[1])
|
|
305
|
+
} else {
|
|
306
|
+
groups.push([sg])
|
|
307
|
+
reach = at[1]
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
for (const group of groups) {
|
|
311
|
+
if (group.length < 2) continue
|
|
312
|
+
// Subgraphs with edges running both ways between them sit where the layout
|
|
313
|
+
// put them, as in mermaid.js; the rest take the reversed order.
|
|
314
|
+
if (group.some((a, i) => group.slice(i + 1).some((b) => connected(a, b)))) {
|
|
315
|
+
continue
|
|
316
|
+
}
|
|
317
|
+
// Declaration order, last to first, is left to right.
|
|
318
|
+
const want = [...group].sort(
|
|
319
|
+
(a, b) => subgraphs.indexOf(b) - subgraphs.indexOf(a),
|
|
320
|
+
)
|
|
321
|
+
const slots = group.map(mean).sort((a, b) => a - b)
|
|
322
|
+
const moves = want.map((sg, i) => slots[i]! - mean(sg))
|
|
323
|
+
want.forEach((sg, i) => {
|
|
324
|
+
for (const id of membersOf(sg)) {
|
|
325
|
+
if (keys.has(id)) keys.set(id, keys.get(id)! + moves[i]!)
|
|
326
|
+
}
|
|
327
|
+
})
|
|
328
|
+
}
|
|
329
|
+
for (const sg of subgraphs) {
|
|
330
|
+
orderSiblings(sg.children, layer, keys, connected)
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* The order to give ELK the nodes in, which becomes the left-to-right order
|
|
336
|
+
* within each layer. Starts from where the first layout put each node across
|
|
337
|
+
* the flow, then moves every node that is not in a subgraph but falls within
|
|
338
|
+
* its span of layers to whichever side of the subgraph it was already nearer,
|
|
339
|
+
* so the subgraph's own nodes stay together. `keys` holds each node's position
|
|
340
|
+
* across the flow (spine nodes included); `original` breaks ties.
|
|
341
|
+
*/
|
|
342
|
+
export function orderNodes(
|
|
343
|
+
original: readonly string[],
|
|
344
|
+
keys: Map<string, number>,
|
|
345
|
+
layer: ReadonlyMap<string, number>,
|
|
346
|
+
clusters: MermaidSubgraph[],
|
|
347
|
+
spineNodes: ReadonlyMap<string, string[]>,
|
|
348
|
+
): string[] {
|
|
349
|
+
const key = new Map(keys)
|
|
350
|
+
// Outer subgraphs first, so an inner one's adjustment is applied last.
|
|
351
|
+
for (const sg of clusters) {
|
|
352
|
+
const inside = membersOf(sg)
|
|
353
|
+
for (const id of spineNodes.get(sg.id) ?? []) inside.add(id)
|
|
354
|
+
const members = [...inside].filter((id) => key.has(id))
|
|
355
|
+
const withLayer = members.filter((id) => layer.has(id))
|
|
356
|
+
if (withLayer.length === 0) continue
|
|
357
|
+
const first = Math.min(...withLayer.map((id) => layer.get(id)!))
|
|
358
|
+
const last = Math.max(...withLayer.map((id) => layer.get(id)!))
|
|
359
|
+
const positions = members.map((id) => key.get(id)!)
|
|
360
|
+
const left = Math.min(...positions)
|
|
361
|
+
const right = Math.max(...positions)
|
|
362
|
+
const centre = (left + right) / 2
|
|
363
|
+
let step = 0
|
|
364
|
+
for (const id of original) {
|
|
365
|
+
if (inside.has(id) || isSpine(id)) continue
|
|
366
|
+
const l = layer.get(id)
|
|
367
|
+
if (l === undefined || l < first || l > last) continue
|
|
368
|
+
step++
|
|
369
|
+
const at = key.get(id)!
|
|
370
|
+
// The tiny per-step offset keeps the nodes pushed to one side in their
|
|
371
|
+
// original relative order.
|
|
372
|
+
key.set(
|
|
373
|
+
id,
|
|
374
|
+
at <= centre
|
|
375
|
+
? Math.min(at, left) - 1 - step * 1e-3
|
|
376
|
+
: Math.max(at, right) + 1 + step * 1e-3,
|
|
377
|
+
)
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
const index = new Map(original.map((id, i) => [id, i]))
|
|
381
|
+
return [...original].sort(
|
|
382
|
+
(a, b) => key.get(a)! - key.get(b)! || index.get(a)! - index.get(b)!,
|
|
383
|
+
)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ============================================================================
|
|
387
|
+
// Boxes
|
|
388
|
+
// ============================================================================
|
|
389
|
+
|
|
390
|
+
/** Whether `l` is between the first and last of `layers`. */
|
|
391
|
+
const withinSpan = (layers: readonly number[], l: number): boolean =>
|
|
392
|
+
l >= Math.min(...layers) && l <= Math.max(...layers)
|
|
393
|
+
|
|
394
|
+
/** The box of every subgraph, nested, drawn around the real nodes in `nodes`. */
|
|
395
|
+
function buildGroups(
|
|
396
|
+
subgraphs: MermaidSubgraph[],
|
|
397
|
+
nodes: ReadonlyMap<string, PositionedNode>,
|
|
398
|
+
fontSizes: FontSizes,
|
|
399
|
+
): PositionedGroup[] {
|
|
400
|
+
const build = (sg: MermaidSubgraph): PositionedGroup => {
|
|
401
|
+
const children = sg.children.map(build)
|
|
402
|
+
const boxes: Box[] = [
|
|
403
|
+
...[...membersOf(sg)].flatMap((id) => {
|
|
404
|
+
const n = nodes.get(id)
|
|
405
|
+
return n ? [n] : []
|
|
406
|
+
}),
|
|
407
|
+
...children,
|
|
408
|
+
]
|
|
409
|
+
const x0 = Math.min(...boxes.map((b) => b.x)) - SUBGRAPH_PADDING.left
|
|
410
|
+
const y0 = Math.min(...boxes.map((b) => b.y)) - SUBGRAPH_PADDING.top
|
|
411
|
+
const x1 =
|
|
412
|
+
Math.max(...boxes.map((b) => b.x + b.width)) + SUBGRAPH_PADDING.right
|
|
413
|
+
const y1 =
|
|
414
|
+
Math.max(...boxes.map((b) => b.y + b.height)) + SUBGRAPH_PADDING.bottom
|
|
415
|
+
// The title has to fit inside the box.
|
|
416
|
+
const titleWidth =
|
|
417
|
+
measureMultilineText(
|
|
418
|
+
sg.label,
|
|
419
|
+
fontSizes.groupHeader,
|
|
420
|
+
FONT_WEIGHTS.groupHeader,
|
|
421
|
+
).width +
|
|
422
|
+
2 * TITLE_INSET
|
|
423
|
+
return {
|
|
424
|
+
id: sg.id,
|
|
425
|
+
label: sg.label,
|
|
426
|
+
x: x0,
|
|
427
|
+
y: y0,
|
|
428
|
+
width: Math.max(x1 - x0, titleWidth),
|
|
429
|
+
height: y1 - y0,
|
|
430
|
+
children,
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return subgraphs.map(build)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const flattenGroups = (groups: PositionedGroup[]): PositionedGroup[] =>
|
|
437
|
+
groups.flatMap((g) => [g, ...flattenGroups(g.children)])
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* How far each subgraph's box reaches into a node that isn't a member, across
|
|
441
|
+
* the flow, for the subgraphs that do. A node in a layer where the subgraph has
|
|
442
|
+
* no member is pushed away by the subgraph's spine nodes there; if the subgraph
|
|
443
|
+
* has none, `stuck` is set. Anything else, a node beside a member, a node
|
|
444
|
+
* before or after the subgraph's layers, or two boxes that overlap each other,
|
|
445
|
+
* is a matter of spacing: `room` is how much more space between nodes (across
|
|
446
|
+
* the flow) or between layers (along it) would separate them, taking whichever
|
|
447
|
+
* is less for each pair.
|
|
448
|
+
*/
|
|
449
|
+
function overlaps(
|
|
450
|
+
groups: PositionedGroup[],
|
|
451
|
+
subgraphs: MermaidSubgraph[],
|
|
452
|
+
nodes: ReadonlyMap<string, PositionedNode>,
|
|
453
|
+
direction: Direction,
|
|
454
|
+
hasSpine: ReadonlySet<string>,
|
|
455
|
+
layer: ReadonlyMap<string, number>,
|
|
456
|
+
): {
|
|
457
|
+
penetration: Map<string, number>
|
|
458
|
+
stuck: boolean
|
|
459
|
+
requests: RoomRequest[]
|
|
460
|
+
} {
|
|
461
|
+
const vertical = isVertical(direction)
|
|
462
|
+
const members = new Map(
|
|
463
|
+
flatten(subgraphs).map((sg) => [sg.id, membersOf(sg)]),
|
|
464
|
+
)
|
|
465
|
+
const penetration = new Map<string, number>()
|
|
466
|
+
let stuck = false
|
|
467
|
+
const requests: RoomRequest[] = []
|
|
468
|
+
const cross = (b: Box): [number, number] =>
|
|
469
|
+
vertical ? [b.x, b.x + b.width] : [b.y, b.y + b.height]
|
|
470
|
+
const along = (b: Box): [number, number] =>
|
|
471
|
+
vertical ? [b.y, b.y + b.height] : [b.x, b.x + b.width]
|
|
472
|
+
const overlap = (
|
|
473
|
+
[a0, a1]: [number, number],
|
|
474
|
+
[b0, b1]: [number, number],
|
|
475
|
+
): number => Math.max(0, Math.min(a1, b1) - Math.max(a0, b0))
|
|
476
|
+
/** A box with the clearance added on every side. */
|
|
477
|
+
const grown = (b: Box): Box => ({
|
|
478
|
+
x: b.x - BOX_CLEARANCE,
|
|
479
|
+
y: b.y - BOX_CLEARANCE,
|
|
480
|
+
width: b.width + 2 * BOX_CLEARANCE,
|
|
481
|
+
height: b.height + 2 * BOX_CLEARANCE,
|
|
482
|
+
})
|
|
483
|
+
/** How far `a` reaches into `b` across the flow, if they overlap along it. */
|
|
484
|
+
const hit = (a: Box, b: Box): number =>
|
|
485
|
+
overlap(along(a), along(b)) > 0 ? overlap(cross(a), cross(b)) : 0
|
|
486
|
+
const all = flattenGroups(groups)
|
|
487
|
+
const wide = new Map(all.map((g) => [g, grown(g)]))
|
|
488
|
+
/**
|
|
489
|
+
* Ask for the room that separates `a` from `b`, along or across, whichever is
|
|
490
|
+
* less. `a` and `b` are the boxes as drawn (the clearance is in the overlap
|
|
491
|
+
* measured here, not in them); whichever comes first gets the room opened
|
|
492
|
+
* after it, see `openRoom`.
|
|
493
|
+
*/
|
|
494
|
+
const needRoom = (a: Box, b: Box, aDrawn: Box, bDrawn: Box): void => {
|
|
495
|
+
const acrossBy = overlap(cross(a), cross(b))
|
|
496
|
+
const alongBy = overlap(along(a), along(b))
|
|
497
|
+
if (acrossBy <= 0 || alongBy <= 0) return
|
|
498
|
+
const isAcross = acrossBy <= alongBy
|
|
499
|
+
// Final coordinates: x across a vertical flow, y along it.
|
|
500
|
+
const useX = isAcross === vertical
|
|
501
|
+
const near = (box: Box): number => (useX ? box.x : box.y)
|
|
502
|
+
const far = (box: Box): number =>
|
|
503
|
+
useX ? box.x + box.width : box.y + box.height
|
|
504
|
+
// The one centred first comes first (a box can start above a node it
|
|
505
|
+
// overlaps and still end before it).
|
|
506
|
+
const centre = (box: Box): number => (near(box) + far(box)) / 2
|
|
507
|
+
const first = centre(aDrawn) <= centre(bDrawn) ? aDrawn : bDrawn
|
|
508
|
+
requests.push({
|
|
509
|
+
axis: useX ? 'x' : 'y',
|
|
510
|
+
amount: isAcross ? acrossBy : alongBy,
|
|
511
|
+
after: far(first),
|
|
512
|
+
})
|
|
513
|
+
}
|
|
514
|
+
for (const group of all) {
|
|
515
|
+
const inside = members.get(group.id)!
|
|
516
|
+
const layers = [...inside]
|
|
517
|
+
.filter((id) => layer.has(id))
|
|
518
|
+
.map((id) => layer.get(id)!)
|
|
519
|
+
const memberLayers = new Set(layers)
|
|
520
|
+
for (const [id, node] of nodes) {
|
|
521
|
+
if (inside.has(id)) continue
|
|
522
|
+
// Beside a member, or before or after the subgraph's layers.
|
|
523
|
+
if (
|
|
524
|
+
memberLayers.has(layer.get(id)!) ||
|
|
525
|
+
!withinSpan(layers, layer.get(id)!)
|
|
526
|
+
) {
|
|
527
|
+
needRoom(wide.get(group)!, node, group, node)
|
|
528
|
+
continue
|
|
529
|
+
}
|
|
530
|
+
const pen = hit(wide.get(group)!, node)
|
|
531
|
+
if (pen <= 0) continue
|
|
532
|
+
if (hasSpine.has(group.id)) {
|
|
533
|
+
penetration.set(group.id, Math.max(penetration.get(group.id) ?? 0, pen))
|
|
534
|
+
} else {
|
|
535
|
+
stuck = true
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
// Two boxes that overlap and don't contain one another.
|
|
540
|
+
const contains = (outer: PositionedGroup, inner: PositionedGroup): boolean =>
|
|
541
|
+
flattenGroups(outer.children).includes(inner)
|
|
542
|
+
for (let i = 0; i < all.length; i++) {
|
|
543
|
+
for (let j = i + 1; j < all.length; j++) {
|
|
544
|
+
const a = all[i]!
|
|
545
|
+
const b = all[j]!
|
|
546
|
+
if (contains(a, b) || contains(b, a)) continue
|
|
547
|
+
needRoom(wide.get(a)!, b, a, b)
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
return { penetration, stuck, requests }
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// ============================================================================
|
|
554
|
+
// Layout
|
|
555
|
+
// ============================================================================
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Lay out `graph` with its subgraphs arranged as described at the top of this
|
|
559
|
+
* file. `layout` lays out a graph (with the hints it is given). Returns
|
|
560
|
+
* `undefined` if the boxes can't be made to clear the other nodes.
|
|
561
|
+
*/
|
|
562
|
+
export function layoutCompoundFlat(
|
|
563
|
+
graph: MermaidGraph,
|
|
564
|
+
layout: LayoutFn,
|
|
565
|
+
fontSizes: FontSizes,
|
|
566
|
+
): PositionedGraph | undefined {
|
|
567
|
+
const realOrder = [...graph.nodes.keys()]
|
|
568
|
+
const flat: MermaidGraph = { ...graph, subgraphs: [] }
|
|
569
|
+
const first = layout(flat)
|
|
570
|
+
const layer = layerIndexes(first.nodes, graph.direction)
|
|
571
|
+
const vertical = isVertical(graph.direction)
|
|
572
|
+
const firstNode = new Map(first.nodes.map((n) => [n.id, n]))
|
|
573
|
+
const crossOf = (id: string): number => {
|
|
574
|
+
const n = firstNode.get(id)!
|
|
575
|
+
return vertical ? n.x : n.y
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const clusters = flatten(graph.subgraphs)
|
|
579
|
+
const plan = planSpines(clusters, layer, crossOf)
|
|
580
|
+
const spineNodes = new Map<string, string[]>()
|
|
581
|
+
for (const g of plan.ghosts) {
|
|
582
|
+
spineNodes.set(g.subgraph, [...(spineNodes.get(g.subgraph) ?? []), g.id])
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Where each node sits across the flow, for the order: the first layout's
|
|
586
|
+
// position, and for a spine node the middle of its subgraph's members.
|
|
587
|
+
const keys = new Map<string, number>(
|
|
588
|
+
first.nodes.map((n) => [n.id, crossOf(n.id)]),
|
|
589
|
+
)
|
|
590
|
+
const joins = (a: MermaidSubgraph, b: MermaidSubgraph): boolean => {
|
|
591
|
+
const inA = membersOf(a)
|
|
592
|
+
const inB = membersOf(b)
|
|
593
|
+
const run = (from: Set<string>, to: Set<string>): boolean =>
|
|
594
|
+
graph.edges.some((e) => from.has(e.source) && to.has(e.target))
|
|
595
|
+
return run(inA, inB) && run(inB, inA)
|
|
596
|
+
}
|
|
597
|
+
orderSiblings(graph.subgraphs, layer, keys, joins)
|
|
598
|
+
for (const g of plan.ghosts) {
|
|
599
|
+
const sg = clusters.find((c) => c.id === g.subgraph)!
|
|
600
|
+
const at = [...membersOf(sg)]
|
|
601
|
+
.filter((id) => keys.has(id))
|
|
602
|
+
.map((id) => keys.get(id)!)
|
|
603
|
+
keys.set(g.id, at.reduce((a, b) => a + b, 0) / at.length)
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const nodes = new Map<string, MermaidNode>(graph.nodes)
|
|
607
|
+
for (const g of plan.ghosts) {
|
|
608
|
+
nodes.set(g.id, { id: g.id, label: ' ', shape: 'rectangle' })
|
|
609
|
+
}
|
|
610
|
+
const ordered = orderNodes(
|
|
611
|
+
[...nodes.keys()],
|
|
612
|
+
keys,
|
|
613
|
+
layer,
|
|
614
|
+
clusters,
|
|
615
|
+
spineNodes,
|
|
616
|
+
)
|
|
617
|
+
const augmented: MermaidGraph = {
|
|
618
|
+
...flat,
|
|
619
|
+
nodes: new Map(ordered.map((id) => [id, nodes.get(id)!])),
|
|
620
|
+
edges: [...graph.edges, ...plan.edges],
|
|
621
|
+
}
|
|
622
|
+
const looseEdges = new Set(plan.edges.map((_, i) => graph.edges.length + i))
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Lay out and widen the spine nodes until no box covers a node that isn't a
|
|
626
|
+
* member, with more room between neighbours in a layer and between layers.
|
|
627
|
+
* Returns the room two overlapping boxes still need if that is all that is
|
|
628
|
+
* wrong, and `undefined` if it can't be fixed.
|
|
629
|
+
*/
|
|
630
|
+
const settle = (
|
|
631
|
+
detachedEdges: ReadonlySet<number>,
|
|
632
|
+
): PositionedGraph | undefined => {
|
|
633
|
+
// How much wider each subgraph's spine nodes are than their minimum, and
|
|
634
|
+
// how far the box reached into a neighbour at the width before.
|
|
635
|
+
const widen = new Map<string, number>()
|
|
636
|
+
const previous = new Map<string, { width: number; overlap: number }>()
|
|
637
|
+
for (let round = 0; ; round++) {
|
|
638
|
+
const fixedWidths = new Map(
|
|
639
|
+
plan.ghosts.map((g) => [
|
|
640
|
+
g.id,
|
|
641
|
+
SPINE_MIN_WIDTH + (widen.get(g.subgraph) ?? 0),
|
|
642
|
+
]),
|
|
643
|
+
)
|
|
644
|
+
const laidOut = layout(augmented, {
|
|
645
|
+
fixedWidths,
|
|
646
|
+
looseEdges,
|
|
647
|
+
detachedEdges,
|
|
648
|
+
walkOrder: realOrder,
|
|
649
|
+
forceNodeOrder: true,
|
|
650
|
+
})
|
|
651
|
+
const real = new Map(
|
|
652
|
+
laidOut.nodes.filter((n) => !isSpine(n.id)).map((n) => [n.id, n]),
|
|
653
|
+
)
|
|
654
|
+
let groups = buildGroups(graph.subgraphs, real, fontSizes)
|
|
655
|
+
let found = overlaps(
|
|
656
|
+
groups,
|
|
657
|
+
graph.subgraphs,
|
|
658
|
+
real,
|
|
659
|
+
graph.direction,
|
|
660
|
+
new Set(spineNodes.keys()),
|
|
661
|
+
layer,
|
|
662
|
+
)
|
|
663
|
+
const { penetration, stuck } = found
|
|
664
|
+
if (stuck) return undefined
|
|
665
|
+
if (penetration.size === 0) {
|
|
666
|
+
// Boxes that sit too close: open up room between them.
|
|
667
|
+
for (let i = 0; found.requests.length > 0; i++) {
|
|
668
|
+
if (i === MAX_ROOM_STEPS) return undefined
|
|
669
|
+
const request = found.requests.reduce((a, b) =>
|
|
670
|
+
b.amount > a.amount ? b : a,
|
|
671
|
+
)
|
|
672
|
+
openRoom(request, [...real.values()], laidOut.edges)
|
|
673
|
+
groups = buildGroups(graph.subgraphs, real, fontSizes)
|
|
674
|
+
found = overlaps(
|
|
675
|
+
groups,
|
|
676
|
+
graph.subgraphs,
|
|
677
|
+
real,
|
|
678
|
+
graph.direction,
|
|
679
|
+
new Set(spineNodes.keys()),
|
|
680
|
+
layer,
|
|
681
|
+
)
|
|
682
|
+
}
|
|
683
|
+
const inner = routeDetached(
|
|
684
|
+
graph,
|
|
685
|
+
detachedEdges,
|
|
686
|
+
real,
|
|
687
|
+
groups,
|
|
688
|
+
clusters,
|
|
689
|
+
)
|
|
690
|
+
return inner && assemble(laidOut, [...real.values()], groups, inner)
|
|
691
|
+
}
|
|
692
|
+
if (round === MAX_WIDENINGS) return undefined
|
|
693
|
+
for (const [id, overlap] of penetration) {
|
|
694
|
+
const width = widen.get(id) ?? 0
|
|
695
|
+
const before = previous.get(id)
|
|
696
|
+
// How far the neighbour moves per px of width: measured from the last
|
|
697
|
+
// two runs, or assumed (a spine node is centred on its column, and the
|
|
698
|
+
// neighbours it pushes also nudge it) until there are two.
|
|
699
|
+
const measured = before
|
|
700
|
+
? (before.overlap - overlap) / (width - before.width)
|
|
701
|
+
: 0
|
|
702
|
+
const slope = measured > MIN_SLOPE ? measured : ASSUMED_SLOPE
|
|
703
|
+
previous.set(id, { width, overlap })
|
|
704
|
+
widen.set(id, width + overlap / slope + WIDEN_SLACK)
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Edges inside a box that would have to go round a spine are drawn by hand
|
|
710
|
+
// (see `inner-edges.ts`); if one can't be routed, lay them out as usual.
|
|
711
|
+
const detached = detachableEdges(graph, plan, layer, clusters)
|
|
712
|
+
return (detached.size > 0 ? settle(detached) : undefined) ?? settle(new Set())
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* The edges between two members of a subgraph that span a layer where it has
|
|
717
|
+
* only a spine node, and whose ends each keep some other edge to hold them in
|
|
718
|
+
* place without it.
|
|
719
|
+
*/
|
|
720
|
+
function detachableEdges(
|
|
721
|
+
graph: MermaidGraph,
|
|
722
|
+
plan: SpinePlan,
|
|
723
|
+
layer: ReadonlyMap<string, number>,
|
|
724
|
+
clusters: MermaidSubgraph[],
|
|
725
|
+
): Set<number> {
|
|
726
|
+
const spans = clusters.map((sg) => {
|
|
727
|
+
const inside = membersOf(sg)
|
|
728
|
+
const layers = new Set(
|
|
729
|
+
[...inside].filter((id) => layer.has(id)).map((id) => layer.get(id)!),
|
|
730
|
+
)
|
|
731
|
+
return { inside, layers }
|
|
732
|
+
})
|
|
733
|
+
const through = (edge: MermaidEdge): boolean => {
|
|
734
|
+
const from = layer.get(edge.source)
|
|
735
|
+
const to = layer.get(edge.target)
|
|
736
|
+
if (from === undefined || to === undefined || edge.source === edge.target) {
|
|
737
|
+
return false
|
|
738
|
+
}
|
|
739
|
+
return spans.some(
|
|
740
|
+
({ inside, layers }) =>
|
|
741
|
+
inside.has(edge.source) &&
|
|
742
|
+
inside.has(edge.target) &&
|
|
743
|
+
Array.from(
|
|
744
|
+
{ length: Math.max(0, Math.abs(to - from) - 1) },
|
|
745
|
+
(_, i) => Math.min(from, to) + 1 + i,
|
|
746
|
+
).some((l) => !layers.has(l)),
|
|
747
|
+
)
|
|
748
|
+
}
|
|
749
|
+
const detached = new Set<number>()
|
|
750
|
+
graph.edges.forEach((edge, index) => {
|
|
751
|
+
if (through(edge)) detached.add(index)
|
|
752
|
+
})
|
|
753
|
+
// What holds each node in place: the edges that stay, and the spine's chain.
|
|
754
|
+
const held = new Map<string, number>()
|
|
755
|
+
const hold = (e: { source: string; target: string }): void => {
|
|
756
|
+
if (e.source === e.target) return
|
|
757
|
+
held.set(e.source, (held.get(e.source) ?? 0) + 1)
|
|
758
|
+
held.set(e.target, (held.get(e.target) ?? 0) + 1)
|
|
759
|
+
}
|
|
760
|
+
graph.edges.forEach((e, i) => {
|
|
761
|
+
if (!detached.has(i)) hold(e)
|
|
762
|
+
})
|
|
763
|
+
plan.edges.forEach(hold)
|
|
764
|
+
for (const i of [...detached]) {
|
|
765
|
+
const e = graph.edges[i]!
|
|
766
|
+
if (!held.get(e.source) || !held.get(e.target)) detached.delete(i)
|
|
767
|
+
}
|
|
768
|
+
return detached
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Draw the detached edges inside the innermost box that holds both ends.
|
|
773
|
+
* `undefined` if one has no clear route.
|
|
774
|
+
*/
|
|
775
|
+
function routeDetached(
|
|
776
|
+
graph: MermaidGraph,
|
|
777
|
+
detached: ReadonlySet<number>,
|
|
778
|
+
nodes: ReadonlyMap<string, PositionedNode>,
|
|
779
|
+
groups: PositionedGroup[],
|
|
780
|
+
clusters: MermaidSubgraph[],
|
|
781
|
+
): PositionedEdge[] | undefined {
|
|
782
|
+
const boxes = new Map(flattenGroups(groups).map((g) => [g.id, g]))
|
|
783
|
+
const out: PositionedEdge[] = []
|
|
784
|
+
for (const index of detached) {
|
|
785
|
+
const edge = graph.edges[index]!
|
|
786
|
+
const source = nodes.get(edge.source)
|
|
787
|
+
const target = nodes.get(edge.target)
|
|
788
|
+
const home = clusters
|
|
789
|
+
.filter((sg) => {
|
|
790
|
+
const inside = membersOf(sg)
|
|
791
|
+
return inside.has(edge.source) && inside.has(edge.target)
|
|
792
|
+
})
|
|
793
|
+
.sort((a, b) => membersOf(a).size - membersOf(b).size)[0]
|
|
794
|
+
const box = home && boxes.get(home.id)
|
|
795
|
+
if (!source || !target || !box) return undefined
|
|
796
|
+
const points = routeInnerEdge(
|
|
797
|
+
source,
|
|
798
|
+
target,
|
|
799
|
+
[...nodes.values()].filter((n) => n !== source && n !== target),
|
|
800
|
+
box,
|
|
801
|
+
graph.direction,
|
|
802
|
+
out.map((e) => e.points),
|
|
803
|
+
)
|
|
804
|
+
if (!points) return undefined
|
|
805
|
+
out.push({
|
|
806
|
+
source: edge.source,
|
|
807
|
+
target: edge.target,
|
|
808
|
+
label: edge.label,
|
|
809
|
+
style: edge.style,
|
|
810
|
+
hasArrowStart: edge.hasArrowStart,
|
|
811
|
+
hasArrowEnd: edge.hasArrowEnd,
|
|
812
|
+
points,
|
|
813
|
+
labelPosition: edge.label ? labelSpot(points) : undefined,
|
|
814
|
+
inlineStyle: resolveEdgeStyle(index, graph),
|
|
815
|
+
id: edge.id,
|
|
816
|
+
animate: edge.animate,
|
|
817
|
+
})
|
|
818
|
+
}
|
|
819
|
+
return out
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Open up `request.amount` px (and a little slack) between two boxes that sit
|
|
824
|
+
* too close, by moving everything from the first node beyond the cut line (where
|
|
825
|
+
* the first of them ends) along by that much: those nodes, and the edges'
|
|
826
|
+
* points that are as far along as they are, so an edge that crosses the cut
|
|
827
|
+
* just gets longer (the segments stay straight and square). An edge's bends in
|
|
828
|
+
* the gap between the cut and those nodes stay where they are, close to the
|
|
829
|
+
* first box, so a bus that fans out of a node stays clear of the boxes below.
|
|
830
|
+
* A node that straddles the cut stays put; whatever it was beside moves away
|
|
831
|
+
* from it, never into it.
|
|
832
|
+
*/
|
|
833
|
+
function openRoom(
|
|
834
|
+
request: RoomRequest,
|
|
835
|
+
nodes: PositionedNode[],
|
|
836
|
+
edges: PositionedEdge[],
|
|
837
|
+
): void {
|
|
838
|
+
const { axis, amount, after: cut } = request
|
|
839
|
+
const by = amount + SPACING_SLACK
|
|
840
|
+
const beyond = nodes.filter((n) => n[axis] >= cut)
|
|
841
|
+
const gapEnd = Math.min(...beyond.map((n) => n[axis]))
|
|
842
|
+
for (const n of beyond) n[axis] += by
|
|
843
|
+
const shift = (p: Point): void => {
|
|
844
|
+
if (p[axis] >= gapEnd) p[axis] += by
|
|
845
|
+
}
|
|
846
|
+
for (const e of edges) {
|
|
847
|
+
e.points.forEach(shift)
|
|
848
|
+
if (e.labelPosition) shift(e.labelPosition)
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/** The result of the last layout without the scaffolding, moved and sized to fit what is left. */
|
|
853
|
+
function assemble(
|
|
854
|
+
laidOut: PositionedGraph,
|
|
855
|
+
nodes: PositionedNode[],
|
|
856
|
+
groups: PositionedGroup[],
|
|
857
|
+
extraEdges: PositionedEdge[],
|
|
858
|
+
): PositionedGraph {
|
|
859
|
+
const edges: PositionedEdge[] = [
|
|
860
|
+
...laidOut.edges.filter((e) => !isSpine(e.source) && !isSpine(e.target)),
|
|
861
|
+
...extraEdges,
|
|
862
|
+
]
|
|
863
|
+
const all = flattenGroups(groups)
|
|
864
|
+
const xs = [
|
|
865
|
+
...nodes.map((n) => n.x),
|
|
866
|
+
...all.map((g) => g.x),
|
|
867
|
+
...edges.flatMap((e) => e.points.map((p) => p.x)),
|
|
868
|
+
]
|
|
869
|
+
const ys = [
|
|
870
|
+
...nodes.map((n) => n.y),
|
|
871
|
+
...all.map((g) => g.y),
|
|
872
|
+
...edges.flatMap((e) => e.points.map((p) => p.y)),
|
|
873
|
+
]
|
|
874
|
+
const dx = DEFAULTS.padding - Math.min(...xs)
|
|
875
|
+
const dy = DEFAULTS.padding - Math.min(...ys)
|
|
876
|
+
for (const n of nodes) {
|
|
877
|
+
n.x += dx
|
|
878
|
+
n.y += dy
|
|
879
|
+
}
|
|
880
|
+
for (const g of all) {
|
|
881
|
+
g.x += dx
|
|
882
|
+
g.y += dy
|
|
883
|
+
}
|
|
884
|
+
for (const e of edges) {
|
|
885
|
+
for (const p of e.points) {
|
|
886
|
+
p.x += dx
|
|
887
|
+
p.y += dy
|
|
888
|
+
}
|
|
889
|
+
if (e.labelPosition) {
|
|
890
|
+
e.labelPosition.x += dx
|
|
891
|
+
e.labelPosition.y += dy
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
// Same allowances `elkToPositioned` makes around edges and labels.
|
|
895
|
+
let width = 0
|
|
896
|
+
let height = 0
|
|
897
|
+
for (const n of nodes) {
|
|
898
|
+
width = Math.max(width, n.x + n.width + DEFAULTS.padding)
|
|
899
|
+
height = Math.max(height, n.y + n.height + DEFAULTS.padding)
|
|
900
|
+
}
|
|
901
|
+
for (const g of all) {
|
|
902
|
+
width = Math.max(width, g.x + g.width + DEFAULTS.padding)
|
|
903
|
+
height = Math.max(height, g.y + g.height + DEFAULTS.padding)
|
|
904
|
+
}
|
|
905
|
+
for (const e of edges) {
|
|
906
|
+
for (const p of e.points) {
|
|
907
|
+
width = Math.max(width, p.x + ARROW_HEAD.width + DEFAULTS.padding)
|
|
908
|
+
height = Math.max(height, p.y + ARROW_HEAD.width + DEFAULTS.padding)
|
|
909
|
+
}
|
|
910
|
+
if (e.labelPosition) {
|
|
911
|
+
width = Math.max(width, e.labelPosition.x + 60 + DEFAULTS.padding)
|
|
912
|
+
height = Math.max(height, e.labelPosition.y + 20 + DEFAULTS.padding)
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
return { ...laidOut, nodes, edges, groups, width, height }
|
|
916
|
+
}
|