dsh-model-everything 0.4.1 → 0.5.1
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/README.en-US.md +75 -19
- package/README.md +377 -345
- package/cordis.patch.yml +9 -0
- package/diagram/package.json +5 -0
- package/lib/client.js +3494 -0
- package/lib/diagram-diff-tool.js +90 -0
- package/lib/diagram-render-tool.js +102 -0
- package/lib/diagram-review-tool.js +99 -0
- package/lib/diagram.js +43 -0
- package/lib/gate.js +14 -12
- package/lib/presentation.js +10 -0
- package/lib/render/budget.js +99 -0
- package/lib/render/channels.js +243 -0
- package/lib/render/collapse.js +155 -0
- package/lib/render/components.js +99 -0
- package/lib/render/coords.js +288 -0
- package/lib/render/cycle-break.js +147 -0
- package/lib/render/diff.js +138 -0
- package/lib/render/dummy.js +43 -0
- package/lib/render/from-mermaid.js +235 -0
- package/lib/render/from-plantuml.js +430 -0
- package/lib/render/index.js +42 -0
- package/lib/render/layer.js +91 -0
- package/lib/render/layout.js +68 -0
- package/lib/render/model.js +17 -0
- package/lib/render/network-simplex.js +272 -0
- package/lib/render/order.js +122 -0
- package/lib/render/paint.js +891 -0
- package/lib/render/palette.js +127 -0
- package/lib/render/place.js +482 -0
- package/lib/render/route.js +225 -0
- package/lib/render/svg.js +229 -0
- package/lib/render/symbol.js +108 -0
- package/lib/render/text.js +74 -0
- package/lib/render/viewmodel.js +126 -0
- package/lib/render/wrap.js +87 -0
- package/lib/render/write.js +14 -0
- package/lib/scan.js +248 -8
- package/lib/structure-tool.js +2 -2
- package/lib/types/client.d.ts +83 -0
- package/lib/types/diagram-diff-tool.d.ts +8 -0
- package/lib/types/diagram-render-tool.d.ts +8 -0
- package/lib/types/diagram-review-tool.d.ts +8 -0
- package/lib/types/diagram.d.ts +34 -0
- package/lib/types/presentation.d.ts +7 -0
- package/lib/types/render/budget.d.ts +55 -0
- package/lib/types/render/channels.d.ts +63 -0
- package/lib/types/render/collapse.d.ts +23 -0
- package/lib/types/render/components.d.ts +57 -0
- package/lib/types/render/coords.d.ts +64 -0
- package/lib/types/render/cycle-break.d.ts +45 -0
- package/lib/types/render/diff.d.ts +44 -0
- package/lib/types/render/dummy.d.ts +32 -0
- package/lib/types/render/from-mermaid.d.ts +27 -0
- package/lib/types/render/from-plantuml.d.ts +33 -0
- package/lib/types/render/index.d.ts +38 -0
- package/lib/types/render/layer.d.ts +28 -0
- package/lib/types/render/layout.d.ts +54 -0
- package/lib/types/render/model.d.ts +73 -0
- package/lib/types/render/network-simplex.d.ts +54 -0
- package/lib/types/render/order.d.ts +27 -0
- package/lib/types/render/paint.d.ts +127 -0
- package/lib/types/render/palette.d.ts +41 -0
- package/lib/types/render/place.d.ts +55 -0
- package/lib/types/render/route.d.ts +77 -0
- package/lib/types/render/svg.d.ts +19 -0
- package/lib/types/render/symbol.d.ts +73 -0
- package/lib/types/render/text.d.ts +38 -0
- package/lib/types/render/viewmodel.d.ts +20 -0
- package/lib/types/render/wrap.d.ts +27 -0
- package/lib/types/render/write.d.ts +14 -0
- package/lib/types/scan.d.ts +10 -2
- package/lib/types/structure.d.ts +1 -0
- package/package.json +148 -132
- package/skills/model-everything/SKILL.md +454 -454
- package/skills/model-everything/references/architecture-domains.md +137 -0
- package/skills/model-everything/references/architecture-views.md +119 -0
- package/skills/model-everything-diagram/SKILL.md +84 -0
- package/src/client.ts +307 -0
- package/src/diagram-diff-tool.ts +94 -0
- package/src/diagram-render-tool.ts +108 -0
- package/src/diagram-review-tool.ts +109 -0
- package/src/diagram.ts +61 -0
- package/src/gate.ts +14 -12
- package/src/presentation.ts +12 -0
- package/src/render/budget.ts +135 -0
- package/src/render/channels.ts +309 -0
- package/src/render/collapse.ts +150 -0
- package/src/render/components.ts +126 -0
- package/src/render/coords.ts +328 -0
- package/src/render/cycle-break.ts +164 -0
- package/src/render/diff.ts +162 -0
- package/src/render/dummy.ts +65 -0
- package/src/render/from-mermaid.ts +261 -0
- package/src/render/from-plantuml.ts +469 -0
- package/src/render/index.ts +73 -0
- package/src/render/layer.ts +98 -0
- package/src/render/layout.ts +101 -0
- package/src/render/model.ts +86 -0
- package/src/render/network-simplex.ts +290 -0
- package/src/render/order.ts +139 -0
- package/src/render/paint.ts +1006 -0
- package/src/render/palette.ts +164 -0
- package/src/render/place.ts +527 -0
- package/src/render/route.ts +269 -0
- package/src/render/svg.ts +270 -0
- package/src/render/symbol.ts +137 -0
- package/src/render/text.ts +86 -0
- package/src/render/viewmodel.ts +136 -0
- package/src/render/wrap.ts +103 -0
- package/src/render/write.ts +17 -0
- package/src/scan.ts +807 -564
- package/src/structure-tool.ts +2 -2
- package/src/structure.ts +1 -1
|
@@ -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,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural diff between two drawings.
|
|
3
|
+
*
|
|
4
|
+
* A text diff of two diagrams tells a reader that a line moved; it does not tell them that `Cache` left the
|
|
5
|
+
* `Platform` package, or that `Core` gained a nested boundary. Those are the changes a design review is about,
|
|
6
|
+
* and they are decided by comparing the containment tree, not the source.
|
|
7
|
+
*
|
|
8
|
+
* The diff judges movement, not merit: a group that moved, a member that changed owner, a boundary added or
|
|
9
|
+
* removed. Whether the new arrangement is better is the reader's call, so the verdict only says whether anything
|
|
10
|
+
* changed and whether the comparison could run at all.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Diagram, DiagramGroup } from './model.js'
|
|
14
|
+
|
|
15
|
+
export type ChangeKind = 'added' | 'removed' | 'renamed' | 'moved' | 'regrouped'
|
|
16
|
+
|
|
17
|
+
export interface StructuralChange {
|
|
18
|
+
code: string
|
|
19
|
+
severity: 'error' | 'warning' | 'info'
|
|
20
|
+
kind: ChangeKind
|
|
21
|
+
/** What changed, by id: a group id or a node id. */
|
|
22
|
+
subject: string
|
|
23
|
+
message: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface DiffReport {
|
|
27
|
+
verdict: 'pass' | 'pass_with_findings' | 'fail'
|
|
28
|
+
changes: StructuralChange[]
|
|
29
|
+
/** Both sides, so an empty change list cannot be confused with a comparison that never ran. */
|
|
30
|
+
measured: { before: { nodes: number; groups: number }; after: { nodes: number; groups: number } }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The group that directly holds a node, from the side's own declarations. */
|
|
34
|
+
function ownerOf(groups: DiagramGroup[], nodeId: string): string | undefined {
|
|
35
|
+
return groups.find((group) => group.members.includes(nodeId))?.id
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function labels(groups: DiagramGroup[]): Map<string, string> {
|
|
39
|
+
return new Map(groups.map((group) => [group.id, group.label] as const))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parents(groups: DiagramGroup[]): Map<string, string | undefined> {
|
|
43
|
+
return new Map(groups.map((group) => [group.id, group.parent] as const))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** How a group is named in a finding: its label, falling back to its id when the other side has no label. */
|
|
47
|
+
function nameOf(labelsById: Map<string, string>, id: string): string {
|
|
48
|
+
return labelsById.get(id) ?? id
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Compare the containment structure of two drawings.
|
|
53
|
+
*
|
|
54
|
+
* Renames are reported by id: a group that kept its id but changed its text is a rename, while a group whose id
|
|
55
|
+
* is gone and another whose id is new are an unrelated removal and addition, because the ids are what a caller
|
|
56
|
+
* tracks across versions.
|
|
57
|
+
*/
|
|
58
|
+
export function diffStructure(before: Diagram, after: Diagram): DiffReport {
|
|
59
|
+
const beforeGroups = before.groups ?? []
|
|
60
|
+
const afterGroups = after.groups ?? []
|
|
61
|
+
const changes: StructuralChange[] = []
|
|
62
|
+
|
|
63
|
+
const beforeIds = new Set(beforeGroups.map((group) => group.id))
|
|
64
|
+
const afterIds = new Set(afterGroups.map((group) => group.id))
|
|
65
|
+
const beforeLabels = labels(beforeGroups)
|
|
66
|
+
const afterLabels = labels(afterGroups)
|
|
67
|
+
const beforeParents = parents(beforeGroups)
|
|
68
|
+
const afterParents = parents(afterGroups)
|
|
69
|
+
|
|
70
|
+
for (const group of afterGroups) {
|
|
71
|
+
if (beforeIds.has(group.id)) continue
|
|
72
|
+
changes.push({
|
|
73
|
+
code: 'H1_GROUP_ADDED',
|
|
74
|
+
severity: 'info',
|
|
75
|
+
kind: 'added',
|
|
76
|
+
subject: group.id,
|
|
77
|
+
message: 'group "' + group.label + '" is new' + (group.parent === undefined ? '' : ' inside "' + nameOf(afterLabels, group.parent) + '"'),
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
for (const group of beforeGroups) {
|
|
81
|
+
if (afterIds.has(group.id)) continue
|
|
82
|
+
changes.push({
|
|
83
|
+
code: 'H2_GROUP_REMOVED',
|
|
84
|
+
severity: 'info',
|
|
85
|
+
kind: 'removed',
|
|
86
|
+
subject: group.id,
|
|
87
|
+
message: 'group "' + group.label + '" is gone',
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const group of afterGroups) {
|
|
92
|
+
if (!beforeIds.has(group.id)) continue
|
|
93
|
+
const wasCalled = nameOf(beforeLabels, group.id)
|
|
94
|
+
if (wasCalled !== group.label) {
|
|
95
|
+
changes.push({
|
|
96
|
+
code: 'H3_GROUP_RENAMED',
|
|
97
|
+
severity: 'info',
|
|
98
|
+
kind: 'renamed',
|
|
99
|
+
subject: group.id,
|
|
100
|
+
message: 'group "' + wasCalled + '" is now called "' + group.label + '"',
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
const was = beforeParents.get(group.id)
|
|
104
|
+
const now = group.parent
|
|
105
|
+
if (was !== now) {
|
|
106
|
+
const from = was === undefined ? 'the top level' : '"' + nameOf(afterLabels, was) + '"'
|
|
107
|
+
const to = now === undefined ? 'the top level' : '"' + nameOf(afterLabels, now) + '"'
|
|
108
|
+
changes.push({
|
|
109
|
+
code: 'H4_GROUP_MOVED',
|
|
110
|
+
severity: 'warning',
|
|
111
|
+
kind: 'moved',
|
|
112
|
+
subject: group.id,
|
|
113
|
+
message: 'group "' + group.label + '" moved from ' + from + ' to ' + to,
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const beforeNodes = new Set(before.nodes.map((node) => node.id))
|
|
119
|
+
const afterNodes = new Set(after.nodes.map((node) => node.id))
|
|
120
|
+
for (const node of after.nodes) {
|
|
121
|
+
if (beforeNodes.has(node.id)) continue
|
|
122
|
+
changes.push({ code: 'H5_NODE_ADDED', severity: 'info', kind: 'added', subject: node.id, message: 'node "' + node.id + '" is new' })
|
|
123
|
+
}
|
|
124
|
+
for (const node of before.nodes) {
|
|
125
|
+
if (afterNodes.has(node.id)) continue
|
|
126
|
+
changes.push({ code: 'H6_NODE_REMOVED', severity: 'info', kind: 'removed', subject: node.id, message: 'node "' + node.id + '" is gone' })
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Movement is only meaningful for a node that exists on both sides.
|
|
130
|
+
for (const node of after.nodes) {
|
|
131
|
+
if (!beforeNodes.has(node.id)) continue
|
|
132
|
+
const was = ownerOf(beforeGroups, node.id)
|
|
133
|
+
const now = ownerOf(afterGroups, node.id)
|
|
134
|
+
if (was === now) continue
|
|
135
|
+
const from = was === undefined ? 'no group' : '"' + nameOf(beforeLabels, was) + '"'
|
|
136
|
+
const to = now === undefined ? 'no group' : '"' + nameOf(afterLabels, now) + '"'
|
|
137
|
+
changes.push({
|
|
138
|
+
code: 'H7_NODE_REGROUPED',
|
|
139
|
+
severity: 'warning',
|
|
140
|
+
kind: 'regrouped',
|
|
141
|
+
subject: node.id,
|
|
142
|
+
message: 'node "' + node.id + '" moved from ' + from + ' to ' + to,
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const measured = {
|
|
147
|
+
before: { nodes: before.nodes.length, groups: beforeGroups.length },
|
|
148
|
+
after: { nodes: after.nodes.length, groups: afterGroups.length },
|
|
149
|
+
}
|
|
150
|
+
// Nothing to compare is not a pass: an empty report from an empty pair would read as "unchanged".
|
|
151
|
+
if (before.nodes.length === 0 && after.nodes.length === 0) {
|
|
152
|
+
return {
|
|
153
|
+
verdict: 'fail',
|
|
154
|
+
changes: [
|
|
155
|
+
{ code: 'D0_NO_NODES', severity: 'error', kind: 'removed', subject: '-', message: 'neither side holds a node, so there was nothing to compare' },
|
|
156
|
+
...changes,
|
|
157
|
+
],
|
|
158
|
+
measured,
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return { verdict: changes.length === 0 ? 'pass' : 'pass_with_findings', changes, measured }
|
|
162
|
+
}
|