@stocksharp/diagram 1.3.0 → 1.5.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 (41) hide show
  1. package/README.md +84 -1
  2. package/dist/esm/canvas-renderer.js +178 -31
  3. package/dist/esm/canvas-renderer.js.map +1 -1
  4. package/dist/esm/color.js +110 -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 +23 -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 +247 -26
  16. package/dist/ssdiagram-headless.js.map +2 -2
  17. package/dist/ssdiagram.js +352 -48
  18. package/dist/ssdiagram.js.map +3 -3
  19. package/dist/types/canvas-renderer.d.ts +26 -3
  20. package/dist/types/canvas-renderer.d.ts.map +1 -1
  21. package/dist/types/color.d.ts +9 -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 +6 -0
  26. package/dist/types/diagram/api.d.ts.map +1 -1
  27. package/dist/types/diagram/stocksharp-diagram.d.ts +11 -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 +182 -30
  35. package/src/color.ts +130 -0
  36. package/src/core/document.ts +12 -0
  37. package/src/core/model.ts +7 -0
  38. package/src/diagram/api.ts +6 -0
  39. package/src/diagram/stocksharp-diagram.ts +27 -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 { 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) {
@@ -202,10 +215,7 @@ function hashHue(s) {
202
215
  // wash out; a light theme passes a low ceiling to darken the links just enough to stay readable
203
216
  // while keeping each hue distinct. Non-hsl inputs are returned untouched.
204
217
  function clampLightness(color, maxL) {
205
- const m = color.match(/^hsl\(\s*([\d.]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*\)$/i);
206
- if (m === null)
207
- return color;
208
- return `hsl(${m[1]}, ${m[2]}%, ${Math.min(+m[3], maxL * 100)}%)`;
218
+ return cappedLightness(color, maxL);
209
219
  }
210
220
  function colorLuminance(color) {
211
221
  if (color === undefined)
@@ -293,6 +303,9 @@ export class Diagram {
293
303
  this.ovGeo = null;
294
304
  this.validator = null;
295
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;
296
309
  this.opts = opts;
297
310
  // Same normalization setTypeColors applies, so a palette handed in at construction is
298
311
  // keyed like one handed in later.
@@ -300,6 +313,7 @@ export class Diagram {
300
313
  this.setTypeColorsInternal(opts.typeColors);
301
314
  this.host = opts.host;
302
315
  this.gridSnapEnabled = opts.gridSnap ?? false;
316
+ this.pageScroll = opts.pageScroll ?? false;
303
317
  this.gridSize = this.normalizeGridSize(opts.gridSize ?? DEFAULT_GRID_SIZE);
304
318
  this.history = new DiagramCommandHistory(() => {
305
319
  // The gated values, not the raw stack: setInteractionPermissions and setReadOnly
@@ -311,9 +325,9 @@ export class Diagram {
311
325
  this.canvas.style.display = 'block';
312
326
  this.canvas.tabIndex = 0; // focusable → key events
313
327
  this.canvas.style.outline = 'none';
314
- // Eat browser-native touch gestures (page scroll / pinch-zoom) so
315
- // fingers actually drive our pan/zoom on mobile instead of the page.
316
- 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();
317
331
  // No OS text-selection on long-press (would pop the iOS magnifier
318
332
  // and select node/link captions instead of firing our menu).
319
333
  this.canvas.style.userSelect = 'none';
@@ -371,6 +385,17 @@ export class Diagram {
371
385
  this.gridSize = nextSize;
372
386
  this.scheduleDraw();
373
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
+ }
374
399
  getGridSnap() {
375
400
  return { enabled: this.gridSnapEnabled, size: this.gridSize };
376
401
  }
@@ -604,7 +629,7 @@ export class Diagram {
604
629
  if (!validation.allowed)
605
630
  return false;
606
631
  }
607
- 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 ?? '');
608
633
  if (this.links.some((l) => l.id === link.id))
609
634
  return false;
610
635
  this.links.splice(clamp(Math.trunc(index), 0, this.links.length), 0, link);
@@ -924,6 +949,8 @@ export class Diagram {
924
949
  return this.updateNodeState(nodeId, 'update node', (node) => {
925
950
  if (patch.name !== undefined)
926
951
  node.name = patch.name;
952
+ if (patch.subtitle !== undefined)
953
+ node.subtitle = patch.subtitle;
927
954
  if (patch.description !== undefined)
928
955
  node.description = patch.description;
929
956
  if (patch.color !== undefined)
@@ -976,6 +1003,7 @@ export class Diagram {
976
1003
  const restored = new NodeModel(snapshot, nodeId);
977
1004
  node.typeId = restored.typeId;
978
1005
  node.name = restored.name;
1006
+ node.subtitle = restored.subtitle;
979
1007
  node.description = restored.description;
980
1008
  node.groupName = restored.groupName;
981
1009
  node.color = restored.color;
@@ -1009,6 +1037,10 @@ export class Diagram {
1009
1037
  return true;
1010
1038
  }
1011
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) {
1012
1044
  this.clear();
1013
1045
  this.idSeq = 1;
1014
1046
  this.linkSeq = 1;
@@ -1046,10 +1078,11 @@ export class Diagram {
1046
1078
  if (this.links.some((existing) => existing.id === id)) {
1047
1079
  throw new Error(`ssdiagram: duplicate link id "${id}"`);
1048
1080
  }
1049
- 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 ?? '');
1050
1082
  if (!this.links.some((existing) => existing.key() === model.key()))
1051
1083
  this.links.push(model);
1052
1084
  }
1085
+ this.zones = zones.map((zone) => ({ ...zone, metadata: copyJsonObject(zone.metadata) }));
1053
1086
  for (const node of this.nodes) {
1054
1087
  if (node.loadError.length === 0)
1055
1088
  continue;
@@ -1074,22 +1107,23 @@ export class Diagram {
1074
1107
  to: l.to,
1075
1108
  toPort: l.toPort,
1076
1109
  style: l.style,
1110
+ ...(l.label === '' ? {} : { label: l.label }),
1077
1111
  metadata: copyJsonObject(l.metadata),
1078
1112
  })),
1079
1113
  };
1080
1114
  }
1081
1115
  loadDocument(source) {
1082
1116
  const document = parseDiagramDocument(source);
1083
- this.load(document.nodes, document.links.map((link) => ({
1117
+ this.loadModel(document.nodes, document.links.map((link) => ({
1084
1118
  id: link.id,
1085
1119
  from: link.from.nodeId,
1086
1120
  fromPort: link.from.portId,
1087
1121
  to: link.to.nodeId,
1088
1122
  toPort: link.to.portId,
1089
1123
  style: link.style,
1124
+ label: link.label,
1090
1125
  metadata: link.metadata,
1091
- })));
1092
- this.zones = document.zones.map((zone) => ({ ...zone, metadata: copyJsonObject(zone.metadata) }));
1126
+ })), document.zones);
1093
1127
  this.documentMetadata = copyJsonObject(document.metadata);
1094
1128
  }
1095
1129
  saveDocument() {
@@ -1100,6 +1134,7 @@ export class Diagram {
1100
1134
  from: { nodeId: link.from, portId: link.fromPort },
1101
1135
  to: { nodeId: link.to, portId: link.toPort },
1102
1136
  style: link.style,
1137
+ label: link.label,
1103
1138
  metadata: link.metadata,
1104
1139
  })),
1105
1140
  zones: this.zones.map((zone) => ({ ...zone, metadata: copyJsonObject(zone.metadata) })),
@@ -1574,8 +1609,10 @@ export class Diagram {
1574
1609
  this.opts.overviewViewportFill = t.overviewViewportFill;
1575
1610
  this.scheduleDraw();
1576
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.
1577
1614
  graphBounds() {
1578
- if (this.nodes.length === 0)
1615
+ if (this.nodes.length === 0 && this.zones.length === 0)
1579
1616
  return null;
1580
1617
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
1581
1618
  for (const n of this.nodes) {
@@ -1584,6 +1621,15 @@ export class Diagram {
1584
1621
  maxX = Math.max(maxX, n.x + n.w);
1585
1622
  maxY = Math.max(maxY, n.y + n.h);
1586
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
+ }
1587
1633
  return { minX, minY, maxX, maxY };
1588
1634
  }
1589
1635
  resize(w, h) {
@@ -1640,12 +1686,17 @@ export class Diagram {
1640
1686
  // ---- geometry ---------------------------------------------------
1641
1687
  layoutNode(n) {
1642
1688
  const ctx = this.ctx;
1643
- ctx.font = '600 12px Segoe UI, Tahoma, sans-serif';
1689
+ ctx.font = TITLE_FONT;
1644
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
+ }
1645
1696
  const rows = Math.max(n.inPorts.length, n.outPorts.length, 1);
1646
- // Node sizing: min 170 wide, height grows by rows.
1647
- n.w = Math.max(170, Math.ceil(titleW) + 28);
1648
- 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);
1649
1700
  // Each side's port stack is vertically centred; the socket
1650
1701
  // square sits OUTSIDE the body (its inner edge flush with the
1651
1702
  // node border).
@@ -1691,6 +1742,15 @@ export class Diagram {
1691
1742
  return p.type;
1692
1743
  return this.portFeed.get(p) ?? p.type;
1693
1744
  }
1745
+ /// A type's colour as a wire.
1746
+ ///
1747
+ /// The same colour a socket of that type is filled with, moved into the band the canvas
1748
+ /// leaves room for. A socket can afford the host's exact colour because it is a filled
1749
+ /// square with an outline; a wire is a thin line with nothing behind it, and one drawn in
1750
+ /// #000000 on a dark canvas is not there at all.
1751
+ linkColor(type) {
1752
+ return legibleOn(this.portColor(type), this.opts.background ?? '#1b1b1f');
1753
+ }
1694
1754
  portColor(type) {
1695
1755
  const maxL = this.opts.linkMaxLightness;
1696
1756
  const light = maxL !== undefined && maxL < 0.5;
@@ -2032,6 +2092,7 @@ export class Diagram {
2032
2092
  // The clipboard is built from saveDocument(), so it carries the style;
2033
2093
  // rebuilding without it silently turned every pasted dashed link solid.
2034
2094
  style: l.style,
2095
+ label: l.label,
2035
2096
  metadata: l.metadata,
2036
2097
  });
2037
2098
  }
@@ -2500,6 +2561,9 @@ export class Diagram {
2500
2561
  this.listen(window, 'pointerup', finish);
2501
2562
  this.listen(window, 'pointercancel', finish);
2502
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;
2503
2567
  e.preventDefault();
2504
2568
  const [sx, sy] = localXY(e);
2505
2569
  const [wx, wy] = this.toWorld(sx, sy);
@@ -2672,6 +2736,7 @@ export class Diagram {
2672
2736
  }
2673
2737
  draw(options = SCREEN_RENDER_OPTIONS) {
2674
2738
  const ctx = this.ctx;
2739
+ this.hostStyle = null;
2675
2740
  ctx.clearRect(0, 0, this.width, this.height);
2676
2741
  const background = this.opts.background ?? '#1b1b1f';
2677
2742
  ctx.fillStyle = background;
@@ -2702,6 +2767,7 @@ export class Diagram {
2702
2767
  // Links next. Each link jumps over the verticals of the links
2703
2768
  // drawn before it (deterministic bridge — Link.JumpOver parity).
2704
2769
  const prior = []; // segments of links already drawn
2770
+ const labels = [];
2705
2771
  const hoveredNode = this.hoverPort?.node ?? this.hoverNode;
2706
2772
  for (const l of this.links) {
2707
2773
  const a = this.endpoint(l, 'from');
@@ -2710,7 +2776,7 @@ export class Diagram {
2710
2776
  continue;
2711
2777
  const fp = this.nodes.find((n) => n.id === l.from)?.outPorts.find((p) => p.id === l.fromPort);
2712
2778
  const baseColor = (this.opts.typedLinkColors ?? false) && fp !== undefined
2713
- ? this.portColor(fp.type)
2779
+ ? this.linkColor(fp.type)
2714
2780
  : this.portColor('');
2715
2781
  const state = options.selection && l === this.selectedLink
2716
2782
  ? 'sel'
@@ -2723,6 +2789,8 @@ export class Diagram {
2723
2789
  const pts = this.routeLink(a, b, new Set([l.from, l.to]));
2724
2790
  this.strokeRoute(pts, color, width, prior, l.style);
2725
2791
  this.drawArrow(pts, color);
2792
+ if (l.label.length > 0)
2793
+ labels.push({ pts, text: l.label, color });
2726
2794
  for (let k = 1; k < pts.length; k += 1) {
2727
2795
  const [x1, y1] = pts[k - 1];
2728
2796
  const [x2, y2] = pts[k];
@@ -2732,6 +2800,9 @@ export class Diagram {
2732
2800
  prior.push({ h: true, c: y1, a: Math.min(x1, x2), b: Math.max(x1, x2) });
2733
2801
  }
2734
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);
2735
2806
  if (options.transient && (this.linking !== null || this.relinking !== null))
2736
2807
  this.drawPendingLink();
2737
2808
  this.portFeed = this.feedTypes();
@@ -2899,8 +2970,9 @@ export class Diagram {
2899
2970
  if (this.zones.length === 0)
2900
2971
  return;
2901
2972
  const ctx = this.ctx;
2973
+ const defaultTint = this.resolveColor(this.opts.zoneColor ?? DEFAULT_ZONE_TINT, DEFAULT_ZONE_TINT);
2902
2974
  for (const zone of this.zones) {
2903
- const tint = zone.color.length > 0 ? zone.color : (this.opts.zoneColor ?? '#8a8f98');
2975
+ const tint = zone.color.length > 0 ? this.resolveColor(zone.color, defaultTint) : defaultTint;
2904
2976
  ctx.save();
2905
2977
  roundRect(ctx, zone.x, zone.y, zone.width, zone.height, 10);
2906
2978
  ctx.globalAlpha = 0.12;
@@ -2917,12 +2989,12 @@ export class Diagram {
2917
2989
  ctx.save();
2918
2990
  ctx.globalAlpha = 0.75;
2919
2991
  ctx.fillStyle = tint;
2920
- ctx.font = '600 12px Segoe UI, Tahoma, sans-serif';
2992
+ ctx.font = TITLE_FONT;
2921
2993
  ctx.textAlign = 'left';
2922
2994
  ctx.textBaseline = 'top';
2923
2995
  // Inside the top-left corner: a caption outside the box would collide with whatever
2924
2996
  // sits above it, and the corner is the one spot a rectangle always has to spare.
2925
- ctx.fillText(zone.name, zone.x + 12, zone.y + 9, Math.max(0, zone.width - 24));
2997
+ ctx.fillText(zone.name, zone.x + 12, zone.y + ZONE_CAPTION_TOP, Math.max(0, zone.width - 24));
2926
2998
  ctx.restore();
2927
2999
  }
2928
3000
  }
@@ -2965,11 +3037,12 @@ export class Diagram {
2965
3037
  const runtime = options.runtime ? getJsonKey(this.runtimeState.nodes, n.id) : undefined;
2966
3038
  const active = options.runtime
2967
3039
  && (this.runtimeState.activeNodeId === n.id || runtime?.active === true);
3040
+ const fill = this.resolveColor(n.color, DEFAULT_NODE_COLOR);
2968
3041
  roundRect(ctx, n.x, n.y, n.w, n.h, 6);
2969
- ctx.fillStyle = hasLoadError ? ERROR_BACKGROUND : active ? '#ffd1dc' : n.color;
3042
+ ctx.fillStyle = hasLoadError ? ERROR_BACKGROUND : active ? '#ffd1dc' : fill;
2970
3043
  ctx.fill();
2971
3044
  ctx.lineWidth = selected ? 2 : 1.5;
2972
- ctx.strokeStyle = selected ? this.selectionColor() : n.border;
3045
+ ctx.strokeStyle = selected ? this.selectionColor() : this.resolveColor(n.border, DEFAULT_NODE_BORDER);
2973
3046
  ctx.stroke();
2974
3047
  if (hasLoadError || hasRuntimeError) {
2975
3048
  let alpha = 1;
@@ -3007,13 +3080,26 @@ export class Diagram {
3007
3080
  // takes its colour from the fill it sits on: that fill comes from the
3008
3081
  // scheme, so a node may well be darker than the title used to assume.
3009
3082
  const titleShift = n.icon ? iconW + 4 : 0;
3083
+ const textX = n.x + titleShift + (n.w - titleShift) / 2;
3084
+ const textWidth = n.w - titleShift - 14;
3010
3085
  ctx.fillStyle = hasLoadError
3011
3086
  ? '#ffffff'
3012
- : readableTextOn(active ? '#ffd1dc' : n.color);
3013
- ctx.font = '600 12px Segoe UI, Tahoma, sans-serif';
3087
+ : readableTextOn(active ? '#ffd1dc' : fill);
3088
+ ctx.font = TITLE_FONT;
3014
3089
  ctx.textAlign = 'center';
3015
3090
  ctx.textBaseline = 'middle';
3016
- ctx.fillText(n.name, n.x + titleShift + (n.w - titleShift) / 2, n.y + n.h / 2, n.w - titleShift - 14);
3091
+ if (n.subtitle.length === 0) {
3092
+ ctx.fillText(n.name, textX, n.y + n.h / 2, textWidth);
3093
+ }
3094
+ else {
3095
+ // Two lines centred as a pair: the name above, the subtitle smaller and muted below.
3096
+ ctx.fillText(n.name, textX, n.y + n.h / 2 - 8, textWidth);
3097
+ ctx.save();
3098
+ ctx.globalAlpha *= 0.72;
3099
+ ctx.font = SUBTITLE_FONT;
3100
+ ctx.fillText(n.subtitle, textX, n.y + n.h / 2 + 9, textWidth);
3101
+ ctx.restore();
3102
+ }
3017
3103
  for (const p of n.inPorts)
3018
3104
  this.drawPort(n, p, options);
3019
3105
  for (const p of n.outPorts)
@@ -3157,6 +3243,48 @@ export class Diagram {
3157
3243
  ctx.fill();
3158
3244
  ctx.restore();
3159
3245
  }
3246
+ // A plate of the canvas colour under the text, edged in the wire's colour: the label reads the same
3247
+ // whatever the wire crosses, and still says which wire it belongs to.
3248
+ drawLinkLabel(pts, text, color, background) {
3249
+ const [x, y] = pathMidpoint(pts);
3250
+ const ctx = this.ctx;
3251
+ ctx.save();
3252
+ ctx.font = LINK_LABEL_FONT;
3253
+ const w = Math.ceil(ctx.measureText(text).width) + 12;
3254
+ const h = 16;
3255
+ roundRect(ctx, x - w / 2, y - h / 2, w, h, h / 2);
3256
+ ctx.fillStyle = background;
3257
+ ctx.fill();
3258
+ ctx.lineWidth = 1;
3259
+ ctx.strokeStyle = color;
3260
+ ctx.stroke();
3261
+ ctx.fillStyle = readableTextOn(background);
3262
+ ctx.textAlign = 'center';
3263
+ ctx.textBaseline = 'middle';
3264
+ ctx.fillText(text, x, y);
3265
+ ctx.restore();
3266
+ }
3267
+ // Scheme colours may name a custom property of the page, var(--token), which a canvas cannot
3268
+ // paint. They resolve against the host element; one that resolves to nothing and has no
3269
+ // fallback of its own gets the renderer's default for that slot.
3270
+ resolveColor(value, fallback) {
3271
+ if (!value.includes('var('))
3272
+ return value;
3273
+ return resolveCssVariables(value, (name) => this.hostProperty(name)) ?? fallback;
3274
+ }
3275
+ hostProperty(name) {
3276
+ if (this.hostStyle === null) {
3277
+ if (typeof getComputedStyle !== 'function')
3278
+ return '';
3279
+ try {
3280
+ this.hostStyle = getComputedStyle(this.host);
3281
+ }
3282
+ catch {
3283
+ return '';
3284
+ }
3285
+ }
3286
+ return this.hostStyle.getPropertyValue(name);
3287
+ }
3160
3288
  drawSelectedLinkEndpoints() {
3161
3289
  if (this.selectedLink === null)
3162
3290
  return;
@@ -3225,7 +3353,7 @@ export class Diagram {
3225
3353
  if (snapped !== null)
3226
3354
  excludedNodes.add(snapped.node.id);
3227
3355
  const points = this.routeLink(a, b, excludedNodes);
3228
- ctx.strokeStyle = this.portColor(colorType);
3356
+ ctx.strokeStyle = this.linkColor(colorType);
3229
3357
  if (snapped !== null) {
3230
3358
  ctx.setLineDash([]);
3231
3359
  ctx.lineWidth = 2.6;
@@ -3290,9 +3418,9 @@ export class Diagram {
3290
3418
  ctx.stroke();
3291
3419
  ctx.clip();
3292
3420
  for (const n of this.nodes) {
3293
- ctx.fillStyle = n.color;
3421
+ ctx.fillStyle = this.resolveColor(n.color, DEFAULT_NODE_COLOR);
3294
3422
  ctx.fillRect(mx(n.x), my(n.y), Math.max(2, n.w * s), Math.max(2, n.h * s));
3295
- ctx.strokeStyle = n.border;
3423
+ ctx.strokeStyle = this.resolveColor(n.border, DEFAULT_NODE_BORDER);
3296
3424
  ctx.lineWidth = 1;
3297
3425
  ctx.strokeRect(mx(n.x), my(n.y), Math.max(2, n.w * s), Math.max(2, n.h * s));
3298
3426
  }
@@ -3340,6 +3468,25 @@ function roundRect(ctx, x, y, w, h, r) {
3340
3468
  // a 3-segment mid-X elbow. Backward/too-close: a 5-segment detour that
3341
3469
  // leaves the source rightward and re-enters the target from the left
3342
3470
  // (AvoidsNodes-style, never a diagonal).
3471
+ /** The point halfway along a polyline, measured by length rather than by vertex count. */
3472
+ function pathMidpoint(pts) {
3473
+ let total = 0;
3474
+ for (let i = 1; i < pts.length; i += 1)
3475
+ total += Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]);
3476
+ let remaining = total / 2;
3477
+ for (let i = 1; i < pts.length; i += 1) {
3478
+ const [x1, y1] = pts[i - 1];
3479
+ const [x2, y2] = pts[i];
3480
+ const length = Math.hypot(x2 - x1, y2 - y1);
3481
+ if (length > 0 && remaining <= length) {
3482
+ const t = remaining / length;
3483
+ return [x1 + (x2 - x1) * t, y1 + (y2 - y1) * t];
3484
+ }
3485
+ remaining -= length;
3486
+ }
3487
+ const last = pts[pts.length - 1];
3488
+ return [last[0], last[1]];
3489
+ }
3343
3490
  function route(a, b) {
3344
3491
  const [ax, ay] = a;
3345
3492
  const [bx, by] = b;