@itsthecsguy/linework 0.1.4 → 0.2.1
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 +38 -2
- package/dist/api.js +66 -9
- package/dist/cli.js +53 -286
- package/dist/commands.js +346 -0
- package/dist/daemon.js +193 -0
- package/dist/drawio/merge.js +47 -8
- package/dist/drawio/write.js +68 -4
- package/dist/layout/layout.js +48 -2
- package/dist/layout/polish.js +112 -22
- package/dist/lint/check.js +16 -0
- package/dist/mcp/server.js +6 -6
- package/dist/patch.js +167 -0
- package/dist/report.js +2 -0
- package/dist/spec/parse.js +2 -0
- package/package.json +2 -1
- package/skills/linework/SKILL.md +57 -200
- package/skills/linework/references/spec.md +1 -1
- package/skills/linework/references/style-guide.md +1 -1
- package/skills/linework/references/troubleshooting.md +11 -0
package/dist/drawio/write.js
CHANGED
|
@@ -175,6 +175,23 @@ export function sceneFromLayout(d, l) {
|
|
|
175
175
|
vertices.push(...nodeChildren(d, n, box));
|
|
176
176
|
}
|
|
177
177
|
const isCyl = (id) => { const n = d.nodes.find((x) => x.id === id); return !!n && kindDef(d.type, n.kind).shape === 'cylinder'; };
|
|
178
|
+
const isDecision = (id) => { const n = d.nodes.find((x) => x.id === id); return !!n && kindDef(d.type, n.kind).shape === 'diamond'; };
|
|
179
|
+
// How far an edge shares its first run with a sibling leaving the same point.
|
|
180
|
+
const sharedStem = (id, from) => {
|
|
181
|
+
const mine = l.edges.get(id).points;
|
|
182
|
+
let shared = 0;
|
|
183
|
+
for (const o of d.edges) {
|
|
184
|
+
const pts = o.id !== id && o.from === from && o.to !== from ? l.edges.get(o.id)?.points : undefined;
|
|
185
|
+
if (!pts || pts.length < 2 || mine.length < 2 || Math.hypot(pts[0].x - mine[0].x, pts[0].y - mine[0].y) > 1)
|
|
186
|
+
continue;
|
|
187
|
+
const dir = (p) => [Math.sign(Math.round(p[1].x - p[0].x)), Math.sign(Math.round(p[1].y - p[0].y))].join();
|
|
188
|
+
if (dir(pts) !== dir(mine))
|
|
189
|
+
continue;
|
|
190
|
+
const len = (p) => Math.hypot(p[1].x - p[0].x, p[1].y - p[0].y);
|
|
191
|
+
shared = Math.max(shared, Math.min(len(pts), len(mine)));
|
|
192
|
+
}
|
|
193
|
+
return shared;
|
|
194
|
+
};
|
|
178
195
|
const edges = d.edges.map((e) => {
|
|
179
196
|
const le = l.edges.get(e.id);
|
|
180
197
|
const cyl = { from: isCyl(e.from), to: isCyl(e.to) };
|
|
@@ -197,7 +214,7 @@ export function sceneFromLayout(d, l) {
|
|
|
197
214
|
...base,
|
|
198
215
|
style: base.style + portStyle(le.points, abs(e.from), abs(e.to), cyl),
|
|
199
216
|
points: le.points.slice(1, -1),
|
|
200
|
-
labelX: le.label && e.label ? (labelSpot(le.points, le.label, l.direction) ?? labelFraction(le.points, { x: le.label.x + le.label.w / 2, y: le.label.y + le.label.h / 2 })) : undefined,
|
|
217
|
+
labelX: le.label && e.label ? ((isDecision(e.from) ? branchSpot(le.points, le.label, l.direction, sharedStem(e.id, e.from)) : undefined) ?? labelSpot(le.points, le.label, l.direction, [...l.nodes].filter(([id]) => id !== e.from && id !== e.to).map(([, b]) => b)) ?? labelFraction(le.points, { x: le.label.x + le.label.w / 2, y: le.label.y + le.label.h / 2 })) : undefined,
|
|
201
218
|
};
|
|
202
219
|
});
|
|
203
220
|
alignLabels(d, l, edges);
|
|
@@ -334,7 +351,7 @@ export function toMxfile(d, l) {
|
|
|
334
351
|
* source node, which reads as a caption of that node. Returns draw.io's -1..1
|
|
335
352
|
* fraction, or undefined when no run is long enough.
|
|
336
353
|
*/
|
|
337
|
-
function labelSpot(pts, label, dir) {
|
|
354
|
+
export function labelSpot(pts, label, dir, avoid = []) {
|
|
338
355
|
let total = 0;
|
|
339
356
|
let best;
|
|
340
357
|
// Runs along the flow belong to one edge; runs across it are often shared
|
|
@@ -348,10 +365,57 @@ function labelSpot(pts, label, dir) {
|
|
|
348
365
|
const along = dir ? vertical === flowVertical : true;
|
|
349
366
|
const better = !best || (along && !best.along) || (along === best.along && room > best.room);
|
|
350
367
|
if (room >= 0 && better)
|
|
351
|
-
best = { room, at: total + len / 2, along };
|
|
368
|
+
best = { room, at: total + len / 2, along, a, b, start: total, len };
|
|
352
369
|
total += len;
|
|
353
370
|
}
|
|
354
|
-
|
|
371
|
+
if (!best || !total)
|
|
372
|
+
return undefined;
|
|
373
|
+
// Halfway along a long run can be right beside a box the edge only passes, where
|
|
374
|
+
// the label reads as that box's caption. Slide along the run to the nearest
|
|
375
|
+
// spot past the box, with a little air; stay in the middle when there is none.
|
|
376
|
+
const { a, b, start, len } = best;
|
|
377
|
+
const vertical = Math.abs(b.x - a.x) < Math.abs(b.y - a.y);
|
|
378
|
+
const half = (vertical ? label.h : label.w) / 2 + 10;
|
|
379
|
+
// Beside: level with the box along the run, and within 12px of it across the run.
|
|
380
|
+
const beside = (at, air) => {
|
|
381
|
+
const t = (at - start) / len, cx = a.x + t * (b.x - a.x), cy = a.y + t * (b.y - a.y);
|
|
382
|
+
const px = vertical ? 12 : air, py = vertical ? air : 12;
|
|
383
|
+
const r = { x: cx - label.w / 2 - px, y: cy - label.h / 2 - py, w: label.w + 2 * px, h: label.h + 2 * py };
|
|
384
|
+
return avoid.some((o) => r.x < o.x + o.w && r.x + r.w > o.x && r.y < o.y + o.h && r.y + r.h > o.y);
|
|
385
|
+
};
|
|
386
|
+
// Level by a few px or more: a label that only grazes the box's end is fine where it is.
|
|
387
|
+
if (beside(best.at, -4))
|
|
388
|
+
for (let step = 4; step <= len / 2 - half; step += 4)
|
|
389
|
+
for (const at of [best.at - step, best.at + step])
|
|
390
|
+
if (!beside(at, 4))
|
|
391
|
+
return (at / total) * 2 - 1;
|
|
392
|
+
return (best.at / total) * 2 - 1;
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* A branch label ("yes", "no") belongs to its decision, so it sits where the
|
|
396
|
+
* branch leaves the diamond, not halfway to the next step: on the run out of the
|
|
397
|
+
* diamond's point when that has room, otherwise at the start of the first run
|
|
398
|
+
* along the flow, or of a long arm that forks off a short stem. `stem` is how much of the first run a sibling branch shares;
|
|
399
|
+
* the label starts past it, so it doesn't cover the place where they part.
|
|
400
|
+
*/
|
|
401
|
+
export function branchSpot(pts, label, dir, stem = 0) {
|
|
402
|
+
const flowVertical = dir === 'TB' || dir === 'BT';
|
|
403
|
+
const lens = pts.slice(1).map((b, i) => Math.hypot(b.x - pts[i].x, b.y - pts[i].y));
|
|
404
|
+
const total = lens.reduce((x, y) => x + y, 0);
|
|
405
|
+
let before = 0;
|
|
406
|
+
for (let i = 0; i < lens.length; i++) {
|
|
407
|
+
const a = pts[i], b = pts[i + 1], len = lens[i];
|
|
408
|
+
const vertical = Math.abs(b.x - a.x) < Math.abs(b.y - a.y);
|
|
409
|
+
const size = vertical ? label.h : label.w;
|
|
410
|
+
const lead = i === 0 && stem > 0 ? stem + 6 : 10;
|
|
411
|
+
// A long arm across the flow, straight after the stem out of the point, is
|
|
412
|
+
// nearer the diamond than the drop at its far end.
|
|
413
|
+
const arm = i === 1 && len >= 96 && lens[0] <= 24;
|
|
414
|
+
if ((i === 0 || arm || vertical === flowVertical) && len >= lead + size + 10)
|
|
415
|
+
return ((before + lead + size / 2) / total) * 2 - 1;
|
|
416
|
+
before += len;
|
|
417
|
+
}
|
|
418
|
+
return undefined;
|
|
355
419
|
}
|
|
356
420
|
// draw.io positions edge labels by fraction along the path, -1..1.
|
|
357
421
|
// Project ELK's label centre onto the route and convert.
|
package/dist/layout/layout.js
CHANGED
|
@@ -156,9 +156,16 @@ function nodeSizes(d, tm, fonts) {
|
|
|
156
156
|
continue;
|
|
157
157
|
}
|
|
158
158
|
if (shape === 'diamond') {
|
|
159
|
-
|
|
159
|
+
// A diamond holds little text for its size. Past two lines, wrap wider:
|
|
160
|
+
// three lines in a wider diamond take less room than four in a tall one.
|
|
161
|
+
let t = textOf(n, fonts.maxText * 0.7);
|
|
162
|
+
if (t.lines > 2)
|
|
163
|
+
t = textOf(n, fonts.maxText);
|
|
160
164
|
const w = snap(Math.max(128, t.width * 1.5 + 40));
|
|
161
|
-
|
|
165
|
+
// The text takes about 0.6 of the width, which leaves it 0.4 of the height.
|
|
166
|
+
// Only that tall, and no taller: a diamond at half its width for a two-line
|
|
167
|
+
// question is the biggest thing in the flow, bigger than any step.
|
|
168
|
+
sizes.set(n.id, { w, h: snap(Math.max(72, w * 0.36, t.lines * fonts.line * 2.5 + 24)) });
|
|
162
169
|
continue;
|
|
163
170
|
}
|
|
164
171
|
const extra = shapeExtra(shape, !!n.icon);
|
|
@@ -274,6 +281,45 @@ async function run(d, dir, sizes, labels, groupMin, groupTop, order, pins = new
|
|
|
274
281
|
if (call)
|
|
275
282
|
flipped.add(pairs === 'reply' ? e.id : call.id);
|
|
276
283
|
});
|
|
284
|
+
// The edge that closes a loop (a retry, "new findings, investigate again")
|
|
285
|
+
// points back at a box that was declared earlier and already appears in an
|
|
286
|
+
// earlier edge. Lay it out reversed, so the main path keeps running forward
|
|
287
|
+
// whichever branch ELK happens to walk first.
|
|
288
|
+
const index = new Map(d.nodes.map((n, i) => [n.id, i]));
|
|
289
|
+
const firstUse = new Map();
|
|
290
|
+
d.edges.forEach((e, i) => {
|
|
291
|
+
for (const id of [e.from, e.to])
|
|
292
|
+
if (!firstUse.has(id))
|
|
293
|
+
firstUse.set(id, i);
|
|
294
|
+
});
|
|
295
|
+
const back = d.edges
|
|
296
|
+
.map((e, i) => ({ e, i }))
|
|
297
|
+
.filter(({ e, i }) => e.from !== e.to && index.get(e.to) < index.get(e.from) && firstUse.get(e.to) < i)
|
|
298
|
+
// Call-and-reply pairs are handled above.
|
|
299
|
+
.filter(({ e }) => !d.edges.some((x) => x.from === e.to && x.to === e.from))
|
|
300
|
+
// Last written first: once the loop's closing edge is cut, an earlier
|
|
301
|
+
// backward edge inside the same loop is no longer part of a cycle and stays.
|
|
302
|
+
.reverse()
|
|
303
|
+
.map(({ e }) => e);
|
|
304
|
+
const cut = new Set();
|
|
305
|
+
const reaches = (from, to) => {
|
|
306
|
+
const seen = new Set([from]);
|
|
307
|
+
for (const q = [from]; q.length;) {
|
|
308
|
+
const cur = q.pop();
|
|
309
|
+
if (cur === to)
|
|
310
|
+
return true;
|
|
311
|
+
for (const x of d.edges)
|
|
312
|
+
if (x.from === cur && !cut.has(x.id) && !seen.has(x.to))
|
|
313
|
+
(seen.add(x.to), q.push(x.to));
|
|
314
|
+
}
|
|
315
|
+
return false;
|
|
316
|
+
};
|
|
317
|
+
for (const e of back) {
|
|
318
|
+
if (!reaches(e.to, e.from))
|
|
319
|
+
continue;
|
|
320
|
+
cut.add(e.id);
|
|
321
|
+
flipped.add(e.id);
|
|
322
|
+
}
|
|
277
323
|
root.edges = d.edges.map((e, i) => {
|
|
278
324
|
const src = pinFor(pins, e.from, e.id, 'from') ?? e.from, dst = pinFor(pins, e.to, e.id, 'to') ?? e.to;
|
|
279
325
|
const flip = flipped.has(e.id);
|
package/dist/layout/polish.js
CHANGED
|
@@ -137,47 +137,108 @@ export function polish(d, l, inp) {
|
|
|
137
137
|
const b = nodes.get(n.id);
|
|
138
138
|
const cx = b.x + b.w / 2, cy = b.y + b.h / 2;
|
|
139
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
|
-
|
|
142
|
-
|
|
140
|
+
// The run each edge settles into once it is clear of the diamond. ELK usually
|
|
141
|
+
// leaves on a short stub and turns; when it leaves straight down instead, the
|
|
142
|
+
// first segment is the run.
|
|
143
|
+
const runAt = (p) => {
|
|
144
|
+
if (p.length >= 2 && vert(p[0], p[1]) && p[1].y > b.y + b.h + 24)
|
|
145
|
+
return 0;
|
|
143
146
|
for (let i = 1; i < p.length - 1; i++)
|
|
144
147
|
if (vert(p[i], p[i + 1]) && p[i + 1].y > b.y + b.h + 1)
|
|
145
|
-
return
|
|
146
|
-
return
|
|
148
|
+
return i;
|
|
149
|
+
return -1;
|
|
150
|
+
};
|
|
151
|
+
const runX = (e) => {
|
|
152
|
+
const i = runAt(e.pts);
|
|
153
|
+
return (i >= 0 ? e.pts[i] : e.pts[e.pts.length - 1]).x;
|
|
147
154
|
};
|
|
155
|
+
// Points from the start of that run on.
|
|
148
156
|
const tail = (e) => {
|
|
149
|
-
|
|
150
|
-
|
|
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);
|
|
157
|
+
const i = runAt(e.pts);
|
|
158
|
+
return i >= 0 ? e.pts.slice(i) : e.pts.slice(-1);
|
|
155
159
|
};
|
|
156
160
|
const sorted = [...outs].sort((a, c) => runX(a) - runX(c));
|
|
157
161
|
let bottomTaken = false;
|
|
162
|
+
// One branch per side point: a second one there would hide the first.
|
|
163
|
+
const taken = { left: false, right: false };
|
|
164
|
+
// A branch whose run sits under the diamond would have to fork off the bottom
|
|
165
|
+
// point next to its sibling. Move the run out past the side point instead, so
|
|
166
|
+
// each branch leaves from its own corner: side point, a short stub, then down.
|
|
167
|
+
const sideStep = (e, rest, left) => {
|
|
168
|
+
if (rest.length < 2)
|
|
169
|
+
return undefined;
|
|
170
|
+
const lab = l.edges.get(e.id)?.label;
|
|
171
|
+
// Far enough out that the branch label, centred on the run, clears the diamond.
|
|
172
|
+
const out = Math.max(24, lab ? Math.ceil(f.boxTo(lab).w / 2) + 8 : 0);
|
|
173
|
+
const beside = left ? b.x - out : b.x + b.w + out;
|
|
174
|
+
const end = rest[1], t = nodes.get(e.to);
|
|
175
|
+
// A run that drops straight into the target has to stay over the target.
|
|
176
|
+
const drops = rest.length === 2;
|
|
177
|
+
if (drops ? ['diamond', 'table'].includes(shapeOf(e.to) ?? '') || !near(end.y, t.y, 1) : !horiz(end, rest[2]) || end.y < b.y + b.h + 12)
|
|
178
|
+
return undefined;
|
|
179
|
+
// Into the target's middle when that is already clear of the diamond, else just beside the diamond.
|
|
180
|
+
const mid = t.x + t.w / 2;
|
|
181
|
+
const tries = drops && (left ? mid <= beside : mid >= beside) ? [mid, beside] : [beside];
|
|
182
|
+
const sideP = { x: left ? b.x : b.x + b.w, y: cy };
|
|
183
|
+
for (const nx of tries) {
|
|
184
|
+
if (drops && (nx < t.x + R || nx > t.x + t.w - R || usedAt(e.to, nx, e, 'top')))
|
|
185
|
+
continue;
|
|
186
|
+
const a = { x: nx, y: cy }, c = { x: nx, y: end.y };
|
|
187
|
+
if (!clear(sideP, a, [n.id]) || !clear(a, c, [n.id, e.to]) || !free(a, c, e))
|
|
188
|
+
continue;
|
|
189
|
+
if (!drops && !clear(c, rest[2], [n.id, e.to]))
|
|
190
|
+
continue;
|
|
191
|
+
return simplify([sideP, a, c, ...rest.slice(2)]);
|
|
192
|
+
}
|
|
193
|
+
return undefined;
|
|
194
|
+
};
|
|
158
195
|
// The edge closest to straight down gets the bottom point.
|
|
159
|
-
const
|
|
160
|
-
|
|
196
|
+
const nearest = (list) => list.reduce((m, e) => (!m || Math.abs(runX(e) - cx) < Math.abs(runX(m) - cx) ? e : m), undefined);
|
|
197
|
+
// Or, failing that, the one whose target reaches under the bottom point, so it can drop straight.
|
|
198
|
+
const reaches = (e) => {
|
|
199
|
+
const t = nodes.get(e.to), rest = tail(e);
|
|
200
|
+
return rest.length === 2 && near(rest[1].y, t.y, 1) && !['diamond', 'table'].includes(shapeOf(e.to) ?? '') && cx >= t.x + 16 && cx <= t.x + t.w - 16;
|
|
201
|
+
};
|
|
202
|
+
const mid = nearest(sorted);
|
|
203
|
+
const straight = mid && Math.abs(runX(mid) - cx) < 12 ? mid : nearest(sorted.filter(reaches));
|
|
204
|
+
// The middle branch goes last: whether it can drop straight depends on where
|
|
205
|
+
// its siblings ended up, not on the stubs ELK gave them at the bottom point.
|
|
206
|
+
for (const e of [...sorted.filter((x) => x !== straight), ...(straight ? [straight] : [])]) {
|
|
161
207
|
const x = runX(e), rest = tail(e);
|
|
162
208
|
const bottom = { x: cx, y: b.y + b.h };
|
|
209
|
+
// A run that starts on the diamond itself: what follows is from its far end on.
|
|
210
|
+
const onEdge = rest[0].y <= b.y + b.h + 1 && rest.length > 1;
|
|
163
211
|
if (e === straight && Math.abs(x - cx) < 12) {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
212
|
+
const run = onEdge ? rest.slice(1) : rest;
|
|
213
|
+
e.pts = simplify([bottom, { x: cx, y: run[0].y }, ...run.slice(1)]);
|
|
214
|
+
if (!near(run[0].x, cx))
|
|
215
|
+
e.pts = simplify([bottom, { x: cx, y: run[0].y }, ...run]);
|
|
216
|
+
bottomTaken = true;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const t = nodes.get(e.to), floor = { x: cx, y: t.y };
|
|
220
|
+
if (e === straight && reaches(e) && !usedAt(e.to, cx, e, 'top') && clear(bottom, floor, [n.id, e.to]) && free(bottom, floor, e)) {
|
|
221
|
+
e.pts = [bottom, floor];
|
|
167
222
|
bottomTaken = true;
|
|
168
223
|
continue;
|
|
169
224
|
}
|
|
170
225
|
const left = x < cx;
|
|
171
226
|
const sideP = { x: left ? b.x : b.x + b.w, y: cy };
|
|
172
227
|
const corner = { x, y: cy };
|
|
173
|
-
const
|
|
174
|
-
if (
|
|
175
|
-
|
|
176
|
-
|
|
228
|
+
const key = left ? 'left' : 'right';
|
|
229
|
+
if (!taken[key]) {
|
|
230
|
+
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]);
|
|
231
|
+
const pts = sideOk ? simplify([sideP, corner, ...rest]) : sideStep(e, rest, left);
|
|
232
|
+
if (pts) {
|
|
233
|
+
e.pts = pts;
|
|
234
|
+
taken[key] = true;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
177
237
|
}
|
|
178
238
|
// Branches that both head down fork from the bottom point on a short stem.
|
|
179
|
-
|
|
180
|
-
|
|
239
|
+
// Such a run turns onto the stem below the point.
|
|
240
|
+
const y = onEdge ? b.y + b.h + 16 : Math.min(rest[0].y, b.y + b.h + 16);
|
|
241
|
+
e.pts = simplify([bottom, { x: cx, y }, { x, y }, ...(onEdge ? rest.slice(1) : rest)]);
|
|
181
242
|
bottomTaken = true;
|
|
182
243
|
}
|
|
183
244
|
for (const e of edges.filter((x) => x.to === n.id && x.from !== n.id)) {
|
|
@@ -188,9 +249,38 @@ export function polish(d, l, inp) {
|
|
|
188
249
|
e.pts[m - 1] = { x: cx, y: b.y };
|
|
189
250
|
continue;
|
|
190
251
|
}
|
|
252
|
+
// Arriving sideways and then dropping to a port beside the top point: carry
|
|
253
|
+
// the sideways run on to the middle. Stepping down first leaves a small kink.
|
|
254
|
+
const before = p[m - 3];
|
|
255
|
+
const across = { x: cx, y: prev.y };
|
|
256
|
+
if (before && horiz(before, prev) && !near(before.x, cx) && clear(prev, across, [e.from, n.id]) && clear(across, { x: cx, y: b.y }, [e.from, n.id])) {
|
|
257
|
+
e.pts = simplify([...p.slice(0, m - 2), across, { x: cx, y: b.y }]);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
191
260
|
const y = Math.max(prev.y, b.y - 16);
|
|
192
261
|
e.pts = simplify([...p.slice(0, m - 1), { x: last.x, y }, { x: cx, y }, { x: cx, y: b.y }]);
|
|
193
262
|
}
|
|
263
|
+
// Branches that join above a decision meet at one height, not a few px apart.
|
|
264
|
+
const joins = edges.flatMap((e) => {
|
|
265
|
+
const p = e.pts, m = p.length;
|
|
266
|
+
if (e.to !== n.id || e.from === n.id || m < 4)
|
|
267
|
+
return [];
|
|
268
|
+
const [up, a, c, top] = [p[m - 4], p[m - 3], p[m - 2], p[m - 1]];
|
|
269
|
+
const ok = vert(up, a) && up.y < a.y && horiz(a, c) && near(c.x, cx) && vert(c, top) && near(top.y, b.y, 1);
|
|
270
|
+
return ok ? [{ e, a, c }] : [];
|
|
271
|
+
});
|
|
272
|
+
if (joins.length > 1) {
|
|
273
|
+
const y = Math.max(...joins.map((j) => j.a.y));
|
|
274
|
+
for (const j of joins) {
|
|
275
|
+
if (near(j.a.y, y) || y - j.a.y > 24)
|
|
276
|
+
continue;
|
|
277
|
+
const na = { x: j.a.x, y }, nc = { x: cx, y };
|
|
278
|
+
if (!clear(na, nc, [j.e.from, n.id]))
|
|
279
|
+
continue;
|
|
280
|
+
const m = j.e.pts.length;
|
|
281
|
+
j.e.pts = simplify([...j.e.pts.slice(0, m - 3), na, nc, j.e.pts[m - 1]]);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
194
284
|
}
|
|
195
285
|
dejog(true);
|
|
196
286
|
// Self-loops: wide enough to read as a loop, label outside it.
|
package/dist/lint/check.js
CHANGED
|
@@ -63,9 +63,25 @@ export function check(cells) {
|
|
|
63
63
|
if (p?.container && !inside(n.box, p.box, 2))
|
|
64
64
|
findings.push({ level: 'warning', rule: 'outside-group', message: `${name(n)} sticks out of its group ${name(p)}` });
|
|
65
65
|
}
|
|
66
|
+
// A box drawn inside a group reads as a member of it.
|
|
67
|
+
const groups = containers.filter((g) => !scaffold(g));
|
|
68
|
+
for (const n of nodes) {
|
|
69
|
+
const mine = ancestors(n.id);
|
|
70
|
+
for (const g of groups)
|
|
71
|
+
if (!mine.has(g.id) && inter(n.box, g.box) > 0.25 * n.box.w * n.box.h)
|
|
72
|
+
findings.push({ level: 'warning', rule: 'in-other-group', message: `${name(n)} sits inside ${name(g)} but isn't in that group; move it out, or put it in the group` });
|
|
73
|
+
}
|
|
66
74
|
for (const e of edges) {
|
|
67
75
|
const pts = e.points;
|
|
68
76
|
const skip = new Set([e.source, e.target, ...ancestors(e.source), ...ancestors(e.target)]);
|
|
77
|
+
// A line through a group it has no end in looks like it connects to something there.
|
|
78
|
+
for (const g of groups) {
|
|
79
|
+
if (skip.has(g.id))
|
|
80
|
+
continue;
|
|
81
|
+
const r = shrink(g.box, 6);
|
|
82
|
+
if (pts.some((p, i) => i < pts.length - 1 && segHitsBox(p, pts[i + 1], r)))
|
|
83
|
+
findings.push({ level: 'warning', rule: 'edge-through-group', message: `edge ${edgeName(e, byId)} runs through ${name(g)}, which neither end is in` });
|
|
84
|
+
}
|
|
69
85
|
for (const n of nodes) {
|
|
70
86
|
if (skip.has(n.id))
|
|
71
87
|
continue;
|
package/dist/mcp/server.js
CHANGED
|
@@ -30,7 +30,7 @@ const PKG = (() => {
|
|
|
30
30
|
}
|
|
31
31
|
throw new Error('linework: bundled skill not found. Reinstall linework.');
|
|
32
32
|
})();
|
|
33
|
-
const TOOL_NAMES = `In this MCP server the CLI commands in the guide are tools: build -> build_diagram, show -> show_diagram, check -> check_diagram, render -> render_diagram, tidy -> tidy_diagram, import -> import_mermaid, icons -> list_icons, edit -> edit_diagram, publish -> publish_diagram. Pass the spec text to build_diagram directly instead of writing a file.\n\n`;
|
|
33
|
+
const TOOL_NAMES = `In this MCP server the CLI commands in the guide are tools: build -> build_diagram, show -> show_diagram, check -> check_diagram, render -> render_diagram, tidy -> tidy_diagram, import -> import_mermaid, icons -> list_icons, edit -> edit_diagram, publish -> publish_diagram. Pass the spec text to build_diagram directly instead of writing a file. There are no set, add or rm tools: for a small change, call show_diagram, edit its output and pass that to build_diagram, which keeps the layout the same way.\n\n`;
|
|
34
34
|
export function createServer(opts) {
|
|
35
35
|
let browser;
|
|
36
36
|
const getBrowser = () => (browser ??= DrawioBrowser.launch({ scale: 2 }));
|
|
@@ -66,13 +66,13 @@ export function createServer(opts) {
|
|
|
66
66
|
description: 'Returns the linework guide: diagram types, the YAML spec format with examples, and the style rules. Read it before your first diagram.',
|
|
67
67
|
inputSchema: z.object({
|
|
68
68
|
topic: z
|
|
69
|
-
.enum(['overview', 'spec', 'style', 'google-docs'])
|
|
69
|
+
.enum(['overview', 'spec', 'style', 'google-docs', 'troubleshooting'])
|
|
70
70
|
.default('overview')
|
|
71
|
-
.describe('overview: workflow and cheat sheet. spec: the full spec reference. style: what makes a good diagram. google-docs: getting diagrams into Google Docs.'),
|
|
71
|
+
.describe('overview: workflow and cheat sheet. spec: the full spec reference. style: what makes a good diagram. google-docs: getting diagrams into Google Docs. troubleshooting: what to do when a command fails.'),
|
|
72
72
|
}),
|
|
73
73
|
annotations: { readOnlyHint: true },
|
|
74
74
|
}, async ({ topic }) => {
|
|
75
|
-
const file = { overview: 'SKILL.md', spec: 'references/spec.md', style: 'references/style-guide.md', 'google-docs': 'references/google-docs.md' }[topic];
|
|
75
|
+
const file = { overview: 'SKILL.md', spec: 'references/spec.md', style: 'references/style-guide.md', 'google-docs': 'references/google-docs.md', troubleshooting: 'references/troubleshooting.md' }[topic];
|
|
76
76
|
const body = fs.readFileSync(path.join(PKG, 'skills', 'linework', file), 'utf8').replace(/^---\n[\s\S]*?\n---\n/, '');
|
|
77
77
|
return { content: [text(TOOL_NAMES + body)] };
|
|
78
78
|
});
|
|
@@ -137,8 +137,8 @@ export function createServer(opts) {
|
|
|
137
137
|
return { content: [text([res.fixed ? `rerouted ${res.fixed} edges in ${file}` : 'nothing to fix', ...checkLines(res.check)].join('\n'))] };
|
|
138
138
|
})());
|
|
139
139
|
server.registerTool('import_mermaid', {
|
|
140
|
-
title: 'Convert Mermaid to
|
|
141
|
-
description: 'Converts a Mermaid diagram (flowchart, stateDiagram, sequenceDiagram, erDiagram) to
|
|
140
|
+
title: 'Convert Mermaid to a linework spec',
|
|
141
|
+
description: 'Converts a Mermaid diagram (flowchart, stateDiagram, sequenceDiagram, erDiagram) to a linework spec. Returns the spec; review the guessed node kinds, then build it. With path, builds it straight away.',
|
|
142
142
|
inputSchema: z.object({ source: z.string().describe('Mermaid source, without the ``` fence.'), path: pathArg.optional() }),
|
|
143
143
|
}, (args) => wrap(async () => {
|
|
144
144
|
const { importMermaid } = await import('../import/mermaid.js');
|
package/dist/patch.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// One-line changes to an existing diagram: set a field, add or remove a box or
|
|
2
|
+
// an edge. Each reads the file's current spec (hand edits included), changes it
|
|
3
|
+
// and builds, so the layout merges the same way a show / edit / build round does.
|
|
4
|
+
import YAML, { isMap, isScalar, isSeq } from 'yaml';
|
|
5
|
+
import { build, show } from './api.js';
|
|
6
|
+
export class PatchError extends Error {
|
|
7
|
+
}
|
|
8
|
+
const TOP = new Set(['title', 'theme', 'direction']);
|
|
9
|
+
const TEXT = new Set(['label', 'tech', 'title']);
|
|
10
|
+
// "from ARROW to" or "from ARROW to: label". ER cardinalities count as arrows too.
|
|
11
|
+
const EDGE = /^(\S+)\s+(\S*(?:->|=>|--|\.\.)\S*|<->)\s+([^\s:]+)(?::\s*(.*))?$/;
|
|
12
|
+
function mapAt(doc, key, create = false) {
|
|
13
|
+
const m = doc.get(key);
|
|
14
|
+
if (isMap(m))
|
|
15
|
+
return m;
|
|
16
|
+
if (!create)
|
|
17
|
+
return undefined;
|
|
18
|
+
const made = doc.createNode({});
|
|
19
|
+
doc.set(key, made);
|
|
20
|
+
return made;
|
|
21
|
+
}
|
|
22
|
+
function seqAt(doc, key, create = false) {
|
|
23
|
+
const s = doc.get(key);
|
|
24
|
+
if (isSeq(s))
|
|
25
|
+
return s;
|
|
26
|
+
if (!create)
|
|
27
|
+
return undefined;
|
|
28
|
+
const made = doc.createNode([]);
|
|
29
|
+
doc.set(key, made);
|
|
30
|
+
return made;
|
|
31
|
+
}
|
|
32
|
+
const edgeEnds = (item) => {
|
|
33
|
+
if (isScalar(item) && typeof item.value === 'string') {
|
|
34
|
+
const m = EDGE.exec(item.value.trim());
|
|
35
|
+
return m ? { from: m[1], to: m[3] } : undefined;
|
|
36
|
+
}
|
|
37
|
+
// Unquoted, "a -> b: label" is a one-pair YAML map whose key is the edge.
|
|
38
|
+
if (isMap(item) && item.items.length === 1 && isScalar(item.items[0].key) && typeof item.items[0].key.value === 'string') {
|
|
39
|
+
const m = EDGE.exec(item.items[0].key.value.trim());
|
|
40
|
+
if (m)
|
|
41
|
+
return { from: m[1], to: m[3] };
|
|
42
|
+
}
|
|
43
|
+
if (isMap(item) && item.has('from') && item.has('to'))
|
|
44
|
+
return { from: String(item.get('from')), to: String(item.get('to')) };
|
|
45
|
+
return undefined;
|
|
46
|
+
};
|
|
47
|
+
/** Apply the ops to a spec's YAML text and return the new text. */
|
|
48
|
+
export function patchSpec(yaml, ops) {
|
|
49
|
+
const doc = YAML.parseDocument(yaml);
|
|
50
|
+
const sequence = doc.get('type') === 'sequence';
|
|
51
|
+
const nodesKey = sequence ? 'participants' : 'nodes';
|
|
52
|
+
const edgesKey = sequence ? 'messages' : 'edges';
|
|
53
|
+
const has = (key, id) => !!mapAt(doc, key)?.has(id);
|
|
54
|
+
const known = () => [...(mapAt(doc, nodesKey)?.items ?? []), ...(mapAt(doc, 'groups')?.items ?? [])].map((p) => String(isScalar(p.key) ? p.key.value : p.key)).join(', ');
|
|
55
|
+
for (const { op, args } of ops) {
|
|
56
|
+
if (!args.length)
|
|
57
|
+
throw new PatchError(`${op} needs at least one argument`);
|
|
58
|
+
for (const arg of args) {
|
|
59
|
+
if (op === 'set') {
|
|
60
|
+
const eq = arg.indexOf('=');
|
|
61
|
+
if (eq < 1)
|
|
62
|
+
throw new PatchError(`can't read "${arg}". Write it as id.field=value, for example api.label="Checkout API", or theme=dark.`);
|
|
63
|
+
const path = arg.slice(0, eq), raw = arg.slice(eq + 1);
|
|
64
|
+
const dot = path.lastIndexOf('.');
|
|
65
|
+
const field = dot < 0 ? path : path.slice(dot + 1);
|
|
66
|
+
const value = TEXT.has(field) ? raw : YAML.parse(raw);
|
|
67
|
+
if (dot < 0) {
|
|
68
|
+
if (!TOP.has(path))
|
|
69
|
+
throw new PatchError(`"${path}" isn't a diagram setting. Settings: title, theme, direction. For a box, write id.field=value.`);
|
|
70
|
+
if (raw === '')
|
|
71
|
+
doc.delete(path);
|
|
72
|
+
else
|
|
73
|
+
doc.set(path, value);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const id = path.slice(0, dot);
|
|
77
|
+
const key = has(nodesKey, id) ? nodesKey : has('groups', id) ? 'groups' : undefined;
|
|
78
|
+
if (!key)
|
|
79
|
+
throw new PatchError(`no box or group named "${id}". There are: ${known()}`);
|
|
80
|
+
const owner = mapAt(doc, key);
|
|
81
|
+
const found = owner.get(id, true);
|
|
82
|
+
let entry;
|
|
83
|
+
if (isMap(found))
|
|
84
|
+
entry = found;
|
|
85
|
+
else {
|
|
86
|
+
// "id: Label" and "id:" are short for a map.
|
|
87
|
+
const label = isScalar(found) && found.value != null ? { label: found.value } : {};
|
|
88
|
+
entry = doc.createNode(label, { flow: true });
|
|
89
|
+
owner.set(id, entry);
|
|
90
|
+
}
|
|
91
|
+
if (raw === '')
|
|
92
|
+
entry.delete(field);
|
|
93
|
+
else
|
|
94
|
+
entry.set(field, value);
|
|
95
|
+
}
|
|
96
|
+
else if (op === 'add') {
|
|
97
|
+
const text = arg.trim();
|
|
98
|
+
if (EDGE.test(text)) {
|
|
99
|
+
seqAt(doc, edgesKey, true).add(doc.createNode(text));
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const group = /^group\s+/.test(text);
|
|
103
|
+
const parsed = YAML.parse(group ? text.replace(/^group\s+/, '') : text);
|
|
104
|
+
const entries = typeof parsed === 'string' ? [[parsed, {}]] : parsed && typeof parsed === 'object' ? Object.entries(parsed) : [];
|
|
105
|
+
if (!entries.length)
|
|
106
|
+
throw new PatchError(`can't read "${arg}". A box: "fraud: { label: Fraud check, in: vpc }". An edge: "api -> fraud: score". A group: "group vpc: { label: Production VPC }".`);
|
|
107
|
+
const key = group ? 'groups' : nodesKey;
|
|
108
|
+
for (const [id, v] of entries) {
|
|
109
|
+
if (has(nodesKey, id) || has('groups', id))
|
|
110
|
+
throw new PatchError(`"${id}" already exists. Use set to change it.`);
|
|
111
|
+
const val = typeof v === 'string' ? { label: v } : (v ?? {});
|
|
112
|
+
mapAt(doc, key, true).set(id, doc.createNode(val, { flow: true }));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
const text = arg.trim();
|
|
117
|
+
const m = EDGE.exec(text);
|
|
118
|
+
if (m) {
|
|
119
|
+
const list = seqAt(doc, edgesKey);
|
|
120
|
+
const i = list?.items.findIndex((it) => {
|
|
121
|
+
const e = edgeEnds(it);
|
|
122
|
+
return e && e.from === m[1] && e.to === m[3];
|
|
123
|
+
});
|
|
124
|
+
if (!list || i === undefined || i < 0)
|
|
125
|
+
throw new PatchError(`no edge from "${m[1]}" to "${m[3]}"`);
|
|
126
|
+
list.delete(i);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (has(nodesKey, text)) {
|
|
130
|
+
mapAt(doc, nodesKey).delete(text);
|
|
131
|
+
const list = seqAt(doc, edgesKey);
|
|
132
|
+
if (list)
|
|
133
|
+
for (let i = list.items.length - 1; i >= 0; i--) {
|
|
134
|
+
const e = edgeEnds(list.items[i]);
|
|
135
|
+
if (e && (e.from === text || e.to === text))
|
|
136
|
+
list.delete(i);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
else if (has('groups', text)) {
|
|
140
|
+
// Its members move up to the group it was in, or out to the top level.
|
|
141
|
+
const groups = mapAt(doc, 'groups');
|
|
142
|
+
const self = groups.get(text);
|
|
143
|
+
const up = isMap(self) ? self.get('in') : undefined;
|
|
144
|
+
groups.delete(text);
|
|
145
|
+
for (const k of [nodesKey, 'groups'])
|
|
146
|
+
for (const p of mapAt(doc, k)?.items ?? [])
|
|
147
|
+
if (isMap(p.value) && p.value.get('in') === text) {
|
|
148
|
+
if (up)
|
|
149
|
+
p.value.set('in', up);
|
|
150
|
+
else
|
|
151
|
+
p.value.delete('in');
|
|
152
|
+
}
|
|
153
|
+
if (!groups.items.length)
|
|
154
|
+
doc.delete('groups');
|
|
155
|
+
}
|
|
156
|
+
else
|
|
157
|
+
throw new PatchError(`no box, group or edge matches "${arg}". There are: ${known()}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return doc.toString({ lineWidth: 0 });
|
|
162
|
+
}
|
|
163
|
+
/** Change a diagram file in place. The existing layout is kept. */
|
|
164
|
+
export async function patchFile(file, ops, opts = {}) {
|
|
165
|
+
const yaml = patchSpec(show(file).yaml, ops);
|
|
166
|
+
return build(yaml, { ...opts, out: file });
|
|
167
|
+
}
|
package/dist/report.js
CHANGED
|
@@ -8,6 +8,8 @@ export function buildLines(res, png) {
|
|
|
8
8
|
parts.push(`placed ${m.placed.join(', ')}`);
|
|
9
9
|
if (m.grown.length)
|
|
10
10
|
parts.push(`grew ${m.grown.join(', ')}`);
|
|
11
|
+
if (m.shrunk.length)
|
|
12
|
+
parts.push(`closed up ${m.shrunk.join(', ')}`);
|
|
11
13
|
if (m.removed.length)
|
|
12
14
|
parts.push(`removed ${m.removed.join(', ')}`);
|
|
13
15
|
if (m.rerouted)
|
package/dist/spec/parse.js
CHANGED
|
@@ -243,6 +243,8 @@ export function parseSpec(raw) {
|
|
|
243
243
|
const out = edges.filter((e) => e.from === n.id);
|
|
244
244
|
if (out.some((e) => !e.label))
|
|
245
245
|
warnings.push({ path: `nodes.${n.id}`, message: 'label every edge leaving a decision, e.g. "yes"/"no"' });
|
|
246
|
+
if (n.label.length > 40)
|
|
247
|
+
warnings.push({ path: `nodes.${n.id}`, message: `a diamond holds little text; shorten "${n.label.slice(0, 24)}..." to a short question and put the detail in the step before it` });
|
|
246
248
|
}
|
|
247
249
|
}
|
|
248
250
|
highlightWarning(real, edges, warnings);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@itsthecsguy/linework",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Software diagrams an AI can write and a person can edit. YAML spec in, .drawio.svg out, editable in draw.io.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"diagram",
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"assets": "node scripts/copy-drawio-assets.mjs",
|
|
42
42
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
43
43
|
"prepack": "npm run build",
|
|
44
|
+
"release": "node scripts/release.mjs",
|
|
44
45
|
"fonts": "node scripts/copy-fonts.mjs"
|
|
45
46
|
},
|
|
46
47
|
"dependencies": {
|