@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.
- package/dist/esm/canvas-renderer.js +79 -28
- package/dist/esm/canvas-renderer.js.map +1 -1
- package/dist/esm/core/action-registry.js +24 -0
- package/dist/esm/core/action-registry.js.map +1 -1
- package/dist/esm/core/document.js +134 -28
- package/dist/esm/core/document.js.map +1 -1
- package/dist/esm/core/json.js +17 -0
- package/dist/esm/core/json.js.map +1 -0
- package/dist/esm/core/model.js +8 -0
- package/dist/esm/core/model.js.map +1 -1
- package/dist/esm/core/state.js +16 -2
- package/dist/esm/core/state.js.map +1 -1
- package/dist/esm/diagram/catalog.js +17 -5
- package/dist/esm/diagram/catalog.js.map +1 -1
- package/dist/esm/diagram/event-emitter.js +10 -0
- package/dist/esm/diagram/event-emitter.js.map +1 -1
- package/dist/esm/diagram/stocksharp-diagram.js +58 -56
- package/dist/esm/diagram/stocksharp-diagram.js.map +1 -1
- package/dist/esm/diagram/types.js +2 -1
- package/dist/esm/diagram/types.js.map +1 -1
- package/dist/esm/embed.js +121 -38
- package/dist/esm/embed.js.map +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/ssdiagram.js +380 -150
- package/dist/ssdiagram.js.map +4 -4
- package/dist/types/canvas-renderer.d.ts +11 -6
- package/dist/types/canvas-renderer.d.ts.map +1 -1
- package/dist/types/core/action-registry.d.ts +7 -0
- package/dist/types/core/action-registry.d.ts.map +1 -1
- package/dist/types/core/document.d.ts +1 -1
- package/dist/types/core/document.d.ts.map +1 -1
- package/dist/types/core/json.d.ts +11 -0
- package/dist/types/core/json.d.ts.map +1 -0
- package/dist/types/core/model.d.ts +45 -2
- package/dist/types/core/model.d.ts.map +1 -1
- package/dist/types/core/state.d.ts +24 -1
- package/dist/types/core/state.d.ts.map +1 -1
- package/dist/types/diagram/api.d.ts +6 -6
- package/dist/types/diagram/api.d.ts.map +1 -1
- package/dist/types/diagram/catalog.d.ts +2 -1
- package/dist/types/diagram/catalog.d.ts.map +1 -1
- package/dist/types/diagram/event-emitter.d.ts +6 -1
- package/dist/types/diagram/event-emitter.d.ts.map +1 -1
- package/dist/types/diagram/palette.d.ts +1 -1
- package/dist/types/diagram/palette.d.ts.map +1 -1
- package/dist/types/diagram/stocksharp-diagram.d.ts +29 -22
- package/dist/types/diagram/stocksharp-diagram.d.ts.map +1 -1
- package/dist/types/diagram/types.d.ts +4 -4
- package/dist/types/diagram/types.d.ts.map +1 -1
- package/dist/types/embed.d.ts.map +1 -1
- package/dist/types/index.d.ts +3 -3
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/canvas-renderer.ts +84 -29
- package/src/core/action-registry.ts +25 -0
- package/src/core/document.ts +147 -31
- package/src/core/json.ts +16 -0
- package/src/core/model.ts +55 -2
- package/src/core/state.ts +41 -3
- package/src/diagram/api.ts +6 -5
- package/src/diagram/catalog.ts +18 -6
- package/src/diagram/event-emitter.ts +12 -1
- package/src/diagram/palette.ts +1 -1
- package/src/diagram/stocksharp-diagram.ts +79 -76
- package/src/diagram/types.ts +6 -5
- package/src/embed.ts +140 -39
- package/src/index.ts +4 -0
package/src/core/document.ts
CHANGED
|
@@ -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
|
|
9
|
+
type DiagramLinkStyle,
|
|
8
10
|
type DiagramDocumentNode,
|
|
9
|
-
type DiagramDocumentNodeInput,
|
|
10
11
|
type DiagramDocumentPort,
|
|
11
|
-
type
|
|
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
|
-
|
|
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 <
|
|
47
|
-
const
|
|
48
|
-
if (
|
|
49
|
-
requireIdentifier(
|
|
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 =
|
|
67
|
+
const links = rawLinks.map((link, index) => {
|
|
58
68
|
let id = link.id;
|
|
59
69
|
if (id === undefined) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
usedLinkIds.
|
|
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 =
|
|
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(
|
|
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
|
|
126
|
-
|
|
127
|
-
|
|
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(
|
|
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 ?? []
|
|
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:
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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:
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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);
|
package/src/core/json.ts
ADDED
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/diagram/api.ts
CHANGED
|
@@ -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
|
|
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;
|
package/src/diagram/catalog.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
44
|
+
this.emitNodeTypesChanged();
|
|
41
45
|
}
|
|
42
46
|
|
|
43
47
|
removeNodeType(id: string): void {
|
|
44
48
|
this.nodeTypes.delete(id.toLowerCase());
|
|
45
|
-
this.
|
|
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
|
-
|
|
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
|
}
|
package/src/diagram/palette.ts
CHANGED
|
@@ -22,7 +22,7 @@ export interface PaletteContextMenuPayload extends PaletteNodePayload {
|
|
|
22
22
|
y: number;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
export interface PaletteEvents
|
|
25
|
+
export interface PaletteEvents {
|
|
26
26
|
selectionChanged: PaletteSelectionChangedPayload;
|
|
27
27
|
nodeActivated: PaletteNodePayload;
|
|
28
28
|
contextMenuRequested: PaletteContextMenuPayload;
|