UNPKG

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
'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)