ngraph.svg 0.0.17 → 0.10.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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  The MIT License (MIT)
2
2
 
3
- Copyright (c) 2014 Andrei Kashcha
3
+ Copyright (c) 2014-2025 Andrei Kashcha
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,15 +1,369 @@
1
1
  # ngraph.svg
2
2
 
3
- Svg based graph rendering
3
+ SVG-based graph visualization library with adaptive rendering. Designed for rendering large graphs with zoom-dependent detail levels, collision-based importance, and viewport culling.
4
4
 
5
- # install
5
+ ## Features
6
6
 
7
- With [npm](https://npmjs.org) do:
7
+ - **Adaptive rendering** — nodes show different detail levels based on zoom (dots → labels → cards)
8
+ - **Collision-based importance** — more important nodes get richer detail at lower zoom levels
9
+ - **Property functions** — style any property with a literal, `d => val`, or `(d, ctx) => val`
10
+ - **Performance** — element pooling, viewport culling via R-tree, batched DOM updates
11
+ - **Force-directed layout** — integrated with ngraph.forcelayout, with layered orchestration and stress refinement
12
+ - **Interactive controls** — pan, zoom, kinetic scrolling on mouse and touch
13
+ - **Directed edges** — arrow markers with automatic endpoint calculation on node boundaries
8
14
 
9
- ```
15
+ ## Installation
16
+
17
+ ```bash
10
18
  npm install ngraph.svg
11
19
  ```
12
20
 
13
- # license
21
+ ## Quick Start
22
+
23
+ ```js
24
+ import createGraph from 'ngraph.graph';
25
+ import {
26
+ createScene, NodeCollection, EdgeCollection, ForceLayoutAdapter
27
+ } from 'ngraph.svg';
28
+
29
+ // 1. Create a graph
30
+ const graph = createGraph();
31
+ graph.addNode('a', { label: 'Node A' });
32
+ graph.addNode('b', { label: 'Node B' });
33
+ graph.addLink('a', 'b');
34
+
35
+ // 2. Create the scene
36
+ const scene = createScene(document.getElementById('container'), {
37
+ panZoom: { minZoom: 0.1, maxZoom: 50 }
38
+ });
39
+
40
+ // 3. Create collections (graph binding auto-syncs nodes/edges)
41
+ const nodeCol = new NodeCollection({
42
+ graph,
43
+ data: (graphNode) => ({
44
+ label: graphNode.data?.label || graphNode.id,
45
+ }),
46
+ maxScale: 2,
47
+ levels: [
48
+ { type: 'circle', radius: 2, fill: '#4a90d9' },
49
+ { minZoom: 2,
50
+ layers: [
51
+ { type: 'circle', radius: 4, fill: '#4a90d9' },
52
+ { type: 'text', text: d => d.label, fontSize: 10, fill: '#fff',
53
+ anchor: 'top', offset: [0, -8] },
54
+ ] },
55
+ ],
56
+ });
57
+
58
+ const edgeCol = new EdgeCollection({
59
+ graph,
60
+ nodeCollection: nodeCol,
61
+ directed: true,
62
+ color: '#666',
63
+ width: 1,
64
+ opacity: 0.5,
65
+ });
66
+
67
+ scene.addCollection(edgeCol);
68
+ scene.addCollection(nodeCol);
69
+
70
+ // 4. Layout
71
+ const layout = new ForceLayoutAdapter(graph, { springLength: 50 });
72
+ layout.onUpdate((positions) => {
73
+ nodeCol.syncPositions(positions);
74
+ edgeCol.syncPositions(positions);
75
+ scene.requestRender();
76
+ });
77
+ layout.start();
78
+ ```
79
+
80
+ ## API Reference
81
+
82
+ ### createScene(container, options)
83
+
84
+ Creates an SVG scene for graph visualization.
85
+
86
+ **Parameters:**
87
+ - `container` — DOM element to attach the scene to
88
+ - `options.viewBox` — initial bounds `{ left, top, right, bottom }` (default: -100 to 100)
89
+ - `options.panZoom` — pan/zoom config `{ minZoom, maxZoom, enabled }` (default: 0.1–20)
90
+
91
+ **Returns:** Scene object with properties and methods:
92
+ - `svg` — the SVG root element
93
+ - `root` — the transform group containing all scene content
94
+ - `drawContext` — current DrawContext
95
+ - `addCollection(collection)` / `removeCollection(collection)` — manage collections
96
+ - `on(event, callback)` / `off(event, callback)` — events: `render`, `transform`, `resize`
97
+ - `requestRender()` — request render on next animation frame
98
+ - `getPanZoom()` — get the pan/zoom controller
99
+ - `flyTo(x, y, scale, duration)` — animated camera movement (returns Promise)
100
+ - `fitToView(bounds, padding)` — fit content to viewport
101
+ - `dispose()` — clean up resources
102
+
103
+ ### NodeCollection
104
+
105
+ Manages batched node rendering with a MapLibre-inspired styling API.
106
+
107
+ ```js
108
+ const nodes = new NodeCollection({
109
+ graph, // ngraph instance (auto-binds nodes)
110
+ data: (graphNode) => ({...}), // extract data from graph node
111
+ maxScale: 2, // counter-scaling cap
112
+
113
+ levels: [
114
+ // Level 0: always visible, no importance gating
115
+ { type: 'circle', radius: 2, fill: '#CFCCDF' },
116
+
117
+ // Level 1: importance-gated, any zoom
118
+ { importance: d => d.importance,
119
+ layers: [
120
+ { type: 'circle', radius: 3, fill: '#CFCCDF' },
121
+ { type: 'text', text: d => d.name, fontSize: 10, fill: '#CFCCDF',
122
+ anchor: 'top', offset: [0, -8] },
123
+ ] },
124
+
125
+ // Level 2: importance-gated, zoom >= 3.5
126
+ { minZoom: 3.5, importance: d => d.importance,
127
+ layers: [
128
+ { type: 'circle', radius: 4, fill: '#CFCCDF' },
129
+ { type: 'text', text: d => d.name, fontSize: 11, fill: '#CFCCDF',
130
+ anchor: 'top', offset: [0, -10] },
131
+ { type: 'text', text: d => d.version, fontSize: 9, fill: '#888',
132
+ anchor: 'bottom', offset: [0, 16], visible: d => !!d.version },
133
+ ] },
134
+ ],
135
+ });
136
+ ```
137
+
138
+ **Methods:**
139
+ - `add({ id, x, y, data })` — add a node, returns node handle
140
+ - `remove(nodeOrId)` — remove a node
141
+ - `get(id)` — get node by ID
142
+ - `setPosition(node, x, y)` — update position
143
+ - `syncPositions(positions)` — sync positions from a `Map<id, {x, y}>`
144
+ - `getNodeAt(screenX, screenY, drawContext)` — hit testing (returns node ID or null)
145
+ - `getNodeShape(nodeId)` — get current shape in world coordinates (for arrow intersection)
146
+ - `setState(nodeOrId, key, value)` — set state (affects `ctx` in property functions + CSS class)
147
+ - `getState(nodeOrId, key)` — get state value
148
+ - `clearState(key)` — remove a state key from all nodes
149
+ - `beginBatch()` / `endBatch()` — batch DOM updates
150
+ - `forEach(callback)` — iterate nodes
151
+ - `count` — number of nodes (getter)
152
+ - `clear()` — remove all nodes
153
+ - `dispose()` — clean up resources
154
+
155
+ ### EdgeCollection
156
+
157
+ Manages batched edge/line rendering.
158
+
159
+ ```js
160
+ const edges = new EdgeCollection({
161
+ graph, // ngraph instance (auto-binds edges)
162
+ nodeCollection: nodeCol, // for directed arrow endpoint calculation
163
+ directed: true, // add arrowhead markers
164
+ color: '#666', // literal, d => val, or (d, ctx) => val
165
+ width: 1,
166
+ opacity: 0.5,
167
+ arrowLength: 10, // screen pixels
168
+ arrowWidth: 5, // screen pixels
169
+ });
170
+ ```
171
+
172
+ **Methods:**
173
+ - `add({ id, fromX, fromY, toX, toY, data })` — add an edge, returns edge handle
174
+ - `remove(edgeOrId)` — remove an edge
175
+ - `get(id)` — get edge by ID
176
+ - `setEndpoints(edge, fromX, fromY, toX, toY)` — update geometry
177
+ - `syncPositions(positions)` — sync endpoints from a `Map<id, {x, y}>` (handles directed arrow offsets)
178
+ - `setState(edgeOrId, key, value)` — set state
179
+ - `getState(edgeOrId, key)` — get state
180
+ - `clearState(key)` — clear state from all edges
181
+ - `beginBatch()` / `endBatch()` — batch DOM updates
182
+ - `forEach(callback)` — iterate edges
183
+ - `count` — number of edges (getter)
184
+ - `clear()` — remove all edges
185
+ - `dispose()` — clean up resources
186
+
187
+ ### Property Functions
188
+
189
+ Every visual property (fill, radius, fontSize, opacity, etc.) can be:
190
+
191
+ ```js
192
+ fill: '#CFCCDF' // literal
193
+ fill: d => d.color // data-driven
194
+ fill: (d, ctx) => ctx.highlighted ? '#fff' : d.color // data + state
195
+ ```
196
+
197
+ The `ctx` object contains:
198
+ - `zoom` — current zoom level
199
+ - Any state keys set via `setState()` (e.g. `ctx.highlighted`, `ctx.dimmed`)
200
+
201
+ ### Levels
202
+
203
+ 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.
204
+
205
+ **Key properties:**
206
+ - `minZoom` — minimum zoom to show this level (default: 0)
207
+ - `maxZoom` — maximum zoom (optional)
208
+ - `importance` — function `d => 0..1` for collision gating. Omit for always-visible levels.
209
+ - `layers` — array of layer definitions
210
+ - `hitArea` — custom hit area for `render`-type layers: `{ type: 'rect', width, height }`
211
+
212
+ **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).
213
+
214
+ **Shorthand** — a level with a single shape can omit the `layers` wrapper:
215
+
216
+ ```js
217
+ // These are equivalent:
218
+ { type: 'circle', radius: 2, fill: '#CCC' }
219
+ { layers: [{ type: 'circle', radius: 2, fill: '#CCC' }] }
220
+ ```
221
+
222
+ **Level transitions** — when a node changes level, it cross-fades over ~150ms.
223
+
224
+ ### Layer Types
225
+
226
+ | Type | Purpose | Key Properties |
227
+ |---|---|---|
228
+ | `circle` | Filled circle | `radius`, `fill`, `stroke`, `strokeWidth`, `opacity`, `filter` |
229
+ | `rect` | Rectangle | `width`, `height`, `rx`, `ry`, `fill`, `stroke`, `strokeWidth`, `opacity`, `filter` |
230
+ | `text` | Text label | `text`, `fontSize`, `fill`, `fontFamily`, `fontWeight`, `anchor`, `offset`, `maxWidth`, `opacity` |
231
+ | `render` | Custom SVG | `render: (data, ctx) => svgString` |
232
+
233
+ All layer types support `visible: d => boolean` for conditional rendering.
234
+
235
+ **Text anchoring:**
236
+ ```js
237
+ { type: 'text', text: d => d.name, fontSize: 10,
238
+ anchor: 'top', // 'top' | 'bottom' | 'left' | 'right' | 'center'
239
+ offset: [0, -6] } // [dx, dy] in counter-scaled pixels
240
+ ```
241
+
242
+ **Text word-wrap:**
243
+ ```js
244
+ { type: 'text', text: d => d.description, fontSize: 8,
245
+ maxWidth: 120 } // wraps into multiple lines using <tspan> elements
246
+ ```
247
+
248
+ ### ForceLayoutAdapter
249
+
250
+ Wraps ngraph.forcelayout with animation loop, position smoothing, layered orchestration, and stress refinement.
251
+
252
+ ```js
253
+ const layout = new ForceLayoutAdapter(graph, {
254
+ springLength: 30,
255
+ springCoefficient: 0.0008,
256
+ gravity: -2.0,
257
+ dragCoefficient: 0.04,
258
+ theta: 0.8,
259
+ timeStep: 20,
260
+ smoothing: 0.15, // position interpolation (0 = very smooth, 1 = raw)
261
+ maxSpeed: 50, // max displacement per frame
262
+ energyThreshold: 0.003, // convergence threshold
263
+ stableFramesRequired: 10, // frames below threshold to confirm stability
264
+ layeredLayout: true, // enable orchestrated layout (default)
265
+ stressThreshold: 0.3, // edge stretch threshold for refinement
266
+ maxStressIterations: 200,
267
+ getNodeSize: (nodeId) => 10, // for size-aware spacing
268
+ nodePadding: 4, // padding between nodes
269
+ onStabilized: () => {}, // called when layout converges
270
+ });
271
+ ```
272
+
273
+ **Methods:**
274
+ - `start()` / `stop()` — control animation loop
275
+ - `step()` — single layout iteration (async)
276
+ - `stabilize(maxIterations)` — run until stable (async)
277
+ - `getNodePosition(nodeId)` — get position (async)
278
+ - `setNodePosition(nodeId, x, y)` — set position (async)
279
+ - `pinNode(nodeId)` / `unpinNode(nodeId)` — lock/unlock position (async)
280
+ - `getPositions()` — get all positions as `Map<id, {x, y}>`
281
+ - `getBounds()` — get bounding box (async)
282
+ - `isStabilized()` / `isRunning()` — query state
283
+ - `onUpdate(callback)` — listen for position updates (called each frame with positions Map)
284
+ - `dispose()` — clean up
285
+
286
+ **Layered orchestration** (enabled by default):
287
+ 1. Computes onion layers via iterative leaf peeling
288
+ 2. Pins all nodes, unpins only the structural core
289
+ 3. After core converges, progressively unpins outer layers
290
+ 4. Detects and refines stretched edges (stress passes)
291
+
292
+ The layout automatically restarts when nodes/edges are added to the graph.
293
+
294
+ ### State System
295
+
296
+ Both NodeCollection and EdgeCollection share the same state API. State keys appear as:
297
+ 1. Properties on the `ctx` object passed to property functions
298
+ 2. CSS classes on the SVG element (for external styling)
299
+
300
+ ```js
301
+ // Set state
302
+ nodeCol.setState('node-1', 'highlighted', true);
303
+ edgeCol.setState('edge-1', 'dimmed', true);
304
+
305
+ // Use state in property functions
306
+ fill: (d, ctx) => ctx.highlighted ? '#fff' : '#ccc'
307
+ opacity: (d, ctx) => ctx.dimmed ? 0.2 : 1
308
+
309
+ // Clear state from all nodes/edges
310
+ nodeCol.clearState('highlighted');
311
+ ```
312
+
313
+ ### Graph Binding
314
+
315
+ 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.
316
+
317
+ NodeCollection also requires a `data` callback when using graph binding:
318
+
319
+ ```js
320
+ const nodeCol = new NodeCollection({
321
+ graph,
322
+ data: (graphNode) => ({
323
+ name: graphNode.data?.name || graphNode.id,
324
+ color: graphNode.data?.color || '#4a90d9',
325
+ }),
326
+ levels: [...],
327
+ });
328
+ ```
329
+
330
+ EdgeCollection stores `fromId` and `toId` from the graph link in each edge's data automatically.
331
+
332
+ ### Other Exports
333
+
334
+ **DrawContext** — transform/viewport state passed to all renderers:
335
+ - `screenToScene(screenX, screenY)` / `sceneToScreen(sceneX, sceneY)` — coordinate conversion
336
+ - `isVisible(x, y, radius)` — check viewport visibility
337
+ - `getVisibleBounds()` — visible area in scene coordinates
338
+ - `getNodeScreenSize(worldSize)` — world-to-screen size conversion
339
+
340
+ **removeOverlaps(rects, originalPositions, options)** — R-tree based overlap removal for post-layout node positioning. Two-phase: separation, then relaxation toward original positions.
341
+
342
+ **separatePair(a, b, padding)** — separate two overlapping rectangles.
343
+
344
+ **intersectCircle / intersectRect / intersectShape** — shape boundary intersection for directed edge arrow placement.
345
+
346
+ **computeLayers(graph)** — iterative leaf peeling (onion decomposition). Returns `{ layerMap, maxLayer }`.
347
+
348
+ **computeStressedNodes(graph, layout, threshold)** — find edges deviating from ideal length. Returns `Set<nodeId>`.
349
+
350
+ ## Demos
351
+
352
+ Run the development server:
353
+
354
+ ```bash
355
+ npm run dev
356
+ ```
357
+
358
+ - `demo/index.html` — random graph with adaptive detail levels
359
+ - `demo/chinese-vocab.html` — Chinese vocabulary hierarchy visualization
360
+
361
+ ## Dependencies
362
+
363
+ - [ngraph.graph](https://github.com/anvaka/ngraph.graph) — graph data structure
364
+ - [ngraph.forcelayout](https://github.com/anvaka/ngraph.forcelayout) — force-directed layout
365
+ - [rbush](https://github.com/mourner/rbush) — R-tree spatial index
366
+
367
+ ## License
14
368
 
15
369
  MIT
package/package.json CHANGED
@@ -1,33 +1,40 @@
1
1
  {
2
2
  "name": "ngraph.svg",
3
- "version": "0.0.17",
4
- "description": "Svg based graph rendering",
5
- "main": "index.js",
6
- "scripts": {
7
- "test": "echo \"Error: no test specified\" && exit 1",
8
- "demo": "browserify example/basic/index.js > example/basic/bundle.js && browserify example/customNode/index.js > example/customNode/bundle.js && browserify example/customSpringLength/index.js > example/customSpringLength/bundle.js"
3
+ "version": "0.10.0",
4
+ "description": "SVG-based graph visualization library with adaptive rendering",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "module": "./src/index.js",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./src/index.js",
11
+ "default": "./src/index.js"
12
+ }
9
13
  },
10
- "keywords": [
11
- "svg",
12
- "graph",
13
- "vivagraph",
14
- "ngraph"
14
+ "files": [
15
+ "dist",
16
+ "src"
15
17
  ],
16
- "author": "Andrei Kashcha",
17
- "license": "MIT",
18
- "repository": {
19
- "type": "git",
20
- "url": "https://github.com/anvaka/ngraph.svg"
18
+ "scripts": {
19
+ "dev": "vite",
20
+ "build": "vite build",
21
+ "preview": "vite preview"
21
22
  },
22
23
  "dependencies": {
23
- "hammerjs": "^2.0.2",
24
- "ngraph.events": "1.2.1",
25
- "ngraph.forcelayout": "3.3.0",
26
- "simplesvg": "0.1.0",
27
- "ngraph.merge": "0.0.1",
28
- "wheel": "0.0.1"
24
+ "ngraph.forcelayout": "^3.3.1",
25
+ "ngraph.graph": "^20.1.1",
26
+ "rbush": "^3.0.1"
29
27
  },
30
28
  "devDependencies": {
31
- "ngraph.graph": "20.0.0"
32
- }
29
+ "vite": "^6.0.11"
30
+ },
31
+ "keywords": [
32
+ "graph",
33
+ "visualization",
34
+ "svg",
35
+ "ngraph",
36
+ "adaptive"
37
+ ],
38
+ "author": "",
39
+ "license": "MIT"
33
40
  }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * DrawContext holds transform/viewport state passed to all renderers.
3
+ * It provides helpers for adaptive rendering calculations.
4
+ */
5
+ export default class DrawContext {
6
+ constructor() {
7
+ this.viewBox = { left: -100, top: -100, right: 100, bottom: 100 };
8
+ this.transform = { scale: 1, x: 0, y: 0 };
9
+ this.width = 0;
10
+ this.height = 0;
11
+ this.pixelRatio = typeof window !== 'undefined' ? window.devicePixelRatio : 1;
12
+
13
+ // Cached visible bounds to avoid per-node allocations
14
+ this._visibleBounds = { left: 0, top: 0, right: 0, bottom: 0 };
15
+ this._visibleBoundsDirty = true;
16
+
17
+ // Preallocated points to avoid per-call allocations
18
+ this._reusableScenePoint = { x: 0, y: 0 };
19
+ this._reusableScreenPoint = { x: 0, y: 0 };
20
+ }
21
+
22
+ /**
23
+ * Update the viewport dimensions
24
+ */
25
+ setSize(width, height) {
26
+ this.width = width;
27
+ this.height = height;
28
+ this._visibleBoundsDirty = true;
29
+ }
30
+
31
+ /**
32
+ * Update the viewBox bounds
33
+ */
34
+ setViewBox(left, top, right, bottom) {
35
+ this.viewBox.left = left;
36
+ this.viewBox.top = top;
37
+ this.viewBox.right = right;
38
+ this.viewBox.bottom = bottom;
39
+ }
40
+
41
+ /**
42
+ * Update the transform state
43
+ */
44
+ setTransform(scale, x, y) {
45
+ this.transform.scale = scale;
46
+ this.transform.x = x;
47
+ this.transform.y = y;
48
+ this._visibleBoundsDirty = true;
49
+ }
50
+
51
+ /**
52
+ * Calculate how many pixels a node of given size occupies on screen.
53
+ * This is the core metric for adaptive rendering decisions.
54
+ */
55
+ getNodeScreenSize(nodeSize) {
56
+ return nodeSize * this.transform.scale;
57
+ }
58
+
59
+ /**
60
+ * Convert screen coordinates to scene coordinates
61
+ */
62
+ screenToScene(screenX, screenY) {
63
+ const p = this._reusableScenePoint;
64
+ p.x = (screenX - this.transform.x) / this.transform.scale;
65
+ p.y = (screenY - this.transform.y) / this.transform.scale;
66
+ return p;
67
+ }
68
+
69
+ /**
70
+ * Convert scene coordinates to screen coordinates
71
+ */
72
+ sceneToScreen(sceneX, sceneY) {
73
+ const p = this._reusableScreenPoint;
74
+ p.x = sceneX * this.transform.scale + this.transform.x;
75
+ p.y = sceneY * this.transform.scale + this.transform.y;
76
+ return p;
77
+ }
78
+
79
+ /**
80
+ * Get the visible bounds in scene coordinates
81
+ */
82
+ /**
83
+ * Returns the cached visible bounds object. Callers must not modify it.
84
+ */
85
+ getVisibleBounds() {
86
+ this._updateVisibleBounds();
87
+ return this._visibleBounds;
88
+ }
89
+
90
+ _updateVisibleBounds() {
91
+ if (this._visibleBoundsDirty) {
92
+ this._visibleBoundsDirty = false;
93
+ const s = this.transform.scale;
94
+ const tx = this.transform.x;
95
+ const ty = this.transform.y;
96
+ this._visibleBounds.left = (0 - tx) / s;
97
+ this._visibleBounds.top = (0 - ty) / s;
98
+ this._visibleBounds.right = (this.width - tx) / s;
99
+ this._visibleBounds.bottom = (this.height - ty) / s;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Check if a point with given radius is visible in the viewport
105
+ */
106
+ isVisible(x, y, radius = 0) {
107
+ this._updateVisibleBounds();
108
+ const bounds = this._visibleBounds;
109
+ return (
110
+ x + radius >= bounds.left &&
111
+ x - radius <= bounds.right &&
112
+ y + radius >= bounds.top &&
113
+ y - radius <= bounds.bottom
114
+ );
115
+ }
116
+
117
+ /**
118
+ * Get the SVG transform string for the current state
119
+ */
120
+ getTransformString() {
121
+ return `translate(${this.transform.x}, ${this.transform.y}) scale(${this.transform.scale})`;
122
+ }
123
+ }