@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
package/dist/ssdiagram.js CHANGED
@@ -53,6 +53,7 @@ var SSDiagram = (() => {
53
53
  parseDiagramViewState: () => parseDiagramViewState,
54
54
  renderAll: () => renderAll,
55
55
  renderDiagramSvg: () => renderDiagramSvg,
56
+ renderDocument: () => renderDocument,
56
57
  renderFromInline: () => renderFromInline,
57
58
  renderFromSource: () => renderFromSource,
58
59
  renderScheme: () => renderScheme,
@@ -162,10 +163,12 @@ var SSDiagram = (() => {
162
163
  function normalizeNode(value, path) {
163
164
  const input = requireStructure(value, path);
164
165
  const id = requireIdentifier(input.id, `${path}.id`);
166
+ const subtitle = requireOptionalText(input.subtitle, `${path}.subtitle`);
165
167
  return {
166
168
  id,
167
169
  typeId: requireIdentifier(input.typeId ?? id, `${path}.typeId`),
168
170
  name: requireString(input.name, `${path}.name`),
171
+ ...subtitle === "" ? {} : { subtitle },
169
172
  description: requireString(input.description ?? "", `${path}.description`),
170
173
  groupName: requireString(input.groupName ?? "Common", `${path}.groupName`),
171
174
  x: requireFiniteNumber(input.x ?? 0, `${path}.x`),
@@ -216,14 +219,19 @@ var SSDiagram = (() => {
216
219
  }
217
220
  function normalizeLink(value, path) {
218
221
  const input = requireStructure(value, path);
222
+ const label2 = requireOptionalText(input.label, `${path}.label`);
219
223
  return {
220
224
  id: requireIdentifier(input.id, `${path}.id`),
221
225
  from: normalizeEndpoint(input.from, `${path}.from`),
222
226
  to: normalizeEndpoint(input.to, `${path}.to`),
223
227
  style: requireLinkStyle(input.style, `${path}.style`),
228
+ ...label2 === "" ? {} : { label: label2 },
224
229
  metadata: cloneJsonObject(input.metadata ?? {}, `${path}.metadata`)
225
230
  };
226
231
  }
232
+ function requireOptionalText(value, path) {
233
+ return value === void 0 || value === null ? "" : requireString(value, path);
234
+ }
227
235
  function requireLinkStyle(value, path) {
228
236
  if (value === void 0 || value === null) return "solid";
229
237
  if (value !== "solid" && value !== "dashed") {
@@ -263,6 +271,7 @@ var SSDiagram = (() => {
263
271
  id: requireString(node.id, `${path}.id`),
264
272
  typeId: requireString(node.typeId, `${path}.typeId`),
265
273
  name: requireString(node.name, `${path}.name`),
274
+ subtitle: node.subtitle,
266
275
  description: requireString(node.description, `${path}.description`),
267
276
  groupName: requireString(node.groupName, `${path}.groupName`),
268
277
  x: requireNumber(node.x, `${path}.x`),
@@ -318,6 +327,7 @@ var SSDiagram = (() => {
318
327
  from: parseEndpoint(link.from, `${path}.from`),
319
328
  to: parseEndpoint(link.to, `${path}.to`),
320
329
  style: link.style === void 0 ? void 0 : requireLinkStyle(link.style, `${path}.style`),
330
+ label: link.label,
321
331
  metadata: cloneJsonObject(link.metadata, `${path}.metadata`)
322
332
  }, path);
323
333
  }
@@ -913,6 +923,42 @@ var SSDiagram = (() => {
913
923
  return color;
914
924
  return `hsl(${Math.round(hue * 10) / 10}, ${Math.round(saturation * 1e3) / 10}%, ${Math.round(maxLightness * 1e3) / 10}%)`;
915
925
  }
926
+ function resolveCssVariables(value, lookup) {
927
+ return resolveAt(value, lookup, 0);
928
+ }
929
+ function resolveAt(value, lookup, depth) {
930
+ const start = value.indexOf("var(");
931
+ if (start < 0)
932
+ return value;
933
+ if (depth > 8)
934
+ return null;
935
+ let level = 0;
936
+ let end = -1;
937
+ for (let i = start + 3; i < value.length; i += 1) {
938
+ if (value[i] === "(")
939
+ level += 1;
940
+ else if (value[i] === ")" && --level === 0) {
941
+ end = i;
942
+ break;
943
+ }
944
+ }
945
+ if (end < 0)
946
+ return null;
947
+ const inner = value.slice(start + 4, end);
948
+ const comma = inner.indexOf(",");
949
+ const name = (comma < 0 ? inner : inner.slice(0, comma)).trim();
950
+ const fallback = comma < 0 ? "" : inner.slice(comma + 1).trim();
951
+ let replacement = name.startsWith("--") ? lookup(name).trim() : "";
952
+ if (replacement === "")
953
+ replacement = fallback;
954
+ if (replacement === "")
955
+ return null;
956
+ const resolved = resolveAt(replacement, lookup, depth + 1);
957
+ const rest = resolveAt(value.slice(end + 1), lookup, depth);
958
+ if (resolved === null || rest === null)
959
+ return null;
960
+ return value.slice(0, start) + resolved + rest;
961
+ }
916
962
 
917
963
  // src/core/history.ts
918
964
  var DiagramCommandHistory = class {
@@ -1202,6 +1248,7 @@ var SSDiagram = (() => {
1202
1248
  this.id = id;
1203
1249
  this.typeId = init.typeId ?? id;
1204
1250
  this.name = init.name;
1251
+ this.subtitle = init.subtitle ?? "";
1205
1252
  this.description = init.description ?? "";
1206
1253
  this.groupName = init.groupName ?? "Common";
1207
1254
  this.color = init.color ?? "#d7d7d7";
@@ -1227,6 +1274,7 @@ var SSDiagram = (() => {
1227
1274
  id: this.id,
1228
1275
  typeId: this.typeId,
1229
1276
  name: this.name,
1277
+ ...this.subtitle === "" ? {} : { subtitle: this.subtitle },
1230
1278
  description: this.description,
1231
1279
  groupName: this.groupName,
1232
1280
  color: this.color,
@@ -1248,12 +1296,13 @@ var SSDiagram = (() => {
1248
1296
  }
1249
1297
  };
1250
1298
  var LinkModel = class {
1251
- constructor(from, fromPort, to, toPort, id = "", metadata, style = "solid") {
1299
+ constructor(from, fromPort, to, toPort, id = "", metadata, style = "solid", label2 = "") {
1252
1300
  this.from = from;
1253
1301
  this.fromPort = fromPort;
1254
1302
  this.to = to;
1255
1303
  this.toPort = toPort;
1256
1304
  this.style = style;
1305
+ this.label = label2;
1257
1306
  this.id = id;
1258
1307
  this.metadata = copyJsonObject(metadata);
1259
1308
  }
@@ -1268,6 +1317,7 @@ var SSDiagram = (() => {
1268
1317
  to: this.to,
1269
1318
  toPort: this.toPort,
1270
1319
  style: this.style,
1320
+ ...this.label === "" ? {} : { label: this.label },
1271
1321
  metadata: copyJsonObject(this.metadata)
1272
1322
  };
1273
1323
  }
@@ -1288,6 +1338,15 @@ var SSDiagram = (() => {
1288
1338
  var INTRO_RISE = 70;
1289
1339
  var ERROR_FLASH_MS = 1100;
1290
1340
  var ERROR_RED = "#f6465d";
1341
+ var DEFAULT_NODE_COLOR = "#d7d7d7";
1342
+ var DEFAULT_NODE_BORDER = "#8c8c8c";
1343
+ var DEFAULT_ZONE_TINT = "#8a8f98";
1344
+ var TITLE_FONT = "600 12px Segoe UI, Tahoma, sans-serif";
1345
+ var SUBTITLE_FONT = "11px Segoe UI, Tahoma, sans-serif";
1346
+ var SUBTITLE_MIN_H = 62;
1347
+ var LINK_LABEL_FONT = "600 10px Segoe UI, Tahoma, sans-serif";
1348
+ var ZONE_CAPTION_TOP = 9;
1349
+ var ZONE_CAPTION_H = 14;
1291
1350
  var ERROR_BACKGROUND = "#7d2632";
1292
1351
  var clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
1293
1352
  function hashHue(s) {
@@ -1385,10 +1444,14 @@ var SSDiagram = (() => {
1385
1444
  this.ovGeo = null;
1386
1445
  this.validator = null;
1387
1446
  this.handlers = /* @__PURE__ */ new Map();
1447
+ // Computed style of the host, read at most once per frame: colours written as var(--token)
1448
+ // resolve against it, so a frame drawn after a theme switch picks up the new values.
1449
+ this.hostStyle = null;
1388
1450
  this.opts = opts;
1389
1451
  if (opts.typeColors !== void 0) this.setTypeColorsInternal(opts.typeColors);
1390
1452
  this.host = opts.host;
1391
1453
  this.gridSnapEnabled = opts.gridSnap ?? false;
1454
+ this.pageScroll = opts.pageScroll ?? false;
1392
1455
  this.gridSize = this.normalizeGridSize(opts.gridSize ?? DEFAULT_GRID_SIZE);
1393
1456
  this.history = new DiagramCommandHistory(() => {
1394
1457
  this.emit("undoStackChanged", { canUndo: this.canUndo(), canRedo: this.canRedo() });
@@ -1397,7 +1460,7 @@ var SSDiagram = (() => {
1397
1460
  this.canvas.style.display = "block";
1398
1461
  this.canvas.tabIndex = 0;
1399
1462
  this.canvas.style.outline = "none";
1400
- this.canvas.style.touchAction = "none";
1463
+ this.applyTouchAction();
1401
1464
  this.canvas.style.userSelect = "none";
1402
1465
  this.canvas.style.webkitUserSelect = "none";
1403
1466
  this.canvas.style.webkitTouchCallout = "none";
@@ -1451,6 +1514,19 @@ var SSDiagram = (() => {
1451
1514
  this.gridSize = nextSize;
1452
1515
  this.scheduleDraw();
1453
1516
  }
1517
+ /** See DiagramOptions.pageScroll. */
1518
+ setPageScroll(enabled) {
1519
+ this.pageScroll = enabled;
1520
+ this.applyTouchAction();
1521
+ }
1522
+ isPageScrollEnabled() {
1523
+ return this.pageScroll;
1524
+ }
1525
+ // pan-y hands a vertical swipe to the browser, which scrolls the page; a horizontal one and a
1526
+ // two-finger pinch still reach the canvas.
1527
+ applyTouchAction() {
1528
+ this.canvas.style.touchAction = this.pageScroll ? "pan-y" : "none";
1529
+ }
1454
1530
  getGridSnap() {
1455
1531
  return { enabled: this.gridSnapEnabled, size: this.gridSize };
1456
1532
  }
@@ -1666,7 +1742,7 @@ var SSDiagram = (() => {
1666
1742
  this.emit("linkValidation", { fromNode: fn, from: fp, toNode: tn, to: tp, ...validation });
1667
1743
  if (!validation.allowed) return false;
1668
1744
  }
1669
- const link = new LinkModel(init.from, init.fromPort, init.to, init.toPort, init.id ?? this.nextLinkId(), init.metadata, init.style ?? "solid");
1745
+ const link = new LinkModel(init.from, init.fromPort, init.to, init.toPort, init.id ?? this.nextLinkId(), init.metadata, init.style ?? "solid", init.label ?? "");
1670
1746
  if (this.links.some((l) => l.id === link.id)) return false;
1671
1747
  this.links.splice(clamp(Math.trunc(index), 0, this.links.length), 0, link);
1672
1748
  this.emit("linkAdded", { link });
@@ -1938,6 +2014,7 @@ var SSDiagram = (() => {
1938
2014
  updateNode(nodeId, patch) {
1939
2015
  return this.updateNodeState(nodeId, "update node", (node) => {
1940
2016
  if (patch.name !== void 0) node.name = patch.name;
2017
+ if (patch.subtitle !== void 0) node.subtitle = patch.subtitle;
1941
2018
  if (patch.description !== void 0) node.description = patch.description;
1942
2019
  if (patch.color !== void 0) node.color = patch.color;
1943
2020
  if (patch.border !== void 0) node.border = patch.border;
@@ -1983,6 +2060,7 @@ var SSDiagram = (() => {
1983
2060
  const restored = new NodeModel(snapshot, nodeId);
1984
2061
  node.typeId = restored.typeId;
1985
2062
  node.name = restored.name;
2063
+ node.subtitle = restored.subtitle;
1986
2064
  node.description = restored.description;
1987
2065
  node.groupName = restored.groupName;
1988
2066
  node.color = restored.color;
@@ -2013,6 +2091,10 @@ var SSDiagram = (() => {
2013
2091
  return true;
2014
2092
  }
2015
2093
  load(nodes, links) {
2094
+ this.loadModel(nodes, links, []);
2095
+ }
2096
+ // Zones are placed before the view is fitted, so the first frame frames them too.
2097
+ loadModel(nodes, links, zones) {
2016
2098
  this.clear();
2017
2099
  this.idSeq = 1;
2018
2100
  this.linkSeq = 1;
@@ -2039,9 +2121,10 @@ var SSDiagram = (() => {
2039
2121
  if (this.links.some((existing) => existing.id === id)) {
2040
2122
  throw new Error(`ssdiagram: duplicate link id "${id}"`);
2041
2123
  }
2042
- const model = new LinkModel(link.from, link.fromPort, link.to, link.toPort, id, link.metadata, link.style ?? "solid");
2124
+ const model = new LinkModel(link.from, link.fromPort, link.to, link.toPort, id, link.metadata, link.style ?? "solid", link.label ?? "");
2043
2125
  if (!this.links.some((existing) => existing.key() === model.key())) this.links.push(model);
2044
2126
  }
2127
+ this.zones = zones.map((zone) => ({ ...zone, metadata: copyJsonObject(zone.metadata) }));
2045
2128
  for (const node of this.nodes) {
2046
2129
  if (node.loadError.length === 0) continue;
2047
2130
  const state = createDiagramNodeRuntimeState();
@@ -2064,13 +2147,14 @@ var SSDiagram = (() => {
2064
2147
  to: l.to,
2065
2148
  toPort: l.toPort,
2066
2149
  style: l.style,
2150
+ ...l.label === "" ? {} : { label: l.label },
2067
2151
  metadata: copyJsonObject(l.metadata)
2068
2152
  }))
2069
2153
  };
2070
2154
  }
2071
2155
  loadDocument(source) {
2072
2156
  const document2 = parseDiagramDocument(source);
2073
- this.load(
2157
+ this.loadModel(
2074
2158
  document2.nodes,
2075
2159
  document2.links.map((link) => ({
2076
2160
  id: link.id,
@@ -2079,10 +2163,11 @@ var SSDiagram = (() => {
2079
2163
  to: link.to.nodeId,
2080
2164
  toPort: link.to.portId,
2081
2165
  style: link.style,
2166
+ label: link.label,
2082
2167
  metadata: link.metadata
2083
- }))
2168
+ })),
2169
+ document2.zones
2084
2170
  );
2085
- this.zones = document2.zones.map((zone) => ({ ...zone, metadata: copyJsonObject(zone.metadata) }));
2086
2171
  this.documentMetadata = copyJsonObject(document2.metadata);
2087
2172
  }
2088
2173
  saveDocument() {
@@ -2093,6 +2178,7 @@ var SSDiagram = (() => {
2093
2178
  from: { nodeId: link.from, portId: link.fromPort },
2094
2179
  to: { nodeId: link.to, portId: link.toPort },
2095
2180
  style: link.style,
2181
+ label: link.label,
2096
2182
  metadata: link.metadata
2097
2183
  })),
2098
2184
  zones: this.zones.map((zone) => ({ ...zone, metadata: copyJsonObject(zone.metadata) })),
@@ -2519,8 +2605,10 @@ var SSDiagram = (() => {
2519
2605
  if (t2.overviewViewportFill !== void 0) this.opts.overviewViewportFill = t2.overviewViewportFill;
2520
2606
  this.scheduleDraw();
2521
2607
  }
2608
+ // Zones count: fitting or exporting only the nodes cut off the places they sit in, captions
2609
+ // included. A caption that is taller than its zone still has to fit.
2522
2610
  graphBounds() {
2523
- if (this.nodes.length === 0) return null;
2611
+ if (this.nodes.length === 0 && this.zones.length === 0) return null;
2524
2612
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
2525
2613
  for (const n2 of this.nodes) {
2526
2614
  minX = Math.min(minX, n2.x);
@@ -2528,6 +2616,13 @@ var SSDiagram = (() => {
2528
2616
  maxX = Math.max(maxX, n2.x + n2.w);
2529
2617
  maxY = Math.max(maxY, n2.y + n2.h);
2530
2618
  }
2619
+ for (const zone of this.zones) {
2620
+ const bottom = zone.name.length > 0 ? Math.max(zone.height, ZONE_CAPTION_TOP + ZONE_CAPTION_H) : zone.height;
2621
+ minX = Math.min(minX, zone.x - 1);
2622
+ minY = Math.min(minY, zone.y - 1);
2623
+ maxX = Math.max(maxX, zone.x + zone.width + 1);
2624
+ maxY = Math.max(maxY, zone.y + bottom + 1);
2625
+ }
2531
2626
  return { minX, minY, maxX, maxY };
2532
2627
  }
2533
2628
  resize(w, h) {
@@ -2579,11 +2674,16 @@ var SSDiagram = (() => {
2579
2674
  // ---- geometry ---------------------------------------------------
2580
2675
  layoutNode(n2) {
2581
2676
  const ctx = this.ctx;
2582
- ctx.font = "600 12px Segoe UI, Tahoma, sans-serif";
2677
+ ctx.font = TITLE_FONT;
2583
2678
  const titleW = ctx.measureText(n2.name).width;
2679
+ let subtitleW = 0;
2680
+ if (n2.subtitle.length > 0) {
2681
+ ctx.font = SUBTITLE_FONT;
2682
+ subtitleW = ctx.measureText(n2.subtitle).width;
2683
+ }
2584
2684
  const rows = Math.max(n2.inPorts.length, n2.outPorts.length, 1);
2585
- n2.w = Math.max(170, Math.ceil(titleW) + 28);
2586
- n2.h = Math.max(48, rows * PORT_ROW_H + 16);
2685
+ n2.w = Math.max(170, Math.ceil(titleW) + 28, Math.ceil(subtitleW) + 28);
2686
+ n2.h = Math.max(n2.subtitle.length > 0 ? SUBTITLE_MIN_H : 48, rows * PORT_ROW_H + 16);
2587
2687
  const off = PORT_SQ / 2;
2588
2688
  const place = (ports, x) => {
2589
2689
  const stack = ports.some(isGrowAnchor) ? [...ports.filter((p) => !isGrowAnchor(p)), ...ports.filter(isGrowAnchor)] : ports;
@@ -2942,6 +3042,7 @@ var SSDiagram = (() => {
2942
3042
  // The clipboard is built from saveDocument(), so it carries the style;
2943
3043
  // rebuilding without it silently turned every pasted dashed link solid.
2944
3044
  style: l.style,
3045
+ label: l.label,
2945
3046
  metadata: l.metadata
2946
3047
  });
2947
3048
  }
@@ -3327,6 +3428,7 @@ var SSDiagram = (() => {
3327
3428
  this.listen(window, "pointerup", finish);
3328
3429
  this.listen(window, "pointercancel", finish);
3329
3430
  this.listen(this.canvas, "wheel", (e) => {
3431
+ if (this.pageScroll && !e.ctrlKey && !e.metaKey) return;
3330
3432
  e.preventDefault();
3331
3433
  const [sx, sy] = localXY(e);
3332
3434
  const [wx, wy] = this.toWorld(sx, sy);
@@ -3472,6 +3574,7 @@ var SSDiagram = (() => {
3472
3574
  }
3473
3575
  draw(options = SCREEN_RENDER_OPTIONS) {
3474
3576
  const ctx = this.ctx;
3577
+ this.hostStyle = null;
3475
3578
  ctx.clearRect(0, 0, this.width, this.height);
3476
3579
  const background = this.opts.background ?? "#1b1b1f";
3477
3580
  ctx.fillStyle = background;
@@ -3499,6 +3602,7 @@ var SSDiagram = (() => {
3499
3602
  );
3500
3603
  this.drawZones();
3501
3604
  const prior = [];
3605
+ const labels = [];
3502
3606
  const hoveredNode = this.hoverPort?.node ?? this.hoverNode;
3503
3607
  for (const l of this.links) {
3504
3608
  const a = this.endpoint(l, "from");
@@ -3512,6 +3616,7 @@ var SSDiagram = (() => {
3512
3616
  const pts = this.routeLink(a, b, /* @__PURE__ */ new Set([l.from, l.to]));
3513
3617
  this.strokeRoute(pts, color, width, prior, l.style);
3514
3618
  this.drawArrow(pts, color);
3619
+ if (l.label.length > 0) labels.push({ pts, text: l.label, color });
3515
3620
  for (let k = 1; k < pts.length; k += 1) {
3516
3621
  const [x1, y1] = pts[k - 1];
3517
3622
  const [x2, y2] = pts[k];
@@ -3519,6 +3624,7 @@ var SSDiagram = (() => {
3519
3624
  else if (y1 === y2 && x1 !== x2) prior.push({ h: true, c: y1, a: Math.min(x1, x2), b: Math.max(x1, x2) });
3520
3625
  }
3521
3626
  }
3627
+ for (const label2 of labels) this.drawLinkLabel(label2.pts, label2.text, label2.color, background);
3522
3628
  if (options.transient && (this.linking !== null || this.relinking !== null)) this.drawPendingLink();
3523
3629
  this.portFeed = this.feedTypes();
3524
3630
  for (const n2 of this.nodes) this.drawNode(n2, options.selection && this.selectedNodes.has(n2), options);
@@ -3673,8 +3779,9 @@ ${runtime.error}`;
3673
3779
  drawZones() {
3674
3780
  if (this.zones.length === 0) return;
3675
3781
  const ctx = this.ctx;
3782
+ const defaultTint = this.resolveColor(this.opts.zoneColor ?? DEFAULT_ZONE_TINT, DEFAULT_ZONE_TINT);
3676
3783
  for (const zone of this.zones) {
3677
- const tint = zone.color.length > 0 ? zone.color : this.opts.zoneColor ?? "#8a8f98";
3784
+ const tint = zone.color.length > 0 ? this.resolveColor(zone.color, defaultTint) : defaultTint;
3678
3785
  ctx.save();
3679
3786
  roundRect(ctx, zone.x, zone.y, zone.width, zone.height, 10);
3680
3787
  ctx.globalAlpha = 0.12;
@@ -3690,10 +3797,10 @@ ${runtime.error}`;
3690
3797
  ctx.save();
3691
3798
  ctx.globalAlpha = 0.75;
3692
3799
  ctx.fillStyle = tint;
3693
- ctx.font = "600 12px Segoe UI, Tahoma, sans-serif";
3800
+ ctx.font = TITLE_FONT;
3694
3801
  ctx.textAlign = "left";
3695
3802
  ctx.textBaseline = "top";
3696
- ctx.fillText(zone.name, zone.x + 12, zone.y + 9, Math.max(0, zone.width - 24));
3803
+ ctx.fillText(zone.name, zone.x + 12, zone.y + ZONE_CAPTION_TOP, Math.max(0, zone.width - 24));
3697
3804
  ctx.restore();
3698
3805
  }
3699
3806
  }
@@ -3738,11 +3845,12 @@ ${runtime.error}`;
3738
3845
  const hasRuntimeError = options.runtime && n2.runtimeError.length > 0;
3739
3846
  const runtime = options.runtime ? getJsonKey(this.runtimeState.nodes, n2.id) : void 0;
3740
3847
  const active = options.runtime && (this.runtimeState.activeNodeId === n2.id || runtime?.active === true);
3848
+ const fill = this.resolveColor(n2.color, DEFAULT_NODE_COLOR);
3741
3849
  roundRect(ctx, n2.x, n2.y, n2.w, n2.h, 6);
3742
- ctx.fillStyle = hasLoadError ? ERROR_BACKGROUND : active ? "#ffd1dc" : n2.color;
3850
+ ctx.fillStyle = hasLoadError ? ERROR_BACKGROUND : active ? "#ffd1dc" : fill;
3743
3851
  ctx.fill();
3744
3852
  ctx.lineWidth = selected ? 2 : 1.5;
3745
- ctx.strokeStyle = selected ? this.selectionColor() : n2.border;
3853
+ ctx.strokeStyle = selected ? this.selectionColor() : this.resolveColor(n2.border, DEFAULT_NODE_BORDER);
3746
3854
  ctx.stroke();
3747
3855
  if (hasLoadError || hasRuntimeError) {
3748
3856
  let alpha = 1;
@@ -3775,11 +3883,22 @@ ${runtime.error}`;
3775
3883
  }
3776
3884
  }
3777
3885
  const titleShift = n2.icon ? iconW + 4 : 0;
3778
- ctx.fillStyle = hasLoadError ? "#ffffff" : readableTextOn(active ? "#ffd1dc" : n2.color);
3779
- ctx.font = "600 12px Segoe UI, Tahoma, sans-serif";
3886
+ const textX = n2.x + titleShift + (n2.w - titleShift) / 2;
3887
+ const textWidth = n2.w - titleShift - 14;
3888
+ ctx.fillStyle = hasLoadError ? "#ffffff" : readableTextOn(active ? "#ffd1dc" : fill);
3889
+ ctx.font = TITLE_FONT;
3780
3890
  ctx.textAlign = "center";
3781
3891
  ctx.textBaseline = "middle";
3782
- ctx.fillText(n2.name, n2.x + titleShift + (n2.w - titleShift) / 2, n2.y + n2.h / 2, n2.w - titleShift - 14);
3892
+ if (n2.subtitle.length === 0) {
3893
+ ctx.fillText(n2.name, textX, n2.y + n2.h / 2, textWidth);
3894
+ } else {
3895
+ ctx.fillText(n2.name, textX, n2.y + n2.h / 2 - 8, textWidth);
3896
+ ctx.save();
3897
+ ctx.globalAlpha *= 0.72;
3898
+ ctx.font = SUBTITLE_FONT;
3899
+ ctx.fillText(n2.subtitle, textX, n2.y + n2.h / 2 + 9, textWidth);
3900
+ ctx.restore();
3901
+ }
3783
3902
  for (const p of n2.inPorts) this.drawPort(n2, p, options);
3784
3903
  for (const p of n2.outPorts) this.drawPort(n2, p, options);
3785
3904
  }
@@ -3896,6 +4015,45 @@ ${runtime.error}`;
3896
4015
  ctx.fill();
3897
4016
  ctx.restore();
3898
4017
  }
4018
+ // A plate of the canvas colour under the text, edged in the wire's colour: the label reads the same
4019
+ // whatever the wire crosses, and still says which wire it belongs to.
4020
+ drawLinkLabel(pts, text, color, background) {
4021
+ const [x, y] = pathMidpoint(pts);
4022
+ const ctx = this.ctx;
4023
+ ctx.save();
4024
+ ctx.font = LINK_LABEL_FONT;
4025
+ const w = Math.ceil(ctx.measureText(text).width) + 12;
4026
+ const h = 16;
4027
+ roundRect(ctx, x - w / 2, y - h / 2, w, h, h / 2);
4028
+ ctx.fillStyle = background;
4029
+ ctx.fill();
4030
+ ctx.lineWidth = 1;
4031
+ ctx.strokeStyle = color;
4032
+ ctx.stroke();
4033
+ ctx.fillStyle = readableTextOn(background);
4034
+ ctx.textAlign = "center";
4035
+ ctx.textBaseline = "middle";
4036
+ ctx.fillText(text, x, y);
4037
+ ctx.restore();
4038
+ }
4039
+ // Scheme colours may name a custom property of the page, var(--token), which a canvas cannot
4040
+ // paint. They resolve against the host element; one that resolves to nothing and has no
4041
+ // fallback of its own gets the renderer's default for that slot.
4042
+ resolveColor(value, fallback) {
4043
+ if (!value.includes("var(")) return value;
4044
+ return resolveCssVariables(value, (name) => this.hostProperty(name)) ?? fallback;
4045
+ }
4046
+ hostProperty(name) {
4047
+ if (this.hostStyle === null) {
4048
+ if (typeof getComputedStyle !== "function") return "";
4049
+ try {
4050
+ this.hostStyle = getComputedStyle(this.host);
4051
+ } catch {
4052
+ return "";
4053
+ }
4054
+ }
4055
+ return this.hostStyle.getPropertyValue(name);
4056
+ }
3899
4057
  drawSelectedLinkEndpoints() {
3900
4058
  if (this.selectedLink === null) return;
3901
4059
  const ctx = this.ctx;
@@ -4011,9 +4169,9 @@ ${runtime.error}`;
4011
4169
  ctx.stroke();
4012
4170
  ctx.clip();
4013
4171
  for (const n2 of this.nodes) {
4014
- ctx.fillStyle = n2.color;
4172
+ ctx.fillStyle = this.resolveColor(n2.color, DEFAULT_NODE_COLOR);
4015
4173
  ctx.fillRect(mx(n2.x), my(n2.y), Math.max(2, n2.w * s), Math.max(2, n2.h * s));
4016
- ctx.strokeStyle = n2.border;
4174
+ ctx.strokeStyle = this.resolveColor(n2.border, DEFAULT_NODE_BORDER);
4017
4175
  ctx.lineWidth = 1;
4018
4176
  ctx.strokeRect(mx(n2.x), my(n2.y), Math.max(2, n2.w * s), Math.max(2, n2.h * s));
4019
4177
  }
@@ -4054,6 +4212,23 @@ ${runtime.error}`;
4054
4212
  ctx.arcTo(x, y, x + w, y, rr);
4055
4213
  ctx.closePath();
4056
4214
  }
4215
+ function pathMidpoint(pts) {
4216
+ let total = 0;
4217
+ 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]);
4218
+ let remaining = total / 2;
4219
+ for (let i = 1; i < pts.length; i += 1) {
4220
+ const [x1, y1] = pts[i - 1];
4221
+ const [x2, y2] = pts[i];
4222
+ const length = Math.hypot(x2 - x1, y2 - y1);
4223
+ if (length > 0 && remaining <= length) {
4224
+ const t2 = remaining / length;
4225
+ return [x1 + (x2 - x1) * t2, y1 + (y2 - y1) * t2];
4226
+ }
4227
+ remaining -= length;
4228
+ }
4229
+ const last = pts[pts.length - 1];
4230
+ return [last[0], last[1]];
4231
+ }
4057
4232
  function route(a, b) {
4058
4233
  const [ax, ay] = a;
4059
4234
  const [bx, by] = b;
@@ -5038,6 +5213,7 @@ ${runtime.error}`;
5038
5213
  this.fullscreen = false;
5039
5214
  this.fullscreenButtonVisible = true;
5040
5215
  this.downloadButtonVisible = false;
5216
+ this.exportEnabled = true;
5041
5217
  this.downloadLabel = null;
5042
5218
  // null means the host never named the button, so its wording comes from the shared bundle.
5043
5219
  // Keeping the two apart is what lets an explicit label win while an untouched control still
@@ -5054,7 +5230,8 @@ ${runtime.error}`;
5054
5230
  host: this.div,
5055
5231
  typeColors: this.portTypeColors(),
5056
5232
  gridSnap: options.gridSnap ?? true,
5057
- gridSize: options.gridSize
5233
+ gridSize: options.gridSize,
5234
+ pageScroll: options.pageScroll ?? false
5058
5235
  });
5059
5236
  this.fullscreenButtonVisible = options.showFullscreenButton ?? true;
5060
5237
  this.fullscreenLabels = options.fullscreenLabels ?? null;
@@ -5132,6 +5309,13 @@ ${runtime.error}`;
5132
5309
  getGridSnap() {
5133
5310
  return this.canvas.getGridSnap();
5134
5311
  }
5312
+ /** See DiagramOptions.pageScroll. */
5313
+ setPageScroll(enabled) {
5314
+ this.canvas.setPageScroll(enabled);
5315
+ }
5316
+ isPageScrollEnabled() {
5317
+ return this.canvas.isPageScrollEnabled();
5318
+ }
5135
5319
  nudgeSelection(dx, dy) {
5136
5320
  return this.canvas.nudgeSelection(dx, dy);
5137
5321
  }
@@ -5313,6 +5497,17 @@ ${runtime.error}`;
5313
5497
  this.downloadButton.hidden = false;
5314
5498
  this.downloadButton.style.display = "inline-flex";
5315
5499
  }
5500
+ /**
5501
+ * Turns the export commands of the context menu on or off. Off, the menu offers them greyed out and
5502
+ * `executeContextCommand` refuses them, for a page that does not want its diagram saved. The
5503
+ * download button is separate: hide it with `setDownloadButtonVisible(false)`.
5504
+ */
5505
+ setExportEnabled(enabled) {
5506
+ this.exportEnabled = enabled;
5507
+ }
5508
+ isExportEnabled() {
5509
+ return this.exportEnabled;
5510
+ }
5316
5511
  isDownloadButtonVisible() {
5317
5512
  return this.downloadButtonVisible;
5318
5513
  }
@@ -5814,15 +6009,15 @@ ${runtime.error}`;
5814
6009
  // picture, and an empty diagram has none to give. They stay on in read-only mode,
5815
6010
  // which forbids changing the diagram, not looking at it.
5816
6011
  exportDocument: {
5817
- canExecute: ({ document: document2 }) => document2.nodes.length > 0,
6012
+ canExecute: ({ document: document2 }) => this.exportEnabled && document2.nodes.length > 0,
5818
6013
  execute: () => this.emit("exportRequested", { format: "document" })
5819
6014
  },
5820
6015
  exportPng: {
5821
- canExecute: ({ document: document2 }) => document2.nodes.length > 0,
6016
+ canExecute: ({ document: document2 }) => this.exportEnabled && document2.nodes.length > 0,
5822
6017
  execute: () => this.emit("exportRequested", { format: "png" })
5823
6018
  },
5824
6019
  exportSvg: {
5825
- canExecute: ({ document: document2 }) => document2.nodes.length > 0,
6020
+ canExecute: ({ document: document2 }) => this.exportEnabled && document2.nodes.length > 0,
5826
6021
  execute: () => this.emit("exportRequested", { format: "svg" })
5827
6022
  },
5828
6023
  // Always available: it shows the overview as readily as it hides it, and an empty
@@ -6458,17 +6653,62 @@ ${runtime.error}`;
6458
6653
  if (renderRevisions.get(div) !== revision)
6459
6654
  return null;
6460
6655
  const catalog = buildCatalog(palette);
6656
+ return mountDiagram(div, catalog, options, false, (diagram) => {
6657
+ const missingTypeTemplate = div.dataset.diagramMissingElement ?? t("embedMissingElement", 'Element type "{typeId}" is missing from the palette.');
6658
+ const converted = toDiagramNodes(
6659
+ scheme,
6660
+ catalog,
6661
+ (typeId) => missingTypeTemplate.replace("{typeId}", typeId || "(empty)")
6662
+ );
6663
+ diagram.load(
6664
+ converted.nodes,
6665
+ scheme.links.map((link) => new Link({
6666
+ outNode: link.from,
6667
+ outPort: link.fromPort,
6668
+ inNode: link.to,
6669
+ inPort: link.toPort
6670
+ })),
6671
+ { nodeErrors: converted.nodeErrors }
6672
+ );
6673
+ });
6674
+ }
6675
+ function renderDocument(div, source, options = {}) {
6676
+ const parsed = readDocument(source);
6677
+ beginRender(div);
6678
+ return mountDocument(div, parsed, options);
6679
+ }
6680
+ function readDocument(source) {
6681
+ let value = source;
6682
+ if (typeof source === "string") {
6683
+ try {
6684
+ value = JSON.parse(source);
6685
+ } catch (error) {
6686
+ throw new DiagramDocumentError(error instanceof Error ? error.message : "invalid JSON");
6687
+ }
6688
+ }
6689
+ if (!isRecord(value))
6690
+ throw new DiagramDocumentError("expected an object");
6691
+ if (value.version !== void 0 && value.version !== DIAGRAM_DOCUMENT_VERSION)
6692
+ throw new DiagramDocumentError(`unsupported document version ${String(value.version)}`, "$.version");
6693
+ return createDiagramDocument(value);
6694
+ }
6695
+ function mountDocument(div, source, options) {
6696
+ return mountDiagram(div, new StockSharpCatalog(), options, true, (diagram) => diagram.loadDocument(source));
6697
+ }
6698
+ function mountDiagram(div, catalog, options, pageScrollByDefault, populate) {
6461
6699
  disposeActiveRender(div);
6462
6700
  div.classList.remove("ss-diagram-error");
6463
6701
  div.replaceChildren();
6464
6702
  const [enterLabel, exitLabel] = (div.dataset.diagramFullscreen ?? "").split("|");
6465
6703
  const downloadLabel = div.dataset.diagramDownload;
6466
- const showDownloadButton = options.onExportRequested !== void 0;
6704
+ const exportAllowed = options.allowExport !== false && div.dataset.diagramExport !== "off";
6705
+ const showDownloadButton = exportAllowed && options.onExportRequested !== void 0;
6467
6706
  let diagram;
6468
6707
  try {
6469
6708
  diagram = new StockSharpDiagram({
6470
6709
  div,
6471
6710
  catalog,
6711
+ pageScroll: options.pageScroll ?? pageScrollByDefault,
6472
6712
  ...enterLabel && exitLabel ? { fullscreenLabels: { enter: enterLabel, exit: exitLabel } } : {},
6473
6713
  ...showDownloadButton ? { showDownloadButton: true } : {},
6474
6714
  ...downloadLabel ? { downloadLabel } : {}
@@ -6518,7 +6758,9 @@ ${runtime.error}`;
6518
6758
  activeRenders.set(div, handle);
6519
6759
  connectedRenders.add(handle);
6520
6760
  ensureDisconnectedHostObserver();
6521
- if (options.onExportRequested !== void 0) {
6761
+ if (!exportAllowed) {
6762
+ diagram.setExportEnabled(false);
6763
+ } else if (options.onExportRequested !== void 0) {
6522
6764
  const notifyHost = options.onExportRequested;
6523
6765
  cleanups.push(diagram.on("exportRequested", (request) => notifyHost(request, handle)));
6524
6766
  }
@@ -6545,22 +6787,7 @@ ${runtime.error}`;
6545
6787
  diagram.setReadOnly(true);
6546
6788
  diagram.setLinkValidator(() => true);
6547
6789
  diagram.setOverviewVisible(false);
6548
- const missingTypeTemplate = div.dataset.diagramMissingElement ?? t("embedMissingElement", 'Element type "{typeId}" is missing from the palette.');
6549
- const converted = toDiagramNodes(
6550
- scheme,
6551
- catalog,
6552
- (typeId) => missingTypeTemplate.replace("{typeId}", typeId || "(empty)")
6553
- );
6554
- diagram.load(
6555
- converted.nodes,
6556
- scheme.links.map((link) => new Link({
6557
- outNode: link.from,
6558
- outPort: link.fromPort,
6559
- inNode: link.to,
6560
- inPort: link.toPort
6561
- })),
6562
- { nodeErrors: converted.nodeErrors }
6563
- );
6790
+ populate(diagram);
6564
6791
  const fit = () => {
6565
6792
  if (!div.isConnected) {
6566
6793
  handle.destroy();
@@ -6657,6 +6884,8 @@ ${runtime.error}`;
6657
6884
  note(div, errors.load, revision);
6658
6885
  return null;
6659
6886
  }
6887
+ if (holdsDocument(div, raw))
6888
+ return drawDocumentAtRevision(div, raw, revision, options, errors);
6660
6889
  const scheme = parseRawScheme(raw);
6661
6890
  if (scheme.nodes.length === 0) {
6662
6891
  note(div, errors.empty, revision);
@@ -6679,6 +6908,8 @@ ${runtime.error}`;
6679
6908
  note(div, errors.empty, revision);
6680
6909
  return null;
6681
6910
  }
6911
+ if (holdsDocument(div, raw))
6912
+ return drawDocumentAtRevision(div, raw, revision, options, errors);
6682
6913
  const scheme = parseRawScheme(raw);
6683
6914
  if (scheme.nodes.length === 0) {
6684
6915
  note(div, errors.empty, revision);
@@ -6691,6 +6922,32 @@ ${runtime.error}`;
6691
6922
  return null;
6692
6923
  }
6693
6924
  }
6925
+ function holdsDocument(div, raw) {
6926
+ if (div.dataset.diagramKind === "document")
6927
+ return true;
6928
+ return isRecord(raw) && typeof raw.version === "number" && Array.isArray(raw.nodes);
6929
+ }
6930
+ function drawDocumentAtRevision(div, raw, revision, options, errors) {
6931
+ if (renderRevisions.get(div) !== revision)
6932
+ return null;
6933
+ let parsed;
6934
+ try {
6935
+ parsed = readDocument(raw);
6936
+ } catch {
6937
+ note(div, errors.empty, revision);
6938
+ return null;
6939
+ }
6940
+ if (parsed.nodes.length === 0 && parsed.zones.length === 0) {
6941
+ note(div, errors.empty, revision);
6942
+ return null;
6943
+ }
6944
+ try {
6945
+ return mountDocument(div, parsed, options);
6946
+ } catch {
6947
+ note(div, errors.draw, revision);
6948
+ return null;
6949
+ }
6950
+ }
6694
6951
  function renderAll(root = document, options = {}) {
6695
6952
  root.querySelectorAll(".ss-diagram-host").forEach((host) => {
6696
6953
  if (host.dataset.rendered === "1")