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