You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
510 lines
20 KiB
510 lines
20 KiB
import { __awaiter } from "tslib";
|
|
import { Graph as GGraph } from '@antv/graphlib';
|
|
import { isNumber } from '@antv/util';
|
|
import { cloneFormatData, formatNodeSizeToNumber } from '../util';
|
|
import { handleSingleNodeGraph } from '../util/common';
|
|
import Body from './body';
|
|
import Quad from './quad';
|
|
import QuadTree from './quad-tree';
|
|
const DEFAULTS_LAYOUT_OPTIONS = {
|
|
center: [0, 0],
|
|
width: 300,
|
|
height: 300,
|
|
kr: 5,
|
|
kg: 1,
|
|
mode: 'normal',
|
|
preventOverlap: false,
|
|
dissuadeHubs: false,
|
|
maxIteration: 0,
|
|
ks: 0.1,
|
|
ksmax: 10,
|
|
tao: 0.1,
|
|
};
|
|
/**
|
|
* <zh/> Atlas2 力导向布局
|
|
*
|
|
* <en/> Force Atlas 2 layout
|
|
*/
|
|
export class ForceAtlas2Layout {
|
|
constructor(options = {}) {
|
|
this.options = options;
|
|
this.id = 'forceAtlas2';
|
|
this.options = Object.assign(Object.assign({}, DEFAULTS_LAYOUT_OPTIONS), options);
|
|
}
|
|
/**
|
|
* Return the positions of nodes and edges(if needed).
|
|
*/
|
|
execute(graph, options) {
|
|
return __awaiter(this, void 0, void 0, function* () {
|
|
return this.genericForceAtlas2Layout(false, graph, options);
|
|
});
|
|
}
|
|
/**
|
|
* To directly assign the positions to the nodes.
|
|
*/
|
|
assign(graph, options) {
|
|
return __awaiter(this, void 0, void 0, function* () {
|
|
yield this.genericForceAtlas2Layout(true, graph, options);
|
|
});
|
|
}
|
|
genericForceAtlas2Layout(assign, graph, options) {
|
|
return __awaiter(this, void 0, void 0, function* () {
|
|
const edges = graph.getAllEdges();
|
|
const nodes = graph.getAllNodes();
|
|
const mergedOptions = this.formatOptions(options, nodes.length);
|
|
const { width, height, prune, maxIteration, nodeSize, center } = mergedOptions;
|
|
if (!(nodes === null || nodes === void 0 ? void 0 : nodes.length) || nodes.length === 1) {
|
|
return handleSingleNodeGraph(graph, assign, center);
|
|
}
|
|
const calcNodes = nodes.map((node) => cloneFormatData(node, [width, height]));
|
|
const calcEdges = edges.filter((edge) => {
|
|
const { source, target } = edge;
|
|
return source !== target;
|
|
});
|
|
const calcGraph = new GGraph({
|
|
nodes: calcNodes,
|
|
edges: calcEdges,
|
|
});
|
|
const sizes = this.getSizes(calcGraph, nodeSize);
|
|
this.run(calcGraph, graph, maxIteration, sizes, assign, mergedOptions);
|
|
// if prune, place the leaves around their parents, and then re-layout for several iterations.
|
|
if (prune) {
|
|
for (let j = 0; j < calcEdges.length; j += 1) {
|
|
const { source, target } = calcEdges[j];
|
|
const sourceDegree = calcGraph.getDegree(source);
|
|
const targetDegree = calcGraph.getDegree(source);
|
|
if (sourceDegree <= 1) {
|
|
const targetNode = calcGraph.getNode(target);
|
|
calcGraph.mergeNodeData(source, {
|
|
x: targetNode.data.x,
|
|
y: targetNode.data.y,
|
|
});
|
|
}
|
|
else if (targetDegree <= 1) {
|
|
const sourceNode = calcGraph.getNode(source);
|
|
calcGraph.mergeNodeData(target, {
|
|
x: sourceNode.data.x,
|
|
y: sourceNode.data.y,
|
|
});
|
|
}
|
|
}
|
|
const postOptions = Object.assign(Object.assign({}, mergedOptions), { prune: false, barnesHut: false });
|
|
this.run(calcGraph, graph, 100, sizes, assign, postOptions);
|
|
}
|
|
return {
|
|
nodes: calcNodes,
|
|
edges,
|
|
};
|
|
});
|
|
}
|
|
/**
|
|
* Init the node positions if there is no initial positions.
|
|
* And pre-calculate the size (max of width and height) for each node.
|
|
* @param calcGraph graph for calculation
|
|
* @param nodeSize node size config from layout options
|
|
* @returns {SizeMap} node'id mapped to max of its width and height
|
|
*/
|
|
getSizes(calcGraph, nodeSize) {
|
|
const nodes = calcGraph.getAllNodes();
|
|
const sizes = {};
|
|
for (let i = 0; i < nodes.length; i += 1) {
|
|
const node = nodes[i];
|
|
sizes[node.id] = formatNodeSizeToNumber(nodeSize, undefined)(node);
|
|
}
|
|
return sizes;
|
|
}
|
|
/**
|
|
* Format the options.
|
|
* @param options input options
|
|
* @param nodeNum number of nodes
|
|
* @returns formatted options
|
|
*/
|
|
formatOptions(options = {}, nodeNum) {
|
|
const mergedOptions = Object.assign(Object.assign({}, this.options), options);
|
|
const { center, width, height, barnesHut, prune, maxIteration, kr, kg } = mergedOptions;
|
|
mergedOptions.width =
|
|
!width && typeof window !== 'undefined' ? window.innerWidth : width;
|
|
mergedOptions.height =
|
|
!height && typeof window !== 'undefined' ? window.innerHeight : height;
|
|
mergedOptions.center = !center
|
|
? [mergedOptions.width / 2, mergedOptions.height / 2]
|
|
: center;
|
|
if (barnesHut === undefined && nodeNum > 250) {
|
|
mergedOptions.barnesHut = true;
|
|
}
|
|
if (prune === undefined && nodeNum > 100)
|
|
mergedOptions.prune = true;
|
|
if (maxIteration === 0 && !prune) {
|
|
mergedOptions.maxIteration = 250;
|
|
if (nodeNum <= 200 && nodeNum > 100)
|
|
mergedOptions.maxIteration = 1000;
|
|
else if (nodeNum > 200)
|
|
mergedOptions.maxIteration = 1200;
|
|
}
|
|
else if (maxIteration === 0 && prune) {
|
|
mergedOptions.maxIteration = 100;
|
|
if (nodeNum <= 200 && nodeNum > 100)
|
|
mergedOptions.maxIteration = 500;
|
|
else if (nodeNum > 200)
|
|
mergedOptions.maxIteration = 950;
|
|
}
|
|
if (!kr) {
|
|
mergedOptions.kr = 50;
|
|
if (nodeNum > 100 && nodeNum <= 500)
|
|
mergedOptions.kr = 20;
|
|
else if (nodeNum > 500)
|
|
mergedOptions.kr = 1;
|
|
}
|
|
if (!kg) {
|
|
mergedOptions.kg = 20;
|
|
if (nodeNum > 100 && nodeNum <= 500)
|
|
mergedOptions.kg = 10;
|
|
else if (nodeNum > 500)
|
|
mergedOptions.kg = 1;
|
|
}
|
|
return mergedOptions;
|
|
}
|
|
/**
|
|
* Loops for fa2.
|
|
* @param calcGraph graph for calculation
|
|
* @param graph original graph
|
|
* @param iteration iteration number
|
|
* @param sizes nodes' size
|
|
* @param options formatted layout options
|
|
* @returns
|
|
*/
|
|
run(calcGraph, graph, iteration, sizes, assign, options) {
|
|
const { kr, barnesHut, onTick } = options;
|
|
const calcNodes = calcGraph.getAllNodes();
|
|
let sg = 0;
|
|
let iter = iteration;
|
|
const forces = {};
|
|
const preForces = {};
|
|
const bodies = {};
|
|
for (let i = 0; i < calcNodes.length; i += 1) {
|
|
const { data, id } = calcNodes[i];
|
|
forces[id] = [0, 0];
|
|
if (barnesHut) {
|
|
const params = {
|
|
id: i,
|
|
rx: data.x,
|
|
ry: data.y,
|
|
mass: 1,
|
|
g: kr,
|
|
degree: calcGraph.getDegree(id),
|
|
};
|
|
bodies[id] = new Body(params);
|
|
}
|
|
}
|
|
while (iter > 0) {
|
|
sg = this.oneStep(calcGraph, {
|
|
iter,
|
|
preventOverlapIters: 50,
|
|
krPrime: 100,
|
|
sg,
|
|
forces,
|
|
preForces,
|
|
bodies,
|
|
sizes,
|
|
}, options);
|
|
iter--;
|
|
onTick === null || onTick === void 0 ? void 0 : onTick({
|
|
nodes: calcNodes,
|
|
edges: graph.getAllEdges(),
|
|
});
|
|
// if (assign) {
|
|
// calcNodes.forEach(({ id, data }) => graph.mergeNodeData(id, {
|
|
// x: data.x,
|
|
// y: data.y
|
|
// }))
|
|
// }
|
|
}
|
|
return calcGraph;
|
|
}
|
|
/**
|
|
* One step for a loop.
|
|
* @param graph graph for calculation
|
|
* @param params parameters for a loop
|
|
* @param options formatted layout's input options
|
|
* @returns
|
|
*/
|
|
oneStep(graph, params, options) {
|
|
const { iter, preventOverlapIters, krPrime, sg, preForces, bodies, sizes } = params;
|
|
let { forces } = params;
|
|
const { preventOverlap, barnesHut } = options;
|
|
const nodes = graph.getAllNodes();
|
|
for (let i = 0; i < nodes.length; i += 1) {
|
|
const { id } = nodes[i];
|
|
preForces[id] = [...forces[id]];
|
|
forces[id] = [0, 0];
|
|
}
|
|
// attractive forces, existing on every actual edge
|
|
forces = this.getAttrForces(graph, iter, preventOverlapIters, sizes, forces, options);
|
|
// repulsive forces and Gravity, existing on every node pair
|
|
// if preventOverlap, using the no-optimized method in the last preventOverlapIters instead.
|
|
if (barnesHut &&
|
|
((preventOverlap && iter > preventOverlapIters) || !preventOverlap)) {
|
|
forces = this.getOptRepGraForces(graph, forces, bodies, options);
|
|
}
|
|
else {
|
|
forces = this.getRepGraForces(graph, iter, preventOverlapIters, forces, krPrime, sizes, options);
|
|
}
|
|
// update the positions
|
|
return this.updatePos(graph, forces, preForces, sg, options);
|
|
}
|
|
/**
|
|
* Calculate the attract forces for nodes.
|
|
* @param graph graph for calculation
|
|
* @param iter current iteration index
|
|
* @param preventOverlapIters the iteration number for preventing overlappings
|
|
* @param sizes nodes' sizes
|
|
* @param forces forces for nodes, which will be modified
|
|
* @param options formatted layout's input options
|
|
* @returns
|
|
*/
|
|
getAttrForces(graph, iter, preventOverlapIters, sizes, forces, options) {
|
|
const { preventOverlap, dissuadeHubs, mode, prune } = options;
|
|
const edges = graph.getAllEdges();
|
|
for (let i = 0; i < edges.length; i += 1) {
|
|
const { source, target } = edges[i];
|
|
const sourceNode = graph.getNode(source);
|
|
const targetNode = graph.getNode(target);
|
|
const sourceDegree = graph.getDegree(source);
|
|
const targetDegree = graph.getDegree(target);
|
|
if (prune && (sourceDegree <= 1 || targetDegree <= 1))
|
|
continue;
|
|
const dir = [
|
|
targetNode.data.x - sourceNode.data.x,
|
|
targetNode.data.y - sourceNode.data.y,
|
|
];
|
|
let eucliDis = Math.hypot(dir[0], dir[1]);
|
|
eucliDis = eucliDis < 0.0001 ? 0.0001 : eucliDis;
|
|
dir[0] = dir[0] / eucliDis;
|
|
dir[1] = dir[1] / eucliDis;
|
|
if (preventOverlap && iter < preventOverlapIters) {
|
|
eucliDis = eucliDis - sizes[source] - sizes[target];
|
|
}
|
|
let fa1 = eucliDis;
|
|
let fa2 = fa1;
|
|
if (mode === 'linlog') {
|
|
fa1 = Math.log(1 + eucliDis);
|
|
fa2 = fa1;
|
|
}
|
|
if (dissuadeHubs) {
|
|
fa1 = eucliDis / sourceDegree;
|
|
fa2 = eucliDis / targetDegree;
|
|
}
|
|
if (preventOverlap && iter < preventOverlapIters && eucliDis <= 0) {
|
|
fa1 = 0;
|
|
fa2 = 0;
|
|
}
|
|
else if (preventOverlap && iter < preventOverlapIters && eucliDis > 0) {
|
|
fa1 = eucliDis;
|
|
fa2 = eucliDis;
|
|
}
|
|
forces[source][0] += fa1 * dir[0];
|
|
forces[target][0] -= fa2 * dir[0];
|
|
forces[source][1] += fa1 * dir[1];
|
|
forces[target][1] -= fa2 * dir[1];
|
|
}
|
|
return forces;
|
|
}
|
|
/**
|
|
* Calculate the repulsive forces for nodes under barnesHut mode.
|
|
* @param graph graph for calculatiion
|
|
* @param forces forces for nodes, which will be modified
|
|
* @param bodies force body map
|
|
* @param options formatted layout's input options
|
|
* @returns
|
|
*/
|
|
getOptRepGraForces(graph, forces, bodies, options) {
|
|
const { kg, center, prune } = options;
|
|
const nodes = graph.getAllNodes();
|
|
const nodeNum = nodes.length;
|
|
let minx = 9e10;
|
|
let maxx = -9e10;
|
|
let miny = 9e10;
|
|
let maxy = -9e10;
|
|
for (let i = 0; i < nodeNum; i += 1) {
|
|
const { id, data } = nodes[i];
|
|
if (prune && graph.getDegree(id) <= 1)
|
|
continue;
|
|
bodies[id].setPos(data.x, data.y);
|
|
if (data.x >= maxx)
|
|
maxx = data.x;
|
|
if (data.x <= minx)
|
|
minx = data.x;
|
|
if (data.y >= maxy)
|
|
maxy = data.y;
|
|
if (data.y <= miny)
|
|
miny = data.y;
|
|
}
|
|
const width = Math.max(maxx - minx, maxy - miny);
|
|
const quadParams = {
|
|
xmid: (maxx + minx) / 2,
|
|
ymid: (maxy + miny) / 2,
|
|
length: width,
|
|
massCenter: center,
|
|
mass: nodeNum,
|
|
};
|
|
const quad = new Quad(quadParams);
|
|
const quadTree = new QuadTree(quad);
|
|
// build the tree, insert the nodes(quads) into the tree
|
|
for (let i = 0; i < nodeNum; i += 1) {
|
|
const { id } = nodes[i];
|
|
if (prune && graph.getDegree(id) <= 1)
|
|
continue;
|
|
if (bodies[id].in(quad))
|
|
quadTree.insert(bodies[id]);
|
|
}
|
|
// update the repulsive forces and the gravity.
|
|
for (let i = 0; i < nodeNum; i += 1) {
|
|
const { id, data } = nodes[i];
|
|
const degree = graph.getDegree(id);
|
|
if (prune && degree <= 1)
|
|
continue;
|
|
bodies[id].resetForce();
|
|
quadTree.updateForce(bodies[id]);
|
|
forces[id][0] -= bodies[id].fx;
|
|
forces[id][1] -= bodies[id].fy;
|
|
// gravity
|
|
const dir = [data.x - center[0], data.y - center[1]];
|
|
let eucliDis = Math.hypot(dir[0], dir[1]);
|
|
eucliDis = eucliDis < 0.0001 ? 0.0001 : eucliDis;
|
|
dir[0] = dir[0] / eucliDis;
|
|
dir[1] = dir[1] / eucliDis;
|
|
const fg = kg * (degree + 1); // tslint:disable-line
|
|
forces[id][0] -= fg * dir[0];
|
|
forces[id][1] -= fg * dir[1];
|
|
}
|
|
return forces;
|
|
}
|
|
/**
|
|
* Calculate the repulsive forces for nodes.
|
|
* @param graph graph for calculatiion
|
|
* @param iter current iteration index
|
|
* @param preventOverlapIters the iteration number for preventing overlappings
|
|
* @param forces forces for nodes, which will be modified
|
|
* @param krPrime larger the krPrime, larger the repulsive force
|
|
* @param sizes nodes' sizes
|
|
* @param options formatted layout's input options
|
|
* @returns
|
|
*/
|
|
getRepGraForces(graph, iter, preventOverlapIters, forces, krPrime, sizes, options) {
|
|
const { preventOverlap, kr, kg, center, prune } = options;
|
|
const nodes = graph.getAllNodes();
|
|
const nodeNum = nodes.length;
|
|
for (let i = 0; i < nodeNum; i += 1) {
|
|
const nodei = nodes[i];
|
|
const degreei = graph.getDegree(nodei.id);
|
|
for (let j = i + 1; j < nodeNum; j += 1) {
|
|
const nodej = nodes[j];
|
|
const degreej = graph.getDegree(nodej.id);
|
|
if (prune && (degreei <= 1 || degreej <= 1))
|
|
continue;
|
|
const dir = [nodej.data.x - nodei.data.x, nodej.data.y - nodei.data.y];
|
|
let eucliDis = Math.hypot(dir[0], dir[1]);
|
|
eucliDis = eucliDis < 0.0001 ? 0.0001 : eucliDis;
|
|
dir[0] = dir[0] / eucliDis;
|
|
dir[1] = dir[1] / eucliDis;
|
|
if (preventOverlap && iter < preventOverlapIters) {
|
|
eucliDis = eucliDis - sizes[nodei.id] - sizes[nodej.id];
|
|
}
|
|
let fr = (kr * (degreei + 1) * (degreej + 1)) / eucliDis;
|
|
if (preventOverlap && iter < preventOverlapIters && eucliDis < 0) {
|
|
fr = krPrime * (degreei + 1) * (degreej + 1);
|
|
}
|
|
else if (preventOverlap &&
|
|
iter < preventOverlapIters &&
|
|
eucliDis === 0) {
|
|
fr = 0;
|
|
}
|
|
else if (preventOverlap &&
|
|
iter < preventOverlapIters &&
|
|
eucliDis > 0) {
|
|
fr = (kr * (degreei + 1) * (degreej + 1)) / eucliDis;
|
|
}
|
|
forces[nodei.id][0] -= fr * dir[0];
|
|
forces[nodej.id][0] += fr * dir[0];
|
|
forces[nodei.id][1] -= fr * dir[1];
|
|
forces[nodej.id][1] += fr * dir[1];
|
|
}
|
|
// gravity
|
|
const dir = [nodei.data.x - center[0], nodei.data.y - center[1]];
|
|
const eucliDis = Math.hypot(dir[0], dir[1]);
|
|
dir[0] = dir[0] / eucliDis;
|
|
dir[1] = dir[1] / eucliDis;
|
|
const fg = kg * (degreei + 1); // tslint:disable-line
|
|
forces[nodei.id][0] -= fg * dir[0];
|
|
forces[nodei.id][1] -= fg * dir[1];
|
|
}
|
|
return forces;
|
|
}
|
|
/**
|
|
* Update node positions.
|
|
* @param graph graph for calculatiion
|
|
* @param forces forces for nodes, which will be modified
|
|
* @param preForces previous forces for nodes, which will be modified
|
|
* @param sg constant for move distance of one step
|
|
* @param options formatted layout's input options
|
|
* @returns
|
|
*/
|
|
updatePos(graph, forces, preForces, sg, options) {
|
|
const { ks, tao, prune, ksmax } = options;
|
|
const nodes = graph.getAllNodes();
|
|
const nodeNum = nodes.length;
|
|
const swgns = [];
|
|
const trans = [];
|
|
// swg(G) and tra(G)
|
|
let swgG = 0;
|
|
let traG = 0;
|
|
let usingSg = sg;
|
|
for (let i = 0; i < nodeNum; i += 1) {
|
|
const { id } = nodes[i];
|
|
const degree = graph.getDegree(id);
|
|
if (prune && degree <= 1)
|
|
continue;
|
|
const minus = [
|
|
forces[id][0] - preForces[id][0],
|
|
forces[id][1] - preForces[id][1],
|
|
];
|
|
const minusNorm = Math.hypot(minus[0], minus[1]);
|
|
const add = [
|
|
forces[id][0] + preForces[id][0],
|
|
forces[id][1] + preForces[id][1],
|
|
];
|
|
const addNorm = Math.hypot(add[0], add[1]);
|
|
swgns[i] = minusNorm;
|
|
trans[i] = addNorm / 2;
|
|
swgG += (degree + 1) * swgns[i];
|
|
traG += (degree + 1) * trans[i];
|
|
}
|
|
const preSG = usingSg;
|
|
usingSg = (tao * traG) / swgG;
|
|
if (preSG !== 0) {
|
|
usingSg = usingSg > 1.5 * preSG ? 1.5 * preSG : usingSg;
|
|
}
|
|
// update the node positions
|
|
for (let i = 0; i < nodeNum; i += 1) {
|
|
const { id, data } = nodes[i];
|
|
const degree = graph.getDegree(id);
|
|
if (prune && degree <= 1)
|
|
continue;
|
|
if (isNumber(data.fx) && isNumber(data.fy))
|
|
continue;
|
|
let sn = (ks * usingSg) / (1 + usingSg * Math.sqrt(swgns[i]));
|
|
let absForce = Math.hypot(forces[id][0], forces[id][1]);
|
|
absForce = absForce < 0.0001 ? 0.0001 : absForce;
|
|
const max = ksmax / absForce;
|
|
sn = sn > max ? max : sn;
|
|
const dnx = sn * forces[id][0];
|
|
const dny = sn * forces[id][1];
|
|
graph.mergeNodeData(id, {
|
|
x: data.x + dnx,
|
|
y: data.y + dny,
|
|
});
|
|
}
|
|
return usingSg;
|
|
}
|
|
}
|
|
//# sourceMappingURL=index.js.map
|