dsh-model-everything 0.5.0 → 0.5.2

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.
Files changed (65) hide show
  1. package/README.en-US.md +12 -0
  2. package/README.md +4 -0
  3. package/lib/client.js +2829 -224
  4. package/lib/render/channels.js +243 -0
  5. package/lib/render/collapse.js +12 -20
  6. package/lib/render/components.js +99 -0
  7. package/lib/render/coords.js +288 -0
  8. package/lib/render/cycle-break.js +147 -0
  9. package/lib/render/dummy.js +43 -0
  10. package/lib/render/from-mermaid.js +34 -10
  11. package/lib/render/from-plantuml.js +318 -46
  12. package/lib/render/layer.js +91 -0
  13. package/lib/render/layout.js +27 -11
  14. package/lib/render/network-simplex.js +272 -0
  15. package/lib/render/order.js +122 -0
  16. package/lib/render/paint.js +1227 -69
  17. package/lib/render/place.js +393 -121
  18. package/lib/render/route.js +225 -0
  19. package/lib/render/svg.js +146 -21
  20. package/lib/render/symbol.js +108 -0
  21. package/lib/render/text.js +74 -0
  22. package/lib/render/trunk.js +213 -0
  23. package/lib/render/wrap.js +87 -0
  24. package/lib/types/render/channels.d.ts +63 -0
  25. package/lib/types/render/components.d.ts +57 -0
  26. package/lib/types/render/coords.d.ts +64 -0
  27. package/lib/types/render/cycle-break.d.ts +45 -0
  28. package/lib/types/render/dummy.d.ts +32 -0
  29. package/lib/types/render/from-plantuml.d.ts +12 -6
  30. package/lib/types/render/layer.d.ts +28 -0
  31. package/lib/types/render/layout.d.ts +43 -11
  32. package/lib/types/render/model.d.ts +17 -0
  33. package/lib/types/render/network-simplex.d.ts +54 -0
  34. package/lib/types/render/order.d.ts +27 -0
  35. package/lib/types/render/paint.d.ts +157 -10
  36. package/lib/types/render/place.d.ts +40 -19
  37. package/lib/types/render/route.d.ts +77 -0
  38. package/lib/types/render/svg.d.ts +1 -1
  39. package/lib/types/render/symbol.d.ts +73 -0
  40. package/lib/types/render/text.d.ts +38 -0
  41. package/lib/types/render/trunk.d.ts +104 -0
  42. package/lib/types/render/wrap.d.ts +27 -0
  43. package/package.json +148 -148
  44. package/skills/model-everything-diagram/SKILL.md +85 -82
  45. package/src/render/channels.ts +309 -0
  46. package/src/render/collapse.ts +150 -155
  47. package/src/render/components.ts +126 -0
  48. package/src/render/coords.ts +328 -0
  49. package/src/render/cycle-break.ts +164 -0
  50. package/src/render/dummy.ts +65 -0
  51. package/src/render/from-mermaid.ts +261 -236
  52. package/src/render/from-plantuml.ts +469 -178
  53. package/src/render/layer.ts +98 -0
  54. package/src/render/layout.ts +101 -62
  55. package/src/render/model.ts +86 -67
  56. package/src/render/network-simplex.ts +290 -0
  57. package/src/render/order.ts +139 -0
  58. package/src/render/paint.ts +1570 -290
  59. package/src/render/place.ts +532 -234
  60. package/src/render/route.ts +269 -0
  61. package/src/render/svg.ts +274 -127
  62. package/src/render/symbol.ts +137 -0
  63. package/src/render/text.ts +86 -0
  64. package/src/render/trunk.ts +275 -0
  65. package/src/render/wrap.ts +103 -0
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Coordinate assignment: how far right each box sits. Brandes-Kopf.
3
+ *
4
+ * The layers are rows and the order inside them is settled; the only freedom left is the x of each box. Three
5
+ * properties make a drawing readable:
6
+ *
7
+ * - A box sits over what it is connected to, and a chain of boxes sits on one vertical line. Brandes and Kopf get
8
+ * this by *aligning*: every box is offered the median of its neighbours in the layer just above (or just below) and
9
+ * is joined to that neighbour's block when the join crosses no other edge. A block is then one node of a smaller
10
+ * graph, and a box that aligned downwards through five rows comes out as one straight segment.
11
+ * - Nothing overlaps, and the empty strip between two boxes in a row is the same everywhere. The blocks are compacted
12
+ * against each other by the least distance their rows allow, which is a longest path over the block graph: a block
13
+ * sits as far left as the block to its left permits, and no further.
14
+ * - The drawing is balanced rather than packed to one side. Aligning downwards alone would lean everything left, so
15
+ * the same two passes are run from the bottom up, and each pair is run once more mirrored left to right. The four
16
+ * layouts are then averaged. Averaging is a convex combination, and the separation constraints are linear, so the
17
+ * average of four layouts that keep the boxes apart also keeps them apart - the balance is free of overlaps by
18
+ * construction. A pass that starts at the top and one that starts at the bottom compact their blocks from opposite
19
+ * ends, so each side is first slid onto the origin of the first pass of that side, and only then averaged.
20
+ *
21
+ * Three things this module owes the rest of the pipeline rather than the paper:
22
+ *
23
+ * - A long edge's dummies share one x. They are one lane, not one point per row, so each lane is collapsed to the mean
24
+ * of the x its two ends want - the straight drop a reader follows. Collapsing can put a lane where a box already is,
25
+ * so the last pass spends the lane's space: boxes are pushed right past it and never the other way, and a row keeps
26
+ * the width its placeholders were given, which is the width a corridor needs when the lane has to go around a box.
27
+ * The lane is a proposal either way - the channel stage moves a lane into a free corridor when routing needs it, and
28
+ * a box never moves for a lane.
29
+ * - A lane is not a neighbour to be aligned with. A box that aligned itself to a lane passing through its row would be
30
+ * pulled off the boxes it does connect to, which is how a decision with one long edge comes out as a staircase. Lanes
31
+ * are placed by the compaction and then by the channel stage, which is where a lane belongs.
32
+ * - The empty strip between two rows is the caller's, not this module's: the y of a row is a running sum with one gap.
33
+ *
34
+ * Pure: layers and chains in, coordinates out.
35
+ */
36
+
37
+ import type { Chain } from './dummy.js'
38
+
39
+ export interface Sized {
40
+ width: number
41
+ height: number
42
+ }
43
+
44
+ export interface CoordInput {
45
+ layers: string[][]
46
+ chains: Chain[]
47
+ sizeOf: (id: string) => Sized
48
+ /** A dummy instance, and the shared lane it belongs to. */
49
+ chainOf: ReadonlyMap<string, string>
50
+ }
51
+
52
+ export interface CoordOptions {
53
+ /** The empty space between two side-by-side boxes. */
54
+ gap: number
55
+ /** The empty strip between two layers. */
56
+ layerGap: number
57
+ }
58
+
59
+ export interface Coordinates {
60
+ /** Centre x, keyed by node id and by lane id. A long edge's dummies share one entry. */
61
+ x: Map<string, number>
62
+ /** The top y of each layer. */
63
+ layerY: number[]
64
+ layerHeight: number[]
65
+ layerWidth: number[]
66
+ width: number
67
+ height: number
68
+ }
69
+
70
+ /** What one pass needs: the rows in its own order, and where each id's neighbours in the row above it are. */
71
+ interface Frame {
72
+ /** The rows, top to bottom in this pass's frame, each already in the pass's left-to-right order. */
73
+ rows: string[][]
74
+ /** For each id, the ids it is joined to in the previous row of this frame. */
75
+ near: ReadonlyMap<string, string[]>
76
+ /** True when this pass reads the rows mirror-image, so its coordinates have to be flipped back. */
77
+ mirrored: boolean
78
+ }
79
+
80
+ /** Where each id sits inside its row, in the row's own order. */
81
+ function positions(rows: string[][]): Map<string, number> {
82
+ const at = new Map<string, number>()
83
+ for (const row of rows) row.forEach((id, index) => at.set(id, index))
84
+ return at
85
+ }
86
+
87
+ /**
88
+ * Join each id to the median of its neighbours in the row above, unless that join would cross another edge.
89
+ *
90
+ * One block per chain of joins, reported as the block's root per id. The `previous` cursor is the whole of the
91
+ * crossing test: as ids are taken left to right, a neighbour has to be strictly right of the one the previous id was
92
+ * joined to, and since the ids are taken in order that is exactly "the joins do not cross".
93
+ */
94
+ function alignBlocks(frame: Frame, at: Map<string, number>): Map<string, string> {
95
+ const root = new Map<string, string>()
96
+ const joined = new Map<string, string>()
97
+ for (const row of frame.rows) {
98
+ for (const id of row) {
99
+ root.set(id, id)
100
+ joined.set(id, id)
101
+ }
102
+ }
103
+ for (let depth = 1; depth < frame.rows.length; depth += 1) {
104
+ let previous = -1
105
+ for (const id of frame.rows[depth]) {
106
+ const close = frame.near.get(id) ?? []
107
+ if (close.length === 0) continue
108
+ const sorted = [...close].sort((left, right) => (at.get(left) as number) - (at.get(right) as number))
109
+ const middle = (sorted.length - 1) / 2
110
+ // An even number of neighbours has two medians. Both are tried, nearest first, so a box with two parents is as
111
+ // happy under either as the paper's version.
112
+ for (let cursor = Math.floor(middle); cursor <= Math.ceil(middle); cursor += 1) {
113
+ if (joined.get(id) !== id) break
114
+ const target = sorted[cursor]
115
+ if (previous >= (at.get(target) as number)) continue
116
+ joined.set(target, id)
117
+ root.set(id, root.get(target) as string)
118
+ joined.set(id, root.get(id) as string)
119
+ previous = at.get(target) as number
120
+ }
121
+ }
122
+ }
123
+ return root
124
+ }
125
+
126
+ /**
127
+ * Place the blocks: each one as far left as the block to its left in any row allows.
128
+ *
129
+ * That is a longest path over the block graph, whose edges run from a block to the block it has to clear, weighted by
130
+ * the least distance between the two ids that forced the edge. Blocks cannot cross by construction, so the graph has
131
+ * no cycle; the guard is there because a cycle would otherwise be an infinite recursion rather than a wrong drawing.
132
+ */
133
+ function compactBlocks(rows: string[][], root: Map<string, string>, widthOf: (id: string) => number, gap: number): Map<string, number> {
134
+ const clears = new Map<string, Array<{ block: string; space: number }>>()
135
+ for (const row of rows) {
136
+ for (let index = 1; index < row.length; index += 1) {
137
+ const left = row[index - 1]
138
+ const right = row[index]
139
+ const leftBlock = root.get(left) as string
140
+ const rightBlock = root.get(right) as string
141
+ const space = (widthOf(left) + widthOf(right)) / 2 + gap
142
+ clears.set(rightBlock, [...(clears.get(rightBlock) ?? []), { block: leftBlock, space }])
143
+ }
144
+ }
145
+ const placed = new Map<string, number>()
146
+ const place = (block: string): number => {
147
+ const known = placed.get(block)
148
+ if (known !== undefined) return known
149
+ let value = 0
150
+ for (const step of clears.get(block) ?? []) value = Math.max(value, place(step.block) + step.space)
151
+ placed.set(block, value)
152
+ return value
153
+ }
154
+ const x = new Map<string, number>()
155
+ for (const row of rows) {
156
+ for (const id of row) {
157
+ const block = root.get(id) as string
158
+ if (!placed.has(block)) place(block)
159
+ x.set(id, placed.get(block) as number)
160
+ }
161
+ }
162
+ return x
163
+ }
164
+
165
+ /** The left edge of the leftmost box and the right edge of the rightmost, in one pass's coordinates. */
166
+ function extentOf(x: Map<string, number>, rows: string[][], widthOf: (id: string) => number): { left: number; right: number } {
167
+ let left = Infinity
168
+ let right = -Infinity
169
+ for (const row of rows) {
170
+ for (const id of row) {
171
+ const value = x.get(id) as number
172
+ left = Math.min(left, value - widthOf(id) / 2)
173
+ right = Math.max(right, value + widthOf(id) / 2)
174
+ }
175
+ }
176
+ return { left, right }
177
+ }
178
+
179
+ /** One pass: align the rows in this frame, compact the blocks, and flip the answer back if the frame is mirrored. */
180
+ function onePass(frame: Frame, widthOf: (id: string) => number, gap: number): Map<string, number> {
181
+ const at = positions(frame.rows)
182
+ const root = alignBlocks(frame, at)
183
+ const x = compactBlocks(frame.rows, root, widthOf, gap)
184
+ if (!frame.mirrored) return x
185
+ const mirrored = new Map<string, number>()
186
+ for (const [id, value] of x) mirrored.set(id, -value)
187
+ return mirrored
188
+ }
189
+
190
+ /** The four passes a balanced layout needs: down and up, each read left to right and right to left. */
191
+ function frames(layers: string[][], above: ReadonlyMap<string, string[]>, below: ReadonlyMap<string, string[]>): Frame[] {
192
+ const fromTop = layers
193
+ const fromBottom = [...layers].reverse()
194
+ const flip = (rows: string[][]): string[][] => rows.map((row) => [...row].reverse())
195
+ return [
196
+ { rows: fromTop, near: above, mirrored: false },
197
+ { rows: flip(fromTop), near: above, mirrored: true },
198
+ { rows: fromBottom, near: below, mirrored: false },
199
+ { rows: flip(fromBottom), near: below, mirrored: true },
200
+ ]
201
+ }
202
+
203
+ export function assignCoordinates(input: CoordInput, options: CoordOptions): Coordinates {
204
+ const { layers, chains, sizeOf, chainOf } = input
205
+ const gap = options.gap
206
+ const laneIds = new Set<string>(chainOf.values())
207
+ const isDummy = (id: string): boolean => chainOf.has(id)
208
+ const isPoint = (id: string): boolean => isDummy(id) || laneIds.has(id)
209
+ const widthOf = (id: string): number => (isPoint(id) ? 0 : sizeOf(id).width)
210
+ const heightOf = (id: string): number => (isPoint(id) ? 0 : sizeOf(id).height)
211
+ const laneOf = (id: string): string => chainOf.get(id) ?? id
212
+ const x = new Map<string, number>()
213
+ const read = (id: string): number => x.get(laneOf(id)) as number
214
+
215
+ const above = new Map<string, string[]>()
216
+ const below = new Map<string, string[]>()
217
+ const laneEnds = new Map<string, string[]>()
218
+ const link = (into: Map<string, string[]>, id: string, other: string): void => {
219
+ into.set(id, [...(into.get(id) ?? []), other])
220
+ }
221
+ for (const chain of chains) {
222
+ for (let cursor = 0; cursor + 1 < chain.path.length; cursor += 1) {
223
+ const top = chain.path[cursor]
224
+ const bottom = chain.path[cursor + 1]
225
+ // A lane is not a neighbour to align with: only two boxes are, which is what keeps a box over the boxes it
226
+ // connects to when a long edge crosses its row.
227
+ if (!isDummy(top) && !isDummy(bottom)) {
228
+ link(below, top, bottom)
229
+ link(above, bottom, top)
230
+ }
231
+ }
232
+ if (chain.path.length > 2) {
233
+ const lane = chainOf.get(chain.path[1])
234
+ if (lane !== undefined) laneEnds.set(lane, [chain.path[0], chain.path[chain.path.length - 1]])
235
+ }
236
+ }
237
+
238
+ // The four layouts, averaged. A pass that starts at the top and a pass that starts at the bottom compact their
239
+ // blocks from opposite ends, so the four answers do not share an origin: each side is first slid onto the origin of
240
+ // the first pass of that side, and only then are they averaged. Averaging is what balances them - a box that aligned
241
+ // in one pass and not in another keeps the part the passes agreed on, which is the paper's last step, at the cost of
242
+ // one average.
243
+ const candidates = frames(layers, above, below).map((frame) => onePass(frame, widthOf, gap))
244
+ const leftOrigin = extentOf(candidates[0], layers, widthOf).left
245
+ const rightOrigin = extentOf(candidates[1], layers, widthOf).right
246
+ for (const [index, candidate] of candidates.entries()) {
247
+ const extent = extentOf(candidate, layers, widthOf)
248
+ const shift = index % 2 === 0 ? leftOrigin - extent.left : rightOrigin - extent.right
249
+ for (const [id, value] of candidate) candidate.set(id, value + shift)
250
+ }
251
+ for (const row of layers) {
252
+ for (const id of row) {
253
+ let total = 0
254
+ for (const candidate of candidates) total += candidate.get(id) as number
255
+ x.set(id, total / candidates.length)
256
+ }
257
+ }
258
+
259
+ // What each row has to make room for. The boxes are one part; the space the lane's placeholders were given by the
260
+ // compaction is the other, because that is where a corridor can run when the lane's own proposal lands on a box. A
261
+ // row that reserved nothing for its lanes is as wide as its boxes; a row whose lane had to go around a box keeps the
262
+ // width that detour needed, so the page is planned for the drawing that will be painted rather than the boxes alone.
263
+ const reserved = layers.map((layer) => {
264
+ let lowest = Infinity
265
+ let highest = -Infinity
266
+ for (const id of layer) {
267
+ const value = x.get(id) as number
268
+ lowest = Math.min(lowest, value - widthOf(id) / 2)
269
+ highest = Math.max(highest, value + widthOf(id) / 2)
270
+ }
271
+ return { lowest, highest }
272
+ })
273
+
274
+ // A lane is one variable across every row it crosses. The four passes give each of its dummies its own x; the lane
275
+ // takes the mean of what its two ends want, because the straight drop between them is what a reader follows. The rows
276
+ // it crosses are the channel stage's to keep it clear of. It stays a proposal either way: `channels.ts` moves a lane
277
+ // that lands on a box.
278
+ for (const [lane, ends] of laneEnds) x.set(lane, ((x.get(ends[0]) as number) + (x.get(ends[1]) as number)) / 2)
279
+ for (const id of chainOf.keys()) x.delete(id)
280
+
281
+ // The rows keep their order and their gap. A lane in a row does not move a box out of the way; a box does move for
282
+ // the box before it, because two boxes on top of each other is the one thing a drawing may not do.
283
+ for (const row of layers) {
284
+ let cursor = -Infinity
285
+ for (const id of row) {
286
+ if (isDummy(id)) {
287
+ cursor = Math.max(cursor, read(id))
288
+ continue
289
+ }
290
+ const width = widthOf(id)
291
+ const centre = Math.max(x.get(id) as number, cursor + width / 2)
292
+ x.set(id, centre)
293
+ cursor = centre + width / 2 + gap
294
+ }
295
+ }
296
+
297
+ let left = 0
298
+ for (const [id, value] of x) left = Math.min(left, value - widthOf(id) / 2)
299
+ for (const [id, value] of x) x.set(id, value - left)
300
+
301
+ const layerHeight = layers.map((layer) => Math.max(0, ...layer.map((id) => heightOf(id))))
302
+ const layerWidth = layers.map((layer, depth) => {
303
+ let lowest = (reserved[depth].lowest as number) - left
304
+ let highest = (reserved[depth].highest as number) - left
305
+ for (const id of layer) {
306
+ const value = read(id)
307
+ lowest = Math.min(lowest, value - widthOf(id) / 2)
308
+ highest = Math.max(highest, value + widthOf(id) / 2)
309
+ }
310
+ return highest - lowest
311
+ })
312
+ const layerY: number[] = []
313
+ let top = 0
314
+ for (const [depth, height] of layerHeight.entries()) {
315
+ layerY.push(top)
316
+ top += height + options.layerGap
317
+ }
318
+ let width = 0
319
+ for (const [id, value] of x) width = Math.max(width, value + widthOf(id) / 2)
320
+ return {
321
+ x,
322
+ layerY,
323
+ layerHeight,
324
+ layerWidth,
325
+ width,
326
+ height: layerY.length === 0 ? 0 : (layerY[layerY.length - 1] as number) + (layerHeight[layerHeight.length - 1] as number),
327
+ }
328
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Cycle breaking, in the order a layered layout does it.
3
+ *
4
+ * A layered drawing is a drawing of a DAG: an edge that runs back up the page has no layer to go to. The fix is to
5
+ * lay such an edge out backwards and draw its arrow the way the source wrote it - but *which* edges to turn around
6
+ * is a decision, and deciding it edge by edge is what makes two back edges of one cycle end up pointing opposite
7
+ * ways.
8
+ *
9
+ * So the graph is decomposed first. Tarjan's algorithm finds the strongly connected components; contracting each to
10
+ * a node gives the condensation, which is a DAG, so every edge between components already points one way and nothing
11
+ * between them needs reversing. Only inside a component - where by definition every edge lies on a cycle - is there
12
+ * an order to choose: the nodes are ranked once, greedily, and every edge inside the component is oriented along that
13
+ * single ranking. One ranking per component is what "consistently" means: after the pass, every surviving edge of a
14
+ * component points from a lower rank to a higher one, so the component cannot contain a cycle any more.
15
+ *
16
+ * The initial state is a special case: it is layer zero by definition, so an edge that runs into it is a back edge
17
+ * before the decomposition even starts. Turning those around leaves the state with no predecessor, so the ranking
18
+ * of its component puts it first and nothing is ever oriented back into it.
19
+ *
20
+ * Pure: the same graph in, the same edge directions out.
21
+ */
22
+
23
+ import type { LayoutEdge, LayoutGraph } from './layout.js'
24
+
25
+ /** One strongly connected component: a set of nodes that can all reach each other. */
26
+ export interface Component {
27
+ /** The component's index in the condensation, numbered in topological order. */
28
+ id: number
29
+ members: string[]
30
+ }
31
+
32
+ export interface BrokenGraph {
33
+ /** The same edges, in the same order, with the ones that close a cycle turned around. */
34
+ edges: LayoutEdge[]
35
+ /** The diagram edges whose direction the layout changed. */
36
+ reversed: Set<number>
37
+ /** The strongly connected components, numbered in topological order. */
38
+ components: Component[]
39
+ componentOf: Map<string, number>
40
+ /** Each node's rank inside its own component: every intra-component edge points from a lower rank to a higher. */
41
+ rank: Map<string, number>
42
+ /** The condensation: an edge per pair of components that has at least one edge between them. Always acyclic. */
43
+ condensation: Array<{ from: number; to: number }>
44
+ }
45
+
46
+ /** Tarjan's strongly connected components, iterative enough to be read: the recursion is the algorithm. */
47
+ function tarjan(nodes: string[], outgoing: Map<string, string[]>): string[][] {
48
+ let counter = 0
49
+ const index = new Map<string, number>()
50
+ const low = new Map<string, number>()
51
+ const stack: string[] = []
52
+ const onStack = new Set<string>()
53
+ const found: string[][] = []
54
+ const visit = (node: string): void => {
55
+ index.set(node, counter)
56
+ low.set(node, counter)
57
+ counter += 1
58
+ stack.push(node)
59
+ onStack.add(node)
60
+ for (const next of outgoing.get(node) ?? []) {
61
+ if (!index.has(next)) {
62
+ visit(next)
63
+ low.set(node, Math.min(low.get(node) as number, low.get(next) as number))
64
+ continue
65
+ }
66
+ if (onStack.has(next)) low.set(node, Math.min(low.get(node) as number, index.get(next) as number))
67
+ }
68
+ if (low.get(node) !== index.get(node)) return
69
+ const component: string[] = []
70
+ let member: string
71
+ do {
72
+ member = stack.pop() as string
73
+ onStack.delete(member)
74
+ component.push(member)
75
+ } while (member !== node)
76
+ found.push(component)
77
+ }
78
+ for (const node of nodes) if (!index.has(node)) visit(node)
79
+ return found
80
+ }
81
+
82
+ export function breakCycles(graph: LayoutGraph): BrokenGraph {
83
+ const known = new Set(graph.nodes.map((node) => node.id))
84
+ const position = new Map(graph.nodes.map((node, offset) => [node.id, offset] as const))
85
+ const edges = graph.edges.map((edge) => ({ ...edge }))
86
+ const reversed = new Set<number>()
87
+ const flip = (index: number): void => {
88
+ const edge = edges[index]
89
+ const next = edge.from
90
+ edge.from = edge.to
91
+ edge.to = next
92
+ }
93
+
94
+ // The initial state is layer zero: every edge running into it is a back edge already.
95
+ if (graph.init !== undefined && known.has(graph.init)) {
96
+ for (const [index, edge] of edges.entries()) {
97
+ if (edge.to === graph.init && edge.from !== graph.init) flip(index)
98
+ }
99
+ }
100
+
101
+ const outgoing = new Map<string, string[]>()
102
+ for (const edge of edges) {
103
+ if (!known.has(edge.from) || !known.has(edge.to) || edge.from === edge.to) continue
104
+ outgoing.set(edge.from, [...(outgoing.get(edge.from) ?? []), edge.to])
105
+ }
106
+ const nodes = graph.nodes.map((node) => node.id)
107
+ const raw = tarjan(nodes, outgoing)
108
+ // Tarjan emits a component after everything it reaches, which is the reverse of a topological order.
109
+ raw.reverse()
110
+
111
+ const componentOf = new Map<string, number>()
112
+ const components: Component[] = raw.map((members, id) => {
113
+ for (const member of members) componentOf.set(member, id)
114
+ return { id, members }
115
+ })
116
+
117
+ const condensation: Array<{ from: number; to: number }> = []
118
+ const seen = new Set<string>()
119
+ for (const edge of edges) {
120
+ const from = componentOf.get(edge.from)
121
+ const to = componentOf.get(edge.to)
122
+ if (from === undefined || to === undefined || from === to) continue
123
+ const key = String(from) + '>' + String(to)
124
+ if (seen.has(key)) continue
125
+ seen.add(key)
126
+ condensation.push({ from, to })
127
+ }
128
+
129
+ // Inside each component: one ranking, decided greedily by how much of the flow leaves the node rather than enters
130
+ // it. That is the order the edges are made to follow, so no two decisions can contradict each other.
131
+ const rank = new Map<string, number>()
132
+ for (const component of components) {
133
+ if (component.members.length < 2) {
134
+ rank.set(component.members[0], 0)
135
+ continue
136
+ }
137
+ const inside = new Set(component.members)
138
+ const score = new Map<string, number>()
139
+ for (const member of component.members) score.set(member, 0)
140
+ for (const edge of edges) {
141
+ if (!inside.has(edge.from) || !inside.has(edge.to) || edge.from === edge.to) continue
142
+ score.set(edge.from, (score.get(edge.from) as number) + 1)
143
+ score.set(edge.to, (score.get(edge.to) as number) - 1)
144
+ }
145
+ const ordered = [...component.members].sort((left, right) => {
146
+ const difference = (score.get(right) as number) - (score.get(left) as number)
147
+ return difference !== 0 ? difference : (position.get(left) as number) - (position.get(right) as number)
148
+ })
149
+ ordered.forEach((member, offset) => rank.set(member, offset))
150
+ }
151
+
152
+ for (const [index, edge] of edges.entries()) {
153
+ const from = rank.get(edge.from)
154
+ const to = rank.get(edge.to)
155
+ if (from === undefined || to === undefined || edge.from === edge.to) continue
156
+ if (componentOf.get(edge.from) !== componentOf.get(edge.to)) continue
157
+ if (from > to) flip(index)
158
+ }
159
+
160
+ edges.forEach((edge, index) => {
161
+ if (graph.edges[index].from !== edge.from) reversed.add(edge.index)
162
+ })
163
+ return { edges, reversed, components, componentOf, rank, condensation }
164
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Dummy nodes: the placeholders a long edge stands on.
3
+ *
4
+ * An edge from layer 0 to layer 3 has to cross layers 1 and 2. The router can go around whatever is in the way, but a
5
+ * drawing reads better if the edge owns a lane through those layers: then ordering can keep other edges off it, and
6
+ * coordinate assignment can give the whole lane one x. A dummy node is that lane: one per layer the edge crosses.
7
+ *
8
+ * A lane is one variable, not one variable per layer. The instances keep their own id (`#d<edge>_<layer>`) so the
9
+ * ordering stage can sort them inside their layer, and they all point at one lane id (`#e<edge>`) so coordinate
10
+ * assignment cannot give the same edge three different x values.
11
+ *
12
+ * Pure: the layers, the chains, and the map between the two.
13
+ */
14
+
15
+ import type { LayoutEdge } from './layout.js'
16
+ import type { Layering } from './layer.js'
17
+
18
+ /** One edge, as the id it passes through in each layer it crosses. */
19
+ export interface Chain {
20
+ /** The diagram edge's index. */
21
+ index: number
22
+ /** The ids from the layout source to the layout target: the endpoints plus one dummy per crossed layer. */
23
+ path: string[]
24
+ }
25
+
26
+ export interface Augmented {
27
+ /** The layers with the dummies inserted; each dummy sits in the layer it crosses. */
28
+ layers: string[][]
29
+ chains: Chain[]
30
+ /** A dummy instance's lane id: the variable coordinate assignment shares between a long edge's dummies. */
31
+ chainOf: Map<string, string>
32
+ /** The lane id of every edge that needed dummies. */
33
+ laneOf: Map<number, string>
34
+ }
35
+
36
+ export function addDummies(layering: Layering, edges: LayoutEdge[]): Augmented {
37
+ const layers = layering.layers.map((layer) => [...layer])
38
+ const chains: Chain[] = []
39
+ const chainOf = new Map<string, string>()
40
+ const laneOf = new Map<number, string>()
41
+ for (const edge of edges) {
42
+ if (edge.from === edge.to) {
43
+ chains.push({ index: edge.index, path: [edge.from, edge.to] })
44
+ continue
45
+ }
46
+ const from = layering.layerOf.get(edge.from)
47
+ const to = layering.layerOf.get(edge.to)
48
+ if (from === undefined || to === undefined || to <= from) {
49
+ chains.push({ index: edge.index, path: [edge.from, edge.to] })
50
+ continue
51
+ }
52
+ const path = [edge.from]
53
+ for (let cursor = from + 1; cursor < to; cursor += 1) {
54
+ const instance = '#d' + String(edge.index) + '_' + String(cursor)
55
+ const lane = '#e' + String(edge.index)
56
+ chainOf.set(instance, lane)
57
+ laneOf.set(edge.index, lane)
58
+ layers[cursor].push(instance)
59
+ path.push(instance)
60
+ }
61
+ path.push(edge.to)
62
+ chains.push({ index: edge.index, path })
63
+ }
64
+ return { layers, chains, chainOf, laneOf }
65
+ }