@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.
@@ -0,0 +1,198 @@
1
+ const DEFAULT_OPTIONS = {
2
+ wheelMode: "pan",
3
+ zoomRequiresCtrlKey: false,
4
+ panRequiresSpaceKey: false,
5
+ minZoom: 0.2,
6
+ maxZoom: 8,
7
+ zoomSpeed: 1,
8
+ invertZoom: false,
9
+ invertPan: false,
10
+ clickDelayMs: 300,
11
+ dragThresholdPx: 5,
12
+ pointerButtons: { panButton: 0 },
13
+ };
14
+ function attachDomInput(host, engine, next) {
15
+ let opts = { ...DEFAULT_OPTIONS, ...(next ?? {}) };
16
+ let ro = null;
17
+ let isSpaceDown = false;
18
+ let panPointerId = null;
19
+ let panStart = null;
20
+ let clickTimer = null;
21
+ let suppressNextClick = false;
22
+ let dragWatch = null;
23
+ function setOptions(patch) {
24
+ opts = { ...opts, ...patch, pointerButtons: { ...opts.pointerButtons, ...patch.pointerButtons } };
25
+ }
26
+ function getOptions() {
27
+ return opts;
28
+ }
29
+ function getLocalPoint(clientX, clientY) {
30
+ const r = host.getBoundingClientRect();
31
+ return { x: clientX - r.left, y: clientY - r.top, width: r.width, height: r.height };
32
+ }
33
+ function setViewportFromRect() {
34
+ const r = host.getBoundingClientRect();
35
+ engine.setViewport({ width: Math.max(1, r.width), height: Math.max(1, r.height), dpr: globalThis.devicePixelRatio ?? 1 });
36
+ }
37
+ function clearClickTimer() {
38
+ if (clickTimer === null)
39
+ return;
40
+ globalThis.clearTimeout(clickTimer);
41
+ clickTimer = null;
42
+ }
43
+ function hitAtClient(clientX, clientY) {
44
+ const p = getLocalPoint(clientX, clientY);
45
+ return engine.hitTestScreenPoint(p.x, p.y);
46
+ }
47
+ const onKeyDown = (e) => {
48
+ if (e.code === "Space")
49
+ isSpaceDown = true;
50
+ };
51
+ const onKeyUp = (e) => {
52
+ if (e.code === "Space")
53
+ isSpaceDown = false;
54
+ };
55
+ const onWheel = (e) => {
56
+ opts.onWheel?.(e);
57
+ e.preventDefault();
58
+ setViewportFromRect();
59
+ const pt = getLocalPoint(e.clientX, e.clientY);
60
+ const ctrl = e.ctrlKey || e.metaKey;
61
+ if (opts.wheelMode === "pan" && !ctrl) {
62
+ const k = opts.invertPan ? -1 : 1;
63
+ engine.panBy(-e.deltaX * k, -e.deltaY * k);
64
+ return;
65
+ }
66
+ if (opts.wheelMode === "zoom" && opts.zoomRequiresCtrlKey && !ctrl)
67
+ return;
68
+ const dy = opts.invertZoom ? -e.deltaY : e.deltaY;
69
+ const factor = Math.exp(-dy * 0.001 * opts.zoomSpeed);
70
+ const cur = engine.getCamera().zoom;
71
+ const nextZoom = clamp(cur * factor, opts.minZoom, opts.maxZoom);
72
+ engine.zoomAt({ x: pt.x, y: pt.y }, nextZoom);
73
+ };
74
+ const onPointerDown = (e) => {
75
+ opts.onPointerDown?.(e);
76
+ if (e.button === (opts.pointerButtons?.panButton ?? 0)) {
77
+ if (panPointerId !== null)
78
+ return;
79
+ if (opts.panRequiresSpaceKey && !isSpaceDown)
80
+ return;
81
+ panPointerId = e.pointerId;
82
+ try {
83
+ host.setPointerCapture?.(e.pointerId);
84
+ }
85
+ catch { }
86
+ setViewportFromRect();
87
+ const pt = getLocalPoint(e.clientX, e.clientY);
88
+ const c = engine.getCamera();
89
+ panStart = { panX: c.panX, panY: c.panY, x: pt.x, y: pt.y };
90
+ }
91
+ if (e.button === 0) {
92
+ dragWatch = {
93
+ pointerId: e.pointerId,
94
+ startClientX: e.clientX,
95
+ startClientY: e.clientY,
96
+ moved: false,
97
+ };
98
+ }
99
+ };
100
+ const onPointerMove = (e) => {
101
+ opts.onPointerMove?.(e);
102
+ const w = dragWatch;
103
+ if (w && e.pointerId === w.pointerId && (e.buttons & 1) === 1) {
104
+ const dx = e.clientX - w.startClientX;
105
+ const dy = e.clientY - w.startClientY;
106
+ if (!w.moved && Math.hypot(dx, dy) >= opts.dragThresholdPx)
107
+ w.moved = true;
108
+ }
109
+ if (panPointerId === null)
110
+ return;
111
+ if (e.pointerId !== panPointerId)
112
+ return;
113
+ if (!panStart)
114
+ return;
115
+ const pt = getLocalPoint(e.clientX, e.clientY);
116
+ const dx = pt.x - panStart.x;
117
+ const dy = pt.y - panStart.y;
118
+ engine.setCamera({ panX: panStart.panX + dx, panY: panStart.panY + dy });
119
+ };
120
+ const onPointerEnd = (e) => {
121
+ opts.onPointerUp?.(e);
122
+ if (panPointerId !== null && e.pointerId === panPointerId) {
123
+ panPointerId = null;
124
+ panStart = null;
125
+ }
126
+ const w = dragWatch;
127
+ if (w && e.pointerId === w.pointerId) {
128
+ dragWatch = null;
129
+ if (w.moved) {
130
+ suppressNextClick = true;
131
+ clearClickTimer();
132
+ }
133
+ }
134
+ };
135
+ const onClick = (e) => {
136
+ if (suppressNextClick) {
137
+ suppressNextClick = false;
138
+ return;
139
+ }
140
+ const delay = Math.max(0, opts.clickDelayMs);
141
+ if (clickTimer !== null) {
142
+ clearClickTimer();
143
+ const hit = hitAtClient(e.clientX, e.clientY);
144
+ opts.onDoubleClick?.(hit, e);
145
+ return;
146
+ }
147
+ clickTimer = globalThis.setTimeout(() => {
148
+ clickTimer = null;
149
+ const hit = hitAtClient(e.clientX, e.clientY);
150
+ opts.onClick?.(hit, e);
151
+ }, delay);
152
+ };
153
+ const onContextMenu = (e) => {
154
+ e.preventDefault();
155
+ clearClickTimer();
156
+ const hit = hitAtClient(e.clientX, e.clientY);
157
+ opts.onRightClick?.(hit, e);
158
+ };
159
+ setViewportFromRect();
160
+ ro = typeof ResizeObserver === "function" ? new ResizeObserver(() => setViewportFromRect()) : null;
161
+ ro?.observe(host);
162
+ globalThis.addEventListener("keydown", onKeyDown);
163
+ globalThis.addEventListener("keyup", onKeyUp);
164
+ host.addEventListener("wheel", onWheel, { passive: false });
165
+ host.addEventListener("pointerdown", onPointerDown);
166
+ host.addEventListener("pointermove", onPointerMove);
167
+ host.addEventListener("pointerup", onPointerEnd);
168
+ host.addEventListener("pointercancel", onPointerEnd);
169
+ host.addEventListener("click", onClick);
170
+ host.addEventListener("contextmenu", onContextMenu);
171
+ const detach = () => {
172
+ ro?.disconnect();
173
+ ro = null;
174
+ clearClickTimer();
175
+ globalThis.removeEventListener("keydown", onKeyDown);
176
+ globalThis.removeEventListener("keyup", onKeyUp);
177
+ host.removeEventListener("wheel", onWheel);
178
+ host.removeEventListener("pointerdown", onPointerDown);
179
+ host.removeEventListener("pointermove", onPointerMove);
180
+ host.removeEventListener("pointerup", onPointerEnd);
181
+ host.removeEventListener("pointercancel", onPointerEnd);
182
+ host.removeEventListener("click", onClick);
183
+ host.removeEventListener("contextmenu", onContextMenu);
184
+ };
185
+ const controller = {
186
+ engine,
187
+ host,
188
+ setOptions,
189
+ getOptions,
190
+ detach,
191
+ };
192
+ return controller;
193
+ }
194
+ function clamp(v, min, max) {
195
+ return Math.max(min, Math.min(max, v));
196
+ }
197
+
198
+ export { attachDomInput };
@@ -0,0 +1,27 @@
1
+ import { createEngine } from './index.js';
2
+ import { attachDomInput } from './input-dom.js';
3
+
4
+ function attachSvgPanZoom(svg, world, opts) {
5
+ const engine = createEngine({ culling: { enabled: false, overscanPx: 0 } });
6
+ if (opts?.initialCamera)
7
+ engine.setCamera(opts.initialCamera);
8
+ const unsub = engine.onCameraChange((c) => {
9
+ world.setAttribute("transform", `matrix(${c.zoom} 0 0 ${c.zoom} ${c.panX} ${c.panY})`);
10
+ });
11
+ const input = attachDomInput(svg, engine, opts);
12
+ engine.start();
13
+ const detach = () => {
14
+ input.detach();
15
+ unsub();
16
+ engine.stop();
17
+ };
18
+ return {
19
+ engine,
20
+ input,
21
+ detach,
22
+ setCamera: (next) => engine.setCamera(next),
23
+ getCamera: () => engine.getCamera(),
24
+ };
25
+ }
26
+
27
+ export { attachSvgPanZoom };
@@ -0,0 +1,261 @@
1
+ const metricsCache = new Map();
2
+ let measureSvg = null;
3
+ let measureG = null;
4
+ function ensureMeasureDom() {
5
+ if (measureSvg && measureG)
6
+ return { svg: measureSvg, g: measureG };
7
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
8
+ svg.setAttribute("width", "0");
9
+ svg.setAttribute("height", "0");
10
+ svg.style.position = "absolute";
11
+ svg.style.left = "-10000px";
12
+ svg.style.top = "-10000px";
13
+ svg.style.visibility = "hidden";
14
+ svg.style.pointerEvents = "none";
15
+ const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
16
+ svg.appendChild(g);
17
+ measureSvg = svg;
18
+ measureG = g;
19
+ return { svg, g };
20
+ }
21
+ function attachMeasureDom(svg) {
22
+ if (!svg.isConnected)
23
+ document.body.appendChild(svg);
24
+ }
25
+ function detachMeasureDom(svg) {
26
+ if (svg.isConnected)
27
+ svg.remove();
28
+ }
29
+ function stripXmlnsDeep(el) {
30
+ if (el.hasAttribute("xmlns"))
31
+ el.removeAttribute("xmlns");
32
+ for (const attr of Array.from(el.attributes)) {
33
+ if (attr.name.startsWith("xmlns:"))
34
+ el.removeAttribute(attr.name);
35
+ }
36
+ for (const child of Array.from(el.children))
37
+ stripXmlnsDeep(child);
38
+ }
39
+ function sanitizeFragment(markup) {
40
+ const s = markup.trim();
41
+ if (!s)
42
+ return "";
43
+ const wrapped = `<svg xmlns="http://www.w3.org/2000/svg">${s}</svg>`;
44
+ try {
45
+ const doc = new DOMParser().parseFromString(wrapped, "image/svg+xml");
46
+ const svg = doc.documentElement;
47
+ if (!svg || svg.nodeName.toLowerCase() !== "svg")
48
+ return "";
49
+ doc.querySelectorAll("script, foreignObject").forEach((n) => n.remove());
50
+ doc.querySelectorAll("*").forEach((el) => {
51
+ for (const attr of Array.from(el.attributes)) {
52
+ if (attr.name.toLowerCase().startsWith("on"))
53
+ el.removeAttribute(attr.name);
54
+ if (attr.name === "xmlns" || attr.name.startsWith("xmlns:"))
55
+ el.removeAttribute(attr.name);
56
+ }
57
+ });
58
+ const inner = svg.innerHTML;
59
+ if (typeof inner === "string")
60
+ return inner.trim();
61
+ return new XMLSerializer()
62
+ .serializeToString(svg)
63
+ .replace(/^<svg[^>]*>|<\/svg>$/g, "")
64
+ .trim();
65
+ }
66
+ catch {
67
+ return "";
68
+ }
69
+ }
70
+ function parseFragmentElements(markup) {
71
+ const s = markup.trim();
72
+ if (!s)
73
+ return [];
74
+ const wrapped = `<svg xmlns="http://www.w3.org/2000/svg">${s}</svg>`;
75
+ try {
76
+ const doc = new DOMParser().parseFromString(wrapped, "image/svg+xml");
77
+ const svg = doc.documentElement;
78
+ if (!svg || svg.nodeName.toLowerCase() !== "svg")
79
+ return [];
80
+ doc.querySelectorAll("script, foreignObject").forEach((n) => n.remove());
81
+ doc.querySelectorAll("*").forEach((el) => {
82
+ for (const attr of Array.from(el.attributes)) {
83
+ if (attr.name.toLowerCase().startsWith("on"))
84
+ el.removeAttribute(attr.name);
85
+ if (attr.name === "xmlns" || attr.name.startsWith("xmlns:"))
86
+ el.removeAttribute(attr.name);
87
+ }
88
+ });
89
+ const imported = Array.from(svg.children).map((el) => document.importNode(el, true));
90
+ for (const el of imported)
91
+ stripXmlnsDeep(el);
92
+ return imported;
93
+ }
94
+ catch {
95
+ return [];
96
+ }
97
+ }
98
+ function measureFragmentMetrics(markup) {
99
+ const key = sanitizeFragment(markup);
100
+ if (!key)
101
+ return null;
102
+ const cached = metricsCache.get(key);
103
+ if (cached)
104
+ return cached;
105
+ const { svg, g } = ensureMeasureDom();
106
+ attachMeasureDom(svg);
107
+ g.replaceChildren();
108
+ const els = parseFragmentElements(key);
109
+ for (const el of els)
110
+ g.appendChild(el.cloneNode(true));
111
+ try {
112
+ const b = g.getBBox();
113
+ let maxStrokeWidth = 0;
114
+ g.querySelectorAll("*").forEach((node) => {
115
+ try {
116
+ const cs = getComputedStyle(node);
117
+ const stroke = cs.stroke;
118
+ if (!stroke || stroke === "none" || stroke === "transparent")
119
+ return;
120
+ const sw = Number.parseFloat(cs.strokeWidth ?? "0");
121
+ if (Number.isFinite(sw) && sw > maxStrokeWidth)
122
+ maxStrokeWidth = sw;
123
+ }
124
+ catch { }
125
+ });
126
+ const pad = Math.max(0, maxStrokeWidth / 2);
127
+ const bbox = { x: b.x, y: b.y, width: b.width, height: b.height };
128
+ const metrics = { bbox, pad };
129
+ metricsCache.set(key, metrics);
130
+ return metrics;
131
+ }
132
+ catch {
133
+ return null;
134
+ }
135
+ finally {
136
+ detachMeasureDom(svg);
137
+ }
138
+ }
139
+
140
+ function createSvgDomRenderer(options) {
141
+ let svg = null;
142
+ let world = null;
143
+ let nodesLayer = null;
144
+ let opts = {
145
+ getFragment: options.getFragment,
146
+ getNodeKey: options.getNodeKey,
147
+ getNodeTransform: options.getNodeTransform,
148
+ };
149
+ const nodeIdToEl = new Map();
150
+ const fragmentCache = new Map();
151
+ function ensureMounted() {
152
+ if (!svg || !world || !nodesLayer)
153
+ throw new Error("SvgDomRenderer is not mounted");
154
+ return { svg, world, nodesLayer };
155
+ }
156
+ function getNodeKey(node) {
157
+ return opts.getNodeKey ? opts.getNodeKey(node) : node.id;
158
+ }
159
+ function getNodeTransform(node) {
160
+ if (opts.getNodeTransform)
161
+ return opts.getNodeTransform(node);
162
+ const x = Number.isFinite(node.x) ? node.x : 0;
163
+ const y = Number.isFinite(node.y) ? node.y : 0;
164
+ return `translate(${x} ${y})`;
165
+ }
166
+ function getNodeEl(id) {
167
+ const existing = nodeIdToEl.get(id);
168
+ if (existing)
169
+ return existing;
170
+ const el = document.createElementNS("http://www.w3.org/2000/svg", "g");
171
+ el.dataset.nodeId = id;
172
+ nodeIdToEl.set(id, el);
173
+ return el;
174
+ }
175
+ function getOrCreateFragmentEntry(fragment) {
176
+ const cleaned = sanitizeFragment(fragment);
177
+ if (!cleaned)
178
+ return null;
179
+ const cached = fragmentCache.get(cleaned);
180
+ if (cached)
181
+ return cached;
182
+ const children = parseFragmentElements(cleaned);
183
+ const metrics = measureFragmentMetrics(cleaned);
184
+ const bbox = metrics?.bbox ?? { x: 0, y: 0, width: 240, height: 160 };
185
+ const pad = metrics?.pad ?? 0;
186
+ const w = Math.max(1, bbox.width + pad * 2);
187
+ const h = Math.max(1, bbox.height + pad * 2);
188
+ const offsetX = -bbox.x + pad;
189
+ const offsetY = -bbox.y + pad;
190
+ const next = { children, w, h, offsetX, offsetY };
191
+ fragmentCache.set(cleaned, next);
192
+ return next;
193
+ }
194
+ function renderNode(el, node) {
195
+ const frag = opts.getFragment(node) ?? "";
196
+ const entry = getOrCreateFragmentEntry(frag);
197
+ el.replaceChildren();
198
+ el.setAttribute("transform", getNodeTransform(node));
199
+ if (!entry)
200
+ return;
201
+ const inner = document.createElementNS("http://www.w3.org/2000/svg", "g");
202
+ inner.setAttribute("transform", `translate(${entry.offsetX} ${entry.offsetY})`);
203
+ for (const child of entry.children)
204
+ inner.appendChild(child.cloneNode(true));
205
+ el.appendChild(inner);
206
+ }
207
+ function setWorldTransform(worldEl, camera) {
208
+ const z = Number.isFinite(camera.zoom) ? camera.zoom : 1;
209
+ const px = Number.isFinite(camera.panX) ? camera.panX : 0;
210
+ const py = Number.isFinite(camera.panY) ? camera.panY : 0;
211
+ worldEl.setAttribute("transform", `matrix(${z} 0 0 ${z} ${px} ${py})`);
212
+ }
213
+ const r = {
214
+ mount(host) {
215
+ svg = host;
216
+ world = document.createElementNS("http://www.w3.org/2000/svg", "g");
217
+ world.dataset.layer = "world";
218
+ nodesLayer = document.createElementNS("http://www.w3.org/2000/svg", "g");
219
+ nodesLayer.dataset.layer = "nodes";
220
+ world.appendChild(nodesLayer);
221
+ svg.replaceChildren(world);
222
+ },
223
+ unmount() {
224
+ if (svg)
225
+ svg.replaceChildren();
226
+ svg = null;
227
+ world = null;
228
+ nodesLayer = null;
229
+ nodeIdToEl.clear();
230
+ fragmentCache.clear();
231
+ },
232
+ render(args) {
233
+ const { world, nodesLayer } = ensureMounted();
234
+ setWorldTransform(world, args.camera);
235
+ const frag = document.createDocumentFragment();
236
+ for (const node of args.visibleNodes) {
237
+ const id = node.id;
238
+ const key = getNodeKey(node);
239
+ const el = getNodeEl(id);
240
+ if (el.dataset.nodeKey !== key) {
241
+ el.dataset.nodeKey = key;
242
+ renderNode(el, node);
243
+ }
244
+ else {
245
+ el.setAttribute("transform", getNodeTransform(node));
246
+ }
247
+ frag.appendChild(el);
248
+ }
249
+ nodesLayer.replaceChildren(frag);
250
+ },
251
+ };
252
+ const api = {
253
+ ...r,
254
+ setOptions(next) {
255
+ opts = { ...opts, ...next };
256
+ },
257
+ };
258
+ return api;
259
+ }
260
+
261
+ export { createSvgDomRenderer, measureFragmentMetrics, parseFragmentElements, sanitizeFragment };
package/dist/vkcha.min.js CHANGED
@@ -1 +1 @@
1
- !function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).SvgCore={})}(this,function(t){"use strict";const e={wheelMode:"pan",zoomRequiresCtrlKey:!1,panRequiresSpaceKey:!1,minZoom:.2,maxZoom:8,zoomSpeed:1,invertZoom:!1,invertPan:!1};class n{svg;world;state={zoom:1,panX:0,panY:0};options={...e};listeners=new Set;notifyScheduled=!1;dragPointerId=null;panStart=null;isSpaceDown=!1;windowKeyDownHandler=null;windowKeyUpHandler=null;svgWheelHandler=null;svgPointerDownHandler=null;svgPointerMoveHandler=null;svgPointerUpHandler=null;svgPointerCancelHandler=null;svgPointerLeaveHandler=null;constructor(t,e={}){this.svg=t,this.world=function(t,e={}){const n=document.createElementNS("http://www.w3.org/2000/svg",t);for(const[t,s]of Object.entries(e))n.setAttribute(t,s);return n}("g"),this.world.dataset.layer="world",this.svg.replaceChildren(this.world),this.setOptions(e),this.svg.style.touchAction="none",this.svg.style.userSelect="none",this.attach(),this.render()}setOptions(t){this.options={...e,...this.options,...t}}subscribe(t){return this.listeners.add(t),()=>this.listeners.delete(t)}setState(t){const e={...this.state,...t};e.zoom===this.state.zoom&&e.panX===this.state.panX&&e.panY===this.state.panY||(this.state=e,this.render(),this.scheduleNotify())}reset(){this.setState({zoom:1,panX:0,panY:0})}destroy(){if(this.windowKeyDownHandler&&(window.removeEventListener("keydown",this.windowKeyDownHandler),this.windowKeyDownHandler=null),this.windowKeyUpHandler&&(window.removeEventListener("keyup",this.windowKeyUpHandler),this.windowKeyUpHandler=null),this.svgWheelHandler&&(this.svg.removeEventListener("wheel",this.svgWheelHandler),this.svgWheelHandler=null),this.svgPointerDownHandler&&(this.svg.removeEventListener("pointerdown",this.svgPointerDownHandler),this.svgPointerDownHandler=null),this.svgPointerMoveHandler&&(this.svg.removeEventListener("pointermove",this.svgPointerMoveHandler),this.svgPointerMoveHandler=null),this.svgPointerUpHandler&&(this.svg.removeEventListener("pointerup",this.svgPointerUpHandler),this.svgPointerUpHandler=null),this.svgPointerCancelHandler&&(this.svg.removeEventListener("pointercancel",this.svgPointerCancelHandler),this.svgPointerCancelHandler=null),this.svgPointerLeaveHandler&&(this.svg.removeEventListener("pointerleave",this.svgPointerLeaveHandler),this.svgPointerLeaveHandler=null),null!==this.dragPointerId){try{this.svg.releasePointerCapture(this.dragPointerId)}catch{}this.dragPointerId=null}this.panStart=null,this.isSpaceDown=!1,this.listeners.clear()}scheduleNotify(){this.notifyScheduled||(this.notifyScheduled=!0,requestAnimationFrame(()=>{this.notifyScheduled=!1;for(const t of this.listeners)t(this.state)}))}attach(){this.windowKeyDownHandler=t=>{"Space"===t.code&&(this.isSpaceDown=!0)},this.windowKeyUpHandler=t=>{"Space"===t.code&&(this.isSpaceDown=!1)},window.addEventListener("keydown",this.windowKeyDownHandler),window.addEventListener("keyup",this.windowKeyUpHandler),this.svgWheelHandler=t=>{t.preventDefault();const e=this.svgPoint(t.clientX,t.clientY),{wheelMode:n,zoomRequiresCtrlKey:s,invertZoom:i,invertPan:o}=this.options,r=t.ctrlKey||t.metaKey;if("pan"===n&&!r){const e=o?-1:1;return void this.setState({panX:this.state.panX-t.deltaX*e,panY:this.state.panY-t.deltaY*e})}if("zoom"===n&&s&&!r)return;const l=this.screenToWorld(e.x,e.y),a=i?-t.deltaY:t.deltaY,h=Math.exp(.001*-a*this.options.zoomSpeed),d=(c=this.state.zoom*h,u=this.options.minZoom,g=this.options.maxZoom,Math.max(u,Math.min(g,c)));var c,u,g;const v=e.x-l.x*d,p=e.y-l.y*d;this.setState({zoom:d,panX:v,panY:p})},this.svg.addEventListener("wheel",this.svgWheelHandler,{passive:!1}),this.svgPointerDownHandler=t=>{if(null!==this.dragPointerId)return;if(this.options.panRequiresSpaceKey&&!this.isSpaceDown)return;this.dragPointerId=t.pointerId,this.svg.setPointerCapture(t.pointerId);const e=this.svgPoint(t.clientX,t.clientY);this.panStart={panX:this.state.panX,panY:this.state.panY,x:e.x,y:e.y}},this.svg.addEventListener("pointerdown",this.svgPointerDownHandler),this.svgPointerMoveHandler=t=>{if(null===this.dragPointerId)return;if(t.pointerId!==this.dragPointerId)return;if(!this.panStart)return;const e=this.svgPoint(t.clientX,t.clientY),n=e.x-this.panStart.x,s=e.y-this.panStart.y;this.setState({panX:this.panStart.panX+n,panY:this.panStart.panY+s})},this.svg.addEventListener("pointermove",this.svgPointerMoveHandler);const t=t=>{null!==this.dragPointerId&&t.pointerId===this.dragPointerId&&(this.dragPointerId=null,this.panStart=null)};this.svgPointerUpHandler=t,this.svgPointerCancelHandler=t,this.svgPointerLeaveHandler=()=>{this.dragPointerId=null,this.panStart=null},this.svg.addEventListener("pointerup",this.svgPointerUpHandler),this.svg.addEventListener("pointercancel",this.svgPointerCancelHandler),this.svg.addEventListener("pointerleave",this.svgPointerLeaveHandler)}render(){const{zoom:t,panX:e,panY:n}=this.state;this.world.setAttribute("transform",`matrix(${t} 0 0 ${t} ${e} ${n})`)}svgPoint(t,e){const n=this.svg.getBoundingClientRect();return{x:t-n.left,y:e-n.top}}screenToWorld(t,e){const{zoom:n,panX:s,panY:i}=this.state;return{x:(t-s)/n,y:(e-i)/n}}}const s=new Map;let i=null,o=null;function r(t){t.hasAttribute("xmlns")&&t.removeAttribute("xmlns");for(const e of Array.from(t.attributes))e.name.startsWith("xmlns:")&&t.removeAttribute(e.name);for(const e of Array.from(t.children))r(e)}function l(t){const e=t.trim();if(!e)return"";const n=`<svg xmlns="http://www.w3.org/2000/svg">${e}</svg>`;try{const t=(new DOMParser).parseFromString(n,"image/svg+xml"),e=t.documentElement;if(!e||"svg"!==e.nodeName.toLowerCase())return"";t.querySelectorAll("script, foreignObject").forEach(t=>t.remove()),t.querySelectorAll("*").forEach(t=>{for(const e of Array.from(t.attributes))e.name.toLowerCase().startsWith("on")&&t.removeAttribute(e.name),("xmlns"===e.name||e.name.startsWith("xmlns:"))&&t.removeAttribute(e.name)});const s=e.innerHTML;return"string"==typeof s?s.trim():(new XMLSerializer).serializeToString(e).replace(/^<svg[^>]*>|<\/svg>$/g,"").trim()}catch{return""}}function a(t){const e=t.trim();if(!e)return[];const n=`<svg xmlns="http://www.w3.org/2000/svg">${e}</svg>`;try{const t=(new DOMParser).parseFromString(n,"image/svg+xml"),e=t.documentElement;if(!e||"svg"!==e.nodeName.toLowerCase())return[];t.querySelectorAll("script, foreignObject").forEach(t=>t.remove()),t.querySelectorAll("*").forEach(t=>{for(const e of Array.from(t.attributes))e.name.toLowerCase().startsWith("on")&&t.removeAttribute(e.name),("xmlns"===e.name||e.name.startsWith("xmlns:"))&&t.removeAttribute(e.name)});const s=Array.from(e.children).map(t=>document.importNode(t,!0));for(const t of s)r(t);return s}catch{return[]}}function h(t){const e=l(t);if(!e)return null;const n=s.get(e);if(n)return n;const{svg:r,g:h}=function(){if(i&&o)return{svg:i,g:o};const t=document.createElementNS("http://www.w3.org/2000/svg","svg");t.setAttribute("width","0"),t.setAttribute("height","0"),t.style.position="absolute",t.style.left="-10000px",t.style.top="-10000px",t.style.visibility="hidden",t.style.pointerEvents="none";const e=document.createElementNS("http://www.w3.org/2000/svg","g");return t.appendChild(e),i=t,o=e,{svg:t,g:e}}();!function(t){t.isConnected||document.body.appendChild(t)}(r),h.replaceChildren();const d=a(e);for(const t of d)h.appendChild(t.cloneNode(!0));try{const t=h.getBBox();let n=0;h.querySelectorAll("*").forEach(t=>{try{const e=getComputedStyle(t),s=e.stroke;if(!s||"none"===s||"transparent"===s)return;const i=Number.parseFloat(e.strokeWidth??"0");Number.isFinite(i)&&i>n&&(n=i)}catch{}});const i=Math.max(0,n/2),o={bbox:{x:t.x,y:t.y,width:t.width,height:t.height},pad:i};return s.set(e,o),o}catch{return null}finally{!function(t){t.isConnected&&t.remove()}(r)}}t.Node=class{id;fragment;x;y;width;height;onClick;onDoubleClick;onRightClick;_el=null;constructor(t){if(!t||"string"!=typeof t.id||""===t.id)throw new Error("Node requires a non-empty 'id' property");this.id=t.id,this.fragment=t.fragment??"",this.x=Number.isFinite(t?.x)?t?.x:0,this.y=Number.isFinite(t?.y)?t?.y:0;const e=t?.width,n=t?.height;this.width="number"==typeof e&&Number.isFinite(e)&&e>0?e:null,this.height="number"==typeof n&&Number.isFinite(n)&&n>0?n:null,this.onClick=t?.onClick,this.onDoubleClick=t?.onDoubleClick,this.onRightClick=t?.onRightClick}get el(){if(!this._el){const t=document.createElementNS("http://www.w3.org/2000/svg","g");t.dataset.nodeId=this.id,this._el=t}return this._el}},t.PanZoomCanvas=n,t.SvgCore=class{canvas;nodesLayer;nodes=[];nodeIdToIndex=new Map;nodeBounds=null;cullingEnabled=!0;cullingOverscanPx=30;resizeObserver=null;unsubPanZoom=null;unsubSvgEvents=null;svgClickTimer=null;suppressNextClick=!1;dragWatch=null;cullingListeners=new Set;lastCullingStats={visible:0,hidden:0,total:0};cullingNotifyScheduled=!1;get svg(){return this.canvas.svg}get world(){return this.canvas.world}get state(){return this.canvas.state}get panZoomOptions(){return this.canvas.options}constructor(t,e){this.canvas=new n(t,e?.panZoom),this.nodesLayer=document.createElementNS("http://www.w3.org/2000/svg","g"),this.nodesLayer.dataset.layer="nodes",this.world.appendChild(this.nodesLayer),this.world.style.pointerEvents="none";const s=e?.culling;"boolean"==typeof s?this.cullingEnabled=s:s&&("boolean"==typeof s.enabled&&(this.cullingEnabled=s.enabled),"number"==typeof s.overscanPx&&(this.cullingOverscanPx=Math.max(0,s.overscanPx))),this.unsubPanZoom=this.canvas.subscribe(()=>this.applyCulling()),this.resizeObserver=new ResizeObserver(()=>this.applyCulling()),this.resizeObserver.observe(this.svg);const i=()=>{null!==this.svgClickTimer&&(window.clearTimeout(this.svgClickTimer),this.svgClickTimer=null)},o=t=>{0===t.button&&(this.dragWatch={pointerId:t.pointerId,startClientX:t.clientX,startClientY:t.clientY,moved:!1})},r=t=>{const e=this.dragWatch;if(!e)return;if(t.pointerId!==e.pointerId)return;if(1&~t.buttons)return;const n=t.clientX-e.startClientX,s=t.clientY-e.startClientY;!e.moved&&Math.hypot(n,s)>=5&&(e.moved=!0)},l=t=>{const e=this.dragWatch;e&&t.pointerId===e.pointerId&&(this.dragWatch=null,e.moved&&(this.suppressNextClick=!0,i()))},a=t=>{if(this.suppressNextClick)this.suppressNextClick=!1;else{if(null!==this.svgClickTimer){i();const e=this.hitTestVisibleNodeAtClient(t.clientX,t.clientY);return void(e?.onDoubleClick&&e.onDoubleClick(e))}this.svgClickTimer=window.setTimeout(()=>{this.svgClickTimer=null;const e=this.hitTestVisibleNodeAtClient(t.clientX,t.clientY);e?.onClick&&e.onClick(e)},300)}},h=t=>{t.preventDefault(),i();const e=this.hitTestVisibleNodeAtClient(t.clientX,t.clientY);e?.onRightClick&&e.onRightClick(e)};this.svg.addEventListener("click",a),this.svg.addEventListener("contextmenu",h),this.svg.addEventListener("pointerdown",o),this.svg.addEventListener("pointermove",r),this.svg.addEventListener("pointerup",l),this.svg.addEventListener("pointercancel",l),this.unsubSvgEvents=()=>{this.svg.removeEventListener("click",a),this.svg.removeEventListener("contextmenu",h),this.svg.removeEventListener("pointerdown",o),this.svg.removeEventListener("pointermove",r),this.svg.removeEventListener("pointerup",l),this.svg.removeEventListener("pointercancel",l),i()}}setZoom(t,e){const n=this.canvas.options.minZoom,s=this.canvas.options.maxZoom,i=Math.min(s,Math.max(n,t)),o=this.svg.getBoundingClientRect(),r=e?.x??Math.max(1,o.width)/2,l=e?.y??Math.max(1,o.height)/2,a=this.state,h=r-(r-a.panX)/Math.max(1e-9,a.zoom)*i,d=l-(l-a.panY)/Math.max(1e-9,a.zoom)*i;this.setState({zoom:i,panX:h,panY:d})}clientToCanvas(t,e){const n=this.svg.getBoundingClientRect(),s=t-n.left,i=e-n.top,{panX:o,panY:r,zoom:l}=this.state,a=Math.max(1e-9,l);return{x:(s-o)/a,y:(i-r)/a}}hitTestVisibleNodeAtClient(t,e){if(!this.nodeBounds||0===this.nodes.length)return null;const n=this.clientToCanvas(t,e),s=this.nodesLayer.children;for(let t=s.length-1;t>=0;t--){const e=s.item(t);if(!e)continue;const i=e.dataset.nodeId;if(!i)continue;const o=this.nodeIdToIndex.get(i);if(void 0===o)continue;const r=this.nodeBounds[o];if(r&&(n.x>=r.x0&&n.x<=r.x1&&n.y>=r.y0&&n.y<=r.y1))return this.nodes[o]}return null}zoomBy(t,e){const n=Number.isFinite(t)?t:1;n<=0||this.setZoom(this.state.zoom*n,e)}setState(t){this.canvas.setState(t)}resetView(){this.canvas.reset()}configurePanZoom(t){this.canvas.setOptions(t)}setNodes(t){const e=new Set,n=new Set;for(let s=0;s<t.length;s++){const i=t[s].id;e.has(i)?n.add(i):e.add(i)}n.size>0&&console.warn(`Duplicate node ids found: ${Array.from(n).map(t=>`"${t}"`).join(", ")}. Each node should have a unique id.`),this.nodes=t,this.nodeIdToIndex.clear();for(let e=0;e<t.length;e++)this.nodeIdToIndex.set(t[e].id,e);this.redraw()}redraw(t){Array.isArray(t)&&t.length>0?(this.renderNodes(t),this.applyCulling()):(this.renderNodes(),this.applyCulling())}setCullingEnabled(t){this.cullingEnabled=t,this.applyCulling()}setCullingOverscanPx(t){this.cullingOverscanPx=Math.max(0,t),this.applyCulling()}onCullingStatsChange(t){return this.cullingListeners.add(t),t(this.lastCullingStats),()=>this.cullingListeners.delete(t)}onPanZoomChange(t){return this.canvas.subscribe(t)}remove(t){if(!t||0===t.length)return this.nodes=[],this.nodeIdToIndex.clear(),this.nodesLayer.replaceChildren(),this.nodeBounds=null,void this.setCullingStats({visible:0,hidden:0,total:0});const e=new Set;for(const n of t){const t=this.nodeIdToIndex.get(n);void 0!==t&&e.add(t)}if(0===e.size)return;const n=Array.from(e).sort((t,e)=>e-t);for(const t of n){const e=this.nodes[t];e&&(e.el.parentElement&&e.el.remove(),this.nodeIdToIndex.delete(e.id)),this.nodes.splice(t,1)}this.nodeIdToIndex.clear();for(let t=0;t<this.nodes.length;t++)this.nodeIdToIndex.set(this.nodes[t].id,t);if(this.nodeBounds){const t=[];for(let e=0;e<this.nodes.length;e++){const n=this.nodes[e],s=h(n.fragment),i=s?.bbox??{width:240,height:160},o=s?.pad??0,r=n.width??Math.max(1,i.width+2*o),l=n.height??Math.max(1,i.height+2*o);t.push({x0:n.x,y0:n.y,x1:n.x+r,y1:n.y+l})}this.nodeBounds=t}this.applyCulling()}destroy(){this.resizeObserver?.disconnect(),this.resizeObserver=null,this.unsubPanZoom?.(),this.unsubPanZoom=null,this.unsubSvgEvents?.(),this.unsubSvgEvents=null,this.cullingListeners.clear(),this.canvas.destroy()}renderNodes(t){if(t&&t.length>0){for(const e of t){const t=this.nodeIdToIndex.get(e);if(void 0===t)continue;const n=this.nodes[t];if(!n)continue;const s=n.el;s.replaceChildren(),s.setAttribute("transform",`translate(${n.x} ${n.y})`);const i=l(n.fragment);if(i){const e=a(i),o=h(i),r=o?.bbox??{x:0,y:0,width:240,height:160},l=o?.pad??0,d=Math.max(1,r.width+2*l),c=Math.max(1,r.height+2*l),u=-r.x+l,g=-r.y+l,v=document.createElementNS("http://www.w3.org/2000/svg","g");v.setAttribute("transform",`translate(${u} ${g})`);for(const t of e)v.appendChild(t.cloneNode(!0));if(s.appendChild(v),this.nodeBounds){const e=n.width??d,s=n.height??c;this.nodeBounds[t]={x0:n.x,y0:n.y,x1:n.x+e,y1:n.y+s}}}s.parentElement||this.nodesLayer.appendChild(s)}return}if(this.nodesLayer.replaceChildren(),this.nodeBounds=null,0===this.nodes.length)return;const e=new Map;for(const t of this.nodes){const n=l(t.fragment);if(!n)continue;if(e.has(n))continue;const s=a(n),i=h(n),o=i?.bbox??{x:0,y:0,width:240,height:160},r=i?.pad??0,d=Math.max(1,o.width+2*r),c=Math.max(1,o.height+2*r),u=-o.x+r,g=-o.y+r;e.set(n,{children:s,w:d,h:c,offsetX:u,offsetY:g})}const n=this.nodes.length,s=document.createDocumentFragment(),i=new Array(n);for(let t=0;t<n;t++){const n=this.nodes[t],o=n.el;o.replaceChildren(),o.setAttribute("transform",`translate(${n.x} ${n.y})`);const r=l(n.fragment),a=r?e.get(r):null;if(a){const t=document.createElementNS("http://www.w3.org/2000/svg","g");t.setAttribute("transform",`translate(${a.offsetX} ${a.offsetY})`);for(const e of a.children)t.appendChild(e.cloneNode(!0));o.appendChild(t)}const h=n.width??a?.w??240,d=n.height??a?.h??160;i[t]={x0:n.x,y0:n.y,x1:n.x+h,y1:n.y+d},s.appendChild(o)}this.nodesLayer.appendChild(s),this.nodeBounds=i}applyCulling(){if(!this.nodeBounds)return void this.setCullingStats({visible:0,hidden:0,total:this.nodes.length});const t=this.nodes.length;if(!this.cullingEnabled){this.nodesLayer.replaceChildren(...this.nodes.map(t=>t.el));for(const t of this.nodes)t.el.removeAttribute("display");return void this.setCullingStats({visible:t,hidden:0,total:t})}const e=this.getWorldViewport(this.state,this.cullingOverscanPx),n=[];for(let s=0;s<t;s++){const t=this.nodeBounds[s];if(t&&this.rectsIntersect(t,e)){const t=this.nodes[s].el;t.removeAttribute("display"),n.push(t)}}this.nodesLayer.replaceChildren(...n),this.setCullingStats({visible:n.length,hidden:t-n.length,total:t})}setCullingStats(t){const e=this.lastCullingStats;e.visible===t.visible&&e.hidden===t.hidden&&e.total===t.total||(this.lastCullingStats=t,this.scheduleCullingNotify())}scheduleCullingNotify(){this.cullingNotifyScheduled||(this.cullingNotifyScheduled=!0,requestAnimationFrame(()=>{this.cullingNotifyScheduled=!1;for(const t of this.cullingListeners)t(this.lastCullingStats)}))}rectsIntersect(t,e){return!(t.x1<e.x0||t.x0>e.x1||t.y1<e.y0||t.y0>e.y1)}getWorldViewport(t,e){const n=this.svg.getBoundingClientRect(),s=Math.max(1,n.width),i=Math.max(1,n.height),o=Math.max(1e-9,t.zoom),r=Math.max(0,e)/o;return{x0:-t.panX/o-r,y0:-t.panY/o-r,x1:(s-t.panX)/o+r,y1:(i-t.panY)/o+r}}},t.measureFragmentMetrics=h});
1
+ !function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).SvgCore={})}(this,function(t){"use strict";function e(t,e={}){const n=document.createElementNS("http://www.w3.org/2000/svg",t);for(const[t,i]of Object.entries(e))n.setAttribute(t,i);return n}const n={wheelMode:"pan",zoomRequiresCtrlKey:!1,panRequiresSpaceKey:!1,minZoom:.2,maxZoom:8,zoomSpeed:1,pinchZoomSpeed:2,invertZoom:!1,invertPan:!1};class i{svg;world;state={zoom:1,panX:0,panY:0};options={...n};listeners=new Set;notifyScheduled=!1;dragPointerId=null;panStart=null;isPanning=!1;isSpaceDown=!1;static DRAG_THRESHOLD_PX=5;animationFrameId=null;windowKeyDownHandler=null;windowKeyUpHandler=null;svgWheelHandler=null;svgPointerDownHandler=null;svgPointerMoveHandler=null;svgPointerUpHandler=null;svgPointerCancelHandler=null;svgPointerLeaveHandler=null;constructor(t,n={}){this.svg=t,this.world=e("g"),this.world.dataset.layer="world",this.svg.replaceChildren(this.world),this.setOptions(n),this.applyWorldGroupConfig(),this.svg.style.touchAction="none",this.svg.style.userSelect="none";const i=this.svg.style;i.webkitUserSelect="none",i.webkitTapHighlightColor="transparent",this.svg.style.outline="none",this.svg.setAttribute("tabindex","-1"),this.world.style.outline="none",this.attach(),this.render()}applyWorldGroupConfig(){const t=this.options.worldGroup;if(t&&(t.id&&(this.world.id=t.id),t.attributes))for(const[e,n]of Object.entries(t.attributes))this.world.setAttribute(e,n)}createLayer(t,n){const i=e("g");return t&&(i.dataset.layer=t),n?.pointerEvents&&(i.style.pointerEvents=n.pointerEvents),"back"===n?.position&&this.world.firstChild?this.world.insertBefore(i,this.world.firstChild):this.world.appendChild(i),i}setOptions(t){this.options={...n,...this.options,...t},t.worldGroup&&(this.applyWorldGroupConfig(),this.render())}subscribe(t){return this.listeners.add(t),()=>this.listeners.delete(t)}setState(t){const e={...this.state,...t};e.zoom===this.state.zoom&&e.panX===this.state.panX&&e.panY===this.state.panY||(this.state=e,this.render(),this.scheduleNotify())}animateTo(t,e=300,n=t=>t){return new Promise(i=>{null!==this.animationFrameId&&(cancelAnimationFrame(this.animationFrameId),this.animationFrameId=null);const s={zoom:this.state.zoom,panX:this.state.panX,panY:this.state.panY},o=t.zoom??s.zoom,r=t.panX??s.panX,a=t.panY??s.panY,l=performance.now(),h=t=>{const d=t-l,c=Math.min(1,d/e),u=n(c);this.setState({zoom:s.zoom+(o-s.zoom)*u,panX:s.panX+(r-s.panX)*u,panY:s.panY+(a-s.panY)*u}),c<1?this.animationFrameId=requestAnimationFrame(h):(this.animationFrameId=null,i())};this.animationFrameId=requestAnimationFrame(h)})}stopAnimation(){null!==this.animationFrameId&&(cancelAnimationFrame(this.animationFrameId),this.animationFrameId=null)}reset(){this.setState({zoom:1,panX:0,panY:0})}destroy(){if(this.stopAnimation(),this.windowKeyDownHandler&&(window.removeEventListener("keydown",this.windowKeyDownHandler),this.windowKeyDownHandler=null),this.windowKeyUpHandler&&(window.removeEventListener("keyup",this.windowKeyUpHandler),this.windowKeyUpHandler=null),this.svgWheelHandler&&(this.svg.removeEventListener("wheel",this.svgWheelHandler),this.svgWheelHandler=null),this.svgPointerDownHandler&&(this.svg.removeEventListener("pointerdown",this.svgPointerDownHandler),this.svgPointerDownHandler=null),this.svgPointerMoveHandler&&(this.svg.removeEventListener("pointermove",this.svgPointerMoveHandler),this.svgPointerMoveHandler=null),this.svgPointerUpHandler&&(this.svg.removeEventListener("pointerup",this.svgPointerUpHandler),this.svgPointerUpHandler=null),this.svgPointerCancelHandler&&(this.svg.removeEventListener("pointercancel",this.svgPointerCancelHandler),this.svgPointerCancelHandler=null),this.svgPointerLeaveHandler&&(this.svg.removeEventListener("pointerleave",this.svgPointerLeaveHandler),this.svgPointerLeaveHandler=null),null!==this.dragPointerId){try{this.svg.releasePointerCapture(this.dragPointerId)}catch{}this.dragPointerId=null}this.panStart=null,this.isPanning=!1,this.isSpaceDown=!1,this.listeners.clear()}scheduleNotify(){this.notifyScheduled||(this.notifyScheduled=!0,requestAnimationFrame(()=>{this.notifyScheduled=!1;for(const t of this.listeners)t(this.state)}))}attach(){this.windowKeyDownHandler=t=>{"Space"===t.code&&(this.isSpaceDown=!0)},this.windowKeyUpHandler=t=>{"Space"===t.code&&(this.isSpaceDown=!1)},window.addEventListener("keydown",this.windowKeyDownHandler),window.addEventListener("keyup",this.windowKeyUpHandler),this.svgWheelHandler=t=>{t.preventDefault();const e=this.svgPoint(t.clientX,t.clientY),{wheelMode:n,zoomRequiresCtrlKey:i,invertZoom:s,invertPan:o}=this.options,r=t.ctrlKey||t.metaKey,a=t.ctrlKey&&!t.metaKey;if("pan"===n&&!r){const e=o?-1:1;return void this.setState({panX:this.state.panX-t.deltaX*e,panY:this.state.panY-t.deltaY*e})}if("zoom"===n&&i&&!r)return;const l=this.screenToWorld(e.x,e.y),h=s?-t.deltaY:t.deltaY,d=a&&t.deltaMode===WheelEvent.DOM_DELTA_PIXEL?this.options.pinchZoomSpeed:1,c=Math.exp(.001*-h*this.options.zoomSpeed*d),u=(p=this.state.zoom*c,g=this.options.minZoom,v=this.options.maxZoom,Math.max(g,Math.min(v,p)));var p,g,v;const m=e.x-l.x*u,f=e.y-l.y*u;this.setState({zoom:u,panX:m,panY:f})},this.svg.addEventListener("wheel",this.svgWheelHandler,{passive:!1}),this.svgPointerDownHandler=t=>{if(0!==t.button)return;if(null!==this.dragPointerId)return;if(this.options.panRequiresSpaceKey&&!this.isSpaceDown)return;this.dragPointerId=t.pointerId,this.isPanning=!1;const e=this.svgPoint(t.clientX,t.clientY);this.panStart={panX:this.state.panX,panY:this.state.panY,x:e.x,y:e.y}},this.svg.addEventListener("pointerdown",this.svgPointerDownHandler),this.svgPointerMoveHandler=t=>{if(null===this.dragPointerId)return;if(t.pointerId!==this.dragPointerId)return;if(!this.panStart)return;const e=this.svgPoint(t.clientX,t.clientY),n=e.x-this.panStart.x,s=e.y-this.panStart.y;if(!this.isPanning){if(Math.hypot(n,s)<i.DRAG_THRESHOLD_PX)return;this.isPanning=!0,this.svg.setPointerCapture(t.pointerId)}this.setState({panX:this.panStart.panX+n,panY:this.panStart.panY+s})},this.svg.addEventListener("pointermove",this.svgPointerMoveHandler);const t=t=>{if(null!==this.dragPointerId&&t.pointerId===this.dragPointerId){if(this.isPanning)try{this.svg.releasePointerCapture(t.pointerId)}catch{}this.dragPointerId=null,this.panStart=null,this.isPanning=!1}};this.svgPointerUpHandler=t,this.svgPointerCancelHandler=t,this.svgPointerLeaveHandler=()=>{if(this.isPanning&&null!==this.dragPointerId)try{this.svg.releasePointerCapture(this.dragPointerId)}catch{}this.dragPointerId=null,this.panStart=null,this.isPanning=!1},this.svg.addEventListener("pointerup",this.svgPointerUpHandler),this.svg.addEventListener("pointercancel",this.svgPointerCancelHandler),this.svg.addEventListener("pointerleave",this.svgPointerLeaveHandler)}render(){const{zoom:t,panX:e,panY:n}=this.state;this.world.setAttribute("transform",`matrix(${t} 0 0 ${t} ${e} ${n})`);const i=this.options.worldGroup?.dynamicAttributes;if(i)for(const[e,n]of Object.entries(i))this.world.setAttribute(e,n(t))}svgPoint(t,e){const n=this.svg.getBoundingClientRect();return{x:t-n.left,y:e-n.top}}screenToWorld(t,e){const{zoom:n,panX:i,panY:s}=this.state;return{x:(t-i)/n,y:(e-s)/n}}}const s=new Map;let o=null,r=null;function a(t){t.hasAttribute("xmlns")&&t.removeAttribute("xmlns");for(const e of Array.from(t.attributes))e.name.startsWith("xmlns:")&&t.removeAttribute(e.name);for(const e of Array.from(t.children))a(e)}function l(t){const e=t.trim();if(!e)return"";const n=`<svg xmlns="http://www.w3.org/2000/svg">${e}</svg>`;try{const t=(new DOMParser).parseFromString(n,"image/svg+xml"),e=t.documentElement;if(!e||"svg"!==e.nodeName.toLowerCase())return"";t.querySelectorAll("script, foreignObject").forEach(t=>t.remove()),t.querySelectorAll("*").forEach(t=>{for(const e of Array.from(t.attributes))e.name.toLowerCase().startsWith("on")&&t.removeAttribute(e.name),("xmlns"===e.name||e.name.startsWith("xmlns:"))&&t.removeAttribute(e.name)});const i=e.innerHTML;return"string"==typeof i?i.trim():(new XMLSerializer).serializeToString(e).replace(/^<svg[^>]*>|<\/svg>$/g,"").trim()}catch{return""}}function h(t){const e=t.trim();if(!e)return[];const n=`<svg xmlns="http://www.w3.org/2000/svg">${e}</svg>`;try{const t=(new DOMParser).parseFromString(n,"image/svg+xml"),e=t.documentElement;if(!e||"svg"!==e.nodeName.toLowerCase())return[];t.querySelectorAll("script, foreignObject").forEach(t=>t.remove()),t.querySelectorAll("*").forEach(t=>{for(const e of Array.from(t.attributes))e.name.toLowerCase().startsWith("on")&&t.removeAttribute(e.name),("xmlns"===e.name||e.name.startsWith("xmlns:"))&&t.removeAttribute(e.name)});const i=Array.from(e.children).map(t=>document.importNode(t,!0));for(const t of i)a(t);return i}catch{return[]}}function d(t){const e=l(t);if(!e)return null;const n=s.get(e);if(n)return n;const{svg:i,g:a}=function(){if(o&&r)return{svg:o,g:r};const t=document.createElementNS("http://www.w3.org/2000/svg","svg");t.setAttribute("width","0"),t.setAttribute("height","0"),t.style.position="absolute",t.style.left="-10000px",t.style.top="-10000px",t.style.visibility="hidden",t.style.pointerEvents="none";const e=document.createElementNS("http://www.w3.org/2000/svg","g");return t.appendChild(e),o=t,r=e,{svg:t,g:e}}();!function(t){t.isConnected||document.body.appendChild(t)}(i),a.replaceChildren();const d=h(e);for(const t of d)a.appendChild(t.cloneNode(!0));try{const t=a.getBBox();let n=0;a.querySelectorAll("*").forEach(t=>{try{const e=getComputedStyle(t),i=e.stroke;if(!i||"none"===i||"transparent"===i)return;const s=Number.parseFloat(e.strokeWidth??"0");Number.isFinite(s)&&s>n&&(n=s)}catch{}});const i=Math.max(0,n/2),o={bbox:{x:t.x,y:t.y,width:t.width,height:t.height},pad:i};return s.set(e,o),o}catch{return null}finally{!function(t){t.isConnected&&t.remove()}(i)}}t.Node=class{id;fragment;x;y;width;height;onClick;onDoubleClick;onRightClick;_el=null;constructor(t){if(!t||"string"!=typeof t.id||""===t.id)throw new Error("Node requires a non-empty 'id' property");this.id=t.id,this.fragment=t.fragment??"",this.x=Number.isFinite(t?.x)?t?.x:0,this.y=Number.isFinite(t?.y)?t?.y:0;const e=t?.width,n=t?.height;this.width="number"==typeof e&&Number.isFinite(e)&&e>0?e:null,this.height="number"==typeof n&&Number.isFinite(n)&&n>0?n:null,this.onClick=t?.onClick,this.onDoubleClick=t?.onDoubleClick,this.onRightClick=t?.onRightClick}get el(){if(!this._el){const t=document.createElementNS("http://www.w3.org/2000/svg","g");t.dataset.nodeId=this.id,this._el=t}return this._el}},t.PanZoomCanvas=i,t.SvgCore=class{canvas;nodesLayer;nodes=[];nodeIdToIndex=new Map;nodeBounds=null;cullingEnabled=!0;cullingOverscanPx=30;resizeObserver=null;unsubPanZoom=null;unsubSvgEvents=null;svgClickTimer=null;suppressNextClick=!1;dragWatch=null;cullingListeners=new Set;lastCullingStats={visible:0,hidden:0,total:0};cullingNotifyScheduled=!1;get svg(){return this.canvas.svg}get world(){return this.canvas.world}createWorldLayer(t,n){const i=e("g");t&&(i.dataset.layer=t),n?.pointerEvents&&(i.style.pointerEvents=n.pointerEvents);return"below-nodes"===(n?.position??"below-nodes")?this.world.insertBefore(i,this.nodesLayer):this.world.appendChild(i),i}get state(){return this.canvas.state}get panZoomOptions(){return this.canvas.options}constructor(t,e){t instanceof i?(this.canvas=t,e?.panZoom&&this.canvas.setOptions(e.panZoom)):this.canvas=new i(t,e?.panZoom),this.nodesLayer=document.createElementNS("http://www.w3.org/2000/svg","g"),this.nodesLayer.dataset.layer="nodes",this.world.appendChild(this.nodesLayer),this.world.style.pointerEvents="none";const n=e?.culling;"boolean"==typeof n?this.cullingEnabled=n:n&&("boolean"==typeof n.enabled&&(this.cullingEnabled=n.enabled),"number"==typeof n.overscanPx&&(this.cullingOverscanPx=Math.max(0,n.overscanPx))),this.unsubPanZoom=this.canvas.subscribe(()=>this.applyCulling()),this.resizeObserver=new ResizeObserver(()=>this.applyCulling()),this.resizeObserver.observe(this.svg);const s=()=>{null!==this.svgClickTimer&&(window.clearTimeout(this.svgClickTimer),this.svgClickTimer=null)},o=t=>{0===t.button&&(this.dragWatch={pointerId:t.pointerId,startClientX:t.clientX,startClientY:t.clientY,moved:!1})},r=t=>{const e=this.dragWatch;if(!e)return;if(t.pointerId!==e.pointerId)return;if(1&~t.buttons)return;const n=t.clientX-e.startClientX,i=t.clientY-e.startClientY;!e.moved&&Math.hypot(n,i)>=5&&(e.moved=!0)},a=t=>{const e=this.dragWatch;e&&t.pointerId===e.pointerId&&(this.dragWatch=null,e.moved&&(this.suppressNextClick=!0,s()))},l=t=>{if(this.suppressNextClick)this.suppressNextClick=!1;else{if(null!==this.svgClickTimer){s();const e=this.hitTestVisibleNodeAtClient(t.clientX,t.clientY);return void(e?.onDoubleClick&&e.onDoubleClick(e))}this.svgClickTimer=window.setTimeout(()=>{this.svgClickTimer=null;const e=this.hitTestVisibleNodeAtClient(t.clientX,t.clientY);e?.onClick&&e.onClick(e)},300)}},h=t=>{t.preventDefault(),s();const e=this.hitTestVisibleNodeAtClient(t.clientX,t.clientY);e?.onRightClick&&e.onRightClick(e)};this.svg.addEventListener("click",l),this.svg.addEventListener("contextmenu",h),this.svg.addEventListener("pointerdown",o),this.svg.addEventListener("pointermove",r),this.svg.addEventListener("pointerup",a),this.svg.addEventListener("pointercancel",a),this.unsubSvgEvents=()=>{this.svg.removeEventListener("click",l),this.svg.removeEventListener("contextmenu",h),this.svg.removeEventListener("pointerdown",o),this.svg.removeEventListener("pointermove",r),this.svg.removeEventListener("pointerup",a),this.svg.removeEventListener("pointercancel",a),s()}}setZoom(t,e){const n=this.canvas.options.minZoom,i=this.canvas.options.maxZoom,s=Math.min(i,Math.max(n,t)),o=this.svg.getBoundingClientRect(),r=e?.x??Math.max(1,o.width)/2,a=e?.y??Math.max(1,o.height)/2,l=this.state,h=r-(r-l.panX)/Math.max(1e-9,l.zoom)*s,d=a-(a-l.panY)/Math.max(1e-9,l.zoom)*s;this.setState({zoom:s,panX:h,panY:d})}clientToCanvas(t,e){const n=this.svg.getBoundingClientRect(),i=t-n.left,s=e-n.top,{panX:o,panY:r,zoom:a}=this.state,l=Math.max(1e-9,a);return{x:(i-o)/l,y:(s-r)/l}}hitTestVisibleNodeAtClient(t,e){if(!this.nodeBounds||0===this.nodes.length)return null;const n=this.clientToCanvas(t,e),i=this.nodesLayer.children;for(let t=i.length-1;t>=0;t--){const e=i.item(t);if(!e)continue;const s=e.dataset.nodeId;if(!s)continue;const o=this.nodeIdToIndex.get(s);if(void 0===o)continue;const r=this.nodeBounds[o];if(r&&(n.x>=r.x0&&n.x<=r.x1&&n.y>=r.y0&&n.y<=r.y1))return this.nodes[o]}return null}zoomBy(t,e){const n=Number.isFinite(t)?t:1;n<=0||this.setZoom(this.state.zoom*n,e)}setState(t){this.canvas.setState(t)}resetView(){this.canvas.reset()}configurePanZoom(t){this.canvas.setOptions(t)}setNodes(t){const e=new Set,n=new Set;for(let i=0;i<t.length;i++){const s=t[i].id;e.has(s)?n.add(s):e.add(s)}n.size>0&&console.warn(`Duplicate node ids found: ${Array.from(n).map(t=>`"${t}"`).join(", ")}. Each node should have a unique id.`),this.nodes=t,this.nodeIdToIndex.clear();for(let e=0;e<t.length;e++)this.nodeIdToIndex.set(t[e].id,e);this.redraw()}redraw(t){Array.isArray(t)&&t.length>0?(this.renderNodes(t),this.applyCulling()):(this.renderNodes(),this.applyCulling())}setCullingEnabled(t){this.cullingEnabled=t,this.applyCulling()}setCullingOverscanPx(t){this.cullingOverscanPx=Math.max(0,t),this.applyCulling()}onCullingStatsChange(t){return this.cullingListeners.add(t),t(this.lastCullingStats),()=>this.cullingListeners.delete(t)}onPanZoomChange(t){return this.canvas.subscribe(t)}remove(t){if(!t||0===t.length)return this.nodes=[],this.nodeIdToIndex.clear(),this.nodesLayer.replaceChildren(),this.nodeBounds=null,void this.setCullingStats({visible:0,hidden:0,total:0});const e=new Set;for(const n of t){const t=this.nodeIdToIndex.get(n);void 0!==t&&e.add(t)}if(0===e.size)return;const n=Array.from(e).sort((t,e)=>e-t);for(const t of n){const e=this.nodes[t];e&&(e.el.parentElement&&e.el.remove(),this.nodeIdToIndex.delete(e.id)),this.nodes.splice(t,1)}this.nodeIdToIndex.clear();for(let t=0;t<this.nodes.length;t++)this.nodeIdToIndex.set(this.nodes[t].id,t);if(this.nodeBounds){const t=[];for(let e=0;e<this.nodes.length;e++){const n=this.nodes[e],i=d(n.fragment),s=i?.bbox??{width:240,height:160},o=i?.pad??0,r=n.width??Math.max(1,s.width+2*o),a=n.height??Math.max(1,s.height+2*o);t.push({x0:n.x,y0:n.y,x1:n.x+r,y1:n.y+a})}this.nodeBounds=t}this.applyCulling()}destroy(){this.resizeObserver?.disconnect(),this.resizeObserver=null,this.unsubPanZoom?.(),this.unsubPanZoom=null,this.unsubSvgEvents?.(),this.unsubSvgEvents=null,this.cullingListeners.clear(),this.canvas.destroy()}renderNodes(t){if(t&&t.length>0){for(const e of t){const t=this.nodeIdToIndex.get(e);if(void 0===t)continue;const n=this.nodes[t];if(!n)continue;const i=n.el;i.replaceChildren(),i.setAttribute("transform",`translate(${n.x} ${n.y})`);const s=l(n.fragment);if(s){const e=h(s),o=d(s),r=o?.bbox??{x:0,y:0,width:240,height:160},a=o?.pad??0,l=Math.max(1,r.width+2*a),c=Math.max(1,r.height+2*a),u=-r.x+a,p=-r.y+a,g=document.createElementNS("http://www.w3.org/2000/svg","g");g.setAttribute("transform",`translate(${u} ${p})`);for(const t of e)g.appendChild(t.cloneNode(!0));if(i.appendChild(g),this.nodeBounds){const e=n.width??l,i=n.height??c;this.nodeBounds[t]={x0:n.x,y0:n.y,x1:n.x+e,y1:n.y+i}}}i.parentElement||this.nodesLayer.appendChild(i)}return}if(this.nodesLayer.replaceChildren(),this.nodeBounds=null,0===this.nodes.length)return;const e=new Map;for(const t of this.nodes){const n=l(t.fragment);if(!n)continue;if(e.has(n))continue;const i=h(n),s=d(n),o=s?.bbox??{x:0,y:0,width:240,height:160},r=s?.pad??0,a=Math.max(1,o.width+2*r),c=Math.max(1,o.height+2*r),u=-o.x+r,p=-o.y+r;e.set(n,{children:i,w:a,h:c,offsetX:u,offsetY:p})}const n=this.nodes.length,i=document.createDocumentFragment(),s=new Array(n);for(let t=0;t<n;t++){const n=this.nodes[t],o=n.el;o.replaceChildren(),o.setAttribute("transform",`translate(${n.x} ${n.y})`);const r=l(n.fragment),a=r?e.get(r):null;if(a){const t=document.createElementNS("http://www.w3.org/2000/svg","g");t.setAttribute("transform",`translate(${a.offsetX} ${a.offsetY})`);for(const e of a.children)t.appendChild(e.cloneNode(!0));o.appendChild(t)}const h=n.width??a?.w??240,d=n.height??a?.h??160;s[t]={x0:n.x,y0:n.y,x1:n.x+h,y1:n.y+d},i.appendChild(o)}this.nodesLayer.appendChild(i),this.nodeBounds=s}applyCulling(){if(!this.nodeBounds)return void this.setCullingStats({visible:0,hidden:0,total:this.nodes.length});const t=this.nodes.length;if(!this.cullingEnabled){this.nodesLayer.replaceChildren(...this.nodes.map(t=>t.el));for(const t of this.nodes)t.el.removeAttribute("display");return void this.setCullingStats({visible:t,hidden:0,total:t})}const e=this.getWorldViewport(this.state,this.cullingOverscanPx),n=[];for(let i=0;i<t;i++){const t=this.nodeBounds[i];if(t&&this.rectsIntersect(t,e)){const t=this.nodes[i].el;t.removeAttribute("display"),n.push(t)}}this.nodesLayer.replaceChildren(...n),this.setCullingStats({visible:n.length,hidden:t-n.length,total:t})}setCullingStats(t){const e=this.lastCullingStats;e.visible===t.visible&&e.hidden===t.hidden&&e.total===t.total||(this.lastCullingStats=t,this.scheduleCullingNotify())}scheduleCullingNotify(){this.cullingNotifyScheduled||(this.cullingNotifyScheduled=!0,requestAnimationFrame(()=>{this.cullingNotifyScheduled=!1;for(const t of this.cullingListeners)t(this.lastCullingStats)}))}rectsIntersect(t,e){return!(t.x1<e.x0||t.x0>e.x1||t.y1<e.y0||t.y0>e.y1)}getWorldViewport(t,e){const n=this.svg.getBoundingClientRect(),i=Math.max(1,n.width),s=Math.max(1,n.height),o=Math.max(1e-9,t.zoom),r=Math.max(0,e)/o;return{x0:-t.panX/o-r,y0:-t.panY/o-r,x1:(i-t.panX)/o+r,y1:(s-t.panY)/o+r}}},t.measureFragmentMetrics=d});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vkcha/svg-core",
3
- "version": "0.1.2",
3
+ "version": "1.0.0",
4
4
  "description": "A lightweight SVG rendering core library in TypeScript for the web: scene graph, viewport culling, zoom/pan, event handling",
5
5
  "keywords": [
6
6
  "svg",
@@ -24,15 +24,18 @@
24
24
  "homepage": "https://vkcha.com",
25
25
  "private": false,
26
26
  "type": "module",
27
- "main": "dist/vkcha.min.js",
27
+ "main": "dist/index.cjs",
28
+ "module": "dist/index.js",
28
29
  "types": "dist/index.d.ts",
29
30
  "exports": {
30
31
  ".": {
31
32
  "types": "./dist/index.d.ts",
32
- "import": "./dist/vkcha.min.js",
33
- "require": "./dist/vkcha.min.js"
33
+ "import": "./dist/index.js",
34
+ "require": "./dist/index.cjs"
34
35
  }
35
36
  },
37
+ "unpkg": "dist/vkcha.min.js",
38
+ "jsdelivr": "dist/vkcha.min.js",
36
39
  "files": [
37
40
  "dist",
38
41
  "src",
package/src/SvgCore.ts CHANGED
@@ -2,6 +2,7 @@ import type { PanZoomListener, PanZoomOptions, PanZoomState } from "./canvas/Pan
2
2
  import { PanZoomCanvas } from "./canvas/PanZoomCanvas";
3
3
  import type { Node } from "./scene/Node";
4
4
  import { measureFragmentMetrics, parseFragmentElements, sanitizeFragment } from "./scene/fragment";
5
+ import { svgEl } from "./utils/dom";
5
6
 
6
7
  export type CullingStats = {
7
8
  visible: number;
@@ -21,6 +22,8 @@ export type InitOptions = {
21
22
  culling?: boolean | CullingOptions;
22
23
  };
23
24
 
25
+ export type WorldLayerPosition = "below-nodes" | "above-nodes";
26
+
24
27
  /**
25
28
  * SvgCore entrypoint.
26
29
  *
@@ -68,6 +71,26 @@ export class SvgCore {
68
71
  return this.canvas.world;
69
72
  }
70
73
 
74
+ /**
75
+ * Create a custom <g> layer inside the world.
76
+ * Useful when you want to add your own SVG content.
77
+ */
78
+ createWorldLayer(
79
+ name?: string,
80
+ opts?: { position?: WorldLayerPosition; pointerEvents?: string },
81
+ ): SVGGElement {
82
+ const layer = svgEl("g");
83
+ if (name) layer.dataset.layer = name;
84
+ if (opts?.pointerEvents) layer.style.pointerEvents = opts.pointerEvents;
85
+ const position = opts?.position ?? "below-nodes";
86
+ if (position === "below-nodes") {
87
+ this.world.insertBefore(layer, this.nodesLayer);
88
+ } else {
89
+ this.world.appendChild(layer);
90
+ }
91
+ return layer;
92
+ }
93
+
71
94
  /** Current pan/zoom state. */
72
95
  get state(): PanZoomState {
73
96
  return this.canvas.state;
@@ -78,8 +101,15 @@ export class SvgCore {
78
101
  return this.canvas.options;
79
102
  }
80
103
 
81
- constructor(svg: SVGSVGElement, opts?: InitOptions) {
82
- this.canvas = new PanZoomCanvas(svg, opts?.panZoom);
104
+ constructor(svg: SVGSVGElement, opts?: InitOptions);
105
+ constructor(canvas: PanZoomCanvas, opts?: InitOptions);
106
+ constructor(svgOrCanvas: SVGSVGElement | PanZoomCanvas, opts?: InitOptions) {
107
+ if (svgOrCanvas instanceof PanZoomCanvas) {
108
+ this.canvas = svgOrCanvas;
109
+ if (opts?.panZoom) this.canvas.setOptions(opts.panZoom);
110
+ } else {
111
+ this.canvas = new PanZoomCanvas(svgOrCanvas, opts?.panZoom);
112
+ }
83
113
 
84
114
  this.nodesLayer = document.createElementNS("http://www.w3.org/2000/svg", "g");
85
115
  this.nodesLayer.dataset.layer = "nodes";