@stocksharp/diagram 0.2.3 → 0.4.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 (68) hide show
  1. package/dist/esm/canvas-renderer.js +79 -28
  2. package/dist/esm/canvas-renderer.js.map +1 -1
  3. package/dist/esm/core/action-registry.js +24 -0
  4. package/dist/esm/core/action-registry.js.map +1 -1
  5. package/dist/esm/core/document.js +134 -28
  6. package/dist/esm/core/document.js.map +1 -1
  7. package/dist/esm/core/json.js +17 -0
  8. package/dist/esm/core/json.js.map +1 -0
  9. package/dist/esm/core/model.js +8 -0
  10. package/dist/esm/core/model.js.map +1 -1
  11. package/dist/esm/core/state.js +16 -2
  12. package/dist/esm/core/state.js.map +1 -1
  13. package/dist/esm/diagram/catalog.js +17 -5
  14. package/dist/esm/diagram/catalog.js.map +1 -1
  15. package/dist/esm/diagram/event-emitter.js +10 -0
  16. package/dist/esm/diagram/event-emitter.js.map +1 -1
  17. package/dist/esm/diagram/stocksharp-diagram.js +58 -56
  18. package/dist/esm/diagram/stocksharp-diagram.js.map +1 -1
  19. package/dist/esm/diagram/types.js +2 -1
  20. package/dist/esm/diagram/types.js.map +1 -1
  21. package/dist/esm/embed.js +121 -38
  22. package/dist/esm/embed.js.map +1 -1
  23. package/dist/esm/index.js +1 -1
  24. package/dist/esm/index.js.map +1 -1
  25. package/dist/ssdiagram.js +380 -150
  26. package/dist/ssdiagram.js.map +4 -4
  27. package/dist/types/canvas-renderer.d.ts +11 -6
  28. package/dist/types/canvas-renderer.d.ts.map +1 -1
  29. package/dist/types/core/action-registry.d.ts +7 -0
  30. package/dist/types/core/action-registry.d.ts.map +1 -1
  31. package/dist/types/core/document.d.ts +1 -1
  32. package/dist/types/core/document.d.ts.map +1 -1
  33. package/dist/types/core/json.d.ts +11 -0
  34. package/dist/types/core/json.d.ts.map +1 -0
  35. package/dist/types/core/model.d.ts +45 -2
  36. package/dist/types/core/model.d.ts.map +1 -1
  37. package/dist/types/core/state.d.ts +24 -1
  38. package/dist/types/core/state.d.ts.map +1 -1
  39. package/dist/types/diagram/api.d.ts +6 -6
  40. package/dist/types/diagram/api.d.ts.map +1 -1
  41. package/dist/types/diagram/catalog.d.ts +2 -1
  42. package/dist/types/diagram/catalog.d.ts.map +1 -1
  43. package/dist/types/diagram/event-emitter.d.ts +6 -1
  44. package/dist/types/diagram/event-emitter.d.ts.map +1 -1
  45. package/dist/types/diagram/palette.d.ts +1 -1
  46. package/dist/types/diagram/palette.d.ts.map +1 -1
  47. package/dist/types/diagram/stocksharp-diagram.d.ts +29 -22
  48. package/dist/types/diagram/stocksharp-diagram.d.ts.map +1 -1
  49. package/dist/types/diagram/types.d.ts +4 -4
  50. package/dist/types/diagram/types.d.ts.map +1 -1
  51. package/dist/types/embed.d.ts.map +1 -1
  52. package/dist/types/index.d.ts +3 -3
  53. package/dist/types/index.d.ts.map +1 -1
  54. package/package.json +2 -2
  55. package/src/canvas-renderer.ts +84 -29
  56. package/src/core/action-registry.ts +25 -0
  57. package/src/core/document.ts +147 -31
  58. package/src/core/json.ts +16 -0
  59. package/src/core/model.ts +55 -2
  60. package/src/core/state.ts +41 -3
  61. package/src/diagram/api.ts +6 -5
  62. package/src/diagram/catalog.ts +18 -6
  63. package/src/diagram/event-emitter.ts +12 -1
  64. package/src/diagram/palette.ts +1 -1
  65. package/src/diagram/stocksharp-diagram.ts +79 -76
  66. package/src/diagram/types.ts +6 -5
  67. package/src/embed.ts +140 -39
  68. package/src/index.ts +4 -0
@@ -1,15 +1,17 @@
1
+ import { setJsonKey } from './json.js';
2
+ import { toPortDynamicMode } from './model.js';
1
3
  import {
2
4
  DIAGRAM_DOCUMENT_VERSION,
3
5
  type DiagramDocument,
4
6
  type DiagramDocumentEndpoint,
5
7
  type DiagramDocumentInput,
6
8
  type DiagramDocumentLink,
7
- type DiagramDocumentLinkInput,
9
+ type DiagramLinkStyle,
8
10
  type DiagramDocumentNode,
9
- type DiagramDocumentNodeInput,
10
11
  type DiagramDocumentPort,
11
- type DiagramDocumentPortInput,
12
+ type DiagramDocumentZone,
12
13
  type DiagramParameterSchema,
14
+ type PortDynamicMode,
13
15
  type JsonObject,
14
16
  type JsonValue,
15
17
  } from './model.js';
@@ -21,10 +23,13 @@ export type {
21
23
  DiagramDocumentInput,
22
24
  DiagramDocumentLink,
23
25
  DiagramDocumentLinkInput,
26
+ DiagramLinkStyle,
24
27
  DiagramDocumentNode,
25
28
  DiagramDocumentNodeInput,
26
29
  DiagramDocumentPort,
27
30
  DiagramDocumentPortInput,
31
+ DiagramDocumentZone,
32
+ DiagramDocumentZoneInput,
28
33
  DiagramDocumentVersion,
29
34
  DiagramParameterSchema,
30
35
  JsonObject,
@@ -40,13 +45,18 @@ export class DiagramDocumentError extends Error {
40
45
  }
41
46
 
42
47
  export function createDiagramDocument(input: DiagramDocumentInput = {}): DiagramDocument {
43
- const nodes = (input.nodes ?? []).map((node, index) => normalizeNode(node, `$.nodes[${index}]`));
48
+ // DiagramDocumentInput is a structural interface, so its shape is a claim by
49
+ // the caller, not a guarantee. Elements are checked the same way parsed ones
50
+ // are, otherwise a malformed one surfaces as a TypeError naming no path.
51
+ const rawNodes = mapElements(input.nodes, '$.nodes');
52
+ const rawLinks = mapElements(input.links, '$.links');
53
+ const nodes = rawNodes.map((node, index) => normalizeNode(node, `$.nodes[${index}]`));
44
54
  const usedLinkIds = new Set<string>();
45
55
 
46
- for (let index = 0; index < (input.links ?? []).length; index++) {
47
- const id = input.links?.[index].id;
48
- if (id === undefined) continue;
49
- requireIdentifier(id, `$.links[${index}].id`);
56
+ for (let index = 0; index < rawLinks.length; index++) {
57
+ const raw = rawLinks[index].id;
58
+ if (raw === undefined) continue;
59
+ const id = requireIdentifier(raw, `$.links[${index}].id`);
50
60
  if (usedLinkIds.has(id)) {
51
61
  throw new DiagramDocumentError(`duplicate link id "${id}"`, `$.links[${index}].id`);
52
62
  }
@@ -54,19 +64,33 @@ export function createDiagramDocument(input: DiagramDocumentInput = {}): Diagram
54
64
  }
55
65
 
56
66
  let sequence = 1;
57
- const links = (input.links ?? []).map((link, index) => {
67
+ const links = rawLinks.map((link, index) => {
58
68
  let id = link.id;
59
69
  if (id === undefined) {
60
- do id = `link_${sequence++}`;
61
- while (usedLinkIds.has(id));
62
- usedLinkIds.add(id);
70
+ let generated: string;
71
+ do generated = `link_${sequence++}`;
72
+ while (usedLinkIds.has(generated));
73
+ usedLinkIds.add(generated);
74
+ id = generated;
63
75
  }
64
76
  return normalizeLink({ ...link, id }, `$.links[${index}]`);
65
77
  });
78
+ const rawZones = mapElements(input.zones, '$.zones');
79
+ const usedZoneIds = new Set<string>();
80
+ const zones = rawZones.map((zone, index) => {
81
+ const normalized = normalizeZone(zone, `$.zones[${index}]`);
82
+ if (usedZoneIds.has(normalized.id)) {
83
+ throw new DiagramDocumentError(`duplicate zone id "${normalized.id}"`, `$.zones[${index}].id`);
84
+ }
85
+ usedZoneIds.add(normalized.id);
86
+ return normalized;
87
+ });
88
+
66
89
  const document: DiagramDocument = {
67
90
  version: DIAGRAM_DOCUMENT_VERSION,
68
91
  nodes,
69
92
  links,
93
+ zones,
70
94
  metadata: cloneJsonObject(input.metadata ?? {}, '$.metadata'),
71
95
  };
72
96
  validateDocument(document);
@@ -92,7 +116,7 @@ export function parseDiagramDocument(source: string | unknown): DiagramDocument
92
116
  }
93
117
  }
94
118
 
95
- const root = requireObject(value, '$');
119
+ const root = requireStructure(value, '$');
96
120
  const version = requireNumber(root.version, '$.version');
97
121
  if (version !== DIAGRAM_DOCUMENT_VERSION) {
98
122
  throw new DiagramDocumentError(`unsupported document version ${version}`, '$.version');
@@ -101,13 +125,19 @@ export function parseDiagramDocument(source: string | unknown): DiagramDocument
101
125
  version: DIAGRAM_DOCUMENT_VERSION,
102
126
  nodes: requireArray(root.nodes, '$.nodes').map((node, index) => parseNode(node, `$.nodes[${index}]`)),
103
127
  links: requireArray(root.links, '$.links').map((link, index) => parseLink(link, `$.links[${index}]`)),
128
+ // Zones arrived after the first documents were written, so their absence is not a fault:
129
+ // an older file simply has none, and reading it must not need a migration step.
130
+ zones: root.zones === undefined || root.zones === null
131
+ ? []
132
+ : requireArray(root.zones, '$.zones').map((zone, index) => normalizeZone(zone, `$.zones[${index}]`)),
104
133
  metadata: cloneJsonObject(root.metadata, '$.metadata'),
105
134
  };
106
135
  validateDocument(document);
107
136
  return document;
108
137
  }
109
138
 
110
- function normalizeNode(input: DiagramDocumentNodeInput, path: string): DiagramDocumentNode {
139
+ function normalizeNode(value: unknown, path: string): DiagramDocumentNode {
140
+ const input = requireStructure(value, path);
111
141
  const id = requireIdentifier(input.id, `${path}.id`);
112
142
  return {
113
143
  id,
@@ -122,30 +152,36 @@ function normalizeNode(input: DiagramDocumentNodeInput, path: string): DiagramDo
122
152
  icon: requireString(input.icon ?? '', `${path}.icon`),
123
153
  message: requireString(input.message ?? '', `${path}.message`),
124
154
  openAction: requireString(input.openAction ?? '', `${path}.openAction`),
125
- inPorts: (input.inPorts ?? []).map((port, index) => normalizePort(port, `${path}.inPorts[${index}]`)),
126
- outPorts: (input.outPorts ?? []).map((port, index) => normalizePort(port, `${path}.outPorts[${index}]`)),
127
- parameters: (input.parameters ?? []).map((parameter, index) => normalizeParameter(parameter, `${path}.parameters[${index}]`)),
155
+ inPorts: mapElements(input.inPorts, `${path}.inPorts`)
156
+ .map((port, index) => normalizePort(port, `${path}.inPorts[${index}]`)),
157
+ outPorts: mapElements(input.outPorts, `${path}.outPorts`)
158
+ .map((port, index) => normalizePort(port, `${path}.outPorts[${index}]`)),
159
+ parameters: mapElements(input.parameters, `${path}.parameters`)
160
+ .map((parameter, index) => normalizeParameter(parameter, `${path}.parameters[${index}]`)),
128
161
  paramValues: cloneStringRecord(input.paramValues ?? {}, `${path}.paramValues`),
129
162
  metadata: cloneJsonObject(input.metadata ?? {}, `${path}.metadata`),
130
163
  };
131
164
  }
132
165
 
133
- function normalizePort(input: DiagramDocumentPortInput, path: string): DiagramDocumentPort {
166
+ function normalizePort(value: unknown, path: string): DiagramDocumentPort {
167
+ const input = requireStructure(value, path);
134
168
  return {
135
169
  id: requireIdentifier(input.id, `${path}.id`),
136
170
  name: requireString(input.name, `${path}.name`),
137
171
  description: requireString(input.description ?? '', `${path}.description`),
138
172
  type: requireString(input.type ?? '', `${path}.type`),
139
173
  maxLinks: requireNonNegativeInteger(input.maxLinks ?? 0, `${path}.maxLinks`),
140
- availableTypes: (input.availableTypes ?? []).map((type, index) => requireString(type, `${path}.availableTypes[${index}]`)),
174
+ availableTypes: requireArray(input.availableTypes ?? [], `${path}.availableTypes`)
175
+ .map((type, index) => requireString(type, `${path}.availableTypes[${index}]`)),
141
176
  isDynamic: requireBoolean(input.isDynamic ?? false, `${path}.isDynamic`),
142
- dynamicMode: requireString(input.dynamicMode ?? '', `${path}.dynamicMode`),
177
+ dynamicMode: requirePortDynamicMode(input.dynamicMode ?? '', `${path}.dynamicMode`),
143
178
  isSibling: requireBoolean(input.isSibling ?? false, `${path}.isSibling`),
144
179
  metadata: cloneJsonObject(input.metadata ?? {}, `${path}.metadata`),
145
180
  };
146
181
  }
147
182
 
148
- function normalizeParameter(input: DiagramParameterSchema, path: string): DiagramParameterSchema {
183
+ function normalizeParameter(value: unknown, path: string): DiagramParameterSchema {
184
+ const input = requireStructure(value, path);
149
185
  return {
150
186
  name: requireIdentifier(input.name, `${path}.name`),
151
187
  displayName: requireString(input.displayName, `${path}.displayName`),
@@ -162,16 +198,51 @@ function normalizeParameter(input: DiagramParameterSchema, path: string): Diagra
162
198
  };
163
199
  }
164
200
 
165
- function normalizeLink(input: DiagramDocumentLinkInput & { id: string }, path: string): DiagramDocumentLink {
201
+ function normalizeLink(value: unknown, path: string): DiagramDocumentLink {
202
+ const input = requireStructure(value, path);
166
203
  return {
167
204
  id: requireIdentifier(input.id, `${path}.id`),
168
205
  from: normalizeEndpoint(input.from, `${path}.from`),
169
206
  to: normalizeEndpoint(input.to, `${path}.to`),
207
+ style: requireLinkStyle(input.style, `${path}.style`),
170
208
  metadata: cloneJsonObject(input.metadata ?? {}, `${path}.metadata`),
171
209
  };
172
210
  }
173
211
 
174
- function normalizeEndpoint(input: DiagramDocumentEndpoint, path: string): DiagramDocumentEndpoint {
212
+ // A style the caller misspelled is a drawing that quietly loses its meaning, so it is refused
213
+ // rather than defaulted - unlike an absent one, which simply means a plain line.
214
+ function requireLinkStyle(value: unknown, path: string): DiagramLinkStyle {
215
+ if (value === undefined || value === null) return 'solid';
216
+ if (value !== 'solid' && value !== 'dashed') {
217
+ throw new DiagramDocumentError(`unknown link style "${String(value)}"`, path);
218
+ }
219
+ return value;
220
+ }
221
+
222
+ function normalizeZone(value: unknown, path: string): DiagramDocumentZone {
223
+ const input = requireStructure(value, path);
224
+ return {
225
+ id: requireIdentifier(input.id, `${path}.id`),
226
+ name: requireString(input.name, `${path}.name`),
227
+ x: requireNumber(input.x, `${path}.x`),
228
+ y: requireNumber(input.y, `${path}.y`),
229
+ width: requirePositiveSize(input.width, `${path}.width`),
230
+ height: requirePositiveSize(input.height, `${path}.height`),
231
+ color: input.color === undefined || input.color === null ? '' : requireString(input.color, `${path}.color`),
232
+ metadata: cloneJsonObject(input.metadata ?? {}, `${path}.metadata`),
233
+ };
234
+ }
235
+
236
+ // A zone with no area cannot be pointed at, and a negative one paints outside itself, so the
237
+ // size is refused rather than clamped - a caller that computed it wrong wants to hear about it.
238
+ function requirePositiveSize(value: unknown, path: string): number {
239
+ const size = requireNumber(value, path);
240
+ if (!(size > 0)) throw new DiagramDocumentError('size must be greater than zero', path);
241
+ return size;
242
+ }
243
+
244
+ function normalizeEndpoint(value: unknown, path: string): DiagramDocumentEndpoint {
245
+ const input = requireStructure(value, path);
175
246
  return {
176
247
  nodeId: requireIdentifier(input.nodeId, `${path}.nodeId`),
177
248
  portId: requireIdentifier(input.portId, `${path}.portId`),
@@ -179,7 +250,7 @@ function normalizeEndpoint(input: DiagramDocumentEndpoint, path: string): Diagra
179
250
  }
180
251
 
181
252
  function parseNode(value: unknown, path: string): DiagramDocumentNode {
182
- const node = requireObject(value, path);
253
+ const node = requireStructure(value, path);
183
254
  return normalizeNode({
184
255
  id: requireString(node.id, `${path}.id`),
185
256
  typeId: requireString(node.typeId, `${path}.typeId`),
@@ -202,7 +273,7 @@ function parseNode(value: unknown, path: string): DiagramDocumentNode {
202
273
  }
203
274
 
204
275
  function parsePort(value: unknown, path: string): DiagramDocumentPort {
205
- const port = requireObject(value, path);
276
+ const port = requireStructure(value, path);
206
277
  return normalizePort({
207
278
  id: requireString(port.id, `${path}.id`),
208
279
  name: requireString(port.name, `${path}.name`),
@@ -211,14 +282,14 @@ function parsePort(value: unknown, path: string): DiagramDocumentPort {
211
282
  maxLinks: requireNumber(port.maxLinks, `${path}.maxLinks`),
212
283
  availableTypes: requireArray(port.availableTypes, `${path}.availableTypes`).map((type, index) => requireString(type, `${path}.availableTypes[${index}]`)),
213
284
  isDynamic: requireBoolean(port.isDynamic, `${path}.isDynamic`),
214
- dynamicMode: requireString(port.dynamicMode, `${path}.dynamicMode`),
285
+ dynamicMode: requirePortDynamicMode(port.dynamicMode, `${path}.dynamicMode`),
215
286
  isSibling: requireBoolean(port.isSibling, `${path}.isSibling`),
216
287
  metadata: cloneJsonObject(port.metadata, `${path}.metadata`),
217
288
  }, path);
218
289
  }
219
290
 
220
291
  function parseParameter(value: unknown, path: string): DiagramParameterSchema {
221
- const parameter = requireObject(value, path);
292
+ const parameter = requireStructure(value, path);
222
293
  return normalizeParameter({
223
294
  name: requireString(parameter.name, `${path}.name`),
224
295
  displayName: requireString(parameter.displayName, `${path}.displayName`),
@@ -236,17 +307,18 @@ function parseParameter(value: unknown, path: string): DiagramParameterSchema {
236
307
  }
237
308
 
238
309
  function parseLink(value: unknown, path: string): DiagramDocumentLink {
239
- const link = requireObject(value, path);
310
+ const link = requireStructure(value, path);
240
311
  return normalizeLink({
241
312
  id: requireString(link.id, `${path}.id`),
242
313
  from: parseEndpoint(link.from, `${path}.from`),
243
314
  to: parseEndpoint(link.to, `${path}.to`),
315
+ style: link.style === undefined ? undefined : requireLinkStyle(link.style, `${path}.style`),
244
316
  metadata: cloneJsonObject(link.metadata, `${path}.metadata`),
245
317
  }, path);
246
318
  }
247
319
 
248
320
  function parseEndpoint(value: unknown, path: string): DiagramDocumentEndpoint {
249
- const endpoint = requireObject(value, path);
321
+ const endpoint = requireStructure(value, path);
250
322
  return {
251
323
  nodeId: requireIdentifier(endpoint.nodeId, `${path}.nodeId`),
252
324
  portId: requireIdentifier(endpoint.portId, `${path}.portId`),
@@ -298,7 +370,7 @@ function validateUniquePorts(ports: readonly DiagramDocumentPort[], path: string
298
370
  function cloneStringRecord(value: unknown, path: string): Record<string, string> {
299
371
  const object = requireObject(value, path);
300
372
  const result: Record<string, string> = {};
301
- for (const [key, item] of Object.entries(object)) result[key] = requireString(item, `${path}.${key}`);
373
+ for (const [key, item] of Object.entries(object)) setJsonKey(result, key, requireString(item, `${path}.${key}`));
302
374
  return result;
303
375
  }
304
376
 
@@ -309,7 +381,7 @@ function cloneJsonObject(value: unknown, path: string, ancestors = new WeakSet<o
309
381
  const result: JsonObject = {};
310
382
  try {
311
383
  for (const [key, item] of Object.entries(object)) {
312
- result[key] = cloneJsonValue(item, `${path}.${key}`, ancestors);
384
+ setJsonKey(result, key, cloneJsonValue(item, `${path}.${key}`, ancestors));
313
385
  }
314
386
  } finally {
315
387
  ancestors.delete(object);
@@ -333,6 +405,23 @@ function cloneJsonValue(value: unknown, path: string, ancestors: WeakSet<object>
333
405
  throw new DiagramDocumentError('expected a JSON value', path);
334
406
  }
335
407
 
408
+ /**
409
+ * A container whose fields are read individually. Any object will do, including
410
+ * a class instance: hosts build schemes out of DiagramNode and Port, not object
411
+ * literals, and every field is validated on its own below.
412
+ */
413
+ function requireStructure(value: unknown, path: string): Record<string, unknown> {
414
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
415
+ throw new DiagramDocumentError('expected an object', path);
416
+ }
417
+ return value as Record<string, unknown>;
418
+ }
419
+
420
+ /**
421
+ * A value that has to survive JSON round-tripping unchanged. Stricter than
422
+ * requireStructure on purpose: a Date or any other class instance would
423
+ * serialize to something the parser could not read back.
424
+ */
336
425
  function requireObject(value: unknown, path: string): Record<string, unknown> {
337
426
  if (!isObject(value)) throw new DiagramDocumentError('expected an object', path);
338
427
  return value;
@@ -344,6 +433,21 @@ function isObject(value: unknown): value is Record<string, unknown> {
344
433
  return prototype === Object.prototype || prototype === null;
345
434
  }
346
435
 
436
+ /**
437
+ * Reads an optional array of objects, rejecting a non-array, a hole or a
438
+ * non-object element. null counts as absent, like the `?? []` every other
439
+ * optional field is read through.
440
+ */
441
+ function mapElements(value: unknown, path: string): Record<string, unknown>[] {
442
+ if (value === undefined || value === null) return [];
443
+ // Array.from rather than map: map skips holes, carrying an undefined element
444
+ // into the result instead of reporting it.
445
+ return Array.from(
446
+ requireArray(value, path),
447
+ (element, index) => requireStructure(element, `${path}[${index}]`),
448
+ );
449
+ }
450
+
347
451
  function requireArray(value: unknown, path: string): unknown[] {
348
452
  if (!Array.isArray(value)) throw new DiagramDocumentError('expected an array', path);
349
453
  return value;
@@ -354,6 +458,18 @@ function requireString(value: unknown, path: string): string {
354
458
  return value;
355
459
  }
356
460
 
461
+ /**
462
+ * Coerces rather than rejects. The set is closed in the types, which is where a
463
+ * typo gets caught, but a persisted scheme can carry anything a JavaScript host
464
+ * wrote before that -- and refusing to load it would brick the whole document
465
+ * over a field whose every value except 'onConnect' behaves identically. The
466
+ * type must still be a string, so a number or an object is a real error.
467
+ */
468
+ function requirePortDynamicMode(value: unknown, path: string): PortDynamicMode {
469
+ const mode = requireString(value, path);
470
+ return toPortDynamicMode(mode);
471
+ }
472
+
357
473
  function requireIdentifier(value: unknown, path: string): string {
358
474
  const id = requireString(value, path);
359
475
  if (id.trim().length === 0) throw new DiagramDocumentError('identifier cannot be empty', path);
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Stores a key that JSON can legitimately carry but plain assignment cannot hold.
3
+ *
4
+ * `JSON.parse` produces `__proto__` as an ordinary own key, yet writing it back
5
+ * with `target[key] = value` hits the inherited `__proto__` setter: the value is
6
+ * dropped and the object's prototype is replaced instead. A document parsed that
7
+ * way loses data and stops satisfying the parser's own "plain object" guard, so
8
+ * anything the parser accepted could no longer be serialized or cloned.
9
+ */
10
+ export function setJsonKey<T>(target: Record<string, T>, key: string, value: T): void {
11
+ if (key === '__proto__') {
12
+ Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true });
13
+ return;
14
+ }
15
+ target[key] = value;
16
+ }
package/src/core/model.ts CHANGED
@@ -20,6 +20,19 @@ export interface DiagramParameterSchema {
20
20
  editorType: string;
21
21
  }
22
22
 
23
+ /**
24
+ * How a dynamic port grows. Only 'onConnect' makes the editor add a typed
25
+ * sibling when a link lands on the port; '' and 'manual' leave it to the host.
26
+ * The renderer compares this with ===, so a value outside the set silently
27
+ * selects the do-nothing branch -- hence a union rather than a string.
28
+ */
29
+ export type PortDynamicMode = '' | 'manual' | 'onConnect';
30
+
31
+ /** Narrows an untyped value to the closed set, defaulting to the inert mode. */
32
+ export function toPortDynamicMode(value: unknown): PortDynamicMode {
33
+ return value === 'manual' || value === 'onConnect' ? value : '';
34
+ }
35
+
23
36
  export interface DiagramDocumentPort {
24
37
  id: string;
25
38
  name: string;
@@ -28,7 +41,7 @@ export interface DiagramDocumentPort {
28
41
  maxLinks: number;
29
42
  availableTypes: string[];
30
43
  isDynamic: boolean;
31
- dynamicMode: string;
44
+ dynamicMode: PortDynamicMode;
32
45
  isSibling: boolean;
33
46
  metadata: JsonObject;
34
47
  }
@@ -59,11 +72,43 @@ export interface DiagramDocumentEndpoint {
59
72
  portId: string;
60
73
  }
61
74
 
75
+ /**
76
+ * How a link is drawn. A dashed link reads as one that may or may not be there -
77
+ * an optional hop, a path taken only in some deployments - which a solid line
78
+ * cannot say. The renderer compares this with ===, so a value outside the set
79
+ * would silently fall through to solid; hence a union rather than a string.
80
+ */
81
+ export type DiagramLinkStyle = 'solid' | 'dashed';
82
+
83
+ /** Narrows an untyped value to the closed set, defaulting to a plain line. */
84
+ export function toDiagramLinkStyle(value: unknown): DiagramLinkStyle {
85
+ return value === 'dashed' ? 'dashed' : 'solid';
86
+ }
87
+
62
88
  export interface DiagramDocumentLink {
63
89
  /** Stable identity used by selection, relinking and history. */
64
90
  id: string;
65
91
  from: DiagramDocumentEndpoint;
66
92
  to: DiagramDocumentEndpoint;
93
+ style: DiagramLinkStyle;
94
+ metadata: JsonObject;
95
+ }
96
+
97
+ /**
98
+ * A labelled rectangle drawn behind the nodes. It says where things are rather
99
+ * than what they do - a colocation cage, a tenant boundary, a process - which no
100
+ * arrangement of nodes and links can state on its own. A zone owns nothing: the
101
+ * nodes that sit on it are not its children, so moving one changes no membership.
102
+ */
103
+ export interface DiagramDocumentZone {
104
+ id: string;
105
+ name: string;
106
+ x: number;
107
+ y: number;
108
+ width: number;
109
+ height: number;
110
+ /** Empty means the renderer picks its own neutral tint. */
111
+ color: string;
67
112
  metadata: JsonObject;
68
113
  }
69
114
 
@@ -74,6 +119,7 @@ export interface DiagramDocument {
74
119
  version: DiagramDocumentVersion;
75
120
  nodes: DiagramDocumentNode[];
76
121
  links: DiagramDocumentLink[];
122
+ zones: DiagramDocumentZone[];
77
123
  metadata: JsonObject;
78
124
  }
79
125
 
@@ -87,13 +133,20 @@ export type DiagramDocumentNodeInput = Pick<DiagramDocumentNode, 'id' | 'name'>
87
133
  outPorts?: readonly DiagramDocumentPortInput[];
88
134
  };
89
135
 
90
- export type DiagramDocumentLinkInput = Omit<DiagramDocumentLink, 'id' | 'metadata'> & {
136
+ export type DiagramDocumentLinkInput = Omit<DiagramDocumentLink, 'id' | 'style' | 'metadata'> & {
91
137
  id?: string;
138
+ style?: DiagramLinkStyle;
139
+ metadata?: JsonObject;
140
+ };
141
+
142
+ export type DiagramDocumentZoneInput = Omit<DiagramDocumentZone, 'color' | 'metadata'> & {
143
+ color?: string;
92
144
  metadata?: JsonObject;
93
145
  };
94
146
 
95
147
  export interface DiagramDocumentInput {
96
148
  nodes?: readonly DiagramDocumentNodeInput[];
97
149
  links?: readonly DiagramDocumentLinkInput[];
150
+ zones?: readonly DiagramDocumentZoneInput[];
98
151
  metadata?: JsonObject;
99
152
  }
package/src/core/state.ts CHANGED
@@ -1,3 +1,20 @@
1
+ /**
2
+ * Recursively read-only view of a state object. The snapshot accessors hand back
3
+ * detached copies, so writing to one is silently discarded -- this makes that a
4
+ * compile error instead. The setters accept it, so a snapshot still round-trips.
5
+ */
6
+ export type DiagramSnapshot<T> = {
7
+ readonly [K in keyof T]: DiagramSnapshotValue<T[K]>;
8
+ };
9
+
10
+ // Distributes over unions, so an optional or nullable object property keeps
11
+ // being descended into instead of falling through as its writable self.
12
+ type DiagramSnapshotValue<T> = T extends readonly (infer TItem)[]
13
+ ? readonly DiagramSnapshotValue<TItem>[]
14
+ : T extends object
15
+ ? DiagramSnapshot<T>
16
+ : T;
17
+
1
18
  export type DiagramPortDirection = 'in' | 'out';
2
19
  export type DiagramNodeErrorKind = 'runtime' | 'load';
3
20
  export type DiagramGlobalErrorKind = 'invalid' | 'load' | 'locked' | 'encrypted';
@@ -23,9 +40,18 @@ export interface DiagramNodePortRuntimeState {
23
40
  out: Record<string, DiagramPortRuntimeState>;
24
41
  }
25
42
 
43
+ /**
44
+ * At most one error per kind. A node can carry a load error and a runtime error
45
+ * at the same time -- the tooltip shows both -- so a single slot could not hold
46
+ * what the editor already models, and every state write dropped one of them.
47
+ */
48
+ export type DiagramNodeErrors = {
49
+ [TKind in DiagramNodeErrorKind]?: DiagramErrorState<TKind>;
50
+ };
51
+
26
52
  export interface DiagramNodeRuntimeState {
27
53
  active: boolean;
28
- error: DiagramErrorState<DiagramNodeErrorKind> | null;
54
+ errors: DiagramNodeErrors;
29
55
  ports: DiagramNodePortRuntimeState;
30
56
  }
31
57
 
@@ -77,7 +103,7 @@ export function cloneDiagramRuntimeState(state: DiagramRuntimeState): DiagramRun
77
103
  globalError: state.globalError === null ? null : { ...state.globalError },
78
104
  nodes: Object.fromEntries(Object.entries(state.nodes).map(([nodeId, node]) => [nodeId, {
79
105
  active: node.active,
80
- error: node.error === null ? null : { ...node.error },
106
+ errors: cloneDiagramNodeErrors(node.errors),
81
107
  ports: {
82
108
  in: Object.fromEntries(Object.entries(node.ports.in)
83
109
  .map(([portId, port]) => [portId, { ...port }])),
@@ -142,7 +168,19 @@ export function createReadOnlyDiagramPermissions(): DiagramInteractionPermission
142
168
  export function createDiagramNodeRuntimeState(): DiagramNodeRuntimeState {
143
169
  return {
144
170
  active: false,
145
- error: null,
171
+ errors: {},
146
172
  ports: { in: {}, out: {} },
147
173
  };
148
174
  }
175
+
176
+ /**
177
+ * Tolerates a missing map: hosts persist runtime snapshots and build them by
178
+ * hand, so a state written before this field existed still has to be readable.
179
+ */
180
+ export function cloneDiagramNodeErrors(errors: DiagramNodeErrors | undefined | null): DiagramNodeErrors {
181
+ const result: DiagramNodeErrors = {};
182
+ if (errors === undefined || errors === null) return result;
183
+ if (errors.runtime !== undefined) result.runtime = { ...errors.runtime };
184
+ if (errors.load !== undefined) result.load = { ...errors.load };
185
+ return result;
186
+ }
@@ -2,6 +2,7 @@ import type { DiagramDocument } from '../core/model.js';
2
2
  import type {
3
3
  DiagramRuntimeState,
4
4
  DiagramSelection,
5
+ DiagramSnapshot,
5
6
  DiagramViewState,
6
7
  } from '../core/state.js';
7
8
  import type { DiagramNode, Link, Port, PortDirection } from './types.js';
@@ -232,7 +233,7 @@ export interface LinkValidatorArgs {
232
233
 
233
234
  export type LinkValidator = (args: LinkValidatorArgs) => boolean;
234
235
 
235
- export interface DiagramEvents extends Record<string, unknown> {
236
+ export interface DiagramEvents {
236
237
  nodeAdded: NodeChangePayload;
237
238
  nodeRemoved: NodeChangePayload;
238
239
  linkAdded: LinkChangePayload;
@@ -256,10 +257,10 @@ export interface DiagramEvents extends Record<string, unknown> {
256
257
  nodeProperties: NodeChangePayload;
257
258
  nodeOpen: NodeChangePayload;
258
259
  nodeHelp: NodeChangePayload;
259
- zoomChanged: DiagramViewState;
260
- viewChanged: DiagramViewState;
261
- selectionChanged: DiagramSelection;
262
- runtimeStateChanged: { state: DiagramRuntimeState };
260
+ zoomChanged: DiagramSnapshot<DiagramViewState>;
261
+ viewChanged: DiagramSnapshot<DiagramViewState>;
262
+ selectionChanged: DiagramSnapshot<DiagramSelection>;
263
+ runtimeStateChanged: { state: DiagramSnapshot<DiagramRuntimeState> };
263
264
  undoStackChanged: { canUndo: boolean; canRedo: boolean };
264
265
  documentLoaded: { document: DiagramDocument };
265
266
  documentLoadFailed: DocumentLoadFailedPayload;
@@ -1,7 +1,7 @@
1
1
  import { EventEmitter } from './event-emitter.js';
2
2
  import { Node, NodeInit, PortType, PortTypeInit } from './types.js';
3
3
 
4
- export interface CatalogEvents extends Record<string, unknown> {
4
+ export interface CatalogEvents {
5
5
  portTypesChanged: PortType[];
6
6
  nodeTypesChanged: Node[];
7
7
  }
@@ -35,21 +35,33 @@ export class StockSharpCatalog extends EventEmitter<CatalogEvents> {
35
35
  // imported node ("Element type is missing from the palette"). Node.id
36
36
  // keeps its original casing so save/load round-trips unchanged.
37
37
  addNodeType(node: Node | NodeInit): void {
38
- const n = node instanceof Node ? node : new Node(node);
38
+ // Copied on the way in as well as on the way out: otherwise the caller
39
+ // keeps a handle on the stored definition and can edit it afterwards.
40
+ // The clone covers the init branch too -- Node's constructor keeps the
41
+ // caller's Port instances and parameters array as they are.
42
+ const n = (node instanceof Node ? node : new Node(node)).clone();
39
43
  this.nodeTypes.set(n.id.toLowerCase(), n);
40
- this.emit('nodeTypesChanged', this.getNodeTypes());
44
+ this.emitNodeTypesChanged();
41
45
  }
42
46
 
43
47
  removeNodeType(id: string): void {
44
48
  this.nodeTypes.delete(id.toLowerCase());
45
- this.emit('nodeTypesChanged', this.getNodeTypes());
49
+ this.emitNodeTypesChanged();
46
50
  }
47
51
 
48
52
  getNodeType(id: string): Node | null {
49
- return this.nodeTypes.get(id.toLowerCase()) ?? null;
53
+ return this.nodeTypes.get(id.toLowerCase())?.clone() ?? null;
50
54
  }
51
55
 
52
56
  getNodeTypes(): Node[] {
53
- return Array.from(this.nodeTypes.values());
57
+ return Array.from(this.nodeTypes.values(), (node) => node.clone());
58
+ }
59
+
60
+ // Node definitions are mutable, so the payload has to be a copy of each one.
61
+ // Building a catalog is a loop of addNodeType calls, which would make that
62
+ // quadratic; skipping it while nobody listens keeps catalog construction --
63
+ // where no subscriber exists yet -- as cheap as it was.
64
+ private emitNodeTypesChanged(): void {
65
+ if (this.hasHandlers('nodeTypesChanged')) this.emit('nodeTypesChanged', this.getNodeTypes());
54
66
  }
55
67
  }
@@ -1,6 +1,9 @@
1
1
  export type EventHandler<T> = (payload: T) => void;
2
2
 
3
- export class EventEmitter<TEvents extends Record<string, unknown>> {
3
+ // TEvents is constrained to object rather than Record<string, unknown>: the
4
+ // latter forces every event map to carry a string index signature, which
5
+ // collapses keyof TEvents to string and lets any misspelled name compile.
6
+ export class EventEmitter<TEvents extends object> {
4
7
  private readonly handlers = new Map<keyof TEvents, Set<EventHandler<unknown>>>();
5
8
 
6
9
  on<K extends keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): () => void {
@@ -38,6 +41,14 @@ export class EventEmitter<TEvents extends Record<string, unknown>> {
38
41
  }
39
42
  }
40
43
 
44
+ /**
45
+ * Whether anyone is listening. Lets a subject skip building a payload that
46
+ * is expensive to produce and that nothing would read.
47
+ */
48
+ protected hasHandlers<K extends keyof TEvents>(event: K): boolean {
49
+ return (this.handlers.get(event)?.size ?? 0) > 0;
50
+ }
51
+
41
52
  protected clearEventHandlers(): void {
42
53
  this.handlers.clear();
43
54
  }
@@ -22,7 +22,7 @@ export interface PaletteContextMenuPayload extends PaletteNodePayload {
22
22
  y: number;
23
23
  }
24
24
 
25
- export interface PaletteEvents extends Record<string, unknown> {
25
+ export interface PaletteEvents {
26
26
  selectionChanged: PaletteSelectionChangedPayload;
27
27
  nodeActivated: PaletteNodePayload;
28
28
  contextMenuRequested: PaletteContextMenuPayload;