diff --git a/demos/edge-animate.html b/demos/edge-animate.html
index 4f25a988f6..1a7d69cb9f 100644
--- a/demos/edge-animate.html
+++ b/demos/edge-animate.html
@@ -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];
diff --git a/demos/radial-interact-layout.html b/demos/radial-interact-layout.html
new file mode 100644
index 0000000000..3a6dfb58e5
--- /dev/null
+++ b/demos/radial-interact-layout.html
@@ -0,0 +1,3451 @@
+
+
+
+
+ Title
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/package.json b/package.json
index 3c253893c1..28f42f8b18 100644
--- a/package.json
+++ b/package.json
@@ -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"
diff --git a/plugins/index.js b/plugins/index.js
index 70f251c847..e5a521bd66 100644
--- a/plugins/index.js
+++ b/plugins/index.js
@@ -2,6 +2,7 @@ const G6Plugins = {
Minimap: require('./minimap'),
Grid: require('./grid'),
Force: require('./force'),
+ Radial: require('./radial'),
Dagre: require('./dagre'),
Menu: require('./menu')
};
diff --git a/plugins/radial/index.js b/plugins/radial/index.js
new file mode 100644
index 0000000000..4298fce1b4
--- /dev/null
+++ b/plugins/radial/index.js
@@ -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;
diff --git a/plugins/radial/mds.js b/plugins/radial/mds.js
new file mode 100644
index 0000000000..64fa995f29
--- /dev/null
+++ b/plugins/radial/mds.js
@@ -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;
diff --git a/src/util/math.js b/src/util/math.js
index 824f987ee6..e94f6125a8 100644
--- a/src/util/math.js
+++ b/src/util/math.js
@@ -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);
diff --git a/test/unit/plugins/mds-spec.js b/test/unit/plugins/mds-spec.js
new file mode 100644
index 0000000000..b938510eb0
--- /dev/null
+++ b/test/unit/plugins/mds-spec.js
@@ -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();
+ });
+});
diff --git a/test/unit/plugins/radial-spec.js b/test/unit/plugins/radial-spec.js
new file mode 100644
index 0000000000..8b0bea4afc
--- /dev/null
+++ b/test/unit/plugins/radial-spec.js
@@ -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();
+ });
+});