Simplify tag cloud scheduling (#10008)

Backports PR #9445

Removes the queue. This was unnecessary. We are only interested in the last update, not all the intermediate ones. This is a small performance enhancement.

Updating of any outstanding request, as well as processing outstanding requests is now done in the same async operation. Previously, these were kept separate, creating possible race conditions.

Also includes code-style changes:
- use promises to represent async tasks
- improve readability/consistency
This commit is contained in:
jasper 2017-01-23 13:22:12 -05:00 committed by Thomas Neirynck
parent 033cd5880a
commit dd2db3eb26
2 changed files with 151 additions and 122 deletions

View file

@ -3,7 +3,6 @@ import d3TagCloud from 'd3-cloud';
import vislibComponentsSeedColorsProvider from 'ui/vis/components/color/seed_colors';
import { EventEmitter } from 'events';
const ORIENTATIONS = {
'single': () => 0,
'right angled': (tag) => {
@ -20,7 +19,6 @@ const D3_SCALING_FUNCTIONS = {
'square root': () => d3.scale.sqrt()
};
class TagCloud extends EventEmitter {
constructor(domNode) {
@ -53,14 +51,16 @@ class TagCloud extends EventEmitter {
this._words = null;
//UTIL
this._handle = null;
this._queue = [];
this._setTimeoutId = null;
this._pendingJob = null;
this._layoutIsUpdating = null;
this._allInViewBox = false;
this._inFlight = false;
this._DOMisUpdating = false;
}
setOptions(options) {
if (JSON.stringify(options) === this._optionsAsString) {
return;
}
@ -101,7 +101,7 @@ class TagCloud extends EventEmitter {
}
destroy() {
clearTimeout(this._handle);
clearTimeout(this._setTimeoutId);
this._element.innerHTML = '';
}
@ -116,116 +116,142 @@ class TagCloud extends EventEmitter {
this._svgGroup.attr('height', this._size[1]);
}
_processQueue() {
_isJobRunning() {
return (this._setTimeoutId || this._layoutIsUpdating || this._DOMisUpdating);
}
if (!this._queue.length) {
this.emit('renderComplete');
async _processPendingJob() {
if (!this._pendingJob) {
return;
}
if (this._inFlight) {
if (this._isJobRunning()) {
return;
}
const job = this._queue.pop();
this._inFlight = true;
this._completedJob = null;
const job = await this._pickPendingJob();
if (job.words.length) {
this._onLayoutEnd(job);
if (job.refreshLayout) {
await this._updateLayout(job);
}
await this._updateDOM(job);
const cloudBBox = this._svgGroup[0][0].getBBox();
this._cloudWidth = cloudBBox.width;
this._cloudHeight = cloudBBox.height;
this._allInViewBox = cloudBBox.x >= 0 &&
cloudBBox.y >= 0 &&
cloudBBox.x + cloudBBox.width <= this._element.offsetWidth &&
cloudBBox.y + cloudBBox.height <= this._element.offsetHeight;
} else {
this._emptyCloud(job);
this._emptyDOM(job);
}
if (this._pendingJob) {
this._processPendingJob();//pick up next job
} else {
this._completedJob = job;
this.emit('renderComplete');
}
}
_emptyCloud(job) {
async _pickPendingJob() {
return await new Promise((resolve) => {
this._setTimeoutId = setTimeout(async() => {
const job = this._pendingJob;
this._pendingJob = null;
this._setTimeoutId = null;
resolve(job);
}, 0);
});
}
_emptyDOM() {
this._svgGroup.selectAll('text').remove();
this._cloudWidth = 0;
this._cloudHeight = 0;
this._allInViewBox = true;
this._inFlight = false;
this._currentJob = job;
this._processQueue();
this._DOMisUpdating = false;
}
_onLayoutEnd(job) {
async _updateDOM(job) {
if (this._handle !== null) {//a new configuration is coming, no need to update
this._processQueue();
const canSkipDomUpdate = this._pendingJob || this._setTimeoutId;
if (canSkipDomUpdate) {
this._DOMisUpdating = false;
return;
}
this._currentJob = null;
this._DOMisUpdating = true;
const affineTransform = positionWord.bind(null, this._element.offsetWidth / 2, this._element.offsetHeight / 2);
const svgTextNodes = this._svgGroup.selectAll('text');
const stage = svgTextNodes.data(job.words, getText);
const enterSelection = stage.enter();
const enteringTags = enterSelection.append('text');
enteringTags.style('font-size', getSizeInPixels);
enteringTags.style('font-style', this._fontStyle);
enteringTags.style('font-weight', () => this._fontWeight);
enteringTags.style('font-family', () => this._fontFamily);
enteringTags.style('fill', getFill);
enteringTags.attr('text-anchor', () => 'middle');
enteringTags.attr('transform', affineTransform);
enteringTags.text(getText);
await new Promise((resolve) => {
const enterSelection = stage.enter();
const enteringTags = enterSelection.append('text');
enteringTags.style('font-size', getSizeInPixels);
enteringTags.style('font-style', this._fontStyle);
enteringTags.style('font-weight', () => this._fontWeight);
enteringTags.style('font-family', () => this._fontFamily);
enteringTags.style('fill', getFill);
enteringTags.attr('text-anchor', () => 'middle');
enteringTags.attr('transform', affineTransform);
enteringTags.text(getText);
const self = this;
enteringTags.on({
click: function (event) {
self.emit('select', event.text);
},
mouseover: function () {
d3.select(this).style('cursor', 'pointer');
},
mouseout: function () {
d3.select(this).style('cursor', 'default');
}
});
const movingTags = stage.transition();
movingTags.duration(600);
movingTags.style('font-size', getSizeInPixels);
movingTags.style('font-style', this._fontStyle);
movingTags.style('font-weight', () => this._fontWeight);
movingTags.style('font-family', () => this._fontFamily);
movingTags.attr('transform', affineTransform);
const exitingTags = stage.exit();
const exitTransition = exitingTags.transition();
exitTransition.duration(200);
exitingTags.style('fill-opacity', 1e-6);
exitingTags.attr('font-size', 1);
exitingTags.remove();
let exits = 0;
let moves = 0;
const resolveWhenDone = () => {
if (exits === 0 && moves === 0) {
this._DOMisUpdating = false;
resolve(true);
}
};
exitTransition.each(_ => exits++);
exitTransition.each('end', () => {
exits--;
resolveWhenDone();
});
movingTags.each(_ => moves++);
movingTags.each('end', () => {
moves--;
resolveWhenDone();
});
const self = this;
enteringTags.on({
click: function (event) {
self.emit('select', event.text);
},
mouseover: function (d) {
d3.select(this).style('cursor', 'pointer');
},
mouseout: function (d) {
d3.select(this).style('cursor', 'default');
}
});
const movingTags = stage.transition();
movingTags.duration(600);
movingTags.style('font-size', getSizeInPixels);
movingTags.style('font-style', this._fontStyle);
movingTags.style('font-weight', () => this._fontWeight);
movingTags.style('font-family', () => this._fontFamily);
movingTags.attr('transform', affineTransform);
const exitingTags = stage.exit();
const exitTransition = exitingTags.transition();
exitTransition.duration(200);
exitingTags.style('fill-opacity', 1e-6);
exitingTags.attr('font-size', 1);
exitingTags.remove();
let exits = 0;
let moves = 0;
const resolveWhenDone = () => {
if (exits === 0 && moves === 0) {
const cloudBBox = this._svgGroup[0][0].getBBox();
this._cloudWidth = cloudBBox.width;
this._cloudHeight = cloudBBox.height;
this._allInViewBox = cloudBBox.x >= 0 && cloudBBox.y >= 0 &&
cloudBBox.x + cloudBBox.width <= this._element.offsetWidth &&
cloudBBox.y + cloudBBox.height <= this._element.offsetHeight;
this._inFlight = false;
this._currentJob = job;
this._processQueue();
}
};
exitTransition.each(_ => exits++);
exitTransition.each('end', () => {
exits--;
resolveWhenDone();
});
movingTags.each(_ => moves++);
movingTags.each('end', () => {
moves--;
resolveWhenDone();
});
}
_makeTextSizeMapper() {
@ -238,76 +264,79 @@ class TagCloud extends EventEmitter {
return mapSizeToFontSize;
}
_makeJob() {
_makeNewJob() {
return {
refreshLayout: true,
size: this._size.slice(),
words: this._words.map(toWordTag)
};
}
_makeJobPreservingLayout() {
return {
refreshLayout: false,
size: this._size.slice(),
words: this._completedJob.words.map(tag => {
return {
x: tag.x,
y: tag.y,
rotate: tag.rotate,
size: tag.size,
text: tag.text
};
})
};
}
_invalidate(keepLayout) {
if (!this._words) {
return;
}
clearTimeout(this._handle);
this._handle = setTimeout(() => {
this._handle = null;
this._updateContainerSize();
if (keepLayout && this._currentJob && this._queue.length === 0) {
this._scheduleLayout({
words: this._currentJob.words.map(tag => {
return {
x: tag.x,
y: tag.y,
rotate: tag.rotate,
size: tag.size,
text: tag.text
};
})
});
} else {
this._updateLayout();
}
}, 0);//unhook from callstack. this avoids kicking off multiple layouts if multiple changes come in succession
this._updateContainerSize();
const canReuseLayout = keepLayout && !this._isJobRunning() && this._completedJob;
this._pendingJob = (canReuseLayout) ? this._makeJobPreservingLayout() : this._makeNewJob();
this._processPendingJob();
}
_scheduleLayout(job) {
this._queue.unshift(job);
this._processQueue();
}
_updateLayout() {
async _updateLayout(job) {
const job = this._makeJob();
const mapSizeToFontSize = this._makeTextSizeMapper();
const tagCloudLayoutGenerator = d3TagCloud();
tagCloudLayoutGenerator.size(this._size);
tagCloudLayoutGenerator.size(job.size);
tagCloudLayoutGenerator.padding(this._padding);
tagCloudLayoutGenerator.rotate(ORIENTATIONS[this._orientation]);
tagCloudLayoutGenerator.font(this._fontFamily);
tagCloudLayoutGenerator.fontStyle(this._fontStyle);
tagCloudLayoutGenerator.fontWeight(this._fontWeight);
tagCloudLayoutGenerator.fontSize(tag => {
return mapSizeToFontSize(tag.value);
});
tagCloudLayoutGenerator.fontSize(tag => mapSizeToFontSize(tag.value));
tagCloudLayoutGenerator.random(seed);
tagCloudLayoutGenerator.spiral(this._spiral);
tagCloudLayoutGenerator.words(job.words);
tagCloudLayoutGenerator.text(getText);
tagCloudLayoutGenerator.timeInterval(this._timeInterval);
tagCloudLayoutGenerator.on('end', () => this._scheduleLayout(job));
tagCloudLayoutGenerator.start();
this._layoutIsUpdating = true;
await new Promise((resolve) => {
tagCloudLayoutGenerator.on('end', () => {
this._layoutIsUpdating = false;
resolve(true);
});
tagCloudLayoutGenerator.start();
});
}
/**
* Returns debug info. For debugging only.
* @return {*}
*/
getDebugInfo() {
const debug = {};
debug.positions = this._currentJob ? this._currentJob.words.map(tag => {
debug.positions = this._completedJob ? this._completedJob.words.map(tag => {
return {
text: tag.text,
x: tag.x,

View file

@ -15,7 +15,7 @@ visTypes.register(function TagCloudProvider(Private) {
title: 'Tag cloud',
implementsRenderComplete: true,
description: 'A tag cloud visualization is a visual representation of text data, ' +
'typically used to visualize free form text. Tags are usually single words. The font size of word corresponds' +
'typically used to visualize individual words. The font size of a word corresponds ' +
'with its importance.',
icon: 'fa-cloud',
template: tagCloudTemplate,