feat: innter layouts

This commit is contained in:
shiwu.wyy 2019-09-16 19:14:39 +08:00
parent 69899661b6
commit d3ad5032bf
16 changed files with 1599 additions and 3 deletions

213
demos/layout-test.html Normal file
View 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>

View File

@ -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",

View File

@ -3,5 +3,6 @@ module.exports = {
Event: require('./event'),
Mode: require('./mode'),
Item: require('./item'),
State: require('./state')
State: require('./state'),
Layout: require('./layout')
};

View 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;

View File

@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}
}
}
});

View 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
View 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];
});
}
});

View 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);
});
});