@graphty/layout
Version:
graph layout algorithms based on networkx
340 lines • 14.4 kB
JavaScript
import { getNodesFromGraph } from '../../utils/graph';
import { _processParams } from '../../utils/params';
import { rescaleLayout } from '../../utils/rescale';
import { RandomNumberGenerator } from '../../utils/random';
/**
* Position nodes using the ForceAtlas2 force-directed algorithm.
*
* @param G - Graph
* @param pos - Initial positions for nodes
* @param maxIter - Maximum number of iterations
* @param jitterTolerance - Controls tolerance for node speed adjustments
* @param scalingRatio - Scaling of attraction and repulsion forces
* @param gravity - Attraction to center to prevent disconnected components from drifting
* @param distributedAction - Distributes attraction force among nodes
* @param strongGravity - Uses a stronger gravity model
* @param nodeMass - Dictionary mapping nodes to their masses
* @param nodeSize - Dictionary mapping nodes to their sizes
* @param weight - Edge attribute for weight
* @param dissuadeHubs - Whether to prevent hub nodes from clustering
* @param linlog - Whether to use logarithmic attraction
* @param seed - Random seed for initial positions
* @param dim - Dimension of layout
* @returns Positions dictionary keyed by node
*/
export function forceatlas2Layout(G, pos = null, maxIter = 100, jitterTolerance = 1.0, scalingRatio = 2.0, gravity = 1.0, distributedAction = false, strongGravity = false, nodeMass = null, nodeSize = null, weight = null, dissuadeHubs = false, linlog = false, seed = null, dim = 2) {
const processed = _processParams(G, null, dim);
const graph = processed.G;
const nodes = getNodesFromGraph(graph);
if (nodes.length === 0) {
return {};
}
// Initialize random number generator
const rng = new RandomNumberGenerator(seed ?? undefined);
// Initialize positions if not provided
let posArray;
if (pos === null) {
pos = {};
posArray = new Array(nodes.length);
for (let i = 0; i < nodes.length; i++) {
posArray[i] = Array(dim).fill(0).map(() => rng.rand() * 2 - 1);
pos[nodes[i]] = posArray[i];
}
}
else if (Object.keys(pos).length === nodes.length) {
// Use provided positions
posArray = new Array(nodes.length);
for (let i = 0; i < nodes.length; i++) {
const nodePos = pos[nodes[i]];
posArray[i] = new Array(dim);
for (let d = 0; d < dim; d++) {
posArray[i][d] = d < nodePos.length ? nodePos[d] : (rng.rand() * 2 - 1);
}
}
}
else {
// Some nodes don't have positions, initialize within the range of existing positions
let minPos = Array(dim).fill(Number.POSITIVE_INFINITY);
let maxPos = Array(dim).fill(Number.NEGATIVE_INFINITY);
// Find min and max of existing positions
for (const node in pos) {
const nodePos = pos[node];
for (let d = 0; d < dim; d++) {
if (d < nodePos.length) {
minPos[d] = Math.min(minPos[d], nodePos[d]);
maxPos[d] = Math.max(maxPos[d], nodePos[d]);
}
}
}
// If min/max are still infinity for some dimensions, use default range
for (let d = 0; d < dim; d++) {
if (!isFinite(minPos[d]) || !isFinite(maxPos[d])) {
minPos[d] = -1;
maxPos[d] = 1;
}
}
posArray = new Array(nodes.length);
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if (pos[node]) {
const nodePos = pos[node];
posArray[i] = new Array(dim);
for (let d = 0; d < dim; d++) {
posArray[i][d] = d < nodePos.length ? nodePos[d] : (rng.rand() * 2 - 1);
}
}
else {
posArray[i] = Array(dim).fill(0).map((_, d) => minPos[d] + rng.rand() * (maxPos[d] - minPos[d]));
pos[node] = posArray[i];
}
}
}
// Initialize mass and size arrays
const mass = new Array(nodes.length).fill(0);
const size = new Array(nodes.length).fill(0);
// Flag to track whether to adjust for node sizes
const adjustSizes = nodeSize !== null;
// Set node masses and sizes
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
mass[i] = nodeMass && nodeMass[node] ?
nodeMass[node] :
(Array.isArray(graph) ? 1 : getNodeDegree(graph, node) + 1);
size[i] = nodeSize && nodeSize[node] ? nodeSize[node] : 1;
}
// Create adjacency matrix
const n = nodes.length;
const A = Array(n).fill(0).map(() => Array(n).fill(0));
// Populate adjacency matrix with edge weights
const edges = Array.isArray(graph) ? [] : graph.edges();
const nodeIndices = {};
nodes.forEach((node, i) => { nodeIndices[node] = i; });
for (const [source, target] of edges) {
const i = nodeIndices[source];
const j = nodeIndices[target];
// Use edge weight if provided, otherwise default to 1
let edgeWeight = 1;
if (weight && !Array.isArray(graph) && graph.getEdgeData) {
edgeWeight = graph.getEdgeData(source, target, weight) || 1;
}
A[i][j] = edgeWeight;
A[j][i] = edgeWeight; // For undirected graphs
}
// Initialize force arrays
const gravities = Array(n).fill(0).map(() => Array(dim).fill(0));
const attraction = Array(n).fill(0).map(() => Array(dim).fill(0));
const repulsion = Array(n).fill(0).map(() => Array(dim).fill(0));
// Simulation parameters
let speed = 1;
let speedEfficiency = 1;
let swing = 1;
let traction = 1;
// Helper function to estimate factor for force scaling
function estimateFactor(n, swing, traction, speed, speedEfficiency, jitterTolerance) {
// Optimal jitter parameters
const optJitter = 0.05 * Math.sqrt(n);
const minJitter = Math.sqrt(optJitter);
const maxJitter = 10;
const minSpeedEfficiency = 0.05;
// Estimate jitter based on current state
const other = Math.min(maxJitter, optJitter * traction / (n * n));
let jitter = jitterTolerance * Math.max(minJitter, other);
// Adjust speed efficiency based on swing/traction ratio
if (swing / traction > 2.0) {
if (speedEfficiency > minSpeedEfficiency) {
speedEfficiency *= 0.5;
}
jitter = Math.max(jitter, jitterTolerance);
}
// Calculate target speed
let targetSpeed = swing === 0 ?
Number.POSITIVE_INFINITY :
jitter * speedEfficiency * traction / swing;
// Further adjust speed efficiency
if (swing > jitter * traction) {
if (speedEfficiency > minSpeedEfficiency) {
speedEfficiency *= 0.7;
}
}
else if (speed < 1000) {
speedEfficiency *= 1.3;
}
// Limit the speed increase
const maxRise = 0.5;
speed = speed + Math.min(targetSpeed - speed, maxRise * speed);
return [speed, speedEfficiency];
}
// Main simulation loop
for (let iter = 0; iter < maxIter; iter++) {
// Reset forces for this iteration
for (let i = 0; i < n; i++) {
for (let d = 0; d < dim; d++) {
attraction[i][d] = 0;
repulsion[i][d] = 0;
gravities[i][d] = 0;
}
}
// Compute pairwise differences and distances
const diff = Array(n).fill(0).map(() => Array(n).fill(0).map(() => Array(dim).fill(0)));
const distance = Array(n).fill(0).map(() => Array(n).fill(0));
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
if (i === j)
continue;
for (let d = 0; d < dim; d++) {
diff[i][j][d] = posArray[i][d] - posArray[j][d];
}
distance[i][j] = Math.sqrt(diff[i][j].reduce((sum, d) => sum + d * d, 0));
// Prevent division by zero
if (distance[i][j] < 0.01)
distance[i][j] = 0.01;
}
}
// Calculate attraction forces
if (linlog) {
// Logarithmic attraction model
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
if (i === j || A[i][j] === 0)
continue;
const dist = distance[i][j];
const factor = -Math.log(1 + dist) / dist * A[i][j];
for (let d = 0; d < dim; d++) {
const force = factor * diff[i][j][d];
attraction[i][d] += force;
}
}
}
}
else {
// Linear attraction model
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
if (i === j || A[i][j] === 0)
continue;
for (let d = 0; d < dim; d++) {
const force = -diff[i][j][d] * A[i][j];
attraction[i][d] += force;
}
}
}
}
// Apply distributed attraction if enabled
if (distributedAction) {
for (let i = 0; i < n; i++) {
for (let d = 0; d < dim; d++) {
attraction[i][d] /= mass[i];
}
}
}
// Calculate repulsion forces
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
if (i === j)
continue;
let dist = distance[i][j];
// Adjust distance for node sizes if needed
if (adjustSizes) {
dist -= size[i] - size[j];
dist = Math.max(dist, 0.01); // Prevent negative or zero distances
}
const distSquared = dist * dist;
const massProduct = mass[i] * mass[j];
const factor = (massProduct / distSquared) * scalingRatio;
for (let d = 0; d < dim; d++) {
const direction = diff[i][j][d] / dist;
repulsion[i][d] += direction * factor;
}
}
}
// Calculate gravity forces
// First find the center of mass
const centerOfMass = Array(dim).fill(0);
for (let i = 0; i < n; i++) {
for (let d = 0; d < dim; d++) {
centerOfMass[d] += posArray[i][d] / n;
}
}
for (let i = 0; i < n; i++) {
const posCentered = Array(dim);
for (let d = 0; d < dim; d++) {
posCentered[d] = posArray[i][d] - centerOfMass[d];
}
if (strongGravity) {
// Strong gravity model
for (let d = 0; d < dim; d++) {
gravities[i][d] = -gravity * mass[i] * posCentered[d];
}
}
else {
// Regular gravity model
const dist = Math.sqrt(posCentered.reduce((sum, val) => sum + val * val, 0));
if (dist > 0.01) {
for (let d = 0; d < dim; d++) {
const direction = posCentered[d] / dist;
gravities[i][d] = -gravity * mass[i] * direction;
}
}
}
}
// Calculate total forces and update positions
const update = Array(n).fill(0).map(() => Array(dim).fill(0));
let totalSwing = 0;
let totalTraction = 0;
for (let i = 0; i < n; i++) {
for (let d = 0; d < dim; d++) {
update[i][d] = attraction[i][d] + repulsion[i][d] + gravities[i][d];
}
// Calculate swing and traction for this node
const oldPos = [...posArray[i]];
const newPos = oldPos.map((p, d) => p + update[i][d]);
const swingVector = oldPos.map((p, d) => p - newPos[d]);
const tractionVector = oldPos.map((p, d) => p + newPos[d]);
const swingMagnitude = Math.sqrt(swingVector.reduce((sum, val) => sum + val * val, 0));
const tractionMagnitude = Math.sqrt(tractionVector.reduce((sum, val) => sum + val * val, 0));
totalSwing += mass[i] * swingMagnitude;
totalTraction += 0.5 * mass[i] * tractionMagnitude;
}
// Update speed and efficiency
[speed, speedEfficiency] = estimateFactor(n, totalSwing, totalTraction, speed, speedEfficiency, jitterTolerance);
// Apply forces to update positions
let totalMovement = 0;
for (let i = 0; i < n; i++) {
let factor;
if (adjustSizes) {
// Calculate displacement magnitude
const df = Math.sqrt(update[i].reduce((sum, val) => sum + val * val, 0));
const swinging = mass[i] * df;
// Determine scaling factor with size adjustments
factor = 0.1 * speed / (1 + Math.sqrt(speed * swinging));
factor = Math.min(factor * df, 10) / df;
}
else {
// Standard scaling factor
const swinging = mass[i] * Math.sqrt(update[i].reduce((sum, val) => sum + val * val, 0));
factor = speed / (1 + Math.sqrt(speed * swinging));
}
// Apply factor to update position
for (let d = 0; d < dim; d++) {
const movement = update[i][d] * factor;
posArray[i][d] += movement;
totalMovement += Math.abs(movement);
}
}
// Check for convergence
if (totalMovement < 1e-10) {
break;
}
}
// Create position dictionary
const positions = {};
for (let i = 0; i < n; i++) {
positions[nodes[i]] = posArray[i];
}
return rescaleLayout(positions);
}
// Helper function to get node degree
function getNodeDegree(graph, node) {
return graph.edges().filter((edge) => edge[0] === node || edge[1] === node).length;
}
//# sourceMappingURL=forceatlas2.js.map