Merge branch 'dev3.1.0' of https://github.com/antvis/g6 into dev3.1.0

This commit is contained in:
baizn 2019-10-24 21:19:48 +08:00
commit cff5d62242
21 changed files with 9990 additions and 102 deletions

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,221 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Circle节点分组 fruchterman 布局</title>
</head>
<body>
<div id="mountNode"></div>
<script src="../build/g6.js"></script>
<script>
/**
* 该案例演示以下功能:
* 1、渲染群组所需要的数据结构
* 2、如何拖动一个群组
* 3、将节点从群组中拖出
* 4、将节点拖入到某个群组中
* 5、拖出拖入节点后动态改变群组大小。
*/
const graph = new G6.Graph({
container: 'mountNode',
width: 800,
height: 600,
layout: {
type: 'fruchtermanGroup'
},
defaultNode: {
shape: 'circle',
size: 10
},
defaultEdge: {
color: '#bae7ff'
},
modes: {
default: [ 'drag-canvas', 'drag-group', 'drag-node-with-group', 'collapse-expand-group' ]
}
});
const oneNodes = []
for(let i = 0; i < 50; i++) {
oneNodes.push({
id: `node-1-${i}`,
groupId: 'group3',
label: `node-1-${i}-group3`,
// ox: 100 + 5 * i,
// oy: 300 + 5 * i,
shape: 'circle'
})
}
const data = {
nodes: [
// ...oneNodes,
{
id: 'node6',
groupId: 'group3',
label: 'node6-group3',
ox: 100,
oy: 300,
shape: 'rect',
size: [40, 20]
},
{
id: 'node6-1',
groupId: 'group3',
label: 'node6-1-group3',
ox: 110,
oy: 300,
shape: 'rect',
size: [40, 20]
},
{
id: 'node6-2',
groupId: 'group3',
label: 'node6-2-group3',
ox: 120,
oy: 300,
shape: 'rect',
size: [40, 20]
},
{
id: 'node6-3',
groupId: 'group3',
label: 'node6-3-group3',
ox: 140,
oy: 300,
shape: 'rect',
size: [40, 20]
},
{
id: 'node6-4',
groupId: 'group3',
label: 'node6-4-group3',
ox: 150,
oy: 300,
shape: 'rect',
size: [40, 20]
},
{
id: 'node6-5',
groupId: 'group3',
label: 'node6-5-group3',
ox: 160,
oy: 300,
shape: 'rect',
size: [40, 20]
},
{
id: 'node1',
label: 'node1-group1',
groupId: 'group1',
ox: 100,
oy: 100
},
{
id: 'node9',
label: 'node9-p1',
groupId: 'p1',
ox: 300,
oy: 210
},
{
id: 'node2',
label: 'node2-group1',
groupId: 'group1',
ox: 150,
oy: 200
},
{
id: 'node3',
label: 'node3-group2',
groupId: 'group2',
ox: 300,
oy: 100
},
{
id: 'node7',
groupId: 'p1',
label: 'node7-p1',
ox: 200,
oy: 200
},
{
id: 'node10',
label: 'node10-p2',
groupId: 'p2',
ox: 300,
oy: 210
}
],
edges: [
{
source: 'node1',
target: 'node2'
},
{
source: 'node2',
target: 'node3'
},
{
source: 'node1',
target: 'node3'
},
{
source: 'node6',
target: 'node1'
}
],
groups: [
{
id: 'group1',
title: {
text: 'group1',
stroke: '#444',
offsetox: -20,
offsetoy: 30
},
parentId: 'p1'
},
{
id: 'group2',
title: {
text: 'group2'
},
title: '2',
parentId: 'p1'
},
{
id: 'group3',
title: {
text: 'group3',
stroke: '#444',
offsetox: -20,
offsetoy: 30
},
parentId: 'p2'
},
{
id: 'p1',
title: {
text: 'p1'
},
title: '3'
},
{
id: 'p2',
title: {
text: 'p2'
},
title: '3'
}
]
};
graph.data(data)
graph.render()
console.log(graph);
</script>
</body>
</html>

View File

@ -0,0 +1,222 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Circle节点分组 fruchterman 布局</title>
</head>
<body>
<div id="mountNode"></div>
<script src="../build/g6.js"></script>
<script>
/**
* 该案例演示以下功能:
* 1、渲染群组所需要的数据结构
* 2、如何拖动一个群组
* 3、将节点从群组中拖出
* 4、将节点拖入到某个群组中
* 5、拖出拖入节点后动态改变群组大小。
*/
const graph = new G6.Graph({
container: 'mountNode',
width: 800,
height: 600,
layout: {
type: 'fruchtermanGroup'
},
defaultNode: {
shape: 'circle',
size: 10
},
defaultEdge: {
color: '#bae7ff'
},
modes: {
default: [ 'drag-canvas', 'drag-group', 'drag-node-with-group', 'collapse-expand-group' ]
},
groupType: 'rect'
});
const oneNodes = []
for(let i = 0; i < 50; i++) {
oneNodes.push({
id: `node-1-${i}`,
groupId: 'group3',
label: `node-1-${i}-group3`,
// ox: 100 + 5 * i,
// oy: 300 + 5 * i,
shape: 'circle'
})
}
const data = {
nodes: [
// ...oneNodes,
{
id: 'node6',
groupId: 'group3',
label: 'node6-group3',
ox: 100,
oy: 300,
shape: 'rect',
size: [40, 20]
},
{
id: 'node6-1',
groupId: 'group3',
label: 'node6-1-group3',
ox: 110,
oy: 300,
shape: 'rect',
size: [40, 20]
},
{
id: 'node6-2',
groupId: 'group3',
label: 'node6-2-group3',
ox: 120,
oy: 300,
shape: 'rect',
size: [40, 20]
},
{
id: 'node6-3',
groupId: 'group3',
label: 'node6-3-group3',
ox: 140,
oy: 300,
shape: 'rect',
size: [40, 20]
},
{
id: 'node6-4',
groupId: 'group3',
label: 'node6-4-group3',
ox: 150,
oy: 300,
shape: 'rect',
size: [40, 20]
},
{
id: 'node6-5',
groupId: 'group3',
label: 'node6-5-group3',
ox: 160,
oy: 300,
shape: 'rect',
size: [40, 20]
},
{
id: 'node1',
label: 'node1-group1',
groupId: 'group1',
ox: 100,
oy: 100
},
{
id: 'node9',
label: 'node9-p1',
groupId: 'p1',
ox: 300,
oy: 210
},
{
id: 'node2',
label: 'node2-group1',
groupId: 'group1',
ox: 150,
oy: 200
},
{
id: 'node3',
label: 'node3-group2',
groupId: 'group2',
ox: 300,
oy: 100
},
{
id: 'node7',
groupId: 'p1',
label: 'node7-p1',
ox: 200,
oy: 200
},
{
id: 'node10',
label: 'node10-p2',
groupId: 'p2',
ox: 300,
oy: 210
}
],
edges: [
{
source: 'node1',
target: 'node2'
},
{
source: 'node2',
target: 'node3'
},
{
source: 'node1',
target: 'node3'
},
{
source: 'node6',
target: 'node1'
}
],
groups: [
{
id: 'group1',
title: {
text: 'group1',
stroke: '#444',
offsetox: -20,
offsetoy: 30
},
parentId: 'p1'
},
{
id: 'group2',
title: {
text: 'group2'
},
title: '2',
parentId: 'p1'
},
{
id: 'group3',
title: {
text: 'group3',
stroke: '#444',
offsetox: -20,
offsetoy: 30
},
parentId: 'p2'
},
{
id: 'p1',
title: {
text: 'p1'
},
title: '3'
},
{
id: 'p2',
title: {
text: 'p2'
},
title: '3'
}
]
};
graph.data(data)
graph.render()
console.log(graph);
</script>
</body>
</html>

View File

@ -7,64 +7,122 @@
</head>
<body>
<button id='changeView'>前置边</button>
<div id='description'>hover 节点或边,前置相关元素</button>
<div id="mountNode"></div>
<script src="../build/g6.js"></script>
<script>
const data = {
nodes: [{
id: 'node1',
id: 'node0',
x: 100,
y: 100,
shape: 'rect',
style: {
fill: '#fff'
}
}, {
id: 'node2',
size: 20
},{
id: 'node1',
x: 200,
y: 200,
shape: 'rect',
style: {
fill: '#fff'
}
}, {
size: 20
},{
id: 'node2',
x: 150,
y: 150,
size: 20
},{
id: 'node3',
x: 300,
y: 300,
shape: 'rect',
style: {
fill: '#fff'
}
x: 150,
y: 250,
size: 20
},{
id: 'node4',
x: 150,
y: 200,
size: 20
}],
edges: [{
id: 'edge0',
source: 'node0',
target: 'node1'
},{
id: 'edge1',
target: 'node3',
source: 'node1',
style: {
endArrow: true
},
labelCfg: {
style: { stroke: 'white', lineWidth: 5 } // 加白边框
}
source: 'node2',
target: 'node3'
}]
};
const graph = new G6.Graph({
container: 'mountNode',
width: 500,
height: 500
width: 800,
height: 600,
groupByTypes: false,
defaultEdge: {
style: {
lineWidth: 2
}
}
});
graph.data(data);
graph.render();
graph.fitView()
document.getElementById('changeView').addEventListener('click', (evt) => {
const edge=graph.findById('edge1')
const nodeGroup = graph.get('nodeGroup')
const edge1G = edge.get('group')
edge1G.toFront()
nodeGroup.toBack();
graph.paint()
})
// 获取图上的所有边实例
const nodes = graph.getNodes();
// 遍历边实例,将所有边提前。
nodes.forEach(node => {
node.toFront();
});
// 更改层级后需要重新绘制图
graph.paint();
// 鼠标进入节点事件
graph.on('edge:mouseenter', ev => {
// 获得鼠标当前目标边
const edge = ev.item;
// 该边的起始点
const source = edge.getSource();
// 该边的结束点
const target = edge.getTarget();
// 先将边提前,再将端点提前。这样该边两个端点还是在该边上层,较符合常规。
edge.toFront();
source.toFront();
target.toFront();
// 注意:必须调用以根据新的层级顺序重绘
graph.paint();
});
graph.on('edge:mouseleave', ev => {
// 获得图上所有边实例
const edges = graph.getEdges();
// 遍历边,将所有边的层级放置在后方,以恢复原样
edges.forEach(edge => {
edge.toBack();
});
// 注意:必须调用以根据新的层级顺序重绘
graph.paint();
});
graph.on('node:mouseenter', ev => {
// 获得鼠标当前目标节点
const node = ev.item;
// 获取该节点的所有相关边
const edges = node.getEdges();
// 遍历相关边,将所有相关边提前,再将相关边的两个端点提前,以保证相关边的端点在边的上方常规效果
edges.forEach(edge => {
edge.toFront();
edge.getSource().toFront();
edge.getTarget().toFront();
});
// 注意:必须调用以根据新的层级顺序重绘
graph.paint();
});
graph.on('node:mouseleave', ev => {
// 获得图上所有边实例
const edges = graph.getEdges();
// 遍历边,将所有边的层级放置在后方,以恢复原样
edges.forEach(edge => {
edge.toBack();
});
// 注意:必须调用以根据新的层级顺序重绘
graph.paint();
});
</script>
</body>
</html>

View File

@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Concentric Layout</title>
</head>
<body>
<div id="mountNode"></div>
<script src="../build/g6.js"></script>
<script src="./assets/jquery-3.2.1.min.js"></script>
<script>
const radius = 100;
const graphSize = [1000, 600];
const graph = new G6.Graph({
container: 'mountNode',
width: graphSize[0],
height: graphSize[1],
fitView: true,
modes: {
default: ['zoom-canvas', 'drag-canvas', 'drag-node']
},
layout: {
type: 'concentric',
center: [500, 300],
maxLevelDiff: 0.5,
sortBy: 'degree'
},
animate: true,
defaultNode: {
size: [5, 5]
},
defaultEdge: {
size: 1,
color: '#e2e2e2'
}
});
$.getJSON('./assets/data/concentric-data.json', data1 => {
graph.data(data1);
graph.render();
});
</script>
</body>
</html>

View File

@ -0,0 +1,118 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>addItem on Dagre Layout and Update layout</title>
</head>
<body>
<div id="description">Dagre 布局,点击节点新增一个节点,点击空白区域重新布局</div>
<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"
}],
"edges": [{
"source": "0",
"target": "1"
},
{
"source": "0",
"target": "2"
},
{
"source": "1",
"target": "3"
},
{
"source": "1",
"target": "4"
},
{
"source": "2",
"target": "5"
}]
};
const graph = new G6.Graph({
container: 'mountNode',
width: 1000,
height: 600,
modes: {
default: ['drag-canvas', 'drag-node'],
},
layout: {
type: 'dagre',
center: [500, 300],
nodeSize: [40, 20],
nodesep: 50,
ranksep: 50,
rankdir: 'TB',
},
animate: true,
defaultNode: {
size: [40, 20],
color: 'steelblue',
shape: 'rect',
style: {
lineWidth: 2,
fill: '#fff'
}
},
defaultEdge: {
size: 1,
color: '#e2e2e2',
style: {
endArrow: {
path: 'M 4,0 L -4,-4 L -4,4 Z',
d: 4
}
}
}
});
graph.data(data);
graph.render();
graph.on('node:click', evt => {
graph.addItem('node', {
id: 'newnode',
x: 100,
y: 100,
style: {
fill: 'red'
}
});
});
graph.on('canvas:click', evt => {
graph.updateLayout({
ranksep: 10,
rankdir: 'LR'
});
});
</script>
</body>
</html>

View File

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<title>Fruchterman Layout with Changing Configurations</title>
<title>Dagre Layout with Changing Configurations</title>
</head>
<body>

View File

@ -230,7 +230,7 @@
const graph = new G6.Graph({
container: 'mountNode',
width: 500,
width: 1000,
height: 500,
layout: {
type: 'dagre',

3591
demos/layout-grid.html Normal file

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,8 @@
<script src="../build/g6.js"></script>
<script>
const data = {
"nodes": [{
"nodes": [
{
"id": "0",
"label": "0"
},
@ -396,6 +397,7 @@
container: 'mountNode',
width: 1000,
height: 600,
fitView: true,
layout: {
type: 'mds',
linkDistance: 100

View File

@ -3888,7 +3888,7 @@
const maxDegree = 4;
// the max degree about foces(clicked) node in the original data
const oMaxDegree = 3;
const unitRadius = 40;
const unitRadius = 100;
const focusNodeId = "2";
// re-place the clicked node far away the exisiting items
// along the radius from center node to it
@ -3905,7 +3905,9 @@
focusNode: "2",
unitRadius,
linkDistance: 180,
preventOverlap: true
preventOverlap: true,
strictRadial: false,
nodeSize: 50
});
subRadialLayout.init({
'nodes': newNodeModels,
@ -3921,7 +3923,7 @@
});
const focusNode = data_m.nodes[20];
const mainUnitRadius = 120;
const mainUnitRadius = 220;
const graph = new G6.Graph({
container: 'mountNode',
width: 1000,
@ -3932,16 +3934,21 @@
maxIteration: 200,
focusNode,
unitRadius: mainUnitRadius,
linkDistance: 100,
linkDistance: 200,
preventOverlap: true,
nodeSize: 20
nodeSize: 50,
strictRadial: true,
maxPreventOverlapIteration: 500
},
animate: true,
modes: {
default: ['drag-node', 'click-select', 'click-add-node', 'drag-canvas']
},
defaultNode: {
size: [20, 20]
size: 50,
style: {
stroke: '#000'
}
},
defaultEdge: {
size: 1,

View File

@ -34,7 +34,7 @@
size: [40, 40]
},
modes: {
default: [ 'brush-select', 'drag-node' ]
default: [ 'brush-select', 'drag-canvas', 'zoom-canvas' ]
}
});
@ -137,8 +137,9 @@
const menu = document.getElementById('contextMenu')
graph.on('node:contextmenu', evt => {
menu.style.left = evt.x + 'px'
menu.style.top = evt.y + 'px'
console.log(evt);
menu.style.left = evt.clientX + 'px'
menu.style.top = evt.clientY + 'px'
})
graph.on('node:mouseleave', evt => {

View File

@ -18,20 +18,9 @@ class LayoutController {
let layoutType = self.layoutType;
const graph = self.graph;
// const data = graph.get('data');
const nodes = [];
const edges = [];
const nodeItems = graph.getNodes();
const edgeItems = graph.getEdges();
nodeItems.forEach(nodeItem => {
const model = nodeItem.getModel();
nodes.push(model);
});
edgeItems.forEach(edgeItem => {
const model = edgeItem.getModel();
edges.push(model);
});
const data = { nodes, edges };
self.data = data;
self.data = self.setDataFromGraph();
const nodes = self.data.nodes;
if (!nodes) {
return;
@ -84,7 +73,7 @@ class LayoutController {
console.warn('The layout method: ' + layoutCfg + ' does not exist! Please specify it first.');
return;
}
layoutMethod.init(data);
layoutMethod.init(self.data);
graph.emit('beforelayout');
layoutMethod.execute();
if (layoutType !== 'force') {
@ -110,6 +99,8 @@ class LayoutController {
const graph = self.graph;
self.layoutType = cfg.type;
const layoutMethod = self.layoutMethod;
self.data = self.setDataFromGraph();
layoutMethod.init(self.data);
layoutMethod.updateCfg(cfg);
graph.emit('beforelayout');
layoutMethod.execute();
@ -139,6 +130,34 @@ class LayoutController {
self.layout();
}
// 从 this.graph 获取数据
setDataFromGraph() {
const self = this;
const nodes = [];
const edges = [];
const nodeItems = self.graph.getNodes();
const edgeItems = self.graph.getEdges();
nodeItems.forEach(nodeItem => {
const model = nodeItem.getModel();
nodes.push(model);
});
edgeItems.forEach(edgeItem => {
const model = edgeItem.getModel();
edges.push(model);
});
const data = { nodes, edges };
if (self.layoutType === 'fruchtermanGroup') {
// const groupsData = self.graph.get('groups');
// const customGroup = self.graph.get('customGroup');
// const groupController = self.graph.get('customGroupControll');
// data.groupsData = groupsData;
// data.customGroup = customGroup;
// data.groupController = groupController;
data.graph = self.graph;
}
return data;
}
// 重新布局
relayout() {
const self = this;

View File

@ -512,10 +512,6 @@ class Graph extends EventEmitter {
Util.each(data.edges, edge => {
self.add(EDGE, edge);
});
// layout
const layoutController = self.get('layoutController');
layoutController.layout();
self.refreshPositions();
// 防止传入的数据不存在nodes
if (data.nodes) {
@ -530,6 +526,29 @@ class Graph extends EventEmitter {
}
}
if (!this.get('groupByTypes')) {
// 为提升性能,选择数量少的进行操作
if (data.nodes.length < data.edges.length) {
const nodes = this.getNodes();
// 遍历节点实例,将所有节点提前。
nodes.forEach(node => {
node.toFront();
});
} else {
const edges = this.getEdges();
// 遍历节点实例,将所有节点提前。
edges.forEach(edge => {
edge.toBack();
});
}
}
// layout
const layoutController = self.get('layoutController');
layoutController.layout();
self.refreshPositions();
if (self.get('fitView')) {
self.get('viewController')._fitView();
}

177
src/layout/concentric.js Normal file
View File

@ -0,0 +1,177 @@
/**
* @fileOverview concentric layout
* @author shiwu.wyy@antfin.com
* this algorithm refers to <cytoscape.js> - https://github.com/cytoscape/cytoscape.js/
*/
const Layout = require('./layout');
const isString = require('@antv/util/lib/type/is-string');
function getDegree(n, nodeIdxMap, edges) {
const degrees = [];
for (let i = 0; i < n; i++) {
degrees[i] = 0;
}
edges.forEach(e => {
degrees[nodeIdxMap.get(e.source)] += 1;
degrees[nodeIdxMap.get(e.target)] += 1;
});
return degrees;
}
/**
* 同心圆布局
*/
Layout.registerLayout('concentric', {
getDefaultCfg() {
return {
center: [ 0, 0 ], // 布局中心
nodeSize: 30,
minNodeSpacing: 10, // min spacing between outside of nodes (used for radius adjustment)
preventOverlap: false, // prevents node overlap, may overflow boundingBox if not enough space
sweep: undefined, // how many radians should be between the first and last node (defaults to full circle)
equidistant: false, // whether levels have an equal radial distance betwen them, may cause bounding box overflow
startAngle: 3 / 2 * Math.PI, // where nodes start in radians
clockwise: true, // whether the layout should go clockwise (true) or counterclockwise/anticlockwise (false)
maxLevelDiff: undefined, // the letiation of concentric values in each level
sortBy: 'degree' // 根据 sortBy 指定的属性进行排布,数值高的放在中心。如果是 sortBy 则会计算节点度数,度数最高的放在中心。
};
},
/**
* 执行布局
*/
execute() {
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;
}
const layoutNodes = [];
let maxNodeSize;
if (isNaN(self.nodeSize)) {
maxNodeSize = Math.max(self.nodeSize[0], self.nodeSize[1]);
} else {
maxNodeSize = self.nodeSize;
}
nodes.forEach(node => {
layoutNodes.push(node);
let nodeSize;
if (isNaN(node.size)) {
nodeSize = Math.max(node.size[0], node.size[1]);
} else {
nodeSize = node.size;
}
maxNodeSize = Math.max(maxNodeSize, nodeSize);
});
self.width = self.width || window.innerHeight;
self.height = self.height || window.innerWidth;
self.clockwise = self.counterclockwise !== undefined ? !self.counterclockwise : self.clockwise;
// layout
const nodeMap = new Map();
const nodeIdxMap = new Map();
layoutNodes.forEach((node, i) => {
nodeMap.set(node.id, node);
nodeIdxMap.set(node.id, i);
});
self.nodeMap = nodeMap;
// get the node degrees
if (self.sortBy === 'degree' || !isString(self.sortBy) || layoutNodes[0][self.sortBy] === undefined) {
self.sortBy = 'degree';
if (isNaN(nodes[0].degree)) {
const values = getDegree(nodes.length, nodeIdxMap, edges);
layoutNodes.forEach((node, i) => {
node.degree = values[i];
});
}
}
// sort nodes by value
layoutNodes.sort((n1, n2) => {
return n2[self.sortBy] - n1[self.sortBy];
});
self.maxValueNode = layoutNodes[0];
self.maxLevelDiff = self.maxLevelDiff || self.maxValueNode[self.sortBy] / 4; // 0.5;
// put the values into levels
const levels = [[]];
let currentLevel = levels[0];
layoutNodes.forEach(node => {
if (currentLevel.length > 0) {
const diff = Math.abs(currentLevel[0][self.sortBy] - node[self.sortBy]);
if (diff >= self.maxLevelDiff) {
currentLevel = [];
levels.push(currentLevel);
}
}
currentLevel.push(node);
});
// create positions for levels
let minDist = maxNodeSize + self.minNodeSpacing; // min dist between nodes
if (!self.preventOverlap) { // then strictly constrain to bb
const firstLvlHasMulti = levels.length > 0 && levels[0].length > 1;
const maxR = (Math.min(self.width, self.height) / 2 - minDist);
const rStep = maxR / (levels.length + firstLvlHasMulti ? 1 : 0);
minDist = Math.min(minDist, rStep);
}
// find the metrics for each level
let r = 0;
levels.forEach(level => {
const sweep = self.sweep === undefined ? 2 * Math.PI - 2 * Math.PI / level.length : self.sweep;
const dTheta = level.dTheta = sweep / (Math.max(1, level.length - 1));
// calculate the radius
if (level.length > 1 && self.preventOverlap) { // but only if more than one node (can't overlap)
const dcos = Math.cos(dTheta) - Math.cos(0);
const dsin = Math.sin(dTheta) - Math.sin(0);
const rMin = Math.sqrt(minDist * minDist / (dcos * dcos + dsin * dsin)); // s.t. no nodes overlapping
r = Math.max(rMin, r);
}
level.r = r;
r += minDist;
});
if (self.equidistant) {
let rDeltaMax = 0;
let r = 0;
for (let i = 0; i < levels.length; i++) {
const level = levels[ i ];
const rDelta = level.r - r;
rDeltaMax = Math.max(rDeltaMax, rDelta);
}
r = 0;
levels.forEach((level, i) => {
if (i === 0) {
r = level.r;
}
level.r = r;
r += rDeltaMax;
});
}
// calculate the node positions
levels.forEach(level => {
const dTheta = level.dTheta;
const r = level.r;
level.forEach((node, j) => {
const theta = self.startAngle + (self.clockwise ? 1 : -1) * dTheta * j;
node.x = center[0] + r * Math.cos(theta);
node.y = center[1] + r * Math.sin(theta);
});
});
}
});

View File

@ -0,0 +1,443 @@
/**
* @fileOverview random layout
* @author shiwu.wyy@antfin.com
*/
const d3Force = require('d3-force');
const Layout = require('./layout');
const SPEED_DIVISOR = 800;
/**
* fruchterman 布局
*/
Layout.registerLayout('fruchtermanGroup', {
getDefaultCfg() {
return {
maxIteration: 1000, // 停止迭代的最大迭代数
center: [ 0, 0 ], // 布局中心
gravity: 1, // 重力大小,影响图的紧凑程度
speed: 1, // 速度
groupGravity: 1, // 聚类力大小
nodeRepulsiveCoefficient: 50,
groupRepulsiveCoefficient: 10,
nodeAttractiveCoefficient: 1,
groupAttractiveCoefficient: 1,
preventGroupOverlap: true,
groupCollideStrength: 0.7 // 防止重叠的力强度
};
},
/**
* 初始化
* @param {object} data 数据
*/
init(data) {
const self = this;
self.nodes = data.nodes;
self.edges = data.edges;
self.graph = data.graph;
self.groupsData = self.graph.get('groups'); // group data
self.customGroup = self.graph.get('customGroup'); // shape group
self.groupController = self.graph.get('customGroupControll'); // controller
},
/**
* 执行布局
*/
execute() {
const self = this;
const nodes = self.nodes;
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 nodeMap = new Map();
const nodeIndexMap = new Map();
nodes.forEach((node, i) => {
nodeMap.set(node.id, node);
nodeIndexMap.set(node.id, i);
});
self.nodeMap = nodeMap;
self.nodeIndexMap = nodeIndexMap;
// layout
self.run();
self.graph.refreshPositions();
// refresh groups' positions
const customGroup = self.customGroup;
const groupItems = customGroup.get('children');
const groupController = self.groupController;
const groupType = self.graph.get('groupType');
groupItems.forEach(gItem => {
const gid = gItem.get('id');
const group = self.groupMap.get(gid);
group.item = gItem;
const paddingValue = groupController.getGroupPadding(gid);
const { x: x1, y: y1, width, height } = groupController.calculationGroupPosition(group.nodeIds);
const groupTitleShape = gItem.findByClassName('group-title');
const gItemKeyShape = gItem.get('children')[0];
let titleX = 0;
let titleY = 0;
if (groupType === 'circle') {
const r = width > height ? width / 2 : height / 2;
const x = (width + 2 * x1) / 2;
const y = (height + 2 * y1) / 2;
gItemKeyShape.attr({
x,
y,
r: r + paddingValue
});
group.x = x;
group.y = y;
group.size = (r + paddingValue) * 2;
titleX = x;
titleY = y - r - paddingValue;
} else if (groupType === 'rect') {
const { default: defaultStyle } = groupController.styles;
const rectPadding = paddingValue * defaultStyle.disCoefficient;
const rectWidth = width + rectPadding * 2;
const rectHeight = height + rectPadding * 2;
const x = x1 - rectPadding;
const y = y1 - rectPadding;
gItemKeyShape.attr({
x,
y,
width: rectWidth,
height: rectHeight
});
group.x = x;
group.y = y;
group.size = [ rectWidth, rectHeight ];
titleX = x1;
titleY = y1;// - rectHeight / 2;
}
if (groupTitleShape) {
const titleConfig = group.groupData.title;
let offsetX = 0;
let offsetY = 0;
if (titleConfig) {
offsetX = titleConfig.offsetox || 0;
offsetY = titleConfig.offsetoy || 0;
titleConfig.offsetX = offsetX;
titleConfig.offsetY = offsetY;
if (groupType === 'rect') {
titleConfig.offsetX = 0;
titleConfig.offsetY = 0;
}
}
let x = titleX + offsetX;
let y = titleY + offsetY;
if (groupType === 'rect') {
x = titleX;
y = titleY;
}
groupTitleShape.attr({ x, y });
group.titlePos = [ x, y ];
}
});
// // find the levels of groups
// const roots = [];
// const groupMarks = {};
// self.groupsData.forEach(gd => {
// const group = self.groupMap.get(gd.id);
// if (!gd.parentId) {
// const groupNodes = [];
// group.nodeIds.forEach(nid => {
// groupNodes.push(nodeMap.get(nid));
// });
// roots.push({
// id: gd.id,
// children: [],
// x: group.cx,
// y: group.cy,
// ox: group.cx,
// oy: group.cy,
// nodes: groupNodes,
// item: group.item,
// size: group.size
// });
// groupMarks[gd.id] = 1;
// }
// });
// const graphWidth = self.graph.get('width');
// const graphHeight = self.graph.get('height');
// self.BFSDivide(graphWidth, graphHeight, roots);
// according to group's size to divide the canvas
self.graph.paint();
},
run() {
const self = this;
const nodes = self.nodes;
const groups = self.groupsData;
const edges = self.edges;
const maxIteration = self.maxIteration;
const width = self.width || window.innerHeight;
const height = self.height || window.innerWidth;
const center = self.center;
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;
const groupMap = new Map();
self.groupMap = groupMap;
nodes.forEach(n => {
if (groupMap.get(n.groupId) === undefined) {
let parentId;
let groupData;
groups.forEach(g => {
if (g.id === n.groupId) {
parentId = g.parentId;
groupData = g;
}
});
const group = {
name: n.groupId,
cx: 0,
cy: 0,
count: 0,
parentId,
nodeIds: [],
groupData
};
groupMap.set(n.groupId, group);
}
const c = groupMap.get(n.groupId);
c.nodeIds.push(n.id);
c.cx += n.x;
c.cy += n.y;
c.count++;
});
groupMap.forEach(c => {
c.cx /= c.count;
c.cy /= c.count;
});
self.DFSSetGroups();
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 for one group
const groupGravity = self.groupGravity || gravity;
nodes.forEach((n, i) => {
const c = groupMap.get(n.groupId);
const distLength = Math.sqrt((n.x - c.cx) * (n.x - c.cx) + (n.y - c.cy) * (n.y - c.cy));
const gravityForce = self.groupAttractiveCoefficient * k * groupGravity;
disp[i].x -= gravityForce * (n.x - c.cx) / distLength;
disp[i].y -= gravityForce * (n.y - c.cy) / distLength;
});
groupMap.forEach(c => {
c.cx = 0;
c.cy = 0;
c.count = 0;
});
nodes.forEach(n => {
const c = groupMap.get(n.groupId);
c.cx += n.x;
c.cy += n.y;
c.count++;
});
groupMap.forEach(c => {
c.cx /= c.count;
c.cy /= c.count;
});
// gravity
nodes.forEach((n, i) => {
const gravityForce = 0.01 * k * gravity;
disp[i].x -= gravityForce * (n.x - center[0]);
disp[i].y -= gravityForce * (n.y - center[1]);
});
// 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);
self.calGroupRepulsive(disp, k);
},
calRepulsive(nodes, disp, k) {
const self = this;
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 = self.nodeRepulsiveCoefficient * (k * k) / vecLengthSqr;
disp[i].x += vecx * common;
disp[i].y += vecy * common;
});
});
},
calAttractive(edges, nodeMap, nodeIndexMap, disp, k) {
const self = this;
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 = self.nodeAttractiveCoefficient * 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;
});
},
calGroupRepulsive(disp, k) {
const self = this;
const groupMap = self.groupMap;
const nodeIndexMap = self.nodeIndexMap;
groupMap.forEach((gv, i) => {
const gDisp = { x: 0, y: 0 };
groupMap.forEach((gu, j) => {
if (i === j) return;
const vecx = gv.cx - gu.cx;
const vecy = gv.cy - gu.cy;
let vecLengthSqr = vecx * vecx + vecy * vecy;
if (vecLengthSqr === 0) vecLengthSqr = 1;
const common = self.groupRepulsiveCoefficient * (k * k) / vecLengthSqr;
gDisp.x += vecx * common;
gDisp.y += vecy * common;
});
// apply group disp to the group's nodes
const groupNodeIds = gv.nodeIds;
groupNodeIds.forEach(gnid => {
const nodeIdx = nodeIndexMap.get(gnid);
disp[nodeIdx].x += gDisp.x;
disp[nodeIdx].y += gDisp.y;
});
});
},
DFSSetGroups() {
const self = this;
const groupMap = self.groupMap;
groupMap.forEach(group => {
const parentGroupId = group.parentId;
if (parentGroupId) {
let parentParentId;
self.groupsData.forEach(g => {
if (g.id === group.groupId) {
parentParentId = g.parentId;
}
});
const parentGroup = groupMap.get(parentGroupId);
if (!parentGroup) {
const pgroup = {
name: parentGroupId,
cx: 0,
cy: 0,
count: 0,
parentId: parentParentId,
nodeIds: group.nodeIds
};
groupMap.set(parentGroupId, pgroup);
} else {
group.nodeIds.forEach(n => {
parentGroup.nodeIds.push(n);
});
}
}
});
},
BFSDivide(width, height, children) {
const self = this;
const nodeForce = d3Force.forceManyBody();
nodeForce.strength(30);
const simulation = d3Force.forceSimulation()
.nodes(children)
.force('center', d3Force.forceCenter(width / 2, height / 2))
.force('charge', nodeForce)
.alpha(0.3)
.alphaDecay(0.01)
.alphaMin(0.001)
.on('tick', () => {
children.forEach(child => {
const groupNodes = child.nodes;
groupNodes.forEach(gn => {
gn.x += (child.x - child.ox);
gn.y += (child.y - child.oy);
});
child.ox = child.x;
child.oy = child.y;
const gItem = child.item;
const gItemKeyShape = gItem.get('children')[0];
gItemKeyShape.attr({
x: child.x,
y: child.y
});
});
self.graph.refreshPositions();
})
.on('end', () => {
});
self.groupOverlapProcess(simulation);
},
groupOverlapProcess(simulation) {
const self = this;
let nodeSize = self.nodeSize;
const groupCollideStrength = self.groupCollideStrength;
if (!nodeSize) {
nodeSize = d => {
if (d.size) {
if (Array.isArray(d.size)) {
return d.size[0] / 2;
}
return d.size / 2;
}
return 10;
};
} else if (!isNaN(nodeSize)) {
nodeSize /= 2;
} else if (nodeSize.length === 2) {
const larger = nodeSize[0] > nodeSize[1] ? nodeSize[0] : nodeSize[1];
nodeSize = larger / 2;
}
// forceCollide's parameter is a radius
simulation.force('collisionForce', d3Force.forceCollide(nodeSize).strength(groupCollideStrength));
}
});

286
src/layout/grid.js Normal file
View File

@ -0,0 +1,286 @@
/**
* @fileOverview grid layout
* @author shiwu.wyy@antfin.com
* this algorithm refers to <cytoscape.js> - https://github.com/cytoscape/cytoscape.js/
*/
const Layout = require('./layout');
const isString = require('@antv/util/lib/type/is-string');
function getDegree(n, nodeIdxMap, edges) {
const degrees = [];
for (let i = 0; i < n; i++) {
degrees[i] = 0;
}
edges.forEach(e => {
degrees[nodeIdxMap.get(e.source)] += 1;
degrees[nodeIdxMap.get(e.target)] += 1;
});
return degrees;
}
/**
* 网格布局
*/
Layout.registerLayout('grid', {
getDefaultCfg() {
return {
begin: [ 0, 0 ], // 布局起始点
preventOverlap: true, // prevents node overlap, may overflow boundingBox if not enough space
preventOverlapPadding: 10, // extra spacing around nodes when preventOverlap: true
condense: false, // uses all available space on false, uses minimal space on true
rows: undefined, // force num of rows in the grid
cols: undefined, // force num of columns in the grid
position() {}, // returns { row, col } for element
sortBy: 'degree', // a sorting function to order the nodes; e.g. function(a, b){ return a.data('weight') - b.data('weight') }
nodeSize: 30
};
},
/**
* 执行布局
*/
execute() {
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;
}
const edges = self.edges;
const layoutNodes = [];
nodes.forEach(node => {
layoutNodes.push(node);
});
const nodeIdxMap = new Map();
layoutNodes.forEach((node, i) => {
nodeIdxMap.set(node.id, i);
});
if (self.sortBy === 'degree' || !isString(self.sortBy) || layoutNodes[0][self.sortBy] === undefined) {
self.sortBy = 'degree';
if (isNaN(nodes[0].degree)) {
const values = getDegree(layoutNodes.length, nodeIdxMap, edges);
layoutNodes.forEach((node, i) => {
node.degree = values[i];
});
}
}
// sort nodes by value
layoutNodes.sort((n1, n2) => {
return n2[self.sortBy] - n1[self.sortBy];
});
self.width = self.width || window.innerHeight;
self.height = self.height || window.innerWidth;
// width/height * splits^2 = cells where splits is number of times to split width
self.cells = n;
self.splits = Math.sqrt(self.cells * self.height / self.width);
self.rows = Math.round(self.splits);
self.cols = Math.round(self.width / self.height * self.splits);
const oRows = self.rows;
const oCols = self.cols != null ? self.cols : self.columns;
// if rows or columns were set in self, use those values
if (oRows != null && oCols != null) {
self.rows = oRows;
self.cols = oCols;
} else if (oRows != null && oCols == null) {
self.rows = oRows;
self.cols = Math.ceil(self.cells / self.rows);
} else if (oRows == null && oCols != null) {
self.cols = oCols;
self.rows = Math.ceil(self.cells / self.cols);
} else if (self.cols * self.rows > self.cells) {
// otherwise use the automatic values and adjust accordingly
// if rounding was up, see if we can reduce rows or columns
const sm = self.small();
const lg = self.large();
// reducing the small side takes away the most cells, so try it first
if ((sm - 1) * lg >= self.cells) {
self.small(sm - 1);
} else if ((lg - 1) * sm >= self.cells) {
self.large(lg - 1);
}
} else {
// if rounding was too low, add rows or columns
while (self.cols * self.rows < self.cells) {
const sm = self.small();
const lg = self.large();
// try to add to larger side first (adds less in multiplication)
if ((lg + 1) * sm >= self.cells) {
self.large(lg + 1);
} else {
self.small(sm + 1);
}
}
}
self.cellWidth = self.width / self.cols;
self.cellHeight = self.height / self.rows;
if (self.condense) {
self.cellWidth = 0;
self.cellHeight = 0;
}
if (self.preventOverlap) {
layoutNodes.forEach(node => {
if (node.x == null || node.y == null) { // for bb
node.x = 0;
node.y = 0;
}
let nodew;
let nodeh;
if (isNaN(node.size)) {
nodew = node.size[0];
nodeh = node.size[1];
} else {
nodew = node.size;
nodeh = node.size;
}
if (isNaN(nodew) || isNaN(nodeh)) {
if (isNaN(self.nodeSize)) {
nodew = self.nodeSize[0];
nodeh = self.nodeSize[1];
} else {
nodew = self.nodeSize;
nodeh = self.nodeSize;
}
}
const p = self.preventOverlapPadding;
const w = nodew + p;
const h = nodeh + p;
self.cellWidth = Math.max(self.cellWidth, w);
self.cellHeight = Math.max(self.cellHeight, h);
});
}
self.cellUsed = {}; // e.g. 'c-0-2' => true
// to keep track of current cell position
self.row = 0;
self.col = 0;
// get a cache of all the manual positions
self.id2manPos = {};
for (let i = 0; i < layoutNodes.length; i++) {
const node = layoutNodes[i];
const rcPos = self.position(node);
if (rcPos && (rcPos.row !== undefined || rcPos.col !== undefined)) { // must have at least row or col def'd
const pos = {
row: rcPos.row,
col: rcPos.col
};
if (pos.col === undefined) { // find unused col
pos.col = 0;
while (self.used(pos.row, pos.col)) {
pos.col++;
}
} else if (pos.row === undefined) { // find unused row
pos.row = 0;
while (self.used(pos.row, pos.col)) {
pos.row++;
}
}
self.id2manPos[ node.id ] = pos;
self.use(pos.row, pos.col);
}
self.getPos(node);
}
},
small(val) {
const self = this;
let res;
if (val == null) {
res = Math.min(self.rows, self.cols);
} else {
const min = Math.min(self.rows, self.cols);
if (min === self.rows) {
self.rows = val;
} else {
self.cols = val;
}
}
return res;
},
large(val) {
const self = this;
let res;
if (val == null) {
res = Math.max(self.rows, self.cols);
} else {
const max = Math.max(self.rows, self.cols);
if (max === self.rows) {
self.rows = val;
} else {
self.cols = val;
}
}
return res;
},
used(row, col) {
const self = this;
return self.cellUsed[ 'c-' + row + '-' + col ] || false;
},
use(row, col) {
const self = this;
self.cellUsed[ 'c-' + row + '-' + col ] = true;
},
moveToNextCell() {
const self = this;
self.col++;
if (self.col >= self.cols) {
self.col = 0;
self.row++;
}
},
getPos(node) {
const self = this;
const begin = self.begin;
const cellWidth = self.cellWidth;
const cellHeight = self.cellHeight;
let x;
let y;
// see if we have a manual position set
const rcPos = self.id2manPos[ node.id ];
if (rcPos) {
x = rcPos.col * cellWidth + cellWidth / 2 + begin[0];
y = rcPos.row * cellHeight + cellHeight / 2 + begin[1];
} else { // otherwise set automatically
while (self.used(self.row, self.col)) {
self.moveToNextCell();
}
x = self.col * cellWidth + cellWidth / 2 + begin[0];
y = self.row * cellHeight + cellHeight / 2 + begin[1];
self.use(self.row, self.col);
self.moveToNextCell();
}
node.x = x;
node.y = y;
}
});

View File

@ -11,6 +11,9 @@ module.exports = {
Fruchterman: require('./fruchterman'),
Radial: require('./radial/radial'),
Force: require('./force'),
Dagre: require('./dagre')
Dagre: require('./dagre'),
Concentric: require('./concentric'),
Grid: require('./grid'),
FruchtermanGroup: require('./fruchterman-group')
};
module.exports = Layout;

View File

@ -34,6 +34,7 @@ Layout.registerLayout('mds', {
// the graph-theoretic distance (shortest path distance) matrix
const adjMatrix = Util.getAdjMatrix({ nodes, edges }, false);
const distances = Util.floydWarshall(adjMatrix);
self.handleInfinity(distances);
self.distances = distances;
// scale the ideal edge length acoording to linkDistance
@ -75,5 +76,25 @@ Layout.registerLayout('mds', {
return ret.U.map(function(row) {
return Numeric.mul(row, eigenValues).splice(0, dimension);
});
},
handleInfinity(distances) {
let maxDistance = -999999;
distances.forEach(row => {
row.forEach(value => {
if (value === Infinity) {
return;
}
if (maxDistance < value) {
maxDistance = value;
}
});
});
distances.forEach((row, i) => {
row.forEach((value, j) => {
if (value === Infinity) {
distances[i][j] = maxDistance;
}
});
});
}
});

View File

@ -46,7 +46,9 @@ Layout.registerLayout('radial', {
unitRadius: null, // 每一圈半径
linkDistance: 50, // 默认边长度
preventOverlap: false, // 是否防止重叠
nodeSize: 10 // 节点直径
nodeSize: 10, // 节点直径
strictRadial: true, // 是否必须是严格的 radial 布局即每一层的节点严格布局在一个环上。preventOverlap 为 true 时生效。
maxPreventOverlapIteration: 200 // 防止重叠步骤的最大迭代次数
};
},
/**
@ -150,12 +152,13 @@ Layout.registerLayout('radial', {
self.run();
const preventOverlap = self.preventOverlap;
const nodeSize = self.nodeSize;
const strictRadial = self.strictRadial;
// stagger the overlapped nodes
if (preventOverlap) {
const nonoverlapForce = new RadialNonoverlapForce({
nodeSize, adjMatrix, positions, radii, height, width,
nodeSize, adjMatrix, positions, radii, height, width, strictRadial,
focusID: focusIndex,
iterations: 200,
iterations: self.maxPreventOverlapIteration || 200,
k: positions.length / 4.5
});
positions = nonoverlapForce.layout();

View File

@ -57,6 +57,11 @@ class RadialNonoverlapForce {
* @type {number}
*/
this.k = params.k || 5;
/**
* if each circle can be separated into subcircles to avoid overlappings
* @type {number}
*/
this.strictRadial = params.strictRadial;
}
layout() {
const self = this;
@ -107,8 +112,10 @@ class RadialNonoverlapForce {
const positions = self.positions;
const disp = self.disp;
const speed = self.speed;
const strictRadial = self.strictRadial;
const f = self.focusID;
if (strictRadial) {
disp.forEach((di, i) => {
const vx = positions[i][0] - positions[f][0];
const vy = positions[i][1] - positions[f][1];
@ -126,6 +133,7 @@ class RadialNonoverlapForce {
di.x = vpx * tdispLength;
di.y = vpy * tdispLength;
});
}
// speed
positions.forEach((n, i) => {
@ -142,6 +150,7 @@ class RadialNonoverlapForce {
const limitedDist = Math.min(self.maxDisplace * (speed / SPEED_DIVISOR), distLength);
n[0] += disp[i].x / distLength * limitedDist;
n[1] += disp[i].y / distLength * limitedDist;
if (strictRadial) {
let vx = n[0] - positions[f][0];
let vy = n[1] - positions[f][1];
const nfDis = Math.sqrt(vx * vx + vy * vy);
@ -150,6 +159,7 @@ class RadialNonoverlapForce {
n[0] = positions[f][0] + vx;
n[1] = positions[f][1] + vy;
}
}
});
}
}