From 61a460a3ed597286480cad4a11ba7ecaa9503679 Mon Sep 17 00:00:00 2001 From: bzhangzju Date: Mon, 18 Nov 2019 02:47:44 +0800 Subject: [PATCH 01/10] =?UTF-8?q?=E6=94=AF=E6=8C=81Web=20Worker=E5=B8=83?= =?UTF-8?q?=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/graph/controller/layout.js | 196 +++++++++++++++++++++++++++++++-- src/graph/graph.js | 50 +++++---- src/layout/force.js | 64 +++++++++-- src/layout/layout.js | 2 +- src/layout/mds.js | 2 +- src/layout/radial/radial.js | 2 +- src/util/layout.js | 83 ++++++++++++++ src/util/math.js | 73 ------------ src/worker/layout.worker.js | 45 ++++++++ src/worker/layoutConst.js | 19 ++++ webpack.config.js | 20 ++-- 11 files changed, 430 insertions(+), 126 deletions(-) create mode 100644 src/util/layout.js create mode 100644 src/worker/layout.worker.js create mode 100644 src/worker/layoutConst.js diff --git a/src/graph/controller/layout.js b/src/graph/controller/layout.js index 30cfcb7092..ea9891b77c 100644 --- a/src/graph/controller/layout.js +++ b/src/graph/controller/layout.js @@ -1,11 +1,33 @@ const Layout = require('../../layout'); const Util = require('../../util'); +const layoutConst = require('../../worker/layoutConst'); +const LayoutWorker = require('../../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; @@ -34,7 +97,7 @@ class LayoutController { self.data = data; if (!nodes) { - return; + return false; } const width = graph.get('width'); const height = graph.get('height'); @@ -44,13 +107,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) { @@ -63,6 +127,13 @@ class LayoutController { if (layoutMethod) { layoutMethod.destroy(); } + + this._stopWorker(); + if (layoutCfg.workerEnabled && this._layoutWithWorker(data, success)) { + // 如果启用布局web worker并且浏览器支持web worker,用web worker布局。否则回退到不用web worker布局。 + return true; + } + if (layoutType === 'force') { const onTick = layoutCfg.onTick; const tick = () => { @@ -76,21 +147,106 @@ 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(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'); + worker.postMessage({ type: LAYOUT_MESSAGE.RUN, nodes, edges, layoutCfg }); + worker.onmessage = event => { + this._handleWorkerMessage(event, data, success); + }; + return true; + } + + // success callback will be called before updating graph positions for the first time. + _handleWorkerMessage(event, data, success) { + const { graph, workerData } = this; + const eventData = event.data; + const { type } = eventData; + 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(); + if (eventData.currentTick === 1 && success) { + success(); + } + + if (eventData.currentTick === eventData.totalTicks) { + // 如果是最后一次tick + graph.emit('afterlayout'); + } 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; + graph.emit('afterlayout'); + }); + } + workerData.requestId = null; + }); + } + break; + case LAYOUT_MESSAGE.END: + // 如果没有tick消息(非力导布局) + if (workerData.currentTick == null) { + graph.emit('afterlayout'); + updateLayoutPosition(data, eventData); + this.refreshLayout(); + // 非力导布局,没有tick消息,只有end消息,所以需要执行一次回调。 + if (success) { + success(); + } + } + break; + case LAYOUT_MESSAGE.ERROR: + break; + default: + break; } - self.layoutMethod = layoutMethod; } // 绘制 @@ -110,6 +266,13 @@ class LayoutController { const graph = self.graph; self.layoutType = cfg.type; const layoutMethod = self.layoutMethod; + + this._stopWorker(); + if (cfg.workerEnabled && this._layoutWithWorker(self.data, null)) { + // 如果启用布局web worker并且浏览器支持web worker,用web worker布局。否则回退到不用web worker布局。 + return; + } + layoutMethod.updateCfg(cfg); graph.emit('beforelayout'); layoutMethod.execute(); @@ -128,7 +291,6 @@ class LayoutController { const layoutMethod = self.layoutMethod; layoutMethod && layoutMethod.destroy(); self.layout(); - self.refreshLayout(); } // 更换数据 @@ -202,8 +364,22 @@ 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; + }); +} + module.exports = LayoutController; diff --git a/src/graph/graph.js b/src/graph/graph.js index 0bac6333ff..f0f08b17e5 100755 --- a/src/graph/graph.js +++ b/src/graph/graph.js @@ -506,25 +506,28 @@ class Graph extends EventEmitter { }); // layout const layoutController = self.get('layoutController'); - layoutController.layout(); - self.refreshPositions(); - - // 获取所有有groupID的node - const nodeInGroup = data.nodes.filter(node => node.groupId); - - // 所有node中存在groupID,则说明需要群组 - if (nodeInGroup.length > 0) { - // 渲染群组 - const groupType = self.get('groupType'); - this.renderCustomGroup(data, groupType); + if (!layoutController.layout(success)) { + success(); } - if (self.get('fitView')) { - self.get('viewController')._fitView(); + function success() { + // 获取所有有groupID的node + const nodeInGroup = data.nodes.filter(node => node.groupId); + + // 所有node中存在groupID,则说明需要群组 + if (nodeInGroup.length > 0) { + // 渲染群组 + const groupType = self.get('groupType'); + self.renderCustomGroup(data, groupType); + } + + if (self.get('fitView')) { + self.get('viewController')._fitView(); + } + self.paint(); + self.setAutoPaint(autoPaint); + self.emit('afterrender'); } - self.paint(); - self.setAutoPaint(autoPaint); - self.emit('afterrender'); } /** @@ -608,11 +611,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; } @@ -1136,8 +1134,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); @@ -1150,6 +1149,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 { diff --git a/src/layout/force.js b/src/layout/force.js index 9316048f5e..bc8a967659 100644 --- a/src/layout/force.js +++ b/src/layout/force.js @@ -5,7 +5,10 @@ const d3Force = require('d3-force'); const Layout = require('./layout'); -const Util = require('../util'); +const Util = require('../util/layout'); +const layoutConst = require('../worker/layoutConst'); + +const { LAYOUT_MESSAGE } = layoutConst; /** * 经典力导布局 force-directed @@ -26,7 +29,9 @@ Layout.registerLayout('force', { collideStrength: 1, // 防止重叠的力强度 tick() {}, onLayoutEnd() {}, // 布局完成回调 - onTick() {} // 每一迭代布局回调 + onTick() {}, // 每一迭代布局回调 + // 是否启用web worker。前提是在web worker里执行布局,否则无效 + workerEnabled: false }; }, /** @@ -67,14 +72,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); } @@ -97,8 +96,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); @@ -164,3 +189,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; +} diff --git a/src/layout/layout.js b/src/layout/layout.js index 837684601d..9e8ee7f575 100644 --- a/src/layout/layout.js +++ b/src/layout/layout.js @@ -3,7 +3,7 @@ * @author shiwu.wyy@antfin.com */ -const Util = require('../util'); +const Util = require('../util/layout'); const Layout = {}; /** diff --git a/src/layout/mds.js b/src/layout/mds.js index 6844f85792..00b6316179 100644 --- a/src/layout/mds.js +++ b/src/layout/mds.js @@ -4,7 +4,7 @@ */ const Layout = require('./layout'); -const Util = require('../util'); +const Util = require('../util/layout'); const Numeric = require('numericjs'); /** diff --git a/src/layout/radial/radial.js b/src/layout/radial/radial.js index 9ae0c870a8..ceee7e11e1 100644 --- a/src/layout/radial/radial.js +++ b/src/layout/radial/radial.js @@ -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'); diff --git a/src/util/layout.js b/src/util/layout.js new file mode 100644 index 0000000000..6a8bf2fc3a --- /dev/null +++ b/src/util/layout.js @@ -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; diff --git a/src/util/math.js b/src/util/math.js index 955cbf18d2..eefef950cf 100644 --- a/src/util/math.js +++ b/src/util/math.js @@ -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++) { diff --git a/src/worker/layout.worker.js b/src/worker/layout.worker.js new file mode 100644 index 0000000000..41427d5636 --- /dev/null +++ b/src/worker/layout.worker.js @@ -0,0 +1,45 @@ +/** + * @fileoverview web worker for layout + * @author changzhe.zb@antfin.com + */ +const Layout = require('../layout'); +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); + } +}; diff --git a/src/worker/layoutConst.js b/src/worker/layoutConst.js new file mode 100644 index 0000000000..4da089a539 --- /dev/null +++ b/src/worker/layoutConst.js @@ -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 +}; diff --git a/webpack.config.js b/webpack.config.js index bf07785157..2be618365e 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -44,6 +44,19 @@ 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 + } + } + }, { test: /\.js$/, exclude: /(node_modules|bower_components)/, @@ -53,13 +66,6 @@ module.exports = { babelrc: true } } - }, - { - test: /\.worker.js$/, - loader: 'worker-loader', - options: { - inline: true - } } ] }, From a2f3a04db1ea1818e5bf0787a74babdcf8c26ea2 Mon Sep 17 00:00:00 2001 From: bzhangzju Date: Mon, 18 Nov 2019 12:50:57 +0800 Subject: [PATCH 02/10] update webpack worker-loader options --- webpack.config.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/webpack.config.js b/webpack.config.js index 2be618365e..6b519053e8 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -53,7 +53,8 @@ module.exports = { loader: 'worker-loader', options: { inline: true, - fallback: false + fallback: false, + name: 'g6Layout.worker.js' } } }, From c06205d82c84315afb061b1550eef30cb49163b8 Mon Sep 17 00:00:00 2001 From: bzhangzju Date: Mon, 18 Nov 2019 23:14:17 +0800 Subject: [PATCH 03/10] Add demos for layout using web worker --- demos/layout-circular-web-worker.html | 424 +++++ demos/layout-data-translation-web-worker.html | 486 +++++ demos/layout-force-timing-web-worker.html | 143 ++ demos/layout-force-web-worker.html | 1588 +++++++++++++++++ .../layout-method-translation-web-worker.html | 497 ++++++ 5 files changed, 3138 insertions(+) create mode 100644 demos/layout-circular-web-worker.html create mode 100644 demos/layout-data-translation-web-worker.html create mode 100644 demos/layout-force-timing-web-worker.html create mode 100644 demos/layout-force-web-worker.html create mode 100644 demos/layout-method-translation-web-worker.html diff --git a/demos/layout-circular-web-worker.html b/demos/layout-circular-web-worker.html new file mode 100644 index 0000000000..9cfaf0829c --- /dev/null +++ b/demos/layout-circular-web-worker.html @@ -0,0 +1,424 @@ + + + + + + Circular Layout using web worker + + + +
+ + + + + \ No newline at end of file diff --git a/demos/layout-data-translation-web-worker.html b/demos/layout-data-translation-web-worker.html new file mode 100644 index 0000000000..1b2924ebca --- /dev/null +++ b/demos/layout-data-translation-web-worker.html @@ -0,0 +1,486 @@ + + + + + + Force Layout(using web worker) with Changing Data + + + +
+ + + + \ No newline at end of file diff --git a/demos/layout-force-timing-web-worker.html b/demos/layout-force-timing-web-worker.html new file mode 100644 index 0000000000..4cfc03b6f0 --- /dev/null +++ b/demos/layout-force-timing-web-worker.html @@ -0,0 +1,143 @@ + + + + + + 力导布局(使用web worker) 监听布局的开始与结束 + + + +
+
+ + + + + \ No newline at end of file diff --git a/demos/layout-force-web-worker.html b/demos/layout-force-web-worker.html new file mode 100644 index 0000000000..98f1011a2b --- /dev/null +++ b/demos/layout-force-web-worker.html @@ -0,0 +1,1588 @@ + + + + + + Force Layout using web worker + + + +
+ + + + + \ No newline at end of file diff --git a/demos/layout-method-translation-web-worker.html b/demos/layout-method-translation-web-worker.html new file mode 100644 index 0000000000..4b4d341972 --- /dev/null +++ b/demos/layout-method-translation-web-worker.html @@ -0,0 +1,497 @@ + + + + + + Change Layouts using web worker + + + +
Random Layout
+
+ + + + + \ No newline at end of file From 7cfa63aa53ec518ce66e5eb10103e47623a62030 Mon Sep 17 00:00:00 2001 From: bzhangzju Date: Tue, 19 Nov 2019 22:38:08 +0800 Subject: [PATCH 04/10] feat: optimize layout using web worker --- src/graph/controller/layout.js | 44 +++++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/src/graph/controller/layout.js b/src/graph/controller/layout.js index 38f66178db..0b6533b407 100644 --- a/src/graph/controller/layout.js +++ b/src/graph/controller/layout.js @@ -177,18 +177,35 @@ class LayoutController { workerData.currentTickData = null; graph.emit('beforelayout'); - worker.postMessage({ type: LAYOUT_MESSAGE.RUN, nodes, edges, layoutCfg }); + // 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 before updating graph positions for the first time. + // success callback will be called when updating graph positions for the first time. _handleWorkerMessage(event, data, success) { - const { graph, workerData } = this; + 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; @@ -197,13 +214,14 @@ class LayoutController { workerData.requestId = helper.requestAnimationFrame(function() { updateLayoutPosition(data, eventData); graph.refreshPositions(); + onTick(); if (eventData.currentTick === 1 && success) { success(); } if (eventData.currentTick === eventData.totalTicks) { // 如果是最后一次tick - graph.emit('afterlayout'); + onLayoutEnd(); } else if (workerData.currentTick === eventData.totalTicks) { // 注意这里workerData.currentTick可能已经不再是前面赋值时候的值了, // 因为在requestAnimationFrame等待时间里,可能产生新的tick。 @@ -212,7 +230,8 @@ class LayoutController { updateLayoutPosition(data, workerData.currentTickData); graph.refreshPositions(); workerData.requestId2 = null; - graph.emit('afterlayout'); + onTick(); + onLayoutEnd(); }); } workerData.requestId = null; @@ -222,13 +241,13 @@ class LayoutController { case LAYOUT_MESSAGE.END: // 如果没有tick消息(非力导布局) if (workerData.currentTick == null) { - graph.emit('afterlayout'); updateLayoutPosition(data, eventData); this.refreshLayout(); // 非力导布局,没有tick消息,只有end消息,所以需要执行一次回调。 if (success) { success(); } + graph.emit('afterlayout'); } break; case LAYOUT_MESSAGE.ERROR: @@ -401,4 +420,17 @@ function updateLayoutPosition(data, layoutData) { }); } +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; From e2b2405adea5dadb54eb50d983770961856b13d7 Mon Sep 17 00:00:00 2001 From: bzhangzju Date: Tue, 19 Nov 2019 23:23:57 +0800 Subject: [PATCH 05/10] feat: add unit tests for layout using web worker --- test/unit/layout/circular-web-worker-spec.js | 67 +++++++++++++++++++ test/unit/layout/force-web-worker-spec.js | 47 ++++++++++++++ test/unit/layout/web-worker-spec.js | 68 ++++++++++++++++++++ 3 files changed, 182 insertions(+) create mode 100644 test/unit/layout/circular-web-worker-spec.js create mode 100644 test/unit/layout/force-web-worker-spec.js create mode 100644 test/unit/layout/web-worker-spec.js diff --git a/test/unit/layout/circular-web-worker-spec.js b/test/unit/layout/circular-web-worker-spec.js new file mode 100644 index 0000000000..314d2baa14 --- /dev/null +++ b/test/unit/layout/circular-web-worker-spec.js @@ -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(); + }); +}); diff --git a/test/unit/layout/force-web-worker-spec.js b/test/unit/layout/force-web-worker-spec.js new file mode 100644 index 0000000000..7be452d16e --- /dev/null +++ b/test/unit/layout/force-web-worker-spec.js @@ -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(); + }); +}); diff --git a/test/unit/layout/web-worker-spec.js b/test/unit/layout/web-worker-spec.js new file mode 100644 index 0000000000..988e754d69 --- /dev/null +++ b/test/unit/layout/web-worker-spec.js @@ -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); + }); +}); From d1970976106ea0a9e455584a777e7525795c6fd3 Mon Sep 17 00:00:00 2001 From: bzhangzju Date: Tue, 19 Nov 2019 23:41:01 +0800 Subject: [PATCH 06/10] feat: move code for layout using web worker to layout directory --- src/graph/controller/layout.js | 4 ++-- src/layout/force.js | 2 +- src/{ => layout}/worker/layout.worker.js | 2 +- src/{ => layout}/worker/layoutConst.js | 0 4 files changed, 4 insertions(+), 4 deletions(-) rename src/{ => layout}/worker/layout.worker.js (96%) rename src/{ => layout}/worker/layoutConst.js (100%) diff --git a/src/graph/controller/layout.js b/src/graph/controller/layout.js index 0b6533b407..56aaaff0dc 100644 --- a/src/graph/controller/layout.js +++ b/src/graph/controller/layout.js @@ -1,7 +1,7 @@ const Layout = require('../../layout'); const Util = require('../../util'); -const layoutConst = require('../../worker/layoutConst'); -const LayoutWorker = require('../../worker/layout.worker'); +const layoutConst = require('../../layout/worker/layoutConst'); +const LayoutWorker = require('../../layout/worker/layout.worker'); const { LAYOUT_MESSAGE } = layoutConst; diff --git a/src/layout/force.js b/src/layout/force.js index c46d27851a..5c8c6ab73a 100644 --- a/src/layout/force.js +++ b/src/layout/force.js @@ -9,7 +9,7 @@ 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 layoutConst = require('./worker/layoutConst'); const { LAYOUT_MESSAGE } = layoutConst; diff --git a/src/worker/layout.worker.js b/src/layout/worker/layout.worker.js similarity index 96% rename from src/worker/layout.worker.js rename to src/layout/worker/layout.worker.js index 41427d5636..52a7055add 100644 --- a/src/worker/layout.worker.js +++ b/src/layout/worker/layout.worker.js @@ -2,7 +2,7 @@ * @fileoverview web worker for layout * @author changzhe.zb@antfin.com */ -const Layout = require('../layout'); +const Layout = require('..'); const layoutConst = require('./layoutConst'); const { LAYOUT_MESSAGE } = layoutConst; diff --git a/src/worker/layoutConst.js b/src/layout/worker/layoutConst.js similarity index 100% rename from src/worker/layoutConst.js rename to src/layout/worker/layoutConst.js From f1718e35175338d88f4a4307734eee582e9e7cd8 Mon Sep 17 00:00:00 2001 From: Yanyan-Wang Date: Wed, 20 Nov 2019 12:26:51 +0800 Subject: [PATCH 07/10] add: webworker demos --- demos/case-simplify.html | 59 +- demos/layout-fruchterman-web-worker.html | 1552 ++++++++++++++++++++++ 2 files changed, 1563 insertions(+), 48 deletions(-) create mode 100644 demos/layout-fruchterman-web-worker.html diff --git a/demos/case-simplify.html b/demos/case-simplify.html index f00f41d1c3..0fb312ddab 100644 --- a/demos/case-simplify.html +++ b/demos/case-simplify.html @@ -17,6 +17,9 @@ padding: 10px 8px; box-shadow: rgb(174, 174, 174) 0px 0px 10px; } + canvas { + border: 1px solid red; + }
布局中,请稍候......
@@ -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,6 +115,9 @@ }, }); + 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 = '布局完成!'; diff --git a/demos/layout-fruchterman-web-worker.html b/demos/layout-fruchterman-web-worker.html new file mode 100644 index 0000000000..7e0178e945 --- /dev/null +++ b/demos/layout-fruchterman-web-worker.html @@ -0,0 +1,1552 @@ + + + + + + Fruchterman Layout + + + +
+ + + + + + \ No newline at end of file From 699008d26fba97d4fb8cc41bfd33349f36f7167c Mon Sep 17 00:00:00 2001 From: bzhangzju Date: Wed, 20 Nov 2019 16:23:21 +0800 Subject: [PATCH 08/10] fix: case-simplify demo --- demos/case-simplify.html | 43 +++++++++++++++++++++------------------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/demos/case-simplify.html b/demos/case-simplify.html index 0fb312ddab..c607668558 100644 --- a/demos/case-simplify.html +++ b/demos/case-simplify.html @@ -121,31 +121,34 @@ 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 worker,graph.render是异步的,立即返回,在web worker里执行布局。 + // 而如果不使用web worker,graph.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(); From 9bccdaa850e5c499f2b611664383dbc428448acc Mon Sep 17 00:00:00 2001 From: bzhangzju Date: Wed, 20 Nov 2019 16:59:08 +0800 Subject: [PATCH 09/10] fix: add missing space character in demo --- demos/layout-force-timing-web-worker.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demos/layout-force-timing-web-worker.html b/demos/layout-force-timing-web-worker.html index 4cfc03b6f0..8c7bfb9a21 100644 --- a/demos/layout-force-timing-web-worker.html +++ b/demos/layout-force-timing-web-worker.html @@ -129,7 +129,7 @@ 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.'; + + ' After it is done, this text will be changed.'; }); graph.on('afterlayout', () => { tipDiv.innerHTML = 'Done!'; From af841d3ec6d47b0f7bafbce66d0902e68772bcd6 Mon Sep 17 00:00:00 2001 From: bzhangzju Date: Wed, 20 Nov 2019 17:25:55 +0800 Subject: [PATCH 10/10] fix: unit tests for layout using web worker require built bundle files --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2637490294..c375b01702 100644 --- a/package.json +++ b/package.json @@ -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",