force-graph
Version:
2D force-directed graph rendered on HTML5 canvas
555 lines (462 loc) • 22.5 kB
JavaScript
import {
forceSimulation as d3ForceSimulation,
forceLink as d3ForceLink,
forceManyBody as d3ForceManyBody,
forceCenter as d3ForceCenter,
forceRadial as d3ForceRadial
} from 'd3-force-3d';
import { Bezier } from 'bezier-js';
import Kapsule from 'kapsule';
import accessorFn from 'accessor-fn';
import indexBy from 'index-array-by';
import { autoColorObjects } from './color-utils';
import getDagDepths from './dagDepths';
//
const DAG_LEVEL_NODE_RATIO = 2;
// whenever styling props are changed that require a canvas redraw
const notifyRedraw = (_, state) => state.onNeedsRedraw && state.onNeedsRedraw();
const updDataPhotons = (_, state) => {
if (!state.isShadow) {
// Add photon particles
const linkParticlesAccessor = accessorFn(state.linkDirectionalParticles);
state.graphData.links.forEach(link => {
const numPhotons = Math.round(Math.abs(linkParticlesAccessor(link)));
if (numPhotons) {
link.__photons = [...Array(numPhotons)].map(() => ({}));
} else {
delete link.__photons;
}
});
}
};
export default Kapsule({
props: {
graphData: {
default: {
nodes: [],
links: []
},
onChange(_, state) {
state.engineRunning = false; // Pause simulation
updDataPhotons(_, state);
}
},
dagMode: { onChange(dagMode, state) { // td, bu, lr, rl, radialin, radialout
!dagMode && (state.graphData.nodes || []).forEach(n => n.fx = n.fy = undefined); // unfix nodes when disabling dag mode
}},
dagLevelDistance: {},
dagNodeFilter: { default: node => true },
onDagError: { triggerUpdate: false },
nodeRelSize: { default: 4, triggerUpdate: false, onChange: notifyRedraw }, // area per val unit
nodeId: { default: 'id' },
nodeVal: { default: 'val', triggerUpdate: false, onChange: notifyRedraw },
nodeColor: { default: 'color', triggerUpdate: false, onChange: notifyRedraw },
nodeAutoColorBy: {},
nodeCanvasObject: { triggerUpdate: false, onChange: notifyRedraw },
nodeCanvasObjectMode: { default: () => 'replace', triggerUpdate: false, onChange: notifyRedraw },
nodeVisibility: { default: true, triggerUpdate: false, onChange: notifyRedraw },
linkSource: { default: 'source' },
linkTarget: { default: 'target' },
linkVisibility: { default: true, triggerUpdate: false, onChange: notifyRedraw },
linkColor: { default: 'color', triggerUpdate: false, onChange: notifyRedraw },
linkAutoColorBy: {},
linkLineDash: { triggerUpdate: false, onChange: notifyRedraw },
linkWidth: { default: 1, triggerUpdate: false, onChange: notifyRedraw },
linkCurvature: { default: 0, triggerUpdate: false, onChange: notifyRedraw },
linkCanvasObject: { triggerUpdate: false, onChange: notifyRedraw },
linkCanvasObjectMode: { default: () => 'replace', triggerUpdate: false, onChange: notifyRedraw },
linkDirectionalArrowLength: { default: 0, triggerUpdate: false, onChange: notifyRedraw },
linkDirectionalArrowColor: { triggerUpdate: false, onChange: notifyRedraw },
linkDirectionalArrowRelPos: { default: 0.5, triggerUpdate: false, onChange: notifyRedraw }, // value between 0<>1 indicating the relative pos along the (exposed) line
linkDirectionalParticles: { default: 0, triggerUpdate: false, onChange: updDataPhotons }, // animate photons travelling in the link direction
linkDirectionalParticleSpeed: { default: 0.01, triggerUpdate: false }, // in link length ratio per frame
linkDirectionalParticleOffset: { default: 0, triggerUpdate: false }, // starting position offset along the link's length, like a pre-delay. Values between [0, 1]
linkDirectionalParticleWidth: { default: 4, triggerUpdate: false },
linkDirectionalParticleColor: { triggerUpdate: false },
linkDirectionalParticleCanvasObject: { triggerUpdate: false },
globalScale: { default: 1, triggerUpdate: false },
d3AlphaMin: { default: 0, triggerUpdate: false},
d3AlphaDecay: { default: 0.0228, triggerUpdate: false, onChange(alphaDecay, state) { state.forceLayout.alphaDecay(alphaDecay) }},
d3AlphaTarget: { default: 0, triggerUpdate: false, onChange(alphaTarget, state) { state.forceLayout.alphaTarget(alphaTarget) }},
d3VelocityDecay: { default: 0.4, triggerUpdate: false, onChange(velocityDecay, state) { state.forceLayout.velocityDecay(velocityDecay) } },
warmupTicks: { default: 0, triggerUpdate: false }, // how many times to tick the force engine at init before starting to render
cooldownTicks: { default: Infinity, triggerUpdate: false },
cooldownTime: { default: 15000, triggerUpdate: false }, // ms
onUpdate: { default: () => {}, triggerUpdate: false },
onFinishUpdate: { default: () => {}, triggerUpdate: false },
onEngineTick: { default: () => {}, triggerUpdate: false },
onEngineStop: { default: () => {}, triggerUpdate: false },
onNeedsRedraw: { triggerUpdate: false },
isShadow: { default: false, triggerUpdate: false }
},
methods: {
// Expose d3 forces for external manipulation
d3Force: function(state, forceName, forceFn) {
if (forceFn === undefined) {
return state.forceLayout.force(forceName); // Force getter
}
state.forceLayout.force(forceName, forceFn); // Force setter
return this;
},
d3ReheatSimulation: function(state) {
state.forceLayout.alpha(1);
this.resetCountdown();
return this;
},
// reset cooldown state
resetCountdown: function(state) {
state.cntTicks = 0;
state.startTickTime = new Date();
state.engineRunning = true;
return this;
},
isEngineRunning: state => !!state.engineRunning,
tickFrame: function(state) {
!state.isShadow && layoutTick();
paintLinks();
!state.isShadow && paintArrows();
!state.isShadow && paintPhotons();
paintNodes();
return this;
//
function layoutTick() {
if (state.engineRunning) {
if (
++state.cntTicks > state.cooldownTicks ||
(new Date()) - state.startTickTime > state.cooldownTime ||
(state.d3AlphaMin > 0 && state.forceLayout.alpha() < state.d3AlphaMin)
) {
state.engineRunning = false; // Stop ticking graph
state.onEngineStop();
} else {
state.forceLayout.tick(); // Tick it
state.onEngineTick();
}
}
}
function paintNodes() {
const getVisibility = accessorFn(state.nodeVisibility);
const getVal = accessorFn(state.nodeVal);
const getColor = accessorFn(state.nodeColor);
const getNodeCanvasObjectMode = accessorFn(state.nodeCanvasObjectMode);
const ctx = state.ctx;
// Draw wider nodes by 1px on shadow canvas for more precise hovering (due to boundary anti-aliasing)
const padAmount = state.isShadow / state.globalScale;
const visibleNodes = state.graphData.nodes.filter(getVisibility);
ctx.save();
visibleNodes.forEach(node => {
const nodeCanvasObjectMode = getNodeCanvasObjectMode(node);
if (state.nodeCanvasObject && (nodeCanvasObjectMode === 'before' ||Â nodeCanvasObjectMode === 'replace')) {
// Custom node before/replace paint
state.nodeCanvasObject(node, ctx, state.globalScale);
if (nodeCanvasObjectMode === 'replace') {
ctx.restore();
return;
}
}
// Draw wider nodes by 1px on shadow canvas for more precise hovering (due to boundary anti-aliasing)
const r = Math.sqrt(Math.max(0, getVal(node) || 1)) * state.nodeRelSize + padAmount;
ctx.beginPath();
ctx.arc(node.x, node.y, r, 0, 2 * Math.PI, false);
ctx.fillStyle = getColor(node) || 'rgba(31, 120, 180, 0.92)';
ctx.fill();
if (state.nodeCanvasObject && nodeCanvasObjectMode === 'after') {
// Custom node after paint
state.nodeCanvasObject(node, state.ctx, state.globalScale);
}
});
ctx.restore();
}
function paintLinks() {
const getVisibility = accessorFn(state.linkVisibility);
const getColor = accessorFn(state.linkColor);
const getWidth = accessorFn(state.linkWidth);
const getLineDash = accessorFn(state.linkLineDash);
const getCurvature = accessorFn(state.linkCurvature);
const getLinkCanvasObjectMode = accessorFn(state.linkCanvasObjectMode);
const ctx = state.ctx;
// Draw wider lines by 2px on shadow canvas for more precise hovering (due to boundary anti-aliasing)
const padAmount = state.isShadow * 2;
const visibleLinks = state.graphData.links.filter(getVisibility);
visibleLinks.forEach(calcLinkControlPoints); // calculate curvature control points for all visible links
let beforeCustomLinks = [], afterCustomLinks = [], defaultPaintLinks = visibleLinks;
if (state.linkCanvasObject) {
const replaceCustomLinks = [], otherCustomLinks = [];
visibleLinks.forEach(d =>
({
before: beforeCustomLinks,
after: afterCustomLinks,
replace: replaceCustomLinks
}[getLinkCanvasObjectMode(d)] || otherCustomLinks).push(d)
);
defaultPaintLinks = [...beforeCustomLinks, ...afterCustomLinks, ...otherCustomLinks];
beforeCustomLinks = beforeCustomLinks.concat(replaceCustomLinks);
}
// Custom link before paints
ctx.save();
beforeCustomLinks.forEach(link => state.linkCanvasObject(link, ctx, state.globalScale));
ctx.restore();
// Bundle strokes per unique color/width/dash for performance optimization
const linksPerColor = indexBy(defaultPaintLinks, [getColor, getWidth, getLineDash]);
ctx.save();
Object.entries(linksPerColor).forEach(([color, linksPerWidth]) => {
const lineColor = !color || color === 'undefined' ? 'rgba(0,0,0,0.15)' : color;
Object.entries(linksPerWidth).forEach(([width, linesPerLineDash]) => {
const lineWidth = (width || 1) / state.globalScale + padAmount;
Object.entries(linesPerLineDash).forEach(([dashSegments, links]) => {
const lineDashSegments = getLineDash(links[0]);
ctx.beginPath();
links.forEach(link => {
const start = link.source;
const end = link.target;
if (!start || !end || !start.hasOwnProperty('x') || !end.hasOwnProperty('x')) return; // skip invalid link
ctx.moveTo(start.x, start.y);
const controlPoints = link.__controlPoints;
if (!controlPoints) { // Straight line
ctx.lineTo(end.x, end.y);
} else {
// Use quadratic curves for regular lines and bezier for loops
ctx[controlPoints.length === 2 ? 'quadraticCurveTo' : 'bezierCurveTo'](...controlPoints, end.x, end.y);
}
});
ctx.strokeStyle = lineColor;
ctx.lineWidth = lineWidth;
ctx.setLineDash(lineDashSegments || []);
ctx.stroke();
});
});
});
ctx.restore();
// Custom link after paints
ctx.save();
afterCustomLinks.forEach(link => state.linkCanvasObject(link, ctx, state.globalScale));
ctx.restore();
//
function calcLinkControlPoints(link) {
const curvature = getCurvature(link);
if (!curvature) { // straight line
link.__controlPoints = null;
return;
}
const start = link.source;
const end = link.target;
if (!start || !end || !start.hasOwnProperty('x') || !end.hasOwnProperty('x')) return; // skip invalid link
const l = Math.sqrt(Math.pow(end.x - start.x, 2) + Math.pow(end.y - start.y, 2)); // line length
if (l > 0) {
const a = Math.atan2(end.y - start.y, end.x - start.x); // line angle
const d = l * curvature; // control point distance
const cp = { // control point
x: (start.x + end.x) / 2 + d * Math.cos(a - Math.PI / 2),
y: (start.y + end.y) / 2 + d * Math.sin(a - Math.PI / 2)
};
link.__controlPoints = [cp.x, cp.y];
} else { // Same point, draw a loop
const d = curvature * 70;
link.__controlPoints = [end.x, end.y - d, end.x + d, end.y];
}
}
}
function paintArrows() {
const ARROW_WH_RATIO = 1.6;
const ARROW_VLEN_RATIO = 0.2;
const getLength = accessorFn(state.linkDirectionalArrowLength);
const getRelPos = accessorFn(state.linkDirectionalArrowRelPos);
const getVisibility = accessorFn(state.linkVisibility);
const getColor = accessorFn(state.linkDirectionalArrowColor || state.linkColor);
const getNodeVal = accessorFn(state.nodeVal);
const ctx = state.ctx;
ctx.save();
state.graphData.links.filter(getVisibility).forEach(link => {
const arrowLength = getLength(link);
if (!arrowLength || arrowLength < 0) return;
const start = link.source;
const end = link.target;
if (!start || !end || !start.hasOwnProperty('x') || !end.hasOwnProperty('x')) return; // skip invalid link
const startR = Math.sqrt(Math.max(0, getNodeVal(start) || 1)) * state.nodeRelSize;
const endR = Math.sqrt(Math.max(0, getNodeVal(end) || 1)) * state.nodeRelSize;
const arrowRelPos = Math.min(1, Math.max(0, getRelPos(link)));
const arrowColor = getColor(link) || 'rgba(0,0,0,0.28)';
const arrowHalfWidth = arrowLength / ARROW_WH_RATIO / 2;
// Construct bezier for curved lines
const bzLine = link.__controlPoints && new Bezier(start.x, start.y, ...link.__controlPoints, end.x, end.y);
const getCoordsAlongLine = bzLine
? t => bzLine.get(t) // get position along bezier line
: t => ({ // straight line: interpolate linearly
x: start.x + (end.x - start.x) * t || 0,
y: start.y + (end.y - start.y) * t || 0
});
const lineLen = bzLine
? bzLine.length()
: Math.sqrt(Math.pow(end.x - start.x, 2) + Math.pow(end.y - start.y, 2));
const posAlongLine = startR + arrowLength + (lineLen - startR - endR - arrowLength) * arrowRelPos;
const arrowHead = getCoordsAlongLine(posAlongLine / lineLen);
const arrowTail = getCoordsAlongLine((posAlongLine - arrowLength) / lineLen);
const arrowTailVertex = getCoordsAlongLine((posAlongLine - arrowLength * (1 - ARROW_VLEN_RATIO)) / lineLen);
const arrowTailAngle = Math.atan2(arrowHead.y - arrowTail.y, arrowHead.x - arrowTail.x) - Math.PI / 2;
ctx.beginPath();
ctx.moveTo(arrowHead.x, arrowHead.y);
ctx.lineTo(arrowTail.x + arrowHalfWidth * Math.cos(arrowTailAngle), arrowTail.y + arrowHalfWidth * Math.sin(arrowTailAngle));
ctx.lineTo(arrowTailVertex.x, arrowTailVertex.y);
ctx.lineTo(arrowTail.x - arrowHalfWidth * Math.cos(arrowTailAngle), arrowTail.y - arrowHalfWidth * Math.sin(arrowTailAngle));
ctx.fillStyle = arrowColor;
ctx.fill();
});
ctx.restore();
}
function paintPhotons() {
const getNumPhotons = accessorFn(state.linkDirectionalParticles);
const getSpeed = accessorFn(state.linkDirectionalParticleSpeed);
const getOffset = accessorFn(state.linkDirectionalParticleOffset);
const getDiameter = accessorFn(state.linkDirectionalParticleWidth);
const getVisibility = accessorFn(state.linkVisibility);
const getColor = accessorFn(state.linkDirectionalParticleColor || state.linkColor);
const ctx = state.ctx;
ctx.save();
state.graphData.links.filter(getVisibility).forEach(link => {
const numCyclePhotons = getNumPhotons(link);
if (!link.hasOwnProperty('__photons') || !link.__photons.length) return;
const start = link.source;
const end = link.target;
if (!start || !end || !start.hasOwnProperty('x') || !end.hasOwnProperty('x')) return; // skip invalid link
const particleSpeed = getSpeed(link);
const particleOffset = Math.abs(getOffset(link));
const photons = link.__photons || [];
const photonR = Math.max(0, getDiameter(link) / 2) / Math.sqrt(state.globalScale);
const photonColor = getColor(link) || 'rgba(0,0,0,0.28)';
ctx.fillStyle = photonColor;
// Construct bezier for curved lines
const bzLine = link.__controlPoints
? new Bezier(start.x, start.y, ...link.__controlPoints, end.x, end.y)
: null;
let cyclePhotonIdx = 0;
let needsCleanup = false; // whether some photons need to be removed from list
photons.forEach(photon => {
const singleHop = !!photon.__singleHop;
if (!photon.hasOwnProperty('__progressRatio')) {
photon.__progressRatio = singleHop ? 0 : (cyclePhotonIdx + particleOffset) / numCyclePhotons;
}
!singleHop && cyclePhotonIdx++; // increase regular photon index
photon.__progressRatio += particleSpeed;
if (photon.__progressRatio >=1) {
if (!singleHop) {
photon.__progressRatio = photon.__progressRatio % 1;
} else {
needsCleanup = true;
return;
}
}
const photonPosRatio = photon.__progressRatio;
const coords = bzLine
? bzLine.get(photonPosRatio) // get position along bezier line
: { // straight line: interpolate linearly
x: start.x + (end.x - start.x) * photonPosRatio || 0,
y: start.y + (end.y - start.y) * photonPosRatio || 0
};
if(state.linkDirectionalParticleCanvasObject) {
state.linkDirectionalParticleCanvasObject(coords.x, coords.y, link, ctx, state.globalScale);
} else {
ctx.beginPath();
ctx.arc(coords.x, coords.y, photonR, 0, 2 * Math.PI, false);
ctx.fill();
}
});
if (needsCleanup) {
// remove expired single hop photons
link.__photons = link.__photons.filter(photon => !photon.__singleHop || photon.__progressRatio <= 1);
}
});
ctx.restore();
}
},
emitParticle: function(state, link) {
if (link) {
!link.__photons && (link.__photons = []);
link.__photons.push({ __singleHop: true }); // add a single hop particle
}
return this;
}
},
stateInit: () => ({
forceLayout: d3ForceSimulation()
.force('link', d3ForceLink())
.force('charge', d3ForceManyBody())
.force('center', d3ForceCenter())
.force('dagRadial', null)
.stop(),
engineRunning: false
}),
init(canvasCtx, state) {
// Main canvas object to manipulate
state.ctx = canvasCtx;
},
update(state, changedProps) {
state.engineRunning = false; // Pause simulation
state.onUpdate();
if (state.nodeAutoColorBy !== null) {
// Auto add color to uncolored nodes
autoColorObjects(state.graphData.nodes, accessorFn(state.nodeAutoColorBy), state.nodeColor);
}
if (state.linkAutoColorBy !== null) {
// Auto add color to uncolored links
autoColorObjects(state.graphData.links, accessorFn(state.linkAutoColorBy), state.linkColor);
}
// parse links
state.graphData.links.forEach(link => {
link.source = link[state.linkSource];
link.target = link[state.linkTarget];
});
// Feed data to force-directed layout
state.forceLayout
.stop()
.alpha(1) // re-heat the simulation
.nodes(state.graphData.nodes);
// add links (if link force is still active)
const linkForce = state.forceLayout.force('link');
if (linkForce) {
linkForce
.id(d => d[state.nodeId])
.links(state.graphData.links);
}
// setup dag force constraints
const nodeDepths = state.dagMode && getDagDepths(
state.graphData,
node => node[state.nodeId],
{
nodeFilter: state.dagNodeFilter,
onLoopError: state.onDagError || undefined
}
);
const maxDepth = Math.max(...Object.values(nodeDepths || []));
const dagLevelDistance = state.dagLevelDistance || (
state.graphData.nodes.length / (maxDepth || 1) * DAG_LEVEL_NODE_RATIO
* (['radialin', 'radialout'].indexOf(state.dagMode) !== -1 ? 0.7 : 1)
);
// Reset relevant fx/fy when swapping dag modes
if (['lr', 'rl', 'td', 'bu'].includes(changedProps.dagMode)) {
const resetProp = ['lr', 'rl'].includes(changedProps.dagMode) ? 'fx' : 'fy';
state.graphData.nodes.filter(state.dagNodeFilter).forEach(node => delete node[resetProp]);
}
// Fix nodes to x,y for dag mode
if (['lr', 'rl', 'td', 'bu'].includes(state.dagMode)) {
const invert = ['rl', 'bu'].includes(state.dagMode);
const fixFn = node => (nodeDepths[node[state.nodeId]] - maxDepth / 2) * dagLevelDistance * (invert ? -1 : 1);
const resetProp = ['lr', 'rl'].includes(state.dagMode) ? 'fx' : 'fy';
state.graphData.nodes.filter(state.dagNodeFilter).forEach(node => node[resetProp] = fixFn(node));
}
// Use radial force for radial dags
state.forceLayout.force('dagRadial',
['radialin', 'radialout'].indexOf(state.dagMode) !== -1
? d3ForceRadial(node => {
const nodeDepth = nodeDepths[node[state.nodeId]] || -1;
return (state.dagMode === 'radialin' ? maxDepth - nodeDepth : nodeDepth) * dagLevelDistance;
})
.strength(node => state.dagNodeFilter(node) ? 1 : 0)
: null
);
for (let i=0; (i<state.warmupTicks) && !(state.d3AlphaMin > 0 && state.forceLayout.alpha() < state.d3AlphaMin); i++) {
state.forceLayout.tick();
} // Initial ticks before starting to render
this.resetCountdown();
state.onFinishUpdate();
}
});