@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.
@@ -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
+ }
@@ -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 + 10 + Math.ceil(tm.width(f.op, fonts.group)) + 8;
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: Math.max(22, edgeLine + 6) },
250
+ box: { x: gx, y: gy, w: gw, h: guardH },
248
251
  });
249
252
  };
250
- guard(f.whens[0], fy, 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({
@@ -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
- const real = nodes.filter((n) => n.box.w > 30 || n.box.h > 30);
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))
@@ -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
- let score = check(await b.inspect(cur)).findings.filter((f) => LABEL_RULES.has(f.rule)).length;
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 res = check(cells);
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 (const x of tries) {
65
- g.setAttribute('x', x.toFixed(3));
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 = { xml: candidate, score: s };
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
- cur = best.xml;
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
- cur = await matchLabelBackgrounds(b, cur);
86
- return { xml: cur, moved };
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();
@@ -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
- constructor(browser, page) {
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
- const browser = await launchChromium();
136
- const page = await browser.newPage({ deviceScaleFactor: opts.scale ?? 2 });
137
- const dir = assetsDir();
138
- const fonts = fontsDir();
139
- const html = host();
140
- await page.route(`${ORIGIN}/**`, async (route) => {
141
- const url = new URL(route.request().url());
142
- if (url.pathname === '/' || url.pathname === '/host.html')
143
- return route.fulfill({ status: 200, contentType: 'text/html', body: html });
144
- if (url.pathname.startsWith('/aiviz-fonts/')) {
145
- const f = FACES.find((x) => `/aiviz-fonts/${x.file}` === url.pathname);
146
- return f ? route.fulfill({ status: 200, contentType: 'font/woff2', body: fs.readFileSync(path.join(fonts, f.file)) }) : route.fulfill({ status: 404, body: '' });
147
- }
148
- const file = path.join(dir, path.normalize(url.pathname));
149
- if (!file.startsWith(dir) || !fs.existsSync(file))
150
- return route.fulfill({ status: 404, body: '' });
151
- return route.fulfill({ status: 200, contentType: TYPES[path.extname(file)] ?? 'application/octet-stream', body: fs.readFileSync(file) });
152
- });
153
- // Nothing else may load: keeps rendering offline and deterministic.
154
- await page.route(/^(?!http:\/\/aiviz\.local\/).*/, (route) => route.abort());
155
- await page.goto(`${ORIGIN}/host.html`);
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
- return this.page.evaluate((x) => window.aiviz.inspect(x), xml);
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
- close() {
189
- return this.browser.close();
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() {