force-graph
Version:
2D force-directed graph rendered on HTML5 canvas
665 lines (576 loc) • 23.4 kB
JavaScript
import { select as d3Select } from 'd3-selection';
import { zoom as d3Zoom, zoomTransform as d3ZoomTransform } from 'd3-zoom';
import { drag as d3Drag } from 'd3-drag';
import { max as d3Max, min as d3Min, sum as d3Sum } from 'd3-array';
import { throttle } from 'lodash-es';
import { Tween, Group as TweenGroup, Easing } from '@tweenjs/tween.js';
import Kapsule from 'kapsule';
import accessorFn from 'accessor-fn';
import ColorTracker from 'canvas-color-tracker';
import Tooltip from 'float-tooltip';
import CanvasForceGraph from './canvas-force-graph';
import linkKapsule from './kapsule-link.js';
const HOVER_CANVAS_THROTTLE_DELAY = 800; // ms to throttle shadow canvas updates for perf improvement
const ZOOM2NODES_FACTOR = 4;
const DRAG_CLICK_TOLERANCE_PX = 5; // How many px can a node be accidentally dragged before disabling the click
// Expose config from forceGraph
const bindFG = linkKapsule('forceGraph', CanvasForceGraph);
const bindBoth = linkKapsule(['forceGraph', 'shadowGraph'], CanvasForceGraph);
const linkedProps = Object.assign(
...[
'nodeColor',
'nodeAutoColorBy',
'nodeCanvasObject',
'nodeCanvasObjectMode',
'linkColor',
'linkAutoColorBy',
'linkLineDash',
'linkWidth',
'linkCanvasObject',
'linkCanvasObjectMode',
'linkDirectionalArrowLength',
'linkDirectionalArrowColor',
'linkDirectionalArrowRelPos',
'linkDirectionalParticles',
'linkDirectionalParticleSpeed',
'linkDirectionalParticleOffset',
'linkDirectionalParticleWidth',
'linkDirectionalParticleColor',
'linkDirectionalParticleCanvasObject',
'dagMode',
'dagLevelDistance',
'dagNodeFilter',
'onDagError',
'd3AlphaMin',
'd3AlphaDecay',
'd3VelocityDecay',
'warmupTicks',
'cooldownTicks',
'cooldownTime',
'onEngineTick',
'onEngineStop'
].map(p => ({ [p]: bindFG.linkProp(p)})),
...[
'nodeRelSize',
'nodeId',
'nodeVal',
'nodeVisibility',
'linkSource',
'linkTarget',
'linkVisibility',
'linkCurvature'
].map(p => ({ [p]: bindBoth.linkProp(p)}))
);
const linkedMethods = Object.assign(...[
'd3Force',
'd3ReheatSimulation',
'emitParticle'
].map(p => ({ [p]: bindFG.linkMethod(p)})));
function adjustCanvasSize(state) {
if (state.canvas) {
let curWidth = state.canvas.width;
let curHeight = state.canvas.height;
if (curWidth === 300 && curHeight === 150) { // Default canvas dimensions
curWidth = curHeight = 0;
}
const pxScale = window.devicePixelRatio; // 2 on retina displays
curWidth /= pxScale;
curHeight /= pxScale;
// Resize canvases
[state.canvas, state.shadowCanvas].forEach(canvas => {
// Element size
canvas.style.width = `${state.width}px`;
canvas.style.height = `${state.height}px`;
// Memory size (scaled to avoid blurriness)
canvas.width = state.width * pxScale;
canvas.height = state.height * pxScale;
// Normalize coordinate system to use css pixels (on init only)
if (!curWidth && !curHeight) {
canvas.getContext('2d').scale(pxScale, pxScale);
}
});
// Relative center panning based on 0,0
const k = d3ZoomTransform(state.canvas).k;
state.zoom.translateBy(state.zoom.__baseElem,
(state.width - curWidth) / 2 / k,
(state.height - curHeight) / 2 / k
);
state.needsRedraw = true;
}
}
function resetTransform(ctx) {
const pxRatio = window.devicePixelRatio;
ctx.setTransform(pxRatio, 0, 0, pxRatio, 0, 0);
}
function clearCanvas(ctx, width, height) {
ctx.save();
resetTransform(ctx); // reset transform
ctx.clearRect(0, 0, width, height);
ctx.restore(); //restore transforms
}
//
export default Kapsule({
props:{
width: { default: window.innerWidth, onChange: (_, state) => adjustCanvasSize(state), triggerUpdate: false } ,
height: { default: window.innerHeight, onChange: (_, state) => adjustCanvasSize(state), triggerUpdate: false },
graphData: {
default: { nodes: [], links: [] },
onChange: ((d, state) => {
// Wipe color registry if all objects are new
[d.nodes, d.links].every(arr => (arr || []).every(d => !d.hasOwnProperty('__indexColor'))) && state.colorTracker.reset();
[{ type: 'Node', objs: d.nodes }, { type: 'Link', objs: d.links }].forEach(hexIndex);
state.forceGraph.graphData(d);
state.shadowGraph.graphData(d);
function hexIndex({ type, objs }) {
objs
.filter(d => {
if (!d.hasOwnProperty('__indexColor')) return true;
const cur = state.colorTracker.lookup(d.__indexColor);
return (!cur || !cur.hasOwnProperty('d') || cur.d !== d);
})
.forEach(d => {
// store object lookup color
d.__indexColor = state.colorTracker.register({ type, d });
});
}
}),
triggerUpdate: false
},
backgroundColor: { onChange(color, state) { state.canvas && color && (state.canvas.style.background = color) }, triggerUpdate: false },
nodeLabel: { default: 'name', triggerUpdate: false },
nodePointerAreaPaint: { onChange(paintFn, state) {
state.shadowGraph.nodeCanvasObject(!paintFn ? null :
(node, ctx, globalScale) => paintFn(node, node.__indexColor, ctx, globalScale)
);
state.flushShadowCanvas && state.flushShadowCanvas();
}, triggerUpdate: false },
linkPointerAreaPaint: { onChange(paintFn, state) {
state.shadowGraph.linkCanvasObject(!paintFn ? null :
(link, ctx, globalScale) => paintFn(link, link.__indexColor, ctx, globalScale)
);
state.flushShadowCanvas && state.flushShadowCanvas();
}, triggerUpdate: false },
linkLabel: { default: 'name', triggerUpdate: false },
linkHoverPrecision: { default: 4, triggerUpdate: false },
minZoom: { default: 0.01, onChange(minZoom, state) { state.zoom.scaleExtent([minZoom, state.zoom.scaleExtent()[1]]); }, triggerUpdate: false },
maxZoom: { default: 1000, onChange(maxZoom, state) { state.zoom.scaleExtent([state.zoom.scaleExtent()[0], maxZoom]) }, triggerUpdate: false },
enableNodeDrag: { default: true, triggerUpdate: false },
enableZoomInteraction: { default: true, triggerUpdate: false },
enablePanInteraction: { default: true, triggerUpdate: false },
enableZoomPanInteraction: { default: true, triggerUpdate: false }, // to be deprecated
enablePointerInteraction: { default: true, onChange(_, state) { state.hoverObj = null; }, triggerUpdate: false },
autoPauseRedraw: { default: true, triggerUpdate: false },
onNodeDrag: { default: () => {}, triggerUpdate: false },
onNodeDragEnd: { default: () => {}, triggerUpdate: false },
onNodeClick: { triggerUpdate: false },
onNodeRightClick: { triggerUpdate: false },
onNodeHover: { triggerUpdate: false },
onLinkClick: { triggerUpdate: false },
onLinkRightClick: { triggerUpdate: false },
onLinkHover: { triggerUpdate: false },
onBackgroundClick: { triggerUpdate: false },
onBackgroundRightClick: { triggerUpdate: false },
onZoom: { triggerUpdate: false },
onZoomEnd: { triggerUpdate: false },
onRenderFramePre: { triggerUpdate: false },
onRenderFramePost: { triggerUpdate: false },
...linkedProps
},
aliases: { // Prop names supported for backwards compatibility
stopAnimation: 'pauseAnimation'
},
methods: {
graph2ScreenCoords: function(state, x, y) {
const t = d3ZoomTransform(state.canvas);
return { x: x * t.k + t.x, y: y * t.k + t.y };
},
screen2GraphCoords: function(state, x, y) {
const t = d3ZoomTransform(state.canvas);
return { x: (x - t.x) / t.k, y: (y - t.y) / t.k };
},
centerAt: function(state, x, y, transitionDuration) {
if (!state.canvas) return null; // no canvas yet
// setter
if (x !== undefined || y !== undefined) {
const finalPos = Object.assign({},
x !== undefined ? { x } : {},
y !== undefined ? { y } : {}
);
if (!transitionDuration) { // no animation
setCenter(finalPos);
} else {
state.tweenGroup.add(
new Tween(getCenter())
.to(finalPos, transitionDuration)
.easing(Easing.Quadratic.Out)
.onUpdate(setCenter)
.start()
);
}
return this;
}
// getter
return getCenter();
//
function getCenter() {
const t = d3ZoomTransform(state.canvas);
return { x: (state.width / 2 - t.x) / t.k, y: (state.height / 2 - t.y) / t.k };
}
function setCenter({ x, y }) {
state.zoom.translateTo(
state.zoom.__baseElem,
x === undefined ? getCenter().x : x,
y === undefined ? getCenter().y : y
);
state.needsRedraw = true;
}
},
zoom: function(state, k, transitionDuration) {
if (!state.canvas) return null; // no canvas yet
// setter
if (k !== undefined) {
if (!transitionDuration) { // no animation
setZoom(k);
} else {
state.tweenGroup.add(
new Tween({ k: getZoom() })
.to({ k }, transitionDuration)
.easing(Easing.Quadratic.Out)
.onUpdate(({ k }) => setZoom(k))
.start()
);
}
return this;
}
// getter
return getZoom();
//
function getZoom() {
return d3ZoomTransform(state.canvas).k;
}
function setZoom(k) {
state.zoom.scaleTo(state.zoom.__baseElem, k);
state.needsRedraw = true;
}
},
zoomToFit: function(state, transitionDuration = 0, padding = 10, ...bboxArgs) {
const bbox = this.getGraphBbox(...bboxArgs);
if (bbox) {
const center = {
x: (bbox.x[0] + bbox.x[1]) / 2,
y: (bbox.y[0] + bbox.y[1]) / 2,
};
const zoomK = Math.max(1e-12, Math.min(1e12,
(state.width - padding * 2) / (bbox.x[1] - bbox.x[0]),
(state.height - padding * 2) / (bbox.y[1] - bbox.y[0]))
);
this.centerAt(center.x, center.y, transitionDuration);
this.zoom(zoomK, transitionDuration);
}
return this;
},
getGraphBbox: function(state, nodeFilter = () => true) {
const getVal = accessorFn(state.nodeVal);
const getR = node => Math.sqrt(Math.max(0, getVal(node) || 1)) * state.nodeRelSize;
const nodesPos = state.graphData.nodes.filter(nodeFilter).map(node => ({
x: node.x,
y: node.y,
r: getR(node)
}));
return !nodesPos.length ? null : {
x: [
d3Min(nodesPos, node => node.x - node.r),
d3Max(nodesPos, node => node.x + node.r)
],
y: [
d3Min(nodesPos, node => node.y - node.r),
d3Max(nodesPos, node => node.y + node.r)
]
};
},
pauseAnimation: function(state) {
if (state.animationFrameRequestId) {
cancelAnimationFrame(state.animationFrameRequestId);
state.animationFrameRequestId = null;
}
return this;
},
resumeAnimation: function(state) {
if (!state.animationFrameRequestId) {
this._animationCycle();
}
return this;
},
_destructor: function() {
this.pauseAnimation();
this.graphData({ nodes: [], links: []});
},
...linkedMethods
},
stateInit: () => ({
lastSetZoom: 1,
zoom: d3Zoom(),
forceGraph: new CanvasForceGraph(),
shadowGraph: new CanvasForceGraph()
.cooldownTicks(0)
.nodeColor('__indexColor')
.linkColor('__indexColor')
.isShadow(true),
colorTracker: new ColorTracker(), // indexed objects for rgb lookup
tweenGroup: new TweenGroup()
}),
init: function(domNode, state) {
// Wipe DOM
domNode.innerHTML = '';
// Container anchor for canvas and tooltip
const container = document.createElement('div');
container.classList.add('force-graph-container');
container.style.position = 'relative';
domNode.appendChild(container);
state.canvas = document.createElement('canvas');
if (state.backgroundColor) state.canvas.style.background = state.backgroundColor;
container.appendChild(state.canvas);
state.shadowCanvas = document.createElement('canvas');
// Show shadow canvas
//state.shadowCanvas.style.position = 'absolute';
//state.shadowCanvas.style.top = '0';
//state.shadowCanvas.style.left = '0';
//container.appendChild(state.shadowCanvas);
const ctx = state.canvas.getContext('2d');
const shadowCtx = state.shadowCanvas.getContext('2d', { willReadFrequently: true });
const pointerPos = { x: -1e12, y: -1e12 };
const getObjUnderPointer = () => {
let obj = null;
const pxScale = window.devicePixelRatio;
const px = (pointerPos.x > 0 && pointerPos.y > 0)
? shadowCtx.getImageData(pointerPos.x * pxScale, pointerPos.y * pxScale, 1, 1)
: null;
// Lookup object per pixel color
px && (obj = state.colorTracker.lookup(px.data));
return obj;
};
// Setup node drag interaction
d3Select(state.canvas).call(
d3Drag()
.subject(() => {
if (!state.enableNodeDrag) { return null; }
const obj = getObjUnderPointer();
return (obj && obj.type === 'Node') ? obj.d : null; // Only drag nodes
})
.on('start', ev => {
const obj = ev.subject;
obj.__initialDragPos = {
x: obj.x,
y: obj.y,
fx: obj.fx,
fy: obj.fy
};
// keep engine running at low intensity throughout drag
if (!ev.active) {
obj.fx = obj.x; obj.fy = obj.y; // Fix points
}
// drag cursor
state.canvas.classList.add('grabbable');
})
.on('drag', ev => {
const obj = ev.subject;
const initPos = obj.__initialDragPos;
const dragPos = ev;
const k = d3ZoomTransform(state.canvas).k;
const translate = {
x: (initPos.x + (dragPos.x - initPos.x) / k) - obj.x,
y: (initPos.y + (dragPos.y - initPos.y) / k) - obj.y
};
// Move fx/fy (and x/y) of nodes based on the scaled drag distance since the drag start
['x', 'y'].forEach(c => obj[`f${c}`] = obj[c] = initPos[c] + (dragPos[c] - initPos[c]) / k);
// Only engage full drag if distance reaches above threshold
if (!obj.__dragged && (DRAG_CLICK_TOLERANCE_PX >= Math.sqrt(d3Sum(['x', 'y'].map(k => (ev[k] - initPos[k])**2)))))
return;
state.forceGraph
.d3AlphaTarget(0.3) // keep engine running at low intensity throughout drag
.resetCountdown(); // prevent freeze while dragging
state.isPointerDragging = true;
obj.__dragged = true;
state.onNodeDrag(obj, translate);
})
.on('end', ev => {
const obj = ev.subject;
const initPos = obj.__initialDragPos;
const translate = {x: obj.x - initPos.x, y: obj.y - initPos.y};
if (initPos.fx === undefined) { obj.fx = undefined; }
if (initPos.fy === undefined) { obj.fy = undefined; }
delete(obj.__initialDragPos);
if (state.forceGraph.d3AlphaTarget()) {
state.forceGraph
.d3AlphaTarget(0) // release engine low intensity
.resetCountdown(); // let the engine readjust after releasing fixed nodes
}
// drag cursor
state.canvas.classList.remove('grabbable');
state.isPointerDragging = false;
if (obj.__dragged) {
delete(obj.__dragged);
state.onNodeDragEnd(obj, translate);
}
})
);
// Setup zoom / pan interaction
state.zoom(state.zoom.__baseElem = d3Select(state.canvas)); // Attach controlling elem for easy access
state.zoom.__baseElem.on('dblclick.zoom', null); // Disable double-click to zoom
state.zoom
.filter(ev =>
// disable zoom interaction
!ev.button
&& state.enableZoomPanInteraction
&& (ev.type !== 'wheel' || accessorFn(state.enableZoomInteraction)(ev))
&& (ev.type === 'wheel' || accessorFn(state.enablePanInteraction)(ev))
)
.on('zoom', ev => {
const t = ev.transform;
[ctx, shadowCtx].forEach(c => {
resetTransform(c);
c.translate(t.x, t.y);
c.scale(t.k, t.k);
});
state.isPointerDragging = true;
state.onZoom && state.onZoom({ ...t, ...this.centerAt() }); // report x,y coordinates relative to canvas center
state.needsRedraw = true;
})
.on('end', ev => {
state.isPointerDragging = false;
state.onZoomEnd && state.onZoomEnd({ ...ev.transform, ...this.centerAt() });
});
adjustCanvasSize(state);
state.forceGraph
.onNeedsRedraw(() => state.needsRedraw = true)
.onFinishUpdate(() => {
// re-zoom, if still in default position (not user modified)
if (d3ZoomTransform(state.canvas).k === state.lastSetZoom && state.graphData.nodes.length) {
state.zoom.scaleTo(state.zoom.__baseElem,
state.lastSetZoom = ZOOM2NODES_FACTOR / Math.cbrt(state.graphData.nodes.length)
);
state.needsRedraw = true;
}
});
// Setup tooltip
state.tooltip = new Tooltip(container);
// Capture pointer coords on move or touchstart
['pointermove', 'pointerdown'].forEach(evType =>
container.addEventListener(evType, ev => {
if (evType === 'pointerdown') {
state.isPointerPressed = true; // track click state
state.pointerDownEvent = ev;
}
// detect pointer drag on canvas pan
!state.isPointerDragging && ev.type === 'pointermove'
&& (state.onBackgroundClick) // only bother detecting drags this way if background clicks are enabled (so they don't trigger accidentally on canvas panning)
&& (ev.pressure > 0 || state.isPointerPressed) // ev.pressure always 0 on Safari, so we use the isPointerPressed tracker
&& (ev.pointerType === 'mouse' || ev.movementX === undefined || [ev.movementX, ev.movementY].some(m => Math.abs(m) > 1)) // relax drag trigger sensitivity on non-mouse (touch/pen) events
&& (state.isPointerDragging = true);
// update the pointer pos
const offset = getOffset(container);
pointerPos.x = ev.pageX - offset.left;
pointerPos.y = ev.pageY - offset.top;
//
function getOffset(el) {
const rect = el.getBoundingClientRect(),
scrollLeft = window.pageXOffset || document.documentElement.scrollLeft,
scrollTop = window.pageYOffset || document.documentElement.scrollTop;
return { top: rect.top + scrollTop, left: rect.left + scrollLeft };
}
}, { passive: true })
);
// Handle click/touch events on nodes/links
container.addEventListener('pointerup', ev => {
if (!state.isPointerPressed) {
return; // don't trigger click events if pointer is not pressed on the canvas
}
state.isPointerPressed = false;
if (state.isPointerDragging) {
state.isPointerDragging = false;
return; // don't trigger click events after pointer drag (pan / node drag functionality)
}
const cbEvents = [ev, state.pointerDownEvent];
requestAnimationFrame(() => { // trigger click events asynchronously, to allow hoverObj to be set (on frame)
if (ev.button === 0) { // mouse left-click or touch
if (state.hoverObj) {
const fn = state[`on${state.hoverObj.type}Click`];
fn && fn(state.hoverObj.d, ...cbEvents);
} else {
state.onBackgroundClick && state.onBackgroundClick(...cbEvents);
}
}
if (ev.button === 2) { // mouse right-click
if (state.hoverObj) {
const fn = state[`on${state.hoverObj.type}RightClick`];
fn && fn(state.hoverObj.d, ...cbEvents);
} else {
state.onBackgroundRightClick && state.onBackgroundRightClick(...cbEvents);
}
}
});
}, { passive: true });
container.addEventListener('contextmenu', ev => {
if (!state.onBackgroundRightClick && !state.onNodeRightClick && !state.onLinkRightClick) return true; // default contextmenu behavior
ev.preventDefault();
return false;
});
state.forceGraph(ctx);
state.shadowGraph(shadowCtx);
//
const refreshShadowCanvas = throttle(() => {
// wipe canvas
clearCanvas(shadowCtx, state.width, state.height);
// Adjust link hover area
state.shadowGraph.linkWidth(l => accessorFn(state.linkWidth)(l) + state.linkHoverPrecision);
// redraw
const t = d3ZoomTransform(state.canvas);
state.shadowGraph.globalScale(t.k).tickFrame();
}, HOVER_CANVAS_THROTTLE_DELAY);
state.flushShadowCanvas = refreshShadowCanvas.flush; // hook to immediately invoke shadow canvas paint
// Kick-off renderer
(this._animationCycle = function animate() { // IIFE
const doRedraw = !state.autoPauseRedraw || !!state.needsRedraw || state.forceGraph.isEngineRunning()
|| state.graphData.links.some(d => d.__photons && d.__photons.length);
state.needsRedraw = false;
if (state.enablePointerInteraction) {
// Update tooltip and trigger onHover events
const obj = !state.isPointerDragging ? getObjUnderPointer() : null; // don't hover during drag
if (obj !== state.hoverObj) {
const prevObj = state.hoverObj;
const prevObjType = prevObj ? prevObj.type : null;
const objType = obj ? obj.type : null;
if (prevObjType && prevObjType !== objType) {
// Hover out
const fn = state[`on${prevObjType}Hover`];
fn && fn(null, prevObj.d);
}
if (objType) {
// Hover in
const fn = state[`on${objType}Hover`];
fn && fn(obj.d, prevObjType === objType ? prevObj.d : null);
}
state.tooltip.content(obj ? accessorFn(state[`${obj.type.toLowerCase()}Label`])(obj.d) || null : null);
// set pointer if hovered object is clickable
state.canvas.classList[
((obj && state[`on${objType}Click`]) || (!obj && state.onBackgroundClick)) ? 'add' : 'remove'
]('clickable');
state.hoverObj = obj;
}
doRedraw && refreshShadowCanvas();
}
if(doRedraw) {
// Wipe canvas
clearCanvas(ctx, state.width, state.height);
// Frame cycle
const globalScale = d3ZoomTransform(state.canvas).k;
state.onRenderFramePre && state.onRenderFramePre(ctx, globalScale);
state.forceGraph.globalScale(globalScale).tickFrame();
state.onRenderFramePost && state.onRenderFramePost(ctx, globalScale);
}
state.tweenGroup.update(); // update canvas animation tweens
state.animationFrameRequestId = requestAnimationFrame(animate);
})();
},
update: function updateFn(state) {}
});