force-graph 1.42.3
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 +228 -0
- package/dist/force-graph.common.js +1754 -0
- package/dist/force-graph.d.ts +195 -0
- package/dist/force-graph.js +12168 -0
- package/dist/force-graph.js.map +1 -0
- package/dist/force-graph.min.js +5 -0
- package/dist/force-graph.module.js +1743 -0
- package/example/auto-colored/index.html +34 -0
- package/example/basic/index.html +29 -0
- package/example/build-a-graph/index.html +108 -0
- package/example/click-to-focus/index.html +28 -0
- package/example/collision-detection/index.html +50 -0
- package/example/curved-links/index.html +37 -0
- package/example/curved-links-computed-curvature/index.html +76 -0
- package/example/custom-node-shape/index.html +44 -0
- package/example/dag-yarn/index.html +96 -0
- package/example/dagre/index.html +119 -0
- package/example/dash-odd-links/index.html +47 -0
- package/example/datasets/blocks.json +1 -0
- package/example/datasets/d3-dependencies.csv +464 -0
- package/example/datasets/miserables.json +337 -0
- package/example/datasets/mplate.mtx +74090 -0
- package/example/directional-links-arrows/index.html +29 -0
- package/example/directional-links-particles/index.html +22 -0
- package/example/dynamic/index.html +42 -0
- package/example/emit-particles/index.html +50 -0
- package/example/expandable-nodes/index.html +66 -0
- package/example/expandable-tree/index.html +85 -0
- package/example/fit-to-canvas/index.html +34 -0
- package/example/fix-dragged-nodes/index.html +24 -0
- package/example/highlight/index.html +84 -0
- package/example/huge-1M/index.html +37 -0
- package/example/img-nodes/imgs/cat.jpg +0 -0
- package/example/img-nodes/imgs/dog.jpg +0 -0
- package/example/img-nodes/imgs/eagle.jpg +0 -0
- package/example/img-nodes/imgs/elephant.jpg +0 -0
- package/example/img-nodes/imgs/grasshopper.jpg +0 -0
- package/example/img-nodes/imgs/octopus.jpg +0 -0
- package/example/img-nodes/imgs/owl.jpg +0 -0
- package/example/img-nodes/imgs/panda.jpg +0 -0
- package/example/img-nodes/imgs/squirrel.jpg +0 -0
- package/example/img-nodes/imgs/tiger.jpg +0 -0
- package/example/img-nodes/imgs/whale.jpg +0 -0
- package/example/img-nodes/index.html +43 -0
- package/example/large-graph/index.html +41 -0
- package/example/load-json/index.html +24 -0
- package/example/medium-graph/index.html +26 -0
- package/example/medium-graph/preview.png +0 -0
- package/example/move-viewport/index.html +42 -0
- package/example/multi-selection/index.html +57 -0
- package/example/responsive/index.html +37 -0
- package/example/text-links/index.html +69 -0
- package/example/text-nodes/index.html +42 -0
- package/example/tree/index.html +71 -0
- package/package.json +72 -0
- package/src/canvas-force-graph.js +544 -0
- package/src/color-utils.js +17 -0
- package/src/dagDepths.js +51 -0
- package/src/force-graph.css +35 -0
- package/src/force-graph.js +644 -0
- package/src/index.d.ts +195 -0
- package/src/index.js +3 -0
- package/src/kapsule-link.js +34 -0
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
import { select as d3Select } from 'd3-selection';
|
|
2
|
+
import { zoom as d3Zoom, zoomTransform as d3ZoomTransform } from 'd3-zoom';
|
|
3
|
+
import { drag as d3Drag } from 'd3-drag';
|
|
4
|
+
import { max as d3Max, min as d3Min } from 'd3-array';
|
|
5
|
+
import throttle from 'lodash.throttle';
|
|
6
|
+
import TWEEN from '@tweenjs/tween.js';
|
|
7
|
+
import Kapsule from 'kapsule';
|
|
8
|
+
import accessorFn from 'accessor-fn';
|
|
9
|
+
import ColorTracker from 'canvas-color-tracker';
|
|
10
|
+
|
|
11
|
+
import CanvasForceGraph from './canvas-force-graph';
|
|
12
|
+
import linkKapsule from './kapsule-link.js';
|
|
13
|
+
|
|
14
|
+
const HOVER_CANVAS_THROTTLE_DELAY = 800; // ms to throttle shadow canvas updates for perf improvement
|
|
15
|
+
const ZOOM2NODES_FACTOR = 4;
|
|
16
|
+
|
|
17
|
+
// Expose config from forceGraph
|
|
18
|
+
const bindFG = linkKapsule('forceGraph', CanvasForceGraph);
|
|
19
|
+
const bindBoth = linkKapsule(['forceGraph', 'shadowGraph'], CanvasForceGraph);
|
|
20
|
+
const linkedProps = Object.assign(
|
|
21
|
+
...[
|
|
22
|
+
'nodeColor',
|
|
23
|
+
'nodeAutoColorBy',
|
|
24
|
+
'nodeCanvasObject',
|
|
25
|
+
'nodeCanvasObjectMode',
|
|
26
|
+
'linkColor',
|
|
27
|
+
'linkAutoColorBy',
|
|
28
|
+
'linkLineDash',
|
|
29
|
+
'linkWidth',
|
|
30
|
+
'linkCanvasObject',
|
|
31
|
+
'linkCanvasObjectMode',
|
|
32
|
+
'linkDirectionalArrowLength',
|
|
33
|
+
'linkDirectionalArrowColor',
|
|
34
|
+
'linkDirectionalArrowRelPos',
|
|
35
|
+
'linkDirectionalParticles',
|
|
36
|
+
'linkDirectionalParticleSpeed',
|
|
37
|
+
'linkDirectionalParticleWidth',
|
|
38
|
+
'linkDirectionalParticleColor',
|
|
39
|
+
'dagMode',
|
|
40
|
+
'dagLevelDistance',
|
|
41
|
+
'dagNodeFilter',
|
|
42
|
+
'onDagError',
|
|
43
|
+
'd3AlphaMin',
|
|
44
|
+
'd3AlphaDecay',
|
|
45
|
+
'd3VelocityDecay',
|
|
46
|
+
'warmupTicks',
|
|
47
|
+
'cooldownTicks',
|
|
48
|
+
'cooldownTime',
|
|
49
|
+
'onEngineTick',
|
|
50
|
+
'onEngineStop'
|
|
51
|
+
].map(p => ({ [p]: bindFG.linkProp(p)})),
|
|
52
|
+
...[
|
|
53
|
+
'nodeRelSize',
|
|
54
|
+
'nodeId',
|
|
55
|
+
'nodeVal',
|
|
56
|
+
'nodeVisibility',
|
|
57
|
+
'linkSource',
|
|
58
|
+
'linkTarget',
|
|
59
|
+
'linkVisibility',
|
|
60
|
+
'linkCurvature'
|
|
61
|
+
].map(p => ({ [p]: bindBoth.linkProp(p)}))
|
|
62
|
+
);
|
|
63
|
+
const linkedMethods = Object.assign(...[
|
|
64
|
+
'd3Force',
|
|
65
|
+
'd3ReheatSimulation',
|
|
66
|
+
'emitParticle'
|
|
67
|
+
].map(p => ({ [p]: bindFG.linkMethod(p)})));
|
|
68
|
+
|
|
69
|
+
function adjustCanvasSize(state) {
|
|
70
|
+
if (state.canvas) {
|
|
71
|
+
let curWidth = state.canvas.width;
|
|
72
|
+
let curHeight = state.canvas.height;
|
|
73
|
+
if (curWidth === 300 && curHeight === 150) { // Default canvas dimensions
|
|
74
|
+
curWidth = curHeight = 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const pxScale = window.devicePixelRatio; // 2 on retina displays
|
|
78
|
+
curWidth /= pxScale;
|
|
79
|
+
curHeight /= pxScale;
|
|
80
|
+
|
|
81
|
+
// Resize canvases
|
|
82
|
+
[state.canvas, state.shadowCanvas].forEach(canvas => {
|
|
83
|
+
// Element size
|
|
84
|
+
canvas.style.width = `${state.width}px`;
|
|
85
|
+
canvas.style.height = `${state.height}px`;
|
|
86
|
+
|
|
87
|
+
// Memory size (scaled to avoid blurriness)
|
|
88
|
+
canvas.width = state.width * pxScale;
|
|
89
|
+
canvas.height = state.height * pxScale;
|
|
90
|
+
|
|
91
|
+
// Normalize coordinate system to use css pixels (on init only)
|
|
92
|
+
if (!curWidth && !curHeight) {
|
|
93
|
+
canvas.getContext('2d').scale(pxScale, pxScale);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Relative center panning based on 0,0
|
|
98
|
+
const k = d3ZoomTransform(state.canvas).k;
|
|
99
|
+
state.zoom.translateBy(state.zoom.__baseElem,
|
|
100
|
+
(state.width - curWidth) / 2 / k,
|
|
101
|
+
(state.height - curHeight) / 2 / k
|
|
102
|
+
);
|
|
103
|
+
state.needsRedraw = true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function resetTransform(ctx) {
|
|
108
|
+
const pxRatio = window.devicePixelRatio;
|
|
109
|
+
ctx.setTransform(pxRatio, 0, 0, pxRatio, 0, 0);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function clearCanvas(ctx, width, height) {
|
|
113
|
+
ctx.save();
|
|
114
|
+
resetTransform(ctx); // reset transform
|
|
115
|
+
ctx.clearRect(0, 0, width, height);
|
|
116
|
+
ctx.restore(); //restore transforms
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
//
|
|
120
|
+
|
|
121
|
+
export default Kapsule({
|
|
122
|
+
props:{
|
|
123
|
+
width: { default: window.innerWidth, onChange: (_, state) => adjustCanvasSize(state), triggerUpdate: false } ,
|
|
124
|
+
height: { default: window.innerHeight, onChange: (_, state) => adjustCanvasSize(state), triggerUpdate: false },
|
|
125
|
+
graphData: {
|
|
126
|
+
default: { nodes: [], links: [] },
|
|
127
|
+
onChange: ((d, state) => {
|
|
128
|
+
[{ type: 'Node', objs: d.nodes }, { type: 'Link', objs: d.links }].forEach(hexIndex);
|
|
129
|
+
state.forceGraph.graphData(d);
|
|
130
|
+
state.shadowGraph.graphData(d);
|
|
131
|
+
|
|
132
|
+
function hexIndex({ type, objs }) {
|
|
133
|
+
objs
|
|
134
|
+
.filter(d => {
|
|
135
|
+
if (!d.hasOwnProperty('__indexColor')) return true;
|
|
136
|
+
const cur = state.colorTracker.lookup(d.__indexColor);
|
|
137
|
+
return (!cur || !cur.hasOwnProperty('d') || cur.d !== d);
|
|
138
|
+
})
|
|
139
|
+
.forEach(d => {
|
|
140
|
+
// store object lookup color
|
|
141
|
+
d.__indexColor = state.colorTracker.register({ type, d });
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}),
|
|
145
|
+
triggerUpdate: false
|
|
146
|
+
},
|
|
147
|
+
backgroundColor: { onChange(color, state) { state.canvas && color && (state.canvas.style.background = color) }, triggerUpdate: false },
|
|
148
|
+
nodeLabel: { default: 'name', triggerUpdate: false },
|
|
149
|
+
nodePointerAreaPaint: { onChange(paintFn, state) {
|
|
150
|
+
state.shadowGraph.nodeCanvasObject(!paintFn ? null :
|
|
151
|
+
(node, ctx, globalScale) => paintFn(node, node.__indexColor, ctx, globalScale)
|
|
152
|
+
);
|
|
153
|
+
state.flushShadowCanvas && state.flushShadowCanvas();
|
|
154
|
+
}, triggerUpdate: false },
|
|
155
|
+
linkPointerAreaPaint: { onChange(paintFn, state) {
|
|
156
|
+
state.shadowGraph.linkCanvasObject(!paintFn ? null :
|
|
157
|
+
(link, ctx, globalScale) => paintFn(link, link.__indexColor, ctx, globalScale)
|
|
158
|
+
);
|
|
159
|
+
state.flushShadowCanvas && state.flushShadowCanvas();
|
|
160
|
+
}, triggerUpdate: false },
|
|
161
|
+
linkLabel: { default: 'name', triggerUpdate: false },
|
|
162
|
+
linkHoverPrecision: { default: 4, triggerUpdate: false },
|
|
163
|
+
minZoom: { default: 0.01, onChange(minZoom, state) { state.zoom.scaleExtent([minZoom, state.zoom.scaleExtent()[1]]); }, triggerUpdate: false },
|
|
164
|
+
maxZoom: { default: 1000, onChange(maxZoom, state) { state.zoom.scaleExtent([state.zoom.scaleExtent()[0], maxZoom]) }, triggerUpdate: false },
|
|
165
|
+
enableNodeDrag: { default: true, triggerUpdate: false },
|
|
166
|
+
enableZoomInteraction: { default: true, triggerUpdate: false },
|
|
167
|
+
enablePanInteraction: { default: true, triggerUpdate: false },
|
|
168
|
+
enableZoomPanInteraction: { default: true, triggerUpdate: false }, // to be deprecated
|
|
169
|
+
enablePointerInteraction: { default: true, onChange(_, state) { state.hoverObj = null; }, triggerUpdate: false },
|
|
170
|
+
autoPauseRedraw: { default: true, triggerUpdate: false },
|
|
171
|
+
onNodeDrag: { default: () => {}, triggerUpdate: false },
|
|
172
|
+
onNodeDragEnd: { default: () => {}, triggerUpdate: false },
|
|
173
|
+
onNodeClick: { triggerUpdate: false },
|
|
174
|
+
onNodeRightClick: { triggerUpdate: false },
|
|
175
|
+
onNodeHover: { triggerUpdate: false },
|
|
176
|
+
onLinkClick: { triggerUpdate: false },
|
|
177
|
+
onLinkRightClick: { triggerUpdate: false },
|
|
178
|
+
onLinkHover: { triggerUpdate: false },
|
|
179
|
+
onBackgroundClick: { triggerUpdate: false },
|
|
180
|
+
onBackgroundRightClick: { triggerUpdate: false },
|
|
181
|
+
onZoom: { default: () => {}, triggerUpdate: false },
|
|
182
|
+
onZoomEnd: { default: () => {}, triggerUpdate: false },
|
|
183
|
+
onRenderFramePre: { triggerUpdate: false },
|
|
184
|
+
onRenderFramePost: { triggerUpdate: false },
|
|
185
|
+
...linkedProps
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
aliases: { // Prop names supported for backwards compatibility
|
|
189
|
+
stopAnimation: 'pauseAnimation'
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
methods: {
|
|
193
|
+
graph2ScreenCoords: function(state, x, y) {
|
|
194
|
+
const t = d3ZoomTransform(state.canvas);
|
|
195
|
+
return { x: x * t.k + t.x, y: y * t.k + t.y };
|
|
196
|
+
},
|
|
197
|
+
screen2GraphCoords: function(state, x, y) {
|
|
198
|
+
const t = d3ZoomTransform(state.canvas);
|
|
199
|
+
return { x: (x - t.x) / t.k, y: (y - t.y) / t.k };
|
|
200
|
+
},
|
|
201
|
+
centerAt: function(state, x, y, transitionDuration) {
|
|
202
|
+
if (!state.canvas) return null; // no canvas yet
|
|
203
|
+
|
|
204
|
+
// setter
|
|
205
|
+
if (x !== undefined || y !== undefined) {
|
|
206
|
+
const finalPos = Object.assign({},
|
|
207
|
+
x !== undefined ? { x } : {},
|
|
208
|
+
y !== undefined ? { y } : {}
|
|
209
|
+
);
|
|
210
|
+
if (!transitionDuration) { // no animation
|
|
211
|
+
setCenter(finalPos);
|
|
212
|
+
} else {
|
|
213
|
+
new TWEEN.Tween(getCenter())
|
|
214
|
+
.to(finalPos, transitionDuration)
|
|
215
|
+
.easing(TWEEN.Easing.Quadratic.Out)
|
|
216
|
+
.onUpdate(setCenter)
|
|
217
|
+
.start();
|
|
218
|
+
}
|
|
219
|
+
return this;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// getter
|
|
223
|
+
return getCenter();
|
|
224
|
+
|
|
225
|
+
//
|
|
226
|
+
|
|
227
|
+
function getCenter() {
|
|
228
|
+
const t = d3ZoomTransform(state.canvas);
|
|
229
|
+
return { x: (state.width / 2 - t.x) / t.k, y: (state.height / 2 - t.y) / t.k };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function setCenter({ x, y }) {
|
|
233
|
+
state.zoom.translateTo(
|
|
234
|
+
state.zoom.__baseElem,
|
|
235
|
+
x === undefined ? getCenter().x : x,
|
|
236
|
+
y === undefined ? getCenter().y : y
|
|
237
|
+
);
|
|
238
|
+
state.needsRedraw = true;
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
zoom: function(state, k, transitionDuration) {
|
|
242
|
+
if (!state.canvas) return null; // no canvas yet
|
|
243
|
+
|
|
244
|
+
// setter
|
|
245
|
+
if (k !== undefined) {
|
|
246
|
+
if (!transitionDuration) { // no animation
|
|
247
|
+
setZoom(k);
|
|
248
|
+
} else {
|
|
249
|
+
new TWEEN.Tween({ k: getZoom() })
|
|
250
|
+
.to({ k }, transitionDuration)
|
|
251
|
+
.easing(TWEEN.Easing.Quadratic.Out)
|
|
252
|
+
.onUpdate(({ k }) => setZoom(k))
|
|
253
|
+
.start();
|
|
254
|
+
}
|
|
255
|
+
return this;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// getter
|
|
259
|
+
return getZoom();
|
|
260
|
+
|
|
261
|
+
//
|
|
262
|
+
|
|
263
|
+
function getZoom() {
|
|
264
|
+
return d3ZoomTransform(state.canvas).k;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function setZoom(k) {
|
|
268
|
+
state.zoom.scaleTo(state.zoom.__baseElem, k);
|
|
269
|
+
state.needsRedraw = true;
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
zoomToFit: function(state, transitionDuration = 0, padding = 10, ...bboxArgs) {
|
|
273
|
+
const bbox = this.getGraphBbox(...bboxArgs);
|
|
274
|
+
|
|
275
|
+
if (bbox) {
|
|
276
|
+
const center = {
|
|
277
|
+
x: (bbox.x[0] + bbox.x[1]) / 2,
|
|
278
|
+
y: (bbox.y[0] + bbox.y[1]) / 2,
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const zoomK = Math.max(1e-12, Math.min(1e12,
|
|
282
|
+
(state.width - padding * 2) / (bbox.x[1] - bbox.x[0]),
|
|
283
|
+
(state.height - padding * 2) / (bbox.y[1] - bbox.y[0]))
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
this.centerAt(center.x, center.y, transitionDuration);
|
|
287
|
+
this.zoom(zoomK, transitionDuration);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return this;
|
|
291
|
+
},
|
|
292
|
+
getGraphBbox: function(state, nodeFilter = () => true) {
|
|
293
|
+
const getVal = accessorFn(state.nodeVal);
|
|
294
|
+
const getR = node => Math.sqrt(Math.max(0, getVal(node) || 1)) * state.nodeRelSize;
|
|
295
|
+
|
|
296
|
+
const nodesPos = state.graphData.nodes.filter(nodeFilter).map(node => ({
|
|
297
|
+
x: node.x,
|
|
298
|
+
y: node.y,
|
|
299
|
+
r: getR(node)
|
|
300
|
+
}));
|
|
301
|
+
|
|
302
|
+
return !nodesPos.length ? null : {
|
|
303
|
+
x: [
|
|
304
|
+
d3Min(nodesPos, node => node.x - node.r),
|
|
305
|
+
d3Max(nodesPos, node => node.x + node.r)
|
|
306
|
+
],
|
|
307
|
+
y: [
|
|
308
|
+
d3Min(nodesPos, node => node.y - node.r),
|
|
309
|
+
d3Max(nodesPos, node => node.y + node.r)
|
|
310
|
+
]
|
|
311
|
+
};
|
|
312
|
+
},
|
|
313
|
+
pauseAnimation: function(state) {
|
|
314
|
+
if (state.animationFrameRequestId) {
|
|
315
|
+
cancelAnimationFrame(state.animationFrameRequestId);
|
|
316
|
+
state.animationFrameRequestId = null;
|
|
317
|
+
}
|
|
318
|
+
return this;
|
|
319
|
+
},
|
|
320
|
+
resumeAnimation: function(state) {
|
|
321
|
+
if (!state.animationFrameRequestId) {
|
|
322
|
+
this._animationCycle();
|
|
323
|
+
}
|
|
324
|
+
return this;
|
|
325
|
+
},
|
|
326
|
+
_destructor: function() {
|
|
327
|
+
this.pauseAnimation();
|
|
328
|
+
this.graphData({ nodes: [], links: []});
|
|
329
|
+
},
|
|
330
|
+
...linkedMethods
|
|
331
|
+
},
|
|
332
|
+
|
|
333
|
+
stateInit: () => ({
|
|
334
|
+
lastSetZoom: 1,
|
|
335
|
+
zoom: d3Zoom(),
|
|
336
|
+
forceGraph: new CanvasForceGraph(),
|
|
337
|
+
shadowGraph: new CanvasForceGraph()
|
|
338
|
+
.cooldownTicks(0)
|
|
339
|
+
.nodeColor('__indexColor')
|
|
340
|
+
.linkColor('__indexColor')
|
|
341
|
+
.isShadow(true),
|
|
342
|
+
colorTracker: new ColorTracker() // indexed objects for rgb lookup
|
|
343
|
+
}),
|
|
344
|
+
|
|
345
|
+
init: function(domNode, state) {
|
|
346
|
+
// Wipe DOM
|
|
347
|
+
domNode.innerHTML = '';
|
|
348
|
+
|
|
349
|
+
// Container anchor for canvas and tooltip
|
|
350
|
+
const container = document.createElement('div');
|
|
351
|
+
container.classList.add('force-graph-container');
|
|
352
|
+
container.style.position = 'relative';
|
|
353
|
+
domNode.appendChild(container);
|
|
354
|
+
|
|
355
|
+
state.canvas = document.createElement('canvas');
|
|
356
|
+
if (state.backgroundColor) state.canvas.style.background = state.backgroundColor;
|
|
357
|
+
container.appendChild(state.canvas);
|
|
358
|
+
|
|
359
|
+
state.shadowCanvas = document.createElement('canvas');
|
|
360
|
+
|
|
361
|
+
// Show shadow canvas
|
|
362
|
+
//state.shadowCanvas.style.position = 'absolute';
|
|
363
|
+
//state.shadowCanvas.style.top = '0';
|
|
364
|
+
//state.shadowCanvas.style.left = '0';
|
|
365
|
+
//container.appendChild(state.shadowCanvas);
|
|
366
|
+
|
|
367
|
+
const ctx = state.canvas.getContext('2d');
|
|
368
|
+
const shadowCtx = state.shadowCanvas.getContext('2d');
|
|
369
|
+
|
|
370
|
+
const pointerPos = { x: -1e12, y: -1e12 };
|
|
371
|
+
const getObjUnderPointer = () => {
|
|
372
|
+
let obj = null;
|
|
373
|
+
const pxScale = window.devicePixelRatio;
|
|
374
|
+
const px = (pointerPos.x > 0 && pointerPos.y > 0)
|
|
375
|
+
? shadowCtx.getImageData(pointerPos.x * pxScale, pointerPos.y * pxScale, 1, 1)
|
|
376
|
+
: null;
|
|
377
|
+
// Lookup object per pixel color
|
|
378
|
+
px && (obj = state.colorTracker.lookup(px.data));
|
|
379
|
+
return obj;
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
// Setup node drag interaction
|
|
383
|
+
d3Select(state.canvas).call(
|
|
384
|
+
d3Drag()
|
|
385
|
+
.subject(() => {
|
|
386
|
+
if (!state.enableNodeDrag) { return null; }
|
|
387
|
+
const obj = getObjUnderPointer();
|
|
388
|
+
return (obj && obj.type === 'Node') ? obj.d : null; // Only drag nodes
|
|
389
|
+
})
|
|
390
|
+
.on('start', ev => {
|
|
391
|
+
const obj = ev.subject;
|
|
392
|
+
obj.__initialDragPos = { x: obj.x, y: obj.y, fx: obj.fx, fy: obj.fy };
|
|
393
|
+
|
|
394
|
+
// keep engine running at low intensity throughout drag
|
|
395
|
+
if (!ev.active) {
|
|
396
|
+
obj.fx = obj.x; obj.fy = obj.y; // Fix points
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// drag cursor
|
|
400
|
+
state.canvas.classList.add('grabbable');
|
|
401
|
+
})
|
|
402
|
+
.on('drag', ev => {
|
|
403
|
+
const obj = ev.subject;
|
|
404
|
+
const initPos = obj.__initialDragPos;
|
|
405
|
+
const dragPos = ev;
|
|
406
|
+
|
|
407
|
+
const k = d3ZoomTransform(state.canvas).k;
|
|
408
|
+
const translate = {
|
|
409
|
+
x: (initPos.x + (dragPos.x - initPos.x) / k) - obj.x,
|
|
410
|
+
y: (initPos.y + (dragPos.y - initPos.y) / k) - obj.y
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
// Move fx/fy (and x/y) of nodes based on the scaled drag distance since the drag start
|
|
414
|
+
['x', 'y'].forEach(c => obj[`f${c}`] = obj[c] = initPos[c] + (dragPos[c] - initPos[c]) / k);
|
|
415
|
+
|
|
416
|
+
// prevent freeze while dragging
|
|
417
|
+
state.forceGraph
|
|
418
|
+
.d3AlphaTarget(0.3) // keep engine running at low intensity throughout drag
|
|
419
|
+
.resetCountdown(); // prevent freeze while dragging
|
|
420
|
+
|
|
421
|
+
state.isPointerDragging = true;
|
|
422
|
+
|
|
423
|
+
obj.__dragged = true;
|
|
424
|
+
state.onNodeDrag(obj, translate);
|
|
425
|
+
})
|
|
426
|
+
.on('end', ev => {
|
|
427
|
+
const obj = ev.subject;
|
|
428
|
+
const initPos = obj.__initialDragPos;
|
|
429
|
+
const translate = {x: obj.x - initPos.x, y: obj.y - initPos.y};
|
|
430
|
+
|
|
431
|
+
if (initPos.fx === undefined) { obj.fx = undefined; }
|
|
432
|
+
if (initPos.fy === undefined) { obj.fy = undefined; }
|
|
433
|
+
delete(obj.__initialDragPos);
|
|
434
|
+
|
|
435
|
+
if (state.forceGraph.d3AlphaTarget()) {
|
|
436
|
+
state.forceGraph
|
|
437
|
+
.d3AlphaTarget(0) // release engine low intensity
|
|
438
|
+
.resetCountdown(); // let the engine readjust after releasing fixed nodes
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// drag cursor
|
|
442
|
+
state.canvas.classList.remove('grabbable');
|
|
443
|
+
|
|
444
|
+
state.isPointerDragging = false;
|
|
445
|
+
|
|
446
|
+
if (obj.__dragged) {
|
|
447
|
+
delete(obj.__dragged);
|
|
448
|
+
state.onNodeDragEnd(obj, translate);
|
|
449
|
+
}
|
|
450
|
+
})
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
// Setup zoom / pan interaction
|
|
454
|
+
state.zoom(state.zoom.__baseElem = d3Select(state.canvas)); // Attach controlling elem for easy access
|
|
455
|
+
|
|
456
|
+
state.zoom.__baseElem.on('dblclick.zoom', null); // Disable double-click to zoom
|
|
457
|
+
|
|
458
|
+
state.zoom
|
|
459
|
+
.filter(ev =>
|
|
460
|
+
// disable zoom interaction
|
|
461
|
+
!ev.button
|
|
462
|
+
&& state.enableZoomPanInteraction
|
|
463
|
+
&& (state.enableZoomInteraction || ev.type !== 'wheel')
|
|
464
|
+
&& (state.enablePanInteraction || ev.type === 'wheel')
|
|
465
|
+
)
|
|
466
|
+
.on('zoom', ev => {
|
|
467
|
+
const t = ev.transform;
|
|
468
|
+
[ctx, shadowCtx].forEach(c => {
|
|
469
|
+
resetTransform(c);
|
|
470
|
+
c.translate(t.x, t.y);
|
|
471
|
+
c.scale(t.k, t.k);
|
|
472
|
+
});
|
|
473
|
+
state.onZoom({ ...t });
|
|
474
|
+
state.needsRedraw = true;
|
|
475
|
+
})
|
|
476
|
+
.on('end', ev => state.onZoomEnd({ ...ev.transform }));
|
|
477
|
+
|
|
478
|
+
adjustCanvasSize(state);
|
|
479
|
+
|
|
480
|
+
state.forceGraph
|
|
481
|
+
.onNeedsRedraw(() => state.needsRedraw = true)
|
|
482
|
+
.onFinishUpdate(() => {
|
|
483
|
+
// re-zoom, if still in default position (not user modified)
|
|
484
|
+
if (d3ZoomTransform(state.canvas).k === state.lastSetZoom && state.graphData.nodes.length) {
|
|
485
|
+
state.zoom.scaleTo(state.zoom.__baseElem,
|
|
486
|
+
state.lastSetZoom = ZOOM2NODES_FACTOR / Math.cbrt(state.graphData.nodes.length)
|
|
487
|
+
);
|
|
488
|
+
state.needsRedraw = true;
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
// Setup tooltip
|
|
493
|
+
const toolTipElem = document.createElement('div');
|
|
494
|
+
toolTipElem.classList.add('graph-tooltip');
|
|
495
|
+
container.appendChild(toolTipElem);
|
|
496
|
+
|
|
497
|
+
// Capture pointer coords on move or touchstart
|
|
498
|
+
['pointermove', 'pointerdown'].forEach(evType =>
|
|
499
|
+
container.addEventListener(evType, ev => {
|
|
500
|
+
if (evType === 'pointerdown') {
|
|
501
|
+
state.isPointerPressed = true; // track click state
|
|
502
|
+
state.pointerDownEvent = ev;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// detect pointer drag on canvas pan
|
|
506
|
+
!state.isPointerDragging && ev.type === 'pointermove'
|
|
507
|
+
&& (state.onBackgroundClick) // only bother detecting drags this way if background clicks are enabled (so they don't trigger accidentally on canvas panning)
|
|
508
|
+
&& (ev.pressure > 0 || state.isPointerPressed) // ev.pressure always 0 on Safari, so we use the isPointerPressed tracker
|
|
509
|
+
&& (ev.pointerType !== 'touch' || ev.movementX === undefined || [ev.movementX, ev.movementY].some(m => Math.abs(m) > 1)) // relax drag trigger sensitivity on touch events
|
|
510
|
+
&& (state.isPointerDragging = true);
|
|
511
|
+
|
|
512
|
+
// update the pointer pos
|
|
513
|
+
const offset = getOffset(container);
|
|
514
|
+
pointerPos.x = ev.pageX - offset.left;
|
|
515
|
+
pointerPos.y = ev.pageY - offset.top;
|
|
516
|
+
|
|
517
|
+
// Move tooltip
|
|
518
|
+
toolTipElem.style.top = `${pointerPos.y}px`;
|
|
519
|
+
toolTipElem.style.left = `${pointerPos.x}px`;
|
|
520
|
+
|
|
521
|
+
//
|
|
522
|
+
|
|
523
|
+
function getOffset(el) {
|
|
524
|
+
const rect = el.getBoundingClientRect(),
|
|
525
|
+
scrollLeft = window.pageXOffset || document.documentElement.scrollLeft,
|
|
526
|
+
scrollTop = window.pageYOffset || document.documentElement.scrollTop;
|
|
527
|
+
return { top: rect.top + scrollTop, left: rect.left + scrollLeft };
|
|
528
|
+
}
|
|
529
|
+
}, { passive: true })
|
|
530
|
+
);
|
|
531
|
+
|
|
532
|
+
// Handle click/touch events on nodes/links
|
|
533
|
+
container.addEventListener('pointerup', ev => {
|
|
534
|
+
state.isPointerPressed = false;
|
|
535
|
+
if (state.isPointerDragging) {
|
|
536
|
+
state.isPointerDragging = false;
|
|
537
|
+
return; // don't trigger click events after pointer drag (pan / node drag functionality)
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const cbEvents = [ev, state.pointerDownEvent];
|
|
541
|
+
requestAnimationFrame(() => { // trigger click events asynchronously, to allow hoverObj to be set (on frame)
|
|
542
|
+
if (ev.button === 0) { // mouse left-click or touch
|
|
543
|
+
if (state.hoverObj) {
|
|
544
|
+
const fn = state[`on${state.hoverObj.type}Click`];
|
|
545
|
+
fn && fn(state.hoverObj.d, ...cbEvents);
|
|
546
|
+
} else {
|
|
547
|
+
state.onBackgroundClick && state.onBackgroundClick(...cbEvents);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (ev.button === 2) { // mouse right-click
|
|
552
|
+
if (state.hoverObj) {
|
|
553
|
+
const fn = state[`on${state.hoverObj.type}RightClick`];
|
|
554
|
+
fn && fn(state.hoverObj.d, ...cbEvents);
|
|
555
|
+
} else {
|
|
556
|
+
state.onBackgroundRightClick && state.onBackgroundRightClick(...cbEvents);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
}, { passive: true });
|
|
561
|
+
|
|
562
|
+
container.addEventListener('contextmenu', ev => {
|
|
563
|
+
if (!state.onBackgroundRightClick && !state.onNodeRightClick && !state.onLinkRightClick) return true; // default contextmenu behavior
|
|
564
|
+
ev.preventDefault();
|
|
565
|
+
return false;
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
state.forceGraph(ctx);
|
|
569
|
+
state.shadowGraph(shadowCtx);
|
|
570
|
+
|
|
571
|
+
//
|
|
572
|
+
|
|
573
|
+
const refreshShadowCanvas = throttle(() => {
|
|
574
|
+
// wipe canvas
|
|
575
|
+
clearCanvas(shadowCtx, state.width, state.height);
|
|
576
|
+
|
|
577
|
+
// Adjust link hover area
|
|
578
|
+
state.shadowGraph.linkWidth(l => accessorFn(state.linkWidth)(l) + state.linkHoverPrecision);
|
|
579
|
+
|
|
580
|
+
// redraw
|
|
581
|
+
const t = d3ZoomTransform(state.canvas);
|
|
582
|
+
state.shadowGraph.globalScale(t.k).tickFrame();
|
|
583
|
+
}, HOVER_CANVAS_THROTTLE_DELAY);
|
|
584
|
+
state.flushShadowCanvas = refreshShadowCanvas.flush; // hook to immediately invoke shadow canvas paint
|
|
585
|
+
|
|
586
|
+
// Kick-off renderer
|
|
587
|
+
(this._animationCycle = function animate() { // IIFE
|
|
588
|
+
const doRedraw = !state.autoPauseRedraw || !!state.needsRedraw || state.forceGraph.isEngineRunning()
|
|
589
|
+
|| state.graphData.links.some(d => d.__photons && d.__photons.length);
|
|
590
|
+
state.needsRedraw = false;
|
|
591
|
+
|
|
592
|
+
if (state.enablePointerInteraction) {
|
|
593
|
+
// Update tooltip and trigger onHover events
|
|
594
|
+
const obj = !state.isPointerDragging ? getObjUnderPointer() : null; // don't hover during drag
|
|
595
|
+
if (obj !== state.hoverObj) {
|
|
596
|
+
const prevObj = state.hoverObj;
|
|
597
|
+
const prevObjType = prevObj ? prevObj.type : null;
|
|
598
|
+
const objType = obj ? obj.type : null;
|
|
599
|
+
|
|
600
|
+
if (prevObjType && prevObjType !== objType) {
|
|
601
|
+
// Hover out
|
|
602
|
+
const fn = state[`on${prevObjType}Hover`];
|
|
603
|
+
fn && fn(null, prevObj.d);
|
|
604
|
+
}
|
|
605
|
+
if (objType) {
|
|
606
|
+
// Hover in
|
|
607
|
+
const fn = state[`on${objType}Hover`];
|
|
608
|
+
fn && fn(obj.d, prevObjType === objType ? prevObj.d : null);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const tooltipContent = obj ? accessorFn(state[`${obj.type.toLowerCase()}Label`])(obj.d) || '' : '';
|
|
612
|
+
toolTipElem.style.visibility = tooltipContent ? 'visible' : 'hidden';
|
|
613
|
+
toolTipElem.innerHTML = tooltipContent;
|
|
614
|
+
|
|
615
|
+
// set pointer if hovered object is clickable
|
|
616
|
+
state.canvas.classList[
|
|
617
|
+
((obj && state[`on${objType}Click`]) || (!obj && state.onBackgroundClick)) ? 'add' : 'remove'
|
|
618
|
+
]('clickable');
|
|
619
|
+
|
|
620
|
+
state.hoverObj = obj;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
doRedraw && refreshShadowCanvas();
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if(doRedraw) {
|
|
627
|
+
// Wipe canvas
|
|
628
|
+
clearCanvas(ctx, state.width, state.height);
|
|
629
|
+
|
|
630
|
+
// Frame cycle
|
|
631
|
+
const globalScale = d3ZoomTransform(state.canvas).k;
|
|
632
|
+
state.onRenderFramePre && state.onRenderFramePre(ctx, globalScale);
|
|
633
|
+
state.forceGraph.globalScale(globalScale).tickFrame();
|
|
634
|
+
state.onRenderFramePost && state.onRenderFramePost(ctx, globalScale);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
TWEEN.update(); // update canvas animation tweens
|
|
638
|
+
|
|
639
|
+
state.animationFrameRequestId = requestAnimationFrame(animate);
|
|
640
|
+
})();
|
|
641
|
+
},
|
|
642
|
+
|
|
643
|
+
update: function updateFn(state) {}
|
|
644
|
+
});
|