@itsthecsguy/linework 0.1.1 → 0.1.3
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 +46 -3
- package/dist/drawio/theme.js +79 -52
- 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/spec/model.js +3 -1
- package/package.json +1 -1
- package/skills/linework/SKILL.md +9 -2
- 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/spec.md +3 -3
- package/skills/linework/references/style-guide.md +9 -1
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
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { kindDef } from '../spec/model.js';
|
|
2
|
+
/** Map the layout so the flow runs top to bottom, do the work, map back. */
|
|
3
|
+
function frame(dir) {
|
|
4
|
+
const to = (p) => (dir === 'TB' ? p : dir === 'BT' ? { x: p.x, y: -p.y } : dir === 'LR' ? { x: p.y, y: p.x } : { x: p.y, y: -p.x });
|
|
5
|
+
const from = (p) => (dir === 'TB' ? p : dir === 'BT' ? { x: p.x, y: -p.y } : dir === 'LR' ? { x: p.y, y: p.x } : { x: -p.y, y: p.x });
|
|
6
|
+
const box = (b, f) => {
|
|
7
|
+
const a = f({ x: b.x, y: b.y }), c = f({ x: b.x + b.w, y: b.y + b.h });
|
|
8
|
+
return { x: Math.min(a.x, c.x), y: Math.min(a.y, c.y), w: Math.abs(c.x - a.x), h: Math.abs(c.y - a.y) };
|
|
9
|
+
};
|
|
10
|
+
return { to, from, boxTo: (b) => box(b, to), boxFrom: (b) => box(b, from) };
|
|
11
|
+
}
|
|
12
|
+
const near = (a, b, tol = 0.5) => Math.abs(a - b) <= tol;
|
|
13
|
+
const vert = (a, b) => near(a.x, b.x);
|
|
14
|
+
const horiz = (a, b) => near(a.y, b.y);
|
|
15
|
+
/** Drop repeated points and points in the middle of a straight run. */
|
|
16
|
+
export function simplify(pts) {
|
|
17
|
+
const out = [];
|
|
18
|
+
for (const p of pts) {
|
|
19
|
+
const last = out[out.length - 1];
|
|
20
|
+
if (last && near(last.x, p.x) && near(last.y, p.y))
|
|
21
|
+
continue;
|
|
22
|
+
const prev = out[out.length - 2];
|
|
23
|
+
if (prev && last && ((vert(prev, last) && vert(last, p)) || (horiz(prev, last) && horiz(last, p))))
|
|
24
|
+
out[out.length - 1] = p;
|
|
25
|
+
else
|
|
26
|
+
out.push(p);
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
function segHits(a, b, r, pad = 0) {
|
|
31
|
+
const x0 = Math.min(a.x, b.x), x1 = Math.max(a.x, b.x), y0 = Math.min(a.y, b.y), y1 = Math.max(a.y, b.y);
|
|
32
|
+
return x1 > r.x - pad && x0 < r.x + r.w + pad && y1 > r.y - pad && y0 < r.y + r.h + pad;
|
|
33
|
+
}
|
|
34
|
+
export function polish(d, l, inp) {
|
|
35
|
+
const f = frame(l.direction);
|
|
36
|
+
const shapeOf = (id) => { const n = d.nodes.find((x) => x.id === id); return n ? kindDef(d.type, n.kind).shape : undefined; };
|
|
37
|
+
const nodes = new Map([...l.nodes].map(([k, b]) => [k, f.boxTo(b)]));
|
|
38
|
+
// Corners are rounded; an edge leaving inside the arc looks detached.
|
|
39
|
+
const R = 8;
|
|
40
|
+
const edges = [];
|
|
41
|
+
for (const e of d.edges) {
|
|
42
|
+
const le = l.edges.get(e.id);
|
|
43
|
+
if (le)
|
|
44
|
+
edges.push({ id: e.id, from: e.from, to: e.to, pts: le.points.map(f.to), label: !!le.label });
|
|
45
|
+
}
|
|
46
|
+
const others = (self) => edges.filter((x) => x !== self);
|
|
47
|
+
const usedAt = (id, x, skip, side) => others(skip).some((o) => {
|
|
48
|
+
const p = o.from === id ? o.pts[0] : o.to === id ? o.pts[o.pts.length - 1] : undefined;
|
|
49
|
+
const b = nodes.get(id);
|
|
50
|
+
return p && near(p.y, side === 'top' ? b.y : b.y + b.h, 1) && Math.abs(p.x - x) < 10;
|
|
51
|
+
});
|
|
52
|
+
// A vertical run no other edge already runs along.
|
|
53
|
+
const free = (a, b, self) => others(self).every((o) => o.pts.slice(1).every((q, i) => {
|
|
54
|
+
const p = o.pts[i];
|
|
55
|
+
// Two labelled runs side by side need room for the labels between them.
|
|
56
|
+
if (!vert(p, q) || Math.abs(p.x - a.x) > (self.label || o.label ? 44 : 8))
|
|
57
|
+
return true;
|
|
58
|
+
return Math.min(Math.max(p.y, q.y), Math.max(a.y, b.y)) - Math.max(Math.min(p.y, q.y), Math.min(a.y, b.y)) <= 0;
|
|
59
|
+
}));
|
|
60
|
+
const clear = (a, b, except) => [...nodes].every(([k, r]) => except.includes(k) || !segHits(a, b, r, 6));
|
|
61
|
+
// Edges out of a decision wait until the decision pass below has picked the
|
|
62
|
+
// point they leave from; their start stub isn't a route to straighten toward.
|
|
63
|
+
// Two passes: a jog can be blocked by another edge's stub that the first pass removes.
|
|
64
|
+
const dejog = (fromDecisions) => {
|
|
65
|
+
for (let pass = 0; pass < 2; pass++)
|
|
66
|
+
for (const e of edges) {
|
|
67
|
+
// Tables keep their edges on the key rows.
|
|
68
|
+
if (e.from === e.to || shapeOf(e.from) === 'table' || shapeOf(e.to) === 'table')
|
|
69
|
+
continue;
|
|
70
|
+
if ((shapeOf(e.from) === 'diamond') !== fromDecisions)
|
|
71
|
+
continue;
|
|
72
|
+
const s = nodes.get(e.from), t = nodes.get(e.to);
|
|
73
|
+
let p = simplify(e.pts);
|
|
74
|
+
// Start: p0 on the box, a short leg, a jog sideways, then the real run.
|
|
75
|
+
for (let guard = 0; guard < 2 && p.length >= 4; guard++) {
|
|
76
|
+
const [p0, p1, p2] = [p[0], p[1], p[2]];
|
|
77
|
+
const side = near(p0.y, s.y + s.h, 1) ? 'bottom' : near(p0.y, s.y, 1) ? 'top' : undefined;
|
|
78
|
+
if (!side || !vert(p0, p1) || !horiz(p1, p2) || shapeOf(e.from) === 'diamond')
|
|
79
|
+
break;
|
|
80
|
+
const p3 = p[3], np = { x: p2.x, y: p0.y };
|
|
81
|
+
// Only a short leg is a jog; a long one is the route itself.
|
|
82
|
+
// The run p2-p3 is already there; only the piece added at the box is new.
|
|
83
|
+
if (!vert(p2, p3) || !free(np, p2, e))
|
|
84
|
+
break;
|
|
85
|
+
if (p2.x < s.x + R || p2.x > s.x + s.w - R || usedAt(e.from, p2.x, e, side) || !clear(np, p3, [e.from, e.to]))
|
|
86
|
+
break;
|
|
87
|
+
p = simplify([np, ...p.slice(3)]);
|
|
88
|
+
}
|
|
89
|
+
// End: the mirror image.
|
|
90
|
+
for (let guard = 0; guard < 2 && p.length >= 4; guard++) {
|
|
91
|
+
const n = p.length;
|
|
92
|
+
const [q0, q1, q2] = [p[n - 1], p[n - 2], p[n - 3]];
|
|
93
|
+
const side = near(q0.y, t.y, 1) ? 'top' : near(q0.y, t.y + t.h, 1) ? 'bottom' : undefined;
|
|
94
|
+
if (!side || !vert(q0, q1) || !horiz(q1, q2) || shapeOf(e.to) === 'diamond')
|
|
95
|
+
break;
|
|
96
|
+
const q3 = p[n - 4], nq = { x: q2.x, y: q0.y };
|
|
97
|
+
if (!vert(q3, q2) || !free(q2, nq, e))
|
|
98
|
+
break;
|
|
99
|
+
if (q2.x < t.x + R || q2.x > t.x + t.w - R || usedAt(e.to, q2.x, e, side) || !clear(q3, nq, [e.from, e.to]))
|
|
100
|
+
break;
|
|
101
|
+
p = simplify([...p.slice(0, n - 3), nq]);
|
|
102
|
+
}
|
|
103
|
+
e.pts = p;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
dejog(false);
|
|
107
|
+
// A Z between boxes that overlap side to side (down, across, down) can be one
|
|
108
|
+
// straight drop through the overlap.
|
|
109
|
+
for (const e of edges) {
|
|
110
|
+
if (e.from === e.to || [e.from, e.to].some((id) => ['table', 'diamond'].includes(shapeOf(id) ?? '')))
|
|
111
|
+
continue;
|
|
112
|
+
const p = simplify(e.pts);
|
|
113
|
+
if (p.length !== 4)
|
|
114
|
+
continue;
|
|
115
|
+
const s = nodes.get(e.from), t = nodes.get(e.to);
|
|
116
|
+
const [a, b, c, z] = p;
|
|
117
|
+
if (!vert(a, b) || !horiz(b, c) || !vert(c, z) || !near(a.y, s.y + s.h, 1) || !near(z.y, t.y, 1))
|
|
118
|
+
continue;
|
|
119
|
+
const lo = Math.max(s.x, t.x) + R, hi = Math.min(s.x + s.w, t.x + t.w) - R;
|
|
120
|
+
if (hi < lo)
|
|
121
|
+
continue;
|
|
122
|
+
const x = Math.round(Math.min(hi, Math.max(lo, (a.x + z.x) / 2)));
|
|
123
|
+
const top = { x, y: a.y }, bot = { x, y: z.y };
|
|
124
|
+
if (usedAt(e.from, x, e, 'bottom') || usedAt(e.to, x, e, 'top') || !clear(top, bot, [e.from, e.to]) || !free(top, bot, e))
|
|
125
|
+
continue;
|
|
126
|
+
e.pts = [top, bot];
|
|
127
|
+
const le = l.edges.get(e.id);
|
|
128
|
+
if (le.label) {
|
|
129
|
+
const lb = f.boxTo(le.label);
|
|
130
|
+
le.label = f.boxFrom({ ...lb, x: x - lb.w / 2, y: (top.y + bot.y) / 2 - lb.h / 2 });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// Decisions: leave from the diamond's bottom or side points, enter at the top point.
|
|
134
|
+
for (const n of d.nodes) {
|
|
135
|
+
if (kindDef(d.type, n.kind).shape !== 'diamond')
|
|
136
|
+
continue;
|
|
137
|
+
const b = nodes.get(n.id);
|
|
138
|
+
const cx = b.x + b.w / 2, cy = b.y + b.h / 2;
|
|
139
|
+
const outs = edges.filter((e) => e.from === n.id && e.to !== n.id);
|
|
140
|
+
// The run each edge settles into once it is clear of the diamond.
|
|
141
|
+
const runX = (e) => {
|
|
142
|
+
const p = e.pts;
|
|
143
|
+
for (let i = 1; i < p.length - 1; i++)
|
|
144
|
+
if (vert(p[i], p[i + 1]) && p[i + 1].y > b.y + b.h + 1)
|
|
145
|
+
return p[i].x;
|
|
146
|
+
return p[p.length - 1].x;
|
|
147
|
+
};
|
|
148
|
+
const tail = (e) => {
|
|
149
|
+
// Points from the start of that run on.
|
|
150
|
+
const p = e.pts;
|
|
151
|
+
for (let i = 1; i < p.length - 1; i++)
|
|
152
|
+
if (vert(p[i], p[i + 1]) && p[i + 1].y > b.y + b.h + 1)
|
|
153
|
+
return p.slice(i);
|
|
154
|
+
return p.slice(-1);
|
|
155
|
+
};
|
|
156
|
+
const sorted = [...outs].sort((a, c) => runX(a) - runX(c));
|
|
157
|
+
let bottomTaken = false;
|
|
158
|
+
// The edge closest to straight down gets the bottom point.
|
|
159
|
+
const straight = sorted.reduce((m, e) => (!m || Math.abs(runX(e) - cx) < Math.abs(runX(m) - cx) ? e : m), undefined);
|
|
160
|
+
for (const e of sorted) {
|
|
161
|
+
const x = runX(e), rest = tail(e);
|
|
162
|
+
const bottom = { x: cx, y: b.y + b.h };
|
|
163
|
+
if (e === straight && Math.abs(x - cx) < 12) {
|
|
164
|
+
e.pts = simplify([bottom, { x: cx, y: rest[0].y }, ...rest.slice(1)].map((pt, i) => (i === 1 ? pt : pt)));
|
|
165
|
+
if (rest.length && !near(rest[0].x, cx))
|
|
166
|
+
e.pts = simplify([bottom, { x: cx, y: rest[0].y }, ...rest]);
|
|
167
|
+
bottomTaken = true;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const left = x < cx;
|
|
171
|
+
const sideP = { x: left ? b.x : b.x + b.w, y: cy };
|
|
172
|
+
const corner = { x, y: cy };
|
|
173
|
+
const sideOk = (left ? x < b.x - 10 : x > b.x + b.w + 10) && clear(sideP, corner, [n.id]) && clear(corner, rest[0], [n.id, e.to]);
|
|
174
|
+
if (sideOk) {
|
|
175
|
+
e.pts = simplify([sideP, corner, ...rest]);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
// Branches that both head down fork from the bottom point on a short stem.
|
|
179
|
+
const y = Math.min(rest[0].y, b.y + b.h + 16);
|
|
180
|
+
e.pts = simplify([bottom, { x: cx, y }, { x, y }, ...rest]);
|
|
181
|
+
bottomTaken = true;
|
|
182
|
+
}
|
|
183
|
+
for (const e of edges.filter((x) => x.to === n.id && x.from !== n.id)) {
|
|
184
|
+
const p = e.pts, m = p.length;
|
|
185
|
+
const last = p[m - 1], prev = p[m - 2];
|
|
186
|
+
if (!prev || !vert(prev, last) || prev.y > last.y || near(last.x, cx)) {
|
|
187
|
+
if (prev && vert(prev, last) && prev.y <= last.y)
|
|
188
|
+
e.pts[m - 1] = { x: cx, y: b.y };
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const y = Math.max(prev.y, b.y - 16);
|
|
192
|
+
e.pts = simplify([...p.slice(0, m - 1), { x: last.x, y }, { x: cx, y }, { x: cx, y: b.y }]);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
dejog(true);
|
|
196
|
+
// Self-loops: wide enough to read as a loop, label outside it.
|
|
197
|
+
for (const e of edges) {
|
|
198
|
+
if (e.from !== e.to)
|
|
199
|
+
continue;
|
|
200
|
+
const b = nodes.get(e.from), le = l.edges.get(e.id);
|
|
201
|
+
const ys = [b.y + b.h * 0.28, b.y + b.h * 0.72];
|
|
202
|
+
const pts = e.pts;
|
|
203
|
+
const leftSide = pts.some((p) => p.x < b.x - 1);
|
|
204
|
+
const xo = leftSide ? b.x - 24 : b.x + b.w + 24;
|
|
205
|
+
const edgeX = leftSide ? b.x : b.x + b.w;
|
|
206
|
+
e.pts = [{ x: edgeX, y: ys[0] }, { x: xo, y: ys[0] }, { x: xo, y: ys[1] }, { x: edgeX, y: ys[1] }];
|
|
207
|
+
if (le.label) {
|
|
208
|
+
const lb = f.boxTo(le.label);
|
|
209
|
+
le.label = f.boxFrom({ ...lb, x: leftSide ? xo - 6 - lb.w : xo + 6, y: b.y + b.h / 2 - lb.h / 2 });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
for (const e of edges) {
|
|
213
|
+
const le = l.edges.get(e.id);
|
|
214
|
+
le.points = e.pts.map(f.from);
|
|
215
|
+
}
|
|
216
|
+
// A one-column group is only as wide as its node, so an edge dropping into the
|
|
217
|
+
// node's middle crosses the title. ELK ignores a minimum size on groups it lays
|
|
218
|
+
// out with their children, so widen them here: evenly to both sides, when the
|
|
219
|
+
// added strips hold nothing else.
|
|
220
|
+
const owner = new Map([...d.groups.map((g) => [g.id, g.parent]), ...d.nodes.map((n) => [n.id, n.parent])]);
|
|
221
|
+
const within = (id, gid) => !!id && (id === gid || within(owner.get(id), gid));
|
|
222
|
+
const depth = (gid) => { let k = 0; for (let p = owner.get(gid); p; p = owner.get(p))
|
|
223
|
+
k++; return k; };
|
|
224
|
+
for (const gid of [...l.groups.keys()].sort((a, b) => depth(b) - depth(a))) {
|
|
225
|
+
const g = l.groups.get(gid), tt = inp.groupTitle.get(gid);
|
|
226
|
+
if (!tt)
|
|
227
|
+
continue;
|
|
228
|
+
const half = Math.ceil((2 * (tt.w + inp.titleInset + 10) - g.w) / 2);
|
|
229
|
+
if (half <= 0)
|
|
230
|
+
continue;
|
|
231
|
+
const strips = [{ x: g.x - half, y: g.y, w: half, h: g.h }, { x: g.x + g.w, y: g.y, w: half, h: g.h }];
|
|
232
|
+
const hits = (r) => strips.some((s) => r.x < s.x + s.w + 8 && r.x + r.w > s.x - 8 && r.y < s.y + s.h && r.y + r.h > s.y);
|
|
233
|
+
const parent = owner.get(gid) ? l.groups.get(owner.get(gid)) : undefined;
|
|
234
|
+
const fits = (!parent || (g.x - half >= parent.x + 8 && g.x + g.w + half <= parent.x + parent.w - 8)) &&
|
|
235
|
+
[...l.nodes].every(([id, b]) => within(id, gid) || !hits(b)) &&
|
|
236
|
+
[...l.groups].every(([id, b]) => id === gid || within(id, gid) || within(gid, id) || !hits(b)) &&
|
|
237
|
+
[...l.edges.values()].every((le) => le.points.slice(1).every((q, i) => strips.every((s) => !segHits(le.points[i], q, s))));
|
|
238
|
+
if (fits)
|
|
239
|
+
l.groups.set(gid, { ...g, x: g.x - half, w: g.w + 2 * half });
|
|
240
|
+
}
|
|
241
|
+
// Keep the drawing clear of the canvas edge after widening.
|
|
242
|
+
const left = Math.min(...[...l.groups.values(), ...l.nodes.values()].map((b) => b.x));
|
|
243
|
+
if (left < 16) {
|
|
244
|
+
const dx = 16 - left;
|
|
245
|
+
const mv = (b) => ({ ...b, x: b.x + dx });
|
|
246
|
+
for (const m of [l.nodes, l.groups])
|
|
247
|
+
for (const [k, b] of m)
|
|
248
|
+
m.set(k, mv(b));
|
|
249
|
+
for (const le of l.edges.values()) {
|
|
250
|
+
le.points = le.points.map((p) => ({ ...p, x: p.x + dx }));
|
|
251
|
+
if (le.label)
|
|
252
|
+
le.label = mv(le.label);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
l.width = Math.max(l.width, ...[...l.groups.values(), ...l.nodes.values()].map((b) => b.x + b.w + 16));
|
|
256
|
+
// Group titles: slide a title right when an edge runs through it.
|
|
257
|
+
for (const [gid, g] of l.groups) {
|
|
258
|
+
const tt = inp.groupTitle.get(gid);
|
|
259
|
+
if (!tt)
|
|
260
|
+
continue;
|
|
261
|
+
const segs = [...l.edges.values()].flatMap((le) => le.points.slice(1).map((p, i) => [le.points[i], p]));
|
|
262
|
+
const blocked = (x) => {
|
|
263
|
+
const r = { x: g.x + x - 6, y: g.y + 4, w: tt.w + 12, h: Math.min(inp.groupTop, tt.h + 14) };
|
|
264
|
+
return segs.some(([a, b]) => segHits(a, b, r));
|
|
265
|
+
};
|
|
266
|
+
if (!blocked(inp.titleInset))
|
|
267
|
+
continue;
|
|
268
|
+
// The other top corner reads as a choice; a title halfway along reads as an accident.
|
|
269
|
+
const right = Math.floor(g.w - tt.w - inp.titleInset);
|
|
270
|
+
if (right > inp.titleInset && !blocked(right)) {
|
|
271
|
+
(l.titleInset ??= new Map()).set(gid, right);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
for (let x = inp.titleInset + 8; x + tt.w + 14 <= g.w; x += 8) {
|
|
275
|
+
if (!blocked(x)) {
|
|
276
|
+
(l.titleInset ??= new Map()).set(gid, x);
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
package/dist/layout/sequence.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { fontsFor } from './layout.js';
|
|
2
2
|
import { TextMeasurer } from './text.js';
|
|
3
|
-
import { LINE, PAD_X, PAD_Y, activationStyle, frameStyle, guardStyle, lifelineStyle, messageStyle, nodeLabel, noteStyle, palette, separatorStyle } from '../drawio/theme.js';
|
|
3
|
+
import { FRAME_TAB_INSET, FRAME_TAB_X, FRAME_TAB_Y, LINE, PAD_X, PAD_Y, activationStyle, frameStyle, guardStyle, lifelineStyle, messageStyle, nodeLabel, noteStyle, palette, separatorStyle } from '../drawio/theme.js';
|
|
4
4
|
import { autoAttr, diagramMeta, lookAttrs } from '../drawio/write.js';
|
|
5
5
|
const TOP = 24, BAR_W = 10, NOTE_W = 220;
|
|
6
6
|
/**
|
|
@@ -230,12 +230,15 @@ export async function sequenceScene(d, measure, opts = {}) {
|
|
|
230
230
|
const fx2 = cx(parts[hi].id) + Math.max(80, headW[hi] / 2 + 16) - inset;
|
|
231
231
|
const fy = rows[f.start].y - 4, fy2 = rows[f.end].y;
|
|
232
232
|
vertices.push({ id: f.id, parent: '1', attrs: { label: f.op, aiviz_type: 'frame', aiviz_frame: f.op }, style: frameStyle(theme), box: { x: fx, y: fy, w: fx2 - fx, h: fy2 - fy } });
|
|
233
|
+
// The first guard shares a line with the operator tab beside it.
|
|
234
|
+
const tabMid = fy + FRAME_TAB_INSET + FRAME_TAB_Y + (fonts.group.size * LINE) / 2;
|
|
235
|
+
const guardH = Math.max(22, edgeLine + 6);
|
|
233
236
|
const guard = (when, gy, k) => {
|
|
234
237
|
if (!when)
|
|
235
238
|
return;
|
|
236
239
|
// Start right of the operator label, then step past any lifeline the text would cover.
|
|
237
240
|
const gw = Math.max(80, Math.ceil(tm.width(`[${when}]`, fonts.edge)) + 16);
|
|
238
|
-
let gx = fx +
|
|
241
|
+
let gx = fx + FRAME_TAB_INSET + 2 * FRAME_TAB_X + Math.ceil(tm.width(f.op, fonts.group)) + 10;
|
|
239
242
|
for (const c of [...parts.map((p) => cx(p.id))].sort((a, b) => a - b))
|
|
240
243
|
if (c + BAR_W / 2 + 4 > gx && c - BAR_W / 2 - 4 < gx + gw)
|
|
241
244
|
gx = c + BAR_W / 2 + 6;
|
|
@@ -244,10 +247,10 @@ export async function sequenceScene(d, measure, opts = {}) {
|
|
|
244
247
|
parent: '1',
|
|
245
248
|
attrs: { label: `[${when}]`, aiviz_type: 'guard', aiviz_guard: `${f.id}:${k}` },
|
|
246
249
|
style: guardStyle(theme),
|
|
247
|
-
box: { x: gx, y: gy, w: gw, h:
|
|
250
|
+
box: { x: gx, y: gy, w: gw, h: guardH },
|
|
248
251
|
});
|
|
249
252
|
};
|
|
250
|
-
guard(f.whens[0],
|
|
253
|
+
guard(f.whens[0], Math.round(tabMid - guardH / 2), 0);
|
|
251
254
|
f.seps.forEach((s, k) => {
|
|
252
255
|
const sy = rows[s].y + 4;
|
|
253
256
|
edges.push({
|
package/dist/lint/check.js
CHANGED
|
@@ -158,7 +158,12 @@ export function check(cells) {
|
|
|
158
158
|
if (segsCross(a[s], a[s + 1], b[t], b[t + 1]))
|
|
159
159
|
crossings++;
|
|
160
160
|
}
|
|
161
|
-
|
|
161
|
+
// A table's field rows are parts of one node, not nodes of their own.
|
|
162
|
+
const isRow = (n) => {
|
|
163
|
+
const p = n.parent ? byId.get(n.parent) : undefined;
|
|
164
|
+
return !!p && p.shape === 'swimlane';
|
|
165
|
+
};
|
|
166
|
+
const real = nodes.filter((n) => (n.box.w > 30 || n.box.h > 30) && !isRow(n));
|
|
162
167
|
if (real.length > 20)
|
|
163
168
|
findings.push({ level: 'warning', rule: 'too-big', message: `${real.length} nodes; consider splitting into two diagrams` });
|
|
164
169
|
if (crossings > Math.max(3, edges.length / 3))
|
package/dist/spec/model.js
CHANGED
|
@@ -36,7 +36,9 @@ export const KINDS = {
|
|
|
36
36
|
person: { shape: 'box', color: 'pink', description: 'a human actor' },
|
|
37
37
|
client: { shape: 'box', color: 'blue', description: 'browser, mobile app, CLI' },
|
|
38
38
|
function: { shape: 'box', color: 'violet', dashed: true, description: 'serverless function or job' },
|
|
39
|
-
database: { shape: 'box', color: 'green', description: '
|
|
39
|
+
database: { shape: 'box', color: 'green', description: 'relational or document database' },
|
|
40
|
+
cache: { shape: 'box', color: 'green', description: 'Redis, Memcached, CDN cache' },
|
|
41
|
+
storage: { shape: 'box', color: 'green', description: 'object or file storage' },
|
|
40
42
|
queue: { shape: 'box', color: 'amber', description: 'queue, topic or event stream' },
|
|
41
43
|
external: { shape: 'box', color: 'gray', dashed: true, description: 'third-party system' },
|
|
42
44
|
},
|