@vkcha/svg-core 0.1.0 → 0.1.2
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/LICENSE +21 -0
- package/README.md +283 -19
- package/package.json +9 -7
- package/src/SvgCore.ts +623 -0
- package/src/canvas/PanZoomCanvas.ts +302 -0
- package/src/index.ts +11 -0
- package/src/scene/Node.ts +66 -0
- package/src/scene/fragment.ts +146 -0
- package/src/utils/dom.ts +17 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vkcha
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,40 +1,304 @@
|
|
|
1
|
-
|
|
1
|
+
### @vkcha/svg-core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Lightweight **SVG scene rendering core** for the web (TypeScript):
|
|
4
4
|
|
|
5
|
-
**
|
|
5
|
+
- **Pan / zoom** on an `<svg>` (wheel + pointer drag)
|
|
6
|
+
- **Scene graph** of many “nodes” backed by `<g>` elements
|
|
7
|
+
- **Viewport culling** (removes offscreen nodes from DOM for performance)
|
|
8
|
+
- **Hit-testing + node events** (`click`, “double click”, right click)
|
|
9
|
+
- **SVG fragment utilities** (sanitize/measure/parse fragments)
|
|
10
|
+
|
|
11
|
+
**Live demo:** `https://vkcha.com`
|
|
6
12
|
|
|
7
13
|
---
|
|
8
14
|
|
|
9
|
-
###
|
|
15
|
+
### Install
|
|
10
16
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
- **`demo/index.html`**: demo page used by Vite
|
|
17
|
+
```bash
|
|
18
|
+
npm i @vkcha/svg-core
|
|
19
|
+
```
|
|
15
20
|
|
|
16
21
|
---
|
|
17
22
|
|
|
18
|
-
###
|
|
23
|
+
### Quick start (Vanilla TS/JS)
|
|
19
24
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
25
|
+
Create an `<svg>` that fills its container:
|
|
26
|
+
|
|
27
|
+
```html
|
|
28
|
+
<div id="root" style="height: 100vh">
|
|
29
|
+
<svg id="canvas" style="width: 100%; height: 100%" xmlns="http://www.w3.org/2000/svg"></svg>
|
|
30
|
+
</div>
|
|
23
31
|
```
|
|
24
32
|
|
|
25
|
-
Then
|
|
33
|
+
Then initialize the core and draw a few nodes:
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { SvgCore, Node } from "@vkcha/svg-core";
|
|
37
|
+
|
|
38
|
+
const svg = document.querySelector("#canvas") as SVGSVGElement;
|
|
39
|
+
|
|
40
|
+
const core = new SvgCore(svg, {
|
|
41
|
+
panZoom: {
|
|
42
|
+
wheelMode: "pan", // or "zoom"
|
|
43
|
+
zoomRequiresCtrlKey: true, // macOS pinch usually sets ctrlKey=true
|
|
44
|
+
},
|
|
45
|
+
culling: { enabled: true, overscanPx: 30 },
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
core.setNodes([
|
|
49
|
+
new Node({
|
|
50
|
+
id: "hello",
|
|
51
|
+
x: 0,
|
|
52
|
+
y: 0,
|
|
53
|
+
fragment: `
|
|
54
|
+
<rect width="160" height="60" rx="10" fill="#111827"/>
|
|
55
|
+
<text x="16" y="38" font-size="18" fill="white">Hello</text>
|
|
56
|
+
`,
|
|
57
|
+
onClick: (n) => console.log("clicked", n.id),
|
|
58
|
+
}),
|
|
59
|
+
new Node({
|
|
60
|
+
id: "world",
|
|
61
|
+
x: 220,
|
|
62
|
+
y: 120,
|
|
63
|
+
fragment: `<circle r="40" cx="40" cy="40" fill="#60a5fa"/>`,
|
|
64
|
+
onRightClick: (n) => console.log("right click", n.id),
|
|
65
|
+
}),
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
// Optional: observe state changes (event-driven, no polling)
|
|
69
|
+
const unsub = core.onPanZoomChange((s) => console.log("pan/zoom", s));
|
|
70
|
+
|
|
71
|
+
// Cleanup
|
|
72
|
+
// unsub(); core.destroy();
|
|
73
|
+
```
|
|
26
74
|
|
|
27
75
|
---
|
|
28
76
|
|
|
29
|
-
###
|
|
77
|
+
### Quick start (React)
|
|
30
78
|
|
|
31
|
-
```
|
|
32
|
-
|
|
79
|
+
```tsx
|
|
80
|
+
import { useEffect, useRef } from "react";
|
|
81
|
+
import { SvgCore, Node } from "@vkcha/svg-core";
|
|
82
|
+
|
|
83
|
+
export function SvgScene() {
|
|
84
|
+
const svgRef = useRef<SVGSVGElement | null>(null);
|
|
85
|
+
const coreRef = useRef<SvgCore | null>(null);
|
|
86
|
+
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (!svgRef.current) return;
|
|
89
|
+
const core = new SvgCore(svgRef.current, {
|
|
90
|
+
panZoom: { wheelMode: "pan", zoomRequiresCtrlKey: true },
|
|
91
|
+
culling: { enabled: true, overscanPx: 30 },
|
|
92
|
+
});
|
|
93
|
+
coreRef.current = core;
|
|
94
|
+
|
|
95
|
+
core.setNodes([
|
|
96
|
+
new Node({
|
|
97
|
+
id: "a",
|
|
98
|
+
x: 0,
|
|
99
|
+
y: 0,
|
|
100
|
+
fragment: `<rect width="120" height="60" rx="10" fill="#10b981"/>`,
|
|
101
|
+
}),
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
return () => {
|
|
105
|
+
core.destroy();
|
|
106
|
+
coreRef.current = null;
|
|
107
|
+
};
|
|
108
|
+
}, []);
|
|
109
|
+
|
|
110
|
+
return <svg ref={svgRef} style={{ width: "100%", height: "100%" }} />;
|
|
111
|
+
}
|
|
33
112
|
```
|
|
34
113
|
|
|
35
|
-
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
### Core concepts
|
|
117
|
+
|
|
118
|
+
#### `SvgCore(svg, options?)`
|
|
119
|
+
|
|
120
|
+
`SvgCore` owns:
|
|
36
121
|
|
|
37
|
-
- `
|
|
38
|
-
-
|
|
122
|
+
- an internal **`PanZoomCanvas`** (creates a `world` `<g>` and applies a `matrix(...)` transform)
|
|
123
|
+
- a dedicated **nodes layer** (`<g data-layer="nodes">`) inside `world`
|
|
124
|
+
- culling + hit-testing + interaction wiring on the root `<svg>`
|
|
125
|
+
|
|
126
|
+
Useful properties:
|
|
127
|
+
|
|
128
|
+
- `core.svg`: the root `SVGSVGElement`
|
|
129
|
+
- `core.world`: a `<g>` for “world space” content
|
|
130
|
+
- `core.state`: `{ zoom, panX, panY }`
|
|
131
|
+
- `core.panZoomOptions`: merged pan/zoom options (min/max, wheel mode, etc.)
|
|
132
|
+
|
|
133
|
+
Pan/zoom can be configured **on init** via `new SvgCore(svg, { panZoom: ... })` and **any time later** via `core.configurePanZoom(...)`.
|
|
134
|
+
|
|
135
|
+
#### Defaults (what you get with `new SvgCore(svg)`)
|
|
136
|
+
|
|
137
|
+
**Pan/zoom state defaults**
|
|
138
|
+
|
|
139
|
+
- `state.zoom = 1`
|
|
140
|
+
- `state.panX = 0`
|
|
141
|
+
- `state.panY = 0`
|
|
142
|
+
|
|
143
|
+
**Pan/zoom option defaults (`PanZoomOptions`)**
|
|
144
|
+
|
|
145
|
+
- `wheelMode: "pan"`
|
|
146
|
+
- `zoomRequiresCtrlKey: false`
|
|
147
|
+
- `panRequiresSpaceKey: false`
|
|
148
|
+
- `minZoom: 0.2`
|
|
149
|
+
- `maxZoom: 8`
|
|
150
|
+
- `zoomSpeed: 1`
|
|
151
|
+
- `invertZoom: false`
|
|
152
|
+
- `invertPan: false`
|
|
153
|
+
|
|
154
|
+
**Culling defaults**
|
|
155
|
+
|
|
156
|
+
- enabled: `true`
|
|
157
|
+
- overscanPx: `30`
|
|
158
|
+
|
|
159
|
+
**Interaction defaults**
|
|
160
|
+
|
|
161
|
+
- double-click time window: `300ms`
|
|
162
|
+
- click suppression after drag threshold: `5px`
|
|
163
|
+
|
|
164
|
+
#### `SvgCore` API (concise reference)
|
|
165
|
+
|
|
166
|
+
**Props**
|
|
167
|
+
|
|
168
|
+
- `core.svg: SVGSVGElement` — the SVG root you passed in.
|
|
169
|
+
- `core.world: SVGGElement` — the world layer (`<g data-layer="world">`) transformed by pan/zoom.
|
|
170
|
+
- `core.state: { zoom: number; panX: number; panY: number }` — current pan/zoom state (`panX/panY` are screen px).
|
|
171
|
+
- `core.panZoomOptions: Readonly<PanZoomOptions>` — current pan/zoom options (min/max zoom, wheel mode, etc.).
|
|
172
|
+
|
|
173
|
+
**Scene**
|
|
174
|
+
|
|
175
|
+
- `core.setNodes(nodes: Node[])` — replace the full scene. Also (re)builds internal id index + bounds.
|
|
176
|
+
- `core.redraw(ids?: string[])` — re-render:
|
|
177
|
+
- no args: redraw all nodes
|
|
178
|
+
- `ids`: redraw only those nodes; still re-applies culling for the full scene
|
|
179
|
+
- `core.remove(ids?: string[])` — remove nodes by id; if `ids` omitted/empty, clears the whole scene.
|
|
180
|
+
|
|
181
|
+
**Pan/zoom**
|
|
182
|
+
|
|
183
|
+
- `core.setState(partialState)` — set `{ zoom?, panX?, panY? }` directly.
|
|
184
|
+
- `core.setZoom(nextZoom, anchor?)` — set zoom while keeping an anchor point stable in screen space.
|
|
185
|
+
- `core.zoomBy(factor, anchor?)` — multiply current zoom by a factor.
|
|
186
|
+
- `core.resetView()` — reset to `{ zoom: 1, panX: 0, panY: 0 }`.
|
|
187
|
+
- `core.configurePanZoom(partialOptions)` — update pan/zoom behavior at runtime.
|
|
188
|
+
|
|
189
|
+
Example: update pan/zoom after init:
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
core.configurePanZoom({ wheelMode: "zoom", zoomRequiresCtrlKey: false, minZoom: 0.5, maxZoom: 12 });
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
**Culling**
|
|
196
|
+
|
|
197
|
+
- `core.setCullingEnabled(enabled)` — enable/disable culling.
|
|
198
|
+
- `core.setCullingOverscanPx(px)` — set **overscan margin in screen px**. Higher values keep nodes “visible” a bit before/after they enter/leave the viewport (fewer pop-ins, more DOM).
|
|
199
|
+
- `core.onCullingStatsChange(fn)` — subscribe to `{ visible, hidden, total }` updates (event-driven).
|
|
200
|
+
|
|
201
|
+
Example: tune overscan:
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
core.setCullingEnabled(true);
|
|
205
|
+
core.setCullingOverscanPx(80);
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
**Picking / coordinates**
|
|
209
|
+
|
|
210
|
+
- `core.clientToCanvas(clientX, clientY)` — convert screen px to world coords.
|
|
211
|
+
- `core.hitTestVisibleNodeAtClient(clientX, clientY)` — returns topmost **visible** node at that point (or `null`).
|
|
212
|
+
|
|
213
|
+
**Events**
|
|
214
|
+
|
|
215
|
+
- `core.onPanZoomChange(fn)` — subscribe to pan/zoom updates (event-driven).
|
|
216
|
+
- Note: `onPanZoomChange` does **not** fire immediately on subscribe; read `core.state` for the current value.
|
|
217
|
+
|
|
218
|
+
**Lifecycle**
|
|
219
|
+
|
|
220
|
+
- `core.destroy()` — removes event listeners / observers and clears internal subscriptions. Call on teardown.
|
|
221
|
+
|
|
222
|
+
#### `Node`
|
|
223
|
+
|
|
224
|
+
A `Node` is a lightweight wrapper around a lazily-created `<g>` element:
|
|
225
|
+
|
|
226
|
+
- `id` (**required**, should be unique)
|
|
227
|
+
- `fragment` (SVG markup **without** an outer `<svg>`)
|
|
228
|
+
- `x`, `y` (world coordinates)
|
|
229
|
+
- optional `width`, `height` (world units). If omitted, bounds are derived from the fragment’s measured bbox.
|
|
230
|
+
- optional callbacks: `onClick`, `onDoubleClick`, `onRightClick`
|
|
231
|
+
|
|
232
|
+
#### `Node` API (concise reference)
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
new Node({
|
|
236
|
+
id: "node-1", // required, non-empty string
|
|
237
|
+
fragment: "<rect .../>", // SVG markup (no outer <svg>)
|
|
238
|
+
x: 100,
|
|
239
|
+
y: 40, // optional (defaults to 0,0)
|
|
240
|
+
width: 240,
|
|
241
|
+
height: 160, // optional; if omitted the core derives bounds from fragment metrics
|
|
242
|
+
onClick: (n) => console.log("click", n.id),
|
|
243
|
+
onDoubleClick: (n) => console.log("double", n.id),
|
|
244
|
+
onRightClick: (n) => console.log("right", n.id),
|
|
245
|
+
});
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
**Node defaults**
|
|
249
|
+
|
|
250
|
+
- `x` / `y`: default to `0`
|
|
251
|
+
- `width` / `height`: default to **unset** (`null`)
|
|
252
|
+
- when unset, the core derives size from `measureFragmentMetrics(fragment)` (bbox + stroke padding)
|
|
253
|
+
- if fragment is empty/invalid or measurement fails, the core falls back to `240×160`
|
|
254
|
+
- event callbacks: default to `undefined`
|
|
255
|
+
|
|
256
|
+
**What if `id` is missing?**
|
|
257
|
+
|
|
258
|
+
- `new Node({ ... })` will **throw** if `id` is not a non-empty string.
|
|
259
|
+
|
|
260
|
+
**What if multiple nodes share the same `id`?**
|
|
261
|
+
|
|
262
|
+
- `core.setNodes(nodes)` will `console.warn(...)` about duplicates.
|
|
263
|
+
- Internally, the core stores an `id -> index` map; **the last node with that id wins** for id-based operations like `redraw(["id"])` / `remove(["id"])` / hit-test lookup.
|
|
264
|
+
- You should treat ids as unique keys.
|
|
265
|
+
|
|
266
|
+
---
|
|
267
|
+
|
|
268
|
+
### SVG fragments: sanitization & sizing
|
|
269
|
+
|
|
270
|
+
This package includes fragment helpers:
|
|
271
|
+
|
|
272
|
+
- `sanitizeFragment(markup)` removes unsafe content and normalizes markup
|
|
273
|
+
- `measureFragmentMetrics(markup)` measures fragment bbox via `getBBox()` (requires DOM)
|
|
274
|
+
- `parseFragmentElements(markup)` parses markup into SVG `Element[]`
|
|
275
|
+
|
|
276
|
+
**Security note:** fragments are sanitized:
|
|
277
|
+
|
|
278
|
+
- removes `<script>` and `<foreignObject>`
|
|
279
|
+
- strips `on*` event handler attributes
|
|
280
|
+
|
|
281
|
+
If you accept SVG from users, you should still apply your own security policy (CSP, allowlists, server-side validation, etc.).
|
|
39
282
|
|
|
40
283
|
---
|
|
284
|
+
|
|
285
|
+
### Culling (performance)
|
|
286
|
+
|
|
287
|
+
**Culling** means: nodes outside the current viewport are treated as not visible and are **removed from the DOM** (only the visible subset is attached to the nodes layer). This can drastically improve performance for large scenes.
|
|
288
|
+
|
|
289
|
+
Use:
|
|
290
|
+
|
|
291
|
+
- `onCullingStatsChange(({ visible, hidden, total }) => ...)`
|
|
292
|
+
- `culling.overscanPx` / `core.setCullingOverscanPx(px)` to keep a margin outside the viewport before hiding nodes
|
|
293
|
+
|
|
294
|
+
---
|
|
295
|
+
|
|
296
|
+
### Interaction model
|
|
297
|
+
|
|
298
|
+
The core wires SVG events on the root `<svg>` and maps them to nodes:
|
|
299
|
+
|
|
300
|
+
- **Click**: delayed slightly to detect a second click
|
|
301
|
+
- **“Double click”**: implemented by timing two clicks (does not use native `dblclick`). If a second click happens inside the time window, the pending single-click is cancelled and `onDoubleClick` fires.
|
|
302
|
+
- **Right click**: uses `contextmenu` and calls `preventDefault()`
|
|
303
|
+
|
|
304
|
+
Hit-testing only considers the **currently visible (unculled) nodes**, and returns the **topmost** hit node based on render order.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vkcha/svg-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
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",
|
|
@@ -14,13 +14,12 @@
|
|
|
14
14
|
],
|
|
15
15
|
"author": "Vitaliy Frolov",
|
|
16
16
|
"license": "MIT",
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/vkcha/svg-core-issues/issues"
|
|
19
|
+
},
|
|
17
20
|
"repository": {
|
|
18
21
|
"type": "git",
|
|
19
|
-
"url": "
|
|
20
|
-
"directory": "packages/svg-core"
|
|
21
|
-
},
|
|
22
|
-
"bugs": {
|
|
23
|
-
"url": "https://vkcha.com/issues"
|
|
22
|
+
"url": "https://github.com/vkcha/svg-core-issues.git"
|
|
24
23
|
},
|
|
25
24
|
"homepage": "https://vkcha.com",
|
|
26
25
|
"private": false,
|
|
@@ -35,7 +34,10 @@
|
|
|
35
34
|
}
|
|
36
35
|
},
|
|
37
36
|
"files": [
|
|
38
|
-
"dist"
|
|
37
|
+
"dist",
|
|
38
|
+
"src",
|
|
39
|
+
"README.md",
|
|
40
|
+
"LICENSE"
|
|
39
41
|
],
|
|
40
42
|
"scripts": {
|
|
41
43
|
"dev": "vite",
|