@itsthecsguy/linework 0.0.0-stage → 0.1.0

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.
Files changed (223) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +11 -0
  3. package/README.md +164 -2
  4. package/assets/drawio/LICENSE +201 -0
  5. package/assets/drawio/VERSION +1 -0
  6. package/assets/drawio/favicon.ico +0 -0
  7. package/assets/drawio/images/1x1.png +0 -0
  8. package/assets/drawio/images/2x2.png +0 -0
  9. package/assets/drawio/images/3x3.png +0 -0
  10. package/assets/drawio/images/ajax-loader.gif +0 -0
  11. package/assets/drawio/images/android-chrome-512x512.png +0 -0
  12. package/assets/drawio/images/apple-touch-icon.png +0 -0
  13. package/assets/drawio/images/aui-wait.gif +0 -0
  14. package/assets/drawio/images/broken.png +0 -0
  15. package/assets/drawio/images/browserconfig.xml +9 -0
  16. package/assets/drawio/images/close.png +0 -0
  17. package/assets/drawio/images/cloud-connector-atlas.png +0 -0
  18. package/assets/drawio/images/collapsed.gif +0 -0
  19. package/assets/drawio/images/delete.png +0 -0
  20. package/assets/drawio/images/document-google-drive-icon-80.png +0 -0
  21. package/assets/drawio/images/draw-search.png +0 -0
  22. package/assets/drawio/images/drawio.svg +19 -0
  23. package/assets/drawio/images/drawlogo-color.svg +50 -0
  24. package/assets/drawio/images/drawlogo-gray.svg +18 -0
  25. package/assets/drawio/images/drawlogo-text-bottom.svg +75 -0
  26. package/assets/drawio/images/drawlogo.svg +18 -0
  27. package/assets/drawio/images/drawlogo128.png +0 -0
  28. package/assets/drawio/images/drawlogo144.png +0 -0
  29. package/assets/drawio/images/drawlogo256.png +0 -0
  30. package/assets/drawio/images/drawlogo48-gray.png +0 -0
  31. package/assets/drawio/images/drawlogo48.png +0 -0
  32. package/assets/drawio/images/drawlogo80.png +0 -0
  33. package/assets/drawio/images/droptarget.png +0 -0
  34. package/assets/drawio/images/edit.gif +0 -0
  35. package/assets/drawio/images/edit16.png +0 -0
  36. package/assets/drawio/images/edit32.png +0 -0
  37. package/assets/drawio/images/edit80.png +0 -0
  38. package/assets/drawio/images/embed-icon.png +0 -0
  39. package/assets/drawio/images/expanded.gif +0 -0
  40. package/assets/drawio/images/facebook.png +0 -0
  41. package/assets/drawio/images/favicon-16x16.png +0 -0
  42. package/assets/drawio/images/favicon-32x32.png +0 -0
  43. package/assets/drawio/images/file.png +0 -0
  44. package/assets/drawio/images/folder.png +0 -0
  45. package/assets/drawio/images/gadget-screenshot.png +0 -0
  46. package/assets/drawio/images/gadget-thumb.png +0 -0
  47. package/assets/drawio/images/github-logo-white.svg +6 -0
  48. package/assets/drawio/images/github-logo.svg +17 -0
  49. package/assets/drawio/images/gitlab-logo-white.svg +1 -0
  50. package/assets/drawio/images/gitlab-logo.svg +1 -0
  51. package/assets/drawio/images/glyphicons_blogger.png +0 -0
  52. package/assets/drawio/images/glyphicons_facebook.png +0 -0
  53. package/assets/drawio/images/glyphicons_github.png +0 -0
  54. package/assets/drawio/images/glyphicons_google.png +0 -0
  55. package/assets/drawio/images/glyphicons_star.png +0 -0
  56. package/assets/drawio/images/glyphicons_twitter.png +0 -0
  57. package/assets/drawio/images/glyphicons_youtube.png +0 -0
  58. package/assets/drawio/images/google-drive-connector-atlas.png +0 -0
  59. package/assets/drawio/images/google-drive-connector-atlas.svg +121 -0
  60. package/assets/drawio/images/google-drive-logo-white.svg +9 -0
  61. package/assets/drawio/images/google-drive-logo.svg +17 -0
  62. package/assets/drawio/images/google-share.png +0 -0
  63. package/assets/drawio/images/icon-192-maskable.png +0 -0
  64. package/assets/drawio/images/icon-192.png +0 -0
  65. package/assets/drawio/images/icon-512-maskable.png +0 -0
  66. package/assets/drawio/images/icon-512.png +0 -0
  67. package/assets/drawio/images/icon-search.svg +21 -0
  68. package/assets/drawio/images/img-hi-res.png +0 -0
  69. package/assets/drawio/images/img-lo-res.png +0 -0
  70. package/assets/drawio/images/list.svg +16 -0
  71. package/assets/drawio/images/locked.png +0 -0
  72. package/assets/drawio/images/log.png +0 -0
  73. package/assets/drawio/images/logo-confluence.png +0 -0
  74. package/assets/drawio/images/logo-flat-small.png +0 -0
  75. package/assets/drawio/images/logo-flat.png +0 -0
  76. package/assets/drawio/images/logo-jira.png +0 -0
  77. package/assets/drawio/images/logo-small.gif +0 -0
  78. package/assets/drawio/images/logo-white.png +0 -0
  79. package/assets/drawio/images/logo.png +0 -0
  80. package/assets/drawio/images/manifest.json +25 -0
  81. package/assets/drawio/images/move.png +0 -0
  82. package/assets/drawio/images/notion-logo-white.svg +1 -0
  83. package/assets/drawio/images/notion-logo.svg +1 -0
  84. package/assets/drawio/images/onedrive-connector-atlas.png +0 -0
  85. package/assets/drawio/images/onedrive-connector-atlas.svg +248 -0
  86. package/assets/drawio/images/onedrive-logo-white.svg +29 -0
  87. package/assets/drawio/images/onedrive-logo.svg +29 -0
  88. package/assets/drawio/images/osa_database.png +0 -0
  89. package/assets/drawio/images/osa_drive-harddisk.png +0 -0
  90. package/assets/drawio/images/plant-uml-atlas.png +0 -0
  91. package/assets/drawio/images/samepage-icon-color.svg +37 -0
  92. package/assets/drawio/images/save.png +0 -0
  93. package/assets/drawio/images/sidebar-android.png +0 -0
  94. package/assets/drawio/images/sidebar-archimate.png +0 -0
  95. package/assets/drawio/images/sidebar-arrows.png +0 -0
  96. package/assets/drawio/images/sidebar-arrows2.png +0 -0
  97. package/assets/drawio/images/sidebar-basic.png +0 -0
  98. package/assets/drawio/images/sidebar-c4.png +0 -0
  99. package/assets/drawio/images/sidebar-cabinets.png +0 -0
  100. package/assets/drawio/images/sidebar-cumulus.png +0 -0
  101. package/assets/drawio/images/sidebar-dfd.png +0 -0
  102. package/assets/drawio/images/sidebar-dynamics365.png +0 -0
  103. package/assets/drawio/images/sidebar-eip.png +0 -0
  104. package/assets/drawio/images/sidebar-er.png +0 -0
  105. package/assets/drawio/images/sidebar-floorplans.png +0 -0
  106. package/assets/drawio/images/sidebar-flowchart.png +0 -0
  107. package/assets/drawio/images/sidebar-gcp3.png +0 -0
  108. package/assets/drawio/images/sidebar-general.png +0 -0
  109. package/assets/drawio/images/sidebar-kubernetes.png +0 -0
  110. package/assets/drawio/images/sidebar-leanmapping.png +0 -0
  111. package/assets/drawio/images/sidebar-mockups.png +0 -0
  112. package/assets/drawio/images/sidebar-network.png +0 -0
  113. package/assets/drawio/images/sidebar-network2.png +0 -0
  114. package/assets/drawio/images/sidebar-openstack.png +0 -0
  115. package/assets/drawio/images/sidebar-sitemap.png +0 -0
  116. package/assets/drawio/images/sidebar-threatmodeling.png +0 -0
  117. package/assets/drawio/images/sidebar-uml.png +0 -0
  118. package/assets/drawio/images/sketch-logo128.png +0 -0
  119. package/assets/drawio/images/spin.gif +0 -0
  120. package/assets/drawio/images/sprites.png +0 -0
  121. package/assets/drawio/images/stop-flat-icon-80.png +0 -0
  122. package/assets/drawio/images/submenu.gif +0 -0
  123. package/assets/drawio/images/touch-submenu.png +0 -0
  124. package/assets/drawio/images/transparent.gif +0 -0
  125. package/assets/drawio/images/trello-logo-white-orange.svg +6 -0
  126. package/assets/drawio/images/trello-logo-white.svg +6 -0
  127. package/assets/drawio/images/trello-logo.svg +1 -0
  128. package/assets/drawio/images/tweet.png +0 -0
  129. package/assets/drawio/images/update16.png +0 -0
  130. package/assets/drawio/images/update32.png +0 -0
  131. package/assets/drawio/images/update80.png +0 -0
  132. package/assets/drawio/images/youtube.png +0 -0
  133. package/assets/drawio/img/clipart/Battery_0_128x128.png +0 -0
  134. package/assets/drawio/img/clipart/Battery_100_128x128.png +0 -0
  135. package/assets/drawio/img/clipart/Battery_50_128x128.png +0 -0
  136. package/assets/drawio/img/clipart/Battery_75_128x128.png +0 -0
  137. package/assets/drawio/img/clipart/Battery_allstates_128x128.png +0 -0
  138. package/assets/drawio/img/clipart/Bluetooth_128x128.png +0 -0
  139. package/assets/drawio/img/clipart/Earth_globe_128x128.png +0 -0
  140. package/assets/drawio/img/clipart/Empty_Folder_128x128.png +0 -0
  141. package/assets/drawio/img/clipart/Full_Folder_128x128.png +0 -0
  142. package/assets/drawio/img/clipart/Gear_128x128.png +0 -0
  143. package/assets/drawio/img/clipart/Keys_128x128.png +0 -0
  144. package/assets/drawio/img/clipart/Lock_128x128.png +0 -0
  145. package/assets/drawio/img/clipart/Mouse_Pointer_128x128.png +0 -0
  146. package/assets/drawio/img/clipart/Plug_128x128.png +0 -0
  147. package/assets/drawio/img/clipart/Ships_Wheel_128x128.png +0 -0
  148. package/assets/drawio/img/clipart/Star_128x128.png +0 -0
  149. package/assets/drawio/img/clipart/Tire_128x128.png +0 -0
  150. package/assets/drawio/index.html +92 -0
  151. package/assets/drawio/js/PostConfig.js +7 -0
  152. package/assets/drawio/js/PreConfig.js +13 -0
  153. package/assets/drawio/js/app.min.js +18466 -0
  154. package/assets/drawio/js/bootstrap.js +428 -0
  155. package/assets/drawio/js/diagramly/LibavoidRouting.js +4459 -0
  156. package/assets/drawio/js/export-init.js +21 -0
  157. package/assets/drawio/js/extensions.min.js +10478 -0
  158. package/assets/drawio/js/libavoid-js/LICENSE +505 -0
  159. package/assets/drawio/js/libavoid-js/libavoid-routing.js +1633 -0
  160. package/assets/drawio/js/libavoid-js/libavoid.min.js +59 -0
  161. package/assets/drawio/js/main.js +22 -0
  162. package/assets/drawio/js/rough/rough.min.js +1 -0
  163. package/assets/drawio/js/shapes-14-6-5.min.js +3457 -0
  164. package/assets/drawio/js/stencils.min.js +447 -0
  165. package/assets/drawio/mxgraph/css/common.css +147 -0
  166. package/assets/drawio/resources/dia.txt +2901 -0
  167. package/assets/drawio/styles/dark-default.xml +109 -0
  168. package/assets/drawio/styles/default-old.xml +109 -0
  169. package/assets/drawio/styles/default.xml +203 -0
  170. package/assets/drawio/styles/fonts/ArchitectsDaughter-Regular.ttf +0 -0
  171. package/assets/drawio/styles/grapheditor.css +4501 -0
  172. package/assets/drawio/styles/high-contrast.css +128 -0
  173. package/assets/drawio/styles/thumb_horz.png +0 -0
  174. package/assets/drawio/styles/thumb_vertical.png +0 -0
  175. package/assets/fonts/ibm-plex-mono-LICENSE.txt +93 -0
  176. package/assets/fonts/ibm-plex-sans-LICENSE.txt +93 -0
  177. package/assets/fonts/kalam-400.woff2 +0 -0
  178. package/assets/fonts/kalam-700.woff2 +0 -0
  179. package/assets/fonts/kalam-LICENSE.txt +93 -0
  180. package/assets/fonts/plex-mono-400.woff2 +0 -0
  181. package/assets/fonts/plex-mono-600.woff2 +0 -0
  182. package/assets/fonts/plex-sans-400.woff2 +0 -0
  183. package/assets/fonts/plex-sans-600.woff2 +0 -0
  184. package/dist/api.js +181 -0
  185. package/dist/cli.js +262 -0
  186. package/dist/drawio/icons.js +121 -0
  187. package/dist/drawio/merge.js +309 -0
  188. package/dist/drawio/read.js +180 -0
  189. package/dist/drawio/show.js +501 -0
  190. package/dist/drawio/theme.js +425 -0
  191. package/dist/drawio/write.js +300 -0
  192. package/dist/edit/server.js +212 -0
  193. package/dist/git.js +46 -0
  194. package/dist/import/mermaid.js +569 -0
  195. package/dist/layout/layout.js +212 -0
  196. package/dist/layout/sequence.js +333 -0
  197. package/dist/layout/text.js +73 -0
  198. package/dist/lint/check.js +184 -0
  199. package/dist/lint/fixlabels.js +128 -0
  200. package/dist/mcp/server.js +239 -0
  201. package/dist/publish.js +121 -0
  202. package/dist/render/browser.js +210 -0
  203. package/dist/render/fonts.js +66 -0
  204. package/dist/report.js +36 -0
  205. package/dist/spec/model.js +53 -0
  206. package/dist/spec/parse.js +497 -0
  207. package/package.json +71 -4
  208. package/skills/linework/SKILL.md +214 -0
  209. package/skills/linework/examples/billing-er.drawio.svg +4 -0
  210. package/skills/linework/examples/billing-er.yaml +31 -0
  211. package/skills/linework/examples/checkout.drawio.svg +4 -0
  212. package/skills/linework/examples/checkout.yaml +28 -0
  213. package/skills/linework/examples/pay-sequence.drawio.svg +4 -0
  214. package/skills/linework/examples/pay-sequence.yaml +28 -0
  215. package/skills/linework/examples/payment-state.drawio.svg +4 -0
  216. package/skills/linework/examples/payment-state.yaml +22 -0
  217. package/skills/linework/examples/refund-flow.drawio.svg +4 -0
  218. package/skills/linework/examples/refund-flow.yaml +29 -0
  219. package/skills/linework/examples/serverless-aws.drawio.svg +4 -0
  220. package/skills/linework/examples/serverless-aws.yaml +23 -0
  221. package/skills/linework/references/google-docs.md +46 -0
  222. package/skills/linework/references/spec.md +312 -0
  223. 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);