Merge pull request #931 from antvis/layout-web-worker

Feat: support layout using web worker
This commit is contained in:
Moyee 2019-11-20 20:03:27 +08:00 committed by GitHub
commit aecfb14f99
22 changed files with 5363 additions and 188 deletions

View File

@ -17,6 +17,9 @@
padding: 10px 8px;
box-shadow: rgb(174, 174, 174) 0px 0px 10px;
}
canvas {
border: 1px solid red;
}
</style>
<body>
<div id="tip">布局中,请稍候......</div>
@ -29,52 +32,6 @@
'#a0d911', '#13c2c2', '#1890ff', '#b37feb', '#eb2f96' ];
const beginColor = '#5b8c00'; // green
const endColor = '#ff4d4f'; // red
const testData = {
nodes: [
{
id: '0',
cluster: 1,
exportValue: 10000,
x: 100,
y: 200,
// label: 0
},
{
id: '1',
cluster: 1,
exportValue: 10000,
x: 200,
y: 200,
// label: 1
},
// {
// id: '2',
// region: '1'
// },
// {
// id: '3',
// region: '2'
// }
],
edges: [
{
source: '0',
target: '1',
},
{
source: '1',
target: '0',
},
// {
// source: '2',
// target: '3',
// },
// {
// source: '3',
// target: '0',
// },
]
}
d3.json("./assets/data/filtered-trade.json", function(data) {
const nodes = data.nodes;
const edges = data.edges;
@ -122,8 +79,11 @@
maxIteration: 8000,
gravity: 10,
clustering: true,
clusterGravity: 30
clusterGravity: 30,
workerEnabled: true
},
padding: 0,
fitViewPadding: 0,
fitView: true,
linkCenter: true,
defaultNode: {
@ -134,7 +94,7 @@
shape: 'quadratic'
},
modes: {
default: [ 'drag-node', 'drag-canvas', {
default: [ 'drag-node', 'zoom-canvas', 'drag-canvas', {
type: 'tooltip',
formatText(model) {
let name = '';
@ -155,34 +115,40 @@
},
});
graph.on('beforelayout', () => {
console.log(data.nodes[0].x, data.nodes[0].y, data.nodes[10].x, data.nodes[10].y);
});
graph.on('afterlayout', () => {
const tipDiv = document.getElementById('tip');
tipDiv.innerHTML = '布局完成!';
const edgeItems = graph.getEdges();
edgeItems.forEach(e => {
const lineWidth = 0.4;
const strokeOpacity = 0.2;
let stroke = 'l(0) 0:' + beginColor + ' 1:' + endColor;
const sourceModel = e.getSource().getModel();
const targetModel = e.getTarget().getModel();
if (sourceModel.x > targetModel.x) {
stroke = 'l(0) 0:' + endColor + ' 1:' + beginColor;
}
e.update({
style: {
lineWidth,
strokeOpacity,
stroke
}
})
});
// 等布局完以后再绘制
graph.paint();
});
graph.data(data);
// 如果使用web workergraph.render是异步的立即返回在web worker里执行布局。
// 而如果不使用web workergraph.render是同步的意味着只有等布局执行完以后才会执行graph.render后面的代码。
graph.render();
const edgeItems = graph.getEdges();
edgeItems.forEach(e => {
const lineWidth = 0.4;
const strokeOpacity = 0.2;
let stroke = 'l(0) 0:' + beginColor + ' 1:' + endColor;
const sourceModel = e.getSource().getModel();
const targetModel = e.getTarget().getModel();
if (sourceModel.x > targetModel.x) {
stroke = 'l(0) 0:' + endColor + ' 1:' + beginColor;
}
e.update({
style: {
lineWidth,
strokeOpacity,
stroke
}
})
});
graph.paint();
graph.on('node:click', e => {
const targetItem = e.item;
const model = targetItem.getModel();

View File

@ -0,0 +1,424 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Circular Layout using web worker</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 graph = new G6.Graph({
container: 'mountNode',
width: 1000,
height: 800,
layout: {
type: 'circular',
center: [500, 400],
radius: 200,
ordering: null, // null || 'topology' || 'degree', null means nodes are ordered by data order
// use web worker to layout
workerEnabled: true,
},
defaultNode: {
size: [20, 20],
color: 'steelblue'
},
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();
</script>
</body>
</html>

View File

@ -0,0 +1,486 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Force Layout(using web worker) with Changing Data</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: 'b0',
label: '0'
},
{
id: 'b1',
label: '1'
},
{
id: 'b2',
label: '2'
},
{
id: 'b3',
label: '3'
},
{
id: 'b4',
label: '4'
},
{
id: 'b5',
label: '5'
}],
"edges": [{
"id": "be1",
"source": "b0",
"target": "b1"
},
{
"id": "be2",
"source": "b0",
"target": "b2"
},
{
"id": "be3",
"source": "b0",
"target": "b3"
},
{
"id": "be4",
"source": "b0",
"target": "b4"
},
{
"id": "be5",
"source": "b0",
"target": "b5"
}]
}
const graph = new G6.Graph({
container: 'mountNode',
width: 1000,
height: 600,
modes: {
default: ['drag-canvas', 'drag-node'],
},
layout: {
type: 'force',
center: [500, 300],
// use web worker to layout
workerEnabled: true,
},
animate: true,
defaultNode: {
size: [20, 20],
color: 'steelblue',
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();
setTimeout(() => {
graph.changeData(data2);
},
2500);
</script>
</body>
</html>

View File

@ -0,0 +1,143 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>力导布局使用web worker 监听布局的开始与结束</title>
</head>
<body>
<div id="timingTip"></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"
},
{
"id": "6",
"label": "6"
},
{
"id": "7",
"label": "7"
},
{
"id": "8",
"label": "8"
},
{
"id": "9",
"label": "9"
}],
"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": "2",
"target": "3"
},
{
"source": "4",
"target": "5"
},
{
"source": "4",
"target": "6"
},
{
"source": "5",
"target": "6"
}]
};
const graph = new G6.Graph({
container: 'mountNode',
width: 1000,
height: 600,
layout: {
type: 'force',
preventOverlap: true,
nodeSize: 20,
// use web worker to layout
workerEnabled: true
},
modes: {
default: ['drag-node']
},
defaultNode: {
size: [20, 20],
color: 'steelblue'
},
defaultEdge: {
size: 1,
color: '#e2e2e2'
}
});
const tipDiv = document.getElementById('timingTip');
graph.on('beforelayout', () => {
tipDiv.innerHTML = 'It is doing force-directed layout now!'
+ ' After it is done, this text will be changed.';
});
graph.on('afterlayout', () => {
tipDiv.innerHTML = 'Done!';
});
graph.data(data);
graph.render();
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,497 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Change Layouts using web worker</title>
</head>
<body>
<div id="tip">Random Layout</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"
},
{
"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 graph = new G6.Graph({
container: 'mountNode',
width: 1000,
height: 600,
modes: {
default: ['drag-canvas', 'drag-node'],
},
layout: {
center: [500, 300]
},
animate: true,
defaultNode: {
size: [20, 20],
color: 'steelblue',
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();
const tipDIv = document.getElementById('tip');
layoutMethodsTranslation();
setInterval(() => {
layoutMethodsTranslation();
}, 10500);
function layoutMethodsTranslation() {
setTimeout(() => {
tipDIv.innerHTML = 'MDS Layout';
graph.updateLayout({
type: 'mds',
// use web worker to layout
workerEnabled: true
});
}, 1000);
setTimeout(() => {
tipDIv.innerHTML = 'Fruchterman Layout';
graph.updateLayout({
type: 'fruchterman',
// use web worker to layout
workerEnabled: true
});
}, 2500);
setTimeout(() => {
tipDIv.innerHTML = 'Force-directed Layout';
graph.updateLayout({
type: 'force',
linkDistance: 50,
preventOverlap: true,
nodeSize: 30,
// use web worker to layout
workerEnabled: true
});
}, 4500);
setTimeout(() => {
tipDIv.innerHTML = 'Circular Layout';
graph.updateLayout({
type: 'circular',
radius: 100,
startAngle: Math.PI / 4,
endAngle: Math.PI,
divisions: 5,
ordering: 'degree'
});
}, 7000);
setTimeout(() => {
tipDIv.innerHTML = 'Radial Layout';
graph.updateLayout({
type: 'radial',
preventOverlap: true,
nodeSize: 20
});
}, 8500);
setTimeout(() => {
tipDIv.innerHTML = 'Random Layout';
graph.updateLayout({
type: 'random',
preventOverlap: true,
nodeSize: 20
});
}, 10000);
}
</script>
</body>
</html>

View File

@ -100,7 +100,7 @@
"prepublishOnly": "npm run build-lib && npm run build",
"screenshot": "node ./bin/screenshot.js",
"start": "npm run dev",
"test": "torch --compile --renderer --opts test/mocha.opts --recursive ./test/unit",
"test": "npm run build && torch --compile --renderer --opts test/mocha.opts --recursive ./test/unit",
"test-live": "torch --compile --interactive --watch --opts test/mocha.opts --recursive ./test/unit",
"test-live-shape": "torch --compile --interactive --watch --opts test/mocha.opts --recursive ./test/unit/shape/nodes/modelRect-spec.js",
"test-live-util": "torch --compile --interactive --watch --opts test/mocha.opts --recursive ./test/unit/shape/edge-spec.js",

View File

@ -1,11 +1,33 @@
const Layout = require('../../layout');
const Util = require('../../util');
const layoutConst = require('../../layout/worker/layoutConst');
const LayoutWorker = require('../../layout/worker/layout.worker');
const { LAYOUT_MESSAGE } = layoutConst;
const helper = {
// pollyfill
requestAnimationFrame(callback) {
const fn = window.requestAnimationFrame || window.webkitRequestAnimationFrame || function(callback) {
return setTimeout(callback, 16);
};
return fn(callback);
},
cancelAnimationFrame(requestId) {
const fn = window.cancelAnimationFrame || window.webkitCancelAnimationFrame || function(requestId) {
return clearTimeout(requestId);
};
return fn(requestId);
}
};
class LayoutController {
constructor(graph) {
this.graph = graph;
this.layoutCfg = graph.get('layout');
this.layoutType = this.layoutCfg ? this.layoutCfg.type : undefined;
const layoutCfg = this.layoutCfg = graph.get('layout') || {};
this.layoutType = layoutCfg.type;
this.worker = null;
this.workerData = {};
this._initLayout();
}
@ -13,7 +35,48 @@ class LayoutController {
// no data before rendering
}
layout() {
// get layout worker and create one if not exists
_getWorker() {
if (this.worker) {
return this.worker;
}
if (typeof Worker === 'undefined') {
// 如果当前浏览器不支持web worker则不使用web worker
console.warn('Web worker is not supported in current browser.');
this.worker = null;
} else {
this.worker = new LayoutWorker();
}
return this.worker;
}
// stop layout worker
_stopWorker() {
const { workerData } = this;
if (!this.worker) {
return;
}
this.worker.terminate();
this.worker = null;
// 重新开始新的布局之前先取消之前布局的requestAnimationFrame。
if (workerData.requestId) {
helper.cancelAnimationFrame(workerData.requestId);
workerData.requestId = null;
}
if (workerData.requestId2) {
helper.cancelAnimationFrame(workerData.requestId2);
workerData.requestId2 = null;
}
}
/**
* @param {function} success callback
* @return {boolean} 是否使用web worker布局
*/
layout(success) {
const self = this;
let layoutType = self.layoutType;
const graph = self.graph;
@ -23,7 +86,7 @@ class LayoutController {
const nodes = self.data.nodes;
if (!nodes) {
return;
return false;
}
const width = graph.get('width');
const height = graph.get('height');
@ -33,13 +96,14 @@ class LayoutController {
height,
center: [ width / 2, height / 2 ]
}, self.layoutCfg);
self.layoutCfg = layoutCfg;
if (layoutType === undefined) {
if (nodes[0] && nodes[0].x === undefined) {
// 创建随机布局
layoutType = 'random';
layoutType = layoutCfg.type = 'random';
} else { // 若未指定布局且数据中有位置信息,则不进行布局,直接按照原数据坐标绘制。
return;
return false;
}
} else {
if (nodes[0] && nodes[0].x === undefined) {
@ -52,6 +116,13 @@ class LayoutController {
if (layoutMethod) {
layoutMethod.destroy();
}
this._stopWorker();
if (layoutCfg.workerEnabled && this._layoutWithWorker(self.data, success)) {
// 如果启用布局web worker并且浏览器支持web worker用web worker布局。否则回退到不用web worker布局。
return true;
}
if (layoutType === 'force') {
const onTick = layoutCfg.onTick;
const tick = () => {
@ -65,21 +136,125 @@ class LayoutController {
graph.emit('afterlayout');
};
}
self.layoutCfg = layoutCfg;
try {
layoutMethod = new Layout[layoutType](layoutCfg);
} catch (e) {
console.warn('The layout method: ' + layoutCfg + ' does not exist! Please specify it first.');
return;
return false;
}
layoutMethod.init(self.data);
graph.emit('beforelayout');
layoutMethod.execute();
self.layoutMethod = layoutMethod;
if (layoutType !== 'force') {
graph.emit('afterlayout');
self.refreshLayout();
}
return false;
}
/**
* layout with web worker
* @param {object} data graph data
* @param {function} success callback function
* @return {boolean} 是否支持web worker
*/
_layoutWithWorker(data, success) {
const { nodes, edges } = data;
const { layoutCfg, graph } = this;
const worker = this._getWorker();
// 每次worker message event handler调用之间的共享数据会被修改。
const { workerData } = this;
if (!worker) {
return false;
}
workerData.requestId = null;
workerData.requestId2 = null;
workerData.currentTick = null;
workerData.currentTickData = null;
graph.emit('beforelayout');
// NOTE: postMessage的message参数里面不能包含函数否则postMessage会报错
// 例如:'function could not be cloned'。
// 详情参考https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm
// 所以这里需要把过滤layoutCfg里的函数字段过滤掉。
const filteredLayoutCfg = filterObject(layoutCfg, value => typeof value !== 'function');
worker.postMessage({ type: LAYOUT_MESSAGE.RUN, nodes, edges, layoutCfg: filteredLayoutCfg });
worker.onmessage = event => {
this._handleWorkerMessage(event, data, success);
};
return true;
}
// success callback will be called when updating graph positions for the first time.
_handleWorkerMessage(event, data, success) {
const { graph, workerData, layoutCfg } = this;
const eventData = event.data;
const { type } = eventData;
const onTick = () => {
if (layoutCfg.onTick) {
layoutCfg.onTick();
}
};
const onLayoutEnd = () => {
if (layoutCfg.onLayoutEnd) {
layoutCfg.onLayoutEnd();
}
graph.emit('afterlayout');
};
switch (type) {
case LAYOUT_MESSAGE.TICK:
workerData.currentTick = eventData.currentTick;
workerData.currentTickData = eventData;
if (!workerData.requestId) {
workerData.requestId = helper.requestAnimationFrame(function() {
updateLayoutPosition(data, eventData);
graph.refreshPositions();
onTick();
if (eventData.currentTick === 1 && success) {
success();
}
if (eventData.currentTick === eventData.totalTicks) {
// 如果是最后一次tick
onLayoutEnd();
} else if (workerData.currentTick === eventData.totalTicks) {
// 注意这里workerData.currentTick可能已经不再是前面赋值时候的值了
// 因为在requestAnimationFrame等待时间里可能产生新的tick。
// 如果当前tick不是最后一次tick并且所有的tick消息都已发出来了那么需要用最后一次tick的数据再刷新一次。
workerData.requestId2 = helper.requestAnimationFrame(function() {
updateLayoutPosition(data, workerData.currentTickData);
graph.refreshPositions();
workerData.requestId2 = null;
onTick();
onLayoutEnd();
});
}
workerData.requestId = null;
});
}
break;
case LAYOUT_MESSAGE.END:
// 如果没有tick消息非力导布局
if (workerData.currentTick == null) {
updateLayoutPosition(data, eventData);
this.refreshLayout();
// 非力导布局没有tick消息只有end消息所以需要执行一次回调。
if (success) {
success();
}
graph.emit('afterlayout');
}
break;
case LAYOUT_MESSAGE.ERROR:
break;
default:
break;
}
self.layoutMethod = layoutMethod;
}
// 绘制
@ -100,6 +275,13 @@ class LayoutController {
self.layoutType = cfg.type;
const layoutMethod = self.layoutMethod;
self.data = self.setDataFromGraph();
this._stopWorker();
if (cfg.workerEnabled && this._layoutWithWorker(self.data, null)) {
// 如果启用布局web worker并且浏览器支持web worker用web worker布局。否则回退到不用web worker布局。
return;
}
layoutMethod.init(self.data);
layoutMethod.updateCfg(cfg);
graph.emit('beforelayout');
@ -119,7 +301,6 @@ class LayoutController {
const layoutMethod = self.layoutMethod;
layoutMethod && layoutMethod.destroy();
self.layout();
self.refreshLayout();
}
// 更换数据
@ -221,8 +402,35 @@ class LayoutController {
self.graph = null;
const layoutMethod = self.layoutMethod;
layoutMethod && layoutMethod.destroy();
const { worker } = this;
if (worker) {
worker.terminate();
this.worker = null;
}
self.destroyed = true;
}
}
function updateLayoutPosition(data, layoutData) {
const { nodes } = data;
const { nodes: layoutNodes } = layoutData;
nodes.forEach((node, i) => {
node.x = layoutNodes[i].x;
node.y = layoutNodes[i].y;
});
}
function filterObject(collection, callback) {
const result = {};
if (collection && typeof collection === 'object') {
for (const key in collection) {
if (collection.hasOwnProperty(key) && callback(collection[key])) {
result[key] = collection[key];
}
}
return result;
}
return collection;
}
module.exports = LayoutController;

View File

@ -546,15 +546,18 @@ class Graph extends EventEmitter {
// layout
const layoutController = self.get('layoutController');
layoutController.layout();
self.refreshPositions();
if (self.get('fitView')) {
self.get('viewController')._fitView();
if (!layoutController.layout(success)) {
success();
}
function success() {
if (self.get('fitView')) {
self.get('viewController')._fitView();
}
self.paint();
self.setAutoPaint(autoPaint);
self.emit('afterrender');
}
self.paint();
self.setAutoPaint(autoPaint);
self.emit('afterrender');
}
/**
@ -649,11 +652,6 @@ class Graph extends EventEmitter {
this.set({ nodes: items.nodes, edges: items.edges });
const layoutController = this.get('layoutController');
layoutController.changeData();
if (self.get('animate')) {
self.positionsAnimate();
} else {
this.paint();
}
this.setAutoPaint(autoPaint);
return this;
}
@ -1179,8 +1177,9 @@ class Graph extends EventEmitter {
if (!newLayoutType || oriLayoutType === newLayoutType) {
// no type or same type, update layout
const layoutCfg = {};
Util.mix(layoutCfg, cfg);
Util.mix(layoutCfg, oriLayoutCfg, cfg);
layoutCfg.type = oriLayoutType ? oriLayoutType : 'random';
this.set('layout', layoutCfg);
layoutController.updateLayoutCfg(layoutCfg);
} else { // has different type, change layout
this.set('layout', cfg);
@ -1193,6 +1192,13 @@ class Graph extends EventEmitter {
*/
layout() {
const layoutController = this.get('layoutController');
const layoutCfg = this.get('layout');
if (layoutCfg.workerEnabled) {
// 如果使用web worker布局
layoutController.layout();
return;
}
if (layoutController.layoutMethod) {
layoutController.relayout();
} else {

View File

@ -4,11 +4,14 @@
*/
const d3Force = require('d3-force');
const Layout = require('./layout');
const Util = require('../util');
const isArray = require('@antv/util/lib/type/is-array');
const isNumber = require('@antv/util/lib/type/is-number');
const isFunction = require('@antv/util/lib/type/is-function');
const Layout = require('./layout');
const Util = require('../util/layout');
const layoutConst = require('./worker/layoutConst');
const { LAYOUT_MESSAGE } = layoutConst;
/**
* 经典力导布局 force-directed
@ -30,7 +33,9 @@ Layout.registerLayout('force', {
collideStrength: 1, // 防止重叠的力强度
tick() {},
onLayoutEnd() {}, // 布局完成回调
onTick() {} // 每一迭代布局回调
onTick() {}, // 每一迭代布局回调
// 是否启用web worker。前提是在web worker里执行布局否则无效
workerEnabled: false
};
},
/**
@ -71,14 +76,8 @@ Layout.registerLayout('force', {
.force('charge', nodeForce)
.alpha(alpha)
.alphaDecay(alphaDecay)
.alphaMin(alphaMin)
.on('tick', () => {
self.tick();
})
.on('end', () => {
self.ticking = false;
self.onLayoutEnd && self.onLayoutEnd();
});
.alphaMin(alphaMin);
if (self.preventOverlap) {
self.overlapProcess(simulation);
}
@ -101,8 +100,34 @@ Layout.registerLayout('force', {
}
simulation.force('link', edgeForce);
}
if (self.workerEnabled && !isInWorker()) {
// 如果不是运行在web worker里不用web worker布局
self.workerEnabled = false;
console.warn('workerEnabled option is only supported when running in web worker.');
}
if (!self.workerEnabled) {
simulation
.on('tick', () => {
self.tick();
})
.on('end', () => {
self.ticking = false;
self.onLayoutEnd && self.onLayoutEnd();
});
self.ticking = true;
} else {
simulation.stop();
const totalTicks = getSimulationTicks(simulation);
for (let currentTick = 1; currentTick <= totalTicks; currentTick++) {
simulation.tick();
// currentTick starts from 1.
postMessage({ type: LAYOUT_MESSAGE.TICK, currentTick, totalTicks, nodes });
}
self.ticking = false;
}
self.forceSimulation = simulation;
self.ticking = true;
} catch (e) {
self.ticking = false;
console.warn(e);
@ -194,3 +219,20 @@ Layout.registerLayout('force', {
self.destroyed = true;
}
});
// Return total ticks of d3-force simulation
function getSimulationTicks(simulation) {
const alphaMin = simulation.alphaMin();
const alphaTarget = simulation.alphaTarget();
const alpha = simulation.alpha();
const totalTicksFloat = Math.log((alphaMin - alphaTarget) / (alpha - alphaTarget)) / Math.log(1 - simulation.alphaDecay());
const totalTicks = Math.ceil(totalTicksFloat);
return totalTicks;
}
// 判断是否运行在web worker里
function isInWorker() {
// eslint-disable-next-line no-undef
return typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope;
}

View File

@ -3,7 +3,7 @@
* @author shiwu.wyy@antfin.com
*/
const Util = require('../util');
const Util = require('../util/layout');
const Layout = {};
/**

View File

@ -4,7 +4,7 @@
*/
const Layout = require('./layout');
const Util = require('../util');
const Util = require('../util/layout');
const Numeric = require('numericjs');
/**

View File

@ -4,7 +4,7 @@
*/
const Layout = require('../layout');
const Util = require('../../util');
const Util = require('../../util/layout');
const RadialNonoverlapForce = require('./radialNonoverlapForce');
const MDS = require('./mds');
const isArray = require('@antv/util/lib/type/is-array');

View File

@ -0,0 +1,45 @@
/**
* @fileoverview web worker for layout
* @author changzhe.zb@antfin.com
*/
const Layout = require('..');
const layoutConst = require('./layoutConst');
const { LAYOUT_MESSAGE } = layoutConst;
function isLayoutMessage(event) {
const { type } = event.data;
return type === LAYOUT_MESSAGE.RUN;
}
function handleLayoutMessage(event) {
const { type } = event.data;
switch (type) {
case LAYOUT_MESSAGE.RUN: {
const { nodes, edges, layoutCfg = {} } = event.data;
const { type: layoutType } = layoutCfg;
const LayoutClass = Layout[layoutType];
if (!LayoutClass) {
postMessage({ type: LAYOUT_MESSAGE.ERROR, message: `layout ${layoutType} not found` });
break;
}
const layoutMethod = new LayoutClass(layoutCfg);
layoutMethod.init({ nodes, edges });
layoutMethod.execute();
postMessage({ type: LAYOUT_MESSAGE.END, nodes });
layoutMethod.destroy();
break;
}
default:
break;
}
}
// listen to message posted to web worker
self.onmessage = event => {
if (isLayoutMessage(event)) {
handleLayoutMessage(event);
}
};

View File

@ -0,0 +1,19 @@
/**
* @fileoverview constants for layout
* @author changzhe.zb@antfin.com
*/
// layout message type
const LAYOUT_MESSAGE = {
// run layout
RUN: 'LAYOUT_RUN',
// layout ended with success
END: 'LAYOUT_END',
// layout error
ERROR: 'LAYOUT_ERROR',
// layout tick, used in force directed layout
TICK: 'LAYOUT_TICK'
};
module.exports = {
LAYOUT_MESSAGE
};

83
src/util/layout.js Normal file
View File

@ -0,0 +1,83 @@
/**
* @fileoverview util for layout
* @author changzhe.zb@antfin.com
*/
const layoutUtil = {
mix: require('@antv/util/lib/mix'),
augment: require('@antv/util/lib/augment'),
isString: require('@antv/util/lib/type/is-string'),
getAdjMatrix(data, directed) {
const nodes = data.nodes;
const edges = data.edges;
const matrix = [];
// map node with index in data.nodes
const nodeMap = new Map();
nodes.forEach((node, i) => {
nodeMap.set(node.id, i);
const row = [];
matrix.push(row);
});
// const n = nodes.length;
edges.forEach(e => {
const source = e.source;
const target = e.target;
const sIndex = nodeMap.get(source);
const tIndex = nodeMap.get(target);
matrix[sIndex][tIndex] = 1;
if (!directed) matrix[tIndex][sIndex] = 1;
});
return matrix;
},
/**
* Floyd Warshall algorithm for shortest path distances matrix
* @param {array} adjMatrix adjacency matrix
* @return {array} distances shortest path distances matrix
*/
floydWarshall(adjMatrix) {
// initialize
const dist = [];
const size = adjMatrix.length;
for (let i = 0; i < size; i += 1) {
dist[i] = [];
for (let j = 0; j < size; j += 1) {
if (i === j) {
dist[i][j] = 0;
} else if (adjMatrix[i][j] === 0 || !adjMatrix[i][j]) {
dist[i][j] = Infinity;
} else {
dist[i][j] = adjMatrix[i][j];
}
}
}
// floyd
for (let k = 0; k < size; k += 1) {
for (let i = 0; i < size; i += 1) {
for (let j = 0; j < size; j += 1) {
if (dist[i][j] > dist[i][k] + dist[k][j]) {
dist[i][j] = dist[i][k] + dist[k][j];
}
}
}
}
return dist;
},
getEDistance(p1, p2) {
return Math.sqrt((p1[0] - p2[0]) * (p1[0] - p2[0])
+ (p1[1] - p2[1]) * (p1[1] - p2[1]));
},
scaleMatrix(matrix, scale) {
const result = [];
matrix.forEach(row => {
const newRow = [];
row.forEach(v => {
newRow.push(v * scale);
});
result.push(newRow);
});
return result;
}
};
module.exports = layoutUtil;

View File

@ -180,63 +180,7 @@ const MathUtil = {
y: vector[1]
};
},
/**
* Floyd Warshall algorithm for shortest path distances matrix
* @param {array} adjMatrix adjacency matrix
* @return {array} distances shortest path distances matrix
*/
floydWarshall(adjMatrix) {
// initialize
const dist = [];
const size = adjMatrix.length;
for (let i = 0; i < size; i += 1) {
dist[i] = [];
for (let j = 0; j < size; j += 1) {
if (i === j) {
dist[i][j] = 0;
} else if (adjMatrix[i][j] === 0 || !adjMatrix[i][j]) {
dist[i][j] = Infinity;
} else {
dist[i][j] = adjMatrix[i][j];
}
}
}
// floyd
for (let k = 0; k < size; k += 1) {
for (let i = 0; i < size; i += 1) {
for (let j = 0; j < size; j += 1) {
if (dist[i][j] > dist[i][k] + dist[k][j]) {
dist[i][j] = dist[i][k] + dist[k][j];
}
}
}
}
return dist;
},
getAdjMatrix(data, directed) {
const nodes = data.nodes;
const edges = data.edges;
const matrix = [];
// map node with index in data.nodes
const nodeMap = new Map();
nodes.forEach((node, i) => {
nodeMap.set(node.id, i);
const row = [];
matrix.push(row);
});
// const n = nodes.length;
edges.forEach(e => {
const source = e.source;
const target = e.target;
const sIndex = nodeMap.get(source);
const tIndex = nodeMap.get(target);
matrix[sIndex][tIndex] = 1;
if (!directed) matrix[tIndex][sIndex] = 1;
});
return matrix;
},
/**
* if the graph about the shortest path matrix is connected
* @param {array} matrix shortest path matrix
@ -251,23 +195,6 @@ const MathUtil = {
return true;
},
getEDistance(p1, p2) {
return Math.sqrt((p1[0] - p2[0]) * (p1[0] - p2[0])
+ (p1[1] - p2[1]) * (p1[1] - p2[1]));
},
scaleMatrix(matrix, scale) {
const result = [];
matrix.forEach(row => {
const newRow = [];
row.forEach(v => {
newRow.push(v * scale);
});
result.push(newRow);
});
return result;
},
randomInitPos(size, xRange = [ 0, 1 ], yRange = [ 0, 1 ]) {
const positions = [];
for (let i = 0; i < size; i++) {

View File

@ -0,0 +1,67 @@
const expect = require('chai').expect;
// 注意这里不能直接require原始的src文件而要使用build后的文件因为web worker代码是通过worker-loader内联进来的。
const G6 = require('../../../build/g6');
const data = require('./data.json');
const div = document.createElement('div');
div.id = 'circular-layout-web-worker';
document.body.appendChild(div);
function mathEqual(a, b) {
return Math.abs(a - b) < 1;
}
describe.only('circular layout(web worker)', () => {
it('circular layout(web worker) with default configs', done => {
const graph = new G6.Graph({
container: div,
layout: {
type: 'circular',
// use web worker to layout
workerEnabled: true
},
width: 500,
height: 500,
defaultNode: { size: 10 }
});
graph.data(data);
graph.on('afterlayout', () => {
const width = graph.get('width');
const height = graph.get('height');
const radius = height > width ? width / 2 : height / 2;
expect(mathEqual(data.nodes[0].x, 250 + radius)).to.equal(true);
expect(mathEqual(data.nodes[0].y, 250)).to.equal(true);
expect(data.nodes[0].y === 250);
graph.destroy();
done();
});
graph.render();
});
it('circular(web worker) counterclockwise, and fixed radius, start angle, end angle', done => {
const graph = new G6.Graph({
container: div,
layout: {
type: 'circular',
center: [ 250, 250 ],
radius: 200,
startAngle: Math.PI / 4,
endAngle: Math.PI,
// use web worker to layout
workerEnabled: true
},
width: 500,
height: 500,
defaultNode: { size: 10 }
});
graph.data(data);
graph.on('afterlayout', () => {
const pos = 200 * Math.sqrt(2) / 2;
expect(mathEqual(data.nodes[0].x, 250 + pos)).to.equal(true);
expect(mathEqual(data.nodes[0].y, 250 + pos)).to.equal(true);
graph.destroy();
done();
});
graph.render();
});
});

View File

@ -0,0 +1,47 @@
const expect = require('chai').expect;
// 注意这里不能直接require原始的src文件而要使用build后的文件因为web worker代码是通过worker-loader内联进来的。
const G6 = require('../../../build/g6');
const data = require('./data.json');
const div = document.createElement('div');
div.id = 'force-layout-web-worker';
document.body.appendChild(div);
describe.only('force layout(web worker)', function() {
this.timeout(10000);
it('force layout(web worker) with default configs', function(done) {
const node = data.nodes[0];
let count = 0;
let ended = false;
const graph = new G6.Graph({
container: div,
layout: {
type: 'force',
onTick() {
count++;
expect(node.x).to.not.be.undefined;
expect(node.y).to.not.be.undefined;
},
onLayoutEnd() {
ended = true;
},
// use web worker to layout
workerEnabled: true
},
width: 500,
height: 500,
defaultNode: { size: 10 }
});
graph.data(data);
graph.on('afterlayout', () => {
expect(node.x).to.not.be.undefined;
expect(node.y).to.not.be.undefined;
expect(count >= 1).to.be.true;
expect(ended).to.be.true;
graph.destroy();
done();
});
graph.render();
});
});

View File

@ -0,0 +1,68 @@
const expect = require('chai').expect;
// 注意这里不能直接require原始的src文件而要使用build后的文件因为web worker代码是通过worker-loader内联进来的。
const G6 = require('../../../build/g6');
const data = require('./data.json');
const div = document.createElement('div');
div.id = 'layout-web-worker';
document.body.appendChild(div);
function mathEqual(a, b) {
return Math.abs(a - b) < 1;
}
describe.only('layout using web worker', function() {
this.timeout(10000);
it('change layout', function(done) {
const node = data.nodes[0];
const graph = new G6.Graph({
container: div,
layout: {
type: 'circular',
// use web worker to layout
workerEnabled: true
},
width: 500,
height: 500,
defaultNode: { size: 10 }
});
graph.data(data);
// 下面的graph.updateLayout又会触发一次afterLayout为了避免这里的event handler重复执行
// 这里用了graph.one.
graph.one('afterlayout', () => {
expect(mathEqual(node.x, 500)).to.equal(true);
expect(mathEqual(node.y, 250)).to.equal(true);
});
graph.render();
let count = 0;
let ended = false;
setTimeout(() => {
// 只执行一次
graph.one('afterlayout', () => {
expect(node.x).to.not.be.undefined;
expect(node.y).to.not.be.undefined;
expect(count >= 1).to.be.true;
expect(ended).to.be.true;
graph.destroy();
done();
});
graph.updateLayout({
type: 'force',
onTick() {
count++;
expect(node.x).to.not.be.undefined;
expect(node.y).to.not.be.undefined;
},
onLayoutEnd() {
ended = true;
},
// use web worker to layout
workerEnabled: true
});
}, 1000);
});
});

View File

@ -44,6 +44,20 @@ module.exports = {
},
module: {
rules: [
{
// 用于web worker代码。注意这条规则必须在.js规则前面
// 这样.worker.js会经过['worker-loader', 'babel-loader']处理
test: /\.worker\.js$/,
exclude: /(node_modules|bower_components)/,
use: {
loader: 'worker-loader',
options: {
inline: true,
fallback: false,
name: 'g6Layout.worker.js'
}
}
},
{
test: /\.js$/,
exclude: /(node_modules|bower_components)/,
@ -53,13 +67,6 @@ module.exports = {
babelrc: true
}
}
},
{
test: /\.worker.js$/,
loader: 'worker-loader',
options: {
inline: true
}
}
]
},