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,225 @@
|
|
|
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
|
+
/** How far outside a box a lane runs, so a route does not graze the border. */
|
|
26
|
+
const LANE_GAP = 10;
|
|
27
|
+
/** The length of the straight run an edge takes before it is allowed to turn. */
|
|
28
|
+
export const ROUTE_STUB = 16;
|
|
29
|
+
/** The outward normal of a side: the direction an edge leaves along. */
|
|
30
|
+
export function normalOf(side) {
|
|
31
|
+
switch (side) {
|
|
32
|
+
case 'top':
|
|
33
|
+
return { x: 0, y: -1 };
|
|
34
|
+
case 'bottom':
|
|
35
|
+
return { x: 0, y: 1 };
|
|
36
|
+
case 'left':
|
|
37
|
+
return { x: -1, y: 0 };
|
|
38
|
+
case 'right':
|
|
39
|
+
return { x: 1, y: 0 };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** What a bend costs, in points of length: a route prefers one long detour to three small ones. */
|
|
43
|
+
const BEND_COST = 24;
|
|
44
|
+
/** A segment crossing a box at all, with a little slack so a route does not shave a corner. */
|
|
45
|
+
export function crossesBox(x1, y1, x2, y2, box) {
|
|
46
|
+
const minX = box.x + 0.5;
|
|
47
|
+
const maxX = box.x + box.width - 0.5;
|
|
48
|
+
const minY = box.y + 0.5;
|
|
49
|
+
const maxY = box.y + box.height - 0.5;
|
|
50
|
+
// Axis-aligned segments are the ones this router emits; test them exactly rather than by sampling.
|
|
51
|
+
if (Math.abs(y1 - y2) < 0.001) {
|
|
52
|
+
const y = y1;
|
|
53
|
+
if (y <= minY || y >= maxY)
|
|
54
|
+
return false;
|
|
55
|
+
return Math.max(x1, x2) > minX && Math.min(x1, x2) < maxX;
|
|
56
|
+
}
|
|
57
|
+
if (Math.abs(x1 - x2) < 0.001) {
|
|
58
|
+
const x = x1;
|
|
59
|
+
if (x <= minX || x >= maxX)
|
|
60
|
+
return false;
|
|
61
|
+
return Math.max(y1, y2) > minY && Math.min(y1, y2) < maxY;
|
|
62
|
+
}
|
|
63
|
+
// A diagonal (the fallback, and the straight segment): sample it. Cheap, and only ever used for one segment.
|
|
64
|
+
const steps = Math.max(8, Math.round(Math.hypot(x2 - x1, y2 - y1)));
|
|
65
|
+
for (let index = 0; index <= steps; index += 1) {
|
|
66
|
+
const x = x1 + ((x2 - x1) * index) / steps;
|
|
67
|
+
const y = y1 + ((y2 - y1) * index) / steps;
|
|
68
|
+
if (x > minX && x < maxX && y > minY && y < maxY)
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
/** The point on a side of a box, in the middle of that side. */
|
|
74
|
+
export function anchor(box, side) {
|
|
75
|
+
switch (side) {
|
|
76
|
+
case 'top':
|
|
77
|
+
return { x: box.x + box.width / 2, y: box.y };
|
|
78
|
+
case 'bottom':
|
|
79
|
+
return { x: box.x + box.width / 2, y: box.y + box.height };
|
|
80
|
+
case 'left':
|
|
81
|
+
return { x: box.x, y: box.y + box.height / 2 };
|
|
82
|
+
case 'right':
|
|
83
|
+
return { x: box.x + box.width, y: box.y + box.height / 2 };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Which side of `from` an edge leaves by, and which side of `to` it arrives at.
|
|
88
|
+
*
|
|
89
|
+
* The side is a property of the drawing, not of the distance between two centres. A layered drawing puts its boxes
|
|
90
|
+
* in horizontal rows, and an edge between two rows approaches vertically: out of the bottom, into the top, and the
|
|
91
|
+
* other way round for an edge drawn against the flow. Only two boxes in the same row are joined sideways, and then
|
|
92
|
+
* the direction of travel decides which side.
|
|
93
|
+
*
|
|
94
|
+
* This replaces the old rule, which compared the two centres' dx and dy and picked the larger axis - so an edge
|
|
95
|
+
* between two rows left through a box's side as soon as the target was far enough sideways, which is the hook a
|
|
96
|
+
* top-down drawing is read as a defect.
|
|
97
|
+
*/
|
|
98
|
+
export function approachSides(from, to) {
|
|
99
|
+
const fromCy = from.y + from.height / 2;
|
|
100
|
+
const toCy = to.y + to.height / 2;
|
|
101
|
+
if (Math.abs(toCy - fromCy) > 0.5)
|
|
102
|
+
return toCy > fromCy ? { exit: 'bottom', entry: 'top' } : { exit: 'top', entry: 'bottom' };
|
|
103
|
+
const fromCx = from.x + from.width / 2;
|
|
104
|
+
const toCx = to.x + to.width / 2;
|
|
105
|
+
return toCx >= fromCx ? { exit: 'right', entry: 'left' } : { exit: 'left', entry: 'right' };
|
|
106
|
+
}
|
|
107
|
+
/** The lane coordinates a route may use: both sides of every obstacle, plus the anchors themselves. */
|
|
108
|
+
function lanes(obstacles, extra) {
|
|
109
|
+
const values = new Set(extra);
|
|
110
|
+
for (const box of obstacles) {
|
|
111
|
+
values.add(box.x - LANE_GAP);
|
|
112
|
+
values.add(box.x + box.width + LANE_GAP);
|
|
113
|
+
values.add(box.y - LANE_GAP);
|
|
114
|
+
values.add(box.y + box.height + LANE_GAP);
|
|
115
|
+
}
|
|
116
|
+
return [...values].sort((left, right) => left - right);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Does this port face away from the box the edge is heading for?
|
|
120
|
+
*
|
|
121
|
+
* A side is usually picked from the direction of travel, so its outward normal points at the other box. A discrete
|
|
122
|
+
* port may break that: when the four points of a diamond are taken, the last edge leaves by the one that is left, and
|
|
123
|
+
* that side can face the wrong way. The route then has to come back round the box it just left.
|
|
124
|
+
*/
|
|
125
|
+
function facesAway(side, port, other) {
|
|
126
|
+
const normal = normalOf(side);
|
|
127
|
+
return normal.x * (other.x + other.width / 2 - port.x) + normal.y * (other.y + other.height / 2 - port.y) < 0;
|
|
128
|
+
}
|
|
129
|
+
export function routeEdge(from, to, obstacles, anchors) {
|
|
130
|
+
const sides = approachSides(from, to);
|
|
131
|
+
const exitSide = anchors?.exitSide ?? sides.exit;
|
|
132
|
+
const entrySide = anchors?.entrySide ?? sides.entry;
|
|
133
|
+
const exit = anchors?.exit ?? anchor(from, exitSide);
|
|
134
|
+
const entry = anchors?.entry ?? anchor(to, entrySide);
|
|
135
|
+
// The stub: an edge leaves square to its side and arrives square at the target's side. Without it a route may
|
|
136
|
+
// slide sideways out of a box, which is what made the line look like it was glued to whatever box was nearest.
|
|
137
|
+
const others = obstacles.filter((box) => box !== from && box !== to);
|
|
138
|
+
// A port that faces away from its target makes the box it belongs to an obstacle of its own: the route has to wrap
|
|
139
|
+
// round that box, and a search that does not know this cuts straight through it. It is an obstacle only then, so
|
|
140
|
+
// every route whose sides point the way it travels is decided exactly as it was.
|
|
141
|
+
const own = [];
|
|
142
|
+
if (facesAway(exitSide, exit, to))
|
|
143
|
+
own.push(from);
|
|
144
|
+
if (facesAway(entrySide, entry, from))
|
|
145
|
+
own.push(to);
|
|
146
|
+
const blockers = own.length === 0 ? others : [...others, ...own];
|
|
147
|
+
// A stub that lands inside another box is no stub: keep the plain anchors and let the search decide.
|
|
148
|
+
const blockedStub = (point) => blockers.some((box) => point.x > box.x && point.x < box.x + box.width && point.y > box.y && point.y < box.y + box.height);
|
|
149
|
+
const outNormal = normalOf(exitSide);
|
|
150
|
+
const inNormal = normalOf(entrySide);
|
|
151
|
+
let exitStub = { x: exit.x + outNormal.x * ROUTE_STUB, y: exit.y + outNormal.y * ROUTE_STUB };
|
|
152
|
+
let entryStub = { x: entry.x + inNormal.x * ROUTE_STUB, y: entry.y + inNormal.y * ROUTE_STUB };
|
|
153
|
+
if (blockedStub(exitStub) || blockedStub(entryStub)) {
|
|
154
|
+
exitStub = exit;
|
|
155
|
+
entryStub = entry;
|
|
156
|
+
}
|
|
157
|
+
const straight = [exit, exitStub, entryStub, entry];
|
|
158
|
+
// A straight line is only offered when it is axis-aligned and clear. A diagonal is clear often enough and still
|
|
159
|
+
// wrong: in a layered drawing it reads as a connection that skips the layers it crosses.
|
|
160
|
+
const axisAligned = Math.abs(exitStub.x - entryStub.x) < 0.5 || Math.abs(exitStub.y - entryStub.y) < 0.5;
|
|
161
|
+
if (axisAligned && !blockers.some((box) => crossesBox(exitStub.x, exitStub.y, entryStub.x, entryStub.y, box))) {
|
|
162
|
+
return { points: simplify(straight), routed: false };
|
|
163
|
+
}
|
|
164
|
+
// The grid: one axis is x-lane values, the other y-lane values, and the routes are their intersections.
|
|
165
|
+
const xs = lanes(blockers, [exitStub.x, entryStub.x]);
|
|
166
|
+
const ys = lanes(blockers, [exitStub.y, entryStub.y]);
|
|
167
|
+
const blocked = (x1, y1, x2, y2) => blockers.some((box) => crossesBox(x1, y1, x2, y2, box));
|
|
168
|
+
const start = { point: exitStub, axis: -1, cost: 0, bends: 0, path: exitStub === exit ? [exit] : [exit, exitStub] };
|
|
169
|
+
const queue = [start];
|
|
170
|
+
const best = new Map();
|
|
171
|
+
const key = (point) => String(Math.round(point.x)) + ',' + String(Math.round(point.y));
|
|
172
|
+
while (queue.length > 0) {
|
|
173
|
+
// Small graphs: a linear scan for the cheapest open node is simpler than a heap and just as correct.
|
|
174
|
+
let index = 0;
|
|
175
|
+
for (let cursor = 1; cursor < queue.length; cursor += 1)
|
|
176
|
+
if (queue[cursor].cost < queue[index].cost)
|
|
177
|
+
index = cursor;
|
|
178
|
+
const current = queue.splice(index, 1)[0];
|
|
179
|
+
if (current.cost > (best.get(key(current.point)) ?? Infinity))
|
|
180
|
+
continue;
|
|
181
|
+
if (Math.abs(current.point.x - entryStub.x) < 0.5 && Math.abs(current.point.y - entryStub.y) < 0.5) {
|
|
182
|
+
return { points: simplify([...current.path, entryStub, entry]), routed: true };
|
|
183
|
+
}
|
|
184
|
+
const options = [];
|
|
185
|
+
for (const x of xs)
|
|
186
|
+
if (Math.abs(x - current.point.x) > 0.5)
|
|
187
|
+
options.push({ x, y: current.point.y });
|
|
188
|
+
for (const y of ys)
|
|
189
|
+
if (Math.abs(y - current.point.y) > 0.5)
|
|
190
|
+
options.push({ x: current.point.x, y });
|
|
191
|
+
for (const next of options) {
|
|
192
|
+
if (blocked(current.point.x, current.point.y, next.x, next.y))
|
|
193
|
+
continue;
|
|
194
|
+
const axis = Math.abs(next.y - current.point.y) < 0.5 ? 0 : 1;
|
|
195
|
+
const bend = current.axis === -1 || current.axis === axis ? 0 : 1;
|
|
196
|
+
const cost = current.cost + Math.abs(next.x - current.point.x) + Math.abs(next.y - current.point.y) + bend * BEND_COST;
|
|
197
|
+
const state = key(next);
|
|
198
|
+
if (cost >= (best.get(state) ?? Infinity))
|
|
199
|
+
continue;
|
|
200
|
+
best.set(state, cost);
|
|
201
|
+
queue.push({ point: next, axis, cost, bends: current.bends + bend, path: [...current.path, next] });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// Nothing found: the sparse grid connects every box to the lane outside the obstacles, so a search that finds
|
|
205
|
+
// nothing is a graph this router cannot help. The straight line is still an edge, and drawing it straight is
|
|
206
|
+
// more honest than dropping it.
|
|
207
|
+
return { points: simplify(straight), routed: false };
|
|
208
|
+
}
|
|
209
|
+
/** Drop the points in the middle of a straight run: three collinear points are one segment. */
|
|
210
|
+
export function simplify(points) {
|
|
211
|
+
if (points.length <= 2)
|
|
212
|
+
return points;
|
|
213
|
+
const out = [points[0]];
|
|
214
|
+
for (let index = 1; index < points.length - 1; index += 1) {
|
|
215
|
+
const previous = out[out.length - 1];
|
|
216
|
+
const current = points[index];
|
|
217
|
+
const next = points[index + 1];
|
|
218
|
+
const collinear = (Math.abs(previous.x - current.x) < 0.5 && Math.abs(current.x - next.x) < 0.5) ||
|
|
219
|
+
(Math.abs(previous.y - current.y) < 0.5 && Math.abs(current.y - next.y) < 0.5);
|
|
220
|
+
if (!collinear)
|
|
221
|
+
out.push(current);
|
|
222
|
+
}
|
|
223
|
+
out.push(points[points.length - 1]);
|
|
224
|
+
return out;
|
|
225
|
+
}
|
package/lib/render/svg.js
CHANGED
|
@@ -11,6 +11,24 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Pure, and the output is asserted as text: escaping, geometry, arrowheads, wrapped captions.
|
|
13
13
|
*/
|
|
14
|
+
/**
|
|
15
|
+
* A box's label, one `tspan` per line.
|
|
16
|
+
*
|
|
17
|
+
* The lines were wrapped before the box was placed, so the text fits by construction; a single `<text>` with the
|
|
18
|
+
* whole label is what used to overflow the box and print on top of its neighbours.
|
|
19
|
+
*/
|
|
20
|
+
function boxText(shape, stroke) {
|
|
21
|
+
const lines = shape.lines === undefined || shape.lines.length === 0 ? [shape.label] : shape.lines;
|
|
22
|
+
const lineHeight = 15;
|
|
23
|
+
const first = shape.y + shape.height / 2 - ((lines.length - 1) * lineHeight) / 2 + 4;
|
|
24
|
+
const spans = lines
|
|
25
|
+
.map((line, index) => '<tspan x="' + n(shape.x + shape.width / 2) + '" y="' + n(first + index * lineHeight) + '">' + escape(line) + '</tspan>')
|
|
26
|
+
.join('');
|
|
27
|
+
return '<text font-size="11" text-anchor="middle" fill="' + stroke + '">' + spans + '</text>';
|
|
28
|
+
}
|
|
29
|
+
import { LABEL_HEIGHT } from './paint.js';
|
|
30
|
+
import { textWidth } from './text.js';
|
|
31
|
+
import { cylinderCaps, diamondPoints, hexagonPoints, stadiumRadius } from './symbol.js';
|
|
14
32
|
/** One decimal place is enough at any zoom, and it keeps the markup readable. */
|
|
15
33
|
const n = (value) => {
|
|
16
34
|
const rounded = Math.round(value * 10) / 10;
|
|
@@ -32,25 +50,63 @@ const writers = {
|
|
|
32
50
|
box: (shape, colours) => {
|
|
33
51
|
const stroke = colours[shape.colour];
|
|
34
52
|
const width = shape.initial ? 2.5 : 1;
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
53
|
+
const tail = '" fill="' + colours.surface + '" stroke="' + stroke + '" stroke-width="' + n(width) + '" />';
|
|
54
|
+
const x = shape.x;
|
|
55
|
+
const y = shape.y;
|
|
56
|
+
const w = shape.width;
|
|
57
|
+
const h = shape.height;
|
|
58
|
+
// The symbol's own points come from `symbol.ts`, which is also where the painter reads a port from: one
|
|
59
|
+
// arithmetic for the outline, so an anchor cannot disagree with the shape it lands on.
|
|
60
|
+
const at = (point) => n(point.x) + ' ' + n(point.y);
|
|
61
|
+
const points = (list) => list.map((point) => n(point.x) + ',' + n(point.y)).join(',');
|
|
62
|
+
const curve = (segment) => ' C' + at(segment.c1) + ' ' + at(segment.c2) + ' ' + at(segment.to);
|
|
63
|
+
switch (shape.shape) {
|
|
64
|
+
// A decision: the notation wrote `{…}`, and a rounded rectangle would say "step".
|
|
65
|
+
case 'diamond':
|
|
66
|
+
return '<polygon points="' + points(diamondPoints(shape)) + tail + boxText(shape, stroke);
|
|
67
|
+
case 'hexagon':
|
|
68
|
+
return '<polygon points="' + points(hexagonPoints(shape)) + tail + boxText(shape, stroke);
|
|
69
|
+
// A datastore: the cylinder UML draws for a stored collection.
|
|
70
|
+
case 'cylinder': {
|
|
71
|
+
const caps = cylinderCaps(shape);
|
|
72
|
+
const path = 'M' + at(caps.top.from) + curve(caps.top) + ' L' + at(caps.bottom.from) + curve(caps.bottom) + ' Z';
|
|
73
|
+
return '<path d="' + path + tail + boxText(shape, stroke);
|
|
74
|
+
}
|
|
75
|
+
// A start or end state: the stadium a terminal node is drawn as.
|
|
76
|
+
case 'stadium':
|
|
77
|
+
return '<rect x="' + n(x) + '" y="' + n(y) + '" width="' + n(w) + '" height="' + n(h) + '" rx="' + n(stadiumRadius(shape)) + tail + boxText(shape, stroke);
|
|
78
|
+
case 'circle':
|
|
79
|
+
return '<ellipse cx="' + n(x + w / 2) + '" cy="' + n(y + h / 2) + '" rx="' + n(w / 2) + '" ry="' + n(h / 2) + tail + boxText(shape, stroke);
|
|
80
|
+
// A subroutine carries its double side lines.
|
|
81
|
+
case 'subroutine':
|
|
82
|
+
return ('<rect x="' + n(x) + '" y="' + n(y) + '" width="' + n(w) + '" height="' + n(h) + tail +
|
|
83
|
+
'<line x1="' + n(x + 6) + '" y1="' + n(y) + '" x2="' + n(x + 6) + '" y2="' + n(y + h) + '" stroke="' + stroke + '" stroke-width="1" />' +
|
|
84
|
+
'<line x1="' + n(x + w - 6) + '" y1="' + n(y) + '" x2="' + n(x + w - 6) + '" y2="' + n(y + h) + '" stroke="' + stroke + '" stroke-width="1" />' +
|
|
85
|
+
boxText(shape, stroke));
|
|
86
|
+
default:
|
|
87
|
+
return '<rect x="' + n(x) + '" y="' + n(y) + '" width="' + n(w) + '" height="' + n(h) + '" rx="6' + tail + boxText(shape, stroke);
|
|
88
|
+
}
|
|
39
89
|
},
|
|
40
90
|
link: (shape, colours) => {
|
|
41
91
|
const stroke = colours[shape.colour];
|
|
42
|
-
const
|
|
92
|
+
const marks = edgeMarks({ x: shape.x1, y: shape.y1 }, { x: shape.x2, y: shape.y2 }, { x: shape.x1, y: shape.y1 }, { x: shape.x2, y: shape.y2 }, shape.arrow, stroke, colours.background);
|
|
43
93
|
return ('<line x1="' + n(shape.x1) + '" y1="' + n(shape.y1) + '" x2="' + n(shape.x2) + '" y2="' + n(shape.y2) +
|
|
44
|
-
'" stroke="' + stroke + '" stroke-width="1" />' +
|
|
94
|
+
'" stroke="' + stroke + '" stroke-width="1"' + dash(shape) + ' />' + marks + edgeLabel(shape.label, shape.labelX, shape.labelY, colours));
|
|
45
95
|
},
|
|
46
96
|
path: (shape, colours) => {
|
|
47
97
|
const stroke = colours[shape.colour];
|
|
48
98
|
const points = shape.points.map((point) => n(point.x) + ',' + n(point.y)).join(' ');
|
|
49
99
|
const last = shape.points[shape.points.length - 1];
|
|
50
100
|
const before = shape.points[shape.points.length - 2] ?? last;
|
|
51
|
-
|
|
52
|
-
|
|
101
|
+
const first = shape.points[0];
|
|
102
|
+
const after = shape.points[1] ?? first;
|
|
103
|
+
return ('<polyline points="' + points + '" fill="none" stroke="' + stroke + '" stroke-width="1" stroke-linejoin="round"' + dash(shape) + ' />' +
|
|
104
|
+
edgeMarks(first, after, before, last, shape.arrow, stroke, colours.background) +
|
|
105
|
+
edgeLabel(shape.label, shape.labelX, shape.labelY, colours));
|
|
53
106
|
},
|
|
107
|
+
// A junction dot: the visible answer to "is this a join or a crossing?". Filled in the line's own colour, so it
|
|
108
|
+
// reads as the line thickening where a branch meets it rather than as a new mark on the page.
|
|
109
|
+
junction: (shape, colours) => '<circle cx="' + n(shape.x) + '" cy="' + n(shape.y) + '" r="2.5" fill="' + colours[shape.colour] + '" />',
|
|
54
110
|
title: (shape, colours) => {
|
|
55
111
|
return '<text x="' + n(shape.x) + '" y="' + n(shape.y) + '" font-size="12" fill="' + colours[shape.colour] + '">' + escape(shape.text) + '</text>';
|
|
56
112
|
},
|
|
@@ -59,30 +115,99 @@ const writers = {
|
|
|
59
115
|
const lines = shape.lines
|
|
60
116
|
.map((line, index) => '<tspan x="' + n(shape.x) + '" dy="' + (index === 0 ? 0 : 15) + '">' + escape(line) + '</tspan>')
|
|
61
117
|
.join('');
|
|
62
|
-
return '<text x="' + n(shape.x) + '" y="' + n(shape.y + 11) + '" font-size="10" fill="' + colour + '">' + lines + '</text>';
|
|
118
|
+
return '<text x="' + n(shape.x) + '" y="' + n(shape.y + 11) + '" font-size="10" text-anchor="middle" fill="' + colour + '">' + lines + '</text>';
|
|
63
119
|
},
|
|
64
120
|
};
|
|
121
|
+
/** The dash pattern a route's source stated, or nothing for a solid line. */
|
|
122
|
+
function dash(shape) {
|
|
123
|
+
return shape.dashed === true ? ' stroke-dasharray="4 3"' : '';
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The marks at an edge's two ends.
|
|
127
|
+
*
|
|
128
|
+
* `source`/`sourceNext` give the direction the line leaves the source, and `targetPrev`/`target` the direction it
|
|
129
|
+
* arrives at the target: the two ends of a polyline run in different directions. An edge with no arrow field keeps
|
|
130
|
+
* the plain arrow it has always been drawn with. A composition or an aggregation moves the mark to the source; a
|
|
131
|
+
* dependency and a realisation are told apart by the line, not by the head.
|
|
132
|
+
*/
|
|
133
|
+
function edgeMarks(source, sourceNext, targetPrev, target, arrow, stroke, background) {
|
|
134
|
+
if (arrow === 'composition' || arrow === 'aggregation') {
|
|
135
|
+
return diamond(source, sourceNext, stroke, arrow === 'composition' ? stroke : background);
|
|
136
|
+
}
|
|
137
|
+
if (arrow === 'generalization' || arrow === 'realization') {
|
|
138
|
+
return hollowTriangle(targetPrev, target, stroke, background);
|
|
139
|
+
}
|
|
140
|
+
return arrowHead(targetPrev.x, targetPrev.y, target.x, target.y, stroke);
|
|
141
|
+
}
|
|
142
|
+
/** The tip and the two base corners of a head that points from `from` to `to`. */
|
|
143
|
+
function tipAndBase(from, to) {
|
|
144
|
+
const length = Math.hypot(to.x - from.x, to.y - from.y);
|
|
145
|
+
if (length < 1)
|
|
146
|
+
return undefined;
|
|
147
|
+
const ux = (to.x - from.x) / length;
|
|
148
|
+
const uy = (to.y - from.y) / length;
|
|
149
|
+
const size = 7;
|
|
150
|
+
const half = 3.2;
|
|
151
|
+
const baseX = to.x - ux * size;
|
|
152
|
+
const baseY = to.y - uy * size;
|
|
153
|
+
return {
|
|
154
|
+
tip: n(to.x) + ',' + n(to.y),
|
|
155
|
+
left: n(baseX - uy * half) + ',' + n(baseY + ux * half),
|
|
156
|
+
right: n(baseX + uy * half) + ',' + n(baseY - ux * half),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
65
159
|
/** A small filled triangle at the end of a segment, pointing along it. */
|
|
66
160
|
function arrowHead(x1, y1, x2, y2, stroke) {
|
|
67
|
-
const
|
|
161
|
+
const points = tipAndBase({ x: x1, y: y1 }, { x: x2, y: y2 });
|
|
162
|
+
if (points === undefined)
|
|
163
|
+
return '';
|
|
164
|
+
return '<polygon points="' + points.tip + ' ' + points.left + ' ' + points.right + '" fill="' + stroke + '" />';
|
|
165
|
+
}
|
|
166
|
+
/** The open triangle a generalisation or a realisation ends in: the surface shows through it. */
|
|
167
|
+
function hollowTriangle(from, to, stroke, background) {
|
|
168
|
+
const points = tipAndBase(from, to);
|
|
169
|
+
if (points === undefined)
|
|
170
|
+
return '';
|
|
171
|
+
return ('<polygon points="' + points.tip + ' ' + points.left + ' ' + points.right +
|
|
172
|
+
'" fill="' + background + '" stroke="' + stroke + '" stroke-width="1" />');
|
|
173
|
+
}
|
|
174
|
+
/** The diamond a composition or an aggregation puts at its source, pointing back along the line. */
|
|
175
|
+
function diamond(at, next, stroke, fill) {
|
|
176
|
+
const length = Math.hypot(next.x - at.x, next.y - at.y);
|
|
68
177
|
if (length < 1)
|
|
69
178
|
return '';
|
|
70
|
-
const ux = (
|
|
71
|
-
const uy = (
|
|
72
|
-
const size =
|
|
73
|
-
const half =
|
|
74
|
-
const
|
|
75
|
-
const
|
|
76
|
-
const left =
|
|
77
|
-
const right =
|
|
78
|
-
|
|
179
|
+
const ux = (next.x - at.x) / length;
|
|
180
|
+
const uy = (next.y - at.y) / length;
|
|
181
|
+
const size = 12;
|
|
182
|
+
const half = 4;
|
|
183
|
+
const back = { x: at.x + ux * size, y: at.y + uy * size };
|
|
184
|
+
const middle = { x: at.x + (ux * size) / 2, y: at.y + (uy * size) / 2 };
|
|
185
|
+
const left = { x: middle.x - uy * half, y: middle.y + ux * half };
|
|
186
|
+
const right = { x: middle.x + uy * half, y: middle.y - ux * half };
|
|
187
|
+
const points = [at, left, back, right].map((point) => n(point.x) + ',' + n(point.y)).join(' ');
|
|
188
|
+
return '<polygon points="' + points + '" fill="' + fill + '" stroke="' + stroke + '" stroke-width="1" />';
|
|
79
189
|
}
|
|
80
|
-
/**
|
|
190
|
+
/**
|
|
191
|
+
* An edge label, or nothing when the edge carries no event.
|
|
192
|
+
*
|
|
193
|
+
* The text is centred on the point the painter chose - a point on the edge itself - and drawn over a rectangle in
|
|
194
|
+
* the page colour. The mask is the whole point of drawing it here: it hides the stretch of line behind the text,
|
|
195
|
+
* so the label interrupts its edge instead of floating beside it, which is the only way a reader can tell that
|
|
196
|
+
* the word names that line and not something it happens to sit next to. The label keeps its own colour: the mask
|
|
197
|
+
* is geometry, not emphasis.
|
|
198
|
+
*/
|
|
81
199
|
function edgeLabel(label, x, y, colours) {
|
|
82
200
|
// Total on purpose: a malformed edge must lose its label, not the whole drawing.
|
|
83
201
|
if (typeof label !== 'string' || label === '')
|
|
84
202
|
return '';
|
|
85
|
-
|
|
203
|
+
const width = textWidth(label);
|
|
204
|
+
const left = x - width / 2;
|
|
205
|
+
const top = y - LABEL_HEIGHT / 2;
|
|
206
|
+
// Ten point text sits on a baseline about a third of the mask above its centre, which centres the cap height.
|
|
207
|
+
const baseline = y + 3.5;
|
|
208
|
+
return ('<rect x="' + n(left) + '" y="' + n(top) + '" width="' + n(width) + '" height="' + n(LABEL_HEIGHT) +
|
|
209
|
+
'" fill="' + colours.background + '" />' +
|
|
210
|
+
'<text x="' + n(x) + '" y="' + n(baseline) + '" font-size="10" text-anchor="middle" fill="' + colours.muted + '">' + escape(label) + '</text>');
|
|
86
211
|
}
|
|
87
212
|
export function toSvg(canvas, colours, label) {
|
|
88
213
|
let dropped = 0;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The geometry of a node's symbol, shared by the module that draws it and the module that attaches edges to it.
|
|
3
|
+
*
|
|
4
|
+
* A box is a rectangle, but a symbol is not: the diamond's side midpoints are its vertices, a circle has no side at
|
|
5
|
+
* all, and a cylinder's top is an arc. Any rule that decides where an edge may meet a node therefore needs the
|
|
6
|
+
* symbol's own points - and the module that draws the symbol needs the same numbers, or the anchor and the outline
|
|
7
|
+
* disagree and an edge ends in the air.
|
|
8
|
+
*
|
|
9
|
+
* So the arithmetic lives here once: the painter reads a port from it, the writer draws the shape from it, and a
|
|
10
|
+
* change to a symbol's outline moves both. Pure data and pure functions, no SVG markup.
|
|
11
|
+
*/
|
|
12
|
+
/** A point on a cubic Bezier at parameter `t`, which is what makes "the top of the cap" a shared fact. */
|
|
13
|
+
export function cubicPoint(curve, t) {
|
|
14
|
+
const u = 1 - t;
|
|
15
|
+
const a = u * u * u;
|
|
16
|
+
const b = 3 * u * u * t;
|
|
17
|
+
const c = 3 * u * t * t;
|
|
18
|
+
const d = t * t * t;
|
|
19
|
+
return {
|
|
20
|
+
x: a * curve.from.x + b * curve.c1.x + c * curve.c2.x + d * curve.to.x,
|
|
21
|
+
y: a * curve.from.y + b * curve.c1.y + c * curve.c2.y + d * curve.to.y,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* A diamond's four points, in drawing order: top, right, bottom, left.
|
|
26
|
+
*
|
|
27
|
+
* These are the only four places an edge may meet a diamond: each side of the box meets the symbol at exactly that
|
|
28
|
+
* point, and every other point on the side is in the air inside or outside the outline.
|
|
29
|
+
*/
|
|
30
|
+
export function diamondPoints(box) {
|
|
31
|
+
return [
|
|
32
|
+
{ x: box.x + box.width / 2, y: box.y },
|
|
33
|
+
{ x: box.x + box.width, y: box.y + box.height / 2 },
|
|
34
|
+
{ x: box.x + box.width / 2, y: box.y + box.height },
|
|
35
|
+
{ x: box.x, y: box.y + box.height / 2 },
|
|
36
|
+
];
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Does this symbol meet each of its four sides at exactly one point?
|
|
40
|
+
*
|
|
41
|
+
* A diamond's side midpoints are its four vertices, and an ellipse's are its four cardinal points: both offer one legal
|
|
42
|
+
* port per side and no run to spread along. Every other symbol offers a run on at least one side - a rectangle along
|
|
43
|
+
* its whole side, a hexagon along the flat between its cut corners, a stadium between its end caps, a cylinder along
|
|
44
|
+
* its straight flank - so a port there has a place to sit rather than a point to take.
|
|
45
|
+
*
|
|
46
|
+
* This is the distinction the painter needs: a node with one legal point per side can give its edges a point each,
|
|
47
|
+
* and a node with a run can only give them places along it. The two points the discrete shapes offer are the same
|
|
48
|
+
* four points of the box, which is why one answer serves both.
|
|
49
|
+
*/
|
|
50
|
+
export function hasDiscretePorts(shape) {
|
|
51
|
+
return shape === 'diamond' || shape === 'circle';
|
|
52
|
+
}
|
|
53
|
+
/** How much of each end of a hexagon the corner cuts away. */
|
|
54
|
+
export function hexagonCut(width) {
|
|
55
|
+
return Math.min(12, width / 4);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* A hexagon's six points, in drawing order: top-left, top-right, right, bottom-right, bottom-left, left.
|
|
59
|
+
*
|
|
60
|
+
* The top and bottom are flat runs between the first two and the last two points; the left and right sides are the
|
|
61
|
+
* single points at index 5 and 2, because the corner cut removed everything else.
|
|
62
|
+
*/
|
|
63
|
+
export function hexagonPoints(box) {
|
|
64
|
+
const cut = hexagonCut(box.width);
|
|
65
|
+
return [
|
|
66
|
+
{ x: box.x + cut, y: box.y },
|
|
67
|
+
{ x: box.x + box.width - cut, y: box.y },
|
|
68
|
+
{ x: box.x + box.width, y: box.y + box.height / 2 },
|
|
69
|
+
{ x: box.x + box.width - cut, y: box.y + box.height },
|
|
70
|
+
{ x: box.x + cut, y: box.y + box.height },
|
|
71
|
+
{ x: box.x, y: box.y + box.height / 2 },
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
/** The radius of a stadium's end caps: half its height, unless the box is narrower than that. */
|
|
75
|
+
export function stadiumRadius(box) {
|
|
76
|
+
return Math.min(box.height / 2, box.width / 2);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* A cylinder's two caps.
|
|
80
|
+
*
|
|
81
|
+
* The writer draws the top cap from the left shoulder to the right shoulder with the box's own top edge as the
|
|
82
|
+
* control line, and the bottom cap mirrored. The cap curve's apex - the point the painter attaches a top or bottom
|
|
83
|
+
* port to - is therefore a property of this curve, not a constant: it sits a quarter of the cap's depth inside the
|
|
84
|
+
* box, not `cap` inside it.
|
|
85
|
+
*/
|
|
86
|
+
export function cylinderCaps(box) {
|
|
87
|
+
const cap = Math.min(10, box.height / 3);
|
|
88
|
+
return {
|
|
89
|
+
cap,
|
|
90
|
+
top: {
|
|
91
|
+
from: { x: box.x, y: box.y + cap },
|
|
92
|
+
c1: { x: box.x, y: box.y },
|
|
93
|
+
c2: { x: box.x + box.width, y: box.y },
|
|
94
|
+
to: { x: box.x + box.width, y: box.y + cap },
|
|
95
|
+
},
|
|
96
|
+
bottom: {
|
|
97
|
+
from: { x: box.x + box.width, y: box.y + box.height - cap },
|
|
98
|
+
c1: { x: box.x + box.width, y: box.y + box.height },
|
|
99
|
+
c2: { x: box.x, y: box.y + box.height },
|
|
100
|
+
to: { x: box.x, y: box.y + box.height - cap },
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/** The apex of a cylinder's top or bottom cap: the only point on that side an edge meets the outline at. */
|
|
105
|
+
export function cylinderApex(box, side) {
|
|
106
|
+
const caps = cylinderCaps(box);
|
|
107
|
+
return cubicPoint(side === 'top' ? caps.top : caps.bottom, 0.5);
|
|
108
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text as the layout sees it: measured, broken into lines, and turned into a box.
|
|
3
|
+
*
|
|
4
|
+
* Mermaid and Graphviz both measure a label in the platform's own text engine and then lay out around that
|
|
5
|
+
* measurement - a node's size is its text plus padding, and a label too long for its maximum width is wrapped
|
|
6
|
+
* before anything is placed. There is no DOM here, so the measurement is an estimate over a per-character width
|
|
7
|
+
* table; what matters is that the *order* is the same: text first, geometry second. A renderer that fixes the box
|
|
8
|
+
* and pours text into it cannot help overflowing.
|
|
9
|
+
*
|
|
10
|
+
* Explicit breaks (`<br>`, `<br/>`, `<br />`, a real newline) are honoured as written, because a source that put
|
|
11
|
+
* one there wanted that line to end.
|
|
12
|
+
*/
|
|
13
|
+
/** Average advance per character, in the drawing's units, at the 11px font the writer emits. */
|
|
14
|
+
const CHAR_WIDTH = 6.6;
|
|
15
|
+
/** A line longer than this wraps on a space, or breaks in the middle when it has none. */
|
|
16
|
+
export const MAX_LINE_CHARS = 30;
|
|
17
|
+
/** Height of one line of box text, and the breathing room around the block. */
|
|
18
|
+
export const LINE_HEIGHT = 15;
|
|
19
|
+
export const TEXT_PADDING = 12;
|
|
20
|
+
/** The smallest and largest box a label may produce. */
|
|
21
|
+
export const MIN_NODE_WIDTH = 88;
|
|
22
|
+
export const MAX_NODE_WIDTH = 260;
|
|
23
|
+
/** `<br>`, `<br/>`, `<br />` and a real newline all end a line. */
|
|
24
|
+
const BREAK = /<br\s*\/?>|\r?\n/gi;
|
|
25
|
+
/** The lines a label asks for, before wrapping: an explicit break is a line end, not a space. */
|
|
26
|
+
export function explicitLines(label) {
|
|
27
|
+
return label.split(BREAK).map((line) => line.trim());
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Wrap one line to the maximum width.
|
|
31
|
+
*
|
|
32
|
+
* A word longer than the limit is broken rather than allowed to overflow: a single token with no spaces is the
|
|
33
|
+
* common case in identifiers, and letting it run off the box is the defect this function exists to prevent.
|
|
34
|
+
*/
|
|
35
|
+
export function wrapLine(line, limit = MAX_LINE_CHARS) {
|
|
36
|
+
if (line === '')
|
|
37
|
+
return [''];
|
|
38
|
+
const out = [];
|
|
39
|
+
let current = '';
|
|
40
|
+
for (const word of line.split(/\s+/).filter((part) => part !== '')) {
|
|
41
|
+
const candidate = current === '' ? word : current + ' ' + word;
|
|
42
|
+
if (candidate.length <= limit) {
|
|
43
|
+
current = candidate;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (current !== '')
|
|
47
|
+
out.push(current);
|
|
48
|
+
if (word.length <= limit) {
|
|
49
|
+
current = word;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
let rest = word;
|
|
53
|
+
while (rest.length > limit) {
|
|
54
|
+
out.push(rest.slice(0, limit));
|
|
55
|
+
rest = rest.slice(limit);
|
|
56
|
+
}
|
|
57
|
+
current = rest;
|
|
58
|
+
}
|
|
59
|
+
if (current !== '')
|
|
60
|
+
out.push(current);
|
|
61
|
+
return out.length === 0 ? [''] : out;
|
|
62
|
+
}
|
|
63
|
+
/** The text block a label produces: its lines, and the box it needs. */
|
|
64
|
+
export function measure(label) {
|
|
65
|
+
const lines = explicitLines(label).flatMap((line) => wrapLine(line));
|
|
66
|
+
const longest = lines.reduce((max, line) => Math.max(max, line.length), 0);
|
|
67
|
+
const width = Math.min(MAX_NODE_WIDTH, Math.max(MIN_NODE_WIDTH, longest * CHAR_WIDTH + TEXT_PADDING * 2));
|
|
68
|
+
const height = lines.length * LINE_HEIGHT + TEXT_PADDING;
|
|
69
|
+
return { lines, width, height };
|
|
70
|
+
}
|
|
71
|
+
/** Rough width of a single-line piece of text, for placing an edge label without a box. */
|
|
72
|
+
export function textWidth(text) {
|
|
73
|
+
return text.length * CHAR_WIDTH;
|
|
74
|
+
}
|