A lightweight SVG rendering core library in TypeScript for the web: scene graph, viewport culling, zoom/pan, event handling
npm install @vkcha/svg-coreLightweight SVG scene rendering core for the web (TypeScript):
- Pan / zoom on an (wheel + pointer drag)
- Scene graph of many “nodes” backed by elements
- Viewport culling (removes offscreen nodes from DOM for performance)
- Hit-testing + node events (click, “double click”, right click)
- SVG fragment utilities (sanitize/measure/parse fragments)
- Smooth animations (animateTo with custom easing)
Live demo: vkcha.com | Docs: vkcha.com/#docs
---
``bash`
npm i @vkcha/svg-core
---
Create an
`html`
Then initialize the core and draw a few nodes:
`ts
import { SvgCore, Node } from "@vkcha/svg-core";
const svg = document.querySelector("#canvas") as SVGSVGElement;
const core = new SvgCore(svg, {
panZoom: {
wheelMode: "pan", // or "zoom"
zoomRequiresCtrlKey: true, // macOS pinch usually sets ctrlKey=true
},
culling: { enabled: true, overscanPx: 30 },
});
core.setNodes([
new Node({
id: "hello",
x: 0,
y: 0,
fragment:
,
onClick: (n) => console.log("clicked", n.id),
}),
new Node({
id: "world",
x: 220,
y: 120,
fragment: ,
onRightClick: (n) => console.log("right click", n.id),
}),
]);
// Optional: observe state changes (event-driven, no polling)
const unsub = core.onPanZoomChange((s) => console.log("pan/zoom", s));
// Cleanup
// unsub(); core.destroy();
`
---
`tsx
import { useEffect, useRef } from "react";
import { SvgCore, Node } from "@vkcha/svg-core";
export function SvgScene() {
const svgRef = useRef
const coreRef = useRef
useEffect(() => {
if (!svgRef.current) return;
const core = new SvgCore(svgRef.current, {
panZoom: { wheelMode: "pan", zoomRequiresCtrlKey: true },
culling: { enabled: true, overscanPx: 30 },
});
coreRef.current = core;
core.setNodes([
new Node({
id: "a",
x: 0,
y: 0,
fragment: ,
}),
]);
return () => {
core.destroy();
coreRef.current = null;
};
}, []);
return ;
}
`
If you prefer a ready-made component wrapper, use @vkcha/svg-core-react:
`tsx
import { useMemo } from "react";
import { SvgCoreView } from "@vkcha/svg-core-react";
export function SvgScene() {
const nodes = useMemo(
() => [
{ id: "a", x: 0, y: 0, fragment: },
],
[],
);
return (
panZoom: { wheelMode: "pan", zoomRequiresCtrlKey: true },
culling: { enabled: true },
}}
nodes={nodes}
style={{ width: "100%", height: "100%" }}
/>
);
}
`
---
If you just want pan/zoom and plan to manage your own SVG content:
`ts
import { PanZoomCanvas } from "@vkcha/svg-core";
const svg = document.querySelector("#canvas") as SVGSVGElement;
const canvas = new PanZoomCanvas(svg, { wheelMode: "pan" });
// Add your own SVG elements under the world layer:
const layer = canvas.createLayer("custom");
layer.innerHTML =
;`
If you want the same thing in React, use @vkcha/svg-core-react:
`tsx
import { useEffect, useRef } from "react";
import { PanZoomCanvasView } from "@vkcha/svg-core-react";
export function PanZoomOnly() {
const viewRef = useRef
useEffect(() => {
const world = viewRef.current?.getWorld();
if (!world) return;
world.innerHTML =
;
}, []);
return (
options={{ wheelMode: "pan" }}
style={{ width: "100%", height: "100%" }}
/>
);
}
`
If you prefer to initialize SvgCore and still add custom content:
`ts
import { SvgCore } from "@vkcha/svg-core";
const svg = document.querySelector("#canvas") as SVGSVGElement;
const core = new SvgCore(svg, { panZoom: { wheelMode: "pan" } });
const layer = core.createWorldLayer("custom", { position: "below-nodes" });
layer.innerHTML = ;`
#### PanZoomCanvas API (concise reference)
State
- canvas.state: { zoom, panX, panY } — current statecanvas.setState(partial)
- — set state immediatelycanvas.reset()
- — reset to { zoom: 1, panX: 0, panY: 0 }
Animation
- canvas.animateTo(target, durationMs?, easing?) — animate to a target state (returns Promise)canvas.stopAnimation()
- — cancel any running animation
Example: smooth zoom with ease-out cubic:
`ts`
await canvas.animateTo(
{ zoom: 2, panX: 100, panY: 50 },
400,
(t) => 1 - Math.pow(1 - t, 3), // ease-out cubic
);
Layers
- canvas.createLayer(name?, opts?) — create a inside the worldopts.position
- : "front" (default) or "back"opts.pointerEvents
- : CSS pointer-events value (e.g., "none")
Subscription
- canvas.subscribe(fn) — listen to state changes (returns unsubscribe function)
Options
- canvas.setOptions(partial) — update options at runtime
World Group Configuration
Configure the world element with custom attributes:
`ts`
const canvas = new PanZoomCanvas(svg, {
worldGroup: {
id: "canvas-world",
attributes: { "data-testid": "world" },
dynamicAttributes: {
"data-zoom": (zoom) => String(Math.round(zoom * 100)),
},
},
});
Cleanup
- canvas.destroy() — remove all listeners, cancel animations
---
#### SvgCore(svg, options?)
SvgCore owns:
- an internal PanZoomCanvas (creates a world and applies a matrix(...) transform)
- a dedicated nodes layer () inside world
- culling + hit-testing + interaction wiring on the root
Useful properties:
- core.svg: the root SVGSVGElementcore.world
- : a for “world space” contentcore.state
- : { zoom, panX, panY }core.panZoomOptions
- : merged pan/zoom options (min/max, wheel mode, etc.)core.createWorldLayer(...)
- : create a custom inside the world layer
Pan/zoom can be configured on init via new SvgCore(svg, { panZoom: ... }) and any time later via core.configurePanZoom(...).
#### Defaults (what you get with new SvgCore(svg))
Pan/zoom state defaults
- state.zoom = 1state.panX = 0
- state.panY = 0
-
Pan/zoom option defaults (PanZoomOptions)
- wheelMode: "pan"zoomRequiresCtrlKey: false
- panRequiresSpaceKey: false
- minZoom: 0.2
- maxZoom: 8
- zoomSpeed: 1
- pinchZoomSpeed: 2
- — extra speed multiplier for trackpad pinch gesturesinvertZoom: false
- invertPan: false
- worldGroup: undefined
- — optional configuration for the world element (id, attributes, dynamic attributes)
Culling defaults
- enabled: true30
- overscanPx:
Interaction defaults
- double-click time window: 300ms5px
- click suppression after drag threshold:
#### SvgCore API (concise reference)
Props
- core.svg: SVGSVGElement — the SVG root you passed in.core.world: SVGGElement
- — the world layer () transformed by pan/zoom.core.state: { zoom: number; panX: number; panY: number }
- — current pan/zoom state (panX/panY are screen px).core.panZoomOptions: Readonly
- — current pan/zoom options (min/max zoom, wheel mode, etc.).
Scene
- core.setNodes(nodes: Node[]) — replace the full scene. Also (re)builds internal id index + bounds.core.redraw(ids?: string[])
- — re-render:ids
- no args: redraw all nodes
- : redraw only those nodes; still re-applies culling for the full scenecore.remove(ids?: string[])
- — remove nodes by id; if ids omitted/empty, clears the whole scene.
Pan/zoom
- core.setState(partialState) — set { zoom?, panX?, panY? } directly.core.setZoom(nextZoom, anchor?)
- — set zoom while keeping an anchor point stable in screen space.core.zoomBy(factor, anchor?)
- — multiply current zoom by a factor.core.resetView()
- — reset to { zoom: 1, panX: 0, panY: 0 }.core.configurePanZoom(partialOptions)
- — update pan/zoom behavior at runtime.
Example: update pan/zoom after init:
`ts`
core.configurePanZoom({ wheelMode: "zoom", zoomRequiresCtrlKey: false, minZoom: 0.5, maxZoom: 12 });
Culling
- core.setCullingEnabled(enabled) — enable/disable culling.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).core.onCullingStatsChange(fn)
- — subscribe to { visible, hidden, total } updates (event-driven).
Example: tune overscan:
`ts`
core.setCullingEnabled(true);
core.setCullingOverscanPx(80);
Picking / coordinates
- core.clientToCanvas(clientX, clientY) — convert screen px to world coords.core.hitTestVisibleNodeAtClient(clientX, clientY)
- — returns topmost visible node at that point (or null).
Events
- core.onPanZoomChange(fn) — subscribe to pan/zoom updates (event-driven).onPanZoomChange
- Note: does not fire immediately on subscribe; read core.state for the current value.
Lifecycle
- core.destroy() — removes event listeners / observers and clears internal subscriptions. Call on teardown.
#### Node
A Node is a lightweight wrapper around a lazily-created element:
- id (required, should be unique)fragment
- (SVG markup without an outer
#### Node API (concise reference)
`ts`
new Node({
id: "node-1", // required, non-empty string
fragment: "
Node defaults
- x / y: default to 0width
- / height: default to unset (null)measureFragmentMetrics(fragment)
- when unset, the core derives size from (bbox + stroke padding)240×160
- if fragment is empty/invalid or measurement fails, the core falls back to undefined
- event callbacks: default to
What if id is missing?
- new Node({ ... }) will throw if id is not a non-empty string.
What if multiple nodes share the same id?
- core.setNodes(nodes) will console.warn(...) about duplicates.id -> index
- Internally, the core stores an map; the last node with that id wins for id-based operations like redraw(["id"]) / remove(["id"]) / hit-test lookup.
- You should treat ids as unique keys.
---
This package includes fragment helpers:
- sanitizeFragment(markup) removes unsafe content and normalizes markupmeasureFragmentMetrics(markup)
- measures fragment bbox via getBBox() (requires DOM)parseFragmentElements(markup)
- parses markup into SVG Element[]
Security note: fragments are sanitized:
- removes