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