@vkcha/svg-core 0.1.2 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +149 -1
- package/dist/compat.js +391 -0
- package/dist/index.cjs +1142 -0
- package/dist/index.d.ts +47 -1
- package/dist/index.js +1137 -0
- package/dist/input-dom.js +198 -0
- package/dist/panzoom-svg.js +27 -0
- package/dist/renderer-svg-dom.js +261 -0
- package/dist/vkcha.min.js +1 -1
- package/package.json +7 -4
- package/src/SvgCore.ts +32 -2
- package/src/canvas/PanZoomCanvas.ts +181 -3
- package/src/index.ts +7 -2
package/dist/index.js
ADDED
|
@@ -0,0 +1,1137 @@
|
|
|
1
|
+
function svgEl(tag, attrs = {}) {
|
|
2
|
+
const el = document.createElementNS("http://www.w3.org/2000/svg", tag);
|
|
3
|
+
for (const [k, v] of Object.entries(attrs))
|
|
4
|
+
el.setAttribute(k, v);
|
|
5
|
+
return el;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const DEFAULT_PANZOOM_OPTIONS = {
|
|
9
|
+
wheelMode: "pan",
|
|
10
|
+
zoomRequiresCtrlKey: false,
|
|
11
|
+
panRequiresSpaceKey: false,
|
|
12
|
+
minZoom: 0.2,
|
|
13
|
+
maxZoom: 8,
|
|
14
|
+
zoomSpeed: 1,
|
|
15
|
+
pinchZoomSpeed: 2,
|
|
16
|
+
invertZoom: false,
|
|
17
|
+
invertPan: false,
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Minimal SVG "canvas" with pan/zoom.
|
|
21
|
+
*
|
|
22
|
+
* - Wheel: zoom around cursor
|
|
23
|
+
* - Pointer drag: pan
|
|
24
|
+
*
|
|
25
|
+
* This is intentionally tiny and standalone so the project can restart from a clean base.
|
|
26
|
+
*/
|
|
27
|
+
class PanZoomCanvas {
|
|
28
|
+
svg;
|
|
29
|
+
world;
|
|
30
|
+
state = { zoom: 1, panX: 0, panY: 0 };
|
|
31
|
+
options = { ...DEFAULT_PANZOOM_OPTIONS };
|
|
32
|
+
listeners = new Set();
|
|
33
|
+
notifyScheduled = false;
|
|
34
|
+
dragPointerId = null;
|
|
35
|
+
panStart = null;
|
|
36
|
+
isPanning = false;
|
|
37
|
+
isSpaceDown = false;
|
|
38
|
+
static DRAG_THRESHOLD_PX = 5;
|
|
39
|
+
animationFrameId = null;
|
|
40
|
+
windowKeyDownHandler = null;
|
|
41
|
+
windowKeyUpHandler = null;
|
|
42
|
+
svgWheelHandler = null;
|
|
43
|
+
svgPointerDownHandler = null;
|
|
44
|
+
svgPointerMoveHandler = null;
|
|
45
|
+
svgPointerUpHandler = null;
|
|
46
|
+
svgPointerCancelHandler = null;
|
|
47
|
+
svgPointerLeaveHandler = null;
|
|
48
|
+
constructor(svg, opts = {}) {
|
|
49
|
+
this.svg = svg;
|
|
50
|
+
this.world = svgEl("g");
|
|
51
|
+
this.world.dataset.layer = "world";
|
|
52
|
+
this.svg.replaceChildren(this.world);
|
|
53
|
+
this.setOptions(opts);
|
|
54
|
+
this.applyWorldGroupConfig();
|
|
55
|
+
// Disable browser gestures, focus outline, and selection highlight on the SVG surface.
|
|
56
|
+
this.svg.style.touchAction = "none";
|
|
57
|
+
this.svg.style.userSelect = "none";
|
|
58
|
+
const svgStyle = this.svg.style;
|
|
59
|
+
svgStyle.webkitUserSelect = "none";
|
|
60
|
+
svgStyle.webkitTapHighlightColor = "transparent";
|
|
61
|
+
this.svg.style.outline = "none";
|
|
62
|
+
this.svg.setAttribute("tabindex", "-1");
|
|
63
|
+
// Also disable outline on the world group.
|
|
64
|
+
this.world.style.outline = "none";
|
|
65
|
+
this.attach();
|
|
66
|
+
this.render();
|
|
67
|
+
}
|
|
68
|
+
applyWorldGroupConfig() {
|
|
69
|
+
const cfg = this.options.worldGroup;
|
|
70
|
+
if (!cfg)
|
|
71
|
+
return;
|
|
72
|
+
if (cfg.id) {
|
|
73
|
+
this.world.id = cfg.id;
|
|
74
|
+
}
|
|
75
|
+
if (cfg.attributes) {
|
|
76
|
+
for (const [key, value] of Object.entries(cfg.attributes)) {
|
|
77
|
+
this.world.setAttribute(key, value);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Create a new <g> layer inside the world.
|
|
83
|
+
* Useful when you want pan/zoom only and manage your own SVG content.
|
|
84
|
+
*/
|
|
85
|
+
createLayer(name, opts) {
|
|
86
|
+
const layer = svgEl("g");
|
|
87
|
+
if (name)
|
|
88
|
+
layer.dataset.layer = name;
|
|
89
|
+
if (opts?.pointerEvents)
|
|
90
|
+
layer.style.pointerEvents = opts.pointerEvents;
|
|
91
|
+
if (opts?.position === "back" && this.world.firstChild) {
|
|
92
|
+
this.world.insertBefore(layer, this.world.firstChild);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
this.world.appendChild(layer);
|
|
96
|
+
}
|
|
97
|
+
return layer;
|
|
98
|
+
}
|
|
99
|
+
setOptions(next) {
|
|
100
|
+
this.options = { ...DEFAULT_PANZOOM_OPTIONS, ...this.options, ...next };
|
|
101
|
+
if (next.worldGroup) {
|
|
102
|
+
this.applyWorldGroupConfig();
|
|
103
|
+
this.render();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Subscribe to pan/zoom state changes (event-driven, no polling).
|
|
108
|
+
*
|
|
109
|
+
* Optimized:
|
|
110
|
+
* - multiple updates within a frame are coalesced into a single notification via rAF
|
|
111
|
+
*/
|
|
112
|
+
subscribe(fn) {
|
|
113
|
+
this.listeners.add(fn);
|
|
114
|
+
return () => this.listeners.delete(fn);
|
|
115
|
+
}
|
|
116
|
+
setState(next) {
|
|
117
|
+
const merged = { ...this.state, ...next };
|
|
118
|
+
if (merged.zoom === this.state.zoom &&
|
|
119
|
+
merged.panX === this.state.panX &&
|
|
120
|
+
merged.panY === this.state.panY) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
this.state = merged;
|
|
124
|
+
this.render();
|
|
125
|
+
this.scheduleNotify();
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Animate to a target state over a duration.
|
|
129
|
+
* Uses linear easing by default. Pass a custom easing function for different curves.
|
|
130
|
+
* Example: `(t) => 1 - Math.pow(1 - t, 3)` for ease-out cubic.
|
|
131
|
+
*/
|
|
132
|
+
animateTo(target, durationMs = 300, easing = (t) => t) {
|
|
133
|
+
return new Promise((resolve) => {
|
|
134
|
+
if (this.animationFrameId !== null) {
|
|
135
|
+
cancelAnimationFrame(this.animationFrameId);
|
|
136
|
+
this.animationFrameId = null;
|
|
137
|
+
}
|
|
138
|
+
const start = {
|
|
139
|
+
zoom: this.state.zoom,
|
|
140
|
+
panX: this.state.panX,
|
|
141
|
+
panY: this.state.panY,
|
|
142
|
+
};
|
|
143
|
+
const end = {
|
|
144
|
+
zoom: target.zoom ?? start.zoom,
|
|
145
|
+
panX: target.panX ?? start.panX,
|
|
146
|
+
panY: target.panY ?? start.panY,
|
|
147
|
+
};
|
|
148
|
+
const startTime = performance.now();
|
|
149
|
+
const step = (now) => {
|
|
150
|
+
const elapsed = now - startTime;
|
|
151
|
+
const t = Math.min(1, elapsed / durationMs);
|
|
152
|
+
const eased = easing(t);
|
|
153
|
+
this.setState({
|
|
154
|
+
zoom: start.zoom + (end.zoom - start.zoom) * eased,
|
|
155
|
+
panX: start.panX + (end.panX - start.panX) * eased,
|
|
156
|
+
panY: start.panY + (end.panY - start.panY) * eased,
|
|
157
|
+
});
|
|
158
|
+
if (t < 1) {
|
|
159
|
+
this.animationFrameId = requestAnimationFrame(step);
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
this.animationFrameId = null;
|
|
163
|
+
resolve();
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
this.animationFrameId = requestAnimationFrame(step);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
/** Stop any running animation. */
|
|
170
|
+
stopAnimation() {
|
|
171
|
+
if (this.animationFrameId !== null) {
|
|
172
|
+
cancelAnimationFrame(this.animationFrameId);
|
|
173
|
+
this.animationFrameId = null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
reset() {
|
|
177
|
+
this.setState({ zoom: 1, panX: 0, panY: 0 });
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Clean up all event listeners and resources.
|
|
181
|
+
* Call this when the canvas is no longer needed to prevent memory leaks.
|
|
182
|
+
*/
|
|
183
|
+
destroy() {
|
|
184
|
+
// Cancel any running animation
|
|
185
|
+
this.stopAnimation();
|
|
186
|
+
// Remove window listeners
|
|
187
|
+
if (this.windowKeyDownHandler) {
|
|
188
|
+
window.removeEventListener("keydown", this.windowKeyDownHandler);
|
|
189
|
+
this.windowKeyDownHandler = null;
|
|
190
|
+
}
|
|
191
|
+
if (this.windowKeyUpHandler) {
|
|
192
|
+
window.removeEventListener("keyup", this.windowKeyUpHandler);
|
|
193
|
+
this.windowKeyUpHandler = null;
|
|
194
|
+
}
|
|
195
|
+
// Remove SVG listeners
|
|
196
|
+
if (this.svgWheelHandler) {
|
|
197
|
+
this.svg.removeEventListener("wheel", this.svgWheelHandler);
|
|
198
|
+
this.svgWheelHandler = null;
|
|
199
|
+
}
|
|
200
|
+
if (this.svgPointerDownHandler) {
|
|
201
|
+
this.svg.removeEventListener("pointerdown", this.svgPointerDownHandler);
|
|
202
|
+
this.svgPointerDownHandler = null;
|
|
203
|
+
}
|
|
204
|
+
if (this.svgPointerMoveHandler) {
|
|
205
|
+
this.svg.removeEventListener("pointermove", this.svgPointerMoveHandler);
|
|
206
|
+
this.svgPointerMoveHandler = null;
|
|
207
|
+
}
|
|
208
|
+
if (this.svgPointerUpHandler) {
|
|
209
|
+
this.svg.removeEventListener("pointerup", this.svgPointerUpHandler);
|
|
210
|
+
this.svgPointerUpHandler = null;
|
|
211
|
+
}
|
|
212
|
+
if (this.svgPointerCancelHandler) {
|
|
213
|
+
this.svg.removeEventListener("pointercancel", this.svgPointerCancelHandler);
|
|
214
|
+
this.svgPointerCancelHandler = null;
|
|
215
|
+
}
|
|
216
|
+
if (this.svgPointerLeaveHandler) {
|
|
217
|
+
this.svg.removeEventListener("pointerleave", this.svgPointerLeaveHandler);
|
|
218
|
+
this.svgPointerLeaveHandler = null;
|
|
219
|
+
}
|
|
220
|
+
// Release pointer capture if active
|
|
221
|
+
if (this.dragPointerId !== null) {
|
|
222
|
+
try {
|
|
223
|
+
this.svg.releasePointerCapture(this.dragPointerId);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
// Ignore errors if pointer is already released
|
|
227
|
+
}
|
|
228
|
+
this.dragPointerId = null;
|
|
229
|
+
}
|
|
230
|
+
// Clear state
|
|
231
|
+
this.panStart = null;
|
|
232
|
+
this.isPanning = false;
|
|
233
|
+
this.isSpaceDown = false;
|
|
234
|
+
this.listeners.clear();
|
|
235
|
+
}
|
|
236
|
+
scheduleNotify() {
|
|
237
|
+
if (this.notifyScheduled)
|
|
238
|
+
return;
|
|
239
|
+
this.notifyScheduled = true;
|
|
240
|
+
requestAnimationFrame(() => {
|
|
241
|
+
this.notifyScheduled = false;
|
|
242
|
+
for (const fn of this.listeners)
|
|
243
|
+
fn(this.state);
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
attach() {
|
|
247
|
+
// Track Space key for "Figma-like" panning.
|
|
248
|
+
this.windowKeyDownHandler = (e) => {
|
|
249
|
+
if (e.code === "Space")
|
|
250
|
+
this.isSpaceDown = true;
|
|
251
|
+
};
|
|
252
|
+
this.windowKeyUpHandler = (e) => {
|
|
253
|
+
if (e.code === "Space")
|
|
254
|
+
this.isSpaceDown = false;
|
|
255
|
+
};
|
|
256
|
+
window.addEventListener("keydown", this.windowKeyDownHandler);
|
|
257
|
+
window.addEventListener("keyup", this.windowKeyUpHandler);
|
|
258
|
+
this.svgWheelHandler = (e) => {
|
|
259
|
+
e.preventDefault();
|
|
260
|
+
const pt = this.svgPoint(e.clientX, e.clientY);
|
|
261
|
+
const { wheelMode, zoomRequiresCtrlKey, invertZoom, invertPan } = this.options;
|
|
262
|
+
const ctrl = e.ctrlKey || e.metaKey;
|
|
263
|
+
const isPinchGesture = e.ctrlKey && !e.metaKey;
|
|
264
|
+
// Desired behavior:
|
|
265
|
+
// - wheelMode="zoom" (default): wheel zooms. If zoomRequiresCtrlKey=true, only zoom when ctrl/cmd is pressed.
|
|
266
|
+
// - wheelMode="pan": wheel pans by default. ctrl/cmd (pinch gesture on macOS) zooms instead.
|
|
267
|
+
if (wheelMode === "pan" && !ctrl) {
|
|
268
|
+
const k = invertPan ? -1 : 1;
|
|
269
|
+
this.setState({
|
|
270
|
+
panX: this.state.panX - e.deltaX * k,
|
|
271
|
+
panY: this.state.panY - e.deltaY * k,
|
|
272
|
+
});
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (wheelMode === "zoom" && zoomRequiresCtrlKey && !ctrl) {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const worldBefore = this.screenToWorld(pt.x, pt.y);
|
|
279
|
+
const dy = invertZoom ? -e.deltaY : e.deltaY;
|
|
280
|
+
const pinchBoost = isPinchGesture && e.deltaMode === WheelEvent.DOM_DELTA_PIXEL
|
|
281
|
+
? this.options.pinchZoomSpeed
|
|
282
|
+
: 1;
|
|
283
|
+
const zoomFactor = Math.exp(-dy * 0.001 * this.options.zoomSpeed * pinchBoost);
|
|
284
|
+
const nextZoom = clamp(this.state.zoom * zoomFactor, this.options.minZoom, this.options.maxZoom);
|
|
285
|
+
// Keep world point under cursor stable:
|
|
286
|
+
// screen = world * zoom + pan => pan = screen - world * zoom
|
|
287
|
+
const nextPanX = pt.x - worldBefore.x * nextZoom;
|
|
288
|
+
const nextPanY = pt.y - worldBefore.y * nextZoom;
|
|
289
|
+
this.setState({ zoom: nextZoom, panX: nextPanX, panY: nextPanY });
|
|
290
|
+
};
|
|
291
|
+
this.svg.addEventListener("wheel", this.svgWheelHandler, { passive: false });
|
|
292
|
+
this.svgPointerDownHandler = (e) => {
|
|
293
|
+
if (e.button !== 0)
|
|
294
|
+
return;
|
|
295
|
+
if (this.dragPointerId !== null)
|
|
296
|
+
return;
|
|
297
|
+
if (this.options.panRequiresSpaceKey && !this.isSpaceDown)
|
|
298
|
+
return;
|
|
299
|
+
this.dragPointerId = e.pointerId;
|
|
300
|
+
this.isPanning = false;
|
|
301
|
+
const pt = this.svgPoint(e.clientX, e.clientY);
|
|
302
|
+
this.panStart = { panX: this.state.panX, panY: this.state.panY, x: pt.x, y: pt.y };
|
|
303
|
+
};
|
|
304
|
+
this.svg.addEventListener("pointerdown", this.svgPointerDownHandler);
|
|
305
|
+
this.svgPointerMoveHandler = (e) => {
|
|
306
|
+
if (this.dragPointerId === null)
|
|
307
|
+
return;
|
|
308
|
+
if (e.pointerId !== this.dragPointerId)
|
|
309
|
+
return;
|
|
310
|
+
if (!this.panStart)
|
|
311
|
+
return;
|
|
312
|
+
const pt = this.svgPoint(e.clientX, e.clientY);
|
|
313
|
+
const dx = pt.x - this.panStart.x;
|
|
314
|
+
const dy = pt.y - this.panStart.y;
|
|
315
|
+
if (!this.isPanning) {
|
|
316
|
+
if (Math.hypot(dx, dy) < PanZoomCanvas.DRAG_THRESHOLD_PX) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
this.isPanning = true;
|
|
320
|
+
this.svg.setPointerCapture(e.pointerId);
|
|
321
|
+
}
|
|
322
|
+
this.setState({ panX: this.panStart.panX + dx, panY: this.panStart.panY + dy });
|
|
323
|
+
};
|
|
324
|
+
this.svg.addEventListener("pointermove", this.svgPointerMoveHandler);
|
|
325
|
+
const end = (e) => {
|
|
326
|
+
if (this.dragPointerId === null)
|
|
327
|
+
return;
|
|
328
|
+
if (e.pointerId !== this.dragPointerId)
|
|
329
|
+
return;
|
|
330
|
+
if (this.isPanning) {
|
|
331
|
+
try {
|
|
332
|
+
this.svg.releasePointerCapture(e.pointerId);
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
// Ignore if already released
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
this.dragPointerId = null;
|
|
339
|
+
this.panStart = null;
|
|
340
|
+
this.isPanning = false;
|
|
341
|
+
};
|
|
342
|
+
this.svgPointerUpHandler = end;
|
|
343
|
+
this.svgPointerCancelHandler = end;
|
|
344
|
+
this.svgPointerLeaveHandler = () => {
|
|
345
|
+
if (this.isPanning && this.dragPointerId !== null) {
|
|
346
|
+
try {
|
|
347
|
+
this.svg.releasePointerCapture(this.dragPointerId);
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
// Ignore
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
this.dragPointerId = null;
|
|
354
|
+
this.panStart = null;
|
|
355
|
+
this.isPanning = false;
|
|
356
|
+
};
|
|
357
|
+
this.svg.addEventListener("pointerup", this.svgPointerUpHandler);
|
|
358
|
+
this.svg.addEventListener("pointercancel", this.svgPointerCancelHandler);
|
|
359
|
+
this.svg.addEventListener("pointerleave", this.svgPointerLeaveHandler);
|
|
360
|
+
}
|
|
361
|
+
render() {
|
|
362
|
+
const { zoom, panX, panY } = this.state;
|
|
363
|
+
this.world.setAttribute("transform", `matrix(${zoom} 0 0 ${zoom} ${panX} ${panY})`);
|
|
364
|
+
const dynamicAttrs = this.options.worldGroup?.dynamicAttributes;
|
|
365
|
+
if (dynamicAttrs) {
|
|
366
|
+
for (const [key, fn] of Object.entries(dynamicAttrs)) {
|
|
367
|
+
this.world.setAttribute(key, fn(zoom));
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
svgPoint(clientX, clientY) {
|
|
372
|
+
const r = this.svg.getBoundingClientRect();
|
|
373
|
+
return { x: clientX - r.left, y: clientY - r.top };
|
|
374
|
+
}
|
|
375
|
+
screenToWorld(x, y) {
|
|
376
|
+
const { zoom, panX, panY } = this.state;
|
|
377
|
+
return { x: (x - panX) / zoom, y: (y - panY) / zoom };
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
function clamp(v, min, max) {
|
|
381
|
+
return Math.max(min, Math.min(max, v));
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Cache fragment -> metrics so we only measure when the fragment changes.
|
|
385
|
+
const metricsCache = new Map();
|
|
386
|
+
let measureSvg = null;
|
|
387
|
+
let measureG = null;
|
|
388
|
+
function ensureMeasureDom() {
|
|
389
|
+
if (measureSvg && measureG)
|
|
390
|
+
return { svg: measureSvg, g: measureG };
|
|
391
|
+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
392
|
+
svg.setAttribute("width", "0");
|
|
393
|
+
svg.setAttribute("height", "0");
|
|
394
|
+
svg.style.position = "absolute";
|
|
395
|
+
svg.style.left = "-10000px";
|
|
396
|
+
svg.style.top = "-10000px";
|
|
397
|
+
svg.style.visibility = "hidden";
|
|
398
|
+
svg.style.pointerEvents = "none";
|
|
399
|
+
const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
|
400
|
+
svg.appendChild(g);
|
|
401
|
+
measureSvg = svg;
|
|
402
|
+
measureG = g;
|
|
403
|
+
return { svg, g };
|
|
404
|
+
}
|
|
405
|
+
function attachMeasureDom(svg) {
|
|
406
|
+
// getBBox() requires the element to be in the document.
|
|
407
|
+
if (!svg.isConnected)
|
|
408
|
+
document.body.appendChild(svg);
|
|
409
|
+
}
|
|
410
|
+
function detachMeasureDom(svg) {
|
|
411
|
+
// Avoid leaving hidden measurement DOM nodes around permanently.
|
|
412
|
+
if (svg.isConnected)
|
|
413
|
+
svg.remove();
|
|
414
|
+
}
|
|
415
|
+
function stripXmlnsDeep(el) {
|
|
416
|
+
// Remove redundant XML namespace declarations from fragments inserted into an existing <svg>.
|
|
417
|
+
if (el.hasAttribute("xmlns"))
|
|
418
|
+
el.removeAttribute("xmlns");
|
|
419
|
+
for (const attr of Array.from(el.attributes)) {
|
|
420
|
+
if (attr.name.startsWith("xmlns:"))
|
|
421
|
+
el.removeAttribute(attr.name);
|
|
422
|
+
}
|
|
423
|
+
for (const child of Array.from(el.children))
|
|
424
|
+
stripXmlnsDeep(child);
|
|
425
|
+
}
|
|
426
|
+
function sanitizeFragment(markup) {
|
|
427
|
+
const s = markup.trim();
|
|
428
|
+
if (!s)
|
|
429
|
+
return "";
|
|
430
|
+
const wrapped = `<svg xmlns="http://www.w3.org/2000/svg">${s}</svg>`;
|
|
431
|
+
try {
|
|
432
|
+
const doc = new DOMParser().parseFromString(wrapped, "image/svg+xml");
|
|
433
|
+
const svg = doc.documentElement;
|
|
434
|
+
if (!svg || svg.nodeName.toLowerCase() !== "svg")
|
|
435
|
+
return "";
|
|
436
|
+
doc.querySelectorAll("script, foreignObject").forEach((n) => n.remove());
|
|
437
|
+
doc.querySelectorAll("*").forEach((el) => {
|
|
438
|
+
for (const attr of Array.from(el.attributes)) {
|
|
439
|
+
if (attr.name.toLowerCase().startsWith("on"))
|
|
440
|
+
el.removeAttribute(attr.name);
|
|
441
|
+
// We insert fragments into an existing <svg>, so explicit xmlns declarations are redundant.
|
|
442
|
+
if (attr.name === "xmlns" || attr.name.startsWith("xmlns:"))
|
|
443
|
+
el.removeAttribute(attr.name);
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
// Prefer innerHTML to avoid browsers sprinkling xmlns="..." on every serialized element.
|
|
447
|
+
const inner = svg.innerHTML;
|
|
448
|
+
if (typeof inner === "string")
|
|
449
|
+
return inner.trim();
|
|
450
|
+
return new XMLSerializer()
|
|
451
|
+
.serializeToString(svg)
|
|
452
|
+
.replace(/^<svg[^>]*>|<\/svg>$/g, "")
|
|
453
|
+
.trim();
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
return "";
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
function parseFragmentElements(markup) {
|
|
460
|
+
const s = markup.trim();
|
|
461
|
+
if (!s)
|
|
462
|
+
return [];
|
|
463
|
+
const wrapped = `<svg xmlns="http://www.w3.org/2000/svg">${s}</svg>`;
|
|
464
|
+
try {
|
|
465
|
+
const doc = new DOMParser().parseFromString(wrapped, "image/svg+xml");
|
|
466
|
+
const svg = doc.documentElement;
|
|
467
|
+
if (!svg || svg.nodeName.toLowerCase() !== "svg")
|
|
468
|
+
return [];
|
|
469
|
+
// Sanitize in DOM (no string re-serialization for canvas nodes).
|
|
470
|
+
doc.querySelectorAll("script, foreignObject").forEach((n) => n.remove());
|
|
471
|
+
doc.querySelectorAll("*").forEach((el) => {
|
|
472
|
+
for (const attr of Array.from(el.attributes)) {
|
|
473
|
+
if (attr.name.toLowerCase().startsWith("on"))
|
|
474
|
+
el.removeAttribute(attr.name);
|
|
475
|
+
if (attr.name === "xmlns" || attr.name.startsWith("xmlns:"))
|
|
476
|
+
el.removeAttribute(attr.name);
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
// Import into the live document so we don't carry XML serializer artifacts (like xmlns on every node).
|
|
480
|
+
const imported = Array.from(svg.children).map((el) => document.importNode(el, true));
|
|
481
|
+
for (const el of imported)
|
|
482
|
+
stripXmlnsDeep(el);
|
|
483
|
+
return imported;
|
|
484
|
+
}
|
|
485
|
+
catch {
|
|
486
|
+
return [];
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
function measureFragmentMetrics(markup) {
|
|
490
|
+
const key = sanitizeFragment(markup);
|
|
491
|
+
if (!key)
|
|
492
|
+
return null;
|
|
493
|
+
const cached = metricsCache.get(key);
|
|
494
|
+
if (cached)
|
|
495
|
+
return cached;
|
|
496
|
+
const { svg, g } = ensureMeasureDom();
|
|
497
|
+
attachMeasureDom(svg);
|
|
498
|
+
g.replaceChildren();
|
|
499
|
+
const els = parseFragmentElements(key);
|
|
500
|
+
for (const el of els)
|
|
501
|
+
g.appendChild(el.cloneNode(true));
|
|
502
|
+
try {
|
|
503
|
+
const b = g.getBBox();
|
|
504
|
+
// getBBox() does NOT include stroke, so compute a padding based on computed stroke-width.
|
|
505
|
+
let maxStrokeWidth = 0;
|
|
506
|
+
g.querySelectorAll("*").forEach((node) => {
|
|
507
|
+
try {
|
|
508
|
+
const cs = getComputedStyle(node);
|
|
509
|
+
const stroke = cs.stroke;
|
|
510
|
+
if (!stroke || stroke === "none" || stroke === "transparent")
|
|
511
|
+
return;
|
|
512
|
+
const sw = Number.parseFloat(cs.strokeWidth ?? "0");
|
|
513
|
+
if (Number.isFinite(sw) && sw > maxStrokeWidth)
|
|
514
|
+
maxStrokeWidth = sw;
|
|
515
|
+
}
|
|
516
|
+
catch {
|
|
517
|
+
// ignore
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
const pad = Math.max(0, maxStrokeWidth / 2);
|
|
521
|
+
const bbox = { x: b.x, y: b.y, width: b.width, height: b.height };
|
|
522
|
+
const metrics = { bbox, pad };
|
|
523
|
+
metricsCache.set(key, metrics);
|
|
524
|
+
return metrics;
|
|
525
|
+
}
|
|
526
|
+
catch {
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
finally {
|
|
530
|
+
// Detach measurement DOM even when getBBox throws.
|
|
531
|
+
detachMeasureDom(svg);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* SvgCore entrypoint.
|
|
537
|
+
*
|
|
538
|
+
* Usage:
|
|
539
|
+
* const v = new SvgCore(svgElement)
|
|
540
|
+
*/
|
|
541
|
+
class SvgCore {
|
|
542
|
+
canvas;
|
|
543
|
+
nodesLayer;
|
|
544
|
+
nodes = [];
|
|
545
|
+
nodeIdToIndex = new Map();
|
|
546
|
+
nodeBounds = null;
|
|
547
|
+
cullingEnabled = true;
|
|
548
|
+
cullingOverscanPx = 30;
|
|
549
|
+
resizeObserver = null;
|
|
550
|
+
unsubPanZoom = null;
|
|
551
|
+
unsubSvgEvents = null;
|
|
552
|
+
svgClickTimer = null;
|
|
553
|
+
suppressNextClick = false;
|
|
554
|
+
dragWatch = null;
|
|
555
|
+
cullingListeners = new Set();
|
|
556
|
+
lastCullingStats = { visible: 0, hidden: 0, total: 0 };
|
|
557
|
+
cullingNotifyScheduled = false;
|
|
558
|
+
/** SVG root passed to the constructor. */
|
|
559
|
+
get svg() {
|
|
560
|
+
return this.canvas.svg;
|
|
561
|
+
}
|
|
562
|
+
/** World layer (<g>) that you draw into. */
|
|
563
|
+
get world() {
|
|
564
|
+
return this.canvas.world;
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Create a custom <g> layer inside the world.
|
|
568
|
+
* Useful when you want to add your own SVG content.
|
|
569
|
+
*/
|
|
570
|
+
createWorldLayer(name, opts) {
|
|
571
|
+
const layer = svgEl("g");
|
|
572
|
+
if (name)
|
|
573
|
+
layer.dataset.layer = name;
|
|
574
|
+
if (opts?.pointerEvents)
|
|
575
|
+
layer.style.pointerEvents = opts.pointerEvents;
|
|
576
|
+
const position = opts?.position ?? "below-nodes";
|
|
577
|
+
if (position === "below-nodes") {
|
|
578
|
+
this.world.insertBefore(layer, this.nodesLayer);
|
|
579
|
+
}
|
|
580
|
+
else {
|
|
581
|
+
this.world.appendChild(layer);
|
|
582
|
+
}
|
|
583
|
+
return layer;
|
|
584
|
+
}
|
|
585
|
+
/** Current pan/zoom state. */
|
|
586
|
+
get state() {
|
|
587
|
+
return this.canvas.state;
|
|
588
|
+
}
|
|
589
|
+
/** Current pan/zoom options (includes minZoom/maxZoom). */
|
|
590
|
+
get panZoomOptions() {
|
|
591
|
+
return this.canvas.options;
|
|
592
|
+
}
|
|
593
|
+
constructor(svgOrCanvas, opts) {
|
|
594
|
+
if (svgOrCanvas instanceof PanZoomCanvas) {
|
|
595
|
+
this.canvas = svgOrCanvas;
|
|
596
|
+
if (opts?.panZoom)
|
|
597
|
+
this.canvas.setOptions(opts.panZoom);
|
|
598
|
+
}
|
|
599
|
+
else {
|
|
600
|
+
this.canvas = new PanZoomCanvas(svgOrCanvas, opts?.panZoom);
|
|
601
|
+
}
|
|
602
|
+
this.nodesLayer = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
|
603
|
+
this.nodesLayer.dataset.layer = "nodes";
|
|
604
|
+
this.world.appendChild(this.nodesLayer);
|
|
605
|
+
this.world.style.pointerEvents = "none";
|
|
606
|
+
const c = opts?.culling;
|
|
607
|
+
if (typeof c === "boolean") {
|
|
608
|
+
this.cullingEnabled = c;
|
|
609
|
+
}
|
|
610
|
+
else if (c) {
|
|
611
|
+
if (typeof c.enabled === "boolean")
|
|
612
|
+
this.cullingEnabled = c.enabled;
|
|
613
|
+
if (typeof c.overscanPx === "number")
|
|
614
|
+
this.cullingOverscanPx = Math.max(0, c.overscanPx);
|
|
615
|
+
}
|
|
616
|
+
// Core-owned: keep culling in sync with pan/zoom changes.
|
|
617
|
+
this.unsubPanZoom = this.canvas.subscribe(() => this.applyCulling());
|
|
618
|
+
// Core-owned: keep culling correct when viewport size changes.
|
|
619
|
+
this.resizeObserver = new ResizeObserver(() => this.applyCulling());
|
|
620
|
+
this.resizeObserver.observe(this.svg);
|
|
621
|
+
// Core-owned: basic SVG interaction events (for now just log).
|
|
622
|
+
// Notes:
|
|
623
|
+
// - Drag-to-pan emits a "click" after pointerup; we suppress that when movement exceeds a threshold.
|
|
624
|
+
// - We do NOT use the native "dblclick" event. Instead:
|
|
625
|
+
// - 1st click starts a short timer
|
|
626
|
+
// - 2nd click within that window becomes "doubleclick" and cancels the pending single-click
|
|
627
|
+
const CLICK_DELAY_MS = 300;
|
|
628
|
+
const DRAG_THRESHOLD_PX = 5;
|
|
629
|
+
const clearClickTimer = () => {
|
|
630
|
+
if (this.svgClickTimer !== null) {
|
|
631
|
+
window.clearTimeout(this.svgClickTimer);
|
|
632
|
+
this.svgClickTimer = null;
|
|
633
|
+
}
|
|
634
|
+
};
|
|
635
|
+
const onPointerDown = (e) => {
|
|
636
|
+
// Only track left-button drags for click suppression.
|
|
637
|
+
if (e.button !== 0)
|
|
638
|
+
return;
|
|
639
|
+
this.dragWatch = {
|
|
640
|
+
pointerId: e.pointerId,
|
|
641
|
+
startClientX: e.clientX,
|
|
642
|
+
startClientY: e.clientY,
|
|
643
|
+
moved: false,
|
|
644
|
+
};
|
|
645
|
+
};
|
|
646
|
+
const onPointerMove = (e) => {
|
|
647
|
+
const w = this.dragWatch;
|
|
648
|
+
if (!w)
|
|
649
|
+
return;
|
|
650
|
+
if (e.pointerId !== w.pointerId)
|
|
651
|
+
return;
|
|
652
|
+
// Only while left button is held.
|
|
653
|
+
if ((e.buttons & 1) !== 1)
|
|
654
|
+
return;
|
|
655
|
+
const dx = e.clientX - w.startClientX;
|
|
656
|
+
const dy = e.clientY - w.startClientY;
|
|
657
|
+
if (!w.moved && Math.hypot(dx, dy) >= DRAG_THRESHOLD_PX)
|
|
658
|
+
w.moved = true;
|
|
659
|
+
};
|
|
660
|
+
const onPointerEnd = (e) => {
|
|
661
|
+
const w = this.dragWatch;
|
|
662
|
+
if (!w)
|
|
663
|
+
return;
|
|
664
|
+
if (e.pointerId !== w.pointerId)
|
|
665
|
+
return;
|
|
666
|
+
this.dragWatch = null;
|
|
667
|
+
if (w.moved) {
|
|
668
|
+
this.suppressNextClick = true;
|
|
669
|
+
clearClickTimer();
|
|
670
|
+
}
|
|
671
|
+
};
|
|
672
|
+
const onClick = (e) => {
|
|
673
|
+
if (this.suppressNextClick) {
|
|
674
|
+
this.suppressNextClick = false;
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
if (this.svgClickTimer !== null) {
|
|
678
|
+
// Second click within the window => treat as "doubleclick".
|
|
679
|
+
clearClickTimer();
|
|
680
|
+
const hit = this.hitTestVisibleNodeAtClient(e.clientX, e.clientY);
|
|
681
|
+
if (hit?.onDoubleClick) {
|
|
682
|
+
hit.onDoubleClick(hit);
|
|
683
|
+
}
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
// First click => delay, so a potential second click can convert it to "doubleclick".
|
|
687
|
+
this.svgClickTimer = window.setTimeout(() => {
|
|
688
|
+
this.svgClickTimer = null;
|
|
689
|
+
const hit = this.hitTestVisibleNodeAtClient(e.clientX, e.clientY);
|
|
690
|
+
if (hit?.onClick) {
|
|
691
|
+
hit.onClick(hit);
|
|
692
|
+
}
|
|
693
|
+
}, CLICK_DELAY_MS);
|
|
694
|
+
};
|
|
695
|
+
const onRightClick = (e) => {
|
|
696
|
+
e.preventDefault(); // treat this as "rightclick" without opening the context menu
|
|
697
|
+
clearClickTimer();
|
|
698
|
+
const hit = this.hitTestVisibleNodeAtClient(e.clientX, e.clientY);
|
|
699
|
+
if (hit?.onRightClick) {
|
|
700
|
+
hit.onRightClick(hit);
|
|
701
|
+
}
|
|
702
|
+
};
|
|
703
|
+
this.svg.addEventListener("click", onClick);
|
|
704
|
+
this.svg.addEventListener("contextmenu", onRightClick);
|
|
705
|
+
this.svg.addEventListener("pointerdown", onPointerDown);
|
|
706
|
+
this.svg.addEventListener("pointermove", onPointerMove);
|
|
707
|
+
this.svg.addEventListener("pointerup", onPointerEnd);
|
|
708
|
+
this.svg.addEventListener("pointercancel", onPointerEnd);
|
|
709
|
+
this.unsubSvgEvents = () => {
|
|
710
|
+
this.svg.removeEventListener("click", onClick);
|
|
711
|
+
this.svg.removeEventListener("contextmenu", onRightClick);
|
|
712
|
+
this.svg.removeEventListener("pointerdown", onPointerDown);
|
|
713
|
+
this.svg.removeEventListener("pointermove", onPointerMove);
|
|
714
|
+
this.svg.removeEventListener("pointerup", onPointerEnd);
|
|
715
|
+
this.svg.removeEventListener("pointercancel", onPointerEnd);
|
|
716
|
+
clearClickTimer();
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Set zoom while keeping a chosen screen-space anchor stable.
|
|
721
|
+
* By default anchors at the viewport center.
|
|
722
|
+
*/
|
|
723
|
+
setZoom(nextZoom, anchor) {
|
|
724
|
+
const minZ = this.canvas.options.minZoom;
|
|
725
|
+
const maxZ = this.canvas.options.maxZoom;
|
|
726
|
+
const z = Math.min(maxZ, Math.max(minZ, nextZoom));
|
|
727
|
+
const r = this.svg.getBoundingClientRect();
|
|
728
|
+
const ax = anchor?.x ?? Math.max(1, r.width) / 2;
|
|
729
|
+
const ay = anchor?.y ?? Math.max(1, r.height) / 2;
|
|
730
|
+
const cur = this.state;
|
|
731
|
+
// screen = world * zoom + pan => world = (screen - pan) / zoom
|
|
732
|
+
const worldX = (ax - cur.panX) / Math.max(1e-9, cur.zoom);
|
|
733
|
+
const worldY = (ay - cur.panY) / Math.max(1e-9, cur.zoom);
|
|
734
|
+
// keep world point under anchor stable:
|
|
735
|
+
// pan = screen - world * zoom
|
|
736
|
+
const nextPanX = ax - worldX * z;
|
|
737
|
+
const nextPanY = ay - worldY * z;
|
|
738
|
+
this.setState({ zoom: z, panX: nextPanX, panY: nextPanY });
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Convert a pointer position (client px) into canvas/world coordinates,
|
|
742
|
+
* using the current pan/zoom state.
|
|
743
|
+
*/
|
|
744
|
+
clientToCanvas(clientX, clientY) {
|
|
745
|
+
const r = this.svg.getBoundingClientRect();
|
|
746
|
+
const sx = clientX - r.left;
|
|
747
|
+
const sy = clientY - r.top;
|
|
748
|
+
const { panX, panY, zoom } = this.state;
|
|
749
|
+
const z = Math.max(1e-9, zoom);
|
|
750
|
+
return { x: (sx - panX) / z, y: (sy - panY) / z };
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Fast hit-test using the culling output: only checks nodes currently attached to `nodesLayer`
|
|
754
|
+
* (i.e. the visible subset after culling).
|
|
755
|
+
*
|
|
756
|
+
* Returns the topmost hit node (based on render order), or null.
|
|
757
|
+
*/
|
|
758
|
+
hitTestVisibleNodeAtClient(clientX, clientY) {
|
|
759
|
+
if (!this.nodeBounds || this.nodes.length === 0)
|
|
760
|
+
return null;
|
|
761
|
+
const p = this.clientToCanvas(clientX, clientY);
|
|
762
|
+
const kids = this.nodesLayer.children;
|
|
763
|
+
// Scan from topmost to bottommost: last child is visually on top.
|
|
764
|
+
for (let k = kids.length - 1; k >= 0; k--) {
|
|
765
|
+
const el = kids.item(k);
|
|
766
|
+
if (!el)
|
|
767
|
+
continue;
|
|
768
|
+
const id = el.dataset.nodeId;
|
|
769
|
+
if (!id)
|
|
770
|
+
continue;
|
|
771
|
+
const idx = this.nodeIdToIndex.get(id);
|
|
772
|
+
if (idx === undefined)
|
|
773
|
+
continue;
|
|
774
|
+
const b = this.nodeBounds[idx];
|
|
775
|
+
if (!b)
|
|
776
|
+
continue;
|
|
777
|
+
if (p.x >= b.x0 && p.x <= b.x1 && p.y >= b.y0 && p.y <= b.y1)
|
|
778
|
+
return this.nodes[idx];
|
|
779
|
+
}
|
|
780
|
+
return null;
|
|
781
|
+
}
|
|
782
|
+
zoomBy(factor, anchor) {
|
|
783
|
+
const f = Number.isFinite(factor) ? factor : 1;
|
|
784
|
+
if (f <= 0)
|
|
785
|
+
return;
|
|
786
|
+
this.setZoom(this.state.zoom * f, anchor);
|
|
787
|
+
}
|
|
788
|
+
setState(next) {
|
|
789
|
+
this.canvas.setState(next);
|
|
790
|
+
}
|
|
791
|
+
resetView() {
|
|
792
|
+
this.canvas.reset();
|
|
793
|
+
}
|
|
794
|
+
configurePanZoom(opts) {
|
|
795
|
+
this.canvas.setOptions(opts);
|
|
796
|
+
}
|
|
797
|
+
setNodes(nodes) {
|
|
798
|
+
// Warn if node IDs are not unique
|
|
799
|
+
const seenIds = new Set();
|
|
800
|
+
const duplicateIds = new Set();
|
|
801
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
802
|
+
const id = nodes[i].id;
|
|
803
|
+
if (seenIds.has(id)) {
|
|
804
|
+
duplicateIds.add(id);
|
|
805
|
+
}
|
|
806
|
+
else {
|
|
807
|
+
seenIds.add(id);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
if (duplicateIds.size > 0) {
|
|
811
|
+
console.warn(`Duplicate node ids found: ${Array.from(duplicateIds)
|
|
812
|
+
.map((id) => `"${id}"`)
|
|
813
|
+
.join(", ")}. Each node should have a unique id.`);
|
|
814
|
+
}
|
|
815
|
+
this.nodes = nodes;
|
|
816
|
+
this.nodeIdToIndex.clear();
|
|
817
|
+
// Build map of node id to index for fast lookup.
|
|
818
|
+
// Note: If there are duplicate IDs, the last occurrence will overwrite previous ones.
|
|
819
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
820
|
+
this.nodeIdToIndex.set(nodes[i].id, i);
|
|
821
|
+
}
|
|
822
|
+
this.redraw();
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* Redraw the currently assigned nodes.
|
|
826
|
+
*
|
|
827
|
+
* Call this if you mutate node properties in-place (e.g. `node.x = ...` or `node.fragment = ...`).
|
|
828
|
+
*
|
|
829
|
+
* @param ids Optional array of node ids to redraw. If provided, only these nodes will be redrawn.
|
|
830
|
+
* If not provided, all nodes will be redrawn.
|
|
831
|
+
*/
|
|
832
|
+
redraw(ids) {
|
|
833
|
+
if (Array.isArray(ids) && ids.length > 0) {
|
|
834
|
+
this.renderNodes(ids);
|
|
835
|
+
// After selective render, we still need to apply culling to all nodes
|
|
836
|
+
this.applyCulling();
|
|
837
|
+
}
|
|
838
|
+
else {
|
|
839
|
+
this.renderNodes();
|
|
840
|
+
this.applyCulling();
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
setCullingEnabled(enabled) {
|
|
844
|
+
this.cullingEnabled = enabled;
|
|
845
|
+
this.applyCulling();
|
|
846
|
+
}
|
|
847
|
+
setCullingOverscanPx(px) {
|
|
848
|
+
this.cullingOverscanPx = Math.max(0, px);
|
|
849
|
+
this.applyCulling();
|
|
850
|
+
}
|
|
851
|
+
/** Subscribe to culling stats updates (event-driven). */
|
|
852
|
+
onCullingStatsChange(fn) {
|
|
853
|
+
this.cullingListeners.add(fn);
|
|
854
|
+
fn(this.lastCullingStats);
|
|
855
|
+
return () => this.cullingListeners.delete(fn);
|
|
856
|
+
}
|
|
857
|
+
/** Subscribe to pan/zoom updates (event-driven). */
|
|
858
|
+
onPanZoomChange(fn) {
|
|
859
|
+
return this.canvas.subscribe(fn);
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* Remove nodes from the scene.
|
|
863
|
+
*
|
|
864
|
+
* @param ids Optional array of node ids to remove. If not provided, removes all nodes.
|
|
865
|
+
*/
|
|
866
|
+
remove(ids) {
|
|
867
|
+
if (!ids || ids.length === 0) {
|
|
868
|
+
// Remove all nodes
|
|
869
|
+
this.nodes = [];
|
|
870
|
+
this.nodeIdToIndex.clear();
|
|
871
|
+
this.nodesLayer.replaceChildren();
|
|
872
|
+
this.nodeBounds = null;
|
|
873
|
+
this.setCullingStats({ visible: 0, hidden: 0, total: 0 });
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
// Remove specific nodes by ids
|
|
877
|
+
const indicesToRemove = new Set();
|
|
878
|
+
for (const id of ids) {
|
|
879
|
+
const idx = this.nodeIdToIndex.get(id);
|
|
880
|
+
if (idx !== undefined) {
|
|
881
|
+
indicesToRemove.add(idx);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
if (indicesToRemove.size === 0)
|
|
885
|
+
return;
|
|
886
|
+
// Remove nodes in reverse order to maintain indices
|
|
887
|
+
const sortedIndices = Array.from(indicesToRemove).sort((a, b) => b - a);
|
|
888
|
+
for (const idx of sortedIndices) {
|
|
889
|
+
const node = this.nodes[idx];
|
|
890
|
+
if (node) {
|
|
891
|
+
// Remove from DOM
|
|
892
|
+
if (node.el.parentElement) {
|
|
893
|
+
node.el.remove();
|
|
894
|
+
}
|
|
895
|
+
// Remove from map
|
|
896
|
+
this.nodeIdToIndex.delete(node.id);
|
|
897
|
+
}
|
|
898
|
+
this.nodes.splice(idx, 1);
|
|
899
|
+
}
|
|
900
|
+
// Rebuild index map
|
|
901
|
+
this.nodeIdToIndex.clear();
|
|
902
|
+
for (let i = 0; i < this.nodes.length; i++) {
|
|
903
|
+
this.nodeIdToIndex.set(this.nodes[i].id, i);
|
|
904
|
+
}
|
|
905
|
+
// Rebuild bounds array
|
|
906
|
+
if (this.nodeBounds) {
|
|
907
|
+
const newBounds = [];
|
|
908
|
+
for (let i = 0; i < this.nodes.length; i++) {
|
|
909
|
+
const node = this.nodes[i];
|
|
910
|
+
const metrics = measureFragmentMetrics(node.fragment);
|
|
911
|
+
const bbox = metrics?.bbox ?? { width: 240, height: 160 };
|
|
912
|
+
const pad = metrics?.pad ?? 0;
|
|
913
|
+
const w = node.width ?? Math.max(1, bbox.width + pad * 2);
|
|
914
|
+
const h = node.height ?? Math.max(1, bbox.height + pad * 2);
|
|
915
|
+
newBounds.push({ x0: node.x, y0: node.y, x1: node.x + w, y1: node.y + h });
|
|
916
|
+
}
|
|
917
|
+
this.nodeBounds = newBounds;
|
|
918
|
+
}
|
|
919
|
+
this.applyCulling();
|
|
920
|
+
}
|
|
921
|
+
destroy() {
|
|
922
|
+
this.resizeObserver?.disconnect();
|
|
923
|
+
this.resizeObserver = null;
|
|
924
|
+
this.unsubPanZoom?.();
|
|
925
|
+
this.unsubPanZoom = null;
|
|
926
|
+
this.unsubSvgEvents?.();
|
|
927
|
+
this.unsubSvgEvents = null;
|
|
928
|
+
this.cullingListeners.clear();
|
|
929
|
+
this.canvas.destroy();
|
|
930
|
+
}
|
|
931
|
+
renderNodes(ids) {
|
|
932
|
+
// If ids are provided, only update those specific nodes
|
|
933
|
+
if (ids && ids.length > 0) {
|
|
934
|
+
for (const id of ids) {
|
|
935
|
+
const idx = this.nodeIdToIndex.get(id);
|
|
936
|
+
if (idx === undefined)
|
|
937
|
+
continue;
|
|
938
|
+
const node = this.nodes[idx];
|
|
939
|
+
if (!node)
|
|
940
|
+
continue;
|
|
941
|
+
const g = node.el;
|
|
942
|
+
g.replaceChildren();
|
|
943
|
+
g.setAttribute("transform", `translate(${node.x} ${node.y})`);
|
|
944
|
+
const cleaned = sanitizeFragment(node.fragment);
|
|
945
|
+
if (cleaned) {
|
|
946
|
+
const children = parseFragmentElements(cleaned);
|
|
947
|
+
const metrics = measureFragmentMetrics(cleaned);
|
|
948
|
+
const bbox = metrics?.bbox ?? { x: 0, y: 0, width: 240, height: 160 };
|
|
949
|
+
const pad = metrics?.pad ?? 0;
|
|
950
|
+
const w = Math.max(1, bbox.width + pad * 2);
|
|
951
|
+
const h = Math.max(1, bbox.height + pad * 2);
|
|
952
|
+
const offsetX = -bbox.x + pad;
|
|
953
|
+
const offsetY = -bbox.y + pad;
|
|
954
|
+
const inner = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
|
955
|
+
inner.setAttribute("transform", `translate(${offsetX} ${offsetY})`);
|
|
956
|
+
for (const child of children)
|
|
957
|
+
inner.appendChild(child.cloneNode(true));
|
|
958
|
+
g.appendChild(inner);
|
|
959
|
+
// Update bounds
|
|
960
|
+
if (this.nodeBounds) {
|
|
961
|
+
const nodeW = node.width ?? w;
|
|
962
|
+
const nodeH = node.height ?? h;
|
|
963
|
+
this.nodeBounds[idx] = {
|
|
964
|
+
x0: node.x,
|
|
965
|
+
y0: node.y,
|
|
966
|
+
x1: node.x + nodeW,
|
|
967
|
+
y1: node.y + nodeH,
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
// Ensure node is attached if it's not already
|
|
972
|
+
if (!g.parentElement) {
|
|
973
|
+
this.nodesLayer.appendChild(g);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
return; // Culling will be applied by redraw()
|
|
977
|
+
}
|
|
978
|
+
// Full render: clear and rebuild everything
|
|
979
|
+
this.nodesLayer.replaceChildren();
|
|
980
|
+
this.nodeBounds = null;
|
|
981
|
+
if (this.nodes.length === 0)
|
|
982
|
+
return;
|
|
983
|
+
// Cache fragment -> parsed children and metrics.
|
|
984
|
+
// This allows each node to carry its own fragment while still keeping render fast.
|
|
985
|
+
const fragmentCache = new Map();
|
|
986
|
+
for (const node of this.nodes) {
|
|
987
|
+
const cleaned = sanitizeFragment(node.fragment);
|
|
988
|
+
if (!cleaned)
|
|
989
|
+
continue;
|
|
990
|
+
if (fragmentCache.has(cleaned))
|
|
991
|
+
continue;
|
|
992
|
+
const children = parseFragmentElements(cleaned);
|
|
993
|
+
const metrics = measureFragmentMetrics(cleaned);
|
|
994
|
+
const bbox = metrics?.bbox ?? { x: 0, y: 0, width: 240, height: 160 };
|
|
995
|
+
const pad = metrics?.pad ?? 0;
|
|
996
|
+
const w = Math.max(1, bbox.width + pad * 2);
|
|
997
|
+
const h = Math.max(1, bbox.height + pad * 2);
|
|
998
|
+
// Normalize fragment so its bbox starts at (0,0) with padding applied.
|
|
999
|
+
const offsetX = -bbox.x + pad;
|
|
1000
|
+
const offsetY = -bbox.y + pad;
|
|
1001
|
+
fragmentCache.set(cleaned, { children, w, h, offsetX, offsetY });
|
|
1002
|
+
}
|
|
1003
|
+
const count = this.nodes.length;
|
|
1004
|
+
const frag = document.createDocumentFragment();
|
|
1005
|
+
const bounds = new Array(count);
|
|
1006
|
+
for (let i = 0; i < count; i++) {
|
|
1007
|
+
const node = this.nodes[i];
|
|
1008
|
+
const g = node.el;
|
|
1009
|
+
g.replaceChildren();
|
|
1010
|
+
g.setAttribute("transform", `translate(${node.x} ${node.y})`);
|
|
1011
|
+
const cleaned = sanitizeFragment(node.fragment);
|
|
1012
|
+
const cached = cleaned ? fragmentCache.get(cleaned) : null;
|
|
1013
|
+
if (cached) {
|
|
1014
|
+
// Insert the fragment content directly into the node group (no nested <svg>),
|
|
1015
|
+
// but normalize it to (0,0) using a wrapper group.
|
|
1016
|
+
const inner = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
|
1017
|
+
inner.setAttribute("transform", `translate(${cached.offsetX} ${cached.offsetY})`);
|
|
1018
|
+
for (const child of cached.children)
|
|
1019
|
+
inner.appendChild(child.cloneNode(true));
|
|
1020
|
+
g.appendChild(inner);
|
|
1021
|
+
}
|
|
1022
|
+
const w = node.width ?? cached?.w ?? 240;
|
|
1023
|
+
const h = node.height ?? cached?.h ?? 160;
|
|
1024
|
+
bounds[i] = { x0: node.x, y0: node.y, x1: node.x + w, y1: node.y + h };
|
|
1025
|
+
frag.appendChild(g);
|
|
1026
|
+
}
|
|
1027
|
+
this.nodesLayer.appendChild(frag);
|
|
1028
|
+
this.nodeBounds = bounds;
|
|
1029
|
+
}
|
|
1030
|
+
applyCulling() {
|
|
1031
|
+
if (!this.nodeBounds) {
|
|
1032
|
+
this.setCullingStats({ visible: 0, hidden: 0, total: this.nodes.length });
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
const count = this.nodes.length;
|
|
1036
|
+
// If disabled, ensure everything is visible/attached.
|
|
1037
|
+
if (!this.cullingEnabled) {
|
|
1038
|
+
this.nodesLayer.replaceChildren(...this.nodes.map((n) => n.el));
|
|
1039
|
+
for (const n of this.nodes)
|
|
1040
|
+
n.el.removeAttribute("display");
|
|
1041
|
+
this.setCullingStats({ visible: count, hidden: 0, total: count });
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
const vp = this.getWorldViewport(this.state, this.cullingOverscanPx);
|
|
1045
|
+
const visibleNodes = [];
|
|
1046
|
+
for (let i = 0; i < count; i++) {
|
|
1047
|
+
const rect = this.nodeBounds[i];
|
|
1048
|
+
if (rect && this.rectsIntersect(rect, vp)) {
|
|
1049
|
+
const el = this.nodes[i].el;
|
|
1050
|
+
el.removeAttribute("display");
|
|
1051
|
+
visibleNodes.push(el);
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
this.nodesLayer.replaceChildren(...visibleNodes);
|
|
1055
|
+
this.setCullingStats({
|
|
1056
|
+
visible: visibleNodes.length,
|
|
1057
|
+
hidden: count - visibleNodes.length,
|
|
1058
|
+
total: count,
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
setCullingStats(next) {
|
|
1062
|
+
const prev = this.lastCullingStats;
|
|
1063
|
+
if (prev.visible === next.visible && prev.hidden === next.hidden && prev.total === next.total)
|
|
1064
|
+
return;
|
|
1065
|
+
this.lastCullingStats = next;
|
|
1066
|
+
this.scheduleCullingNotify();
|
|
1067
|
+
}
|
|
1068
|
+
scheduleCullingNotify() {
|
|
1069
|
+
if (this.cullingNotifyScheduled)
|
|
1070
|
+
return;
|
|
1071
|
+
this.cullingNotifyScheduled = true;
|
|
1072
|
+
requestAnimationFrame(() => {
|
|
1073
|
+
this.cullingNotifyScheduled = false;
|
|
1074
|
+
for (const fn of this.cullingListeners)
|
|
1075
|
+
fn(this.lastCullingStats);
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
rectsIntersect(a, b) {
|
|
1079
|
+
return !(a.x1 < b.x0 || a.x0 > b.x1 || a.y1 < b.y0 || a.y0 > b.y1);
|
|
1080
|
+
}
|
|
1081
|
+
getWorldViewport(s, overscanPx) {
|
|
1082
|
+
const r = this.svg.getBoundingClientRect();
|
|
1083
|
+
const w = Math.max(1, r.width);
|
|
1084
|
+
const h = Math.max(1, r.height);
|
|
1085
|
+
const z = Math.max(1e-9, s.zoom);
|
|
1086
|
+
// screen = world * zoom + pan
|
|
1087
|
+
const o = Math.max(0, overscanPx) / z; // expand in world units
|
|
1088
|
+
const x0 = -s.panX / z - o;
|
|
1089
|
+
const y0 = -s.panY / z - o;
|
|
1090
|
+
const x1 = (w - s.panX) / z + o;
|
|
1091
|
+
const y1 = (h - s.panY) / z + o;
|
|
1092
|
+
return { x0, y0, x1, y1 };
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/**
|
|
1097
|
+
* A scene-graph "node" for the SVG core.
|
|
1098
|
+
*/
|
|
1099
|
+
class Node {
|
|
1100
|
+
id;
|
|
1101
|
+
fragment;
|
|
1102
|
+
x;
|
|
1103
|
+
y;
|
|
1104
|
+
width;
|
|
1105
|
+
height;
|
|
1106
|
+
onClick;
|
|
1107
|
+
onDoubleClick;
|
|
1108
|
+
onRightClick;
|
|
1109
|
+
/** Backing element (created lazily by the core). */
|
|
1110
|
+
_el = null;
|
|
1111
|
+
constructor(opts) {
|
|
1112
|
+
if (!opts || typeof opts.id !== "string" || opts.id === "") {
|
|
1113
|
+
throw new Error("Node requires a non-empty 'id' property");
|
|
1114
|
+
}
|
|
1115
|
+
this.id = opts.id;
|
|
1116
|
+
this.fragment = opts.fragment ?? "";
|
|
1117
|
+
this.x = Number.isFinite(opts?.x) ? opts?.x : 0;
|
|
1118
|
+
this.y = Number.isFinite(opts?.y) ? opts?.y : 0;
|
|
1119
|
+
const w = opts?.width;
|
|
1120
|
+
const h = opts?.height;
|
|
1121
|
+
this.width = typeof w === "number" && Number.isFinite(w) && w > 0 ? w : null;
|
|
1122
|
+
this.height = typeof h === "number" && Number.isFinite(h) && h > 0 ? h : null;
|
|
1123
|
+
this.onClick = opts?.onClick;
|
|
1124
|
+
this.onDoubleClick = opts?.onDoubleClick;
|
|
1125
|
+
this.onRightClick = opts?.onRightClick;
|
|
1126
|
+
}
|
|
1127
|
+
get el() {
|
|
1128
|
+
if (!this._el) {
|
|
1129
|
+
const el = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
|
1130
|
+
el.dataset.nodeId = this.id;
|
|
1131
|
+
this._el = el;
|
|
1132
|
+
}
|
|
1133
|
+
return this._el;
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
export { Node, PanZoomCanvas, SvgCore, measureFragmentMetrics };
|