@itsthecsguy/linework 0.1.2 → 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 CHANGED
@@ -102,6 +102,29 @@ Linework lays it out with ELK and draws it with draw.io's own renderer. It draws
102
102
 
103
103
  To change a diagram, the agent runs `linework show` to get the spec back, hand edits included, edits it and rebuilds. The rebuild merges into your layout. Your boxes stay where you put them, new ones go next to their neighbours, and only the edges that need it get rerouted (with draw.io's libavoid router).
104
104
 
105
+ ## Examples
106
+
107
+ These are first builds of the specs in [`examples/`](examples), with no hand edits. Each one is a `.drawio.svg`, so the image you see here is also the file you'd open in draw.io. Click one to see its spec.
108
+
109
+ <table>
110
+ <tr>
111
+ <td width="50%" valign="top">Architecture, with AWS icons<br><br><a href="examples/serverless-aws.yaml"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/gallery/serverless-aws-dark.drawio.svg"><img alt="Architecture diagram: an uploader posts to an API Gateway, a Lambda presigns an S3 upload, and S3 events queue resize jobs for a second Lambda." src="docs/gallery/serverless-aws-light.drawio.svg" width="100%"></picture></a></td>
112
+ <td width="50%" valign="top">Flow<br><br><a href="examples/refund-flow.yaml"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/gallery/refund-flow-dark.drawio.svg"><img alt="Flowchart of a refund: look up the order, check the 30-day window and the amount, then auto-approve, queue for review or deny, and email the customer." src="docs/gallery/refund-flow-light.drawio.svg" width="100%"></picture></a></td>
113
+ </tr>
114
+ </table>
115
+
116
+ State
117
+
118
+ <a href="examples/job-state.yaml"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/gallery/job-state-dark.drawio.svg"><img alt="State diagram of a background job: queued, running, paused, waiting to retry, succeeded, failed and cancelled, with the transitions between them." src="docs/gallery/job-state-light.drawio.svg"></picture></a>
119
+
120
+ Sequence
121
+
122
+ <a href="examples/pay-sequence.yaml"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/gallery/pay-sequence-dark.drawio.svg"><img alt="Sequence diagram of a card payment: the web app posts to the checkout API, which writes the order and creates a Stripe PaymentIntent, with an alt frame for success and decline." src="docs/gallery/pay-sequence-light.drawio.svg"></picture></a>
123
+
124
+ ER
125
+
126
+ <a href="examples/shop-er.yaml"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/gallery/shop-er-dark.drawio.svg"><img alt="Entity relationship diagram of a shop: customers, addresses, orders, order items, products, inventory and payments, with keys and crow's-foot cardinality." src="docs/gallery/shop-er-light.drawio.svg"></picture></a>
127
+
105
128
  ## Styles
106
129
 
107
130
  Set `theme:` in the spec.
@@ -115,6 +138,21 @@ Set `theme:` in the spec.
115
138
  | `blueprint` | technical deep dives. Blue ink on pale drafting paper, monospace. |
116
139
  | `c4` | C4-model diagrams, in quiet blues and grays |
117
140
 
141
+ The same spec in each style:
142
+
143
+ <table>
144
+ <tr>
145
+ <td width="33%" align="center" valign="top"><code>clean</code><br><br><img alt="A web app, checkout API and orders database in the clean style" src="docs/gallery/style-clean.drawio.svg"></td>
146
+ <td width="33%" align="center" valign="top"><code>soft</code><br><br><img alt="A web app, checkout API and orders database in the soft style" src="docs/gallery/style-soft.drawio.svg"></td>
147
+ <td width="33%" align="center" valign="top"><code>dark</code><br><br><img alt="A web app, checkout API and orders database in the dark style" src="docs/gallery/style-dark.drawio.svg"></td>
148
+ </tr>
149
+ <tr>
150
+ <td width="33%" align="center" valign="top"><code>sketch</code><br><br><img alt="A web app, checkout API and orders database in the sketch style" src="docs/gallery/style-sketch.drawio.svg"></td>
151
+ <td width="33%" align="center" valign="top"><code>blueprint</code><br><br><img alt="A web app, checkout API and orders database in the blueprint style" src="docs/gallery/style-blueprint.drawio.svg"></td>
152
+ <td width="33%" align="center" valign="top"><code>c4</code><br><br><img alt="A web app, checkout API and orders database in the c4 style" src="docs/gallery/style-c4.drawio.svg"></td>
153
+ </tr>
154
+ </table>
155
+
118
156
  Color is for meaning. Nodes stay neutral by default, and errors and successes are red and green in every style. Mark the part the doc is about with `highlight: true` on a node or `==>` on an edge, and it's drawn in the style's accent color. Use `color: red` (or blue, violet, pink, orange, amber, green, teal, gray) for categories.
119
157
 
120
158
  Linework sizes every box from the real width of its text in the font it renders with, wrapping long labels instead of widening boxes. Each SVG carries a subset of that font (about 10 to 15KB), so text fits the same way on GitHub, in a browser or in a doc. `check` flags labels that overflow, overlap a box, another label, another edge or a group border, and `build` moves edge labels until they're clear.
@@ -76,10 +76,10 @@ function lightPaint(sw, neutral, text, muted, accent) {
76
76
  return (color, highlight) => {
77
77
  if (highlight) {
78
78
  const s = sw[color === 'neutral' || color === 'ink' ? accent : color];
79
- return { fill: s.solid, stroke: s.solid, text: '#FFFFFF', sub: 'rgba(255,255,255,0.78)', strokeWidth: 1 };
79
+ return { fill: s.solid, stroke: s.solid, text: '#FFFFFF', sub: 'rgba(255,255,255,0.9)', strokeWidth: 1 };
80
80
  }
81
81
  if (color === 'ink')
82
- return { fill: text, stroke: text, text: '#FFFFFF', sub: muted, strokeWidth: 1 };
82
+ return { fill: text, stroke: text, text: '#FFFFFF', sub: 'rgba(255,255,255,0.72)', strokeWidth: 1 };
83
83
  if (color === 'neutral')
84
84
  return { ...neutral, text, sub: muted, strokeWidth: 1 };
85
85
  const s = sw[color];
@@ -92,16 +92,16 @@ const CLEAN = {
92
92
  canvas: '#FFFFFF',
93
93
  text: '#17171C',
94
94
  muted: '#6B6B76',
95
- edge: '#A0A0AB',
95
+ edge: '#868691',
96
96
  edgeText: '#52525C',
97
97
  edgeWidth: 1.25,
98
98
  accent: 'blue',
99
99
  kindColors: false,
100
100
  radius: 10,
101
- group: { fill: '#F5F5F7', nested: '#EBEBEF', stroke: 'none', dashed: false, text: '#6B6B76' },
101
+ group: { fill: '#F5F5F7', nested: '#EBEBEF', stroke: 'none', dashed: false, text: '#61616C' },
102
102
  note: { fill: '#FFF4CC', stroke: 'none', text: '#5C4300' },
103
103
  well: '#F3F3F5',
104
- paint: lightPaint(LIGHT_SW, { fill: '#FFFFFF', stroke: '#DADAE0' }, '#17171C', '#6B6B76', 'blue'),
104
+ paint: lightPaint(LIGHT_SW, { fill: '#FFFFFF', stroke: '#C4C4CC' }, '#17171C', '#6B6B76', 'blue'),
105
105
  edgePaint: (color, hl) => {
106
106
  const s = LIGHT_SW[color ?? 'blue'];
107
107
  if (hl)
@@ -115,12 +115,12 @@ const SOFT = {
115
115
  name: 'soft',
116
116
  kindColors: true,
117
117
  radius: 12,
118
- group: { fill: '#F7F7F9', nested: '#EFEFF3', stroke: 'none', dashed: false, text: '#6B6B76' },
118
+ group: { fill: '#F7F7F9', nested: '#EFEFF3', stroke: 'none', dashed: false, text: '#61616C' },
119
119
  paint: (color, hl) => {
120
120
  if (hl || color === 'ink')
121
121
  return CLEAN.paint(color, hl);
122
122
  const c = color === 'neutral' ? 'gray' : color;
123
- return { fill: SOFT_TINT[c], stroke: 'none', text: c === 'gray' ? '#17171C' : LIGHT_SW[c].ink, sub: c === 'gray' ? '#6B6B76' : LIGHT_SW[c].ink + 'B3', strokeWidth: 1 };
123
+ return { fill: SOFT_TINT[c], stroke: 'none', text: c === 'gray' ? '#17171C' : LIGHT_SW[c].ink, sub: c === 'gray' ? '#6B6B76' : LIGHT_SW[c].ink + 'E0', strokeWidth: 1 };
124
124
  },
125
125
  };
126
126
  const DARK = {
@@ -141,7 +141,7 @@ const DARK = {
141
141
  paint: (color, hl) => {
142
142
  if (hl) {
143
143
  const s = DARK_SW[color === 'neutral' || color === 'ink' ? 'blue' : color];
144
- return { fill: s.solid, stroke: s.solid, text: '#FFFFFF', sub: 'rgba(255,255,255,0.78)', strokeWidth: 1 };
144
+ return { fill: s.solid, stroke: s.solid, text: '#FFFFFF', sub: 'rgba(255,255,255,0.9)', strokeWidth: 1 };
145
145
  }
146
146
  if (color === 'ink')
147
147
  return { fill: '#E6EDF3', stroke: '#E6EDF3', text: '#0D1117', sub: '#59636E', strokeWidth: 1 };
@@ -212,7 +212,7 @@ const BLUEPRINT = {
212
212
  accent: 'blue',
213
213
  kindColors: false,
214
214
  radius: 4,
215
- group: { fill: 'none', stroke: '#9DB0D0', dashed: true, text: '#5A6F92' },
215
+ group: { fill: 'none', stroke: '#7489AF', dashed: true, text: '#5A6F92' },
216
216
  note: { fill: '#EAF0FA', stroke: '#9DB0D0', text: BP_INK },
217
217
  well: '#EAF0FA',
218
218
  paint: (color, hl) => {
@@ -236,7 +236,7 @@ const BLUEPRINT = {
236
236
  // Other diagram types use `clean`.
237
237
  const C4_KIND = {
238
238
  person: { fill: '#1E3466', stroke: '#1E3466', text: '#FFFFFF', type: 'Person' },
239
- external: { fill: '#6B7383', stroke: '#6B7383', text: '#FFFFFF', type: 'External system' },
239
+ external: { fill: '#626A7A', stroke: '#626A7A', text: '#FFFFFF', type: 'External system' },
240
240
  };
241
241
  const C4_CONTAINER = { fill: '#456A9E', stroke: '#456A9E', text: '#FFFFFF', type: 'Container' };
242
242
  const C4 = { ...CLEAN, name: 'c4', group: { fill: 'none', stroke: '#444444', dashed: true, text: '#444444' }, edge: '#707070', edgeText: '#444444' };
@@ -250,7 +250,7 @@ export function paintFor(type, kind, theme, look = {}) {
250
250
  const t = palette(theme);
251
251
  if (theme === 'c4' && type === 'architecture' && !look.highlight && !look.color) {
252
252
  const c = C4_KIND[kind] ?? C4_CONTAINER;
253
- return { fill: c.fill, stroke: c.stroke, text: c.text, sub: 'rgba(255,255,255,0.8)', strokeWidth: 1 };
253
+ return { fill: c.fill, stroke: c.stroke, text: c.text, sub: 'rgba(255,255,255,0.92)', strokeWidth: 1 };
254
254
  }
255
255
  const k = kindDef(type, kind);
256
256
  // A highlight is the style's accent unless the spec names a color.
@@ -274,7 +274,9 @@ export function nodeStyle(type, kind, theme = 'clean', look = {}) {
274
274
  const r = t.radius;
275
275
  switch (k.shape) {
276
276
  case 'cylinder':
277
- return `shape=cylinder3;boundedLbl=1;backgroundOutline=1;size=7;${base}`;
277
+ // With an outline the lid is drawn, and the text centres on the face below
278
+ // it. Without one (soft) the lid is invisible: centre on the whole shape.
279
+ return `shape=cylinder3;boundedLbl=${stroke === 'none' ? 0 : 1};backgroundOutline=1;size=${CYL_SIZE};${base}`;
278
280
  case 'pill':
279
281
  return `rounded=1;arcSize=50;${base}`;
280
282
  case 'diamond':
@@ -298,6 +300,17 @@ export function nodeStyle(type, kind, theme = 'clean', look = {}) {
298
300
  return `rounded=1;absoluteArcSize=1;arcSize=${r * 2};${base}`;
299
301
  }
300
302
  }
303
+ /** Depth of a cylinder's lid and base curve. */
304
+ export const CYL_SIZE = 7;
305
+ /**
306
+ * Vertical centre of a node's text, from the top of its box. draw.io bounds a
307
+ * cylinder's label between its lid (2 x size) and 0.3 x size above the base.
308
+ */
309
+ export function textMiddle(style, h) {
310
+ if (!/shape=cylinder3/.test(style) || !/boundedLbl=1/.test(style))
311
+ return h / 2;
312
+ return (2 * CYL_SIZE + h - 0.3 * CYL_SIZE) / 2;
313
+ }
301
314
  /** Horizontal text padding inside a node, each side. */
302
315
  export const PAD_X = 16;
303
316
  /** Vertical text padding inside a node, each side. */
@@ -309,6 +322,8 @@ export function fieldStyle(theme = 'clean') {
309
322
  return ('text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;spacingLeft=0;spacingRight=0;overflow=fill;' +
310
323
  `rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;whiteSpace=wrap;${fontPart(t, t.type.size.field, t.text)}`);
311
324
  }
325
+ /** Left inset of a group's title. */
326
+ export const GROUP_INSET = 14;
312
327
  export function groupStyle(theme = 'clean', look = {}, depth = 0) {
313
328
  const t = palette(theme);
314
329
  let { fill, stroke, text } = t.group;
@@ -324,7 +339,7 @@ export function groupStyle(theme = 'clean', look = {}, depth = 0) {
324
339
  return (`rounded=1;absoluteArcSize=1;arcSize=${t.radius * 2 + 4};html=1;whiteSpace=wrap;container=1;collapsible=0;` +
325
340
  `fillColor=${fill};strokeColor=${stroke};strokeWidth=${look.highlight ? 1.5 : 1};` +
326
341
  (t.group.dashed ? dashes(t) : '') +
327
- 'verticalAlign=top;align=left;spacingLeft=14;spacingTop=9;' +
342
+ `verticalAlign=top;align=left;spacingLeft=${GROUP_INSET};spacingTop=9;` +
328
343
  `${fontPart(t, t.type.size.group, text)}fontStyle=1;${t.extra}`);
329
344
  }
330
345
  /** Group title HTML: sentence case, except blueprint's drafting capitals. */
@@ -358,8 +373,10 @@ export function lifelineStyle(kind, theme = 'clean', headH = 48, look = {}) {
358
373
  const t = palette(theme);
359
374
  const c = paintFor('sequence', kind, theme, look);
360
375
  const k = kindDef('sequence', kind);
361
- const stroke = c.stroke === 'none' ? (k.dashed ? '#A1A1AA' : c.fill) : c.stroke;
362
- // The lifeline's dashed stem uses the stroke colour: keep it quiet even when the head is loud.
376
+ // The dashed stem takes the stroke color, so a highlighted head keeps the plain
377
+ // participant line: the accent stays on the head and the highlighted messages.
378
+ const quiet = t.paint('neutral', false).stroke;
379
+ const stroke = look.highlight ? (quiet === 'none' ? t.edge : quiet) : c.stroke === 'none' ? (k.dashed ? '#A1A1AA' : c.fill) : c.stroke;
363
380
  return (`shape=umlLifeline;perimeter=lifelinePerimeter;container=1;collapsible=0;recursiveResize=0;outlineConnect=0;size=${headH};` +
364
381
  `html=1;whiteSpace=wrap;rounded=1;absoluteArcSize=1;arcSize=${t.radius * 2};fillColor=${c.fill};strokeColor=${stroke};strokeWidth=${Math.min(1.5, c.strokeWidth)};` +
365
382
  `spacingLeft=${PAD_X - 4};spacingRight=${PAD_X - 4};` +
@@ -384,12 +401,17 @@ export function messageStyle(style, theme = 'clean', self = false, look = {}) {
384
401
  (self ? 'edgeStyle=orthogonalEdgeStyle;rounded=1;align=left;spacingLeft=6;verticalAlign=middle;' : '') +
385
402
  `${fontPart(t, t.type.size.edge, p.text)}${look.highlight ? 'fontStyle=1;' : ''}labelBackgroundColor=${t.canvas};labelPadding=1 5;${t.extra}`);
386
403
  }
404
+ /** Padding of a sequence frame's operator tab, and its inset from the frame's corner. */
405
+ export const FRAME_TAB_X = 9, FRAME_TAB_Y = 3, FRAME_TAB_INSET = 4;
387
406
  export function frameStyle(theme = 'clean') {
388
407
  const t = palette(theme);
389
- const line = t.name === 'dark' ? '#3D444D' : t.name === 'clean' || t.name === 'soft' || t.name === 'c4' ? '#DADAE0' : t.muted;
390
- // The operator (alt, loop, opt) sits in the corner as a small label, not a UML tab.
391
- return (`rounded=1;absoluteArcSize=1;arcSize=${t.radius * 2};whiteSpace=wrap;html=1;container=0;verticalAlign=top;align=left;spacing=0;spacingLeft=10;spacingTop=4;` +
392
- `fillColor=none;strokeColor=${line};strokeWidth=1;${fontPart(t, t.type.size.group, t.muted)}fontStyle=1;${t.extra}`);
408
+ // The operator (alt, loop, opt) sits in a small tab in the frame's corner: UML's
409
+ // pentagon, drawn as a chip, inset the same from both sides so its square corner
410
+ // stays inside the frame's rounded one. draw.io draws an HTML label's background
411
+ // 1px left of and 3.5px below where its spacing puts it; the +1 and -3.5 cancel that.
412
+ return (`rounded=1;absoluteArcSize=1;arcSize=${t.radius * 2};whiteSpace=wrap;html=1;container=0;verticalAlign=top;align=left;spacing=0;spacingLeft=${FRAME_TAB_X + FRAME_TAB_INSET + 1};spacingTop=${FRAME_TAB_Y + FRAME_TAB_INSET - 3.5};` +
413
+ `labelBackgroundColor=${t.well};labelPadding=${FRAME_TAB_Y} ${FRAME_TAB_X};` +
414
+ `fillColor=none;strokeColor=${t.edge};strokeWidth=1;${fontPart(t, t.type.size.group, t.text)}fontStyle=1;${t.extra}`);
393
415
  }
394
416
  export function guardStyle(theme = 'clean') {
395
417
  const t = palette(theme);
@@ -397,7 +419,7 @@ export function guardStyle(theme = 'clean') {
397
419
  }
398
420
  export function separatorStyle(theme = 'clean') {
399
421
  const t = palette(theme);
400
- return `endArrow=none;dashed=1;dashPattern=4 4;html=1;strokeColor=${t.name === 'dark' ? '#3D444D' : t.name === 'clean' || t.name === 'soft' || t.name === 'c4' ? '#DADAE0' : t.muted};`;
422
+ return `endArrow=none;dashed=1;dashPattern=4 4;html=1;strokeColor=${t.edge};`;
401
423
  }
402
424
  export function noteStyle(theme = 'clean') {
403
425
  const t = palette(theme);
@@ -1,7 +1,7 @@
1
1
  import { kindDef } from '../spec/model.js';
2
2
  import { DOMParser, XMLSerializer } from '@xmldom/xmldom';
3
3
  import { ICON_PAD, ICON_SIZE, resolveIcon } from './icons.js';
4
- import { ER_HEAD, ER_ROW, edgeStyle, erEdgeStyle, fieldLabel, fieldStyle, groupLabel, groupStyle, nodeLabel, nodeStyle, paintFor, palette } from './theme.js';
4
+ import { CYL_SIZE, ER_HEAD, ER_ROW, edgeStyle, erEdgeStyle, fieldLabel, fieldStyle, groupLabel, groupStyle, nodeLabel, nodeStyle, paintFor, palette, textMiddle, typeface } from './theme.js';
5
5
  export { ER_HEAD, ER_ROW };
6
6
  export const FORMAT_VERSION = '1';
7
7
  export const xmlEsc = (s) => s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '&#10;');
@@ -72,7 +72,8 @@ export function nodeChildren(d, n, box) {
72
72
  parent: n.id,
73
73
  attrs: { label: '', aiviz_type: 'icon' },
74
74
  style: r.style,
75
- box: { x: (ICON_PAD - ICON_SIZE) / 2 + 2, y: Math.round((box.h - ICON_SIZE) / 2), w: ICON_SIZE, h: ICON_SIZE },
75
+ // Level with the text, which on a cylinder sits below the lid.
76
+ box: { x: (ICON_PAD - ICON_SIZE) / 2 + 2, y: Math.round(textMiddle(styleFor(d, n), box.h) - ICON_SIZE / 2), w: ICON_SIZE, h: ICON_SIZE },
76
77
  });
77
78
  }
78
79
  return out;
@@ -115,12 +116,38 @@ export const groupStyleFor = (d, g) => groupStyle(d.theme, g, groupDepth(d, g));
115
116
  export function groupAttrs(g, theme = 'clean') {
116
117
  return { label: groupLabel(g.label, theme), aiviz_type: 'group', aiviz_label: g.label, ...lookAttrs(g) };
117
118
  }
119
+ /**
120
+ * Layout sizes a decision's diamond from its text wrapped narrow (see nodeSizes),
121
+ * but draw.io wraps at the diamond's full width, so a long question runs out over
122
+ * the slanted sides. Inset the label to the width layout measured.
123
+ */
124
+ function diamondInset(d, n, box) {
125
+ if (kindDef(d.type, n.kind).shape !== 'diamond')
126
+ return '';
127
+ const text = (box.w - 40) / 1.5;
128
+ const side = Math.max(0, Math.floor((box.w - text - 10) / 2));
129
+ return `spacingLeft=${side};spacingRight=${side};`;
130
+ }
131
+ /**
132
+ * Where a vertical edge at `x` meets a cylinder's curved top or base, as a
133
+ * fraction of its height. Off centre the curve sits inside the bounding box, and
134
+ * an arrow stopped at the box would float short of it.
135
+ */
136
+ function rimY(p, b) {
137
+ const top = Math.abs(p.y - b.y) < 0.5, base = Math.abs(p.y - (b.y + b.h)) < 0.5;
138
+ if (!top && !base)
139
+ return undefined;
140
+ const u = Math.min(1, Math.abs(p.x - (b.x + b.w / 2)) / (b.w / 2));
141
+ const dip = CYL_SIZE * (1 - Math.sqrt(1 - u * u));
142
+ return top ? dip / b.h : (b.h - dip) / b.h;
143
+ }
118
144
  /** Exit/entry constraints so draw.io leaves and enters nodes where ELK did. */
119
- export function portStyle(points, s, t) {
145
+ export function portStyle(points, s, t, cyl = {}) {
120
146
  const start = points[0], end = points[points.length - 1];
121
147
  const c01 = (v) => Math.min(1, Math.max(0, Math.round(v * 1000) / 1000));
122
- return (`exitX=${c01((start.x - s.x) / s.w)};exitY=${c01((start.y - s.y) / s.h)};exitDx=0;exitDy=0;` +
123
- `entryX=${c01((end.x - t.x) / t.w)};entryY=${c01((end.y - t.y) / t.h)};entryDx=0;entryDy=0;`);
148
+ const sr = cyl.from ? rimY(start, s) : undefined, tr = cyl.to ? rimY(end, t) : undefined;
149
+ return (`exitX=${c01((start.x - s.x) / s.w)};exitY=${c01(sr ?? (start.y - s.y) / s.h)};exitDx=0;exitDy=0;${sr !== undefined ? 'exitPerimeter=0;' : ''}` +
150
+ `entryX=${c01((end.x - t.x) / t.w)};entryY=${c01(tr ?? (end.y - t.y) / t.h)};entryDx=0;entryDy=0;${tr !== undefined ? 'entryPerimeter=0;' : ''}`);
124
151
  }
125
152
  export function sceneFromLayout(d, l) {
126
153
  const parentOf = new Map();
@@ -139,43 +166,140 @@ export function sceneFromLayout(d, l) {
139
166
  const vertices = [];
140
167
  for (const g of d.groups) {
141
168
  const box = rel(g.id);
142
- vertices.push({ id: g.id, parent: g.parent ?? '1', attrs: { ...groupAttrs(g, d.theme), aiviz_auto: autoAttr(box) }, style: groupStyleFor(d, g), box });
169
+ const inset = l.titleInset?.get(g.id);
170
+ vertices.push({ id: g.id, parent: g.parent ?? '1', attrs: { ...groupAttrs(g, d.theme), aiviz_auto: autoAttr(box) }, style: groupStyleFor(d, g) + (inset ? `spacingLeft=${inset};` : ''), box });
143
171
  }
144
172
  for (const n of d.nodes) {
145
173
  const box = rel(n.id);
146
- vertices.push({ id: n.id, parent: n.parent ?? '1', attrs: { ...nodeAttrs(d, n), aiviz_auto: autoAttr(box) }, style: styleFor(d, n), box });
174
+ vertices.push({ id: n.id, parent: n.parent ?? '1', attrs: { ...nodeAttrs(d, n), aiviz_auto: autoAttr(box) }, style: styleFor(d, n) + diamondInset(d, n, box), box });
147
175
  vertices.push(...nodeChildren(d, n, box));
148
176
  }
177
+ const isCyl = (id) => { const n = d.nodes.find((x) => x.id === id); return !!n && kindDef(d.type, n.kind).shape === 'cylinder'; };
149
178
  const edges = d.edges.map((e) => {
150
179
  const le = l.edges.get(e.id);
180
+ const cyl = { from: isCyl(e.from), to: isCyl(e.to) };
151
181
  const base = { id: edgeCellId(e.id), parent: '1', attrs: edgeAttrs(e), style: edgeStyleFor(d, e, labelBg(d, l, le?.label)), source: e.from, target: e.to };
152
182
  if (!le)
153
183
  return base;
184
+ if (e.from === e.to && le.label && e.label && le.points.length === 4) {
185
+ // A self-loop: the label sits beside the loop's outer run, not on it.
186
+ const [, a, b] = le.points;
187
+ const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
188
+ return {
189
+ ...base,
190
+ style: base.style + portStyle(le.points, abs(e.from), abs(e.to), cyl),
191
+ points: le.points.slice(1, -1),
192
+ labelX: 0.001,
193
+ labelOffset: { x: le.label.x + le.label.w / 2 - mid.x, y: le.label.y + le.label.h / 2 - mid.y },
194
+ };
195
+ }
154
196
  return {
155
197
  ...base,
156
- style: base.style + portStyle(le.points, abs(e.from), abs(e.to)),
198
+ style: base.style + portStyle(le.points, abs(e.from), abs(e.to), cyl),
157
199
  points: le.points.slice(1, -1),
158
- labelX: le.label && e.label ? (labelSpot(le.points, le.label) ?? labelFraction(le.points, { x: le.label.x + le.label.w / 2, y: le.label.y + le.label.h / 2 })) : undefined,
200
+ labelX: le.label && e.label ? (labelSpot(le.points, le.label, l.direction) ?? labelFraction(le.points, { x: le.label.x + le.label.w / 2, y: le.label.y + le.label.h / 2 })) : undefined,
159
201
  };
160
202
  });
203
+ alignLabels(d, l, edges);
161
204
  return { name: d.title ?? 'Diagram', background: palette(d.theme).canvas, meta: diagramMeta(d), vertices, edges, raw: [] };
162
205
  }
206
+ /** The point at a draw.io label fraction (-1..1) along a route, and the run it sits on. */
207
+ function spotAt(pts, frac) {
208
+ const lens = pts.slice(1).map((b, i) => Math.hypot(b.x - pts[i].x, b.y - pts[i].y));
209
+ let at = ((frac + 1) / 2) * lens.reduce((x, y) => x + y, 0);
210
+ for (let i = 0; i < lens.length; i++) {
211
+ const a = pts[i], b = pts[i + 1], len = lens[i];
212
+ if (at <= len + 1e-6 || i === lens.length - 1) {
213
+ const t = len ? Math.min(1, at / len) : 0;
214
+ return { p: { x: a.x + t * (b.x - a.x), y: a.y + t * (b.y - a.y) }, a, b };
215
+ }
216
+ at -= len;
217
+ }
218
+ return undefined;
219
+ }
220
+ /**
221
+ * Labels on neighbouring parallel runs (two edges dropping side by side) sit at
222
+ * whatever height each run's midpoint gives, often a few px apart. Put them on
223
+ * one line when each can move there and stay on its own run.
224
+ */
225
+ function alignLabels(d, l, edges) {
226
+ const h = Math.ceil(typeface(d.theme).size.edge * 1.2) + 4;
227
+ const spots = edges.flatMap((se) => {
228
+ const e = d.edges.find((x) => edgeCellId(x.id) === se.id);
229
+ const le = e && l.edges.get(e.id);
230
+ if (!le || se.labelX === undefined || se.labelOffset || !e.label)
231
+ return [];
232
+ const s = spotAt(le.points, se.labelX);
233
+ if (!s)
234
+ return [];
235
+ const vertical = Math.abs(s.a.x - s.b.x) < 0.5;
236
+ if (!vertical && Math.abs(s.a.y - s.b.y) >= 0.5)
237
+ return [];
238
+ return [{ se, pts: le.points, ...s, vertical }];
239
+ });
240
+ for (const vertical of [true, false]) {
241
+ const along = (p) => (vertical ? p.y : p.x), across = (p) => (vertical ? p.x : p.y);
242
+ const group = spots.filter((s) => s.vertical === vertical).sort((a, b) => along(a.p) - along(b.p));
243
+ for (let i = 0; i < group.length;) {
244
+ let j = i + 1;
245
+ while (j < group.length && along(group[j].p) - along(group[i].p) <= 8)
246
+ j++;
247
+ const set = group.slice(i, j);
248
+ i = j;
249
+ if (set.length < 2)
250
+ continue;
251
+ // Side by side on different runs, not one run's labels stacked.
252
+ const xs = set.map((s) => across(s.p));
253
+ if (Math.max(...xs) - Math.min(...xs) < 20)
254
+ continue;
255
+ const target = Math.round(set.reduce((m, s) => m + along(s.p), 0) / set.length);
256
+ const fits = set.every((s) => {
257
+ const lo = Math.min(along(s.a), along(s.b)) + h / 2 + 4, hi = Math.max(along(s.a), along(s.b)) - h / 2 - 4;
258
+ return target >= lo && target <= hi;
259
+ });
260
+ if (!fits)
261
+ continue;
262
+ for (const s of set) {
263
+ const c = vertical ? { x: s.p.x, y: target } : { x: target, y: s.p.y };
264
+ s.se.labelX = labelFraction(s.pts, c);
265
+ }
266
+ }
267
+ }
268
+ }
163
269
  const attrString = (attrs) => Object.entries(attrs)
164
270
  .filter(([k]) => k !== 'id')
165
271
  .map(([k, v]) => ` ${k}="${xmlEsc(v)}"`)
166
272
  .join('');
167
273
  export function serialize(scene) {
168
- // Parents before children.
169
274
  const parentOf = new Map(scene.vertices.map((v) => [v.id, v.parent]));
170
- const depth = (id) => {
171
- const p = parentOf.get(id);
172
- return p && p !== '1' ? 1 + depth(p) : 0;
173
- };
174
- // Containers first (outermost first), then everything else in its original order,
175
- // which show() reads back and ELK uses as a placement hint.
176
- const isParent = new Set(scene.vertices.map((v) => v.parent));
177
- const containers = scene.vertices.filter((v) => isParent.has(v.id) || v.attrs.aiviz_type === 'group').sort((a, b) => depth(a.id) - depth(b.id));
178
- const vertices = [...containers, ...scene.vertices.filter((v) => !containers.includes(v))];
275
+ // Every cell after its parent, otherwise in the scene's order, which is spec
276
+ // order: show() reads it back and ELK uses it as a placement hint. Groups go
277
+ // first so nodes can sit in them; a node's own children (icon, table rows)
278
+ // already follow it.
279
+ const groups = scene.vertices.filter((v) => v.attrs.aiviz_type === 'group');
280
+ const rest = scene.vertices.filter((v) => v.attrs.aiviz_type !== 'group');
281
+ const placed = new Set(['1']);
282
+ const vertices = [];
283
+ for (const list of [groups, rest]) {
284
+ let todo = [...list];
285
+ while (todo.length) {
286
+ const left = [];
287
+ for (const v of todo) {
288
+ if (placed.has(v.parent) || !parentOf.has(v.parent)) {
289
+ vertices.push(v);
290
+ placed.add(v.id);
291
+ }
292
+ else
293
+ left.push(v);
294
+ }
295
+ // A cycle or a dangling parent: write the rest as they are rather than drop them.
296
+ if (left.length === todo.length) {
297
+ vertices.push(...left);
298
+ break;
299
+ }
300
+ todo = left;
301
+ }
302
+ }
179
303
  const cells = [];
180
304
  for (const v of vertices) {
181
305
  const b = v.box;
@@ -188,6 +312,7 @@ export function serialize(scene) {
188
312
  const geom = e.geomRaw ??
189
313
  `<mxGeometry ${e.labelX ? `x="${e.labelX.toFixed(3)}" ` : ''}relative="1" as="geometry">` +
190
314
  (e.points?.length ? `<Array as="points">${e.points.map((p) => `<mxPoint x="${r(p.x)}" y="${r(p.y)}"/>`).join('')}</Array>` : '') +
315
+ (e.labelOffset ? `<mxPoint x="${r(e.labelOffset.x)}" y="${r(e.labelOffset.y)}" as="offset"/>` : '') +
191
316
  `</mxGeometry>`;
192
317
  cells.push(`<object id="${xmlEsc(e.id)}"${attrString({ label: '', ...e.attrs })}>` +
193
318
  `<mxCell style="${xmlEsc(e.style)}" edge="1" parent="${xmlEsc(e.parent)}"` +
@@ -209,16 +334,21 @@ export function toMxfile(d, l) {
209
334
  * source node, which reads as a caption of that node. Returns draw.io's -1..1
210
335
  * fraction, or undefined when no run is long enough.
211
336
  */
212
- function labelSpot(pts, label) {
337
+ function labelSpot(pts, label, dir) {
213
338
  let total = 0;
214
339
  let best;
340
+ // Runs along the flow belong to one edge; runs across it are often shared
341
+ // corridors where several edges' labels would stack up. Prefer the former.
342
+ const flowVertical = dir === 'TB' || dir === 'BT';
215
343
  for (let i = 0; i < pts.length - 1; i++) {
216
344
  const a = pts[i], b = pts[i + 1];
217
345
  const len = Math.hypot(b.x - a.x, b.y - a.y);
218
346
  const vertical = Math.abs(b.x - a.x) < Math.abs(b.y - a.y);
219
347
  const room = len - (vertical ? label.h : label.w) - 20;
220
- if (room >= 0 && (!best || room > best.room))
221
- best = { room, at: total + len / 2 };
348
+ const along = dir ? vertical === flowVertical : true;
349
+ const better = !best || (along && !best.along) || (along === best.along && room > best.room);
350
+ if (room >= 0 && better)
351
+ best = { room, at: total + len / 2, along };
222
352
  total += len;
223
353
  }
224
354
  return best && total ? (best.at / total) * 2 - 1 : undefined;