@itsthecsguy/linework 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/NOTICE +11 -0
- package/README.md +206 -2
- package/assets/drawio/LICENSE +201 -0
- package/assets/drawio/VERSION +1 -0
- package/assets/drawio/favicon.ico +0 -0
- package/assets/drawio/images/1x1.png +0 -0
- package/assets/drawio/images/2x2.png +0 -0
- package/assets/drawio/images/3x3.png +0 -0
- package/assets/drawio/images/ajax-loader.gif +0 -0
- package/assets/drawio/images/android-chrome-512x512.png +0 -0
- package/assets/drawio/images/apple-touch-icon.png +0 -0
- package/assets/drawio/images/aui-wait.gif +0 -0
- package/assets/drawio/images/broken.png +0 -0
- package/assets/drawio/images/browserconfig.xml +9 -0
- package/assets/drawio/images/close.png +0 -0
- package/assets/drawio/images/cloud-connector-atlas.png +0 -0
- package/assets/drawio/images/collapsed.gif +0 -0
- package/assets/drawio/images/delete.png +0 -0
- package/assets/drawio/images/document-google-drive-icon-80.png +0 -0
- package/assets/drawio/images/draw-search.png +0 -0
- package/assets/drawio/images/drawio.svg +19 -0
- package/assets/drawio/images/drawlogo-color.svg +50 -0
- package/assets/drawio/images/drawlogo-gray.svg +18 -0
- package/assets/drawio/images/drawlogo-text-bottom.svg +75 -0
- package/assets/drawio/images/drawlogo.svg +18 -0
- package/assets/drawio/images/drawlogo128.png +0 -0
- package/assets/drawio/images/drawlogo144.png +0 -0
- package/assets/drawio/images/drawlogo256.png +0 -0
- package/assets/drawio/images/drawlogo48-gray.png +0 -0
- package/assets/drawio/images/drawlogo48.png +0 -0
- package/assets/drawio/images/drawlogo80.png +0 -0
- package/assets/drawio/images/droptarget.png +0 -0
- package/assets/drawio/images/edit.gif +0 -0
- package/assets/drawio/images/edit16.png +0 -0
- package/assets/drawio/images/edit32.png +0 -0
- package/assets/drawio/images/edit80.png +0 -0
- package/assets/drawio/images/embed-icon.png +0 -0
- package/assets/drawio/images/expanded.gif +0 -0
- package/assets/drawio/images/facebook.png +0 -0
- package/assets/drawio/images/favicon-16x16.png +0 -0
- package/assets/drawio/images/favicon-32x32.png +0 -0
- package/assets/drawio/images/file.png +0 -0
- package/assets/drawio/images/folder.png +0 -0
- package/assets/drawio/images/gadget-screenshot.png +0 -0
- package/assets/drawio/images/gadget-thumb.png +0 -0
- package/assets/drawio/images/github-logo-white.svg +6 -0
- package/assets/drawio/images/github-logo.svg +17 -0
- package/assets/drawio/images/gitlab-logo-white.svg +1 -0
- package/assets/drawio/images/gitlab-logo.svg +1 -0
- package/assets/drawio/images/glyphicons_blogger.png +0 -0
- package/assets/drawio/images/glyphicons_facebook.png +0 -0
- package/assets/drawio/images/glyphicons_github.png +0 -0
- package/assets/drawio/images/glyphicons_google.png +0 -0
- package/assets/drawio/images/glyphicons_star.png +0 -0
- package/assets/drawio/images/glyphicons_twitter.png +0 -0
- package/assets/drawio/images/glyphicons_youtube.png +0 -0
- package/assets/drawio/images/google-drive-connector-atlas.png +0 -0
- package/assets/drawio/images/google-drive-connector-atlas.svg +121 -0
- package/assets/drawio/images/google-drive-logo-white.svg +9 -0
- package/assets/drawio/images/google-drive-logo.svg +17 -0
- package/assets/drawio/images/google-share.png +0 -0
- package/assets/drawio/images/icon-192-maskable.png +0 -0
- package/assets/drawio/images/icon-192.png +0 -0
- package/assets/drawio/images/icon-512-maskable.png +0 -0
- package/assets/drawio/images/icon-512.png +0 -0
- package/assets/drawio/images/icon-search.svg +21 -0
- package/assets/drawio/images/img-hi-res.png +0 -0
- package/assets/drawio/images/img-lo-res.png +0 -0
- package/assets/drawio/images/list.svg +16 -0
- package/assets/drawio/images/locked.png +0 -0
- package/assets/drawio/images/log.png +0 -0
- package/assets/drawio/images/logo-confluence.png +0 -0
- package/assets/drawio/images/logo-flat-small.png +0 -0
- package/assets/drawio/images/logo-flat.png +0 -0
- package/assets/drawio/images/logo-jira.png +0 -0
- package/assets/drawio/images/logo-small.gif +0 -0
- package/assets/drawio/images/logo-white.png +0 -0
- package/assets/drawio/images/logo.png +0 -0
- package/assets/drawio/images/manifest.json +25 -0
- package/assets/drawio/images/move.png +0 -0
- package/assets/drawio/images/notion-logo-white.svg +1 -0
- package/assets/drawio/images/notion-logo.svg +1 -0
- package/assets/drawio/images/onedrive-connector-atlas.png +0 -0
- package/assets/drawio/images/onedrive-connector-atlas.svg +248 -0
- package/assets/drawio/images/onedrive-logo-white.svg +29 -0
- package/assets/drawio/images/onedrive-logo.svg +29 -0
- package/assets/drawio/images/osa_database.png +0 -0
- package/assets/drawio/images/osa_drive-harddisk.png +0 -0
- package/assets/drawio/images/plant-uml-atlas.png +0 -0
- package/assets/drawio/images/samepage-icon-color.svg +37 -0
- package/assets/drawio/images/save.png +0 -0
- package/assets/drawio/images/sidebar-android.png +0 -0
- package/assets/drawio/images/sidebar-archimate.png +0 -0
- package/assets/drawio/images/sidebar-arrows.png +0 -0
- package/assets/drawio/images/sidebar-arrows2.png +0 -0
- package/assets/drawio/images/sidebar-basic.png +0 -0
- package/assets/drawio/images/sidebar-c4.png +0 -0
- package/assets/drawio/images/sidebar-cabinets.png +0 -0
- package/assets/drawio/images/sidebar-cumulus.png +0 -0
- package/assets/drawio/images/sidebar-dfd.png +0 -0
- package/assets/drawio/images/sidebar-dynamics365.png +0 -0
- package/assets/drawio/images/sidebar-eip.png +0 -0
- package/assets/drawio/images/sidebar-er.png +0 -0
- package/assets/drawio/images/sidebar-floorplans.png +0 -0
- package/assets/drawio/images/sidebar-flowchart.png +0 -0
- package/assets/drawio/images/sidebar-gcp3.png +0 -0
- package/assets/drawio/images/sidebar-general.png +0 -0
- package/assets/drawio/images/sidebar-kubernetes.png +0 -0
- package/assets/drawio/images/sidebar-leanmapping.png +0 -0
- package/assets/drawio/images/sidebar-mockups.png +0 -0
- package/assets/drawio/images/sidebar-network.png +0 -0
- package/assets/drawio/images/sidebar-network2.png +0 -0
- package/assets/drawio/images/sidebar-openstack.png +0 -0
- package/assets/drawio/images/sidebar-sitemap.png +0 -0
- package/assets/drawio/images/sidebar-threatmodeling.png +0 -0
- package/assets/drawio/images/sidebar-uml.png +0 -0
- package/assets/drawio/images/sketch-logo128.png +0 -0
- package/assets/drawio/images/spin.gif +0 -0
- package/assets/drawio/images/sprites.png +0 -0
- package/assets/drawio/images/stop-flat-icon-80.png +0 -0
- package/assets/drawio/images/submenu.gif +0 -0
- package/assets/drawio/images/touch-submenu.png +0 -0
- package/assets/drawio/images/transparent.gif +0 -0
- package/assets/drawio/images/trello-logo-white-orange.svg +6 -0
- package/assets/drawio/images/trello-logo-white.svg +6 -0
- package/assets/drawio/images/trello-logo.svg +1 -0
- package/assets/drawio/images/tweet.png +0 -0
- package/assets/drawio/images/update16.png +0 -0
- package/assets/drawio/images/update32.png +0 -0
- package/assets/drawio/images/update80.png +0 -0
- package/assets/drawio/images/youtube.png +0 -0
- package/assets/drawio/img/clipart/Battery_0_128x128.png +0 -0
- package/assets/drawio/img/clipart/Battery_100_128x128.png +0 -0
- package/assets/drawio/img/clipart/Battery_50_128x128.png +0 -0
- package/assets/drawio/img/clipart/Battery_75_128x128.png +0 -0
- package/assets/drawio/img/clipart/Battery_allstates_128x128.png +0 -0
- package/assets/drawio/img/clipart/Bluetooth_128x128.png +0 -0
- package/assets/drawio/img/clipart/Earth_globe_128x128.png +0 -0
- package/assets/drawio/img/clipart/Empty_Folder_128x128.png +0 -0
- package/assets/drawio/img/clipart/Full_Folder_128x128.png +0 -0
- package/assets/drawio/img/clipart/Gear_128x128.png +0 -0
- package/assets/drawio/img/clipart/Keys_128x128.png +0 -0
- package/assets/drawio/img/clipart/Lock_128x128.png +0 -0
- package/assets/drawio/img/clipart/Mouse_Pointer_128x128.png +0 -0
- package/assets/drawio/img/clipart/Plug_128x128.png +0 -0
- package/assets/drawio/img/clipart/Ships_Wheel_128x128.png +0 -0
- package/assets/drawio/img/clipart/Star_128x128.png +0 -0
- package/assets/drawio/img/clipart/Tire_128x128.png +0 -0
- package/assets/drawio/index.html +92 -0
- package/assets/drawio/js/PostConfig.js +7 -0
- package/assets/drawio/js/PreConfig.js +13 -0
- package/assets/drawio/js/app.min.js +18466 -0
- package/assets/drawio/js/bootstrap.js +428 -0
- package/assets/drawio/js/diagramly/LibavoidRouting.js +4459 -0
- package/assets/drawio/js/export-init.js +21 -0
- package/assets/drawio/js/extensions.min.js +10478 -0
- package/assets/drawio/js/libavoid-js/LICENSE +505 -0
- package/assets/drawio/js/libavoid-js/libavoid-routing.js +1633 -0
- package/assets/drawio/js/libavoid-js/libavoid.min.js +59 -0
- package/assets/drawio/js/main.js +22 -0
- package/assets/drawio/js/rough/rough.min.js +1 -0
- package/assets/drawio/js/shapes-14-6-5.min.js +3457 -0
- package/assets/drawio/js/stencils.min.js +447 -0
- package/assets/drawio/mxgraph/css/common.css +147 -0
- package/assets/drawio/resources/dia.txt +2901 -0
- package/assets/drawio/styles/dark-default.xml +109 -0
- package/assets/drawio/styles/default-old.xml +109 -0
- package/assets/drawio/styles/default.xml +203 -0
- package/assets/drawio/styles/fonts/ArchitectsDaughter-Regular.ttf +0 -0
- package/assets/drawio/styles/grapheditor.css +4501 -0
- package/assets/drawio/styles/high-contrast.css +128 -0
- package/assets/drawio/styles/thumb_horz.png +0 -0
- package/assets/drawio/styles/thumb_vertical.png +0 -0
- package/assets/fonts/ibm-plex-mono-LICENSE.txt +93 -0
- package/assets/fonts/ibm-plex-sans-LICENSE.txt +93 -0
- package/assets/fonts/kalam-400.woff2 +0 -0
- package/assets/fonts/kalam-700.woff2 +0 -0
- package/assets/fonts/kalam-LICENSE.txt +93 -0
- package/assets/fonts/plex-mono-400.woff2 +0 -0
- package/assets/fonts/plex-mono-600.woff2 +0 -0
- package/assets/fonts/plex-sans-400.woff2 +0 -0
- package/assets/fonts/plex-sans-600.woff2 +0 -0
- package/dist/api.js +218 -0
- package/dist/cli.js +275 -0
- package/dist/drawio/icons.js +121 -0
- package/dist/drawio/merge.js +309 -0
- package/dist/drawio/read.js +180 -0
- package/dist/drawio/show.js +501 -0
- package/dist/drawio/theme.js +425 -0
- package/dist/drawio/write.js +300 -0
- package/dist/edit/server.js +212 -0
- package/dist/git.js +46 -0
- package/dist/import/mermaid.js +569 -0
- package/dist/layout/layout.js +212 -0
- package/dist/layout/sequence.js +333 -0
- package/dist/layout/text.js +73 -0
- package/dist/lint/check.js +188 -0
- package/dist/lint/fixlabels.js +128 -0
- package/dist/mcp/server.js +239 -0
- package/dist/publish.js +121 -0
- package/dist/render/browser.js +236 -0
- package/dist/render/fonts.js +66 -0
- package/dist/report.js +36 -0
- package/dist/spec/model.js +53 -0
- package/dist/spec/parse.js +497 -0
- package/package.json +71 -4
- package/skills/linework/SKILL.md +233 -0
- package/skills/linework/examples/billing-er.yaml +31 -0
- package/skills/linework/examples/checkout.yaml +28 -0
- package/skills/linework/examples/pay-sequence.yaml +28 -0
- package/skills/linework/examples/payment-state.yaml +22 -0
- package/skills/linework/examples/refund-flow.yaml +29 -0
- package/skills/linework/examples/serverless-aws.yaml +23 -0
- package/skills/linework/references/google-docs.md +46 -0
- package/skills/linework/references/spec.md +312 -0
- package/skills/linework/references/style-guide.md +53 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// Diagram -> absolute geometry, via ELK layered.
|
|
2
|
+
import ElkModule from 'elkjs/lib/elk.bundled.js';
|
|
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';
|
|
5
|
+
import { TextMeasurer } from './text.js';
|
|
6
|
+
import { ICON_PAD } from '../drawio/icons.js';
|
|
7
|
+
/** Crude fallback when no browser is available (tests). */
|
|
8
|
+
export const estimateMeasure = async (reqs) => reqs.map(([t, size, weight]) => Math.ceil(t.length * size * (weight >= 600 ? 0.6 : 0.55)));
|
|
9
|
+
// elkjs is CommonJS; under NodeNext the constructor sits on .default.
|
|
10
|
+
const ElkCtor = (ElkModule.default ?? ElkModule);
|
|
11
|
+
const elk = new ElkCtor();
|
|
12
|
+
const snap = (v, g = 8) => Math.ceil(v / g) * g;
|
|
13
|
+
const ELK_DIR = { TB: 'DOWN', LR: 'RIGHT', BT: 'UP', RL: 'LEFT' };
|
|
14
|
+
export function fontsFor(theme) {
|
|
15
|
+
const t = typeface(theme);
|
|
16
|
+
const f = (size, weight = 400, tracking) => ({ size, weight, family: t.family, tracking });
|
|
17
|
+
return {
|
|
18
|
+
label: f(t.size.label, t.strong),
|
|
19
|
+
sub: f(t.size.sub),
|
|
20
|
+
edge: f(t.size.edge),
|
|
21
|
+
group: f(t.size.group, 700, t.upperGroups ? t.size.group * 0.06 : 0),
|
|
22
|
+
field: f(t.size.field),
|
|
23
|
+
fieldKey: f(t.size.field, t.strong),
|
|
24
|
+
line: Math.round(t.size.label * LINE * 10) / 10,
|
|
25
|
+
maxText: Math.round(t.size.label * 13),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export async function layout(d, measure = estimateMeasure) {
|
|
29
|
+
const fonts = fontsFor(d.theme);
|
|
30
|
+
const tm = new TextMeasurer(measure);
|
|
31
|
+
await tm.prepare([
|
|
32
|
+
...d.nodes.flatMap((n) => [
|
|
33
|
+
[n.label, kindDef(d.type, n.kind).shape === 'table' ? fonts.label : fonts.label],
|
|
34
|
+
[n.tech, fonts.sub],
|
|
35
|
+
...(n.fields ?? []).flatMap((fl) => [
|
|
36
|
+
[fl.name, fl.flags.includes('pk') ? fonts.fieldKey : fonts.field],
|
|
37
|
+
[fieldRight(fl), fonts.field],
|
|
38
|
+
]),
|
|
39
|
+
]),
|
|
40
|
+
...d.edges.map((e) => [e.label, fonts.edge]),
|
|
41
|
+
...d.groups.map((g) => [groupText(g.label, d.theme), fonts.group]),
|
|
42
|
+
]);
|
|
43
|
+
const { sizes, natural } = nodeSizes(d, tm, fonts);
|
|
44
|
+
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
|
+
const groupMin = new Map(d.groups.map((g) => [g.id, Math.ceil(tm.width(groupText(g.label, d.theme), fonts.group)) + 40]));
|
|
46
|
+
const groupTop = Math.round(8 + fonts.group.size * LINE + 20);
|
|
47
|
+
const go = (dir) => run(d, dir, sizes, labels, groupMin, groupTop);
|
|
48
|
+
if (d.direction !== 'auto')
|
|
49
|
+
return { ...(await go(d.direction)), natural };
|
|
50
|
+
// Pick the direction whose result fits a document page best.
|
|
51
|
+
const [tb, lr] = await Promise.all([go('TB'), go('LR')]);
|
|
52
|
+
return { ...(score(tb) <= score(lr) ? tb : lr), natural };
|
|
53
|
+
}
|
|
54
|
+
const groupText = (label, theme) => (typeface(theme).upperGroups ? label.toUpperCase() : label);
|
|
55
|
+
const fieldRight = (f) => {
|
|
56
|
+
const keys = f.flags.filter((x) => x === 'pk' || x === 'fk').map((x) => x.toUpperCase());
|
|
57
|
+
return [f.type, ...f.flags.filter((x) => x !== 'pk' && x !== 'fk'), ...keys].filter(Boolean).join(' ');
|
|
58
|
+
};
|
|
59
|
+
function score(l) {
|
|
60
|
+
const ratio = l.width / Math.max(1, l.height);
|
|
61
|
+
return Math.abs(Math.log(ratio / 1.4)) + Math.max(0, l.width - 1200) / 400;
|
|
62
|
+
}
|
|
63
|
+
/** Extra width a shape needs around its text, beyond PAD_X on each side. */
|
|
64
|
+
function shapeExtra(shape, icon) {
|
|
65
|
+
return (shape === 'parallelogram' ? 28 : shape === 'pill' ? 16 : 0) + (icon ? ICON_PAD - PAD_X + 4 : 0);
|
|
66
|
+
}
|
|
67
|
+
function boxHeight(lines, fonts, shape) {
|
|
68
|
+
return snap(PAD_Y * 2 + Math.max(1, lines) * fonts.line + (shape === 'cylinder' ? 16 : 0), 4);
|
|
69
|
+
}
|
|
70
|
+
function nodeSizes(d, tm, fonts) {
|
|
71
|
+
const sizes = new Map();
|
|
72
|
+
const minW = d.type === 'architecture' ? 136 : 112;
|
|
73
|
+
const textOf = (n, maxW) => {
|
|
74
|
+
const a = tm.wrap(n.label, fonts.label, maxW), b = tm.wrap(n.tech, fonts.sub, maxW);
|
|
75
|
+
return { width: Math.max(a.width, b.width), lines: a.lines.length + b.lines.length };
|
|
76
|
+
};
|
|
77
|
+
for (const n of d.nodes) {
|
|
78
|
+
const shape = kindDef(d.type, n.kind).shape;
|
|
79
|
+
if (shape === 'table') {
|
|
80
|
+
let w = tm.width(n.label, fonts.label) + 2 * FIELD_PAD + 8;
|
|
81
|
+
for (const f of n.fields ?? []) {
|
|
82
|
+
const nameW = tm.width(f.name, f.flags.includes('pk') ? fonts.fieldKey : fonts.field);
|
|
83
|
+
w = Math.max(w, nameW + tm.width(fieldRight(f), fonts.field) + 2 * FIELD_PAD + 28);
|
|
84
|
+
}
|
|
85
|
+
const rows = n.fields?.length ?? 0;
|
|
86
|
+
sizes.set(n.id, { w: snap(Math.max(176, w)), h: rows ? ER_HEAD + rows * ER_ROW + 6 : ER_HEAD });
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (shape === 'start' || shape === 'end') {
|
|
90
|
+
sizes.set(n.id, { w: 24, h: 24 });
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (shape === 'diamond') {
|
|
94
|
+
const t = textOf(n, fonts.maxText * 0.7);
|
|
95
|
+
const w = snap(Math.max(128, t.width * 1.5 + 40));
|
|
96
|
+
sizes.set(n.id, { w, h: snap(Math.max(72, w * 0.5, t.lines * fonts.line * 2 + 24)) });
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const extra = shapeExtra(shape, !!n.icon);
|
|
100
|
+
const t = textOf(n, fonts.maxText);
|
|
101
|
+
sizes.set(n.id, { w: snap(Math.max(minW, t.width + 2 * PAD_X + extra)), h: boxHeight(t.lines, fonts, shape) });
|
|
102
|
+
}
|
|
103
|
+
const natural = new Map([...sizes].map(([k, v]) => [k, { ...v }]));
|
|
104
|
+
// Boxes of nearly the same width read calmer at exactly the same width. Boxes
|
|
105
|
+
// much wider than the rest keep their own width rather than stretching every box.
|
|
106
|
+
const uniform = d.nodes.filter((n) => !['start', 'end', 'diamond', 'table'].includes(kindDef(d.type, n.kind).shape));
|
|
107
|
+
const sorted = [...uniform].sort((a, b) => sizes.get(a.id).w - sizes.get(b.id).w);
|
|
108
|
+
let cluster = [];
|
|
109
|
+
const flush = () => {
|
|
110
|
+
const w = Math.max(0, ...cluster.map((n) => sizes.get(n.id).w));
|
|
111
|
+
for (const n of cluster) {
|
|
112
|
+
const s = sizes.get(n.id);
|
|
113
|
+
if (s.w === w)
|
|
114
|
+
continue;
|
|
115
|
+
s.w = w;
|
|
116
|
+
// A wider box may fit the text on fewer lines.
|
|
117
|
+
const shape = kindDef(d.type, n.kind).shape;
|
|
118
|
+
s.h = boxHeight(textOf(n, w - 2 * PAD_X - shapeExtra(shape, !!n.icon)).lines, fonts, shape);
|
|
119
|
+
}
|
|
120
|
+
cluster = [];
|
|
121
|
+
};
|
|
122
|
+
for (const n of sorted) {
|
|
123
|
+
if (cluster.length && sizes.get(n.id).w > sizes.get(cluster[0].id).w * 1.25)
|
|
124
|
+
flush();
|
|
125
|
+
cluster.push(n);
|
|
126
|
+
}
|
|
127
|
+
flush();
|
|
128
|
+
return { sizes, natural };
|
|
129
|
+
}
|
|
130
|
+
async function run(d, dir, sizes, labels, groupMin, groupTop) {
|
|
131
|
+
const byId = new Map();
|
|
132
|
+
const root = {
|
|
133
|
+
id: '__root',
|
|
134
|
+
layoutOptions: {
|
|
135
|
+
'elk.algorithm': 'layered',
|
|
136
|
+
'elk.direction': ELK_DIR[dir],
|
|
137
|
+
'elk.hierarchyHandling': 'INCLUDE_CHILDREN',
|
|
138
|
+
'elk.edgeRouting': 'ORTHOGONAL',
|
|
139
|
+
'elk.layered.nodePlacement.strategy': 'BRANDES_KOEPF',
|
|
140
|
+
'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED',
|
|
141
|
+
'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES',
|
|
142
|
+
'elk.layered.spacing.nodeNodeBetweenLayers': '32',
|
|
143
|
+
'elk.spacing.nodeNode': '40',
|
|
144
|
+
'elk.layered.spacing.edgeNodeBetweenLayers': '12',
|
|
145
|
+
'elk.spacing.edgeNode': '24',
|
|
146
|
+
'elk.spacing.edgeEdge': '16',
|
|
147
|
+
'elk.layered.spacing.edgeEdgeBetweenLayers': '14',
|
|
148
|
+
'elk.spacing.edgeLabel': '4',
|
|
149
|
+
'elk.edgeLabels.inline': 'true',
|
|
150
|
+
'elk.layered.edgeLabels.sideSelection': 'ALWAYS_UP',
|
|
151
|
+
'elk.layered.mergeEdges': 'false',
|
|
152
|
+
'elk.padding': '[top=16,left=16,bottom=16,right=16]',
|
|
153
|
+
'elk.json.shapeCoords': 'ROOT',
|
|
154
|
+
'elk.json.edgeCoords': 'ROOT',
|
|
155
|
+
},
|
|
156
|
+
children: [],
|
|
157
|
+
edges: [],
|
|
158
|
+
};
|
|
159
|
+
byId.set(root.id, root);
|
|
160
|
+
for (const g of d.groups) {
|
|
161
|
+
byId.set(g.id, {
|
|
162
|
+
id: g.id,
|
|
163
|
+
children: [],
|
|
164
|
+
layoutOptions: {
|
|
165
|
+
'elk.padding': `[top=${groupTop},left=20,bottom=20,right=20]`,
|
|
166
|
+
// Wide enough for its title.
|
|
167
|
+
'elk.nodeSize.constraints': 'MINIMUM_SIZE',
|
|
168
|
+
'elk.nodeSize.minimum': `(${groupMin.get(g.id) ?? 0}, 0)`,
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
for (const g of d.groups)
|
|
173
|
+
byId.get(g.parent ?? '__root').children.push(byId.get(g.id));
|
|
174
|
+
for (const n of d.nodes) {
|
|
175
|
+
const s = sizes.get(n.id);
|
|
176
|
+
const en = { id: n.id, width: s.w, height: s.h };
|
|
177
|
+
if (n.kind === 'start')
|
|
178
|
+
en.layoutOptions = { 'elk.layered.layering.layerConstraint': 'FIRST' };
|
|
179
|
+
byId.set(n.id, en);
|
|
180
|
+
byId.get(n.parent ?? '__root').children.push(en);
|
|
181
|
+
}
|
|
182
|
+
root.edges = d.edges.map((e, i) => ({
|
|
183
|
+
id: e.id,
|
|
184
|
+
sources: [e.from],
|
|
185
|
+
targets: [e.to],
|
|
186
|
+
labels: labels[i] ? [{ text: e.label, width: labels[i].w, height: labels[i].h }] : [],
|
|
187
|
+
}));
|
|
188
|
+
const out = await elk.layout(root);
|
|
189
|
+
const nodes = new Map();
|
|
190
|
+
const groups = new Map();
|
|
191
|
+
const groupIds = new Set(d.groups.map((g) => g.id));
|
|
192
|
+
const walk = (n) => {
|
|
193
|
+
for (const c of n.children ?? []) {
|
|
194
|
+
const box = { x: c.x, y: c.y, w: c.width, h: c.height };
|
|
195
|
+
(groupIds.has(c.id) ? groups : nodes).set(c.id, box);
|
|
196
|
+
walk(c);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
walk(out);
|
|
200
|
+
const edges = new Map();
|
|
201
|
+
for (const e of (out.edges ?? [])) {
|
|
202
|
+
const s = e.sections?.[0];
|
|
203
|
+
if (!s)
|
|
204
|
+
continue;
|
|
205
|
+
const lab = e.labels?.[0];
|
|
206
|
+
edges.set(e.id, {
|
|
207
|
+
points: [s.startPoint, ...(s.bendPoints ?? []), s.endPoint].map((p) => ({ x: p.x, y: p.y })),
|
|
208
|
+
label: lab && lab.x != null ? { x: lab.x, y: lab.y, w: lab.width, h: lab.height } : undefined,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
return { direction: dir, width: out.width, height: out.height, nodes, groups, edges };
|
|
212
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { fontsFor } from './layout.js';
|
|
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';
|
|
4
|
+
import { autoAttr, diagramMeta, lookAttrs } from '../drawio/write.js';
|
|
5
|
+
const TOP = 24, BAR_W = 10, NOTE_W = 220;
|
|
6
|
+
/**
|
|
7
|
+
* A label centred on a long message covers the lifelines it passes. Put it in the
|
|
8
|
+
* gap nearest the sender that fits it; keep it centred when none does.
|
|
9
|
+
* Returns draw.io's -1..1 position along the arrow.
|
|
10
|
+
*/
|
|
11
|
+
function labelGap(x1, x2, w, centers) {
|
|
12
|
+
const lo = Math.min(x1, x2), hi = Math.max(x1, x2);
|
|
13
|
+
const stops = [lo, ...centers.filter((c) => c > lo + 1 && c < hi - 1).sort((a, b) => a - b), hi];
|
|
14
|
+
if (stops.length <= 2)
|
|
15
|
+
return undefined;
|
|
16
|
+
const gaps = stops.slice(1).map((b, k) => ({ a: stops[k], b }));
|
|
17
|
+
if (x2 < x1)
|
|
18
|
+
gaps.reverse(); // nearest the sender first
|
|
19
|
+
const fit = gaps.find((g) => g.b - g.a >= w + BAR_W);
|
|
20
|
+
if (!fit)
|
|
21
|
+
return undefined;
|
|
22
|
+
const mid = (fit.a + fit.b) / 2;
|
|
23
|
+
return ((mid - x1) / (x2 - x1)) * 2 - 1;
|
|
24
|
+
}
|
|
25
|
+
export async function sequenceScene(d, measure, opts = {}) {
|
|
26
|
+
const parts = d.nodes;
|
|
27
|
+
const idx = new Map(parts.map((p, i) => [p.id, i]));
|
|
28
|
+
const theme = d.theme;
|
|
29
|
+
// 1. Flatten items into rows, remembering frames.
|
|
30
|
+
const rows = [];
|
|
31
|
+
const frames = [];
|
|
32
|
+
const notes = [];
|
|
33
|
+
const walk = (items, depth) => {
|
|
34
|
+
for (const it of items) {
|
|
35
|
+
if (it.kind === 'msg')
|
|
36
|
+
rows.push({ t: 'msg', item: it, y: 0 });
|
|
37
|
+
else if (it.kind === 'note') {
|
|
38
|
+
rows.push({ t: 'note', item: it, y: 0, h: 0 });
|
|
39
|
+
notes.push(it);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
const f = { id: `frame_${frames.length + 1}`, op: it.op, whens: it.branches.map((b) => b.when), start: rows.length, seps: [], end: 0, depth, parts: new Set() };
|
|
43
|
+
frames.push(f);
|
|
44
|
+
rows.push({ t: 'gap', y: 0 });
|
|
45
|
+
it.branches.forEach((b, bi) => {
|
|
46
|
+
if (bi > 0) {
|
|
47
|
+
f.seps.push(rows.length);
|
|
48
|
+
rows.push({ t: 'gap', y: 0 });
|
|
49
|
+
}
|
|
50
|
+
const before = rows.length;
|
|
51
|
+
walk(b.items, depth + 1);
|
|
52
|
+
for (const r of rows.slice(before))
|
|
53
|
+
if (r.t === 'msg') {
|
|
54
|
+
f.parts.add(r.item.from);
|
|
55
|
+
f.parts.add(r.item.to);
|
|
56
|
+
}
|
|
57
|
+
else if (r.t === 'note')
|
|
58
|
+
r.item.over.forEach((p) => f.parts.add(p));
|
|
59
|
+
});
|
|
60
|
+
f.end = rows.length;
|
|
61
|
+
rows.push({ t: 'gap', y: 0 });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
walk(d.seq ?? [], 0);
|
|
66
|
+
// Frames include nested frames' participants.
|
|
67
|
+
for (const f of [...frames].reverse())
|
|
68
|
+
for (const g of frames)
|
|
69
|
+
if (g !== f && g.start > f.start && g.end < f.end)
|
|
70
|
+
g.parts.forEach((p) => f.parts.add(p));
|
|
71
|
+
// 2. Measure.
|
|
72
|
+
const fonts = fontsFor(theme);
|
|
73
|
+
const tm = new TextMeasurer(measure);
|
|
74
|
+
const guards = frames.flatMap((f) => f.whens.filter((w) => !!w).map((w) => `[${w}]`));
|
|
75
|
+
await tm.prepare([
|
|
76
|
+
...parts.flatMap((p) => [[p.label, fonts.label], [p.tech, fonts.sub]]),
|
|
77
|
+
...rows.map((r) => [r.t === 'msg' ? r.item.label : r.t === 'note' ? r.item.text : undefined, fonts.edge]),
|
|
78
|
+
...guards.map((g) => [g, fonts.edge]),
|
|
79
|
+
...frames.map((f) => [f.op, fonts.group]),
|
|
80
|
+
]);
|
|
81
|
+
const headText = parts.map((p) => {
|
|
82
|
+
const a = tm.wrap(p.label, fonts.label, fonts.maxText), b = tm.wrap(p.tech, fonts.sub, fonts.maxText);
|
|
83
|
+
return { w: Math.max(a.width, b.width), lines: a.lines.length + b.lines.length };
|
|
84
|
+
});
|
|
85
|
+
const headW = headText.map((t) => Math.max(120, Math.ceil((t.w + 2 * PAD_X) / 8) * 8));
|
|
86
|
+
const HEAD = Math.ceil((PAD_Y * 2 + Math.max(...headText.map((t) => t.lines), 1) * fonts.line) / 4) * 4;
|
|
87
|
+
const edgeLine = Math.ceil(fonts.edge.size * LINE);
|
|
88
|
+
const ROW = Math.max(40, edgeLine + 24);
|
|
89
|
+
const rowW = rows.map((r) => (r.t === 'msg' ? tm.width(r.item.label, fonts.edge) : r.t === 'note' ? Math.min(tm.width(r.item.text, fonts.edge), NOTE_W - 20) : 0));
|
|
90
|
+
const noteLines = rows.map((r) => (r.t === 'note' ? Math.max(1, tm.wrap(r.item.text, fonts.edge, NOTE_W - 20).lines.length) : 0));
|
|
91
|
+
// 3. Columns: centre-to-centre distance wide enough for every label between neighbours.
|
|
92
|
+
const need = parts.map(() => 0); // need[i]: min centre distance between i and i+1
|
|
93
|
+
rows.forEach((r, i) => {
|
|
94
|
+
if (r.t !== 'msg' || r.item.from === r.item.to)
|
|
95
|
+
return;
|
|
96
|
+
const a = Math.min(idx.get(r.item.from), idx.get(r.item.to)), b = Math.max(idx.get(r.item.from), idx.get(r.item.to));
|
|
97
|
+
const per = (rowW[i] + 48) / (b - a);
|
|
98
|
+
for (let k = a; k < b; k++)
|
|
99
|
+
need[k] = Math.max(need[k], per);
|
|
100
|
+
});
|
|
101
|
+
rows.forEach((r, i) => {
|
|
102
|
+
if (r.t !== 'msg' || r.item.from !== r.item.to)
|
|
103
|
+
return;
|
|
104
|
+
const k = idx.get(r.item.from);
|
|
105
|
+
if (k < parts.length - 1)
|
|
106
|
+
need[k] = Math.max(need[k], rowW[i] + 72);
|
|
107
|
+
});
|
|
108
|
+
const xs = [];
|
|
109
|
+
parts.forEach((p, i) => {
|
|
110
|
+
const kept = opts.keepX?.get(p.id);
|
|
111
|
+
if (i === 0)
|
|
112
|
+
return xs.push(kept?.x ?? 40);
|
|
113
|
+
const prevC = xs[i - 1] + headW[i - 1] / 2;
|
|
114
|
+
const minC = prevC + Math.max(need[i - 1], headW[i - 1] / 2 + headW[i] / 2 + 32);
|
|
115
|
+
const fresh = minC - headW[i] / 2;
|
|
116
|
+
xs.push(kept ? Math.max(kept.x, prevC + headW[i - 1] / 2 + 24) : fresh);
|
|
117
|
+
});
|
|
118
|
+
parts.forEach((p, i) => {
|
|
119
|
+
const kept = opts.keepX?.get(p.id);
|
|
120
|
+
if (kept && kept.w > headW[i])
|
|
121
|
+
headW[i] = kept.w;
|
|
122
|
+
});
|
|
123
|
+
const cx = (id) => xs[idx.get(id)] + headW[idx.get(id)] / 2;
|
|
124
|
+
// 4. Rows.
|
|
125
|
+
let y = TOP + HEAD + 28;
|
|
126
|
+
rows.forEach((r, i) => {
|
|
127
|
+
r.y = y;
|
|
128
|
+
if (r.t === 'msg')
|
|
129
|
+
y += r.item.from === r.item.to ? ROW + 16 : ROW;
|
|
130
|
+
else if (r.t === 'note') {
|
|
131
|
+
r.h = 14 + noteLines[i] * edgeLine;
|
|
132
|
+
y += r.h + 16;
|
|
133
|
+
}
|
|
134
|
+
else
|
|
135
|
+
y += (frames.some((f) => f.end === i) ? 20 : 34);
|
|
136
|
+
});
|
|
137
|
+
// Lifelines stop a short way past the last row instead of trailing off the page.
|
|
138
|
+
const lifeH = y - TOP - 6;
|
|
139
|
+
const bars = [];
|
|
140
|
+
let open = new Map();
|
|
141
|
+
const copy = (o) => new Map([...o].map(([k, v]) => [k, [...v]]));
|
|
142
|
+
// Each branch of an alt/par starts from the calls open at the top of the frame,
|
|
143
|
+
// not from wherever the previous branch left off.
|
|
144
|
+
const frameAt = new Map(frames.map((f) => [f.start, f]));
|
|
145
|
+
const sepOf = new Map(frames.flatMap((f) => f.seps.map((s) => [s, f])));
|
|
146
|
+
const snapshots = new Map();
|
|
147
|
+
const barAt = new Map();
|
|
148
|
+
rows.forEach((r, i) => {
|
|
149
|
+
const fs = frameAt.get(i);
|
|
150
|
+
if (fs)
|
|
151
|
+
snapshots.set(fs, copy(open));
|
|
152
|
+
const fsep = sepOf.get(i);
|
|
153
|
+
if (fsep)
|
|
154
|
+
open = copy(snapshots.get(fsep));
|
|
155
|
+
if (r.t !== 'msg' || r.item.from === r.item.to)
|
|
156
|
+
return;
|
|
157
|
+
const { from, to, style } = r.item;
|
|
158
|
+
if (style === 'sync') {
|
|
159
|
+
const st = open.get(to) ?? [];
|
|
160
|
+
st.push({ from: i, caller: from });
|
|
161
|
+
open.set(to, st);
|
|
162
|
+
}
|
|
163
|
+
else if (style === 'reply') {
|
|
164
|
+
const st = open.get(from) ?? [];
|
|
165
|
+
const k = st.map((s) => s.caller).lastIndexOf(to);
|
|
166
|
+
if (k >= 0) {
|
|
167
|
+
const s = st.splice(k, 1)[0];
|
|
168
|
+
bars.push({ id: `bar_${from}_${bars.length + 1}`, part: from, from: s.from, to: i });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
// A call answered in more than one branch is one bar, to the last answer.
|
|
173
|
+
for (let k = bars.length - 1; k >= 0; k--) {
|
|
174
|
+
const b = bars[k];
|
|
175
|
+
const first = bars.find((o) => o !== b && o.part === b.part && o.from === b.from);
|
|
176
|
+
if (first) {
|
|
177
|
+
first.to = Math.max(first.to, b.to);
|
|
178
|
+
bars.splice(k, 1);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const activeBar = (part, row) => bars.filter((b) => b.part === part && b.from <= row && b.to >= row).pop();
|
|
182
|
+
rows.forEach((r, i) => {
|
|
183
|
+
if (r.t !== 'msg' || r.item.from === r.item.to)
|
|
184
|
+
return;
|
|
185
|
+
const { from, to, style } = r.item;
|
|
186
|
+
const dst = style === 'sync' ? bars.find((b) => b.part === to && b.from === i) : activeBar(to, i);
|
|
187
|
+
const src = style === 'reply' ? (bars.find((b) => b.part === from && b.to === i) ?? activeBar(from, i)) : activeBar(from, i);
|
|
188
|
+
barAt.set(i, { src, dst });
|
|
189
|
+
});
|
|
190
|
+
// 6. Cells.
|
|
191
|
+
const vertices = [];
|
|
192
|
+
const edges = [];
|
|
193
|
+
const top = (b) => rows[b.from].y - 6;
|
|
194
|
+
const barH = (b) => rows[b.to].y + 6 - top(b);
|
|
195
|
+
parts.forEach((p, i) => {
|
|
196
|
+
const box = { x: xs[i], y: TOP, w: headW[i], h: lifeH };
|
|
197
|
+
const keep = opts.keep?.get(p.id);
|
|
198
|
+
vertices.push({
|
|
199
|
+
id: p.id,
|
|
200
|
+
parent: '1',
|
|
201
|
+
attrs: {
|
|
202
|
+
...(keep?.attrs ?? {}),
|
|
203
|
+
label: keep?.label ?? nodeLabel(p.label, p.tech, { theme, type: 'sequence', kind: p.kind, color: p.color, highlight: p.highlight }),
|
|
204
|
+
aiviz_kind: p.kind,
|
|
205
|
+
aiviz_label: p.label,
|
|
206
|
+
...(p.tech ? { aiviz_tech: p.tech } : {}),
|
|
207
|
+
...lookAttrs(p),
|
|
208
|
+
aiviz_auto: autoAttr(box),
|
|
209
|
+
},
|
|
210
|
+
style: keep?.style?.replace(/(^|;)size=\d+/, `$1size=${HEAD}`) ?? lifelineStyle(p.kind, theme, HEAD, p),
|
|
211
|
+
box,
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
for (const b of bars) {
|
|
215
|
+
const i = idx.get(b.part);
|
|
216
|
+
vertices.push({
|
|
217
|
+
id: b.id,
|
|
218
|
+
parent: b.part,
|
|
219
|
+
attrs: { aiviz_type: 'bar' },
|
|
220
|
+
style: activationStyle(parts[i].kind, theme, parts[i]),
|
|
221
|
+
box: { x: headW[i] / 2 - BAR_W / 2, y: top(b) - TOP, w: BAR_W, h: barH(b) },
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
// Frames before messages so arrows draw on top.
|
|
225
|
+
for (const f of frames) {
|
|
226
|
+
const ps = [...f.parts].map((p) => idx.get(p)).filter((v) => v != null);
|
|
227
|
+
const lo = ps.length ? Math.min(...ps) : 0, hi = ps.length ? Math.max(...ps) : 0;
|
|
228
|
+
const inset = f.depth * 10;
|
|
229
|
+
const fx = cx(parts[lo].id) - Math.max(80, headW[lo] / 2 + 16) + inset;
|
|
230
|
+
const fx2 = cx(parts[hi].id) + Math.max(80, headW[hi] / 2 + 16) - inset;
|
|
231
|
+
const fy = rows[f.start].y - 4, fy2 = rows[f.end].y;
|
|
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
|
+
const guard = (when, gy, k) => {
|
|
234
|
+
if (!when)
|
|
235
|
+
return;
|
|
236
|
+
// Start right of the operator label, then step past any lifeline the text would cover.
|
|
237
|
+
const gw = Math.max(80, Math.ceil(tm.width(`[${when}]`, fonts.edge)) + 16);
|
|
238
|
+
let gx = fx + 10 + Math.ceil(tm.width(f.op, fonts.group)) + 8;
|
|
239
|
+
for (const c of [...parts.map((p) => cx(p.id))].sort((a, b) => a - b))
|
|
240
|
+
if (c + BAR_W / 2 + 4 > gx && c - BAR_W / 2 - 4 < gx + gw)
|
|
241
|
+
gx = c + BAR_W / 2 + 6;
|
|
242
|
+
vertices.push({
|
|
243
|
+
id: `${f.id}_g${k}`,
|
|
244
|
+
parent: '1',
|
|
245
|
+
attrs: { label: `[${when}]`, aiviz_type: 'guard', aiviz_guard: `${f.id}:${k}` },
|
|
246
|
+
style: guardStyle(theme),
|
|
247
|
+
box: { x: gx, y: gy, w: gw, h: Math.max(22, edgeLine + 6) },
|
|
248
|
+
});
|
|
249
|
+
};
|
|
250
|
+
guard(f.whens[0], fy, 0);
|
|
251
|
+
f.seps.forEach((s, k) => {
|
|
252
|
+
const sy = rows[s].y + 4;
|
|
253
|
+
edges.push({
|
|
254
|
+
id: `${f.id}_s${k + 1}`,
|
|
255
|
+
parent: '1',
|
|
256
|
+
attrs: { label: '', aiviz_type: 'sep', aiviz_sep: `${f.id}:${k + 1}` },
|
|
257
|
+
style: separatorStyle(theme),
|
|
258
|
+
source: '',
|
|
259
|
+
target: '',
|
|
260
|
+
geomRaw: `<mxGeometry relative="1" as="geometry"><mxPoint x="${fx}" y="${sy}" as="sourcePoint"/><mxPoint x="${fx2}" y="${sy}" as="targetPoint"/></mxGeometry>`,
|
|
261
|
+
});
|
|
262
|
+
guard(f.whens[k + 1], sy + 2, k + 1);
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
rows.forEach((r, i) => {
|
|
266
|
+
if (r.t === 'note') {
|
|
267
|
+
const it = r.item;
|
|
268
|
+
const ids = it.over.map((p) => idx.get(p)).sort((a, b) => a - b);
|
|
269
|
+
const w = rowW[i] + 24;
|
|
270
|
+
let x, width;
|
|
271
|
+
if (it.side === 'over') {
|
|
272
|
+
const l = cx(parts[ids[0]].id), rr = cx(parts[ids[ids.length - 1]].id);
|
|
273
|
+
width = Math.max(w, rr - l + 48, 96);
|
|
274
|
+
x = (l + rr) / 2 - width / 2;
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
width = Math.min(Math.max(w, 96), NOTE_W);
|
|
278
|
+
const c = cx(parts[ids[0]].id);
|
|
279
|
+
x = it.side === 'right' ? c + 16 : c - 16 - width;
|
|
280
|
+
}
|
|
281
|
+
vertices.push({
|
|
282
|
+
id: `note_${i}`,
|
|
283
|
+
parent: '1',
|
|
284
|
+
attrs: { label: it.text, aiviz_type: 'note', aiviz_note: it.side, aiviz_over: it.over.join(',') },
|
|
285
|
+
style: noteStyle(theme),
|
|
286
|
+
box: { x, y: r.y - 4, w: Math.ceil(width / 8) * 8, h: r.h },
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
rows.forEach((r, i) => {
|
|
291
|
+
if (r.t !== 'msg')
|
|
292
|
+
return;
|
|
293
|
+
const { from, to, style, label } = r.item;
|
|
294
|
+
const self = from === to;
|
|
295
|
+
const attrs = { label: label ?? '', aiviz_msg: style, ...lookAttrs(r.item) };
|
|
296
|
+
const id = `m_${i}`;
|
|
297
|
+
if (self) {
|
|
298
|
+
const c = cx(from), lh = lifeH;
|
|
299
|
+
edges.push({
|
|
300
|
+
id, parent: '1', attrs,
|
|
301
|
+
style: messageStyle(style, theme, true, r.item) + `exitX=0.5;exitY=${((r.y - TOP) / lh).toFixed(4)};entryX=0.5;entryY=${((r.y + 20 - TOP) / lh).toFixed(4)};exitDx=0;exitDy=0;entryDx=0;entryDy=0;`,
|
|
302
|
+
source: from, target: from,
|
|
303
|
+
points: [{ x: c + 40, y: r.y }, { x: c + 40, y: r.y + 20 }],
|
|
304
|
+
});
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const at = barAt.get(i) ?? {};
|
|
308
|
+
const rightward = idx.get(to) > idx.get(from);
|
|
309
|
+
const end = (part, bar, isSource) => {
|
|
310
|
+
if (bar) {
|
|
311
|
+
const fy = (r.y - top(bar)) / barH(bar);
|
|
312
|
+
const side = isSource === rightward ? 1 : 0;
|
|
313
|
+
return { cell: bar.id, x: side, y: fy };
|
|
314
|
+
}
|
|
315
|
+
return { cell: part, x: 0.5, y: (r.y - TOP) / lifeH };
|
|
316
|
+
};
|
|
317
|
+
const s = end(from, at.src, true), t = end(to, at.dst, false);
|
|
318
|
+
edges.push({
|
|
319
|
+
id, parent: '1', attrs,
|
|
320
|
+
labelX: label ? labelGap(cx(from), cx(to), rowW[i] + 16, parts.map((p) => cx(p.id))) : undefined,
|
|
321
|
+
style: messageStyle(style, theme, false, r.item) + `exitX=${s.x};exitY=${s.y.toFixed(4)};exitDx=0;exitDy=0;entryX=${t.x};entryY=${t.y.toFixed(4)};entryDx=0;entryDy=0;`,
|
|
322
|
+
source: s.cell, target: t.cell, points: [],
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
return {
|
|
326
|
+
name: opts.name ?? d.title ?? 'Sequence',
|
|
327
|
+
meta: diagramMeta(d, opts.extraMeta),
|
|
328
|
+
background: palette(theme).canvas,
|
|
329
|
+
vertices,
|
|
330
|
+
edges,
|
|
331
|
+
raw: [],
|
|
332
|
+
};
|
|
333
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
const key = (f) => `${f.weight} ${f.size} ${f.family}`;
|
|
2
|
+
export class TextMeasurer {
|
|
3
|
+
measure;
|
|
4
|
+
widths = new Map();
|
|
5
|
+
constructor(measure) {
|
|
6
|
+
this.measure = measure;
|
|
7
|
+
}
|
|
8
|
+
/** Measure every word (and the space) of these texts. Call before wrap/width. */
|
|
9
|
+
async prepare(items) {
|
|
10
|
+
const reqs = [];
|
|
11
|
+
const keys = [];
|
|
12
|
+
const add = (word, f) => {
|
|
13
|
+
const k = `${key(f)}|${word}`;
|
|
14
|
+
if (this.widths.has(k) || keys.includes(k))
|
|
15
|
+
return;
|
|
16
|
+
keys.push(k);
|
|
17
|
+
reqs.push([word, f.size, f.weight, f.family]);
|
|
18
|
+
};
|
|
19
|
+
for (const [text, f] of items) {
|
|
20
|
+
if (!text)
|
|
21
|
+
continue;
|
|
22
|
+
add(' ', f);
|
|
23
|
+
add(text, f);
|
|
24
|
+
for (const w of words(text))
|
|
25
|
+
add(w, f);
|
|
26
|
+
}
|
|
27
|
+
if (!reqs.length)
|
|
28
|
+
return;
|
|
29
|
+
const out = await this.measure(reqs);
|
|
30
|
+
keys.forEach((k, i) => this.widths.set(k, out[i]));
|
|
31
|
+
}
|
|
32
|
+
get(word, f) {
|
|
33
|
+
const w = this.widths.get(`${key(f)}|${word}`);
|
|
34
|
+
if (w == null)
|
|
35
|
+
throw new Error(`linework: text not measured: "${word}"`);
|
|
36
|
+
return w + (f.tracking ?? 0) * word.length;
|
|
37
|
+
}
|
|
38
|
+
/** Width of the text on one line. */
|
|
39
|
+
width(text, f) {
|
|
40
|
+
return text ? this.get(text, f) : 0;
|
|
41
|
+
}
|
|
42
|
+
/** Greedy word wrap at maxW. */
|
|
43
|
+
wrap(text, f, maxW) {
|
|
44
|
+
if (!text)
|
|
45
|
+
return { lines: [], width: 0 };
|
|
46
|
+
const whole = this.get(text, f);
|
|
47
|
+
if (whole <= maxW)
|
|
48
|
+
return { lines: [text], width: whole };
|
|
49
|
+
const space = this.get(' ', f);
|
|
50
|
+
const lines = [];
|
|
51
|
+
let cur = '', curW = 0, width = 0;
|
|
52
|
+
for (const w of words(text)) {
|
|
53
|
+
const ww = this.get(w, f);
|
|
54
|
+
if (cur && curW + space + ww > maxW) {
|
|
55
|
+
lines.push(cur);
|
|
56
|
+
width = Math.max(width, curW);
|
|
57
|
+
cur = w;
|
|
58
|
+
curW = ww;
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
curW = cur ? curW + space + ww : ww;
|
|
62
|
+
cur = cur ? `${cur} ${w}` : w;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (cur) {
|
|
66
|
+
lines.push(cur);
|
|
67
|
+
width = Math.max(width, curW);
|
|
68
|
+
}
|
|
69
|
+
// Summing word widths drops kerning across spaces; round up a hair.
|
|
70
|
+
return { lines, width: Math.ceil(width + 1) };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const words = (t) => t.split(/\s+/).filter(Boolean);
|