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,544 @@
|
|
|
1
|
+
import {
|
|
2
|
+
forceSimulation as d3ForceSimulation,
|
|
3
|
+
forceLink as d3ForceLink,
|
|
4
|
+
forceManyBody as d3ForceManyBody,
|
|
5
|
+
forceCenter as d3ForceCenter,
|
|
6
|
+
forceRadial as d3ForceRadial
|
|
7
|
+
} from 'd3-force-3d';
|
|
8
|
+
|
|
9
|
+
import { Bezier } from 'bezier-js';
|
|
10
|
+
|
|
11
|
+
import Kapsule from 'kapsule';
|
|
12
|
+
import accessorFn from 'accessor-fn';
|
|
13
|
+
import indexBy from 'index-array-by';
|
|
14
|
+
|
|
15
|
+
import { autoColorObjects } from './color-utils';
|
|
16
|
+
import getDagDepths from './dagDepths';
|
|
17
|
+
|
|
18
|
+
//
|
|
19
|
+
|
|
20
|
+
const DAG_LEVEL_NODE_RATIO = 2;
|
|
21
|
+
|
|
22
|
+
// whenever styling props are changed that require a canvas redraw
|
|
23
|
+
const notifyRedraw = (_, state) => state.onNeedsRedraw && state.onNeedsRedraw();
|
|
24
|
+
|
|
25
|
+
export default Kapsule({
|
|
26
|
+
|
|
27
|
+
props: {
|
|
28
|
+
graphData: {
|
|
29
|
+
default: {
|
|
30
|
+
nodes: [],
|
|
31
|
+
links: []
|
|
32
|
+
},
|
|
33
|
+
onChange(_, state) {
|
|
34
|
+
state.engineRunning = false;
|
|
35
|
+
} // Pause simulation
|
|
36
|
+
},
|
|
37
|
+
dagMode: { onChange(dagMode, state) { // td, bu, lr, rl, radialin, radialout
|
|
38
|
+
!dagMode && (state.graphData.nodes || []).forEach(n => n.fx = n.fy = undefined); // unfix nodes when disabling dag mode
|
|
39
|
+
}},
|
|
40
|
+
dagLevelDistance: {},
|
|
41
|
+
dagNodeFilter: { default: node => true },
|
|
42
|
+
onDagError: { triggerUpdate: false },
|
|
43
|
+
nodeRelSize: { default: 4, triggerUpdate: false, onChange: notifyRedraw }, // area per val unit
|
|
44
|
+
nodeId: { default: 'id' },
|
|
45
|
+
nodeVal: { default: 'val', triggerUpdate: false, onChange: notifyRedraw },
|
|
46
|
+
nodeColor: { default: 'color', triggerUpdate: false, onChange: notifyRedraw },
|
|
47
|
+
nodeAutoColorBy: {},
|
|
48
|
+
nodeCanvasObject: { triggerUpdate: false, onChange: notifyRedraw },
|
|
49
|
+
nodeCanvasObjectMode: { default: () => 'replace', triggerUpdate: false, onChange: notifyRedraw },
|
|
50
|
+
nodeVisibility: { default: true, triggerUpdate: false, onChange: notifyRedraw },
|
|
51
|
+
linkSource: { default: 'source' },
|
|
52
|
+
linkTarget: { default: 'target' },
|
|
53
|
+
linkVisibility: { default: true, triggerUpdate: false, onChange: notifyRedraw },
|
|
54
|
+
linkColor: { default: 'color', triggerUpdate: false, onChange: notifyRedraw },
|
|
55
|
+
linkAutoColorBy: {},
|
|
56
|
+
linkLineDash: { triggerUpdate: false, onChange: notifyRedraw },
|
|
57
|
+
linkWidth: { default: 1, triggerUpdate: false, onChange: notifyRedraw },
|
|
58
|
+
linkCurvature: { default: 0, triggerUpdate: false, onChange: notifyRedraw },
|
|
59
|
+
linkCanvasObject: { triggerUpdate: false, onChange: notifyRedraw },
|
|
60
|
+
linkCanvasObjectMode: { default: () => 'replace', triggerUpdate: false, onChange: notifyRedraw },
|
|
61
|
+
linkDirectionalArrowLength: { default: 0, triggerUpdate: false, onChange: notifyRedraw },
|
|
62
|
+
linkDirectionalArrowColor: { triggerUpdate: false, onChange: notifyRedraw },
|
|
63
|
+
linkDirectionalArrowRelPos: { default: 0.5, triggerUpdate: false, onChange: notifyRedraw }, // value between 0<>1 indicating the relative pos along the (exposed) line
|
|
64
|
+
linkDirectionalParticles: { default: 0 }, // animate photons travelling in the link direction
|
|
65
|
+
linkDirectionalParticleSpeed: { default: 0.01, triggerUpdate: false }, // in link length ratio per frame
|
|
66
|
+
linkDirectionalParticleWidth: { default: 4, triggerUpdate: false },
|
|
67
|
+
linkDirectionalParticleColor: { triggerUpdate: false },
|
|
68
|
+
globalScale: { default: 1, triggerUpdate: false },
|
|
69
|
+
d3AlphaMin: { default: 0, triggerUpdate: false},
|
|
70
|
+
d3AlphaDecay: { default: 0.0228, triggerUpdate: false, onChange(alphaDecay, state) { state.forceLayout.alphaDecay(alphaDecay) }},
|
|
71
|
+
d3AlphaTarget: { default: 0, triggerUpdate: false, onChange(alphaTarget, state) { state.forceLayout.alphaTarget(alphaTarget) }},
|
|
72
|
+
d3VelocityDecay: { default: 0.4, triggerUpdate: false, onChange(velocityDecay, state) { state.forceLayout.velocityDecay(velocityDecay) } },
|
|
73
|
+
warmupTicks: { default: 0, triggerUpdate: false }, // how many times to tick the force engine at init before starting to render
|
|
74
|
+
cooldownTicks: { default: Infinity, triggerUpdate: false },
|
|
75
|
+
cooldownTime: { default: 15000, triggerUpdate: false }, // ms
|
|
76
|
+
onUpdate: { default: () => {}, triggerUpdate: false },
|
|
77
|
+
onFinishUpdate: { default: () => {}, triggerUpdate: false },
|
|
78
|
+
onEngineTick: { default: () => {}, triggerUpdate: false },
|
|
79
|
+
onEngineStop: { default: () => {}, triggerUpdate: false },
|
|
80
|
+
onNeedsRedraw: { triggerUpdate: false },
|
|
81
|
+
isShadow: { default: false, triggerUpdate: false }
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
methods: {
|
|
85
|
+
// Expose d3 forces for external manipulation
|
|
86
|
+
d3Force: function(state, forceName, forceFn) {
|
|
87
|
+
if (forceFn === undefined) {
|
|
88
|
+
return state.forceLayout.force(forceName); // Force getter
|
|
89
|
+
}
|
|
90
|
+
state.forceLayout.force(forceName, forceFn); // Force setter
|
|
91
|
+
return this;
|
|
92
|
+
},
|
|
93
|
+
d3ReheatSimulation: function(state) {
|
|
94
|
+
state.forceLayout.alpha(1);
|
|
95
|
+
this.resetCountdown();
|
|
96
|
+
return this;
|
|
97
|
+
},
|
|
98
|
+
// reset cooldown state
|
|
99
|
+
resetCountdown: function(state) {
|
|
100
|
+
state.cntTicks = 0;
|
|
101
|
+
state.startTickTime = new Date();
|
|
102
|
+
state.engineRunning = true;
|
|
103
|
+
return this;
|
|
104
|
+
},
|
|
105
|
+
isEngineRunning: state => !!state.engineRunning,
|
|
106
|
+
tickFrame: function(state) {
|
|
107
|
+
!state.isShadow && layoutTick();
|
|
108
|
+
paintLinks();
|
|
109
|
+
!state.isShadow && paintArrows();
|
|
110
|
+
!state.isShadow && paintPhotons();
|
|
111
|
+
paintNodes();
|
|
112
|
+
|
|
113
|
+
return this;
|
|
114
|
+
|
|
115
|
+
//
|
|
116
|
+
|
|
117
|
+
function layoutTick() {
|
|
118
|
+
if (state.engineRunning) {
|
|
119
|
+
if (
|
|
120
|
+
++state.cntTicks > state.cooldownTicks ||
|
|
121
|
+
(new Date()) - state.startTickTime > state.cooldownTime ||
|
|
122
|
+
(state.d3AlphaMin > 0 && state.forceLayout.alpha() < state.d3AlphaMin)
|
|
123
|
+
) {
|
|
124
|
+
state.engineRunning = false; // Stop ticking graph
|
|
125
|
+
state.onEngineStop();
|
|
126
|
+
} else {
|
|
127
|
+
state.forceLayout.tick(); // Tick it
|
|
128
|
+
state.onEngineTick();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function paintNodes() {
|
|
134
|
+
const getVisibility = accessorFn(state.nodeVisibility);
|
|
135
|
+
const getVal = accessorFn(state.nodeVal);
|
|
136
|
+
const getColor = accessorFn(state.nodeColor);
|
|
137
|
+
const getNodeCanvasObjectMode = accessorFn(state.nodeCanvasObjectMode);
|
|
138
|
+
|
|
139
|
+
const ctx = state.ctx;
|
|
140
|
+
|
|
141
|
+
// Draw wider nodes by 1px on shadow canvas for more precise hovering (due to boundary anti-aliasing)
|
|
142
|
+
const padAmount = state.isShadow / state.globalScale;
|
|
143
|
+
|
|
144
|
+
const visibleNodes = state.graphData.nodes.filter(getVisibility);
|
|
145
|
+
|
|
146
|
+
ctx.save();
|
|
147
|
+
visibleNodes.forEach(node => {
|
|
148
|
+
const nodeCanvasObjectMode = getNodeCanvasObjectMode(node);
|
|
149
|
+
|
|
150
|
+
if (state.nodeCanvasObject && (nodeCanvasObjectMode === 'before' || nodeCanvasObjectMode === 'replace')) {
|
|
151
|
+
// Custom node before/replace paint
|
|
152
|
+
state.nodeCanvasObject(node, ctx, state.globalScale);
|
|
153
|
+
|
|
154
|
+
if (nodeCanvasObjectMode === 'replace') {
|
|
155
|
+
ctx.restore();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Draw wider nodes by 1px on shadow canvas for more precise hovering (due to boundary anti-aliasing)
|
|
161
|
+
const r = Math.sqrt(Math.max(0, getVal(node) || 1)) * state.nodeRelSize + padAmount;
|
|
162
|
+
|
|
163
|
+
ctx.beginPath();
|
|
164
|
+
ctx.arc(node.x, node.y, r, 0, 2 * Math.PI, false);
|
|
165
|
+
ctx.fillStyle = getColor(node) || 'rgba(31, 120, 180, 0.92)';
|
|
166
|
+
ctx.fill();
|
|
167
|
+
|
|
168
|
+
if (state.nodeCanvasObject && nodeCanvasObjectMode === 'after') {
|
|
169
|
+
// Custom node after paint
|
|
170
|
+
state.nodeCanvasObject(node, state.ctx, state.globalScale);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
ctx.restore();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function paintLinks() {
|
|
177
|
+
const getVisibility = accessorFn(state.linkVisibility);
|
|
178
|
+
const getColor = accessorFn(state.linkColor);
|
|
179
|
+
const getWidth = accessorFn(state.linkWidth);
|
|
180
|
+
const getLineDash = accessorFn(state.linkLineDash);
|
|
181
|
+
const getCurvature = accessorFn(state.linkCurvature);
|
|
182
|
+
const getLinkCanvasObjectMode = accessorFn(state.linkCanvasObjectMode);
|
|
183
|
+
|
|
184
|
+
const ctx = state.ctx;
|
|
185
|
+
|
|
186
|
+
// Draw wider lines by 2px on shadow canvas for more precise hovering (due to boundary anti-aliasing)
|
|
187
|
+
const padAmount = state.isShadow * 2;
|
|
188
|
+
|
|
189
|
+
const visibleLinks = state.graphData.links.filter(getVisibility);
|
|
190
|
+
|
|
191
|
+
visibleLinks.forEach(calcLinkControlPoints); // calculate curvature control points for all visible links
|
|
192
|
+
|
|
193
|
+
let beforeCustomLinks = [], afterCustomLinks = [], defaultPaintLinks = visibleLinks;
|
|
194
|
+
if (state.linkCanvasObject) {
|
|
195
|
+
const replaceCustomLinks = [], otherCustomLinks = [];
|
|
196
|
+
|
|
197
|
+
visibleLinks.forEach(d =>
|
|
198
|
+
({
|
|
199
|
+
before: beforeCustomLinks,
|
|
200
|
+
after: afterCustomLinks,
|
|
201
|
+
replace: replaceCustomLinks
|
|
202
|
+
}[getLinkCanvasObjectMode(d)] || otherCustomLinks).push(d)
|
|
203
|
+
);
|
|
204
|
+
defaultPaintLinks = [...beforeCustomLinks, ...afterCustomLinks, ...otherCustomLinks];
|
|
205
|
+
beforeCustomLinks = beforeCustomLinks.concat(replaceCustomLinks);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Custom link before paints
|
|
209
|
+
ctx.save();
|
|
210
|
+
beforeCustomLinks.forEach(link => state.linkCanvasObject(link, ctx, state.globalScale));
|
|
211
|
+
ctx.restore();
|
|
212
|
+
|
|
213
|
+
// Bundle strokes per unique color/width/dash for performance optimization
|
|
214
|
+
const linksPerColor = indexBy(defaultPaintLinks, [getColor, getWidth, getLineDash]);
|
|
215
|
+
|
|
216
|
+
ctx.save();
|
|
217
|
+
Object.entries(linksPerColor).forEach(([color, linksPerWidth]) => {
|
|
218
|
+
const lineColor = !color || color === 'undefined' ? 'rgba(0,0,0,0.15)' : color;
|
|
219
|
+
Object.entries(linksPerWidth).forEach(([width, linesPerLineDash]) => {
|
|
220
|
+
const lineWidth = (width || 1) / state.globalScale + padAmount;
|
|
221
|
+
Object.entries(linesPerLineDash).forEach(([dashSegments, links]) => {
|
|
222
|
+
const lineDashSegments = getLineDash(links[0]);
|
|
223
|
+
ctx.beginPath();
|
|
224
|
+
links.forEach(link => {
|
|
225
|
+
const start = link.source;
|
|
226
|
+
const end = link.target;
|
|
227
|
+
if (!start || !end || !start.hasOwnProperty('x') || !end.hasOwnProperty('x')) return; // skip invalid link
|
|
228
|
+
|
|
229
|
+
ctx.moveTo(start.x, start.y);
|
|
230
|
+
|
|
231
|
+
const controlPoints = link.__controlPoints;
|
|
232
|
+
|
|
233
|
+
if (!controlPoints) { // Straight line
|
|
234
|
+
ctx.lineTo(end.x, end.y);
|
|
235
|
+
} else {
|
|
236
|
+
// Use quadratic curves for regular lines and bezier for loops
|
|
237
|
+
ctx[controlPoints.length === 2 ? 'quadraticCurveTo' : 'bezierCurveTo'](...controlPoints, end.x, end.y);
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
ctx.strokeStyle = lineColor;
|
|
241
|
+
ctx.lineWidth = lineWidth;
|
|
242
|
+
ctx.setLineDash(lineDashSegments || []);
|
|
243
|
+
ctx.stroke();
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
ctx.restore();
|
|
248
|
+
|
|
249
|
+
// Custom link after paints
|
|
250
|
+
ctx.save();
|
|
251
|
+
afterCustomLinks.forEach(link => state.linkCanvasObject(link, ctx, state.globalScale));
|
|
252
|
+
ctx.restore();
|
|
253
|
+
|
|
254
|
+
//
|
|
255
|
+
|
|
256
|
+
function calcLinkControlPoints(link) {
|
|
257
|
+
const curvature = getCurvature(link);
|
|
258
|
+
|
|
259
|
+
if (!curvature) { // straight line
|
|
260
|
+
link.__controlPoints = null;
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const start = link.source;
|
|
265
|
+
const end = link.target;
|
|
266
|
+
if (!start || !end || !start.hasOwnProperty('x') || !end.hasOwnProperty('x')) return; // skip invalid link
|
|
267
|
+
|
|
268
|
+
const l = Math.sqrt(Math.pow(end.x - start.x, 2) + Math.pow(end.y - start.y, 2)); // line length
|
|
269
|
+
|
|
270
|
+
if (l > 0) {
|
|
271
|
+
const a = Math.atan2(end.y - start.y, end.x - start.x); // line angle
|
|
272
|
+
const d = l * curvature; // control point distance
|
|
273
|
+
|
|
274
|
+
const cp = { // control point
|
|
275
|
+
x: (start.x + end.x) / 2 + d * Math.cos(a - Math.PI / 2),
|
|
276
|
+
y: (start.y + end.y) / 2 + d * Math.sin(a - Math.PI / 2)
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
link.__controlPoints = [cp.x, cp.y];
|
|
280
|
+
} else { // Same point, draw a loop
|
|
281
|
+
const d = curvature * 70;
|
|
282
|
+
link.__controlPoints = [end.x, end.y - d, end.x + d, end.y];
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function paintArrows() {
|
|
288
|
+
const ARROW_WH_RATIO = 1.6;
|
|
289
|
+
const ARROW_VLEN_RATIO = 0.2;
|
|
290
|
+
|
|
291
|
+
const getLength = accessorFn(state.linkDirectionalArrowLength);
|
|
292
|
+
const getRelPos = accessorFn(state.linkDirectionalArrowRelPos);
|
|
293
|
+
const getVisibility = accessorFn(state.linkVisibility);
|
|
294
|
+
const getColor = accessorFn(state.linkDirectionalArrowColor || state.linkColor);
|
|
295
|
+
const getNodeVal = accessorFn(state.nodeVal);
|
|
296
|
+
const ctx = state.ctx;
|
|
297
|
+
|
|
298
|
+
ctx.save();
|
|
299
|
+
state.graphData.links.filter(getVisibility).forEach(link => {
|
|
300
|
+
const arrowLength = getLength(link);
|
|
301
|
+
if (!arrowLength || arrowLength < 0) return;
|
|
302
|
+
|
|
303
|
+
const start = link.source;
|
|
304
|
+
const end = link.target;
|
|
305
|
+
|
|
306
|
+
if (!start || !end || !start.hasOwnProperty('x') || !end.hasOwnProperty('x')) return; // skip invalid link
|
|
307
|
+
|
|
308
|
+
const startR = Math.sqrt(Math.max(0, getNodeVal(start) || 1)) * state.nodeRelSize;
|
|
309
|
+
const endR = Math.sqrt(Math.max(0, getNodeVal(end) || 1)) * state.nodeRelSize;
|
|
310
|
+
|
|
311
|
+
const arrowRelPos = Math.min(1, Math.max(0, getRelPos(link)));
|
|
312
|
+
const arrowColor = getColor(link) || 'rgba(0,0,0,0.28)';
|
|
313
|
+
const arrowHalfWidth = arrowLength / ARROW_WH_RATIO / 2;
|
|
314
|
+
|
|
315
|
+
// Construct bezier for curved lines
|
|
316
|
+
const bzLine = link.__controlPoints && new Bezier(start.x, start.y, ...link.__controlPoints, end.x, end.y);
|
|
317
|
+
|
|
318
|
+
const getCoordsAlongLine = bzLine
|
|
319
|
+
? t => bzLine.get(t) // get position along bezier line
|
|
320
|
+
: t => ({ // straight line: interpolate linearly
|
|
321
|
+
x: start.x + (end.x - start.x) * t || 0,
|
|
322
|
+
y: start.y + (end.y - start.y) * t || 0
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
const lineLen = bzLine
|
|
326
|
+
? bzLine.length()
|
|
327
|
+
: Math.sqrt(Math.pow(end.x - start.x, 2) + Math.pow(end.y - start.y, 2));
|
|
328
|
+
|
|
329
|
+
const posAlongLine = startR + arrowLength + (lineLen - startR - endR - arrowLength) * arrowRelPos;
|
|
330
|
+
|
|
331
|
+
const arrowHead = getCoordsAlongLine(posAlongLine / lineLen);
|
|
332
|
+
const arrowTail = getCoordsAlongLine((posAlongLine - arrowLength) / lineLen);
|
|
333
|
+
const arrowTailVertex = getCoordsAlongLine((posAlongLine - arrowLength * (1 - ARROW_VLEN_RATIO)) / lineLen);
|
|
334
|
+
|
|
335
|
+
const arrowTailAngle = Math.atan2(arrowHead.y - arrowTail.y, arrowHead.x - arrowTail.x) - Math.PI / 2;
|
|
336
|
+
|
|
337
|
+
ctx.beginPath();
|
|
338
|
+
|
|
339
|
+
ctx.moveTo(arrowHead.x, arrowHead.y);
|
|
340
|
+
ctx.lineTo(arrowTail.x + arrowHalfWidth * Math.cos(arrowTailAngle), arrowTail.y + arrowHalfWidth * Math.sin(arrowTailAngle));
|
|
341
|
+
ctx.lineTo(arrowTailVertex.x, arrowTailVertex.y);
|
|
342
|
+
ctx.lineTo(arrowTail.x - arrowHalfWidth * Math.cos(arrowTailAngle), arrowTail.y - arrowHalfWidth * Math.sin(arrowTailAngle));
|
|
343
|
+
|
|
344
|
+
ctx.fillStyle = arrowColor;
|
|
345
|
+
ctx.fill();
|
|
346
|
+
});
|
|
347
|
+
ctx.restore();
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function paintPhotons() {
|
|
351
|
+
const getNumPhotons = accessorFn(state.linkDirectionalParticles);
|
|
352
|
+
const getSpeed = accessorFn(state.linkDirectionalParticleSpeed);
|
|
353
|
+
const getDiameter = accessorFn(state.linkDirectionalParticleWidth);
|
|
354
|
+
const getVisibility = accessorFn(state.linkVisibility);
|
|
355
|
+
const getColor = accessorFn(state.linkDirectionalParticleColor || state.linkColor);
|
|
356
|
+
const ctx = state.ctx;
|
|
357
|
+
|
|
358
|
+
ctx.save();
|
|
359
|
+
state.graphData.links.filter(getVisibility).forEach(link => {
|
|
360
|
+
const numCyclePhotons = getNumPhotons(link);
|
|
361
|
+
|
|
362
|
+
if (!link.hasOwnProperty('__photons') || !link.__photons.length) return;
|
|
363
|
+
|
|
364
|
+
const start = link.source;
|
|
365
|
+
const end = link.target;
|
|
366
|
+
|
|
367
|
+
if (!start || !end || !start.hasOwnProperty('x') || !end.hasOwnProperty('x')) return; // skip invalid link
|
|
368
|
+
|
|
369
|
+
const particleSpeed = getSpeed(link);
|
|
370
|
+
const photons = link.__photons || [];
|
|
371
|
+
const photonR = Math.max(0, getDiameter(link) / 2) / Math.sqrt(state.globalScale);
|
|
372
|
+
const photonColor = getColor(link) || 'rgba(0,0,0,0.28)';
|
|
373
|
+
|
|
374
|
+
ctx.fillStyle = photonColor;
|
|
375
|
+
|
|
376
|
+
// Construct bezier for curved lines
|
|
377
|
+
const bzLine = link.__controlPoints
|
|
378
|
+
? new Bezier(start.x, start.y, ...link.__controlPoints, end.x, end.y)
|
|
379
|
+
: null;
|
|
380
|
+
|
|
381
|
+
let cyclePhotonIdx = 0;
|
|
382
|
+
let needsCleanup = false; // whether some photons need to be removed from list
|
|
383
|
+
photons.forEach(photon => {
|
|
384
|
+
const singleHop = !!photon.__singleHop;
|
|
385
|
+
|
|
386
|
+
if (!photon.hasOwnProperty('__progressRatio')) {
|
|
387
|
+
photon.__progressRatio = singleHop ? 0 : cyclePhotonIdx / numCyclePhotons;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
!singleHop && cyclePhotonIdx++; // increase regular photon index
|
|
391
|
+
|
|
392
|
+
photon.__progressRatio += particleSpeed;
|
|
393
|
+
|
|
394
|
+
if (photon.__progressRatio >=1) {
|
|
395
|
+
if (!singleHop) {
|
|
396
|
+
photon.__progressRatio = photon.__progressRatio % 1;
|
|
397
|
+
} else {
|
|
398
|
+
needsCleanup = true;
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const photonPosRatio = photon.__progressRatio;
|
|
404
|
+
|
|
405
|
+
const coords = bzLine
|
|
406
|
+
? bzLine.get(photonPosRatio) // get position along bezier line
|
|
407
|
+
: { // straight line: interpolate linearly
|
|
408
|
+
x: start.x + (end.x - start.x) * photonPosRatio || 0,
|
|
409
|
+
y: start.y + (end.y - start.y) * photonPosRatio || 0
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
ctx.beginPath();
|
|
413
|
+
ctx.arc(coords.x, coords.y, photonR, 0, 2 * Math.PI, false);
|
|
414
|
+
ctx.fill();
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
if (needsCleanup) {
|
|
418
|
+
// remove expired single hop photons
|
|
419
|
+
link.__photons = link.__photons.filter(photon => !photon.__singleHop || photon.__progressRatio <= 1);
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
ctx.restore();
|
|
423
|
+
}
|
|
424
|
+
},
|
|
425
|
+
emitParticle: function(state, link) {
|
|
426
|
+
if (link) {
|
|
427
|
+
!link.__photons && (link.__photons = []);
|
|
428
|
+
link.__photons.push({__singleHop: true}); // add a single hop particle
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return this;
|
|
432
|
+
}
|
|
433
|
+
},
|
|
434
|
+
|
|
435
|
+
stateInit: () => ({
|
|
436
|
+
forceLayout: d3ForceSimulation()
|
|
437
|
+
.force('link', d3ForceLink())
|
|
438
|
+
.force('charge', d3ForceManyBody())
|
|
439
|
+
.force('center', d3ForceCenter())
|
|
440
|
+
.force('dagRadial', null)
|
|
441
|
+
.stop(),
|
|
442
|
+
engineRunning: false
|
|
443
|
+
}),
|
|
444
|
+
|
|
445
|
+
init(canvasCtx, state) {
|
|
446
|
+
// Main canvas object to manipulate
|
|
447
|
+
state.ctx = canvasCtx;
|
|
448
|
+
},
|
|
449
|
+
|
|
450
|
+
update(state) {
|
|
451
|
+
state.engineRunning = false; // Pause simulation
|
|
452
|
+
state.onUpdate();
|
|
453
|
+
|
|
454
|
+
if (state.nodeAutoColorBy !== null) {
|
|
455
|
+
// Auto add color to uncolored nodes
|
|
456
|
+
autoColorObjects(state.graphData.nodes, accessorFn(state.nodeAutoColorBy), state.nodeColor);
|
|
457
|
+
}
|
|
458
|
+
if (state.linkAutoColorBy !== null) {
|
|
459
|
+
// Auto add color to uncolored links
|
|
460
|
+
autoColorObjects(state.graphData.links, accessorFn(state.linkAutoColorBy), state.linkColor);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// parse links
|
|
464
|
+
state.graphData.links.forEach(link => {
|
|
465
|
+
link.source = link[state.linkSource];
|
|
466
|
+
link.target = link[state.linkTarget];
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
if (!state.isShadow) {
|
|
470
|
+
// Add photon particles
|
|
471
|
+
const linkParticlesAccessor = accessorFn(state.linkDirectionalParticles);
|
|
472
|
+
state.graphData.links.forEach(link => {
|
|
473
|
+
const numPhotons = Math.round(Math.abs(linkParticlesAccessor(link)));
|
|
474
|
+
if (numPhotons) {
|
|
475
|
+
link.__photons = [...Array(numPhotons)].map(() => ({}));
|
|
476
|
+
} else {
|
|
477
|
+
delete link.__photons;
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Feed data to force-directed layout
|
|
483
|
+
state.forceLayout
|
|
484
|
+
.stop()
|
|
485
|
+
.alpha(1) // re-heat the simulation
|
|
486
|
+
.nodes(state.graphData.nodes);
|
|
487
|
+
|
|
488
|
+
// add links (if link force is still active)
|
|
489
|
+
const linkForce = state.forceLayout.force('link');
|
|
490
|
+
if (linkForce) {
|
|
491
|
+
linkForce
|
|
492
|
+
.id(d => d[state.nodeId])
|
|
493
|
+
.links(state.graphData.links);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// setup dag force constraints
|
|
497
|
+
const nodeDepths = state.dagMode && getDagDepths(
|
|
498
|
+
state.graphData,
|
|
499
|
+
node => node[state.nodeId],
|
|
500
|
+
{
|
|
501
|
+
nodeFilter: state.dagNodeFilter,
|
|
502
|
+
onLoopError: state.onDagError || undefined
|
|
503
|
+
}
|
|
504
|
+
);
|
|
505
|
+
const maxDepth = Math.max(...Object.values(nodeDepths || []));
|
|
506
|
+
const dagLevelDistance = state.dagLevelDistance || (
|
|
507
|
+
state.graphData.nodes.length / (maxDepth || 1) * DAG_LEVEL_NODE_RATIO
|
|
508
|
+
* (['radialin', 'radialout'].indexOf(state.dagMode) !== -1 ? 0.7 : 1)
|
|
509
|
+
);
|
|
510
|
+
|
|
511
|
+
// Fix nodes to x,y for dag mode
|
|
512
|
+
if (state.dagMode) {
|
|
513
|
+
const getFFn = (fix, invert) => node => !fix
|
|
514
|
+
? undefined
|
|
515
|
+
: (nodeDepths[node[state.nodeId]] - maxDepth / 2) * dagLevelDistance * (invert ? -1 : 1);
|
|
516
|
+
|
|
517
|
+
const fxFn = getFFn(['lr', 'rl'].indexOf(state.dagMode) !== -1, state.dagMode === 'rl');
|
|
518
|
+
const fyFn = getFFn(['td', 'bu'].indexOf(state.dagMode) !== -1, state.dagMode === 'bu');
|
|
519
|
+
|
|
520
|
+
state.graphData.nodes.filter(state.dagNodeFilter).forEach(node => {
|
|
521
|
+
node.fx = fxFn(node);
|
|
522
|
+
node.fy = fyFn(node);
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Use radial force for radial dags
|
|
527
|
+
state.forceLayout.force('dagRadial',
|
|
528
|
+
['radialin', 'radialout'].indexOf(state.dagMode) !== -1
|
|
529
|
+
? d3ForceRadial(node => {
|
|
530
|
+
const nodeDepth = nodeDepths[node[state.nodeId]] || -1;
|
|
531
|
+
return (state.dagMode === 'radialin' ? maxDepth - nodeDepth : nodeDepth) * dagLevelDistance;
|
|
532
|
+
})
|
|
533
|
+
.strength(node => state.dagNodeFilter(node) ? 1 : 0)
|
|
534
|
+
: null
|
|
535
|
+
);
|
|
536
|
+
|
|
537
|
+
for (let i=0; (i<state.warmupTicks) && !(state.d3AlphaMin > 0 && state.forceLayout.alpha() < state.d3AlphaMin); i++) {
|
|
538
|
+
state.forceLayout.tick();
|
|
539
|
+
} // Initial ticks before starting to render
|
|
540
|
+
|
|
541
|
+
this.resetCountdown();
|
|
542
|
+
state.onFinishUpdate();
|
|
543
|
+
}
|
|
544
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { scaleOrdinal } from 'd3-scale';
|
|
2
|
+
import { schemePaired } from 'd3-scale-chromatic';
|
|
3
|
+
|
|
4
|
+
const autoColorScale = scaleOrdinal(schemePaired);
|
|
5
|
+
|
|
6
|
+
// Autoset attribute colorField by colorByAccessor property
|
|
7
|
+
// If an object has already a color, don't set it
|
|
8
|
+
// Objects can be nodes or links
|
|
9
|
+
function autoColorObjects(objects, colorByAccessor, colorField) {
|
|
10
|
+
if (!colorByAccessor || typeof colorField !== 'string') return;
|
|
11
|
+
|
|
12
|
+
objects.filter(obj => !obj[colorField]).forEach(obj => {
|
|
13
|
+
obj[colorField] = autoColorScale(colorByAccessor(obj));
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export { autoColorObjects };
|
package/src/dagDepths.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export default function({ nodes, links }, idAccessor, {
|
|
2
|
+
nodeFilter = () => true,
|
|
3
|
+
onLoopError = loopIds => { throw `Invalid DAG structure! Found cycle in node path: ${loopIds.join(' -> ')}.` }
|
|
4
|
+
} = {}) {
|
|
5
|
+
// linked graph
|
|
6
|
+
const graph = {};
|
|
7
|
+
|
|
8
|
+
nodes.forEach(node => graph[idAccessor(node)] = { data: node, out : [], depth: -1, skip: !nodeFilter(node) });
|
|
9
|
+
links.forEach(({ source, target }) => {
|
|
10
|
+
const sourceId = getNodeId(source);
|
|
11
|
+
const targetId = getNodeId(target);
|
|
12
|
+
if (!graph.hasOwnProperty(sourceId)) throw `Missing source node with id: ${sourceId}`;
|
|
13
|
+
if (!graph.hasOwnProperty(targetId)) throw `Missing target node with id: ${targetId}`;
|
|
14
|
+
const sourceNode = graph[sourceId];
|
|
15
|
+
const targetNode = graph[targetId];
|
|
16
|
+
|
|
17
|
+
sourceNode.out.push(targetNode);
|
|
18
|
+
|
|
19
|
+
function getNodeId(node) {
|
|
20
|
+
return typeof node === 'object' ? idAccessor(node) : node;
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const foundLoops = [];
|
|
25
|
+
traverse(Object.values(graph));
|
|
26
|
+
|
|
27
|
+
const nodeDepths = Object.assign({}, ...Object.entries(graph)
|
|
28
|
+
.filter(([, node]) => !node.skip)
|
|
29
|
+
.map(([id, node]) => ({ [id]: node.depth }))
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
return nodeDepths;
|
|
33
|
+
|
|
34
|
+
function traverse(nodes, nodeStack = [], currentDepth = 0) {
|
|
35
|
+
for (let i=0, l=nodes.length; i<l; i++) {
|
|
36
|
+
const node = nodes[i];
|
|
37
|
+
if (nodeStack.indexOf(node) !== -1) {
|
|
38
|
+
const loop = [...nodeStack.slice(nodeStack.indexOf(node)), node].map(d => idAccessor(d.data));
|
|
39
|
+
if (!foundLoops.some(foundLoop => foundLoop.length === loop.length && foundLoop.every((id, idx) => id === loop[idx]))) {
|
|
40
|
+
foundLoops.push(loop);
|
|
41
|
+
onLoopError(loop);
|
|
42
|
+
}
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (currentDepth > node.depth) { // Don't unnecessarily revisit chunks of the graph
|
|
46
|
+
node.depth = currentDepth;
|
|
47
|
+
traverse(node.out, [...nodeStack, node], currentDepth + (node.skip ? 0 : 1));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
.force-graph-container canvas {
|
|
2
|
+
display: block;
|
|
3
|
+
user-select: none;
|
|
4
|
+
outline: none;
|
|
5
|
+
-webkit-tap-highlight-color: transparent;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.force-graph-container .graph-tooltip {
|
|
9
|
+
position: absolute;
|
|
10
|
+
transform: translate(-50%, 25px);
|
|
11
|
+
font-family: sans-serif;
|
|
12
|
+
font-size: 16px;
|
|
13
|
+
padding: 4px;
|
|
14
|
+
border-radius: 3px;
|
|
15
|
+
color: #eee;
|
|
16
|
+
background: rgba(0,0,0,0.65);
|
|
17
|
+
visibility: hidden; /* by default */
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.force-graph-container .clickable {
|
|
21
|
+
cursor: pointer;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.force-graph-container .grabbable {
|
|
25
|
+
cursor: move;
|
|
26
|
+
cursor: grab;
|
|
27
|
+
cursor: -moz-grab;
|
|
28
|
+
cursor: -webkit-grab;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.force-graph-container .grabbable:active {
|
|
32
|
+
cursor: grabbing;
|
|
33
|
+
cursor: -moz-grabbing;
|
|
34
|
+
cursor: -webkit-grabbing;
|
|
35
|
+
}
|