@stocksharp/diagram 1.4.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 +166 -25
  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 +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 +195 -21
  16. package/dist/ssdiagram-headless.js.map +2 -2
  17. package/dist/ssdiagram.js +300 -43
  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 +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 +169 -25
  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 +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
@@ -458,6 +458,42 @@ var SSDiagramHeadless = (() => {
458
458
  return color;
459
459
  return `hsl(${Math.round(hue * 10) / 10}, ${Math.round(saturation * 1e3) / 10}%, ${Math.round(maxLightness * 1e3) / 10}%)`;
460
460
  }
461
+ function resolveCssVariables(value, lookup) {
462
+ return resolveAt(value, lookup, 0);
463
+ }
464
+ function resolveAt(value, lookup, depth) {
465
+ const start = value.indexOf("var(");
466
+ if (start < 0)
467
+ return value;
468
+ if (depth > 8)
469
+ return null;
470
+ let level = 0;
471
+ let end = -1;
472
+ for (let i = start + 3; i < value.length; i += 1) {
473
+ if (value[i] === "(")
474
+ level += 1;
475
+ else if (value[i] === ")" && --level === 0) {
476
+ end = i;
477
+ break;
478
+ }
479
+ }
480
+ if (end < 0)
481
+ return null;
482
+ const inner = value.slice(start + 4, end);
483
+ const comma = inner.indexOf(",");
484
+ const name = (comma < 0 ? inner : inner.slice(0, comma)).trim();
485
+ const fallback = comma < 0 ? "" : inner.slice(comma + 1).trim();
486
+ let replacement = name.startsWith("--") ? lookup(name).trim() : "";
487
+ if (replacement === "")
488
+ replacement = fallback;
489
+ if (replacement === "")
490
+ return null;
491
+ const resolved = resolveAt(replacement, lookup, depth + 1);
492
+ const rest = resolveAt(value.slice(end + 1), lookup, depth);
493
+ if (resolved === null || rest === null)
494
+ return null;
495
+ return value.slice(0, start) + resolved + rest;
496
+ }
461
497
 
462
498
  // src/core/json.ts
463
499
  function setJsonKey(target, key, value) {
@@ -555,10 +591,12 @@ var SSDiagramHeadless = (() => {
555
591
  function normalizeNode(value, path) {
556
592
  const input = requireStructure(value, path);
557
593
  const id = requireIdentifier(input.id, `${path}.id`);
594
+ const subtitle = requireOptionalText(input.subtitle, `${path}.subtitle`);
558
595
  return {
559
596
  id,
560
597
  typeId: requireIdentifier(input.typeId ?? id, `${path}.typeId`),
561
598
  name: requireString(input.name, `${path}.name`),
599
+ ...subtitle === "" ? {} : { subtitle },
562
600
  description: requireString(input.description ?? "", `${path}.description`),
563
601
  groupName: requireString(input.groupName ?? "Common", `${path}.groupName`),
564
602
  x: requireFiniteNumber(input.x ?? 0, `${path}.x`),
@@ -609,14 +647,19 @@ var SSDiagramHeadless = (() => {
609
647
  }
610
648
  function normalizeLink(value, path) {
611
649
  const input = requireStructure(value, path);
650
+ const label = requireOptionalText(input.label, `${path}.label`);
612
651
  return {
613
652
  id: requireIdentifier(input.id, `${path}.id`),
614
653
  from: normalizeEndpoint(input.from, `${path}.from`),
615
654
  to: normalizeEndpoint(input.to, `${path}.to`),
616
655
  style: requireLinkStyle(input.style, `${path}.style`),
656
+ ...label === "" ? {} : { label },
617
657
  metadata: cloneJsonObject(input.metadata ?? {}, `${path}.metadata`)
618
658
  };
619
659
  }
660
+ function requireOptionalText(value, path) {
661
+ return value === void 0 || value === null ? "" : requireString(value, path);
662
+ }
620
663
  function requireLinkStyle(value, path) {
621
664
  if (value === void 0 || value === null) return "solid";
622
665
  if (value !== "solid" && value !== "dashed") {
@@ -656,6 +699,7 @@ var SSDiagramHeadless = (() => {
656
699
  id: requireString(node.id, `${path}.id`),
657
700
  typeId: requireString(node.typeId, `${path}.typeId`),
658
701
  name: requireString(node.name, `${path}.name`),
702
+ subtitle: node.subtitle,
659
703
  description: requireString(node.description, `${path}.description`),
660
704
  groupName: requireString(node.groupName, `${path}.groupName`),
661
705
  x: requireNumber(node.x, `${path}.x`),
@@ -711,6 +755,7 @@ var SSDiagramHeadless = (() => {
711
755
  from: parseEndpoint(link.from, `${path}.from`),
712
756
  to: parseEndpoint(link.to, `${path}.to`),
713
757
  style: link.style === void 0 ? void 0 : requireLinkStyle(link.style, `${path}.style`),
758
+ label: link.label,
714
759
  metadata: cloneJsonObject(link.metadata, `${path}.metadata`)
715
760
  }, path);
716
761
  }
@@ -1141,6 +1186,7 @@ var SSDiagramHeadless = (() => {
1141
1186
  this.id = id;
1142
1187
  this.typeId = init.typeId ?? id;
1143
1188
  this.name = init.name;
1189
+ this.subtitle = init.subtitle ?? "";
1144
1190
  this.description = init.description ?? "";
1145
1191
  this.groupName = init.groupName ?? "Common";
1146
1192
  this.color = init.color ?? "#d7d7d7";
@@ -1166,6 +1212,7 @@ var SSDiagramHeadless = (() => {
1166
1212
  id: this.id,
1167
1213
  typeId: this.typeId,
1168
1214
  name: this.name,
1215
+ ...this.subtitle === "" ? {} : { subtitle: this.subtitle },
1169
1216
  description: this.description,
1170
1217
  groupName: this.groupName,
1171
1218
  color: this.color,
@@ -1187,12 +1234,13 @@ var SSDiagramHeadless = (() => {
1187
1234
  }
1188
1235
  };
1189
1236
  var LinkModel = class {
1190
- constructor(from, fromPort, to, toPort, id = "", metadata, style = "solid") {
1237
+ constructor(from, fromPort, to, toPort, id = "", metadata, style = "solid", label = "") {
1191
1238
  this.from = from;
1192
1239
  this.fromPort = fromPort;
1193
1240
  this.to = to;
1194
1241
  this.toPort = toPort;
1195
1242
  this.style = style;
1243
+ this.label = label;
1196
1244
  this.id = id;
1197
1245
  this.metadata = copyJsonObject(metadata);
1198
1246
  }
@@ -1207,6 +1255,7 @@ var SSDiagramHeadless = (() => {
1207
1255
  to: this.to,
1208
1256
  toPort: this.toPort,
1209
1257
  style: this.style,
1258
+ ...this.label === "" ? {} : { label: this.label },
1210
1259
  metadata: copyJsonObject(this.metadata)
1211
1260
  };
1212
1261
  }
@@ -1227,6 +1276,15 @@ var SSDiagramHeadless = (() => {
1227
1276
  var INTRO_RISE = 70;
1228
1277
  var ERROR_FLASH_MS = 1100;
1229
1278
  var ERROR_RED = "#f6465d";
1279
+ var DEFAULT_NODE_COLOR = "#d7d7d7";
1280
+ var DEFAULT_NODE_BORDER = "#8c8c8c";
1281
+ var DEFAULT_ZONE_TINT = "#8a8f98";
1282
+ var TITLE_FONT = "600 12px Segoe UI, Tahoma, sans-serif";
1283
+ var SUBTITLE_FONT = "11px Segoe UI, Tahoma, sans-serif";
1284
+ var SUBTITLE_MIN_H = 62;
1285
+ var LINK_LABEL_FONT = "600 10px Segoe UI, Tahoma, sans-serif";
1286
+ var ZONE_CAPTION_TOP = 9;
1287
+ var ZONE_CAPTION_H = 14;
1230
1288
  var ERROR_BACKGROUND = "#7d2632";
1231
1289
  var clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
1232
1290
  function hashHue(s) {
@@ -1324,10 +1382,14 @@ var SSDiagramHeadless = (() => {
1324
1382
  this.ovGeo = null;
1325
1383
  this.validator = null;
1326
1384
  this.handlers = /* @__PURE__ */ new Map();
1385
+ // Computed style of the host, read at most once per frame: colours written as var(--token)
1386
+ // resolve against it, so a frame drawn after a theme switch picks up the new values.
1387
+ this.hostStyle = null;
1327
1388
  this.opts = opts;
1328
1389
  if (opts.typeColors !== void 0) this.setTypeColorsInternal(opts.typeColors);
1329
1390
  this.host = opts.host;
1330
1391
  this.gridSnapEnabled = opts.gridSnap ?? false;
1392
+ this.pageScroll = opts.pageScroll ?? false;
1331
1393
  this.gridSize = this.normalizeGridSize(opts.gridSize ?? DEFAULT_GRID_SIZE);
1332
1394
  this.history = new DiagramCommandHistory(() => {
1333
1395
  this.emit("undoStackChanged", { canUndo: this.canUndo(), canRedo: this.canRedo() });
@@ -1336,7 +1398,7 @@ var SSDiagramHeadless = (() => {
1336
1398
  this.canvas.style.display = "block";
1337
1399
  this.canvas.tabIndex = 0;
1338
1400
  this.canvas.style.outline = "none";
1339
- this.canvas.style.touchAction = "none";
1401
+ this.applyTouchAction();
1340
1402
  this.canvas.style.userSelect = "none";
1341
1403
  this.canvas.style.webkitUserSelect = "none";
1342
1404
  this.canvas.style.webkitTouchCallout = "none";
@@ -1390,6 +1452,19 @@ var SSDiagramHeadless = (() => {
1390
1452
  this.gridSize = nextSize;
1391
1453
  this.scheduleDraw();
1392
1454
  }
1455
+ /** See DiagramOptions.pageScroll. */
1456
+ setPageScroll(enabled) {
1457
+ this.pageScroll = enabled;
1458
+ this.applyTouchAction();
1459
+ }
1460
+ isPageScrollEnabled() {
1461
+ return this.pageScroll;
1462
+ }
1463
+ // pan-y hands a vertical swipe to the browser, which scrolls the page; a horizontal one and a
1464
+ // two-finger pinch still reach the canvas.
1465
+ applyTouchAction() {
1466
+ this.canvas.style.touchAction = this.pageScroll ? "pan-y" : "none";
1467
+ }
1393
1468
  getGridSnap() {
1394
1469
  return { enabled: this.gridSnapEnabled, size: this.gridSize };
1395
1470
  }
@@ -1605,7 +1680,7 @@ var SSDiagramHeadless = (() => {
1605
1680
  this.emit("linkValidation", { fromNode: fn, from: fp, toNode: tn, to: tp, ...validation });
1606
1681
  if (!validation.allowed) return false;
1607
1682
  }
1608
- const link = new LinkModel(init.from, init.fromPort, init.to, init.toPort, init.id ?? this.nextLinkId(), init.metadata, init.style ?? "solid");
1683
+ const link = new LinkModel(init.from, init.fromPort, init.to, init.toPort, init.id ?? this.nextLinkId(), init.metadata, init.style ?? "solid", init.label ?? "");
1609
1684
  if (this.links.some((l) => l.id === link.id)) return false;
1610
1685
  this.links.splice(clamp(Math.trunc(index), 0, this.links.length), 0, link);
1611
1686
  this.emit("linkAdded", { link });
@@ -1877,6 +1952,7 @@ var SSDiagramHeadless = (() => {
1877
1952
  updateNode(nodeId, patch) {
1878
1953
  return this.updateNodeState(nodeId, "update node", (node) => {
1879
1954
  if (patch.name !== void 0) node.name = patch.name;
1955
+ if (patch.subtitle !== void 0) node.subtitle = patch.subtitle;
1880
1956
  if (patch.description !== void 0) node.description = patch.description;
1881
1957
  if (patch.color !== void 0) node.color = patch.color;
1882
1958
  if (patch.border !== void 0) node.border = patch.border;
@@ -1922,6 +1998,7 @@ var SSDiagramHeadless = (() => {
1922
1998
  const restored = new NodeModel(snapshot, nodeId);
1923
1999
  node.typeId = restored.typeId;
1924
2000
  node.name = restored.name;
2001
+ node.subtitle = restored.subtitle;
1925
2002
  node.description = restored.description;
1926
2003
  node.groupName = restored.groupName;
1927
2004
  node.color = restored.color;
@@ -1952,6 +2029,10 @@ var SSDiagramHeadless = (() => {
1952
2029
  return true;
1953
2030
  }
1954
2031
  load(nodes, links) {
2032
+ this.loadModel(nodes, links, []);
2033
+ }
2034
+ // Zones are placed before the view is fitted, so the first frame frames them too.
2035
+ loadModel(nodes, links, zones) {
1955
2036
  this.clear();
1956
2037
  this.idSeq = 1;
1957
2038
  this.linkSeq = 1;
@@ -1978,9 +2059,10 @@ var SSDiagramHeadless = (() => {
1978
2059
  if (this.links.some((existing) => existing.id === id)) {
1979
2060
  throw new Error(`ssdiagram: duplicate link id "${id}"`);
1980
2061
  }
1981
- const model = new LinkModel(link.from, link.fromPort, link.to, link.toPort, id, link.metadata, link.style ?? "solid");
2062
+ const model = new LinkModel(link.from, link.fromPort, link.to, link.toPort, id, link.metadata, link.style ?? "solid", link.label ?? "");
1982
2063
  if (!this.links.some((existing) => existing.key() === model.key())) this.links.push(model);
1983
2064
  }
2065
+ this.zones = zones.map((zone) => ({ ...zone, metadata: copyJsonObject(zone.metadata) }));
1984
2066
  for (const node of this.nodes) {
1985
2067
  if (node.loadError.length === 0) continue;
1986
2068
  const state = createDiagramNodeRuntimeState();
@@ -2003,13 +2085,14 @@ var SSDiagramHeadless = (() => {
2003
2085
  to: l.to,
2004
2086
  toPort: l.toPort,
2005
2087
  style: l.style,
2088
+ ...l.label === "" ? {} : { label: l.label },
2006
2089
  metadata: copyJsonObject(l.metadata)
2007
2090
  }))
2008
2091
  };
2009
2092
  }
2010
2093
  loadDocument(source) {
2011
2094
  const document2 = parseDiagramDocument(source);
2012
- this.load(
2095
+ this.loadModel(
2013
2096
  document2.nodes,
2014
2097
  document2.links.map((link) => ({
2015
2098
  id: link.id,
@@ -2018,10 +2101,11 @@ var SSDiagramHeadless = (() => {
2018
2101
  to: link.to.nodeId,
2019
2102
  toPort: link.to.portId,
2020
2103
  style: link.style,
2104
+ label: link.label,
2021
2105
  metadata: link.metadata
2022
- }))
2106
+ })),
2107
+ document2.zones
2023
2108
  );
2024
- this.zones = document2.zones.map((zone) => ({ ...zone, metadata: copyJsonObject(zone.metadata) }));
2025
2109
  this.documentMetadata = copyJsonObject(document2.metadata);
2026
2110
  }
2027
2111
  saveDocument() {
@@ -2032,6 +2116,7 @@ var SSDiagramHeadless = (() => {
2032
2116
  from: { nodeId: link.from, portId: link.fromPort },
2033
2117
  to: { nodeId: link.to, portId: link.toPort },
2034
2118
  style: link.style,
2119
+ label: link.label,
2035
2120
  metadata: link.metadata
2036
2121
  })),
2037
2122
  zones: this.zones.map((zone) => ({ ...zone, metadata: copyJsonObject(zone.metadata) })),
@@ -2458,8 +2543,10 @@ var SSDiagramHeadless = (() => {
2458
2543
  if (t.overviewViewportFill !== void 0) this.opts.overviewViewportFill = t.overviewViewportFill;
2459
2544
  this.scheduleDraw();
2460
2545
  }
2546
+ // Zones count: fitting or exporting only the nodes cut off the places they sit in, captions
2547
+ // included. A caption that is taller than its zone still has to fit.
2461
2548
  graphBounds() {
2462
- if (this.nodes.length === 0) return null;
2549
+ if (this.nodes.length === 0 && this.zones.length === 0) return null;
2463
2550
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
2464
2551
  for (const n2 of this.nodes) {
2465
2552
  minX = Math.min(minX, n2.x);
@@ -2467,6 +2554,13 @@ var SSDiagramHeadless = (() => {
2467
2554
  maxX = Math.max(maxX, n2.x + n2.w);
2468
2555
  maxY = Math.max(maxY, n2.y + n2.h);
2469
2556
  }
2557
+ for (const zone of this.zones) {
2558
+ const bottom = zone.name.length > 0 ? Math.max(zone.height, ZONE_CAPTION_TOP + ZONE_CAPTION_H) : zone.height;
2559
+ minX = Math.min(minX, zone.x - 1);
2560
+ minY = Math.min(minY, zone.y - 1);
2561
+ maxX = Math.max(maxX, zone.x + zone.width + 1);
2562
+ maxY = Math.max(maxY, zone.y + bottom + 1);
2563
+ }
2470
2564
  return { minX, minY, maxX, maxY };
2471
2565
  }
2472
2566
  resize(w, h) {
@@ -2518,11 +2612,16 @@ var SSDiagramHeadless = (() => {
2518
2612
  // ---- geometry ---------------------------------------------------
2519
2613
  layoutNode(n2) {
2520
2614
  const ctx = this.ctx;
2521
- ctx.font = "600 12px Segoe UI, Tahoma, sans-serif";
2615
+ ctx.font = TITLE_FONT;
2522
2616
  const titleW = ctx.measureText(n2.name).width;
2617
+ let subtitleW = 0;
2618
+ if (n2.subtitle.length > 0) {
2619
+ ctx.font = SUBTITLE_FONT;
2620
+ subtitleW = ctx.measureText(n2.subtitle).width;
2621
+ }
2523
2622
  const rows = Math.max(n2.inPorts.length, n2.outPorts.length, 1);
2524
- n2.w = Math.max(170, Math.ceil(titleW) + 28);
2525
- n2.h = Math.max(48, rows * PORT_ROW_H + 16);
2623
+ n2.w = Math.max(170, Math.ceil(titleW) + 28, Math.ceil(subtitleW) + 28);
2624
+ n2.h = Math.max(n2.subtitle.length > 0 ? SUBTITLE_MIN_H : 48, rows * PORT_ROW_H + 16);
2526
2625
  const off = PORT_SQ / 2;
2527
2626
  const place = (ports, x) => {
2528
2627
  const stack = ports.some(isGrowAnchor) ? [...ports.filter((p) => !isGrowAnchor(p)), ...ports.filter(isGrowAnchor)] : ports;
@@ -2881,6 +2980,7 @@ var SSDiagramHeadless = (() => {
2881
2980
  // The clipboard is built from saveDocument(), so it carries the style;
2882
2981
  // rebuilding without it silently turned every pasted dashed link solid.
2883
2982
  style: l.style,
2983
+ label: l.label,
2884
2984
  metadata: l.metadata
2885
2985
  });
2886
2986
  }
@@ -3266,6 +3366,7 @@ var SSDiagramHeadless = (() => {
3266
3366
  this.listen(window, "pointerup", finish);
3267
3367
  this.listen(window, "pointercancel", finish);
3268
3368
  this.listen(this.canvas, "wheel", (e) => {
3369
+ if (this.pageScroll && !e.ctrlKey && !e.metaKey) return;
3269
3370
  e.preventDefault();
3270
3371
  const [sx, sy] = localXY(e);
3271
3372
  const [wx, wy] = this.toWorld(sx, sy);
@@ -3411,6 +3512,7 @@ var SSDiagramHeadless = (() => {
3411
3512
  }
3412
3513
  draw(options = SCREEN_RENDER_OPTIONS) {
3413
3514
  const ctx = this.ctx;
3515
+ this.hostStyle = null;
3414
3516
  ctx.clearRect(0, 0, this.width, this.height);
3415
3517
  const background = this.opts.background ?? "#1b1b1f";
3416
3518
  ctx.fillStyle = background;
@@ -3438,6 +3540,7 @@ var SSDiagramHeadless = (() => {
3438
3540
  );
3439
3541
  this.drawZones();
3440
3542
  const prior = [];
3543
+ const labels = [];
3441
3544
  const hoveredNode = this.hoverPort?.node ?? this.hoverNode;
3442
3545
  for (const l of this.links) {
3443
3546
  const a = this.endpoint(l, "from");
@@ -3451,6 +3554,7 @@ var SSDiagramHeadless = (() => {
3451
3554
  const pts = this.routeLink(a, b, /* @__PURE__ */ new Set([l.from, l.to]));
3452
3555
  this.strokeRoute(pts, color, width, prior, l.style);
3453
3556
  this.drawArrow(pts, color);
3557
+ if (l.label.length > 0) labels.push({ pts, text: l.label, color });
3454
3558
  for (let k = 1; k < pts.length; k += 1) {
3455
3559
  const [x1, y1] = pts[k - 1];
3456
3560
  const [x2, y2] = pts[k];
@@ -3458,6 +3562,7 @@ var SSDiagramHeadless = (() => {
3458
3562
  else if (y1 === y2 && x1 !== x2) prior.push({ h: true, c: y1, a: Math.min(x1, x2), b: Math.max(x1, x2) });
3459
3563
  }
3460
3564
  }
3565
+ for (const label of labels) this.drawLinkLabel(label.pts, label.text, label.color, background);
3461
3566
  if (options.transient && (this.linking !== null || this.relinking !== null)) this.drawPendingLink();
3462
3567
  this.portFeed = this.feedTypes();
3463
3568
  for (const n2 of this.nodes) this.drawNode(n2, options.selection && this.selectedNodes.has(n2), options);
@@ -3612,8 +3717,9 @@ ${runtime.error}`;
3612
3717
  drawZones() {
3613
3718
  if (this.zones.length === 0) return;
3614
3719
  const ctx = this.ctx;
3720
+ const defaultTint = this.resolveColor(this.opts.zoneColor ?? DEFAULT_ZONE_TINT, DEFAULT_ZONE_TINT);
3615
3721
  for (const zone of this.zones) {
3616
- const tint = zone.color.length > 0 ? zone.color : this.opts.zoneColor ?? "#8a8f98";
3722
+ const tint = zone.color.length > 0 ? this.resolveColor(zone.color, defaultTint) : defaultTint;
3617
3723
  ctx.save();
3618
3724
  roundRect(ctx, zone.x, zone.y, zone.width, zone.height, 10);
3619
3725
  ctx.globalAlpha = 0.12;
@@ -3629,10 +3735,10 @@ ${runtime.error}`;
3629
3735
  ctx.save();
3630
3736
  ctx.globalAlpha = 0.75;
3631
3737
  ctx.fillStyle = tint;
3632
- ctx.font = "600 12px Segoe UI, Tahoma, sans-serif";
3738
+ ctx.font = TITLE_FONT;
3633
3739
  ctx.textAlign = "left";
3634
3740
  ctx.textBaseline = "top";
3635
- ctx.fillText(zone.name, zone.x + 12, zone.y + 9, Math.max(0, zone.width - 24));
3741
+ ctx.fillText(zone.name, zone.x + 12, zone.y + ZONE_CAPTION_TOP, Math.max(0, zone.width - 24));
3636
3742
  ctx.restore();
3637
3743
  }
3638
3744
  }
@@ -3677,11 +3783,12 @@ ${runtime.error}`;
3677
3783
  const hasRuntimeError = options.runtime && n2.runtimeError.length > 0;
3678
3784
  const runtime = options.runtime ? getJsonKey(this.runtimeState.nodes, n2.id) : void 0;
3679
3785
  const active = options.runtime && (this.runtimeState.activeNodeId === n2.id || runtime?.active === true);
3786
+ const fill = this.resolveColor(n2.color, DEFAULT_NODE_COLOR);
3680
3787
  roundRect(ctx, n2.x, n2.y, n2.w, n2.h, 6);
3681
- ctx.fillStyle = hasLoadError ? ERROR_BACKGROUND : active ? "#ffd1dc" : n2.color;
3788
+ ctx.fillStyle = hasLoadError ? ERROR_BACKGROUND : active ? "#ffd1dc" : fill;
3682
3789
  ctx.fill();
3683
3790
  ctx.lineWidth = selected ? 2 : 1.5;
3684
- ctx.strokeStyle = selected ? this.selectionColor() : n2.border;
3791
+ ctx.strokeStyle = selected ? this.selectionColor() : this.resolveColor(n2.border, DEFAULT_NODE_BORDER);
3685
3792
  ctx.stroke();
3686
3793
  if (hasLoadError || hasRuntimeError) {
3687
3794
  let alpha = 1;
@@ -3714,11 +3821,22 @@ ${runtime.error}`;
3714
3821
  }
3715
3822
  }
3716
3823
  const titleShift = n2.icon ? iconW + 4 : 0;
3717
- ctx.fillStyle = hasLoadError ? "#ffffff" : readableTextOn(active ? "#ffd1dc" : n2.color);
3718
- ctx.font = "600 12px Segoe UI, Tahoma, sans-serif";
3824
+ const textX = n2.x + titleShift + (n2.w - titleShift) / 2;
3825
+ const textWidth = n2.w - titleShift - 14;
3826
+ ctx.fillStyle = hasLoadError ? "#ffffff" : readableTextOn(active ? "#ffd1dc" : fill);
3827
+ ctx.font = TITLE_FONT;
3719
3828
  ctx.textAlign = "center";
3720
3829
  ctx.textBaseline = "middle";
3721
- ctx.fillText(n2.name, n2.x + titleShift + (n2.w - titleShift) / 2, n2.y + n2.h / 2, n2.w - titleShift - 14);
3830
+ if (n2.subtitle.length === 0) {
3831
+ ctx.fillText(n2.name, textX, n2.y + n2.h / 2, textWidth);
3832
+ } else {
3833
+ ctx.fillText(n2.name, textX, n2.y + n2.h / 2 - 8, textWidth);
3834
+ ctx.save();
3835
+ ctx.globalAlpha *= 0.72;
3836
+ ctx.font = SUBTITLE_FONT;
3837
+ ctx.fillText(n2.subtitle, textX, n2.y + n2.h / 2 + 9, textWidth);
3838
+ ctx.restore();
3839
+ }
3722
3840
  for (const p of n2.inPorts) this.drawPort(n2, p, options);
3723
3841
  for (const p of n2.outPorts) this.drawPort(n2, p, options);
3724
3842
  }
@@ -3835,6 +3953,45 @@ ${runtime.error}`;
3835
3953
  ctx.fill();
3836
3954
  ctx.restore();
3837
3955
  }
3956
+ // A plate of the canvas colour under the text, edged in the wire's colour: the label reads the same
3957
+ // whatever the wire crosses, and still says which wire it belongs to.
3958
+ drawLinkLabel(pts, text, color, background) {
3959
+ const [x, y] = pathMidpoint(pts);
3960
+ const ctx = this.ctx;
3961
+ ctx.save();
3962
+ ctx.font = LINK_LABEL_FONT;
3963
+ const w = Math.ceil(ctx.measureText(text).width) + 12;
3964
+ const h = 16;
3965
+ roundRect(ctx, x - w / 2, y - h / 2, w, h, h / 2);
3966
+ ctx.fillStyle = background;
3967
+ ctx.fill();
3968
+ ctx.lineWidth = 1;
3969
+ ctx.strokeStyle = color;
3970
+ ctx.stroke();
3971
+ ctx.fillStyle = readableTextOn(background);
3972
+ ctx.textAlign = "center";
3973
+ ctx.textBaseline = "middle";
3974
+ ctx.fillText(text, x, y);
3975
+ ctx.restore();
3976
+ }
3977
+ // Scheme colours may name a custom property of the page, var(--token), which a canvas cannot
3978
+ // paint. They resolve against the host element; one that resolves to nothing and has no
3979
+ // fallback of its own gets the renderer's default for that slot.
3980
+ resolveColor(value, fallback) {
3981
+ if (!value.includes("var(")) return value;
3982
+ return resolveCssVariables(value, (name) => this.hostProperty(name)) ?? fallback;
3983
+ }
3984
+ hostProperty(name) {
3985
+ if (this.hostStyle === null) {
3986
+ if (typeof getComputedStyle !== "function") return "";
3987
+ try {
3988
+ this.hostStyle = getComputedStyle(this.host);
3989
+ } catch {
3990
+ return "";
3991
+ }
3992
+ }
3993
+ return this.hostStyle.getPropertyValue(name);
3994
+ }
3838
3995
  drawSelectedLinkEndpoints() {
3839
3996
  if (this.selectedLink === null) return;
3840
3997
  const ctx = this.ctx;
@@ -3950,9 +4107,9 @@ ${runtime.error}`;
3950
4107
  ctx.stroke();
3951
4108
  ctx.clip();
3952
4109
  for (const n2 of this.nodes) {
3953
- ctx.fillStyle = n2.color;
4110
+ ctx.fillStyle = this.resolveColor(n2.color, DEFAULT_NODE_COLOR);
3954
4111
  ctx.fillRect(mx(n2.x), my(n2.y), Math.max(2, n2.w * s), Math.max(2, n2.h * s));
3955
- ctx.strokeStyle = n2.border;
4112
+ ctx.strokeStyle = this.resolveColor(n2.border, DEFAULT_NODE_BORDER);
3956
4113
  ctx.lineWidth = 1;
3957
4114
  ctx.strokeRect(mx(n2.x), my(n2.y), Math.max(2, n2.w * s), Math.max(2, n2.h * s));
3958
4115
  }
@@ -3993,6 +4150,23 @@ ${runtime.error}`;
3993
4150
  ctx.arcTo(x, y, x + w, y, rr);
3994
4151
  ctx.closePath();
3995
4152
  }
4153
+ function pathMidpoint(pts) {
4154
+ let total = 0;
4155
+ for (let i = 1; i < pts.length; i += 1) total += Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]);
4156
+ let remaining = total / 2;
4157
+ for (let i = 1; i < pts.length; i += 1) {
4158
+ const [x1, y1] = pts[i - 1];
4159
+ const [x2, y2] = pts[i];
4160
+ const length = Math.hypot(x2 - x1, y2 - y1);
4161
+ if (length > 0 && remaining <= length) {
4162
+ const t = remaining / length;
4163
+ return [x1 + (x2 - x1) * t, y1 + (y2 - y1) * t];
4164
+ }
4165
+ remaining -= length;
4166
+ }
4167
+ const last = pts[pts.length - 1];
4168
+ return [last[0], last[1]];
4169
+ }
3996
4170
  function route(a, b) {
3997
4171
  const [ax, ay] = a;
3998
4172
  const [bx, by] = b;