dsh-model-everything 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.en-US.md +12 -0
  2. package/README.md +4 -0
  3. package/lib/client.js +2829 -224
  4. package/lib/render/channels.js +243 -0
  5. package/lib/render/collapse.js +12 -20
  6. package/lib/render/components.js +99 -0
  7. package/lib/render/coords.js +288 -0
  8. package/lib/render/cycle-break.js +147 -0
  9. package/lib/render/dummy.js +43 -0
  10. package/lib/render/from-mermaid.js +34 -10
  11. package/lib/render/from-plantuml.js +318 -46
  12. package/lib/render/layer.js +91 -0
  13. package/lib/render/layout.js +27 -11
  14. package/lib/render/network-simplex.js +272 -0
  15. package/lib/render/order.js +122 -0
  16. package/lib/render/paint.js +1227 -69
  17. package/lib/render/place.js +393 -121
  18. package/lib/render/route.js +225 -0
  19. package/lib/render/svg.js +146 -21
  20. package/lib/render/symbol.js +108 -0
  21. package/lib/render/text.js +74 -0
  22. package/lib/render/trunk.js +213 -0
  23. package/lib/render/wrap.js +87 -0
  24. package/lib/types/render/channels.d.ts +63 -0
  25. package/lib/types/render/components.d.ts +57 -0
  26. package/lib/types/render/coords.d.ts +64 -0
  27. package/lib/types/render/cycle-break.d.ts +45 -0
  28. package/lib/types/render/dummy.d.ts +32 -0
  29. package/lib/types/render/from-plantuml.d.ts +12 -6
  30. package/lib/types/render/layer.d.ts +28 -0
  31. package/lib/types/render/layout.d.ts +43 -11
  32. package/lib/types/render/model.d.ts +17 -0
  33. package/lib/types/render/network-simplex.d.ts +54 -0
  34. package/lib/types/render/order.d.ts +27 -0
  35. package/lib/types/render/paint.d.ts +157 -10
  36. package/lib/types/render/place.d.ts +40 -19
  37. package/lib/types/render/route.d.ts +77 -0
  38. package/lib/types/render/svg.d.ts +1 -1
  39. package/lib/types/render/symbol.d.ts +73 -0
  40. package/lib/types/render/text.d.ts +38 -0
  41. package/lib/types/render/trunk.d.ts +104 -0
  42. package/lib/types/render/wrap.d.ts +27 -0
  43. package/package.json +148 -148
  44. package/skills/model-everything-diagram/SKILL.md +85 -82
  45. package/src/render/channels.ts +309 -0
  46. package/src/render/collapse.ts +150 -155
  47. package/src/render/components.ts +126 -0
  48. package/src/render/coords.ts +328 -0
  49. package/src/render/cycle-break.ts +164 -0
  50. package/src/render/dummy.ts +65 -0
  51. package/src/render/from-mermaid.ts +261 -236
  52. package/src/render/from-plantuml.ts +469 -178
  53. package/src/render/layer.ts +98 -0
  54. package/src/render/layout.ts +101 -62
  55. package/src/render/model.ts +86 -67
  56. package/src/render/network-simplex.ts +290 -0
  57. package/src/render/order.ts +139 -0
  58. package/src/render/paint.ts +1570 -290
  59. package/src/render/place.ts +532 -234
  60. package/src/render/route.ts +269 -0
  61. package/src/render/svg.ts +274 -127
  62. package/src/render/symbol.ts +137 -0
  63. package/src/render/text.ts +86 -0
  64. package/src/render/trunk.ts +275 -0
  65. package/src/render/wrap.ts +103 -0
@@ -0,0 +1,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
+