@stocksharp/diagram 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/LICENSE +30 -0
  2. package/NOTICE +21 -0
  3. package/README.md +346 -0
  4. package/dist/esm/canvas-renderer.js +3226 -0
  5. package/dist/esm/canvas-renderer.js.map +1 -0
  6. package/dist/esm/core/action-registry.js +35 -0
  7. package/dist/esm/core/action-registry.js.map +1 -0
  8. package/dist/esm/core/document.js +350 -0
  9. package/dist/esm/core/document.js.map +1 -0
  10. package/dist/esm/core/history.js +136 -0
  11. package/dist/esm/core/history.js.map +1 -0
  12. package/dist/esm/core/model.js +2 -0
  13. package/dist/esm/core/model.js.map +1 -0
  14. package/dist/esm/core/state.js +73 -0
  15. package/dist/esm/core/state.js.map +1 -0
  16. package/dist/esm/core/view-state.js +70 -0
  17. package/dist/esm/core/view-state.js.map +1 -0
  18. package/dist/esm/diagram/api.js +2 -0
  19. package/dist/esm/diagram/api.js.map +1 -0
  20. package/dist/esm/diagram/catalog.js +45 -0
  21. package/dist/esm/diagram/catalog.js.map +1 -0
  22. package/dist/esm/diagram/event-emitter.js +42 -0
  23. package/dist/esm/diagram/event-emitter.js.map +1 -0
  24. package/dist/esm/diagram/palette.js +269 -0
  25. package/dist/esm/diagram/palette.js.map +1 -0
  26. package/dist/esm/diagram/stocksharp-diagram.js +784 -0
  27. package/dist/esm/diagram/stocksharp-diagram.js.map +1 -0
  28. package/dist/esm/diagram/types.js +105 -0
  29. package/dist/esm/diagram/types.js.map +1 -0
  30. package/dist/esm/embed.js +398 -0
  31. package/dist/esm/embed.js.map +1 -0
  32. package/dist/esm/i18n.js +12 -0
  33. package/dist/esm/i18n.js.map +1 -0
  34. package/dist/esm/index.js +12 -0
  35. package/dist/esm/index.js.map +1 -0
  36. package/dist/ssdiagram.js +5141 -0
  37. package/dist/ssdiagram.js.map +7 -0
  38. package/dist/types/canvas-renderer.d.ts +512 -0
  39. package/dist/types/canvas-renderer.d.ts.map +1 -0
  40. package/dist/types/core/action-registry.d.ts +18 -0
  41. package/dist/types/core/action-registry.d.ts.map +1 -0
  42. package/dist/types/core/document.d.ts +12 -0
  43. package/dist/types/core/document.d.ts.map +1 -0
  44. package/dist/types/core/history.d.ts +33 -0
  45. package/dist/types/core/history.d.ts.map +1 -0
  46. package/dist/types/core/model.d.ts +85 -0
  47. package/dist/types/core/model.d.ts.map +1 -0
  48. package/dist/types/core/state.d.ts +68 -0
  49. package/dist/types/core/state.d.ts.map +1 -0
  50. package/dist/types/core/view-state.d.ts +14 -0
  51. package/dist/types/core/view-state.d.ts.map +1 -0
  52. package/dist/types/diagram/api.d.ts +213 -0
  53. package/dist/types/diagram/api.d.ts.map +1 -0
  54. package/dist/types/diagram/catalog.d.ts +19 -0
  55. package/dist/types/diagram/catalog.d.ts.map +1 -0
  56. package/dist/types/diagram/event-emitter.d.ts +9 -0
  57. package/dist/types/diagram/event-emitter.d.ts.map +1 -0
  58. package/dist/types/diagram/palette.d.ts +63 -0
  59. package/dist/types/diagram/palette.d.ts.map +1 -0
  60. package/dist/types/diagram/stocksharp-diagram.d.ts +157 -0
  61. package/dist/types/diagram/stocksharp-diagram.d.ts.map +1 -0
  62. package/dist/types/diagram/types.d.ts +171 -0
  63. package/dist/types/diagram/types.d.ts.map +1 -0
  64. package/dist/types/embed.d.ts +35 -0
  65. package/dist/types/embed.d.ts.map +1 -0
  66. package/dist/types/i18n.d.ts +167 -0
  67. package/dist/types/i18n.d.ts.map +1 -0
  68. package/dist/types/index.d.ts +22 -0
  69. package/dist/types/index.d.ts.map +1 -0
  70. package/package.json +126 -0
  71. package/sample.png +0 -0
  72. package/src/canvas-renderer.ts +3249 -0
  73. package/src/core/action-registry.ts +46 -0
  74. package/src/core/document.ts +387 -0
  75. package/src/core/history.ts +154 -0
  76. package/src/core/model.ts +99 -0
  77. package/src/core/state.ts +148 -0
  78. package/src/core/view-state.ts +82 -0
  79. package/src/diagram/api.ts +257 -0
  80. package/src/diagram/catalog.ts +55 -0
  81. package/src/diagram/event-emitter.ts +44 -0
  82. package/src/diagram/palette.ts +312 -0
  83. package/src/diagram/stocksharp-diagram.ts +945 -0
  84. package/src/diagram/types.ts +332 -0
  85. package/src/embed.ts +508 -0
  86. package/src/i18n.ts +210 -0
  87. package/src/index.ts +171 -0
@@ -0,0 +1,3226 @@
1
+ // Internal dependency-free canvas renderer used by StockSharpDiagram.
2
+ //
3
+ // Dependency-free, pure 2D canvas. Demonstrates the hard parts: typed
4
+ // in/out ports, bezier links, node drag, drag-to-link with validation,
5
+ // selection, delete, zoom/pan, load/save round-trip.
6
+ import { createDiagramDocument, parseDiagramDocument } from './core/document.js';
7
+ import { DiagramCommandHistory } from './core/history.js';
8
+ import { cloneDiagramRuntimeState, createDiagramNodeRuntimeState, createDiagramPortRuntimeState, createDiagramRuntimeState, createEditableDiagramPermissions, createReadOnlyDiagramPermissions, } from './core/state.js';
9
+ const SCREEN_RENDER_OPTIONS = {
10
+ grid: true,
11
+ overview: true,
12
+ selection: true,
13
+ runtime: true,
14
+ transient: true,
15
+ };
16
+ // ---- model ----------------------------------------------------------
17
+ function copyJsonValue(value) {
18
+ if (Array.isArray(value))
19
+ return value.map(copyJsonValue);
20
+ if (value !== null && typeof value === 'object')
21
+ return copyJsonObject(value);
22
+ return value;
23
+ }
24
+ function copyJsonObject(value) {
25
+ if (value === undefined)
26
+ return {};
27
+ const result = {};
28
+ for (const [key, item] of Object.entries(value))
29
+ result[key] = copyJsonValue(item);
30
+ return result;
31
+ }
32
+ function copyParameters(value) {
33
+ return (value ?? []).map((parameter) => ({ ...parameter, options: [...parameter.options] }));
34
+ }
35
+ function normalizePortType(type) {
36
+ return type.trim().toLowerCase();
37
+ }
38
+ function isWildcardPortType(type) {
39
+ const normalized = normalizePortType(type);
40
+ return normalized === ''
41
+ || normalized === '*'
42
+ || normalized === 'any'
43
+ || normalized === 'anydata'
44
+ || normalized === 'any data'
45
+ || normalized === 'object'
46
+ || normalized === 'system.object'
47
+ || normalized.startsWith('system.object,');
48
+ }
49
+ function arePortTypesCompatible(fromType, toType, availableTypes) {
50
+ if (isWildcardPortType(fromType) || isWildcardPortType(toType))
51
+ return true;
52
+ const normalizedFrom = normalizePortType(fromType);
53
+ return [toType, ...availableTypes].some((type) => isWildcardPortType(type) || normalizePortType(type) === normalizedFrom);
54
+ }
55
+ export class PortModel {
56
+ constructor(init, dir) {
57
+ // layout cache (world coords), filled at draw time
58
+ this.cx = 0;
59
+ this.cy = 0;
60
+ this.id = init.id;
61
+ this.name = init.name;
62
+ this.description = init.description ?? '';
63
+ this.type = init.type ?? '';
64
+ this.direction = dir;
65
+ this.maxLinks = typeof init.maxLinks === 'number' ? init.maxLinks : 0;
66
+ this.availableTypes = [...(init.availableTypes ?? [])];
67
+ this.isDynamic = init.isDynamic ?? false;
68
+ this.dynamicMode = init.dynamicMode ?? '';
69
+ this.isSibling = init.isSibling ?? false;
70
+ this.metadata = copyJsonObject(init.metadata);
71
+ }
72
+ toInit() {
73
+ return {
74
+ id: this.id,
75
+ name: this.name,
76
+ description: this.description,
77
+ type: this.type,
78
+ maxLinks: this.maxLinks,
79
+ availableTypes: [...this.availableTypes],
80
+ isDynamic: this.isDynamic,
81
+ dynamicMode: this.dynamicMode,
82
+ isSibling: this.isSibling,
83
+ metadata: copyJsonObject(this.metadata),
84
+ };
85
+ }
86
+ }
87
+ export class NodeModel {
88
+ constructor(init, id) {
89
+ this.runtimeError = '';
90
+ this.errorFlashStart = null;
91
+ // layout cache
92
+ this.w = 160;
93
+ this.h = 48;
94
+ this.id = id;
95
+ this.typeId = init.typeId ?? id;
96
+ this.name = init.name;
97
+ this.description = init.description ?? '';
98
+ this.groupName = init.groupName ?? 'Common';
99
+ this.color = init.color ?? '#d7d7d7';
100
+ this.border = init.border ?? '#8c8c8c';
101
+ this.icon = init.icon ?? '';
102
+ this.openAction = init.openAction ?? '';
103
+ this.message = init.message ?? '';
104
+ this.parameters = copyParameters(init.parameters);
105
+ this.paramValues = { ...(init.paramValues ?? {}) };
106
+ this.metadata = copyJsonObject(init.metadata);
107
+ this.isPlaceholder = init.isPlaceholder ?? false;
108
+ this.loadError = init.loadError ?? '';
109
+ this.x = typeof init.x === 'number' ? init.x : 0;
110
+ this.y = typeof init.y === 'number' ? init.y : 0;
111
+ this.inPorts = (init.inPorts ?? []).map((p) => new PortModel(p, 'in'));
112
+ this.outPorts = (init.outPorts ?? []).map((p) => new PortModel(p, 'out'));
113
+ }
114
+ port(id) {
115
+ return this.inPorts.find((p) => p.id === id) ?? this.outPorts.find((p) => p.id === id);
116
+ }
117
+ toInit(includeRuntimeState = true) {
118
+ const init = {
119
+ id: this.id,
120
+ typeId: this.typeId,
121
+ name: this.name,
122
+ description: this.description,
123
+ groupName: this.groupName,
124
+ color: this.color,
125
+ border: this.border,
126
+ icon: this.icon,
127
+ openAction: this.openAction,
128
+ message: this.message,
129
+ parameters: copyParameters(this.parameters),
130
+ paramValues: { ...this.paramValues },
131
+ metadata: copyJsonObject(this.metadata),
132
+ isPlaceholder: this.isPlaceholder,
133
+ x: this.x,
134
+ y: this.y,
135
+ inPorts: this.inPorts.map((port) => port.toInit()),
136
+ outPorts: this.outPorts.map((port) => port.toInit()),
137
+ };
138
+ if (includeRuntimeState && this.loadError.length > 0)
139
+ init.loadError = this.loadError;
140
+ return init;
141
+ }
142
+ }
143
+ export class LinkModel {
144
+ constructor(from, fromPort, to, toPort, id = '', metadata) {
145
+ this.from = from;
146
+ this.fromPort = fromPort;
147
+ this.to = to;
148
+ this.toPort = toPort;
149
+ this.id = id;
150
+ this.metadata = copyJsonObject(metadata);
151
+ }
152
+ key() { return `${this.from}|${this.fromPort}|${this.to}|${this.toPort}`; }
153
+ toInit() {
154
+ return {
155
+ id: this.id,
156
+ from: this.from,
157
+ fromPort: this.fromPort,
158
+ to: this.to,
159
+ toPort: this.toPort,
160
+ metadata: copyJsonObject(this.metadata),
161
+ };
162
+ }
163
+ }
164
+ const HEADER_H = 22;
165
+ const PORT_R = 6;
166
+ const PORT_ROW_H = 20;
167
+ const NODE_PAD = 10;
168
+ const RELINK_HANDLE_PX = 8; // WPF relink adornment is an 8x8 diamond
169
+ const RELINK_DRAG_THRESHOLD_PX = 4;
170
+ const PORT_SQ = 9; // socket square size — sits outside the node
171
+ const LINK_STUB = 22; // min horizontal lead-in/out before the elbow
172
+ const LINK_HOP = 5; // jump-over arc radius at crossings
173
+ const SNAP_PX = 32; // plug↔socket magnet radius (screen px)
174
+ const DEFAULT_GRID_SIZE = 28;
175
+ const ZOOM_MIN = 0.15;
176
+ const ZOOM_MAX = 4;
177
+ const INTRO_MS = 520; // entrance animation duration
178
+ const INTRO_RISE = 70; // px the scheme rises from below
179
+ const ERROR_FLASH_MS = 1100;
180
+ const ERROR_RED = '#f6465d';
181
+ const ERROR_BACKGROUND = '#7d2632';
182
+ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
183
+ function hashHue(s) {
184
+ let h = 0;
185
+ for (let i = 0; i < s.length; i += 1)
186
+ h = (h * 31 + s.charCodeAt(i)) | 0;
187
+ return ((h % 360) + 360) % 360;
188
+ }
189
+ // Clamp an hsl() colour's lightness to a ceiling (0..1), keeping its hue and saturation. The link
190
+ // palette is tuned for a dark canvas (light, ~58%-lightness colours), so on a light canvas those
191
+ // wash out; a light theme passes a low ceiling to darken the links just enough to stay readable
192
+ // while keeping each hue distinct. Non-hsl inputs are returned untouched.
193
+ function clampLightness(color, maxL) {
194
+ const m = color.match(/^hsl\(\s*([\d.]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*\)$/i);
195
+ if (m === null)
196
+ return color;
197
+ return `hsl(${m[1]}, ${m[2]}%, ${Math.min(+m[3], maxL * 100)}%)`;
198
+ }
199
+ function colorLuminance(color) {
200
+ if (color === undefined)
201
+ return 0;
202
+ const hex = color.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
203
+ if (hex !== null) {
204
+ const raw = hex[1].length === 3
205
+ ? hex[1].split('').map((x) => x + x).join('')
206
+ : hex[1];
207
+ const value = parseInt(raw, 16);
208
+ return 0.299 * ((value >> 16) & 255)
209
+ + 0.587 * ((value >> 8) & 255)
210
+ + 0.114 * (value & 255);
211
+ }
212
+ const rgb = color.match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i);
213
+ if (rgb !== null)
214
+ return 0.299 * +rgb[1] + 0.587 * +rgb[2] + 0.114 * +rgb[3];
215
+ return 0;
216
+ }
217
+ export class Diagram {
218
+ constructor(opts) {
219
+ this.nodes = [];
220
+ this.links = [];
221
+ this.idSeq = 1;
222
+ this.linkSeq = 1;
223
+ this.documentMetadata = {};
224
+ this.runtimeState = createDiagramRuntimeState();
225
+ this.runtimePulse = 0;
226
+ this.globalErrorFlashStart = null;
227
+ // viewport transform: screen = world * scale + offset
228
+ this.scale = 1;
229
+ this.offX = 0;
230
+ this.offY = 0;
231
+ this.width = 0;
232
+ this.height = 0;
233
+ this.dpr = 1;
234
+ this.drawScheduled = false;
235
+ // interaction state
236
+ this.selectedNode = null; // primary (last) selected
237
+ this.selectedNodes = new Set(); // multi-selection
238
+ this.selectedLink = null;
239
+ this.selectedPort = null;
240
+ this.dragNode = null;
241
+ this.dragStart = []; // group-drag origin
242
+ this.dragAnchor = { wx: 0, wy: 0 };
243
+ this.dragDX = 0;
244
+ this.dragDY = 0;
245
+ this.panning = false;
246
+ this.panX = 0;
247
+ this.panY = 0;
248
+ this.permissions = createEditableDiagramPermissions();
249
+ this.showNodeMessages = true;
250
+ this.iconCache = new Map(); // node icon images by URL (null = loading/failed)
251
+ this.rubber = null; // world rect
252
+ this.clip = null;
253
+ this.linking = null;
254
+ this.relinking = null;
255
+ this.relinkCandidate = null;
256
+ this.linkSnap = null;
257
+ this.cursor = { x: 0, y: 0 }; // screen
258
+ this.hoverPort = null;
259
+ this.hoverNode = null;
260
+ this.hoveredLink = null;
261
+ this.tipTimer = null;
262
+ this.tipShow = false;
263
+ this.tipTarget = null;
264
+ // Long-press (touch) / right-click (desktop) → contextMenu event.
265
+ this.lpTimer = null;
266
+ this.lpStart = null;
267
+ this.lpDelayMs = 550;
268
+ this.lpMoveTol = 7;
269
+ this.domDisposables = [];
270
+ this.destroyed = false;
271
+ // Overview minimap.
272
+ this.introStart = null;
273
+ this.overviewVisible = true;
274
+ this.ovDragging = false;
275
+ this.ovGeo = null;
276
+ this.validator = null;
277
+ this.handlers = new Map();
278
+ this.opts = opts;
279
+ this.host = opts.host;
280
+ this.gridSnapEnabled = opts.gridSnap ?? false;
281
+ this.gridSize = this.normalizeGridSize(opts.gridSize ?? DEFAULT_GRID_SIZE);
282
+ this.history = new DiagramCommandHistory(({ canUndo, canRedo }) => {
283
+ this.emit('undoStackChanged', { canUndo, canRedo });
284
+ });
285
+ this.canvas = document.createElement('canvas');
286
+ this.canvas.style.display = 'block';
287
+ this.canvas.tabIndex = 0; // focusable → key events
288
+ this.canvas.style.outline = 'none';
289
+ // Eat browser-native touch gestures (page scroll / pinch-zoom) so
290
+ // fingers actually drive our pan/zoom on mobile instead of the page.
291
+ this.canvas.style.touchAction = 'none';
292
+ // No OS text-selection on long-press (would pop the iOS magnifier
293
+ // and select node/link captions instead of firing our menu).
294
+ this.canvas.style.userSelect = 'none';
295
+ this.canvas.style.webkitUserSelect = 'none';
296
+ this.canvas.style.webkitTouchCallout = 'none';
297
+ this.host.appendChild(this.canvas);
298
+ const ctx = this.canvas.getContext('2d');
299
+ if (ctx === null)
300
+ throw new Error('ssdiagram: 2d context unavailable');
301
+ this.ctx = ctx;
302
+ this.resize(this.host.clientWidth || 800, this.host.clientHeight || 480);
303
+ this.bind();
304
+ }
305
+ // ---- events -----------------------------------------------------
306
+ on(ev, h) {
307
+ let set = this.handlers.get(ev);
308
+ if (set === undefined) {
309
+ set = new Set();
310
+ this.handlers.set(ev, set);
311
+ }
312
+ set.add(h);
313
+ return () => set.delete(h);
314
+ }
315
+ emit(ev, p) {
316
+ const set = this.handlers.get(ev);
317
+ if (set === undefined)
318
+ return;
319
+ for (const h of set) {
320
+ try {
321
+ h(p);
322
+ }
323
+ catch (e) {
324
+ console.error(e);
325
+ }
326
+ }
327
+ }
328
+ // ---- public API (Layer-A-shaped) --------------------------------
329
+ setLinkValidator(fn) { this.validator = fn; }
330
+ setGridSnap(enabled, size) {
331
+ const nextSize = size === undefined ? this.gridSize : this.normalizeGridSize(size);
332
+ this.gridSnapEnabled = enabled;
333
+ this.gridSize = nextSize;
334
+ this.scheduleDraw();
335
+ }
336
+ getGridSnap() {
337
+ return { enabled: this.gridSnapEnabled, size: this.gridSize };
338
+ }
339
+ normalizeGridSize(size) {
340
+ if (!Number.isFinite(size) || size <= 0)
341
+ throw new RangeError('ssdiagram: grid size must be a positive finite number');
342
+ return size;
343
+ }
344
+ nextNodeId() {
345
+ let id;
346
+ do
347
+ id = `n${this.idSeq++}`;
348
+ while (this.nodes.some((node) => node.id === id));
349
+ return id;
350
+ }
351
+ nextLinkId() {
352
+ let id;
353
+ do
354
+ id = `link_${this.linkSeq++}`;
355
+ while (this.links.some((link) => link.id === id));
356
+ return id;
357
+ }
358
+ addDiagramNode(init) {
359
+ const id = init.id ?? this.nextNodeId();
360
+ if (id.trim().length === 0)
361
+ throw new Error('ssdiagram: node id cannot be empty');
362
+ if (this.nodes.some((node) => node.id === id))
363
+ throw new Error(`ssdiagram: duplicate node id "${id}"`);
364
+ const fullInit = { ...init, id };
365
+ this.doAddNode(fullInit);
366
+ this.record({
367
+ do: () => { this.doAddNode(fullInit); },
368
+ undo: () => { this.doRemoveNode(id); },
369
+ label: 'add node',
370
+ });
371
+ return id;
372
+ }
373
+ doAddNode(init) {
374
+ const id = init.id ?? this.nextNodeId();
375
+ if (this.nodes.some((node) => node.id === id))
376
+ return;
377
+ const node = new NodeModel(init, id);
378
+ this.layoutNode(node);
379
+ this.nodes.push(node);
380
+ this.emit('nodeAdded', { node });
381
+ this.scheduleDraw();
382
+ }
383
+ removeDiagramNode(id) {
384
+ const node = this.nodes.find((n) => n.id === id);
385
+ if (node === undefined)
386
+ return;
387
+ // Capture full state for undo BEFORE mutating — we need both
388
+ // the node init AND every link that gets cascaded out, so undo
389
+ // can rebuild them in one shot.
390
+ const snapshot = node.toInit(false);
391
+ const cascaded = this.links
392
+ .map((link, index) => ({ link, index }))
393
+ .filter(({ link }) => link.from === id || link.to === id)
394
+ .map(({ link, index }) => ({ init: link.toInit(), index }));
395
+ const siblingTargets = [];
396
+ for (const { init } of cascaded) {
397
+ if (init.from !== id || init.to === id)
398
+ continue;
399
+ const targetPort = this.findNode(init.to)?.inPorts.find((port) => port.id === init.toPort);
400
+ if (targetPort?.isSibling !== true)
401
+ continue;
402
+ if (!siblingTargets.some((target) => target.nodeId === init.to && target.portId === init.toPort)) {
403
+ siblingTargets.push({ nodeId: init.to, portId: init.toPort });
404
+ }
405
+ }
406
+ const remove = () => {
407
+ this.doRemoveNode(id);
408
+ this.record({
409
+ do: () => { this.doRemoveNode(id); },
410
+ undo: () => {
411
+ this.doAddNode(snapshot);
412
+ for (const { init, index } of cascaded)
413
+ this.doAddLink(init, false, index);
414
+ },
415
+ label: 'remove node',
416
+ });
417
+ };
418
+ if (siblingTargets.length === 0) {
419
+ remove();
420
+ }
421
+ else {
422
+ this.withTransaction('remove node', () => {
423
+ remove();
424
+ for (const target of siblingTargets)
425
+ this.pruneDynamicSibling(target.nodeId, target.portId);
426
+ });
427
+ }
428
+ }
429
+ doRemoveNode(id) {
430
+ const node = this.nodes.find((n) => n.id === id);
431
+ if (node === undefined)
432
+ return;
433
+ const removedLinks = this.links.filter((l) => l.from === id || l.to === id);
434
+ this.links = this.links.filter((l) => l.from !== id && l.to !== id);
435
+ this.nodes = this.nodes.filter((n) => n !== node);
436
+ let selectionChanged = this.selectedNodes.delete(node);
437
+ if (this.selectedNode === node) {
438
+ const remaining = [...this.selectedNodes];
439
+ this.selectedNode = remaining[remaining.length - 1] ?? null;
440
+ selectionChanged = true;
441
+ }
442
+ if (this.selectedPort?.node === node) {
443
+ this.selectedPort = null;
444
+ selectionChanged = true;
445
+ }
446
+ if (this.selectedLink !== null && removedLinks.includes(this.selectedLink)) {
447
+ this.selectedLink = null;
448
+ selectionChanged = true;
449
+ }
450
+ if (selectionChanged)
451
+ this.emitSelectionChanged();
452
+ for (const l of removedLinks)
453
+ this.emit('linkRemoved', { link: l });
454
+ this.emit('nodeRemoved', { node });
455
+ this.scheduleDraw();
456
+ }
457
+ moveNode(id, x, y) {
458
+ const node = this.nodes.find((n) => n.id === id);
459
+ if (node === undefined)
460
+ return;
461
+ const fromX = node.x, fromY = node.y;
462
+ this.doMoveNode(id, x, y);
463
+ if (fromX !== x || fromY !== y) {
464
+ this.record({
465
+ do: () => { this.doMoveNode(id, x, y); },
466
+ undo: () => { this.doMoveNode(id, fromX, fromY); },
467
+ label: 'move node',
468
+ });
469
+ }
470
+ }
471
+ nudgeSelection(dx, dy) {
472
+ if (!Number.isFinite(dx) || !Number.isFinite(dy))
473
+ throw new RangeError('ssdiagram: nudge delta must be finite');
474
+ if ((dx === 0 && dy === 0) || this.selectedNodes.size === 0)
475
+ return false;
476
+ const moves = [...this.selectedNodes].map((node) => ({
477
+ id: node.id,
478
+ fromX: node.x,
479
+ fromY: node.y,
480
+ toX: node.x + dx,
481
+ toY: node.y + dy,
482
+ }));
483
+ for (const move of moves)
484
+ this.doMoveNode(move.id, move.toX, move.toY);
485
+ this.record({
486
+ do: () => { for (const move of moves)
487
+ this.doMoveNode(move.id, move.toX, move.toY); },
488
+ undo: () => { for (const move of moves)
489
+ this.doMoveNode(move.id, move.fromX, move.fromY); },
490
+ label: 'nudge',
491
+ });
492
+ return true;
493
+ }
494
+ doMoveNode(id, x, y) {
495
+ const node = this.nodes.find((n) => n.id === id);
496
+ if (node === undefined)
497
+ return;
498
+ node.x = x;
499
+ node.y = y;
500
+ this.layoutNode(node);
501
+ this.emit('nodeMoved', { node });
502
+ this.scheduleDraw();
503
+ }
504
+ addLink(init) {
505
+ const fullInit = {
506
+ ...init,
507
+ id: init.id ?? this.nextLinkId(),
508
+ metadata: copyJsonObject(init.metadata),
509
+ };
510
+ if (fullInit.id !== undefined && this.links.some((link) => link.id === fullInit.id))
511
+ return false;
512
+ const fromNode = this.nodes.find((node) => node.id === fullInit.from);
513
+ const toNode = this.nodes.find((node) => node.id === fullInit.to);
514
+ const fromPort = fromNode?.outPorts.find((port) => port.id === fullInit.fromPort);
515
+ const toPort = toNode?.inPorts.find((port) => port.id === fullInit.toPort);
516
+ if (fromNode !== undefined && toNode !== undefined && fromPort !== undefined && toPort !== undefined
517
+ && toPort.isDynamic && toPort.dynamicMode === 'onConnect' && !toPort.isSibling) {
518
+ const validation = this.validateLinkModels(fromNode, fromPort, toNode, toPort);
519
+ this.emit('linkValidation', { fromNode, from: fromPort, toNode, to: toPort, ...validation });
520
+ if (!validation.allowed)
521
+ return false;
522
+ const sibling = this.createDynamicSibling(toNode, toPort, fromPort);
523
+ let added = false;
524
+ const failed = new Error('ssdiagram: dynamic link transaction failed');
525
+ try {
526
+ this.withTransaction('add dynamic link', () => {
527
+ if (!this.addPort(toNode.id, 'in', sibling))
528
+ throw failed;
529
+ added = this.addLinkApplied({ ...fullInit, toPort: sibling.id }, false);
530
+ if (!added)
531
+ throw failed;
532
+ });
533
+ }
534
+ catch (error) {
535
+ if (error === failed)
536
+ return false;
537
+ throw error;
538
+ }
539
+ return added;
540
+ }
541
+ return this.addLinkApplied(fullInit, true);
542
+ }
543
+ addLinkApplied(init, validate) {
544
+ const ok = this.doAddLink(init, validate);
545
+ if (ok) {
546
+ this.record({
547
+ do: () => { this.doAddLink(init, false); },
548
+ undo: () => { this.doRemoveLink(init); },
549
+ label: 'add link',
550
+ });
551
+ }
552
+ return ok;
553
+ }
554
+ doAddLink(init, validate = true, index = this.links.length) {
555
+ const fn = this.nodes.find((n) => n.id === init.from);
556
+ const tn = this.nodes.find((n) => n.id === init.to);
557
+ if (fn === undefined || tn === undefined)
558
+ return false;
559
+ const fp = fn.outPorts.find((p) => p.id === init.fromPort);
560
+ const tp = tn.inPorts.find((p) => p.id === init.toPort);
561
+ if (fp === undefined || tp === undefined)
562
+ return false;
563
+ if (validate) {
564
+ const validation = this.validateLinkModels(fn, fp, tn, tp);
565
+ this.emit('linkValidation', { fromNode: fn, from: fp, toNode: tn, to: tp, ...validation });
566
+ if (!validation.allowed)
567
+ return false;
568
+ }
569
+ const link = new LinkModel(init.from, init.fromPort, init.to, init.toPort, init.id ?? this.nextLinkId(), init.metadata);
570
+ if (this.links.some((l) => l.id === link.id))
571
+ return false;
572
+ this.links.splice(clamp(Math.trunc(index), 0, this.links.length), 0, link);
573
+ this.emit('linkAdded', { link });
574
+ this.scheduleDraw();
575
+ return true;
576
+ }
577
+ removeLink(link) {
578
+ this.removeLinkInternal(link, true);
579
+ }
580
+ removeLinkInternal(link, pruneDynamicSibling) {
581
+ const key = new LinkModel(link.from, link.fromPort, link.to, link.toPort).key();
582
+ const found = link.id === undefined || link.id.length === 0
583
+ ? this.links.find((candidate) => candidate.key() === key)
584
+ : this.links.find((candidate) => candidate.id === link.id);
585
+ if (found === undefined)
586
+ return;
587
+ const init = found.toInit();
588
+ const index = this.links.indexOf(found);
589
+ const targetPort = this.findNode(found.to)?.inPorts.find((port) => port.id === found.toPort);
590
+ const remove = () => {
591
+ this.doRemoveLink(init);
592
+ this.record({
593
+ do: () => { this.doRemoveLink(init); },
594
+ undo: () => { this.doAddLink(init, false, index); },
595
+ label: 'remove link',
596
+ });
597
+ };
598
+ if (pruneDynamicSibling && targetPort?.isSibling === true) {
599
+ this.withTransaction('remove dynamic link', () => {
600
+ remove();
601
+ this.pruneDynamicSibling(init.to, init.toPort);
602
+ });
603
+ }
604
+ else {
605
+ remove();
606
+ }
607
+ }
608
+ doRemoveLink(link) {
609
+ const k = new LinkModel(link.from, link.fromPort, link.to, link.toPort).key();
610
+ const found = this.links.find((l) => l.key() === k);
611
+ if (found === undefined)
612
+ return;
613
+ this.links = this.links.filter((l) => l !== found);
614
+ if (this.selectedLink === found)
615
+ this.selectLink(null);
616
+ this.emit('linkRemoved', { link: found });
617
+ this.scheduleDraw();
618
+ }
619
+ createDynamicSibling(toNode, anchor, source) {
620
+ let sequence = 1;
621
+ while (toNode.inPorts.some((port) => port.id === `${anchor.id}_${sequence}`))
622
+ sequence += 1;
623
+ return {
624
+ id: `${anchor.id}_${sequence}`,
625
+ name: `${anchor.name} ${sequence}`,
626
+ description: anchor.description,
627
+ type: source.type || anchor.type,
628
+ maxLinks: 1,
629
+ availableTypes: [...anchor.availableTypes],
630
+ isDynamic: false,
631
+ dynamicMode: '',
632
+ isSibling: true,
633
+ metadata: copyJsonObject(anchor.metadata),
634
+ };
635
+ }
636
+ pruneDynamicSibling(nodeId, portId) {
637
+ const node = this.findNode(nodeId);
638
+ const port = node?.inPorts.find((candidate) => candidate.id === portId);
639
+ if (port?.isSibling !== true)
640
+ return;
641
+ if (this.links.some((link) => link.to === nodeId && link.toPort === portId))
642
+ return;
643
+ this.removePort(nodeId, 'in', portId);
644
+ }
645
+ // ---- undo / redo public surface ---------------------------------
646
+ canUndo() { return this.permissions.history && this.history.state.canUndo; }
647
+ canRedo() { return this.permissions.history && this.history.state.canRedo; }
648
+ undo() { if (this.permissions.history)
649
+ this.history.undo(); }
650
+ redo() { if (this.permissions.history)
651
+ this.history.redo(); }
652
+ cutSelection() {
653
+ if (!this.permissions.copy || !this.permissions.deleteSelection)
654
+ return;
655
+ // The copy/delete pair is observable as ONE undo step.
656
+ this.copySelection();
657
+ this.withTransaction('cut', () => { this.deleteSelection(); });
658
+ }
659
+ // Group multiple mutations into one undo step (paste of N nodes,
660
+ // bulk delete, multi-drag, etc.). Re-entrant: nested calls flatten
661
+ // into the outer batch.
662
+ withTransaction(label, fn) {
663
+ return this.history.transaction(label, fn);
664
+ }
665
+ record(action) {
666
+ this.history.recordApplied({
667
+ label: action.label,
668
+ execute: action.do,
669
+ undo: action.undo,
670
+ });
671
+ }
672
+ deleteSelection() {
673
+ if (!this.permissions.deleteSelection)
674
+ return;
675
+ if (this.selectedNodes.size > 0) {
676
+ this.withTransaction('delete selection', () => {
677
+ for (const id of [...this.selectedNodes].map((n) => n.id))
678
+ this.removeDiagramNode(id);
679
+ });
680
+ this.selectedNodes.clear();
681
+ this.selectedNode = null;
682
+ return;
683
+ }
684
+ if (this.selectedLink !== null) {
685
+ this.removeLink(this.selectedLink);
686
+ }
687
+ }
688
+ clear() {
689
+ this.nodes = [];
690
+ this.links = [];
691
+ this.selectedNode = null;
692
+ this.selectedNodes.clear();
693
+ this.selectedLink = null;
694
+ this.selectedPort = null;
695
+ this.documentMetadata = {};
696
+ this.runtimeState = createDiagramRuntimeState();
697
+ this.runtimePulse = 0;
698
+ this.globalErrorFlashStart = null;
699
+ this.history.clear();
700
+ this.emitRuntimeStateChanged();
701
+ }
702
+ relink(linkId, next) {
703
+ const link = this.links.find((candidate) => candidate.id === linkId);
704
+ if (link === undefined)
705
+ return { allowed: false, reason: 'missing-link' };
706
+ const validation = this.validateLink(next, linkId);
707
+ const fromNode = this.nodes.find((node) => node.id === next.from);
708
+ const toNode = this.nodes.find((node) => node.id === next.to);
709
+ const fromPort = fromNode?.outPorts.find((port) => port.id === next.fromPort);
710
+ const toPort = toNode?.inPorts.find((port) => port.id === next.toPort);
711
+ if (fromNode !== undefined && toNode !== undefined && fromPort !== undefined && toPort !== undefined) {
712
+ this.emit('linkValidation', { fromNode, from: fromPort, toNode, to: toPort, ...validation });
713
+ }
714
+ if (!validation.allowed)
715
+ return validation;
716
+ const before = link.toInit();
717
+ if (before.from === next.from && before.fromPort === next.fromPort
718
+ && before.to === next.to && before.toPort === next.toPort)
719
+ return validation;
720
+ const dynamicSibling = fromPort !== undefined && toNode !== undefined && toPort !== undefined
721
+ && toPort.isDynamic && toPort.dynamicMode === 'onConnect' && !toPort.isSibling
722
+ ? this.createDynamicSibling(toNode, toPort, fromPort)
723
+ : null;
724
+ const after = {
725
+ ...before,
726
+ ...next,
727
+ toPort: dynamicSibling?.id ?? next.toPort,
728
+ };
729
+ const oldTargetPort = this.findNode(before.to)?.inPorts.find((port) => port.id === before.toPort);
730
+ const apply = () => {
731
+ this.doRelink(linkId, after);
732
+ this.record({
733
+ do: () => { this.doRelink(linkId, after); },
734
+ undo: () => { this.doRelink(linkId, before); },
735
+ label: 'relink',
736
+ });
737
+ };
738
+ const pruneOldSibling = oldTargetPort?.isSibling === true
739
+ && (before.to !== after.to || before.toPort !== after.toPort);
740
+ if (dynamicSibling !== null || pruneOldSibling) {
741
+ this.withTransaction('relink dynamic link', () => {
742
+ if (dynamicSibling !== null && !this.addPort(after.to, 'in', dynamicSibling)) {
743
+ throw new Error('ssdiagram: could not create a dynamic sibling port');
744
+ }
745
+ apply();
746
+ if (pruneOldSibling)
747
+ this.pruneDynamicSibling(before.to, before.toPort);
748
+ });
749
+ }
750
+ else {
751
+ apply();
752
+ }
753
+ return validation;
754
+ }
755
+ doRelink(linkId, next) {
756
+ const link = this.links.find((candidate) => candidate.id === linkId);
757
+ if (link === undefined)
758
+ return;
759
+ const previous = link.toInit();
760
+ link.from = next.from;
761
+ link.fromPort = next.fromPort;
762
+ link.to = next.to;
763
+ link.toPort = next.toPort;
764
+ this.emit('linkRelinked', { link, previous });
765
+ this.scheduleDraw();
766
+ }
767
+ addPort(nodeId, direction, init) {
768
+ const node = this.findNode(nodeId);
769
+ if (node === undefined)
770
+ return false;
771
+ const ports = direction === 'in' ? node.inPorts : node.outPorts;
772
+ if (ports.some((port) => port.id === init.id))
773
+ return false;
774
+ return this.updateNodeState(nodeId, 'add port', (target) => {
775
+ const list = direction === 'in' ? target.inPorts : target.outPorts;
776
+ list.push(new PortModel(init, direction));
777
+ });
778
+ }
779
+ removePort(nodeId, direction, portId) {
780
+ const node = this.findNode(nodeId);
781
+ if (node === undefined)
782
+ return false;
783
+ const ports = direction === 'in' ? node.inPorts : node.outPorts;
784
+ if (!ports.some((port) => port.id === portId))
785
+ return false;
786
+ let changed = false;
787
+ this.withTransaction('remove port', () => {
788
+ const affected = this.links.filter((link) => direction === 'in'
789
+ ? link.to === nodeId && link.toPort === portId
790
+ : link.from === nodeId && link.fromPort === portId);
791
+ for (const link of affected)
792
+ this.removeLinkInternal(link, false);
793
+ changed = this.updateNodeState(nodeId, 'remove port', (target) => {
794
+ if (direction === 'in')
795
+ target.inPorts = target.inPorts.filter((port) => port.id !== portId);
796
+ else
797
+ target.outPorts = target.outPorts.filter((port) => port.id !== portId);
798
+ });
799
+ });
800
+ return changed;
801
+ }
802
+ updatePortType(nodeId, direction, portId, type) {
803
+ return this.updatePort(nodeId, direction, portId, { type });
804
+ }
805
+ updatePort(nodeId, direction, portId, patch) {
806
+ const node = this.findNode(nodeId);
807
+ const port = direction === 'in'
808
+ ? node?.inPorts.find((candidate) => candidate.id === portId)
809
+ : node?.outPorts.find((candidate) => candidate.id === portId);
810
+ if (port === undefined)
811
+ return false;
812
+ if (patch.maxLinks !== undefined && (!Number.isInteger(patch.maxLinks) || patch.maxLinks < 0)) {
813
+ throw new RangeError('ssdiagram: port maxLinks must be a non-negative integer');
814
+ }
815
+ const compatibilityChanged = (patch.type !== undefined && patch.type !== port.type)
816
+ || (patch.availableTypes !== undefined
817
+ && JSON.stringify(patch.availableTypes) !== JSON.stringify(port.availableTypes));
818
+ let changed = false;
819
+ this.withTransaction('update port', () => {
820
+ changed = this.updateNodeState(nodeId, 'update port', (target) => {
821
+ const targetPort = direction === 'in'
822
+ ? target.inPorts.find((candidate) => candidate.id === portId)
823
+ : target.outPorts.find((candidate) => candidate.id === portId);
824
+ if (targetPort === undefined)
825
+ return;
826
+ if (patch.name !== undefined)
827
+ targetPort.name = patch.name;
828
+ if (patch.description !== undefined)
829
+ targetPort.description = patch.description;
830
+ if (patch.type !== undefined)
831
+ targetPort.type = patch.type;
832
+ if (patch.maxLinks !== undefined)
833
+ targetPort.maxLinks = patch.maxLinks;
834
+ if (patch.availableTypes !== undefined)
835
+ targetPort.availableTypes = [...patch.availableTypes];
836
+ if (patch.isDynamic !== undefined)
837
+ targetPort.isDynamic = patch.isDynamic;
838
+ if (patch.dynamicMode !== undefined)
839
+ targetPort.dynamicMode = patch.dynamicMode;
840
+ if (patch.isSibling !== undefined)
841
+ targetPort.isSibling = patch.isSibling;
842
+ if (patch.metadata !== undefined)
843
+ targetPort.metadata = copyJsonObject(patch.metadata);
844
+ });
845
+ if (changed && compatibilityChanged) {
846
+ this.removeIncompatibleLinks((link) => direction === 'in'
847
+ ? link.to === nodeId && link.toPort === portId
848
+ : link.from === nodeId && link.fromPort === portId);
849
+ }
850
+ });
851
+ return changed;
852
+ }
853
+ setNodePorts(nodeId, inPorts, outPorts) {
854
+ const node = this.findNode(nodeId);
855
+ if (node === undefined)
856
+ return false;
857
+ const inputIds = new Set(inPorts.map((port) => port.id));
858
+ const outputIds = new Set(outPorts.map((port) => port.id));
859
+ let changed = false;
860
+ this.withTransaction('set node ports', () => {
861
+ const invalid = this.links.filter((link) => (link.to === nodeId && !inputIds.has(link.toPort))
862
+ || (link.from === nodeId && !outputIds.has(link.fromPort)));
863
+ for (const link of invalid)
864
+ this.removeLinkInternal(link, false);
865
+ changed = this.updateNodeState(nodeId, 'set node ports', (target) => {
866
+ target.inPorts = inPorts.map((port) => new PortModel(port, 'in'));
867
+ target.outPorts = outPorts.map((port) => new PortModel(port, 'out'));
868
+ });
869
+ if (changed) {
870
+ this.removeIncompatibleLinks((link) => link.from === nodeId || link.to === nodeId);
871
+ }
872
+ });
873
+ return changed;
874
+ }
875
+ updateNode(nodeId, patch) {
876
+ return this.updateNodeState(nodeId, 'update node', (node) => {
877
+ if (patch.name !== undefined)
878
+ node.name = patch.name;
879
+ if (patch.description !== undefined)
880
+ node.description = patch.description;
881
+ if (patch.color !== undefined)
882
+ node.color = patch.color;
883
+ if (patch.border !== undefined)
884
+ node.border = patch.border;
885
+ if (patch.message !== undefined)
886
+ node.message = patch.message;
887
+ if (patch.openAction !== undefined)
888
+ node.openAction = patch.openAction;
889
+ });
890
+ }
891
+ setNodeParamValue(nodeId, name, value) {
892
+ return this.updateNodeState(nodeId, 'set node parameter', (node) => {
893
+ if (value === undefined)
894
+ delete node.paramValues[name];
895
+ else
896
+ node.paramValues[name] = value;
897
+ });
898
+ }
899
+ setShowNodeMessages(show) {
900
+ this.showNodeMessages = show;
901
+ this.scheduleDraw();
902
+ }
903
+ updateNodeState(nodeId, label, mutate) {
904
+ const node = this.findNode(nodeId);
905
+ if (node === undefined)
906
+ return false;
907
+ const before = node.toInit(false);
908
+ mutate(node);
909
+ if (this.reconcileSelectedPort(node))
910
+ this.emitSelectionChanged();
911
+ const after = node.toInit(false);
912
+ if (JSON.stringify(before) === JSON.stringify(after))
913
+ return false;
914
+ this.layoutNode(node);
915
+ this.emit('nodeChanged', { node });
916
+ this.scheduleDraw();
917
+ this.record({
918
+ do: () => { this.applyNodeSnapshot(nodeId, after); },
919
+ undo: () => { this.applyNodeSnapshot(nodeId, before); },
920
+ label,
921
+ });
922
+ return true;
923
+ }
924
+ applyNodeSnapshot(nodeId, snapshot) {
925
+ const node = this.findNode(nodeId);
926
+ if (node === undefined)
927
+ return;
928
+ const restored = new NodeModel(snapshot, nodeId);
929
+ node.typeId = restored.typeId;
930
+ node.name = restored.name;
931
+ node.description = restored.description;
932
+ node.groupName = restored.groupName;
933
+ node.color = restored.color;
934
+ node.border = restored.border;
935
+ node.icon = restored.icon;
936
+ node.openAction = restored.openAction;
937
+ node.message = restored.message;
938
+ node.parameters = restored.parameters;
939
+ node.paramValues = restored.paramValues;
940
+ node.metadata = restored.metadata;
941
+ node.isPlaceholder = restored.isPlaceholder;
942
+ node.x = restored.x;
943
+ node.y = restored.y;
944
+ node.inPorts = restored.inPorts;
945
+ node.outPorts = restored.outPorts;
946
+ if (this.reconcileSelectedPort(node))
947
+ this.emitSelectionChanged();
948
+ this.layoutNode(node);
949
+ this.emit('nodeChanged', { node });
950
+ this.scheduleDraw();
951
+ }
952
+ reconcileSelectedPort(node) {
953
+ if (this.selectedPort?.node !== node)
954
+ return false;
955
+ const selected = this.selectedPort.port;
956
+ const ports = selected.direction === 'in' ? node.inPorts : node.outPorts;
957
+ const replacement = ports.find((port) => port.id === selected.id);
958
+ if (replacement === selected)
959
+ return false;
960
+ this.selectedPort = replacement === undefined ? null : { node, port: replacement };
961
+ return true;
962
+ }
963
+ load(nodes, links) {
964
+ this.clear();
965
+ this.idSeq = 1;
966
+ this.linkSeq = 1;
967
+ // Initial load is "model seed" — should NOT be undoable, and
968
+ // shouldn't leave the user with 12 entries to Ctrl+Z through
969
+ // before their stack reaches their first real action.
970
+ for (const node of nodes) {
971
+ const id = node.id ?? this.nextNodeId();
972
+ this.doAddNode({ ...node, id });
973
+ }
974
+ for (const link of links) {
975
+ const id = link.id ?? this.nextLinkId();
976
+ if (this.links.some((existing) => existing.id === id)) {
977
+ throw new Error(`ssdiagram: duplicate link id "${id}"`);
978
+ }
979
+ const model = new LinkModel(link.from, link.fromPort, link.to, link.toPort, id, link.metadata);
980
+ if (!this.links.some((existing) => existing.key() === model.key()))
981
+ this.links.push(model);
982
+ }
983
+ for (const node of this.nodes) {
984
+ if (node.loadError.length === 0)
985
+ continue;
986
+ const state = createDiagramNodeRuntimeState();
987
+ state.error = { kind: 'load', message: node.loadError, pulse: ++this.runtimePulse };
988
+ this.runtimeState.nodes[node.id] = state;
989
+ }
990
+ if (Object.keys(this.runtimeState.nodes).length > 0)
991
+ this.emitRuntimeStateChanged();
992
+ this.history.clear();
993
+ this.emit('loadFinished', { nodes: this.nodes.slice(), links: this.links.slice() });
994
+ this.zoomToFit();
995
+ this.playIntro();
996
+ }
997
+ save() {
998
+ return {
999
+ nodes: this.nodes.map((node) => node.toInit(false)),
1000
+ links: this.links.map((l) => ({ from: l.from, fromPort: l.fromPort, to: l.to, toPort: l.toPort })),
1001
+ };
1002
+ }
1003
+ loadDocument(source) {
1004
+ const document = parseDiagramDocument(source);
1005
+ this.load(document.nodes, document.links.map((link) => ({
1006
+ id: link.id,
1007
+ from: link.from.nodeId,
1008
+ fromPort: link.from.portId,
1009
+ to: link.to.nodeId,
1010
+ toPort: link.to.portId,
1011
+ metadata: link.metadata,
1012
+ })));
1013
+ this.documentMetadata = copyJsonObject(document.metadata);
1014
+ }
1015
+ saveDocument() {
1016
+ return createDiagramDocument({
1017
+ nodes: this.nodes.map((node) => node.toInit(false)),
1018
+ links: this.links.map((link) => ({
1019
+ id: link.id,
1020
+ from: { nodeId: link.from, portId: link.fromPort },
1021
+ to: { nodeId: link.to, portId: link.toPort },
1022
+ metadata: link.metadata,
1023
+ })),
1024
+ metadata: this.documentMetadata,
1025
+ });
1026
+ }
1027
+ selectedNodeId() { return this.selectedNode?.id ?? null; }
1028
+ getSelection() {
1029
+ return {
1030
+ nodeIds: [...this.selectedNodes].map((node) => node.id),
1031
+ linkIds: this.selectedLink === null ? [] : [this.selectedLink.id],
1032
+ port: this.selectedPort === null ? null : {
1033
+ nodeId: this.selectedPort.node.id,
1034
+ portId: this.selectedPort.port.id,
1035
+ direction: this.selectedPort.port.direction,
1036
+ },
1037
+ primaryNodeId: this.selectedNode?.id ?? null,
1038
+ primaryLinkId: this.selectedLink?.id ?? null,
1039
+ };
1040
+ }
1041
+ selectNodesById(ids) {
1042
+ const wanted = new Set(ids);
1043
+ this.setSelection(this.nodes.filter((node) => wanted.has(node.id)));
1044
+ }
1045
+ selectLinkById(id) {
1046
+ this.selectLink(id === null ? null : (this.links.find((link) => link.id === id) ?? null));
1047
+ }
1048
+ selectPortById(nodeId, direction, portId) {
1049
+ const node = this.findNode(nodeId);
1050
+ const port = direction === 'in'
1051
+ ? node?.inPorts.find((candidate) => candidate.id === portId)
1052
+ : node?.outPorts.find((candidate) => candidate.id === portId);
1053
+ if (node !== undefined && port !== undefined)
1054
+ this.selectPort({ node, port });
1055
+ }
1056
+ getViewState() {
1057
+ return {
1058
+ zoom: this.scale,
1059
+ panX: this.offX,
1060
+ panY: this.offY,
1061
+ overviewVisible: this.overviewVisible,
1062
+ };
1063
+ }
1064
+ setViewState(state) {
1065
+ this.scale = clamp(state.zoom, ZOOM_MIN, ZOOM_MAX);
1066
+ this.offX = state.panX;
1067
+ this.offY = state.panY;
1068
+ this.overviewVisible = state.overviewVisible;
1069
+ this.emitViewChanged(true);
1070
+ this.scheduleDraw();
1071
+ }
1072
+ // Renderer operations consumed by the high-level component.
1073
+ findNode(id) { return this.nodes.find((n) => n.id === id); }
1074
+ requestRedraw() { this.relayout(); this.scheduleDraw(); }
1075
+ /**
1076
+ * Creates a detached canvas. With no options it is an exact copy of the
1077
+ * current frame, matching Charts.takeScreenshot(). Content scope renders
1078
+ * the whole graph without changing the visible viewport.
1079
+ */
1080
+ takeScreenshot(options = {}) {
1081
+ if (this.destroyed)
1082
+ throw new Error('ssdiagram: cannot export a destroyed diagram');
1083
+ const scope = options.scope ?? 'viewport';
1084
+ if (scope !== 'viewport' && scope !== 'content') {
1085
+ throw new RangeError(`ssdiagram: unsupported screenshot scope "${String(scope)}"`);
1086
+ }
1087
+ if (scope === 'viewport' && Object.keys(options).length === 0) {
1088
+ const copy = document.createElement('canvas');
1089
+ copy.width = this.canvas.width;
1090
+ copy.height = this.canvas.height;
1091
+ const copyContext = copy.getContext('2d');
1092
+ if (copyContext === null)
1093
+ throw new Error('ssdiagram: screenshot 2d context unavailable');
1094
+ copyContext.drawImage(this.canvas, 0, 0);
1095
+ return copy;
1096
+ }
1097
+ const pixelRatio = this.positiveScreenshotNumber(options.pixelRatio ?? this.dpr, 'pixelRatio');
1098
+ const exportScale = scope === 'content'
1099
+ ? this.positiveScreenshotNumber(options.scale ?? 1, 'scale')
1100
+ : this.scale;
1101
+ const padding = scope === 'content'
1102
+ ? this.nonNegativeScreenshotNumber(options.padding ?? 32, 'padding')
1103
+ : 0;
1104
+ const bounds = scope === 'content' ? this.graphBounds() : null;
1105
+ const width = scope === 'content'
1106
+ ? Math.max(1, Math.ceil(((bounds?.maxX ?? 1) - (bounds?.minX ?? 0)) * exportScale + padding * 2))
1107
+ : this.width;
1108
+ const height = scope === 'content'
1109
+ ? Math.max(1, Math.ceil(((bounds?.maxY ?? 1) - (bounds?.minY ?? 0)) * exportScale + padding * 2))
1110
+ : this.height;
1111
+ const pixelWidth = Math.ceil(width * pixelRatio);
1112
+ const pixelHeight = Math.ceil(height * pixelRatio);
1113
+ if (pixelWidth > 16384 || pixelHeight > 16384 || pixelWidth * pixelHeight > 268435456) {
1114
+ throw new RangeError(`ssdiagram: screenshot is too large (${pixelWidth}x${pixelHeight})`);
1115
+ }
1116
+ const output = document.createElement('canvas');
1117
+ output.width = pixelWidth;
1118
+ output.height = pixelHeight;
1119
+ output.style.width = `${width}px`;
1120
+ output.style.height = `${height}px`;
1121
+ const outputContext = output.getContext('2d');
1122
+ if (outputContext === null)
1123
+ throw new Error('ssdiagram: screenshot 2d context unavailable');
1124
+ const previous = {
1125
+ ctx: this.ctx,
1126
+ width: this.width,
1127
+ height: this.height,
1128
+ dpr: this.dpr,
1129
+ scale: this.scale,
1130
+ offX: this.offX,
1131
+ offY: this.offY,
1132
+ overviewVisible: this.overviewVisible,
1133
+ background: this.opts.background,
1134
+ };
1135
+ try {
1136
+ this.ctx = outputContext;
1137
+ this.width = width;
1138
+ this.height = height;
1139
+ this.dpr = pixelRatio;
1140
+ this.scale = exportScale;
1141
+ this.offX = scope === 'content' && bounds !== null ? padding - bounds.minX * exportScale : previous.offX;
1142
+ this.offY = scope === 'content' && bounds !== null ? padding - bounds.minY * exportScale : previous.offY;
1143
+ this.overviewVisible = options.includeOverview ?? (scope === 'viewport' && previous.overviewVisible);
1144
+ if (options.background !== undefined)
1145
+ this.opts.background = options.background;
1146
+ outputContext.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
1147
+ this.draw({
1148
+ grid: options.includeGrid ?? true,
1149
+ overview: this.overviewVisible,
1150
+ selection: options.includeSelection ?? scope === 'viewport',
1151
+ runtime: options.includeRuntimeState ?? true,
1152
+ transient: false,
1153
+ });
1154
+ }
1155
+ finally {
1156
+ this.ctx = previous.ctx;
1157
+ this.width = previous.width;
1158
+ this.height = previous.height;
1159
+ this.dpr = previous.dpr;
1160
+ this.scale = previous.scale;
1161
+ this.offX = previous.offX;
1162
+ this.offY = previous.offY;
1163
+ this.overviewVisible = previous.overviewVisible;
1164
+ this.opts.background = previous.background;
1165
+ }
1166
+ return output;
1167
+ }
1168
+ positiveScreenshotNumber(value, name) {
1169
+ if (!Number.isFinite(value) || value <= 0) {
1170
+ throw new RangeError(`ssdiagram: screenshot ${name} must be a positive finite number`);
1171
+ }
1172
+ return value;
1173
+ }
1174
+ nonNegativeScreenshotNumber(value, name) {
1175
+ if (!Number.isFinite(value) || value < 0) {
1176
+ throw new RangeError(`ssdiagram: screenshot ${name} must be a non-negative finite number`);
1177
+ }
1178
+ return value;
1179
+ }
1180
+ getRuntimeState() {
1181
+ return cloneDiagramRuntimeState(this.runtimeState);
1182
+ }
1183
+ setRuntimeState(state) {
1184
+ const previous = this.runtimeState;
1185
+ this.runtimeState = cloneDiagramRuntimeState(state);
1186
+ if (this.runtimeState.globalError === null) {
1187
+ this.globalErrorFlashStart = null;
1188
+ }
1189
+ else if (previous.globalError?.pulse !== this.runtimeState.globalError.pulse) {
1190
+ this.globalErrorFlashStart = performance.now();
1191
+ }
1192
+ this.runtimePulse = Math.max(this.runtimePulse, this.runtimeState.globalError?.pulse ?? 0, ...Object.values(this.runtimeState.nodes).map((node) => node.error?.pulse ?? 0));
1193
+ for (const node of this.nodes) {
1194
+ const error = this.runtimeState.nodes[node.id]?.error ?? null;
1195
+ const previousError = previous.nodes[node.id]?.error ?? null;
1196
+ node.runtimeError = error?.kind === 'runtime' ? error.message : '';
1197
+ node.loadError = error?.kind === 'load' ? error.message : '';
1198
+ if (error?.kind === 'runtime') {
1199
+ if (previousError?.kind !== 'runtime' || previousError.pulse !== error.pulse) {
1200
+ node.errorFlashStart = performance.now();
1201
+ }
1202
+ }
1203
+ else {
1204
+ node.errorFlashStart = null;
1205
+ }
1206
+ }
1207
+ this.emitRuntimeStateChanged();
1208
+ }
1209
+ clearRuntimeState() {
1210
+ this.setRuntimeState(createDiagramRuntimeState());
1211
+ }
1212
+ setActiveNode(nodeId) {
1213
+ if (nodeId !== null && this.findNode(nodeId) === undefined)
1214
+ return false;
1215
+ const state = this.getRuntimeState();
1216
+ state.activeNodeId = nodeId;
1217
+ this.setRuntimeState(state);
1218
+ return true;
1219
+ }
1220
+ setPortRuntimeState(nodeId, direction, portId, patch) {
1221
+ const node = this.findNode(nodeId);
1222
+ const port = direction === 'in'
1223
+ ? node?.inPorts.find((candidate) => candidate.id === portId)
1224
+ : node?.outPorts.find((candidate) => candidate.id === portId);
1225
+ if (node === undefined || port === undefined)
1226
+ return false;
1227
+ const state = this.getRuntimeState();
1228
+ const nodeState = state.nodes[nodeId] ?? createDiagramNodeRuntimeState();
1229
+ state.nodes[nodeId] = nodeState;
1230
+ const current = nodeState.ports[direction][portId] ?? createDiagramPortRuntimeState();
1231
+ nodeState.ports[direction][portId] = { ...current, ...patch };
1232
+ this.setRuntimeState(state);
1233
+ return true;
1234
+ }
1235
+ setGlobalError(message, kind = 'invalid') {
1236
+ const state = this.getRuntimeState();
1237
+ state.globalError = message === null || message.length === 0
1238
+ ? null
1239
+ : { kind, message, pulse: ++this.runtimePulse };
1240
+ this.setRuntimeState(state);
1241
+ }
1242
+ setNodeError(id, message, options = {}) {
1243
+ const node = this.findNode(id);
1244
+ if (node === undefined)
1245
+ return false;
1246
+ const kind = options.kind ?? 'runtime';
1247
+ if (kind === 'load') {
1248
+ node.loadError = message;
1249
+ }
1250
+ else {
1251
+ node.runtimeError = message;
1252
+ node.errorFlashStart = message.length > 0 && options.animate !== false
1253
+ ? performance.now()
1254
+ : null;
1255
+ }
1256
+ const state = this.getRuntimeState();
1257
+ const nodeState = state.nodes[id] ?? createDiagramNodeRuntimeState();
1258
+ state.nodes[id] = nodeState;
1259
+ nodeState.error = message.length === 0 ? null : { kind, message, pulse: ++this.runtimePulse };
1260
+ this.runtimeState = state;
1261
+ this.emitRuntimeStateChanged();
1262
+ return true;
1263
+ }
1264
+ clearNodeError(id, kind) {
1265
+ const node = this.findNode(id);
1266
+ if (node === undefined)
1267
+ return false;
1268
+ if (kind === undefined || kind === 'runtime') {
1269
+ node.runtimeError = '';
1270
+ node.errorFlashStart = null;
1271
+ }
1272
+ if (kind === undefined || kind === 'load')
1273
+ node.loadError = '';
1274
+ const state = this.getRuntimeState();
1275
+ const nodeState = state.nodes[id];
1276
+ if (nodeState !== undefined && (kind === undefined || nodeState.error?.kind === kind)) {
1277
+ nodeState.error = node.runtimeError.length > 0
1278
+ ? { kind: 'runtime', message: node.runtimeError, pulse: ++this.runtimePulse }
1279
+ : node.loadError.length > 0
1280
+ ? { kind: 'load', message: node.loadError, pulse: ++this.runtimePulse }
1281
+ : null;
1282
+ }
1283
+ this.runtimeState = state;
1284
+ this.emitRuntimeStateChanged();
1285
+ return true;
1286
+ }
1287
+ emitRuntimeStateChanged() {
1288
+ this.emit('runtimeStateChanged', { state: this.getRuntimeState() });
1289
+ this.scheduleDraw();
1290
+ }
1291
+ viewToWorld(sx, sy) { return this.toWorld(sx, sy); }
1292
+ worldToView(wx, wy) { return this.toScreen(wx, wy); }
1293
+ selectNodeById(id) {
1294
+ const node = id === null ? null : (this.nodes.find((n) => n.id === id) ?? null);
1295
+ this.selectNode(node);
1296
+ }
1297
+ setZoom(scale) {
1298
+ const cx = this.width / 2;
1299
+ const cy = this.height / 2;
1300
+ const wx = (cx - this.offX) / this.scale;
1301
+ const wy = (cy - this.offY) / this.scale;
1302
+ this.scale = clamp(scale, ZOOM_MIN, ZOOM_MAX);
1303
+ this.offX = cx - wx * this.scale;
1304
+ this.offY = cy - wy * this.scale;
1305
+ this.emitViewChanged(true);
1306
+ this.scheduleDraw();
1307
+ }
1308
+ zoomToFit() {
1309
+ const gb = this.graphBounds();
1310
+ if (gb === null) {
1311
+ this.scale = 1;
1312
+ this.offX = 0;
1313
+ this.offY = 0;
1314
+ this.emitViewChanged(true);
1315
+ this.scheduleDraw();
1316
+ return;
1317
+ }
1318
+ const pad = 40;
1319
+ const gw = (gb.maxX - gb.minX) + pad * 2;
1320
+ const gh = (gb.maxY - gb.minY) + pad * 2;
1321
+ // Cap at 1.0 — never blow a small graph up past 100%.
1322
+ this.scale = clamp(Math.min(this.width / gw, this.height / gh), ZOOM_MIN, 1);
1323
+ this.offX = (this.width - (gb.maxX + gb.minX) * this.scale) / 2;
1324
+ this.offY = (this.height - (gb.maxY + gb.minY) * this.scale) / 2;
1325
+ this.emitViewChanged(true);
1326
+ this.scheduleDraw();
1327
+ }
1328
+ // Entrance animation: the whole scheme rises from below + fades in.
1329
+ playIntro() { this.introStart = performance.now(); this.scheduleDraw(); }
1330
+ setOverviewVisible(v) {
1331
+ if (this.overviewVisible === v)
1332
+ return;
1333
+ this.overviewVisible = v;
1334
+ this.emitViewChanged(false);
1335
+ this.scheduleDraw();
1336
+ }
1337
+ setTypeColors(colors) {
1338
+ this.opts.typeColors = { ...colors };
1339
+ this.scheduleDraw();
1340
+ }
1341
+ setTheme(t) {
1342
+ if (t.background !== undefined)
1343
+ this.opts.background = t.background;
1344
+ if (t.gridColor !== undefined)
1345
+ this.opts.gridColor = t.gridColor;
1346
+ if (t.linkMaxLightness !== undefined)
1347
+ this.opts.linkMaxLightness = t.linkMaxLightness;
1348
+ if (t.overviewBackground !== undefined)
1349
+ this.opts.overviewBackground = t.overviewBackground;
1350
+ if (t.overviewBorderColor !== undefined)
1351
+ this.opts.overviewBorderColor = t.overviewBorderColor;
1352
+ if (t.overviewViewportColor !== undefined)
1353
+ this.opts.overviewViewportColor = t.overviewViewportColor;
1354
+ if (t.overviewViewportFill !== undefined)
1355
+ this.opts.overviewViewportFill = t.overviewViewportFill;
1356
+ this.scheduleDraw();
1357
+ }
1358
+ graphBounds() {
1359
+ if (this.nodes.length === 0)
1360
+ return null;
1361
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
1362
+ for (const n of this.nodes) {
1363
+ minX = Math.min(minX, n.x);
1364
+ minY = Math.min(minY, n.y);
1365
+ maxX = Math.max(maxX, n.x + n.w);
1366
+ maxY = Math.max(maxY, n.y + n.h);
1367
+ }
1368
+ return { minX, minY, maxX, maxY };
1369
+ }
1370
+ resize(w, h) {
1371
+ if (w < 2 || h < 2)
1372
+ return;
1373
+ this.width = w;
1374
+ this.height = h;
1375
+ this.dpr = window.devicePixelRatio || 1;
1376
+ this.canvas.width = Math.round(w * this.dpr);
1377
+ this.canvas.height = Math.round(h * this.dpr);
1378
+ this.canvas.style.width = `${w}px`;
1379
+ this.canvas.style.height = `${h}px`;
1380
+ this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
1381
+ this.scheduleDraw();
1382
+ }
1383
+ getInteractionPermissions() {
1384
+ return { ...this.permissions };
1385
+ }
1386
+ setInteractionPermissions(patch) {
1387
+ this.permissions = { ...this.permissions, ...patch };
1388
+ if (!this.permissions.select) {
1389
+ this.selectedNode = null;
1390
+ this.selectedNodes.clear();
1391
+ this.selectedLink = null;
1392
+ this.selectedPort = null;
1393
+ this.emitSelectionChanged();
1394
+ }
1395
+ this.emit('undoStackChanged', { canUndo: this.canUndo(), canRedo: this.canRedo() });
1396
+ this.scheduleDraw();
1397
+ }
1398
+ /** View-only mode keeps selection, inspection and copy enabled. */
1399
+ setReadOnly(value) {
1400
+ this.permissions = value
1401
+ ? createReadOnlyDiagramPermissions()
1402
+ : createEditableDiagramPermissions();
1403
+ this.emit('undoStackChanged', { canUndo: this.canUndo(), canRedo: this.canRedo() });
1404
+ this.scheduleDraw();
1405
+ }
1406
+ destroy() {
1407
+ if (this.destroyed)
1408
+ return;
1409
+ this.destroyed = true;
1410
+ this.cancelLongPress();
1411
+ if (this.tipTimer !== null) {
1412
+ clearTimeout(this.tipTimer);
1413
+ this.tipTimer = null;
1414
+ }
1415
+ for (const dispose of this.domDisposables.splice(0).reverse())
1416
+ dispose();
1417
+ this.handlers.clear();
1418
+ this.iconCache.clear();
1419
+ this.canvas.remove();
1420
+ }
1421
+ // ---- geometry ---------------------------------------------------
1422
+ layoutNode(n) {
1423
+ const ctx = this.ctx;
1424
+ ctx.font = '600 12px Segoe UI, Tahoma, sans-serif';
1425
+ const titleW = ctx.measureText(n.name).width;
1426
+ const rows = Math.max(n.inPorts.length, n.outPorts.length, 1);
1427
+ // Node sizing: min 170 wide, height grows by rows.
1428
+ n.w = Math.max(170, Math.ceil(titleW) + 28);
1429
+ n.h = Math.max(48, rows * PORT_ROW_H + 16);
1430
+ // Each side's port stack is vertically centred; the socket
1431
+ // square sits OUTSIDE the body (its inner edge flush with the
1432
+ // node border).
1433
+ const off = PORT_SQ / 2;
1434
+ const place = (ports, x) => {
1435
+ const startY = n.y + (n.h - ports.length * PORT_ROW_H) / 2 + PORT_ROW_H / 2;
1436
+ ports.forEach((p, i) => { p.cx = x; p.cy = startY + i * PORT_ROW_H; });
1437
+ };
1438
+ place(n.inPorts, n.x - off);
1439
+ place(n.outPorts, n.x + n.w + off);
1440
+ }
1441
+ relayout() { for (const n of this.nodes)
1442
+ this.layoutNode(n); }
1443
+ toScreen(wx, wy) {
1444
+ return [wx * this.scale + this.offX, wy * this.scale + this.offY];
1445
+ }
1446
+ toWorld(sx, sy) {
1447
+ return [(sx - this.offX) / this.scale, (sy - this.offY) / this.scale];
1448
+ }
1449
+ portColor(type) {
1450
+ const maxL = this.opts.linkMaxLightness;
1451
+ const light = maxL !== undefined && maxL < 0.5;
1452
+ if (type === '')
1453
+ return light ? '#585c62' : '#9aa0a6';
1454
+ const map = this.opts.typeColors ?? {};
1455
+ const base = map[type] ?? `hsl(${hashHue(type)}, 62%, 58%)`;
1456
+ return maxL !== undefined ? clampLightness(base, maxL) : base;
1457
+ }
1458
+ // ---- hit testing (world coords) ---------------------------------
1459
+ portAt(wx, wy) {
1460
+ for (let i = this.nodes.length - 1; i >= 0; i -= 1) {
1461
+ const n = this.nodes[i];
1462
+ for (const p of [...n.inPorts, ...n.outPorts]) {
1463
+ if ((wx - p.cx) ** 2 + (wy - p.cy) ** 2 <= (PORT_R + 4) ** 2)
1464
+ return { node: n, port: p };
1465
+ }
1466
+ }
1467
+ return null;
1468
+ }
1469
+ selectedLinkEndpointAt(wx, wy) {
1470
+ if (this.selectedLink === null)
1471
+ return null;
1472
+ const radius = (PORT_R + 7) / this.scale;
1473
+ for (const end of ['from', 'to']) {
1474
+ const point = this.endpoint(this.selectedLink, end);
1475
+ if (point !== null && (wx - point[0]) ** 2 + (wy - point[1]) ** 2 <= radius ** 2)
1476
+ return end;
1477
+ }
1478
+ return null;
1479
+ }
1480
+ directRelinkAtPort(node, port) {
1481
+ if (port.direction !== 'in')
1482
+ return null;
1483
+ const incoming = this.links.filter((link) => link.to === node.id && link.toPort === port.id);
1484
+ return incoming.length === 1 ? { link: incoming[0], end: 'to' } : null;
1485
+ }
1486
+ nodeAt(wx, wy) {
1487
+ for (let i = this.nodes.length - 1; i >= 0; i -= 1) {
1488
+ const n = this.nodes[i];
1489
+ if (wx >= n.x && wx <= n.x + n.w && wy >= n.y && wy <= n.y + n.h)
1490
+ return n;
1491
+ }
1492
+ return null;
1493
+ }
1494
+ // Other nodes (not the link's own endpoints) become obstacles,
1495
+ // inflated so links keep a little clearance — the AvoidsNodes margin.
1496
+ obstaclesFor(exclude) {
1497
+ const inf = 12;
1498
+ const out = [];
1499
+ for (const n of this.nodes) {
1500
+ if (exclude.has(n.id))
1501
+ continue;
1502
+ out.push([n.x - inf, n.y - inf, n.w + inf * 2, n.h + inf * 2]);
1503
+ }
1504
+ return out;
1505
+ }
1506
+ routeLink(a, b, exclude) {
1507
+ return routeAvoid(a, b, this.obstaclesFor(exclude));
1508
+ }
1509
+ linkAt(wx, wy) {
1510
+ const tol = 5 / this.scale;
1511
+ // topmost first so the hovered/selected one wins
1512
+ for (let i = this.links.length - 1; i >= 0; i -= 1) {
1513
+ const l = this.links[i];
1514
+ const a = this.endpoint(l, 'from');
1515
+ const b = this.endpoint(l, 'to');
1516
+ if (a === null || b === null)
1517
+ continue;
1518
+ const pts = this.routeLink(a, b, new Set([l.from, l.to]));
1519
+ for (let k = 1; k < pts.length; k += 1) {
1520
+ if (ptSegDist(wx, wy, pts[k - 1][0], pts[k - 1][1], pts[k][0], pts[k][1]) <= tol)
1521
+ return l;
1522
+ }
1523
+ }
1524
+ return null;
1525
+ }
1526
+ endpoint(l, end) {
1527
+ const node = this.nodes.find((n) => n.id === (end === 'from' ? l.from : l.to));
1528
+ if (node === undefined)
1529
+ return null;
1530
+ const port = end === 'from'
1531
+ ? node.outPorts.find((p) => p.id === l.fromPort)
1532
+ : node.inPorts.find((p) => p.id === l.toPort);
1533
+ if (port === undefined)
1534
+ return null;
1535
+ return [port.cx, port.cy];
1536
+ }
1537
+ // ---- linking rules ----------------------------------------------
1538
+ validateLink(init, excludeLinkId) {
1539
+ const fromNode = this.nodes.find((node) => node.id === init.from);
1540
+ const toNode = this.nodes.find((node) => node.id === init.to);
1541
+ if (fromNode === undefined || toNode === undefined)
1542
+ return { allowed: false, reason: 'missing-node' };
1543
+ const fromPort = fromNode.outPorts.find((port) => port.id === init.fromPort);
1544
+ const toPort = toNode.inPorts.find((port) => port.id === init.toPort);
1545
+ if (fromPort === undefined || toPort === undefined)
1546
+ return { allowed: false, reason: 'missing-port' };
1547
+ return this.validateLinkModels(fromNode, fromPort, toNode, toPort, excludeLinkId);
1548
+ }
1549
+ validateLinkModels(fromNode, fromPort, toNode, toPort, excludeLinkId) {
1550
+ if (fromNode === toNode)
1551
+ return { allowed: false, reason: 'same-node' };
1552
+ if (fromPort.direction !== 'out' || toPort.direction !== 'in') {
1553
+ return { allowed: false, reason: 'invalid-direction' };
1554
+ }
1555
+ const relevantLinks = this.links.filter((link) => link.id !== excludeLinkId);
1556
+ if (relevantLinks.some((link) => link.from === fromNode.id
1557
+ && link.fromPort === fromPort.id
1558
+ && link.to === toNode.id
1559
+ && link.toPort === toPort.id)) {
1560
+ return { allowed: false, reason: 'duplicate-link' };
1561
+ }
1562
+ if (fromPort.maxLinks > 0) {
1563
+ const used = relevantLinks.filter((link) => link.from === fromNode.id && link.fromPort === fromPort.id).length;
1564
+ if (used >= fromPort.maxLinks)
1565
+ return { allowed: false, reason: 'source-limit' };
1566
+ }
1567
+ if (toPort.maxLinks > 0) {
1568
+ const used = relevantLinks.filter((link) => link.to === toNode.id && link.toPort === toPort.id).length;
1569
+ if (used >= toPort.maxLinks)
1570
+ return { allowed: false, reason: 'target-limit' };
1571
+ }
1572
+ if (!arePortTypesCompatible(fromPort.type, toPort.type, toPort.availableTypes)) {
1573
+ return { allowed: false, reason: 'incompatible-type' };
1574
+ }
1575
+ if (this.validator !== null && !this.validator({ fromNode, fromPort, toNode, toPort })) {
1576
+ return { allowed: false, reason: 'host-rejected' };
1577
+ }
1578
+ return { allowed: true, reason: 'allowed' };
1579
+ }
1580
+ removeIncompatibleLinks(matches) {
1581
+ const incompatible = this.links.filter((link) => matches(link) && !this.canExistingLinkRemain(link));
1582
+ for (const link of incompatible)
1583
+ this.removeLinkInternal(link, true);
1584
+ }
1585
+ canExistingLinkRemain(link) {
1586
+ const fromNode = this.nodes.find((node) => node.id === link.from);
1587
+ const toNode = this.nodes.find((node) => node.id === link.to);
1588
+ const fromPort = fromNode?.outPorts.find((port) => port.id === link.fromPort);
1589
+ const toPort = toNode?.inPorts.find((port) => port.id === link.toPort);
1590
+ if (fromNode === undefined || toNode === undefined || fromPort === undefined || toPort === undefined) {
1591
+ return false;
1592
+ }
1593
+ if (!arePortTypesCompatible(fromPort.type, toPort.type, toPort.availableTypes))
1594
+ return false;
1595
+ return this.validator === null || this.validator({ fromNode, fromPort, toNode, toPort });
1596
+ }
1597
+ // Plug↔socket magnet: while dragging from an out-port, find the
1598
+ // nearest in-port (within SNAP_PX, screen space) the link is allowed
1599
+ // to land on. Returns null when none is close → the pending link
1600
+ // detaches and follows the cursor again.
1601
+ findSnap() {
1602
+ if (this.linking === null && this.relinking === null)
1603
+ return null;
1604
+ let direction;
1605
+ let accepts;
1606
+ if (this.linking !== null) {
1607
+ const source = this.linking;
1608
+ direction = 'in';
1609
+ accepts = (node, port) => this.validateLinkModels(source.node, source.port, node, port).allowed;
1610
+ }
1611
+ else {
1612
+ const gesture = this.relinking;
1613
+ if (gesture.end === 'to') {
1614
+ const sourceNode = this.nodes.find((node) => node.id === gesture.link.from);
1615
+ const sourcePort = sourceNode?.outPorts.find((port) => port.id === gesture.link.fromPort);
1616
+ if (sourceNode === undefined || sourcePort === undefined)
1617
+ return null;
1618
+ direction = 'in';
1619
+ accepts = (node, port) => this.validateLinkModels(sourceNode, sourcePort, node, port, gesture.link.id).allowed;
1620
+ }
1621
+ else {
1622
+ const targetNode = this.nodes.find((node) => node.id === gesture.link.to);
1623
+ const targetPort = targetNode?.inPorts.find((port) => port.id === gesture.link.toPort);
1624
+ if (targetNode === undefined || targetPort === undefined)
1625
+ return null;
1626
+ direction = 'out';
1627
+ accepts = (node, port) => this.validateLinkModels(node, port, targetNode, targetPort, gesture.link.id).allowed;
1628
+ }
1629
+ }
1630
+ let best = null;
1631
+ let bestD = SNAP_PX;
1632
+ for (const n of this.nodes) {
1633
+ const ports = direction === 'in' ? n.inPorts : n.outPorts;
1634
+ for (const p of ports) {
1635
+ if (!accepts(n, p))
1636
+ continue;
1637
+ const [sx, sy] = this.toScreen(p.cx, p.cy);
1638
+ const dpx = Math.hypot(sx - this.cursor.x, sy - this.cursor.y);
1639
+ if (dpx <= bestD) {
1640
+ bestD = dpx;
1641
+ best = { node: n, port: p };
1642
+ }
1643
+ }
1644
+ }
1645
+ return best;
1646
+ }
1647
+ selectNode(n) {
1648
+ this.selectedLink = null;
1649
+ this.selectedPort = null;
1650
+ this.selectedNodes.clear();
1651
+ if (n !== null)
1652
+ this.selectedNodes.add(n);
1653
+ this.selectedNode = n;
1654
+ this.emit('nodeSelected', { node: n, selected: n !== null });
1655
+ this.emitSelectionChanged();
1656
+ this.scheduleDraw();
1657
+ }
1658
+ toggleSelect(n) {
1659
+ this.selectedLink = null;
1660
+ this.selectedPort = null;
1661
+ if (this.selectedNodes.has(n)) {
1662
+ this.selectedNodes.delete(n);
1663
+ if (this.selectedNode === n)
1664
+ this.selectedNode = null;
1665
+ }
1666
+ else {
1667
+ this.selectedNodes.add(n);
1668
+ this.selectedNode = n;
1669
+ }
1670
+ this.emit('nodeSelected', { node: this.selectedNode, selected: this.selectedNodes.size > 0 });
1671
+ this.emitSelectionChanged();
1672
+ this.scheduleDraw();
1673
+ }
1674
+ setSelection(ns) {
1675
+ this.selectedLink = null;
1676
+ this.selectedPort = null;
1677
+ this.selectedNodes = new Set(ns);
1678
+ this.selectedNode = ns.length > 0 ? ns[ns.length - 1] : null;
1679
+ this.emit('nodeSelected', { node: this.selectedNode, selected: ns.length > 0 });
1680
+ this.emitSelectionChanged();
1681
+ this.scheduleDraw();
1682
+ }
1683
+ selectLink(l) {
1684
+ if (this.selectedLink === l)
1685
+ return;
1686
+ this.selectedNode = null;
1687
+ this.selectedNodes.clear();
1688
+ this.selectedPort = null;
1689
+ this.selectedLink = l;
1690
+ this.emit('linkSelected', { link: l, selected: l !== null });
1691
+ this.emitSelectionChanged();
1692
+ this.scheduleDraw();
1693
+ }
1694
+ selectPort(target) {
1695
+ this.selectedPort = target;
1696
+ this.emit('portSelected', target);
1697
+ this.emitSelectionChanged();
1698
+ this.scheduleDraw();
1699
+ }
1700
+ emitSelectionChanged() {
1701
+ this.emit('selectionChanged', this.getSelection());
1702
+ }
1703
+ // ---- clipboard (copy / paste of the selection) ------------------
1704
+ copySelection() {
1705
+ this.copySelectionDocument();
1706
+ }
1707
+ copySelectionDocument() {
1708
+ if (!this.permissions.copy)
1709
+ return null;
1710
+ if (this.selectedNodes.size === 0) {
1711
+ this.clip = null;
1712
+ return null;
1713
+ }
1714
+ const ids = new Set([...this.selectedNodes].map((n) => n.id));
1715
+ const all = this.saveDocument();
1716
+ this.clip = createDiagramDocument({
1717
+ nodes: all.nodes.filter((n) => ids.has(n.id)),
1718
+ links: all.links.filter((link) => ids.has(link.from.nodeId) && ids.has(link.to.nodeId)),
1719
+ });
1720
+ return parseDiagramDocument(this.clip);
1721
+ }
1722
+ hasClipboard() {
1723
+ return this.clip !== null && this.clip.nodes.length > 0;
1724
+ }
1725
+ getClipboardDocument() {
1726
+ return this.clip === null ? null : parseDiagramDocument(this.clip);
1727
+ }
1728
+ setClipboardDocument(source) {
1729
+ this.clip = parseDiagramDocument(source);
1730
+ }
1731
+ pasteSelection() {
1732
+ if (this.clip === null)
1733
+ return [];
1734
+ return this.pasteDocument(this.clip);
1735
+ }
1736
+ pasteDocument(source, offset) {
1737
+ if (!this.permissions.paste)
1738
+ return [];
1739
+ const clipboard = parseDiagramDocument(source);
1740
+ if (clipboard.nodes.length === 0)
1741
+ return [];
1742
+ const pasteOffset = offset ?? { x: this.gridSize, y: this.gridSize };
1743
+ this.clip = clipboard;
1744
+ const map = new Map();
1745
+ const added = [];
1746
+ // Paste of N nodes + M links collapses into ONE undo step.
1747
+ this.withTransaction('paste', () => {
1748
+ for (const sn of clipboard.nodes) {
1749
+ const nid = this.nextNodeId();
1750
+ map.set(sn.id, nid);
1751
+ const id = this.addDiagramNode({
1752
+ ...sn,
1753
+ id: nid,
1754
+ x: sn.x + pasteOffset.x,
1755
+ y: sn.y + pasteOffset.y,
1756
+ });
1757
+ const nn = this.nodes.find((x) => x.id === id);
1758
+ if (nn !== undefined)
1759
+ added.push(nn);
1760
+ }
1761
+ for (const l of clipboard.links) {
1762
+ const f = map.get(l.from.nodeId);
1763
+ const t = map.get(l.to.nodeId);
1764
+ if (f !== undefined && t !== undefined)
1765
+ this.addLink({
1766
+ from: f,
1767
+ fromPort: l.from.portId,
1768
+ to: t,
1769
+ toPort: l.to.portId,
1770
+ metadata: l.metadata,
1771
+ });
1772
+ }
1773
+ });
1774
+ this.setSelection(added);
1775
+ return added.map((node) => node.id);
1776
+ }
1777
+ // ---- input ------------------------------------------------------
1778
+ cancelLongPress() {
1779
+ if (this.lpTimer !== null) {
1780
+ clearTimeout(this.lpTimer);
1781
+ this.lpTimer = null;
1782
+ }
1783
+ this.lpStart = null;
1784
+ }
1785
+ // Hit-test at screen coords and emit a contextMenu event. Cancels
1786
+ // any partial drag/rubber/link gesture that may have started — once
1787
+ // the menu opens we don't want a half-drag racing it.
1788
+ fireContextMenu(sx, sy, pageX, pageY) {
1789
+ const [wx, wy] = this.toWorld(sx, sy);
1790
+ const port = this.portAt(wx, wy);
1791
+ const node = port?.node ?? this.nodeAt(wx, wy);
1792
+ const link = port === null && node === null ? this.linkAt(wx, wy) : null;
1793
+ if (this.permissions.select) {
1794
+ if (port !== null) {
1795
+ const selectedLink = this.selectedLink;
1796
+ const isSelectedEndpoint = selectedLink !== null
1797
+ && ((selectedLink.from === port.node.id && selectedLink.fromPort === port.port.id)
1798
+ || (selectedLink.to === port.node.id && selectedLink.toPort === port.port.id));
1799
+ if (!isSelectedEndpoint && !this.selectedNodes.has(port.node))
1800
+ this.selectNode(port.node);
1801
+ this.selectPort(port);
1802
+ }
1803
+ else if (node !== null && !this.selectedNodes.has(node))
1804
+ this.selectNode(node);
1805
+ else if (link !== null && this.selectedLink !== link)
1806
+ this.selectLink(link);
1807
+ }
1808
+ this.dragNode = null;
1809
+ this.dragStart = [];
1810
+ this.rubber = null;
1811
+ this.panning = false;
1812
+ this.linking = null;
1813
+ this.relinking = null;
1814
+ this.relinkCandidate = null;
1815
+ this.linkSnap = null;
1816
+ this.scheduleDraw();
1817
+ this.emit('contextMenu', { x: pageX, y: pageY, link, node, port });
1818
+ }
1819
+ listen(target, type, listener, options) {
1820
+ target.addEventListener(type, listener, options);
1821
+ this.domDisposables.push(() => target.removeEventListener(type, listener, options));
1822
+ }
1823
+ bind() {
1824
+ const localXY = (e) => {
1825
+ const r = this.canvas.getBoundingClientRect();
1826
+ return [e.clientX - r.left, e.clientY - r.top];
1827
+ };
1828
+ this.listen(this.canvas, 'pointerdown', (e) => {
1829
+ try {
1830
+ this.canvas.setPointerCapture(e.pointerId);
1831
+ }
1832
+ catch { /* unsupported */ }
1833
+ // Arm a long-press timer on every pointerdown. Cancelled by
1834
+ // move-beyond-tolerance, pointerup, or any other gesture.
1835
+ this.cancelLongPress();
1836
+ this.relinkCandidate = null;
1837
+ this.lpStart = { sx: e.clientX, sy: e.clientY, px: e.clientX, py: e.clientY };
1838
+ const downSx = e.clientX, downSy = e.clientY;
1839
+ const r0 = this.canvas.getBoundingClientRect();
1840
+ const localSx = downSx - r0.left, localSy = downSy - r0.top;
1841
+ this.lpTimer = setTimeout(() => {
1842
+ this.lpTimer = null;
1843
+ this.fireContextMenu(localSx, localSy, downSx, downSy);
1844
+ }, this.lpDelayMs);
1845
+ this.canvas.focus();
1846
+ const [sx, sy] = localXY(e);
1847
+ if (this.ovHit(sx, sy)) {
1848
+ this.ovDragging = true;
1849
+ this.ovPanTo(sx, sy);
1850
+ return;
1851
+ }
1852
+ if (!this.permissions.inspect)
1853
+ this.cancelLongPress();
1854
+ const [wx, wy] = this.toWorld(sx, sy);
1855
+ const selectedEndpoint = this.selectedLinkEndpointAt(wx, wy);
1856
+ if (e.button === 0 && this.permissions.createLinks && this.selectedLink !== null
1857
+ && selectedEndpoint !== null) {
1858
+ this.relinking = { link: this.selectedLink, end: selectedEndpoint };
1859
+ this.linking = null;
1860
+ this.linkSnap = null;
1861
+ this.cursor = { x: sx, y: sy };
1862
+ return;
1863
+ }
1864
+ const portHit = this.portAt(wx, wy);
1865
+ if (portHit !== null) {
1866
+ const selectedLink = this.selectedLink;
1867
+ const isFrom = selectedLink !== null
1868
+ && portHit.port.direction === 'out'
1869
+ && selectedLink.from === portHit.node.id
1870
+ && selectedLink.fromPort === portHit.port.id;
1871
+ const isTo = selectedLink !== null
1872
+ && portHit.port.direction === 'in'
1873
+ && selectedLink.to === portHit.node.id
1874
+ && selectedLink.toPort === portHit.port.id;
1875
+ if (this.permissions.inspect) {
1876
+ if (this.permissions.select && !isFrom && !isTo && !this.selectedNodes.has(portHit.node)) {
1877
+ this.selectNode(portHit.node);
1878
+ }
1879
+ this.selectPort(portHit);
1880
+ if (e.button === 0 || e.button === 2) {
1881
+ this.emit('portClicked', {
1882
+ ...portHit,
1883
+ action: e.button === 2 ? 'rightClick' : 'leftClick',
1884
+ ctrlKey: e.ctrlKey,
1885
+ shiftKey: e.shiftKey,
1886
+ altKey: e.altKey,
1887
+ metaKey: e.metaKey,
1888
+ });
1889
+ }
1890
+ }
1891
+ if (e.button !== 0)
1892
+ return;
1893
+ if (this.permissions.createLinks && selectedLink !== null) {
1894
+ if (isFrom || isTo) {
1895
+ this.relinking = { link: selectedLink, end: isFrom ? 'from' : 'to' };
1896
+ this.linking = null;
1897
+ this.linkSnap = null;
1898
+ this.cursor = { x: sx, y: sy };
1899
+ return;
1900
+ }
1901
+ }
1902
+ // A single existing input link can be rewired in one drag
1903
+ // without a separate selection click.
1904
+ const directRelink = this.permissions.createLinks
1905
+ ? this.directRelinkAtPort(portHit.node, portHit.port)
1906
+ : null;
1907
+ if (directRelink !== null) {
1908
+ this.relinkCandidate = { ...directRelink, sx, sy };
1909
+ this.linking = null;
1910
+ this.relinking = null;
1911
+ this.linkSnap = null;
1912
+ this.cursor = { x: sx, y: sy };
1913
+ return;
1914
+ }
1915
+ // Other output sockets start an additional link; input sockets
1916
+ // consume the gesture so they never drag the whole diagram.
1917
+ if (this.permissions.createLinks && portHit.port.direction === 'out') {
1918
+ this.linking = portHit;
1919
+ this.relinking = null;
1920
+ this.linkSnap = null;
1921
+ this.cursor = { x: sx, y: sy };
1922
+ }
1923
+ return;
1924
+ }
1925
+ const node = this.nodeAt(wx, wy);
1926
+ if (node !== null) {
1927
+ const add = e.shiftKey || e.ctrlKey || e.metaKey;
1928
+ if (this.permissions.select) {
1929
+ if (add) {
1930
+ this.toggleSelect(node);
1931
+ return;
1932
+ }
1933
+ if (!this.selectedNodes.has(node))
1934
+ this.selectNode(node);
1935
+ }
1936
+ if (this.permissions.moveNodes) {
1937
+ this.dragNode = node;
1938
+ this.dragAnchor = { wx, wy };
1939
+ const moving = this.selectedNodes.has(node) ? [...this.selectedNodes] : [node];
1940
+ this.dragStart = moving.map((item) => ({ n: item, x: item.x, y: item.y }));
1941
+ const selected = new Set(moving);
1942
+ this.nodes = this.nodes.filter((item) => !selected.has(item)).concat(moving);
1943
+ }
1944
+ return;
1945
+ }
1946
+ const link = this.linkAt(wx, wy);
1947
+ if (link !== null) {
1948
+ if (this.permissions.select)
1949
+ this.selectLink(link);
1950
+ return;
1951
+ }
1952
+ // empty space: middle-button / Ctrl / Alt / touch = pan;
1953
+ // else rubber-band select. Touch always pans because mobile
1954
+ // users can't hold modifier keys and rubber-band drag-select
1955
+ // is awkward with a finger.
1956
+ const wantPan = !this.permissions.select || e.button === 1 || e.ctrlKey || e.altKey || e.pointerType === 'touch';
1957
+ if (wantPan) {
1958
+ if (this.permissions.select && !e.shiftKey)
1959
+ this.selectNode(null);
1960
+ this.panning = true;
1961
+ this.panX = sx;
1962
+ this.panY = sy;
1963
+ }
1964
+ else {
1965
+ if (!e.shiftKey)
1966
+ this.selectNode(null);
1967
+ this.rubber = { x0: wx, y0: wy, x: wx, y: wy };
1968
+ }
1969
+ });
1970
+ this.listen(this.canvas, 'pointermove', (e) => {
1971
+ const [sx, sy] = localXY(e);
1972
+ this.cursor = { x: sx, y: sy };
1973
+ // movement past tolerance → not a long-press anymore
1974
+ if (this.lpStart !== null) {
1975
+ const dx = e.clientX - this.lpStart.px;
1976
+ const dy = e.clientY - this.lpStart.py;
1977
+ if (Math.hypot(dx, dy) > this.lpMoveTol)
1978
+ this.cancelLongPress();
1979
+ }
1980
+ if (this.ovDragging) {
1981
+ this.ovPanTo(sx, sy);
1982
+ return;
1983
+ }
1984
+ const [wx, wy] = this.toWorld(sx, sy);
1985
+ if (this.relinkCandidate !== null) {
1986
+ const candidate = this.relinkCandidate;
1987
+ if (Math.hypot(sx - candidate.sx, sy - candidate.sy) <= RELINK_DRAG_THRESHOLD_PX)
1988
+ return;
1989
+ this.relinkCandidate = null;
1990
+ this.relinking = { link: candidate.link, end: candidate.end };
1991
+ if (this.permissions.select)
1992
+ this.selectLink(candidate.link);
1993
+ this.linkSnap = this.findSnap();
1994
+ this.scheduleDraw();
1995
+ return;
1996
+ }
1997
+ if (this.dragNode !== null) {
1998
+ let ddx = wx - this.dragAnchor.wx;
1999
+ let ddy = wy - this.dragAnchor.wy;
2000
+ if (this.gridSnapEnabled) {
2001
+ const primary = this.dragStart.find((item) => item.n === this.dragNode);
2002
+ if (primary !== undefined) {
2003
+ ddx = Math.round((primary.x + ddx) / this.gridSize) * this.gridSize - primary.x;
2004
+ ddy = Math.round((primary.y + ddy) / this.gridSize) * this.gridSize - primary.y;
2005
+ }
2006
+ }
2007
+ for (const it of this.dragStart) {
2008
+ it.n.x = it.x + ddx;
2009
+ it.n.y = it.y + ddy;
2010
+ this.layoutNode(it.n);
2011
+ }
2012
+ this.scheduleDraw();
2013
+ return;
2014
+ }
2015
+ if (this.rubber !== null) {
2016
+ this.rubber.x = wx;
2017
+ this.rubber.y = wy;
2018
+ this.scheduleDraw();
2019
+ return;
2020
+ }
2021
+ if (this.panning) {
2022
+ this.offX += sx - this.panX;
2023
+ this.offY += sy - this.panY;
2024
+ this.panX = sx;
2025
+ this.panY = sy;
2026
+ this.scheduleDraw();
2027
+ return;
2028
+ }
2029
+ if (this.linking !== null || this.relinking !== null) {
2030
+ this.linkSnap = this.findSnap();
2031
+ this.scheduleDraw();
2032
+ return;
2033
+ }
2034
+ if (!this.permissions.inspect)
2035
+ return;
2036
+ let dirty = false;
2037
+ // port hover
2038
+ const hit = this.portAt(wx, wy);
2039
+ if ((hit?.port ?? null) !== (this.hoverPort?.port ?? null)) {
2040
+ if (this.hoverPort !== null)
2041
+ this.emit('portHover', { node: this.hoverPort.node, port: this.hoverPort.port, hovering: false });
2042
+ this.hoverPort = hit;
2043
+ if (hit !== null)
2044
+ this.emit('portHover', { node: hit.node, port: hit.port, hovering: true });
2045
+ dirty = true;
2046
+ }
2047
+ // node hover (for tooltip)
2048
+ const nodeHit = hit === null ? this.nodeAt(wx, wy) : null;
2049
+ if (nodeHit !== this.hoverNode) {
2050
+ if (this.hoverNode !== null)
2051
+ this.emit('nodeHover', { node: this.hoverNode, hovering: false });
2052
+ this.hoverNode = nodeHit;
2053
+ if (nodeHit !== null)
2054
+ this.emit('nodeHover', { node: nodeHit, hovering: true });
2055
+ dirty = true;
2056
+ }
2057
+ // hover-delay before the tooltip appears
2058
+ const tipTgt = this.hoverPort?.port ?? this.hoverNode;
2059
+ if (tipTgt !== this.tipTarget) {
2060
+ this.tipTarget = tipTgt;
2061
+ this.tipShow = false;
2062
+ if (this.tipTimer !== null) {
2063
+ clearTimeout(this.tipTimer);
2064
+ this.tipTimer = null;
2065
+ }
2066
+ if (tipTgt !== null) {
2067
+ this.tipTimer = setTimeout(() => {
2068
+ this.tipTimer = null;
2069
+ this.tipShow = true;
2070
+ this.scheduleDraw();
2071
+ }, 400);
2072
+ }
2073
+ dirty = true;
2074
+ }
2075
+ // link hover (only when not over a port/node)
2076
+ const overNode = nodeHit !== null;
2077
+ const linkHit = hit === null && !overNode ? this.linkAt(wx, wy) : null;
2078
+ if (linkHit !== this.hoveredLink) {
2079
+ if (this.hoveredLink !== null)
2080
+ this.emit('linkHover', { link: this.hoveredLink, hovering: false });
2081
+ this.hoveredLink = linkHit;
2082
+ if (linkHit !== null)
2083
+ this.emit('linkHover', { link: linkHit, hovering: true });
2084
+ dirty = true;
2085
+ }
2086
+ const endpointHit = this.permissions.createLinks ? this.selectedLinkEndpointAt(wx, wy) : null;
2087
+ const directRelinkHit = this.permissions.createLinks && hit !== null
2088
+ ? this.directRelinkAtPort(hit.node, hit.port)
2089
+ : null;
2090
+ this.canvas.style.cursor = endpointHit !== null || directRelinkHit !== null ? 'grab'
2091
+ : hit !== null ? 'pointer'
2092
+ : overNode ? 'move'
2093
+ : linkHit !== null ? 'pointer' : 'default';
2094
+ if (dirty)
2095
+ this.scheduleDraw();
2096
+ });
2097
+ const finish = (e) => {
2098
+ this.cancelLongPress();
2099
+ this.relinkCandidate = null;
2100
+ if (this.dragNode !== null) {
2101
+ // Capture before/after positions so the whole multi-node
2102
+ // drag is ONE undo step. Skip the action if nothing
2103
+ // actually moved (a "click" that bypassed the threshold).
2104
+ const moves = this.dragStart
2105
+ .map((it) => ({ id: it.n.id, fromX: it.x, fromY: it.y, toX: it.n.x, toY: it.n.y }))
2106
+ .filter((m) => m.fromX !== m.toX || m.fromY !== m.toY);
2107
+ for (const it of this.dragStart)
2108
+ this.emit('nodeMoved', { node: it.n });
2109
+ this.dragNode = null;
2110
+ this.dragStart = [];
2111
+ if (moves.length > 0) {
2112
+ this.record({
2113
+ do: () => { for (const m of moves)
2114
+ this.doMoveNode(m.id, m.toX, m.toY); },
2115
+ undo: () => { for (const m of moves)
2116
+ this.doMoveNode(m.id, m.fromX, m.fromY); },
2117
+ label: 'drag',
2118
+ });
2119
+ }
2120
+ }
2121
+ if (this.rubber !== null) {
2122
+ const rx0 = Math.min(this.rubber.x0, this.rubber.x);
2123
+ const ry0 = Math.min(this.rubber.y0, this.rubber.y);
2124
+ const rx1 = Math.max(this.rubber.x0, this.rubber.x);
2125
+ const ry1 = Math.max(this.rubber.y0, this.rubber.y);
2126
+ this.rubber = null;
2127
+ if (rx1 - rx0 > 3 || ry1 - ry0 > 3) {
2128
+ const inRect = this.nodes.filter((n) => n.x < rx1 && n.x + n.w > rx0 && n.y < ry1 && n.y + n.h > ry0);
2129
+ const merge = e.shiftKey ? [...this.selectedNodes, ...inRect] : inRect;
2130
+ this.setSelection([...new Set(merge)]);
2131
+ }
2132
+ this.scheduleDraw();
2133
+ }
2134
+ const viewportMoved = this.panning || this.ovDragging;
2135
+ this.panning = false;
2136
+ this.ovDragging = false;
2137
+ if (viewportMoved)
2138
+ this.emitViewChanged(false);
2139
+ if (this.linking !== null) {
2140
+ const [sx, sy] = localXY(e);
2141
+ const [wx, wy] = this.toWorld(sx, sy);
2142
+ // Commit to the magnetised socket if one is engaged;
2143
+ // otherwise fall back to whatever is exactly under the
2144
+ // cursor (covers a precise drop without a snap).
2145
+ const direct = this.portAt(wx, wy);
2146
+ const target = this.linkSnap
2147
+ ?? (direct !== null && direct.port.direction === 'in' ? direct : null);
2148
+ if (target !== null) {
2149
+ this.addLink({
2150
+ from: this.linking.node.id, fromPort: this.linking.port.id,
2151
+ to: target.node.id, toPort: target.port.id,
2152
+ });
2153
+ }
2154
+ this.linking = null;
2155
+ this.linkSnap = null;
2156
+ this.scheduleDraw();
2157
+ }
2158
+ if (this.relinking !== null) {
2159
+ const gesture = this.relinking;
2160
+ const [sx, sy] = localXY(e);
2161
+ const [wx, wy] = this.toWorld(sx, sy);
2162
+ const direct = this.portAt(wx, wy);
2163
+ const expectedDirection = gesture.end === 'from' ? 'out' : 'in';
2164
+ const target = this.linkSnap
2165
+ ?? (direct !== null && direct.port.direction === expectedDirection ? direct : null);
2166
+ if (target !== null) {
2167
+ this.relink(gesture.link.id, {
2168
+ from: gesture.end === 'from' ? target.node.id : gesture.link.from,
2169
+ fromPort: gesture.end === 'from' ? target.port.id : gesture.link.fromPort,
2170
+ to: gesture.end === 'to' ? target.node.id : gesture.link.to,
2171
+ toPort: gesture.end === 'to' ? target.port.id : gesture.link.toPort,
2172
+ });
2173
+ }
2174
+ this.relinking = null;
2175
+ this.linkSnap = null;
2176
+ this.scheduleDraw();
2177
+ }
2178
+ };
2179
+ this.listen(window, 'pointerup', finish);
2180
+ this.listen(window, 'pointercancel', finish);
2181
+ this.listen(this.canvas, 'wheel', (e) => {
2182
+ e.preventDefault();
2183
+ const [sx, sy] = localXY(e);
2184
+ const [wx, wy] = this.toWorld(sx, sy);
2185
+ // Smooth, device-consistent: zoom amount tracks wheel delta
2186
+ // magnitude instead of a fixed step (no more big jumps).
2187
+ const factor = Math.exp(-e.deltaY * 0.0015);
2188
+ this.scale = clamp(this.scale * factor, ZOOM_MIN, ZOOM_MAX);
2189
+ this.offX = sx - wx * this.scale;
2190
+ this.offY = sy - wy * this.scale;
2191
+ this.emitViewChanged(true);
2192
+ this.scheduleDraw();
2193
+ }, { passive: false });
2194
+ this.listen(this.canvas, 'pointerleave', () => {
2195
+ if (this.hoverPort !== null)
2196
+ this.emit('portHover', { node: this.hoverPort.node, port: this.hoverPort.port, hovering: false });
2197
+ if (this.hoverNode !== null)
2198
+ this.emit('nodeHover', { node: this.hoverNode, hovering: false });
2199
+ if (this.hoveredLink !== null)
2200
+ this.emit('linkHover', { link: this.hoveredLink, hovering: false });
2201
+ this.hoverPort = null;
2202
+ this.hoverNode = null;
2203
+ this.hoveredLink = null;
2204
+ this.tipTarget = null;
2205
+ this.tipShow = false;
2206
+ if (this.tipTimer !== null) {
2207
+ clearTimeout(this.tipTimer);
2208
+ this.tipTimer = null;
2209
+ }
2210
+ this.canvas.style.cursor = 'default';
2211
+ this.scheduleDraw();
2212
+ });
2213
+ this.listen(this.canvas, 'dblclick', (e) => {
2214
+ const [sx, sy] = localXY(e);
2215
+ const [wx, wy] = this.toWorld(sx, sy);
2216
+ if (this.portAt(wx, wy) !== null)
2217
+ return;
2218
+ const node = this.nodeAt(wx, wy);
2219
+ if (node !== null) {
2220
+ if (this.permissions.inspect && node.openAction.length > 0) {
2221
+ e.preventDefault();
2222
+ this.emit('nodeOpen', { node });
2223
+ }
2224
+ return;
2225
+ }
2226
+ this.zoomToFit();
2227
+ });
2228
+ // Desktop right-click → same contextMenu event as touch long-press.
2229
+ this.listen(this.canvas, 'contextmenu', (e) => {
2230
+ e.preventDefault();
2231
+ if (!this.permissions.inspect)
2232
+ return;
2233
+ const [sx, sy] = localXY(e);
2234
+ this.cancelLongPress();
2235
+ this.fireContextMenu(sx, sy, e.clientX, e.clientY);
2236
+ });
2237
+ // Two-finger pinch → uniform zoom around the initial midpoint
2238
+ // between the fingers. Mobile analog of mouse wheel.
2239
+ let pinchDist = 0;
2240
+ let pinchScale = 1;
2241
+ let pinchPivotW = [0, 0];
2242
+ let pinchPivotS = [0, 0];
2243
+ let pinching = false;
2244
+ this.listen(this.canvas, 'touchstart', (e) => {
2245
+ if (e.touches.length === 2) {
2246
+ e.preventDefault();
2247
+ const r = this.canvas.getBoundingClientRect();
2248
+ const t0 = e.touches[0], t1 = e.touches[1];
2249
+ const x0 = t0.clientX - r.left, y0 = t0.clientY - r.top;
2250
+ const x1 = t1.clientX - r.left, y1 = t1.clientY - r.top;
2251
+ pinchDist = Math.max(1, Math.hypot(x1 - x0, y1 - y0));
2252
+ pinchScale = this.scale;
2253
+ const mx = (x0 + x1) / 2, my = (y0 + y1) / 2;
2254
+ pinchPivotS = [mx, my];
2255
+ pinchPivotW = this.toWorld(mx, my);
2256
+ pinching = true;
2257
+ this.dragNode = null;
2258
+ this.dragStart = [];
2259
+ this.panning = false;
2260
+ this.rubber = null;
2261
+ this.linking = null;
2262
+ this.relinking = null;
2263
+ this.relinkCandidate = null;
2264
+ }
2265
+ }, { passive: false });
2266
+ this.listen(this.canvas, 'touchmove', (e) => {
2267
+ if (pinching && e.touches.length === 2) {
2268
+ e.preventDefault();
2269
+ const r = this.canvas.getBoundingClientRect();
2270
+ const t0 = e.touches[0], t1 = e.touches[1];
2271
+ const x0 = t0.clientX - r.left, y0 = t0.clientY - r.top;
2272
+ const x1 = t1.clientX - r.left, y1 = t1.clientY - r.top;
2273
+ const d = Math.max(1, Math.hypot(x1 - x0, y1 - y0));
2274
+ const ns = clamp(pinchScale * (d / pinchDist), ZOOM_MIN, ZOOM_MAX);
2275
+ this.scale = ns;
2276
+ // Keep the pinch midpoint anchored at the world point it
2277
+ // started on (pivot stays put under the fingers).
2278
+ this.offX = pinchPivotS[0] - pinchPivotW[0] * ns;
2279
+ this.offY = pinchPivotS[1] - pinchPivotW[1] * ns;
2280
+ this.emitViewChanged(true);
2281
+ this.scheduleDraw();
2282
+ }
2283
+ }, { passive: false });
2284
+ const endPinch = () => { pinching = false; };
2285
+ this.listen(this.canvas, 'touchend', endPinch);
2286
+ this.listen(this.canvas, 'touchcancel', endPinch);
2287
+ this.listen(this.canvas, 'keydown', (e) => {
2288
+ if (this.permissions.moveNodes && !e.ctrlKey && !e.metaKey && !e.altKey) {
2289
+ const step = (this.gridSnapEnabled ? this.gridSize : 1) * (e.shiftKey ? 5 : 1);
2290
+ const delta = e.key === 'ArrowLeft' ? [-step, 0]
2291
+ : e.key === 'ArrowRight' ? [step, 0]
2292
+ : e.key === 'ArrowUp' ? [0, -step]
2293
+ : e.key === 'ArrowDown' ? [0, step]
2294
+ : null;
2295
+ if (delta !== null && this.nudgeSelection(delta[0], delta[1])) {
2296
+ e.preventDefault();
2297
+ return;
2298
+ }
2299
+ }
2300
+ if (this.permissions.deleteSelection && (e.key === 'Delete' || e.key === 'Backspace')) {
2301
+ e.preventDefault();
2302
+ this.deleteSelection();
2303
+ return;
2304
+ }
2305
+ const mod = e.ctrlKey || e.metaKey;
2306
+ if (mod && this.permissions.copy && e.code === 'KeyC') {
2307
+ e.preventDefault();
2308
+ this.copySelection();
2309
+ return;
2310
+ }
2311
+ if (mod && this.permissions.paste && e.code === 'KeyV') {
2312
+ e.preventDefault();
2313
+ this.pasteSelection();
2314
+ return;
2315
+ }
2316
+ if (mod && this.permissions.copy && this.permissions.deleteSelection && e.code === 'KeyX') {
2317
+ e.preventDefault();
2318
+ this.cutSelection();
2319
+ return;
2320
+ }
2321
+ if (mod && this.permissions.select && e.code === 'KeyA') {
2322
+ e.preventDefault();
2323
+ this.setSelection(this.nodes.slice());
2324
+ return;
2325
+ }
2326
+ // Ctrl+Z = undo; Ctrl+Y or Ctrl+Shift+Z = redo.
2327
+ if (mod && this.permissions.history && e.code === 'KeyZ' && !e.shiftKey) {
2328
+ e.preventDefault();
2329
+ this.undo();
2330
+ return;
2331
+ }
2332
+ if (mod && this.permissions.history && (e.code === 'KeyY' || (e.code === 'KeyZ' && e.shiftKey))) {
2333
+ e.preventDefault();
2334
+ this.redo();
2335
+ return;
2336
+ }
2337
+ });
2338
+ }
2339
+ // ---- drawing ----------------------------------------------------
2340
+ scheduleDraw() {
2341
+ if (this.destroyed || this.drawScheduled)
2342
+ return;
2343
+ this.drawScheduled = true;
2344
+ requestAnimationFrame(() => {
2345
+ this.drawScheduled = false;
2346
+ if (!this.destroyed)
2347
+ this.draw();
2348
+ });
2349
+ }
2350
+ draw(options = SCREEN_RENDER_OPTIONS) {
2351
+ const ctx = this.ctx;
2352
+ ctx.clearRect(0, 0, this.width, this.height);
2353
+ ctx.fillStyle = this.opts.background ?? '#1b1b1f';
2354
+ ctx.fillRect(0, 0, this.width, this.height);
2355
+ // Entrance animation — scheme rises from below + fades in over
2356
+ // the opaque background.
2357
+ let introDy = 0;
2358
+ let introAlpha = 1;
2359
+ if (options.transient && this.introStart !== null) {
2360
+ const e = Math.min(1, (performance.now() - this.introStart) / INTRO_MS);
2361
+ const k = 1 - Math.pow(1 - e, 3); // easeOutCubic
2362
+ introAlpha = k;
2363
+ introDy = (1 - k) * INTRO_RISE;
2364
+ if (e >= 1)
2365
+ this.introStart = null;
2366
+ }
2367
+ ctx.save();
2368
+ ctx.globalAlpha = introAlpha;
2369
+ if (options.grid)
2370
+ this.drawGrid();
2371
+ ctx.setTransform(this.dpr * this.scale, 0, 0, this.dpr * this.scale, this.dpr * this.offX, this.dpr * (this.offY + introDy));
2372
+ // Links first. Each link jumps over the verticals of the links
2373
+ // drawn before it (deterministic bridge — Link.JumpOver parity).
2374
+ const prior = []; // segments of links already drawn
2375
+ const hoveredNode = this.hoverPort?.node ?? this.hoverNode;
2376
+ for (const l of this.links) {
2377
+ const a = this.endpoint(l, 'from');
2378
+ const b = this.endpoint(l, 'to');
2379
+ if (a === null || b === null)
2380
+ continue;
2381
+ const fp = this.nodes.find((n) => n.id === l.from)?.outPorts.find((p) => p.id === l.fromPort);
2382
+ const baseColor = fp ? this.portColor(fp.type) : '#7d828a';
2383
+ const state = options.selection && l === this.selectedLink
2384
+ ? 'sel'
2385
+ : options.transient && (l === this.hoveredLink
2386
+ || (hoveredNode !== null && (l.from === hoveredNode.id || l.to === hoveredNode.id)))
2387
+ ? 'hov'
2388
+ : 'norm';
2389
+ const color = state === 'sel' ? '#4aa3ff' : state === 'hov' ? '#cfe3ff' : baseColor;
2390
+ const width = state === 'sel' ? 3 : state === 'hov' ? 2.6 : 2;
2391
+ const pts = this.routeLink(a, b, new Set([l.from, l.to]));
2392
+ this.strokeRoute(pts, color, width, prior);
2393
+ this.drawArrow(pts, color);
2394
+ for (let k = 1; k < pts.length; k += 1) {
2395
+ const [x1, y1] = pts[k - 1];
2396
+ const [x2, y2] = pts[k];
2397
+ if (x1 === x2 && y1 !== y2)
2398
+ prior.push({ h: false, c: x1, a: Math.min(y1, y2), b: Math.max(y1, y2) });
2399
+ else if (y1 === y2 && x1 !== x2)
2400
+ prior.push({ h: true, c: y1, a: Math.min(x1, x2), b: Math.max(x1, x2) });
2401
+ }
2402
+ }
2403
+ if (options.transient && (this.linking !== null || this.relinking !== null))
2404
+ this.drawPendingLink();
2405
+ for (const n of this.nodes)
2406
+ this.drawNode(n, options.selection && this.selectedNodes.has(n), options);
2407
+ if (options.selection && this.permissions.createLinks && this.relinking === null)
2408
+ this.drawSelectedLinkEndpoints();
2409
+ if (options.transient && this.rubber !== null) {
2410
+ const rx = Math.min(this.rubber.x0, this.rubber.x);
2411
+ const ry = Math.min(this.rubber.y0, this.rubber.y);
2412
+ const rw = Math.abs(this.rubber.x - this.rubber.x0);
2413
+ const rh = Math.abs(this.rubber.y - this.rubber.y0);
2414
+ ctx.fillStyle = 'rgba(74,163,255,0.12)';
2415
+ ctx.fillRect(rx, ry, rw, rh);
2416
+ ctx.strokeStyle = '#4aa3ff';
2417
+ ctx.lineWidth = 1 / this.scale;
2418
+ ctx.strokeRect(rx, ry, rw, rh);
2419
+ }
2420
+ ctx.restore();
2421
+ if (options.overview)
2422
+ this.drawOverview();
2423
+ if (options.runtime)
2424
+ this.drawGlobalError();
2425
+ if (options.transient)
2426
+ this.drawTooltip();
2427
+ if (options.transient && (this.introStart !== null || this.globalErrorFlashStart !== null
2428
+ || this.nodes.some((n) => n.errorFlashStart !== null)))
2429
+ this.scheduleDraw(); // keep entrance / error feedback animating
2430
+ }
2431
+ drawGlobalError() {
2432
+ const error = this.runtimeState.globalError;
2433
+ if (error === null)
2434
+ return;
2435
+ const ctx = this.ctx;
2436
+ let flashAlpha = 1;
2437
+ if (this.globalErrorFlashStart !== null) {
2438
+ const elapsed = performance.now() - this.globalErrorFlashStart;
2439
+ if (elapsed >= ERROR_FLASH_MS) {
2440
+ this.globalErrorFlashStart = null;
2441
+ }
2442
+ else {
2443
+ flashAlpha = 0.7 + 0.3 * (0.5 + 0.5 * Math.sin(elapsed / ERROR_FLASH_MS * Math.PI * 6 - Math.PI / 2));
2444
+ }
2445
+ }
2446
+ const neutral = error.kind === 'locked' || error.kind === 'encrypted';
2447
+ ctx.save();
2448
+ ctx.globalAlpha = flashAlpha;
2449
+ ctx.fillStyle = neutral ? 'rgba(18,20,26,0.82)' : 'rgba(55,16,22,0.78)';
2450
+ ctx.fillRect(0, 0, this.width, this.height);
2451
+ ctx.font = '600 15px Segoe UI, Tahoma, sans-serif';
2452
+ const lines = this.wrapTooltip(error.message, Math.max(120, Math.min(520, this.width - 80)));
2453
+ const lineHeight = 21;
2454
+ const boxWidth = Math.min(this.width - 32, Math.max(180, ...lines.map((line) => ctx.measureText(line).width + 36)));
2455
+ const boxHeight = lines.length * lineHeight + 32;
2456
+ const x = (this.width - boxWidth) / 2;
2457
+ const y = (this.height - boxHeight) / 2;
2458
+ roundRect(ctx, x, y, boxWidth, boxHeight, 8);
2459
+ ctx.fillStyle = neutral ? 'rgba(36,39,48,0.98)' : 'rgba(75,22,31,0.98)';
2460
+ ctx.fill();
2461
+ ctx.strokeStyle = neutral ? '#858b98' : ERROR_RED;
2462
+ ctx.lineWidth = 2;
2463
+ ctx.stroke();
2464
+ ctx.fillStyle = '#ffffff';
2465
+ ctx.textAlign = 'center';
2466
+ ctx.textBaseline = 'middle';
2467
+ lines.forEach((line, index) => {
2468
+ ctx.fillText(line, this.width / 2, y + 16 + lineHeight * index + lineHeight / 2);
2469
+ });
2470
+ ctx.restore();
2471
+ }
2472
+ drawTooltip() {
2473
+ if (!this.tipShow)
2474
+ return; // wait out the hover delay
2475
+ if (this.dragNode !== null || this.linking !== null || this.relinking !== null || this.panning || this.ovDragging)
2476
+ return;
2477
+ let text = '';
2478
+ let isError = false;
2479
+ if (this.hoverPort !== null) {
2480
+ const p = this.hoverPort.port;
2481
+ text = p.type ? `${p.name} · ${p.type}` : p.name;
2482
+ const runtime = this.portRuntimeState(this.hoverPort.node, p);
2483
+ if (runtime?.value !== null && runtime?.value !== undefined)
2484
+ text += `\nValue: ${runtime.value}`;
2485
+ if (runtime?.error !== null && runtime?.error !== undefined) {
2486
+ text += `\n${runtime.error}`;
2487
+ isError = true;
2488
+ }
2489
+ }
2490
+ else if (this.hoverNode !== null) {
2491
+ const n = this.hoverNode;
2492
+ const errors = [n.runtimeError, n.loadError].filter((value) => value.length > 0);
2493
+ if (errors.length > 0) {
2494
+ text = errors.join('\n');
2495
+ isError = true;
2496
+ }
2497
+ else if (this.showNodeMessages && n.message.length > 0) {
2498
+ text = n.message;
2499
+ }
2500
+ else if (n.description.length > 0) {
2501
+ text = n.description;
2502
+ }
2503
+ else {
2504
+ text = n.typeId && n.typeId !== n.name ? `${n.name} [${n.typeId}]` : n.name;
2505
+ }
2506
+ }
2507
+ else {
2508
+ return;
2509
+ }
2510
+ const ctx = this.ctx;
2511
+ ctx.font = '11px Segoe UI, Tahoma, sans-serif';
2512
+ const padX = 9;
2513
+ const padY = 6;
2514
+ const lineH = 15;
2515
+ const lines = this.wrapTooltip(text, 340);
2516
+ const h = lines.length * lineH + padY * 2;
2517
+ const w = Math.max(...lines.map((line) => ctx.measureText(line).width)) + padX * 2;
2518
+ let x = this.cursor.x + 14;
2519
+ let y = this.cursor.y + 18;
2520
+ if (x + w > this.width)
2521
+ x = this.width - w - 4;
2522
+ if (y + h > this.height)
2523
+ y = this.cursor.y - h - 10;
2524
+ roundRect(ctx, x, y, w, h, 4);
2525
+ ctx.fillStyle = isError ? 'rgba(55,16,22,0.97)' : 'rgba(18,20,26,0.96)';
2526
+ ctx.fill();
2527
+ ctx.lineWidth = 1;
2528
+ ctx.strokeStyle = isError ? ERROR_RED : '#3a3a46';
2529
+ ctx.stroke();
2530
+ ctx.fillStyle = '#e8e8ee';
2531
+ ctx.textBaseline = 'middle';
2532
+ ctx.textAlign = 'left';
2533
+ lines.forEach((line, index) => {
2534
+ ctx.fillText(line, x + padX, y + padY + lineH * index + lineH / 2);
2535
+ });
2536
+ }
2537
+ wrapTooltip(text, maxWidth) {
2538
+ const lines = [];
2539
+ for (const paragraph of text.split(/\r?\n/)) {
2540
+ const words = paragraph.split(/\s+/).filter(Boolean);
2541
+ if (words.length === 0) {
2542
+ lines.push('');
2543
+ continue;
2544
+ }
2545
+ let line = words[0];
2546
+ for (let i = 1; i < words.length; i += 1) {
2547
+ const next = `${line} ${words[i]}`;
2548
+ if (this.ctx.measureText(next).width <= maxWidth)
2549
+ line = next;
2550
+ else {
2551
+ lines.push(line);
2552
+ line = words[i];
2553
+ }
2554
+ }
2555
+ lines.push(line);
2556
+ }
2557
+ return lines.length > 0 ? lines : [''];
2558
+ }
2559
+ drawGrid() {
2560
+ const ctx = this.ctx;
2561
+ const step = this.gridSize * this.scale;
2562
+ if (step < 6)
2563
+ return;
2564
+ ctx.strokeStyle = this.opts.gridColor ?? '#26262c';
2565
+ ctx.lineWidth = 1;
2566
+ const ox = this.offX % step;
2567
+ const oy = this.offY % step;
2568
+ ctx.beginPath();
2569
+ for (let x = ox; x < this.width; x += step) {
2570
+ ctx.moveTo(x + 0.5, 0);
2571
+ ctx.lineTo(x + 0.5, this.height);
2572
+ }
2573
+ for (let y = oy; y < this.height; y += step) {
2574
+ ctx.moveTo(0, y + 0.5);
2575
+ ctx.lineTo(this.width, y + 0.5);
2576
+ }
2577
+ ctx.stroke();
2578
+ }
2579
+ // Lazily load + cache a node-icon image by URL; repaints once it decodes.
2580
+ getIcon(url) {
2581
+ const cached = this.iconCache.get(url);
2582
+ if (cached !== undefined)
2583
+ return cached; // img = ready; null = loading/failed
2584
+ this.iconCache.set(url, null); // mark in-flight to avoid duplicate loads
2585
+ const img = new Image();
2586
+ img.onload = () => { this.iconCache.set(url, img); this.scheduleDraw(); };
2587
+ img.onerror = () => { this.iconCache.set(url, null); };
2588
+ img.src = url;
2589
+ return null;
2590
+ }
2591
+ drawNode(n, selected, options) {
2592
+ const ctx = this.ctx;
2593
+ const hasLoadError = options.runtime && n.loadError.length > 0;
2594
+ const hasRuntimeError = options.runtime && n.runtimeError.length > 0;
2595
+ const runtime = options.runtime ? this.runtimeState.nodes[n.id] : undefined;
2596
+ const active = options.runtime
2597
+ && (this.runtimeState.activeNodeId === n.id || runtime?.active === true);
2598
+ roundRect(ctx, n.x, n.y, n.w, n.h, 6);
2599
+ ctx.fillStyle = hasLoadError ? ERROR_BACKGROUND : active ? '#ffd1dc' : n.color;
2600
+ ctx.fill();
2601
+ ctx.lineWidth = selected ? 2 : 1.5;
2602
+ ctx.strokeStyle = selected ? '#4aa3ff' : n.border;
2603
+ ctx.stroke();
2604
+ if (hasLoadError || hasRuntimeError) {
2605
+ let alpha = 1;
2606
+ if (options.transient && hasRuntimeError && n.errorFlashStart !== null) {
2607
+ const elapsed = performance.now() - n.errorFlashStart;
2608
+ if (elapsed >= ERROR_FLASH_MS) {
2609
+ n.errorFlashStart = null;
2610
+ }
2611
+ else {
2612
+ // Three clear pulses before the border settles on red.
2613
+ alpha = 0.12 + 0.88 * (0.5 + 0.5 * Math.sin(elapsed / ERROR_FLASH_MS * Math.PI * 6 - Math.PI / 2));
2614
+ }
2615
+ }
2616
+ ctx.save();
2617
+ ctx.globalAlpha *= alpha;
2618
+ roundRect(ctx, n.x, n.y, n.w, n.h, 6);
2619
+ ctx.lineWidth = hasRuntimeError ? 3 : 2;
2620
+ ctx.strokeStyle = ERROR_RED;
2621
+ ctx.stroke();
2622
+ ctx.restore();
2623
+ }
2624
+ // Element icon on the left, vertically centred (lazy-loaded, cached).
2625
+ const iconW = 18;
2626
+ if (n.icon) {
2627
+ const img = this.getIcon(n.icon);
2628
+ if (img) {
2629
+ try {
2630
+ ctx.drawImage(img, n.x + 8, n.y + (n.h - iconW) / 2, iconW, iconW);
2631
+ }
2632
+ catch { /* undecodable */ }
2633
+ }
2634
+ }
2635
+ // Bold title centred on the light body (nudged right when an icon shows so
2636
+ // they don't overlap); ports are bare colour squares on the edges.
2637
+ const titleShift = n.icon ? iconW + 4 : 0;
2638
+ ctx.fillStyle = hasLoadError ? '#ffffff' : '#1b1b1b';
2639
+ ctx.font = '600 12px Segoe UI, Tahoma, sans-serif';
2640
+ ctx.textAlign = 'center';
2641
+ ctx.textBaseline = 'middle';
2642
+ ctx.fillText(n.name, n.x + titleShift + (n.w - titleShift) / 2, n.y + n.h / 2, n.w - titleShift - 14);
2643
+ for (const p of n.inPorts)
2644
+ this.drawPort(n, p, options);
2645
+ for (const p of n.outPorts)
2646
+ this.drawPort(n, p, options);
2647
+ }
2648
+ drawPort(node, p, options) {
2649
+ const ctx = this.ctx;
2650
+ const hovered = options.transient && this.hoverPort?.port === p;
2651
+ const magnet = options.transient && this.linkSnap?.port === p;
2652
+ const runtime = options.runtime ? this.portRuntimeState(node, p) : null;
2653
+ const s = magnet ? PORT_SQ + 4 : hovered ? PORT_SQ + 2 : PORT_SQ;
2654
+ const x = p.cx - s / 2;
2655
+ const y = p.cy - s / 2;
2656
+ const r = Math.min(2.5, s / 4);
2657
+ if (magnet) {
2658
+ roundRect(ctx, x - 4, y - 4, s + 8, s + 8, r + 2);
2659
+ ctx.fillStyle = 'rgba(255,255,255,0.16)';
2660
+ ctx.fill();
2661
+ }
2662
+ // Single rounded path filled + stroked (aligned, anti-aliased —
2663
+ // no half-pixel double edge).
2664
+ roundRect(ctx, x, y, s, s, r);
2665
+ ctx.fillStyle = this.portColor(p.type);
2666
+ ctx.fill();
2667
+ ctx.lineWidth = magnet ? 1.5 : 1;
2668
+ ctx.strokeStyle = magnet ? '#ffffff' : 'rgba(12,12,16,0.55)';
2669
+ ctx.stroke();
2670
+ const runtimeSelected = runtime?.selected === true || (options.selection && this.selectedPort?.port === p);
2671
+ const breakpoint = runtime?.breakpoint === true;
2672
+ const breakpointActive = runtime?.breakpointActive === true;
2673
+ const runtimeActive = runtime?.active === true;
2674
+ const hasError = runtime?.error !== null && runtime?.error !== undefined;
2675
+ if (runtimeSelected || breakpoint || breakpointActive || runtimeActive || hasError) {
2676
+ ctx.beginPath();
2677
+ ctx.arc(p.cx, p.cy, s / 2 + 4, 0, Math.PI * 2);
2678
+ if (breakpointActive) {
2679
+ ctx.fillStyle = '#8b0000';
2680
+ ctx.fill();
2681
+ }
2682
+ else if (runtimeSelected) {
2683
+ ctx.fillStyle = 'rgba(211,211,211,0.56)';
2684
+ ctx.fill();
2685
+ }
2686
+ ctx.lineWidth = 2;
2687
+ ctx.strokeStyle = hasError || breakpointActive ? ERROR_RED
2688
+ : breakpoint ? '#ffb6c1'
2689
+ : runtimeActive ? '#ffffff'
2690
+ : '#d3d3d3';
2691
+ ctx.stroke();
2692
+ }
2693
+ }
2694
+ portRuntimeState(node, port) {
2695
+ return this.runtimeState.nodes[node.id]?.ports[port.direction][port.id] ?? null;
2696
+ }
2697
+ // Symmetric jump-over: any segment of THIS link hops the perpendicular
2698
+ // segments of links drawn before it (H over earlier V, V over earlier
2699
+ // H). Exactly one bridge per real crossing, consistent everywhere.
2700
+ strokeRoute(pts, color, width, prior) {
2701
+ const ctx = this.ctx;
2702
+ ctx.strokeStyle = color;
2703
+ ctx.lineWidth = width;
2704
+ ctx.lineJoin = 'miter';
2705
+ ctx.lineCap = 'butt';
2706
+ ctx.beginPath();
2707
+ ctx.moveTo(pts[0][0], pts[0][1]);
2708
+ const H = LINK_HOP;
2709
+ for (let i = 1; i < pts.length; i += 1) {
2710
+ const [x1, y1] = pts[i - 1];
2711
+ const [x2, y2] = pts[i];
2712
+ if (y1 === y2 && x1 !== x2) {
2713
+ const dir = x2 > x1 ? 1 : -1;
2714
+ const cuts = prior
2715
+ .filter((s) => !s.h && s.a < y1 - 0.5 && y1 + 0.5 < s.b &&
2716
+ Math.min(x1, x2) + H < s.c && s.c < Math.max(x1, x2) - H)
2717
+ .map((s) => s.c)
2718
+ .sort((p, q) => dir * (p - q));
2719
+ for (const cx of cuts) {
2720
+ ctx.lineTo(cx - dir * H, y1);
2721
+ if (dir > 0)
2722
+ ctx.arc(cx, y1, H, Math.PI, 2 * Math.PI, false);
2723
+ else
2724
+ ctx.arc(cx, y1, H, 0, Math.PI, true);
2725
+ }
2726
+ ctx.lineTo(x2, y2);
2727
+ }
2728
+ else if (x1 === x2 && y1 !== y2) {
2729
+ const dir = y2 > y1 ? 1 : -1;
2730
+ const cuts = prior
2731
+ .filter((s) => s.h && s.a < x1 - 0.5 && x1 + 0.5 < s.b &&
2732
+ Math.min(y1, y2) + H < s.c && s.c < Math.max(y1, y2) - H)
2733
+ .map((s) => s.c)
2734
+ .sort((p, q) => dir * (p - q));
2735
+ for (const cy of cuts) {
2736
+ ctx.lineTo(x1, cy - dir * H);
2737
+ if (dir > 0)
2738
+ ctx.arc(x1, cy, H, -Math.PI / 2, Math.PI / 2, false);
2739
+ else
2740
+ ctx.arc(x1, cy, H, Math.PI / 2, -Math.PI / 2, true);
2741
+ }
2742
+ ctx.lineTo(x2, y2);
2743
+ }
2744
+ else {
2745
+ ctx.lineTo(x2, y2);
2746
+ }
2747
+ }
2748
+ ctx.stroke();
2749
+ }
2750
+ drawArrow(pts, color) {
2751
+ const n = pts.length;
2752
+ const [px, py] = pts[n - 2];
2753
+ const [ex, ey] = pts[n - 1];
2754
+ const ang = Math.atan2(ey - py, ex - px);
2755
+ const ctx = this.ctx;
2756
+ ctx.save();
2757
+ ctx.translate(ex, ey);
2758
+ ctx.rotate(ang);
2759
+ ctx.fillStyle = color;
2760
+ ctx.beginPath();
2761
+ ctx.moveTo(0, 0);
2762
+ ctx.lineTo(-9, -4.5);
2763
+ ctx.lineTo(-9, 4.5);
2764
+ ctx.closePath();
2765
+ ctx.fill();
2766
+ ctx.restore();
2767
+ }
2768
+ drawSelectedLinkEndpoints() {
2769
+ if (this.selectedLink === null)
2770
+ return;
2771
+ const ctx = this.ctx;
2772
+ const halfSize = RELINK_HANDLE_PX / 2 / this.scale;
2773
+ for (const end of ['from', 'to']) {
2774
+ const point = this.endpoint(this.selectedLink, end);
2775
+ if (point === null)
2776
+ continue;
2777
+ ctx.beginPath();
2778
+ ctx.moveTo(point[0], point[1] - halfSize);
2779
+ ctx.lineTo(point[0] + halfSize, point[1]);
2780
+ ctx.lineTo(point[0], point[1] + halfSize);
2781
+ ctx.lineTo(point[0] - halfSize, point[1]);
2782
+ ctx.closePath();
2783
+ ctx.fillStyle = '#00ffff';
2784
+ ctx.fill();
2785
+ ctx.strokeStyle = '#000000';
2786
+ ctx.lineWidth = 1 / this.scale;
2787
+ ctx.stroke();
2788
+ }
2789
+ }
2790
+ drawPendingLink() {
2791
+ if (this.linking === null && this.relinking === null)
2792
+ return;
2793
+ const ctx = this.ctx;
2794
+ const snapped = this.linkSnap;
2795
+ const cursor = this.toWorld(this.cursor.x, this.cursor.y);
2796
+ let a;
2797
+ let b;
2798
+ let colorType = '';
2799
+ let snapPoint = null;
2800
+ const excludedNodes = new Set();
2801
+ if (this.linking !== null) {
2802
+ a = [this.linking.port.cx, this.linking.port.cy];
2803
+ b = snapped === null ? cursor : [snapped.port.cx, snapped.port.cy];
2804
+ colorType = this.linking.port.type;
2805
+ excludedNodes.add(this.linking.node.id);
2806
+ if (snapped !== null)
2807
+ snapPoint = b;
2808
+ }
2809
+ else {
2810
+ const gesture = this.relinking;
2811
+ const fixed = this.endpoint(gesture.link, gesture.end === 'from' ? 'to' : 'from');
2812
+ if (fixed === null)
2813
+ return;
2814
+ const sourcePort = this.nodes.find((node) => node.id === gesture.link.from)?.outPorts
2815
+ .find((port) => port.id === gesture.link.fromPort);
2816
+ if (gesture.end === 'from') {
2817
+ a = snapped === null ? cursor : [snapped.port.cx, snapped.port.cy];
2818
+ b = fixed;
2819
+ colorType = snapped?.port.type ?? sourcePort?.type ?? '';
2820
+ excludedNodes.add(gesture.link.to);
2821
+ if (snapped !== null)
2822
+ snapPoint = a;
2823
+ }
2824
+ else {
2825
+ a = fixed;
2826
+ b = snapped === null ? cursor : [snapped.port.cx, snapped.port.cy];
2827
+ colorType = sourcePort?.type ?? '';
2828
+ excludedNodes.add(gesture.link.from);
2829
+ if (snapped !== null)
2830
+ snapPoint = b;
2831
+ }
2832
+ }
2833
+ if (snapped !== null)
2834
+ excludedNodes.add(snapped.node.id);
2835
+ const points = this.routeLink(a, b, excludedNodes);
2836
+ ctx.strokeStyle = this.portColor(colorType);
2837
+ if (snapped !== null) {
2838
+ ctx.setLineDash([]);
2839
+ ctx.lineWidth = 2.6;
2840
+ }
2841
+ else {
2842
+ ctx.setLineDash([5, 4]);
2843
+ ctx.lineWidth = 2;
2844
+ }
2845
+ ctx.beginPath();
2846
+ ctx.moveTo(points[0][0], points[0][1]);
2847
+ for (let index = 1; index < points.length; index += 1)
2848
+ ctx.lineTo(points[index][0], points[index][1]);
2849
+ ctx.stroke();
2850
+ ctx.setLineDash([]);
2851
+ if (snapPoint !== null) {
2852
+ ctx.beginPath();
2853
+ ctx.arc(snapPoint[0], snapPoint[1], PORT_R + 4, 0, Math.PI * 2);
2854
+ ctx.strokeStyle = '#ffffff';
2855
+ ctx.lineWidth = 2;
2856
+ ctx.stroke();
2857
+ }
2858
+ }
2859
+ // ---- overview minimap -------------------------------------------
2860
+ overviewRect() {
2861
+ if (!this.overviewVisible || this.width < 320 || this.height < 220)
2862
+ return null;
2863
+ const w = 190, h = 130, m = 14;
2864
+ return { x: this.width - w - m, y: this.height - h - m, w, h };
2865
+ }
2866
+ drawOverview() {
2867
+ const r = this.overviewRect();
2868
+ const gb = this.graphBounds();
2869
+ if (r === null || gb === null) {
2870
+ this.ovGeo = null;
2871
+ return;
2872
+ }
2873
+ const ctx = this.ctx;
2874
+ const pad = 8;
2875
+ const gw = (gb.maxX - gb.minX) || 1;
2876
+ const gh = (gb.maxY - gb.minY) || 1;
2877
+ const s = Math.min((r.w - pad * 2) / gw, (r.h - pad * 2) / gh);
2878
+ const ox = r.x + pad + ((r.w - pad * 2) - gw * s) / 2;
2879
+ const oy = r.y + pad + ((r.h - pad * 2) - gh * s) / 2;
2880
+ this.ovGeo = { x: ox, y: oy, w: r.w, h: r.h, s, minX: gb.minX, minY: gb.minY };
2881
+ const mx = (wx) => ox + (wx - gb.minX) * s;
2882
+ const my = (wy) => oy + (wy - gb.minY) * s;
2883
+ const light = colorLuminance(this.opts.background) >= 150;
2884
+ const overviewBackground = this.opts.overviewBackground
2885
+ ?? (light ? 'rgba(255,255,255,0.94)' : 'rgba(16,16,20,0.88)');
2886
+ const overviewBorder = this.opts.overviewBorderColor
2887
+ ?? (light ? '#cbd5e1' : '#3a3a44');
2888
+ const viewportColor = this.opts.overviewViewportColor
2889
+ ?? (light ? '#d97706' : '#4aa3ff');
2890
+ const viewportFill = this.opts.overviewViewportFill
2891
+ ?? (light ? 'rgba(217,119,6,0.10)' : 'rgba(74,163,255,0.10)');
2892
+ ctx.save();
2893
+ roundRect(ctx, r.x, r.y, r.w, r.h, 6);
2894
+ ctx.fillStyle = overviewBackground;
2895
+ ctx.fill();
2896
+ ctx.strokeStyle = overviewBorder;
2897
+ ctx.lineWidth = 1;
2898
+ ctx.stroke();
2899
+ ctx.clip();
2900
+ for (const n of this.nodes) {
2901
+ ctx.fillStyle = n.color;
2902
+ ctx.fillRect(mx(n.x), my(n.y), Math.max(2, n.w * s), Math.max(2, n.h * s));
2903
+ ctx.strokeStyle = n.border;
2904
+ ctx.lineWidth = 1;
2905
+ ctx.strokeRect(mx(n.x), my(n.y), Math.max(2, n.w * s), Math.max(2, n.h * s));
2906
+ }
2907
+ // viewport rectangle (what the main canvas currently shows)
2908
+ const [vx0, vy0] = this.toWorld(0, 0);
2909
+ const [vx1, vy1] = this.toWorld(this.width, this.height);
2910
+ ctx.strokeStyle = viewportColor;
2911
+ ctx.lineWidth = 1.5;
2912
+ ctx.strokeRect(mx(vx0), my(vy0), (vx1 - vx0) * s, (vy1 - vy0) * s);
2913
+ ctx.fillStyle = viewportFill;
2914
+ ctx.fillRect(mx(vx0), my(vy0), (vx1 - vx0) * s, (vy1 - vy0) * s);
2915
+ ctx.restore();
2916
+ }
2917
+ ovHit(sx, sy) {
2918
+ const r = this.overviewRect();
2919
+ return r !== null && sx >= r.x && sx <= r.x + r.w && sy >= r.y && sy <= r.y + r.h;
2920
+ }
2921
+ ovPanTo(sx, sy) {
2922
+ if (this.ovGeo === null)
2923
+ return;
2924
+ const g = this.ovGeo;
2925
+ const wx = g.minX + (sx - g.x) / g.s;
2926
+ const wy = g.minY + (sy - g.y) / g.s;
2927
+ this.offX = this.width / 2 - wx * this.scale;
2928
+ this.offY = this.height / 2 - wy * this.scale;
2929
+ this.scheduleDraw();
2930
+ }
2931
+ emitViewChanged(zoomChanged) {
2932
+ if (zoomChanged)
2933
+ this.emit('zoomChanged', { scale: this.scale });
2934
+ this.emit('viewChanged', this.getViewState());
2935
+ }
2936
+ }
2937
+ function roundRect(ctx, x, y, w, h, r) {
2938
+ const rr = Math.min(r, w / 2, h / 2);
2939
+ ctx.beginPath();
2940
+ ctx.moveTo(x + rr, y);
2941
+ ctx.arcTo(x + w, y, x + w, y + h, rr);
2942
+ ctx.arcTo(x + w, y + h, x, y + h, rr);
2943
+ ctx.arcTo(x, y + h, x, y, rr);
2944
+ ctx.arcTo(x, y, x + w, y, rr);
2945
+ ctx.closePath();
2946
+ }
2947
+ // Orthogonal route out-port → in-port. Forward (target to the right):
2948
+ // a 3-segment mid-X elbow. Backward/too-close: a 5-segment detour that
2949
+ // leaves the source rightward and re-enters the target from the left
2950
+ // (AvoidsNodes-style, never a diagonal).
2951
+ function route(a, b) {
2952
+ const [ax, ay] = a;
2953
+ const [bx, by] = b;
2954
+ if (bx - ax >= LINK_STUB * 2) {
2955
+ const mx = (ax + bx) / 2;
2956
+ return [[ax, ay], [mx, ay], [mx, by], [bx, by]];
2957
+ }
2958
+ const midY = (ay + by) / 2;
2959
+ return [[ax, ay], [ax + LINK_STUB, ay], [ax + LINK_STUB, midY],
2960
+ [bx - LINK_STUB, midY], [bx - LINK_STUB, by], [bx, by]];
2961
+ }
2962
+ // --- AvoidsNodes routing ------------------------------------------------
2963
+ // Obstacle rects are [x,y,w,h] (already inflated by the caller). Fast
2964
+ // path: the plain elbow when it doesn't touch any box (only bend when
2965
+ // you must). Otherwise an orthogonal A* around the boxes — so a node
2966
+ // dropped on a link makes it re-route ("push apart") instead of passing
2967
+ // underneath.
2968
+ const ROUTE_CELL = 14;
2969
+ const A_STEP = 10;
2970
+ const A_TURN = 40; // heavy turn cost → A* strongly prefers straight runs
2971
+ function segHitsRect(x1, y1, x2, y2, r) {
2972
+ const rx2 = r[0] + r[2];
2973
+ const ry2 = r[1] + r[3];
2974
+ if (y1 === y2) {
2975
+ if (y1 <= r[1] || y1 >= ry2)
2976
+ return false;
2977
+ return Math.min(x1, x2) < rx2 && Math.max(x1, x2) > r[0];
2978
+ }
2979
+ if (x1 === x2) {
2980
+ if (x1 <= r[0] || x1 >= rx2)
2981
+ return false;
2982
+ return Math.min(y1, y2) < ry2 && Math.max(y1, y2) > r[1];
2983
+ }
2984
+ return false;
2985
+ }
2986
+ function polyHitsRects(pts, rects) {
2987
+ for (let i = 1; i < pts.length; i += 1)
2988
+ for (const r of rects)
2989
+ if (segHitsRect(pts[i - 1][0], pts[i - 1][1], pts[i][0], pts[i][1], r))
2990
+ return true;
2991
+ return false;
2992
+ }
2993
+ function orthogonalize(ps) {
2994
+ const o = [ps[0]];
2995
+ for (let i = 1; i < ps.length; i += 1) {
2996
+ const pr = o[o.length - 1];
2997
+ const p = ps[i];
2998
+ if (pr[0] !== p[0] && pr[1] !== p[1])
2999
+ o.push([p[0], pr[1]]);
3000
+ o.push(p);
3001
+ }
3002
+ return o;
3003
+ }
3004
+ function collapse(ps) {
3005
+ const o = [];
3006
+ for (const p of ps) {
3007
+ if (o.length > 0) {
3008
+ const last = o[o.length - 1];
3009
+ if (last[0] === p[0] && last[1] === p[1])
3010
+ continue;
3011
+ }
3012
+ if (o.length >= 2) {
3013
+ const a = o[o.length - 2];
3014
+ const b = o[o.length - 1];
3015
+ if ((a[0] === b[0] && b[0] === p[0]) || (a[1] === b[1] && b[1] === p[1])) {
3016
+ o[o.length - 1] = p;
3017
+ continue;
3018
+ }
3019
+ }
3020
+ o.push(p);
3021
+ }
3022
+ return o;
3023
+ }
3024
+ function hitsAny(x1, y1, x2, y2, rects) {
3025
+ for (const r of rects)
3026
+ if (segHitsRect(x1, y1, x2, y2, r))
3027
+ return true;
3028
+ return false;
3029
+ }
3030
+ // Staircase → L: repeatedly try to replace two consecutive bends with a
3031
+ // single corner when the shortcut stays orthogonal and clear of boxes.
3032
+ // Cuts the A* zig-zag down to the minimum number of bends.
3033
+ function simplifyOrtho(pts, rects) {
3034
+ let p = pts.slice();
3035
+ let changed = true;
3036
+ let guard = 0;
3037
+ while (changed && guard < 40) {
3038
+ changed = false;
3039
+ guard += 1;
3040
+ // Keep the first/last two points (port → stub) untouched so the
3041
+ // entry/exit stays a clean horizontal run into the socket.
3042
+ for (let i = 2; i + 3 < p.length; i += 1) {
3043
+ const A = p[i - 1];
3044
+ const D = p[i + 2];
3045
+ for (const E of [[D[0], A[1]], [A[0], D[1]]]) {
3046
+ if ((E[0] === A[0] && E[1] === A[1]) || (E[0] === D[0] && E[1] === D[1]))
3047
+ continue;
3048
+ if (hitsAny(A[0], A[1], E[0], E[1], rects))
3049
+ continue;
3050
+ if (hitsAny(E[0], E[1], D[0], D[1], rects))
3051
+ continue;
3052
+ p = p.slice(0, i).concat([E], p.slice(i + 2));
3053
+ changed = true;
3054
+ break;
3055
+ }
3056
+ if (changed)
3057
+ break;
3058
+ }
3059
+ }
3060
+ return p;
3061
+ }
3062
+ function aStarGrid(start, goal, rects) {
3063
+ let minX = Math.min(start[0], goal[0]);
3064
+ let minY = Math.min(start[1], goal[1]);
3065
+ let maxX = Math.max(start[0], goal[0]);
3066
+ let maxY = Math.max(start[1], goal[1]);
3067
+ for (const r of rects) {
3068
+ minX = Math.min(minX, r[0]);
3069
+ minY = Math.min(minY, r[1]);
3070
+ maxX = Math.max(maxX, r[0] + r[2]);
3071
+ maxY = Math.max(maxY, r[1] + r[3]);
3072
+ }
3073
+ const pad = LINK_STUB + ROUTE_CELL * 2;
3074
+ minX -= pad;
3075
+ minY -= pad;
3076
+ maxX += pad;
3077
+ maxY += pad;
3078
+ let cell = ROUTE_CELL;
3079
+ let cols = Math.ceil((maxX - minX) / cell);
3080
+ let rows = Math.ceil((maxY - minY) / cell);
3081
+ const CAP = 260;
3082
+ if (cols > CAP || rows > CAP) {
3083
+ cell = Math.max((maxX - minX) / CAP, (maxY - minY) / CAP);
3084
+ cols = Math.ceil((maxX - minX) / cell);
3085
+ rows = Math.ceil((maxY - minY) / cell);
3086
+ }
3087
+ const n = cols * rows;
3088
+ if (n <= 0 || n > 80000)
3089
+ return null;
3090
+ const cx = (c) => minX + (c + 0.5) * cell;
3091
+ const cy = (r) => minY + (r + 0.5) * cell;
3092
+ const blocked = new Uint8Array(n);
3093
+ for (const r of rects) {
3094
+ const c0 = Math.max(0, Math.floor((r[0] - minX) / cell));
3095
+ const c1 = Math.min(cols - 1, Math.floor((r[0] + r[2] - minX) / cell));
3096
+ const r0 = Math.max(0, Math.floor((r[1] - minY) / cell));
3097
+ const r1 = Math.min(rows - 1, Math.floor((r[1] + r[3] - minY) / cell));
3098
+ for (let rr = r0; rr <= r1; rr += 1)
3099
+ for (let cc = c0; cc <= c1; cc += 1)
3100
+ blocked[rr * cols + cc] = 1;
3101
+ }
3102
+ const sc = clamp(Math.floor((start[0] - minX) / cell), 0, cols - 1);
3103
+ const sr = clamp(Math.floor((start[1] - minY) / cell), 0, rows - 1);
3104
+ const gc = clamp(Math.floor((goal[0] - minX) / cell), 0, cols - 1);
3105
+ const gr = clamp(Math.floor((goal[1] - minY) / cell), 0, rows - 1);
3106
+ const si = sr * cols + sc;
3107
+ const gi = gr * cols + gc;
3108
+ blocked[si] = 0;
3109
+ blocked[gi] = 0;
3110
+ const g = new Float64Array(n).fill(Infinity);
3111
+ const prev = new Int32Array(n).fill(-1);
3112
+ const dir = new Int8Array(n).fill(-1); // 0=h,1=v incoming move
3113
+ g[si] = 0;
3114
+ // binary heap of indices keyed by f
3115
+ const heap = [si];
3116
+ const f = new Float64Array(n);
3117
+ f[si] = (Math.abs(gc - sc) + Math.abs(gr - sr)) * A_STEP;
3118
+ const swap = (i, j) => { const t = heap[i]; heap[i] = heap[j]; heap[j] = t; };
3119
+ const push = (v) => {
3120
+ heap.push(v);
3121
+ let i = heap.length - 1;
3122
+ while (i > 0) {
3123
+ const p = (i - 1) >> 1;
3124
+ if (f[heap[p]] <= f[heap[i]])
3125
+ break;
3126
+ swap(i, p);
3127
+ i = p;
3128
+ }
3129
+ };
3130
+ const pop = () => {
3131
+ const top = heap[0];
3132
+ const last = heap.pop();
3133
+ if (heap.length > 0) {
3134
+ heap[0] = last;
3135
+ let i = 0;
3136
+ for (;;) {
3137
+ const l = 2 * i + 1;
3138
+ const r = 2 * i + 2;
3139
+ let s = i;
3140
+ if (l < heap.length && f[heap[l]] < f[heap[s]])
3141
+ s = l;
3142
+ if (r < heap.length && f[heap[r]] < f[heap[s]])
3143
+ s = r;
3144
+ if (s === i)
3145
+ break;
3146
+ swap(i, s);
3147
+ i = s;
3148
+ }
3149
+ }
3150
+ return top;
3151
+ };
3152
+ const seen = new Uint8Array(n);
3153
+ while (heap.length > 0) {
3154
+ const cur = pop();
3155
+ if (cur === gi)
3156
+ break;
3157
+ if (seen[cur])
3158
+ continue;
3159
+ seen[cur] = 1;
3160
+ const ccol = cur % cols;
3161
+ const crow = (cur - ccol) / cols;
3162
+ const steps = [
3163
+ [1, 0, 0], [-1, 0, 0], [0, 1, 1], [0, -1, 1],
3164
+ ];
3165
+ for (const [dc, dr, md] of steps) {
3166
+ const nc = ccol + dc;
3167
+ const nr = crow + dr;
3168
+ if (nc < 0 || nc >= cols || nr < 0 || nr >= rows)
3169
+ continue;
3170
+ const ni = nr * cols + nc;
3171
+ if (blocked[ni])
3172
+ continue;
3173
+ const turn = dir[cur] !== -1 && dir[cur] !== md ? A_TURN : 0;
3174
+ const ng = g[cur] + A_STEP + turn;
3175
+ if (ng < g[ni]) {
3176
+ g[ni] = ng;
3177
+ prev[ni] = cur;
3178
+ dir[ni] = md;
3179
+ f[ni] = ng + (Math.abs(gc - nc) + Math.abs(gr - nr)) * A_STEP;
3180
+ push(ni);
3181
+ }
3182
+ }
3183
+ }
3184
+ if (prev[gi] === -1 && gi !== si)
3185
+ return null;
3186
+ const out = [];
3187
+ let p = gi;
3188
+ while (p !== -1) {
3189
+ out.push([cx(p % cols), cy((p - (p % cols)) / cols)]);
3190
+ p = prev[p];
3191
+ }
3192
+ out.reverse();
3193
+ return out;
3194
+ }
3195
+ function routeAvoid(a, b, rects) {
3196
+ const simple = route(a, b);
3197
+ if (rects.length === 0 || !polyHitsRects(simple, rects))
3198
+ return simple;
3199
+ const A2 = [a[0] + LINK_STUB, a[1]];
3200
+ const B2 = [b[0] - LINK_STUB, b[1]];
3201
+ const grid = aStarGrid(A2, B2, rects);
3202
+ if (grid === null)
3203
+ return simple;
3204
+ // Keep the approach monotonic: clamp interior X into the corridor
3205
+ // between the two stubs so the path can't overshoot past the port
3206
+ // and double back (the ⊓ "hook" at the socket). The vertical
3207
+ // alignment then lands cleanly at the stub X, STUB away from the
3208
+ // port — a straight horizontal entry.
3209
+ const lo = Math.min(A2[0], B2[0]);
3210
+ const hi = Math.max(A2[0], B2[0]);
3211
+ const clipped = grid.map((p) => [clamp(p[0], lo, hi), p[1]]);
3212
+ const poly = collapse(orthogonalize([a, A2, ...clipped, B2, b]));
3213
+ return collapse(simplifyOrtho(poly, rects));
3214
+ }
3215
+ function ptSegDist(px, py, x1, y1, x2, y2) {
3216
+ const dx = x2 - x1;
3217
+ const dy = y2 - y1;
3218
+ const len2 = dx * dx + dy * dy;
3219
+ let t = len2 === 0 ? 0 : ((px - x1) * dx + (py - y1) * dy) / len2;
3220
+ t = clamp(t, 0, 1);
3221
+ const cx = x1 + t * dx;
3222
+ const cy = y1 + t * dy;
3223
+ return Math.hypot(px - cx, py - cy);
3224
+ }
3225
+ export const version = '0.1.0';
3226
+ //# sourceMappingURL=canvas-renderer.js.map