@stocksharp/diagram 1.0.1 → 1.2.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/README.md +8 -0
- package/dist/esm/diagram/stocksharp-diagram.js +76 -0
- package/dist/esm/diagram/stocksharp-diagram.js.map +1 -1
- package/dist/esm/embed.js +10 -0
- package/dist/esm/embed.js.map +1 -1
- package/dist/esm/headless.js +111 -0
- package/dist/esm/headless.js.map +1 -0
- package/dist/esm/i18n.js.map +1 -1
- package/dist/esm/index.js +1 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/ssdiagram-headless.js +4176 -0
- package/dist/ssdiagram-headless.js.map +7 -0
- package/dist/ssdiagram.js +516 -329
- package/dist/ssdiagram.js.map +4 -4
- package/dist/types/diagram/api.d.ts +7 -0
- package/dist/types/diagram/api.d.ts.map +1 -1
- package/dist/types/diagram/stocksharp-diagram.d.ts +10 -0
- package/dist/types/diagram/stocksharp-diagram.d.ts.map +1 -1
- package/dist/types/embed.d.ts +6 -1
- package/dist/types/embed.d.ts.map +1 -1
- package/dist/types/headless.d.ts +24 -0
- package/dist/types/headless.d.ts.map +1 -0
- package/dist/types/i18n.d.ts +1 -0
- package/dist/types/i18n.d.ts.map +1 -1
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/diagram/api.ts +7 -0
- package/src/diagram/stocksharp-diagram.ts +83 -0
- package/src/embed.ts +20 -1
- package/src/headless.ts +139 -0
- package/src/i18n.ts +1 -0
- package/src/index.ts +3 -0
|
@@ -0,0 +1,4176 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var SSDiagramHeadless = (() => {
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
var __copyProps = (to, from, except, desc) => {
|
|
12
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
13
|
+
for (let key of __getOwnPropNames(from))
|
|
14
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
15
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
16
|
+
}
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
20
|
+
|
|
21
|
+
// src/headless.ts
|
|
22
|
+
var headless_exports = {};
|
|
23
|
+
__export(headless_exports, {
|
|
24
|
+
renderDiagramSvg: () => renderDiagramSvg
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// src/svg-surface.ts
|
|
28
|
+
var IDENTITY = [1, 0, 0, 1, 0, 0];
|
|
29
|
+
function multiply(m, n2) {
|
|
30
|
+
return [
|
|
31
|
+
m[0] * n2[0] + m[2] * n2[1],
|
|
32
|
+
m[1] * n2[0] + m[3] * n2[1],
|
|
33
|
+
m[0] * n2[2] + m[2] * n2[3],
|
|
34
|
+
m[1] * n2[2] + m[3] * n2[3],
|
|
35
|
+
m[0] * n2[4] + m[2] * n2[5] + m[4],
|
|
36
|
+
m[1] * n2[4] + m[3] * n2[5] + m[5]
|
|
37
|
+
];
|
|
38
|
+
}
|
|
39
|
+
function isIdentity(m) {
|
|
40
|
+
return m[0] === 1 && m[1] === 0 && m[2] === 0 && m[3] === 1 && m[4] === 0 && m[5] === 0;
|
|
41
|
+
}
|
|
42
|
+
function n(value) {
|
|
43
|
+
if (!Number.isFinite(value)) return "0";
|
|
44
|
+
return (Math.round(value * 1e3) / 1e3).toString();
|
|
45
|
+
}
|
|
46
|
+
function escapeXml(value) {
|
|
47
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
48
|
+
}
|
|
49
|
+
var ANCHOR = {
|
|
50
|
+
left: "start",
|
|
51
|
+
start: "start",
|
|
52
|
+
center: "middle",
|
|
53
|
+
right: "end",
|
|
54
|
+
end: "end"
|
|
55
|
+
};
|
|
56
|
+
var BASELINE = {
|
|
57
|
+
top: "text-before-edge",
|
|
58
|
+
hanging: "hanging",
|
|
59
|
+
middle: "central",
|
|
60
|
+
alphabetic: "alphabetic",
|
|
61
|
+
ideographic: "ideographic",
|
|
62
|
+
bottom: "text-after-edge"
|
|
63
|
+
};
|
|
64
|
+
var SvgSurface = class {
|
|
65
|
+
constructor(options) {
|
|
66
|
+
this.body = [];
|
|
67
|
+
this.defs = [];
|
|
68
|
+
this.stack = [];
|
|
69
|
+
this.state = {
|
|
70
|
+
fillStyle: "#000000",
|
|
71
|
+
strokeStyle: "#000000",
|
|
72
|
+
lineWidth: 1,
|
|
73
|
+
lineJoin: "miter",
|
|
74
|
+
lineCap: "butt",
|
|
75
|
+
globalAlpha: 1,
|
|
76
|
+
font: "10px sans-serif",
|
|
77
|
+
textAlign: "start",
|
|
78
|
+
textBaseline: "alphabetic",
|
|
79
|
+
dash: [],
|
|
80
|
+
matrix: IDENTITY,
|
|
81
|
+
clipId: null
|
|
82
|
+
};
|
|
83
|
+
/** Path being built by moveTo/lineTo/arc/arcTo, in the units the caller passes. */
|
|
84
|
+
this.path = [];
|
|
85
|
+
this.cursor = null;
|
|
86
|
+
this.subpathStart = null;
|
|
87
|
+
this.clipSeq = 0;
|
|
88
|
+
this.width = options.width;
|
|
89
|
+
this.height = options.height;
|
|
90
|
+
this.background = options.background;
|
|
91
|
+
this.metrics = options.metrics ?? null;
|
|
92
|
+
}
|
|
93
|
+
// ===== state =========================================================
|
|
94
|
+
get fillStyle() {
|
|
95
|
+
return this.state.fillStyle;
|
|
96
|
+
}
|
|
97
|
+
set fillStyle(value) {
|
|
98
|
+
if (typeof value === "string") this.state.fillStyle = value;
|
|
99
|
+
}
|
|
100
|
+
get strokeStyle() {
|
|
101
|
+
return this.state.strokeStyle;
|
|
102
|
+
}
|
|
103
|
+
set strokeStyle(value) {
|
|
104
|
+
if (typeof value === "string") this.state.strokeStyle = value;
|
|
105
|
+
}
|
|
106
|
+
get lineWidth() {
|
|
107
|
+
return this.state.lineWidth;
|
|
108
|
+
}
|
|
109
|
+
set lineWidth(value) {
|
|
110
|
+
this.state.lineWidth = value;
|
|
111
|
+
}
|
|
112
|
+
get lineJoin() {
|
|
113
|
+
return this.state.lineJoin;
|
|
114
|
+
}
|
|
115
|
+
set lineJoin(value) {
|
|
116
|
+
this.state.lineJoin = value;
|
|
117
|
+
}
|
|
118
|
+
get lineCap() {
|
|
119
|
+
return this.state.lineCap;
|
|
120
|
+
}
|
|
121
|
+
set lineCap(value) {
|
|
122
|
+
this.state.lineCap = value;
|
|
123
|
+
}
|
|
124
|
+
get globalAlpha() {
|
|
125
|
+
return this.state.globalAlpha;
|
|
126
|
+
}
|
|
127
|
+
set globalAlpha(value) {
|
|
128
|
+
this.state.globalAlpha = value;
|
|
129
|
+
}
|
|
130
|
+
get font() {
|
|
131
|
+
return this.state.font;
|
|
132
|
+
}
|
|
133
|
+
set font(value) {
|
|
134
|
+
this.state.font = value;
|
|
135
|
+
if (this.metrics !== null) this.metrics.font = value;
|
|
136
|
+
}
|
|
137
|
+
get textAlign() {
|
|
138
|
+
return this.state.textAlign;
|
|
139
|
+
}
|
|
140
|
+
set textAlign(value) {
|
|
141
|
+
this.state.textAlign = value;
|
|
142
|
+
}
|
|
143
|
+
get textBaseline() {
|
|
144
|
+
return this.state.textBaseline;
|
|
145
|
+
}
|
|
146
|
+
set textBaseline(value) {
|
|
147
|
+
this.state.textBaseline = value;
|
|
148
|
+
}
|
|
149
|
+
save() {
|
|
150
|
+
this.stack.push({ ...this.state, dash: [...this.state.dash], matrix: [...this.state.matrix] });
|
|
151
|
+
}
|
|
152
|
+
restore() {
|
|
153
|
+
const previous = this.stack.pop();
|
|
154
|
+
if (previous !== void 0) this.state = previous;
|
|
155
|
+
}
|
|
156
|
+
setLineDash(segments) {
|
|
157
|
+
this.state.dash = [...segments];
|
|
158
|
+
}
|
|
159
|
+
// ===== transform =====================================================
|
|
160
|
+
setTransform(a, b, c, d, e, f) {
|
|
161
|
+
this.state.matrix = [a, b, c, d, e, f];
|
|
162
|
+
}
|
|
163
|
+
translate(x, y) {
|
|
164
|
+
this.state.matrix = multiply(this.state.matrix, [1, 0, 0, 1, x, y]);
|
|
165
|
+
}
|
|
166
|
+
rotate(angle) {
|
|
167
|
+
const cos = Math.cos(angle);
|
|
168
|
+
const sin = Math.sin(angle);
|
|
169
|
+
this.state.matrix = multiply(this.state.matrix, [cos, sin, -sin, cos, 0, 0]);
|
|
170
|
+
}
|
|
171
|
+
// ===== paths =========================================================
|
|
172
|
+
beginPath() {
|
|
173
|
+
this.path = [];
|
|
174
|
+
this.cursor = null;
|
|
175
|
+
this.subpathStart = null;
|
|
176
|
+
}
|
|
177
|
+
closePath() {
|
|
178
|
+
if (this.cursor === null) return;
|
|
179
|
+
this.path.push("Z");
|
|
180
|
+
if (this.subpathStart !== null) this.cursor = [...this.subpathStart];
|
|
181
|
+
}
|
|
182
|
+
moveTo(x, y) {
|
|
183
|
+
this.path.push(`M${n(x)} ${n(y)}`);
|
|
184
|
+
this.cursor = [x, y];
|
|
185
|
+
this.subpathStart = [x, y];
|
|
186
|
+
}
|
|
187
|
+
lineTo(x, y) {
|
|
188
|
+
if (this.cursor === null) {
|
|
189
|
+
this.moveTo(x, y);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
this.path.push(`L${n(x)} ${n(y)}`);
|
|
193
|
+
this.cursor = [x, y];
|
|
194
|
+
}
|
|
195
|
+
arc(cx, cy, r, start, end, ccw = false) {
|
|
196
|
+
const sx = cx + r * Math.cos(start);
|
|
197
|
+
const sy = cy + r * Math.sin(start);
|
|
198
|
+
if (this.cursor === null) this.moveTo(sx, sy);
|
|
199
|
+
else this.lineTo(sx, sy);
|
|
200
|
+
let delta = end - start;
|
|
201
|
+
if (ccw) {
|
|
202
|
+
while (delta > 0) delta -= Math.PI * 2;
|
|
203
|
+
if (delta <= -Math.PI * 2) delta = -Math.PI * 2;
|
|
204
|
+
} else {
|
|
205
|
+
while (delta < 0) delta += Math.PI * 2;
|
|
206
|
+
if (delta >= Math.PI * 2) delta = Math.PI * 2;
|
|
207
|
+
}
|
|
208
|
+
const sweep = ccw ? 0 : 1;
|
|
209
|
+
if (Math.abs(delta) >= Math.PI * 2 - 1e-9) {
|
|
210
|
+
const mid = start + delta / 2;
|
|
211
|
+
const mx = cx + r * Math.cos(mid);
|
|
212
|
+
const my = cy + r * Math.sin(mid);
|
|
213
|
+
this.path.push(`A${n(r)} ${n(r)} 0 0 ${sweep} ${n(mx)} ${n(my)}`);
|
|
214
|
+
this.path.push(`A${n(r)} ${n(r)} 0 0 ${sweep} ${n(sx)} ${n(sy)}`);
|
|
215
|
+
this.cursor = [sx, sy];
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const ex = cx + r * Math.cos(start + delta);
|
|
219
|
+
const ey = cy + r * Math.sin(start + delta);
|
|
220
|
+
const large = Math.abs(delta) > Math.PI ? 1 : 0;
|
|
221
|
+
this.path.push(`A${n(r)} ${n(r)} 0 ${large} ${sweep} ${n(ex)} ${n(ey)}`);
|
|
222
|
+
this.cursor = [ex, ey];
|
|
223
|
+
}
|
|
224
|
+
arcTo(x1, y1, x2, y2, r) {
|
|
225
|
+
if (this.cursor === null) {
|
|
226
|
+
this.moveTo(x1, y1);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const [x0, y0] = this.cursor;
|
|
230
|
+
const a = [x0 - x1, y0 - y1];
|
|
231
|
+
const b = [x2 - x1, y2 - y1];
|
|
232
|
+
const la = Math.hypot(a[0], a[1]);
|
|
233
|
+
const lb = Math.hypot(b[0], b[1]);
|
|
234
|
+
if (la === 0 || lb === 0 || r === 0) {
|
|
235
|
+
this.lineTo(x1, y1);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const ua = [a[0] / la, a[1] / la];
|
|
239
|
+
const ub = [b[0] / lb, b[1] / lb];
|
|
240
|
+
const cross = ua[0] * ub[1] - ua[1] * ub[0];
|
|
241
|
+
if (Math.abs(cross) < 1e-9) {
|
|
242
|
+
this.lineTo(x1, y1);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const angle = Math.acos(Math.max(-1, Math.min(1, ua[0] * ub[0] + ua[1] * ub[1])));
|
|
246
|
+
const tangent = r / Math.tan(angle / 2);
|
|
247
|
+
const t = Math.min(tangent, la, lb);
|
|
248
|
+
const radius = t * Math.tan(angle / 2);
|
|
249
|
+
const t1 = [x1 + ua[0] * t, y1 + ua[1] * t];
|
|
250
|
+
const t2 = [x1 + ub[0] * t, y1 + ub[1] * t];
|
|
251
|
+
this.lineTo(t1[0], t1[1]);
|
|
252
|
+
const sweep = cross < 0 ? 1 : 0;
|
|
253
|
+
this.path.push(`A${n(radius)} ${n(radius)} 0 0 ${sweep} ${n(t2[0])} ${n(t2[1])}`);
|
|
254
|
+
this.cursor = [t2[0], t2[1]];
|
|
255
|
+
}
|
|
256
|
+
fill() {
|
|
257
|
+
if (this.path.length === 0) return;
|
|
258
|
+
this.emit(`<path d="${this.path.join(" ")}" ${this.fillAttrs()}/>`);
|
|
259
|
+
}
|
|
260
|
+
stroke() {
|
|
261
|
+
if (this.path.length === 0) return;
|
|
262
|
+
this.emit(`<path d="${this.path.join(" ")}" fill="none" ${this.strokeAttrs()}/>`);
|
|
263
|
+
}
|
|
264
|
+
clip() {
|
|
265
|
+
if (this.path.length === 0) return;
|
|
266
|
+
const id = `clip${++this.clipSeq}`;
|
|
267
|
+
this.defs.push(`<clipPath id="${id}"><path d="${this.path.join(" ")}"/></clipPath>`);
|
|
268
|
+
this.state.clipId = id;
|
|
269
|
+
}
|
|
270
|
+
// ===== rectangles ====================================================
|
|
271
|
+
clearRect(x, y, w, h) {
|
|
272
|
+
void x;
|
|
273
|
+
void y;
|
|
274
|
+
void w;
|
|
275
|
+
void h;
|
|
276
|
+
}
|
|
277
|
+
fillRect(x, y, w, h) {
|
|
278
|
+
this.emit(`<rect x="${n(x)}" y="${n(y)}" width="${n(w)}" height="${n(h)}" ${this.fillAttrs()}/>`);
|
|
279
|
+
}
|
|
280
|
+
strokeRect(x, y, w, h) {
|
|
281
|
+
this.emit(`<rect x="${n(x)}" y="${n(y)}" width="${n(w)}" height="${n(h)}" fill="none" ${this.strokeAttrs()}/>`);
|
|
282
|
+
}
|
|
283
|
+
// ===== text ==========================================================
|
|
284
|
+
fillText(text, x, y, maxWidth) {
|
|
285
|
+
if (text === "") return;
|
|
286
|
+
const anchor = ANCHOR[this.state.textAlign] ?? "start";
|
|
287
|
+
const baseline = BASELINE[this.state.textBaseline] ?? "alphabetic";
|
|
288
|
+
let constraint = "";
|
|
289
|
+
if (maxWidth !== void 0 && maxWidth > 0 && this.measureText(text).width > maxWidth) {
|
|
290
|
+
constraint = ` textLength="${n(maxWidth)}" lengthAdjust="spacingAndGlyphs"`;
|
|
291
|
+
}
|
|
292
|
+
this.emit(
|
|
293
|
+
`<text x="${n(x)}" y="${n(y)}" style="font:${escapeXml(this.state.font)}" text-anchor="${anchor}" dominant-baseline="${baseline}"${constraint} ${this.fillAttrs()}>${escapeXml(text)}</text>`
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
measureText(text) {
|
|
297
|
+
if (this.metrics !== null) return { width: this.metrics.measureText(text).width };
|
|
298
|
+
const size = parseFloat(this.state.font) || 10;
|
|
299
|
+
return { width: text.length * size * 0.55 };
|
|
300
|
+
}
|
|
301
|
+
// ===== images ========================================================
|
|
302
|
+
drawImage(image, dx, dy, dw, dh) {
|
|
303
|
+
const href = this.imageHref(image);
|
|
304
|
+
if (href === null) return;
|
|
305
|
+
this.emit(`<image x="${n(dx)}" y="${n(dy)}" width="${n(dw)}" height="${n(dh)}" href="${escapeXml(href)}"${this.commonAttrs()}/>`);
|
|
306
|
+
}
|
|
307
|
+
imageHref(image) {
|
|
308
|
+
try {
|
|
309
|
+
if (typeof HTMLCanvasElement !== "undefined" && image instanceof HTMLCanvasElement) return image.toDataURL();
|
|
310
|
+
if (typeof HTMLImageElement !== "undefined" && image instanceof HTMLImageElement) return image.src;
|
|
311
|
+
} catch {
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
// ===== output ========================================================
|
|
317
|
+
/** The finished document. Safe to call more than once; it does not consume the recording. */
|
|
318
|
+
toSvg() {
|
|
319
|
+
const background = this.background === void 0 ? "" : `<rect width="100%" height="100%" fill="${escapeXml(this.background)}"/>`;
|
|
320
|
+
const defs = this.defs.length === 0 ? "" : `<defs>${this.defs.join("")}</defs>`;
|
|
321
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${n(this.width)}" height="${n(this.height)}" viewBox="0 0 ${n(this.width)} ${n(this.height)}">${defs}${background}${this.body.join("")}</svg>`;
|
|
322
|
+
}
|
|
323
|
+
// ===== internals =====================================================
|
|
324
|
+
emit(element) {
|
|
325
|
+
this.body.push(element);
|
|
326
|
+
}
|
|
327
|
+
commonAttrs() {
|
|
328
|
+
const parts = [];
|
|
329
|
+
if (!isIdentity(this.state.matrix)) {
|
|
330
|
+
parts.push(` transform="matrix(${this.state.matrix.map(n).join(" ")})"`);
|
|
331
|
+
}
|
|
332
|
+
if (this.state.globalAlpha !== 1) parts.push(` opacity="${n(this.state.globalAlpha)}"`);
|
|
333
|
+
if (this.state.clipId !== null) parts.push(` clip-path="url(#${this.state.clipId})"`);
|
|
334
|
+
return parts.join("");
|
|
335
|
+
}
|
|
336
|
+
fillAttrs() {
|
|
337
|
+
return `fill="${escapeXml(this.state.fillStyle)}"${this.commonAttrs()}`;
|
|
338
|
+
}
|
|
339
|
+
strokeAttrs() {
|
|
340
|
+
const parts = [
|
|
341
|
+
`stroke="${escapeXml(this.state.strokeStyle)}"`,
|
|
342
|
+
`stroke-width="${n(this.state.lineWidth)}"`
|
|
343
|
+
];
|
|
344
|
+
if (this.state.lineJoin !== "miter") parts.push(`stroke-linejoin="${this.state.lineJoin}"`);
|
|
345
|
+
if (this.state.lineCap !== "butt") parts.push(`stroke-linecap="${this.state.lineCap}"`);
|
|
346
|
+
if (this.state.dash.length > 0) parts.push(`stroke-dasharray="${this.state.dash.map(n).join(" ")}"`);
|
|
347
|
+
return parts.join(" ") + this.commonAttrs();
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
// src/core/json.ts
|
|
352
|
+
function setJsonKey(target, key, value) {
|
|
353
|
+
if (key === "__proto__") {
|
|
354
|
+
Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true });
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
target[key] = value;
|
|
358
|
+
}
|
|
359
|
+
function getJsonKey(target, key) {
|
|
360
|
+
return Object.prototype.hasOwnProperty.call(target, key) ? target[key] : void 0;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// src/core/model.ts
|
|
364
|
+
function toPortDynamicMode(value) {
|
|
365
|
+
return value === "manual" || value === "onConnect" ? value : "";
|
|
366
|
+
}
|
|
367
|
+
var DIAGRAM_DOCUMENT_VERSION = 1;
|
|
368
|
+
|
|
369
|
+
// src/core/document.ts
|
|
370
|
+
var DiagramDocumentError = class extends Error {
|
|
371
|
+
constructor(message, path = "$") {
|
|
372
|
+
super(`${path}: ${message}`);
|
|
373
|
+
this.path = path;
|
|
374
|
+
this.name = "DiagramDocumentError";
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
function createDiagramDocument(input = {}) {
|
|
378
|
+
const rawNodes = mapElements(input.nodes, "$.nodes");
|
|
379
|
+
const rawLinks = mapElements(input.links, "$.links");
|
|
380
|
+
const nodes = rawNodes.map((node, index) => normalizeNode(node, `$.nodes[${index}]`));
|
|
381
|
+
const usedLinkIds = /* @__PURE__ */ new Set();
|
|
382
|
+
for (let index = 0; index < rawLinks.length; index++) {
|
|
383
|
+
const raw = rawLinks[index].id;
|
|
384
|
+
if (raw === void 0) continue;
|
|
385
|
+
const id = requireIdentifier(raw, `$.links[${index}].id`);
|
|
386
|
+
if (usedLinkIds.has(id)) {
|
|
387
|
+
throw new DiagramDocumentError(`duplicate link id "${id}"`, `$.links[${index}].id`);
|
|
388
|
+
}
|
|
389
|
+
usedLinkIds.add(id);
|
|
390
|
+
}
|
|
391
|
+
let sequence = 1;
|
|
392
|
+
const links = rawLinks.map((link, index) => {
|
|
393
|
+
let id = link.id;
|
|
394
|
+
if (id === void 0) {
|
|
395
|
+
let generated;
|
|
396
|
+
do
|
|
397
|
+
generated = `link_${sequence++}`;
|
|
398
|
+
while (usedLinkIds.has(generated));
|
|
399
|
+
usedLinkIds.add(generated);
|
|
400
|
+
id = generated;
|
|
401
|
+
}
|
|
402
|
+
return normalizeLink({ ...link, id }, `$.links[${index}]`);
|
|
403
|
+
});
|
|
404
|
+
const rawZones = mapElements(input.zones, "$.zones");
|
|
405
|
+
const zones = rawZones.map((zone, index) => normalizeZone(zone, `$.zones[${index}]`));
|
|
406
|
+
validateUniqueZones(zones);
|
|
407
|
+
const document2 = {
|
|
408
|
+
version: DIAGRAM_DOCUMENT_VERSION,
|
|
409
|
+
nodes,
|
|
410
|
+
links,
|
|
411
|
+
zones,
|
|
412
|
+
metadata: cloneJsonObject(input.metadata ?? {}, "$.metadata")
|
|
413
|
+
};
|
|
414
|
+
validateDocument(document2);
|
|
415
|
+
return document2;
|
|
416
|
+
}
|
|
417
|
+
function parseDiagramDocument(source) {
|
|
418
|
+
let value = source;
|
|
419
|
+
if (typeof source === "string") {
|
|
420
|
+
try {
|
|
421
|
+
value = JSON.parse(source);
|
|
422
|
+
} catch (error) {
|
|
423
|
+
const reason = error instanceof Error ? error.message : "invalid JSON";
|
|
424
|
+
throw new DiagramDocumentError(reason);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
const root = requireStructure(value, "$");
|
|
428
|
+
const version = requireNumber(root.version, "$.version");
|
|
429
|
+
if (version !== DIAGRAM_DOCUMENT_VERSION) {
|
|
430
|
+
throw new DiagramDocumentError(`unsupported document version ${version}`, "$.version");
|
|
431
|
+
}
|
|
432
|
+
const document2 = {
|
|
433
|
+
version: DIAGRAM_DOCUMENT_VERSION,
|
|
434
|
+
nodes: requireArray(root.nodes, "$.nodes").map((node, index) => parseNode(node, `$.nodes[${index}]`)),
|
|
435
|
+
links: requireArray(root.links, "$.links").map((link, index) => parseLink(link, `$.links[${index}]`)),
|
|
436
|
+
// Zones arrived after the first documents were written, so their absence is not a fault:
|
|
437
|
+
// an older file simply has none, and reading it must not need a migration step.
|
|
438
|
+
zones: root.zones === void 0 || root.zones === null ? [] : requireArray(root.zones, "$.zones").map((zone, index) => normalizeZone(zone, `$.zones[${index}]`)),
|
|
439
|
+
metadata: cloneJsonObject(root.metadata, "$.metadata")
|
|
440
|
+
};
|
|
441
|
+
validateDocument(document2);
|
|
442
|
+
return document2;
|
|
443
|
+
}
|
|
444
|
+
function normalizeNode(value, path) {
|
|
445
|
+
const input = requireStructure(value, path);
|
|
446
|
+
const id = requireIdentifier(input.id, `${path}.id`);
|
|
447
|
+
return {
|
|
448
|
+
id,
|
|
449
|
+
typeId: requireIdentifier(input.typeId ?? id, `${path}.typeId`),
|
|
450
|
+
name: requireString(input.name, `${path}.name`),
|
|
451
|
+
description: requireString(input.description ?? "", `${path}.description`),
|
|
452
|
+
groupName: requireString(input.groupName ?? "Common", `${path}.groupName`),
|
|
453
|
+
x: requireFiniteNumber(input.x ?? 0, `${path}.x`),
|
|
454
|
+
y: requireFiniteNumber(input.y ?? 0, `${path}.y`),
|
|
455
|
+
color: requireString(input.color ?? "#d7d7d7", `${path}.color`),
|
|
456
|
+
border: requireString(input.border ?? "#8c8c8c", `${path}.border`),
|
|
457
|
+
icon: requireString(input.icon ?? "", `${path}.icon`),
|
|
458
|
+
message: requireString(input.message ?? "", `${path}.message`),
|
|
459
|
+
openAction: requireString(input.openAction ?? "", `${path}.openAction`),
|
|
460
|
+
inPorts: mapElements(input.inPorts, `${path}.inPorts`).map((port, index) => normalizePort(port, `${path}.inPorts[${index}]`)),
|
|
461
|
+
outPorts: mapElements(input.outPorts, `${path}.outPorts`).map((port, index) => normalizePort(port, `${path}.outPorts[${index}]`)),
|
|
462
|
+
parameters: mapElements(input.parameters, `${path}.parameters`).map((parameter, index) => normalizeParameter(parameter, `${path}.parameters[${index}]`)),
|
|
463
|
+
paramValues: cloneStringRecord(input.paramValues ?? {}, `${path}.paramValues`),
|
|
464
|
+
metadata: cloneJsonObject(input.metadata ?? {}, `${path}.metadata`)
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
function normalizePort(value, path) {
|
|
468
|
+
const input = requireStructure(value, path);
|
|
469
|
+
return {
|
|
470
|
+
id: requireIdentifier(input.id, `${path}.id`),
|
|
471
|
+
name: requireString(input.name, `${path}.name`),
|
|
472
|
+
description: requireString(input.description ?? "", `${path}.description`),
|
|
473
|
+
type: requireString(input.type ?? "", `${path}.type`),
|
|
474
|
+
maxLinks: requireNonNegativeInteger(input.maxLinks ?? 0, `${path}.maxLinks`),
|
|
475
|
+
availableTypes: requireArray(input.availableTypes ?? [], `${path}.availableTypes`).map((type, index) => requireString(type, `${path}.availableTypes[${index}]`)),
|
|
476
|
+
isDynamic: requireBoolean(input.isDynamic ?? false, `${path}.isDynamic`),
|
|
477
|
+
dynamicMode: requirePortDynamicMode(input.dynamicMode ?? "", `${path}.dynamicMode`),
|
|
478
|
+
isSibling: requireBoolean(input.isSibling ?? false, `${path}.isSibling`),
|
|
479
|
+
metadata: cloneJsonObject(input.metadata ?? {}, `${path}.metadata`)
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
function normalizeParameter(value, path) {
|
|
483
|
+
const input = requireStructure(value, path);
|
|
484
|
+
return {
|
|
485
|
+
name: requireIdentifier(input.name, `${path}.name`),
|
|
486
|
+
displayName: requireString(input.displayName, `${path}.displayName`),
|
|
487
|
+
description: requireString(input.description, `${path}.description`),
|
|
488
|
+
type: requireString(input.type, `${path}.type`),
|
|
489
|
+
defaultValue: requireString(input.defaultValue, `${path}.defaultValue`),
|
|
490
|
+
options: requireArray(input.options, `${path}.options`).map((option, index) => requireString(option, `${path}.options[${index}]`)),
|
|
491
|
+
min: requireNullableFiniteNumber(input.min, `${path}.min`),
|
|
492
|
+
max: requireNullableFiniteNumber(input.max, `${path}.max`),
|
|
493
|
+
displayOrder: requireFiniteNumber(input.displayOrder, `${path}.displayOrder`),
|
|
494
|
+
category: requireString(input.category, `${path}.category`),
|
|
495
|
+
isBasic: requireBoolean(input.isBasic, `${path}.isBasic`),
|
|
496
|
+
editorType: requireString(input.editorType, `${path}.editorType`)
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
function normalizeLink(value, path) {
|
|
500
|
+
const input = requireStructure(value, path);
|
|
501
|
+
return {
|
|
502
|
+
id: requireIdentifier(input.id, `${path}.id`),
|
|
503
|
+
from: normalizeEndpoint(input.from, `${path}.from`),
|
|
504
|
+
to: normalizeEndpoint(input.to, `${path}.to`),
|
|
505
|
+
style: requireLinkStyle(input.style, `${path}.style`),
|
|
506
|
+
metadata: cloneJsonObject(input.metadata ?? {}, `${path}.metadata`)
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
function requireLinkStyle(value, path) {
|
|
510
|
+
if (value === void 0 || value === null) return "solid";
|
|
511
|
+
if (value !== "solid" && value !== "dashed") {
|
|
512
|
+
throw new DiagramDocumentError(`unknown link style "${String(value)}"`, path);
|
|
513
|
+
}
|
|
514
|
+
return value;
|
|
515
|
+
}
|
|
516
|
+
function normalizeZone(value, path) {
|
|
517
|
+
const input = requireStructure(value, path);
|
|
518
|
+
return {
|
|
519
|
+
id: requireIdentifier(input.id, `${path}.id`),
|
|
520
|
+
name: requireString(input.name, `${path}.name`),
|
|
521
|
+
// Finite, like node coordinates: NaN survives every arithmetic check and writes as null.
|
|
522
|
+
x: requireFiniteNumber(input.x, `${path}.x`),
|
|
523
|
+
y: requireFiniteNumber(input.y, `${path}.y`),
|
|
524
|
+
width: requirePositiveSize(input.width, `${path}.width`),
|
|
525
|
+
height: requirePositiveSize(input.height, `${path}.height`),
|
|
526
|
+
color: input.color === void 0 || input.color === null ? "" : requireString(input.color, `${path}.color`),
|
|
527
|
+
metadata: cloneJsonObject(input.metadata ?? {}, `${path}.metadata`)
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
function requirePositiveSize(value, path) {
|
|
531
|
+
const size = requireFiniteNumber(value, path);
|
|
532
|
+
if (!(size > 0)) throw new DiagramDocumentError("size must be greater than zero", path);
|
|
533
|
+
return size;
|
|
534
|
+
}
|
|
535
|
+
function normalizeEndpoint(value, path) {
|
|
536
|
+
const input = requireStructure(value, path);
|
|
537
|
+
return {
|
|
538
|
+
nodeId: requireIdentifier(input.nodeId, `${path}.nodeId`),
|
|
539
|
+
portId: requireIdentifier(input.portId, `${path}.portId`)
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
function parseNode(value, path) {
|
|
543
|
+
const node = requireStructure(value, path);
|
|
544
|
+
return normalizeNode({
|
|
545
|
+
id: requireString(node.id, `${path}.id`),
|
|
546
|
+
typeId: requireString(node.typeId, `${path}.typeId`),
|
|
547
|
+
name: requireString(node.name, `${path}.name`),
|
|
548
|
+
description: requireString(node.description, `${path}.description`),
|
|
549
|
+
groupName: requireString(node.groupName, `${path}.groupName`),
|
|
550
|
+
x: requireNumber(node.x, `${path}.x`),
|
|
551
|
+
y: requireNumber(node.y, `${path}.y`),
|
|
552
|
+
color: requireString(node.color, `${path}.color`),
|
|
553
|
+
border: requireString(node.border, `${path}.border`),
|
|
554
|
+
icon: requireString(node.icon, `${path}.icon`),
|
|
555
|
+
message: requireString(node.message, `${path}.message`),
|
|
556
|
+
openAction: requireString(node.openAction, `${path}.openAction`),
|
|
557
|
+
inPorts: requireArray(node.inPorts, `${path}.inPorts`).map((port, index) => parsePort(port, `${path}.inPorts[${index}]`)),
|
|
558
|
+
outPorts: requireArray(node.outPorts, `${path}.outPorts`).map((port, index) => parsePort(port, `${path}.outPorts[${index}]`)),
|
|
559
|
+
parameters: requireArray(node.parameters, `${path}.parameters`).map((parameter, index) => parseParameter(parameter, `${path}.parameters[${index}]`)),
|
|
560
|
+
paramValues: cloneStringRecord(node.paramValues, `${path}.paramValues`),
|
|
561
|
+
metadata: cloneJsonObject(node.metadata, `${path}.metadata`)
|
|
562
|
+
}, path);
|
|
563
|
+
}
|
|
564
|
+
function parsePort(value, path) {
|
|
565
|
+
const port = requireStructure(value, path);
|
|
566
|
+
return normalizePort({
|
|
567
|
+
id: requireString(port.id, `${path}.id`),
|
|
568
|
+
name: requireString(port.name, `${path}.name`),
|
|
569
|
+
description: requireString(port.description, `${path}.description`),
|
|
570
|
+
type: requireString(port.type, `${path}.type`),
|
|
571
|
+
maxLinks: requireNumber(port.maxLinks, `${path}.maxLinks`),
|
|
572
|
+
availableTypes: requireArray(port.availableTypes, `${path}.availableTypes`).map((type, index) => requireString(type, `${path}.availableTypes[${index}]`)),
|
|
573
|
+
isDynamic: requireBoolean(port.isDynamic, `${path}.isDynamic`),
|
|
574
|
+
dynamicMode: requirePortDynamicMode(port.dynamicMode, `${path}.dynamicMode`),
|
|
575
|
+
isSibling: requireBoolean(port.isSibling, `${path}.isSibling`),
|
|
576
|
+
metadata: cloneJsonObject(port.metadata, `${path}.metadata`)
|
|
577
|
+
}, path);
|
|
578
|
+
}
|
|
579
|
+
function parseParameter(value, path) {
|
|
580
|
+
const parameter = requireStructure(value, path);
|
|
581
|
+
return normalizeParameter({
|
|
582
|
+
name: requireString(parameter.name, `${path}.name`),
|
|
583
|
+
displayName: requireString(parameter.displayName, `${path}.displayName`),
|
|
584
|
+
description: requireString(parameter.description, `${path}.description`),
|
|
585
|
+
type: requireString(parameter.type, `${path}.type`),
|
|
586
|
+
defaultValue: requireString(parameter.defaultValue, `${path}.defaultValue`),
|
|
587
|
+
options: requireArray(parameter.options, `${path}.options`).map((option, index) => requireString(option, `${path}.options[${index}]`)),
|
|
588
|
+
min: requireNullableFiniteNumber(parameter.min, `${path}.min`),
|
|
589
|
+
max: requireNullableFiniteNumber(parameter.max, `${path}.max`),
|
|
590
|
+
displayOrder: requireNumber(parameter.displayOrder, `${path}.displayOrder`),
|
|
591
|
+
category: requireString(parameter.category, `${path}.category`),
|
|
592
|
+
isBasic: requireBoolean(parameter.isBasic, `${path}.isBasic`),
|
|
593
|
+
editorType: requireString(parameter.editorType, `${path}.editorType`)
|
|
594
|
+
}, path);
|
|
595
|
+
}
|
|
596
|
+
function parseLink(value, path) {
|
|
597
|
+
const link = requireStructure(value, path);
|
|
598
|
+
return normalizeLink({
|
|
599
|
+
id: requireString(link.id, `${path}.id`),
|
|
600
|
+
from: parseEndpoint(link.from, `${path}.from`),
|
|
601
|
+
to: parseEndpoint(link.to, `${path}.to`),
|
|
602
|
+
style: link.style === void 0 ? void 0 : requireLinkStyle(link.style, `${path}.style`),
|
|
603
|
+
metadata: cloneJsonObject(link.metadata, `${path}.metadata`)
|
|
604
|
+
}, path);
|
|
605
|
+
}
|
|
606
|
+
function parseEndpoint(value, path) {
|
|
607
|
+
const endpoint = requireStructure(value, path);
|
|
608
|
+
return {
|
|
609
|
+
nodeId: requireIdentifier(endpoint.nodeId, `${path}.nodeId`),
|
|
610
|
+
portId: requireIdentifier(endpoint.portId, `${path}.portId`)
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
function validateUniqueZones(zones) {
|
|
614
|
+
const seen = /* @__PURE__ */ new Set();
|
|
615
|
+
for (let index = 0; index < zones.length; index++) {
|
|
616
|
+
const zone = zones[index];
|
|
617
|
+
if (seen.has(zone.id)) throw new DiagramDocumentError(`duplicate zone id "${zone.id}"`, `$.zones[${index}].id`);
|
|
618
|
+
seen.add(zone.id);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
function validateDocument(document2) {
|
|
622
|
+
const nodes = /* @__PURE__ */ new Map();
|
|
623
|
+
for (let index = 0; index < document2.nodes.length; index++) {
|
|
624
|
+
const node = document2.nodes[index];
|
|
625
|
+
if (nodes.has(node.id)) throw new DiagramDocumentError(`duplicate node id "${node.id}"`, `$.nodes[${index}].id`);
|
|
626
|
+
validateUniquePorts(node.inPorts, `$.nodes[${index}].inPorts`);
|
|
627
|
+
validateUniquePorts(node.outPorts, `$.nodes[${index}].outPorts`);
|
|
628
|
+
nodes.set(node.id, node);
|
|
629
|
+
}
|
|
630
|
+
const linkIds = /* @__PURE__ */ new Set();
|
|
631
|
+
const endpoints = /* @__PURE__ */ new Set();
|
|
632
|
+
for (let index = 0; index < document2.links.length; index++) {
|
|
633
|
+
const link = document2.links[index];
|
|
634
|
+
const path = `$.links[${index}]`;
|
|
635
|
+
if (linkIds.has(link.id)) throw new DiagramDocumentError(`duplicate link id "${link.id}"`, `${path}.id`);
|
|
636
|
+
linkIds.add(link.id);
|
|
637
|
+
const fromNode = nodes.get(link.from.nodeId);
|
|
638
|
+
const toNode = nodes.get(link.to.nodeId);
|
|
639
|
+
if (fromNode === void 0) throw new DiagramDocumentError(`unknown source node "${link.from.nodeId}"`, `${path}.from.nodeId`);
|
|
640
|
+
if (toNode === void 0) throw new DiagramDocumentError(`unknown target node "${link.to.nodeId}"`, `${path}.to.nodeId`);
|
|
641
|
+
if (!fromNode.outPorts.some((port) => port.id === link.from.portId)) {
|
|
642
|
+
throw new DiagramDocumentError(`unknown output port "${link.from.portId}"`, `${path}.from.portId`);
|
|
643
|
+
}
|
|
644
|
+
if (!toNode.inPorts.some((port) => port.id === link.to.portId)) {
|
|
645
|
+
throw new DiagramDocumentError(`unknown input port "${link.to.portId}"`, `${path}.to.portId`);
|
|
646
|
+
}
|
|
647
|
+
const key = `${link.from.nodeId}\0${link.from.portId}\0${link.to.nodeId}\0${link.to.portId}`;
|
|
648
|
+
if (endpoints.has(key)) throw new DiagramDocumentError("duplicate link endpoints", path);
|
|
649
|
+
endpoints.add(key);
|
|
650
|
+
}
|
|
651
|
+
validateUniqueZones(document2.zones);
|
|
652
|
+
}
|
|
653
|
+
function validateUniquePorts(ports, path) {
|
|
654
|
+
const ids = /* @__PURE__ */ new Set();
|
|
655
|
+
for (let index = 0; index < ports.length; index++) {
|
|
656
|
+
const id = ports[index].id;
|
|
657
|
+
if (ids.has(id)) throw new DiagramDocumentError(`duplicate port id "${id}"`, `${path}[${index}].id`);
|
|
658
|
+
ids.add(id);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
function cloneStringRecord(value, path) {
|
|
662
|
+
const object = requireObject(value, path);
|
|
663
|
+
const result = {};
|
|
664
|
+
for (const [key, item] of Object.entries(object)) setJsonKey(result, key, requireString(item, `${path}.${key}`));
|
|
665
|
+
return result;
|
|
666
|
+
}
|
|
667
|
+
function cloneJsonObject(value, path, ancestors = /* @__PURE__ */ new WeakSet()) {
|
|
668
|
+
const object = requireObject(value, path);
|
|
669
|
+
if (ancestors.has(object)) throw new DiagramDocumentError("circular JSON value", path);
|
|
670
|
+
ancestors.add(object);
|
|
671
|
+
const result = {};
|
|
672
|
+
try {
|
|
673
|
+
for (const [key, item] of Object.entries(object)) {
|
|
674
|
+
setJsonKey(result, key, cloneJsonValue(item, `${path}.${key}`, ancestors));
|
|
675
|
+
}
|
|
676
|
+
} finally {
|
|
677
|
+
ancestors.delete(object);
|
|
678
|
+
}
|
|
679
|
+
return result;
|
|
680
|
+
}
|
|
681
|
+
function cloneJsonValue(value, path, ancestors) {
|
|
682
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return value;
|
|
683
|
+
if (typeof value === "number") return requireFiniteNumber(value, path);
|
|
684
|
+
if (Array.isArray(value)) {
|
|
685
|
+
if (ancestors.has(value)) throw new DiagramDocumentError("circular JSON value", path);
|
|
686
|
+
ancestors.add(value);
|
|
687
|
+
try {
|
|
688
|
+
return value.map((item, index) => cloneJsonValue(item, `${path}[${index}]`, ancestors));
|
|
689
|
+
} finally {
|
|
690
|
+
ancestors.delete(value);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
if (isObject(value)) return cloneJsonObject(value, path, ancestors);
|
|
694
|
+
throw new DiagramDocumentError("expected a JSON value", path);
|
|
695
|
+
}
|
|
696
|
+
function requireStructure(value, path) {
|
|
697
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
698
|
+
throw new DiagramDocumentError("expected an object", path);
|
|
699
|
+
}
|
|
700
|
+
return value;
|
|
701
|
+
}
|
|
702
|
+
function requireObject(value, path) {
|
|
703
|
+
if (!isObject(value)) throw new DiagramDocumentError("expected an object", path);
|
|
704
|
+
return value;
|
|
705
|
+
}
|
|
706
|
+
function isObject(value) {
|
|
707
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
708
|
+
const prototype = Object.getPrototypeOf(value);
|
|
709
|
+
return prototype === Object.prototype || prototype === null;
|
|
710
|
+
}
|
|
711
|
+
function mapElements(value, path) {
|
|
712
|
+
if (value === void 0 || value === null) return [];
|
|
713
|
+
return Array.from(
|
|
714
|
+
requireArray(value, path),
|
|
715
|
+
(element, index) => requireStructure(element, `${path}[${index}]`)
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
function requireArray(value, path) {
|
|
719
|
+
if (!Array.isArray(value)) throw new DiagramDocumentError("expected an array", path);
|
|
720
|
+
return value;
|
|
721
|
+
}
|
|
722
|
+
function requireString(value, path) {
|
|
723
|
+
if (typeof value !== "string") throw new DiagramDocumentError("expected a string", path);
|
|
724
|
+
return value;
|
|
725
|
+
}
|
|
726
|
+
function requirePortDynamicMode(value, path) {
|
|
727
|
+
const mode = requireString(value, path);
|
|
728
|
+
return toPortDynamicMode(mode);
|
|
729
|
+
}
|
|
730
|
+
function requireIdentifier(value, path) {
|
|
731
|
+
const id = requireString(value, path);
|
|
732
|
+
if (id.trim().length === 0) throw new DiagramDocumentError("identifier cannot be empty", path);
|
|
733
|
+
return id;
|
|
734
|
+
}
|
|
735
|
+
function requireNumber(value, path) {
|
|
736
|
+
if (typeof value !== "number") throw new DiagramDocumentError("expected a number", path);
|
|
737
|
+
return value;
|
|
738
|
+
}
|
|
739
|
+
function requireFiniteNumber(value, path) {
|
|
740
|
+
const number = requireNumber(value, path);
|
|
741
|
+
if (!Number.isFinite(number)) throw new DiagramDocumentError("expected a finite number", path);
|
|
742
|
+
return number;
|
|
743
|
+
}
|
|
744
|
+
function requireNullableFiniteNumber(value, path) {
|
|
745
|
+
return value === null ? null : requireFiniteNumber(value, path);
|
|
746
|
+
}
|
|
747
|
+
function requireNonNegativeInteger(value, path) {
|
|
748
|
+
const number = requireFiniteNumber(value, path);
|
|
749
|
+
if (!Number.isInteger(number) || number < 0) throw new DiagramDocumentError("expected a non-negative integer", path);
|
|
750
|
+
return number;
|
|
751
|
+
}
|
|
752
|
+
function requireBoolean(value, path) {
|
|
753
|
+
if (typeof value !== "boolean") throw new DiagramDocumentError("expected a boolean", path);
|
|
754
|
+
return value;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// src/core/history.ts
|
|
758
|
+
var DiagramCommandHistory = class {
|
|
759
|
+
constructor(listener) {
|
|
760
|
+
this.listener = listener;
|
|
761
|
+
this.undoCommands = [];
|
|
762
|
+
this.redoCommands = [];
|
|
763
|
+
this.transactions = [];
|
|
764
|
+
this.replaying = false;
|
|
765
|
+
}
|
|
766
|
+
get state() {
|
|
767
|
+
return {
|
|
768
|
+
canUndo: this.undoCommands.length > 0,
|
|
769
|
+
canRedo: this.redoCommands.length > 0,
|
|
770
|
+
undoDepth: this.undoCommands.length,
|
|
771
|
+
redoDepth: this.redoCommands.length,
|
|
772
|
+
undoLabel: this.undoCommands[this.undoCommands.length - 1]?.label ?? null,
|
|
773
|
+
redoLabel: this.redoCommands[this.redoCommands.length - 1]?.label ?? null
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
execute(command) {
|
|
777
|
+
if (this.replaying) {
|
|
778
|
+
command.execute();
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
command.execute();
|
|
782
|
+
this.recordApplied(command);
|
|
783
|
+
}
|
|
784
|
+
/** Records a gesture that already changed the document, such as pointer drag. */
|
|
785
|
+
recordApplied(command) {
|
|
786
|
+
if (this.replaying) return;
|
|
787
|
+
const transaction = this.transactions[this.transactions.length - 1];
|
|
788
|
+
if (transaction !== void 0) {
|
|
789
|
+
transaction.commands.push(command);
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
this.undoCommands.push(command);
|
|
793
|
+
this.redoCommands.length = 0;
|
|
794
|
+
this.notify();
|
|
795
|
+
}
|
|
796
|
+
transaction(label, action) {
|
|
797
|
+
const frame = { label, commands: [] };
|
|
798
|
+
this.transactions.push(frame);
|
|
799
|
+
try {
|
|
800
|
+
const result = action();
|
|
801
|
+
this.transactions.pop();
|
|
802
|
+
const command = compose(frame);
|
|
803
|
+
if (command !== null) this.recordApplied(command);
|
|
804
|
+
return result;
|
|
805
|
+
} catch (error) {
|
|
806
|
+
this.transactions.pop();
|
|
807
|
+
this.replay(() => {
|
|
808
|
+
for (const command of frame.commands.slice().reverse()) command.undo();
|
|
809
|
+
});
|
|
810
|
+
throw error;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
assertNoOpenTransaction(operation) {
|
|
814
|
+
if (this.transactions.length > 0) {
|
|
815
|
+
throw new Error(`Cannot ${operation} command history inside a transaction.`);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
undo() {
|
|
819
|
+
this.assertNoOpenTransaction("undo");
|
|
820
|
+
const command = this.undoCommands.pop();
|
|
821
|
+
if (command === void 0) return false;
|
|
822
|
+
try {
|
|
823
|
+
this.replay(() => command.undo());
|
|
824
|
+
} catch (error) {
|
|
825
|
+
this.undoCommands.push(command);
|
|
826
|
+
throw error;
|
|
827
|
+
}
|
|
828
|
+
this.redoCommands.push(command);
|
|
829
|
+
this.notify();
|
|
830
|
+
return true;
|
|
831
|
+
}
|
|
832
|
+
redo() {
|
|
833
|
+
this.assertNoOpenTransaction("redo");
|
|
834
|
+
const command = this.redoCommands.pop();
|
|
835
|
+
if (command === void 0) return false;
|
|
836
|
+
try {
|
|
837
|
+
this.replay(() => command.execute());
|
|
838
|
+
} catch (error) {
|
|
839
|
+
this.redoCommands.push(command);
|
|
840
|
+
throw error;
|
|
841
|
+
}
|
|
842
|
+
this.undoCommands.push(command);
|
|
843
|
+
this.notify();
|
|
844
|
+
return true;
|
|
845
|
+
}
|
|
846
|
+
clear() {
|
|
847
|
+
this.assertNoOpenTransaction("clear");
|
|
848
|
+
this.undoCommands.length = 0;
|
|
849
|
+
this.redoCommands.length = 0;
|
|
850
|
+
this.notify();
|
|
851
|
+
}
|
|
852
|
+
replay(action) {
|
|
853
|
+
const previous = this.replaying;
|
|
854
|
+
this.replaying = true;
|
|
855
|
+
try {
|
|
856
|
+
action();
|
|
857
|
+
} finally {
|
|
858
|
+
this.replaying = previous;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
notify() {
|
|
862
|
+
this.listener?.(this.state);
|
|
863
|
+
}
|
|
864
|
+
};
|
|
865
|
+
function compose(frame) {
|
|
866
|
+
if (frame.commands.length === 0) return null;
|
|
867
|
+
if (frame.commands.length === 1) {
|
|
868
|
+
const command = frame.commands[0];
|
|
869
|
+
return frame.label === command.label ? command : {
|
|
870
|
+
label: frame.label,
|
|
871
|
+
execute: () => command.execute(),
|
|
872
|
+
undo: () => command.undo()
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
const commands = [...frame.commands];
|
|
876
|
+
return {
|
|
877
|
+
label: frame.label,
|
|
878
|
+
execute: () => {
|
|
879
|
+
for (const command of commands) command.execute();
|
|
880
|
+
},
|
|
881
|
+
undo: () => {
|
|
882
|
+
for (const command of commands.slice().reverse()) command.undo();
|
|
883
|
+
}
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// src/core/state.ts
|
|
888
|
+
function createDiagramRuntimeState() {
|
|
889
|
+
return { activeNodeId: null, nodes: {}, globalError: null };
|
|
890
|
+
}
|
|
891
|
+
function cloneDiagramRuntimeState(state) {
|
|
892
|
+
return {
|
|
893
|
+
activeNodeId: state.activeNodeId,
|
|
894
|
+
globalError: state.globalError === null ? null : { ...state.globalError },
|
|
895
|
+
nodes: Object.fromEntries(Object.entries(state.nodes).map(([nodeId, node]) => [nodeId, {
|
|
896
|
+
active: node.active,
|
|
897
|
+
errors: cloneDiagramNodeErrors(node.errors),
|
|
898
|
+
ports: {
|
|
899
|
+
in: Object.fromEntries(Object.entries(node.ports.in).map(([portId, port]) => [portId, { ...port }])),
|
|
900
|
+
out: Object.fromEntries(Object.entries(node.ports.out).map(([portId, port]) => [portId, { ...port }]))
|
|
901
|
+
}
|
|
902
|
+
}]))
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
function createDiagramPortRuntimeState() {
|
|
906
|
+
return {
|
|
907
|
+
active: false,
|
|
908
|
+
selected: false,
|
|
909
|
+
breakpoint: false,
|
|
910
|
+
breakpointActive: false,
|
|
911
|
+
value: null,
|
|
912
|
+
error: null
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
function createEditableDiagramPermissions() {
|
|
916
|
+
return {
|
|
917
|
+
select: true,
|
|
918
|
+
inspect: true,
|
|
919
|
+
copy: true,
|
|
920
|
+
moveNodes: true,
|
|
921
|
+
createLinks: true,
|
|
922
|
+
deleteSelection: true,
|
|
923
|
+
paste: true,
|
|
924
|
+
history: true
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
function createReadOnlyDiagramPermissions() {
|
|
928
|
+
return {
|
|
929
|
+
select: true,
|
|
930
|
+
inspect: true,
|
|
931
|
+
copy: true,
|
|
932
|
+
moveNodes: false,
|
|
933
|
+
createLinks: false,
|
|
934
|
+
deleteSelection: false,
|
|
935
|
+
paste: false,
|
|
936
|
+
history: false
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
function createDiagramNodeRuntimeState() {
|
|
940
|
+
return {
|
|
941
|
+
active: false,
|
|
942
|
+
errors: {},
|
|
943
|
+
ports: { in: {}, out: {} }
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
function cloneDiagramNodeErrors(errors) {
|
|
947
|
+
const result = {};
|
|
948
|
+
if (errors === void 0 || errors === null) return result;
|
|
949
|
+
if (errors.runtime !== void 0) result.runtime = { ...errors.runtime };
|
|
950
|
+
if (errors.load !== void 0) result.load = { ...errors.load };
|
|
951
|
+
return result;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// src/canvas-renderer.ts
|
|
955
|
+
var SCREEN_RENDER_OPTIONS = {
|
|
956
|
+
grid: true,
|
|
957
|
+
overview: true,
|
|
958
|
+
selection: true,
|
|
959
|
+
runtime: true,
|
|
960
|
+
transient: true
|
|
961
|
+
};
|
|
962
|
+
function copyJsonValue(value) {
|
|
963
|
+
if (Array.isArray(value)) return value.map(copyJsonValue);
|
|
964
|
+
if (value !== null && typeof value === "object") return copyJsonObject(value);
|
|
965
|
+
return value;
|
|
966
|
+
}
|
|
967
|
+
function copyJsonObject(value) {
|
|
968
|
+
if (value === void 0) return {};
|
|
969
|
+
const result = {};
|
|
970
|
+
for (const [key, item] of Object.entries(value)) setJsonKey(result, key, copyJsonValue(item));
|
|
971
|
+
return result;
|
|
972
|
+
}
|
|
973
|
+
function copyParameters(value) {
|
|
974
|
+
return (value ?? []).map((parameter) => ({ ...parameter, options: [...parameter.options] }));
|
|
975
|
+
}
|
|
976
|
+
function normalizePortType(type) {
|
|
977
|
+
return type.trim().toLowerCase();
|
|
978
|
+
}
|
|
979
|
+
function isWildcardPortType(type) {
|
|
980
|
+
const normalized = normalizePortType(type);
|
|
981
|
+
return normalized === "" || normalized === "*" || normalized === "any" || normalized === "anydata" || normalized === "any data" || normalized === "object" || normalized === "system.object" || normalized.startsWith("system.object,");
|
|
982
|
+
}
|
|
983
|
+
function arePortTypesCompatible(fromType, toType, availableTypes) {
|
|
984
|
+
if (isWildcardPortType(fromType) || isWildcardPortType(toType)) return true;
|
|
985
|
+
const normalizedFrom = normalizePortType(fromType);
|
|
986
|
+
return [toType, ...availableTypes].some((type) => isWildcardPortType(type) || normalizePortType(type) === normalizedFrom);
|
|
987
|
+
}
|
|
988
|
+
var PortModel = class {
|
|
989
|
+
constructor(init, dir) {
|
|
990
|
+
// layout cache (world coords), filled at draw time
|
|
991
|
+
this.cx = 0;
|
|
992
|
+
this.cy = 0;
|
|
993
|
+
this.id = init.id;
|
|
994
|
+
this.name = init.name;
|
|
995
|
+
this.description = init.description ?? "";
|
|
996
|
+
this.type = init.type ?? "";
|
|
997
|
+
this.direction = dir;
|
|
998
|
+
this.maxLinks = typeof init.maxLinks === "number" ? init.maxLinks : 0;
|
|
999
|
+
this.availableTypes = [...init.availableTypes ?? []];
|
|
1000
|
+
this.isDynamic = init.isDynamic ?? false;
|
|
1001
|
+
this.dynamicMode = toPortDynamicMode(init.dynamicMode);
|
|
1002
|
+
this.isSibling = init.isSibling ?? false;
|
|
1003
|
+
this.metadata = copyJsonObject(init.metadata);
|
|
1004
|
+
}
|
|
1005
|
+
toInit() {
|
|
1006
|
+
return {
|
|
1007
|
+
id: this.id,
|
|
1008
|
+
name: this.name,
|
|
1009
|
+
description: this.description,
|
|
1010
|
+
type: this.type,
|
|
1011
|
+
maxLinks: this.maxLinks,
|
|
1012
|
+
availableTypes: [...this.availableTypes],
|
|
1013
|
+
isDynamic: this.isDynamic,
|
|
1014
|
+
dynamicMode: this.dynamicMode,
|
|
1015
|
+
isSibling: this.isSibling,
|
|
1016
|
+
metadata: copyJsonObject(this.metadata)
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
};
|
|
1020
|
+
var NodeModel = class {
|
|
1021
|
+
constructor(init, id) {
|
|
1022
|
+
this.runtimeError = "";
|
|
1023
|
+
this.errorFlashStart = null;
|
|
1024
|
+
// layout cache
|
|
1025
|
+
this.w = 160;
|
|
1026
|
+
this.h = 48;
|
|
1027
|
+
this.id = id;
|
|
1028
|
+
this.typeId = init.typeId ?? id;
|
|
1029
|
+
this.name = init.name;
|
|
1030
|
+
this.description = init.description ?? "";
|
|
1031
|
+
this.groupName = init.groupName ?? "Common";
|
|
1032
|
+
this.color = init.color ?? "#d7d7d7";
|
|
1033
|
+
this.border = init.border ?? "#8c8c8c";
|
|
1034
|
+
this.icon = init.icon ?? "";
|
|
1035
|
+
this.openAction = init.openAction ?? "";
|
|
1036
|
+
this.message = init.message ?? "";
|
|
1037
|
+
this.parameters = copyParameters(init.parameters);
|
|
1038
|
+
this.paramValues = { ...init.paramValues ?? {} };
|
|
1039
|
+
this.metadata = copyJsonObject(init.metadata);
|
|
1040
|
+
this.isPlaceholder = init.isPlaceholder ?? false;
|
|
1041
|
+
this.loadError = init.loadError ?? "";
|
|
1042
|
+
this.x = typeof init.x === "number" ? init.x : 0;
|
|
1043
|
+
this.y = typeof init.y === "number" ? init.y : 0;
|
|
1044
|
+
this.inPorts = (init.inPorts ?? []).map((p) => new PortModel(p, "in"));
|
|
1045
|
+
this.outPorts = (init.outPorts ?? []).map((p) => new PortModel(p, "out"));
|
|
1046
|
+
}
|
|
1047
|
+
port(id) {
|
|
1048
|
+
return this.inPorts.find((p) => p.id === id) ?? this.outPorts.find((p) => p.id === id);
|
|
1049
|
+
}
|
|
1050
|
+
toInit(includeRuntimeState = true) {
|
|
1051
|
+
const init = {
|
|
1052
|
+
id: this.id,
|
|
1053
|
+
typeId: this.typeId,
|
|
1054
|
+
name: this.name,
|
|
1055
|
+
description: this.description,
|
|
1056
|
+
groupName: this.groupName,
|
|
1057
|
+
color: this.color,
|
|
1058
|
+
border: this.border,
|
|
1059
|
+
icon: this.icon,
|
|
1060
|
+
openAction: this.openAction,
|
|
1061
|
+
message: this.message,
|
|
1062
|
+
parameters: copyParameters(this.parameters),
|
|
1063
|
+
paramValues: { ...this.paramValues },
|
|
1064
|
+
metadata: copyJsonObject(this.metadata),
|
|
1065
|
+
isPlaceholder: this.isPlaceholder,
|
|
1066
|
+
x: this.x,
|
|
1067
|
+
y: this.y,
|
|
1068
|
+
inPorts: this.inPorts.map((port) => port.toInit()),
|
|
1069
|
+
outPorts: this.outPorts.map((port) => port.toInit())
|
|
1070
|
+
};
|
|
1071
|
+
if (includeRuntimeState && this.loadError.length > 0) init.loadError = this.loadError;
|
|
1072
|
+
return init;
|
|
1073
|
+
}
|
|
1074
|
+
};
|
|
1075
|
+
var LinkModel = class {
|
|
1076
|
+
constructor(from, fromPort, to, toPort, id = "", metadata, style = "solid") {
|
|
1077
|
+
this.from = from;
|
|
1078
|
+
this.fromPort = fromPort;
|
|
1079
|
+
this.to = to;
|
|
1080
|
+
this.toPort = toPort;
|
|
1081
|
+
this.style = style;
|
|
1082
|
+
this.id = id;
|
|
1083
|
+
this.metadata = copyJsonObject(metadata);
|
|
1084
|
+
}
|
|
1085
|
+
key() {
|
|
1086
|
+
return `${this.from}|${this.fromPort}|${this.to}|${this.toPort}`;
|
|
1087
|
+
}
|
|
1088
|
+
toInit() {
|
|
1089
|
+
return {
|
|
1090
|
+
id: this.id,
|
|
1091
|
+
from: this.from,
|
|
1092
|
+
fromPort: this.fromPort,
|
|
1093
|
+
to: this.to,
|
|
1094
|
+
toPort: this.toPort,
|
|
1095
|
+
style: this.style,
|
|
1096
|
+
metadata: copyJsonObject(this.metadata)
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
};
|
|
1100
|
+
var PORT_R = 6;
|
|
1101
|
+
var PORT_ROW_H = 20;
|
|
1102
|
+
var RELINK_HANDLE_PX = 8;
|
|
1103
|
+
var RELINK_DRAG_THRESHOLD_PX = 4;
|
|
1104
|
+
var PORT_SQ = 9;
|
|
1105
|
+
var LINK_STUB = 22;
|
|
1106
|
+
var LINK_HOP = 5;
|
|
1107
|
+
var LINK_CORNER = 8;
|
|
1108
|
+
var SNAP_PX = 32;
|
|
1109
|
+
var DEFAULT_GRID_SIZE = 28;
|
|
1110
|
+
var ZOOM_MIN = 0.15;
|
|
1111
|
+
var ZOOM_MAX = 4;
|
|
1112
|
+
var INTRO_MS = 520;
|
|
1113
|
+
var INTRO_RISE = 70;
|
|
1114
|
+
var ERROR_FLASH_MS = 1100;
|
|
1115
|
+
var ERROR_RED = "#f6465d";
|
|
1116
|
+
var ERROR_BACKGROUND = "#7d2632";
|
|
1117
|
+
var clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
|
1118
|
+
function hashHue(s) {
|
|
1119
|
+
let h = 0;
|
|
1120
|
+
for (let i = 0; i < s.length; i += 1) h = h * 31 + s.charCodeAt(i) | 0;
|
|
1121
|
+
return (h % 360 + 360) % 360;
|
|
1122
|
+
}
|
|
1123
|
+
function clampLightness(color, maxL) {
|
|
1124
|
+
const m = color.match(/^hsl\(\s*([\d.]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*\)$/i);
|
|
1125
|
+
if (m === null) return color;
|
|
1126
|
+
return `hsl(${m[1]}, ${m[2]}%, ${Math.min(+m[3], maxL * 100)}%)`;
|
|
1127
|
+
}
|
|
1128
|
+
function colorLuminance(color) {
|
|
1129
|
+
if (color === void 0) return 0;
|
|
1130
|
+
const hex = color.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
|
|
1131
|
+
if (hex !== null) {
|
|
1132
|
+
const raw = hex[1].length === 3 ? hex[1].split("").map((x) => x + x).join("") : hex[1];
|
|
1133
|
+
const value = parseInt(raw, 16);
|
|
1134
|
+
return 0.299 * (value >> 16 & 255) + 0.587 * (value >> 8 & 255) + 0.114 * (value & 255);
|
|
1135
|
+
}
|
|
1136
|
+
const rgb = color.match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i);
|
|
1137
|
+
if (rgb !== null)
|
|
1138
|
+
return 0.299 * +rgb[1] + 0.587 * +rgb[2] + 0.114 * +rgb[3];
|
|
1139
|
+
return 0;
|
|
1140
|
+
}
|
|
1141
|
+
var Diagram = class {
|
|
1142
|
+
constructor(opts) {
|
|
1143
|
+
this.nodes = [];
|
|
1144
|
+
this.links = [];
|
|
1145
|
+
this.zones = [];
|
|
1146
|
+
this.idSeq = 1;
|
|
1147
|
+
this.linkSeq = 1;
|
|
1148
|
+
this.documentMetadata = {};
|
|
1149
|
+
this.runtimeState = createDiagramRuntimeState();
|
|
1150
|
+
this.runtimePulse = 0;
|
|
1151
|
+
this.globalErrorFlashStart = null;
|
|
1152
|
+
// viewport transform: screen = world * scale + offset
|
|
1153
|
+
this.scale = 1;
|
|
1154
|
+
this.offX = 0;
|
|
1155
|
+
this.offY = 0;
|
|
1156
|
+
this.width = 0;
|
|
1157
|
+
this.height = 0;
|
|
1158
|
+
this.dpr = 1;
|
|
1159
|
+
this.drawScheduled = false;
|
|
1160
|
+
// interaction state
|
|
1161
|
+
this.selectedNode = null;
|
|
1162
|
+
// primary (last) selected
|
|
1163
|
+
this.selectedNodes = /* @__PURE__ */ new Set();
|
|
1164
|
+
// multi-selection
|
|
1165
|
+
this.selectedLink = null;
|
|
1166
|
+
this.selectedPort = null;
|
|
1167
|
+
this.dragNode = null;
|
|
1168
|
+
this.dragStart = [];
|
|
1169
|
+
// group-drag origin
|
|
1170
|
+
this.dragAnchor = { wx: 0, wy: 0 };
|
|
1171
|
+
this.panning = false;
|
|
1172
|
+
this.panX = 0;
|
|
1173
|
+
this.panY = 0;
|
|
1174
|
+
this.permissions = createEditableDiagramPermissions();
|
|
1175
|
+
this.showNodeMessages = true;
|
|
1176
|
+
this.iconCache = /* @__PURE__ */ new Map();
|
|
1177
|
+
// node icon images by URL (null = loading/failed)
|
|
1178
|
+
this.rubber = null;
|
|
1179
|
+
// world rect
|
|
1180
|
+
this.clip = null;
|
|
1181
|
+
this.linking = null;
|
|
1182
|
+
this.relinking = null;
|
|
1183
|
+
this.relinkCandidate = null;
|
|
1184
|
+
this.linkSnap = null;
|
|
1185
|
+
this.cursor = { x: 0, y: 0 };
|
|
1186
|
+
// screen
|
|
1187
|
+
this.hoverPort = null;
|
|
1188
|
+
this.hoverNode = null;
|
|
1189
|
+
this.hoveredLink = null;
|
|
1190
|
+
this.tipTimer = null;
|
|
1191
|
+
this.tipShow = false;
|
|
1192
|
+
this.tipTarget = null;
|
|
1193
|
+
// Long-press (touch) / right-click (desktop) → contextMenu event.
|
|
1194
|
+
this.lpTimer = null;
|
|
1195
|
+
this.lpStart = null;
|
|
1196
|
+
this.lpDelayMs = 550;
|
|
1197
|
+
this.lpMoveTol = 7;
|
|
1198
|
+
this.domDisposables = [];
|
|
1199
|
+
this.destroyed = false;
|
|
1200
|
+
// Overview minimap.
|
|
1201
|
+
this.introStart = null;
|
|
1202
|
+
this.overviewVisible = true;
|
|
1203
|
+
this.ovDragging = false;
|
|
1204
|
+
this.historyShortcut = (direction) => {
|
|
1205
|
+
if (direction === "undo") this.undo();
|
|
1206
|
+
else this.redo();
|
|
1207
|
+
};
|
|
1208
|
+
this.ovGeo = null;
|
|
1209
|
+
this.validator = null;
|
|
1210
|
+
this.handlers = /* @__PURE__ */ new Map();
|
|
1211
|
+
this.opts = opts;
|
|
1212
|
+
if (opts.typeColors !== void 0) this.setTypeColorsInternal(opts.typeColors);
|
|
1213
|
+
this.host = opts.host;
|
|
1214
|
+
this.gridSnapEnabled = opts.gridSnap ?? false;
|
|
1215
|
+
this.gridSize = this.normalizeGridSize(opts.gridSize ?? DEFAULT_GRID_SIZE);
|
|
1216
|
+
this.history = new DiagramCommandHistory(() => {
|
|
1217
|
+
this.emit("undoStackChanged", { canUndo: this.canUndo(), canRedo: this.canRedo() });
|
|
1218
|
+
});
|
|
1219
|
+
this.canvas = document.createElement("canvas");
|
|
1220
|
+
this.canvas.style.display = "block";
|
|
1221
|
+
this.canvas.tabIndex = 0;
|
|
1222
|
+
this.canvas.style.outline = "none";
|
|
1223
|
+
this.canvas.style.touchAction = "none";
|
|
1224
|
+
this.canvas.style.userSelect = "none";
|
|
1225
|
+
this.canvas.style.webkitUserSelect = "none";
|
|
1226
|
+
this.canvas.style.webkitTouchCallout = "none";
|
|
1227
|
+
this.host.appendChild(this.canvas);
|
|
1228
|
+
const ctx = this.canvas.getContext("2d");
|
|
1229
|
+
if (ctx === null) throw new Error("ssdiagram: 2d context unavailable");
|
|
1230
|
+
this.ctx = ctx;
|
|
1231
|
+
this.resize(this.host.clientWidth || 800, this.host.clientHeight || 480);
|
|
1232
|
+
this.bind();
|
|
1233
|
+
}
|
|
1234
|
+
// ---- events -----------------------------------------------------
|
|
1235
|
+
on(ev, h) {
|
|
1236
|
+
let set = this.handlers.get(ev);
|
|
1237
|
+
if (set === void 0) {
|
|
1238
|
+
set = /* @__PURE__ */ new Set();
|
|
1239
|
+
this.handlers.set(ev, set);
|
|
1240
|
+
}
|
|
1241
|
+
set.add(h);
|
|
1242
|
+
return () => set.delete(h);
|
|
1243
|
+
}
|
|
1244
|
+
emit(ev, p) {
|
|
1245
|
+
const set = this.handlers.get(ev);
|
|
1246
|
+
if (set === void 0) return;
|
|
1247
|
+
for (const h of set) {
|
|
1248
|
+
try {
|
|
1249
|
+
h(p);
|
|
1250
|
+
} catch (e) {
|
|
1251
|
+
console.error(e);
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
// ---- public API (Layer-A-shaped) --------------------------------
|
|
1256
|
+
setLinkValidator(fn) {
|
|
1257
|
+
this.validator = fn;
|
|
1258
|
+
}
|
|
1259
|
+
setGridSnap(enabled, size) {
|
|
1260
|
+
const nextSize = size === void 0 ? this.gridSize : this.normalizeGridSize(size);
|
|
1261
|
+
this.gridSnapEnabled = enabled;
|
|
1262
|
+
this.gridSize = nextSize;
|
|
1263
|
+
this.scheduleDraw();
|
|
1264
|
+
}
|
|
1265
|
+
getGridSnap() {
|
|
1266
|
+
return { enabled: this.gridSnapEnabled, size: this.gridSize };
|
|
1267
|
+
}
|
|
1268
|
+
normalizeGridSize(size) {
|
|
1269
|
+
if (!Number.isFinite(size) || size <= 0) throw new RangeError("ssdiagram: grid size must be a positive finite number");
|
|
1270
|
+
return size;
|
|
1271
|
+
}
|
|
1272
|
+
nextNodeId() {
|
|
1273
|
+
let id;
|
|
1274
|
+
do
|
|
1275
|
+
id = `n${this.idSeq++}`;
|
|
1276
|
+
while (this.nodes.some((node) => node.id === id));
|
|
1277
|
+
return id;
|
|
1278
|
+
}
|
|
1279
|
+
nextLinkId() {
|
|
1280
|
+
let id;
|
|
1281
|
+
do
|
|
1282
|
+
id = `link_${this.linkSeq++}`;
|
|
1283
|
+
while (this.links.some((link) => link.id === id));
|
|
1284
|
+
return id;
|
|
1285
|
+
}
|
|
1286
|
+
addDiagramNode(init) {
|
|
1287
|
+
const id = init.id ?? this.nextNodeId();
|
|
1288
|
+
if (id.trim().length === 0) throw new Error("ssdiagram: node id cannot be empty");
|
|
1289
|
+
if (this.nodes.some((node) => node.id === id)) throw new Error(`ssdiagram: duplicate node id "${id}"`);
|
|
1290
|
+
const fullInit = { ...init, id };
|
|
1291
|
+
this.doAddNode(fullInit);
|
|
1292
|
+
this.record({
|
|
1293
|
+
do: () => {
|
|
1294
|
+
this.doAddNode(fullInit);
|
|
1295
|
+
},
|
|
1296
|
+
undo: () => {
|
|
1297
|
+
this.doRemoveNode(id);
|
|
1298
|
+
},
|
|
1299
|
+
label: "add node"
|
|
1300
|
+
});
|
|
1301
|
+
return id;
|
|
1302
|
+
}
|
|
1303
|
+
doAddNode(init) {
|
|
1304
|
+
const id = init.id ?? this.nextNodeId();
|
|
1305
|
+
if (this.nodes.some((node2) => node2.id === id)) return;
|
|
1306
|
+
const node = new NodeModel(init, id);
|
|
1307
|
+
this.layoutNode(node);
|
|
1308
|
+
this.nodes.push(node);
|
|
1309
|
+
this.emit("nodeAdded", { node });
|
|
1310
|
+
this.scheduleDraw();
|
|
1311
|
+
}
|
|
1312
|
+
removeDiagramNode(id) {
|
|
1313
|
+
const node = this.nodes.find((n2) => n2.id === id);
|
|
1314
|
+
if (node === void 0) return;
|
|
1315
|
+
const snapshot = node.toInit(false);
|
|
1316
|
+
const cascaded = this.links.map((link, index) => ({ link, index })).filter(({ link }) => link.from === id || link.to === id).map(({ link, index }) => ({ init: link.toInit(), index }));
|
|
1317
|
+
const siblingTargets = [];
|
|
1318
|
+
for (const { init } of cascaded) {
|
|
1319
|
+
if (init.from !== id || init.to === id) continue;
|
|
1320
|
+
const targetPort = this.findNode(init.to)?.inPorts.find((port) => port.id === init.toPort);
|
|
1321
|
+
if (targetPort?.isSibling !== true) continue;
|
|
1322
|
+
if (!siblingTargets.some((target) => target.nodeId === init.to && target.portId === init.toPort)) {
|
|
1323
|
+
siblingTargets.push({ nodeId: init.to, portId: init.toPort });
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
const remove = () => {
|
|
1327
|
+
this.doRemoveNode(id);
|
|
1328
|
+
this.record({
|
|
1329
|
+
do: () => {
|
|
1330
|
+
this.doRemoveNode(id);
|
|
1331
|
+
},
|
|
1332
|
+
undo: () => {
|
|
1333
|
+
this.doAddNode(snapshot);
|
|
1334
|
+
for (const { init, index } of cascaded) this.doAddLink(init, false, index);
|
|
1335
|
+
},
|
|
1336
|
+
label: "remove node"
|
|
1337
|
+
});
|
|
1338
|
+
};
|
|
1339
|
+
if (siblingTargets.length === 0) {
|
|
1340
|
+
remove();
|
|
1341
|
+
} else {
|
|
1342
|
+
this.withTransaction("remove node", () => {
|
|
1343
|
+
remove();
|
|
1344
|
+
for (const target of siblingTargets) this.pruneDynamicSibling(target.nodeId, target.portId);
|
|
1345
|
+
});
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
doRemoveNode(id) {
|
|
1349
|
+
const node = this.nodes.find((n2) => n2.id === id);
|
|
1350
|
+
if (node === void 0) return;
|
|
1351
|
+
const removedLinks = this.links.filter((l) => l.from === id || l.to === id);
|
|
1352
|
+
this.links = this.links.filter((l) => l.from !== id && l.to !== id);
|
|
1353
|
+
this.nodes = this.nodes.filter((n2) => n2 !== node);
|
|
1354
|
+
let selectionChanged = this.selectedNodes.delete(node);
|
|
1355
|
+
if (this.selectedNode === node) {
|
|
1356
|
+
const remaining = [...this.selectedNodes];
|
|
1357
|
+
this.selectedNode = remaining[remaining.length - 1] ?? null;
|
|
1358
|
+
selectionChanged = true;
|
|
1359
|
+
}
|
|
1360
|
+
if (this.selectedPort?.node === node) {
|
|
1361
|
+
this.selectedPort = null;
|
|
1362
|
+
selectionChanged = true;
|
|
1363
|
+
}
|
|
1364
|
+
if (this.selectedLink !== null && removedLinks.includes(this.selectedLink)) {
|
|
1365
|
+
this.selectedLink = null;
|
|
1366
|
+
selectionChanged = true;
|
|
1367
|
+
}
|
|
1368
|
+
if (selectionChanged) this.emitSelectionChanged();
|
|
1369
|
+
for (const l of removedLinks) this.emit("linkRemoved", { link: l });
|
|
1370
|
+
this.emit("nodeRemoved", { node });
|
|
1371
|
+
this.scheduleDraw();
|
|
1372
|
+
}
|
|
1373
|
+
moveNode(id, x, y) {
|
|
1374
|
+
const node = this.nodes.find((n2) => n2.id === id);
|
|
1375
|
+
if (node === void 0) return;
|
|
1376
|
+
const fromX = node.x, fromY = node.y;
|
|
1377
|
+
this.doMoveNode(id, x, y);
|
|
1378
|
+
if (fromX !== x || fromY !== y) {
|
|
1379
|
+
this.record({
|
|
1380
|
+
do: () => {
|
|
1381
|
+
this.doMoveNode(id, x, y);
|
|
1382
|
+
},
|
|
1383
|
+
undo: () => {
|
|
1384
|
+
this.doMoveNode(id, fromX, fromY);
|
|
1385
|
+
},
|
|
1386
|
+
label: "move node"
|
|
1387
|
+
});
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
nudgeSelection(dx, dy) {
|
|
1391
|
+
if (!Number.isFinite(dx) || !Number.isFinite(dy)) throw new RangeError("ssdiagram: nudge delta must be finite");
|
|
1392
|
+
if (dx === 0 && dy === 0 || this.selectedNodes.size === 0) return false;
|
|
1393
|
+
const moves = [...this.selectedNodes].map((node) => ({
|
|
1394
|
+
id: node.id,
|
|
1395
|
+
fromX: node.x,
|
|
1396
|
+
fromY: node.y,
|
|
1397
|
+
toX: node.x + dx,
|
|
1398
|
+
toY: node.y + dy
|
|
1399
|
+
}));
|
|
1400
|
+
for (const move of moves) this.doMoveNode(move.id, move.toX, move.toY);
|
|
1401
|
+
this.record({
|
|
1402
|
+
do: () => {
|
|
1403
|
+
for (const move of moves) this.doMoveNode(move.id, move.toX, move.toY);
|
|
1404
|
+
},
|
|
1405
|
+
undo: () => {
|
|
1406
|
+
for (const move of moves) this.doMoveNode(move.id, move.fromX, move.fromY);
|
|
1407
|
+
},
|
|
1408
|
+
label: "nudge"
|
|
1409
|
+
});
|
|
1410
|
+
return true;
|
|
1411
|
+
}
|
|
1412
|
+
doMoveNode(id, x, y) {
|
|
1413
|
+
const node = this.nodes.find((n2) => n2.id === id);
|
|
1414
|
+
if (node === void 0) return;
|
|
1415
|
+
node.x = x;
|
|
1416
|
+
node.y = y;
|
|
1417
|
+
this.layoutNode(node);
|
|
1418
|
+
this.emit("nodeMoved", { node });
|
|
1419
|
+
this.scheduleDraw();
|
|
1420
|
+
}
|
|
1421
|
+
addLink(init) {
|
|
1422
|
+
const fullInit = {
|
|
1423
|
+
...init,
|
|
1424
|
+
id: init.id ?? this.nextLinkId(),
|
|
1425
|
+
metadata: copyJsonObject(init.metadata)
|
|
1426
|
+
};
|
|
1427
|
+
if (fullInit.id !== void 0 && this.links.some((link) => link.id === fullInit.id)) return false;
|
|
1428
|
+
const fromNode = this.nodes.find((node) => node.id === fullInit.from);
|
|
1429
|
+
const toNode = this.nodes.find((node) => node.id === fullInit.to);
|
|
1430
|
+
const fromPort = fromNode?.outPorts.find((port) => port.id === fullInit.fromPort);
|
|
1431
|
+
const toPort = toNode?.inPorts.find((port) => port.id === fullInit.toPort);
|
|
1432
|
+
if (fromNode !== void 0 && toNode !== void 0 && fromPort !== void 0 && toPort !== void 0 && toPort.isDynamic && toPort.dynamicMode === "onConnect" && !toPort.isSibling) {
|
|
1433
|
+
const validation = this.validateLinkModels(fromNode, fromPort, toNode, toPort);
|
|
1434
|
+
this.emit("linkValidation", { fromNode, from: fromPort, toNode, to: toPort, ...validation });
|
|
1435
|
+
if (!validation.allowed) return false;
|
|
1436
|
+
const sibling = this.createDynamicSibling(toNode, toPort, fromPort);
|
|
1437
|
+
let added = false;
|
|
1438
|
+
const failed = new Error("ssdiagram: dynamic link transaction failed");
|
|
1439
|
+
try {
|
|
1440
|
+
this.withTransaction("add dynamic link", () => {
|
|
1441
|
+
if (!this.addPort(toNode.id, "in", sibling)) throw failed;
|
|
1442
|
+
added = this.addLinkApplied({ ...fullInit, toPort: sibling.id }, false);
|
|
1443
|
+
if (!added) throw failed;
|
|
1444
|
+
});
|
|
1445
|
+
} catch (error) {
|
|
1446
|
+
if (error === failed) return false;
|
|
1447
|
+
throw error;
|
|
1448
|
+
}
|
|
1449
|
+
return added;
|
|
1450
|
+
}
|
|
1451
|
+
return this.addLinkApplied(fullInit, true);
|
|
1452
|
+
}
|
|
1453
|
+
addLinkApplied(init, validate) {
|
|
1454
|
+
const ok = this.doAddLink(init, validate);
|
|
1455
|
+
if (ok) {
|
|
1456
|
+
this.record({
|
|
1457
|
+
do: () => {
|
|
1458
|
+
this.doAddLink(init, false);
|
|
1459
|
+
},
|
|
1460
|
+
undo: () => {
|
|
1461
|
+
this.doRemoveLink(init);
|
|
1462
|
+
},
|
|
1463
|
+
label: "add link"
|
|
1464
|
+
});
|
|
1465
|
+
}
|
|
1466
|
+
return ok;
|
|
1467
|
+
}
|
|
1468
|
+
doAddLink(init, validate = true, index = this.links.length) {
|
|
1469
|
+
const fn = this.nodes.find((n2) => n2.id === init.from);
|
|
1470
|
+
const tn = this.nodes.find((n2) => n2.id === init.to);
|
|
1471
|
+
if (fn === void 0 || tn === void 0) return false;
|
|
1472
|
+
const fp = fn.outPorts.find((p) => p.id === init.fromPort);
|
|
1473
|
+
const tp = tn.inPorts.find((p) => p.id === init.toPort);
|
|
1474
|
+
if (fp === void 0 || tp === void 0) return false;
|
|
1475
|
+
if (validate) {
|
|
1476
|
+
const validation = this.validateLinkModels(fn, fp, tn, tp);
|
|
1477
|
+
this.emit("linkValidation", { fromNode: fn, from: fp, toNode: tn, to: tp, ...validation });
|
|
1478
|
+
if (!validation.allowed) return false;
|
|
1479
|
+
}
|
|
1480
|
+
const link = new LinkModel(init.from, init.fromPort, init.to, init.toPort, init.id ?? this.nextLinkId(), init.metadata, init.style ?? "solid");
|
|
1481
|
+
if (this.links.some((l) => l.id === link.id)) return false;
|
|
1482
|
+
this.links.splice(clamp(Math.trunc(index), 0, this.links.length), 0, link);
|
|
1483
|
+
this.emit("linkAdded", { link });
|
|
1484
|
+
this.scheduleDraw();
|
|
1485
|
+
return true;
|
|
1486
|
+
}
|
|
1487
|
+
removeLink(link) {
|
|
1488
|
+
this.removeLinkInternal(link, true);
|
|
1489
|
+
}
|
|
1490
|
+
removeLinkInternal(link, pruneDynamicSibling) {
|
|
1491
|
+
const key = new LinkModel(link.from, link.fromPort, link.to, link.toPort).key();
|
|
1492
|
+
const found = link.id === void 0 || link.id.length === 0 ? this.links.find((candidate) => candidate.key() === key) : this.links.find((candidate) => candidate.id === link.id);
|
|
1493
|
+
if (found === void 0) return;
|
|
1494
|
+
const init = found.toInit();
|
|
1495
|
+
const index = this.links.indexOf(found);
|
|
1496
|
+
const targetPort = this.findNode(found.to)?.inPorts.find((port) => port.id === found.toPort);
|
|
1497
|
+
const remove = () => {
|
|
1498
|
+
this.doRemoveLink(init);
|
|
1499
|
+
this.record({
|
|
1500
|
+
do: () => {
|
|
1501
|
+
this.doRemoveLink(init);
|
|
1502
|
+
},
|
|
1503
|
+
undo: () => {
|
|
1504
|
+
this.doAddLink(init, false, index);
|
|
1505
|
+
},
|
|
1506
|
+
label: "remove link"
|
|
1507
|
+
});
|
|
1508
|
+
};
|
|
1509
|
+
if (pruneDynamicSibling && targetPort?.isSibling === true) {
|
|
1510
|
+
this.withTransaction("remove dynamic link", () => {
|
|
1511
|
+
remove();
|
|
1512
|
+
this.pruneDynamicSibling(init.to, init.toPort);
|
|
1513
|
+
});
|
|
1514
|
+
} else {
|
|
1515
|
+
remove();
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
doRemoveLink(link) {
|
|
1519
|
+
const k = new LinkModel(link.from, link.fromPort, link.to, link.toPort).key();
|
|
1520
|
+
const found = this.links.find((l) => l.key() === k);
|
|
1521
|
+
if (found === void 0) return;
|
|
1522
|
+
this.links = this.links.filter((l) => l !== found);
|
|
1523
|
+
if (this.selectedLink === found) this.selectLink(null);
|
|
1524
|
+
this.emit("linkRemoved", { link: found });
|
|
1525
|
+
this.scheduleDraw();
|
|
1526
|
+
}
|
|
1527
|
+
createDynamicSibling(toNode, anchor, source) {
|
|
1528
|
+
let sequence = 1;
|
|
1529
|
+
while (toNode.inPorts.some((port) => port.id === `${anchor.id}_${sequence}`)) sequence += 1;
|
|
1530
|
+
return {
|
|
1531
|
+
id: `${anchor.id}_${sequence}`,
|
|
1532
|
+
name: `${anchor.name} ${sequence}`,
|
|
1533
|
+
description: anchor.description,
|
|
1534
|
+
type: source.type || anchor.type,
|
|
1535
|
+
maxLinks: 1,
|
|
1536
|
+
availableTypes: [...anchor.availableTypes],
|
|
1537
|
+
isDynamic: false,
|
|
1538
|
+
dynamicMode: "",
|
|
1539
|
+
isSibling: true,
|
|
1540
|
+
metadata: copyJsonObject(anchor.metadata)
|
|
1541
|
+
};
|
|
1542
|
+
}
|
|
1543
|
+
pruneDynamicSibling(nodeId, portId) {
|
|
1544
|
+
const node = this.findNode(nodeId);
|
|
1545
|
+
const port = node?.inPorts.find((candidate) => candidate.id === portId);
|
|
1546
|
+
if (port?.isSibling !== true) return;
|
|
1547
|
+
if (this.links.some((link) => link.to === nodeId && link.toPort === portId)) return;
|
|
1548
|
+
this.removePort(nodeId, "in", portId);
|
|
1549
|
+
}
|
|
1550
|
+
// ---- undo / redo public surface ---------------------------------
|
|
1551
|
+
canUndo() {
|
|
1552
|
+
return this.permissions.history && this.history.state.canUndo;
|
|
1553
|
+
}
|
|
1554
|
+
canRedo() {
|
|
1555
|
+
return this.permissions.history && this.history.state.canRedo;
|
|
1556
|
+
}
|
|
1557
|
+
undo() {
|
|
1558
|
+
if (this.permissions.history) this.history.undo();
|
|
1559
|
+
}
|
|
1560
|
+
redo() {
|
|
1561
|
+
if (this.permissions.history) this.history.redo();
|
|
1562
|
+
}
|
|
1563
|
+
cutSelection() {
|
|
1564
|
+
if (!this.permissions.copy || !this.permissions.deleteSelection) return;
|
|
1565
|
+
this.copySelection();
|
|
1566
|
+
this.withTransaction("cut", () => {
|
|
1567
|
+
this.deleteSelection();
|
|
1568
|
+
});
|
|
1569
|
+
}
|
|
1570
|
+
// Group multiple mutations into one undo step (paste of N nodes,
|
|
1571
|
+
// bulk delete, multi-drag, etc.). Re-entrant: nested calls flatten
|
|
1572
|
+
// into the outer batch.
|
|
1573
|
+
withTransaction(label, fn) {
|
|
1574
|
+
return this.history.transaction(label, fn);
|
|
1575
|
+
}
|
|
1576
|
+
record(action) {
|
|
1577
|
+
this.history.recordApplied({
|
|
1578
|
+
label: action.label,
|
|
1579
|
+
execute: action.do,
|
|
1580
|
+
undo: action.undo
|
|
1581
|
+
});
|
|
1582
|
+
}
|
|
1583
|
+
deleteSelection() {
|
|
1584
|
+
if (!this.permissions.deleteSelection) return;
|
|
1585
|
+
if (this.selectedNodes.size > 0) {
|
|
1586
|
+
this.withTransaction("delete selection", () => {
|
|
1587
|
+
for (const id of [...this.selectedNodes].map((n2) => n2.id)) this.removeDiagramNode(id);
|
|
1588
|
+
});
|
|
1589
|
+
this.selectedNodes.clear();
|
|
1590
|
+
this.selectedNode = null;
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
if (this.selectedLink !== null) {
|
|
1594
|
+
this.removeLink(this.selectedLink);
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
clear() {
|
|
1598
|
+
this.nodes = [];
|
|
1599
|
+
this.links = [];
|
|
1600
|
+
this.zones = [];
|
|
1601
|
+
const hadSelection = this.selectedNode !== null || this.selectedNodes.size > 0 || this.selectedLink !== null || this.selectedPort !== null;
|
|
1602
|
+
this.selectedNode = null;
|
|
1603
|
+
this.selectedNodes.clear();
|
|
1604
|
+
this.selectedLink = null;
|
|
1605
|
+
this.selectedPort = null;
|
|
1606
|
+
this.documentMetadata = {};
|
|
1607
|
+
this.runtimeState = createDiagramRuntimeState();
|
|
1608
|
+
this.runtimePulse = 0;
|
|
1609
|
+
this.globalErrorFlashStart = null;
|
|
1610
|
+
this.history.clear();
|
|
1611
|
+
if (hadSelection) this.emitSelectionChanged();
|
|
1612
|
+
this.emitRuntimeStateChanged();
|
|
1613
|
+
}
|
|
1614
|
+
relink(linkId, next) {
|
|
1615
|
+
const link = this.links.find((candidate) => candidate.id === linkId);
|
|
1616
|
+
if (link === void 0) return { allowed: false, reason: "missing-link" };
|
|
1617
|
+
const validation = this.validateLink(next, linkId);
|
|
1618
|
+
const fromNode = this.nodes.find((node) => node.id === next.from);
|
|
1619
|
+
const toNode = this.nodes.find((node) => node.id === next.to);
|
|
1620
|
+
const fromPort = fromNode?.outPorts.find((port) => port.id === next.fromPort);
|
|
1621
|
+
const toPort = toNode?.inPorts.find((port) => port.id === next.toPort);
|
|
1622
|
+
if (fromNode !== void 0 && toNode !== void 0 && fromPort !== void 0 && toPort !== void 0) {
|
|
1623
|
+
this.emit("linkValidation", { fromNode, from: fromPort, toNode, to: toPort, ...validation });
|
|
1624
|
+
}
|
|
1625
|
+
if (!validation.allowed) return validation;
|
|
1626
|
+
const before = link.toInit();
|
|
1627
|
+
if (before.from === next.from && before.fromPort === next.fromPort && before.to === next.to && before.toPort === next.toPort) return validation;
|
|
1628
|
+
const dynamicSibling = fromPort !== void 0 && toNode !== void 0 && toPort !== void 0 && toPort.isDynamic && toPort.dynamicMode === "onConnect" && !toPort.isSibling ? this.createDynamicSibling(toNode, toPort, fromPort) : null;
|
|
1629
|
+
const after = {
|
|
1630
|
+
...before,
|
|
1631
|
+
...next,
|
|
1632
|
+
toPort: dynamicSibling?.id ?? next.toPort
|
|
1633
|
+
};
|
|
1634
|
+
const oldTargetPort = this.findNode(before.to)?.inPorts.find((port) => port.id === before.toPort);
|
|
1635
|
+
const apply = () => {
|
|
1636
|
+
this.doRelink(linkId, after);
|
|
1637
|
+
this.record({
|
|
1638
|
+
do: () => {
|
|
1639
|
+
this.doRelink(linkId, after);
|
|
1640
|
+
},
|
|
1641
|
+
undo: () => {
|
|
1642
|
+
this.doRelink(linkId, before);
|
|
1643
|
+
},
|
|
1644
|
+
label: "relink"
|
|
1645
|
+
});
|
|
1646
|
+
};
|
|
1647
|
+
const pruneOldSibling = oldTargetPort?.isSibling === true && (before.to !== after.to || before.toPort !== after.toPort);
|
|
1648
|
+
if (dynamicSibling !== null || pruneOldSibling) {
|
|
1649
|
+
this.withTransaction("relink dynamic link", () => {
|
|
1650
|
+
if (dynamicSibling !== null && !this.addPort(after.to, "in", dynamicSibling)) {
|
|
1651
|
+
throw new Error("ssdiagram: could not create a dynamic sibling port");
|
|
1652
|
+
}
|
|
1653
|
+
apply();
|
|
1654
|
+
if (pruneOldSibling) this.pruneDynamicSibling(before.to, before.toPort);
|
|
1655
|
+
});
|
|
1656
|
+
} else {
|
|
1657
|
+
apply();
|
|
1658
|
+
}
|
|
1659
|
+
return validation;
|
|
1660
|
+
}
|
|
1661
|
+
doRelink(linkId, next) {
|
|
1662
|
+
const link = this.links.find((candidate) => candidate.id === linkId);
|
|
1663
|
+
if (link === void 0) return;
|
|
1664
|
+
const previous = link.toInit();
|
|
1665
|
+
link.from = next.from;
|
|
1666
|
+
link.fromPort = next.fromPort;
|
|
1667
|
+
link.to = next.to;
|
|
1668
|
+
link.toPort = next.toPort;
|
|
1669
|
+
this.emit("linkRelinked", { link, previous });
|
|
1670
|
+
this.scheduleDraw();
|
|
1671
|
+
}
|
|
1672
|
+
addPort(nodeId, direction, init) {
|
|
1673
|
+
const node = this.findNode(nodeId);
|
|
1674
|
+
if (node === void 0) return false;
|
|
1675
|
+
const ports = direction === "in" ? node.inPorts : node.outPorts;
|
|
1676
|
+
if (ports.some((port) => port.id === init.id)) return false;
|
|
1677
|
+
return this.updateNodeState(nodeId, "add port", (target) => {
|
|
1678
|
+
const list = direction === "in" ? target.inPorts : target.outPorts;
|
|
1679
|
+
list.push(new PortModel(init, direction));
|
|
1680
|
+
});
|
|
1681
|
+
}
|
|
1682
|
+
removePort(nodeId, direction, portId) {
|
|
1683
|
+
const node = this.findNode(nodeId);
|
|
1684
|
+
if (node === void 0) return false;
|
|
1685
|
+
const ports = direction === "in" ? node.inPorts : node.outPorts;
|
|
1686
|
+
if (!ports.some((port) => port.id === portId)) return false;
|
|
1687
|
+
let changed = false;
|
|
1688
|
+
this.withTransaction("remove port", () => {
|
|
1689
|
+
const affected = this.links.filter((link) => direction === "in" ? link.to === nodeId && link.toPort === portId : link.from === nodeId && link.fromPort === portId);
|
|
1690
|
+
for (const link of affected) this.removeLinkInternal(link, false);
|
|
1691
|
+
changed = this.updateNodeState(nodeId, "remove port", (target) => {
|
|
1692
|
+
if (direction === "in") target.inPorts = target.inPorts.filter((port) => port.id !== portId);
|
|
1693
|
+
else target.outPorts = target.outPorts.filter((port) => port.id !== portId);
|
|
1694
|
+
});
|
|
1695
|
+
});
|
|
1696
|
+
return changed;
|
|
1697
|
+
}
|
|
1698
|
+
updatePortType(nodeId, direction, portId, type) {
|
|
1699
|
+
return this.updatePort(nodeId, direction, portId, { type });
|
|
1700
|
+
}
|
|
1701
|
+
updatePort(nodeId, direction, portId, patch) {
|
|
1702
|
+
const node = this.findNode(nodeId);
|
|
1703
|
+
const port = direction === "in" ? node?.inPorts.find((candidate) => candidate.id === portId) : node?.outPorts.find((candidate) => candidate.id === portId);
|
|
1704
|
+
if (port === void 0) return false;
|
|
1705
|
+
if (patch.maxLinks !== void 0 && (!Number.isInteger(patch.maxLinks) || patch.maxLinks < 0)) {
|
|
1706
|
+
throw new RangeError("ssdiagram: port maxLinks must be a non-negative integer");
|
|
1707
|
+
}
|
|
1708
|
+
const compatibilityChanged = patch.type !== void 0 && patch.type !== port.type || patch.availableTypes !== void 0 && JSON.stringify(patch.availableTypes) !== JSON.stringify(port.availableTypes);
|
|
1709
|
+
let changed = false;
|
|
1710
|
+
this.withTransaction("update port", () => {
|
|
1711
|
+
changed = this.updateNodeState(nodeId, "update port", (target) => {
|
|
1712
|
+
const targetPort = direction === "in" ? target.inPorts.find((candidate) => candidate.id === portId) : target.outPorts.find((candidate) => candidate.id === portId);
|
|
1713
|
+
if (targetPort === void 0) return;
|
|
1714
|
+
if (patch.name !== void 0) targetPort.name = patch.name;
|
|
1715
|
+
if (patch.description !== void 0) targetPort.description = patch.description;
|
|
1716
|
+
if (patch.type !== void 0) targetPort.type = patch.type;
|
|
1717
|
+
if (patch.maxLinks !== void 0) targetPort.maxLinks = patch.maxLinks;
|
|
1718
|
+
if (patch.availableTypes !== void 0) targetPort.availableTypes = [...patch.availableTypes];
|
|
1719
|
+
if (patch.isDynamic !== void 0) targetPort.isDynamic = patch.isDynamic;
|
|
1720
|
+
if (patch.dynamicMode !== void 0) targetPort.dynamicMode = patch.dynamicMode;
|
|
1721
|
+
if (patch.isSibling !== void 0) targetPort.isSibling = patch.isSibling;
|
|
1722
|
+
if (patch.metadata !== void 0) targetPort.metadata = copyJsonObject(patch.metadata);
|
|
1723
|
+
});
|
|
1724
|
+
if (changed && compatibilityChanged) {
|
|
1725
|
+
this.removeIncompatibleLinks((link) => direction === "in" ? link.to === nodeId && link.toPort === portId : link.from === nodeId && link.fromPort === portId);
|
|
1726
|
+
}
|
|
1727
|
+
});
|
|
1728
|
+
return changed;
|
|
1729
|
+
}
|
|
1730
|
+
setNodePorts(nodeId, inPorts, outPorts) {
|
|
1731
|
+
const node = this.findNode(nodeId);
|
|
1732
|
+
if (node === void 0) return false;
|
|
1733
|
+
const inputIds = new Set(inPorts.map((port) => port.id));
|
|
1734
|
+
const outputIds = new Set(outPorts.map((port) => port.id));
|
|
1735
|
+
let changed = false;
|
|
1736
|
+
this.withTransaction("set node ports", () => {
|
|
1737
|
+
const invalid = this.links.filter((link) => link.to === nodeId && !inputIds.has(link.toPort) || link.from === nodeId && !outputIds.has(link.fromPort));
|
|
1738
|
+
for (const link of invalid) this.removeLinkInternal(link, false);
|
|
1739
|
+
changed = this.updateNodeState(nodeId, "set node ports", (target) => {
|
|
1740
|
+
target.inPorts = inPorts.map((port) => new PortModel(port, "in"));
|
|
1741
|
+
target.outPorts = outPorts.map((port) => new PortModel(port, "out"));
|
|
1742
|
+
});
|
|
1743
|
+
if (changed) {
|
|
1744
|
+
this.removeIncompatibleLinks((link) => link.from === nodeId || link.to === nodeId);
|
|
1745
|
+
}
|
|
1746
|
+
});
|
|
1747
|
+
return changed;
|
|
1748
|
+
}
|
|
1749
|
+
updateNode(nodeId, patch) {
|
|
1750
|
+
return this.updateNodeState(nodeId, "update node", (node) => {
|
|
1751
|
+
if (patch.name !== void 0) node.name = patch.name;
|
|
1752
|
+
if (patch.description !== void 0) node.description = patch.description;
|
|
1753
|
+
if (patch.color !== void 0) node.color = patch.color;
|
|
1754
|
+
if (patch.border !== void 0) node.border = patch.border;
|
|
1755
|
+
if (patch.message !== void 0) node.message = patch.message;
|
|
1756
|
+
if (patch.openAction !== void 0) node.openAction = patch.openAction;
|
|
1757
|
+
});
|
|
1758
|
+
}
|
|
1759
|
+
setNodeParamValue(nodeId, name, value) {
|
|
1760
|
+
return this.updateNodeState(nodeId, "set node parameter", (node) => {
|
|
1761
|
+
if (value === void 0) delete node.paramValues[name];
|
|
1762
|
+
else setJsonKey(node.paramValues, name, value);
|
|
1763
|
+
});
|
|
1764
|
+
}
|
|
1765
|
+
setShowNodeMessages(show) {
|
|
1766
|
+
this.showNodeMessages = show;
|
|
1767
|
+
this.scheduleDraw();
|
|
1768
|
+
}
|
|
1769
|
+
updateNodeState(nodeId, label, mutate) {
|
|
1770
|
+
const node = this.findNode(nodeId);
|
|
1771
|
+
if (node === void 0) return false;
|
|
1772
|
+
const before = node.toInit(false);
|
|
1773
|
+
mutate(node);
|
|
1774
|
+
if (this.reconcileSelectedPort(node)) this.emitSelectionChanged();
|
|
1775
|
+
const after = node.toInit(false);
|
|
1776
|
+
if (JSON.stringify(before) === JSON.stringify(after)) return false;
|
|
1777
|
+
this.layoutNode(node);
|
|
1778
|
+
this.emit("nodeChanged", { node });
|
|
1779
|
+
this.scheduleDraw();
|
|
1780
|
+
this.record({
|
|
1781
|
+
do: () => {
|
|
1782
|
+
this.applyNodeSnapshot(nodeId, after);
|
|
1783
|
+
},
|
|
1784
|
+
undo: () => {
|
|
1785
|
+
this.applyNodeSnapshot(nodeId, before);
|
|
1786
|
+
},
|
|
1787
|
+
label
|
|
1788
|
+
});
|
|
1789
|
+
return true;
|
|
1790
|
+
}
|
|
1791
|
+
applyNodeSnapshot(nodeId, snapshot) {
|
|
1792
|
+
const node = this.findNode(nodeId);
|
|
1793
|
+
if (node === void 0) return;
|
|
1794
|
+
const restored = new NodeModel(snapshot, nodeId);
|
|
1795
|
+
node.typeId = restored.typeId;
|
|
1796
|
+
node.name = restored.name;
|
|
1797
|
+
node.description = restored.description;
|
|
1798
|
+
node.groupName = restored.groupName;
|
|
1799
|
+
node.color = restored.color;
|
|
1800
|
+
node.border = restored.border;
|
|
1801
|
+
node.icon = restored.icon;
|
|
1802
|
+
node.openAction = restored.openAction;
|
|
1803
|
+
node.message = restored.message;
|
|
1804
|
+
node.parameters = restored.parameters;
|
|
1805
|
+
node.paramValues = restored.paramValues;
|
|
1806
|
+
node.metadata = restored.metadata;
|
|
1807
|
+
node.isPlaceholder = restored.isPlaceholder;
|
|
1808
|
+
node.x = restored.x;
|
|
1809
|
+
node.y = restored.y;
|
|
1810
|
+
node.inPorts = restored.inPorts;
|
|
1811
|
+
node.outPorts = restored.outPorts;
|
|
1812
|
+
if (this.reconcileSelectedPort(node)) this.emitSelectionChanged();
|
|
1813
|
+
this.layoutNode(node);
|
|
1814
|
+
this.emit("nodeChanged", { node });
|
|
1815
|
+
this.scheduleDraw();
|
|
1816
|
+
}
|
|
1817
|
+
reconcileSelectedPort(node) {
|
|
1818
|
+
if (this.selectedPort?.node !== node) return false;
|
|
1819
|
+
const selected = this.selectedPort.port;
|
|
1820
|
+
const ports = selected.direction === "in" ? node.inPorts : node.outPorts;
|
|
1821
|
+
const replacement = ports.find((port) => port.id === selected.id);
|
|
1822
|
+
if (replacement === selected) return false;
|
|
1823
|
+
this.selectedPort = replacement === void 0 ? null : { node, port: replacement };
|
|
1824
|
+
return true;
|
|
1825
|
+
}
|
|
1826
|
+
load(nodes, links) {
|
|
1827
|
+
this.clear();
|
|
1828
|
+
this.idSeq = 1;
|
|
1829
|
+
this.linkSeq = 1;
|
|
1830
|
+
const seenNodeIds = /* @__PURE__ */ new Set();
|
|
1831
|
+
for (const node of nodes) {
|
|
1832
|
+
const id = node.id ?? "";
|
|
1833
|
+
if (id === "") continue;
|
|
1834
|
+
if (seenNodeIds.has(id)) throw new Error(`ssdiagram: duplicate node id "${id}"`);
|
|
1835
|
+
seenNodeIds.add(id);
|
|
1836
|
+
}
|
|
1837
|
+
const seenLinkIds = /* @__PURE__ */ new Set();
|
|
1838
|
+
for (const link of links) {
|
|
1839
|
+
const id = link.id ?? "";
|
|
1840
|
+
if (id === "") continue;
|
|
1841
|
+
if (seenLinkIds.has(id)) throw new Error(`ssdiagram: duplicate link id "${id}"`);
|
|
1842
|
+
seenLinkIds.add(id);
|
|
1843
|
+
}
|
|
1844
|
+
for (const node of nodes) {
|
|
1845
|
+
const id = node.id ?? this.nextNodeId();
|
|
1846
|
+
this.doAddNode({ ...node, id });
|
|
1847
|
+
}
|
|
1848
|
+
for (const link of links) {
|
|
1849
|
+
const id = link.id ?? this.nextLinkId();
|
|
1850
|
+
if (this.links.some((existing) => existing.id === id)) {
|
|
1851
|
+
throw new Error(`ssdiagram: duplicate link id "${id}"`);
|
|
1852
|
+
}
|
|
1853
|
+
const model = new LinkModel(link.from, link.fromPort, link.to, link.toPort, id, link.metadata, link.style ?? "solid");
|
|
1854
|
+
if (!this.links.some((existing) => existing.key() === model.key())) this.links.push(model);
|
|
1855
|
+
}
|
|
1856
|
+
for (const node of this.nodes) {
|
|
1857
|
+
if (node.loadError.length === 0) continue;
|
|
1858
|
+
const state = createDiagramNodeRuntimeState();
|
|
1859
|
+
state.errors.load = { kind: "load", message: node.loadError, pulse: ++this.runtimePulse };
|
|
1860
|
+
setJsonKey(this.runtimeState.nodes, node.id, state);
|
|
1861
|
+
}
|
|
1862
|
+
if (Object.keys(this.runtimeState.nodes).length > 0) this.emitRuntimeStateChanged();
|
|
1863
|
+
this.history.clear();
|
|
1864
|
+
this.emit("loadFinished", { nodes: this.nodes.slice(), links: this.links.slice() });
|
|
1865
|
+
this.zoomToFit();
|
|
1866
|
+
this.playIntro();
|
|
1867
|
+
}
|
|
1868
|
+
save() {
|
|
1869
|
+
return {
|
|
1870
|
+
nodes: this.nodes.map((node) => node.toInit(false)),
|
|
1871
|
+
links: this.links.map((l) => ({
|
|
1872
|
+
id: l.id,
|
|
1873
|
+
from: l.from,
|
|
1874
|
+
fromPort: l.fromPort,
|
|
1875
|
+
to: l.to,
|
|
1876
|
+
toPort: l.toPort,
|
|
1877
|
+
style: l.style,
|
|
1878
|
+
metadata: copyJsonObject(l.metadata)
|
|
1879
|
+
}))
|
|
1880
|
+
};
|
|
1881
|
+
}
|
|
1882
|
+
loadDocument(source) {
|
|
1883
|
+
const document2 = parseDiagramDocument(source);
|
|
1884
|
+
this.load(
|
|
1885
|
+
document2.nodes,
|
|
1886
|
+
document2.links.map((link) => ({
|
|
1887
|
+
id: link.id,
|
|
1888
|
+
from: link.from.nodeId,
|
|
1889
|
+
fromPort: link.from.portId,
|
|
1890
|
+
to: link.to.nodeId,
|
|
1891
|
+
toPort: link.to.portId,
|
|
1892
|
+
style: link.style,
|
|
1893
|
+
metadata: link.metadata
|
|
1894
|
+
}))
|
|
1895
|
+
);
|
|
1896
|
+
this.zones = document2.zones.map((zone) => ({ ...zone, metadata: copyJsonObject(zone.metadata) }));
|
|
1897
|
+
this.documentMetadata = copyJsonObject(document2.metadata);
|
|
1898
|
+
}
|
|
1899
|
+
saveDocument() {
|
|
1900
|
+
return createDiagramDocument({
|
|
1901
|
+
nodes: this.nodes.map((node) => node.toInit(false)),
|
|
1902
|
+
links: this.links.map((link) => ({
|
|
1903
|
+
id: link.id,
|
|
1904
|
+
from: { nodeId: link.from, portId: link.fromPort },
|
|
1905
|
+
to: { nodeId: link.to, portId: link.toPort },
|
|
1906
|
+
style: link.style,
|
|
1907
|
+
metadata: link.metadata
|
|
1908
|
+
})),
|
|
1909
|
+
zones: this.zones.map((zone) => ({ ...zone, metadata: copyJsonObject(zone.metadata) })),
|
|
1910
|
+
metadata: this.documentMetadata
|
|
1911
|
+
});
|
|
1912
|
+
}
|
|
1913
|
+
selectedNodeId() {
|
|
1914
|
+
return this.selectedNode?.id ?? null;
|
|
1915
|
+
}
|
|
1916
|
+
getSelection() {
|
|
1917
|
+
return {
|
|
1918
|
+
nodeIds: [...this.selectedNodes].map((node) => node.id),
|
|
1919
|
+
linkIds: this.selectedLink === null ? [] : [this.selectedLink.id],
|
|
1920
|
+
port: this.selectedPort === null ? null : {
|
|
1921
|
+
nodeId: this.selectedPort.node.id,
|
|
1922
|
+
portId: this.selectedPort.port.id,
|
|
1923
|
+
direction: this.selectedPort.port.direction
|
|
1924
|
+
},
|
|
1925
|
+
primaryNodeId: this.selectedNode?.id ?? null,
|
|
1926
|
+
primaryLinkId: this.selectedLink?.id ?? null
|
|
1927
|
+
};
|
|
1928
|
+
}
|
|
1929
|
+
selectNodesById(ids) {
|
|
1930
|
+
const wanted = new Set(ids);
|
|
1931
|
+
this.setSelection(this.nodes.filter((node) => wanted.has(node.id)));
|
|
1932
|
+
}
|
|
1933
|
+
selectLinkById(id) {
|
|
1934
|
+
this.selectLink(id === null ? null : this.links.find((link) => link.id === id) ?? null);
|
|
1935
|
+
}
|
|
1936
|
+
selectPortById(nodeId, direction, portId) {
|
|
1937
|
+
const node = this.findNode(nodeId);
|
|
1938
|
+
const port = direction === "in" ? node?.inPorts.find((candidate) => candidate.id === portId) : node?.outPorts.find((candidate) => candidate.id === portId);
|
|
1939
|
+
if (node !== void 0 && port !== void 0) this.selectPort({ node, port });
|
|
1940
|
+
}
|
|
1941
|
+
getViewState() {
|
|
1942
|
+
return {
|
|
1943
|
+
zoom: this.scale,
|
|
1944
|
+
panX: this.offX,
|
|
1945
|
+
panY: this.offY,
|
|
1946
|
+
overviewVisible: this.overviewVisible
|
|
1947
|
+
};
|
|
1948
|
+
}
|
|
1949
|
+
/**
|
|
1950
|
+
* Replaces what Ctrl+Z / Ctrl+Y do. Passing null restores the built-in behaviour, which is
|
|
1951
|
+
* plain undo()/redo() -- so a renderer used on its own keeps its keyboard history.
|
|
1952
|
+
*/
|
|
1953
|
+
setHistoryShortcutHandler(handler) {
|
|
1954
|
+
this.historyShortcut = handler ?? ((direction) => {
|
|
1955
|
+
if (direction === "undo") this.undo();
|
|
1956
|
+
else this.redo();
|
|
1957
|
+
});
|
|
1958
|
+
}
|
|
1959
|
+
setViewState(state) {
|
|
1960
|
+
if (!Number.isFinite(state.zoom) || !Number.isFinite(state.panX) || !Number.isFinite(state.panY)) {
|
|
1961
|
+
throw new RangeError("ssdiagram: view state zoom and pan must be finite numbers");
|
|
1962
|
+
}
|
|
1963
|
+
this.scale = clamp(state.zoom, ZOOM_MIN, ZOOM_MAX);
|
|
1964
|
+
this.offX = state.panX;
|
|
1965
|
+
this.offY = state.panY;
|
|
1966
|
+
this.overviewVisible = state.overviewVisible;
|
|
1967
|
+
this.emitViewChanged(true);
|
|
1968
|
+
this.scheduleDraw();
|
|
1969
|
+
}
|
|
1970
|
+
// Renderer operations consumed by the high-level component.
|
|
1971
|
+
findNode(id) {
|
|
1972
|
+
return this.nodes.find((n2) => n2.id === id);
|
|
1973
|
+
}
|
|
1974
|
+
requestRedraw() {
|
|
1975
|
+
this.relayout();
|
|
1976
|
+
this.scheduleDraw();
|
|
1977
|
+
}
|
|
1978
|
+
/**
|
|
1979
|
+
* Creates a detached canvas. With no options it is an exact copy of the
|
|
1980
|
+
* current frame, matching Charts.takeScreenshot(). Content scope renders
|
|
1981
|
+
* the whole graph without changing the visible viewport.
|
|
1982
|
+
*/
|
|
1983
|
+
takeScreenshot(options = {}) {
|
|
1984
|
+
if (this.destroyed) throw new Error("ssdiagram: cannot export a destroyed diagram");
|
|
1985
|
+
const scope = options.scope ?? "viewport";
|
|
1986
|
+
if (scope !== "viewport" && scope !== "content") {
|
|
1987
|
+
throw new RangeError(`ssdiagram: unsupported screenshot scope "${String(scope)}"`);
|
|
1988
|
+
}
|
|
1989
|
+
if (scope === "viewport" && Object.keys(options).length === 0) {
|
|
1990
|
+
if (this.drawScheduled) this.draw();
|
|
1991
|
+
const copy = document.createElement("canvas");
|
|
1992
|
+
copy.width = this.canvas.width;
|
|
1993
|
+
copy.height = this.canvas.height;
|
|
1994
|
+
const copyContext = copy.getContext("2d");
|
|
1995
|
+
if (copyContext === null) throw new Error("ssdiagram: screenshot 2d context unavailable");
|
|
1996
|
+
copyContext.drawImage(this.canvas, 0, 0);
|
|
1997
|
+
return copy;
|
|
1998
|
+
}
|
|
1999
|
+
const layout = this.exportLayout(scope, options);
|
|
2000
|
+
const { pixelRatio, exportScale, padding, bounds, width, height, pixelWidth, pixelHeight } = layout;
|
|
2001
|
+
const output = document.createElement("canvas");
|
|
2002
|
+
output.width = pixelWidth;
|
|
2003
|
+
output.height = pixelHeight;
|
|
2004
|
+
output.style.width = `${width}px`;
|
|
2005
|
+
output.style.height = `${height}px`;
|
|
2006
|
+
const outputContext = output.getContext("2d");
|
|
2007
|
+
if (outputContext === null) throw new Error("ssdiagram: screenshot 2d context unavailable");
|
|
2008
|
+
const previous = {
|
|
2009
|
+
ctx: this.ctx,
|
|
2010
|
+
width: this.width,
|
|
2011
|
+
height: this.height,
|
|
2012
|
+
dpr: this.dpr,
|
|
2013
|
+
scale: this.scale,
|
|
2014
|
+
offX: this.offX,
|
|
2015
|
+
offY: this.offY,
|
|
2016
|
+
overviewVisible: this.overviewVisible,
|
|
2017
|
+
background: this.opts.background,
|
|
2018
|
+
// Drawing the overview recomputes this from the export frame. Left behind, the next
|
|
2019
|
+
// click on the on-screen minimap panned to a point in the export's coordinates.
|
|
2020
|
+
ovGeo: this.ovGeo
|
|
2021
|
+
};
|
|
2022
|
+
try {
|
|
2023
|
+
this.ctx = outputContext;
|
|
2024
|
+
this.width = width;
|
|
2025
|
+
this.height = height;
|
|
2026
|
+
this.dpr = pixelRatio;
|
|
2027
|
+
this.scale = exportScale;
|
|
2028
|
+
this.offX = scope === "content" && bounds !== null ? padding - bounds.minX * exportScale : previous.offX;
|
|
2029
|
+
this.offY = scope === "content" && bounds !== null ? padding - bounds.minY * exportScale : previous.offY;
|
|
2030
|
+
this.overviewVisible = options.includeOverview ?? (scope === "viewport" && previous.overviewVisible);
|
|
2031
|
+
if (options.background !== void 0) this.opts.background = options.background;
|
|
2032
|
+
outputContext.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
|
2033
|
+
this.draw({
|
|
2034
|
+
grid: options.includeGrid ?? true,
|
|
2035
|
+
overview: this.overviewVisible,
|
|
2036
|
+
selection: options.includeSelection ?? scope === "viewport",
|
|
2037
|
+
runtime: options.includeRuntimeState ?? true,
|
|
2038
|
+
transient: false
|
|
2039
|
+
});
|
|
2040
|
+
} finally {
|
|
2041
|
+
this.ctx = previous.ctx;
|
|
2042
|
+
this.width = previous.width;
|
|
2043
|
+
this.height = previous.height;
|
|
2044
|
+
this.dpr = previous.dpr;
|
|
2045
|
+
this.scale = previous.scale;
|
|
2046
|
+
this.offX = previous.offX;
|
|
2047
|
+
this.offY = previous.offY;
|
|
2048
|
+
this.overviewVisible = previous.overviewVisible;
|
|
2049
|
+
this.opts.background = previous.background;
|
|
2050
|
+
this.ovGeo = previous.ovGeo;
|
|
2051
|
+
}
|
|
2052
|
+
return output;
|
|
2053
|
+
}
|
|
2054
|
+
// Everything an export needs to know about size and placement, for either output. Shared on
|
|
2055
|
+
// purpose: a raster and a vector export of the same scope must frame the picture identically,
|
|
2056
|
+
// and two copies of this arithmetic would eventually disagree about padding or where the origin
|
|
2057
|
+
// sits. A block comment here would be lifted onto the next public member in the .d.ts.
|
|
2058
|
+
exportLayout(scope, options) {
|
|
2059
|
+
const pixelRatio = this.positiveScreenshotNumber(options.pixelRatio ?? this.dpr, "pixelRatio");
|
|
2060
|
+
const exportScale = scope === "content" ? this.positiveScreenshotNumber(options.scale ?? 1, "scale") : this.scale;
|
|
2061
|
+
const padding = scope === "content" ? this.nonNegativeScreenshotNumber(options.padding ?? 32, "padding") : 0;
|
|
2062
|
+
const bounds = scope === "content" ? this.graphBounds() : null;
|
|
2063
|
+
const width = scope === "content" ? Math.max(1, Math.ceil(((bounds?.maxX ?? 1) - (bounds?.minX ?? 0)) * exportScale + padding * 2)) : this.width;
|
|
2064
|
+
const height = scope === "content" ? Math.max(1, Math.ceil(((bounds?.maxY ?? 1) - (bounds?.minY ?? 0)) * exportScale + padding * 2)) : this.height;
|
|
2065
|
+
const pixelWidth = Math.ceil(width * pixelRatio);
|
|
2066
|
+
const pixelHeight = Math.ceil(height * pixelRatio);
|
|
2067
|
+
if (pixelWidth > 16384 || pixelHeight > 16384 || pixelWidth * pixelHeight > 268435456) {
|
|
2068
|
+
throw new RangeError(`ssdiagram: screenshot is too large (${pixelWidth}x${pixelHeight})`);
|
|
2069
|
+
}
|
|
2070
|
+
return { pixelRatio, exportScale, padding, bounds, width, height, pixelWidth, pixelHeight };
|
|
2071
|
+
}
|
|
2072
|
+
/**
|
|
2073
|
+
* The same picture takeScreenshot produces, as SVG.
|
|
2074
|
+
*
|
|
2075
|
+
* It is the renderer that draws it -- the surface underneath simply records instead of paints --
|
|
2076
|
+
* so the vector output cannot drift from what the screen and the raster export show. Text keeps
|
|
2077
|
+
* the on-screen metrics by measuring through a real 2D context.
|
|
2078
|
+
*
|
|
2079
|
+
* Scale and pixelRatio still frame the picture (they decide the viewBox), but nothing is
|
|
2080
|
+
* resampled: the result is resolution-independent, which is the point of asking for it.
|
|
2081
|
+
*/
|
|
2082
|
+
takeSvg(options = {}) {
|
|
2083
|
+
if (this.destroyed) throw new Error("ssdiagram: cannot export a destroyed diagram");
|
|
2084
|
+
const scope = options.scope ?? "viewport";
|
|
2085
|
+
if (scope !== "viewport" && scope !== "content") {
|
|
2086
|
+
throw new RangeError(`ssdiagram: unsupported screenshot scope "${String(scope)}"`);
|
|
2087
|
+
}
|
|
2088
|
+
const { exportScale, padding, bounds, width, height } = this.exportLayout(scope, options);
|
|
2089
|
+
const metrics = this.canvas.getContext("2d");
|
|
2090
|
+
const surface = new SvgSurface({
|
|
2091
|
+
width,
|
|
2092
|
+
height,
|
|
2093
|
+
background: options.background ?? this.opts.background ?? "#1b1b1f",
|
|
2094
|
+
metrics: metrics ?? void 0
|
|
2095
|
+
});
|
|
2096
|
+
const previous = {
|
|
2097
|
+
ctx: this.ctx,
|
|
2098
|
+
width: this.width,
|
|
2099
|
+
height: this.height,
|
|
2100
|
+
dpr: this.dpr,
|
|
2101
|
+
scale: this.scale,
|
|
2102
|
+
offX: this.offX,
|
|
2103
|
+
offY: this.offY,
|
|
2104
|
+
overviewVisible: this.overviewVisible,
|
|
2105
|
+
background: this.opts.background,
|
|
2106
|
+
// Drawing the overview recomputes this from the export frame. Left behind, the next
|
|
2107
|
+
// click on the on-screen minimap panned to a point in the export's coordinates.
|
|
2108
|
+
ovGeo: this.ovGeo
|
|
2109
|
+
};
|
|
2110
|
+
try {
|
|
2111
|
+
this.ctx = surface;
|
|
2112
|
+
if (options.background !== void 0) this.opts.background = options.background;
|
|
2113
|
+
this.width = width;
|
|
2114
|
+
this.height = height;
|
|
2115
|
+
this.dpr = 1;
|
|
2116
|
+
this.scale = exportScale;
|
|
2117
|
+
this.offX = scope === "content" && bounds !== null ? padding - bounds.minX * exportScale : previous.offX;
|
|
2118
|
+
this.offY = scope === "content" && bounds !== null ? padding - bounds.minY * exportScale : previous.offY;
|
|
2119
|
+
this.overviewVisible = options.includeOverview ?? (scope === "viewport" && previous.overviewVisible);
|
|
2120
|
+
this.draw({
|
|
2121
|
+
grid: options.includeGrid ?? true,
|
|
2122
|
+
overview: this.overviewVisible,
|
|
2123
|
+
selection: options.includeSelection ?? scope === "viewport",
|
|
2124
|
+
runtime: options.includeRuntimeState ?? true,
|
|
2125
|
+
transient: false
|
|
2126
|
+
});
|
|
2127
|
+
} finally {
|
|
2128
|
+
this.ctx = previous.ctx;
|
|
2129
|
+
this.width = previous.width;
|
|
2130
|
+
this.height = previous.height;
|
|
2131
|
+
this.dpr = previous.dpr;
|
|
2132
|
+
this.scale = previous.scale;
|
|
2133
|
+
this.offX = previous.offX;
|
|
2134
|
+
this.offY = previous.offY;
|
|
2135
|
+
this.overviewVisible = previous.overviewVisible;
|
|
2136
|
+
this.opts.background = previous.background;
|
|
2137
|
+
this.ovGeo = previous.ovGeo;
|
|
2138
|
+
}
|
|
2139
|
+
return surface.toSvg();
|
|
2140
|
+
}
|
|
2141
|
+
positiveScreenshotNumber(value, name) {
|
|
2142
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
2143
|
+
throw new RangeError(`ssdiagram: screenshot ${name} must be a positive finite number`);
|
|
2144
|
+
}
|
|
2145
|
+
return value;
|
|
2146
|
+
}
|
|
2147
|
+
nonNegativeScreenshotNumber(value, name) {
|
|
2148
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
2149
|
+
throw new RangeError(`ssdiagram: screenshot ${name} must be a non-negative finite number`);
|
|
2150
|
+
}
|
|
2151
|
+
return value;
|
|
2152
|
+
}
|
|
2153
|
+
getRuntimeState() {
|
|
2154
|
+
return cloneDiagramRuntimeState(this.runtimeState);
|
|
2155
|
+
}
|
|
2156
|
+
setRuntimeState(state) {
|
|
2157
|
+
const previous = this.runtimeState;
|
|
2158
|
+
this.runtimeState = cloneDiagramRuntimeState(state);
|
|
2159
|
+
if (this.runtimeState.globalError === null) {
|
|
2160
|
+
this.globalErrorFlashStart = null;
|
|
2161
|
+
} else if (previous.globalError?.pulse !== this.runtimeState.globalError.pulse) {
|
|
2162
|
+
this.globalErrorFlashStart = performance.now();
|
|
2163
|
+
}
|
|
2164
|
+
this.runtimePulse = Math.max(
|
|
2165
|
+
this.runtimePulse,
|
|
2166
|
+
this.runtimeState.globalError?.pulse ?? 0,
|
|
2167
|
+
...Object.values(this.runtimeState.nodes).flatMap((node) => [node.errors.runtime?.pulse ?? 0, node.errors.load?.pulse ?? 0])
|
|
2168
|
+
);
|
|
2169
|
+
for (const node of this.nodes) {
|
|
2170
|
+
const errors = getJsonKey(this.runtimeState.nodes, node.id)?.errors;
|
|
2171
|
+
const previousRuntime = getJsonKey(previous.nodes, node.id)?.errors.runtime;
|
|
2172
|
+
const runtimeError = errors?.runtime;
|
|
2173
|
+
node.runtimeError = runtimeError?.message ?? "";
|
|
2174
|
+
node.loadError = errors?.load?.message ?? "";
|
|
2175
|
+
if (runtimeError !== void 0) {
|
|
2176
|
+
if (previousRuntime === void 0 || previousRuntime.pulse !== runtimeError.pulse) {
|
|
2177
|
+
node.errorFlashStart = performance.now();
|
|
2178
|
+
}
|
|
2179
|
+
} else {
|
|
2180
|
+
node.errorFlashStart = null;
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
this.emitRuntimeStateChanged();
|
|
2184
|
+
}
|
|
2185
|
+
clearRuntimeState() {
|
|
2186
|
+
this.setRuntimeState(createDiagramRuntimeState());
|
|
2187
|
+
}
|
|
2188
|
+
setActiveNode(nodeId) {
|
|
2189
|
+
if (nodeId !== null && this.findNode(nodeId) === void 0) return false;
|
|
2190
|
+
const state = this.getRuntimeState();
|
|
2191
|
+
state.activeNodeId = nodeId;
|
|
2192
|
+
this.setRuntimeState(state);
|
|
2193
|
+
return true;
|
|
2194
|
+
}
|
|
2195
|
+
setPortRuntimeState(nodeId, direction, portId, patch) {
|
|
2196
|
+
const node = this.findNode(nodeId);
|
|
2197
|
+
const port = direction === "in" ? node?.inPorts.find((candidate) => candidate.id === portId) : node?.outPorts.find((candidate) => candidate.id === portId);
|
|
2198
|
+
if (node === void 0 || port === void 0) return false;
|
|
2199
|
+
const state = this.getRuntimeState();
|
|
2200
|
+
const nodeState = getJsonKey(state.nodes, nodeId) ?? createDiagramNodeRuntimeState();
|
|
2201
|
+
setJsonKey(state.nodes, nodeId, nodeState);
|
|
2202
|
+
const current = nodeState.ports[direction][portId] ?? createDiagramPortRuntimeState();
|
|
2203
|
+
nodeState.ports[direction][portId] = { ...current, ...patch };
|
|
2204
|
+
this.setRuntimeState(state);
|
|
2205
|
+
return true;
|
|
2206
|
+
}
|
|
2207
|
+
setGlobalError(message, kind = "invalid") {
|
|
2208
|
+
const state = this.getRuntimeState();
|
|
2209
|
+
state.globalError = message === null || message.length === 0 ? null : { kind, message, pulse: ++this.runtimePulse };
|
|
2210
|
+
this.setRuntimeState(state);
|
|
2211
|
+
}
|
|
2212
|
+
setNodeError(id, message, options = {}) {
|
|
2213
|
+
const node = this.findNode(id);
|
|
2214
|
+
if (node === void 0) return false;
|
|
2215
|
+
const kind = options.kind ?? "runtime";
|
|
2216
|
+
if (kind === "load") {
|
|
2217
|
+
node.loadError = message;
|
|
2218
|
+
} else {
|
|
2219
|
+
node.runtimeError = message;
|
|
2220
|
+
node.errorFlashStart = message.length > 0 && options.animate !== false ? performance.now() : null;
|
|
2221
|
+
}
|
|
2222
|
+
const state = this.getRuntimeState();
|
|
2223
|
+
const nodeState = getJsonKey(state.nodes, id) ?? createDiagramNodeRuntimeState();
|
|
2224
|
+
setJsonKey(state.nodes, id, nodeState);
|
|
2225
|
+
if (message.length === 0) delete nodeState.errors[kind];
|
|
2226
|
+
else if (kind === "load") nodeState.errors.load = { kind, message, pulse: ++this.runtimePulse };
|
|
2227
|
+
else nodeState.errors.runtime = { kind, message, pulse: ++this.runtimePulse };
|
|
2228
|
+
this.runtimeState = state;
|
|
2229
|
+
this.emitRuntimeStateChanged();
|
|
2230
|
+
return true;
|
|
2231
|
+
}
|
|
2232
|
+
clearNodeError(id, kind) {
|
|
2233
|
+
const node = this.findNode(id);
|
|
2234
|
+
if (node === void 0) return false;
|
|
2235
|
+
if (kind === void 0 || kind === "runtime") {
|
|
2236
|
+
node.runtimeError = "";
|
|
2237
|
+
node.errorFlashStart = null;
|
|
2238
|
+
}
|
|
2239
|
+
if (kind === void 0 || kind === "load") node.loadError = "";
|
|
2240
|
+
const state = this.getRuntimeState();
|
|
2241
|
+
const nodeState = getJsonKey(state.nodes, id);
|
|
2242
|
+
if (nodeState !== void 0) {
|
|
2243
|
+
if (kind === void 0 || kind === "runtime") delete nodeState.errors.runtime;
|
|
2244
|
+
if (kind === void 0 || kind === "load") delete nodeState.errors.load;
|
|
2245
|
+
}
|
|
2246
|
+
this.runtimeState = state;
|
|
2247
|
+
this.emitRuntimeStateChanged();
|
|
2248
|
+
return true;
|
|
2249
|
+
}
|
|
2250
|
+
emitRuntimeStateChanged() {
|
|
2251
|
+
this.emit("runtimeStateChanged", { state: this.getRuntimeState() });
|
|
2252
|
+
this.scheduleDraw();
|
|
2253
|
+
}
|
|
2254
|
+
viewToWorld(sx, sy) {
|
|
2255
|
+
return this.toWorld(sx, sy);
|
|
2256
|
+
}
|
|
2257
|
+
worldToView(wx, wy) {
|
|
2258
|
+
return this.toScreen(wx, wy);
|
|
2259
|
+
}
|
|
2260
|
+
selectNodeById(id) {
|
|
2261
|
+
const node = id === null ? null : this.nodes.find((n2) => n2.id === id) ?? null;
|
|
2262
|
+
this.selectNode(node);
|
|
2263
|
+
}
|
|
2264
|
+
setZoom(scale) {
|
|
2265
|
+
if (!Number.isFinite(scale)) throw new RangeError("ssdiagram: zoom must be a finite number");
|
|
2266
|
+
const cx = this.width / 2;
|
|
2267
|
+
const cy = this.height / 2;
|
|
2268
|
+
const wx = (cx - this.offX) / this.scale;
|
|
2269
|
+
const wy = (cy - this.offY) / this.scale;
|
|
2270
|
+
this.scale = clamp(scale, ZOOM_MIN, ZOOM_MAX);
|
|
2271
|
+
this.offX = cx - wx * this.scale;
|
|
2272
|
+
this.offY = cy - wy * this.scale;
|
|
2273
|
+
this.emitViewChanged(true);
|
|
2274
|
+
this.scheduleDraw();
|
|
2275
|
+
}
|
|
2276
|
+
zoomToFit() {
|
|
2277
|
+
const gb = this.graphBounds();
|
|
2278
|
+
if (gb === null) {
|
|
2279
|
+
this.scale = 1;
|
|
2280
|
+
this.offX = 0;
|
|
2281
|
+
this.offY = 0;
|
|
2282
|
+
this.emitViewChanged(true);
|
|
2283
|
+
this.scheduleDraw();
|
|
2284
|
+
return;
|
|
2285
|
+
}
|
|
2286
|
+
const pad = 40;
|
|
2287
|
+
const gw = gb.maxX - gb.minX + pad * 2;
|
|
2288
|
+
const gh = gb.maxY - gb.minY + pad * 2;
|
|
2289
|
+
this.scale = clamp(Math.min(this.width / gw, this.height / gh), ZOOM_MIN, 1);
|
|
2290
|
+
this.offX = (this.width - (gb.maxX + gb.minX) * this.scale) / 2;
|
|
2291
|
+
this.offY = (this.height - (gb.maxY + gb.minY) * this.scale) / 2;
|
|
2292
|
+
this.emitViewChanged(true);
|
|
2293
|
+
this.scheduleDraw();
|
|
2294
|
+
}
|
|
2295
|
+
// Entrance animation: the whole scheme rises from below + fades in.
|
|
2296
|
+
playIntro() {
|
|
2297
|
+
this.introStart = performance.now();
|
|
2298
|
+
this.scheduleDraw();
|
|
2299
|
+
}
|
|
2300
|
+
setOverviewVisible(v) {
|
|
2301
|
+
if (this.overviewVisible === v) return;
|
|
2302
|
+
this.overviewVisible = v;
|
|
2303
|
+
this.emitViewChanged(false);
|
|
2304
|
+
this.scheduleDraw();
|
|
2305
|
+
}
|
|
2306
|
+
setTypeColors(colors) {
|
|
2307
|
+
this.setTypeColorsInternal(colors);
|
|
2308
|
+
this.scheduleDraw();
|
|
2309
|
+
}
|
|
2310
|
+
/**
|
|
2311
|
+
* Keyed the way port types are compared everywhere else. Connection compatibility is case- and
|
|
2312
|
+
* space-insensitive, so an imported scheme spelling a socket "candle " connects to "Candle" and
|
|
2313
|
+
* then fell through to a hash colour, showing one logical type in two colours in one diagram.
|
|
2314
|
+
*/
|
|
2315
|
+
setTypeColorsInternal(colors) {
|
|
2316
|
+
const normalized = {};
|
|
2317
|
+
for (const [type, color] of Object.entries(colors)) {
|
|
2318
|
+
setJsonKey(normalized, normalizePortType(type), color);
|
|
2319
|
+
}
|
|
2320
|
+
this.opts.typeColors = normalized;
|
|
2321
|
+
}
|
|
2322
|
+
setTheme(t) {
|
|
2323
|
+
if (t.background !== void 0) this.opts.background = t.background;
|
|
2324
|
+
if (t.gridColor !== void 0) this.opts.gridColor = t.gridColor;
|
|
2325
|
+
if (t.linkMaxLightness !== void 0) this.opts.linkMaxLightness = t.linkMaxLightness;
|
|
2326
|
+
if (t.typedLinkColors !== void 0) this.opts.typedLinkColors = t.typedLinkColors;
|
|
2327
|
+
if (t.overviewBackground !== void 0) this.opts.overviewBackground = t.overviewBackground;
|
|
2328
|
+
if (t.overviewBorderColor !== void 0) this.opts.overviewBorderColor = t.overviewBorderColor;
|
|
2329
|
+
if (t.overviewViewportColor !== void 0) this.opts.overviewViewportColor = t.overviewViewportColor;
|
|
2330
|
+
if (t.overviewViewportFill !== void 0) this.opts.overviewViewportFill = t.overviewViewportFill;
|
|
2331
|
+
this.scheduleDraw();
|
|
2332
|
+
}
|
|
2333
|
+
graphBounds() {
|
|
2334
|
+
if (this.nodes.length === 0) return null;
|
|
2335
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
2336
|
+
for (const n2 of this.nodes) {
|
|
2337
|
+
minX = Math.min(minX, n2.x);
|
|
2338
|
+
minY = Math.min(minY, n2.y);
|
|
2339
|
+
maxX = Math.max(maxX, n2.x + n2.w);
|
|
2340
|
+
maxY = Math.max(maxY, n2.y + n2.h);
|
|
2341
|
+
}
|
|
2342
|
+
return { minX, minY, maxX, maxY };
|
|
2343
|
+
}
|
|
2344
|
+
resize(w, h) {
|
|
2345
|
+
if (w < 2 || h < 2) return;
|
|
2346
|
+
this.width = w;
|
|
2347
|
+
this.height = h;
|
|
2348
|
+
this.dpr = window.devicePixelRatio || 1;
|
|
2349
|
+
this.canvas.width = Math.round(w * this.dpr);
|
|
2350
|
+
this.canvas.height = Math.round(h * this.dpr);
|
|
2351
|
+
this.canvas.style.width = `${w}px`;
|
|
2352
|
+
this.canvas.style.height = `${h}px`;
|
|
2353
|
+
this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
|
|
2354
|
+
this.scheduleDraw();
|
|
2355
|
+
}
|
|
2356
|
+
getInteractionPermissions() {
|
|
2357
|
+
return { ...this.permissions };
|
|
2358
|
+
}
|
|
2359
|
+
setInteractionPermissions(patch) {
|
|
2360
|
+
this.permissions = { ...this.permissions, ...patch };
|
|
2361
|
+
if (!this.permissions.select) {
|
|
2362
|
+
this.selectedNode = null;
|
|
2363
|
+
this.selectedNodes.clear();
|
|
2364
|
+
this.selectedLink = null;
|
|
2365
|
+
this.selectedPort = null;
|
|
2366
|
+
this.emitSelectionChanged();
|
|
2367
|
+
}
|
|
2368
|
+
this.emit("undoStackChanged", { canUndo: this.canUndo(), canRedo: this.canRedo() });
|
|
2369
|
+
this.scheduleDraw();
|
|
2370
|
+
}
|
|
2371
|
+
/** View-only mode keeps selection, inspection and copy enabled. */
|
|
2372
|
+
setReadOnly(value) {
|
|
2373
|
+
this.permissions = value ? createReadOnlyDiagramPermissions() : createEditableDiagramPermissions();
|
|
2374
|
+
this.emit("undoStackChanged", { canUndo: this.canUndo(), canRedo: this.canRedo() });
|
|
2375
|
+
this.scheduleDraw();
|
|
2376
|
+
}
|
|
2377
|
+
destroy() {
|
|
2378
|
+
if (this.destroyed) return;
|
|
2379
|
+
this.destroyed = true;
|
|
2380
|
+
this.cancelLongPress();
|
|
2381
|
+
if (this.tipTimer !== null) {
|
|
2382
|
+
clearTimeout(this.tipTimer);
|
|
2383
|
+
this.tipTimer = null;
|
|
2384
|
+
}
|
|
2385
|
+
for (const dispose of this.domDisposables.splice(0).reverse()) dispose();
|
|
2386
|
+
this.handlers.clear();
|
|
2387
|
+
this.iconCache.clear();
|
|
2388
|
+
this.canvas.remove();
|
|
2389
|
+
}
|
|
2390
|
+
// ---- geometry ---------------------------------------------------
|
|
2391
|
+
layoutNode(n2) {
|
|
2392
|
+
const ctx = this.ctx;
|
|
2393
|
+
ctx.font = "600 12px Segoe UI, Tahoma, sans-serif";
|
|
2394
|
+
const titleW = ctx.measureText(n2.name).width;
|
|
2395
|
+
const rows = Math.max(n2.inPorts.length, n2.outPorts.length, 1);
|
|
2396
|
+
n2.w = Math.max(170, Math.ceil(titleW) + 28);
|
|
2397
|
+
n2.h = Math.max(48, rows * PORT_ROW_H + 16);
|
|
2398
|
+
const off = PORT_SQ / 2;
|
|
2399
|
+
const place = (ports, x) => {
|
|
2400
|
+
const startY = n2.y + (n2.h - ports.length * PORT_ROW_H) / 2 + PORT_ROW_H / 2;
|
|
2401
|
+
ports.forEach((p, i) => {
|
|
2402
|
+
p.cx = x;
|
|
2403
|
+
p.cy = startY + i * PORT_ROW_H;
|
|
2404
|
+
});
|
|
2405
|
+
};
|
|
2406
|
+
place(n2.inPorts, n2.x - off);
|
|
2407
|
+
place(n2.outPorts, n2.x + n2.w + off);
|
|
2408
|
+
}
|
|
2409
|
+
relayout() {
|
|
2410
|
+
for (const n2 of this.nodes) this.layoutNode(n2);
|
|
2411
|
+
}
|
|
2412
|
+
toScreen(wx, wy) {
|
|
2413
|
+
return [wx * this.scale + this.offX, wy * this.scale + this.offY];
|
|
2414
|
+
}
|
|
2415
|
+
toWorld(sx, sy) {
|
|
2416
|
+
return [(sx - this.offX) / this.scale, (sy - this.offY) / this.scale];
|
|
2417
|
+
}
|
|
2418
|
+
portColor(type) {
|
|
2419
|
+
const maxL = this.opts.linkMaxLightness;
|
|
2420
|
+
const light = maxL !== void 0 && maxL < 0.5;
|
|
2421
|
+
if (type === "") return light ? "#585c62" : "#9aa0a6";
|
|
2422
|
+
const map = this.opts.typeColors ?? {};
|
|
2423
|
+
const normalized = normalizePortType(type);
|
|
2424
|
+
const base = getJsonKey(map, normalized) ?? `hsl(${hashHue(normalized)}, 62%, 58%)`;
|
|
2425
|
+
return maxL !== void 0 ? clampLightness(base, maxL) : base;
|
|
2426
|
+
}
|
|
2427
|
+
// Selection/hover accent. The dark-canvas blue washes out on a light canvas, so a light theme
|
|
2428
|
+
// (linkMaxLightness < 0.5) inverts to a darker, more saturated blue that stays clearly visible.
|
|
2429
|
+
selectionColor() {
|
|
2430
|
+
const maxL = this.opts.linkMaxLightness;
|
|
2431
|
+
return maxL !== void 0 && maxL < 0.5 ? "#0d6efd" : "#4aa3ff";
|
|
2432
|
+
}
|
|
2433
|
+
linkHoverColor() {
|
|
2434
|
+
const maxL = this.opts.linkMaxLightness;
|
|
2435
|
+
return maxL !== void 0 && maxL < 0.5 ? "#6aa9f0" : "#cfe3ff";
|
|
2436
|
+
}
|
|
2437
|
+
// ---- hit testing (world coords) ---------------------------------
|
|
2438
|
+
portAt(wx, wy) {
|
|
2439
|
+
for (let i = this.nodes.length - 1; i >= 0; i -= 1) {
|
|
2440
|
+
const n2 = this.nodes[i];
|
|
2441
|
+
for (const p of [...n2.inPorts, ...n2.outPorts]) {
|
|
2442
|
+
if ((wx - p.cx) ** 2 + (wy - p.cy) ** 2 <= (PORT_R + 4) ** 2) return { node: n2, port: p };
|
|
2443
|
+
}
|
|
2444
|
+
if (wx >= n2.x && wx <= n2.x + n2.w && wy >= n2.y && wy <= n2.y + n2.h) return null;
|
|
2445
|
+
}
|
|
2446
|
+
return null;
|
|
2447
|
+
}
|
|
2448
|
+
selectedLinkEndpointAt(wx, wy) {
|
|
2449
|
+
if (this.selectedLink === null) return null;
|
|
2450
|
+
const radius = (PORT_R + 7) / this.scale;
|
|
2451
|
+
for (const end of ["from", "to"]) {
|
|
2452
|
+
const point = this.endpoint(this.selectedLink, end);
|
|
2453
|
+
if (point !== null && (wx - point[0]) ** 2 + (wy - point[1]) ** 2 <= radius ** 2) return end;
|
|
2454
|
+
}
|
|
2455
|
+
return null;
|
|
2456
|
+
}
|
|
2457
|
+
directRelinkAtPort(node, port) {
|
|
2458
|
+
if (port.direction !== "in") return null;
|
|
2459
|
+
const incoming = this.links.filter((link) => link.to === node.id && link.toPort === port.id);
|
|
2460
|
+
return incoming.length === 1 ? { link: incoming[0], end: "to" } : null;
|
|
2461
|
+
}
|
|
2462
|
+
nodeAt(wx, wy) {
|
|
2463
|
+
for (let i = this.nodes.length - 1; i >= 0; i -= 1) {
|
|
2464
|
+
const n2 = this.nodes[i];
|
|
2465
|
+
if (wx >= n2.x && wx <= n2.x + n2.w && wy >= n2.y && wy <= n2.y + n2.h) return n2;
|
|
2466
|
+
}
|
|
2467
|
+
return null;
|
|
2468
|
+
}
|
|
2469
|
+
// Other nodes (not the link's own endpoints) become obstacles,
|
|
2470
|
+
// inflated so links keep a little clearance — the AvoidsNodes margin.
|
|
2471
|
+
obstaclesFor(exclude) {
|
|
2472
|
+
const inf = 12;
|
|
2473
|
+
const out = [];
|
|
2474
|
+
for (const n2 of this.nodes) {
|
|
2475
|
+
if (exclude.has(n2.id)) continue;
|
|
2476
|
+
out.push([n2.x - inf, n2.y - inf, n2.w + inf * 2, n2.h + inf * 2]);
|
|
2477
|
+
}
|
|
2478
|
+
return out;
|
|
2479
|
+
}
|
|
2480
|
+
routeLink(a, b, exclude) {
|
|
2481
|
+
return routeAvoid(a, b, this.obstaclesFor(exclude));
|
|
2482
|
+
}
|
|
2483
|
+
linkAt(wx, wy) {
|
|
2484
|
+
const tol = 5 / this.scale;
|
|
2485
|
+
for (let i = this.links.length - 1; i >= 0; i -= 1) {
|
|
2486
|
+
const l = this.links[i];
|
|
2487
|
+
const a = this.endpoint(l, "from");
|
|
2488
|
+
const b = this.endpoint(l, "to");
|
|
2489
|
+
if (a === null || b === null) continue;
|
|
2490
|
+
const pts = this.routeLink(a, b, /* @__PURE__ */ new Set([l.from, l.to]));
|
|
2491
|
+
for (let k = 1; k < pts.length; k += 1) {
|
|
2492
|
+
if (ptSegDist(wx, wy, pts[k - 1][0], pts[k - 1][1], pts[k][0], pts[k][1]) <= tol) return l;
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
return null;
|
|
2496
|
+
}
|
|
2497
|
+
endpoint(l, end) {
|
|
2498
|
+
const node = this.nodes.find((n2) => n2.id === (end === "from" ? l.from : l.to));
|
|
2499
|
+
if (node === void 0) return null;
|
|
2500
|
+
const port = end === "from" ? node.outPorts.find((p) => p.id === l.fromPort) : node.inPorts.find((p) => p.id === l.toPort);
|
|
2501
|
+
if (port === void 0) return null;
|
|
2502
|
+
return [port.cx, port.cy];
|
|
2503
|
+
}
|
|
2504
|
+
// ---- linking rules ----------------------------------------------
|
|
2505
|
+
validateLink(init, excludeLinkId) {
|
|
2506
|
+
const fromNode = this.nodes.find((node) => node.id === init.from);
|
|
2507
|
+
const toNode = this.nodes.find((node) => node.id === init.to);
|
|
2508
|
+
if (fromNode === void 0 || toNode === void 0) return { allowed: false, reason: "missing-node" };
|
|
2509
|
+
const fromPort = fromNode.outPorts.find((port) => port.id === init.fromPort);
|
|
2510
|
+
const toPort = toNode.inPorts.find((port) => port.id === init.toPort);
|
|
2511
|
+
if (fromPort === void 0 || toPort === void 0) return { allowed: false, reason: "missing-port" };
|
|
2512
|
+
return this.validateLinkModels(fromNode, fromPort, toNode, toPort, excludeLinkId);
|
|
2513
|
+
}
|
|
2514
|
+
validateLinkModels(fromNode, fromPort, toNode, toPort, excludeLinkId) {
|
|
2515
|
+
if (fromNode === toNode) return { allowed: false, reason: "same-node" };
|
|
2516
|
+
if (fromPort.direction !== "out" || toPort.direction !== "in") {
|
|
2517
|
+
return { allowed: false, reason: "invalid-direction" };
|
|
2518
|
+
}
|
|
2519
|
+
const relevantLinks = this.links.filter((link) => link.id !== excludeLinkId);
|
|
2520
|
+
if (relevantLinks.some((link) => link.from === fromNode.id && link.fromPort === fromPort.id && link.to === toNode.id && link.toPort === toPort.id)) {
|
|
2521
|
+
return { allowed: false, reason: "duplicate-link" };
|
|
2522
|
+
}
|
|
2523
|
+
if (fromPort.maxLinks > 0) {
|
|
2524
|
+
const used = relevantLinks.filter((link) => link.from === fromNode.id && link.fromPort === fromPort.id).length;
|
|
2525
|
+
if (used >= fromPort.maxLinks) return { allowed: false, reason: "source-limit" };
|
|
2526
|
+
}
|
|
2527
|
+
if (toPort.maxLinks > 0) {
|
|
2528
|
+
const used = relevantLinks.filter((link) => link.to === toNode.id && link.toPort === toPort.id).length;
|
|
2529
|
+
if (used >= toPort.maxLinks) return { allowed: false, reason: "target-limit" };
|
|
2530
|
+
}
|
|
2531
|
+
if (!arePortTypesCompatible(fromPort.type, toPort.type, toPort.availableTypes)) {
|
|
2532
|
+
return { allowed: false, reason: "incompatible-type" };
|
|
2533
|
+
}
|
|
2534
|
+
if (this.validator !== null && !this.validator({ fromNode, fromPort, toNode, toPort })) {
|
|
2535
|
+
return { allowed: false, reason: "host-rejected" };
|
|
2536
|
+
}
|
|
2537
|
+
return { allowed: true, reason: "allowed" };
|
|
2538
|
+
}
|
|
2539
|
+
removeIncompatibleLinks(matches) {
|
|
2540
|
+
const incompatible = this.links.filter((link) => matches(link) && !this.canExistingLinkRemain(link));
|
|
2541
|
+
for (const link of incompatible) this.removeLinkInternal(link, true);
|
|
2542
|
+
}
|
|
2543
|
+
canExistingLinkRemain(link) {
|
|
2544
|
+
const fromNode = this.nodes.find((node) => node.id === link.from);
|
|
2545
|
+
const toNode = this.nodes.find((node) => node.id === link.to);
|
|
2546
|
+
const fromPort = fromNode?.outPorts.find((port) => port.id === link.fromPort);
|
|
2547
|
+
const toPort = toNode?.inPorts.find((port) => port.id === link.toPort);
|
|
2548
|
+
if (fromNode === void 0 || toNode === void 0 || fromPort === void 0 || toPort === void 0) {
|
|
2549
|
+
return false;
|
|
2550
|
+
}
|
|
2551
|
+
if (!arePortTypesCompatible(fromPort.type, toPort.type, toPort.availableTypes)) return false;
|
|
2552
|
+
return this.validator === null || this.validator({ fromNode, fromPort, toNode, toPort });
|
|
2553
|
+
}
|
|
2554
|
+
// Plug↔socket magnet: while dragging from an out-port, find the
|
|
2555
|
+
// nearest in-port (within SNAP_PX, screen space) the link is allowed
|
|
2556
|
+
// to land on. Returns null when none is close → the pending link
|
|
2557
|
+
// detaches and follows the cursor again.
|
|
2558
|
+
findSnap() {
|
|
2559
|
+
if (this.linking === null && this.relinking === null) return null;
|
|
2560
|
+
let direction;
|
|
2561
|
+
let accepts;
|
|
2562
|
+
if (this.linking !== null) {
|
|
2563
|
+
const source = this.linking;
|
|
2564
|
+
direction = "in";
|
|
2565
|
+
accepts = (node, port) => this.validateLinkModels(source.node, source.port, node, port).allowed;
|
|
2566
|
+
} else {
|
|
2567
|
+
const gesture = this.relinking;
|
|
2568
|
+
if (gesture.end === "to") {
|
|
2569
|
+
const sourceNode = this.nodes.find((node) => node.id === gesture.link.from);
|
|
2570
|
+
const sourcePort = sourceNode?.outPorts.find((port) => port.id === gesture.link.fromPort);
|
|
2571
|
+
if (sourceNode === void 0 || sourcePort === void 0) return null;
|
|
2572
|
+
direction = "in";
|
|
2573
|
+
accepts = (node, port) => this.validateLinkModels(
|
|
2574
|
+
sourceNode,
|
|
2575
|
+
sourcePort,
|
|
2576
|
+
node,
|
|
2577
|
+
port,
|
|
2578
|
+
gesture.link.id
|
|
2579
|
+
).allowed;
|
|
2580
|
+
} else {
|
|
2581
|
+
const targetNode = this.nodes.find((node) => node.id === gesture.link.to);
|
|
2582
|
+
const targetPort = targetNode?.inPorts.find((port) => port.id === gesture.link.toPort);
|
|
2583
|
+
if (targetNode === void 0 || targetPort === void 0) return null;
|
|
2584
|
+
direction = "out";
|
|
2585
|
+
accepts = (node, port) => this.validateLinkModels(
|
|
2586
|
+
node,
|
|
2587
|
+
port,
|
|
2588
|
+
targetNode,
|
|
2589
|
+
targetPort,
|
|
2590
|
+
gesture.link.id
|
|
2591
|
+
).allowed;
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
let best = null;
|
|
2595
|
+
let bestD = SNAP_PX;
|
|
2596
|
+
for (const n2 of this.nodes) {
|
|
2597
|
+
const ports = direction === "in" ? n2.inPorts : n2.outPorts;
|
|
2598
|
+
for (const p of ports) {
|
|
2599
|
+
if (!accepts(n2, p)) continue;
|
|
2600
|
+
const [sx, sy] = this.toScreen(p.cx, p.cy);
|
|
2601
|
+
const dpx = Math.hypot(sx - this.cursor.x, sy - this.cursor.y);
|
|
2602
|
+
if (dpx <= bestD) {
|
|
2603
|
+
bestD = dpx;
|
|
2604
|
+
best = { node: n2, port: p };
|
|
2605
|
+
}
|
|
2606
|
+
}
|
|
2607
|
+
}
|
|
2608
|
+
return best;
|
|
2609
|
+
}
|
|
2610
|
+
selectNode(n2) {
|
|
2611
|
+
this.selectedLink = null;
|
|
2612
|
+
this.selectedPort = null;
|
|
2613
|
+
this.selectedNodes.clear();
|
|
2614
|
+
if (n2 !== null) this.selectedNodes.add(n2);
|
|
2615
|
+
this.selectedNode = n2;
|
|
2616
|
+
this.emit("nodeSelected", { node: n2, selected: n2 !== null });
|
|
2617
|
+
this.emitSelectionChanged();
|
|
2618
|
+
this.scheduleDraw();
|
|
2619
|
+
}
|
|
2620
|
+
toggleSelect(n2) {
|
|
2621
|
+
this.selectedLink = null;
|
|
2622
|
+
this.selectedPort = null;
|
|
2623
|
+
if (this.selectedNodes.has(n2)) {
|
|
2624
|
+
this.selectedNodes.delete(n2);
|
|
2625
|
+
if (this.selectedNode === n2) this.selectedNode = null;
|
|
2626
|
+
} else {
|
|
2627
|
+
this.selectedNodes.add(n2);
|
|
2628
|
+
this.selectedNode = n2;
|
|
2629
|
+
}
|
|
2630
|
+
this.emit("nodeSelected", { node: this.selectedNode, selected: this.selectedNodes.size > 0 });
|
|
2631
|
+
this.emitSelectionChanged();
|
|
2632
|
+
this.scheduleDraw();
|
|
2633
|
+
}
|
|
2634
|
+
setSelection(ns) {
|
|
2635
|
+
this.selectedLink = null;
|
|
2636
|
+
this.selectedPort = null;
|
|
2637
|
+
this.selectedNodes = new Set(ns);
|
|
2638
|
+
this.selectedNode = ns.length > 0 ? ns[ns.length - 1] : null;
|
|
2639
|
+
this.emit("nodeSelected", { node: this.selectedNode, selected: ns.length > 0 });
|
|
2640
|
+
this.emitSelectionChanged();
|
|
2641
|
+
this.scheduleDraw();
|
|
2642
|
+
}
|
|
2643
|
+
selectLink(l) {
|
|
2644
|
+
if (this.selectedLink === l) return;
|
|
2645
|
+
this.selectedNode = null;
|
|
2646
|
+
this.selectedNodes.clear();
|
|
2647
|
+
this.selectedPort = null;
|
|
2648
|
+
this.selectedLink = l;
|
|
2649
|
+
this.emit("linkSelected", { link: l, selected: l !== null });
|
|
2650
|
+
this.emitSelectionChanged();
|
|
2651
|
+
this.scheduleDraw();
|
|
2652
|
+
}
|
|
2653
|
+
selectPort(target) {
|
|
2654
|
+
this.selectedPort = target;
|
|
2655
|
+
this.emit("portSelected", target);
|
|
2656
|
+
this.emitSelectionChanged();
|
|
2657
|
+
this.scheduleDraw();
|
|
2658
|
+
}
|
|
2659
|
+
emitSelectionChanged() {
|
|
2660
|
+
this.emit("selectionChanged", this.getSelection());
|
|
2661
|
+
}
|
|
2662
|
+
// ---- clipboard (copy / paste of the selection) ------------------
|
|
2663
|
+
copySelection() {
|
|
2664
|
+
this.copySelectionDocument();
|
|
2665
|
+
}
|
|
2666
|
+
copySelectionDocument() {
|
|
2667
|
+
if (!this.permissions.copy) return null;
|
|
2668
|
+
if (this.selectedNodes.size === 0) {
|
|
2669
|
+
this.clip = null;
|
|
2670
|
+
return null;
|
|
2671
|
+
}
|
|
2672
|
+
const ids = new Set([...this.selectedNodes].map((n2) => n2.id));
|
|
2673
|
+
const all = this.saveDocument();
|
|
2674
|
+
this.clip = createDiagramDocument({
|
|
2675
|
+
nodes: all.nodes.filter((n2) => ids.has(n2.id)),
|
|
2676
|
+
links: all.links.filter((link) => ids.has(link.from.nodeId) && ids.has(link.to.nodeId))
|
|
2677
|
+
});
|
|
2678
|
+
return parseDiagramDocument(this.clip);
|
|
2679
|
+
}
|
|
2680
|
+
hasClipboard() {
|
|
2681
|
+
return this.clip !== null && this.clip.nodes.length > 0;
|
|
2682
|
+
}
|
|
2683
|
+
getClipboardDocument() {
|
|
2684
|
+
return this.clip === null ? null : parseDiagramDocument(this.clip);
|
|
2685
|
+
}
|
|
2686
|
+
setClipboardDocument(source) {
|
|
2687
|
+
this.clip = parseDiagramDocument(source);
|
|
2688
|
+
}
|
|
2689
|
+
pasteSelection() {
|
|
2690
|
+
if (this.clip === null) return [];
|
|
2691
|
+
return this.pasteDocument(this.clip);
|
|
2692
|
+
}
|
|
2693
|
+
pasteDocument(source, offset) {
|
|
2694
|
+
if (!this.permissions.paste) return [];
|
|
2695
|
+
const clipboard = parseDiagramDocument(source);
|
|
2696
|
+
if (clipboard.nodes.length === 0) return [];
|
|
2697
|
+
const pasteOffset = offset ?? { x: this.gridSize, y: this.gridSize };
|
|
2698
|
+
this.clip = clipboard;
|
|
2699
|
+
const map = /* @__PURE__ */ new Map();
|
|
2700
|
+
const added = [];
|
|
2701
|
+
this.withTransaction("paste", () => {
|
|
2702
|
+
for (const sn of clipboard.nodes) {
|
|
2703
|
+
const nid = this.nextNodeId();
|
|
2704
|
+
map.set(sn.id, nid);
|
|
2705
|
+
const id = this.addDiagramNode({
|
|
2706
|
+
...sn,
|
|
2707
|
+
id: nid,
|
|
2708
|
+
x: sn.x + pasteOffset.x,
|
|
2709
|
+
y: sn.y + pasteOffset.y
|
|
2710
|
+
});
|
|
2711
|
+
const nn = this.nodes.find((x) => x.id === id);
|
|
2712
|
+
if (nn !== void 0) added.push(nn);
|
|
2713
|
+
}
|
|
2714
|
+
for (const l of clipboard.links) {
|
|
2715
|
+
const f = map.get(l.from.nodeId);
|
|
2716
|
+
const t = map.get(l.to.nodeId);
|
|
2717
|
+
if (f !== void 0 && t !== void 0)
|
|
2718
|
+
this.addLink({
|
|
2719
|
+
from: f,
|
|
2720
|
+
fromPort: l.from.portId,
|
|
2721
|
+
to: t,
|
|
2722
|
+
toPort: l.to.portId,
|
|
2723
|
+
// The clipboard is built from saveDocument(), so it carries the style;
|
|
2724
|
+
// rebuilding without it silently turned every pasted dashed link solid.
|
|
2725
|
+
style: l.style,
|
|
2726
|
+
metadata: l.metadata
|
|
2727
|
+
});
|
|
2728
|
+
}
|
|
2729
|
+
});
|
|
2730
|
+
this.setSelection(added);
|
|
2731
|
+
return added.map((node) => node.id);
|
|
2732
|
+
}
|
|
2733
|
+
// ---- input ------------------------------------------------------
|
|
2734
|
+
// Ends hover and everything hanging off it: highlight, cursor and the delayed tooltip.
|
|
2735
|
+
clearHover() {
|
|
2736
|
+
if (this.hoverPort !== null) this.emit("portHover", { node: this.hoverPort.node, port: this.hoverPort.port, hovering: false });
|
|
2737
|
+
if (this.hoverNode !== null) this.emit("nodeHover", { node: this.hoverNode, hovering: false });
|
|
2738
|
+
if (this.hoveredLink !== null) this.emit("linkHover", { link: this.hoveredLink, hovering: false });
|
|
2739
|
+
this.hoverPort = null;
|
|
2740
|
+
this.hoverNode = null;
|
|
2741
|
+
this.hoveredLink = null;
|
|
2742
|
+
this.tipTarget = null;
|
|
2743
|
+
this.tipShow = false;
|
|
2744
|
+
if (this.tipTimer !== null) {
|
|
2745
|
+
clearTimeout(this.tipTimer);
|
|
2746
|
+
this.tipTimer = null;
|
|
2747
|
+
}
|
|
2748
|
+
this.canvas.style.cursor = "default";
|
|
2749
|
+
this.scheduleDraw();
|
|
2750
|
+
}
|
|
2751
|
+
cancelLongPress() {
|
|
2752
|
+
if (this.lpTimer !== null) {
|
|
2753
|
+
clearTimeout(this.lpTimer);
|
|
2754
|
+
this.lpTimer = null;
|
|
2755
|
+
}
|
|
2756
|
+
this.lpStart = null;
|
|
2757
|
+
}
|
|
2758
|
+
// Hit-test at screen coords and emit a contextMenu event. Cancels
|
|
2759
|
+
// any partial drag/rubber/link gesture that may have started — once
|
|
2760
|
+
// the menu opens we don't want a half-drag racing it.
|
|
2761
|
+
fireContextMenu(sx, sy, pageX, pageY) {
|
|
2762
|
+
const [wx, wy] = this.toWorld(sx, sy);
|
|
2763
|
+
const port = this.portAt(wx, wy);
|
|
2764
|
+
const node = port?.node ?? this.nodeAt(wx, wy);
|
|
2765
|
+
const link = port === null && node === null ? this.linkAt(wx, wy) : null;
|
|
2766
|
+
if (this.permissions.select) {
|
|
2767
|
+
if (port !== null) {
|
|
2768
|
+
const selectedLink = this.selectedLink;
|
|
2769
|
+
const isSelectedEndpoint = selectedLink !== null && (selectedLink.from === port.node.id && selectedLink.fromPort === port.port.id || selectedLink.to === port.node.id && selectedLink.toPort === port.port.id);
|
|
2770
|
+
if (!isSelectedEndpoint && !this.selectedNodes.has(port.node)) this.selectNode(port.node);
|
|
2771
|
+
this.selectPort(port);
|
|
2772
|
+
} else if (node !== null && !this.selectedNodes.has(node)) this.selectNode(node);
|
|
2773
|
+
else if (link !== null && this.selectedLink !== link) this.selectLink(link);
|
|
2774
|
+
}
|
|
2775
|
+
this.dragNode = null;
|
|
2776
|
+
this.dragStart = [];
|
|
2777
|
+
this.rubber = null;
|
|
2778
|
+
this.panning = false;
|
|
2779
|
+
this.linking = null;
|
|
2780
|
+
this.relinking = null;
|
|
2781
|
+
this.relinkCandidate = null;
|
|
2782
|
+
this.linkSnap = null;
|
|
2783
|
+
this.clearHover();
|
|
2784
|
+
this.emit("contextMenu", { x: pageX, y: pageY, link, node, port });
|
|
2785
|
+
}
|
|
2786
|
+
listen(target, type, listener, options) {
|
|
2787
|
+
target.addEventListener(type, listener, options);
|
|
2788
|
+
this.domDisposables.push(() => target.removeEventListener(type, listener, options));
|
|
2789
|
+
}
|
|
2790
|
+
bind() {
|
|
2791
|
+
const localXY = (e) => {
|
|
2792
|
+
const r = this.canvas.getBoundingClientRect();
|
|
2793
|
+
return [e.clientX - r.left, e.clientY - r.top];
|
|
2794
|
+
};
|
|
2795
|
+
this.listen(this.canvas, "pointerdown", (e) => {
|
|
2796
|
+
try {
|
|
2797
|
+
this.canvas.setPointerCapture(e.pointerId);
|
|
2798
|
+
} catch {
|
|
2799
|
+
}
|
|
2800
|
+
this.cancelLongPress();
|
|
2801
|
+
this.relinkCandidate = null;
|
|
2802
|
+
if (e.pointerType !== "mouse") {
|
|
2803
|
+
this.lpStart = { sx: e.clientX, sy: e.clientY, px: e.clientX, py: e.clientY };
|
|
2804
|
+
const downSx = e.clientX, downSy = e.clientY;
|
|
2805
|
+
const r0 = this.canvas.getBoundingClientRect();
|
|
2806
|
+
const localSx = downSx - r0.left, localSy = downSy - r0.top;
|
|
2807
|
+
this.lpTimer = setTimeout(() => {
|
|
2808
|
+
this.lpTimer = null;
|
|
2809
|
+
this.fireContextMenu(localSx, localSy, downSx, downSy);
|
|
2810
|
+
}, this.lpDelayMs);
|
|
2811
|
+
}
|
|
2812
|
+
this.canvas.focus();
|
|
2813
|
+
const [sx, sy] = localXY(e);
|
|
2814
|
+
if (this.ovHit(sx, sy)) {
|
|
2815
|
+
this.ovDragging = true;
|
|
2816
|
+
this.ovPanTo(sx, sy);
|
|
2817
|
+
return;
|
|
2818
|
+
}
|
|
2819
|
+
if (!this.permissions.inspect) this.cancelLongPress();
|
|
2820
|
+
const [wx, wy] = this.toWorld(sx, sy);
|
|
2821
|
+
const selectedEndpoint = this.selectedLinkEndpointAt(wx, wy);
|
|
2822
|
+
if (e.button === 0 && this.permissions.createLinks && this.selectedLink !== null && selectedEndpoint !== null) {
|
|
2823
|
+
this.relinking = { link: this.selectedLink, end: selectedEndpoint };
|
|
2824
|
+
this.linking = null;
|
|
2825
|
+
this.linkSnap = null;
|
|
2826
|
+
this.cursor = { x: sx, y: sy };
|
|
2827
|
+
return;
|
|
2828
|
+
}
|
|
2829
|
+
const portHit = this.portAt(wx, wy);
|
|
2830
|
+
if (portHit !== null) {
|
|
2831
|
+
const selectedLink = this.selectedLink;
|
|
2832
|
+
const isFrom = selectedLink !== null && portHit.port.direction === "out" && selectedLink.from === portHit.node.id && selectedLink.fromPort === portHit.port.id;
|
|
2833
|
+
const isTo = selectedLink !== null && portHit.port.direction === "in" && selectedLink.to === portHit.node.id && selectedLink.toPort === portHit.port.id;
|
|
2834
|
+
if (this.permissions.inspect) {
|
|
2835
|
+
if (this.permissions.select && !isFrom && !isTo && !this.selectedNodes.has(portHit.node)) {
|
|
2836
|
+
this.selectNode(portHit.node);
|
|
2837
|
+
}
|
|
2838
|
+
this.selectPort(portHit);
|
|
2839
|
+
if (e.button === 0 || e.button === 2) {
|
|
2840
|
+
this.emit("portClicked", {
|
|
2841
|
+
...portHit,
|
|
2842
|
+
action: e.button === 2 ? "rightClick" : "leftClick",
|
|
2843
|
+
ctrlKey: e.ctrlKey,
|
|
2844
|
+
shiftKey: e.shiftKey,
|
|
2845
|
+
altKey: e.altKey,
|
|
2846
|
+
metaKey: e.metaKey
|
|
2847
|
+
});
|
|
2848
|
+
}
|
|
2849
|
+
}
|
|
2850
|
+
if (e.button !== 0) return;
|
|
2851
|
+
if (this.permissions.createLinks && selectedLink !== null) {
|
|
2852
|
+
if (isFrom || isTo) {
|
|
2853
|
+
this.relinking = { link: selectedLink, end: isFrom ? "from" : "to" };
|
|
2854
|
+
this.linking = null;
|
|
2855
|
+
this.linkSnap = null;
|
|
2856
|
+
this.cursor = { x: sx, y: sy };
|
|
2857
|
+
return;
|
|
2858
|
+
}
|
|
2859
|
+
}
|
|
2860
|
+
const directRelink = this.permissions.createLinks ? this.directRelinkAtPort(portHit.node, portHit.port) : null;
|
|
2861
|
+
if (directRelink !== null) {
|
|
2862
|
+
this.relinkCandidate = { ...directRelink, sx, sy };
|
|
2863
|
+
this.linking = null;
|
|
2864
|
+
this.relinking = null;
|
|
2865
|
+
this.linkSnap = null;
|
|
2866
|
+
this.cursor = { x: sx, y: sy };
|
|
2867
|
+
return;
|
|
2868
|
+
}
|
|
2869
|
+
if (this.permissions.createLinks && portHit.port.direction === "out") {
|
|
2870
|
+
this.linking = portHit;
|
|
2871
|
+
this.relinking = null;
|
|
2872
|
+
this.linkSnap = null;
|
|
2873
|
+
this.cursor = { x: sx, y: sy };
|
|
2874
|
+
}
|
|
2875
|
+
return;
|
|
2876
|
+
}
|
|
2877
|
+
const node = this.nodeAt(wx, wy);
|
|
2878
|
+
if (node !== null) {
|
|
2879
|
+
const add = e.shiftKey || e.ctrlKey || e.metaKey;
|
|
2880
|
+
if (this.permissions.select) {
|
|
2881
|
+
if (add && e.button === 0) {
|
|
2882
|
+
this.toggleSelect(node);
|
|
2883
|
+
return;
|
|
2884
|
+
}
|
|
2885
|
+
if (!this.selectedNodes.has(node)) this.selectNode(node);
|
|
2886
|
+
}
|
|
2887
|
+
if (this.permissions.moveNodes && e.button === 0) {
|
|
2888
|
+
this.dragNode = node;
|
|
2889
|
+
this.dragAnchor = { wx, wy };
|
|
2890
|
+
const moving = this.selectedNodes.has(node) ? [...this.selectedNodes] : [node];
|
|
2891
|
+
this.dragStart = moving.map((item) => ({ n: item, x: item.x, y: item.y }));
|
|
2892
|
+
const selected = new Set(moving);
|
|
2893
|
+
this.nodes = this.nodes.filter((item) => !selected.has(item)).concat(moving);
|
|
2894
|
+
}
|
|
2895
|
+
return;
|
|
2896
|
+
}
|
|
2897
|
+
const link = this.linkAt(wx, wy);
|
|
2898
|
+
if (link !== null) {
|
|
2899
|
+
if (this.permissions.select) this.selectLink(link);
|
|
2900
|
+
return;
|
|
2901
|
+
}
|
|
2902
|
+
if (e.button !== 0 && e.button !== 1) return;
|
|
2903
|
+
const wantPan = !this.permissions.select || e.button === 1 || e.ctrlKey || e.altKey || e.pointerType === "touch";
|
|
2904
|
+
if (wantPan) {
|
|
2905
|
+
if (this.permissions.select && !e.shiftKey) this.selectNode(null);
|
|
2906
|
+
this.panning = true;
|
|
2907
|
+
this.panX = sx;
|
|
2908
|
+
this.panY = sy;
|
|
2909
|
+
} else {
|
|
2910
|
+
if (!e.shiftKey) this.selectNode(null);
|
|
2911
|
+
this.rubber = { x0: wx, y0: wy, x: wx, y: wy };
|
|
2912
|
+
}
|
|
2913
|
+
});
|
|
2914
|
+
this.listen(this.canvas, "pointermove", (e) => {
|
|
2915
|
+
const [sx, sy] = localXY(e);
|
|
2916
|
+
this.cursor = { x: sx, y: sy };
|
|
2917
|
+
if (this.lpStart !== null) {
|
|
2918
|
+
const dx = e.clientX - this.lpStart.px;
|
|
2919
|
+
const dy = e.clientY - this.lpStart.py;
|
|
2920
|
+
if (Math.hypot(dx, dy) > this.lpMoveTol) this.cancelLongPress();
|
|
2921
|
+
}
|
|
2922
|
+
if (this.ovDragging) {
|
|
2923
|
+
this.ovPanTo(sx, sy);
|
|
2924
|
+
return;
|
|
2925
|
+
}
|
|
2926
|
+
const [wx, wy] = this.toWorld(sx, sy);
|
|
2927
|
+
if (this.relinkCandidate !== null) {
|
|
2928
|
+
const candidate = this.relinkCandidate;
|
|
2929
|
+
if (Math.hypot(sx - candidate.sx, sy - candidate.sy) <= RELINK_DRAG_THRESHOLD_PX) return;
|
|
2930
|
+
this.relinkCandidate = null;
|
|
2931
|
+
this.relinking = { link: candidate.link, end: candidate.end };
|
|
2932
|
+
if (this.permissions.select) this.selectLink(candidate.link);
|
|
2933
|
+
this.linkSnap = this.findSnap();
|
|
2934
|
+
this.scheduleDraw();
|
|
2935
|
+
return;
|
|
2936
|
+
}
|
|
2937
|
+
if (this.dragNode !== null) {
|
|
2938
|
+
let ddx = wx - this.dragAnchor.wx;
|
|
2939
|
+
let ddy = wy - this.dragAnchor.wy;
|
|
2940
|
+
if (this.gridSnapEnabled) {
|
|
2941
|
+
const primary = this.dragStart.find((item) => item.n === this.dragNode);
|
|
2942
|
+
if (primary !== void 0) {
|
|
2943
|
+
ddx = Math.round((primary.x + ddx) / this.gridSize) * this.gridSize - primary.x;
|
|
2944
|
+
ddy = Math.round((primary.y + ddy) / this.gridSize) * this.gridSize - primary.y;
|
|
2945
|
+
}
|
|
2946
|
+
}
|
|
2947
|
+
for (const it of this.dragStart) {
|
|
2948
|
+
it.n.x = it.x + ddx;
|
|
2949
|
+
it.n.y = it.y + ddy;
|
|
2950
|
+
this.layoutNode(it.n);
|
|
2951
|
+
}
|
|
2952
|
+
this.scheduleDraw();
|
|
2953
|
+
return;
|
|
2954
|
+
}
|
|
2955
|
+
if (this.rubber !== null) {
|
|
2956
|
+
this.rubber.x = wx;
|
|
2957
|
+
this.rubber.y = wy;
|
|
2958
|
+
this.scheduleDraw();
|
|
2959
|
+
return;
|
|
2960
|
+
}
|
|
2961
|
+
if (this.panning) {
|
|
2962
|
+
this.offX += sx - this.panX;
|
|
2963
|
+
this.offY += sy - this.panY;
|
|
2964
|
+
this.panX = sx;
|
|
2965
|
+
this.panY = sy;
|
|
2966
|
+
this.scheduleDraw();
|
|
2967
|
+
return;
|
|
2968
|
+
}
|
|
2969
|
+
if (this.linking !== null || this.relinking !== null) {
|
|
2970
|
+
this.linkSnap = this.findSnap();
|
|
2971
|
+
this.scheduleDraw();
|
|
2972
|
+
return;
|
|
2973
|
+
}
|
|
2974
|
+
if (!this.permissions.inspect) return;
|
|
2975
|
+
let dirty = false;
|
|
2976
|
+
const hit = this.portAt(wx, wy);
|
|
2977
|
+
if ((hit?.port ?? null) !== (this.hoverPort?.port ?? null)) {
|
|
2978
|
+
if (this.hoverPort !== null)
|
|
2979
|
+
this.emit("portHover", { node: this.hoverPort.node, port: this.hoverPort.port, hovering: false });
|
|
2980
|
+
this.hoverPort = hit;
|
|
2981
|
+
if (hit !== null) this.emit("portHover", { node: hit.node, port: hit.port, hovering: true });
|
|
2982
|
+
dirty = true;
|
|
2983
|
+
}
|
|
2984
|
+
const nodeHit = hit === null ? this.nodeAt(wx, wy) : null;
|
|
2985
|
+
if (nodeHit !== this.hoverNode) {
|
|
2986
|
+
if (this.hoverNode !== null) this.emit("nodeHover", { node: this.hoverNode, hovering: false });
|
|
2987
|
+
this.hoverNode = nodeHit;
|
|
2988
|
+
if (nodeHit !== null) this.emit("nodeHover", { node: nodeHit, hovering: true });
|
|
2989
|
+
dirty = true;
|
|
2990
|
+
}
|
|
2991
|
+
const tipTgt = this.hoverPort?.port ?? this.hoverNode;
|
|
2992
|
+
if (tipTgt !== this.tipTarget) {
|
|
2993
|
+
this.tipTarget = tipTgt;
|
|
2994
|
+
this.tipShow = false;
|
|
2995
|
+
if (this.tipTimer !== null) {
|
|
2996
|
+
clearTimeout(this.tipTimer);
|
|
2997
|
+
this.tipTimer = null;
|
|
2998
|
+
}
|
|
2999
|
+
if (tipTgt !== null) {
|
|
3000
|
+
this.tipTimer = setTimeout(() => {
|
|
3001
|
+
this.tipTimer = null;
|
|
3002
|
+
this.tipShow = true;
|
|
3003
|
+
this.scheduleDraw();
|
|
3004
|
+
}, 400);
|
|
3005
|
+
}
|
|
3006
|
+
dirty = true;
|
|
3007
|
+
}
|
|
3008
|
+
const overNode = nodeHit !== null;
|
|
3009
|
+
const linkHit = hit === null && !overNode ? this.linkAt(wx, wy) : null;
|
|
3010
|
+
if (linkHit !== this.hoveredLink) {
|
|
3011
|
+
if (this.hoveredLink !== null) this.emit("linkHover", { link: this.hoveredLink, hovering: false });
|
|
3012
|
+
this.hoveredLink = linkHit;
|
|
3013
|
+
if (linkHit !== null) this.emit("linkHover", { link: linkHit, hovering: true });
|
|
3014
|
+
dirty = true;
|
|
3015
|
+
}
|
|
3016
|
+
const endpointHit = this.permissions.createLinks ? this.selectedLinkEndpointAt(wx, wy) : null;
|
|
3017
|
+
const directRelinkHit = this.permissions.createLinks && hit !== null ? this.directRelinkAtPort(hit.node, hit.port) : null;
|
|
3018
|
+
this.canvas.style.cursor = endpointHit !== null || directRelinkHit !== null ? "grab" : hit !== null ? "pointer" : overNode ? "move" : linkHit !== null ? "pointer" : "default";
|
|
3019
|
+
if (dirty) this.scheduleDraw();
|
|
3020
|
+
});
|
|
3021
|
+
const finish = (e) => {
|
|
3022
|
+
this.cancelLongPress();
|
|
3023
|
+
this.relinkCandidate = null;
|
|
3024
|
+
if (this.dragNode !== null) {
|
|
3025
|
+
const moves = this.dragStart.map((it) => ({ id: it.n.id, fromX: it.x, fromY: it.y, toX: it.n.x, toY: it.n.y })).filter((m) => m.fromX !== m.toX || m.fromY !== m.toY);
|
|
3026
|
+
const moved = new Set(moves.map((m) => m.id));
|
|
3027
|
+
for (const it of this.dragStart) {
|
|
3028
|
+
if (moved.has(it.n.id)) this.emit("nodeMoved", { node: it.n });
|
|
3029
|
+
}
|
|
3030
|
+
this.dragNode = null;
|
|
3031
|
+
this.dragStart = [];
|
|
3032
|
+
if (moves.length > 0) {
|
|
3033
|
+
this.record({
|
|
3034
|
+
do: () => {
|
|
3035
|
+
for (const m of moves) this.doMoveNode(m.id, m.toX, m.toY);
|
|
3036
|
+
},
|
|
3037
|
+
undo: () => {
|
|
3038
|
+
for (const m of moves) this.doMoveNode(m.id, m.fromX, m.fromY);
|
|
3039
|
+
},
|
|
3040
|
+
label: "drag"
|
|
3041
|
+
});
|
|
3042
|
+
}
|
|
3043
|
+
}
|
|
3044
|
+
if (this.rubber !== null) {
|
|
3045
|
+
const rx0 = Math.min(this.rubber.x0, this.rubber.x);
|
|
3046
|
+
const ry0 = Math.min(this.rubber.y0, this.rubber.y);
|
|
3047
|
+
const rx1 = Math.max(this.rubber.x0, this.rubber.x);
|
|
3048
|
+
const ry1 = Math.max(this.rubber.y0, this.rubber.y);
|
|
3049
|
+
this.rubber = null;
|
|
3050
|
+
if (rx1 - rx0 > 3 || ry1 - ry0 > 3) {
|
|
3051
|
+
const inRect = this.nodes.filter((n2) => n2.x < rx1 && n2.x + n2.w > rx0 && n2.y < ry1 && n2.y + n2.h > ry0);
|
|
3052
|
+
const merge = e.shiftKey ? [...this.selectedNodes, ...inRect] : inRect;
|
|
3053
|
+
this.setSelection([...new Set(merge)]);
|
|
3054
|
+
}
|
|
3055
|
+
this.scheduleDraw();
|
|
3056
|
+
}
|
|
3057
|
+
const viewportMoved = this.panning || this.ovDragging;
|
|
3058
|
+
this.panning = false;
|
|
3059
|
+
this.ovDragging = false;
|
|
3060
|
+
if (viewportMoved) this.emitViewChanged(false);
|
|
3061
|
+
if (this.linking !== null) {
|
|
3062
|
+
const [sx, sy] = localXY(e);
|
|
3063
|
+
const [wx, wy] = this.toWorld(sx, sy);
|
|
3064
|
+
const direct = this.portAt(wx, wy);
|
|
3065
|
+
const target = this.linkSnap ?? (direct !== null && direct.port.direction === "in" ? direct : null);
|
|
3066
|
+
if (target !== null) {
|
|
3067
|
+
this.addLink({
|
|
3068
|
+
from: this.linking.node.id,
|
|
3069
|
+
fromPort: this.linking.port.id,
|
|
3070
|
+
to: target.node.id,
|
|
3071
|
+
toPort: target.port.id
|
|
3072
|
+
});
|
|
3073
|
+
}
|
|
3074
|
+
this.linking = null;
|
|
3075
|
+
this.linkSnap = null;
|
|
3076
|
+
this.scheduleDraw();
|
|
3077
|
+
}
|
|
3078
|
+
if (this.relinking !== null) {
|
|
3079
|
+
const gesture = this.relinking;
|
|
3080
|
+
const [sx, sy] = localXY(e);
|
|
3081
|
+
const [wx, wy] = this.toWorld(sx, sy);
|
|
3082
|
+
const direct = this.portAt(wx, wy);
|
|
3083
|
+
const expectedDirection = gesture.end === "from" ? "out" : "in";
|
|
3084
|
+
const target = this.linkSnap ?? (direct !== null && direct.port.direction === expectedDirection ? direct : null);
|
|
3085
|
+
if (target !== null) {
|
|
3086
|
+
this.relink(gesture.link.id, {
|
|
3087
|
+
from: gesture.end === "from" ? target.node.id : gesture.link.from,
|
|
3088
|
+
fromPort: gesture.end === "from" ? target.port.id : gesture.link.fromPort,
|
|
3089
|
+
to: gesture.end === "to" ? target.node.id : gesture.link.to,
|
|
3090
|
+
toPort: gesture.end === "to" ? target.port.id : gesture.link.toPort
|
|
3091
|
+
});
|
|
3092
|
+
}
|
|
3093
|
+
this.relinking = null;
|
|
3094
|
+
this.linkSnap = null;
|
|
3095
|
+
this.scheduleDraw();
|
|
3096
|
+
}
|
|
3097
|
+
};
|
|
3098
|
+
this.listen(window, "pointerup", finish);
|
|
3099
|
+
this.listen(window, "pointercancel", finish);
|
|
3100
|
+
this.listen(this.canvas, "wheel", (e) => {
|
|
3101
|
+
e.preventDefault();
|
|
3102
|
+
const [sx, sy] = localXY(e);
|
|
3103
|
+
const [wx, wy] = this.toWorld(sx, sy);
|
|
3104
|
+
const factor = Math.exp(-e.deltaY * 15e-4);
|
|
3105
|
+
this.scale = clamp(this.scale * factor, ZOOM_MIN, ZOOM_MAX);
|
|
3106
|
+
this.offX = sx - wx * this.scale;
|
|
3107
|
+
this.offY = sy - wy * this.scale;
|
|
3108
|
+
this.emitViewChanged(true);
|
|
3109
|
+
this.scheduleDraw();
|
|
3110
|
+
}, { passive: false });
|
|
3111
|
+
this.listen(this.canvas, "pointerleave", () => this.clearHover());
|
|
3112
|
+
this.listen(this.canvas, "dblclick", (e) => {
|
|
3113
|
+
const [sx, sy] = localXY(e);
|
|
3114
|
+
const [wx, wy] = this.toWorld(sx, sy);
|
|
3115
|
+
if (this.portAt(wx, wy) !== null) return;
|
|
3116
|
+
if (this.linkAt(wx, wy) !== null) return;
|
|
3117
|
+
const node = this.nodeAt(wx, wy);
|
|
3118
|
+
if (node !== null) {
|
|
3119
|
+
if (this.permissions.inspect && node.openAction.length > 0) {
|
|
3120
|
+
e.preventDefault();
|
|
3121
|
+
this.emit("nodeOpen", { node });
|
|
3122
|
+
}
|
|
3123
|
+
return;
|
|
3124
|
+
}
|
|
3125
|
+
this.zoomToFit();
|
|
3126
|
+
});
|
|
3127
|
+
this.listen(this.canvas, "contextmenu", (e) => {
|
|
3128
|
+
e.preventDefault();
|
|
3129
|
+
if (!this.permissions.inspect) return;
|
|
3130
|
+
const [sx, sy] = localXY(e);
|
|
3131
|
+
this.cancelLongPress();
|
|
3132
|
+
this.fireContextMenu(sx, sy, e.clientX, e.clientY);
|
|
3133
|
+
});
|
|
3134
|
+
let pinchDist = 0;
|
|
3135
|
+
let pinchScale = 1;
|
|
3136
|
+
let pinchPivotW = [0, 0];
|
|
3137
|
+
let pinchPivotS = [0, 0];
|
|
3138
|
+
let pinching = false;
|
|
3139
|
+
this.listen(this.canvas, "touchstart", (e) => {
|
|
3140
|
+
if (e.touches.length === 2) {
|
|
3141
|
+
e.preventDefault();
|
|
3142
|
+
const r = this.canvas.getBoundingClientRect();
|
|
3143
|
+
const t0 = e.touches[0], t1 = e.touches[1];
|
|
3144
|
+
const x0 = t0.clientX - r.left, y0 = t0.clientY - r.top;
|
|
3145
|
+
const x1 = t1.clientX - r.left, y1 = t1.clientY - r.top;
|
|
3146
|
+
pinchDist = Math.max(1, Math.hypot(x1 - x0, y1 - y0));
|
|
3147
|
+
pinchScale = this.scale;
|
|
3148
|
+
const mx = (x0 + x1) / 2, my = (y0 + y1) / 2;
|
|
3149
|
+
pinchPivotS = [mx, my];
|
|
3150
|
+
pinchPivotW = this.toWorld(mx, my);
|
|
3151
|
+
pinching = true;
|
|
3152
|
+
this.dragNode = null;
|
|
3153
|
+
this.dragStart = [];
|
|
3154
|
+
this.panning = false;
|
|
3155
|
+
this.rubber = null;
|
|
3156
|
+
this.linking = null;
|
|
3157
|
+
this.relinking = null;
|
|
3158
|
+
this.relinkCandidate = null;
|
|
3159
|
+
this.ovDragging = false;
|
|
3160
|
+
this.linkSnap = null;
|
|
3161
|
+
}
|
|
3162
|
+
}, { passive: false });
|
|
3163
|
+
this.listen(this.canvas, "touchmove", (e) => {
|
|
3164
|
+
if (pinching && e.touches.length === 2) {
|
|
3165
|
+
e.preventDefault();
|
|
3166
|
+
const r = this.canvas.getBoundingClientRect();
|
|
3167
|
+
const t0 = e.touches[0], t1 = e.touches[1];
|
|
3168
|
+
const x0 = t0.clientX - r.left, y0 = t0.clientY - r.top;
|
|
3169
|
+
const x1 = t1.clientX - r.left, y1 = t1.clientY - r.top;
|
|
3170
|
+
const d = Math.max(1, Math.hypot(x1 - x0, y1 - y0));
|
|
3171
|
+
const ns = clamp(pinchScale * (d / pinchDist), ZOOM_MIN, ZOOM_MAX);
|
|
3172
|
+
this.scale = ns;
|
|
3173
|
+
this.offX = pinchPivotS[0] - pinchPivotW[0] * ns;
|
|
3174
|
+
this.offY = pinchPivotS[1] - pinchPivotW[1] * ns;
|
|
3175
|
+
this.emitViewChanged(true);
|
|
3176
|
+
this.scheduleDraw();
|
|
3177
|
+
}
|
|
3178
|
+
}, { passive: false });
|
|
3179
|
+
const endPinch = () => {
|
|
3180
|
+
pinching = false;
|
|
3181
|
+
};
|
|
3182
|
+
this.listen(this.canvas, "touchend", endPinch);
|
|
3183
|
+
this.listen(this.canvas, "touchcancel", endPinch);
|
|
3184
|
+
this.listen(this.canvas, "keydown", (e) => {
|
|
3185
|
+
if (this.permissions.moveNodes && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
3186
|
+
const step = (this.gridSnapEnabled ? this.gridSize : 1) * (e.shiftKey ? 5 : 1);
|
|
3187
|
+
const delta = e.key === "ArrowLeft" ? [-step, 0] : e.key === "ArrowRight" ? [step, 0] : e.key === "ArrowUp" ? [0, -step] : e.key === "ArrowDown" ? [0, step] : null;
|
|
3188
|
+
if (delta !== null && this.nudgeSelection(delta[0], delta[1])) {
|
|
3189
|
+
e.preventDefault();
|
|
3190
|
+
return;
|
|
3191
|
+
}
|
|
3192
|
+
}
|
|
3193
|
+
if (this.permissions.deleteSelection && (e.key === "Delete" || e.key === "Backspace")) {
|
|
3194
|
+
e.preventDefault();
|
|
3195
|
+
this.deleteSelection();
|
|
3196
|
+
return;
|
|
3197
|
+
}
|
|
3198
|
+
const mod = e.ctrlKey || e.metaKey;
|
|
3199
|
+
if (mod && this.permissions.copy && e.code === "KeyC") {
|
|
3200
|
+
e.preventDefault();
|
|
3201
|
+
this.copySelection();
|
|
3202
|
+
return;
|
|
3203
|
+
}
|
|
3204
|
+
if (mod && this.permissions.paste && e.code === "KeyV") {
|
|
3205
|
+
e.preventDefault();
|
|
3206
|
+
this.pasteSelection();
|
|
3207
|
+
return;
|
|
3208
|
+
}
|
|
3209
|
+
if (mod && this.permissions.copy && this.permissions.deleteSelection && e.code === "KeyX") {
|
|
3210
|
+
e.preventDefault();
|
|
3211
|
+
this.cutSelection();
|
|
3212
|
+
return;
|
|
3213
|
+
}
|
|
3214
|
+
if (mod && this.permissions.select && e.code === "KeyA") {
|
|
3215
|
+
e.preventDefault();
|
|
3216
|
+
this.setSelection(this.nodes.slice());
|
|
3217
|
+
return;
|
|
3218
|
+
}
|
|
3219
|
+
if (mod && this.permissions.history && e.code === "KeyZ" && !e.shiftKey) {
|
|
3220
|
+
e.preventDefault();
|
|
3221
|
+
this.historyShortcut("undo");
|
|
3222
|
+
return;
|
|
3223
|
+
}
|
|
3224
|
+
if (mod && this.permissions.history && (e.code === "KeyY" || e.code === "KeyZ" && e.shiftKey)) {
|
|
3225
|
+
e.preventDefault();
|
|
3226
|
+
this.historyShortcut("redo");
|
|
3227
|
+
return;
|
|
3228
|
+
}
|
|
3229
|
+
});
|
|
3230
|
+
}
|
|
3231
|
+
// ---- drawing ----------------------------------------------------
|
|
3232
|
+
scheduleDraw() {
|
|
3233
|
+
if (this.destroyed || this.drawScheduled) return;
|
|
3234
|
+
this.drawScheduled = true;
|
|
3235
|
+
requestAnimationFrame(() => {
|
|
3236
|
+
this.drawScheduled = false;
|
|
3237
|
+
if (!this.destroyed) this.draw();
|
|
3238
|
+
});
|
|
3239
|
+
}
|
|
3240
|
+
draw(options = SCREEN_RENDER_OPTIONS) {
|
|
3241
|
+
const ctx = this.ctx;
|
|
3242
|
+
ctx.clearRect(0, 0, this.width, this.height);
|
|
3243
|
+
ctx.fillStyle = this.opts.background ?? "#1b1b1f";
|
|
3244
|
+
ctx.fillRect(0, 0, this.width, this.height);
|
|
3245
|
+
let introDy = 0;
|
|
3246
|
+
let introAlpha = 1;
|
|
3247
|
+
if (options.transient && this.introStart !== null) {
|
|
3248
|
+
const e = Math.min(1, (performance.now() - this.introStart) / INTRO_MS);
|
|
3249
|
+
const k = 1 - Math.pow(1 - e, 3);
|
|
3250
|
+
introAlpha = k;
|
|
3251
|
+
introDy = (1 - k) * INTRO_RISE;
|
|
3252
|
+
if (e >= 1) this.introStart = null;
|
|
3253
|
+
}
|
|
3254
|
+
ctx.save();
|
|
3255
|
+
ctx.globalAlpha = introAlpha;
|
|
3256
|
+
if (options.grid) this.drawGrid();
|
|
3257
|
+
ctx.setTransform(
|
|
3258
|
+
this.dpr * this.scale,
|
|
3259
|
+
0,
|
|
3260
|
+
0,
|
|
3261
|
+
this.dpr * this.scale,
|
|
3262
|
+
this.dpr * this.offX,
|
|
3263
|
+
this.dpr * (this.offY + introDy)
|
|
3264
|
+
);
|
|
3265
|
+
this.drawZones();
|
|
3266
|
+
const prior = [];
|
|
3267
|
+
const hoveredNode = this.hoverPort?.node ?? this.hoverNode;
|
|
3268
|
+
for (const l of this.links) {
|
|
3269
|
+
const a = this.endpoint(l, "from");
|
|
3270
|
+
const b = this.endpoint(l, "to");
|
|
3271
|
+
if (a === null || b === null) continue;
|
|
3272
|
+
const fp = this.nodes.find((n2) => n2.id === l.from)?.outPorts.find((p) => p.id === l.fromPort);
|
|
3273
|
+
const baseColor = (this.opts.typedLinkColors ?? false) && fp !== void 0 ? this.portColor(fp.type) : this.portColor("");
|
|
3274
|
+
const state = options.selection && l === this.selectedLink ? "sel" : options.transient && (l === this.hoveredLink || hoveredNode !== null && (l.from === hoveredNode.id || l.to === hoveredNode.id)) ? "hov" : "norm";
|
|
3275
|
+
const color = state === "sel" ? this.selectionColor() : state === "hov" ? this.linkHoverColor() : baseColor;
|
|
3276
|
+
const width = state === "sel" ? 3 : state === "hov" ? 2.6 : 2;
|
|
3277
|
+
const pts = this.routeLink(a, b, /* @__PURE__ */ new Set([l.from, l.to]));
|
|
3278
|
+
this.strokeRoute(pts, color, width, prior, l.style);
|
|
3279
|
+
this.drawArrow(pts, color);
|
|
3280
|
+
for (let k = 1; k < pts.length; k += 1) {
|
|
3281
|
+
const [x1, y1] = pts[k - 1];
|
|
3282
|
+
const [x2, y2] = pts[k];
|
|
3283
|
+
if (x1 === x2 && y1 !== y2) prior.push({ h: false, c: x1, a: Math.min(y1, y2), b: Math.max(y1, y2) });
|
|
3284
|
+
else if (y1 === y2 && x1 !== x2) prior.push({ h: true, c: y1, a: Math.min(x1, x2), b: Math.max(x1, x2) });
|
|
3285
|
+
}
|
|
3286
|
+
}
|
|
3287
|
+
if (options.transient && (this.linking !== null || this.relinking !== null)) this.drawPendingLink();
|
|
3288
|
+
for (const n2 of this.nodes) this.drawNode(n2, options.selection && this.selectedNodes.has(n2), options);
|
|
3289
|
+
if (options.selection && this.permissions.createLinks && this.relinking === null)
|
|
3290
|
+
this.drawSelectedLinkEndpoints();
|
|
3291
|
+
if (options.transient && this.rubber !== null) {
|
|
3292
|
+
const rx = Math.min(this.rubber.x0, this.rubber.x);
|
|
3293
|
+
const ry = Math.min(this.rubber.y0, this.rubber.y);
|
|
3294
|
+
const rw = Math.abs(this.rubber.x - this.rubber.x0);
|
|
3295
|
+
const rh = Math.abs(this.rubber.y - this.rubber.y0);
|
|
3296
|
+
ctx.fillStyle = "rgba(74,163,255,0.12)";
|
|
3297
|
+
ctx.fillRect(rx, ry, rw, rh);
|
|
3298
|
+
ctx.strokeStyle = "#4aa3ff";
|
|
3299
|
+
ctx.lineWidth = 1 / this.scale;
|
|
3300
|
+
ctx.strokeRect(rx, ry, rw, rh);
|
|
3301
|
+
}
|
|
3302
|
+
ctx.restore();
|
|
3303
|
+
if (options.overview) this.drawOverview();
|
|
3304
|
+
if (options.runtime) this.drawGlobalError(options.transient);
|
|
3305
|
+
if (options.transient) this.drawTooltip();
|
|
3306
|
+
if (options.transient && (this.introStart !== null || this.globalErrorFlashStart !== null || this.nodes.some((n2) => n2.errorFlashStart !== null)))
|
|
3307
|
+
this.scheduleDraw();
|
|
3308
|
+
}
|
|
3309
|
+
/**
|
|
3310
|
+
* `transient` gates the flash exactly as it gates every other animation, which an export must
|
|
3311
|
+
* not see: it would bake in whatever point of the pulse it caught, so the same export taken
|
|
3312
|
+
* twice differed in opacity. The gate also keeps the export from consuming the animation --
|
|
3313
|
+
* clearing globalErrorFlashStart from an export cut the flash short on screen.
|
|
3314
|
+
*/
|
|
3315
|
+
drawGlobalError(transient) {
|
|
3316
|
+
const error = this.runtimeState.globalError;
|
|
3317
|
+
if (error === null) return;
|
|
3318
|
+
const ctx = this.ctx;
|
|
3319
|
+
let flashAlpha = 1;
|
|
3320
|
+
if (transient && this.globalErrorFlashStart !== null) {
|
|
3321
|
+
const elapsed = performance.now() - this.globalErrorFlashStart;
|
|
3322
|
+
if (elapsed >= ERROR_FLASH_MS) {
|
|
3323
|
+
this.globalErrorFlashStart = null;
|
|
3324
|
+
} else {
|
|
3325
|
+
flashAlpha = 0.7 + 0.3 * (0.5 + 0.5 * Math.sin(
|
|
3326
|
+
elapsed / ERROR_FLASH_MS * Math.PI * 6 - Math.PI / 2
|
|
3327
|
+
));
|
|
3328
|
+
}
|
|
3329
|
+
}
|
|
3330
|
+
const neutral = error.kind === "locked" || error.kind === "encrypted";
|
|
3331
|
+
ctx.save();
|
|
3332
|
+
ctx.globalAlpha = flashAlpha;
|
|
3333
|
+
ctx.fillStyle = neutral ? "rgba(18,20,26,0.82)" : "rgba(55,16,22,0.78)";
|
|
3334
|
+
ctx.fillRect(0, 0, this.width, this.height);
|
|
3335
|
+
ctx.font = "600 15px Segoe UI, Tahoma, sans-serif";
|
|
3336
|
+
const lines = this.wrapTooltip(error.message, Math.max(120, Math.min(520, this.width - 80)));
|
|
3337
|
+
const lineHeight = 21;
|
|
3338
|
+
const boxWidth = Math.min(
|
|
3339
|
+
this.width - 32,
|
|
3340
|
+
Math.max(180, ...lines.map((line) => ctx.measureText(line).width + 36))
|
|
3341
|
+
);
|
|
3342
|
+
const boxHeight = lines.length * lineHeight + 32;
|
|
3343
|
+
const x = (this.width - boxWidth) / 2;
|
|
3344
|
+
const y = (this.height - boxHeight) / 2;
|
|
3345
|
+
roundRect(ctx, x, y, boxWidth, boxHeight, 8);
|
|
3346
|
+
ctx.fillStyle = neutral ? "rgba(36,39,48,0.98)" : "rgba(75,22,31,0.98)";
|
|
3347
|
+
ctx.fill();
|
|
3348
|
+
ctx.strokeStyle = neutral ? "#858b98" : ERROR_RED;
|
|
3349
|
+
ctx.lineWidth = 2;
|
|
3350
|
+
ctx.stroke();
|
|
3351
|
+
ctx.fillStyle = "#ffffff";
|
|
3352
|
+
ctx.textAlign = "center";
|
|
3353
|
+
ctx.textBaseline = "middle";
|
|
3354
|
+
lines.forEach((line, index) => {
|
|
3355
|
+
ctx.fillText(line, this.width / 2, y + 16 + lineHeight * index + lineHeight / 2);
|
|
3356
|
+
});
|
|
3357
|
+
ctx.restore();
|
|
3358
|
+
}
|
|
3359
|
+
drawTooltip() {
|
|
3360
|
+
if (!this.tipShow) return;
|
|
3361
|
+
if (this.dragNode !== null || this.linking !== null || this.relinking !== null || this.panning || this.ovDragging) return;
|
|
3362
|
+
let text = "";
|
|
3363
|
+
let isError = false;
|
|
3364
|
+
if (this.hoverPort !== null) {
|
|
3365
|
+
const p = this.hoverPort.port;
|
|
3366
|
+
text = p.type ? `${p.name} \xB7 ${p.type}` : p.name;
|
|
3367
|
+
const runtime = this.portRuntimeState(this.hoverPort.node, p);
|
|
3368
|
+
if (runtime?.value !== null && runtime?.value !== void 0) text += `
|
|
3369
|
+
Value: ${runtime.value}`;
|
|
3370
|
+
if (runtime?.error !== null && runtime?.error !== void 0) {
|
|
3371
|
+
text += `
|
|
3372
|
+
${runtime.error}`;
|
|
3373
|
+
isError = true;
|
|
3374
|
+
}
|
|
3375
|
+
} else if (this.hoverNode !== null) {
|
|
3376
|
+
const n2 = this.hoverNode;
|
|
3377
|
+
const errors = [n2.runtimeError, n2.loadError].filter((value) => value.length > 0);
|
|
3378
|
+
if (errors.length > 0) {
|
|
3379
|
+
text = errors.join("\n");
|
|
3380
|
+
isError = true;
|
|
3381
|
+
} else if (this.showNodeMessages && n2.message.length > 0) {
|
|
3382
|
+
text = n2.message;
|
|
3383
|
+
} else if (n2.description.length > 0) {
|
|
3384
|
+
text = n2.description;
|
|
3385
|
+
} else {
|
|
3386
|
+
text = n2.typeId && n2.typeId !== n2.name ? `${n2.name} [${n2.typeId}]` : n2.name;
|
|
3387
|
+
}
|
|
3388
|
+
} else {
|
|
3389
|
+
return;
|
|
3390
|
+
}
|
|
3391
|
+
const ctx = this.ctx;
|
|
3392
|
+
ctx.font = "11px Segoe UI, Tahoma, sans-serif";
|
|
3393
|
+
const padX = 9;
|
|
3394
|
+
const padY = 6;
|
|
3395
|
+
const lineH = 15;
|
|
3396
|
+
const lines = this.wrapTooltip(text, 340);
|
|
3397
|
+
const h = lines.length * lineH + padY * 2;
|
|
3398
|
+
const w = Math.max(...lines.map((line) => ctx.measureText(line).width)) + padX * 2;
|
|
3399
|
+
let x = this.cursor.x + 14;
|
|
3400
|
+
let y = this.cursor.y + 18;
|
|
3401
|
+
if (x + w > this.width) x = this.width - w - 4;
|
|
3402
|
+
if (y + h > this.height) y = this.cursor.y - h - 10;
|
|
3403
|
+
roundRect(ctx, x, y, w, h, 4);
|
|
3404
|
+
ctx.fillStyle = isError ? "rgba(55,16,22,0.97)" : "rgba(18,20,26,0.96)";
|
|
3405
|
+
ctx.fill();
|
|
3406
|
+
ctx.lineWidth = 1;
|
|
3407
|
+
ctx.strokeStyle = isError ? ERROR_RED : "#3a3a46";
|
|
3408
|
+
ctx.stroke();
|
|
3409
|
+
ctx.fillStyle = "#e8e8ee";
|
|
3410
|
+
ctx.textBaseline = "middle";
|
|
3411
|
+
ctx.textAlign = "left";
|
|
3412
|
+
lines.forEach((line, index) => {
|
|
3413
|
+
ctx.fillText(line, x + padX, y + padY + lineH * index + lineH / 2);
|
|
3414
|
+
});
|
|
3415
|
+
}
|
|
3416
|
+
wrapTooltip(text, maxWidth) {
|
|
3417
|
+
const lines = [];
|
|
3418
|
+
for (const paragraph of text.split(/\r?\n/)) {
|
|
3419
|
+
const words = paragraph.split(/\s+/).filter(Boolean);
|
|
3420
|
+
if (words.length === 0) {
|
|
3421
|
+
lines.push("");
|
|
3422
|
+
continue;
|
|
3423
|
+
}
|
|
3424
|
+
let line = words[0];
|
|
3425
|
+
for (let i = 1; i < words.length; i += 1) {
|
|
3426
|
+
const next = `${line} ${words[i]}`;
|
|
3427
|
+
if (this.ctx.measureText(next).width <= maxWidth) line = next;
|
|
3428
|
+
else {
|
|
3429
|
+
lines.push(line);
|
|
3430
|
+
line = words[i];
|
|
3431
|
+
}
|
|
3432
|
+
}
|
|
3433
|
+
lines.push(line);
|
|
3434
|
+
}
|
|
3435
|
+
return lines.length > 0 ? lines : [""];
|
|
3436
|
+
}
|
|
3437
|
+
drawZones() {
|
|
3438
|
+
if (this.zones.length === 0) return;
|
|
3439
|
+
const ctx = this.ctx;
|
|
3440
|
+
for (const zone of this.zones) {
|
|
3441
|
+
const tint = zone.color.length > 0 ? zone.color : this.opts.zoneColor ?? "#8a8f98";
|
|
3442
|
+
ctx.save();
|
|
3443
|
+
roundRect(ctx, zone.x, zone.y, zone.width, zone.height, 10);
|
|
3444
|
+
ctx.globalAlpha = 0.12;
|
|
3445
|
+
ctx.fillStyle = tint;
|
|
3446
|
+
ctx.fill();
|
|
3447
|
+
ctx.globalAlpha = 0.5;
|
|
3448
|
+
ctx.setLineDash([10, 6]);
|
|
3449
|
+
ctx.lineWidth = 1.5;
|
|
3450
|
+
ctx.strokeStyle = tint;
|
|
3451
|
+
ctx.stroke();
|
|
3452
|
+
ctx.restore();
|
|
3453
|
+
if (zone.name.length === 0) continue;
|
|
3454
|
+
ctx.save();
|
|
3455
|
+
ctx.globalAlpha = 0.75;
|
|
3456
|
+
ctx.fillStyle = tint;
|
|
3457
|
+
ctx.font = "600 12px Segoe UI, Tahoma, sans-serif";
|
|
3458
|
+
ctx.textAlign = "left";
|
|
3459
|
+
ctx.textBaseline = "top";
|
|
3460
|
+
ctx.fillText(zone.name, zone.x + 12, zone.y + 9, Math.max(0, zone.width - 24));
|
|
3461
|
+
ctx.restore();
|
|
3462
|
+
}
|
|
3463
|
+
}
|
|
3464
|
+
drawGrid() {
|
|
3465
|
+
const ctx = this.ctx;
|
|
3466
|
+
const step = this.gridSize * this.scale;
|
|
3467
|
+
if (step < 6) return;
|
|
3468
|
+
ctx.strokeStyle = this.opts.gridColor ?? "#26262c";
|
|
3469
|
+
ctx.lineWidth = 1;
|
|
3470
|
+
const ox = this.offX % step;
|
|
3471
|
+
const oy = this.offY % step;
|
|
3472
|
+
ctx.beginPath();
|
|
3473
|
+
for (let x = ox; x < this.width; x += step) {
|
|
3474
|
+
ctx.moveTo(x + 0.5, 0);
|
|
3475
|
+
ctx.lineTo(x + 0.5, this.height);
|
|
3476
|
+
}
|
|
3477
|
+
for (let y = oy; y < this.height; y += step) {
|
|
3478
|
+
ctx.moveTo(0, y + 0.5);
|
|
3479
|
+
ctx.lineTo(this.width, y + 0.5);
|
|
3480
|
+
}
|
|
3481
|
+
ctx.stroke();
|
|
3482
|
+
}
|
|
3483
|
+
// Lazily load + cache a node-icon image by URL; repaints once it decodes.
|
|
3484
|
+
getIcon(url) {
|
|
3485
|
+
const cached = this.iconCache.get(url);
|
|
3486
|
+
if (cached !== void 0) return cached;
|
|
3487
|
+
this.iconCache.set(url, null);
|
|
3488
|
+
const img = new Image();
|
|
3489
|
+
img.onload = () => {
|
|
3490
|
+
this.iconCache.set(url, img);
|
|
3491
|
+
this.scheduleDraw();
|
|
3492
|
+
};
|
|
3493
|
+
img.onerror = () => {
|
|
3494
|
+
this.iconCache.set(url, null);
|
|
3495
|
+
};
|
|
3496
|
+
img.src = url;
|
|
3497
|
+
return null;
|
|
3498
|
+
}
|
|
3499
|
+
drawNode(n2, selected, options) {
|
|
3500
|
+
const ctx = this.ctx;
|
|
3501
|
+
const hasLoadError = options.runtime && n2.loadError.length > 0;
|
|
3502
|
+
const hasRuntimeError = options.runtime && n2.runtimeError.length > 0;
|
|
3503
|
+
const runtime = options.runtime ? getJsonKey(this.runtimeState.nodes, n2.id) : void 0;
|
|
3504
|
+
const active = options.runtime && (this.runtimeState.activeNodeId === n2.id || runtime?.active === true);
|
|
3505
|
+
roundRect(ctx, n2.x, n2.y, n2.w, n2.h, 6);
|
|
3506
|
+
ctx.fillStyle = hasLoadError ? ERROR_BACKGROUND : active ? "#ffd1dc" : n2.color;
|
|
3507
|
+
ctx.fill();
|
|
3508
|
+
ctx.lineWidth = selected ? 2 : 1.5;
|
|
3509
|
+
ctx.strokeStyle = selected ? this.selectionColor() : n2.border;
|
|
3510
|
+
ctx.stroke();
|
|
3511
|
+
if (hasLoadError || hasRuntimeError) {
|
|
3512
|
+
let alpha = 1;
|
|
3513
|
+
if (options.transient && hasRuntimeError && n2.errorFlashStart !== null) {
|
|
3514
|
+
const elapsed = performance.now() - n2.errorFlashStart;
|
|
3515
|
+
if (elapsed >= ERROR_FLASH_MS) {
|
|
3516
|
+
n2.errorFlashStart = null;
|
|
3517
|
+
} else {
|
|
3518
|
+
alpha = 0.12 + 0.88 * (0.5 + 0.5 * Math.sin(
|
|
3519
|
+
elapsed / ERROR_FLASH_MS * Math.PI * 6 - Math.PI / 2
|
|
3520
|
+
));
|
|
3521
|
+
}
|
|
3522
|
+
}
|
|
3523
|
+
ctx.save();
|
|
3524
|
+
ctx.globalAlpha *= alpha;
|
|
3525
|
+
roundRect(ctx, n2.x, n2.y, n2.w, n2.h, 6);
|
|
3526
|
+
ctx.lineWidth = hasRuntimeError ? 3 : 2;
|
|
3527
|
+
ctx.strokeStyle = ERROR_RED;
|
|
3528
|
+
ctx.stroke();
|
|
3529
|
+
ctx.restore();
|
|
3530
|
+
}
|
|
3531
|
+
const iconW = 18;
|
|
3532
|
+
if (n2.icon) {
|
|
3533
|
+
const img = this.getIcon(n2.icon);
|
|
3534
|
+
if (img) {
|
|
3535
|
+
try {
|
|
3536
|
+
ctx.drawImage(img, n2.x + 8, n2.y + (n2.h - iconW) / 2, iconW, iconW);
|
|
3537
|
+
} catch {
|
|
3538
|
+
}
|
|
3539
|
+
}
|
|
3540
|
+
}
|
|
3541
|
+
const titleShift = n2.icon ? iconW + 4 : 0;
|
|
3542
|
+
ctx.fillStyle = hasLoadError ? "#ffffff" : "#1b1b1b";
|
|
3543
|
+
ctx.font = "600 12px Segoe UI, Tahoma, sans-serif";
|
|
3544
|
+
ctx.textAlign = "center";
|
|
3545
|
+
ctx.textBaseline = "middle";
|
|
3546
|
+
ctx.fillText(n2.name, n2.x + titleShift + (n2.w - titleShift) / 2, n2.y + n2.h / 2, n2.w - titleShift - 14);
|
|
3547
|
+
for (const p of n2.inPorts) this.drawPort(n2, p, options);
|
|
3548
|
+
for (const p of n2.outPorts) this.drawPort(n2, p, options);
|
|
3549
|
+
}
|
|
3550
|
+
drawPort(node, p, options) {
|
|
3551
|
+
const ctx = this.ctx;
|
|
3552
|
+
const hovered = options.transient && this.hoverPort?.port === p;
|
|
3553
|
+
const magnet = options.transient && this.linkSnap?.port === p;
|
|
3554
|
+
const runtime = options.runtime ? this.portRuntimeState(node, p) : null;
|
|
3555
|
+
const s = magnet ? PORT_SQ + 4 : hovered ? PORT_SQ + 2 : PORT_SQ;
|
|
3556
|
+
const x = p.cx - s / 2;
|
|
3557
|
+
const y = p.cy - s / 2;
|
|
3558
|
+
const r = Math.min(2.5, s / 4);
|
|
3559
|
+
if (magnet) {
|
|
3560
|
+
roundRect(ctx, x - 4, y - 4, s + 8, s + 8, r + 2);
|
|
3561
|
+
ctx.fillStyle = "rgba(255,255,255,0.16)";
|
|
3562
|
+
ctx.fill();
|
|
3563
|
+
}
|
|
3564
|
+
roundRect(ctx, x, y, s, s, r);
|
|
3565
|
+
ctx.fillStyle = this.portColor(p.type);
|
|
3566
|
+
ctx.fill();
|
|
3567
|
+
ctx.lineWidth = magnet ? 1.5 : 1;
|
|
3568
|
+
ctx.strokeStyle = magnet ? "#ffffff" : "rgba(12,12,16,0.55)";
|
|
3569
|
+
ctx.stroke();
|
|
3570
|
+
const runtimeSelected = runtime?.selected === true || options.selection && this.selectedPort?.port === p;
|
|
3571
|
+
const breakpoint = runtime?.breakpoint === true;
|
|
3572
|
+
const breakpointActive = runtime?.breakpointActive === true;
|
|
3573
|
+
const runtimeActive = runtime?.active === true;
|
|
3574
|
+
const hasError = runtime?.error !== null && runtime?.error !== void 0;
|
|
3575
|
+
if (runtimeSelected || breakpoint || breakpointActive || runtimeActive || hasError) {
|
|
3576
|
+
ctx.beginPath();
|
|
3577
|
+
ctx.arc(p.cx, p.cy, s / 2 + 4, 0, Math.PI * 2);
|
|
3578
|
+
if (breakpointActive) {
|
|
3579
|
+
ctx.fillStyle = "#8b0000";
|
|
3580
|
+
ctx.fill();
|
|
3581
|
+
} else if (runtimeSelected) {
|
|
3582
|
+
ctx.fillStyle = "rgba(211,211,211,0.56)";
|
|
3583
|
+
ctx.fill();
|
|
3584
|
+
}
|
|
3585
|
+
ctx.lineWidth = 2;
|
|
3586
|
+
ctx.strokeStyle = hasError || breakpointActive ? ERROR_RED : breakpoint ? "#ffb6c1" : runtimeActive ? "#ffffff" : "#d3d3d3";
|
|
3587
|
+
ctx.stroke();
|
|
3588
|
+
}
|
|
3589
|
+
}
|
|
3590
|
+
portRuntimeState(node, port) {
|
|
3591
|
+
return getJsonKey(this.runtimeState.nodes, node.id)?.ports[port.direction][port.id] ?? null;
|
|
3592
|
+
}
|
|
3593
|
+
// Symmetric jump-over: any segment of THIS link hops the perpendicular
|
|
3594
|
+
// segments of links drawn before it (H over earlier V, V over earlier
|
|
3595
|
+
// H). Exactly one bridge per real crossing, consistent everywhere.
|
|
3596
|
+
strokeRoute(pts, color, width, prior, style = "solid") {
|
|
3597
|
+
const ctx = this.ctx;
|
|
3598
|
+
ctx.strokeStyle = color;
|
|
3599
|
+
ctx.lineWidth = width;
|
|
3600
|
+
if (style === "dashed") ctx.setLineDash([9, 6]);
|
|
3601
|
+
ctx.lineJoin = "miter";
|
|
3602
|
+
ctx.lineCap = "butt";
|
|
3603
|
+
ctx.beginPath();
|
|
3604
|
+
ctx.moveTo(pts[0][0], pts[0][1]);
|
|
3605
|
+
const H = LINK_HOP;
|
|
3606
|
+
const finish = (i, x1, y1, x2, y2) => {
|
|
3607
|
+
if (i < pts.length - 1) {
|
|
3608
|
+
const nx = pts[i + 1][0];
|
|
3609
|
+
const ny = pts[i + 1][1];
|
|
3610
|
+
const seg1 = Math.abs(x2 - x1) + Math.abs(y2 - y1);
|
|
3611
|
+
const seg2 = Math.abs(nx - x2) + Math.abs(ny - y2);
|
|
3612
|
+
ctx.arcTo(x2, y2, nx, ny, Math.min(LINK_CORNER, seg1 / 2, seg2 / 2));
|
|
3613
|
+
} else {
|
|
3614
|
+
ctx.lineTo(x2, y2);
|
|
3615
|
+
}
|
|
3616
|
+
};
|
|
3617
|
+
for (let i = 1; i < pts.length; i += 1) {
|
|
3618
|
+
const [x1, y1] = pts[i - 1];
|
|
3619
|
+
const [x2, y2] = pts[i];
|
|
3620
|
+
if (y1 === y2 && x1 !== x2) {
|
|
3621
|
+
const dir = x2 > x1 ? 1 : -1;
|
|
3622
|
+
const cuts = prior.filter((s) => !s.h && s.a < y1 - 0.5 && y1 + 0.5 < s.b && Math.min(x1, x2) + H < s.c && s.c < Math.max(x1, x2) - H).map((s) => s.c).sort((p, q) => dir * (p - q));
|
|
3623
|
+
for (const cx of cuts) {
|
|
3624
|
+
ctx.lineTo(cx - dir * H, y1);
|
|
3625
|
+
if (dir > 0) ctx.arc(cx, y1, H, Math.PI, 2 * Math.PI, false);
|
|
3626
|
+
else ctx.arc(cx, y1, H, 0, Math.PI, true);
|
|
3627
|
+
}
|
|
3628
|
+
finish(i, x1, y1, x2, y2);
|
|
3629
|
+
} else if (x1 === x2 && y1 !== y2) {
|
|
3630
|
+
const dir = y2 > y1 ? 1 : -1;
|
|
3631
|
+
const cuts = prior.filter((s) => s.h && s.a < x1 - 0.5 && x1 + 0.5 < s.b && Math.min(y1, y2) + H < s.c && s.c < Math.max(y1, y2) - H).map((s) => s.c).sort((p, q) => dir * (p - q));
|
|
3632
|
+
for (const cy of cuts) {
|
|
3633
|
+
ctx.lineTo(x1, cy - dir * H);
|
|
3634
|
+
if (dir > 0) ctx.arc(x1, cy, H, -Math.PI / 2, Math.PI / 2, false);
|
|
3635
|
+
else ctx.arc(x1, cy, H, Math.PI / 2, -Math.PI / 2, true);
|
|
3636
|
+
}
|
|
3637
|
+
finish(i, x1, y1, x2, y2);
|
|
3638
|
+
} else {
|
|
3639
|
+
ctx.lineTo(x2, y2);
|
|
3640
|
+
}
|
|
3641
|
+
}
|
|
3642
|
+
ctx.stroke();
|
|
3643
|
+
ctx.setLineDash([]);
|
|
3644
|
+
}
|
|
3645
|
+
drawArrow(pts, color) {
|
|
3646
|
+
const n2 = pts.length;
|
|
3647
|
+
const [px, py] = pts[n2 - 2];
|
|
3648
|
+
const [ex, ey] = pts[n2 - 1];
|
|
3649
|
+
const ang = Math.atan2(ey - py, ex - px);
|
|
3650
|
+
const ctx = this.ctx;
|
|
3651
|
+
ctx.save();
|
|
3652
|
+
ctx.translate(ex, ey);
|
|
3653
|
+
ctx.rotate(ang);
|
|
3654
|
+
ctx.fillStyle = color;
|
|
3655
|
+
ctx.beginPath();
|
|
3656
|
+
ctx.moveTo(0, 0);
|
|
3657
|
+
ctx.lineTo(-9, -4.5);
|
|
3658
|
+
ctx.lineTo(-9, 4.5);
|
|
3659
|
+
ctx.closePath();
|
|
3660
|
+
ctx.fill();
|
|
3661
|
+
ctx.restore();
|
|
3662
|
+
}
|
|
3663
|
+
drawSelectedLinkEndpoints() {
|
|
3664
|
+
if (this.selectedLink === null) return;
|
|
3665
|
+
const ctx = this.ctx;
|
|
3666
|
+
const halfSize = RELINK_HANDLE_PX / 2 / this.scale;
|
|
3667
|
+
for (const end of ["from", "to"]) {
|
|
3668
|
+
const point = this.endpoint(this.selectedLink, end);
|
|
3669
|
+
if (point === null) continue;
|
|
3670
|
+
ctx.beginPath();
|
|
3671
|
+
ctx.moveTo(point[0], point[1] - halfSize);
|
|
3672
|
+
ctx.lineTo(point[0] + halfSize, point[1]);
|
|
3673
|
+
ctx.lineTo(point[0], point[1] + halfSize);
|
|
3674
|
+
ctx.lineTo(point[0] - halfSize, point[1]);
|
|
3675
|
+
ctx.closePath();
|
|
3676
|
+
ctx.fillStyle = "#00ffff";
|
|
3677
|
+
ctx.fill();
|
|
3678
|
+
ctx.strokeStyle = "#000000";
|
|
3679
|
+
ctx.lineWidth = 1 / this.scale;
|
|
3680
|
+
ctx.stroke();
|
|
3681
|
+
}
|
|
3682
|
+
}
|
|
3683
|
+
drawPendingLink() {
|
|
3684
|
+
if (this.linking === null && this.relinking === null) return;
|
|
3685
|
+
const ctx = this.ctx;
|
|
3686
|
+
const snapped = this.linkSnap;
|
|
3687
|
+
const cursor = this.toWorld(this.cursor.x, this.cursor.y);
|
|
3688
|
+
let a;
|
|
3689
|
+
let b;
|
|
3690
|
+
let colorType = "";
|
|
3691
|
+
let snapPoint = null;
|
|
3692
|
+
const excludedNodes = /* @__PURE__ */ new Set();
|
|
3693
|
+
if (this.linking !== null) {
|
|
3694
|
+
a = [this.linking.port.cx, this.linking.port.cy];
|
|
3695
|
+
b = snapped === null ? cursor : [snapped.port.cx, snapped.port.cy];
|
|
3696
|
+
colorType = this.linking.port.type;
|
|
3697
|
+
excludedNodes.add(this.linking.node.id);
|
|
3698
|
+
if (snapped !== null) snapPoint = b;
|
|
3699
|
+
} else {
|
|
3700
|
+
const gesture = this.relinking;
|
|
3701
|
+
const fixed = this.endpoint(gesture.link, gesture.end === "from" ? "to" : "from");
|
|
3702
|
+
if (fixed === null) return;
|
|
3703
|
+
const sourcePort = this.nodes.find((node) => node.id === gesture.link.from)?.outPorts.find((port) => port.id === gesture.link.fromPort);
|
|
3704
|
+
if (gesture.end === "from") {
|
|
3705
|
+
a = snapped === null ? cursor : [snapped.port.cx, snapped.port.cy];
|
|
3706
|
+
b = fixed;
|
|
3707
|
+
colorType = snapped?.port.type ?? sourcePort?.type ?? "";
|
|
3708
|
+
excludedNodes.add(gesture.link.to);
|
|
3709
|
+
if (snapped !== null) snapPoint = a;
|
|
3710
|
+
} else {
|
|
3711
|
+
a = fixed;
|
|
3712
|
+
b = snapped === null ? cursor : [snapped.port.cx, snapped.port.cy];
|
|
3713
|
+
colorType = sourcePort?.type ?? "";
|
|
3714
|
+
excludedNodes.add(gesture.link.from);
|
|
3715
|
+
if (snapped !== null) snapPoint = b;
|
|
3716
|
+
}
|
|
3717
|
+
}
|
|
3718
|
+
if (snapped !== null) excludedNodes.add(snapped.node.id);
|
|
3719
|
+
const points = this.routeLink(a, b, excludedNodes);
|
|
3720
|
+
ctx.strokeStyle = this.portColor(colorType);
|
|
3721
|
+
if (snapped !== null) {
|
|
3722
|
+
ctx.setLineDash([]);
|
|
3723
|
+
ctx.lineWidth = 2.6;
|
|
3724
|
+
} else {
|
|
3725
|
+
ctx.setLineDash([5, 4]);
|
|
3726
|
+
ctx.lineWidth = 2;
|
|
3727
|
+
}
|
|
3728
|
+
ctx.beginPath();
|
|
3729
|
+
ctx.moveTo(points[0][0], points[0][1]);
|
|
3730
|
+
for (let index = 1; index < points.length; index += 1) ctx.lineTo(points[index][0], points[index][1]);
|
|
3731
|
+
ctx.stroke();
|
|
3732
|
+
ctx.setLineDash([]);
|
|
3733
|
+
if (snapPoint !== null) {
|
|
3734
|
+
ctx.beginPath();
|
|
3735
|
+
ctx.arc(snapPoint[0], snapPoint[1], PORT_R + 4, 0, Math.PI * 2);
|
|
3736
|
+
ctx.strokeStyle = "#ffffff";
|
|
3737
|
+
ctx.lineWidth = 2;
|
|
3738
|
+
ctx.stroke();
|
|
3739
|
+
}
|
|
3740
|
+
}
|
|
3741
|
+
// ---- overview minimap -------------------------------------------
|
|
3742
|
+
overviewRect() {
|
|
3743
|
+
if (!this.overviewVisible || this.width < 320 || this.height < 220) return null;
|
|
3744
|
+
const w = 190, h = 130, m = 14;
|
|
3745
|
+
return { x: this.width - w - m, y: this.height - h - m, w, h };
|
|
3746
|
+
}
|
|
3747
|
+
drawOverview() {
|
|
3748
|
+
const r = this.overviewRect();
|
|
3749
|
+
const gb = this.graphBounds();
|
|
3750
|
+
if (r === null || gb === null) {
|
|
3751
|
+
this.ovGeo = null;
|
|
3752
|
+
return;
|
|
3753
|
+
}
|
|
3754
|
+
const ctx = this.ctx;
|
|
3755
|
+
const pad = 8;
|
|
3756
|
+
const gw = gb.maxX - gb.minX || 1;
|
|
3757
|
+
const gh = gb.maxY - gb.minY || 1;
|
|
3758
|
+
const s = Math.min((r.w - pad * 2) / gw, (r.h - pad * 2) / gh);
|
|
3759
|
+
const ox = r.x + pad + (r.w - pad * 2 - gw * s) / 2;
|
|
3760
|
+
const oy = r.y + pad + (r.h - pad * 2 - gh * s) / 2;
|
|
3761
|
+
this.ovGeo = { x: ox, y: oy, w: r.w, h: r.h, s, minX: gb.minX, minY: gb.minY };
|
|
3762
|
+
const mx = (wx) => ox + (wx - gb.minX) * s;
|
|
3763
|
+
const my = (wy) => oy + (wy - gb.minY) * s;
|
|
3764
|
+
const light = colorLuminance(this.opts.background) >= 150;
|
|
3765
|
+
const overviewBackground = this.opts.overviewBackground ?? (light ? "rgba(255,255,255,0.94)" : "rgba(16,16,20,0.88)");
|
|
3766
|
+
const overviewBorder = this.opts.overviewBorderColor ?? (light ? "#cbd5e1" : "#3a3a44");
|
|
3767
|
+
const viewportColor = this.opts.overviewViewportColor ?? (light ? "#d97706" : "#4aa3ff");
|
|
3768
|
+
const viewportFill = this.opts.overviewViewportFill ?? (light ? "rgba(217,119,6,0.10)" : "rgba(74,163,255,0.10)");
|
|
3769
|
+
ctx.save();
|
|
3770
|
+
roundRect(ctx, r.x, r.y, r.w, r.h, 6);
|
|
3771
|
+
ctx.fillStyle = overviewBackground;
|
|
3772
|
+
ctx.fill();
|
|
3773
|
+
ctx.strokeStyle = overviewBorder;
|
|
3774
|
+
ctx.lineWidth = 1;
|
|
3775
|
+
ctx.stroke();
|
|
3776
|
+
ctx.clip();
|
|
3777
|
+
for (const n2 of this.nodes) {
|
|
3778
|
+
ctx.fillStyle = n2.color;
|
|
3779
|
+
ctx.fillRect(mx(n2.x), my(n2.y), Math.max(2, n2.w * s), Math.max(2, n2.h * s));
|
|
3780
|
+
ctx.strokeStyle = n2.border;
|
|
3781
|
+
ctx.lineWidth = 1;
|
|
3782
|
+
ctx.strokeRect(mx(n2.x), my(n2.y), Math.max(2, n2.w * s), Math.max(2, n2.h * s));
|
|
3783
|
+
}
|
|
3784
|
+
const [vx0, vy0] = this.toWorld(0, 0);
|
|
3785
|
+
const [vx1, vy1] = this.toWorld(this.width, this.height);
|
|
3786
|
+
ctx.strokeStyle = viewportColor;
|
|
3787
|
+
ctx.lineWidth = 1.5;
|
|
3788
|
+
ctx.strokeRect(mx(vx0), my(vy0), (vx1 - vx0) * s, (vy1 - vy0) * s);
|
|
3789
|
+
ctx.fillStyle = viewportFill;
|
|
3790
|
+
ctx.fillRect(mx(vx0), my(vy0), (vx1 - vx0) * s, (vy1 - vy0) * s);
|
|
3791
|
+
ctx.restore();
|
|
3792
|
+
}
|
|
3793
|
+
ovHit(sx, sy) {
|
|
3794
|
+
const r = this.overviewRect();
|
|
3795
|
+
return r !== null && sx >= r.x && sx <= r.x + r.w && sy >= r.y && sy <= r.y + r.h;
|
|
3796
|
+
}
|
|
3797
|
+
ovPanTo(sx, sy) {
|
|
3798
|
+
if (this.ovGeo === null) return;
|
|
3799
|
+
const g = this.ovGeo;
|
|
3800
|
+
const wx = g.minX + (sx - g.x) / g.s;
|
|
3801
|
+
const wy = g.minY + (sy - g.y) / g.s;
|
|
3802
|
+
this.offX = this.width / 2 - wx * this.scale;
|
|
3803
|
+
this.offY = this.height / 2 - wy * this.scale;
|
|
3804
|
+
this.scheduleDraw();
|
|
3805
|
+
}
|
|
3806
|
+
emitViewChanged(zoomChanged) {
|
|
3807
|
+
if (zoomChanged) this.emit("zoomChanged", { scale: this.scale });
|
|
3808
|
+
this.emit("viewChanged", this.getViewState());
|
|
3809
|
+
}
|
|
3810
|
+
};
|
|
3811
|
+
function roundRect(ctx, x, y, w, h, r) {
|
|
3812
|
+
const rr = Math.min(r, w / 2, h / 2);
|
|
3813
|
+
ctx.beginPath();
|
|
3814
|
+
ctx.moveTo(x + rr, y);
|
|
3815
|
+
ctx.arcTo(x + w, y, x + w, y + h, rr);
|
|
3816
|
+
ctx.arcTo(x + w, y + h, x, y + h, rr);
|
|
3817
|
+
ctx.arcTo(x, y + h, x, y, rr);
|
|
3818
|
+
ctx.arcTo(x, y, x + w, y, rr);
|
|
3819
|
+
ctx.closePath();
|
|
3820
|
+
}
|
|
3821
|
+
function route(a, b) {
|
|
3822
|
+
const [ax, ay] = a;
|
|
3823
|
+
const [bx, by] = b;
|
|
3824
|
+
if (bx - ax >= LINK_STUB * 2) {
|
|
3825
|
+
const mx = (ax + bx) / 2;
|
|
3826
|
+
return [[ax, ay], [mx, ay], [mx, by], [bx, by]];
|
|
3827
|
+
}
|
|
3828
|
+
const midY = (ay + by) / 2;
|
|
3829
|
+
return [
|
|
3830
|
+
[ax, ay],
|
|
3831
|
+
[ax + LINK_STUB, ay],
|
|
3832
|
+
[ax + LINK_STUB, midY],
|
|
3833
|
+
[bx - LINK_STUB, midY],
|
|
3834
|
+
[bx - LINK_STUB, by],
|
|
3835
|
+
[bx, by]
|
|
3836
|
+
];
|
|
3837
|
+
}
|
|
3838
|
+
var ROUTE_CELL = 14;
|
|
3839
|
+
var A_STEP = 10;
|
|
3840
|
+
var A_TURN = 40;
|
|
3841
|
+
function segHitsRect(x1, y1, x2, y2, r) {
|
|
3842
|
+
const rx2 = r[0] + r[2];
|
|
3843
|
+
const ry2 = r[1] + r[3];
|
|
3844
|
+
if (y1 === y2) {
|
|
3845
|
+
if (y1 <= r[1] || y1 >= ry2) return false;
|
|
3846
|
+
return Math.min(x1, x2) < rx2 && Math.max(x1, x2) > r[0];
|
|
3847
|
+
}
|
|
3848
|
+
if (x1 === x2) {
|
|
3849
|
+
if (x1 <= r[0] || x1 >= rx2) return false;
|
|
3850
|
+
return Math.min(y1, y2) < ry2 && Math.max(y1, y2) > r[1];
|
|
3851
|
+
}
|
|
3852
|
+
return false;
|
|
3853
|
+
}
|
|
3854
|
+
function polyHitsRects(pts, rects) {
|
|
3855
|
+
for (let i = 1; i < pts.length; i += 1)
|
|
3856
|
+
for (const r of rects)
|
|
3857
|
+
if (segHitsRect(pts[i - 1][0], pts[i - 1][1], pts[i][0], pts[i][1], r)) return true;
|
|
3858
|
+
return false;
|
|
3859
|
+
}
|
|
3860
|
+
function orthogonalize(ps) {
|
|
3861
|
+
const o = [ps[0]];
|
|
3862
|
+
for (let i = 1; i < ps.length; i += 1) {
|
|
3863
|
+
const pr = o[o.length - 1];
|
|
3864
|
+
const p = ps[i];
|
|
3865
|
+
if (pr[0] !== p[0] && pr[1] !== p[1]) o.push([p[0], pr[1]]);
|
|
3866
|
+
o.push(p);
|
|
3867
|
+
}
|
|
3868
|
+
return o;
|
|
3869
|
+
}
|
|
3870
|
+
function collapse(ps) {
|
|
3871
|
+
const o = [];
|
|
3872
|
+
for (const p of ps) {
|
|
3873
|
+
if (o.length > 0) {
|
|
3874
|
+
const last = o[o.length - 1];
|
|
3875
|
+
if (last[0] === p[0] && last[1] === p[1]) continue;
|
|
3876
|
+
}
|
|
3877
|
+
if (o.length >= 2) {
|
|
3878
|
+
const a = o[o.length - 2];
|
|
3879
|
+
const b = o[o.length - 1];
|
|
3880
|
+
if (a[0] === b[0] && b[0] === p[0] || a[1] === b[1] && b[1] === p[1]) {
|
|
3881
|
+
o[o.length - 1] = p;
|
|
3882
|
+
continue;
|
|
3883
|
+
}
|
|
3884
|
+
}
|
|
3885
|
+
o.push(p);
|
|
3886
|
+
}
|
|
3887
|
+
return o;
|
|
3888
|
+
}
|
|
3889
|
+
function hitsAny(x1, y1, x2, y2, rects) {
|
|
3890
|
+
for (const r of rects) if (segHitsRect(x1, y1, x2, y2, r)) return true;
|
|
3891
|
+
return false;
|
|
3892
|
+
}
|
|
3893
|
+
function simplifyOrtho(pts, rects) {
|
|
3894
|
+
let p = pts.slice();
|
|
3895
|
+
let changed = true;
|
|
3896
|
+
let guard = 0;
|
|
3897
|
+
while (changed && guard < 40) {
|
|
3898
|
+
changed = false;
|
|
3899
|
+
guard += 1;
|
|
3900
|
+
for (let i = 2; i + 3 < p.length; i += 1) {
|
|
3901
|
+
const A = p[i - 1];
|
|
3902
|
+
const D = p[i + 2];
|
|
3903
|
+
for (const E of [[D[0], A[1]], [A[0], D[1]]]) {
|
|
3904
|
+
if (E[0] === A[0] && E[1] === A[1] || E[0] === D[0] && E[1] === D[1]) continue;
|
|
3905
|
+
if (hitsAny(A[0], A[1], E[0], E[1], rects)) continue;
|
|
3906
|
+
if (hitsAny(E[0], E[1], D[0], D[1], rects)) continue;
|
|
3907
|
+
p = p.slice(0, i).concat([E], p.slice(i + 2));
|
|
3908
|
+
changed = true;
|
|
3909
|
+
break;
|
|
3910
|
+
}
|
|
3911
|
+
if (changed) break;
|
|
3912
|
+
}
|
|
3913
|
+
}
|
|
3914
|
+
return p;
|
|
3915
|
+
}
|
|
3916
|
+
function aStarGrid(start, goal, rects) {
|
|
3917
|
+
let minX = Math.min(start[0], goal[0]);
|
|
3918
|
+
let minY = Math.min(start[1], goal[1]);
|
|
3919
|
+
let maxX = Math.max(start[0], goal[0]);
|
|
3920
|
+
let maxY = Math.max(start[1], goal[1]);
|
|
3921
|
+
for (const r of rects) {
|
|
3922
|
+
minX = Math.min(minX, r[0]);
|
|
3923
|
+
minY = Math.min(minY, r[1]);
|
|
3924
|
+
maxX = Math.max(maxX, r[0] + r[2]);
|
|
3925
|
+
maxY = Math.max(maxY, r[1] + r[3]);
|
|
3926
|
+
}
|
|
3927
|
+
const pad = LINK_STUB + ROUTE_CELL * 2;
|
|
3928
|
+
minX -= pad;
|
|
3929
|
+
minY -= pad;
|
|
3930
|
+
maxX += pad;
|
|
3931
|
+
maxY += pad;
|
|
3932
|
+
let cell = ROUTE_CELL;
|
|
3933
|
+
let cols = Math.ceil((maxX - minX) / cell);
|
|
3934
|
+
let rows = Math.ceil((maxY - minY) / cell);
|
|
3935
|
+
const CAP = 260;
|
|
3936
|
+
if (cols > CAP || rows > CAP) {
|
|
3937
|
+
cell = Math.max((maxX - minX) / CAP, (maxY - minY) / CAP);
|
|
3938
|
+
cols = Math.ceil((maxX - minX) / cell);
|
|
3939
|
+
rows = Math.ceil((maxY - minY) / cell);
|
|
3940
|
+
}
|
|
3941
|
+
const n2 = cols * rows;
|
|
3942
|
+
if (n2 <= 0 || n2 > 8e4) return null;
|
|
3943
|
+
const cx = (c) => minX + (c + 0.5) * cell;
|
|
3944
|
+
const cy = (r) => minY + (r + 0.5) * cell;
|
|
3945
|
+
const blocked = new Uint8Array(n2);
|
|
3946
|
+
for (const r of rects) {
|
|
3947
|
+
const c0 = Math.max(0, Math.floor((r[0] - minX) / cell));
|
|
3948
|
+
const c1 = Math.min(cols - 1, Math.floor((r[0] + r[2] - minX) / cell));
|
|
3949
|
+
const r0 = Math.max(0, Math.floor((r[1] - minY) / cell));
|
|
3950
|
+
const r1 = Math.min(rows - 1, Math.floor((r[1] + r[3] - minY) / cell));
|
|
3951
|
+
for (let rr = r0; rr <= r1; rr += 1)
|
|
3952
|
+
for (let cc = c0; cc <= c1; cc += 1) blocked[rr * cols + cc] = 1;
|
|
3953
|
+
}
|
|
3954
|
+
const sc = clamp(Math.floor((start[0] - minX) / cell), 0, cols - 1);
|
|
3955
|
+
const sr = clamp(Math.floor((start[1] - minY) / cell), 0, rows - 1);
|
|
3956
|
+
const gc = clamp(Math.floor((goal[0] - minX) / cell), 0, cols - 1);
|
|
3957
|
+
const gr = clamp(Math.floor((goal[1] - minY) / cell), 0, rows - 1);
|
|
3958
|
+
const si = sr * cols + sc;
|
|
3959
|
+
const gi = gr * cols + gc;
|
|
3960
|
+
blocked[si] = 0;
|
|
3961
|
+
blocked[gi] = 0;
|
|
3962
|
+
const g = new Float64Array(n2).fill(Infinity);
|
|
3963
|
+
const prev = new Int32Array(n2).fill(-1);
|
|
3964
|
+
const dir = new Int8Array(n2).fill(-1);
|
|
3965
|
+
g[si] = 0;
|
|
3966
|
+
const heap = [si];
|
|
3967
|
+
const f = new Float64Array(n2);
|
|
3968
|
+
f[si] = (Math.abs(gc - sc) + Math.abs(gr - sr)) * A_STEP;
|
|
3969
|
+
const swap = (i, j) => {
|
|
3970
|
+
const t = heap[i];
|
|
3971
|
+
heap[i] = heap[j];
|
|
3972
|
+
heap[j] = t;
|
|
3973
|
+
};
|
|
3974
|
+
const push = (v) => {
|
|
3975
|
+
heap.push(v);
|
|
3976
|
+
let i = heap.length - 1;
|
|
3977
|
+
while (i > 0) {
|
|
3978
|
+
const p2 = i - 1 >> 1;
|
|
3979
|
+
if (f[heap[p2]] <= f[heap[i]]) break;
|
|
3980
|
+
swap(i, p2);
|
|
3981
|
+
i = p2;
|
|
3982
|
+
}
|
|
3983
|
+
};
|
|
3984
|
+
const pop = () => {
|
|
3985
|
+
const top = heap[0];
|
|
3986
|
+
const last = heap.pop();
|
|
3987
|
+
if (heap.length > 0) {
|
|
3988
|
+
heap[0] = last;
|
|
3989
|
+
let i = 0;
|
|
3990
|
+
for (; ; ) {
|
|
3991
|
+
const l = 2 * i + 1;
|
|
3992
|
+
const r = 2 * i + 2;
|
|
3993
|
+
let s = i;
|
|
3994
|
+
if (l < heap.length && f[heap[l]] < f[heap[s]]) s = l;
|
|
3995
|
+
if (r < heap.length && f[heap[r]] < f[heap[s]]) s = r;
|
|
3996
|
+
if (s === i) break;
|
|
3997
|
+
swap(i, s);
|
|
3998
|
+
i = s;
|
|
3999
|
+
}
|
|
4000
|
+
}
|
|
4001
|
+
return top;
|
|
4002
|
+
};
|
|
4003
|
+
const seen = new Uint8Array(n2);
|
|
4004
|
+
while (heap.length > 0) {
|
|
4005
|
+
const cur = pop();
|
|
4006
|
+
if (cur === gi) break;
|
|
4007
|
+
if (seen[cur]) continue;
|
|
4008
|
+
seen[cur] = 1;
|
|
4009
|
+
const ccol = cur % cols;
|
|
4010
|
+
const crow = (cur - ccol) / cols;
|
|
4011
|
+
const steps = [
|
|
4012
|
+
[1, 0, 0],
|
|
4013
|
+
[-1, 0, 0],
|
|
4014
|
+
[0, 1, 1],
|
|
4015
|
+
[0, -1, 1]
|
|
4016
|
+
];
|
|
4017
|
+
for (const [dc, dr, md] of steps) {
|
|
4018
|
+
const nc = ccol + dc;
|
|
4019
|
+
const nr = crow + dr;
|
|
4020
|
+
if (nc < 0 || nc >= cols || nr < 0 || nr >= rows) continue;
|
|
4021
|
+
const ni = nr * cols + nc;
|
|
4022
|
+
if (blocked[ni]) continue;
|
|
4023
|
+
const turn = dir[cur] !== -1 && dir[cur] !== md ? A_TURN : 0;
|
|
4024
|
+
const ng = g[cur] + A_STEP + turn;
|
|
4025
|
+
if (ng < g[ni]) {
|
|
4026
|
+
g[ni] = ng;
|
|
4027
|
+
prev[ni] = cur;
|
|
4028
|
+
dir[ni] = md;
|
|
4029
|
+
f[ni] = ng + (Math.abs(gc - nc) + Math.abs(gr - nr)) * A_STEP;
|
|
4030
|
+
push(ni);
|
|
4031
|
+
}
|
|
4032
|
+
}
|
|
4033
|
+
}
|
|
4034
|
+
if (prev[gi] === -1 && gi !== si) return null;
|
|
4035
|
+
const out = [];
|
|
4036
|
+
let p = gi;
|
|
4037
|
+
while (p !== -1) {
|
|
4038
|
+
out.push([cx(p % cols), cy((p - p % cols) / cols)]);
|
|
4039
|
+
p = prev[p];
|
|
4040
|
+
}
|
|
4041
|
+
out.reverse();
|
|
4042
|
+
return out;
|
|
4043
|
+
}
|
|
4044
|
+
function routeAvoid(a, b, rects) {
|
|
4045
|
+
const simple = route(a, b);
|
|
4046
|
+
if (rects.length === 0 || !polyHitsRects(simple, rects)) return simple;
|
|
4047
|
+
const A2 = [a[0] + LINK_STUB, a[1]];
|
|
4048
|
+
const B2 = [b[0] - LINK_STUB, b[1]];
|
|
4049
|
+
const grid = aStarGrid(A2, B2, rects);
|
|
4050
|
+
if (grid === null) return simple;
|
|
4051
|
+
const lo = Math.min(A2[0], B2[0]);
|
|
4052
|
+
const hi = Math.max(A2[0], B2[0]);
|
|
4053
|
+
const clipped = grid.map((p) => [clamp(p[0], lo, hi), p[1]]);
|
|
4054
|
+
const poly = collapse(orthogonalize([a, A2, ...clipped, B2, b]));
|
|
4055
|
+
return collapse(simplifyOrtho(poly, rects));
|
|
4056
|
+
}
|
|
4057
|
+
function ptSegDist(px, py, x1, y1, x2, y2) {
|
|
4058
|
+
const dx = x2 - x1;
|
|
4059
|
+
const dy = y2 - y1;
|
|
4060
|
+
const len2 = dx * dx + dy * dy;
|
|
4061
|
+
let t = len2 === 0 ? 0 : ((px - x1) * dx + (py - y1) * dy) / len2;
|
|
4062
|
+
t = clamp(t, 0, 1);
|
|
4063
|
+
const cx = x1 + t * dx;
|
|
4064
|
+
const cy = y1 + t * dy;
|
|
4065
|
+
return Math.hypot(px - cx, py - cy);
|
|
4066
|
+
}
|
|
4067
|
+
|
|
4068
|
+
// src/headless.ts
|
|
4069
|
+
function createContext() {
|
|
4070
|
+
const own = {
|
|
4071
|
+
globalAlpha: 1,
|
|
4072
|
+
measureText: (text) => ({ width: text.length * 7 })
|
|
4073
|
+
};
|
|
4074
|
+
return new Proxy(own, {
|
|
4075
|
+
get: (target, property) => property in target ? target[property] : () => void 0,
|
|
4076
|
+
set: (target, property, value) => {
|
|
4077
|
+
target[property] = value;
|
|
4078
|
+
return true;
|
|
4079
|
+
}
|
|
4080
|
+
});
|
|
4081
|
+
}
|
|
4082
|
+
var StubElement = class {
|
|
4083
|
+
constructor() {
|
|
4084
|
+
this.style = {};
|
|
4085
|
+
this.className = "";
|
|
4086
|
+
this.tabIndex = 0;
|
|
4087
|
+
this.width = 0;
|
|
4088
|
+
this.height = 0;
|
|
4089
|
+
this.textContent = "";
|
|
4090
|
+
this.children = [];
|
|
4091
|
+
this.classList = { toggle: () => false, add: () => void 0, remove: () => void 0 };
|
|
4092
|
+
this.context = createContext();
|
|
4093
|
+
}
|
|
4094
|
+
appendChild(child) {
|
|
4095
|
+
this.children.push(child);
|
|
4096
|
+
return child;
|
|
4097
|
+
}
|
|
4098
|
+
removeChild(child) {
|
|
4099
|
+
const at = this.children.indexOf(child);
|
|
4100
|
+
if (at >= 0) this.children.splice(at, 1);
|
|
4101
|
+
return child;
|
|
4102
|
+
}
|
|
4103
|
+
remove() {
|
|
4104
|
+
}
|
|
4105
|
+
setAttribute() {
|
|
4106
|
+
}
|
|
4107
|
+
focus() {
|
|
4108
|
+
}
|
|
4109
|
+
addEventListener() {
|
|
4110
|
+
}
|
|
4111
|
+
removeEventListener() {
|
|
4112
|
+
}
|
|
4113
|
+
getContext() {
|
|
4114
|
+
return this.context;
|
|
4115
|
+
}
|
|
4116
|
+
getBoundingClientRect() {
|
|
4117
|
+
return { left: 0, top: 0, right: this.width, bottom: this.height, width: this.width, height: this.height, x: 0, y: 0, toJSON: () => ({}) };
|
|
4118
|
+
}
|
|
4119
|
+
get clientWidth() {
|
|
4120
|
+
return this.width;
|
|
4121
|
+
}
|
|
4122
|
+
get clientHeight() {
|
|
4123
|
+
return this.height;
|
|
4124
|
+
}
|
|
4125
|
+
};
|
|
4126
|
+
var GLOBAL_KEYS = ["window", "document", "requestAnimationFrame", "cancelAnimationFrame", "Image", "getComputedStyle", "performance"];
|
|
4127
|
+
function installGlobals() {
|
|
4128
|
+
const target = globalThis;
|
|
4129
|
+
if (target.document !== void 0)
|
|
4130
|
+
return () => void 0;
|
|
4131
|
+
const before = /* @__PURE__ */ new Map();
|
|
4132
|
+
for (const key of GLOBAL_KEYS) before.set(key, target[key]);
|
|
4133
|
+
const stubDocument = Object.assign(new StubElement(), {
|
|
4134
|
+
documentElement: new StubElement(),
|
|
4135
|
+
createElement: () => new StubElement()
|
|
4136
|
+
});
|
|
4137
|
+
Object.assign(target, {
|
|
4138
|
+
window: Object.assign(new StubElement(), { devicePixelRatio: 1 }),
|
|
4139
|
+
document: stubDocument,
|
|
4140
|
+
requestAnimationFrame: () => 1,
|
|
4141
|
+
cancelAnimationFrame: () => void 0,
|
|
4142
|
+
Image: class {
|
|
4143
|
+
},
|
|
4144
|
+
getComputedStyle: () => ({ getPropertyValue: () => "" }),
|
|
4145
|
+
// A host without a browser may have no clock either -- an embedded JS engine has none. The drawing
|
|
4146
|
+
// reads it for animation, and a still picture has no animation to advance.
|
|
4147
|
+
performance: globalThis.performance ?? { now: () => 0 }
|
|
4148
|
+
});
|
|
4149
|
+
return () => {
|
|
4150
|
+
for (const [key, value] of before) {
|
|
4151
|
+
if (value === void 0) delete target[key];
|
|
4152
|
+
else target[key] = value;
|
|
4153
|
+
}
|
|
4154
|
+
};
|
|
4155
|
+
}
|
|
4156
|
+
function renderDiagramSvg(source, options = {}) {
|
|
4157
|
+
const { hostWidth = 1200, hostHeight = 800, ...screenshot } = options;
|
|
4158
|
+
const restore = installGlobals();
|
|
4159
|
+
try {
|
|
4160
|
+
const host = new StubElement();
|
|
4161
|
+
host.width = hostWidth;
|
|
4162
|
+
host.height = hostHeight;
|
|
4163
|
+
const diagram = new Diagram({ host });
|
|
4164
|
+
try {
|
|
4165
|
+
diagram.loadDocument(parseDiagramDocument(source));
|
|
4166
|
+
return diagram.takeSvg({ scope: "content", ...screenshot });
|
|
4167
|
+
} finally {
|
|
4168
|
+
diagram.destroy();
|
|
4169
|
+
}
|
|
4170
|
+
} finally {
|
|
4171
|
+
restore();
|
|
4172
|
+
}
|
|
4173
|
+
}
|
|
4174
|
+
return __toCommonJS(headless_exports);
|
|
4175
|
+
})();
|
|
4176
|
+
//# sourceMappingURL=ssdiagram-headless.js.map
|