netviz
Version:
Network visualization library with multiple layout algorithms and rendering modes. Create interactive, publication-quality network visualizations with a simple, reactive API.
1,771 lines (1,536 loc) • 87.4 kB
JavaScript
'use strict';
var d3 = require('d3');
function _interopNamespaceDefault(e) {
var n = Object.create(null);
if (e) {
Object.keys(e).forEach(function (k) {
if (k !== 'default') {
var d = Object.getOwnPropertyDescriptor(e, k);
Object.defineProperty(n, k, d.get ? d : {
enumerable: true,
get: function () { return e[k]; }
});
}
});
}
n.default = e;
return Object.freeze(n);
}
var d3__namespace = /*#__PURE__*/_interopNamespaceDefault(d3);
// https://github.com/john-guerra/forceInABox#readme v1.0.2 Copyright 2024 undefined
function forceInABox() {
// d3 style
function constant(_) {
return () => _;
}
function index(d) {
return d.index;
}
let id = index,
nodes = [],
links = [], //needed for the force version
tree,
size = [100, 100],
forceNodeSize = constant(1), // The expected node size used for computing the cluster node
forceCharge = constant(-1),
forceLinkDistance = constant(100),
forceLinkStrength = constant(0.1),
foci = {},
// oldStart = force.start,
linkStrengthIntraCluster = 0.1,
linkStrengthInterCluster = 0.001,
// oldGravity = force.gravity(),
templateNodes = [],
offset = [0, 0],
templateForce,
groupBy = function (d) {
return d.cluster;
},
template = "treemap",
enableGrouping = true,
strength = 0.1;
// showingTemplate = false;
function force(alpha) {
if (!enableGrouping) {
return force;
}
if (template === "force") {
//Do the tick of the template force and get the new focis
templateForce.tick();
getFocisFromTemplate();
}
for (let i = 0, n = nodes.length, node, k = alpha * strength; i < n; ++i) {
node = nodes[i];
node.vx += (foci[groupBy(node)].x - node.x) * k;
node.vy += (foci[groupBy(node)].y - node.y) * k;
}
}
function initialize() {
if (!nodes) return;
// let i,
// n = nodes.length,
// m = links.length,
// nodeById = map(nodes, id),
// link;
if (template === "treemap") {
initializeWithTreemap();
} else {
initializeWithForce();
}
}
force.initialize = function (_) {
nodes = _;
initialize();
};
function getLinkKey(l) {
let sourceID = groupBy(l.source),
targetID = groupBy(l.target);
return sourceID <= targetID
? sourceID + "~" + targetID
: targetID + "~" + sourceID;
}
function computeClustersNodeCounts(nodes) {
let clustersCounts = new Map(),
tmpCount = {};
nodes.forEach(function (d) {
if (!clustersCounts.has(groupBy(d))) {
clustersCounts.set(groupBy(d), { count: 0, sumforceNodeSize: 0 });
}
});
nodes.forEach(function (d) {
// if (!d.show) { return; }
tmpCount = clustersCounts.get(groupBy(d));
tmpCount.count = tmpCount.count + 1;
tmpCount.sumforceNodeSize =
tmpCount.sumforceNodeSize +
Math.PI * (forceNodeSize(d) * forceNodeSize(d)) * 1.3;
clustersCounts.set(groupBy(d), tmpCount);
});
return clustersCounts;
}
//Returns
function computeClustersLinkCounts(links) {
let dClusterLinks = new Map(),
clusterLinks = [];
links.forEach(function (l) {
let key = getLinkKey(l),
count;
if (dClusterLinks.has(key)) {
count = dClusterLinks.get(key);
} else {
count = 0;
}
count += 1;
dClusterLinks.set(key, count);
});
dClusterLinks.forEach(function (value, key) {
let source, target;
source = key.split("~")[0];
target = key.split("~")[1];
if (source !== undefined && target !== undefined) {
clusterLinks.push({
source: source,
target: target,
count: value,
});
}
});
return clusterLinks;
}
//Returns the metagraph of the clusters
function getGroupsGraph() {
let gnodes = [],
glinks = [],
// edges = [],
dNodes = new Map(),
// totalSize = 0,
c,
i,
cc,
clustersCounts,
clustersLinks;
clustersCounts = computeClustersNodeCounts(nodes);
clustersLinks = computeClustersLinkCounts(links);
for (c of clustersCounts.keys()) {
cc = clustersCounts.get(c);
gnodes.push({
id: c,
size: cc.count,
r: Math.sqrt(cc.sumforceNodeSize / Math.PI),
}); // Uses approx meta-node size
dNodes.set(c, i);
// totalSize += size;
}
clustersLinks.forEach(function (l) {
let source = dNodes.get(l.source),
target = dNodes.get(l.target);
if (source !== undefined && target !== undefined) {
glinks.push({
source: source,
target: target,
count: l.count,
});
}
});
return { nodes: gnodes, links: glinks };
}
function getGroupsTree() {
let children = [],
c,
cc,
clustersCounts;
clustersCounts = computeClustersNodeCounts(force.nodes());
for (c of clustersCounts.keys()) {
cc = clustersCounts.get(c);
children.push({ id: c, size: cc.count });
}
return { id: "clustersTree", children: children };
}
function getFocisFromTemplate() {
//compute foci
foci.none = { x: 0, y: 0 };
templateNodes.forEach(function (d) {
if (template === "treemap") {
foci[d.data.id] = {
x: d.x0 + (d.x1 - d.x0) / 2 - offset[0],
y: d.y0 + (d.y1 - d.y0) / 2 - offset[1],
};
} else {
foci[d.id] = {
x: d.x - offset[0],
y: d.y - offset[1],
};
}
});
return foci;
}
function initializeWithTreemap() {
let treemap = d3__namespace.treemap().size(force.size());
tree = d3__namespace
.hierarchy(getGroupsTree())
.sum(function (d) {
return d.size;
})
.sort(function (a, b) {
return b.height - a.height || b.value - a.value;
});
templateNodes = treemap(tree).leaves();
getFocisFromTemplate();
}
function checkLinksAsObjects() {
// Check if links come in the format of indexes instead of objects
let linkCount = 0;
if (nodes.length === 0) return;
links.forEach(function (link) {
let source, target;
if (!nodes) return;
source = link.source;
target = link.target;
if (typeof link.source !== "object") source = nodes[link.source];
if (typeof link.target !== "object") target = nodes[link.target];
if (source === undefined || target === undefined) {
// console.error(link);
throw Error(
"Error setting links, couldnt find nodes for a link (see it on the console)"
);
}
link.source = source;
link.target = target;
link.index = linkCount++;
});
}
function initializeWithForce() {
let net;
if (!nodes || !nodes.length) {
return;
}
if (nodes && nodes.length > 0) {
if (groupBy(nodes[0]) === undefined) {
throw Error(
"Couldnt find the grouping attribute for the nodes. Make sure to set it up with forceInABox.groupBy('clusterAttr') before calling .links()"
);
}
}
checkLinksAsObjects();
net = getGroupsGraph();
templateForce = d3__namespace
.forceSimulation(net.nodes)
.force("x", d3__namespace.forceX(size[0] / 2).strength(0.1))
.force("y", d3__namespace.forceY(size[1] / 2).strength(0.1))
.force(
"collide",
d3__namespace
.forceCollide(function (d) {
return d.r;
})
.iterations(4)
)
.force("charge", d3__namespace.forceManyBody().strength(forceCharge))
.force(
"links",
d3__namespace
.forceLink(net.nodes.length ? net.links : [])
.distance(forceLinkDistance)
.strength(forceLinkStrength)
);
// console.log("Initialize with force ", templateForce.nodes().length, " ", templateForce.force("links").links().length);
// let i = 0;
// while (i++ < 500) templateForce.tick();
templateNodes = templateForce.nodes();
getFocisFromTemplate();
}
function drawTreemap(container) {
// Delete the circle Template if it exists
container.selectAll("circle.cell").remove();
container.selectAll("line.cell").remove();
container
.selectAll("rect.cell")
.data(templateNodes)
.enter()
.append("svg:rect")
.attr("class", "cell")
.attr("x", function (d) {
return d.x0;
})
.attr("y", function (d) {
return d.y0;
})
.attr("width", function (d) {
return d.x1 - d.x0;
})
.attr("height", function (d) {
return d.y1 - d.y0;
});
}
function drawGraph(container) {
// Delete the treemap if any
container.selectAll("rect.cell").remove();
let templateLinksSel = container
.selectAll("line.cell")
.data(templateForce.force("links").links());
templateLinksSel
.enter()
.append("line")
.attr("class", "cell")
.merge(templateLinksSel)
.attr("x2", function (d) {
return d.source.x;
})
.attr("y2", function (d) {
return d.source.y;
})
.attr("x1", function (d) {
return d.target.x;
})
.attr("y1", function (d) {
return d.target.y;
})
.style("stroke-width", "1px")
.style("stroke-opacity", "0.5");
let templateNodesSel = container
.selectAll("circle.cell")
.data(templateForce.nodes());
templateNodesSel
.enter()
.append("svg:circle")
.attr("class", "cell")
.merge(templateNodesSel)
.attr("cx", function (d) {
return d.x;
})
.attr("cy", function (d) {
return d.y;
})
.attr("r", function (d) {
return d.r;
});
templateForce
.on("tick", () => {
// console.log("tick");
drawGraph(container);
})
.restart();
templateNodesSel.exit().remove();
templateLinksSel.exit().remove();
}
force.drawTemplate = function (container) {
// showingTemplate = true;
if (template === "treemap") {
drawTreemap(container);
} else {
drawGraph(container);
}
return force;
};
//Backwards compatibility
force.drawTreemap = force.drawTemplate;
force.deleteTemplate = function (container) {
// showingTemplate = false;
container.selectAll(".cell").remove();
if (templateForce) {
templateForce.on("tick", null).restart();
}
return force;
};
force.template = function (x) {
if (!arguments.length) return template;
template = x;
initialize();
return force;
};
force.groupBy = function (x) {
if (!arguments.length) return groupBy;
if (typeof x === "string") {
groupBy = function (d) {
return d[x];
};
return force;
}
groupBy = x;
return force;
};
force.enableGrouping = function (x) {
if (!arguments.length) return enableGrouping;
enableGrouping = x;
// update();
return force;
};
force.strength = function (x) {
if (!arguments.length) return strength;
strength = x;
return force;
};
force.getLinkStrength = function (e) {
if (enableGrouping) {
if (groupBy(e.source) === groupBy(e.target)) {
if (typeof linkStrengthIntraCluster === "function") {
return linkStrengthIntraCluster(e);
} else {
return linkStrengthIntraCluster;
}
} else {
if (typeof linkStrengthInterCluster === "function") {
return linkStrengthInterCluster(e);
} else {
return linkStrengthInterCluster;
}
}
} else {
// Not grouping return the intracluster
if (typeof linkStrengthIntraCluster === "function") {
return linkStrengthIntraCluster(e);
} else {
return linkStrengthIntraCluster;
}
}
};
force.id = function (_) {
return arguments.length ? ((id = _), force) : id;
};
force.size = function (_) {
return arguments.length ? ((size = _), force) : size;
};
force.linkStrengthInterCluster = function (_) {
return arguments.length
? ((linkStrengthInterCluster = _), force)
: linkStrengthInterCluster;
};
force.linkStrengthIntraCluster = function (_) {
return arguments.length
? ((linkStrengthIntraCluster = _), force)
: linkStrengthIntraCluster;
};
force.nodes = function (_) {
return arguments.length ? ((nodes = _), force) : nodes;
};
force.links = function (_) {
if (!arguments.length) return links;
if (_ === null) links = [];
else links = _;
initialize();
return force;
};
force.forceNodeSize = function (_) {
return arguments.length
? ((forceNodeSize = typeof _ === "function" ? _ : constant(+_)),
initialize(),
force)
: forceNodeSize;
};
// Legacy support
force.nodeSize = force.forceNodeSize;
force.forceCharge = function (_) {
return arguments.length
? ((forceCharge = typeof _ === "function" ? _ : constant(+_)),
initialize(),
force)
: forceCharge;
};
force.forceLinkDistance = function (_) {
return arguments.length
? ((forceLinkDistance = typeof _ === "function" ? _ : constant(+_)),
initialize(),
force)
: forceLinkDistance;
};
force.forceLinkStrength = function (_) {
return arguments.length
? ((forceLinkStrength = typeof _ === "function" ? _ : constant(+_)),
initialize(),
force)
: forceLinkStrength;
};
force.offset = function (_) {
return arguments.length
? ((offset = typeof _ === "function" ? _ : constant(+_)), force)
: offset;
};
force.getFocis = getFocisFromTemplate;
return force;
}
// https://observablehq.com/@john-guerra/d3-force-boundary v0.0.2 Copyright 2022 John Alexis Guerra Gómez
function constant(x) {
return function() {
return x;
};
}
function forceBoundary(x0, y0, x1, y1) {
var strength = constant(0.1),
hardBoundary = true,
border = constant( Math.min((x1 - x0)/2, (y1 - y0)/2) ),
nodes,
strengthsX,
strengthsY,
x0z, x1z,
y0z, y1z,
borderz,
halfX, halfY;
if (typeof x0 !== "function") x0 = constant(x0 == null ? -100 : +x0);
if (typeof x1 !== "function") x1 = constant(x1 == null ? 100 : +x1);
if (typeof y0 !== "function") y0 = constant(y0 == null ? -100 : +y0);
if (typeof y1 !== "function") y1 = constant(y1 == null ? 100 : +y1);
function getVx(halfX, x, strengthX, border, alpha) {
return (halfX - x) * Math.min(2, Math.abs( halfX - x) / halfX) * strengthX * alpha;
}
function force(alpha) {
for (var i = 0, n = nodes.length, node; i < n; ++i) {
node = nodes[i];
// debugger;
if ((node.x < (x0z[i] + borderz[i]) || node.x > (x1z[i] - borderz[i])) ||
(node.y < (y0z[i] + borderz[i]) || node.y > (y1z[i] - borderz[i]))) {
node.vx += getVx(halfX[i], node.x, strengthsX[i], borderz[i], alpha);
node.vy += getVx(halfY[i], node.y, strengthsY[i], borderz[i], alpha);
} else if (node.y < (y0z[i] + borderz[i]) || node.y > (y1z[i] - borderz[i])) ;
if (hardBoundary) {
if (node.x >= x1z[i]) node.vx += x1z[i] - node.x;
if (node.x <= x0z[i]) node.vx += x0z[i] - node.x;
if (node.y >= y1z[i]) node.vy += y1z[i] - node.y;
if (node.y <= y0z[i]) node.vy += y0z[i] - node.y;
}
}
}
function initialize() {
if (!nodes) return;
var i, n = nodes.length;
strengthsX = new Array(n);
strengthsY = new Array(n);
x0z = new Array(n);
y0z = new Array(n);
x1z = new Array(n);
y1z = new Array(n);
halfY = new Array(n);
halfX = new Array(n);
borderz = new Array(n);
for (i = 0; i < n; ++i) {
strengthsX[i] = (isNaN(x0z[i] = +x0(nodes[i], i, nodes)) ||
isNaN(x1z[i] = +x1(nodes[i], i, nodes))) ? 0 : +strength(nodes[i], i, nodes);
strengthsY[i] = (isNaN(y0z[i] = +y0(nodes[i], i, nodes)) ||
isNaN(y1z[i] = +y1(nodes[i], i, nodes))) ? 0 : +strength(nodes[i], i, nodes);
halfX[i] = x0z[i] + (x1z[i] - x0z[i])/2,
halfY[i] = y0z[i] + (y1z[i] - y0z[i])/2;
borderz[i] = +border(nodes[i], i, nodes);
}
}
force.initialize = function(_) {
nodes = _;
initialize();
};
force.x0 = function(_) {
return arguments.length ? (x0 = typeof _ === "function" ? _ : constant(+_), initialize(), force) : x0;
};
force.x1 = function(_) {
return arguments.length ? (x1 = typeof _ === "function" ? _ : constant(+_), initialize(), force) : x1;
};
force.y0 = function(_) {
return arguments.length ? (y0 = typeof _ === "function" ? _ : constant(+_), initialize(), force) : y0;
};
force.y1 = function(_) {
return arguments.length ? (y1 = typeof _ === "function" ? _ : constant(+_), initialize(), force) : y1;
};
force.strength = function(_) {
return arguments.length ? (strength = typeof _ === "function" ? _ : constant(+_), initialize(), force) : strength;
};
force.border = function(_) {
return arguments.length ? (border = typeof _ === "function" ? _ : constant(+_), initialize(), force) : border;
};
force.hardBoundary = function(_) {
return arguments.length ? (hardBoundary = _, force) : hardBoundary;
};
return force;
}
/**
* forceTransport - A D3 force that transports nodes to stay within bounds
* by sorting them and distributing them evenly within the extent.
*
* Original source: https://observablehq.com/d/21d2053b3bc85bce
* Implementation discussion: https://github.com/d3/d3-force/issues/89
*
* @param {Array} extent - [[x0, y0], [x1, y1]] bounding box
* @param {number} margin - Margin from the extent edges (default: 0)
* @param {number} strength - Force strength multiplier (default: 1)
* @returns {Function} D3 force function
*/
function forceTransport(extent, margin, strength) {
let nodes;
if (extent === undefined)
extent = [
[0, 0],
[960, 500],
];
if (margin === undefined) margin = 0;
if (strength === undefined) strength = 1;
const X = d3__namespace
.scaleLinear()
.range([extent[0][0] + margin, extent[1][0] - margin]);
const Y = d3__namespace
.scaleLinear()
.range([extent[0][1] + margin, extent[1][1] - margin]);
let indices = [];
function force(alpha) {
if (indices.length !== nodes.length) {
indices = Uint32Array.from(d3__namespace.range(nodes.length));
X.domain([-1, nodes.length]);
Y.domain([-1, nodes.length]);
}
// Sort nodes by x position and distribute them evenly
indices.sort((i, j) => nodes[i].x - nodes[j].x);
for (let i = 0; i < nodes.length; ++i) {
const node = nodes[indices[i]];
const target = X(i);
node.vx += (target - node.x) * strength;
}
// Sort nodes by y position and distribute them evenly
indices.sort((i, j) => nodes[i].y - nodes[j].y);
for (let i = 0; i < nodes.length; ++i) {
const node = nodes[indices[i]];
const target = Y(i);
node.vy += (target - node.y) * strength * alpha;
}
}
force.initialize = function (_) {
nodes = _;
};
force.extent = function (_) {
return arguments.length ? ((extent = _), force) : extent;
};
force.margin = function (_) {
return arguments.length ? ((margin = +_), force) : margin;
};
force.strength = function (_) {
return arguments.length ? ((strength = +_), force) : strength;
};
return force;
}
/**
* forceExtent - A D3 force that clamps nodes to stay within bounds
*
* Original source: https://observablehq.com/d/21d2053b3bc85bce
* Implementation discussion: https://github.com/d3/d3-force/issues/89
*
* @param {Array} extent - [[x0, y0], [x1, y1]] bounding box
* @returns {Function} D3 force function
*/
function forceExtent(extent) {
let nodes;
if (extent === undefined)
extent = [
[0, 0],
[960, 500],
];
function clamp(x, min, max) {
return Math.max(min, Math.min(max, x));
}
function force() {
for (let i = 0; i < nodes.length; ++i) {
const node = nodes[i];
const r = node.radius || 0;
node.x = clamp(node.x, extent[0][0] - r, extent[1][0] + r);
node.y = clamp(node.y, extent[0][1] - r, extent[1][1] + r);
}
}
force.initialize = function (_) {
nodes = _;
};
force.extent = function (_) {
return arguments.length ? ((extent = _), force) : extent;
};
return force;
}
/**
* Force Edge Bundling
*
* Bundles edges together using a force simulation.
*
* Original source: https://observablehq.com/@john-guerra/force-edge-bundling
* Adapted from: https://github.com/upphiminn/d3.ForceBundle
*
* LICENSE: GNU General Public License v2
* Copyright (C) John Alexis Guerra Gómez
*
* This is an adaptation of the Force Directed Edge Bundling d3 plugin to work
* with ES6 and modern D3, with bug fixes from:
* https://github.com/upphiminn/d3.ForceBundle/pull/11
*
* Note: Make sure no two nodes have the same x,y coordinates
*/
/**
* ForceEdgeBundling - Core edge bundling algorithm
* @returns {Function} Edge bundling force function
*/
function ForceEdgeBundling() {
let data_nodes = {}, // {'nodeid':{'x':,'y':},..}
data_edges = [], // [{'source':'nodeid1', 'target':'nodeid2'},..]
compatibility_list_for_edge = [],
subdivision_points_for_edge = [],
K = 0.1, // global bundling constant controlling edge stiffness
S_initial = 0.1, // init. distance to move points
P_initial = 1, // init. subdivision number
P_rate = 2, // subdivision rate increase
C = 6, // number of cycles to perform
I_initial = 90, // init. number of iterations for cycle
I_rate = 0.6666667, // rate at which iteration number decreases i.e. 2/3
compatibility_threshold = 0.6,
eps = 1e-6,
P = null;
/*** Geometry Helper Methods ***/
function vector_dot_product(p, q) {
return p.x * q.x + p.y * q.y;
}
function edge_as_vector(P) {
return {
x: data_nodes[P.target].x - data_nodes[P.source].x,
y: data_nodes[P.target].y - data_nodes[P.source].y,
};
}
function edge_length(e) {
// handling nodes that are on the same location, so that K/edge_length != Inf
if (
Math.abs(data_nodes[e.source].x - data_nodes[e.target].x) < eps &&
Math.abs(data_nodes[e.source].y - data_nodes[e.target].y) < eps
) {
return eps;
}
return Math.sqrt(
Math.pow(data_nodes[e.source].x - data_nodes[e.target].x, 2) +
Math.pow(data_nodes[e.source].y - data_nodes[e.target].y, 2)
);
}
function custom_edge_length(e) {
return Math.sqrt(
Math.pow(e.source.x - e.target.x, 2) +
Math.pow(e.source.y - e.target.y, 2)
);
}
function edge_midpoint(e) {
let middle_x = (data_nodes[e.source].x + data_nodes[e.target].x) / 2.0;
let middle_y = (data_nodes[e.source].y + data_nodes[e.target].y) / 2.0;
return {
x: middle_x,
y: middle_y,
};
}
function compute_divided_edge_length(e_idx) {
let length = 0;
for (let i = 1; i < subdivision_points_for_edge[e_idx].length; i++) {
let segment_length = euclidean_distance(
subdivision_points_for_edge[e_idx][i],
subdivision_points_for_edge[e_idx][i - 1]
);
length += segment_length;
}
return length;
}
function euclidean_distance(p, q) {
return Math.sqrt(Math.pow(p.x - q.x, 2) + Math.pow(p.y - q.y, 2));
}
function project_point_on_line(p, Q) {
let L = Math.sqrt(
(Q.target.x - Q.source.x) * (Q.target.x - Q.source.x) +
(Q.target.y - Q.source.y) * (Q.target.y - Q.source.y)
);
let r =
((Q.source.y - p.y) * (Q.source.y - Q.target.y) -
(Q.source.x - p.x) * (Q.target.x - Q.source.x)) /
(L * L);
return {
x: Q.source.x + r * (Q.target.x - Q.source.x),
y: Q.source.y + r * (Q.target.y - Q.source.y),
};
}
/*** Initialization Methods ***/
function initialize_edge_subdivisions() {
for (let i = 0; i < data_edges.length; i++) {
{
subdivision_points_for_edge[i] = []; //0 subdivisions
}
}
}
function initialize_compatibility_lists() {
for (let i = 0; i < data_edges.length; i++) {
compatibility_list_for_edge[i] = []; //0 compatible edges.
}
}
function filter_self_loops(edgelist) {
let filtered_edge_list = [];
for (let e = 0; e < edgelist.length; e++) {
if (
data_nodes[edgelist[e].source].x != data_nodes[edgelist[e].target].x ||
data_nodes[edgelist[e].source].y != data_nodes[edgelist[e].target].y
) {
//or smaller than eps
filtered_edge_list.push(edgelist[e]);
}
}
return filtered_edge_list;
}
/*** Force Calculation Methods ***/
function apply_spring_force(e_idx, i, kP) {
let prev = subdivision_points_for_edge[e_idx][i - 1];
let next = subdivision_points_for_edge[e_idx][i + 1];
let crnt = subdivision_points_for_edge[e_idx][i];
let x = prev.x - crnt.x + next.x - crnt.x;
let y = prev.y - crnt.y + next.y - crnt.y;
x *= kP;
y *= kP;
return {
x: x,
y: y,
};
}
function apply_electrostatic_force(e_idx, i) {
let sum_of_forces = {
x: 0,
y: 0,
};
let compatible_edges_list = compatibility_list_for_edge[e_idx];
for (let oe = 0; oe < compatible_edges_list.length; oe++) {
let force = {
x:
subdivision_points_for_edge[compatible_edges_list[oe]][i].x -
subdivision_points_for_edge[e_idx][i].x,
y:
subdivision_points_for_edge[compatible_edges_list[oe]][i].y -
subdivision_points_for_edge[e_idx][i].y,
};
if (Math.abs(force.x) > eps || Math.abs(force.y) > eps) {
let diff =
1 /
Math.pow(
custom_edge_length({
source: subdivision_points_for_edge[compatible_edges_list[oe]][i],
target: subdivision_points_for_edge[e_idx][i],
}),
1
);
sum_of_forces.x += force.x * diff;
sum_of_forces.y += force.y * diff;
}
}
return sum_of_forces;
}
function apply_resulting_forces_on_subdivision_points(e_idx, P, S) {
let kP = K / (edge_length(data_edges[e_idx]) * (P + 1)); // kP=K/|P|(number of segments), where |P| is the initial length of edge P.
// (length * (num of sub division pts - 1))
let resulting_forces_for_subdivision_points = [
{
x: 0,
y: 0,
},
];
for (let i = 1; i < P + 1; i++) {
// exclude initial end points of the edge 0 and P+1
let resulting_force = {
x: 0,
y: 0,
};
let spring_force = apply_spring_force(e_idx, i, kP);
let electrostatic_force = apply_electrostatic_force(e_idx, i);
resulting_force.x = S * (spring_force.x + electrostatic_force.x);
resulting_force.y = S * (spring_force.y + electrostatic_force.y);
resulting_forces_for_subdivision_points.push(resulting_force);
}
resulting_forces_for_subdivision_points.push({
x: 0,
y: 0,
});
return resulting_forces_for_subdivision_points;
}
/*** Edge Division Calculation Methods ***/
function update_edge_divisions(P) {
for (let e_idx = 0; e_idx < data_edges.length; e_idx++) {
if (P === 1) {
subdivision_points_for_edge[e_idx].push(
data_nodes[data_edges[e_idx].source]
); // source
subdivision_points_for_edge[e_idx].push(
edge_midpoint(data_edges[e_idx])
); // mid point
subdivision_points_for_edge[e_idx].push(
data_nodes[data_edges[e_idx].target]
); // target
} else {
let divided_edge_length = compute_divided_edge_length(e_idx);
let segment_length = divided_edge_length / (P + 1);
let current_segment_length = segment_length;
let new_subdivision_points = [];
new_subdivision_points.push(data_nodes[data_edges[e_idx].source]); //source
for (let i = 1; i < subdivision_points_for_edge[e_idx].length; i++) {
let old_segment_length = euclidean_distance(
subdivision_points_for_edge[e_idx][i],
subdivision_points_for_edge[e_idx][i - 1]
);
while (old_segment_length > current_segment_length) {
let percent_position = current_segment_length / old_segment_length;
let new_subdivision_point_x =
subdivision_points_for_edge[e_idx][i - 1].x;
let new_subdivision_point_y =
subdivision_points_for_edge[e_idx][i - 1].y;
new_subdivision_point_x +=
percent_position *
(subdivision_points_for_edge[e_idx][i].x -
subdivision_points_for_edge[e_idx][i - 1].x);
new_subdivision_point_y +=
percent_position *
(subdivision_points_for_edge[e_idx][i].y -
subdivision_points_for_edge[e_idx][i - 1].y);
new_subdivision_points.push({
x: new_subdivision_point_x,
y: new_subdivision_point_y,
});
old_segment_length -= current_segment_length;
current_segment_length = segment_length;
}
current_segment_length -= old_segment_length;
}
new_subdivision_points.push(data_nodes[data_edges[e_idx].target]); //target
subdivision_points_for_edge[e_idx] = new_subdivision_points;
}
}
}
/*** Edge compatibility measures ***/
function angle_compatibility(P, Q) {
return Math.abs(
vector_dot_product(edge_as_vector(P), edge_as_vector(Q)) /
(edge_length(P) * edge_length(Q))
);
}
function scale_compatibility(P, Q) {
let lavg = (edge_length(P) + edge_length(Q)) / 2.0;
return (
2.0 /
(lavg / Math.min(edge_length(P), edge_length(Q)) +
Math.max(edge_length(P), edge_length(Q)) / lavg)
);
}
function position_compatibility(P, Q) {
let lavg = (edge_length(P) + edge_length(Q)) / 2.0;
let midP = {
x: (data_nodes[P.source].x + data_nodes[P.target].x) / 2.0,
y: (data_nodes[P.source].y + data_nodes[P.target].y) / 2.0,
};
let midQ = {
x: (data_nodes[Q.source].x + data_nodes[Q.target].x) / 2.0,
y: (data_nodes[Q.source].y + data_nodes[Q.target].y) / 2.0,
};
return lavg / (lavg + euclidean_distance(midP, midQ));
}
function edge_visibility(P, Q) {
let I0 = project_point_on_line(data_nodes[Q.source], {
source: data_nodes[P.source],
target: data_nodes[P.target],
});
let I1 = project_point_on_line(data_nodes[Q.target], {
source: data_nodes[P.source],
target: data_nodes[P.target],
}); //send actual edge points positions
let midI = {
x: (I0.x + I1.x) / 2.0,
y: (I0.y + I1.y) / 2.0,
};
let midP = {
x: (data_nodes[P.source].x + data_nodes[P.target].x) / 2.0,
y: (data_nodes[P.source].y + data_nodes[P.target].y) / 2.0,
};
return Math.max(
0,
1 - (2 * euclidean_distance(midP, midI)) / euclidean_distance(I0, I1)
);
}
function visibility_compatibility(P, Q) {
return Math.min(edge_visibility(P, Q), edge_visibility(Q, P));
}
function compatibility_score(P, Q) {
return (
angle_compatibility(P, Q) *
scale_compatibility(P, Q) *
position_compatibility(P, Q) *
visibility_compatibility(P, Q)
);
}
function are_compatible(P, Q) {
return compatibility_score(P, Q) >= compatibility_threshold;
}
function compute_compatibility_lists() {
for (let e = 0; e < data_edges.length - 1; e++) {
for (let oe = e + 1; oe < data_edges.length; oe++) {
// don't want any duplicates
if (are_compatible(data_edges[e], data_edges[oe])) {
compatibility_list_for_edge[e].push(oe);
compatibility_list_for_edge[oe].push(e);
}
}
}
}
/*** Main Bundling Loop Methods ***/
let forcebundle = function () {
let S = S_initial;
let I = I_initial;
let P = P_initial;
initialize_edge_subdivisions();
initialize_compatibility_lists();
update_edge_divisions(P);
compute_compatibility_lists();
for (let cycle = 0; cycle < C; cycle++) {
for (let iteration = 0; iteration < I; iteration++) {
let forces = [];
for (let edge = 0; edge < data_edges.length; edge++) {
forces[edge] = apply_resulting_forces_on_subdivision_points(
edge,
P,
S
);
}
for (let e = 0; e < data_edges.length; e++) {
for (let i = 0; i < P + 1; i++) {
subdivision_points_for_edge[e][i].x += forces[e][i].x;
subdivision_points_for_edge[e][i].y += forces[e][i].y;
}
}
}
// prepare for next cycle
S = S / 2;
P = P * P_rate;
I = I_rate * I;
update_edge_divisions(P);
}
return subdivision_points_for_edge;
};
/*** Getters/Setters Methods ***/
forcebundle.nodes = function (nl) {
if (arguments.length === 0) {
return data_nodes;
} else {
data_nodes = nl;
}
return forcebundle;
};
forcebundle.edges = function (ll) {
if (arguments.length === 0) {
return data_edges;
} else {
data_edges = filter_self_loops(ll); //remove edges to from to the same point
}
return forcebundle;
};
forcebundle.bundling_stiffness = function (k) {
if (arguments.length === 0) {
return K;
} else {
K = k;
}
return forcebundle;
};
forcebundle.step_size = function (step) {
if (arguments.length === 0) {
return S_initial;
} else {
S_initial = step;
}
return forcebundle;
};
forcebundle.cycles = function (c) {
if (arguments.length === 0) {
return C;
} else {
C = c;
}
return forcebundle;
};
forcebundle.iterations = function (i) {
if (arguments.length === 0) {
return I_initial;
} else {
I_initial = i;
}
return forcebundle;
};
forcebundle.iterations_rate = function (i) {
if (arguments.length === 0) {
return I_rate;
} else {
I_rate = i;
}
return forcebundle;
};
forcebundle.subdivision_points_seed = function (p) {
if (arguments.length == 0) {
return P;
} else {
P = p;
}
return forcebundle;
};
forcebundle.subdivision_rate = function (r) {
if (arguments.length === 0) {
return P_rate;
} else {
P_rate = r;
}
return forcebundle;
};
forcebundle.compatibility_threshold = function (t) {
if (arguments.length === 0) {
return compatibility_threshold;
} else {
compatibility_threshold = t;
}
return forcebundle;
};
return forcebundle;
}
/**
* edgeBundling - Convenience wrapper for ForceEdgeBundling
*
* @param {Object} data - {nodes, links} where nodes have x,y coords
* @param {Object} options - Configuration options
* @returns {Object} Edge bundling instance with update() method
*/
function edgeBundling(
{
nodes, // Array of nodes including x and y coords e.g. [{id: "a", x: 10, y:10}, ...]
links, // Array of links in D3 forceSimulation format e.g. [{source: "a", target: "b"}, ...]
},
{
id = (d) => d.id,
pathAttr = "path", // name of the attribute to save the paths
bundling_stiffness = 0.1, // global bundling constant controlling edge stiffness
step_size = 0.1, // init. distance to move points
subdivision_rate = 2, // subdivision rate increase
cycles = 6, // number of cycles to perform
iterations = 90, // init. number of iterations for cycle
iterations_rate = 0.6666667, // rate at which iteration number decreases i.e. 2/3
compatibility_threshold = 0.6, // "which pairs of edges should be considered compatible (default is set to 0.6, 60% compatiblity)"
} = {}
) {
// The library wants the links as the index positions in the nodes array
const dNodes = new Map(nodes.map((d, i) => [id(d), i]));
const linksIs = links.map((l) => ({
source: dNodes.get(typeof l.source === "object" ? id(l.source) : l.source),
target: dNodes.get(typeof l.target === "object" ? id(l.target) : l.target),
}));
const edgeBundling = ForceEdgeBundling()
.nodes(nodes)
.edges(linksIs)
.bundling_stiffness(bundling_stiffness)
.step_size(step_size)
.subdivision_rate(subdivision_rate)
.cycles(cycles)
.iterations(iterations)
.iterations_rate(iterations_rate)
.compatibility_threshold(compatibility_threshold);
edgeBundling.update = () => {
const paths = edgeBundling();
links.map((l, i) => (l[pathAttr] = paths[i]));
};
edgeBundling.update();
return edgeBundling;
}
/**
* sample - Samples an array to limit the number of elements
* Used for edge bundling to limit the number of links processed
*
* Original source: https://observablehq.com/@john-guerra/force-directed-graph
* From 89207a2280891f15@1859.js lines 885-890
*
* @param {Array} array - Array to sample
* @param {number} n - Maximum number of elements to return
* @returns {Array} Sampled array
*/
function sample(array, n) {
if (n >= array.length) return array;
return array.filter((d, i) => i % Math.floor(array.length / n) === 0);
}
/**
* drag - Custom drag behavior that works with zoom transforms
*
* Original source: https://observablehq.com/@john-guerra/force-directed-graph
* From 89207a2280891f15@1859.js lines 830-875
*
* @param {Object} simulation - D3 force simulation
* @param {HTMLElement} node - DOM node (SVG or Canvas element)
* @param {Object} opts - Options object with x, y scales and minDistanceForDrag
* @returns {Function} D3 drag behavior
*/
function drag(simulation, node, opts) {
function dragsubject(event) {
const transform = d3__namespace.zoomTransform(node);
let [x, y] = transform.invert([event.x, event.y]);
x = opts.x.invert(x);
y = opts.y.invert(y);
let subject = simulation.find(x, y);
let d = Math.hypot(x - subject.x, y - subject.y);
return d < opts.minDistanceForDrag
? {
circle: subject,
x: transform.applyX(opts.x(subject.x)),
y: transform.applyY(opts.y(subject.y)),
}
: null;
}
function dragstarted(event) {
if (!event.active) simulation.alphaTarget(0.3).restart();
event.subject.circle.fx = event.subject.circle.x;
event.subject.circle.fy = event.subject.circle.y;
}
function dragged(event) {
const transform = d3__namespace.zoomTransform(node);
event.subject.circle.fx = opts.x.invert(transform.invertX(event.x));
event.subject.circle.fy = opts.y.invert(transform.invertY(event.y));
}
function dragended(event) {
if (!event.active) simulation.alphaTarget(0);
event.subject.circle.fx = null;
event.subject.circle.fy = null;
}
return d3__namespace
.drag()
.subject(dragsubject)
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended);
}
/**
* computeAutoFit - Adjusts x/y scales to fit nodes in viewport
* with optional aspect ratio preservation
*
* Original source: https://observablehq.com/@john-guerra/force-directed-graph
* From 89207a2280891f15@1859.js lines 979-1022
*
* @param {Object} opts - Options object with nodes, x, y scales, width, height, autoFit, keepAspectRatio
*/
function computeAutoFit(opts) {
if (opts.autoFit) {
const yExtent = d3__namespace.extent(opts.nodes, (d) => d.y);
const xExtent = d3__namespace.extent(opts.nodes, (d) => d.x);
opts.x.domain(d3__namespace.extent(opts.nodes, (d) => d.x));
opts.y.domain(d3__namespace.extent(opts.nodes, (d) => d.y));
if (opts.keepAspectRatio) {
const ratio = opts.width / opts.height;
const newRatio = (xExtent[1] - xExtent[0]) / (yExtent[1] - yExtent[0]);
if (newRatio < ratio) {
// Adjust x axis to fit
const d =
(opts.width / opts.height) * (yExtent[1] - yExtent[0]) -
(xExtent[1] - xExtent[0]);
opts.x.domain([xExtent[0] - d / 2, xExtent[1] + d / 2]);
opts.y.domain(yExtent);
} else {
// Adjust y axis to fit
const d =
(opts.height / opts.width) * (xExtent[1] - xExtent[0]) -
(yExtent[1] - yExtent[0]);
opts.y.domain([yExtent[0] - d / 2, yExtent[1] + d / 2]);
opts.x.domain(xExtent);
}
}
}
}
/**
* applyTransform - Applies zoom transform to node/link coordinates
*
* Original source: https://observablehq.com/@john-guerra/force-directed-graph
* From 89207a2280891f15@1859.js lines 823-828
*
* @param {Object} d - Node or point with x, y coordinates
* @param {Object} transform - D3 zoom transform
* @param {Object} opts - Options object with x, y scales
* @returns {Object} Transformed point with x, y
*/
function applyTransform(d, transform, opts) {
const [x, y] = transform.apply([opts.x(d.x), opts.y(d.y)]);
return { ...d, x, y };
}
// smart-labels v0.0.11 Copyright (c) 2024 John Alexis Guerra Gómez
// Based on https://observablehq.com/@d3/voronoi-labels by Mike Bostock
// ISC License
// Copyright 2018–2023 Observable, Inc.
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
function smartLabels(
data,
{
x = (d) => d[0], // x coordinate accessor, expected to be in pixels
y = (d) => d[1], // y coordinate accessor, expected to be in pixels
r = () => 3, // radius accessor, expected to be in pixels
label = (d, i) => i, // Accessor for the label
fill = "#333", // label fill color
stroke = "white", // label stroke color
width = null,
height = null,
target = null, // Where do you want it to draw
renderer = "svg", // canvas or svg
font = () => "10pt sans-serif",
hover = true, // Show label of the hovered point
onHover = (i) => i, // callback when hovered, will pass the index of the selected element
hoverFont = () => "bolder 12pt sans-serif",
labelsInCentroids = true,
threshold = 2000, // Areas over this size would get labels
alwaysShow = (d) => false, // If returns true for the node, it will always show the label
showLabel = (d, cell) => alwaysShow(d) || -d3__namespace.polygonArea(cell) > threshold, // If true, show the label
backgroundFill = "#fefefe01", // What to paint the bg rect of the labels. Needed for the onHover
strokeWidth = 5,
showVoronoi = false,
voronoiStroke = "#ccc",
showAnchors = false,
anchorsStroke = "orange",
anchorsFill = "none",
useOcclusion = true,
occludedStyle = "opacity: 0.2", // css style rules to be used on occluded labels
// For debugging
showPoints = false,
pointsFill = "#ccc",
pointsSelectedFill = "firebrick",
pointsStroke = "#ccc",
debug = false,
selected = null,
padding = 3, // label padding in pixels
} = {}
) {
if (!data || data?.length === 0) {
console.log("smartLabels: No data to render");
return target;
}
data = data.filter(
(d, index) =>
x(d, index) !== undefined &&
x(d, index) !== null &&
y(d, index) !== undefined &&
y(d, index) !== null
);
if (typeof font === "string") font = () => font;
if (typeof hoverFont === "string") hoverFont = () => hoverFont;
let xExtent = d3__namespace.extent(data, x),
yExtent = d3__namespace.extent(data, y);
width = width || xExtent[1] - xExtent[0];
height = height || yExtent[1] - yExtent[0];
if (debug) console.log("✅ smartLabels renderer", renderer);
function checkIfTargetMatchesRenderer(target, renderer) {
if (target && target.node) {
target = target.node();
}
if (renderer.toLocaleLowerCase() === "canvas") {
return target instanceof HTMLCanvasElement;
} else {
return target instanceof SVGElement;
}
}
if (target && !checkIfTargetMatchesRenderer(target, renderer)) {
if (debug)
console.log(
"❌ smartLabels Target doesn't match the renderer",
target,
renderer
);
throw new Error(
"Smartlabels Target doesn't match the renderer",
target,
renderer
);
}
// Try to reuse the target
if (renderer.toLocaleLowerCase() === "canvas") {
if (target) {
target = d3__namespace.select(target.node ? target.node() : target);
} else {
target = d3__namespace.create("canvas").attr("width", width).attr("height", height);
}
useOcclusion = false;
} else {
if (target) {
target = d3__namespace.select(target.node ? target.node() : target);
} else {
target = d3__namespace.create("svg").attr("viewBox", [0, 0, width, height]);
}
target = target || d3__namespace.create("svg").attr("viewBox", [0, 0, width, height]);
}
const delaunay = d3__namespace.Delaunay.from(data, x, y);
const voronoi = delaunay.voronoi([
xExtent[0] - 1,
yExtent[0] - 1,
xExtent[1] + 1,
yExtent[1] + 1,
]);
let cells = data.map((d, i) => [d, voronoi.cellPolygon(i)]);
// Replace null cells with the nearest one
cells = cells
.map(([d, cell], index) => [d, getNearestCell(d, cell, index)])
.map(([d, cell]) => ({ d, cell, show: showLabel(d, cell) }));
// cells can be null when we have duplicated coords
// https://github.com/d3/d3-delaunay/issues/106
function getNearestCell(d, cell, index) {
if (!cell) {
const i = delaunay.find(x(d, index), y(d, index));
if (i === -1) {
console.log("couldn't find cell", i, d, x(d, index), y(d, index));
return null;
}
cell = cells[i][1];
}
return cell;
}
function renderSVG() {
let anchors = null;
if (useOcclusion) {
target
.selectAll("style.smartLabels")
.data([0])
.join("style")
.attr("class", "smartLabels").html(`
svg g.labels > text.occluded:not(.selected) { ${occludedStyle} }
`);
}
const mouseRect = target
.selectAll("rect.smartLabels")
.data([0])
.join("rect")
.attr("class", "smartLabels")
.attr("width", width)
.attr("height", height)
.attr("fill", backgroundFill)
.attr("stroke", "none");
const orient = {
top: (text) =>
text
.attr("text-anchor", "middle")
.attr("y", (d, i) => -(r(d, i) + padding)),
right: (text) =>
text
.attr("text-anchor", "start")
.attr("dy", "0.35em")
.attr("x", (d, i) => r(d, i) + padding),
bottom: (text) =>
text
.attr("text-anchor", "middle")
.attr("dy", "0.71em")
.attr("y", (d, i) => r(d, i) + padding),
left: (text) =>
text
.attr("text-anchor", "end")
.attr("dy", "0.35em")
.attr("x", (d, i) => -(r(d, i) + padding)),
};
if (showAnchors || labelsInCentroids) {
anchors = target
.selectAll("g#anchors")
.data([cells])
.join("g")
.attr("pointer-events", "none")
.attr("id", "anchors")
.attr("stroke", anchorsStroke)
.attr("fill", anchorsFill)
.selectAll("path.anchor")
.data((d) => d)
.join("path")
.attr("class", "anchor")
.attr("display", ({ show }) => (show ? null : "none"))
.attr("d", ({ d, cell }, index) =>
cell
? `M${d3__namespace.polygonCentroid(cell)}L${x(d, index)},${y(d, index)}`
: null
);
}
if (showVoronoi) {
target
.selectAll("path#voronoi")
.data([1])
.join("path")
.attr("id", "voronoi")
.attr("stroke", voronoiStroke)
.attr("pointer-events", "none")
.attr("d", voronoi.render());
}
let points;
if (showPoints) {
points = target
.selectAll("circle#points")
.data([1])
.join("path")
.attr("id", "points")
.attr("pointer-events", "none")
.attr("stroke", pointsStroke)
.attr("fill", pointsFill)
.attr("d", delaunay.renderPoints(null, 2));
}
const labels = target
.selectAll("g.labels")
.data([cells])
.join("g")
.attr("class", "labels")
.attr("fill", fill)
.attr("stroke", stroke)
.attr("stroke-width", strokeWidth)
.attr("paint-order", "stroke")
.attr("pointer-events", "none")
.selectAll("text")
.data((d) => d)
.join("text")
.style("font", ({d}, i) => font(d, i))
.each(function ({ d, cell }, index) {
if (!cell) return;
const [cx, cy] = d3__namespace.polygonCentroid(cell);
const angle =
(Math.round(
(Math.atan2(cy - y(d, index), cx - x(d, index)) / Math.PI) * 2
) +
4) %
4;
d3__namespace.select(this).call(
angle === 0
? orient.right
: angle === 3
? orient.top
: angle === 1
? orient.bottom
: orient.left
);
})
.attr("transform", ({ d, cell }, index) => {
if (labelsInCentroids)