@vkcha/svg-core 0.1.2 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +149 -1
- package/dist/compat.js +391 -0
- package/dist/index.cjs +1142 -0
- package/dist/index.d.ts +47 -1
- package/dist/index.js +1137 -0
- package/dist/input-dom.js +198 -0
- package/dist/panzoom-svg.js +27 -0
- package/dist/renderer-svg-dom.js +261 -0
- package/dist/vkcha.min.js +1 -1
- package/package.json +7 -4
- package/src/SvgCore.ts +32 -2
- package/src/canvas/PanZoomCanvas.ts +181 -3
- package/src/index.ts +7 -2
package/README.md
CHANGED
|
@@ -7,8 +7,9 @@ Lightweight **SVG scene rendering core** for the web (TypeScript):
|
|
|
7
7
|
- **Viewport culling** (removes offscreen nodes from DOM for performance)
|
|
8
8
|
- **Hit-testing + node events** (`click`, “double click”, right click)
|
|
9
9
|
- **SVG fragment utilities** (sanitize/measure/parse fragments)
|
|
10
|
+
- **Smooth animations** (`animateTo` with custom easing)
|
|
10
11
|
|
|
11
|
-
**Live demo:**
|
|
12
|
+
**Live demo:** [vkcha.com](https://vkcha.com) | **Docs:** [vkcha.com/#docs](https://vkcha.com/#docs)
|
|
12
13
|
|
|
13
14
|
---
|
|
14
15
|
|
|
@@ -111,6 +112,150 @@ export function SvgScene() {
|
|
|
111
112
|
}
|
|
112
113
|
```
|
|
113
114
|
|
|
115
|
+
If you prefer a ready-made component wrapper, use `@vkcha/svg-core-react`:
|
|
116
|
+
|
|
117
|
+
```tsx
|
|
118
|
+
import { useMemo } from "react";
|
|
119
|
+
import { SvgCoreView } from "@vkcha/svg-core-react";
|
|
120
|
+
|
|
121
|
+
export function SvgScene() {
|
|
122
|
+
const nodes = useMemo(
|
|
123
|
+
() => [
|
|
124
|
+
{ id: "a", x: 0, y: 0, fragment: `<rect width="120" height="60" rx="10" fill="#10b981"/>` },
|
|
125
|
+
],
|
|
126
|
+
[],
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<SvgCoreView
|
|
131
|
+
init={{
|
|
132
|
+
panZoom: { wheelMode: "pan", zoomRequiresCtrlKey: true },
|
|
133
|
+
culling: { enabled: true },
|
|
134
|
+
}}
|
|
135
|
+
nodes={nodes}
|
|
136
|
+
style={{ width: "100%", height: "100%" }}
|
|
137
|
+
/>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
### Pan/zoom only (no nodes)
|
|
145
|
+
|
|
146
|
+
If you just want pan/zoom and plan to manage your own SVG content:
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
import { PanZoomCanvas } from "@vkcha/svg-core";
|
|
150
|
+
|
|
151
|
+
const svg = document.querySelector("#canvas") as SVGSVGElement;
|
|
152
|
+
const canvas = new PanZoomCanvas(svg, { wheelMode: "pan" });
|
|
153
|
+
|
|
154
|
+
// Add your own SVG elements under the world layer:
|
|
155
|
+
const layer = canvas.createLayer("custom");
|
|
156
|
+
layer.innerHTML = `
|
|
157
|
+
<rect x="0" y="0" width="200" height="120" fill="#60a5fa" />
|
|
158
|
+
<circle cx="240" cy="80" r="40" fill="#f59e0b" />
|
|
159
|
+
`;
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
If you want the same thing in React, use `@vkcha/svg-core-react`:
|
|
163
|
+
|
|
164
|
+
```tsx
|
|
165
|
+
import { useEffect, useRef } from "react";
|
|
166
|
+
import { PanZoomCanvasView } from "@vkcha/svg-core-react";
|
|
167
|
+
|
|
168
|
+
export function PanZoomOnly() {
|
|
169
|
+
const viewRef = useRef<React.ElementRef<typeof PanZoomCanvasView> | null>(null);
|
|
170
|
+
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
const world = viewRef.current?.getWorld();
|
|
173
|
+
if (!world) return;
|
|
174
|
+
world.innerHTML = `
|
|
175
|
+
<rect x="0" y="0" width="200" height="120" fill="#60a5fa" />
|
|
176
|
+
<circle cx="240" cy="80" r="40" fill="#f59e0b" />
|
|
177
|
+
`;
|
|
178
|
+
}, []);
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<PanZoomCanvasView
|
|
182
|
+
ref={viewRef}
|
|
183
|
+
options={{ wheelMode: "pan" }}
|
|
184
|
+
style={{ width: "100%", height: "100%" }}
|
|
185
|
+
/>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
If you prefer to initialize `SvgCore` and still add custom content:
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
import { SvgCore } from "@vkcha/svg-core";
|
|
194
|
+
|
|
195
|
+
const svg = document.querySelector("#canvas") as SVGSVGElement;
|
|
196
|
+
const core = new SvgCore(svg, { panZoom: { wheelMode: "pan" } });
|
|
197
|
+
|
|
198
|
+
const layer = core.createWorldLayer("custom", { position: "below-nodes" });
|
|
199
|
+
layer.innerHTML = `<path d="M10 10H200V60Z" fill="#34d399" />`;
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
#### `PanZoomCanvas` API (concise reference)
|
|
203
|
+
|
|
204
|
+
**State**
|
|
205
|
+
|
|
206
|
+
- `canvas.state: { zoom, panX, panY }` — current state
|
|
207
|
+
- `canvas.setState(partial)` — set state immediately
|
|
208
|
+
- `canvas.reset()` — reset to `{ zoom: 1, panX: 0, panY: 0 }`
|
|
209
|
+
|
|
210
|
+
**Animation**
|
|
211
|
+
|
|
212
|
+
- `canvas.animateTo(target, durationMs?, easing?)` — animate to a target state (returns `Promise<void>`)
|
|
213
|
+
- `canvas.stopAnimation()` — cancel any running animation
|
|
214
|
+
|
|
215
|
+
Example: smooth zoom with ease-out cubic:
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
await canvas.animateTo(
|
|
219
|
+
{ zoom: 2, panX: 100, panY: 50 },
|
|
220
|
+
400,
|
|
221
|
+
(t) => 1 - Math.pow(1 - t, 3), // ease-out cubic
|
|
222
|
+
);
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
**Layers**
|
|
226
|
+
|
|
227
|
+
- `canvas.createLayer(name?, opts?)` — create a `<g>` inside the world
|
|
228
|
+
- `opts.position`: `"front"` (default) or `"back"`
|
|
229
|
+
- `opts.pointerEvents`: CSS `pointer-events` value (e.g., `"none"`)
|
|
230
|
+
|
|
231
|
+
**Subscription**
|
|
232
|
+
|
|
233
|
+
- `canvas.subscribe(fn)` — listen to state changes (returns unsubscribe function)
|
|
234
|
+
|
|
235
|
+
**Options**
|
|
236
|
+
|
|
237
|
+
- `canvas.setOptions(partial)` — update options at runtime
|
|
238
|
+
|
|
239
|
+
**World Group Configuration**
|
|
240
|
+
|
|
241
|
+
Configure the world `<g>` element with custom attributes:
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
const canvas = new PanZoomCanvas(svg, {
|
|
245
|
+
worldGroup: {
|
|
246
|
+
id: "canvas-world",
|
|
247
|
+
attributes: { "data-testid": "world" },
|
|
248
|
+
dynamicAttributes: {
|
|
249
|
+
"data-zoom": (zoom) => String(Math.round(zoom * 100)),
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
**Cleanup**
|
|
256
|
+
|
|
257
|
+
- `canvas.destroy()` — remove all listeners, cancel animations
|
|
258
|
+
|
|
114
259
|
---
|
|
115
260
|
|
|
116
261
|
### Core concepts
|
|
@@ -129,6 +274,7 @@ Useful properties:
|
|
|
129
274
|
- `core.world`: a `<g>` for “world space” content
|
|
130
275
|
- `core.state`: `{ zoom, panX, panY }`
|
|
131
276
|
- `core.panZoomOptions`: merged pan/zoom options (min/max, wheel mode, etc.)
|
|
277
|
+
- `core.createWorldLayer(...)`: create a custom `<g>` inside the world layer
|
|
132
278
|
|
|
133
279
|
Pan/zoom can be configured **on init** via `new SvgCore(svg, { panZoom: ... })` and **any time later** via `core.configurePanZoom(...)`.
|
|
134
280
|
|
|
@@ -148,8 +294,10 @@ Pan/zoom can be configured **on init** via `new SvgCore(svg, { panZoom: ... })`
|
|
|
148
294
|
- `minZoom: 0.2`
|
|
149
295
|
- `maxZoom: 8`
|
|
150
296
|
- `zoomSpeed: 1`
|
|
297
|
+
- `pinchZoomSpeed: 2` — extra speed multiplier for trackpad pinch gestures
|
|
151
298
|
- `invertZoom: false`
|
|
152
299
|
- `invertPan: false`
|
|
300
|
+
- `worldGroup: undefined` — optional configuration for the world `<g>` element (id, attributes, dynamic attributes)
|
|
153
301
|
|
|
154
302
|
**Culling defaults**
|
|
155
303
|
|
package/dist/compat.js
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import { createEngine } from './index.js';
|
|
2
|
+
import { attachDomInput } from './input-dom.js';
|
|
3
|
+
import { createSvgDomRenderer, measureFragmentMetrics } from './renderer-svg-dom.js';
|
|
4
|
+
export { parseFragmentElements, sanitizeFragment } from './renderer-svg-dom.js';
|
|
5
|
+
|
|
6
|
+
class SvgCore {
|
|
7
|
+
engine;
|
|
8
|
+
input;
|
|
9
|
+
renderer;
|
|
10
|
+
worldEl;
|
|
11
|
+
nodesLayerEl;
|
|
12
|
+
nodes = [];
|
|
13
|
+
nodeById = new Map();
|
|
14
|
+
cullingListeners = new Set();
|
|
15
|
+
lastCullingStats = { visible: 0, hidden: 0, total: 0 };
|
|
16
|
+
get svg() {
|
|
17
|
+
return this.input.host;
|
|
18
|
+
}
|
|
19
|
+
get world() {
|
|
20
|
+
return this.worldEl;
|
|
21
|
+
}
|
|
22
|
+
get state() {
|
|
23
|
+
const c = this.engine.getCamera();
|
|
24
|
+
return { zoom: c.zoom, panX: c.panX, panY: c.panY };
|
|
25
|
+
}
|
|
26
|
+
get panZoomOptions() {
|
|
27
|
+
const o = this.input.getOptions();
|
|
28
|
+
return {
|
|
29
|
+
wheelMode: o.wheelMode,
|
|
30
|
+
zoomRequiresCtrlKey: o.zoomRequiresCtrlKey,
|
|
31
|
+
panRequiresSpaceKey: o.panRequiresSpaceKey,
|
|
32
|
+
minZoom: o.minZoom,
|
|
33
|
+
maxZoom: o.maxZoom,
|
|
34
|
+
zoomSpeed: o.zoomSpeed,
|
|
35
|
+
invertZoom: o.invertZoom,
|
|
36
|
+
invertPan: o.invertPan,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
constructor(svg, opts) {
|
|
40
|
+
const c = opts?.culling;
|
|
41
|
+
const cullingEnabled = typeof c === "boolean" ? c : c?.enabled ?? true;
|
|
42
|
+
const overscanPx = Math.max(0, typeof c === "object" && c ? (c.overscanPx ?? 30) : 30);
|
|
43
|
+
this.engine = createEngine({
|
|
44
|
+
culling: { enabled: cullingEnabled, overscanPx },
|
|
45
|
+
getNodeBounds: (node) => this.getNodeBounds(node),
|
|
46
|
+
order: (a, b) => {
|
|
47
|
+
const za = a.zIndex ?? 0;
|
|
48
|
+
const zb = b.zIndex ?? 0;
|
|
49
|
+
if (za !== zb)
|
|
50
|
+
return za - zb;
|
|
51
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
const renderer = createSvgDomRenderer({
|
|
55
|
+
getFragment: (n) => n.data?.fragment ?? "",
|
|
56
|
+
getNodeKey: (n) => n.data?.key ?? n.id,
|
|
57
|
+
});
|
|
58
|
+
this.renderer = renderer;
|
|
59
|
+
renderer.mount(svg);
|
|
60
|
+
this.engine.attachRenderer(renderer);
|
|
61
|
+
const world = svg.querySelector(`g[data-layer="world"]`);
|
|
62
|
+
const nodesLayer = svg.querySelector(`g[data-layer="nodes"]`);
|
|
63
|
+
if (!(world instanceof SVGGElement) || !(nodesLayer instanceof SVGGElement)) {
|
|
64
|
+
throw new Error("SvgCore renderer mount failed");
|
|
65
|
+
}
|
|
66
|
+
this.worldEl = world;
|
|
67
|
+
this.nodesLayerEl = nodesLayer;
|
|
68
|
+
this.worldEl.style.pointerEvents = "none";
|
|
69
|
+
this.nodesLayerEl.style.pointerEvents = "none";
|
|
70
|
+
svg.style.touchAction = "none";
|
|
71
|
+
svg.style.userSelect = "none";
|
|
72
|
+
this.input = attachDomInput(svg, this.engine, {
|
|
73
|
+
...(opts?.panZoom ?? {}),
|
|
74
|
+
clickDelayMs: 300,
|
|
75
|
+
dragThresholdPx: 5,
|
|
76
|
+
onClick: (hit) => {
|
|
77
|
+
const node = hit ? this.nodeById.get(hit.id) ?? null : null;
|
|
78
|
+
node?.onClick?.(node);
|
|
79
|
+
},
|
|
80
|
+
onDoubleClick: (hit) => {
|
|
81
|
+
const node = hit ? this.nodeById.get(hit.id) ?? null : null;
|
|
82
|
+
node?.onDoubleClick?.(node);
|
|
83
|
+
},
|
|
84
|
+
onRightClick: (hit) => {
|
|
85
|
+
const node = hit ? this.nodeById.get(hit.id) ?? null : null;
|
|
86
|
+
node?.onRightClick?.(node);
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
this.engine.onCullingStatsChange((s) => {
|
|
90
|
+
const next = { visible: s.visible, hidden: s.hidden, total: s.total };
|
|
91
|
+
this.lastCullingStats = next;
|
|
92
|
+
for (const fn of this.cullingListeners)
|
|
93
|
+
fn(next);
|
|
94
|
+
});
|
|
95
|
+
this.engine.start();
|
|
96
|
+
}
|
|
97
|
+
setZoom(nextZoom, anchor) {
|
|
98
|
+
const o = this.panZoomOptions;
|
|
99
|
+
const z = clamp(nextZoom, o.minZoom, o.maxZoom);
|
|
100
|
+
const vp = this.engine.getViewport();
|
|
101
|
+
const ax = anchor?.x ?? Math.max(1, vp.width) / 2;
|
|
102
|
+
const ay = anchor?.y ?? Math.max(1, vp.height) / 2;
|
|
103
|
+
this.engine.zoomAt({ x: ax, y: ay }, z);
|
|
104
|
+
}
|
|
105
|
+
zoomBy(factor, anchor) {
|
|
106
|
+
const f = Number.isFinite(factor) ? factor : 1;
|
|
107
|
+
if (f <= 0)
|
|
108
|
+
return;
|
|
109
|
+
this.setZoom(this.state.zoom * f, anchor);
|
|
110
|
+
}
|
|
111
|
+
setState(next) {
|
|
112
|
+
this.engine.setCamera(next);
|
|
113
|
+
}
|
|
114
|
+
resetView() {
|
|
115
|
+
this.engine.setCamera({ zoom: 1, panX: 0, panY: 0 });
|
|
116
|
+
}
|
|
117
|
+
configurePanZoom(next) {
|
|
118
|
+
this.input.setOptions({
|
|
119
|
+
wheelMode: next.wheelMode,
|
|
120
|
+
zoomRequiresCtrlKey: next.zoomRequiresCtrlKey,
|
|
121
|
+
panRequiresSpaceKey: next.panRequiresSpaceKey,
|
|
122
|
+
minZoom: next.minZoom,
|
|
123
|
+
maxZoom: next.maxZoom,
|
|
124
|
+
zoomSpeed: next.zoomSpeed,
|
|
125
|
+
invertZoom: next.invertZoom,
|
|
126
|
+
invertPan: next.invertPan,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
clientToCanvas(clientX, clientY) {
|
|
130
|
+
const r = this.svg.getBoundingClientRect();
|
|
131
|
+
const sx = clientX - r.left;
|
|
132
|
+
const sy = clientY - r.top;
|
|
133
|
+
return this.engine.screenToWorld(sx, sy);
|
|
134
|
+
}
|
|
135
|
+
hitTestVisibleNodeAtClient(clientX, clientY) {
|
|
136
|
+
const r = this.svg.getBoundingClientRect();
|
|
137
|
+
const sx = clientX - r.left;
|
|
138
|
+
const sy = clientY - r.top;
|
|
139
|
+
const hit = this.engine.hitTestScreenPoint(sx, sy);
|
|
140
|
+
return hit ? this.nodeById.get(hit.id) ?? null : null;
|
|
141
|
+
}
|
|
142
|
+
setNodes(nodes) {
|
|
143
|
+
const seenIds = new Set();
|
|
144
|
+
const duplicateIds = new Set();
|
|
145
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
146
|
+
const id = nodes[i].id;
|
|
147
|
+
if (seenIds.has(id))
|
|
148
|
+
duplicateIds.add(id);
|
|
149
|
+
else
|
|
150
|
+
seenIds.add(id);
|
|
151
|
+
}
|
|
152
|
+
if (duplicateIds.size > 0) {
|
|
153
|
+
console.warn(`Duplicate node ids found: ${Array.from(duplicateIds)
|
|
154
|
+
.map((id) => `"${id}"`)
|
|
155
|
+
.join(", ")}. Each node should have a unique id.`);
|
|
156
|
+
}
|
|
157
|
+
this.nodes = nodes;
|
|
158
|
+
this.nodeById.clear();
|
|
159
|
+
for (const n of nodes)
|
|
160
|
+
this.nodeById.set(n.id, n);
|
|
161
|
+
this.engine.setScene(nodes.map((n) => this.toSceneNode(n)));
|
|
162
|
+
this.engine.renderOnce();
|
|
163
|
+
}
|
|
164
|
+
redraw(ids) {
|
|
165
|
+
if (!ids || ids.length === 0) {
|
|
166
|
+
this.engine.setScene(this.nodes.map((n) => this.toSceneNode(n)));
|
|
167
|
+
this.engine.renderOnce();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
for (const id of ids) {
|
|
171
|
+
const n = this.nodeById.get(id);
|
|
172
|
+
if (!n)
|
|
173
|
+
continue;
|
|
174
|
+
this.engine.updateNode(id, this.toSceneNode(n));
|
|
175
|
+
}
|
|
176
|
+
this.engine.renderOnce();
|
|
177
|
+
}
|
|
178
|
+
setCullingEnabled(enabled) {
|
|
179
|
+
this.engine.setCulling({ enabled });
|
|
180
|
+
this.engine.renderOnce();
|
|
181
|
+
}
|
|
182
|
+
setCullingOverscanPx(px) {
|
|
183
|
+
this.engine.setCulling({ overscanPx: Math.max(0, px) });
|
|
184
|
+
this.engine.renderOnce();
|
|
185
|
+
}
|
|
186
|
+
onCullingStatsChange(fn) {
|
|
187
|
+
this.cullingListeners.add(fn);
|
|
188
|
+
fn(this.lastCullingStats);
|
|
189
|
+
return () => this.cullingListeners.delete(fn);
|
|
190
|
+
}
|
|
191
|
+
onPanZoomChange(fn) {
|
|
192
|
+
let first = true;
|
|
193
|
+
return this.engine.onCameraChange((c) => {
|
|
194
|
+
if (first) {
|
|
195
|
+
first = false;
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
fn({ zoom: c.zoom, panX: c.panX, panY: c.panY });
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
remove(ids) {
|
|
202
|
+
if (!ids || ids.length === 0) {
|
|
203
|
+
this.nodes = [];
|
|
204
|
+
this.nodeById.clear();
|
|
205
|
+
this.engine.removeNodes();
|
|
206
|
+
this.engine.renderOnce();
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const idSet = new Set(ids);
|
|
210
|
+
this.nodes = this.nodes.filter((n) => !idSet.has(n.id));
|
|
211
|
+
for (const id of ids)
|
|
212
|
+
this.nodeById.delete(id);
|
|
213
|
+
this.engine.removeNodes(ids);
|
|
214
|
+
this.engine.renderOnce();
|
|
215
|
+
}
|
|
216
|
+
destroy() {
|
|
217
|
+
this.input.detach();
|
|
218
|
+
this.engine.stop();
|
|
219
|
+
this.engine.detachRenderer();
|
|
220
|
+
this.renderer.unmount();
|
|
221
|
+
this.nodesLayerEl.replaceChildren();
|
|
222
|
+
this.nodeById.clear();
|
|
223
|
+
this.nodes = [];
|
|
224
|
+
this.cullingListeners.clear();
|
|
225
|
+
}
|
|
226
|
+
toSceneNode(n) {
|
|
227
|
+
const width = n.width === null ? undefined : n.width;
|
|
228
|
+
const height = n.height === null ? undefined : n.height;
|
|
229
|
+
const fragment = n.fragment ?? "";
|
|
230
|
+
const key = `${n.id}:${fragment}`;
|
|
231
|
+
return {
|
|
232
|
+
id: n.id,
|
|
233
|
+
x: n.x,
|
|
234
|
+
y: n.y,
|
|
235
|
+
width,
|
|
236
|
+
height,
|
|
237
|
+
zIndex: n.zIndex,
|
|
238
|
+
data: { fragment, key },
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
getNodeBounds(node) {
|
|
242
|
+
const d = node.data;
|
|
243
|
+
const fragment = d?.fragment ?? "";
|
|
244
|
+
const metrics = measureFragmentMetrics(fragment);
|
|
245
|
+
const bbox = metrics?.bbox ?? { width: 240, height: 160 };
|
|
246
|
+
const pad = metrics?.pad ?? 0;
|
|
247
|
+
const ww = typeof node.width === "number" && Number.isFinite(node.width) && node.width > 0
|
|
248
|
+
? node.width
|
|
249
|
+
: Math.max(1, bbox.width + pad * 2);
|
|
250
|
+
const hh = typeof node.height === "number" && Number.isFinite(node.height) && node.height > 0
|
|
251
|
+
? node.height
|
|
252
|
+
: Math.max(1, bbox.height + pad * 2);
|
|
253
|
+
return { x0: node.x, y0: node.y, x1: node.x + ww, y1: node.y + hh };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
function clamp(v, min, max) {
|
|
257
|
+
const n = Number.isFinite(v) ? v : min;
|
|
258
|
+
return Math.max(min, Math.min(max, n));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const DEFAULT_PANZOOM_OPTIONS = {
|
|
262
|
+
wheelMode: "pan",
|
|
263
|
+
zoomRequiresCtrlKey: false,
|
|
264
|
+
panRequiresSpaceKey: false,
|
|
265
|
+
minZoom: 0.2,
|
|
266
|
+
maxZoom: 8,
|
|
267
|
+
zoomSpeed: 1,
|
|
268
|
+
invertZoom: false,
|
|
269
|
+
invertPan: false,
|
|
270
|
+
};
|
|
271
|
+
class PanZoomCanvas {
|
|
272
|
+
svg;
|
|
273
|
+
world;
|
|
274
|
+
engine;
|
|
275
|
+
options = { ...DEFAULT_PANZOOM_OPTIONS };
|
|
276
|
+
input = null;
|
|
277
|
+
listeners = new Set();
|
|
278
|
+
notifyScheduled = false;
|
|
279
|
+
unsubEngine = null;
|
|
280
|
+
constructor(svg, opts = {}) {
|
|
281
|
+
this.svg = svg;
|
|
282
|
+
this.world = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
|
283
|
+
this.world.dataset.layer = "world";
|
|
284
|
+
this.svg.replaceChildren(this.world);
|
|
285
|
+
this.engine = createEngine({ culling: { enabled: false, overscanPx: 0 } });
|
|
286
|
+
this.setOptions(opts);
|
|
287
|
+
this.svg.style.touchAction = "none";
|
|
288
|
+
this.svg.style.userSelect = "none";
|
|
289
|
+
this.unsubEngine = this.engine.onCameraChange((c) => {
|
|
290
|
+
this.world.setAttribute("transform", `matrix(${c.zoom} 0 0 ${c.zoom} ${c.panX} ${c.panY})`);
|
|
291
|
+
this.scheduleNotify();
|
|
292
|
+
});
|
|
293
|
+
this.input = attachDomInput(this.svg, this.engine, {
|
|
294
|
+
wheelMode: this.options.wheelMode,
|
|
295
|
+
zoomRequiresCtrlKey: this.options.zoomRequiresCtrlKey,
|
|
296
|
+
panRequiresSpaceKey: this.options.panRequiresSpaceKey,
|
|
297
|
+
minZoom: this.options.minZoom,
|
|
298
|
+
maxZoom: this.options.maxZoom,
|
|
299
|
+
zoomSpeed: this.options.zoomSpeed,
|
|
300
|
+
invertZoom: this.options.invertZoom,
|
|
301
|
+
invertPan: this.options.invertPan,
|
|
302
|
+
clickDelayMs: 0,
|
|
303
|
+
dragThresholdPx: 0,
|
|
304
|
+
});
|
|
305
|
+
this.engine.start();
|
|
306
|
+
}
|
|
307
|
+
get state() {
|
|
308
|
+
const c = this.engine.getCamera();
|
|
309
|
+
return { zoom: c.zoom, panX: c.panX, panY: c.panY };
|
|
310
|
+
}
|
|
311
|
+
setOptions(next) {
|
|
312
|
+
this.options = { ...DEFAULT_PANZOOM_OPTIONS, ...this.options, ...next };
|
|
313
|
+
this.input?.setOptions({
|
|
314
|
+
wheelMode: this.options.wheelMode,
|
|
315
|
+
zoomRequiresCtrlKey: this.options.zoomRequiresCtrlKey,
|
|
316
|
+
panRequiresSpaceKey: this.options.panRequiresSpaceKey,
|
|
317
|
+
minZoom: this.options.minZoom,
|
|
318
|
+
maxZoom: this.options.maxZoom,
|
|
319
|
+
zoomSpeed: this.options.zoomSpeed,
|
|
320
|
+
invertZoom: this.options.invertZoom,
|
|
321
|
+
invertPan: this.options.invertPan,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
subscribe(fn) {
|
|
325
|
+
this.listeners.add(fn);
|
|
326
|
+
return () => this.listeners.delete(fn);
|
|
327
|
+
}
|
|
328
|
+
setState(next) {
|
|
329
|
+
this.engine.setCamera(next);
|
|
330
|
+
}
|
|
331
|
+
reset() {
|
|
332
|
+
this.engine.setCamera({ zoom: 1, panX: 0, panY: 0 });
|
|
333
|
+
}
|
|
334
|
+
destroy() {
|
|
335
|
+
this.unsubEngine?.();
|
|
336
|
+
this.unsubEngine = null;
|
|
337
|
+
this.listeners.clear();
|
|
338
|
+
this.input?.detach();
|
|
339
|
+
this.input = null;
|
|
340
|
+
this.engine.stop();
|
|
341
|
+
}
|
|
342
|
+
scheduleNotify() {
|
|
343
|
+
if (this.notifyScheduled)
|
|
344
|
+
return;
|
|
345
|
+
this.notifyScheduled = true;
|
|
346
|
+
requestAnimationFrame(() => {
|
|
347
|
+
this.notifyScheduled = false;
|
|
348
|
+
const s = this.state;
|
|
349
|
+
for (const fn of this.listeners)
|
|
350
|
+
fn(s);
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* A scene-graph "node" for the SVG core.
|
|
357
|
+
*/
|
|
358
|
+
class Node {
|
|
359
|
+
id;
|
|
360
|
+
fragment;
|
|
361
|
+
x;
|
|
362
|
+
y;
|
|
363
|
+
width;
|
|
364
|
+
height;
|
|
365
|
+
zIndex;
|
|
366
|
+
data;
|
|
367
|
+
onClick;
|
|
368
|
+
onDoubleClick;
|
|
369
|
+
onRightClick;
|
|
370
|
+
constructor(opts) {
|
|
371
|
+
if (!opts || typeof opts.id !== "string" || opts.id === "") {
|
|
372
|
+
throw new Error("Node requires a non-empty 'id' property");
|
|
373
|
+
}
|
|
374
|
+
this.id = opts.id;
|
|
375
|
+
this.fragment = opts.fragment ?? "";
|
|
376
|
+
this.x = Number.isFinite(opts?.x) ? opts?.x : 0;
|
|
377
|
+
this.y = Number.isFinite(opts?.y) ? opts?.y : 0;
|
|
378
|
+
const w = opts?.width;
|
|
379
|
+
const h = opts?.height;
|
|
380
|
+
this.width = typeof w === "number" && Number.isFinite(w) && w > 0 ? w : null;
|
|
381
|
+
this.height = typeof h === "number" && Number.isFinite(h) && h > 0 ? h : null;
|
|
382
|
+
const z = opts?.zIndex;
|
|
383
|
+
this.zIndex = typeof z === "number" && Number.isFinite(z) ? z : 0;
|
|
384
|
+
this.data = opts?.data;
|
|
385
|
+
this.onClick = opts?.onClick;
|
|
386
|
+
this.onDoubleClick = opts?.onDoubleClick;
|
|
387
|
+
this.onRightClick = opts?.onRightClick;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export { Node, PanZoomCanvas, SvgCore, measureFragmentMetrics };
|