@stocksharp/diagram 1.4.0 → 1.5.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.
Files changed (41) hide show
  1. package/README.md +84 -1
  2. package/dist/esm/canvas-renderer.js +169 -26
  3. package/dist/esm/canvas-renderer.js.map +1 -1
  4. package/dist/esm/color.js +42 -0
  5. package/dist/esm/color.js.map +1 -1
  6. package/dist/esm/core/document.js +11 -0
  7. package/dist/esm/core/document.js.map +1 -1
  8. package/dist/esm/core/model.js.map +1 -1
  9. package/dist/esm/diagram/stocksharp-diagram.js +31 -3
  10. package/dist/esm/diagram/stocksharp-diagram.js.map +1 -1
  11. package/dist/esm/embed.js +104 -20
  12. package/dist/esm/embed.js.map +1 -1
  13. package/dist/esm/index.js +1 -1
  14. package/dist/esm/index.js.map +1 -1
  15. package/dist/ssdiagram-headless.js +197 -22
  16. package/dist/ssdiagram-headless.js.map +2 -2
  17. package/dist/ssdiagram.js +310 -44
  18. package/dist/ssdiagram.js.map +3 -3
  19. package/dist/types/canvas-renderer.d.ts +25 -3
  20. package/dist/types/canvas-renderer.d.ts.map +1 -1
  21. package/dist/types/color.d.ts +7 -0
  22. package/dist/types/color.d.ts.map +1 -1
  23. package/dist/types/core/model.d.ts +7 -0
  24. package/dist/types/core/model.d.ts.map +1 -1
  25. package/dist/types/diagram/api.d.ts +7 -0
  26. package/dist/types/diagram/api.d.ts.map +1 -1
  27. package/dist/types/diagram/stocksharp-diagram.d.ts +14 -0
  28. package/dist/types/diagram/stocksharp-diagram.d.ts.map +1 -1
  29. package/dist/types/embed.d.ts +21 -0
  30. package/dist/types/embed.d.ts.map +1 -1
  31. package/dist/types/index.d.ts +2 -2
  32. package/dist/types/index.d.ts.map +1 -1
  33. package/package.json +1 -1
  34. package/src/canvas-renderer.ts +172 -26
  35. package/src/color.ts +48 -0
  36. package/src/core/document.ts +12 -0
  37. package/src/core/model.ts +7 -0
  38. package/src/diagram/api.ts +7 -0
  39. package/src/diagram/stocksharp-diagram.ts +38 -3
  40. package/src/embed.ts +147 -29
  41. package/src/index.ts +2 -0
package/README.md CHANGED
@@ -177,6 +177,89 @@ option and `setFullscreenLabels()` on `StockSharpDiagram`. With neither the
177
177
  attribute nor the option, the wording comes from the bundle, so a page that has
178
178
  already been translated needs no per-diagram markup at all.
179
179
 
180
+ Two more attributes change what a host is rather than what it says:
181
+ `data-diagram-kind="document"` marks a generic diagram document (see
182
+ *Architecture diagrams* below), and `data-diagram-export="off"` removes the
183
+ download button and the export entries for that one diagram, the same as the
184
+ `allowExport: false` option.
185
+
186
+ ### Architecture diagrams: generic documents
187
+
188
+ The same engine draws pictures that are not strategies - a system architecture,
189
+ a data flow, a deployment - from a generic diagram document. Such a document
190
+ names its own nodes, colours and zones, so it needs no palette:
191
+
192
+ ```ts
193
+ import { createDiagramDocument, renderDocument } from '@stocksharp/diagram';
194
+
195
+ const drawing = createDiagramDocument({
196
+ nodes: [
197
+ {
198
+ id: 'gate', name: 'Gateway', subtitle: 'FIX / REST / WebSocket',
199
+ description: 'Shown as the hover tooltip',
200
+ color: 'var(--ss-red, #c0392b)', border: 'var(--ss-red-dark, #7a1f16)',
201
+ x: 0, y: 0, outPorts: [{ id: 'out', name: 'Orders' }],
202
+ },
203
+ { id: 'core', name: 'Matching engine', x: 420, y: 0, inPorts: [{ id: 'in', name: 'Orders' }] },
204
+ ],
205
+ links: [{
206
+ from: { nodeId: 'gate', portId: 'out' },
207
+ to: { nodeId: 'core', portId: 'in' },
208
+ label: 'SBE', // short text at the middle of the routed wire
209
+ style: 'dashed', // 'solid' (default) or 'dashed'
210
+ }],
211
+ zones: [{ id: 'colo', name: 'Colocation', x: -60, y: -60, width: 760, height: 200, color: 'var(--ss-gold)' }],
212
+ });
213
+
214
+ const handle = renderDocument(document.querySelector<HTMLElement>('#architecture')!, drawing);
215
+ ```
216
+
217
+ - `subtitle` is an optional second line under the node name, smaller and
218
+ muted; the node grows to fit it. `label` is an optional short text drawn on a
219
+ plate of the canvas colour at the middle of the link. Both are left out of a
220
+ saved document when empty, so documents written before them keep their shape.
221
+ - Zones count as content: `zoomToFit()` and `takeScreenshot` / `takeSvg` with
222
+ `scope: 'content'` frame every zone and its caption, not only the nodes.
223
+ - Node `color` / `border` and zone `color` may name a page token -
224
+ `var(--ss-red)`, with or without a fallback. It is resolved against the element
225
+ the diagram is mounted in on every repaint, so the picture follows the page's
226
+ theme; the saved document keeps the reference, and an SVG export carries the
227
+ resolved colour. A token that is unset and has no fallback gets the default
228
+ node, border or zone colour.
229
+
230
+ `renderDocument(div, document, options)` mounts a read-only viewer: no minimap,
231
+ no link validation, the canvas and grid colours read from `--diagram-bg` /
232
+ `--diagram-grid` on the page (repainted when `data-theme` changes on `<html>`,
233
+ which also re-reads every token colour), and a `ResizeObserver` that keeps the
234
+ drawing fitted. It accepts a saved document or one written by hand, leaving out
235
+ whatever has a default, and throws `DiagramDocumentError` for anything else. It
236
+ takes the same `onFullscreenRequested`, `onExportRequested` and `onDestroyed`
237
+ hooks as `renderScheme`, plus:
238
+
239
+ - `pageScroll` - on by default for a document: a plain wheel scrolls the page and
240
+ only Ctrl/Meta+wheel (and a trackpad pinch) zooms; on touch screens a
241
+ one-finger vertical swipe scrolls the page (`touch-action: pan-y`), while a
242
+ horizontal drag pans the diagram and a two-finger pinch zooms it. Pass `false`
243
+ to keep every gesture in the diagram. `StockSharpDiagram` has the same switch
244
+ as the `pageScroll` option and `setPageScroll()`; it is off there by default.
245
+ - `allowExport: false` - no download button and no export entries in the menu,
246
+ even when `onExportRequested` is passed (`StockSharpDiagram.setExportEnabled()`
247
+ underneath).
248
+
249
+ Pages that use `renderAll()` mark such a host with `data-diagram-kind="document"`
250
+ and give it the document inline or through `data-diagram-src`. A host without
251
+ the attribute is still recognised when its JSON has a numeric `version` and a
252
+ `nodes` array at the root, which a saved strategy never has; strategy hosts are
253
+ drawn exactly as before.
254
+
255
+ ```html
256
+ <div class="ss-diagram-host" data-diagram-kind="document" data-diagram-export="off">
257
+ <script type="application/json">
258
+ { "nodes": [{ "id": "a", "name": "Client", "subtitle": "FIX" }], "links": [] }
259
+ </script>
260
+ </div>
261
+ ```
262
+
180
263
  ### Node actions and errors
181
264
 
182
265
  Double-click handling is opt-in. Give only the node types controlled by the
@@ -354,7 +437,7 @@ The component has one document model and a separate rendering layer:
354
437
  | Document core | `src/core/*` | Versioned document serialization plus independent runtime, view and selection state |
355
438
  | Public component API | `src/diagram/stocksharp-diagram.ts` | `StockSharpDiagram`: catalog-aware nodes, ports, validation, events, persistence, history and theming |
356
439
  | Models and palette | `src/diagram/{types,catalog,palette}.ts` | Public data model and draggable HTML element palette |
357
- | Web embed | `src/embed.ts` | Self-contained read-only rendering for web applications |
440
+ | Web embed | `src/embed.ts` | Self-contained read-only rendering of strategy schemes and generic documents for web applications |
358
441
  | Canvas renderer | `src/canvas-renderer.ts` | Internal drawing, routing, selection, editing, zoom, touch and overview |
359
442
 
360
443
  Applications use `StockSharpDiagram` or the read-only embed. The renderer is
@@ -1,5 +1,5 @@
1
1
  import { SvgSurface } from './svg-surface.js';
2
- import { cappedLightness, legibleOn, readableOutlineOn, readableTextOn } from './color.js';
2
+ import { cappedLightness, legibleOn, readableOutlineOn, readableTextOn, resolveCssVariables } from './color.js';
3
3
  // Internal dependency-free canvas renderer used by StockSharpDiagram.
4
4
  //
5
5
  // Dependency-free, pure 2D canvas. Demonstrates the hard parts: typed
@@ -104,6 +104,7 @@ export class NodeModel {
104
104
  this.id = id;
105
105
  this.typeId = init.typeId ?? id;
106
106
  this.name = init.name;
107
+ this.subtitle = init.subtitle ?? '';
107
108
  this.description = init.description ?? '';
108
109
  this.groupName = init.groupName ?? 'Common';
109
110
  this.color = init.color ?? '#d7d7d7';
@@ -129,6 +130,7 @@ export class NodeModel {
129
130
  id: this.id,
130
131
  typeId: this.typeId,
131
132
  name: this.name,
133
+ ...(this.subtitle === '' ? {} : { subtitle: this.subtitle }),
132
134
  description: this.description,
133
135
  groupName: this.groupName,
134
136
  color: this.color,
@@ -151,12 +153,13 @@ export class NodeModel {
151
153
  }
152
154
  }
153
155
  export class LinkModel {
154
- constructor(from, fromPort, to, toPort, id = '', metadata, style = 'solid') {
156
+ constructor(from, fromPort, to, toPort, id = '', metadata, style = 'solid', label = '') {
155
157
  this.from = from;
156
158
  this.fromPort = fromPort;
157
159
  this.to = to;
158
160
  this.toPort = toPort;
159
161
  this.style = style;
162
+ this.label = label;
160
163
  this.id = id;
161
164
  this.metadata = copyJsonObject(metadata);
162
165
  }
@@ -169,6 +172,7 @@ export class LinkModel {
169
172
  to: this.to,
170
173
  toPort: this.toPort,
171
174
  style: this.style,
175
+ ...(this.label === '' ? {} : { label: this.label }),
172
176
  metadata: copyJsonObject(this.metadata),
173
177
  };
174
178
  }
@@ -189,6 +193,15 @@ const INTRO_MS = 520; // entrance animation duration
189
193
  const INTRO_RISE = 70; // px the scheme rises from below
190
194
  const ERROR_FLASH_MS = 1100;
191
195
  const ERROR_RED = '#f6465d';
196
+ const DEFAULT_NODE_COLOR = '#d7d7d7';
197
+ const DEFAULT_NODE_BORDER = '#8c8c8c';
198
+ const DEFAULT_ZONE_TINT = '#8a8f98';
199
+ const TITLE_FONT = '600 12px Segoe UI, Tahoma, sans-serif';
200
+ const SUBTITLE_FONT = '11px Segoe UI, Tahoma, sans-serif';
201
+ const SUBTITLE_MIN_H = 62; // body height that fits a name and a subtitle line
202
+ const LINK_LABEL_FONT = '600 10px Segoe UI, Tahoma, sans-serif';
203
+ const ZONE_CAPTION_TOP = 9; // caption offset inside the zone's top-left corner
204
+ const ZONE_CAPTION_H = 14;
192
205
  const ERROR_BACKGROUND = '#7d2632';
193
206
  const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
194
207
  function hashHue(s) {
@@ -290,6 +303,9 @@ export class Diagram {
290
303
  this.ovGeo = null;
291
304
  this.validator = null;
292
305
  this.handlers = new Map();
306
+ // Computed style of the host, read at most once per frame: colours written as var(--token)
307
+ // resolve against it, so a frame drawn after a theme switch picks up the new values.
308
+ this.hostStyle = null;
293
309
  this.opts = opts;
294
310
  // Same normalization setTypeColors applies, so a palette handed in at construction is
295
311
  // keyed like one handed in later.
@@ -297,6 +313,7 @@ export class Diagram {
297
313
  this.setTypeColorsInternal(opts.typeColors);
298
314
  this.host = opts.host;
299
315
  this.gridSnapEnabled = opts.gridSnap ?? false;
316
+ this.pageScroll = opts.pageScroll ?? false;
300
317
  this.gridSize = this.normalizeGridSize(opts.gridSize ?? DEFAULT_GRID_SIZE);
301
318
  this.history = new DiagramCommandHistory(() => {
302
319
  // The gated values, not the raw stack: setInteractionPermissions and setReadOnly
@@ -308,9 +325,9 @@ export class Diagram {
308
325
  this.canvas.style.display = 'block';
309
326
  this.canvas.tabIndex = 0; // focusable → key events
310
327
  this.canvas.style.outline = 'none';
311
- // Eat browser-native touch gestures (page scroll / pinch-zoom) so
312
- // fingers actually drive our pan/zoom on mobile instead of the page.
313
- this.canvas.style.touchAction = 'none';
328
+ // Eat browser-native touch gestures (page scroll / pinch-zoom) so fingers actually drive our
329
+ // pan/zoom on mobile instead of the page - unless the page asked to keep its scrolling.
330
+ this.applyTouchAction();
314
331
  // No OS text-selection on long-press (would pop the iOS magnifier
315
332
  // and select node/link captions instead of firing our menu).
316
333
  this.canvas.style.userSelect = 'none';
@@ -368,6 +385,17 @@ export class Diagram {
368
385
  this.gridSize = nextSize;
369
386
  this.scheduleDraw();
370
387
  }
388
+ /** See DiagramOptions.pageScroll. */
389
+ setPageScroll(enabled) {
390
+ this.pageScroll = enabled;
391
+ this.applyTouchAction();
392
+ }
393
+ isPageScrollEnabled() { return this.pageScroll; }
394
+ // pan-y hands a vertical swipe to the browser, which scrolls the page; a horizontal one and a
395
+ // two-finger pinch still reach the canvas.
396
+ applyTouchAction() {
397
+ this.canvas.style.touchAction = this.pageScroll ? 'pan-y' : 'none';
398
+ }
371
399
  getGridSnap() {
372
400
  return { enabled: this.gridSnapEnabled, size: this.gridSize };
373
401
  }
@@ -601,7 +629,7 @@ export class Diagram {
601
629
  if (!validation.allowed)
602
630
  return false;
603
631
  }
604
- const link = new LinkModel(init.from, init.fromPort, init.to, init.toPort, init.id ?? this.nextLinkId(), init.metadata, init.style ?? 'solid');
632
+ const link = new LinkModel(init.from, init.fromPort, init.to, init.toPort, init.id ?? this.nextLinkId(), init.metadata, init.style ?? 'solid', init.label ?? '');
605
633
  if (this.links.some((l) => l.id === link.id))
606
634
  return false;
607
635
  this.links.splice(clamp(Math.trunc(index), 0, this.links.length), 0, link);
@@ -921,6 +949,8 @@ export class Diagram {
921
949
  return this.updateNodeState(nodeId, 'update node', (node) => {
922
950
  if (patch.name !== undefined)
923
951
  node.name = patch.name;
952
+ if (patch.subtitle !== undefined)
953
+ node.subtitle = patch.subtitle;
924
954
  if (patch.description !== undefined)
925
955
  node.description = patch.description;
926
956
  if (patch.color !== undefined)
@@ -973,6 +1003,7 @@ export class Diagram {
973
1003
  const restored = new NodeModel(snapshot, nodeId);
974
1004
  node.typeId = restored.typeId;
975
1005
  node.name = restored.name;
1006
+ node.subtitle = restored.subtitle;
976
1007
  node.description = restored.description;
977
1008
  node.groupName = restored.groupName;
978
1009
  node.color = restored.color;
@@ -1006,6 +1037,10 @@ export class Diagram {
1006
1037
  return true;
1007
1038
  }
1008
1039
  load(nodes, links) {
1040
+ this.loadModel(nodes, links, []);
1041
+ }
1042
+ // Zones are placed before the view is fitted, so the first frame frames them too.
1043
+ loadModel(nodes, links, zones) {
1009
1044
  this.clear();
1010
1045
  this.idSeq = 1;
1011
1046
  this.linkSeq = 1;
@@ -1043,10 +1078,11 @@ export class Diagram {
1043
1078
  if (this.links.some((existing) => existing.id === id)) {
1044
1079
  throw new Error(`ssdiagram: duplicate link id "${id}"`);
1045
1080
  }
1046
- const model = new LinkModel(link.from, link.fromPort, link.to, link.toPort, id, link.metadata, link.style ?? 'solid');
1081
+ const model = new LinkModel(link.from, link.fromPort, link.to, link.toPort, id, link.metadata, link.style ?? 'solid', link.label ?? '');
1047
1082
  if (!this.links.some((existing) => existing.key() === model.key()))
1048
1083
  this.links.push(model);
1049
1084
  }
1085
+ this.zones = zones.map((zone) => ({ ...zone, metadata: copyJsonObject(zone.metadata) }));
1050
1086
  for (const node of this.nodes) {
1051
1087
  if (node.loadError.length === 0)
1052
1088
  continue;
@@ -1071,22 +1107,23 @@ export class Diagram {
1071
1107
  to: l.to,
1072
1108
  toPort: l.toPort,
1073
1109
  style: l.style,
1110
+ ...(l.label === '' ? {} : { label: l.label }),
1074
1111
  metadata: copyJsonObject(l.metadata),
1075
1112
  })),
1076
1113
  };
1077
1114
  }
1078
1115
  loadDocument(source) {
1079
1116
  const document = parseDiagramDocument(source);
1080
- this.load(document.nodes, document.links.map((link) => ({
1117
+ this.loadModel(document.nodes, document.links.map((link) => ({
1081
1118
  id: link.id,
1082
1119
  from: link.from.nodeId,
1083
1120
  fromPort: link.from.portId,
1084
1121
  to: link.to.nodeId,
1085
1122
  toPort: link.to.portId,
1086
1123
  style: link.style,
1124
+ label: link.label,
1087
1125
  metadata: link.metadata,
1088
- })));
1089
- this.zones = document.zones.map((zone) => ({ ...zone, metadata: copyJsonObject(zone.metadata) }));
1126
+ })), document.zones);
1090
1127
  this.documentMetadata = copyJsonObject(document.metadata);
1091
1128
  }
1092
1129
  saveDocument() {
@@ -1097,6 +1134,7 @@ export class Diagram {
1097
1134
  from: { nodeId: link.from, portId: link.fromPort },
1098
1135
  to: { nodeId: link.to, portId: link.toPort },
1099
1136
  style: link.style,
1137
+ label: link.label,
1100
1138
  metadata: link.metadata,
1101
1139
  })),
1102
1140
  zones: this.zones.map((zone) => ({ ...zone, metadata: copyJsonObject(zone.metadata) })),
@@ -1571,8 +1609,10 @@ export class Diagram {
1571
1609
  this.opts.overviewViewportFill = t.overviewViewportFill;
1572
1610
  this.scheduleDraw();
1573
1611
  }
1612
+ // Zones count: fitting or exporting only the nodes cut off the places they sit in, captions
1613
+ // included. A caption that is taller than its zone still has to fit.
1574
1614
  graphBounds() {
1575
- if (this.nodes.length === 0)
1615
+ if (this.nodes.length === 0 && this.zones.length === 0)
1576
1616
  return null;
1577
1617
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
1578
1618
  for (const n of this.nodes) {
@@ -1581,6 +1621,15 @@ export class Diagram {
1581
1621
  maxX = Math.max(maxX, n.x + n.w);
1582
1622
  maxY = Math.max(maxY, n.y + n.h);
1583
1623
  }
1624
+ for (const zone of this.zones) {
1625
+ const bottom = zone.name.length > 0
1626
+ ? Math.max(zone.height, ZONE_CAPTION_TOP + ZONE_CAPTION_H)
1627
+ : zone.height;
1628
+ minX = Math.min(minX, zone.x - 1);
1629
+ minY = Math.min(minY, zone.y - 1);
1630
+ maxX = Math.max(maxX, zone.x + zone.width + 1);
1631
+ maxY = Math.max(maxY, zone.y + bottom + 1);
1632
+ }
1584
1633
  return { minX, minY, maxX, maxY };
1585
1634
  }
1586
1635
  resize(w, h) {
@@ -1637,12 +1686,17 @@ export class Diagram {
1637
1686
  // ---- geometry ---------------------------------------------------
1638
1687
  layoutNode(n) {
1639
1688
  const ctx = this.ctx;
1640
- ctx.font = '600 12px Segoe UI, Tahoma, sans-serif';
1689
+ ctx.font = TITLE_FONT;
1641
1690
  const titleW = ctx.measureText(n.name).width;
1691
+ let subtitleW = 0;
1692
+ if (n.subtitle.length > 0) {
1693
+ ctx.font = SUBTITLE_FONT;
1694
+ subtitleW = ctx.measureText(n.subtitle).width;
1695
+ }
1642
1696
  const rows = Math.max(n.inPorts.length, n.outPorts.length, 1);
1643
- // Node sizing: min 170 wide, height grows by rows.
1644
- n.w = Math.max(170, Math.ceil(titleW) + 28);
1645
- n.h = Math.max(48, rows * PORT_ROW_H + 16);
1697
+ // Node sizing: min 170 wide, height grows by rows; a subtitle adds a second text line.
1698
+ n.w = Math.max(170, Math.ceil(titleW) + 28, Math.ceil(subtitleW) + 28);
1699
+ n.h = Math.max(n.subtitle.length > 0 ? SUBTITLE_MIN_H : 48, rows * PORT_ROW_H + 16);
1646
1700
  // Each side's port stack is vertically centred; the socket
1647
1701
  // square sits OUTSIDE the body (its inner edge flush with the
1648
1702
  // node border).
@@ -2038,6 +2092,7 @@ export class Diagram {
2038
2092
  // The clipboard is built from saveDocument(), so it carries the style;
2039
2093
  // rebuilding without it silently turned every pasted dashed link solid.
2040
2094
  style: l.style,
2095
+ label: l.label,
2041
2096
  metadata: l.metadata,
2042
2097
  });
2043
2098
  }
@@ -2506,6 +2561,9 @@ export class Diagram {
2506
2561
  this.listen(window, 'pointerup', finish);
2507
2562
  this.listen(window, 'pointercancel', finish);
2508
2563
  this.listen(this.canvas, 'wheel', (e) => {
2564
+ // Ctrl+wheel is also what a trackpad pinch arrives as, so zoom stays one gesture away.
2565
+ if (this.pageScroll && !e.ctrlKey && !e.metaKey)
2566
+ return;
2509
2567
  e.preventDefault();
2510
2568
  const [sx, sy] = localXY(e);
2511
2569
  const [wx, wy] = this.toWorld(sx, sy);
@@ -2678,6 +2736,7 @@ export class Diagram {
2678
2736
  }
2679
2737
  draw(options = SCREEN_RENDER_OPTIONS) {
2680
2738
  const ctx = this.ctx;
2739
+ this.hostStyle = null;
2681
2740
  ctx.clearRect(0, 0, this.width, this.height);
2682
2741
  const background = this.opts.background ?? '#1b1b1f';
2683
2742
  ctx.fillStyle = background;
@@ -2708,6 +2767,7 @@ export class Diagram {
2708
2767
  // Links next. Each link jumps over the verticals of the links
2709
2768
  // drawn before it (deterministic bridge — Link.JumpOver parity).
2710
2769
  const prior = []; // segments of links already drawn
2770
+ const labels = [];
2711
2771
  const hoveredNode = this.hoverPort?.node ?? this.hoverNode;
2712
2772
  for (const l of this.links) {
2713
2773
  const a = this.endpoint(l, 'from');
@@ -2729,6 +2789,8 @@ export class Diagram {
2729
2789
  const pts = this.routeLink(a, b, new Set([l.from, l.to]));
2730
2790
  this.strokeRoute(pts, color, width, prior, l.style);
2731
2791
  this.drawArrow(pts, color);
2792
+ if (l.label.length > 0)
2793
+ labels.push({ pts, text: l.label, color });
2732
2794
  for (let k = 1; k < pts.length; k += 1) {
2733
2795
  const [x1, y1] = pts[k - 1];
2734
2796
  const [x2, y2] = pts[k];
@@ -2738,6 +2800,9 @@ export class Diagram {
2738
2800
  prior.push({ h: true, c: y1, a: Math.min(x1, x2), b: Math.max(x1, x2) });
2739
2801
  }
2740
2802
  }
2803
+ // After every wire, so no later wire runs across a label; before the nodes, which stay on top.
2804
+ for (const label of labels)
2805
+ this.drawLinkLabel(label.pts, label.text, label.color, background);
2741
2806
  if (options.transient && (this.linking !== null || this.relinking !== null))
2742
2807
  this.drawPendingLink();
2743
2808
  this.portFeed = this.feedTypes();
@@ -2846,7 +2911,9 @@ export class Diagram {
2846
2911
  text = n.description;
2847
2912
  }
2848
2913
  else {
2849
- text = n.typeId && n.typeId !== n.name ? `${n.name} [${n.typeId}]` : n.name;
2914
+ // A node given no type carries its id as one; an id is a key, not a word for the reader.
2915
+ const typed = n.typeId.length > 0 && n.typeId !== n.name && n.typeId !== n.id;
2916
+ text = typed ? `${n.name} [${n.typeId}]` : n.name;
2850
2917
  }
2851
2918
  }
2852
2919
  else {
@@ -2905,8 +2972,9 @@ export class Diagram {
2905
2972
  if (this.zones.length === 0)
2906
2973
  return;
2907
2974
  const ctx = this.ctx;
2975
+ const defaultTint = this.resolveColor(this.opts.zoneColor ?? DEFAULT_ZONE_TINT, DEFAULT_ZONE_TINT);
2908
2976
  for (const zone of this.zones) {
2909
- const tint = zone.color.length > 0 ? zone.color : (this.opts.zoneColor ?? '#8a8f98');
2977
+ const tint = zone.color.length > 0 ? this.resolveColor(zone.color, defaultTint) : defaultTint;
2910
2978
  ctx.save();
2911
2979
  roundRect(ctx, zone.x, zone.y, zone.width, zone.height, 10);
2912
2980
  ctx.globalAlpha = 0.12;
@@ -2923,12 +2991,12 @@ export class Diagram {
2923
2991
  ctx.save();
2924
2992
  ctx.globalAlpha = 0.75;
2925
2993
  ctx.fillStyle = tint;
2926
- ctx.font = '600 12px Segoe UI, Tahoma, sans-serif';
2994
+ ctx.font = TITLE_FONT;
2927
2995
  ctx.textAlign = 'left';
2928
2996
  ctx.textBaseline = 'top';
2929
2997
  // Inside the top-left corner: a caption outside the box would collide with whatever
2930
2998
  // sits above it, and the corner is the one spot a rectangle always has to spare.
2931
- ctx.fillText(zone.name, zone.x + 12, zone.y + 9, Math.max(0, zone.width - 24));
2999
+ ctx.fillText(zone.name, zone.x + 12, zone.y + ZONE_CAPTION_TOP, Math.max(0, zone.width - 24));
2932
3000
  ctx.restore();
2933
3001
  }
2934
3002
  }
@@ -2971,11 +3039,12 @@ export class Diagram {
2971
3039
  const runtime = options.runtime ? getJsonKey(this.runtimeState.nodes, n.id) : undefined;
2972
3040
  const active = options.runtime
2973
3041
  && (this.runtimeState.activeNodeId === n.id || runtime?.active === true);
3042
+ const fill = this.resolveColor(n.color, DEFAULT_NODE_COLOR);
2974
3043
  roundRect(ctx, n.x, n.y, n.w, n.h, 6);
2975
- ctx.fillStyle = hasLoadError ? ERROR_BACKGROUND : active ? '#ffd1dc' : n.color;
3044
+ ctx.fillStyle = hasLoadError ? ERROR_BACKGROUND : active ? '#ffd1dc' : fill;
2976
3045
  ctx.fill();
2977
3046
  ctx.lineWidth = selected ? 2 : 1.5;
2978
- ctx.strokeStyle = selected ? this.selectionColor() : n.border;
3047
+ ctx.strokeStyle = selected ? this.selectionColor() : this.resolveColor(n.border, DEFAULT_NODE_BORDER);
2979
3048
  ctx.stroke();
2980
3049
  if (hasLoadError || hasRuntimeError) {
2981
3050
  let alpha = 1;
@@ -3013,13 +3082,26 @@ export class Diagram {
3013
3082
  // takes its colour from the fill it sits on: that fill comes from the
3014
3083
  // scheme, so a node may well be darker than the title used to assume.
3015
3084
  const titleShift = n.icon ? iconW + 4 : 0;
3085
+ const textX = n.x + titleShift + (n.w - titleShift) / 2;
3086
+ const textWidth = n.w - titleShift - 14;
3016
3087
  ctx.fillStyle = hasLoadError
3017
3088
  ? '#ffffff'
3018
- : readableTextOn(active ? '#ffd1dc' : n.color);
3019
- ctx.font = '600 12px Segoe UI, Tahoma, sans-serif';
3089
+ : readableTextOn(active ? '#ffd1dc' : fill);
3090
+ ctx.font = TITLE_FONT;
3020
3091
  ctx.textAlign = 'center';
3021
3092
  ctx.textBaseline = 'middle';
3022
- ctx.fillText(n.name, n.x + titleShift + (n.w - titleShift) / 2, n.y + n.h / 2, n.w - titleShift - 14);
3093
+ if (n.subtitle.length === 0) {
3094
+ ctx.fillText(n.name, textX, n.y + n.h / 2, textWidth);
3095
+ }
3096
+ else {
3097
+ // Two lines centred as a pair: the name above, the subtitle smaller and muted below.
3098
+ ctx.fillText(n.name, textX, n.y + n.h / 2 - 8, textWidth);
3099
+ ctx.save();
3100
+ ctx.globalAlpha *= 0.72;
3101
+ ctx.font = SUBTITLE_FONT;
3102
+ ctx.fillText(n.subtitle, textX, n.y + n.h / 2 + 9, textWidth);
3103
+ ctx.restore();
3104
+ }
3023
3105
  for (const p of n.inPorts)
3024
3106
  this.drawPort(n, p, options);
3025
3107
  for (const p of n.outPorts)
@@ -3163,6 +3245,48 @@ export class Diagram {
3163
3245
  ctx.fill();
3164
3246
  ctx.restore();
3165
3247
  }
3248
+ // A plate of the canvas colour under the text, edged in the wire's colour: the label reads the same
3249
+ // whatever the wire crosses, and still says which wire it belongs to.
3250
+ drawLinkLabel(pts, text, color, background) {
3251
+ const [x, y] = pathMidpoint(pts);
3252
+ const ctx = this.ctx;
3253
+ ctx.save();
3254
+ ctx.font = LINK_LABEL_FONT;
3255
+ const w = Math.ceil(ctx.measureText(text).width) + 12;
3256
+ const h = 16;
3257
+ roundRect(ctx, x - w / 2, y - h / 2, w, h, h / 2);
3258
+ ctx.fillStyle = background;
3259
+ ctx.fill();
3260
+ ctx.lineWidth = 1;
3261
+ ctx.strokeStyle = color;
3262
+ ctx.stroke();
3263
+ ctx.fillStyle = readableTextOn(background);
3264
+ ctx.textAlign = 'center';
3265
+ ctx.textBaseline = 'middle';
3266
+ ctx.fillText(text, x, y);
3267
+ ctx.restore();
3268
+ }
3269
+ // Scheme colours may name a custom property of the page, var(--token), which a canvas cannot
3270
+ // paint. They resolve against the host element; one that resolves to nothing and has no
3271
+ // fallback of its own gets the renderer's default for that slot.
3272
+ resolveColor(value, fallback) {
3273
+ if (!value.includes('var('))
3274
+ return value;
3275
+ return resolveCssVariables(value, (name) => this.hostProperty(name)) ?? fallback;
3276
+ }
3277
+ hostProperty(name) {
3278
+ if (this.hostStyle === null) {
3279
+ if (typeof getComputedStyle !== 'function')
3280
+ return '';
3281
+ try {
3282
+ this.hostStyle = getComputedStyle(this.host);
3283
+ }
3284
+ catch {
3285
+ return '';
3286
+ }
3287
+ }
3288
+ return this.hostStyle.getPropertyValue(name);
3289
+ }
3166
3290
  drawSelectedLinkEndpoints() {
3167
3291
  if (this.selectedLink === null)
3168
3292
  return;
@@ -3296,9 +3420,9 @@ export class Diagram {
3296
3420
  ctx.stroke();
3297
3421
  ctx.clip();
3298
3422
  for (const n of this.nodes) {
3299
- ctx.fillStyle = n.color;
3423
+ ctx.fillStyle = this.resolveColor(n.color, DEFAULT_NODE_COLOR);
3300
3424
  ctx.fillRect(mx(n.x), my(n.y), Math.max(2, n.w * s), Math.max(2, n.h * s));
3301
- ctx.strokeStyle = n.border;
3425
+ ctx.strokeStyle = this.resolveColor(n.border, DEFAULT_NODE_BORDER);
3302
3426
  ctx.lineWidth = 1;
3303
3427
  ctx.strokeRect(mx(n.x), my(n.y), Math.max(2, n.w * s), Math.max(2, n.h * s));
3304
3428
  }
@@ -3346,6 +3470,25 @@ function roundRect(ctx, x, y, w, h, r) {
3346
3470
  // a 3-segment mid-X elbow. Backward/too-close: a 5-segment detour that
3347
3471
  // leaves the source rightward and re-enters the target from the left
3348
3472
  // (AvoidsNodes-style, never a diagonal).
3473
+ /** The point halfway along a polyline, measured by length rather than by vertex count. */
3474
+ function pathMidpoint(pts) {
3475
+ let total = 0;
3476
+ for (let i = 1; i < pts.length; i += 1)
3477
+ total += Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]);
3478
+ let remaining = total / 2;
3479
+ for (let i = 1; i < pts.length; i += 1) {
3480
+ const [x1, y1] = pts[i - 1];
3481
+ const [x2, y2] = pts[i];
3482
+ const length = Math.hypot(x2 - x1, y2 - y1);
3483
+ if (length > 0 && remaining <= length) {
3484
+ const t = remaining / length;
3485
+ return [x1 + (x2 - x1) * t, y1 + (y2 - y1) * t];
3486
+ }
3487
+ remaining -= length;
3488
+ }
3489
+ const last = pts[pts.length - 1];
3490
+ return [last[0], last[1]];
3491
+ }
3349
3492
  function route(a, b) {
3350
3493
  const [ax, ay] = a;
3351
3494
  const [bx, by] = b;