Merge branch 'radiallayout' into plugins180708

This commit is contained in:
shiwu.wyy 2019-08-05 12:33:01 +08:00
commit be45c94f2c
9 changed files with 4025 additions and 5 deletions

View File

@ -104,6 +104,7 @@
const lineDash = [4, 2, 1, 2];
const interval = 9;
debugger
G6.registerEdge('line-dash', {
afterDraw(cfg, group) {
const shape = group.get('children')[0];

File diff suppressed because it is too large Load Diff

View File

@ -35,6 +35,7 @@
],
"license": "MIT",
"devDependencies": {
"@antv/hierarchy": "~0.4.0",
"@babel/cli": "^7.0.0",
"@babel/core": "^7.0.0",
"@babel/preset-env": "^7.0.0",
@ -60,10 +61,12 @@
"eslint-plugin-react": "~7.1.0",
"event-simulate": "~1.0.0",
"get-port": "~3.2.0",
"hard-source-webpack-plugin": "~0.13.1",
"home": "~1.0.1",
"jquery": "^3.3.1",
"jszip": "~3.1.5",
"nightmare": "~2.10.0",
"numericjs": "^1.2.6",
"nunjucks": "~3.0.1",
"open": "~0.0.5",
"parseurl": "~1.3.2",
@ -76,9 +79,7 @@
"uglify-js": "~3.1.10",
"webpack": "~4.10.2",
"webpack-cli": "~3.0.0",
"worker-loader": "^2.0.0",
"@antv/hierarchy": "~0.4.0",
"hard-source-webpack-plugin": "~0.13.1"
"worker-loader": "^2.0.0"
},
"scripts": {
"update": "rm -rf node_modules/ && tnpm i && rm -rf build && tnpm run build",
@ -99,7 +100,7 @@
"screenshot": "node ./bin/screenshot.js",
"start": "npm run dev",
"test": "torch --compile --renderer --opts test/mocha.opts --recursive ./test/unit",
"test-live": "torch --compile --interactive --watch --opts test/mocha.opts --recursive ./test/unit",
"test-live": "torch --compile --interactive --watch --opts test/mocha.opts --recursive ./test/unit/",
"test-bugs": "torch --compile --renderer --recursive ./test/bugs",
"test-bugs-live": "torch --compile --interactive --watch --recursive ./test/bugs",
"test-all": "npm run test && npm run test-bugs",
@ -118,7 +119,8 @@
"@antv/hierarchy": "~0.5.0",
"@antv/util": "~1.3.1",
"d3-force": "^2.0.1",
"dagre": "^0.8.4"
"dagre": "^0.8.4",
"numeric": "^1.2.6"
},
"engines": {
"node": ">=8.9.0"

View File

@ -2,6 +2,7 @@ const G6Plugins = {
Minimap: require('./minimap'),
Grid: require('./grid'),
Force: require('./force'),
Radial: require('./radial'),
Dagre: require('./dagre'),
Menu: require('./menu')
};

282
plugins/radial/index.js Normal file
View File

@ -0,0 +1,282 @@
// const Numeric = require('numericjs');
const Base = require('../base');
const Util = require('@antv/g6').Util;
const MDS = require('./mds');
function getWeightMatrix(M) {
const rows = M.length;
const cols = M[0].length;
const result = [];
for (let i = 0; i < rows; i++) {
const row = [];
for (let j = 0; j < cols; j++) {
if (M[i][j] !== 0) row.push(1 / Math.pow(M[i][j], 2));
else row.push(0);
}
result.push(row);
}
return result;
}
function getIndexById(array, id) {
let index = -1;
array.forEach((a, i) => {
if (a.id === id) {
index = i;
return;
}
});
return index;
}
class Radial extends Base {
getDefaultCfgs() {
return {
maxIteration: 1000, // 停止迭代的最大迭代数
focusNode: null, // 中心点,默认为数据中第一个点
center: [ 0, 0 ], // 布局中心
unitRadius: null, // 默认边长度
linkDistance: 50, // 默认边长度
animate: true, // 插值动画效果变换节点位置
nonOverlap: false, // 是否防止重叠
nodeSize: 10, // 节点半径
onLayoutEnd() {}, // 布局完成回调
onTick() {} // 每一迭代布局回调
};
}
init() {
const graph = this.get('graph');
const onTick = this.get('onTick');
const tick = () => {
onTick && onTick();
graph.refreshPositions();
};
this.set('tick', tick);
}
layout(data) {
const self = this;
self.set('data', data);
const graph = self.get('graph');
const nodes = data.nodes;
const center = self.get('center');
if (nodes.length === 0) {
return;
} else if (nodes.length === 1) {
nodes[0].x = center[0];
nodes[0].y = center[1];
return;
}
const linkDistance = self.get('linkDistance');
const unitRadius = self.get('unitRadius');
// 如果正在布局,忽略布局请求
if (self.isTicking()) {
return;
}
// layout
let focusNode = self.get('focusNode');
if (Util.isString(focusNode)) {
let found = false;
for (let i = 0; i < nodes.length; i++) {
if (nodes[i].id === focusNode) {
focusNode = nodes[i];
self.set('focusNode', focusNode);
found = true;
i = nodes.length;
}
}
if (!found) focusNode = null;
}
// default focus node
if (!focusNode) {
focusNode = nodes[0];
if (!focusNode) return;
self.set('focusNode', focusNode);
}
// the graph-theoretic distance (shortest path distance) matrix
const adjMatrix = Util.getAdjMatrix(data, false);
const D = Util.floydWarshall(adjMatrix);
self.set('distances', D);
// the index of the focusNode in data
const focusIndex = getIndexById(nodes, focusNode.id);
self.set('focusIndex', focusIndex);
// the shortest path distance from each node to focusNode
const focusNodeD = D[focusIndex];
const width = graph.get('width');
const height = graph.get('height');
// the maxRadius of the graph
const maxRadius = height > width ? width / 2 : height / 2;
const maxD = Math.max(...focusNodeD);
// the radius for each nodes away from focusNode
const radii = [];
focusNodeD.forEach((value, i) => {
if (!unitRadius) radii[i] = value * maxRadius / maxD;
else radii[i] = value * unitRadius;
});
self.set('radii', radii);
const eIdealD = self.eIdealDisMatrix(D, linkDistance, radii);
// const eIdealD = scaleMatrix(D, linkDistance);
self.set('eIdealDistances', eIdealD);
// the weight matrix, Wij = 1 / dij^(-2)
const W = getWeightMatrix(eIdealD);
self.set('weights', W);
// the initial positions from mds
const mds = new MDS({ distances: eIdealD, dimension: 2 });
const positions = mds.layout();
self.set('positions', positions);
positions.forEach((p, i) => {
nodes[i].x = p[0] + center[0];
nodes[i].y = p[1] + center[1];
});
// move the graph to origin, centered at focusNode
positions.forEach(p => {
p[0] -= positions[focusIndex][0];
p[1] -= positions[focusIndex][1];
});
self.run();
const nonOverlap = self.get('nonOverlap');
const nodeSize = self.get('nodeSize');
// stagger the overlapped nodes
if (nonOverlap) {
let hasOverlaps = true;
let iter = 0;
const oMaxIter = 5;
while (hasOverlaps && iter < oMaxIter) {
hasOverlaps = false;
iter++;
positions.forEach((v, i) => {
positions.forEach((u, j) => {
if (i <= j) return;
// u and j are in different circle, will not overlaps
if (radii[i] !== radii[j]) return;
// u has been moved
const edis = Util.getEDistance(v, u);
if (edis < nodeSize) { // overlapped
hasOverlaps = true;
// the vector focusNode -> u
let vecx = u[0] - positions[focusIndex][0];
let vecy = u[1] - positions[focusIndex][1];
const length = Math.sqrt(vecx * vecx + vecy * vecy);
vecx = (length + nodeSize) * vecx / length;
vecy = (length + nodeSize) * vecy / length;
u[0] = vecx + positions[focusIndex][0];
u[1] = vecy + positions[focusIndex][1];
}
});
});
}
}
// move the graph to center
positions.forEach((p, i) => {
nodes[i].x = p[0] + center[0];
nodes[i].y = p[1] + center[1];
});
graph.refreshPositions();
const onLayoutEnd = self.get('onLayoutEnd');
onLayoutEnd();
}
run() {
const self = this;
const maxIteration = self.get('maxIteration');
const positions = self.get('positions');
const W = self.get('weights');
const eIdealDis = self.get('eIdealDistances');
const radii = self.get('radii');
for (let i = 0; i <= maxIteration; i++) {
const param = i / maxIteration;
self.oneIteration(param, positions, radii, eIdealDis, W);
}
}
oneIteration(param, positions, radii, D, W) {
const self = this;
const vparam = 1 - param;
const focusIndex = self.get('focusIndex');
positions.forEach((v, i) => { // v
const originDis = Util.getEDistance(v, [ 0, 0 ]);
const reciODis = originDis === 0 ? 0 : 1 / originDis;
if (i === focusIndex) return;
let xMolecule = 0;
let yMolecule = 0;
let denominator = 0;
positions.forEach((u, j) => { // u
if (i === j) return;
// if (j === focusIndex) return;
// the euclidean distance between v and u
const edis = Util.getEDistance(v, u);
const reciEdis = edis === 0 ? 0 : 1 / edis;
const idealDis = D[j][i];
// same for x and y
denominator += W[i][j];
// x
xMolecule += W[i][j] * (u[0] + idealDis * (v[0] - u[0]) * reciEdis);
// y
yMolecule += W[i][j] * (u[1] + idealDis * (v[1] - u[1]) * reciEdis);
});
const reciR = radii[i] === 0 ? 0 : 1 / radii[i];
denominator *= vparam;
denominator += param * Math.pow(reciR, 2);
// x
xMolecule *= vparam;
xMolecule += param * reciR * v[0] * reciODis;
v[0] = xMolecule / denominator;
// y
yMolecule *= vparam;
yMolecule += param * reciR * v[1] * reciODis;
v[1] = yMolecule / denominator;
});
}
updateLayout(cfg) {
const self = this;
const data = cfg.data;
if (data) {
self.set('data', data);
}
if (self.get('ticking')) {
// stop layout
self.set('ticking', false);
}
Object.keys(cfg).forEach(key => {
self.set(key, cfg[key]);
});
self.layout(data);
}
isTicking() {
return this.get('ticking');
}
destroy() {
if (this.get('ticking')) {
this.getSimulation().stop();
}
super.destroy();
}
eIdealDisMatrix() {
const D = this.get('distances');
const linkDis = this.get('linkDistance');
const radii = this.get('radii');
const unitRadius = this.get('unitRadius');
const result = [];
D.forEach((row, i) => {
const newRow = [];
row.forEach((v, j) => {
if (i === j) newRow.push(0);
else if (radii[i] === radii[j]) { // i and j are on the same circle
newRow.push(v * linkDis / (radii[i] / unitRadius));
} else { // i and j are on different circle
const link = (linkDis + unitRadius) / 2;
newRow.push(v * link);
}
});
result.push(newRow);
});
return result;
}
}
module.exports = Radial;

50
plugins/radial/mds.js Normal file
View File

@ -0,0 +1,50 @@
const Numeric = require('numericjs');
class MDS {
// getDefaultCfgs() {
// return {
// distances: null, // 停止迭代的最大迭代数
// demension: 2 // 中心点,默认为数据中第一个点
// };
// }
constructor(params) {
/**
* distance matrix
* @type {array}
*/
this.distances = params.distances;
/**
* dimensions
* @type {number}
*/
this.dimension = params.dimension || 2;
}
layout() {
const self = this;
const dimension = self.dimension;
const distances = self.distances;
// square distances
const M = Numeric.mul(-0.5, Numeric.pow(distances, 2));
// double centre the rows/columns
function mean(A) { return Numeric.div(Numeric.add.apply(null, A), A.length); }
const rowMeans = mean(M),
colMeans = mean(Numeric.transpose(M)),
totalMean = mean(rowMeans);
for (let i = 0; i < M.length; ++i) {
for (let j = 0; j < M[0].length; ++j) {
M[i][j] += totalMean - rowMeans[i] - colMeans[j];
}
}
// take the SVD of the double centred matrix, and return the
// points from it
const ret = Numeric.svd(M);
const eigenValues = Numeric.sqrt(ret.S);
return ret.U.map(function(row) {
return Numeric.mul(row, eigenValues).splice(0, dimension);
});
}
}
module.exports = MDS;

View File

@ -179,6 +179,90 @@ const MathUtil = {
x: vector[0],
y: vector[1]
};
},
/**
* Floyd Warshall algorithm for shortest path distances matrix
* @param {array} adjMatrix adjacency matrix
* @return {array} distances shortest path distances matrix
*/
floydWarshall(adjMatrix) {
// initialize
const dist = [];
const size = adjMatrix.length;
for (let i = 0; i < size; i += 1) {
dist[i] = [];
for (let j = 0; j < size; j += 1) {
if (i === j) {
dist[i][j] = 0;
} else if (adjMatrix[i][j] === 0 || !adjMatrix[i][j]) {
dist[i][j] = Infinity;
} else {
dist[i][j] = adjMatrix[i][j];
}
}
}
// floyd
for (let k = 0; k < size; k += 1) {
for (let i = 0; i < size; i += 1) {
for (let j = 0; j < size; j += 1) {
if (dist[i][j] > dist[i][k] + dist[k][j]) {
dist[i][j] = dist[i][k] + dist[k][j];
}
}
}
}
return dist;
},
getAdjMatrix(data, directed) {
const nodes = data.nodes;
const edges = data.edges;
const matrix = [];
// map node with index in data.nodes
const nodeMap = new Map();
nodes.forEach((node, i) => {
nodeMap.set(node.id, i);
const row = [];
matrix.push(row);
});
// const n = nodes.length;
edges.forEach(e => {
const source = e.source;
const target = e.target;
const sIndex = nodeMap.get(source);
const tIndex = nodeMap.get(target);
matrix[sIndex][tIndex] = 1;
if (!directed) matrix[tIndex][sIndex] = 1;
});
return matrix;
},
getEDistance(p1, p2) {
return Math.sqrt((p1[0] - p2[0]) * (p1[0] - p2[0])
+ (p1[1] - p2[1]) * (p1[1] - p2[1]));
},
scaleMatrix(matrix, scale) {
const result = [];
matrix.forEach(row => {
const newRow = [];
row.forEach(v => {
newRow.push(v * scale);
});
result.push(newRow);
});
return result;
},
randomInitPos(size, xRange = [ 0, 1 ], yRange = [ 0, 1 ]) {
const positions = [];
for (let i = 0; i < size; i++) {
const x = Math.random() * (xRange[1] - xRange[0]) + xRange[0];
const y = Math.random() * (yRange[1] - yRange[0]) + yRange[0];
positions.push([ x, y ]);
}
return positions;
}
};
module.exports = BaseUtil.mix({}, BaseUtil, MathUtil);

View File

@ -0,0 +1,56 @@
const expect = require('chai').expect;
const G6 = require('../../../src');
const MDS = require('../../../plugins/mds');
const data = require('./data.json');
const div = document.createElement('div');
div.id = 'mds-layout';
document.body.appendChild(div);
describe('mds layout', () => {
const graph = new G6.Graph({
container: div,
width: 500,
height: 500
});
it('mds layout with default configs', done => {
const mds = new MDS({
center: [ 250, 250 ]
});
mds.initPlugin(graph);
mds.layout(data);
expect(data.nodes[0].x != null).to.equal(true);
done();
});
it('mds with fixed link length', done => {
const mds = new MDS({
center: [ 250, 250 ],
linkDistance: 120
});
mds.initPlugin(graph);
expect(data.nodes[0].x != null).to.equal(true);
done();
});
it('mds update cfg and data', done => {
const mds = new MDS({
center: [ 250, 250 ],
linkDistance: 250
});
mds.initPlugin(graph);
mds.layout(data);
data.nodes = [
{ id: '0' }, { id: '1' }, { id: '2' }
];
data.edges = [
{ source: '0', target: '1' },
{ source: '1', target: '2' },
{ source: '0', target: '2' }
];
mds.updateLayout({ gravity: 100, data });
expect(data.nodes[0].x != null).to.equal(true);
done();
});
});

View File

@ -0,0 +1,93 @@
const expect = require('chai').expect;
const G6 = require('../../../src');
const Radial = require('../../../plugins/radial');
const data = require('./data.json');
const div = document.createElement('div');
div.id = 'radial-layout';
document.body.appendChild(div);
function mathEqual(a, b) {
return Math.abs(a - b) < 1;
}
describe('radial layout', () => {
const graph = new G6.Graph({
container: div,
width: 500,
height: 500
});
it('radial layout with default configs', done => {
const radial = new Radial({
center: [ 250, 250 ]
});
radial.initPlugin(graph);
radial.layout(data);
expect(mathEqual(data.nodes[0].x, 250)).to.equal(true);
expect(mathEqual(data.nodes[0].y, 250)).to.equal(true);
done();
});
it('radial with fixed focusNode, unit radius, link length, and max iteration', done => {
const unitRadius = 100;
const radial = new Radial({
center: [ 250, 250 ],
maxIteration: 10,
focusNode: data.nodes[2],
unitRadius,
linkDistance: 100
});
radial.initPlugin(graph);
radial.layout(data);
expect(mathEqual(data.nodes[2].x, 250)).to.equal(true);
expect(mathEqual(data.nodes[2].y, 250)).to.equal(true);
const vx = data.nodes[0].x - data.nodes[2].x;
const vy = data.nodes[0].y - data.nodes[2].y;
const distToFocus = Math.sqrt(vx * vx + vy * vy);
expect(mathEqual(distToFocus % unitRadius, 0)).to.equal(true);
done();
});
it('radial with fixed id focusNode', done => {
const radial = new Radial({
center: [ 250, 250 ],
focusNode: 'Belgium'
});
let focusNodeIndex = -1;
data.nodes.forEach((node, i) => {
if (node.id === 'Belgium') focusNodeIndex = i;
return;
});
radial.initPlugin(graph);
expect(mathEqual(data.nodes[focusNodeIndex].x, 250)).to.equal(true);
expect(mathEqual(data.nodes[focusNodeIndex].y, 250)).to.equal(true);
done();
});
it('radial update cfg and data', done => {
const radial = new Radial({
center: [ 250, 250 ],
maxIteration: 120
});
radial.initPlugin(graph);
radial.layout(data);
data.nodes = [
{ id: '0' }, { id: '1' }, { id: '2' }
];
data.edges = [
{ source: '0', target: '1' },
{ source: '1', target: '2' },
{ source: '0', target: '2' }
];
const newUnitRadius = 80;
radial.updateLayout({ center: [ 100, 150 ], unitRadius: newUnitRadius, linkDistance: 70, focusNode: data.nodes[1], data });
expect(mathEqual(data.nodes[1].x, 100)).to.equal(true);
expect(mathEqual(data.nodes[1].y, 150)).to.equal(true);
const vx = data.nodes[2].x - data.nodes[1].x;
const vy = data.nodes[2].y - data.nodes[1].y;
const distToFocus = Math.sqrt(vx * vx + vy * vy);
expect(mathEqual(distToFocus % newUnitRadius, 0)).to.equal(true);
done();
});
});