dsh-model-everything 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en-US.md +12 -0
- package/README.md +4 -0
- package/lib/client.js +2829 -224
- package/lib/render/channels.js +243 -0
- package/lib/render/collapse.js +12 -20
- 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/dummy.js +43 -0
- package/lib/render/from-mermaid.js +34 -10
- package/lib/render/from-plantuml.js +318 -46
- package/lib/render/layer.js +91 -0
- package/lib/render/layout.js +27 -11
- package/lib/render/network-simplex.js +272 -0
- package/lib/render/order.js +122 -0
- package/lib/render/paint.js +1227 -69
- package/lib/render/place.js +393 -121
- package/lib/render/route.js +225 -0
- package/lib/render/svg.js +146 -21
- package/lib/render/symbol.js +108 -0
- package/lib/render/text.js +74 -0
- package/lib/render/trunk.js +213 -0
- package/lib/render/wrap.js +87 -0
- package/lib/types/render/channels.d.ts +63 -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/dummy.d.ts +32 -0
- package/lib/types/render/from-plantuml.d.ts +12 -6
- package/lib/types/render/layer.d.ts +28 -0
- package/lib/types/render/layout.d.ts +43 -11
- package/lib/types/render/model.d.ts +17 -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 +157 -10
- package/lib/types/render/place.d.ts +40 -19
- package/lib/types/render/route.d.ts +77 -0
- package/lib/types/render/svg.d.ts +1 -1
- package/lib/types/render/symbol.d.ts +73 -0
- package/lib/types/render/text.d.ts +38 -0
- package/lib/types/render/trunk.d.ts +104 -0
- package/lib/types/render/wrap.d.ts +27 -0
- package/package.json +148 -148
- package/skills/model-everything-diagram/SKILL.md +85 -82
- package/src/render/channels.ts +309 -0
- package/src/render/collapse.ts +150 -155
- 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/dummy.ts +65 -0
- package/src/render/from-mermaid.ts +261 -236
- package/src/render/from-plantuml.ts +469 -178
- package/src/render/layer.ts +98 -0
- package/src/render/layout.ts +101 -62
- package/src/render/model.ts +86 -67
- package/src/render/network-simplex.ts +290 -0
- package/src/render/order.ts +139 -0
- package/src/render/paint.ts +1570 -290
- package/src/render/place.ts +532 -234
- package/src/render/route.ts +269 -0
- package/src/render/svg.ts +274 -127
- package/src/render/symbol.ts +137 -0
- package/src/render/text.ts +86 -0
- package/src/render/trunk.ts +275 -0
- package/src/render/wrap.ts +103 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orthogonal edge routing around boxes.
|
|
3
|
+
*
|
|
4
|
+
* A straight line between two boxes is right until a third box sits between them; then it is a line drawn through a
|
|
5
|
+
* component, which reads as a connection that is not there. Graphviz solves this by routing along a visibility
|
|
6
|
+
* graph and then fitting splines; ELK, yFiles and mxGraph build a *sparse* grid from the boxes' own edges and run
|
|
7
|
+
* A* over it with a bend penalty. Sparse is the whole trick: a pixel grid is O(pixels²), while the candidate lines
|
|
8
|
+
* are the coordinate of every box edge plus a margin - O(boxes) per axis.
|
|
9
|
+
*
|
|
10
|
+
* So this module does exactly that, in the two steps those routers use:
|
|
11
|
+
*
|
|
12
|
+
* 1. **Candidate lines.** Every box contributes four: one lane just outside each side. Together with the anchor
|
|
13
|
+
* coordinates they form the grid a route may travel on.
|
|
14
|
+
* 2. **A* with bends.** Moving along a candidate line costs its length; changing direction costs extra, because a
|
|
15
|
+
* route that zig-zags to save three points is harder to follow than one that goes around.
|
|
16
|
+
*
|
|
17
|
+
* A route is only computed for an edge that needs it: the cheap straight segment is tried first, and routing is the
|
|
18
|
+
* fallback. That keeps the common case - a box directly above another - at two points and no search.
|
|
19
|
+
*
|
|
20
|
+
* The layered painter mostly does not ask: an edge between two rows and an edge with a lane are both built from the
|
|
21
|
+
* channel stage's answer, because a route searched on its own cannot know which lanes the other edges already hold.
|
|
22
|
+
* What is left for this router is the drawing the layers do not describe - an edge across two wrapped columns, an
|
|
23
|
+
* edge one of whose ends is a boundary - and the fallback when a channel route would meet a box.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { Point } from './paint.js'
|
|
27
|
+
|
|
28
|
+
/** A rectangle an edge may not cross. */
|
|
29
|
+
export interface Obstacle {
|
|
30
|
+
x: number
|
|
31
|
+
y: number
|
|
32
|
+
width: number
|
|
33
|
+
height: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The four sides a route may leave a box from. */
|
|
37
|
+
export type Side = 'top' | 'bottom' | 'left' | 'right'
|
|
38
|
+
|
|
39
|
+
/** How far outside a box a lane runs, so a route does not graze the border. */
|
|
40
|
+
const LANE_GAP = 10
|
|
41
|
+
|
|
42
|
+
/** The length of the straight run an edge takes before it is allowed to turn. */
|
|
43
|
+
export const ROUTE_STUB = 16
|
|
44
|
+
|
|
45
|
+
/** The outward normal of a side: the direction an edge leaves along. */
|
|
46
|
+
export function normalOf(side: Side): Point {
|
|
47
|
+
switch (side) {
|
|
48
|
+
case 'top':
|
|
49
|
+
return { x: 0, y: -1 }
|
|
50
|
+
case 'bottom':
|
|
51
|
+
return { x: 0, y: 1 }
|
|
52
|
+
case 'left':
|
|
53
|
+
return { x: -1, y: 0 }
|
|
54
|
+
case 'right':
|
|
55
|
+
return { x: 1, y: 0 }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** What a bend costs, in points of length: a route prefers one long detour to three small ones. */
|
|
60
|
+
const BEND_COST = 24
|
|
61
|
+
|
|
62
|
+
/** A segment crossing a box at all, with a little slack so a route does not shave a corner. */
|
|
63
|
+
export function crossesBox(x1: number, y1: number, x2: number, y2: number, box: Obstacle): boolean {
|
|
64
|
+
const minX = box.x + 0.5
|
|
65
|
+
const maxX = box.x + box.width - 0.5
|
|
66
|
+
const minY = box.y + 0.5
|
|
67
|
+
const maxY = box.y + box.height - 0.5
|
|
68
|
+
// Axis-aligned segments are the ones this router emits; test them exactly rather than by sampling.
|
|
69
|
+
if (Math.abs(y1 - y2) < 0.001) {
|
|
70
|
+
const y = y1
|
|
71
|
+
if (y <= minY || y >= maxY) return false
|
|
72
|
+
return Math.max(x1, x2) > minX && Math.min(x1, x2) < maxX
|
|
73
|
+
}
|
|
74
|
+
if (Math.abs(x1 - x2) < 0.001) {
|
|
75
|
+
const x = x1
|
|
76
|
+
if (x <= minX || x >= maxX) return false
|
|
77
|
+
return Math.max(y1, y2) > minY && Math.min(y1, y2) < maxY
|
|
78
|
+
}
|
|
79
|
+
// A diagonal (the fallback, and the straight segment): sample it. Cheap, and only ever used for one segment.
|
|
80
|
+
const steps = Math.max(8, Math.round(Math.hypot(x2 - x1, y2 - y1)))
|
|
81
|
+
for (let index = 0; index <= steps; index += 1) {
|
|
82
|
+
const x = x1 + ((x2 - x1) * index) / steps
|
|
83
|
+
const y = y1 + ((y2 - y1) * index) / steps
|
|
84
|
+
if (x > minX && x < maxX && y > minY && y < maxY) return true
|
|
85
|
+
}
|
|
86
|
+
return false
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The point on a side of a box, in the middle of that side. */
|
|
90
|
+
export function anchor(box: Obstacle, side: Side): Point {
|
|
91
|
+
switch (side) {
|
|
92
|
+
case 'top':
|
|
93
|
+
return { x: box.x + box.width / 2, y: box.y }
|
|
94
|
+
case 'bottom':
|
|
95
|
+
return { x: box.x + box.width / 2, y: box.y + box.height }
|
|
96
|
+
case 'left':
|
|
97
|
+
return { x: box.x, y: box.y + box.height / 2 }
|
|
98
|
+
case 'right':
|
|
99
|
+
return { x: box.x + box.width, y: box.y + box.height / 2 }
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Which side of `from` an edge leaves by, and which side of `to` it arrives at.
|
|
105
|
+
*
|
|
106
|
+
* The side is a property of the drawing, not of the distance between two centres. A layered drawing puts its boxes
|
|
107
|
+
* in horizontal rows, and an edge between two rows approaches vertically: out of the bottom, into the top, and the
|
|
108
|
+
* other way round for an edge drawn against the flow. Only two boxes in the same row are joined sideways, and then
|
|
109
|
+
* the direction of travel decides which side.
|
|
110
|
+
*
|
|
111
|
+
* This replaces the old rule, which compared the two centres' dx and dy and picked the larger axis - so an edge
|
|
112
|
+
* between two rows left through a box's side as soon as the target was far enough sideways, which is the hook a
|
|
113
|
+
* top-down drawing is read as a defect.
|
|
114
|
+
*/
|
|
115
|
+
export function approachSides(from: Obstacle, to: Obstacle): { exit: Side; entry: Side } {
|
|
116
|
+
const fromCy = from.y + from.height / 2
|
|
117
|
+
const toCy = to.y + to.height / 2
|
|
118
|
+
if (Math.abs(toCy - fromCy) > 0.5) return toCy > fromCy ? { exit: 'bottom', entry: 'top' } : { exit: 'top', entry: 'bottom' }
|
|
119
|
+
const fromCx = from.x + from.width / 2
|
|
120
|
+
const toCx = to.x + to.width / 2
|
|
121
|
+
return toCx >= fromCx ? { exit: 'right', entry: 'left' } : { exit: 'left', entry: 'right' }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
interface Candidate {
|
|
125
|
+
point: Point
|
|
126
|
+
/** The direction the route is travelling when it arrives: 0 horizontal, 1 vertical, -1 unknown. */
|
|
127
|
+
axis: -1 | 0 | 1
|
|
128
|
+
cost: number
|
|
129
|
+
bends: number
|
|
130
|
+
path: Point[]
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The lane coordinates a route may use: both sides of every obstacle, plus the anchors themselves. */
|
|
134
|
+
function lanes(obstacles: Obstacle[], extra: number[]): number[] {
|
|
135
|
+
const values = new Set<number>(extra)
|
|
136
|
+
for (const box of obstacles) {
|
|
137
|
+
values.add(box.x - LANE_GAP)
|
|
138
|
+
values.add(box.x + box.width + LANE_GAP)
|
|
139
|
+
values.add(box.y - LANE_GAP)
|
|
140
|
+
values.add(box.y + box.height + LANE_GAP)
|
|
141
|
+
}
|
|
142
|
+
return [...values].sort((left, right) => left - right)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Route from one box to another, going around any box in the way.
|
|
147
|
+
*
|
|
148
|
+
* Returns the polyline to draw. When nothing blocks the straight segment, or when every candidate is blocked (a
|
|
149
|
+
* single box that fills the drawing), the straight line is returned and the caller keeps a two-point shape - an
|
|
150
|
+
* unroutable edge is still an edge, and drawing it straight is more honest than dropping it.
|
|
151
|
+
*/
|
|
152
|
+
export interface Ports {
|
|
153
|
+
exit: Point
|
|
154
|
+
entry: Point
|
|
155
|
+
exitSide: Side
|
|
156
|
+
entrySide: Side
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Does this port face away from the box the edge is heading for?
|
|
161
|
+
*
|
|
162
|
+
* A side is usually picked from the direction of travel, so its outward normal points at the other box. A discrete
|
|
163
|
+
* port may break that: when the four points of a diamond are taken, the last edge leaves by the one that is left, and
|
|
164
|
+
* that side can face the wrong way. The route then has to come back round the box it just left.
|
|
165
|
+
*/
|
|
166
|
+
function facesAway(side: Side, port: Point, other: Obstacle): boolean {
|
|
167
|
+
const normal = normalOf(side)
|
|
168
|
+
return normal.x * (other.x + other.width / 2 - port.x) + normal.y * (other.y + other.height / 2 - port.y) < 0
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function routeEdge(
|
|
172
|
+
from: Obstacle,
|
|
173
|
+
to: Obstacle,
|
|
174
|
+
obstacles: Obstacle[],
|
|
175
|
+
anchors?: Ports,
|
|
176
|
+
): { points: Point[]; routed: boolean } {
|
|
177
|
+
const sides = approachSides(from, to)
|
|
178
|
+
const exitSide = anchors?.exitSide ?? sides.exit
|
|
179
|
+
const entrySide = anchors?.entrySide ?? sides.entry
|
|
180
|
+
const exit = anchors?.exit ?? anchor(from, exitSide)
|
|
181
|
+
const entry = anchors?.entry ?? anchor(to, entrySide)
|
|
182
|
+
|
|
183
|
+
// The stub: an edge leaves square to its side and arrives square at the target's side. Without it a route may
|
|
184
|
+
// slide sideways out of a box, which is what made the line look like it was glued to whatever box was nearest.
|
|
185
|
+
const others = obstacles.filter((box) => box !== from && box !== to)
|
|
186
|
+
// A port that faces away from its target makes the box it belongs to an obstacle of its own: the route has to wrap
|
|
187
|
+
// round that box, and a search that does not know this cuts straight through it. It is an obstacle only then, so
|
|
188
|
+
// every route whose sides point the way it travels is decided exactly as it was.
|
|
189
|
+
const own: Obstacle[] = []
|
|
190
|
+
if (facesAway(exitSide, exit, to)) own.push(from)
|
|
191
|
+
if (facesAway(entrySide, entry, from)) own.push(to)
|
|
192
|
+
const blockers = own.length === 0 ? others : [...others, ...own]
|
|
193
|
+
// A stub that lands inside another box is no stub: keep the plain anchors and let the search decide.
|
|
194
|
+
const blockedStub = (point: Point): boolean => blockers.some((box) => point.x > box.x && point.x < box.x + box.width && point.y > box.y && point.y < box.y + box.height)
|
|
195
|
+
const outNormal = normalOf(exitSide)
|
|
196
|
+
const inNormal = normalOf(entrySide)
|
|
197
|
+
let exitStub = { x: exit.x + outNormal.x * ROUTE_STUB, y: exit.y + outNormal.y * ROUTE_STUB }
|
|
198
|
+
let entryStub = { x: entry.x + inNormal.x * ROUTE_STUB, y: entry.y + inNormal.y * ROUTE_STUB }
|
|
199
|
+
if (blockedStub(exitStub) || blockedStub(entryStub)) {
|
|
200
|
+
exitStub = exit
|
|
201
|
+
entryStub = entry
|
|
202
|
+
}
|
|
203
|
+
const straight = [exit, exitStub, entryStub, entry]
|
|
204
|
+
|
|
205
|
+
// A straight line is only offered when it is axis-aligned and clear. A diagonal is clear often enough and still
|
|
206
|
+
// wrong: in a layered drawing it reads as a connection that skips the layers it crosses.
|
|
207
|
+
const axisAligned = Math.abs(exitStub.x - entryStub.x) < 0.5 || Math.abs(exitStub.y - entryStub.y) < 0.5
|
|
208
|
+
if (axisAligned && !blockers.some((box) => crossesBox(exitStub.x, exitStub.y, entryStub.x, entryStub.y, box))) {
|
|
209
|
+
return { points: simplify(straight), routed: false }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// The grid: one axis is x-lane values, the other y-lane values, and the routes are their intersections.
|
|
213
|
+
const xs = lanes(blockers, [exitStub.x, entryStub.x])
|
|
214
|
+
const ys = lanes(blockers, [exitStub.y, entryStub.y])
|
|
215
|
+
|
|
216
|
+
const blocked = (x1: number, y1: number, x2: number, y2: number): boolean =>
|
|
217
|
+
blockers.some((box) => crossesBox(x1, y1, x2, y2, box))
|
|
218
|
+
|
|
219
|
+
const start: Candidate = { point: exitStub, axis: -1, cost: 0, bends: 0, path: exitStub === exit ? [exit] : [exit, exitStub] }
|
|
220
|
+
const queue: Candidate[] = [start]
|
|
221
|
+
const best = new Map<string, number>()
|
|
222
|
+
const key = (point: Point): string => String(Math.round(point.x)) + ',' + String(Math.round(point.y))
|
|
223
|
+
|
|
224
|
+
while (queue.length > 0) {
|
|
225
|
+
// Small graphs: a linear scan for the cheapest open node is simpler than a heap and just as correct.
|
|
226
|
+
let index = 0
|
|
227
|
+
for (let cursor = 1; cursor < queue.length; cursor += 1) if (queue[cursor].cost < queue[index].cost) index = cursor
|
|
228
|
+
const current = queue.splice(index, 1)[0]
|
|
229
|
+
if (current.cost > (best.get(key(current.point)) ?? Infinity)) continue
|
|
230
|
+
if (Math.abs(current.point.x - entryStub.x) < 0.5 && Math.abs(current.point.y - entryStub.y) < 0.5) {
|
|
231
|
+
return { points: simplify([...current.path, entryStub, entry]), routed: true }
|
|
232
|
+
}
|
|
233
|
+
const options: Point[] = []
|
|
234
|
+
for (const x of xs) if (Math.abs(x - current.point.x) > 0.5) options.push({ x, y: current.point.y })
|
|
235
|
+
for (const y of ys) if (Math.abs(y - current.point.y) > 0.5) options.push({ x: current.point.x, y })
|
|
236
|
+
for (const next of options) {
|
|
237
|
+
if (blocked(current.point.x, current.point.y, next.x, next.y)) continue
|
|
238
|
+
const axis: 0 | 1 = Math.abs(next.y - current.point.y) < 0.5 ? 0 : 1
|
|
239
|
+
const bend = current.axis === -1 || current.axis === axis ? 0 : 1
|
|
240
|
+
const cost = current.cost + Math.abs(next.x - current.point.x) + Math.abs(next.y - current.point.y) + bend * BEND_COST
|
|
241
|
+
const state = key(next)
|
|
242
|
+
if (cost >= (best.get(state) ?? Infinity)) continue
|
|
243
|
+
best.set(state, cost)
|
|
244
|
+
queue.push({ point: next, axis, cost, bends: current.bends + bend, path: [...current.path, next] })
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// Nothing found: the sparse grid connects every box to the lane outside the obstacles, so a search that finds
|
|
248
|
+
// nothing is a graph this router cannot help. The straight line is still an edge, and drawing it straight is
|
|
249
|
+
// more honest than dropping it.
|
|
250
|
+
return { points: simplify(straight), routed: false }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Drop the points in the middle of a straight run: three collinear points are one segment. */
|
|
254
|
+
export function simplify(points: Point[]): Point[] {
|
|
255
|
+
if (points.length <= 2) return points
|
|
256
|
+
const out: Point[] = [points[0]]
|
|
257
|
+
for (let index = 1; index < points.length - 1; index += 1) {
|
|
258
|
+
const previous = out[out.length - 1]
|
|
259
|
+
const current = points[index]
|
|
260
|
+
const next = points[index + 1]
|
|
261
|
+
const collinear =
|
|
262
|
+
(Math.abs(previous.x - current.x) < 0.5 && Math.abs(current.x - next.x) < 0.5) ||
|
|
263
|
+
(Math.abs(previous.y - current.y) < 0.5 && Math.abs(current.y - next.y) < 0.5)
|
|
264
|
+
if (!collinear) out.push(current)
|
|
265
|
+
}
|
|
266
|
+
out.push(points[points.length - 1])
|
|
267
|
+
return out
|
|
268
|
+
}
|
|
269
|
+
|