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