@vkcha/svg-core
v0.1.2
Published
A lightweight SVG rendering core library in TypeScript for the web: scene graph, viewport culling, zoom/pan, event handling
Downloads
254
Maintainers
Readme
@vkcha/svg-core
Lightweight SVG scene rendering core for the web (TypeScript):
- Pan / zoom on an
<svg>(wheel + pointer drag) - Scene graph of many “nodes” backed by
<g>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)
Live demo: https://vkcha.com
Install
npm i @vkcha/svg-coreQuick start (Vanilla TS/JS)
Create an <svg> that fills its container:
<div id="root" style="height: 100vh">
<svg id="canvas" style="width: 100%; height: 100%" xmlns="http://www.w3.org/2000/svg"></svg>
</div>Then initialize the core and draw a few nodes:
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: `
<rect width="160" height="60" rx="10" fill="#111827"/>
<text x="16" y="38" font-size="18" fill="white">Hello</text>
`,
onClick: (n) => console.log("clicked", n.id),
}),
new Node({
id: "world",
x: 220,
y: 120,
fragment: `<circle r="40" cx="40" cy="40" fill="#60a5fa"/>`,
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();Quick start (React)
import { useEffect, useRef } from "react";
import { SvgCore, Node } from "@vkcha/svg-core";
export function SvgScene() {
const svgRef = useRef<SVGSVGElement | null>(null);
const coreRef = useRef<SvgCore | null>(null);
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: `<rect width="120" height="60" rx="10" fill="#10b981"/>`,
}),
]);
return () => {
core.destroy();
coreRef.current = null;
};
}, []);
return <svg ref={svgRef} style={{ width: "100%", height: "100%" }} />;
}Core concepts
SvgCore(svg, options?)
SvgCore owns:
- an internal
PanZoomCanvas(creates aworld<g>and applies amatrix(...)transform) - a dedicated nodes layer (
<g data-layer="nodes">) insideworld - culling + hit-testing + interaction wiring on the root
<svg>
Useful properties:
core.svg: the rootSVGSVGElementcore.world: a<g>for “world space” contentcore.state:{ zoom, panX, panY }core.panZoomOptions: merged pan/zoom options (min/max, wheel mode, etc.)
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 = 0state.panY = 0
Pan/zoom option defaults (PanZoomOptions)
wheelMode: "pan"zoomRequiresCtrlKey: falsepanRequiresSpaceKey: falseminZoom: 0.2maxZoom: 8zoomSpeed: 1invertZoom: falseinvertPan: false
Culling defaults
- enabled:
true - overscanPx:
30
Interaction defaults
- double-click time window:
300ms - click suppression after drag threshold:
5px
SvgCore API (concise reference)
Props
core.svg: SVGSVGElement— the SVG root you passed in.core.world: SVGGElement— the world layer (<g data-layer="world">) transformed by pan/zoom.core.state: { zoom: number; panX: number; panY: number }— current pan/zoom state (panX/panYare screen px).core.panZoomOptions: Readonly<PanZoomOptions>— 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:- no args: redraw all nodes
ids: redraw only those nodes; still re-applies culling for the full scene
core.remove(ids?: string[])— remove nodes by id; ifidsomitted/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:
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:
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 (ornull).
Events
core.onPanZoomChange(fn)— subscribe to pan/zoom updates (event-driven).- Note:
onPanZoomChangedoes not fire immediately on subscribe; readcore.statefor 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 <g> element:
id(required, should be unique)fragment(SVG markup without an outer<svg>)x,y(world coordinates)- optional
width,height(world units). If omitted, bounds are derived from the fragment’s measured bbox. - optional callbacks:
onClick,onDoubleClick,onRightClick
Node API (concise reference)
new Node({
id: "node-1", // required, non-empty string
fragment: "<rect .../>", // SVG markup (no outer <svg>)
x: 100,
y: 40, // optional (defaults to 0,0)
width: 240,
height: 160, // optional; if omitted the core derives bounds from fragment metrics
onClick: (n) => console.log("click", n.id),
onDoubleClick: (n) => console.log("double", n.id),
onRightClick: (n) => console.log("right", n.id),
});Node defaults
x/y: default to0width/height: default to unset (null)- when unset, the core derives size from
measureFragmentMetrics(fragment)(bbox + stroke padding) - if fragment is empty/invalid or measurement fails, the core falls back to
240×160
- when unset, the core derives size from
- event callbacks: default to
undefined
What if id is missing?
new Node({ ... })will throw ifidis not a non-empty string.
What if multiple nodes share the same id?
core.setNodes(nodes)willconsole.warn(...)about duplicates.- Internally, the core stores an
id -> indexmap; the last node with that id wins for id-based operations likeredraw(["id"])/remove(["id"])/ hit-test lookup. - You should treat ids as unique keys.
SVG fragments: sanitization & sizing
This package includes fragment helpers:
sanitizeFragment(markup)removes unsafe content and normalizes markupmeasureFragmentMetrics(markup)measures fragment bbox viagetBBox()(requires DOM)parseFragmentElements(markup)parses markup into SVGElement[]
Security note: fragments are sanitized:
- removes
<script>and<foreignObject> - strips
on*event handler attributes
If you accept SVG from users, you should still apply your own security policy (CSP, allowlists, server-side validation, etc.).
Culling (performance)
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.
Use:
onCullingStatsChange(({ visible, hidden, total }) => ...)culling.overscanPx/core.setCullingOverscanPx(px)to keep a margin outside the viewport before hiding nodes
Interaction model
The core wires SVG events on the root <svg> and maps them to nodes:
- Click: delayed slightly to detect a second click
- “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 andonDoubleClickfires. - Right click: uses
contextmenuand callspreventDefault()
Hit-testing only considers the currently visible (unculled) nodes, and returns the topmost hit node based on render order.
