netviz
Version:
Network visualization library with multiple layout algorithms and rendering modes. Create interactive, publication-quality network visualizations with a simple, reactive API.
335 lines (301 loc) • 10 kB
JavaScript
import * as d3 from "d3";
import forceInABox from "force-in-a-box";
import forceBoundary from "d3-force-boundary";
import { forceTransport } from "./forces/forceTransport.js";
import { forceExtent } from "./forces/forceExtent.js";
import { edgeBundling } from "./forces/edgeBundling.js";
import { sample } from "./utils/sample.js";
import { drag } from "./utils/drag.js";
import { renderCanvas } from "./renderers/renderCanvas.js";
import { renderSVG } from "./renderers/renderSVG.js";
import { getDefaultOpts } from "./defaults.js";
/**
* ForceGraph - Core force-directed graph function
*
* Original source: https://observablehq.com/@john-guerra/force-directed-graph
* From 89207a2280891f15@1859.js lines 230-488
*
* @param {Object} data - {nodes, links} graph data
* @param {Object} _opts - User options
* @returns {HTMLElement} DOM element (SVG or Canvas) with additional properties
*/
export function ForceGraph(
{ nodes, links = [] }, // an iterable of node and link objects
_opts = {}
) {
// Validation: Check for required data
if (!nodes) {
throw new Error(
"netviz: Missing required 'nodes' array in data. " +
"Expected: ForceGraph({ nodes: [...], links: [...] }, options)"
);
}
if (!Array.isArray(nodes)) {
throw new Error(
`netviz: 'nodes' must be an array, received ${typeof nodes}. ` +
"Expected: ForceGraph({ nodes: [...], links: [...] }, options)"
);
}
if (nodes.length === 0) {
console.warn(
"netviz: Empty 'nodes' array provided. The graph will be empty."
);
}
if (!Array.isArray(links)) {
throw new Error(
`netviz: 'links' must be an array, received ${typeof links}. ` +
"Expected: ForceGraph({ nodes: [...], links: [...] }, options)"
);
}
// Validate that nodes have IDs
const nodeIdAccessor = _opts.nodeId || ((d) => d?.id);
const invalidNodes = nodes.filter((node) => {
const id = nodeIdAccessor(node);
return id === undefined || id === null;
});
if (invalidNodes.length > 0) {
throw new Error(
`netviz: ${invalidNodes.length} node(s) are missing an 'id' property. ` +
"All nodes must have an 'id' field. " +
"Example: { id: 'nodeA', ...otherProps }. " +
"Or provide a custom nodeId function in options."
);
}
let opts = {
...getDefaultOpts(_opts),
..._opts,
edgeBundling: {
...getDefaultOpts(_opts).edgeBundling,
..._opts.edgeBundling,
},
forceInABox: {
...getDefaultOpts(_opts).forceInABox,
..._opts.forceInABox,
},
smartLabels: {
...getDefaultOpts(_opts).smartLabels,
..._opts.smartLabels,
},
};
if (opts.nodeRadius && typeof opts.nodeRadius !== "function") {
const numberNodeRadius = opts.nodeRadius;
opts.nodeRadius = () => numberNodeRadius;
}
if (opts.nodeStroke && typeof opts.nodeStroke !== "function") {
const nodeStroke = opts.nodeStroke;
opts.nodeStroke = () => nodeStroke;
}
// Handle both object references and numeric indices in links
opts.linkSource =
opts.linkSource ||
(({ source }) => {
// If source is a number, it's an index into the nodes array
if (typeof source === "number") {
return opts.nodeId(nodes[source]);
}
// If source is an object, get its ID
return opts.nodeId(source) || source;
});
opts.linkTarget =
opts.linkTarget ||
(({ target }) => {
// If target is a number, it's an index into the nodes array
if (typeof target === "number") {
return opts.nodeId(nodes[target]);
}
// If target is an object, get its ID
return opts.nodeId(target) || target;
});
// Compute values
const N = d3.map(nodes, opts.nodeId).map(intern);
const LS = d3.map(links, opts.linkSource).map(intern);
const LT = d3.map(links, opts.linkTarget).map(intern);
const T = opts.nodeLabel == null ? null : d3.map(nodes, opts.nodeLabel);
const R = opts.nodeRadius == null ? null : d3.map(nodes, opts.nodeRadius);
const G =
opts.nodeGroup == null ? null : d3.map(nodes, opts.nodeGroup).map(intern);
const NS =
opts.nodeStroke == null ? null : d3.map(nodes, opts.nodeStroke).map(intern);
const W =
typeof opts.linkStrokeWidth !== "function"
? null
: d3.map(links, opts.linkStrokeWidth);
const L =
typeof opts.linkStroke !== "function"
? null
: d3.map(links, opts.linkStroke);
const LO =
typeof opts.linkStrokeOpacity !== "function"
? null
: d3.map(links, opts.linkStrokeOpacity);
opts.x = d3.scaleLinear().domain([0, opts.width]).range([0, opts.width]);
opts.y = d3.scaleLinear().domain([0, opts.height]).range([0, opts.height]);
// Copy nodes
if (opts._this?.nodes) {
// Make a shallow copy to protect against mutation, while
// recycling old nodes to preserve position and velocity
const oldNodes = new Map(opts._this?.nodes.map((d) => [d.id, d]));
nodes = nodes.map((n, i) =>
Object.assign(
oldNodes.get(opts.nodeId(n)) || { ...n, id: N[i], groupBy: G && G[i] }
)
);
} else {
// Replace the input nodes and links with mutable objects for the simulation
nodes = d3.map(nodes, (n, i) => ({
...n,
id: N[i],
groupBy: G && G[i], // we need the groupBy for forceInABox
}));
}
links = d3.map(links, (_, i) => ({ source: LS[i], target: LT[i] }));
// Initialize towards the middle
for (let n of nodes) {
const randomFactor = 3; // how far from the center should nodes be initialized
n.x =
n.x !== undefined
? n.x
: (Math.random() - 0.5) * (opts.width / randomFactor) + opts.width / 2;
n.y =
n.y !== undefined
? n.y
: (Math.random() - 0.5) * (opts.height / randomFactor) +
opts.height / 2;
}
// Compute default domains
if (G && opts.nodeGroups === undefined) opts.nodeGroups = d3.sort(G);
// Construct the scales
const color = opts.color
? opts.color
: opts.nodeGroup == null
? null
: d3.scaleOrdinal(opts.nodeGroups, opts.colors);
let simulation =
opts._this?.simulation?.alpha(opts.simulationRestartAlpha)?.restart() ||
d3.forceSimulation();
simulation.nodes(nodes);
if (opts.forces) {
for (let k in opts.forces) {
simulation.force(k, opts.forces[k]({ nodes, links }));
}
} else {
// Construct the forces
const forceNode = d3.forceManyBody();
const forceLink = d3.forceLink(links).id(({ index: i }) => N[i]);
if (opts.nodeStrength !== undefined) forceNode.strength(opts.nodeStrength);
if (opts.linkStrength !== undefined) forceLink.strength(opts.linkStrength);
if (opts.forceXStrength === undefined) {
opts.forceXStrength = (opts.height / opts.width) * opts.centeringStrength;
}
if (opts.forceYStrength === undefined) {
opts.forceYStrength = (opts.width / opts.height) * opts.centeringStrength;
}
const forceCollide = opts.collide
? d3
.forceCollide((_, i) => R[i] + opts.collidePadding)
.iterations(opts.collideIterations)
: null;
const forceX = d3.forceX(opts.width / 2).strength(opts.forceXStrength);
const forceY = d3.forceY(opts.height / 2).strength(opts.forceYStrength);
let groupingForce;
if (opts.useForceInABox) {
groupingForce = forceInABox()
.size([opts.width, opts.height - 100])
.template(opts.forceInABox.template)
.groupBy("groupBy")
.strength(opts.forceInABox.strength)
.links(links)
.linkStrengthInterCluster(opts.forceInABox.linkStrengthInterCluster)
.linkStrengthIntraCluster(opts.forceInABox.linkStrengthIntraCluster)
.forceLinkDistance(opts.forceInABox.forceLinkDistance)
.forceLinkStrength(opts.forceInABox.forceLinkStrength)
.forceCharge(opts.forceInABox.forceCharge)
.forceNodeSize(opts.forceInABox.forceNodeSize);
}
simulation
.force(
"link",
forceLink
.distance(opts.linkDistance)
.strength(
opts.useForceInABox
? groupingForce.getLinkStrength
: opts.linkStrength || 0.1
)
)
.force("charge", forceNode)
.force("x", opts.useForceInABox ? null : opts.disjoint ? forceX : null)
.force("y", opts.useForceInABox ? null : opts.disjoint ? forceY : null)
.force(
"center",
opts.useForceInABox
? null
: !opts.disjoint
? d3.forceCenter(opts.width / 2, opts.height / 2)
: null
)
.force("collide", forceCollide)
.force("forceInABox", opts.useForceInABox ? groupingForce : null);
}
// Edge bundling
opts.bundling =
opts.useEdgeBundling &&
edgeBundling(
{ nodes, links: sample(links, opts.edgeBundling.max_links) },
{
...opts.edgeBundling,
}
);
simulation.alphaTarget(opts.alphaTarget).restart();
if (opts.extent) {
simulation.force(
"boundary",
opts.extentForce === "forceBoundary"
? forceBoundary(...opts.extent.flat(2))
.border(opts.extentBorder)
.hardBoundary(true)
.strength(opts.extentStrength)
: opts.extentForce === "forceExtent"
? forceExtent(opts.extent)
: forceTransport(opts.extent, 5, opts.extentStrength * 10)
);
}
opts = {
...opts,
nodes,
links,
N,
LS,
LT,
T,
G,
W,
L,
LO,
R,
NS,
drag,
simulation,
color,
};
const { target, ticked } =
opts.renderer === "canvas" ? renderCanvas(opts) : renderSVG(opts);
simulation.on("tick", ticked);
if (opts.invalidation) {
opts.invalidation.then(() => {
simulation.stop();
});
}
function intern(value) {
return value !== null && typeof value === "object"
? value.valueOf()
: value;
}
ticked();
return Object.assign(target, {
scales: { color },
simulation,
nodes,
links,
});
}