@vkcha/svg-core 0.1.2 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +149 -1
- package/dist/compat.js +391 -0
- package/dist/index.cjs +1142 -0
- package/dist/index.d.ts +47 -1
- package/dist/index.js +1137 -0
- package/dist/input-dom.js +198 -0
- package/dist/panzoom-svg.js +27 -0
- package/dist/renderer-svg-dom.js +261 -0
- package/dist/vkcha.min.js +1 -1
- package/package.json +7 -4
- package/src/SvgCore.ts +32 -2
- package/src/canvas/PanZoomCanvas.ts +181 -3
- package/src/index.ts +7 -2
|
@@ -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.
|
|
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/
|
|
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/
|
|
33
|
-
"require": "./dist/
|
|
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
|
-
|
|
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";
|