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,1006 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Geometry: what a diagram turns into, in coordinates.
|
|
3
|
+
*
|
|
4
|
+
* A drawing has to survive three containers - inline in a conversation, full-screen, and a persistent
|
|
5
|
+
* sidebar tab - so nothing here knows about pixel size. It emits a content-sized canvas and lets the
|
|
6
|
+
* container scale it: the same numbers serve a 360px card and a 1200px whiteboard.
|
|
7
|
+
*
|
|
8
|
+
* The drawing is placed by the layered pipeline (`place.ts`), which also says which lane a long edge reserved.
|
|
9
|
+
* Edges are drawn from where the boxes ended up, in two ways only: a long edge follows its lane as one polyline,
|
|
10
|
+
* and everything else goes through the orthogonal router. Which side of a box an edge leaves by is read off the
|
|
11
|
+
* route, not picked by comparing the boxes' centres: a route between two rows arrives vertically, so the endpoint
|
|
12
|
+
* is snapped onto the top or bottom perimeter in the direction the route approaches. Where along that side it sits
|
|
13
|
+
* follows the symbol: a rectangle spreads its ports along the side, a cylinder's top port sits on its cap curve, and a
|
|
14
|
+
* diamond or a circle has four legal points and no side to spread along at all.
|
|
15
|
+
*
|
|
16
|
+
* A symbol with discrete points gives each of its edges a point of its own: the point whose outward normal faces the
|
|
17
|
+
* box at the other end is the one that edge takes, and an edge only shares a point when all four are spoken for. A
|
|
18
|
+
* node with a run on its side keeps doing what it always did, because there the ports are places on the run and not
|
|
19
|
+
* points at all. Sharing is the fallback, not the rule: two labelled branches of a decision leave by two vertices.
|
|
20
|
+
*
|
|
21
|
+
* An edge's name is drawn on the edge, not beside it: it is anchored at the midpoint of the edge's longest straight
|
|
22
|
+
* run, over a rectangle in the page colour that masks the line behind the text, and it slides along that run when
|
|
23
|
+
* something is in the way. Labels are placed after every edge is drawn, because what a label has to avoid is the
|
|
24
|
+
* whole picture.
|
|
25
|
+
*
|
|
26
|
+
* Pure: a diagram in, shapes out, every decision assertable.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { MARGIN, place, textOf, type Box } from './place.js'
|
|
30
|
+
import { PORT_INSET } from './channels.js'
|
|
31
|
+
import { anchor, approachSides, crossesBox, normalOf, ROUTE_STUB, routeEdge, simplify, type Ports, type Side } from './route.js'
|
|
32
|
+
import { cylinderApex, cylinderCaps, hasDiscretePorts, hexagonCut, stadiumRadius } from './symbol.js'
|
|
33
|
+
import { textWidth } from './text.js'
|
|
34
|
+
import { palette, roleForVerdict, type ColourRole, type Palette, type PaletteOptions } from './palette.js'
|
|
35
|
+
import { labelOf, type Diagram, type DiagramEdge, type DiagramGroup, type DiagramNode, type EdgeArrow, type NodeShape } from './model.js'
|
|
36
|
+
|
|
37
|
+
export interface Point {
|
|
38
|
+
x: number
|
|
39
|
+
y: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface BoxShape {
|
|
43
|
+
kind: 'box'
|
|
44
|
+
x: number
|
|
45
|
+
y: number
|
|
46
|
+
width: number
|
|
47
|
+
height: number
|
|
48
|
+
/** The lines to draw, already wrapped; `label` keeps the whole text for anything that reads the shape. */
|
|
49
|
+
label: string
|
|
50
|
+
lines: string[]
|
|
51
|
+
/** The symbol to draw. Undefined means a rectangle. */
|
|
52
|
+
shape?: NodeShape
|
|
53
|
+
colour: ColourRole
|
|
54
|
+
initial: boolean
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface LinkShape {
|
|
58
|
+
kind: 'link'
|
|
59
|
+
x1: number
|
|
60
|
+
y1: number
|
|
61
|
+
x2: number
|
|
62
|
+
y2: number
|
|
63
|
+
label: string
|
|
64
|
+
/** The centre the label is drawn on, and therefore a point on the edge itself. */
|
|
65
|
+
labelX: number
|
|
66
|
+
labelY: number
|
|
67
|
+
colour: ColourRole
|
|
68
|
+
/** A dashed line, when the notation distinguished one. */
|
|
69
|
+
dashed?: boolean
|
|
70
|
+
/** The arrow the source edge stated. Undefined means the plain arrow every edge has been drawn with. */
|
|
71
|
+
arrow?: EdgeArrow
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface PathShape {
|
|
75
|
+
kind: 'path'
|
|
76
|
+
points: Point[]
|
|
77
|
+
label: string
|
|
78
|
+
/** The centre the label is drawn on, and therefore a point on the edge itself. */
|
|
79
|
+
labelX: number
|
|
80
|
+
labelY: number
|
|
81
|
+
colour: ColourRole
|
|
82
|
+
dashed?: boolean
|
|
83
|
+
arrow?: EdgeArrow
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A boundary around several states: a package, a layer, a subgraph. */
|
|
87
|
+
export interface GroupShape {
|
|
88
|
+
kind: 'group'
|
|
89
|
+
/** The group's own id, so a container can fold it without guessing from the label. */
|
|
90
|
+
id: string
|
|
91
|
+
x: number
|
|
92
|
+
y: number
|
|
93
|
+
width: number
|
|
94
|
+
height: number
|
|
95
|
+
label: string
|
|
96
|
+
colour: ColourRole
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface CaptionShape {
|
|
100
|
+
kind: 'caption'
|
|
101
|
+
x: number
|
|
102
|
+
y: number
|
|
103
|
+
lines: string[]
|
|
104
|
+
colour: ColourRole
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface TitleShape {
|
|
108
|
+
kind: 'title'
|
|
109
|
+
x: number
|
|
110
|
+
y: number
|
|
111
|
+
text: string
|
|
112
|
+
colour: ColourRole
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export type Shape = GroupShape | BoxShape | LinkShape | PathShape | CaptionShape | TitleShape
|
|
116
|
+
|
|
117
|
+
/** Which side of each box an edge leaves by and arrives at, and, for a long edge, the lane x it meets there. */
|
|
118
|
+
interface Facing {
|
|
119
|
+
exit: Side
|
|
120
|
+
entry: Side
|
|
121
|
+
at?: number
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface Canvas {
|
|
125
|
+
width: number
|
|
126
|
+
height: number
|
|
127
|
+
shapes: Shape[]
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Geometry constants in one place: a container with different proportions changes these, not the rules. */
|
|
131
|
+
const TITLE_HEIGHT = 26
|
|
132
|
+
const CAPTION_CHARS = 78
|
|
133
|
+
const CAPTION_LINE = 15
|
|
134
|
+
const CAPTION_GAP = 16
|
|
135
|
+
const CAPTION_CHAR_WIDTH = 6.2
|
|
136
|
+
|
|
137
|
+
/** The closest two ports on one side may sit, so two edges never leave from the same pixel. */
|
|
138
|
+
const PORT_SEPARATION = 14
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The sides of a box, in the order a symbol's own points are drawn: a diamond's top, right, bottom and left vertices.
|
|
142
|
+
*
|
|
143
|
+
* A shape with discrete ports has exactly one legal point per side, so this is also the order its points are offered
|
|
144
|
+
* to the edges that want one, and the order a tie between two equally good sides falls back to.
|
|
145
|
+
*/
|
|
146
|
+
const PORT_SIDES: Side[] = ['top', 'right', 'bottom', 'left']
|
|
147
|
+
|
|
148
|
+
/** Is this one of the two sides a vertical run leaves by? A run is vertical in its own axis, not in the page. */
|
|
149
|
+
const isVerticalSide = (side: Side): boolean => side === 'top' || side === 'bottom'
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* How far apart two edges that join the same pair are drawn.
|
|
153
|
+
*
|
|
154
|
+
* Wider than the least two ports may sit: two statements about one pair have to read as two lines, and a gap the
|
|
155
|
+
* port packer would allow is a gap a reader sees as one thick line. Exported so a test can name the pattern.
|
|
156
|
+
*/
|
|
157
|
+
export const MULTI_EDGE_SPACING = 20
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Spread the ports of one side along it: each stays as close to the point it asked for as the gap to its neighbours
|
|
161
|
+
* allows, and the whole row is kept on the box.
|
|
162
|
+
*
|
|
163
|
+
* This is the same monotone fit coordinate assignment uses on x, applied to one side of one box: a forward pass that
|
|
164
|
+
* keeps every port at least `PORT_SEPARATION` to the right of the one before it, then a backward pass that pulls the
|
|
165
|
+
* row back onto the box without closing those gaps.
|
|
166
|
+
*/
|
|
167
|
+
function packPorts(wanted: number[], low: number, high: number): number[] {
|
|
168
|
+
const placed: number[] = []
|
|
169
|
+
let previous = low - PORT_SEPARATION
|
|
170
|
+
for (const value of wanted) {
|
|
171
|
+
previous = Math.min(high, Math.max(value, previous + PORT_SEPARATION))
|
|
172
|
+
placed.push(previous)
|
|
173
|
+
}
|
|
174
|
+
let next = high + PORT_SEPARATION
|
|
175
|
+
for (let index = placed.length - 1; index >= 0; index -= 1) {
|
|
176
|
+
next = Math.max(low, Math.min(placed[index], next - PORT_SEPARATION))
|
|
177
|
+
placed[index] = next
|
|
178
|
+
}
|
|
179
|
+
return placed
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Which colour a box earns from its role. One mapping, used by every container. */
|
|
183
|
+
export function roleOfNode(node: DiagramNode): ColourRole {
|
|
184
|
+
if (node.role === 'error') return 'fail'
|
|
185
|
+
if (node.role === 'warning') return 'findings'
|
|
186
|
+
if (node.role === 'muted') return 'muted'
|
|
187
|
+
return 'edge'
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Wrap on spaces; break a token longer than the line rather than let it overflow the canvas. */
|
|
191
|
+
function wrap(text: string, limit: number): string[] {
|
|
192
|
+
const lines: string[] = []
|
|
193
|
+
let line = ''
|
|
194
|
+
for (const word of text.split(/\s+/).filter((part) => part !== '')) {
|
|
195
|
+
const candidate = line === '' ? word : line + ' ' + word
|
|
196
|
+
if (candidate.length <= limit) {
|
|
197
|
+
line = candidate
|
|
198
|
+
continue
|
|
199
|
+
}
|
|
200
|
+
if (line !== '') lines.push(line)
|
|
201
|
+
line = ''
|
|
202
|
+
let rest = word
|
|
203
|
+
while (rest.length > limit) {
|
|
204
|
+
lines.push(rest.slice(0, limit))
|
|
205
|
+
rest = rest.slice(limit)
|
|
206
|
+
}
|
|
207
|
+
line = rest
|
|
208
|
+
}
|
|
209
|
+
if (line !== '') lines.push(line)
|
|
210
|
+
return lines.length === 0 ? [''] : lines
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The colour a caption earns, read from the verdict it quotes. */
|
|
214
|
+
function captionRole(caption: string): ColourRole {
|
|
215
|
+
return roleForVerdict(caption.split(' — ')[0] as string)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* How tall the mask an edge label is drawn over is.
|
|
221
|
+
*
|
|
222
|
+
* Exported because two modules must agree on it: the painter reserves the rectangle when it looks for a clear
|
|
223
|
+
* place, and the writer draws the rectangle that was reserved. A label with two sizes is a label half masked.
|
|
224
|
+
*/
|
|
225
|
+
export const LABEL_HEIGHT = 12
|
|
226
|
+
|
|
227
|
+
/** The room a label's mask keeps from anything it may not cover, so two rectangles never read as one. */
|
|
228
|
+
const LABEL_PAD = 2
|
|
229
|
+
|
|
230
|
+
/** How far a label steps along its own run while it looks for a clear place to sit. */
|
|
231
|
+
const LABEL_STEP = 3
|
|
232
|
+
|
|
233
|
+
/** A straight piece of an edge, as the collision test needs it. */
|
|
234
|
+
interface Segment {
|
|
235
|
+
x1: number
|
|
236
|
+
y1: number
|
|
237
|
+
x2: number
|
|
238
|
+
y2: number
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Where a label sits: the point it is centred on, and the rectangle its mask covers. */
|
|
242
|
+
interface LabelSpot {
|
|
243
|
+
x: number
|
|
244
|
+
y: number
|
|
245
|
+
width: number
|
|
246
|
+
height: number
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** The point an edge's label is drawn at.
|
|
250
|
+
*
|
|
251
|
+
* A label is not furniture parked beside the line: it names the transition, so it sits on the line - anchored at
|
|
252
|
+
* the middle of the longest straight run of its own edge - and the writer masks the stretch of line it covers.
|
|
253
|
+
* Text beside a line can be read as belonging to whatever it happens to sit next to; text that interrupts its own
|
|
254
|
+
* line reads as part of it.
|
|
255
|
+
*
|
|
256
|
+
* The anchor then has to be earned. A mask over a box hides a border, over another label it makes both
|
|
257
|
+
* unreadable, and over a third edge's line it hides that line. So the label walks along its own run in small
|
|
258
|
+
* steps, in both directions and bounded so its rectangle stays on that run, and takes the first place that is
|
|
259
|
+
* clear. It never leaves the run: a label moved onto a neighbouring edge is a label that names the wrong thing.
|
|
260
|
+
*/
|
|
261
|
+
function placeLabel(
|
|
262
|
+
label: string,
|
|
263
|
+
from: Point,
|
|
264
|
+
to: Point,
|
|
265
|
+
boxes: Iterable<Box>,
|
|
266
|
+
placed: LabelSpot[],
|
|
267
|
+
segments: Segment[],
|
|
268
|
+
): LabelSpot {
|
|
269
|
+
const width = textWidth(label)
|
|
270
|
+
const height = LABEL_HEIGHT
|
|
271
|
+
const spotAt = (x: number, y: number): LabelSpot => ({ x, y, width, height })
|
|
272
|
+
const hitsBox = (spot: LabelSpot, box: Box): boolean =>
|
|
273
|
+
spot.x - spot.width / 2 < box.x + box.width + LABEL_PAD &&
|
|
274
|
+
spot.x + spot.width / 2 > box.x - LABEL_PAD &&
|
|
275
|
+
spot.y - spot.height / 2 < box.y + box.height + LABEL_PAD &&
|
|
276
|
+
spot.y + spot.height / 2 > box.y - LABEL_PAD
|
|
277
|
+
const hitsSpot = (spot: LabelSpot, other: LabelSpot): boolean =>
|
|
278
|
+
spot.x - spot.width / 2 < other.x + other.width / 2 + LABEL_PAD &&
|
|
279
|
+
spot.x + spot.width / 2 > other.x - other.width / 2 - LABEL_PAD &&
|
|
280
|
+
spot.y - spot.height / 2 < other.y + other.height / 2 + LABEL_PAD &&
|
|
281
|
+
spot.y + spot.height / 2 > other.y - other.height / 2 - LABEL_PAD
|
|
282
|
+
// A label on top of a line is as unreadable as one on top of a box: the mask hides the stretch of line it
|
|
283
|
+
// covers, which on its own edge is the point of the mask and on any other edge is a line that goes missing.
|
|
284
|
+
const hitsSegment = (spot: LabelSpot, segment: Segment): boolean =>
|
|
285
|
+
spot.x + spot.width / 2 > Math.min(segment.x1, segment.x2) - LABEL_PAD &&
|
|
286
|
+
spot.x - spot.width / 2 < Math.max(segment.x1, segment.x2) + LABEL_PAD &&
|
|
287
|
+
spot.y + spot.height / 2 > Math.min(segment.y1, segment.y2) - LABEL_PAD &&
|
|
288
|
+
spot.y - spot.height / 2 < Math.max(segment.y1, segment.y2) + LABEL_PAD
|
|
289
|
+
const clearOfBoxes = (spot: LabelSpot): boolean => {
|
|
290
|
+
for (const box of boxes) if (hitsBox(spot, box)) return false
|
|
291
|
+
for (const earlier of placed) if (hitsSpot(spot, earlier)) return false
|
|
292
|
+
return true
|
|
293
|
+
}
|
|
294
|
+
const clearOfEdges = (spot: LabelSpot): boolean => {
|
|
295
|
+
for (const segment of segments) if (hitsSegment(spot, segment)) return false
|
|
296
|
+
return true
|
|
297
|
+
}
|
|
298
|
+
const middle = { x: (from.x + to.x) / 2, y: (from.y + to.y) / 2 }
|
|
299
|
+
// The run is axis-aligned, so its Manhattan length is its length.
|
|
300
|
+
const dx = to.x - from.x
|
|
301
|
+
const dy = to.y - from.y
|
|
302
|
+
const length = Math.abs(dx) + Math.abs(dy)
|
|
303
|
+
const ux = dx / length
|
|
304
|
+
const uy = dy / length
|
|
305
|
+
// The walk is bounded so the label's own rectangle stays on the run that names it: a mask that hangs off the
|
|
306
|
+
// end of its segment would be half over the next thing and would not read as sitting on this edge.
|
|
307
|
+
const along = (Math.abs(ux) * width) / 2 + (Math.abs(uy) * height) / 2
|
|
308
|
+
const bound = Math.max(0, length / 2 - along)
|
|
309
|
+
const walk: number[] = [0]
|
|
310
|
+
for (let step = LABEL_STEP; step <= bound + 0.001; step += LABEL_STEP) walk.push(step, -step)
|
|
311
|
+
const at = (offset: number): LabelSpot => spotAt(middle.x + ux * offset, middle.y + uy * offset)
|
|
312
|
+
for (const offset of walk) {
|
|
313
|
+
const spot = at(offset)
|
|
314
|
+
if (clearOfBoxes(spot) && clearOfEdges(spot)) return spot
|
|
315
|
+
}
|
|
316
|
+
// Nothing on the run is clear of every other edge - two runs may lie side by side for their whole length, and
|
|
317
|
+
// then no place on one is clear of the other. Covering a neighbour's line is the least of the three: text over
|
|
318
|
+
// a line still reads, while text over a box hides the box and text over text is unreadable. The label stays
|
|
319
|
+
// on its own edge, which is the one thing it may not give up.
|
|
320
|
+
for (const offset of walk) {
|
|
321
|
+
const spot = at(offset)
|
|
322
|
+
if (clearOfBoxes(spot)) return spot
|
|
323
|
+
}
|
|
324
|
+
return at(0)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
/** The furthest right and bottom point any shape reaches. */
|
|
329
|
+
function drawnExtent(shapes: Shape[]): { width: number; height: number } {
|
|
330
|
+
let width = 0
|
|
331
|
+
let height = 0
|
|
332
|
+
const consider = (x: number, y: number): void => {
|
|
333
|
+
if (x > width) width = x
|
|
334
|
+
if (y > height) height = y
|
|
335
|
+
}
|
|
336
|
+
for (const shape of shapes) {
|
|
337
|
+
switch (shape.kind) {
|
|
338
|
+
case 'box':
|
|
339
|
+
case 'group':
|
|
340
|
+
consider(shape.x + shape.width, shape.y + shape.height)
|
|
341
|
+
break
|
|
342
|
+
case 'link':
|
|
343
|
+
consider(Math.max(shape.x1, shape.x2), Math.max(shape.y1, shape.y2))
|
|
344
|
+
if (shape.label !== '') consider(shape.labelX + textWidth(shape.label) / 2, shape.labelY + LABEL_HEIGHT / 2)
|
|
345
|
+
break
|
|
346
|
+
case 'path': {
|
|
347
|
+
for (const point of shape.points) consider(point.x, point.y)
|
|
348
|
+
if (shape.label !== '') consider(shape.labelX + textWidth(shape.label) / 2, shape.labelY + LABEL_HEIGHT / 2)
|
|
349
|
+
break
|
|
350
|
+
}
|
|
351
|
+
case 'caption':
|
|
352
|
+
case 'title':
|
|
353
|
+
consider(textWidth((shape as { lines?: string[]; text?: string }).lines?.[0] ?? '') + shape.x, shape.y + 12)
|
|
354
|
+
break
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return { width, height }
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export function paintWith(diagram: Diagram, overrides: Partial<Palette> = {}, options: PaletteOptions = {}): { canvas: Canvas; colours: Palette } {
|
|
361
|
+
const colours = palette(overrides, options)
|
|
362
|
+
const shapes: Shape[] = []
|
|
363
|
+
|
|
364
|
+
const groups = diagram.groups ?? []
|
|
365
|
+
const topOffset = MARGIN + TITLE_HEIGHT
|
|
366
|
+
const placement = place(diagram, topOffset)
|
|
367
|
+
const { boxes, groupRects } = placement
|
|
368
|
+
|
|
369
|
+
// An edge may end on a boundary instead of a box: the boundary's rectangle is then the port surface, and
|
|
370
|
+
// the arrow head lands on its border. A group is not a member box, so every lookup that used to ask only
|
|
371
|
+
// `boxes` asks this instead.
|
|
372
|
+
const endBox = (id: string): Box | undefined => boxes.get(id) ?? groupRects.get(id)
|
|
373
|
+
|
|
374
|
+
// The symbol a box is drawn as, which decides which points on its sides an edge may meet. A boundary, and a node
|
|
375
|
+
// the source gave no shape, is a rectangle.
|
|
376
|
+
const shapeById = new Map<string, NodeShape>()
|
|
377
|
+
for (const node of diagram.nodes) if (node.shape !== undefined && !shapeById.has(node.id)) shapeById.set(node.id, node.shape)
|
|
378
|
+
const shapeOf = (id: string): NodeShape => shapeById.get(id) ?? 'rect'
|
|
379
|
+
|
|
380
|
+
// Boundaries first, so everything else is painted on top of them.
|
|
381
|
+
for (const group of groups) {
|
|
382
|
+
const rect = groupRects.get(group.id)
|
|
383
|
+
if (rect === undefined) continue
|
|
384
|
+
shapes.push({ kind: 'group', id: group.id, x: rect.x, y: rect.y, width: rect.width, height: rect.height, label: group.label, colour: 'muted' })
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const drawn = new Set<string>()
|
|
388
|
+
// A Map iterator is consumed by the first pass: collision checks need a list.
|
|
389
|
+
const boxList = Array.from(boxes.values())
|
|
390
|
+
/** The edges drawn as lines, in paint order: their labels are placed once every one of them is known. */
|
|
391
|
+
const routes: Array<LinkShape | PathShape> = []
|
|
392
|
+
for (const node of diagram.nodes) {
|
|
393
|
+
// A state id may repeat in a malformed model: the first placement wins and the repeat is skipped.
|
|
394
|
+
if (drawn.has(node.id)) continue
|
|
395
|
+
const box = boxes.get(node.id) as Box
|
|
396
|
+
drawn.add(node.id)
|
|
397
|
+
const text = textOf(node)
|
|
398
|
+
shapes.push({
|
|
399
|
+
kind: 'box',
|
|
400
|
+
x: box.x,
|
|
401
|
+
y: box.y,
|
|
402
|
+
width: box.width,
|
|
403
|
+
height: box.height,
|
|
404
|
+
label: labelOf(node),
|
|
405
|
+
lines: text.lines,
|
|
406
|
+
...(node.shape === undefined ? {} : { shape: node.shape }),
|
|
407
|
+
colour: roleOfNode(node),
|
|
408
|
+
initial: diagram.init === node.id,
|
|
409
|
+
})
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const roleById = new Map(diagram.nodes.map((node) => [node.id, roleOfNode(node)] as const))
|
|
413
|
+
|
|
414
|
+
// One pair may carry two statements - an association and a composition, or the same arrow written twice. They are
|
|
415
|
+
// two relations, not one, and one bend path between them reads as a thick line or as a mistake. So every edge of
|
|
416
|
+
// such a set is a line of its own: the first keeps the place the drawing gave it, and the rest take an offset
|
|
417
|
+
// perpendicular to the direction they run, alternating sign and growing - 0, +1, -1, +2, -2 spacings - so the set
|
|
418
|
+
// fans out from the pair instead of doubling it. A self loop is left out: it has no main direction to be
|
|
419
|
+
// perpendicular to, and its own shape is already not a line between two sides.
|
|
420
|
+
const multiEdgeSets = new Map<string, number[]>()
|
|
421
|
+
diagram.edges.forEach((edge, index) => {
|
|
422
|
+
if (edge.from === edge.to) return
|
|
423
|
+
if (endBox(edge.from) === undefined || endBox(edge.to) === undefined) return
|
|
424
|
+
const key = edge.from < edge.to ? edge.from + '\u0000' + edge.to : edge.to + '\u0000' + edge.from
|
|
425
|
+
const list = multiEdgeSets.get(key)
|
|
426
|
+
if (list === undefined) multiEdgeSets.set(key, [index])
|
|
427
|
+
else list.push(index)
|
|
428
|
+
})
|
|
429
|
+
const fanOf = new Map<number, number>()
|
|
430
|
+
for (const list of multiEdgeSets.values()) {
|
|
431
|
+
if (list.length < 2) continue
|
|
432
|
+
list.forEach((index, position) => {
|
|
433
|
+
if (position === 0) return
|
|
434
|
+
fanOf.set(index, (position % 2 === 1 ? 1 : -1) * Math.ceil(position / 2) * MULTI_EDGE_SPACING)
|
|
435
|
+
})
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Ports. The side comes from the route, not from comparing the two boxes' centres: a layered drawing runs
|
|
439
|
+
// downwards, so an edge between two rows leaves the bottom and arrives at the top, and a back edge does the
|
|
440
|
+
// opposite - unless the symbol has four legal points, and then the side is the point the edge was given. Where along
|
|
441
|
+
// the side the port sits follows the drawing: a long edge takes the point its lane meets, so the lane leaves and
|
|
442
|
+
// arrives square on, and a short edge points at the box it joins. Two edges that want the same place are spaced
|
|
443
|
+
// apart, so two ports never share a pixel.
|
|
444
|
+
const endKey = (index: number, end: 'exit' | 'entry'): string => String(index) + ':' + end
|
|
445
|
+
const endsAt = new Map<string, number>()
|
|
446
|
+
for (const edge of diagram.edges) {
|
|
447
|
+
if (edge.from === edge.to) continue
|
|
448
|
+
if (endBox(edge.from) === undefined || endBox(edge.to) === undefined) continue
|
|
449
|
+
endsAt.set(edge.from, (endsAt.get(edge.from) ?? 0) + 1)
|
|
450
|
+
endsAt.set(edge.to, (endsAt.get(edge.to) ?? 0) + 1)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** The side each edge end's port is on, where the port is a point rather than a place along a run. */
|
|
454
|
+
const claimed = new Map<string, Side>()
|
|
455
|
+
const claimedAt = new Map<string, Side[]>()
|
|
456
|
+
const claim = (id: string, index: number, end: 'exit' | 'entry', side: Side): void => {
|
|
457
|
+
claimed.set(endKey(index, end), side)
|
|
458
|
+
claimedAt.set(id, [...(claimedAt.get(id) ?? []), side])
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* The side a discrete port takes: the one whose outward normal points most nearly at the other box.
|
|
463
|
+
*
|
|
464
|
+
* The score is the cosine of the angle between the normal and the direction to the other box, so 1 is dead ahead
|
|
465
|
+
* and the four sides of a diamond rank by how much of the way to the target each one faces. Equal scores are broken
|
|
466
|
+
* by the point nearest the other box - the port the route has least ground to cover from - and then by the order the
|
|
467
|
+
* sides are drawn in. That last tie-break is what "the earlier edge keeps the earlier point" means in practice: the
|
|
468
|
+
* edges are walked in source order, and each one takes the best side still free.
|
|
469
|
+
*/
|
|
470
|
+
const bestSide = (id: string, other: Box): Side | undefined => {
|
|
471
|
+
const box = endBox(id) as Box
|
|
472
|
+
const centre = { x: box.x + box.width / 2, y: box.y + box.height / 2 }
|
|
473
|
+
const toward = { x: other.x + other.width / 2 - centre.x, y: other.y + other.height / 2 - centre.y }
|
|
474
|
+
const reach = Math.hypot(toward.x, toward.y)
|
|
475
|
+
const taken = claimedAt.get(id) ?? []
|
|
476
|
+
let best: { side: Side; score: number; near: number } | undefined
|
|
477
|
+
for (const side of PORT_SIDES) {
|
|
478
|
+
if (taken.includes(side)) continue
|
|
479
|
+
const normal = normalOf(side)
|
|
480
|
+
const score = reach === 0 ? 0 : (normal.x * toward.x + normal.y * toward.y) / reach
|
|
481
|
+
// How far the point is from the other box's rectangle: the nearest port is the one the edge reaches first.
|
|
482
|
+
const point = anchor(box, side)
|
|
483
|
+
const near = Math.hypot(
|
|
484
|
+
Math.max(other.x - point.x, 0, point.x - (other.x + other.width)),
|
|
485
|
+
Math.max(other.y - point.y, 0, point.y - (other.y + other.height)),
|
|
486
|
+
)
|
|
487
|
+
const better =
|
|
488
|
+
best === undefined ||
|
|
489
|
+
score > best.score + 1e-9 ||
|
|
490
|
+
(Math.abs(score - best.score) <= 1e-9 && near < best.near - 1e-9)
|
|
491
|
+
if (better) best = { side, score, near }
|
|
492
|
+
}
|
|
493
|
+
return best?.side
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// A side the drawing has already decided is claimed first, because a point there would point the route away from
|
|
497
|
+
// the corridor it has to run in: a long edge leaves by the side its lane starts from, and an edge read across two
|
|
498
|
+
// wrapped columns leaves and arrives on the sides the reading order turns at.
|
|
499
|
+
diagram.edges.forEach((edge, index) => {
|
|
500
|
+
if (edge.from === edge.to) return
|
|
501
|
+
const source = endBox(edge.from)
|
|
502
|
+
const target = endBox(edge.to)
|
|
503
|
+
if (source === undefined || target === undefined) return
|
|
504
|
+
if (placement.across.has(index)) {
|
|
505
|
+
claim(edge.from, index, 'exit', 'right')
|
|
506
|
+
claim(edge.to, index, 'entry', 'left')
|
|
507
|
+
return
|
|
508
|
+
}
|
|
509
|
+
const lane = placement.lanes.get(index)
|
|
510
|
+
if (lane === undefined || lane.points.length === 0) return
|
|
511
|
+
const down = target.y > source.y
|
|
512
|
+
claim(edge.from, index, 'exit', down ? 'bottom' : 'top')
|
|
513
|
+
claim(edge.to, index, 'entry', down ? 'top' : 'bottom')
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
// Then a symbol with four legal points gives every edge end a point of its own, in source order, until the four are
|
|
517
|
+
// gone. Only a node that needs one port, or one whose points are all spoken for, keeps the old answer - the side the
|
|
518
|
+
// route approaches by - and then two edges share that side's single point and fan apart later. That is the fallback,
|
|
519
|
+
// and it is why a decision with two labelled branches no longer draws both of them from one vertex.
|
|
520
|
+
diagram.edges.forEach((edge, index) => {
|
|
521
|
+
if (edge.from === edge.to) return
|
|
522
|
+
const source = endBox(edge.from)
|
|
523
|
+
const target = endBox(edge.to)
|
|
524
|
+
if (source === undefined || target === undefined) return
|
|
525
|
+
for (const end of ['exit', 'entry'] as const) {
|
|
526
|
+
if (claimed.has(endKey(index, end))) continue
|
|
527
|
+
const id = end === 'exit' ? edge.from : edge.to
|
|
528
|
+
if ((endsAt.get(id) ?? 0) < 2) continue
|
|
529
|
+
if (!hasDiscretePorts(shapeOf(id))) continue
|
|
530
|
+
const side = bestSide(id, end === 'exit' ? target : source)
|
|
531
|
+
if (side === undefined) continue
|
|
532
|
+
claim(id, index, end, side)
|
|
533
|
+
}
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
const facing = diagram.edges.map((edge, index): Facing | undefined => {
|
|
537
|
+
const source = endBox(edge.from)
|
|
538
|
+
const target = endBox(edge.to)
|
|
539
|
+
if (source === undefined || target === undefined || edge.from === edge.to) return undefined
|
|
540
|
+
// A wrapped drawing is read in columns: the edge from one column to the next leaves and arrives at the side,
|
|
541
|
+
// which is the direction that reading order travels. A column is always to the right of the one before it.
|
|
542
|
+
const sides = placement.across.has(index)
|
|
543
|
+
? { exit: 'right' as Side, entry: 'left' as Side }
|
|
544
|
+
: approachSides(source, target)
|
|
545
|
+
const lane = placement.lanes.get(index)
|
|
546
|
+
if (lane === undefined || lane.points.length === 0) {
|
|
547
|
+
return {
|
|
548
|
+
exit: claimed.get(endKey(index, 'exit')) ?? sides.exit,
|
|
549
|
+
entry: claimed.get(endKey(index, 'entry')) ?? sides.entry,
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
// A lane runs down the page, so its ends face the rows above and below wherever the box centres are.
|
|
553
|
+
const down = target.y > source.y
|
|
554
|
+
return {
|
|
555
|
+
exit: claimed.get(endKey(index, 'exit')) ?? (down ? 'bottom' : 'top'),
|
|
556
|
+
entry: claimed.get(endKey(index, 'entry')) ?? (down ? 'top' : 'bottom'),
|
|
557
|
+
at: lane.points[0].x,
|
|
558
|
+
}
|
|
559
|
+
})
|
|
560
|
+
// A long edge's lane is where the whole edge runs, so a fan moves the lane instead of bending the edge twice at its
|
|
561
|
+
// ends. It tries the place that keeps the set evenly spaced first, then its own place moved by the same offset; a
|
|
562
|
+
// move that would cross a box is refused, because an edge through a box is the worse defect. Refused, the edge keeps
|
|
563
|
+
// its lane - the two ports still differ, because every long edge already has a lane of its own.
|
|
564
|
+
const laneClear = (points: Point[], x: number, from: Box, to: Box): boolean => {
|
|
565
|
+
for (let at = 0; at + 1 < points.length; at += 1) {
|
|
566
|
+
for (const box of boxList) {
|
|
567
|
+
if (box === from || box === to) continue
|
|
568
|
+
if (crossesBox(x, points[at].y, x, points[at + 1].y, box)) return false
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
return true
|
|
572
|
+
}
|
|
573
|
+
const laneShift = new Map<number, number>()
|
|
574
|
+
for (const list of multiEdgeSets.values()) {
|
|
575
|
+
if (list.length < 2) continue
|
|
576
|
+
const lead = placement.lanes.get(list[0] as number)
|
|
577
|
+
if (lead === undefined || lead.points.length === 0) continue
|
|
578
|
+
const base = (lead.points[0] as Point).x
|
|
579
|
+
const taken = new Set<number>()
|
|
580
|
+
for (const index of list) {
|
|
581
|
+
const lane = placement.lanes.get(index) as { points: Point[] }
|
|
582
|
+
const own = lane.points[0].x
|
|
583
|
+
const offset = fanOf.get(index) ?? 0
|
|
584
|
+
const edge = diagram.edges[index] as DiagramEdge
|
|
585
|
+
const from = endBox(edge.from) as Box
|
|
586
|
+
const to = endBox(edge.to) as Box
|
|
587
|
+
let target = own
|
|
588
|
+
if (offset !== 0) {
|
|
589
|
+
for (const candidate of [base + offset, own + offset]) {
|
|
590
|
+
if (taken.has(candidate) || !laneClear(lane.points, candidate, from, to)) continue
|
|
591
|
+
target = candidate
|
|
592
|
+
break
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
taken.add(target)
|
|
596
|
+
if (target !== own) laneShift.set(index, target - own)
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const wanted = new Map<string, number>()
|
|
601
|
+
const slotKey = (boxId: string, side: Side, index: number): string => boxId + ':' + side + ':' + String(index)
|
|
602
|
+
/**
|
|
603
|
+
* Where a port may sit on one side of one node, as the interval its coordinate may take.
|
|
604
|
+
*
|
|
605
|
+
* This is the per-symbol rule, and it is why the rule lives here: a port slot is chosen in this module, so this is
|
|
606
|
+
* where the symbol has to be known. A rectangle spreads a port along the side, and a subroutine does the same. A
|
|
607
|
+
* diamond's side meets its outline at exactly one point - the vertex - so its interval is that point, and a circle's
|
|
608
|
+
* is its cardinal point; a point is all that side has to give, so two edges on one side share it rather than take
|
|
609
|
+
* two points in the air. A hexagon's top and bottom are the flat run between the cut corners, and its left and right
|
|
610
|
+
* are the vertices. A stadium's flat run stops at the end caps, and a cylinder's flat sides are the two vertical
|
|
611
|
+
* runs, never the caps.
|
|
612
|
+
*
|
|
613
|
+
* A zero-width interval is not a failure: the packer keeps every port on it, so a symbol that offers one point on a
|
|
614
|
+
* side hands that point to every edge that wants it. Which is why a symbol with four points is given one edge per
|
|
615
|
+
* side first - the point is only shared when the four are taken.
|
|
616
|
+
*/
|
|
617
|
+
const spanOf = (boxId: string, side: Side, corner: boolean): { low: number; high: number } => {
|
|
618
|
+
const box = endBox(boxId) as Box
|
|
619
|
+
const vertical = side === 'top' || side === 'bottom'
|
|
620
|
+
const middle = vertical ? box.x + box.width / 2 : box.y + box.height / 2
|
|
621
|
+
const within = (low: number, high: number): { low: number; high: number } => ({ low, high: Math.max(low, high) })
|
|
622
|
+
switch (shapeOf(boxId)) {
|
|
623
|
+
// The four vertices of a diamond, and the four cardinal points of an ellipse, are the middles of its sides.
|
|
624
|
+
case 'diamond':
|
|
625
|
+
case 'circle':
|
|
626
|
+
return within(middle, middle)
|
|
627
|
+
case 'hexagon':
|
|
628
|
+
return vertical ? within(box.x + hexagonCut(box.width), box.x + box.width - hexagonCut(box.width)) : within(middle, middle)
|
|
629
|
+
case 'stadium': {
|
|
630
|
+
const radius = stadiumRadius(box)
|
|
631
|
+
return vertical ? within(box.x + radius, box.x + box.width - radius) : within(middle, middle)
|
|
632
|
+
}
|
|
633
|
+
case 'cylinder': {
|
|
634
|
+
if (vertical) return within(middle, middle)
|
|
635
|
+
const cap = cylinderCaps(box).cap
|
|
636
|
+
return within(box.y + cap, box.y + box.height - cap)
|
|
637
|
+
}
|
|
638
|
+
default: {
|
|
639
|
+
// A rectangle, and a subroutine's doubled side, keep the margins they have always had; `corner` relaxes them
|
|
640
|
+
// because a side that is crowded uses its corners.
|
|
641
|
+
const inset = corner ? 2 : vertical ? PORT_INSET : 8
|
|
642
|
+
return vertical ? within(box.x + inset, box.x + box.width - inset) : within(box.y + inset, box.y + box.height - inset)
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
const clampTo = (boxId: string, side: Side, value: number, corner: boolean): number => {
|
|
647
|
+
const span = spanOf(boxId, side, corner)
|
|
648
|
+
return Math.min(span.high, Math.max(span.low, value))
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* The boundary point a coordinate along one side takes.
|
|
652
|
+
*
|
|
653
|
+
* The box's rectangle is the point for every symbol whose side is the box's side. A cylinder's top and bottom are
|
|
654
|
+
* arcs, so a port there sits on the cap curve's apex - `cap / 4` inside the box, not on the box's own edge.
|
|
655
|
+
*/
|
|
656
|
+
const pointOn = (boxId: string, side: Side, along: number): Point => {
|
|
657
|
+
const box = endBox(boxId) as Box
|
|
658
|
+
if (side === 'left') return { x: box.x, y: along }
|
|
659
|
+
if (side === 'right') return { x: box.x + box.width, y: along }
|
|
660
|
+
if (side === 'top') return { x: along, y: shapeOf(boxId) === 'cylinder' ? cylinderApex(box, 'top').y : box.y }
|
|
661
|
+
return { x: along, y: shapeOf(boxId) === 'cylinder' ? cylinderApex(box, 'bottom').y : box.y + box.height }
|
|
662
|
+
}
|
|
663
|
+
for (const [index, entry] of facing.entries()) {
|
|
664
|
+
if (entry === undefined) continue
|
|
665
|
+
const edge = diagram.edges[index]
|
|
666
|
+
const from = endBox(edge.from) as Box
|
|
667
|
+
const to = endBox(edge.to) as Box
|
|
668
|
+
const middle = (box: Box, side: Side): number => (isVerticalSide(side) ? box.x + box.width / 2 : box.y + box.height / 2)
|
|
669
|
+
const fromMiddle = middle(from, entry.exit)
|
|
670
|
+
const toMiddle = middle(to, entry.entry)
|
|
671
|
+
// Both ports sit on one line - and the edge is a straight run - when the source's middle falls on the target's
|
|
672
|
+
// side; otherwise the line is the target's middle clamped into the source's side, which is as close to a straight
|
|
673
|
+
// run as a box that does not overlap the other allows. A long edge asks for the point its own lane meets instead,
|
|
674
|
+
// and takes the corner nearest it when the lane runs outside the box: that edge turns anyway, and a straight edge
|
|
675
|
+
// made to give up its port would gain two bends for nothing.
|
|
676
|
+
const corner = entry.at !== undefined
|
|
677
|
+
// The k-th edge of a multi-edge set runs on a line of its own, perpendicular to the direction it runs: the fan's
|
|
678
|
+
// offset is added to the side's own coordinate, which is x for a vertical run and y for a horizontal one. A long
|
|
679
|
+
// edge carries the offset in its lane instead, so the whole edge moves rather than gaining two bends.
|
|
680
|
+
const offset = fanOf.get(index) ?? 0
|
|
681
|
+
let exitWanted: number
|
|
682
|
+
let entryWanted: number
|
|
683
|
+
if (entry.at !== undefined) {
|
|
684
|
+
const chosen = entry.at + (laneShift.get(index) ?? 0)
|
|
685
|
+
exitWanted = clampTo(edge.from, entry.exit, chosen, corner)
|
|
686
|
+
entryWanted = clampTo(edge.to, entry.entry, chosen, corner)
|
|
687
|
+
} else if (isVerticalSide(entry.exit) === isVerticalSide(entry.entry)) {
|
|
688
|
+
// Two ports on sides of the same kind: one coordinate serves both, as it always has.
|
|
689
|
+
const target = spanOf(edge.to, entry.entry, false)
|
|
690
|
+
const chosen = (fromMiddle >= target.low && fromMiddle <= target.high ? fromMiddle : clampTo(edge.from, entry.exit, toMiddle, false)) + offset
|
|
691
|
+
exitWanted = clampTo(edge.from, entry.exit, chosen, corner)
|
|
692
|
+
entryWanted = clampTo(edge.to, entry.entry, chosen, corner)
|
|
693
|
+
} else {
|
|
694
|
+
// The two ports sit on sides of different kinds, which only a discrete point causes. The end that is fixed on
|
|
695
|
+
// its symbol's point keeps that point's coordinate; the other one sits where the route actually arrives - one
|
|
696
|
+
// step out of the fixed port along its own normal - so the two legs of the route meet in a straight drop
|
|
697
|
+
// instead of jogging sideways to reach a middle that the route never passes.
|
|
698
|
+
exitWanted = hasDiscretePorts(shapeOf(edge.from))
|
|
699
|
+
? fromMiddle
|
|
700
|
+
: clampTo(edge.from, entry.exit, anchor(to, entry.entry).x + normalOf(entry.entry).x * ROUTE_STUB, false) + offset
|
|
701
|
+
entryWanted = hasDiscretePorts(shapeOf(edge.to))
|
|
702
|
+
? toMiddle
|
|
703
|
+
: clampTo(edge.to, entry.entry, anchor(from, entry.exit).x + normalOf(entry.exit).x * ROUTE_STUB, false) + offset
|
|
704
|
+
}
|
|
705
|
+
wanted.set(slotKey(edge.from, entry.exit, index), exitWanted)
|
|
706
|
+
wanted.set(slotKey(edge.to, entry.entry, index), entryWanted)
|
|
707
|
+
}
|
|
708
|
+
const sides = new Map<string, Array<{ index: number; wanted: number }>>()
|
|
709
|
+
for (const [index, entry] of facing.entries()) {
|
|
710
|
+
if (entry === undefined) continue
|
|
711
|
+
const edge = diagram.edges[index]
|
|
712
|
+
sides.set(edge.from + ':' + entry.exit, [
|
|
713
|
+
...(sides.get(edge.from + ':' + entry.exit) ?? []),
|
|
714
|
+
{ index, wanted: wanted.get(slotKey(edge.from, entry.exit, index)) as number },
|
|
715
|
+
])
|
|
716
|
+
sides.set(edge.to + ':' + entry.entry, [
|
|
717
|
+
...(sides.get(edge.to + ':' + entry.entry) ?? []),
|
|
718
|
+
{ index, wanted: wanted.get(slotKey(edge.to, entry.entry, index)) as number },
|
|
719
|
+
])
|
|
720
|
+
}
|
|
721
|
+
// Pack every side, then hand each entry the position its exit was given: a pair that faced each other sat on one x
|
|
722
|
+
// before packing, and an edge the packing had to move should keep its straight run rather than gain two bends.
|
|
723
|
+
let placedPort = new Map<string, number>()
|
|
724
|
+
for (let round = 0; round < 3; round += 1) {
|
|
725
|
+
placedPort = new Map<string, number>()
|
|
726
|
+
for (const [key, list] of sides) {
|
|
727
|
+
const [boxId, side] = key.split(':') as [string, Side]
|
|
728
|
+
const span = spanOf(boxId, side, true)
|
|
729
|
+
const order = [...list].sort((left, right) => left.wanted - right.wanted || left.index - right.index)
|
|
730
|
+
const packed = packPorts(
|
|
731
|
+
order.map((entry) => clampTo(boxId, side, entry.wanted, true)),
|
|
732
|
+
span.low,
|
|
733
|
+
span.high,
|
|
734
|
+
)
|
|
735
|
+
order.forEach((entry, at) => placedPort.set(slotKey(boxId, side, entry.index), packed[at]))
|
|
736
|
+
}
|
|
737
|
+
let moved = false
|
|
738
|
+
for (const [index, entry] of facing.entries()) {
|
|
739
|
+
if (entry === undefined) continue
|
|
740
|
+
const edge = diagram.edges[index]
|
|
741
|
+
const vertical = entry.exit === 'bottom' || entry.exit === 'top'
|
|
742
|
+
if (!vertical) continue
|
|
743
|
+
const exit = placedPort.get(slotKey(edge.from, entry.exit, index)) as number
|
|
744
|
+
if (Math.abs(exit - (placedPort.get(slotKey(edge.to, entry.entry, index)) as number)) < 0.01) continue
|
|
745
|
+
// Only an entry that can reach the exit's x asks for it: a box that does not span it keeps its own point.
|
|
746
|
+
if (Math.abs(clampTo(edge.to, entry.entry, exit, true) - exit) > 0.01) continue
|
|
747
|
+
for (const slot of sides.get(edge.to + ':' + entry.entry) as Array<{ index: number; wanted: number }>) {
|
|
748
|
+
if (slot.index !== index) continue
|
|
749
|
+
slot.wanted = exit
|
|
750
|
+
moved = true
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
if (!moved) break
|
|
754
|
+
}
|
|
755
|
+
const portOf = (boxId: string, side: Side, edgeIndex: number): Point => {
|
|
756
|
+
const coordinate = placedPort.get(slotKey(boxId, side, edgeIndex)) as number
|
|
757
|
+
return pointOn(boxId, side, coordinate)
|
|
758
|
+
}
|
|
759
|
+
const anchorsFor = new Map<number, Ports>()
|
|
760
|
+
for (const [index, entry] of facing.entries()) {
|
|
761
|
+
if (entry === undefined) continue
|
|
762
|
+
const edge = diagram.edges[index]
|
|
763
|
+
anchorsFor.set(index, {
|
|
764
|
+
exit: portOf(edge.from, entry.exit, index),
|
|
765
|
+
entry: portOf(edge.to, entry.entry, index),
|
|
766
|
+
exitSide: entry.exit,
|
|
767
|
+
entrySide: entry.entry,
|
|
768
|
+
})
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/** The longest straight run of a polyline: where a label has the most room. */
|
|
772
|
+
const longestRun = (points: Point[]): { from: Point; to: Point } => {
|
|
773
|
+
let best = { from: points[0], to: points[points.length - 1], length: -1 }
|
|
774
|
+
for (let index = 0; index + 1 < points.length; index += 1) {
|
|
775
|
+
const from = points[index]
|
|
776
|
+
const to = points[index + 1]
|
|
777
|
+
const length = Math.abs(to.x - from.x) + Math.abs(to.y - from.y)
|
|
778
|
+
if (length > best.length) best = { from, to, length }
|
|
779
|
+
}
|
|
780
|
+
return best
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** The points a drawn edge passes through, whichever of the two shapes carries it. */
|
|
784
|
+
const pointsOfRoute = (shape: LinkShape | PathShape): Point[] =>
|
|
785
|
+
shape.kind === 'path' ? shape.points : [{ x: shape.x1, y: shape.y1 }, { x: shape.x2, y: shape.y2 }]
|
|
786
|
+
|
|
787
|
+
/** The straight pieces an edge draws, so a label can tell its own line from every other one. */
|
|
788
|
+
const segmentsOfRoute = (shape: LinkShape | PathShape): Segment[] => {
|
|
789
|
+
const points = pointsOfRoute(shape)
|
|
790
|
+
const out: Segment[] = []
|
|
791
|
+
for (let index = 0; index + 1 < points.length; index += 1) {
|
|
792
|
+
const from = points[index]
|
|
793
|
+
const to = points[index + 1]
|
|
794
|
+
out.push({ x1: from.x, y1: from.y, x2: to.x, y2: to.y })
|
|
795
|
+
}
|
|
796
|
+
return out
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/** Paint one edge as a polyline. Its label is placed once every edge is drawn; see the pass after the loop. */
|
|
800
|
+
const paintPath = (edge: DiagramEdge, points: Point[], colour: ColourRole): void => {
|
|
801
|
+
const shape: PathShape = {
|
|
802
|
+
kind: 'path',
|
|
803
|
+
points,
|
|
804
|
+
label: edge.label,
|
|
805
|
+
labelX: 0,
|
|
806
|
+
labelY: 0,
|
|
807
|
+
colour,
|
|
808
|
+
...(edge.dashed === undefined ? {} : { dashed: edge.dashed }),
|
|
809
|
+
...(edge.arrow === undefined ? {} : { arrow: edge.arrow }),
|
|
810
|
+
}
|
|
811
|
+
shapes.push(shape)
|
|
812
|
+
routes.push(shape)
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/** Two points are a line and more of them are a polyline; both are an edge carrying a label. */
|
|
816
|
+
const paintRoute = (edge: DiagramEdge, points: Point[], colour: ColourRole): void => {
|
|
817
|
+
if (points.length > 2) {
|
|
818
|
+
paintPath(edge, points, colour)
|
|
819
|
+
return
|
|
820
|
+
}
|
|
821
|
+
const exit = points[0]
|
|
822
|
+
const entry = points[points.length - 1]
|
|
823
|
+
const shape: LinkShape = {
|
|
824
|
+
kind: 'link',
|
|
825
|
+
x1: exit.x,
|
|
826
|
+
y1: exit.y,
|
|
827
|
+
x2: entry.x,
|
|
828
|
+
y2: entry.y,
|
|
829
|
+
label: edge.label,
|
|
830
|
+
labelX: 0,
|
|
831
|
+
labelY: 0,
|
|
832
|
+
colour,
|
|
833
|
+
...(edge.dashed === undefined ? {} : { dashed: edge.dashed }),
|
|
834
|
+
...(edge.arrow === undefined ? {} : { arrow: edge.arrow }),
|
|
835
|
+
}
|
|
836
|
+
shapes.push(shape)
|
|
837
|
+
routes.push(shape)
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* The polyline through a run: out of one port, across the strip between two rows, and into the other, square to
|
|
842
|
+
* whatever side each port belongs to.
|
|
843
|
+
*
|
|
844
|
+
* Two ports on top and bottom sides come out as the polyline the run always drew. A port on a left or right side
|
|
845
|
+
* cannot be entered by a vertical drop, so the route steps out of it by one stub first and the strip meets the port
|
|
846
|
+
* at a right angle: the edge leaves - or arrives - by the side its point belongs to, which is the side the router is
|
|
847
|
+
* told about too.
|
|
848
|
+
*/
|
|
849
|
+
const runRoute = (ports: Ports, runY: number): Point[] => {
|
|
850
|
+
const out = isVerticalSide(ports.exitSide) ? 0 : normalOf(ports.exitSide).x * ROUTE_STUB
|
|
851
|
+
const into = isVerticalSide(ports.entrySide) ? 0 : normalOf(ports.entrySide).x * ROUTE_STUB
|
|
852
|
+
const points: Point[] = [ports.exit]
|
|
853
|
+
if (out !== 0) points.push({ x: ports.exit.x + out, y: ports.exit.y })
|
|
854
|
+
points.push({ x: ports.exit.x + out, y: runY }, { x: ports.entry.x + into, y: runY })
|
|
855
|
+
if (into !== 0) points.push({ x: ports.entry.x + into, y: ports.entry.y })
|
|
856
|
+
points.push(ports.entry)
|
|
857
|
+
return simplify(points)
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Can the run draw this edge at all?
|
|
862
|
+
*
|
|
863
|
+
* A port on a top or bottom side faces along the vertical run, and the strip has to be on the side the port faces. A
|
|
864
|
+
* point that faces away - the last of a diamond's four, when the others are taken - would have the route leave the
|
|
865
|
+
* port and come straight back through its own box. Such an edge is the router's to draw: going round a box is what
|
|
866
|
+
* the router does, and it knows the box is there.
|
|
867
|
+
*/
|
|
868
|
+
const runFits = (ports: Ports, runY: number): boolean => {
|
|
869
|
+
if (isVerticalSide(ports.exitSide) && Math.abs(runY - ports.exit.y) > 0.5) {
|
|
870
|
+
if (Math.sign(runY - ports.exit.y) !== Math.sign(normalOf(ports.exitSide).y)) return false
|
|
871
|
+
}
|
|
872
|
+
if (isVerticalSide(ports.entrySide) && Math.abs(ports.entry.y - runY) > 0.5) {
|
|
873
|
+
if (Math.sign(ports.entry.y - runY) !== -Math.sign(normalOf(ports.entrySide).y)) return false
|
|
874
|
+
}
|
|
875
|
+
return true
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/** Does the route pass through a box it does not attach to? A run is an empty strip; a step out of a side may not be. */
|
|
879
|
+
const clearsBoxes = (points: Point[], from: Box, to: Box): boolean => {
|
|
880
|
+
for (let at = 0; at + 1 < points.length; at += 1) {
|
|
881
|
+
const a = points[at]
|
|
882
|
+
const b = points[at + 1]
|
|
883
|
+
for (const box of boxList) {
|
|
884
|
+
if (box === from || box === to) continue
|
|
885
|
+
if (crossesBox(a.x, a.y, b.x, b.y, box)) return false
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
return true
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
for (const [edgeIndex, edge] of diagram.edges.entries()) {
|
|
892
|
+
const source = endBox(edge.from)
|
|
893
|
+
const target = endBox(edge.to)
|
|
894
|
+
if (source === undefined || target === undefined) continue
|
|
895
|
+
const colour = roleById.get(edge.from) ?? 'muted'
|
|
896
|
+
|
|
897
|
+
if (edge.from === edge.to) {
|
|
898
|
+
const x = source.x + source.width
|
|
899
|
+
const y = source.y + source.height / 2
|
|
900
|
+
shapes.push({
|
|
901
|
+
kind: 'path',
|
|
902
|
+
points: [
|
|
903
|
+
{ x, y: y - 9 },
|
|
904
|
+
{ x: x + 14, y: y - 9 },
|
|
905
|
+
{ x: x + 14, y: y + 9 },
|
|
906
|
+
{ x, y: y + 9 },
|
|
907
|
+
],
|
|
908
|
+
label: edge.label,
|
|
909
|
+
// The one label that is not on its own edge: a self loop's longest run is eighteen points, and its name
|
|
910
|
+
// does not fit on that. It sits beside the loop, where it belongs to the edge and touches nothing.
|
|
911
|
+
labelX: x + 20 + textWidth(edge.label) / 2,
|
|
912
|
+
labelY: y,
|
|
913
|
+
colour,
|
|
914
|
+
...(edge.dashed === undefined ? {} : { dashed: edge.dashed }),
|
|
915
|
+
...(edge.arrow === undefined ? {} : { arrow: edge.arrow }),
|
|
916
|
+
})
|
|
917
|
+
continue
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
const anchors = anchorsFor.get(edgeIndex)
|
|
921
|
+
|
|
922
|
+
// A long edge follows the lane the channel stage gave it: down the exit stub, across to the lane's x on the run
|
|
923
|
+
// the gap was assigned, down the lane, and across to the entry stub. The spine carries one point per gap, so the
|
|
924
|
+
// two runs are the places where a port and the lane disagree, and everything between them is one straight drop.
|
|
925
|
+
const lane = placement.lanes.get(edgeIndex)
|
|
926
|
+
if (lane !== undefined && lane.points.length > 0 && anchors !== undefined) {
|
|
927
|
+
const shift = laneShift.get(edgeIndex) ?? 0
|
|
928
|
+
const ordered = lane.reversed ? [...lane.points].reverse() : lane.points
|
|
929
|
+
const waypoints = shift === 0 ? ordered : ordered.map((point) => ({ x: point.x + shift, y: point.y }))
|
|
930
|
+
const points: Point[] = [anchors.exit]
|
|
931
|
+
let previous = anchors.exit
|
|
932
|
+
for (const waypoint of waypoints) {
|
|
933
|
+
if (Math.abs(previous.x - waypoint.x) > 0.5 && Math.abs(previous.y - waypoint.y) > 0.5) points.push({ x: previous.x, y: waypoint.y })
|
|
934
|
+
points.push(waypoint)
|
|
935
|
+
previous = waypoint
|
|
936
|
+
}
|
|
937
|
+
if (Math.abs(previous.x - anchors.entry.x) > 0.5 && Math.abs(previous.y - anchors.entry.y) > 0.5) points.push({ x: anchors.entry.x, y: previous.y })
|
|
938
|
+
points.push(anchors.entry)
|
|
939
|
+
paintPath(edge, simplify(points), colour)
|
|
940
|
+
continue
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// Two rows with nothing between them: the edge is the two-bend run the channel stage stacked. A run sits in the
|
|
944
|
+
// empty strip between its own two rows and inside its own component's column, so it cannot meet a box - unless a
|
|
945
|
+
// port on a side steps out of it, and then the step is checked and a route that would meet a box is the router's.
|
|
946
|
+
const runY = placement.runs.get(edgeIndex)
|
|
947
|
+
if (runY !== undefined && anchors !== undefined && runFits(anchors, runY)) {
|
|
948
|
+
const points = runRoute(anchors, runY)
|
|
949
|
+
const mixed = isVerticalSide(anchors.exitSide) !== isVerticalSide(anchors.entrySide)
|
|
950
|
+
if (!mixed || clearsBoxes(points, source, target)) {
|
|
951
|
+
paintRoute(edge, points, colour)
|
|
952
|
+
continue
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
paintRoute(edge, routeEdge(source, target, boxList, anchors).points, colour)
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// Labels last. A label has to clear every other edge, and until the last edge is drawn the painter does not know
|
|
960
|
+
// where those are: this is why the whole pass exists rather than a decision made edge by edge.
|
|
961
|
+
const routeSegments = routes.map((shape) => segmentsOfRoute(shape))
|
|
962
|
+
const placedLabels: LabelSpot[] = []
|
|
963
|
+
routes.forEach((shape, index) => {
|
|
964
|
+
const run = longestRun(pointsOfRoute(shape))
|
|
965
|
+
// The middle of the longest run is where the label belongs; a label with nothing to avoid stays exactly there.
|
|
966
|
+
let spot: LabelSpot = { x: (run.from.x + run.to.x) / 2, y: (run.from.y + run.to.y) / 2, width: textWidth(shape.label), height: LABEL_HEIGHT }
|
|
967
|
+
if (shape.label !== '') {
|
|
968
|
+
spot = placeLabel(shape.label, run.from, run.to, boxList, placedLabels, routeSegments.flatMap((list, at) => (at === index ? [] : list)))
|
|
969
|
+
placedLabels.push(spot)
|
|
970
|
+
}
|
|
971
|
+
shape.labelX = spot.x
|
|
972
|
+
shape.labelY = spot.y
|
|
973
|
+
})
|
|
974
|
+
|
|
975
|
+
shapes.push({ kind: 'title', x: MARGIN, y: MARGIN + 12, text: diagram.title, colour: 'text' })
|
|
976
|
+
|
|
977
|
+
const contentRight = Math.max(
|
|
978
|
+
MARGIN,
|
|
979
|
+
...Array.from(boxes.values(), (box) => box.x + box.width),
|
|
980
|
+
...Array.from(groupRects.values(), (rect) => rect.x + rect.width),
|
|
981
|
+
)
|
|
982
|
+
const captionLines = wrap(diagram.caption, CAPTION_CHARS)
|
|
983
|
+
const captionWidth = Math.max(...captionLines.map((line) => line.length * CAPTION_CHAR_WIDTH)) + MARGIN * 2
|
|
984
|
+
const width = Math.max(contentRight + MARGIN, captionWidth, 240)
|
|
985
|
+
|
|
986
|
+
const contentBottom = Math.max(
|
|
987
|
+
topOffset,
|
|
988
|
+
...Array.from(boxes.values(), (box) => box.y + box.height),
|
|
989
|
+
...Array.from(groupRects.values(), (rect) => rect.y + rect.height),
|
|
990
|
+
)
|
|
991
|
+
const captionTop = contentBottom + CAPTION_GAP
|
|
992
|
+
shapes.push({ kind: 'caption', x: MARGIN, y: captionTop, lines: captionLines, colour: captionRole(diagram.caption) })
|
|
993
|
+
const height = captionTop + captionLines.length * CAPTION_LINE + MARGIN / 2
|
|
994
|
+
|
|
995
|
+
// Everything drawn counts, including the parts that leave the box grid: an edge routed beside the drawing and a
|
|
996
|
+
// label wider than the gap between two boxes were both cut off at the right edge.
|
|
997
|
+
const extent = drawnExtent(shapes)
|
|
998
|
+
const canvasWidth = Math.max(width, extent.width + MARGIN)
|
|
999
|
+
const canvasHeight = Math.max(height, extent.height + MARGIN)
|
|
1000
|
+
|
|
1001
|
+
return { canvas: { width: Math.round(canvasWidth), height: Math.round(canvasHeight), shapes }, colours }
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
export function paint(diagram: Diagram, overrides: Partial<Palette> = {}): Canvas {
|
|
1005
|
+
return paintWith(diagram, overrides).canvas
|
|
1006
|
+
}
|