@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
@@ -418,6 +418,82 @@ var SSDiagramHeadless = (() => {
418
418
  return DARK_OUTLINE;
419
419
  return contrast(background, relativeLuminance(LIGHT_OUTLINE)) > contrast(background, relativeLuminance(DARK_OUTLINE)) ? LIGHT_OUTLINE : DARK_OUTLINE;
420
420
  }
421
+ function toHsl(color) {
422
+ const hsl = color.trim().match(/^hsl\(\s*([\d.]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*\)$/i);
423
+ if (hsl !== null)
424
+ return [+hsl[1], +hsl[2] / 100, +hsl[3] / 100];
425
+ const rgb = parseRgb(color);
426
+ if (rgb === null)
427
+ return null;
428
+ const [r, g, b] = rgb.map((channel) => channel / 255);
429
+ const max = Math.max(r, g, b);
430
+ const min = Math.min(r, g, b);
431
+ const lightness = (max + min) / 2;
432
+ if (max === min)
433
+ return [0, 0, lightness];
434
+ const span = max - min;
435
+ const saturation = lightness > 0.5 ? span / (2 - max - min) : span / (max + min);
436
+ const hue = max === r ? (g - b) / span + (g < b ? 6 : 0) : max === g ? (b - r) / span + 2 : (r - g) / span + 4;
437
+ return [hue * 60, saturation, lightness];
438
+ }
439
+ var DARK_CANVAS_FLOOR = 0.42;
440
+ var LIGHT_CANVAS_CEILING = 0.45;
441
+ function legibleOn(color, background) {
442
+ const hsl = toHsl(color);
443
+ if (hsl === null)
444
+ return color;
445
+ const canvas = relativeLuminance(background);
446
+ const [hue, saturation, lightness] = hsl;
447
+ const moved = canvas < 0 ? lightness : canvas > 0.18 ? Math.min(lightness, LIGHT_CANVAS_CEILING) : Math.max(lightness, DARK_CANVAS_FLOOR);
448
+ if (moved === lightness && /^hsl/i.test(color.trim()))
449
+ return color;
450
+ return `hsl(${Math.round(hue * 10) / 10}, ${Math.round(saturation * 1e3) / 10}%, ${Math.round(moved * 1e3) / 10}%)`;
451
+ }
452
+ function cappedLightness(color, maxLightness) {
453
+ const hsl = toHsl(color);
454
+ if (hsl === null)
455
+ return color;
456
+ const [hue, saturation, lightness] = hsl;
457
+ if (lightness <= maxLightness)
458
+ return color;
459
+ return `hsl(${Math.round(hue * 10) / 10}, ${Math.round(saturation * 1e3) / 10}%, ${Math.round(maxLightness * 1e3) / 10}%)`;
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
+ }
421
497
 
422
498
  // src/core/json.ts
423
499
  function setJsonKey(target, key, value) {
@@ -515,10 +591,12 @@ var SSDiagramHeadless = (() => {
515
591
  function normalizeNode(value, path) {
516
592
  const input = requireStructure(value, path);
517
593
  const id = requireIdentifier(input.id, `${path}.id`);
594
+ const subtitle = requireOptionalText(input.subtitle, `${path}.subtitle`);
518
595
  return {
519
596
  id,
520
597
  typeId: requireIdentifier(input.typeId ?? id, `${path}.typeId`),
521
598
  name: requireString(input.name, `${path}.name`),
599
+ ...subtitle === "" ? {} : { subtitle },
522
600
  description: requireString(input.description ?? "", `${path}.description`),
523
601
  groupName: requireString(input.groupName ?? "Common", `${path}.groupName`),
524
602
  x: requireFiniteNumber(input.x ?? 0, `${path}.x`),
@@ -569,14 +647,19 @@ var SSDiagramHeadless = (() => {
569
647
  }
570
648
  function normalizeLink(value, path) {
571
649
  const input = requireStructure(value, path);
650
+ const label = requireOptionalText(input.label, `${path}.label`);
572
651
  return {
573
652
  id: requireIdentifier(input.id, `${path}.id`),
574
653
  from: normalizeEndpoint(input.from, `${path}.from`),
575
654
  to: normalizeEndpoint(input.to, `${path}.to`),
576
655
  style: requireLinkStyle(input.style, `${path}.style`),
656
+ ...label === "" ? {} : { label },
577
657
  metadata: cloneJsonObject(input.metadata ?? {}, `${path}.metadata`)
578
658
  };
579
659
  }
660
+ function requireOptionalText(value, path) {
661
+ return value === void 0 || value === null ? "" : requireString(value, path);
662
+ }
580
663
  function requireLinkStyle(value, path) {
581
664
  if (value === void 0 || value === null) return "solid";
582
665
  if (value !== "solid" && value !== "dashed") {
@@ -616,6 +699,7 @@ var SSDiagramHeadless = (() => {
616
699
  id: requireString(node.id, `${path}.id`),
617
700
  typeId: requireString(node.typeId, `${path}.typeId`),
618
701
  name: requireString(node.name, `${path}.name`),
702
+ subtitle: node.subtitle,
619
703
  description: requireString(node.description, `${path}.description`),
620
704
  groupName: requireString(node.groupName, `${path}.groupName`),
621
705
  x: requireNumber(node.x, `${path}.x`),
@@ -671,6 +755,7 @@ var SSDiagramHeadless = (() => {
671
755
  from: parseEndpoint(link.from, `${path}.from`),
672
756
  to: parseEndpoint(link.to, `${path}.to`),
673
757
  style: link.style === void 0 ? void 0 : requireLinkStyle(link.style, `${path}.style`),
758
+ label: link.label,
674
759
  metadata: cloneJsonObject(link.metadata, `${path}.metadata`)
675
760
  }, path);
676
761
  }
@@ -1101,6 +1186,7 @@ var SSDiagramHeadless = (() => {
1101
1186
  this.id = id;
1102
1187
  this.typeId = init.typeId ?? id;
1103
1188
  this.name = init.name;
1189
+ this.subtitle = init.subtitle ?? "";
1104
1190
  this.description = init.description ?? "";
1105
1191
  this.groupName = init.groupName ?? "Common";
1106
1192
  this.color = init.color ?? "#d7d7d7";
@@ -1126,6 +1212,7 @@ var SSDiagramHeadless = (() => {
1126
1212
  id: this.id,
1127
1213
  typeId: this.typeId,
1128
1214
  name: this.name,
1215
+ ...this.subtitle === "" ? {} : { subtitle: this.subtitle },
1129
1216
  description: this.description,
1130
1217
  groupName: this.groupName,
1131
1218
  color: this.color,
@@ -1147,12 +1234,13 @@ var SSDiagramHeadless = (() => {
1147
1234
  }
1148
1235
  };
1149
1236
  var LinkModel = class {
1150
- constructor(from, fromPort, to, toPort, id = "", metadata, style = "solid") {
1237
+ constructor(from, fromPort, to, toPort, id = "", metadata, style = "solid", label = "") {
1151
1238
  this.from = from;
1152
1239
  this.fromPort = fromPort;
1153
1240
  this.to = to;
1154
1241
  this.toPort = toPort;
1155
1242
  this.style = style;
1243
+ this.label = label;
1156
1244
  this.id = id;
1157
1245
  this.metadata = copyJsonObject(metadata);
1158
1246
  }
@@ -1167,6 +1255,7 @@ var SSDiagramHeadless = (() => {
1167
1255
  to: this.to,
1168
1256
  toPort: this.toPort,
1169
1257
  style: this.style,
1258
+ ...this.label === "" ? {} : { label: this.label },
1170
1259
  metadata: copyJsonObject(this.metadata)
1171
1260
  };
1172
1261
  }
@@ -1187,6 +1276,15 @@ var SSDiagramHeadless = (() => {
1187
1276
  var INTRO_RISE = 70;
1188
1277
  var ERROR_FLASH_MS = 1100;
1189
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;
1190
1288
  var ERROR_BACKGROUND = "#7d2632";
1191
1289
  var clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
1192
1290
  function hashHue(s) {
@@ -1195,9 +1293,7 @@ var SSDiagramHeadless = (() => {
1195
1293
  return (h % 360 + 360) % 360;
1196
1294
  }
1197
1295
  function clampLightness(color, maxL) {
1198
- const m = color.match(/^hsl\(\s*([\d.]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*\)$/i);
1199
- if (m === null) return color;
1200
- return `hsl(${m[1]}, ${m[2]}%, ${Math.min(+m[3], maxL * 100)}%)`;
1296
+ return cappedLightness(color, maxL);
1201
1297
  }
1202
1298
  function colorLuminance(color) {
1203
1299
  if (color === void 0) return 0;
@@ -1286,10 +1382,14 @@ var SSDiagramHeadless = (() => {
1286
1382
  this.ovGeo = null;
1287
1383
  this.validator = null;
1288
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;
1289
1388
  this.opts = opts;
1290
1389
  if (opts.typeColors !== void 0) this.setTypeColorsInternal(opts.typeColors);
1291
1390
  this.host = opts.host;
1292
1391
  this.gridSnapEnabled = opts.gridSnap ?? false;
1392
+ this.pageScroll = opts.pageScroll ?? false;
1293
1393
  this.gridSize = this.normalizeGridSize(opts.gridSize ?? DEFAULT_GRID_SIZE);
1294
1394
  this.history = new DiagramCommandHistory(() => {
1295
1395
  this.emit("undoStackChanged", { canUndo: this.canUndo(), canRedo: this.canRedo() });
@@ -1298,7 +1398,7 @@ var SSDiagramHeadless = (() => {
1298
1398
  this.canvas.style.display = "block";
1299
1399
  this.canvas.tabIndex = 0;
1300
1400
  this.canvas.style.outline = "none";
1301
- this.canvas.style.touchAction = "none";
1401
+ this.applyTouchAction();
1302
1402
  this.canvas.style.userSelect = "none";
1303
1403
  this.canvas.style.webkitUserSelect = "none";
1304
1404
  this.canvas.style.webkitTouchCallout = "none";
@@ -1352,6 +1452,19 @@ var SSDiagramHeadless = (() => {
1352
1452
  this.gridSize = nextSize;
1353
1453
  this.scheduleDraw();
1354
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
+ }
1355
1468
  getGridSnap() {
1356
1469
  return { enabled: this.gridSnapEnabled, size: this.gridSize };
1357
1470
  }
@@ -1567,7 +1680,7 @@ var SSDiagramHeadless = (() => {
1567
1680
  this.emit("linkValidation", { fromNode: fn, from: fp, toNode: tn, to: tp, ...validation });
1568
1681
  if (!validation.allowed) return false;
1569
1682
  }
1570
- 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 ?? "");
1571
1684
  if (this.links.some((l) => l.id === link.id)) return false;
1572
1685
  this.links.splice(clamp(Math.trunc(index), 0, this.links.length), 0, link);
1573
1686
  this.emit("linkAdded", { link });
@@ -1839,6 +1952,7 @@ var SSDiagramHeadless = (() => {
1839
1952
  updateNode(nodeId, patch) {
1840
1953
  return this.updateNodeState(nodeId, "update node", (node) => {
1841
1954
  if (patch.name !== void 0) node.name = patch.name;
1955
+ if (patch.subtitle !== void 0) node.subtitle = patch.subtitle;
1842
1956
  if (patch.description !== void 0) node.description = patch.description;
1843
1957
  if (patch.color !== void 0) node.color = patch.color;
1844
1958
  if (patch.border !== void 0) node.border = patch.border;
@@ -1884,6 +1998,7 @@ var SSDiagramHeadless = (() => {
1884
1998
  const restored = new NodeModel(snapshot, nodeId);
1885
1999
  node.typeId = restored.typeId;
1886
2000
  node.name = restored.name;
2001
+ node.subtitle = restored.subtitle;
1887
2002
  node.description = restored.description;
1888
2003
  node.groupName = restored.groupName;
1889
2004
  node.color = restored.color;
@@ -1914,6 +2029,10 @@ var SSDiagramHeadless = (() => {
1914
2029
  return true;
1915
2030
  }
1916
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) {
1917
2036
  this.clear();
1918
2037
  this.idSeq = 1;
1919
2038
  this.linkSeq = 1;
@@ -1940,9 +2059,10 @@ var SSDiagramHeadless = (() => {
1940
2059
  if (this.links.some((existing) => existing.id === id)) {
1941
2060
  throw new Error(`ssdiagram: duplicate link id "${id}"`);
1942
2061
  }
1943
- 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 ?? "");
1944
2063
  if (!this.links.some((existing) => existing.key() === model.key())) this.links.push(model);
1945
2064
  }
2065
+ this.zones = zones.map((zone) => ({ ...zone, metadata: copyJsonObject(zone.metadata) }));
1946
2066
  for (const node of this.nodes) {
1947
2067
  if (node.loadError.length === 0) continue;
1948
2068
  const state = createDiagramNodeRuntimeState();
@@ -1965,13 +2085,14 @@ var SSDiagramHeadless = (() => {
1965
2085
  to: l.to,
1966
2086
  toPort: l.toPort,
1967
2087
  style: l.style,
2088
+ ...l.label === "" ? {} : { label: l.label },
1968
2089
  metadata: copyJsonObject(l.metadata)
1969
2090
  }))
1970
2091
  };
1971
2092
  }
1972
2093
  loadDocument(source) {
1973
2094
  const document2 = parseDiagramDocument(source);
1974
- this.load(
2095
+ this.loadModel(
1975
2096
  document2.nodes,
1976
2097
  document2.links.map((link) => ({
1977
2098
  id: link.id,
@@ -1980,10 +2101,11 @@ var SSDiagramHeadless = (() => {
1980
2101
  to: link.to.nodeId,
1981
2102
  toPort: link.to.portId,
1982
2103
  style: link.style,
2104
+ label: link.label,
1983
2105
  metadata: link.metadata
1984
- }))
2106
+ })),
2107
+ document2.zones
1985
2108
  );
1986
- this.zones = document2.zones.map((zone) => ({ ...zone, metadata: copyJsonObject(zone.metadata) }));
1987
2109
  this.documentMetadata = copyJsonObject(document2.metadata);
1988
2110
  }
1989
2111
  saveDocument() {
@@ -1994,6 +2116,7 @@ var SSDiagramHeadless = (() => {
1994
2116
  from: { nodeId: link.from, portId: link.fromPort },
1995
2117
  to: { nodeId: link.to, portId: link.toPort },
1996
2118
  style: link.style,
2119
+ label: link.label,
1997
2120
  metadata: link.metadata
1998
2121
  })),
1999
2122
  zones: this.zones.map((zone) => ({ ...zone, metadata: copyJsonObject(zone.metadata) })),
@@ -2420,8 +2543,10 @@ var SSDiagramHeadless = (() => {
2420
2543
  if (t.overviewViewportFill !== void 0) this.opts.overviewViewportFill = t.overviewViewportFill;
2421
2544
  this.scheduleDraw();
2422
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.
2423
2548
  graphBounds() {
2424
- if (this.nodes.length === 0) return null;
2549
+ if (this.nodes.length === 0 && this.zones.length === 0) return null;
2425
2550
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
2426
2551
  for (const n2 of this.nodes) {
2427
2552
  minX = Math.min(minX, n2.x);
@@ -2429,6 +2554,13 @@ var SSDiagramHeadless = (() => {
2429
2554
  maxX = Math.max(maxX, n2.x + n2.w);
2430
2555
  maxY = Math.max(maxY, n2.y + n2.h);
2431
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
+ }
2432
2564
  return { minX, minY, maxX, maxY };
2433
2565
  }
2434
2566
  resize(w, h) {
@@ -2480,11 +2612,16 @@ var SSDiagramHeadless = (() => {
2480
2612
  // ---- geometry ---------------------------------------------------
2481
2613
  layoutNode(n2) {
2482
2614
  const ctx = this.ctx;
2483
- ctx.font = "600 12px Segoe UI, Tahoma, sans-serif";
2615
+ ctx.font = TITLE_FONT;
2484
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
+ }
2485
2622
  const rows = Math.max(n2.inPorts.length, n2.outPorts.length, 1);
2486
- n2.w = Math.max(170, Math.ceil(titleW) + 28);
2487
- 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);
2488
2625
  const off = PORT_SQ / 2;
2489
2626
  const place = (ports, x) => {
2490
2627
  const stack = ports.some(isGrowAnchor) ? [...ports.filter((p) => !isGrowAnchor(p)), ...ports.filter(isGrowAnchor)] : ports;
@@ -2526,6 +2663,15 @@ var SSDiagramHeadless = (() => {
2526
2663
  if (p.direction !== "in" || p.type === "" || !isWildcardPortType(p.type)) return p.type;
2527
2664
  return this.portFeed.get(p) ?? p.type;
2528
2665
  }
2666
+ /// A type's colour as a wire.
2667
+ ///
2668
+ /// The same colour a socket of that type is filled with, moved into the band the canvas
2669
+ /// leaves room for. A socket can afford the host's exact colour because it is a filled
2670
+ /// square with an outline; a wire is a thin line with nothing behind it, and one drawn in
2671
+ /// #000000 on a dark canvas is not there at all.
2672
+ linkColor(type) {
2673
+ return legibleOn(this.portColor(type), this.opts.background ?? "#1b1b1f");
2674
+ }
2529
2675
  portColor(type) {
2530
2676
  const maxL = this.opts.linkMaxLightness;
2531
2677
  const light = maxL !== void 0 && maxL < 0.5;
@@ -2834,6 +2980,7 @@ var SSDiagramHeadless = (() => {
2834
2980
  // The clipboard is built from saveDocument(), so it carries the style;
2835
2981
  // rebuilding without it silently turned every pasted dashed link solid.
2836
2982
  style: l.style,
2983
+ label: l.label,
2837
2984
  metadata: l.metadata
2838
2985
  });
2839
2986
  }
@@ -3219,6 +3366,7 @@ var SSDiagramHeadless = (() => {
3219
3366
  this.listen(window, "pointerup", finish);
3220
3367
  this.listen(window, "pointercancel", finish);
3221
3368
  this.listen(this.canvas, "wheel", (e) => {
3369
+ if (this.pageScroll && !e.ctrlKey && !e.metaKey) return;
3222
3370
  e.preventDefault();
3223
3371
  const [sx, sy] = localXY(e);
3224
3372
  const [wx, wy] = this.toWorld(sx, sy);
@@ -3364,6 +3512,7 @@ var SSDiagramHeadless = (() => {
3364
3512
  }
3365
3513
  draw(options = SCREEN_RENDER_OPTIONS) {
3366
3514
  const ctx = this.ctx;
3515
+ this.hostStyle = null;
3367
3516
  ctx.clearRect(0, 0, this.width, this.height);
3368
3517
  const background = this.opts.background ?? "#1b1b1f";
3369
3518
  ctx.fillStyle = background;
@@ -3391,19 +3540,21 @@ var SSDiagramHeadless = (() => {
3391
3540
  );
3392
3541
  this.drawZones();
3393
3542
  const prior = [];
3543
+ const labels = [];
3394
3544
  const hoveredNode = this.hoverPort?.node ?? this.hoverNode;
3395
3545
  for (const l of this.links) {
3396
3546
  const a = this.endpoint(l, "from");
3397
3547
  const b = this.endpoint(l, "to");
3398
3548
  if (a === null || b === null) continue;
3399
3549
  const fp = this.nodes.find((n2) => n2.id === l.from)?.outPorts.find((p) => p.id === l.fromPort);
3400
- const baseColor = (this.opts.typedLinkColors ?? false) && fp !== void 0 ? this.portColor(fp.type) : this.portColor("");
3550
+ const baseColor = (this.opts.typedLinkColors ?? false) && fp !== void 0 ? this.linkColor(fp.type) : this.portColor("");
3401
3551
  const state = options.selection && l === this.selectedLink ? "sel" : options.transient && (l === this.hoveredLink || hoveredNode !== null && (l.from === hoveredNode.id || l.to === hoveredNode.id)) ? "hov" : "norm";
3402
3552
  const color = state === "sel" ? this.selectionColor() : state === "hov" ? this.linkHoverColor() : baseColor;
3403
3553
  const width = state === "sel" ? 3 : state === "hov" ? 2.6 : 2;
3404
3554
  const pts = this.routeLink(a, b, /* @__PURE__ */ new Set([l.from, l.to]));
3405
3555
  this.strokeRoute(pts, color, width, prior, l.style);
3406
3556
  this.drawArrow(pts, color);
3557
+ if (l.label.length > 0) labels.push({ pts, text: l.label, color });
3407
3558
  for (let k = 1; k < pts.length; k += 1) {
3408
3559
  const [x1, y1] = pts[k - 1];
3409
3560
  const [x2, y2] = pts[k];
@@ -3411,6 +3562,7 @@ var SSDiagramHeadless = (() => {
3411
3562
  else if (y1 === y2 && x1 !== x2) prior.push({ h: true, c: y1, a: Math.min(x1, x2), b: Math.max(x1, x2) });
3412
3563
  }
3413
3564
  }
3565
+ for (const label of labels) this.drawLinkLabel(label.pts, label.text, label.color, background);
3414
3566
  if (options.transient && (this.linking !== null || this.relinking !== null)) this.drawPendingLink();
3415
3567
  this.portFeed = this.feedTypes();
3416
3568
  for (const n2 of this.nodes) this.drawNode(n2, options.selection && this.selectedNodes.has(n2), options);
@@ -3565,8 +3717,9 @@ ${runtime.error}`;
3565
3717
  drawZones() {
3566
3718
  if (this.zones.length === 0) return;
3567
3719
  const ctx = this.ctx;
3720
+ const defaultTint = this.resolveColor(this.opts.zoneColor ?? DEFAULT_ZONE_TINT, DEFAULT_ZONE_TINT);
3568
3721
  for (const zone of this.zones) {
3569
- 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;
3570
3723
  ctx.save();
3571
3724
  roundRect(ctx, zone.x, zone.y, zone.width, zone.height, 10);
3572
3725
  ctx.globalAlpha = 0.12;
@@ -3582,10 +3735,10 @@ ${runtime.error}`;
3582
3735
  ctx.save();
3583
3736
  ctx.globalAlpha = 0.75;
3584
3737
  ctx.fillStyle = tint;
3585
- ctx.font = "600 12px Segoe UI, Tahoma, sans-serif";
3738
+ ctx.font = TITLE_FONT;
3586
3739
  ctx.textAlign = "left";
3587
3740
  ctx.textBaseline = "top";
3588
- 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));
3589
3742
  ctx.restore();
3590
3743
  }
3591
3744
  }
@@ -3630,11 +3783,12 @@ ${runtime.error}`;
3630
3783
  const hasRuntimeError = options.runtime && n2.runtimeError.length > 0;
3631
3784
  const runtime = options.runtime ? getJsonKey(this.runtimeState.nodes, n2.id) : void 0;
3632
3785
  const active = options.runtime && (this.runtimeState.activeNodeId === n2.id || runtime?.active === true);
3786
+ const fill = this.resolveColor(n2.color, DEFAULT_NODE_COLOR);
3633
3787
  roundRect(ctx, n2.x, n2.y, n2.w, n2.h, 6);
3634
- ctx.fillStyle = hasLoadError ? ERROR_BACKGROUND : active ? "#ffd1dc" : n2.color;
3788
+ ctx.fillStyle = hasLoadError ? ERROR_BACKGROUND : active ? "#ffd1dc" : fill;
3635
3789
  ctx.fill();
3636
3790
  ctx.lineWidth = selected ? 2 : 1.5;
3637
- ctx.strokeStyle = selected ? this.selectionColor() : n2.border;
3791
+ ctx.strokeStyle = selected ? this.selectionColor() : this.resolveColor(n2.border, DEFAULT_NODE_BORDER);
3638
3792
  ctx.stroke();
3639
3793
  if (hasLoadError || hasRuntimeError) {
3640
3794
  let alpha = 1;
@@ -3667,11 +3821,22 @@ ${runtime.error}`;
3667
3821
  }
3668
3822
  }
3669
3823
  const titleShift = n2.icon ? iconW + 4 : 0;
3670
- ctx.fillStyle = hasLoadError ? "#ffffff" : readableTextOn(active ? "#ffd1dc" : n2.color);
3671
- 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;
3672
3828
  ctx.textAlign = "center";
3673
3829
  ctx.textBaseline = "middle";
3674
- 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
+ }
3675
3840
  for (const p of n2.inPorts) this.drawPort(n2, p, options);
3676
3841
  for (const p of n2.outPorts) this.drawPort(n2, p, options);
3677
3842
  }
@@ -3788,6 +3953,45 @@ ${runtime.error}`;
3788
3953
  ctx.fill();
3789
3954
  ctx.restore();
3790
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
+ }
3791
3995
  drawSelectedLinkEndpoints() {
3792
3996
  if (this.selectedLink === null) return;
3793
3997
  const ctx = this.ctx;
@@ -3845,7 +4049,7 @@ ${runtime.error}`;
3845
4049
  }
3846
4050
  if (snapped !== null) excludedNodes.add(snapped.node.id);
3847
4051
  const points = this.routeLink(a, b, excludedNodes);
3848
- ctx.strokeStyle = this.portColor(colorType);
4052
+ ctx.strokeStyle = this.linkColor(colorType);
3849
4053
  if (snapped !== null) {
3850
4054
  ctx.setLineDash([]);
3851
4055
  ctx.lineWidth = 2.6;
@@ -3903,9 +4107,9 @@ ${runtime.error}`;
3903
4107
  ctx.stroke();
3904
4108
  ctx.clip();
3905
4109
  for (const n2 of this.nodes) {
3906
- ctx.fillStyle = n2.color;
4110
+ ctx.fillStyle = this.resolveColor(n2.color, DEFAULT_NODE_COLOR);
3907
4111
  ctx.fillRect(mx(n2.x), my(n2.y), Math.max(2, n2.w * s), Math.max(2, n2.h * s));
3908
- ctx.strokeStyle = n2.border;
4112
+ ctx.strokeStyle = this.resolveColor(n2.border, DEFAULT_NODE_BORDER);
3909
4113
  ctx.lineWidth = 1;
3910
4114
  ctx.strokeRect(mx(n2.x), my(n2.y), Math.max(2, n2.w * s), Math.max(2, n2.h * s));
3911
4115
  }
@@ -3946,6 +4150,23 @@ ${runtime.error}`;
3946
4150
  ctx.arcTo(x, y, x + w, y, rr);
3947
4151
  ctx.closePath();
3948
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
+ }
3949
4170
  function route(a, b) {
3950
4171
  const [ax, ay] = a;
3951
4172
  const [bx, by] = b;