SVG-based graph visualization library with adaptive rendering
npm install ngraph.svgSVG-based graph visualization library with adaptive rendering. Designed for rendering large graphs with zoom-dependent detail levels, collision-based importance, and viewport culling.
- Adaptive rendering — nodes show different detail levels based on zoom (dots → labels → cards)
- Collision-based importance — more important nodes get richer detail at lower zoom levels
- Property functions — style any property with a literal, d => val, or (d, ctx) => val
- Performance — element pooling, viewport culling via R-tree, batched DOM updates
- Force-directed layout — integrated with ngraph.forcelayout, with layered orchestration and stress refinement
- Interactive controls — pan, zoom, kinetic scrolling on mouse and touch
- Directed edges — arrow markers with automatic endpoint calculation on node boundaries
``bash`
npm install ngraph.svg
`js
import createGraph from 'ngraph.graph';
import {
createScene, NodeCollection, EdgeCollection, ForceLayoutAdapter
} from 'ngraph.svg';
// 1. Create a graph
const graph = createGraph();
graph.addNode('a', { label: 'Node A' });
graph.addNode('b', { label: 'Node B' });
graph.addLink('a', 'b');
// 2. Create the scene
const scene = createScene(document.getElementById('container'), {
panZoom: { minZoom: 0.1, maxZoom: 50 }
});
// 3. Create collections (graph binding auto-syncs nodes/edges)
const nodeCol = new NodeCollection({
graph,
data: (graphNode) => ({
label: graphNode.data?.label || graphNode.id,
}),
maxScale: 2,
levels: [
{ type: 'circle', radius: 2, fill: '#4a90d9' },
{ minZoom: 2,
layers: [
{ type: 'circle', radius: 4, fill: '#4a90d9' },
{ type: 'text', text: d => d.label, fontSize: 10, fill: '#fff',
anchor: 'top', offset: [0, -8] },
] },
],
});
const edgeCol = new EdgeCollection({
graph,
nodeCollection: nodeCol,
directed: true,
color: '#666',
width: 1,
opacity: 0.5,
});
scene.addCollection(edgeCol);
scene.addCollection(nodeCol);
// 4. Layout
const layout = new ForceLayoutAdapter(graph, { springLength: 50 });
layout.onUpdate((positions) => {
nodeCol.syncPositions(positions);
edgeCol.syncPositions(positions);
scene.requestRender();
});
layout.start();
`
Creates an SVG scene for graph visualization.
Parameters:
- container — DOM element to attach the scene tooptions.viewBox
- — initial bounds { left, top, right, bottom } (default: -100 to 100)options.panZoom
- — pan/zoom config { minZoom, maxZoom, enabled } (default: 0.1–20)
Returns: Scene object with properties and methods:
- svg — the SVG root elementroot
- — the transform group containing all scene contentdrawContext
- — current DrawContextaddCollection(collection)
- / removeCollection(collection) — manage collectionson(event, callback)
- / off(event, callback) — events: render, transform, resizerequestRender()
- — request render on next animation framegetPanZoom()
- — get the pan/zoom controllerflyTo(x, y, scale, duration)
- — animated camera movement (returns Promise)fitToView(bounds, padding)
- — fit content to viewportdispose()
- — clean up resources
Manages batched node rendering with a MapLibre-inspired styling API.
`js
const nodes = new NodeCollection({
graph, // ngraph instance (auto-binds nodes)
data: (graphNode) => ({...}), // extract data from graph node
maxScale: 2, // counter-scaling cap
levels: [
// Level 0: always visible, no importance gating
{ type: 'circle', radius: 2, fill: '#CFCCDF' },
// Level 1: importance-gated, any zoom
{ importance: d => d.importance,
layers: [
{ type: 'circle', radius: 3, fill: '#CFCCDF' },
{ type: 'text', text: d => d.name, fontSize: 10, fill: '#CFCCDF',
anchor: 'top', offset: [0, -8] },
] },
// Level 2: importance-gated, zoom >= 3.5
{ minZoom: 3.5, importance: d => d.importance,
layers: [
{ type: 'circle', radius: 4, fill: '#CFCCDF' },
{ type: 'text', text: d => d.name, fontSize: 11, fill: '#CFCCDF',
anchor: 'top', offset: [0, -10] },
{ type: 'text', text: d => d.version, fontSize: 9, fill: '#888',
anchor: 'bottom', offset: [0, 16], visible: d => !!d.version },
] },
],
});
`
Methods:
- add({ id, x, y, data }) — add a node, returns node handleremove(nodeOrId)
- — remove a nodeget(id)
- — get node by IDsetPosition(node, x, y)
- — update positionsyncPositions(positions)
- — sync positions from a MapgetNodeAt(screenX, screenY, drawContext)
- — hit testing (returns node ID or null)getNodeShape(nodeId)
- — get current shape in world coordinates (for arrow intersection)setState(nodeOrId, key, value)
- — set state (affects ctx in property functions + CSS class)getState(nodeOrId, key)
- — get state valueclearState(key)
- — remove a state key from all nodesbeginBatch()
- / endBatch() — batch DOM updatesforEach(callback)
- — iterate nodescount
- — number of nodes (getter)clear()
- — remove all nodesdispose()
- — clean up resources
Manages batched edge/line rendering.
`js`
const edges = new EdgeCollection({
graph, // ngraph instance (auto-binds edges)
nodeCollection: nodeCol, // for directed arrow endpoint calculation
directed: true, // add arrowhead markers
color: '#666', // literal, d => val, or (d, ctx) => val
width: 1,
opacity: 0.5,
arrowLength: 10, // screen pixels
arrowWidth: 5, // screen pixels
});
Methods:
- add({ id, fromX, fromY, toX, toY, data }) — add an edge, returns edge handleremove(edgeOrId)
- — remove an edgeget(id)
- — get edge by IDsetEndpoints(edge, fromX, fromY, toX, toY)
- — update geometrysyncPositions(positions)
- — sync endpoints from a Map (handles directed arrow offsets)setState(edgeOrId, key, value)
- — set stategetState(edgeOrId, key)
- — get stateclearState(key)
- — clear state from all edgesbeginBatch()
- / endBatch() — batch DOM updatesforEach(callback)
- — iterate edgescount
- — number of edges (getter)clear()
- — remove all edgesdispose()
- — clean up resources
Every visual property (fill, radius, fontSize, opacity, etc.) can be:
`js`
fill: '#CFCCDF' // literal
fill: d => d.color // data-driven
fill: (d, ctx) => ctx.highlighted ? '#fff' : d.color // data + state
The ctx object contains:zoom
- — current zoom levelsetState()
- Any state keys set via (e.g. ctx.highlighted, ctx.dimmed)
Levels define zoom-dependent rendering with collision-based importance. They are ordered by minZoom (lowest first). At any given zoom, the highest applicable level is the candidate.
Key properties:
- minZoom — minimum zoom to show this level (default: 0)maxZoom
- — maximum zoom (optional)importance
- — function d => 0..1 for collision gating. Omit for always-visible levels.layers
- — array of layer definitionshitArea
- — custom hit area for render-type layers: { type: 'rect', width, height }
How collision works: Nodes with importance compete for screen space. More important nodes always win. If a node collides at level N, it falls back to level N-1, and so on down to level 0 (which has no importance gating and always renders).
Shorthand — a level with a single shape can omit the layers wrapper:
`js`
// These are equivalent:
{ type: 'circle', radius: 2, fill: '#CCC' }
{ layers: [{ type: 'circle', radius: 2, fill: '#CCC' }] }
Level transitions — when a node changes level, it cross-fades over ~150ms.
| Type | Purpose | Key Properties |
|---|---|---|
| circle | Filled circle | radius, fill, stroke, strokeWidth, opacity, filter |rect
| | Rectangle | width, height, rx, ry, fill, stroke, strokeWidth, opacity, filter |text
| | Text label | text, fontSize, fill, fontFamily, fontWeight, anchor, offset, maxWidth, opacity |render
| | Custom SVG | render: (data, ctx) => svgString |
All layer types support visible: d => boolean for conditional rendering.
Text anchoring:
`js`
{ type: 'text', text: d => d.name, fontSize: 10,
anchor: 'top', // 'top' | 'bottom' | 'left' | 'right' | 'center'
offset: [0, -6] } // [dx, dy] in counter-scaled pixels
Text word-wrap:
`js`
{ type: 'text', text: d => d.description, fontSize: 8,
maxWidth: 120 } // wraps into multiple lines using
Wraps ngraph.forcelayout with animation loop, position smoothing, layered orchestration, and stress refinement.
`js`
const layout = new ForceLayoutAdapter(graph, {
springLength: 30,
springCoefficient: 0.0008,
gravity: -2.0,
dragCoefficient: 0.04,
theta: 0.8,
timeStep: 20,
smoothing: 0.15, // position interpolation (0 = very smooth, 1 = raw)
maxSpeed: 50, // max displacement per frame
energyThreshold: 0.003, // convergence threshold
stableFramesRequired: 10, // frames below threshold to confirm stability
layeredLayout: true, // enable orchestrated layout (default)
stressThreshold: 0.3, // edge stretch threshold for refinement
maxStressIterations: 200,
getNodeSize: (nodeId) => 10, // for size-aware spacing
nodePadding: 4, // padding between nodes
onStabilized: () => {}, // called when layout converges
});
Methods:
- start() / stop() — control animation loopstep()
- — single layout iteration (async)stabilize(maxIterations)
- — run until stable (async)getNodePosition(nodeId)
- — get position (async)setNodePosition(nodeId, x, y)
- — set position (async)pinNode(nodeId)
- / unpinNode(nodeId) — lock/unlock position (async)getPositions()
- — get all positions as MapgetBounds()
- — get bounding box (async)isStabilized()
- / isRunning() — query stateonUpdate(callback)
- — listen for position updates (called each frame with positions Map)dispose()
- — clean up
Layered orchestration (enabled by default):
1. Computes onion layers via iterative leaf peeling
2. Pins all nodes, unpins only the structural core
3. After core converges, progressively unpins outer layers
4. Detects and refines stretched edges (stress passes)
The layout automatically restarts when nodes/edges are added to the graph.
Both NodeCollection and EdgeCollection share the same state API. State keys appear as:
1. Properties on the ctx object passed to property functions
2. CSS classes on the SVG element (for external styling)
`js
// Set state
nodeCol.setState('node-1', 'highlighted', true);
edgeCol.setState('edge-1', 'dimmed', true);
// Use state in property functions
fill: (d, ctx) => ctx.highlighted ? '#fff' : '#ccc'
opacity: (d, ctx) => ctx.dimmed ? 0.2 : 1
// Clear state from all nodes/edges
nodeCol.clearState('highlighted');
`
When a graph option is passed to NodeCollection or EdgeCollection, the collection automatically syncs with the graph — adding/removing visual elements as nodes/edges are added/removed.
NodeCollection also requires a data callback when using graph binding:
`js`
const nodeCol = new NodeCollection({
graph,
data: (graphNode) => ({
name: graphNode.data?.name || graphNode.id,
color: graphNode.data?.color || '#4a90d9',
}),
levels: [...],
});
EdgeCollection stores fromId and toId from the graph link in each edge's data automatically.
DrawContext — transform/viewport state passed to all renderers:
- screenToScene(screenX, screenY) / sceneToScreen(sceneX, sceneY) — coordinate conversionisVisible(x, y, radius)
- — check viewport visibilitygetVisibleBounds()
- — visible area in scene coordinatesgetNodeScreenSize(worldSize)
- — world-to-screen size conversion
removeOverlaps(rects, originalPositions, options) — R-tree based overlap removal for post-layout node positioning. Two-phase: separation, then relaxation toward original positions.
separatePair(a, b, padding) — separate two overlapping rectangles.
intersectCircle / intersectRect / intersectShape — shape boundary intersection for directed edge arrow placement.
computeLayers(graph) — iterative leaf peeling (onion decomposition). Returns { layerMap, maxLayer }.
computeStressedNodes(graph, layout, threshold) — find edges deviating from ideal length. Returns Set.
Run the development server:
`bash`
npm run dev
- demo/index.html — random graph with adaptive detail levelsdemo/chinese-vocab.html` — Chinese vocabulary hierarchy visualization
-
- ngraph.graph — graph data structure
- ngraph.forcelayout — force-directed layout
- rbush — R-tree spatial index
MIT