mirror of
https://gitee.com/antv/g6.git
synced 2024-12-04 20:59:15 +08:00
feat: innter layouts
This commit is contained in:
parent
69899661b6
commit
d3ad5032bf
213
demos/layout-test.html
Normal file
213
demos/layout-test.html
Normal file
@ -0,0 +1,213 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Title</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="mountNode"></div>
|
||||
<script src="../build/g6.js"></script>
|
||||
<script>
|
||||
|
||||
const data = {
|
||||
"nodes": [
|
||||
{"id": "0", "label": "0"},
|
||||
{"id": "1", "label": "1"},
|
||||
{"id": "2", "label": "2"},
|
||||
{"id": "3", "label": "3"},
|
||||
{"id": "4", "label": "4"},
|
||||
{"id": "5", "label": "5"},
|
||||
{"id": "6", "label": "6"},
|
||||
{"id": "7", "label": "7"},
|
||||
{"id": "8", "label": "8"},
|
||||
{"id": "9", "label": "9"},
|
||||
{"id": "10", "label": "10"},
|
||||
{"id": "11", "label": "11"},
|
||||
{"id": "12", "label": "12"},
|
||||
{"id": "13", "label": "13"},
|
||||
{"id": "14", "label": "14"},
|
||||
{"id": "15", "label": "15"},
|
||||
{"id": "16", "label": "16"},
|
||||
{"id": "17", "label": "17"},
|
||||
{"id": "18", "label": "18"},
|
||||
{"id": "19", "label": "19"},
|
||||
{"id": "20", "label": "20"},
|
||||
{"id": "21", "label": "21"},
|
||||
{"id": "22", "label": "22"},
|
||||
{"id": "23", "label": "23"},
|
||||
{"id": "24", "label": "24"},
|
||||
{"id": "25", "label": "25"},
|
||||
{"id": "26", "label": "26"},
|
||||
{"id": "27", "label": "27"},
|
||||
{"id": "28", "label": "28"},
|
||||
{"id": "29", "label": "29"},
|
||||
{"id": "30", "label": "30"},
|
||||
{"id": "31", "label": "31"},
|
||||
{"id": "32", "label": "32"},
|
||||
{"id": "33", "label": "33"}
|
||||
],
|
||||
"edges": [
|
||||
{"source": "0", "target": "1"},
|
||||
{"source": "0", "target": "2"},
|
||||
{"source": "0", "target": "3"},
|
||||
{"source": "0", "target": "4"},
|
||||
{"source": "0", "target": "5"},
|
||||
{"source": "0", "target": "7"},
|
||||
{"source": "0", "target": "8"},
|
||||
{"source": "0", "target": "9"},
|
||||
{"source": "0", "target": "10"},
|
||||
{"source": "0", "target": "11"},
|
||||
{"source": "0", "target": "13"},
|
||||
{"source": "0", "target": "14"},
|
||||
{"source": "0", "target": "15"},
|
||||
{"source": "0", "target": "16"},
|
||||
{"source": "2", "target": "3"},
|
||||
{"source": "4", "target": "5"},
|
||||
{"source": "4", "target": "6"},
|
||||
{"source": "5", "target": "6"},
|
||||
{"source": "7", "target": "13"},
|
||||
{"source": "8", "target": "14"},
|
||||
{"source": "9", "target": "10"},
|
||||
{"source": "10", "target": "22"},
|
||||
{"source": "10", "target": "14"},
|
||||
{"source": "10", "target": "12"},
|
||||
{"source": "10", "target": "24"},
|
||||
{"source": "10", "target": "21"},
|
||||
{"source": "10", "target": "20"},
|
||||
{"source": "11", "target": "24"},
|
||||
{"source": "11", "target": "22"},
|
||||
{"source": "11", "target": "14"},
|
||||
{"source": "12", "target": "13"},
|
||||
{"source": "16", "target": "17"},
|
||||
{"source": "16", "target": "18"},
|
||||
{"source": "16", "target": "21"},
|
||||
{"source": "16", "target": "22"},
|
||||
{"source": "17", "target": "18"},
|
||||
{"source": "17", "target": "20"},
|
||||
{"source": "18", "target": "19"},
|
||||
{"source": "19", "target": "20"},
|
||||
{"source": "19", "target": "33"},
|
||||
{"source": "19", "target": "22"},
|
||||
{"source": "19", "target": "23"},
|
||||
{"source": "20", "target": "21"},
|
||||
{"source": "21", "target": "22"},
|
||||
{"source": "22", "target": "24"},
|
||||
{"source": "22", "target": "25"},
|
||||
{"source": "22", "target": "26"},
|
||||
{"source": "22", "target": "23"},
|
||||
{"source": "22", "target": "28"},
|
||||
{"source": "22", "target": "30"},
|
||||
{"source": "22", "target": "31"},
|
||||
{"source": "22", "target": "32"},
|
||||
{"source": "22", "target": "33"},
|
||||
{"source": "23", "target": "28"},
|
||||
{"source": "23", "target": "27"},
|
||||
{"source": "23", "target": "29"},
|
||||
{"source": "23", "target": "30"},
|
||||
{"source": "23", "target": "31"},
|
||||
{"source": "23", "target": "33"},
|
||||
{"source": "32", "target": "33"}
|
||||
]
|
||||
};
|
||||
|
||||
const data2 = {
|
||||
"nodes": [
|
||||
{"id": "0", "label": "0", "x": 100, "y": 20 },
|
||||
{"id": "1", "label": "1", "x": 10, "y": 210 },
|
||||
{"id": "2", "label": "2", "x": 150, "y": 100 },
|
||||
{"id": "3", "label": "3", "x": 120, "y": 100 },
|
||||
{"id": "4", "label": "4", "x": 50, "y": 250 },
|
||||
{"id": "5", "label": "5", "x": 130, "y": 50 }
|
||||
],
|
||||
"edges": [
|
||||
{"id": "e1", "source": "0", "target": "1"},
|
||||
{"id": "e2", "source": "0", "target": "2"},
|
||||
{"id": "e3", "source": "0", "target": "3"},
|
||||
{"id": "e4", "source": "0", "target": "4"},
|
||||
{"id": "e5", "source": "0", "target": "5"}
|
||||
]
|
||||
}
|
||||
const data3 = {
|
||||
"nodes": [
|
||||
{"id": "0", "label": "0", "x": 10, "y": 120 },
|
||||
{"id": "1", "label": "1", "x": 100, "y": 10 },
|
||||
{"id": "2", "label": "2", "x": 50, "y": 50 }
|
||||
],
|
||||
"edges": [
|
||||
{"id": "e1", "source": "0", "target": "1"},
|
||||
{"id": "e2", "source": "0", "target": "2"}
|
||||
]
|
||||
}
|
||||
|
||||
const graph = new G6.Graph({
|
||||
container: 'mountNode',
|
||||
width: 1000,
|
||||
height: 600,
|
||||
modes: {
|
||||
default: ['drag-canvas', 'drag-node'],
|
||||
},
|
||||
layout: 'random',
|
||||
// layougCfg: {
|
||||
// maxIteration: 8000
|
||||
// },
|
||||
layoutCfg: {
|
||||
center: [ 500, 300 ],
|
||||
},
|
||||
animate: true,
|
||||
defaultNode: {
|
||||
size: [20, 20],
|
||||
color: 'steelblue'
|
||||
},
|
||||
defaultEdge: {
|
||||
size: 1,
|
||||
color: '#e2e2e2'
|
||||
},
|
||||
nodeStyle: {
|
||||
default: {
|
||||
lineWidth: 2,
|
||||
fill: '#fff'
|
||||
},
|
||||
selected: {
|
||||
fill: 'steelblue'
|
||||
}
|
||||
},
|
||||
edgeStyle: {
|
||||
default: {
|
||||
endArrow: {
|
||||
path: 'M 4,0 L -4,-4 L -4,4 Z',
|
||||
d: 4
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
graph.data(data);
|
||||
graph.render();
|
||||
|
||||
setTimeout(() => {
|
||||
// graph.changeData(data2);
|
||||
graph.changeLayout('circular', {
|
||||
radius: 100,
|
||||
startAngle: Math.PI / 4,
|
||||
endAngle: Math.PI,
|
||||
divisions: 5,
|
||||
ordering: 'degree'
|
||||
});
|
||||
// graph.updateLayoutCfg({
|
||||
// linkDistance: 100
|
||||
// // divisions: 3,
|
||||
// // ordering: 'topology'
|
||||
// // maxIteration: 1000
|
||||
// });
|
||||
}, 1000);
|
||||
|
||||
setTimeout(() => {
|
||||
graph.changeLayout('circular', {
|
||||
startRadius: 10,
|
||||
endRadius: 300,
|
||||
divisions: 1,
|
||||
ordering: 'topology'
|
||||
});
|
||||
}, 2000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
@ -100,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/plugins/",
|
||||
"test-live": "torch --compile --interactive --watch --opts test/mocha.opts --recursive ./test/unit/graph/graph-spec.js",
|
||||
"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",
|
||||
|
@ -3,5 +3,6 @@ module.exports = {
|
||||
Event: require('./event'),
|
||||
Mode: require('./mode'),
|
||||
Item: require('./item'),
|
||||
State: require('./state')
|
||||
State: require('./state'),
|
||||
Layout: require('./layout')
|
||||
};
|
||||
|
155
src/graph/controller/layout.js
Normal file
155
src/graph/controller/layout.js
Normal file
@ -0,0 +1,155 @@
|
||||
const Layout = require('../../layout');
|
||||
const Util = require('../../util');
|
||||
|
||||
class LayoutController {
|
||||
constructor(graph) {
|
||||
this.graph = graph;
|
||||
this.layoutType = graph.get('layout');
|
||||
// if (layout === undefined) {
|
||||
// console.log(graph);
|
||||
// if (graph.getNodes()[0].x === undefined) {
|
||||
// // 创建随机布局
|
||||
// const randomLayout = new Layout.Random();
|
||||
// this.set('layout', randomLayout);
|
||||
// } else { // 若未指定布局且数据中有位置信息,则不进行布局,直接按照原数据坐标绘制。
|
||||
// return;
|
||||
// }
|
||||
// }
|
||||
// layout = this._getLayout();
|
||||
this._initLayout();
|
||||
}
|
||||
|
||||
_initLayout() {
|
||||
// const layout = this.layout;
|
||||
// const graph = this.graph;
|
||||
// const nodes = graph.getNodes();
|
||||
// const edges = graph.getEdges();
|
||||
// layout.init(nodes, edges);
|
||||
}
|
||||
|
||||
layout() {
|
||||
const self = this;
|
||||
let layoutType = self.layoutType;
|
||||
const graph = self.graph;
|
||||
const data = self.data || graph.get('data');
|
||||
const nodes = data.nodes || [];
|
||||
const edges = data.edges || [];
|
||||
const width = graph.get('width');
|
||||
const height = graph.get('height');
|
||||
const layoutCfg = [];
|
||||
Util.mix(layoutCfg, {
|
||||
width,
|
||||
height,
|
||||
center: [ width / 2, height / 2 ]
|
||||
}, graph.get('layoutCfg'));
|
||||
|
||||
if (layoutType === undefined) {
|
||||
if (nodes[0] && nodes[0].x === undefined) {
|
||||
// 创建随机布局
|
||||
layoutType = 'random';
|
||||
} else { // 若未指定布局且数据中有位置信息,则不进行布局,直接按照原数据坐标绘制。
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let layoutMethod = self.layoutMethod;
|
||||
if (layoutMethod) {
|
||||
layoutMethod.destroy();
|
||||
}
|
||||
if (layoutType === 'force') {
|
||||
const onTick = layoutCfg.onTick;
|
||||
const tick = () => {
|
||||
onTick && onTick();
|
||||
graph.refreshPositions();
|
||||
};
|
||||
layoutCfg.tick = tick;
|
||||
}
|
||||
layoutMethod = new Layout[layoutType](nodes, edges, layoutCfg);
|
||||
layoutMethod.init();
|
||||
layoutMethod.excute();
|
||||
self.layoutMethod = layoutMethod;
|
||||
|
||||
}
|
||||
|
||||
// 绘制
|
||||
refreshLayout() {
|
||||
const self = this;
|
||||
const graph = self.graph;
|
||||
if (graph.get('animate')) {
|
||||
graph.positionsAnimate();
|
||||
} else {
|
||||
graph.refreshPositions();
|
||||
}
|
||||
}
|
||||
|
||||
// 更新布局参数
|
||||
updateLayoutCfg(cfg) {
|
||||
const self = this;
|
||||
const layoutMethod = self.layoutMethod;
|
||||
layoutMethod.updateCfg(cfg);
|
||||
if (self.layoutType !== 'force') {
|
||||
self.moveToZero();
|
||||
}
|
||||
layoutMethod.excute();
|
||||
self.refreshLayout();
|
||||
}
|
||||
|
||||
// 更换布局
|
||||
changeLayout(layoutType) { // , layoutCfg = null
|
||||
const self = this;
|
||||
self.layoutType = layoutType;
|
||||
const layoutMethod = self.layoutMethod;
|
||||
layoutMethod.destroy();
|
||||
self.moveToZero();
|
||||
self.layout();
|
||||
self.refreshLayout();
|
||||
}
|
||||
|
||||
// 更换数据
|
||||
changeData(data) {
|
||||
const self = this;
|
||||
// const graph = self.graph;
|
||||
self.data = data;
|
||||
self.layout();
|
||||
// graph.refreshPositions();
|
||||
}
|
||||
|
||||
// 控制布局动画
|
||||
layoutAnimate() {
|
||||
}
|
||||
|
||||
// 根据 type 创建 Layout 实例
|
||||
_getLayout() {
|
||||
}
|
||||
|
||||
// 将当前节点的平均中心移动到原点
|
||||
moveToZero() {
|
||||
const self = this;
|
||||
const graph = self.graph;
|
||||
const data = graph.get('data');
|
||||
const nodes = data.nodes;
|
||||
if (nodes[0].x === undefined || nodes[0].x === null || isNaN(nodes[0].x)) {
|
||||
return;
|
||||
}
|
||||
const meanCenter = [ 0, 0 ];
|
||||
nodes.forEach(node => {
|
||||
meanCenter[0] += node.x;
|
||||
meanCenter[1] += node.y;
|
||||
});
|
||||
meanCenter[0] /= nodes.length;
|
||||
meanCenter[1] /= nodes.length;
|
||||
nodes.forEach(node => {
|
||||
node.x -= meanCenter[0];
|
||||
node.y -= meanCenter[1];
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.graph = null;
|
||||
const layoutMethod = this.layoutMethod;
|
||||
layoutMethod.destroy();
|
||||
this.destroyed = true;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = LayoutController;
|
@ -193,7 +193,9 @@ class Graph extends EventEmitter {
|
||||
const modeController = new Controller.Mode(this);
|
||||
const itemController = new Controller.Item(this);
|
||||
const stateController = new Controller.State(this);
|
||||
this.set({ eventController, viewController, modeController, itemController, stateController });
|
||||
const layoutController = new Controller.Layout(this);
|
||||
this.set({ eventController, viewController, modeController,
|
||||
itemController, stateController, layoutController });
|
||||
this._initPlugins();
|
||||
}
|
||||
_initCanvas() {
|
||||
@ -439,6 +441,10 @@ class Graph extends EventEmitter {
|
||||
this.emit('beforerender');
|
||||
const autoPaint = this.get('autoPaint');
|
||||
this.setAutoPaint(false);
|
||||
// layout
|
||||
const layoutController = self.get('layoutController');
|
||||
layoutController.layout();
|
||||
|
||||
Util.each(data.nodes, node => {
|
||||
self.add(NODE, node);
|
||||
});
|
||||
@ -476,6 +482,8 @@ class Graph extends EventEmitter {
|
||||
self.data(data);
|
||||
self.render();
|
||||
}
|
||||
const layoutController = this.get('layoutController');
|
||||
layoutController.changeData(data);
|
||||
const autoPaint = this.get('autoPaint');
|
||||
const itemMap = this.get('itemMap');
|
||||
const items = {
|
||||
@ -998,6 +1006,22 @@ class Graph extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更换布局
|
||||
* @param {string} layoutType 新布局名字
|
||||
* @param {object} cfg 新布局配置项
|
||||
*/
|
||||
changeLayout(layoutType, cfg = null) {
|
||||
const layoutController = this.get('layoutController');
|
||||
this.set('layoutCfg', cfg);
|
||||
layoutController.changeLayout(layoutType);
|
||||
}
|
||||
|
||||
updateLayoutCfg(cfg) {
|
||||
const layoutController = this.get('layoutController');
|
||||
layoutController.updateLayoutCfg(cfg);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除画布元素
|
||||
* @return {object} this
|
||||
@ -1023,6 +1047,7 @@ class Graph extends EventEmitter {
|
||||
this.get('modeController').destroy();
|
||||
this.get('viewController').destroy();
|
||||
this.get('stateController').destroy();
|
||||
this.get('layoutController').destroy();
|
||||
this.get('canvas').destroy();
|
||||
this._cfg = null;
|
||||
this.destroyed = true;
|
||||
|
226
src/layout/circular.js
Normal file
226
src/layout/circular.js
Normal file
@ -0,0 +1,226 @@
|
||||
/**
|
||||
* @fileOverview random layout
|
||||
* @author shiwu.wyy@antfin.com
|
||||
*/
|
||||
|
||||
const Layout = require('./layout');
|
||||
|
||||
function getDegree(n, nodeMap, edges) {
|
||||
const degrees = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
degrees[i] = 0;
|
||||
}
|
||||
edges.forEach(e => {
|
||||
degrees[nodeMap.get(e.source)] += 1;
|
||||
degrees[nodeMap.get(e.target)] += 1;
|
||||
});
|
||||
return degrees;
|
||||
}
|
||||
|
||||
function initHierarchy(nodes, edges, nodeMap, directed) {
|
||||
nodes.forEach((n, i) => {
|
||||
nodes[i].children = [];
|
||||
nodes[i].parent = [];
|
||||
});
|
||||
if (directed) {
|
||||
edges.forEach(e => {
|
||||
const sourceIdx = nodeMap.get(e.source);
|
||||
const targetIdx = nodeMap.get(e.target);
|
||||
nodes[sourceIdx].children.push(nodes[targetIdx]);
|
||||
nodes[targetIdx].parent.push(nodes[sourceIdx]);
|
||||
});
|
||||
} else {
|
||||
edges.forEach(e => {
|
||||
const sourceIdx = nodeMap.get(e.source);
|
||||
const targetIdx = nodeMap.get(e.target);
|
||||
nodes[sourceIdx].children.push(nodes[targetIdx]);
|
||||
nodes[targetIdx].children.push(nodes[sourceIdx]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function connect(a, b, edges) {
|
||||
const m = edges.length;
|
||||
for (let i = 0; i < m; i++) {
|
||||
if ((a.id === edges[i].source && b.id === edges[i].target)
|
||||
|| (b.id === edges[i].source && a.id === edges[i].target)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function compareDegree(a, b) {
|
||||
if (a.degree < b.degree) {
|
||||
return -1;
|
||||
}
|
||||
if (a.degree > b.degree) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 圆形布局
|
||||
*/
|
||||
Layout.registerLayout('circular', {
|
||||
layoutType: 'circular',
|
||||
getDefaultCfg() {
|
||||
return {
|
||||
center: [ 0, 0 ], // 布局中心
|
||||
radius: null, // 默认固定半径,若设置了 radius,则 startRadius 与 endRadius 不起效
|
||||
startRadius: null, // 默认起始半径
|
||||
endRadius: null, // 默认终止半径
|
||||
startAngle: 0, // 默认起始角度
|
||||
endAngle: 2 * Math.PI, // 默认终止角度
|
||||
nodeSize: 10, // 节点半径
|
||||
clockwise: true, // 是否顺时针
|
||||
divisions: 1, // 节点在环上分成段数(几个段将均匀分布),在 endRadius - startRadius != 0 时生效
|
||||
ordering: null, // 节点在环上排序的依据。可选: 'topology', 'degree', 'null'
|
||||
angleRatio: 1 // how many 2*pi from first to last nodes
|
||||
};
|
||||
},
|
||||
/**
|
||||
* 执行布局
|
||||
*/
|
||||
excute() {
|
||||
const self = this;
|
||||
const nodes = self.nodes;
|
||||
const edges = self.edges;
|
||||
const n = nodes.length;
|
||||
const center = self.center;
|
||||
if (n === 0) {
|
||||
return;
|
||||
} else if (n === 1) {
|
||||
nodes[0].x = center[0];
|
||||
nodes[0].y = center[1];
|
||||
return;
|
||||
}
|
||||
|
||||
let radius = self.radius;
|
||||
let startRadius = self.startRadius;
|
||||
let endRadius = self.endRadius;
|
||||
const divisions = self.divisions;
|
||||
const startAngle = self.startAngle;
|
||||
const endAngle = self.endAngle;
|
||||
const angleStep = (endAngle - startAngle) / n;
|
||||
// layout
|
||||
const nodeMap = new Map();
|
||||
nodes.forEach((node, i) => {
|
||||
nodeMap.set(node.id, i);
|
||||
});
|
||||
self.nodeMap = nodeMap;
|
||||
const degrees = getDegree(nodes.length, nodeMap, edges);
|
||||
self.degrees = degrees;
|
||||
|
||||
const width = self.width;
|
||||
const height = self.height;
|
||||
if (!radius && !startRadius && !endRadius) {
|
||||
radius = height > width ? width / 2 : height / 2;
|
||||
} else if (!startRadius && endRadius) {
|
||||
startRadius = endRadius;
|
||||
} else if (startRadius && !endRadius) {
|
||||
endRadius = startRadius;
|
||||
}
|
||||
const angleRatio = self.angleRatio;
|
||||
const astep = angleStep * angleRatio;
|
||||
self.astep = astep;
|
||||
|
||||
const ordering = self.ordering;
|
||||
let layoutNodes = [];
|
||||
if (ordering === 'topology') {
|
||||
// layout according to the topology
|
||||
layoutNodes = self.topologyOrdering();
|
||||
} else if (ordering === 'degree') {
|
||||
// layout according to the descent order of degrees
|
||||
layoutNodes = self.degreeOrdering();
|
||||
} else {
|
||||
// layout according to the original order in the data.nodes
|
||||
layoutNodes = nodes;
|
||||
}
|
||||
|
||||
const clockwise = self.clockwise;
|
||||
const divN = Math.ceil(n / divisions); // node number in each division
|
||||
for (let i = 0; i < n; ++i) {
|
||||
let r = radius;
|
||||
if (!r) {
|
||||
r = startRadius + (i * (endRadius - startRadius)) / (n - 1);
|
||||
}
|
||||
let angle = startAngle + (i % divN) * astep
|
||||
+ ((2 * Math.PI) / divisions) * Math.floor(i / divN);
|
||||
if (!clockwise) {
|
||||
angle = endAngle - (i % divN) * astep
|
||||
- ((2 * Math.PI) / divisions) * Math.floor(i / divN);
|
||||
}
|
||||
layoutNodes[i].x = center[0] + Math.cos(angle) * r;
|
||||
layoutNodes[i].y = center[1] + Math.sin(angle) * r;
|
||||
layoutNodes[i].weight = degrees[i];
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 根据节点的拓扑结构排序
|
||||
* @return {array} orderedNodes 排序后的结果
|
||||
*/
|
||||
topologyOrdering() {
|
||||
const self = this;
|
||||
const degrees = self.degrees;
|
||||
const edges = self.edges;
|
||||
const nodes = self.nodes;
|
||||
const nodeMap = self.nodeMap;
|
||||
const orderedNodes = [ nodes[0] ];
|
||||
const pickFlags = [];
|
||||
const n = nodes.length;
|
||||
pickFlags[0] = true;
|
||||
initHierarchy(nodes, edges, nodeMap, false);
|
||||
let k = 0;
|
||||
nodes.forEach((node, i) => {
|
||||
if (i === 0) return;
|
||||
else if ((i === n - 1 || degrees[i] !== degrees[i + 1]
|
||||
|| connect(orderedNodes[k], node, edges)) && pickFlags[i] !== true) {
|
||||
orderedNodes.push(node);
|
||||
pickFlags[i] = true;
|
||||
k++;
|
||||
} else {
|
||||
const children = orderedNodes[k].children;
|
||||
let foundChild = false;
|
||||
for (let j = 0; j < children.length; ++j) {
|
||||
const childIdx = nodeMap.get(children[j].id);
|
||||
if (degrees[childIdx] === degrees[i] && pickFlags[childIdx] !== true) {
|
||||
orderedNodes.push(nodes[childIdx]);
|
||||
pickFlags[childIdx] = true;
|
||||
foundChild = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let ii = 0;
|
||||
while (!foundChild) {
|
||||
if (!pickFlags[ii]) {
|
||||
orderedNodes.push(nodes[ii]);
|
||||
pickFlags[ii] = true;
|
||||
foundChild = true;
|
||||
}
|
||||
ii++;
|
||||
if (ii === n) break;
|
||||
}
|
||||
}
|
||||
});
|
||||
return orderedNodes;
|
||||
},
|
||||
/**
|
||||
* 根据节点度数大小排序
|
||||
* @return {array} orderedNodes 排序后的结果
|
||||
*/
|
||||
degreeOrdering() {
|
||||
const self = this;
|
||||
const nodes = self.nodes;
|
||||
const orderedNodes = [];
|
||||
const degrees = self.degrees;
|
||||
nodes.forEach((node, i) => {
|
||||
node.degree = degrees[i];
|
||||
orderedNodes.push(node);
|
||||
});
|
||||
orderedNodes.sort(compareDegree);
|
||||
return orderedNodes;
|
||||
}
|
||||
});
|
121
src/layout/force.js
Normal file
121
src/layout/force.js
Normal file
@ -0,0 +1,121 @@
|
||||
/**
|
||||
* @fileOverview random layout
|
||||
* @author shiwu.wyy@antfin.com
|
||||
*/
|
||||
|
||||
const d3Force = require('d3-force');
|
||||
const Layout = require('./layout');
|
||||
|
||||
/**
|
||||
* 经典力导布局 force-directed
|
||||
*/
|
||||
Layout.registerLayout('force', {
|
||||
layoutType: 'force',
|
||||
getDefaultCfg() {
|
||||
return {
|
||||
center: [ 0, 0 ], // 向心力作用点
|
||||
nodeStrength: null, // 节点作用力
|
||||
preventOverlap: false, // 是否防止节点相互覆盖
|
||||
nodeRadius: null, // 节点半径
|
||||
edgeStrength: null, // 边的作用力, 默认为根据节点的入度出度自适应
|
||||
linkDistance: 50, // 默认边长度
|
||||
forceSimulation: null, // 自定义 force 方法
|
||||
maxIteration: null, // 停止迭代的最大迭代数
|
||||
threshold: null, // 停止迭代的阈值
|
||||
onLayoutEnd() {}, // 布局完成回调
|
||||
onTick() {} // 每一迭代布局回调
|
||||
};
|
||||
},
|
||||
/**
|
||||
* 初始化
|
||||
*/
|
||||
init() {
|
||||
const self = this;
|
||||
self.ticking = false;
|
||||
},
|
||||
/**
|
||||
* 执行布局
|
||||
*/
|
||||
excute() {
|
||||
const self = this;
|
||||
const nodes = self.nodes;
|
||||
const edges = self.edges;
|
||||
// 如果正在布局,忽略布局请求
|
||||
if (self.ticking) {
|
||||
return;
|
||||
}
|
||||
let simulation = self.forceSimulation;
|
||||
if (!simulation) {
|
||||
try {
|
||||
// 定义节点的力
|
||||
const nodeForce = d3Force.forceManyBody();
|
||||
if (self.nodeStrength) {
|
||||
nodeForce.strength(self.nodeStrength);
|
||||
}
|
||||
simulation = d3Force.forceSimulation()
|
||||
.nodes(nodes)
|
||||
.force('center', d3Force.forceCenter(self.center[0], self.center[1]))
|
||||
.force('charge', nodeForce)
|
||||
// .alphaTarget(0.3)
|
||||
.on('tick', () => {
|
||||
self.tick();
|
||||
})
|
||||
.on('end', () => {
|
||||
self.ticking = false;
|
||||
self.onLayoutEnd && self.onLayoutEnd();
|
||||
});
|
||||
if (self.preventOverlap) {
|
||||
let nodeRadius = self.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('collisionForce', d3Force.forceCollide(nodeRadius).strength(1));
|
||||
}
|
||||
// 如果有边,定义边的力
|
||||
if (edges) {
|
||||
// d3 的 forceLayout 会重新生成边的数据模型,为了避免污染源数据
|
||||
const d3Edges = edges.map(edge => {
|
||||
return {
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target
|
||||
};
|
||||
});
|
||||
const edgeForce = d3Force.forceLink().id(function(d) { return d.id; }).links(d3Edges);
|
||||
if (self.edgeStrength) {
|
||||
edgeForce.strength(self.edgeStrength);
|
||||
}
|
||||
if (self.linkDistance) {
|
||||
edgeForce.distance(self.linkDistance);
|
||||
}
|
||||
simulation.force('link', edgeForce);
|
||||
}
|
||||
self.forceSimulation = simulation;
|
||||
self.ticking = true;
|
||||
} catch (e) {
|
||||
self.ticking = false;
|
||||
console.warn(e);
|
||||
}
|
||||
} else {
|
||||
simulation.alphaTarget(0.3).restart();
|
||||
this.ticking = true;
|
||||
}
|
||||
},
|
||||
destroy() {
|
||||
if (this.ticking) {
|
||||
this.forceSimulation.stop();
|
||||
}
|
||||
const self = this;
|
||||
self.nodes = null;
|
||||
self.edges = null;
|
||||
self.destroyed = true;
|
||||
}
|
||||
});
|
142
src/layout/fruchterman.js
Normal file
142
src/layout/fruchterman.js
Normal file
@ -0,0 +1,142 @@
|
||||
/**
|
||||
* @fileOverview random layout
|
||||
* @author shiwu.wyy@antfin.com
|
||||
*/
|
||||
|
||||
const Layout = require('./layout');
|
||||
const Util = require('../util');
|
||||
|
||||
const SPEED_DIVISOR = 800;
|
||||
|
||||
/**
|
||||
* fruchterman 布局
|
||||
*/
|
||||
Layout.registerLayout('fruchterman', {
|
||||
layoutType: 'fruchterman',
|
||||
getDefaultCfg() {
|
||||
return {
|
||||
center: [ 0, 0 ], // 布局中心
|
||||
maxIteration: 8000, // 停止迭代的最大迭代数
|
||||
gravity: 10, // 重力大小,影响图的紧凑程度
|
||||
speed: 1 // 速度
|
||||
};
|
||||
},
|
||||
/**
|
||||
* 执行布局
|
||||
*/
|
||||
excute() {
|
||||
const self = this;
|
||||
const nodes = self.nodes;
|
||||
const center = self.center;
|
||||
const width = self.width;
|
||||
const height = self.height;
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return;
|
||||
} else if (nodes.length === 1) {
|
||||
nodes[0].x = center[0];
|
||||
nodes[0].y = center[1];
|
||||
return;
|
||||
}
|
||||
const positions = Util.randomInitPos(nodes.length, [ 0, width ], [ 0, height ]);
|
||||
const nodeMap = new Map();
|
||||
const nodeIndexMap = new Map();
|
||||
const hasOriPos = (nodes[0].x !== undefined && nodes[0].x !== null && !isNaN(nodes[0].x));
|
||||
nodes.forEach((node, i) => {
|
||||
if (!hasOriPos) {
|
||||
node.x = positions[i][0];
|
||||
node.y = positions[i][1];
|
||||
}
|
||||
nodeMap.set(node.id, node);
|
||||
nodeIndexMap.set(node.id, i);
|
||||
});
|
||||
self.nodeMap = nodeMap;
|
||||
self.nodeIndexMap = nodeIndexMap;
|
||||
// layout
|
||||
self.run();
|
||||
nodes.forEach(node => {
|
||||
node.x += center[0];
|
||||
node.y += center[1];
|
||||
});
|
||||
},
|
||||
run() {
|
||||
const self = this;
|
||||
const nodes = self.nodes;
|
||||
const edges = self.edges;
|
||||
const maxIteration = self.maxIteration;
|
||||
const width = self.width;
|
||||
const height = self.height;
|
||||
const nodeMap = self.nodeMap;
|
||||
const nodeIndexMap = self.nodeIndexMap;
|
||||
const maxDisplace = width / 10;
|
||||
const k = Math.sqrt(width * height / (nodes.length + 1));
|
||||
const gravity = self.gravity;
|
||||
const speed = self.speed;
|
||||
for (let i = 0; i < maxIteration; i++) {
|
||||
const disp = [];
|
||||
nodes.forEach((n, i) => {
|
||||
disp[i] = { x: 0, y: 0 };
|
||||
});
|
||||
self.getDisp(nodes, edges, nodeMap, nodeIndexMap, disp, k);
|
||||
|
||||
// gravity
|
||||
nodes.forEach((n, i) => {
|
||||
const distLength = Math.sqrt(disp[i].x * disp[i].x + disp[i].y * disp[i].y);
|
||||
const gravityForce = 0.01 * k * gravity * distLength;
|
||||
disp[i].x -= gravityForce * n.x / distLength;
|
||||
disp[i].y -= gravityForce * n.y / distLength;
|
||||
});
|
||||
// speed
|
||||
nodes.forEach((n, i) => {
|
||||
disp[i].dx *= speed / SPEED_DIVISOR;
|
||||
disp[i].dy *= speed / SPEED_DIVISOR;
|
||||
});
|
||||
|
||||
// move
|
||||
nodes.forEach((n, i) => {
|
||||
const distLength = Math.sqrt(disp[i].x * disp[i].x + disp[i].y * disp[i].y);
|
||||
if (distLength > 0) { // && !n.isFixed()
|
||||
const limitedDist = Math.min(maxDisplace * (speed / SPEED_DIVISOR), distLength);
|
||||
n.x += disp[i].x / distLength * limitedDist;
|
||||
n.y += disp[i].y / distLength * limitedDist;
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
getDisp(nodes, edges, nodeMap, nodeIndexMap, disp, k) {
|
||||
const self = this;
|
||||
self.calRepulsive(nodes, disp, k);
|
||||
self.calAttractive(edges, nodeMap, nodeIndexMap, disp, k);
|
||||
},
|
||||
calRepulsive(nodes, disp, k) {
|
||||
nodes.forEach((v, i) => {
|
||||
disp[i] = { x: 0, y: 0 };
|
||||
nodes.forEach((u, j) => {
|
||||
if (i === j) return;
|
||||
const vecx = v.x - u.x;
|
||||
const vecy = v.y - u.y;
|
||||
let vecLengthSqr = vecx * vecx + vecy * vecy;
|
||||
if (vecLengthSqr === 0) vecLengthSqr = 1;
|
||||
const common = (k * k) / vecLengthSqr;
|
||||
disp[i].x += vecx * common;
|
||||
disp[i].y += vecy * common;
|
||||
});
|
||||
});
|
||||
},
|
||||
calAttractive(edges, nodeMap, nodeIndexMap, disp, k) {
|
||||
edges.forEach(e => {
|
||||
const uIndex = nodeIndexMap.get(e.source);
|
||||
const vIndex = nodeIndexMap.get(e.target);
|
||||
const u = nodeMap.get(e.source);
|
||||
const v = nodeMap.get(e.target);
|
||||
const vecx = v.x - u.x;
|
||||
const vecy = v.y - u.y;
|
||||
const vecLength = Math.sqrt(vecx * vecx + vecy * vecy);
|
||||
const common = vecLength * vecLength / k;
|
||||
disp[vIndex].x -= vecx / vecLength * common;
|
||||
disp[vIndex].y -= vecy / vecLength * common;
|
||||
disp[uIndex].x += vecx / vecLength * common;
|
||||
disp[uIndex].y += vecy / vecLength * common;
|
||||
});
|
||||
}
|
||||
});
|
15
src/layout/index.js
Normal file
15
src/layout/index.js
Normal file
@ -0,0 +1,15 @@
|
||||
/**
|
||||
* @fileOverview layout entry file
|
||||
* @author shiwu.wyy@antfin.com
|
||||
*/
|
||||
|
||||
const Layout = require('./layout');
|
||||
module.exports = {
|
||||
Random: require('./random'),
|
||||
Mds: require('./mds'),
|
||||
Circular: require('./circular'),
|
||||
Fruchterman: require('./fruchterman'),
|
||||
Radial: require('./radial/radial'),
|
||||
Force: require('./force')
|
||||
};
|
||||
module.exports = Layout;
|
63
src/layout/layout.js
Normal file
63
src/layout/layout.js
Normal file
@ -0,0 +1,63 @@
|
||||
/**
|
||||
* @fileOverview layout base file
|
||||
* @author shiwu.wyy@antfin.com
|
||||
*/
|
||||
|
||||
const Util = require('../util');
|
||||
const Layout = {};
|
||||
|
||||
/**
|
||||
* 注册布局的方法
|
||||
* @param {string} type 布局类型,外部引用指定必须,不要与已有布局类型重名
|
||||
* @param {object} layout 行为内容
|
||||
*/
|
||||
Layout.registerLayout = function(type, layout) {
|
||||
if (!layout) {
|
||||
throw new Error('please specify handler for this layout:' + type);
|
||||
}
|
||||
const base = function(nodes, edges, cfg) {
|
||||
const self = this;
|
||||
self.nodes = nodes;
|
||||
self.edges = edges;
|
||||
Util.mix(self, self.getDefaultCfg(), cfg);
|
||||
self.init();
|
||||
};
|
||||
Util.augment(base, {
|
||||
/**
|
||||
* 初始化
|
||||
*/
|
||||
init() {
|
||||
// const self = this;
|
||||
},
|
||||
/**
|
||||
* 执行布局,不改变原数据模型位置,只返回布局后但结果位置
|
||||
*/
|
||||
excute() {
|
||||
},
|
||||
/**
|
||||
* 更新布局配置,但不执行布局
|
||||
* @param {object} cfg 需要更新的配置项
|
||||
*/
|
||||
updateCfg(cfg) {
|
||||
const self = this;
|
||||
Util.mix(self, cfg);
|
||||
},
|
||||
/**
|
||||
* 销毁
|
||||
*/
|
||||
destroy() {
|
||||
const self = this;
|
||||
self.positions = null;
|
||||
self.nodes = null;
|
||||
self.edges = null;
|
||||
self.destroyed = true;
|
||||
},
|
||||
/**
|
||||
* 定义自定义行为的默认参数,会与用户传入的参数进行合并
|
||||
*/
|
||||
getDefaultCfg() {}
|
||||
}, layout);
|
||||
Layout[type] = base;
|
||||
};
|
||||
|
||||
module.exports = Layout;
|
80
src/layout/mds.js
Normal file
80
src/layout/mds.js
Normal file
@ -0,0 +1,80 @@
|
||||
/**
|
||||
* @fileOverview random layout
|
||||
* @author shiwu.wyy@antfin.com
|
||||
*/
|
||||
|
||||
const Layout = require('./layout');
|
||||
const Util = require('../util');
|
||||
const Numeric = require('numericjs');
|
||||
|
||||
/**
|
||||
* mds 布局
|
||||
*/
|
||||
Layout.registerLayout('mds', {
|
||||
layoutType: 'mds',
|
||||
getDefaultCfg() {
|
||||
return {
|
||||
center: [ 0, 0 ], // 布局中心
|
||||
linkDistance: 50 // 默认边长度
|
||||
};
|
||||
},
|
||||
/**
|
||||
* 执行布局
|
||||
*/
|
||||
excute() {
|
||||
const self = this;
|
||||
const nodes = self.nodes;
|
||||
const edges = self.edges;
|
||||
const center = self.center;
|
||||
if (nodes.length === 0) return;
|
||||
else if (nodes.length === 1) {
|
||||
nodes[0].x = center[0];
|
||||
nodes[0].y = center[1];
|
||||
}
|
||||
const linkDistance = self.linkDistance;
|
||||
// the graph-theoretic distance (shortest path distance) matrix
|
||||
const adjMatrix = Util.getAdjMatrix({ nodes, edges }, false);
|
||||
const distances = Util.floydWarshall(adjMatrix);
|
||||
self.distances = distances;
|
||||
|
||||
// scale the ideal edge length acoording to linkDistance
|
||||
const scaledD = Util.scaleMatrix(distances, linkDistance);
|
||||
self.scaledDistances = scaledD;
|
||||
|
||||
// get positions by MDS
|
||||
const positions = self.runMDS();
|
||||
self.positions = positions;
|
||||
positions.forEach((p, i) => {
|
||||
nodes[i].x = p[0] + center[0];
|
||||
nodes[i].y = p[1] + center[1];
|
||||
});
|
||||
},
|
||||
/**
|
||||
* mds 算法
|
||||
* @return {array} positions 计算后的节点位置数组
|
||||
*/
|
||||
runMDS() {
|
||||
const self = this;
|
||||
const dimension = 2;
|
||||
const distances = self.scaledDistances;
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
});
|
70
src/layout/radial/mds.js
Normal file
70
src/layout/radial/mds.js
Normal file
@ -0,0 +1,70 @@
|
||||
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;
|
||||
/**
|
||||
* link distance
|
||||
* @type {number}
|
||||
*/
|
||||
this.linkDistance = params.linkDistance;
|
||||
}
|
||||
layout() {
|
||||
const self = this;
|
||||
const dimension = self.dimension;
|
||||
const distances = self.distances;
|
||||
const linkDistance = self.linkDistance;
|
||||
|
||||
// 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
|
||||
let ret;
|
||||
let res = [];
|
||||
try {
|
||||
ret = Numeric.svd(M);
|
||||
} catch (e) {
|
||||
const length = distances.length;
|
||||
for (let i = 0; i < length; i++) {
|
||||
const x = Math.random() * linkDistance;
|
||||
const y = Math.random() * linkDistance;
|
||||
res.push([ x, y ]);
|
||||
}
|
||||
}
|
||||
if (res.length === 0) {
|
||||
const eigenValues = Numeric.sqrt(ret.S);
|
||||
res = ret.U.map(function(row) {
|
||||
return Numeric.mul(row, eigenValues).splice(0, dimension);
|
||||
});
|
||||
}
|
||||
return res;
|
||||
}
|
||||
}
|
||||
module.exports = MDS;
|
262
src/layout/radial/radial.js
Normal file
262
src/layout/radial/radial.js
Normal file
@ -0,0 +1,262 @@
|
||||
/**
|
||||
* @fileOverview random layout
|
||||
* @author shiwu.wyy@antfin.com
|
||||
*/
|
||||
|
||||
const Layout = require('../layout');
|
||||
const Util = require('../../util');
|
||||
const RadialNonoverlapForce = require('./radialNonoverlapForce');
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 随机布局
|
||||
*/
|
||||
Layout.registerLayout('radial', {
|
||||
layoutType: 'radial',
|
||||
getDefaultCfg() {
|
||||
return {
|
||||
center: [ 0, 0 ], // 布局中心
|
||||
maxIteration: 1000, // 停止迭代的最大迭代数
|
||||
focusNode: null, // 中心点,默认为数据中第一个点
|
||||
unitRadius: null, // 每一圈半径
|
||||
linkDistance: 50, // 默认边长度
|
||||
nonOverlap: false, // 是否防止重叠
|
||||
nodeSize: 10 // 节点半径
|
||||
};
|
||||
},
|
||||
/**
|
||||
* 执行布局
|
||||
*/
|
||||
excute() {
|
||||
const self = this;
|
||||
const nodes = self.nodes;
|
||||
const edges = self.edges;
|
||||
const center = self.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.linkDistance;
|
||||
const unitRadius = self.unitRadius;
|
||||
// layout
|
||||
let focusNode = self.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.focusNode = focusNode;
|
||||
found = true;
|
||||
i = nodes.length;
|
||||
}
|
||||
}
|
||||
if (!found) focusNode = null;
|
||||
}
|
||||
// default focus node
|
||||
if (!focusNode) {
|
||||
focusNode = nodes[0];
|
||||
if (!focusNode) return;
|
||||
self.focusNode = focusNode;
|
||||
}
|
||||
// the index of the focusNode in data
|
||||
const focusIndex = getIndexById(nodes, focusNode.id);
|
||||
self.focusIndex = focusIndex;
|
||||
|
||||
// the graph-theoretic distance (shortest path distance) matrix
|
||||
const adjMatrix = Util.getAdjMatrix({ nodes, edges }, false);
|
||||
self.handleAbnormalMatrix(adjMatrix, focusIndex);
|
||||
const D = Util.floydWarshall(adjMatrix);
|
||||
const connected = Util.isConnected(D);
|
||||
self.distances = D;
|
||||
|
||||
// the shortest path distance from each node to focusNode
|
||||
const focusNodeD = D[focusIndex];
|
||||
const width = self.width;
|
||||
const height = self.height;
|
||||
const semiWidth = width - center[0] > center[0] ? center[0] : width - center[0];
|
||||
const semiHeight = height - center[1] > center[1] ? center[1] : height - center[1];
|
||||
// the maxRadius of the graph
|
||||
const maxRadius = semiHeight > semiWidth ? semiWidth : semiHeight;
|
||||
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.radii = radii;
|
||||
|
||||
|
||||
const eIdealD = self.eIdealDisMatrix(D, linkDistance, radii);
|
||||
// const eIdealD = scaleMatrix(D, linkDistance);
|
||||
self.eIdealDistances = eIdealD;
|
||||
// the weight matrix, Wij = 1 / dij^(-2)
|
||||
const W = getWeightMatrix(eIdealD);
|
||||
self.weights = W;
|
||||
|
||||
// the initial positions from mds
|
||||
const mds = new MDS({ distances: eIdealD, linkDistance, dimension: 2 });
|
||||
let positions = mds.layout();
|
||||
positions.forEach(p => {
|
||||
if (isNaN(p[0])) p[0] = Math.random() * linkDistance;
|
||||
if (isNaN(p[1])) p[1] = Math.random() * linkDistance;
|
||||
});
|
||||
self.positions = positions;
|
||||
positions.forEach((p, i) => {
|
||||
nodes[i].x = p[0] + center[0];
|
||||
nodes[i].y = p[1] + center[1];
|
||||
});
|
||||
// if the graph is connected, layout by radial layout and force nonoverlap
|
||||
if (connected) {
|
||||
// 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.nonOverlap;
|
||||
const nodeSize = self.nodeSize;
|
||||
// stagger the overlapped nodes
|
||||
if (nonOverlap) {
|
||||
const nonoverlapForce = new RadialNonoverlapForce({
|
||||
nodeSize, adjMatrix, positions, radii, height, width,
|
||||
focusID: focusIndex,
|
||||
iterations: 200,
|
||||
k: positions.length / 4.5
|
||||
});
|
||||
positions = nonoverlapForce.layout();
|
||||
}
|
||||
// move the graph to center
|
||||
positions.forEach((p, i) => {
|
||||
nodes[i].x = p[0] + center[0];
|
||||
nodes[i].y = p[1] + center[1];
|
||||
});
|
||||
}
|
||||
},
|
||||
run() {
|
||||
const self = this;
|
||||
const maxIteration = self.maxIteration;
|
||||
const positions = self.positions;
|
||||
const W = self.weights;
|
||||
const eIdealDis = self.eIdealDistances;
|
||||
const radii = self.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.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;
|
||||
// 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;
|
||||
});
|
||||
},
|
||||
eIdealDisMatrix() {
|
||||
const self = this;
|
||||
const D = self.distances;
|
||||
const linkDis = self.linkDistance;
|
||||
const radii = self.radii;
|
||||
const unitRadius = self.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;
|
||||
},
|
||||
handleAbnormalMatrix(matrix, focusIndex) {
|
||||
const rows = matrix.length;
|
||||
let emptyMatrix = true;
|
||||
for (let i = 0; i < rows; i++) {
|
||||
if (matrix[i].length !== 0) emptyMatrix = false;
|
||||
let hasDis = true;
|
||||
for (let j = 0; j < matrix[i].length; j++) {
|
||||
if (!matrix[i][j]) hasDis = false;
|
||||
}
|
||||
if (hasDis) {
|
||||
matrix[i][focusIndex] = 1;
|
||||
matrix[focusIndex][i] = 1;
|
||||
}
|
||||
}
|
||||
if (emptyMatrix) {
|
||||
let value = 0;
|
||||
for (let i = 0; i < rows; i++) {
|
||||
for (let j = 0; j < rows; j++) {
|
||||
if (i === focusIndex || j === focusIndex) value = 1;
|
||||
matrix[i][j] = value;
|
||||
value = 0;
|
||||
}
|
||||
value = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
156
src/layout/radial/radialNonoverlapForce.js
Normal file
156
src/layout/radial/radialNonoverlapForce.js
Normal file
@ -0,0 +1,156 @@
|
||||
const SPEED_DIVISOR = 800;
|
||||
|
||||
class RadialNonoverlapForce {
|
||||
constructor(params) {
|
||||
/**
|
||||
* node positions
|
||||
* @type {array}
|
||||
*/
|
||||
this.positions = params.positions;
|
||||
/**
|
||||
* adjacency matrix
|
||||
* @type {array}
|
||||
*/
|
||||
this.adjMatrix = params.adjMatrix;
|
||||
/**
|
||||
* focus node
|
||||
* @type {array}
|
||||
*/
|
||||
this.focusID = params.focusID;
|
||||
/**
|
||||
* radii
|
||||
* @type {number}
|
||||
*/
|
||||
this.radii = params.radii;
|
||||
/**
|
||||
* the number of iterations
|
||||
* @type {number}
|
||||
*/
|
||||
this.iterations = params.iterations || 10;
|
||||
/**
|
||||
* the height of the canvas
|
||||
* @type {number}
|
||||
*/
|
||||
this.height = params.height || 10;
|
||||
/**
|
||||
* the width of the canvas
|
||||
* @type {number}
|
||||
*/
|
||||
this.width = params.width || 10;
|
||||
/**
|
||||
* the moving speed
|
||||
* @type {number}
|
||||
*/
|
||||
this.speed = params.speed || 100;
|
||||
/**
|
||||
* the gravity
|
||||
* @type {number}
|
||||
*/
|
||||
this.gravity = params.gravity || 10;
|
||||
/**
|
||||
* the node size
|
||||
* @type {number}
|
||||
*/
|
||||
this.nodeSize = params.nodeSize || 35;
|
||||
/**
|
||||
* the strength of forces
|
||||
* @type {number}
|
||||
*/
|
||||
this.k = params.k || 5;
|
||||
}
|
||||
layout() {
|
||||
const self = this;
|
||||
const positions = self.positions;
|
||||
const disp = [];
|
||||
const iterations = self.iterations;
|
||||
const maxDisplace = self.width / 10;
|
||||
self.maxDisplace = maxDisplace;
|
||||
self.disp = disp;
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
positions.forEach((p, k) => {
|
||||
disp[k] = { x: 0, y: 0 };
|
||||
});
|
||||
// 给重叠的节点增加斥力
|
||||
self.getRepulsion();
|
||||
self.updatePositions();
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
getRepulsion() {
|
||||
const self = this;
|
||||
const positions = self.positions;
|
||||
const disp = self.disp;
|
||||
const k = self.k;
|
||||
const radii = self.radii;
|
||||
|
||||
positions.forEach((v, i) => {
|
||||
disp[i] = { x: 0, y: 0 };
|
||||
positions.forEach((u, j) => {
|
||||
if (i === j) return;
|
||||
// v and u are not on the same circle, return
|
||||
if (radii[i] !== radii[j]) return;
|
||||
const vecx = v[0] - u[0];
|
||||
const vecy = v[1] - u[1];
|
||||
let vecLength = Math.sqrt(vecx * vecx + vecy * vecy);
|
||||
if (vecLength === 0) vecLength = 1;
|
||||
// these two nodes overlap
|
||||
if (vecLength < self.nodeSize) {
|
||||
const common = k * k / (vecLength);
|
||||
disp[i].x += vecx / vecLength * common;
|
||||
disp[i].y += vecy / vecLength * common;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
updatePositions() {
|
||||
const self = this;
|
||||
const positions = self.positions;
|
||||
const disp = self.disp;
|
||||
const speed = self.speed;
|
||||
const f = self.focusID;
|
||||
|
||||
disp.forEach((di, i) => {
|
||||
const vx = positions[i][0] - positions[f][0];
|
||||
const vy = positions[i][1] - positions[f][1];
|
||||
const vLength = Math.sqrt(vx * vx + vy * vy);
|
||||
let vpx = vy / vLength;
|
||||
let vpy = -vx / vLength;
|
||||
const diLength = Math.sqrt(di.x * di.x + di.y * di.y);
|
||||
let alpha = Math.acos((vpx * di.x + vpy * di.y) / diLength);
|
||||
if (alpha > Math.PI / 2) {
|
||||
alpha -= Math.PI / 2;
|
||||
vpx *= -1;
|
||||
vpy *= -1;
|
||||
}
|
||||
const tdispLength = Math.cos(alpha) * diLength;
|
||||
di.x = vpx * tdispLength;
|
||||
di.y = vpy * tdispLength;
|
||||
});
|
||||
|
||||
// speed
|
||||
positions.forEach((n, i) => {
|
||||
disp[i].dx *= speed / SPEED_DIVISOR;
|
||||
disp[i].dy *= speed / SPEED_DIVISOR;
|
||||
});
|
||||
|
||||
// move
|
||||
const radii = self.radii;
|
||||
positions.forEach((n, i) => {
|
||||
if (i === f) return;
|
||||
const distLength = Math.sqrt(disp[i].x * disp[i].x + disp[i].y * disp[i].y);
|
||||
if (distLength > 0 && i !== f) {
|
||||
const limitedDist = Math.min(self.maxDisplace * (speed / SPEED_DIVISOR), distLength);
|
||||
n[0] += disp[i].x / distLength * limitedDist;
|
||||
n[1] += disp[i].y / distLength * limitedDist;
|
||||
let vx = n[0] - positions[f][0];
|
||||
let vy = n[1] - positions[f][1];
|
||||
const nfDis = Math.sqrt(vx * vx + vy * vy);
|
||||
vx = vx / nfDis * radii[i];
|
||||
vy = vy / nfDis * radii[i];
|
||||
n[0] = positions[f][0] + vx;
|
||||
n[1] = positions[f][1] + vy;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
module.exports = RadialNonoverlapForce;
|
33
src/layout/random.js
Normal file
33
src/layout/random.js
Normal file
@ -0,0 +1,33 @@
|
||||
/**
|
||||
* @fileOverview random layout
|
||||
* @author shiwu.wyy@antfin.com
|
||||
*/
|
||||
|
||||
const Layout = require('./layout');
|
||||
|
||||
/**
|
||||
* 随机布局
|
||||
*/
|
||||
Layout.registerLayout('random', {
|
||||
layoutType: 'random',
|
||||
getDefaultCfg() {
|
||||
return {
|
||||
center: [ 0, 0 ] // 布局中心
|
||||
};
|
||||
},
|
||||
/**
|
||||
* 执行布局
|
||||
*/
|
||||
excute() {
|
||||
const self = this;
|
||||
const nodes = self.nodes;
|
||||
const layoutScale = 0.9;
|
||||
const center = self.center;
|
||||
const semiWidth = (self.width - center[0]) > center[0] ? center[0] : (self.width - center[0]);
|
||||
const semiHeight = (self.height - center[1]) > center[1] ? center[1] : (self.height - center[1]);
|
||||
nodes.forEach(node => {
|
||||
node.x = (Math.random() - 0.5) * layoutScale * semiWidth + center[0];
|
||||
node.y = (Math.random() - 0.5) * layoutScale * semiHeight + center[1];
|
||||
});
|
||||
}
|
||||
});
|
34
test/unit/layout/random-spec.js
Normal file
34
test/unit/layout/random-spec.js
Normal file
@ -0,0 +1,34 @@
|
||||
const expect = require('chai').expect;
|
||||
const G6 = require('../../../src');
|
||||
|
||||
// const Util = require('../../../src/util');
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.id = 'graph-spec';
|
||||
document.body.appendChild(div);
|
||||
|
||||
const data = {
|
||||
nodes: [
|
||||
{
|
||||
id: '0'
|
||||
},
|
||||
{
|
||||
id: '1'
|
||||
}
|
||||
],
|
||||
edges: [
|
||||
]
|
||||
};
|
||||
|
||||
describe('random', () => {
|
||||
it('new graph without layout', () => {
|
||||
const graph = new G6.Graph({
|
||||
container: div,
|
||||
width: 500,
|
||||
height: 500
|
||||
});
|
||||
graph.data(data);
|
||||
graph.render();
|
||||
// expect(length - div.childNodes.length).to.equal(1);
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue
Block a user