diff --git a/package.json b/package.json index 7df117f944..1afe890c0c 100644 --- a/package.json +++ b/package.json @@ -116,7 +116,8 @@ "dependencies": { "@antv/g": "~3.4.4", "@antv/hierarchy": "~0.5.0", - "@antv/util": "~1.3.1" + "@antv/util": "~1.3.1", + "d3-force": "^2.0.1" }, "engines": { "node": ">=8.9.0" diff --git a/plugins/force/index.js b/plugins/force/index.js new file mode 100644 index 0000000000..df9c91a22f --- /dev/null +++ b/plugins/force/index.js @@ -0,0 +1,113 @@ +const d3Force = require('d3-force'); +const Base = require('../base'); + +class Force extends Base { + getDefaultCfgs() { + return { + center: [ 0, 0 ], // 向心力作用点 + nodeStrength: null, // 节点作用力 + preventOverlap: false, // 是否防止节点相互覆盖 + nodeRadius: null, // 节点半径 + edgeStrength: null, // 边的作用力, 默认为根据节点的入度出度自适应 + linkDistance: 50, // 默认边长度 + forceSimulation: null, // 自定义 force 方法 + onLayoutEnd() {}, // 布局完成回调 + onTick() {} // 每一迭代布局回调 + }; + } + layout(data) { + const self = this; + // 如果正在布局,忽略布局请求 + if (self.isTicking()) { + return; + } + const cfgs = self._cfgs; + let simulation = cfgs.forceSimulation; + if (!simulation) { + try { + // 定义节点的力 + const nodeForce = d3Force.forceManyBody(); + if (cfgs.nodeStrength) { + nodeForce.strength(cfgs.nodeStrength); + } + simulation = d3Force.forceSimulation() + .nodes(data.nodes) + .force('center', d3Force.forceCenter(cfgs.center[0], cfgs.center[1])) + .force('charge', nodeForce) + .on('tick', cfgs.tick) + .on('end', () => { + self.set('ticking', false); + cfgs.onLayoutEnd && cfgs.onLayoutEnd(); + }); + if (cfgs.preventOverlap) { + let nodeRadius = cfgs.nodeRadius; + if (!nodeRadius) { + nodeRadius = d => { + if (d.size) { + if (Array.isArray(d.size)) { + return d.size[0] / 2; + } + return d.size / 2; + } + return 20; + }; + } + simulation.force('collide', d3Force.forceCollide().radius(nodeRadius)); + } + // 如果有边,定义边的力 + if (data.edges) { + // d3 的 forceLayout 会重新生成边的数据模型,为了避免污染源数据 + const edges = data.edges.map(edge => { + return { + id: edge.id, + source: edge.source, + target: edge.target + }; + }); + const edgeForce = d3Force.forceLink().id(function(d) { return d.id; }).links(edges); + if (cfgs.edgeStrength) { + edgeForce.strength(cfgs.edgeStrength); + } + if (cfgs.distance) { + edgeForce.distance(cfgs.distance); + } + simulation.force('link', edgeForce); + } + this.set('forceSimulation', simulation); + this.set('ticking', true); + } catch (e) { + this.set('ticking', false); + console.warn(e); + } + } else { + simulation.alphaTarget(0.3).restart(); + this.set('ticking', true); + } + } + updateLayout(cfg) { + const self = this; + const simulation = self.getSimulation(); + if (self.get('ticking')) { + simulation.stop(); + self.set('ticking', false); + } + this.set('forceSimulation', null); + this.set('tick', null); + Object.keys(cfg).forEach(key => { + self.set(key, cfg[key]); + }); + } + isTicking() { + return this.get('ticking'); + } + getSimulation() { + return this.get('forceSimulation'); + } + destroy() { + if (this.get('ticking')) { + this.getSimulation().stop(); + } + super.destroy(); + } +} +module.exports = Force; diff --git a/test/unit/plugins/data.json b/test/unit/plugins/data.json new file mode 100644 index 0000000000..d06401466d --- /dev/null +++ b/test/unit/plugins/data.json @@ -0,0 +1,547 @@ +{ + "nodes": [{ + "id": "Argentina", + "name": "Argentina" + }, { + "id": "Australia", + "name": "Australia" + }, { + "id": "Belgium", + "name": "Belgium" + }, { + "id": "Brazil", + "name": "Brazil" + }, { + "id": "Colombia", + "name": "Colombia" + }, { + "id": "Costa Rica", + "name": "Costa Rica" + }, { + "id": "Croatia", + "name": "Croatia" + }, { + "id": "Denmark", + "name": "Denmark" + }, { + "id": "Egypt", + "name": "Egypt" + }, { + "id": "England", + "name": "England" + }, { + "id": "France", + "name": "France" + }, { + "id": "Germany", + "name": "Germany" + }, { + "id": "Iceland", + "name": "Iceland" + }, { + "id": "IR Iran", + "name": "IR Iran" + }, { + "id": "Japan", + "name": "Japan" + }, { + "id": "Korea Republic", + "name": "Korea Republic" + }, { + "id": "Mexico", + "name": "Mexico" + }, { + "id": "Morocco", + "name": "Morocco" + }, { + "id": "Nigeria", + "name": "Nigeria" + }, { + "id": "Panama", + "name": "Panama" + }, { + "id": "Peru", + "name": "Peru" + }, { + "id": "Poland", + "name": "Poland" + }, { + "id": "Portugal", + "name": "Portugal" + }, { + "id": "Russia", + "name": "Russia" + }, { + "id": "Saudi Arabia", + "name": "Saudi Arabia" + }, { + "id": "Senegal", + "name": "Senegal" + }, { + "id": "Serbia", + "name": "Serbia" + }, { + "id": "Spain", + "name": "Spain" + }, { + "id": "Sweden", + "name": "Sweden" + }, { + "id": "Switzerland", + "name": "Switzerland" + }, { + "id": "Tunisia", + "name": "Tunisia" + }, { + "id": "Uruguay", + "name": "Uruguay" + }], + "edges": [{ + "id": "0", + "target": "Russia", + "source": "Saudi Arabia", + "target_score": 5, + "source_score": 0, + "directed": true + }, { + "id": "1", + "target": "Uruguay", + "source": "Egypt", + "target_score": 1, + "source_score": 0, + "directed": true + }, { + "id": "2", + "target": "Russia", + "source": "Egypt", + "target_score": 3, + "source_score": 1, + "directed": true + }, { + "id": "3", + "target": "Uruguay", + "source": "Saudi Arabia", + "target_score": 1, + "source_score": 0, + "directed": true + }, { + "id": "4", + "target": "Uruguay", + "source": "Russia", + "target_score": 3, + "source_score": 0, + "directed": true + }, { + "id": "5", + "target": "Saudi Arabia", + "source": "Egypt", + "target_score": 2, + "source_score": 1, + "directed": true + }, { + "id": "6", + "target": "IR Iran", + "source": "Morocco", + "target_score": 1, + "source_score": 0, + "directed": true + }, { + "id": "7", + "target": "Portugal", + "source": "Spain", + "target_score": 3, + "source_score": 3, + "directed": false + }, { + "id": "8", + "target": "Portugal", + "source": "Morocco", + "target_score": 1, + "source_score": 0, + "directed": true + }, { + "id": "9", + "target": "Spain", + "source": "IR Iran", + "target_score": 1, + "source_score": 0, + "directed": true + }, { + "id": "10", + "target": "IR Iran", + "source": "Portugal", + "target_score": 1, + "source_score": 1, + "directed": false + }, { + "id": "11", + "target": "Spain", + "source": "Morocco", + "target_score": 2, + "source_score": 2, + "directed": false + }, { + "id": "12", + "target": "France", + "source": "Australia", + "target_score": 2, + "source_score": 1, + "directed": true + }, { + "id": "13", + "target": "Denmark", + "source": "Peru", + "target_score": 1, + "source_score": 0, + "directed": true + }, { + "id": "14", + "target": "Denmark", + "source": "Australia", + "target_score": 1, + "source_score": 1, + "directed": false + }, { + "id": "15", + "target": "France", + "source": "Peru", + "target_score": 1, + "source_score": 0, + "directed": true + }, { + "id": "16", + "target": "Denmark", + "source": "France", + "target_score": 0, + "source_score": 0, + "directed": false + }, { + "id": "17", + "target": "Peru", + "source": "Australia", + "target_score": 2, + "source_score": 0, + "directed": true + }, { + "id": "18", + "target": "Argentina", + "source": "Iceland", + "target_score": 1, + "source_score": 1 + }, { + "id": "19", + "target": "Croatia", + "source": "Nigeria", + "target_score": 2, + "source_score": 0, + "directed": true + }, { + "id": "20", + "target": "Croatia", + "source": "Argentina", + "target_score": 3, + "source_score": 0, + "directed": true + }, { + "id": "21", + "target": "Nigeria", + "source": "Iceland", + "target_score": 2, + "source_score": 0, + "directed": true + }, { + "id": "22", + "target": "Argentina", + "source": "Nigeria", + "target_score": 2, + "source_score": 1, + "directed": true + }, { + "id": "23", + "target": "Croatia", + "source": "Iceland", + "target_score": 2, + "source_score": 1, + "directed": true + }, { + "id": "24", + "target": "Serbia", + "source": "Costa Rica", + "target_score": 1, + "source_score": 0, + "directed": true + }, { + "id": "25", + "target": "Brazil", + "source": "Switzerland", + "target_score": 1, + "source_score": 1, + "directed": false + }, { + "id": "26", + "target": "Brazil", + "source": "Costa Rica", + "target_score": 2, + "source_score": 0, + "directed": true + }, { + "id": "27", + "target": "Switzerland", + "source": "Serbia", + "target_score": 2, + "source_score": 1, + "directed": true + }, { + "id": "28", + "target": "Brazil", + "source": "Serbia", + "target_score": 2, + "source_score": 0, + "directed": true + }, { + "id": "29", + "target": "Switzerland", + "source": "Costa Rica", + "target_score": 2, + "source_score": 2, + "directed": false + }, { + "id": "30", + "target": "Mexico", + "source": "Germany", + "target_score": 1, + "source_score": 0, + "directed": true + }, { + "id": "31", + "target": "Sweden", + "source": "Korea Republic", + "target_score": 1, + "source_score": 0, + "directed": true + }, { + "id": "32", + "target": "Mexico", + "source": "Korea Republic", + "target_score": 1, + "source_score": 0, + "directed": true + }, { + "id": "33", + "target": "Germany", + "source": "Sweden", + "target_score": 2, + "source_score": 1, + "directed": true + }, { + "id": "34", + "target": "Korea Republic", + "source": "Germany", + "target_score": 2, + "source_score": 0, + "directed": true + }, { + "id": "35", + "target": "Sweden", + "source": "Mexico", + "target_score": 3, + "source_score": 0, + "directed": true + }, { + "id": "36", + "target": "Belgium", + "source": "Panama", + "target_score": 3, + "source_score": 0, + "directed": true + }, { + "id": "37", + "target": "England", + "source": "Tunisia", + "target_score": 2, + "source_score": 1, + "directed": true + }, { + "id": "38", + "target": "Belgium", + "source": "Tunisia", + "target_score": 5, + "source_score": 2, + "directed": true + }, { + "id": "39", + "target": "England", + "source": "Panama", + "target_score": 6, + "source_score": 1, + "directed": true + }, { + "id": "40", + "target": "Belgium", + "source": "England", + "target_score": 1, + "source_score": 0, + "directed": true + }, { + "id": "41", + "target": "Tunisia", + "source": "Panama", + "target_score": 2, + "source_score": 1, + "directed": true + }, { + "id": "42", + "target": "Japan", + "source": "Colombia", + "target_score": 2, + "source_score": 1, + "directed": true + }, { + "id": "43", + "target": "Senegal", + "source": "Poland", + "target_score": 2, + "source_score": 1, + "directed": true + }, { + "id": "44", + "target": "Japan", + "source": "Senegal", + "target_score": 2, + "source_score": 2, + "directed": false + }, { + "id": "45", + "target": "Colombia", + "source": "Poland", + "target_score": 3, + "source_score": 0, + "directed": true + }, { + "id": "46", + "target": "Poland", + "source": "Japan", + "target_score": 1, + "source_score": 0, + "directed": true + }, { + "id": "47", + "target": "Colombia", + "source": "Senegal", + "target_score": 1, + "source_score": 0, + "directed": true + }, { + "id": "48", + "target": "Uruguay", + "source": "Portugal", + "target_score": 2, + "source_score": 1, + "directed": true + }, { + "id": "49", + "target": "France", + "source": "Argentina", + "target_score": 4, + "source_score": 3, + "directed": true + }, { + "id": "50", + "target": "Russia", + "source": "Spain", + "target_score": 5, + "source_score": 4, + "directed": true + }, { + "id": "51", + "target": "Croatia", + "source": "Denmark", + "target_score": 4, + "source_score": 3, + "directed": true + }, { + "id": "52", + "target": "Brazil", + "source": "Mexico", + "target_score": 2, + "source_score": 0, + "directed": true + }, { + "id": "53", + "target": "Belgium", + "source": "Japan", + "target_score": 3, + "source_score": 2, + "directed": true + }, { + "id": "54", + "target": "Sweden", + "source": "Switzerland", + "target_score": 1, + "source_score": 0, + "directed": true + }, { + "id": "55", + "target": "England", + "source": "Colombia", + "target_score": 4, + "source_score": 3, + "directed": true + }, { + "id": "56", + "target": "France", + "source": "Uruguay", + "target_score": 2, + "source_score": 0, + "directed": true + }, { + "id": "57", + "target": "Belgium", + "source": "Brazil", + "target_score": 2, + "source_score": 1, + "directed": true + }, { + "id": "58", + "target": "Croatia", + "source": "Russia", + "target_score": 6, + "source_score": 5, + "directed": true + }, { + "id": "59", + "target": "England", + "source": "Sweden", + "target_score": 2, + "source_score": 0, + "directed": true + }, { + "id": "60", + "target": "France", + "source": "Belgium", + "target_score": 1, + "source_score": 0, + "directed": true + }, { + "id": "61", + "target": "Croatia", + "source": "England", + "target_score": 2, + "source_score": 1, + "directed": true + }, { + "id": "62", + "target": "Belgium", + "source": "England", + "target_score": 2, + "source_score": 0, + "directed": true + }, { + "id": "63", + "target": "France", + "source": "Croatia", + "target_score": 4, + "source_score": 2, + "directed": true + }] +} diff --git a/test/unit/plugins/force-spec.js b/test/unit/plugins/force-spec.js new file mode 100644 index 0000000000..4af270cb67 --- /dev/null +++ b/test/unit/plugins/force-spec.js @@ -0,0 +1,121 @@ +const expect = require('chai').expect; +// const G6 = require('../../../src'); +const Force = require('../../../plugins/force'); +const data = require('./data.json'); + +const div = document.createElement('div'); +div.id = 'force-layout'; +document.body.appendChild(div); + +describe('force layout', () => { + /* const graph = new G6.Graph({ + container: div, + width: 500, + height: 500, + defaultNode: { size: 10 } + });*/ + it('force layout with default configs', done => { + const node = data.nodes[0]; + const edge = data.edges[0]; + let x, + y, + count = 0; + const force = new Force({ + center: [ 250, 250 ], + tick() { + count++; + expect(node.x !== x); + expect(node.y !== y); + expect(edge.x).to.be.undefined; + expect(edge.y).to.be.undefined; + x = node.x; + y = node.y; + }, + onLayoutEnd() { + expect(node.x); + expect(node.y); + expect(edge.x).to.be.undefined; + expect(edge.y).to.be.undefined; + } + }); + force.layout(data); + setTimeout(() => { + expect(count > 30); + done(); + }, 500); + }); + + it('force with fixed link length', done => { + const node = data.nodes[0]; + const edge = data.edges[0]; + let x, + y, + count = 0; + const force = new Force({ + center: [ 250, 250 ], + distance: 40, + edgeStrength: 0.5, + tick() { + count++; + expect(node.x !== x); + expect(node.y !== y); + expect(edge.x).to.be.undefined; + expect(edge.y).to.be.undefined; + x = node.x; + y = node.y; + }, + onLayoutEnd() { + expect(node.x); + expect(node.y); + expect(edge.x).to.be.undefined; + expect(edge.y).to.be.undefined; + } + }); + force.layout(data); + setTimeout(() => { + force.layout(data); + expect(count > 30); + done(); + }, 500); + }); + it('force collide & update', done => { + const node = data.nodes[0]; + const edge = data.edges[0]; + let x, + y, + count = 0; + const force = new Force({ + center: [ 250, 250 ], + preventOverlap: true, + nodeRadius(d) { + d.size = Math.random() * 100 + 10; + return d.size / 2; + }, + nodeStrength: 10, + tick() { + count++; + expect(node.x !== x); + expect(node.y !== y); + expect(edge.x).to.be.undefined; + expect(edge.y).to.be.undefined; + x = node.x; + y = node.y; + }, + onLayoutEnd() { + expect(node.x); + expect(node.y); + expect(edge.x).to.be.undefined; + expect(edge.y).to.be.undefined; + } + }); + force.layout({ nodes: data.nodes }); + setTimeout(() => { + force.updateLayout({ + preventOverlap: false + }); + expect(force.get('preventOverlap')).to.be.false; + expect(count > 30); + done(); + }, 500); + }); +});