@stocksharp/diagram 0.4.1 → 0.6.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 (45) hide show
  1. package/README.md +105 -2
  2. package/dist/esm/canvas-renderer.js +116 -36
  3. package/dist/esm/canvas-renderer.js.map +1 -1
  4. package/dist/esm/diagram/context-menu.js +319 -0
  5. package/dist/esm/diagram/context-menu.js.map +1 -0
  6. package/dist/esm/diagram/stocksharp-diagram.js +121 -9
  7. package/dist/esm/diagram/stocksharp-diagram.js.map +1 -1
  8. package/dist/esm/draw-surface.js +2 -0
  9. package/dist/esm/draw-surface.js.map +1 -0
  10. package/dist/esm/embed.js +7 -4
  11. package/dist/esm/embed.js.map +1 -1
  12. package/dist/esm/i18n.js +15 -4
  13. package/dist/esm/i18n.js.map +1 -1
  14. package/dist/esm/index.js.map +1 -1
  15. package/dist/esm/svg-surface.js +341 -0
  16. package/dist/esm/svg-surface.js.map +1 -0
  17. package/dist/ssdiagram.js +978 -188
  18. package/dist/ssdiagram.js.map +4 -4
  19. package/dist/types/canvas-renderer.d.ts +13 -0
  20. package/dist/types/canvas-renderer.d.ts.map +1 -1
  21. package/dist/types/diagram/api.d.ts +42 -2
  22. package/dist/types/diagram/api.d.ts.map +1 -1
  23. package/dist/types/diagram/context-menu.d.ts +33 -0
  24. package/dist/types/diagram/context-menu.d.ts.map +1 -0
  25. package/dist/types/diagram/stocksharp-diagram.d.ts +18 -5
  26. package/dist/types/diagram/stocksharp-diagram.d.ts.map +1 -1
  27. package/dist/types/draw-surface.d.ts +52 -0
  28. package/dist/types/draw-surface.d.ts.map +1 -0
  29. package/dist/types/embed.d.ts.map +1 -1
  30. package/dist/types/i18n.d.ts +12 -0
  31. package/dist/types/i18n.d.ts.map +1 -1
  32. package/dist/types/index.d.ts +2 -1
  33. package/dist/types/index.d.ts.map +1 -1
  34. package/dist/types/svg-surface.d.ts +74 -0
  35. package/dist/types/svg-surface.d.ts.map +1 -0
  36. package/package.json +1 -1
  37. package/src/canvas-renderer.ts +120 -27
  38. package/src/diagram/api.ts +53 -2
  39. package/src/diagram/context-menu.ts +363 -0
  40. package/src/diagram/stocksharp-diagram.ts +134 -10
  41. package/src/draw-surface.ts +61 -0
  42. package/src/embed.ts +7 -4
  43. package/src/i18n.ts +41 -6
  44. package/src/index.ts +10 -0
  45. package/src/svg-surface.ts +430 -0
package/README.md CHANGED
@@ -154,7 +154,9 @@ missing type. Sites can localize that message through
154
154
  `data-diagram-missing-element="Missing: {typeId}"` on the host.
155
155
 
156
156
  Every text the embedded control shows comes from the host, so a translated page
157
- carries no English leftovers:
157
+ carries no English leftovers. These attributes are per-instance overrides on top
158
+ of the page's translation bundle (see *Translating the control* below); set them
159
+ when one diagram has to read differently from the rest of the page:
158
160
 
159
161
  | Host attribute | What it renames |
160
162
  |---|---|
@@ -163,7 +165,9 @@ carries no English leftovers:
163
165
  | `data-diagram-missing-element="Missing: {typeId}"` | the placeholder for an element the palette lacks |
164
166
 
165
167
  The same labels are available to custom integrations as the `fullscreenLabels`
166
- option and `setFullscreenLabels()` on `StockSharpDiagram`.
168
+ option and `setFullscreenLabels()` on `StockSharpDiagram`. With neither the
169
+ attribute nor the option, the wording comes from the bundle, so a page that has
170
+ already been translated needs no per-diagram markup at all.
167
171
 
168
172
  ### Node actions and errors
169
173
 
@@ -188,6 +192,105 @@ Socket input is reported separately through `portClicked`, with
188
192
  never starts a wire. `contextMenuRequested` also includes the exact port when
189
193
  the menu was opened over a socket.
190
194
 
195
+ ### The context menu
196
+
197
+ Right-click opens the control's own menu, with an Export submenu. It is on by
198
+ default, because right-click suppresses the browser's menu either way and a
199
+ control that takes one away owes one back. It needs no stylesheet: it is styled
200
+ inline over `--ssdiagram-*` custom properties (`--ssdiagram-menu-background`,
201
+ `--ssdiagram-menu-border`, `--ssdiagram-menu-color`, `--ssdiagram-menu-hover`,
202
+ `--ssdiagram-menu-disabled-color`, `--ssdiagram-menu-font`), falling back to the
203
+ `--ssdiagram-control-*` values the fullscreen button uses. Its class names —
204
+ `ssdiagram-context-menu`, `-item`, `-label`, `-arrow`, `-submenu`, `-separator` —
205
+ are stable if you would rather use CSS.
206
+
207
+ To draw your own instead, pass `showContextMenu: false` (or call
208
+ `setContextMenuEnabled(false)`) and handle `contextMenuRequested`, which is
209
+ emitted either way — and emitted *before* the built-in menu opens, so a handler
210
+ can still switch it off.
211
+
212
+ `commands` is a tree in display order. An entry with a `group` is a submenu;
213
+ anything else is a command. `enabled` is computed for both — a submenu is off
214
+ when every item inside it is:
215
+
216
+ ```ts
217
+ diagram.on('contextMenuRequested', ({ x, y, commands }) => {
218
+ showMenu(x, y, commands.map((item) => 'group' in item
219
+ ? { label: t(item.group), enabled: item.enabled, items: item.commands }
220
+ : { label: t(item.command), enabled: item.enabled }));
221
+ });
222
+
223
+ menu.on('pick', (command) => diagram.executeContextCommand(command));
224
+ ```
225
+
226
+ `undo`, `redo`, `cut`, `copy`, `paste`, `delete` and `overview` are carried out
227
+ by the control itself. `overview` toggles the corner minimap, and reports its
228
+ current state as `checked` on the command so a menu can tick it rather than offer
229
+ a dead show/hide pair. The rest are requests it cannot answer alone: `open`,
230
+ `properties` and `help` raise `nodeOpen` / `nodeProperties` / `nodeHelp`, and
231
+ the `export` submenu raises `exportRequested` with the chosen format. Nothing
232
+ is produced until the host acts on it — only the host knows where the result
233
+ should go, and with which options:
234
+
235
+ ```ts
236
+ diagram.on('exportRequested', ({ format }) => {
237
+ if (format === 'document') return download('strategy.json', diagram.saveDocument());
238
+ const options = { scope: 'content', padding: 40 };
239
+ if (format === 'svg') return download('strategy.svg', diagram.takeSvg(options));
240
+ diagram.takeScreenshot(options).toBlob((blob) => download('strategy.png', blob));
241
+ });
242
+ ```
243
+
244
+ Export is offered whenever the diagram has a node, including in read-only mode,
245
+ because it only reads.
246
+
247
+ #### Translating the control
248
+
249
+ There is one mechanism, and it is `window.__designerI18n`: an object typed by the
250
+ exported `DesignerI18n`, holding every string the package renders itself — the
251
+ menu, the fullscreen button's tooltip, and the embed failure notes. It is read on
252
+ every lookup, so it can be assigned at any point, before or after the bundle
253
+ loads, and reassigned later to change language. A key that is missing or empty
254
+ falls back to English.
255
+
256
+ Per-instance settings — the `fullscreenLabels` option, `setFullscreenLabels()`,
257
+ and the `data-diagram-*` attributes above — override the bundle for one diagram.
258
+ Resolution is: explicit setting, then bundle, then the built-in English.
259
+
260
+ The context menu is rebuilt on every open and needs nothing after a language
261
+ change. The fullscreen button is drawn once, so call `refreshLabels()` to
262
+ re-read the bundle for it.
263
+
264
+ ```ts
265
+ import type { DesignerI18n } from '@stocksharp/diagram';
266
+
267
+ const labels: DesignerI18n = { cut: 'Вырезать', ctxDelete: 'Удалить', /* … */ };
268
+ (window as unknown as { __designerI18n: DesignerI18n }).__designerI18n = labels;
269
+ ```
270
+
271
+ The key is not always the command name, so here is the whole menu:
272
+
273
+ | command | key | English |
274
+ |---|---|---|
275
+ | `undo` / `redo` | `undo` / `redo` | Undo / Redo |
276
+ | `cut` / `copy` / `paste` | `cut` / `copy` / `paste` | Cut / Copy / Paste |
277
+ | `open` | `ctxOpen` | Open |
278
+ | `delete` | `ctxDelete` | Delete |
279
+ | *(the submenu)* | `ctxExportAs` | Export as |
280
+ | `exportDocument` | `ctxExportDocument` | Scheme |
281
+ | `exportPng` / `exportSvg` | `ctxExportPng` / `ctxExportSvg` | PNG image / SVG image |
282
+ | `overview` | `ctxOverview` | Overview |
283
+ | `properties` | `properties` | Properties |
284
+ | `help` | `ctxHelp` | Help |
285
+ | *(fullscreen button)* | `fullscreenEnter` / `fullscreenExit` | Enter / Exit fullscreen |
286
+ | *(embed failures)* | `embedErrorLoad` / `embedErrorEmpty` / `embedErrorDraw` | the three notes shown in place of a diagram |
287
+ | *(embed placeholder)* | `embedMissingElement` | the missing-element tooltip |
288
+
289
+ `ctxDelete` is separate from the solution explorer's `delete` on purpose: that
290
+ one removes a whole strategy, this one the selected nodes and links, and a
291
+ language that declines the object cannot serve both from one key. The same goes
292
+ for `ctxExportAs` against `ctxExport`.
293
+
191
294
  Runtime failures flash the node border before leaving it red. Errors found
192
295
  while loading a scheme use a red background. Hovering either state shows the
193
296
  full error text in a tooltip:
@@ -1,3 +1,4 @@
1
+ import { SvgSurface } from './svg-surface.js';
1
2
  // Internal dependency-free canvas renderer used by StockSharpDiagram.
2
3
  //
3
4
  // Dependency-free, pure 2D canvas. Demonstrates the hard parts: typed
@@ -1100,6 +1101,65 @@ export class Diagram {
1100
1101
  copyContext.drawImage(this.canvas, 0, 0);
1101
1102
  return copy;
1102
1103
  }
1104
+ const layout = this.exportLayout(scope, options);
1105
+ const { pixelRatio, exportScale, padding, bounds, width, height, pixelWidth, pixelHeight } = layout;
1106
+ const output = document.createElement('canvas');
1107
+ output.width = pixelWidth;
1108
+ output.height = pixelHeight;
1109
+ output.style.width = `${width}px`;
1110
+ output.style.height = `${height}px`;
1111
+ const outputContext = output.getContext('2d');
1112
+ if (outputContext === null)
1113
+ throw new Error('ssdiagram: screenshot 2d context unavailable');
1114
+ const previous = {
1115
+ ctx: this.ctx,
1116
+ width: this.width,
1117
+ height: this.height,
1118
+ dpr: this.dpr,
1119
+ scale: this.scale,
1120
+ offX: this.offX,
1121
+ offY: this.offY,
1122
+ overviewVisible: this.overviewVisible,
1123
+ background: this.opts.background,
1124
+ };
1125
+ try {
1126
+ this.ctx = outputContext;
1127
+ this.width = width;
1128
+ this.height = height;
1129
+ this.dpr = pixelRatio;
1130
+ this.scale = exportScale;
1131
+ this.offX = scope === 'content' && bounds !== null ? padding - bounds.minX * exportScale : previous.offX;
1132
+ this.offY = scope === 'content' && bounds !== null ? padding - bounds.minY * exportScale : previous.offY;
1133
+ this.overviewVisible = options.includeOverview ?? (scope === 'viewport' && previous.overviewVisible);
1134
+ if (options.background !== undefined)
1135
+ this.opts.background = options.background;
1136
+ outputContext.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
1137
+ this.draw({
1138
+ grid: options.includeGrid ?? true,
1139
+ overview: this.overviewVisible,
1140
+ selection: options.includeSelection ?? scope === 'viewport',
1141
+ runtime: options.includeRuntimeState ?? true,
1142
+ transient: false,
1143
+ });
1144
+ }
1145
+ finally {
1146
+ this.ctx = previous.ctx;
1147
+ this.width = previous.width;
1148
+ this.height = previous.height;
1149
+ this.dpr = previous.dpr;
1150
+ this.scale = previous.scale;
1151
+ this.offX = previous.offX;
1152
+ this.offY = previous.offY;
1153
+ this.overviewVisible = previous.overviewVisible;
1154
+ this.opts.background = previous.background;
1155
+ }
1156
+ return output;
1157
+ }
1158
+ // Everything an export needs to know about size and placement, for either output. Shared on
1159
+ // purpose: a raster and a vector export of the same scope must frame the picture identically,
1160
+ // and two copies of this arithmetic would eventually disagree about padding or where the origin
1161
+ // sits. A block comment here would be lifted onto the next public member in the .d.ts.
1162
+ exportLayout(scope, options) {
1103
1163
  const pixelRatio = this.positiveScreenshotNumber(options.pixelRatio ?? this.dpr, 'pixelRatio');
1104
1164
  const exportScale = scope === 'content'
1105
1165
  ? this.positiveScreenshotNumber(options.scale ?? 1, 'scale')
@@ -1119,14 +1179,33 @@ export class Diagram {
1119
1179
  if (pixelWidth > 16384 || pixelHeight > 16384 || pixelWidth * pixelHeight > 268435456) {
1120
1180
  throw new RangeError(`ssdiagram: screenshot is too large (${pixelWidth}x${pixelHeight})`);
1121
1181
  }
1122
- const output = document.createElement('canvas');
1123
- output.width = pixelWidth;
1124
- output.height = pixelHeight;
1125
- output.style.width = `${width}px`;
1126
- output.style.height = `${height}px`;
1127
- const outputContext = output.getContext('2d');
1128
- if (outputContext === null)
1129
- throw new Error('ssdiagram: screenshot 2d context unavailable');
1182
+ return { pixelRatio, exportScale, padding, bounds, width, height, pixelWidth, pixelHeight };
1183
+ }
1184
+ /**
1185
+ * The same picture takeScreenshot produces, as SVG.
1186
+ *
1187
+ * It is the renderer that draws it -- the surface underneath simply records instead of paints --
1188
+ * so the vector output cannot drift from what the screen and the raster export show. Text keeps
1189
+ * the on-screen metrics by measuring through a real 2D context.
1190
+ *
1191
+ * Scale and pixelRatio still frame the picture (they decide the viewBox), but nothing is
1192
+ * resampled: the result is resolution-independent, which is the point of asking for it.
1193
+ */
1194
+ takeSvg(options = {}) {
1195
+ if (this.destroyed)
1196
+ throw new Error('ssdiagram: cannot export a destroyed diagram');
1197
+ const scope = options.scope ?? 'viewport';
1198
+ if (scope !== 'viewport' && scope !== 'content') {
1199
+ throw new RangeError(`ssdiagram: unsupported screenshot scope "${String(scope)}"`);
1200
+ }
1201
+ const { exportScale, padding, bounds, width, height } = this.exportLayout(scope, options);
1202
+ const metrics = this.canvas.getContext('2d');
1203
+ const surface = new SvgSurface({
1204
+ width,
1205
+ height,
1206
+ background: options.background ?? this.opts.background ?? '#1b1b1f',
1207
+ metrics: metrics ?? undefined,
1208
+ });
1130
1209
  const previous = {
1131
1210
  ctx: this.ctx,
1132
1211
  width: this.width,
@@ -1136,20 +1215,17 @@ export class Diagram {
1136
1215
  offX: this.offX,
1137
1216
  offY: this.offY,
1138
1217
  overviewVisible: this.overviewVisible,
1139
- background: this.opts.background,
1140
1218
  };
1141
1219
  try {
1142
- this.ctx = outputContext;
1220
+ this.ctx = surface;
1143
1221
  this.width = width;
1144
1222
  this.height = height;
1145
- this.dpr = pixelRatio;
1223
+ // 1: an SVG has no device pixels to multiply by, and the viewBox carries the size.
1224
+ this.dpr = 1;
1146
1225
  this.scale = exportScale;
1147
1226
  this.offX = scope === 'content' && bounds !== null ? padding - bounds.minX * exportScale : previous.offX;
1148
1227
  this.offY = scope === 'content' && bounds !== null ? padding - bounds.minY * exportScale : previous.offY;
1149
1228
  this.overviewVisible = options.includeOverview ?? (scope === 'viewport' && previous.overviewVisible);
1150
- if (options.background !== undefined)
1151
- this.opts.background = options.background;
1152
- outputContext.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
1153
1229
  this.draw({
1154
1230
  grid: options.includeGrid ?? true,
1155
1231
  overview: this.overviewVisible,
@@ -1167,9 +1243,8 @@ export class Diagram {
1167
1243
  this.offX = previous.offX;
1168
1244
  this.offY = previous.offY;
1169
1245
  this.overviewVisible = previous.overviewVisible;
1170
- this.opts.background = previous.background;
1171
1246
  }
1172
- return output;
1247
+ return surface.toSvg();
1173
1248
  }
1174
1249
  positiveScreenshotNumber(value, name) {
1175
1250
  if (!Number.isFinite(value) || value <= 0) {
@@ -1801,6 +1876,26 @@ export class Diagram {
1801
1876
  return added.map((node) => node.id);
1802
1877
  }
1803
1878
  // ---- input ------------------------------------------------------
1879
+ // Ends hover and everything hanging off it: highlight, cursor and the delayed tooltip.
1880
+ clearHover() {
1881
+ if (this.hoverPort !== null)
1882
+ this.emit('portHover', { node: this.hoverPort.node, port: this.hoverPort.port, hovering: false });
1883
+ if (this.hoverNode !== null)
1884
+ this.emit('nodeHover', { node: this.hoverNode, hovering: false });
1885
+ if (this.hoveredLink !== null)
1886
+ this.emit('linkHover', { link: this.hoveredLink, hovering: false });
1887
+ this.hoverPort = null;
1888
+ this.hoverNode = null;
1889
+ this.hoveredLink = null;
1890
+ this.tipTarget = null;
1891
+ this.tipShow = false;
1892
+ if (this.tipTimer !== null) {
1893
+ clearTimeout(this.tipTimer);
1894
+ this.tipTimer = null;
1895
+ }
1896
+ this.canvas.style.cursor = 'default';
1897
+ this.scheduleDraw();
1898
+ }
1804
1899
  cancelLongPress() {
1805
1900
  if (this.lpTimer !== null) {
1806
1901
  clearTimeout(this.lpTimer);
@@ -1839,7 +1934,10 @@ export class Diagram {
1839
1934
  this.relinking = null;
1840
1935
  this.relinkCandidate = null;
1841
1936
  this.linkSnap = null;
1842
- this.scheduleDraw();
1937
+ // A menu is about to cover this spot, and it opens under a cursor that has not moved --
1938
+ // which fires no boundary event, so nothing else would end the hover. Without this the
1939
+ // tooltip armed by the last pointermove appears 400ms later, through the open menu.
1940
+ this.clearHover();
1843
1941
  this.emit('contextMenu', { x: pageX, y: pageY, link, node, port });
1844
1942
  }
1845
1943
  listen(target, type, listener, options) {
@@ -2217,25 +2315,7 @@ export class Diagram {
2217
2315
  this.emitViewChanged(true);
2218
2316
  this.scheduleDraw();
2219
2317
  }, { passive: false });
2220
- this.listen(this.canvas, 'pointerleave', () => {
2221
- if (this.hoverPort !== null)
2222
- this.emit('portHover', { node: this.hoverPort.node, port: this.hoverPort.port, hovering: false });
2223
- if (this.hoverNode !== null)
2224
- this.emit('nodeHover', { node: this.hoverNode, hovering: false });
2225
- if (this.hoveredLink !== null)
2226
- this.emit('linkHover', { link: this.hoveredLink, hovering: false });
2227
- this.hoverPort = null;
2228
- this.hoverNode = null;
2229
- this.hoveredLink = null;
2230
- this.tipTarget = null;
2231
- this.tipShow = false;
2232
- if (this.tipTimer !== null) {
2233
- clearTimeout(this.tipTimer);
2234
- this.tipTimer = null;
2235
- }
2236
- this.canvas.style.cursor = 'default';
2237
- this.scheduleDraw();
2238
- });
2318
+ this.listen(this.canvas, 'pointerleave', () => this.clearHover());
2239
2319
  this.listen(this.canvas, 'dblclick', (e) => {
2240
2320
  const [sx, sy] = localXY(e);
2241
2321
  const [wx, wy] = this.toWorld(sx, sy);