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(); + }); +});