@itsthecsguy/linework 0.1.2 → 0.1.4
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.md +40 -0
- package/dist/api.js +9 -6
- package/dist/cli.js +88 -43
- package/dist/drawio/theme.js +42 -20
- package/dist/drawio/write.js +152 -22
- package/dist/layout/layout.js +225 -17
- package/dist/layout/polish.js +281 -0
- package/dist/layout/sequence.js +7 -4
- package/dist/lint/check.js +6 -1
- package/dist/lint/fixlabels.js +22 -14
- package/dist/render/browser.js +96 -32
- package/dist/render/fonts.js +65 -1
- package/dist/spec/model.js +3 -1
- package/package.json +1 -1
- package/skills/linework/SKILL.md +8 -1
- package/skills/linework/examples/auth-seq.yaml +35 -0
- package/skills/linework/examples/deploy-flow.yaml +32 -0
- package/skills/linework/examples/gcp-ingest.yaml +21 -0
- package/skills/linework/examples/job-state.yaml +25 -0
- package/skills/linework/examples/platform.yaml +45 -0
- package/skills/linework/examples/shop-er.yaml +26 -0
- package/skills/linework/references/style-guide.md +9 -1
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { kindDef } from '../spec/model.js';
|
|
2
|
+
/** Map the layout so the flow runs top to bottom, do the work, map back. */
|
|
3
|
+
function frame(dir) {
|
|
4
|
+
const to = (p) => (dir === 'TB' ? p : dir === 'BT' ? { x: p.x, y: -p.y } : dir === 'LR' ? { x: p.y, y: p.x } : { x: p.y, y: -p.x });
|
|
5
|
+
const from = (p) => (dir === 'TB' ? p : dir === 'BT' ? { x: p.x, y: -p.y } : dir === 'LR' ? { x: p.y, y: p.x } : { x: -p.y, y: p.x });
|
|
6
|
+
const box = (b, f) => {
|
|
7
|
+
const a = f({ x: b.x, y: b.y }), c = f({ x: b.x + b.w, y: b.y + b.h });
|
|
8
|
+
return { x: Math.min(a.x, c.x), y: Math.min(a.y, c.y), w: Math.abs(c.x - a.x), h: Math.abs(c.y - a.y) };
|
|
9
|
+
};
|
|
10
|
+
return { to, from, boxTo: (b) => box(b, to), boxFrom: (b) => box(b, from) };
|
|
11
|
+
}
|
|
12
|
+
const near = (a, b, tol = 0.5) => Math.abs(a - b) <= tol;
|
|
13
|
+
const vert = (a, b) => near(a.x, b.x);
|
|
14
|
+
const horiz = (a, b) => near(a.y, b.y);
|
|
15
|
+
/** Drop repeated points and points in the middle of a straight run. */
|
|
16
|
+
export function simplify(pts) {
|
|
17
|
+
const out = [];
|
|
18
|
+
for (const p of pts) {
|
|
19
|
+
const last = out[out.length - 1];
|
|
20
|
+
if (last && near(last.x, p.x) && near(last.y, p.y))
|
|
21
|
+
continue;
|
|
22
|
+
const prev = out[out.length - 2];
|
|
23
|
+
if (prev && last && ((vert(prev, last) && vert(last, p)) || (horiz(prev, last) && horiz(last, p))))
|
|
24
|
+
out[out.length - 1] = p;
|
|
25
|
+
else
|
|
26
|
+
out.push(p);
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
function segHits(a, b, r, pad = 0) {
|
|
31
|
+
const x0 = Math.min(a.x, b.x), x1 = Math.max(a.x, b.x), y0 = Math.min(a.y, b.y), y1 = Math.max(a.y, b.y);
|
|
32
|
+
return x1 > r.x - pad && x0 < r.x + r.w + pad && y1 > r.y - pad && y0 < r.y + r.h + pad;
|
|
33
|
+
}
|
|
34
|
+
export function polish(d, l, inp) {
|
|
35
|
+
const f = frame(l.direction);
|
|
36
|
+
const shapeOf = (id) => { const n = d.nodes.find((x) => x.id === id); return n ? kindDef(d.type, n.kind).shape : undefined; };
|
|
37
|
+
const nodes = new Map([...l.nodes].map(([k, b]) => [k, f.boxTo(b)]));
|
|
38
|
+
// Corners are rounded; an edge leaving inside the arc looks detached.
|
|
39
|
+
const R = 8;
|
|
40
|
+
const edges = [];
|
|
41
|
+
for (const e of d.edges) {
|
|
42
|
+
const le = l.edges.get(e.id);
|
|
43
|
+
if (le)
|
|
44
|
+
edges.push({ id: e.id, from: e.from, to: e.to, pts: le.points.map(f.to), label: !!le.label });
|
|
45
|
+
}
|
|
46
|
+
const others = (self) => edges.filter((x) => x !== self);
|
|
47
|
+
const usedAt = (id, x, skip, side) => others(skip).some((o) => {
|
|
48
|
+
const p = o.from === id ? o.pts[0] : o.to === id ? o.pts[o.pts.length - 1] : undefined;
|
|
49
|
+
const b = nodes.get(id);
|
|
50
|
+
return p && near(p.y, side === 'top' ? b.y : b.y + b.h, 1) && Math.abs(p.x - x) < 10;
|
|
51
|
+
});
|
|
52
|
+
// A vertical run no other edge already runs along.
|
|
53
|
+
const free = (a, b, self) => others(self).every((o) => o.pts.slice(1).every((q, i) => {
|
|
54
|
+
const p = o.pts[i];
|
|
55
|
+
// Two labelled runs side by side need room for the labels between them.
|
|
56
|
+
if (!vert(p, q) || Math.abs(p.x - a.x) > (self.label || o.label ? 44 : 8))
|
|
57
|
+
return true;
|
|
58
|
+
return Math.min(Math.max(p.y, q.y), Math.max(a.y, b.y)) - Math.max(Math.min(p.y, q.y), Math.min(a.y, b.y)) <= 0;
|
|
59
|
+
}));
|
|
60
|
+
const clear = (a, b, except) => [...nodes].every(([k, r]) => except.includes(k) || !segHits(a, b, r, 6));
|
|
61
|
+
// Edges out of a decision wait until the decision pass below has picked the
|
|
62
|
+
// point they leave from; their start stub isn't a route to straighten toward.
|
|
63
|
+
// Two passes: a jog can be blocked by another edge's stub that the first pass removes.
|
|
64
|
+
const dejog = (fromDecisions) => {
|
|
65
|
+
for (let pass = 0; pass < 2; pass++)
|
|
66
|
+
for (const e of edges) {
|
|
67
|
+
// Tables keep their edges on the key rows.
|
|
68
|
+
if (e.from === e.to || shapeOf(e.from) === 'table' || shapeOf(e.to) === 'table')
|
|
69
|
+
continue;
|
|
70
|
+
if ((shapeOf(e.from) === 'diamond') !== fromDecisions)
|
|
71
|
+
continue;
|
|
72
|
+
const s = nodes.get(e.from), t = nodes.get(e.to);
|
|
73
|
+
let p = simplify(e.pts);
|
|
74
|
+
// Start: p0 on the box, a short leg, a jog sideways, then the real run.
|
|
75
|
+
for (let guard = 0; guard < 2 && p.length >= 4; guard++) {
|
|
76
|
+
const [p0, p1, p2] = [p[0], p[1], p[2]];
|
|
77
|
+
const side = near(p0.y, s.y + s.h, 1) ? 'bottom' : near(p0.y, s.y, 1) ? 'top' : undefined;
|
|
78
|
+
if (!side || !vert(p0, p1) || !horiz(p1, p2) || shapeOf(e.from) === 'diamond')
|
|
79
|
+
break;
|
|
80
|
+
const p3 = p[3], np = { x: p2.x, y: p0.y };
|
|
81
|
+
// Only a short leg is a jog; a long one is the route itself.
|
|
82
|
+
// The run p2-p3 is already there; only the piece added at the box is new.
|
|
83
|
+
if (!vert(p2, p3) || !free(np, p2, e))
|
|
84
|
+
break;
|
|
85
|
+
if (p2.x < s.x + R || p2.x > s.x + s.w - R || usedAt(e.from, p2.x, e, side) || !clear(np, p3, [e.from, e.to]))
|
|
86
|
+
break;
|
|
87
|
+
p = simplify([np, ...p.slice(3)]);
|
|
88
|
+
}
|
|
89
|
+
// End: the mirror image.
|
|
90
|
+
for (let guard = 0; guard < 2 && p.length >= 4; guard++) {
|
|
91
|
+
const n = p.length;
|
|
92
|
+
const [q0, q1, q2] = [p[n - 1], p[n - 2], p[n - 3]];
|
|
93
|
+
const side = near(q0.y, t.y, 1) ? 'top' : near(q0.y, t.y + t.h, 1) ? 'bottom' : undefined;
|
|
94
|
+
if (!side || !vert(q0, q1) || !horiz(q1, q2) || shapeOf(e.to) === 'diamond')
|
|
95
|
+
break;
|
|
96
|
+
const q3 = p[n - 4], nq = { x: q2.x, y: q0.y };
|
|
97
|
+
if (!vert(q3, q2) || !free(q2, nq, e))
|
|
98
|
+
break;
|
|
99
|
+
if (q2.x < t.x + R || q2.x > t.x + t.w - R || usedAt(e.to, q2.x, e, side) || !clear(q3, nq, [e.from, e.to]))
|
|
100
|
+
break;
|
|
101
|
+
p = simplify([...p.slice(0, n - 3), nq]);
|
|
102
|
+
}
|
|
103
|
+
e.pts = p;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
dejog(false);
|
|
107
|
+
// A Z between boxes that overlap side to side (down, across, down) can be one
|
|
108
|
+
// straight drop through the overlap.
|
|
109
|
+
for (const e of edges) {
|
|
110
|
+
if (e.from === e.to || [e.from, e.to].some((id) => ['table', 'diamond'].includes(shapeOf(id) ?? '')))
|
|
111
|
+
continue;
|
|
112
|
+
const p = simplify(e.pts);
|
|
113
|
+
if (p.length !== 4)
|
|
114
|
+
continue;
|
|
115
|
+
const s = nodes.get(e.from), t = nodes.get(e.to);
|
|
116
|
+
const [a, b, c, z] = p;
|
|
117
|
+
if (!vert(a, b) || !horiz(b, c) || !vert(c, z) || !near(a.y, s.y + s.h, 1) || !near(z.y, t.y, 1))
|
|
118
|
+
continue;
|
|
119
|
+
const lo = Math.max(s.x, t.x) + R, hi = Math.min(s.x + s.w, t.x + t.w) - R;
|
|
120
|
+
if (hi < lo)
|
|
121
|
+
continue;
|
|
122
|
+
const x = Math.round(Math.min(hi, Math.max(lo, (a.x + z.x) / 2)));
|
|
123
|
+
const top = { x, y: a.y }, bot = { x, y: z.y };
|
|
124
|
+
if (usedAt(e.from, x, e, 'bottom') || usedAt(e.to, x, e, 'top') || !clear(top, bot, [e.from, e.to]) || !free(top, bot, e))
|
|
125
|
+
continue;
|
|
126
|
+
e.pts = [top, bot];
|
|
127
|
+
const le = l.edges.get(e.id);
|
|
128
|
+
if (le.label) {
|
|
129
|
+
const lb = f.boxTo(le.label);
|
|
130
|
+
le.label = f.boxFrom({ ...lb, x: x - lb.w / 2, y: (top.y + bot.y) / 2 - lb.h / 2 });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// Decisions: leave from the diamond's bottom or side points, enter at the top point.
|
|
134
|
+
for (const n of d.nodes) {
|
|
135
|
+
if (kindDef(d.type, n.kind).shape !== 'diamond')
|
|
136
|
+
continue;
|
|
137
|
+
const b = nodes.get(n.id);
|
|
138
|
+
const cx = b.x + b.w / 2, cy = b.y + b.h / 2;
|
|
139
|
+
const outs = edges.filter((e) => e.from === n.id && e.to !== n.id);
|
|
140
|
+
// The run each edge settles into once it is clear of the diamond.
|
|
141
|
+
const runX = (e) => {
|
|
142
|
+
const p = e.pts;
|
|
143
|
+
for (let i = 1; i < p.length - 1; i++)
|
|
144
|
+
if (vert(p[i], p[i + 1]) && p[i + 1].y > b.y + b.h + 1)
|
|
145
|
+
return p[i].x;
|
|
146
|
+
return p[p.length - 1].x;
|
|
147
|
+
};
|
|
148
|
+
const tail = (e) => {
|
|
149
|
+
// Points from the start of that run on.
|
|
150
|
+
const p = e.pts;
|
|
151
|
+
for (let i = 1; i < p.length - 1; i++)
|
|
152
|
+
if (vert(p[i], p[i + 1]) && p[i + 1].y > b.y + b.h + 1)
|
|
153
|
+
return p.slice(i);
|
|
154
|
+
return p.slice(-1);
|
|
155
|
+
};
|
|
156
|
+
const sorted = [...outs].sort((a, c) => runX(a) - runX(c));
|
|
157
|
+
let bottomTaken = false;
|
|
158
|
+
// The edge closest to straight down gets the bottom point.
|
|
159
|
+
const straight = sorted.reduce((m, e) => (!m || Math.abs(runX(e) - cx) < Math.abs(runX(m) - cx) ? e : m), undefined);
|
|
160
|
+
for (const e of sorted) {
|
|
161
|
+
const x = runX(e), rest = tail(e);
|
|
162
|
+
const bottom = { x: cx, y: b.y + b.h };
|
|
163
|
+
if (e === straight && Math.abs(x - cx) < 12) {
|
|
164
|
+
e.pts = simplify([bottom, { x: cx, y: rest[0].y }, ...rest.slice(1)].map((pt, i) => (i === 1 ? pt : pt)));
|
|
165
|
+
if (rest.length && !near(rest[0].x, cx))
|
|
166
|
+
e.pts = simplify([bottom, { x: cx, y: rest[0].y }, ...rest]);
|
|
167
|
+
bottomTaken = true;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const left = x < cx;
|
|
171
|
+
const sideP = { x: left ? b.x : b.x + b.w, y: cy };
|
|
172
|
+
const corner = { x, y: cy };
|
|
173
|
+
const sideOk = (left ? x < b.x - 10 : x > b.x + b.w + 10) && clear(sideP, corner, [n.id]) && clear(corner, rest[0], [n.id, e.to]);
|
|
174
|
+
if (sideOk) {
|
|
175
|
+
e.pts = simplify([sideP, corner, ...rest]);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
// Branches that both head down fork from the bottom point on a short stem.
|
|
179
|
+
const y = Math.min(rest[0].y, b.y + b.h + 16);
|
|
180
|
+
e.pts = simplify([bottom, { x: cx, y }, { x, y }, ...rest]);
|
|
181
|
+
bottomTaken = true;
|
|
182
|
+
}
|
|
183
|
+
for (const e of edges.filter((x) => x.to === n.id && x.from !== n.id)) {
|
|
184
|
+
const p = e.pts, m = p.length;
|
|
185
|
+
const last = p[m - 1], prev = p[m - 2];
|
|
186
|
+
if (!prev || !vert(prev, last) || prev.y > last.y || near(last.x, cx)) {
|
|
187
|
+
if (prev && vert(prev, last) && prev.y <= last.y)
|
|
188
|
+
e.pts[m - 1] = { x: cx, y: b.y };
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const y = Math.max(prev.y, b.y - 16);
|
|
192
|
+
e.pts = simplify([...p.slice(0, m - 1), { x: last.x, y }, { x: cx, y }, { x: cx, y: b.y }]);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
dejog(true);
|
|
196
|
+
// Self-loops: wide enough to read as a loop, label outside it.
|
|
197
|
+
for (const e of edges) {
|
|
198
|
+
if (e.from !== e.to)
|
|
199
|
+
continue;
|
|
200
|
+
const b = nodes.get(e.from), le = l.edges.get(e.id);
|
|
201
|
+
const ys = [b.y + b.h * 0.28, b.y + b.h * 0.72];
|
|
202
|
+
const pts = e.pts;
|
|
203
|
+
const leftSide = pts.some((p) => p.x < b.x - 1);
|
|
204
|
+
const xo = leftSide ? b.x - 24 : b.x + b.w + 24;
|
|
205
|
+
const edgeX = leftSide ? b.x : b.x + b.w;
|
|
206
|
+
e.pts = [{ x: edgeX, y: ys[0] }, { x: xo, y: ys[0] }, { x: xo, y: ys[1] }, { x: edgeX, y: ys[1] }];
|
|
207
|
+
if (le.label) {
|
|
208
|
+
const lb = f.boxTo(le.label);
|
|
209
|
+
le.label = f.boxFrom({ ...lb, x: leftSide ? xo - 6 - lb.w : xo + 6, y: b.y + b.h / 2 - lb.h / 2 });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
for (const e of edges) {
|
|
213
|
+
const le = l.edges.get(e.id);
|
|
214
|
+
le.points = e.pts.map(f.from);
|
|
215
|
+
}
|
|
216
|
+
// A one-column group is only as wide as its node, so an edge dropping into the
|
|
217
|
+
// node's middle crosses the title. ELK ignores a minimum size on groups it lays
|
|
218
|
+
// out with their children, so widen them here: evenly to both sides, when the
|
|
219
|
+
// added strips hold nothing else.
|
|
220
|
+
const owner = new Map([...d.groups.map((g) => [g.id, g.parent]), ...d.nodes.map((n) => [n.id, n.parent])]);
|
|
221
|
+
const within = (id, gid) => !!id && (id === gid || within(owner.get(id), gid));
|
|
222
|
+
const depth = (gid) => { let k = 0; for (let p = owner.get(gid); p; p = owner.get(p))
|
|
223
|
+
k++; return k; };
|
|
224
|
+
for (const gid of [...l.groups.keys()].sort((a, b) => depth(b) - depth(a))) {
|
|
225
|
+
const g = l.groups.get(gid), tt = inp.groupTitle.get(gid);
|
|
226
|
+
if (!tt)
|
|
227
|
+
continue;
|
|
228
|
+
const half = Math.ceil((2 * (tt.w + inp.titleInset + 10) - g.w) / 2);
|
|
229
|
+
if (half <= 0)
|
|
230
|
+
continue;
|
|
231
|
+
const strips = [{ x: g.x - half, y: g.y, w: half, h: g.h }, { x: g.x + g.w, y: g.y, w: half, h: g.h }];
|
|
232
|
+
const hits = (r) => strips.some((s) => r.x < s.x + s.w + 8 && r.x + r.w > s.x - 8 && r.y < s.y + s.h && r.y + r.h > s.y);
|
|
233
|
+
const parent = owner.get(gid) ? l.groups.get(owner.get(gid)) : undefined;
|
|
234
|
+
const fits = (!parent || (g.x - half >= parent.x + 8 && g.x + g.w + half <= parent.x + parent.w - 8)) &&
|
|
235
|
+
[...l.nodes].every(([id, b]) => within(id, gid) || !hits(b)) &&
|
|
236
|
+
[...l.groups].every(([id, b]) => id === gid || within(id, gid) || within(gid, id) || !hits(b)) &&
|
|
237
|
+
[...l.edges.values()].every((le) => le.points.slice(1).every((q, i) => strips.every((s) => !segHits(le.points[i], q, s))));
|
|
238
|
+
if (fits)
|
|
239
|
+
l.groups.set(gid, { ...g, x: g.x - half, w: g.w + 2 * half });
|
|
240
|
+
}
|
|
241
|
+
// Keep the drawing clear of the canvas edge after widening.
|
|
242
|
+
const left = Math.min(...[...l.groups.values(), ...l.nodes.values()].map((b) => b.x));
|
|
243
|
+
if (left < 16) {
|
|
244
|
+
const dx = 16 - left;
|
|
245
|
+
const mv = (b) => ({ ...b, x: b.x + dx });
|
|
246
|
+
for (const m of [l.nodes, l.groups])
|
|
247
|
+
for (const [k, b] of m)
|
|
248
|
+
m.set(k, mv(b));
|
|
249
|
+
for (const le of l.edges.values()) {
|
|
250
|
+
le.points = le.points.map((p) => ({ ...p, x: p.x + dx }));
|
|
251
|
+
if (le.label)
|
|
252
|
+
le.label = mv(le.label);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
l.width = Math.max(l.width, ...[...l.groups.values(), ...l.nodes.values()].map((b) => b.x + b.w + 16));
|
|
256
|
+
// Group titles: slide a title right when an edge runs through it.
|
|
257
|
+
for (const [gid, g] of l.groups) {
|
|
258
|
+
const tt = inp.groupTitle.get(gid);
|
|
259
|
+
if (!tt)
|
|
260
|
+
continue;
|
|
261
|
+
const segs = [...l.edges.values()].flatMap((le) => le.points.slice(1).map((p, i) => [le.points[i], p]));
|
|
262
|
+
const blocked = (x) => {
|
|
263
|
+
const r = { x: g.x + x - 6, y: g.y + 4, w: tt.w + 12, h: Math.min(inp.groupTop, tt.h + 14) };
|
|
264
|
+
return segs.some(([a, b]) => segHits(a, b, r));
|
|
265
|
+
};
|
|
266
|
+
if (!blocked(inp.titleInset))
|
|
267
|
+
continue;
|
|
268
|
+
// The other top corner reads as a choice; a title halfway along reads as an accident.
|
|
269
|
+
const right = Math.floor(g.w - tt.w - inp.titleInset);
|
|
270
|
+
if (right > inp.titleInset && !blocked(right)) {
|
|
271
|
+
(l.titleInset ??= new Map()).set(gid, right);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
for (let x = inp.titleInset + 8; x + tt.w + 14 <= g.w; x += 8) {
|
|
275
|
+
if (!blocked(x)) {
|
|
276
|
+
(l.titleInset ??= new Map()).set(gid, x);
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
package/dist/layout/sequence.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { fontsFor } from './layout.js';
|
|
2
2
|
import { TextMeasurer } from './text.js';
|
|
3
|
-
import { LINE, PAD_X, PAD_Y, activationStyle, frameStyle, guardStyle, lifelineStyle, messageStyle, nodeLabel, noteStyle, palette, separatorStyle } from '../drawio/theme.js';
|
|
3
|
+
import { FRAME_TAB_INSET, FRAME_TAB_X, FRAME_TAB_Y, LINE, PAD_X, PAD_Y, activationStyle, frameStyle, guardStyle, lifelineStyle, messageStyle, nodeLabel, noteStyle, palette, separatorStyle } from '../drawio/theme.js';
|
|
4
4
|
import { autoAttr, diagramMeta, lookAttrs } from '../drawio/write.js';
|
|
5
5
|
const TOP = 24, BAR_W = 10, NOTE_W = 220;
|
|
6
6
|
/**
|
|
@@ -230,12 +230,15 @@ export async function sequenceScene(d, measure, opts = {}) {
|
|
|
230
230
|
const fx2 = cx(parts[hi].id) + Math.max(80, headW[hi] / 2 + 16) - inset;
|
|
231
231
|
const fy = rows[f.start].y - 4, fy2 = rows[f.end].y;
|
|
232
232
|
vertices.push({ id: f.id, parent: '1', attrs: { label: f.op, aiviz_type: 'frame', aiviz_frame: f.op }, style: frameStyle(theme), box: { x: fx, y: fy, w: fx2 - fx, h: fy2 - fy } });
|
|
233
|
+
// The first guard shares a line with the operator tab beside it.
|
|
234
|
+
const tabMid = fy + FRAME_TAB_INSET + FRAME_TAB_Y + (fonts.group.size * LINE) / 2;
|
|
235
|
+
const guardH = Math.max(22, edgeLine + 6);
|
|
233
236
|
const guard = (when, gy, k) => {
|
|
234
237
|
if (!when)
|
|
235
238
|
return;
|
|
236
239
|
// Start right of the operator label, then step past any lifeline the text would cover.
|
|
237
240
|
const gw = Math.max(80, Math.ceil(tm.width(`[${when}]`, fonts.edge)) + 16);
|
|
238
|
-
let gx = fx +
|
|
241
|
+
let gx = fx + FRAME_TAB_INSET + 2 * FRAME_TAB_X + Math.ceil(tm.width(f.op, fonts.group)) + 10;
|
|
239
242
|
for (const c of [...parts.map((p) => cx(p.id))].sort((a, b) => a - b))
|
|
240
243
|
if (c + BAR_W / 2 + 4 > gx && c - BAR_W / 2 - 4 < gx + gw)
|
|
241
244
|
gx = c + BAR_W / 2 + 6;
|
|
@@ -244,10 +247,10 @@ export async function sequenceScene(d, measure, opts = {}) {
|
|
|
244
247
|
parent: '1',
|
|
245
248
|
attrs: { label: `[${when}]`, aiviz_type: 'guard', aiviz_guard: `${f.id}:${k}` },
|
|
246
249
|
style: guardStyle(theme),
|
|
247
|
-
box: { x: gx, y: gy, w: gw, h:
|
|
250
|
+
box: { x: gx, y: gy, w: gw, h: guardH },
|
|
248
251
|
});
|
|
249
252
|
};
|
|
250
|
-
guard(f.whens[0],
|
|
253
|
+
guard(f.whens[0], Math.round(tabMid - guardH / 2), 0);
|
|
251
254
|
f.seps.forEach((s, k) => {
|
|
252
255
|
const sy = rows[s].y + 4;
|
|
253
256
|
edges.push({
|
package/dist/lint/check.js
CHANGED
|
@@ -158,7 +158,12 @@ export function check(cells) {
|
|
|
158
158
|
if (segsCross(a[s], a[s + 1], b[t], b[t + 1]))
|
|
159
159
|
crossings++;
|
|
160
160
|
}
|
|
161
|
-
|
|
161
|
+
// A table's field rows are parts of one node, not nodes of their own.
|
|
162
|
+
const isRow = (n) => {
|
|
163
|
+
const p = n.parent ? byId.get(n.parent) : undefined;
|
|
164
|
+
return !!p && p.shape === 'swimlane';
|
|
165
|
+
};
|
|
166
|
+
const real = nodes.filter((n) => (n.box.w > 30 || n.box.h > 30) && !isRow(n));
|
|
162
167
|
if (real.length > 20)
|
|
163
168
|
findings.push({ level: 'warning', rule: 'too-big', message: `${real.length} nodes; consider splitting into two diagrams` });
|
|
164
169
|
if (crossings > Math.max(3, edges.length / 3))
|
package/dist/lint/fixlabels.js
CHANGED
|
@@ -42,14 +42,19 @@ export function runSpots(pts, label) {
|
|
|
42
42
|
function labelEdges(findings) {
|
|
43
43
|
return [...new Set(findings.filter((f) => LABEL_RULES.has(f.rule) && f.labelEdges).flatMap((f) => f.labelEdges))];
|
|
44
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Returns the fixed XML and its cells. Only label backgrounds change after the
|
|
47
|
+
* cells are read, and those don't move anything, so the cells stay valid for it.
|
|
48
|
+
*/
|
|
45
49
|
export async function fixLabels(b, xml, maxRounds = 6) {
|
|
46
50
|
let cur = xml;
|
|
47
|
-
|
|
51
|
+
const labelScore = (cells) => check(cells).findings.filter((f) => LABEL_RULES.has(f.rule)).length;
|
|
52
|
+
let score = labelScore(await b.inspect(cur));
|
|
48
53
|
let moved = 0;
|
|
49
54
|
for (let round = 0; round < maxRounds && score > 0; round++) {
|
|
55
|
+
// The cells of `cur`, kept current as labels move: a move changes only that label's box.
|
|
50
56
|
const cells = await b.inspect(cur);
|
|
51
|
-
const
|
|
52
|
-
const targets = labelEdges(res.findings);
|
|
57
|
+
const targets = labelEdges(check(cells).findings);
|
|
53
58
|
let improved = false;
|
|
54
59
|
for (const id of targets) {
|
|
55
60
|
const doc = new DOMParser().parseFromString(cur, 'text/xml');
|
|
@@ -59,19 +64,21 @@ export async function fixLabels(b, xml, maxRounds = 6) {
|
|
|
59
64
|
const base = Number(g.getAttribute('x') ?? 0);
|
|
60
65
|
const cell = cells.find((c) => c.id === id);
|
|
61
66
|
const spots = cell?.points && cell.textBox ? runSpots(cell.points, cell.textBox) : [];
|
|
62
|
-
const tries = [...spots, ...OFFSETS.map((off) => Math.max(-0.9, Math.min(0.9, base + off)))];
|
|
67
|
+
const tries = [...spots, ...OFFSETS.map((off) => Math.max(-0.9, Math.min(0.9, base + off)))].map((x) => Number(x.toFixed(3)));
|
|
68
|
+
const boxes = await b.labelBoxes(cur, id, tries);
|
|
63
69
|
let best;
|
|
64
|
-
for (
|
|
65
|
-
|
|
66
|
-
const candidate = new XMLSerializer().serializeToString(doc);
|
|
67
|
-
const s = check(await b.inspect(candidate)).findings.filter((f) => LABEL_RULES.has(f.rule)).length;
|
|
70
|
+
for (let i = 0; i < tries.length; i++) {
|
|
71
|
+
const s = labelScore(cells.map((c) => (c.id === id ? { ...c, textBox: boxes[i] } : c)));
|
|
68
72
|
if (s < (best?.score ?? score))
|
|
69
|
-
best = {
|
|
73
|
+
best = { x: tries[i], score: s, box: boxes[i] };
|
|
70
74
|
if (s === 0)
|
|
71
75
|
break;
|
|
72
76
|
}
|
|
73
77
|
if (best) {
|
|
74
|
-
|
|
78
|
+
g.setAttribute('x', best.x.toFixed(3));
|
|
79
|
+
cur = new XMLSerializer().serializeToString(doc);
|
|
80
|
+
if (cell)
|
|
81
|
+
cell.textBox = best.box;
|
|
75
82
|
score = best.score;
|
|
76
83
|
moved++;
|
|
77
84
|
improved = true;
|
|
@@ -82,16 +89,17 @@ export async function fixLabels(b, xml, maxRounds = 6) {
|
|
|
82
89
|
if (!improved)
|
|
83
90
|
break;
|
|
84
91
|
}
|
|
85
|
-
|
|
86
|
-
|
|
92
|
+
const cells = await b.inspect(cur);
|
|
93
|
+
cur = await matchLabelBackgrounds(b, cur, cells);
|
|
94
|
+
return { xml: cur, moved, cells };
|
|
87
95
|
}
|
|
88
96
|
/**
|
|
89
97
|
* A label's backing has to match what it sits on: the fill of the smallest group
|
|
90
98
|
* under it, or the canvas. Run after labels move, since a move can carry a label
|
|
91
99
|
* from the canvas onto a group or back.
|
|
92
100
|
*/
|
|
93
|
-
export async function matchLabelBackgrounds(b, xml) {
|
|
94
|
-
const cells = await b.inspect(xml);
|
|
101
|
+
export async function matchLabelBackgrounds(b, xml, known) {
|
|
102
|
+
const cells = known ?? (await b.inspect(xml));
|
|
95
103
|
const doc = new DOMParser().parseFromString(xml, 'text/xml');
|
|
96
104
|
const canvas = doc.getElementsByTagName('mxGraphModel')[0]?.getAttribute('background') ?? '#FFFFFF';
|
|
97
105
|
const styleOf = new Map();
|
package/dist/render/browser.js
CHANGED
|
@@ -5,10 +5,10 @@ import fs from 'node:fs';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { createRequire } from 'node:module';
|
|
8
|
+
import http from 'node:http';
|
|
8
9
|
import { chromium } from 'playwright-core';
|
|
9
10
|
import { FONT_STACK } from '../drawio/theme.js';
|
|
10
11
|
import { FACES, embedFonts, fontFaceCss, fontsDir } from './fonts.js';
|
|
11
|
-
const ORIGIN = 'http://aiviz.local';
|
|
12
12
|
export function assetsDir() {
|
|
13
13
|
// dist/render/browser.js or src/render/browser.ts -> <pkg>/assets/drawio
|
|
14
14
|
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -90,6 +90,23 @@ window.aiviz = {
|
|
|
90
90
|
hasStencil: function (name) {
|
|
91
91
|
return mxStencilRegistry.getStencil(name) != null;
|
|
92
92
|
},
|
|
93
|
+
// Where an edge's label lands at each of several positions along the edge
|
|
94
|
+
// (draw.io's -1..1 x). Moving a label changes nothing else in the diagram, so
|
|
95
|
+
// one decode serves every candidate instead of one full inspect per try.
|
|
96
|
+
labelBoxes: function (xml, id, xs) {
|
|
97
|
+
return withGraph(xml, function (graph) {
|
|
98
|
+
var model = graph.getModel(), cell = model.getCell(id);
|
|
99
|
+
if (!cell) return xs.map(function () { return null; });
|
|
100
|
+
return xs.map(function (x) {
|
|
101
|
+
var g = cell.geometry.clone();
|
|
102
|
+
g.x = x;
|
|
103
|
+
model.setGeometry(cell, g);
|
|
104
|
+
var st = graph.view.getState(cell);
|
|
105
|
+
var tb = st && st.text && st.text.boundingBox;
|
|
106
|
+
return tb ? { x: tb.x, y: tb.y, w: tb.width, h: tb.height } : null;
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
},
|
|
93
110
|
inspect: function (xml) {
|
|
94
111
|
return withGraph(xml, function (graph) {
|
|
95
112
|
var model = graph.getModel(), out = [];
|
|
@@ -127,38 +144,34 @@ const TYPES = { '.js': 'text/javascript', '.css': 'text/css', '.html': 'text/htm
|
|
|
127
144
|
export class DrawioBrowser {
|
|
128
145
|
browser;
|
|
129
146
|
page;
|
|
130
|
-
|
|
147
|
+
server;
|
|
148
|
+
constructor(browser, page, server) {
|
|
131
149
|
this.browser = browser;
|
|
132
150
|
this.page = page;
|
|
151
|
+
this.server = server;
|
|
133
152
|
}
|
|
134
153
|
static async launch(opts = {}) {
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
await page.waitForFunction(() => window.ready === true, null, { timeout: 30000 });
|
|
157
|
-
// Measuring before a font loads would size every box for the fallback font.
|
|
158
|
-
const ok = await page.evaluate((faces) => Promise.all(faces.map((f) => document.fonts.load(`${f.weight} 16px "${f.family}"`).then((r) => r.length > 0))), FACES.map(({ family, weight }) => ({ family, weight })));
|
|
159
|
-
if (ok.includes(false))
|
|
160
|
-
throw new Error('linework: bundled fonts failed to load (assets/fonts). Reinstall linework.');
|
|
161
|
-
return new DrawioBrowser(browser, page);
|
|
154
|
+
// Chromium starts while the asset server binds.
|
|
155
|
+
const [browser, server] = await Promise.all([launchChromium(), serveAssets()]);
|
|
156
|
+
const origin = `http://127.0.0.1:${server.address().port}`;
|
|
157
|
+
try {
|
|
158
|
+
const page = await browser.newPage({ deviceScaleFactor: opts.scale ?? 2 });
|
|
159
|
+
// Nothing else may load: keeps rendering offline and deterministic.
|
|
160
|
+
const own = new RegExp(`^${origin.replace(/[.]/g, '\\.')}/`);
|
|
161
|
+
await page.route((url) => !own.test(url.href), (route) => route.abort());
|
|
162
|
+
await page.goto(`${origin}/host.html`);
|
|
163
|
+
await page.waitForFunction(() => window.ready === true, null, { timeout: 30000 });
|
|
164
|
+
// Measuring before a font loads would size every box for the fallback font.
|
|
165
|
+
const ok = await page.evaluate((faces) => Promise.all(faces.map((f) => document.fonts.load(`${f.weight} 16px "${f.family}"`).then((r) => r.length > 0))), FACES.map(({ family, weight }) => ({ family, weight })));
|
|
166
|
+
if (ok.includes(false))
|
|
167
|
+
throw new Error('linework: bundled fonts failed to load (assets/fonts). Reinstall linework.');
|
|
168
|
+
return new DrawioBrowser(browser, page, server);
|
|
169
|
+
}
|
|
170
|
+
catch (e) {
|
|
171
|
+
await browser.close().catch(() => { });
|
|
172
|
+
server.close();
|
|
173
|
+
throw e;
|
|
174
|
+
}
|
|
162
175
|
}
|
|
163
176
|
measure(reqs) {
|
|
164
177
|
return this.page.evaluate((r) => window.aiviz.measure(r), reqs);
|
|
@@ -182,12 +195,63 @@ export class DrawioBrowser {
|
|
|
182
195
|
hasStencil(name) {
|
|
183
196
|
return this.page.evaluate((n) => window.aiviz.hasStencil(n), name);
|
|
184
197
|
}
|
|
198
|
+
// inspect is a pure function of the XML, and a build asks about the same XML
|
|
199
|
+
// more than once (lint, label fixing, the final check).
|
|
200
|
+
seen = new Map();
|
|
185
201
|
inspect(xml) {
|
|
186
|
-
|
|
202
|
+
let hit = this.seen.get(xml);
|
|
203
|
+
if (!hit) {
|
|
204
|
+
hit = this.page.evaluate((x) => window.aiviz.inspect(x), xml);
|
|
205
|
+
hit.catch(() => this.seen.delete(xml));
|
|
206
|
+
if (this.seen.size >= 8)
|
|
207
|
+
this.seen.delete(this.seen.keys().next().value);
|
|
208
|
+
this.seen.set(xml, hit);
|
|
209
|
+
}
|
|
210
|
+
// Callers may change the cells; each gets its own copy.
|
|
211
|
+
return hit.then((cells) => structuredClone(cells));
|
|
187
212
|
}
|
|
188
|
-
|
|
189
|
-
|
|
213
|
+
/** Label boxes for edge `id` with its label at each draw.io position in `xs`. */
|
|
214
|
+
labelBoxes(xml, id, xs) {
|
|
215
|
+
return this.page.evaluate(([x, i, p]) => window.aiviz.labelBoxes(x, i, p), [xml, id, xs]);
|
|
190
216
|
}
|
|
217
|
+
async close() {
|
|
218
|
+
await this.browser.close();
|
|
219
|
+
this.server.closeAllConnections();
|
|
220
|
+
this.server.close();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* The draw.io runtime is 16MB of script. Served from a local port, Chromium reads
|
|
225
|
+
* it straight off the socket; through Playwright's request interception every
|
|
226
|
+
* byte would cross the DevTools protocol first.
|
|
227
|
+
*/
|
|
228
|
+
function serveAssets() {
|
|
229
|
+
const dir = assetsDir();
|
|
230
|
+
const fonts = fontsDir();
|
|
231
|
+
const html = host();
|
|
232
|
+
const send = (res, status, type, body) => {
|
|
233
|
+
res.writeHead(status, { 'content-type': type, 'cache-control': 'no-store' });
|
|
234
|
+
res.end(body);
|
|
235
|
+
};
|
|
236
|
+
const server = http.createServer((req, res) => {
|
|
237
|
+
const pathname = new URL(req.url ?? '/', 'http://x').pathname;
|
|
238
|
+
if (pathname === '/' || pathname === '/host.html')
|
|
239
|
+
return send(res, 200, 'text/html', html);
|
|
240
|
+
if (pathname.startsWith('/aiviz-fonts/')) {
|
|
241
|
+
const f = FACES.find((x) => `/aiviz-fonts/${x.file}` === pathname);
|
|
242
|
+
return f ? send(res, 200, 'font/woff2', fs.readFileSync(path.join(fonts, f.file))) : send(res, 404, 'text/plain', '');
|
|
243
|
+
}
|
|
244
|
+
const file = path.join(dir, path.normalize(decodeURIComponent(pathname)));
|
|
245
|
+
if (!file.startsWith(dir + path.sep) || !fs.existsSync(file) || !fs.statSync(file).isFile())
|
|
246
|
+
return send(res, 404, 'text/plain', '');
|
|
247
|
+
send(res, 200, TYPES[path.extname(file)] ?? 'application/octet-stream', fs.readFileSync(file));
|
|
248
|
+
});
|
|
249
|
+
// A server that's only waiting on a browser that already exited shouldn't keep node alive.
|
|
250
|
+
server.unref();
|
|
251
|
+
return new Promise((resolve, reject) => {
|
|
252
|
+
server.once('error', reject);
|
|
253
|
+
server.listen(0, '127.0.0.1', () => resolve(server));
|
|
254
|
+
});
|
|
191
255
|
}
|
|
192
256
|
/** Playwright-core's own installer, so the Chromium build always matches the version we run. */
|
|
193
257
|
export function browserInstallCommand() {
|