@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 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:** `https://vkcha.com`
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 };