@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
package/dist/drawio/write.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { kindDef } from '../spec/model.js';
|
|
2
2
|
import { DOMParser, XMLSerializer } from '@xmldom/xmldom';
|
|
3
3
|
import { ICON_PAD, ICON_SIZE, resolveIcon } from './icons.js';
|
|
4
|
-
import { ER_HEAD, ER_ROW, edgeStyle, erEdgeStyle, fieldLabel, fieldStyle, groupLabel, groupStyle, nodeLabel, nodeStyle, paintFor, palette } from './theme.js';
|
|
4
|
+
import { CYL_SIZE, ER_HEAD, ER_ROW, edgeStyle, erEdgeStyle, fieldLabel, fieldStyle, groupLabel, groupStyle, nodeLabel, nodeStyle, paintFor, palette, textMiddle, typeface } from './theme.js';
|
|
5
5
|
export { ER_HEAD, ER_ROW };
|
|
6
6
|
export const FORMAT_VERSION = '1';
|
|
7
7
|
export const xmlEsc = (s) => s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, ' ');
|
|
@@ -72,7 +72,8 @@ export function nodeChildren(d, n, box) {
|
|
|
72
72
|
parent: n.id,
|
|
73
73
|
attrs: { label: '', aiviz_type: 'icon' },
|
|
74
74
|
style: r.style,
|
|
75
|
-
|
|
75
|
+
// Level with the text, which on a cylinder sits below the lid.
|
|
76
|
+
box: { x: (ICON_PAD - ICON_SIZE) / 2 + 2, y: Math.round(textMiddle(styleFor(d, n), box.h) - ICON_SIZE / 2), w: ICON_SIZE, h: ICON_SIZE },
|
|
76
77
|
});
|
|
77
78
|
}
|
|
78
79
|
return out;
|
|
@@ -115,12 +116,38 @@ export const groupStyleFor = (d, g) => groupStyle(d.theme, g, groupDepth(d, g));
|
|
|
115
116
|
export function groupAttrs(g, theme = 'clean') {
|
|
116
117
|
return { label: groupLabel(g.label, theme), aiviz_type: 'group', aiviz_label: g.label, ...lookAttrs(g) };
|
|
117
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* Layout sizes a decision's diamond from its text wrapped narrow (see nodeSizes),
|
|
121
|
+
* but draw.io wraps at the diamond's full width, so a long question runs out over
|
|
122
|
+
* the slanted sides. Inset the label to the width layout measured.
|
|
123
|
+
*/
|
|
124
|
+
function diamondInset(d, n, box) {
|
|
125
|
+
if (kindDef(d.type, n.kind).shape !== 'diamond')
|
|
126
|
+
return '';
|
|
127
|
+
const text = (box.w - 40) / 1.5;
|
|
128
|
+
const side = Math.max(0, Math.floor((box.w - text - 10) / 2));
|
|
129
|
+
return `spacingLeft=${side};spacingRight=${side};`;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Where a vertical edge at `x` meets a cylinder's curved top or base, as a
|
|
133
|
+
* fraction of its height. Off centre the curve sits inside the bounding box, and
|
|
134
|
+
* an arrow stopped at the box would float short of it.
|
|
135
|
+
*/
|
|
136
|
+
function rimY(p, b) {
|
|
137
|
+
const top = Math.abs(p.y - b.y) < 0.5, base = Math.abs(p.y - (b.y + b.h)) < 0.5;
|
|
138
|
+
if (!top && !base)
|
|
139
|
+
return undefined;
|
|
140
|
+
const u = Math.min(1, Math.abs(p.x - (b.x + b.w / 2)) / (b.w / 2));
|
|
141
|
+
const dip = CYL_SIZE * (1 - Math.sqrt(1 - u * u));
|
|
142
|
+
return top ? dip / b.h : (b.h - dip) / b.h;
|
|
143
|
+
}
|
|
118
144
|
/** Exit/entry constraints so draw.io leaves and enters nodes where ELK did. */
|
|
119
|
-
export function portStyle(points, s, t) {
|
|
145
|
+
export function portStyle(points, s, t, cyl = {}) {
|
|
120
146
|
const start = points[0], end = points[points.length - 1];
|
|
121
147
|
const c01 = (v) => Math.min(1, Math.max(0, Math.round(v * 1000) / 1000));
|
|
122
|
-
|
|
123
|
-
|
|
148
|
+
const sr = cyl.from ? rimY(start, s) : undefined, tr = cyl.to ? rimY(end, t) : undefined;
|
|
149
|
+
return (`exitX=${c01((start.x - s.x) / s.w)};exitY=${c01(sr ?? (start.y - s.y) / s.h)};exitDx=0;exitDy=0;${sr !== undefined ? 'exitPerimeter=0;' : ''}` +
|
|
150
|
+
`entryX=${c01((end.x - t.x) / t.w)};entryY=${c01(tr ?? (end.y - t.y) / t.h)};entryDx=0;entryDy=0;${tr !== undefined ? 'entryPerimeter=0;' : ''}`);
|
|
124
151
|
}
|
|
125
152
|
export function sceneFromLayout(d, l) {
|
|
126
153
|
const parentOf = new Map();
|
|
@@ -139,43 +166,140 @@ export function sceneFromLayout(d, l) {
|
|
|
139
166
|
const vertices = [];
|
|
140
167
|
for (const g of d.groups) {
|
|
141
168
|
const box = rel(g.id);
|
|
142
|
-
|
|
169
|
+
const inset = l.titleInset?.get(g.id);
|
|
170
|
+
vertices.push({ id: g.id, parent: g.parent ?? '1', attrs: { ...groupAttrs(g, d.theme), aiviz_auto: autoAttr(box) }, style: groupStyleFor(d, g) + (inset ? `spacingLeft=${inset};` : ''), box });
|
|
143
171
|
}
|
|
144
172
|
for (const n of d.nodes) {
|
|
145
173
|
const box = rel(n.id);
|
|
146
|
-
vertices.push({ id: n.id, parent: n.parent ?? '1', attrs: { ...nodeAttrs(d, n), aiviz_auto: autoAttr(box) }, style: styleFor(d, n), box });
|
|
174
|
+
vertices.push({ id: n.id, parent: n.parent ?? '1', attrs: { ...nodeAttrs(d, n), aiviz_auto: autoAttr(box) }, style: styleFor(d, n) + diamondInset(d, n, box), box });
|
|
147
175
|
vertices.push(...nodeChildren(d, n, box));
|
|
148
176
|
}
|
|
177
|
+
const isCyl = (id) => { const n = d.nodes.find((x) => x.id === id); return !!n && kindDef(d.type, n.kind).shape === 'cylinder'; };
|
|
149
178
|
const edges = d.edges.map((e) => {
|
|
150
179
|
const le = l.edges.get(e.id);
|
|
180
|
+
const cyl = { from: isCyl(e.from), to: isCyl(e.to) };
|
|
151
181
|
const base = { id: edgeCellId(e.id), parent: '1', attrs: edgeAttrs(e), style: edgeStyleFor(d, e, labelBg(d, l, le?.label)), source: e.from, target: e.to };
|
|
152
182
|
if (!le)
|
|
153
183
|
return base;
|
|
184
|
+
if (e.from === e.to && le.label && e.label && le.points.length === 4) {
|
|
185
|
+
// A self-loop: the label sits beside the loop's outer run, not on it.
|
|
186
|
+
const [, a, b] = le.points;
|
|
187
|
+
const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
|
188
|
+
return {
|
|
189
|
+
...base,
|
|
190
|
+
style: base.style + portStyle(le.points, abs(e.from), abs(e.to), cyl),
|
|
191
|
+
points: le.points.slice(1, -1),
|
|
192
|
+
labelX: 0.001,
|
|
193
|
+
labelOffset: { x: le.label.x + le.label.w / 2 - mid.x, y: le.label.y + le.label.h / 2 - mid.y },
|
|
194
|
+
};
|
|
195
|
+
}
|
|
154
196
|
return {
|
|
155
197
|
...base,
|
|
156
|
-
style: base.style + portStyle(le.points, abs(e.from), abs(e.to)),
|
|
198
|
+
style: base.style + portStyle(le.points, abs(e.from), abs(e.to), cyl),
|
|
157
199
|
points: le.points.slice(1, -1),
|
|
158
|
-
labelX: le.label && e.label ? (labelSpot(le.points, le.label) ?? labelFraction(le.points, { x: le.label.x + le.label.w / 2, y: le.label.y + le.label.h / 2 })) : undefined,
|
|
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,
|
|
159
201
|
};
|
|
160
202
|
});
|
|
203
|
+
alignLabels(d, l, edges);
|
|
161
204
|
return { name: d.title ?? 'Diagram', background: palette(d.theme).canvas, meta: diagramMeta(d), vertices, edges, raw: [] };
|
|
162
205
|
}
|
|
206
|
+
/** The point at a draw.io label fraction (-1..1) along a route, and the run it sits on. */
|
|
207
|
+
function spotAt(pts, frac) {
|
|
208
|
+
const lens = pts.slice(1).map((b, i) => Math.hypot(b.x - pts[i].x, b.y - pts[i].y));
|
|
209
|
+
let at = ((frac + 1) / 2) * lens.reduce((x, y) => x + y, 0);
|
|
210
|
+
for (let i = 0; i < lens.length; i++) {
|
|
211
|
+
const a = pts[i], b = pts[i + 1], len = lens[i];
|
|
212
|
+
if (at <= len + 1e-6 || i === lens.length - 1) {
|
|
213
|
+
const t = len ? Math.min(1, at / len) : 0;
|
|
214
|
+
return { p: { x: a.x + t * (b.x - a.x), y: a.y + t * (b.y - a.y) }, a, b };
|
|
215
|
+
}
|
|
216
|
+
at -= len;
|
|
217
|
+
}
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Labels on neighbouring parallel runs (two edges dropping side by side) sit at
|
|
222
|
+
* whatever height each run's midpoint gives, often a few px apart. Put them on
|
|
223
|
+
* one line when each can move there and stay on its own run.
|
|
224
|
+
*/
|
|
225
|
+
function alignLabels(d, l, edges) {
|
|
226
|
+
const h = Math.ceil(typeface(d.theme).size.edge * 1.2) + 4;
|
|
227
|
+
const spots = edges.flatMap((se) => {
|
|
228
|
+
const e = d.edges.find((x) => edgeCellId(x.id) === se.id);
|
|
229
|
+
const le = e && l.edges.get(e.id);
|
|
230
|
+
if (!le || se.labelX === undefined || se.labelOffset || !e.label)
|
|
231
|
+
return [];
|
|
232
|
+
const s = spotAt(le.points, se.labelX);
|
|
233
|
+
if (!s)
|
|
234
|
+
return [];
|
|
235
|
+
const vertical = Math.abs(s.a.x - s.b.x) < 0.5;
|
|
236
|
+
if (!vertical && Math.abs(s.a.y - s.b.y) >= 0.5)
|
|
237
|
+
return [];
|
|
238
|
+
return [{ se, pts: le.points, ...s, vertical }];
|
|
239
|
+
});
|
|
240
|
+
for (const vertical of [true, false]) {
|
|
241
|
+
const along = (p) => (vertical ? p.y : p.x), across = (p) => (vertical ? p.x : p.y);
|
|
242
|
+
const group = spots.filter((s) => s.vertical === vertical).sort((a, b) => along(a.p) - along(b.p));
|
|
243
|
+
for (let i = 0; i < group.length;) {
|
|
244
|
+
let j = i + 1;
|
|
245
|
+
while (j < group.length && along(group[j].p) - along(group[i].p) <= 8)
|
|
246
|
+
j++;
|
|
247
|
+
const set = group.slice(i, j);
|
|
248
|
+
i = j;
|
|
249
|
+
if (set.length < 2)
|
|
250
|
+
continue;
|
|
251
|
+
// Side by side on different runs, not one run's labels stacked.
|
|
252
|
+
const xs = set.map((s) => across(s.p));
|
|
253
|
+
if (Math.max(...xs) - Math.min(...xs) < 20)
|
|
254
|
+
continue;
|
|
255
|
+
const target = Math.round(set.reduce((m, s) => m + along(s.p), 0) / set.length);
|
|
256
|
+
const fits = set.every((s) => {
|
|
257
|
+
const lo = Math.min(along(s.a), along(s.b)) + h / 2 + 4, hi = Math.max(along(s.a), along(s.b)) - h / 2 - 4;
|
|
258
|
+
return target >= lo && target <= hi;
|
|
259
|
+
});
|
|
260
|
+
if (!fits)
|
|
261
|
+
continue;
|
|
262
|
+
for (const s of set) {
|
|
263
|
+
const c = vertical ? { x: s.p.x, y: target } : { x: target, y: s.p.y };
|
|
264
|
+
s.se.labelX = labelFraction(s.pts, c);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
163
269
|
const attrString = (attrs) => Object.entries(attrs)
|
|
164
270
|
.filter(([k]) => k !== 'id')
|
|
165
271
|
.map(([k, v]) => ` ${k}="${xmlEsc(v)}"`)
|
|
166
272
|
.join('');
|
|
167
273
|
export function serialize(scene) {
|
|
168
|
-
// Parents before children.
|
|
169
274
|
const parentOf = new Map(scene.vertices.map((v) => [v.id, v.parent]));
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
const
|
|
177
|
-
const
|
|
178
|
-
const
|
|
275
|
+
// Every cell after its parent, otherwise in the scene's order, which is spec
|
|
276
|
+
// order: show() reads it back and ELK uses it as a placement hint. Groups go
|
|
277
|
+
// first so nodes can sit in them; a node's own children (icon, table rows)
|
|
278
|
+
// already follow it.
|
|
279
|
+
const groups = scene.vertices.filter((v) => v.attrs.aiviz_type === 'group');
|
|
280
|
+
const rest = scene.vertices.filter((v) => v.attrs.aiviz_type !== 'group');
|
|
281
|
+
const placed = new Set(['1']);
|
|
282
|
+
const vertices = [];
|
|
283
|
+
for (const list of [groups, rest]) {
|
|
284
|
+
let todo = [...list];
|
|
285
|
+
while (todo.length) {
|
|
286
|
+
const left = [];
|
|
287
|
+
for (const v of todo) {
|
|
288
|
+
if (placed.has(v.parent) || !parentOf.has(v.parent)) {
|
|
289
|
+
vertices.push(v);
|
|
290
|
+
placed.add(v.id);
|
|
291
|
+
}
|
|
292
|
+
else
|
|
293
|
+
left.push(v);
|
|
294
|
+
}
|
|
295
|
+
// A cycle or a dangling parent: write the rest as they are rather than drop them.
|
|
296
|
+
if (left.length === todo.length) {
|
|
297
|
+
vertices.push(...left);
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
todo = left;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
179
303
|
const cells = [];
|
|
180
304
|
for (const v of vertices) {
|
|
181
305
|
const b = v.box;
|
|
@@ -188,6 +312,7 @@ export function serialize(scene) {
|
|
|
188
312
|
const geom = e.geomRaw ??
|
|
189
313
|
`<mxGeometry ${e.labelX ? `x="${e.labelX.toFixed(3)}" ` : ''}relative="1" as="geometry">` +
|
|
190
314
|
(e.points?.length ? `<Array as="points">${e.points.map((p) => `<mxPoint x="${r(p.x)}" y="${r(p.y)}"/>`).join('')}</Array>` : '') +
|
|
315
|
+
(e.labelOffset ? `<mxPoint x="${r(e.labelOffset.x)}" y="${r(e.labelOffset.y)}" as="offset"/>` : '') +
|
|
191
316
|
`</mxGeometry>`;
|
|
192
317
|
cells.push(`<object id="${xmlEsc(e.id)}"${attrString({ label: '', ...e.attrs })}>` +
|
|
193
318
|
`<mxCell style="${xmlEsc(e.style)}" edge="1" parent="${xmlEsc(e.parent)}"` +
|
|
@@ -209,16 +334,21 @@ export function toMxfile(d, l) {
|
|
|
209
334
|
* source node, which reads as a caption of that node. Returns draw.io's -1..1
|
|
210
335
|
* fraction, or undefined when no run is long enough.
|
|
211
336
|
*/
|
|
212
|
-
function labelSpot(pts, label) {
|
|
337
|
+
function labelSpot(pts, label, dir) {
|
|
213
338
|
let total = 0;
|
|
214
339
|
let best;
|
|
340
|
+
// Runs along the flow belong to one edge; runs across it are often shared
|
|
341
|
+
// corridors where several edges' labels would stack up. Prefer the former.
|
|
342
|
+
const flowVertical = dir === 'TB' || dir === 'BT';
|
|
215
343
|
for (let i = 0; i < pts.length - 1; i++) {
|
|
216
344
|
const a = pts[i], b = pts[i + 1];
|
|
217
345
|
const len = Math.hypot(b.x - a.x, b.y - a.y);
|
|
218
346
|
const vertical = Math.abs(b.x - a.x) < Math.abs(b.y - a.y);
|
|
219
347
|
const room = len - (vertical ? label.h : label.w) - 20;
|
|
220
|
-
|
|
221
|
-
|
|
348
|
+
const along = dir ? vertical === flowVertical : true;
|
|
349
|
+
const better = !best || (along && !best.along) || (along === best.along && room > best.room);
|
|
350
|
+
if (room >= 0 && better)
|
|
351
|
+
best = { room, at: total + len / 2, along };
|
|
222
352
|
total += len;
|
|
223
353
|
}
|
|
224
354
|
return best && total ? (best.at / total) * 2 - 1 : undefined;
|
package/dist/layout/layout.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// Diagram -> absolute geometry, via ELK layered.
|
|
2
2
|
import ElkModule from 'elkjs/lib/elk.bundled.js';
|
|
3
3
|
import { kindDef } from '../spec/model.js';
|
|
4
|
-
import { ER_HEAD, ER_ROW, FIELD_PAD, LINE, PAD_X, PAD_Y, typeface } from '../drawio/theme.js';
|
|
4
|
+
import { ER_HEAD, ER_ROW, FIELD_PAD, GROUP_INSET, LINE, PAD_X, PAD_Y, typeface } from '../drawio/theme.js';
|
|
5
5
|
import { TextMeasurer } from './text.js';
|
|
6
6
|
import { ICON_PAD } from '../drawio/icons.js';
|
|
7
|
+
import { polish } from './polish.js';
|
|
7
8
|
/** Crude fallback when no browser is available (tests). */
|
|
8
9
|
export const estimateMeasure = async (reqs) => reqs.map(([t, size, weight]) => Math.ceil(t.length * size * (weight >= 600 ? 0.6 : 0.55)));
|
|
9
10
|
// elkjs is CommonJS; under NodeNext the constructor sits on .default.
|
|
@@ -44,12 +45,76 @@ export async function layout(d, measure = estimateMeasure) {
|
|
|
44
45
|
const labels = d.edges.map((e) => (e.label ? { w: Math.ceil(tm.width(e.label, fonts.edge)) + 10, h: Math.ceil(fonts.edge.size * LINE) + 4 } : undefined));
|
|
45
46
|
const groupMin = new Map(d.groups.map((g) => [g.id, Math.ceil(tm.width(groupText(g.label, d.theme), fonts.group)) + 40]));
|
|
46
47
|
const groupTop = Math.round(8 + fonts.group.size * LINE + 20);
|
|
47
|
-
const
|
|
48
|
+
const groupTitle = new Map(d.groups.map((g) => [g.id, { w: Math.ceil(tm.width(groupText(g.label, d.theme), fonts.group)), h: Math.ceil(fonts.group.size * LINE) }]));
|
|
49
|
+
const once = async (dir, order, pairs = 'reply') => {
|
|
50
|
+
let l = { ...(await run(d, dir, sizes, labels, groupMin, groupTop, order, undefined, pairs)), natural };
|
|
51
|
+
// ER: once the tables are placed, run again with each relationship pinned to
|
|
52
|
+
// its key rows, on the sides that face each other.
|
|
53
|
+
const keys = d.type === 'er' ? keyRows(d) : new Map();
|
|
54
|
+
if (keys.size) {
|
|
55
|
+
const pins = new Map();
|
|
56
|
+
for (const [eid, k] of keys) {
|
|
57
|
+
const e = d.edges.find((x) => x.id === eid);
|
|
58
|
+
const a = l.nodes.get(e.from), b = l.nodes.get(e.to);
|
|
59
|
+
const aLeft = a.x + a.w / 2 <= b.x + b.w / 2;
|
|
60
|
+
const pin = (node, row, east, end) => {
|
|
61
|
+
if (row === undefined)
|
|
62
|
+
return;
|
|
63
|
+
const list = pins.get(node) ?? [];
|
|
64
|
+
list.push({ edge: eid, end, row, side: east ? 'EAST' : 'WEST' });
|
|
65
|
+
pins.set(node, list);
|
|
66
|
+
};
|
|
67
|
+
pin(e.from, k.from, aLeft, 'from');
|
|
68
|
+
pin(e.to, k.to, !aLeft, 'to');
|
|
69
|
+
}
|
|
70
|
+
// Two relationships on one key row would meet at one point and stack their
|
|
71
|
+
// markers. Spread them across the row, the one from higher up on top.
|
|
72
|
+
for (const [node, list] of pins) {
|
|
73
|
+
const rows = new Map();
|
|
74
|
+
for (const p of list)
|
|
75
|
+
rows.set(`${p.side}:${p.row}`, [...(rows.get(`${p.side}:${p.row}`) ?? []), p]);
|
|
76
|
+
for (const same of rows.values()) {
|
|
77
|
+
if (same.length < 2)
|
|
78
|
+
continue;
|
|
79
|
+
const other = (p) => {
|
|
80
|
+
const e = d.edges.find((x) => x.id === p.edge);
|
|
81
|
+
const b = l.nodes.get(e.from === node && p.end === 'from' ? e.to : e.from);
|
|
82
|
+
return b.y + b.h / 2;
|
|
83
|
+
};
|
|
84
|
+
same.sort((a, b) => other(a) - other(b));
|
|
85
|
+
const step = Math.min(12, Math.floor((ER_ROW - 4) / same.length));
|
|
86
|
+
same.forEach((p, i) => (p.dy = Math.round((i - (same.length - 1) / 2) * step)));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
l = { ...(await run(d, dir, sizes, labels, groupMin, groupTop, order, pins, pairs)), natural };
|
|
90
|
+
}
|
|
91
|
+
polish(d, l, { groupTitle, titleInset: GROUP_INSET, groupTop });
|
|
92
|
+
return toPixels(l);
|
|
93
|
+
};
|
|
94
|
+
// Spec order first: it keeps a diagram stable as the spec grows. Free ordering
|
|
95
|
+
// wins only when it crosses fewer edges.
|
|
96
|
+
const hasPairs = d.edges.some((e, i) => e.from !== e.to && d.edges.slice(0, i).some((x) => x.from === e.to && x.to === e.from));
|
|
97
|
+
const go = async (dir) => {
|
|
98
|
+
const tries = [['NODES_AND_EDGES', 'reply'], ['NONE', 'reply']];
|
|
99
|
+
if (hasPairs)
|
|
100
|
+
tries.push(['NODES_AND_EDGES', 'call'], ['NONE', 'call']);
|
|
101
|
+
const all = await Promise.all(tries.map(([o, p]) => once(dir, o, p)));
|
|
102
|
+
// Fewest crossings; then clearly shorter edges; ties go to the earlier try.
|
|
103
|
+
return all.reduce((best, l) => {
|
|
104
|
+
const [cb, cl] = [crossings(best), crossings(l)];
|
|
105
|
+
if (cl !== cb)
|
|
106
|
+
return cl < cb ? l : best;
|
|
107
|
+
return edgeLength(l) < 0.9 * edgeLength(best) ? l : best;
|
|
108
|
+
});
|
|
109
|
+
};
|
|
48
110
|
if (d.direction !== 'auto')
|
|
49
|
-
return
|
|
111
|
+
return go(d.direction);
|
|
112
|
+
// Relationships attach to rows, on a table's sides, so tables read left to right.
|
|
113
|
+
if (d.type === 'er')
|
|
114
|
+
return go('LR');
|
|
50
115
|
// Pick the direction whose result fits a document page best.
|
|
51
116
|
const [tb, lr] = await Promise.all([go('TB'), go('LR')]);
|
|
52
|
-
return
|
|
117
|
+
return score(tb) <= score(lr) ? tb : lr;
|
|
53
118
|
}
|
|
54
119
|
const groupText = (label, theme) => (typeface(theme).upperGroups ? label.toUpperCase() : label);
|
|
55
120
|
const fieldRight = (f) => {
|
|
@@ -101,9 +166,11 @@ function nodeSizes(d, tm, fonts) {
|
|
|
101
166
|
sizes.set(n.id, { w: snap(Math.max(minW, t.width + 2 * PAD_X + extra)), h: boxHeight(t.lines, fonts, shape) });
|
|
102
167
|
}
|
|
103
168
|
const natural = new Map([...sizes].map(([k, v]) => [k, { ...v }]));
|
|
104
|
-
// Boxes of nearly the same width read calmer at exactly the same width
|
|
105
|
-
//
|
|
106
|
-
|
|
169
|
+
// Boxes of nearly the same width read calmer at exactly the same width: their
|
|
170
|
+
// edges line up down a column. Tables too, so their key rows' connectors start
|
|
171
|
+
// at one x. Boxes much wider than the rest keep their own width rather than
|
|
172
|
+
// stretching every box.
|
|
173
|
+
const uniform = d.nodes.filter((n) => !['start', 'end', 'diamond'].includes(kindDef(d.type, n.kind).shape));
|
|
107
174
|
const sorted = [...uniform].sort((a, b) => sizes.get(a.id).w - sizes.get(b.id).w);
|
|
108
175
|
let cluster = [];
|
|
109
176
|
const flush = () => {
|
|
@@ -113,8 +180,10 @@ function nodeSizes(d, tm, fonts) {
|
|
|
113
180
|
if (s.w === w)
|
|
114
181
|
continue;
|
|
115
182
|
s.w = w;
|
|
116
|
-
// A wider box may fit the text on fewer lines.
|
|
183
|
+
// A wider box may fit the text on fewer lines. A table's rows don't wrap.
|
|
117
184
|
const shape = kindDef(d.type, n.kind).shape;
|
|
185
|
+
if (shape === 'table')
|
|
186
|
+
continue;
|
|
118
187
|
s.h = boxHeight(textOf(n, w - 2 * PAD_X - shapeExtra(shape, !!n.icon)).lines, fonts, shape);
|
|
119
188
|
}
|
|
120
189
|
cluster = [];
|
|
@@ -127,7 +196,7 @@ function nodeSizes(d, tm, fonts) {
|
|
|
127
196
|
flush();
|
|
128
197
|
return { sizes, natural };
|
|
129
198
|
}
|
|
130
|
-
async function run(d, dir, sizes, labels, groupMin, groupTop) {
|
|
199
|
+
async function run(d, dir, sizes, labels, groupMin, groupTop, order, pins = new Map(), pairs = 'reply') {
|
|
131
200
|
const byId = new Map();
|
|
132
201
|
const root = {
|
|
133
202
|
id: '__root',
|
|
@@ -138,7 +207,10 @@ async function run(d, dir, sizes, labels, groupMin, groupTop) {
|
|
|
138
207
|
'elk.edgeRouting': 'ORTHOGONAL',
|
|
139
208
|
'elk.layered.nodePlacement.strategy': 'BRANDES_KOEPF',
|
|
140
209
|
'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED',
|
|
141
|
-
'elk.layered.considerModelOrder.strategy':
|
|
210
|
+
'elk.layered.considerModelOrder.strategy': order,
|
|
211
|
+
// Walk from the sources in spec order, so the edge that closes a loop (a retry,
|
|
212
|
+
// a resume) is the one drawn backwards, not the main path.
|
|
213
|
+
'elk.layered.cycleBreaking.strategy': 'DEPTH_FIRST',
|
|
142
214
|
'elk.layered.spacing.nodeNodeBetweenLayers': '32',
|
|
143
215
|
'elk.spacing.nodeNode': '40',
|
|
144
216
|
'elk.layered.spacing.edgeNodeBetweenLayers': '12',
|
|
@@ -176,15 +248,42 @@ async function run(d, dir, sizes, labels, groupMin, groupTop) {
|
|
|
176
248
|
const en = { id: n.id, width: s.w, height: s.h };
|
|
177
249
|
if (n.kind === 'start')
|
|
178
250
|
en.layoutOptions = { 'elk.layered.layering.layerConstraint': 'FIRST' };
|
|
251
|
+
const np = pins.get(n.id);
|
|
252
|
+
if (np?.length) {
|
|
253
|
+
en.layoutOptions = { ...en.layoutOptions, 'elk.portConstraints': 'FIXED_POS' };
|
|
254
|
+
en.ports = np.map((p) => ({
|
|
255
|
+
id: portId(p),
|
|
256
|
+
width: 0,
|
|
257
|
+
height: 0,
|
|
258
|
+
x: p.side === 'EAST' ? s.w : 0,
|
|
259
|
+
y: ER_HEAD + p.row * ER_ROW + ER_ROW / 2 + (p.dy ?? 0),
|
|
260
|
+
layoutOptions: { 'elk.port.side': p.side },
|
|
261
|
+
}));
|
|
262
|
+
}
|
|
179
263
|
byId.set(n.id, en);
|
|
180
264
|
byId.get(n.parent ?? '__root').children.push(en);
|
|
181
265
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
266
|
+
// A reply to an earlier edge (stripe -> api after api -> stripe) is laid out in
|
|
267
|
+
// the call's direction, so the pair runs side by side instead of one of them
|
|
268
|
+
// being drawn backwards around everything in between. Its points flip back below.
|
|
269
|
+
// Which of the two to flip is tried both ways by the caller: flipping the call
|
|
270
|
+
// puts the other end ahead of the caller, often outside its group.
|
|
271
|
+
const flipped = new Set();
|
|
272
|
+
d.edges.forEach((e, i) => {
|
|
273
|
+
const call = e.from !== e.to ? d.edges.slice(0, i).find((x) => x.from === e.to && x.to === e.from) : undefined;
|
|
274
|
+
if (call)
|
|
275
|
+
flipped.add(pairs === 'reply' ? e.id : call.id);
|
|
276
|
+
});
|
|
277
|
+
root.edges = d.edges.map((e, i) => {
|
|
278
|
+
const src = pinFor(pins, e.from, e.id, 'from') ?? e.from, dst = pinFor(pins, e.to, e.id, 'to') ?? e.to;
|
|
279
|
+
const flip = flipped.has(e.id);
|
|
280
|
+
return {
|
|
281
|
+
id: e.id,
|
|
282
|
+
sources: [flip ? dst : src],
|
|
283
|
+
targets: [flip ? src : dst],
|
|
284
|
+
labels: labels[i] ? [{ text: e.label, width: labels[i].w, height: labels[i].h }] : [],
|
|
285
|
+
};
|
|
286
|
+
});
|
|
188
287
|
const out = await elk.layout(root);
|
|
189
288
|
const nodes = new Map();
|
|
190
289
|
const groups = new Map();
|
|
@@ -204,9 +303,118 @@ async function run(d, dir, sizes, labels, groupMin, groupTop) {
|
|
|
204
303
|
continue;
|
|
205
304
|
const lab = e.labels?.[0];
|
|
206
305
|
edges.set(e.id, {
|
|
207
|
-
points: [s.startPoint, ...(s.bendPoints ?? []), s.endPoint].map((p) => ({ x: p.x, y: p.y })),
|
|
306
|
+
points: (flipped.has(e.id) ? (pts) => pts.reverse() : (pts) => pts)([s.startPoint, ...(s.bendPoints ?? []), s.endPoint].map((p) => ({ x: p.x, y: p.y }))),
|
|
208
307
|
label: lab && lab.x != null ? { x: lab.x, y: lab.y, w: lab.width, h: lab.height } : undefined,
|
|
209
308
|
});
|
|
210
309
|
}
|
|
211
310
|
return { direction: dir, width: out.width, height: out.height, nodes, groups, edges };
|
|
212
311
|
}
|
|
312
|
+
/**
|
|
313
|
+
* Whole-pixel geometry. ELK centres boxes at fractions of a pixel; draw.io then
|
|
314
|
+
* rounds the boxes but not the edges, so a straight drop gets 0.1px kinks and
|
|
315
|
+
* 1px lines blur across two pixels. Sizes are whole already, so rounding every
|
|
316
|
+
* coordinate keeps each edge end on its box.
|
|
317
|
+
*/
|
|
318
|
+
function toPixels(l) {
|
|
319
|
+
const rp = (p) => ({ x: Math.round(p.x), y: Math.round(p.y) });
|
|
320
|
+
const rb = (b) => ({ ...rp(b), w: Math.round(b.w), h: Math.round(b.h) });
|
|
321
|
+
for (const m of [l.nodes, l.groups])
|
|
322
|
+
for (const [k, b] of m)
|
|
323
|
+
m.set(k, rb(b));
|
|
324
|
+
for (const [k, e] of l.edges)
|
|
325
|
+
l.edges.set(k, { ...e, points: e.points.map(rp), label: e.label && rb(e.label) });
|
|
326
|
+
return { ...l, width: Math.ceil(l.width), height: Math.ceil(l.height) };
|
|
327
|
+
}
|
|
328
|
+
/** Proper crossings between edge segments: what a reader has to untangle. */
|
|
329
|
+
export function crossings(l) {
|
|
330
|
+
const segs = [];
|
|
331
|
+
for (const [id, e] of l.edges)
|
|
332
|
+
for (let i = 0; i < e.points.length - 1; i++)
|
|
333
|
+
segs.push([e.points[i], e.points[i + 1], id]);
|
|
334
|
+
let n = 0;
|
|
335
|
+
for (let i = 0; i < segs.length; i++)
|
|
336
|
+
for (let j = i + 1; j < segs.length; j++) {
|
|
337
|
+
const [a, b, ea] = segs[i], [c, dd, eb] = segs[j];
|
|
338
|
+
if (ea === eb)
|
|
339
|
+
continue;
|
|
340
|
+
const o = (p, q, r) => Math.sign((q.x - p.x) * (r.y - p.y) - (q.y - p.y) * (r.x - p.x));
|
|
341
|
+
if (o(a, b, c) * o(a, b, dd) < 0 && o(c, dd, a) * o(c, dd, b) < 0)
|
|
342
|
+
n++;
|
|
343
|
+
}
|
|
344
|
+
return n;
|
|
345
|
+
}
|
|
346
|
+
export function edgeLength(l) {
|
|
347
|
+
let n = 0;
|
|
348
|
+
for (const e of l.edges.values())
|
|
349
|
+
for (let i = 1; i < e.points.length; i++)
|
|
350
|
+
n += Math.abs(e.points[i].x - e.points[i - 1].x) + Math.abs(e.points[i].y - e.points[i - 1].y);
|
|
351
|
+
return n;
|
|
352
|
+
}
|
|
353
|
+
const portId = (p) => `${p.edge}::${p.end}`;
|
|
354
|
+
const pinFor = (pins, node, edge, end) => {
|
|
355
|
+
const p = pins.get(node)?.find((x) => x.edge === edge && x.end === end);
|
|
356
|
+
return p ? portId(p) : undefined;
|
|
357
|
+
};
|
|
358
|
+
/** "addresses" -> "address", "categories" -> "category", "orgs" -> "org". */
|
|
359
|
+
export function singular(word) {
|
|
360
|
+
const w = word.toLowerCase();
|
|
361
|
+
if (w.endsWith('ies'))
|
|
362
|
+
return w.slice(0, -3) + 'y';
|
|
363
|
+
if (/(ss|x|ch|sh)es$/.test(w))
|
|
364
|
+
return w.slice(0, -2);
|
|
365
|
+
if (w.endsWith('s') && !w.endsWith('ss'))
|
|
366
|
+
return w.slice(0, -1);
|
|
367
|
+
return w;
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* For each ER relationship, the rows its ends attach to: the foreign key on one
|
|
371
|
+
* table that names the other ("customer_id" -> customers), and that table's
|
|
372
|
+
* primary key. Relationships with no matching key attach to the table as a whole.
|
|
373
|
+
*/
|
|
374
|
+
export function keyRows(d) {
|
|
375
|
+
const out = new Map();
|
|
376
|
+
const byId = new Map(d.nodes.map((n) => [n.id, n]));
|
|
377
|
+
const pk = (id) => {
|
|
378
|
+
const i = byId.get(id)?.fields?.findIndex((f) => f.flags.includes('pk')) ?? -1;
|
|
379
|
+
return i >= 0 ? i : undefined;
|
|
380
|
+
};
|
|
381
|
+
const fkTo = (child, parent) => {
|
|
382
|
+
const p = byId.get(parent);
|
|
383
|
+
const names = [...new Set([p.id, p.label].map((x) => singular(x.replace(/[\s-]+/g, '_'))))];
|
|
384
|
+
const fields = byId.get(child)?.fields ?? [];
|
|
385
|
+
const score = (name) => {
|
|
386
|
+
const f = name.toLowerCase();
|
|
387
|
+
for (const n of names) {
|
|
388
|
+
if (f === `${n}_id` || f === `${n}id`)
|
|
389
|
+
return 3;
|
|
390
|
+
if (f.endsWith(`_${n}_id`) || f.startsWith(`${n}_`))
|
|
391
|
+
return 2;
|
|
392
|
+
if (f.includes(n))
|
|
393
|
+
return 1;
|
|
394
|
+
}
|
|
395
|
+
return 0;
|
|
396
|
+
};
|
|
397
|
+
let best;
|
|
398
|
+
fields.forEach((f, i) => {
|
|
399
|
+
if (!f.flags.includes('fk'))
|
|
400
|
+
return;
|
|
401
|
+
const s = score(f.name);
|
|
402
|
+
if (s && (!best || s > best.s))
|
|
403
|
+
best = { i, s };
|
|
404
|
+
});
|
|
405
|
+
return best?.i;
|
|
406
|
+
};
|
|
407
|
+
for (const e of d.edges) {
|
|
408
|
+
if (e.from === e.to)
|
|
409
|
+
continue;
|
|
410
|
+
const a = fkTo(e.from, e.to);
|
|
411
|
+
if (a !== undefined) {
|
|
412
|
+
out.set(e.id, { from: a, to: pk(e.to) });
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
const b = fkTo(e.to, e.from);
|
|
416
|
+
if (b !== undefined)
|
|
417
|
+
out.set(e.id, { from: pk(e.from), to: b });
|
|
418
|
+
}
|
|
419
|
+
return out;
|
|
420
|
+
}
|