Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve performance canvas heatmap #574

Merged
merged 17 commits into from
May 13, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions src/canvas/canvasRenderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,14 @@ var canvasRenderer = function (arg) {

var m_this = this,
m_renderAnimFrameRef = null,
m_clearCanvas = true,
s_init = this._init,
s_exit = this._exit;

this.clearCanvas = function (arg) {
m_clearCanvas = arg;
};

////////////////////////////////////////////////////////////////////////////
/**
* Get API used by the renderer
Expand Down Expand Up @@ -91,9 +96,12 @@ var canvasRenderer = function (arg) {
map = layer.map(),
camera = map.camera(),
viewport = camera._viewport;

// Clear the canvas.
m_this.context2d.setTransform(1, 0, 0, 1, 0, 0);
m_this.context2d.clearRect(0, 0, viewport.width, viewport.height);
if (m_clearCanvas) {
m_this.context2d.setTransform(1, 0, 0, 1, 0, 0);
m_this.context2d.clearRect(0, 0, viewport.width, viewport.height);
}

var features = layer.features();
for (var i = 0; i < features.length; i += 1) {
Expand Down
153 changes: 125 additions & 28 deletions src/canvas/heatmapFeature.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
var inherit = require('../inherit');
var registerFeature = require('../registry').registerFeature;
var heatmapFeature = require('../heatmapFeature');
var timestamp = require('../timestamp');

//////////////////////////////////////////////////////////////////////////////
/**
Expand All @@ -27,10 +28,17 @@ var canvas_heatmapFeature = function (arg) {
* @private
*/
////////////////////////////////////////////////////////////////////////////
var geo_event = require('../event');

var m_this = this,
m_typedBuffer,
m_typedClampedBuffer,
m_typedBufferData,
m_heatMapPosition,
s_exit = this._exit,
s_init = this._init,
s_update = this._update;
s_update = this._update,
m_renderTime = timestamp();

////////////////////////////////////////////////////////////////////////////
/**
Expand Down Expand Up @@ -117,18 +125,23 @@ var canvas_heatmapFeature = function (arg) {
*/
////////////////////////////////////////////////////////////////////////////
this._colorize = function (pixels, gradient) {
var i, j;
for (i = 0; i < pixels.length; i += 4) {
// Get opacity from the temporary canvas image,
// then multiply by 4 to get the color index on linear gradient
j = pixels[i + 3] * 4;
var grad = new Uint32Array(gradient.buffer),
pixlen = pixels.length,
i, j, k;
if (!m_typedBuffer || m_typedBuffer.length !== pixlen) {
m_typedBuffer = new ArrayBuffer(pixlen);
m_typedClampedBuffer = new Uint8ClampedArray(m_typedBuffer);
m_typedBufferData = new Uint32Array(m_typedBuffer);
}
for (i = 3, k = 0; i < pixlen; i += 4, k += 1) {
// Get opacity from the temporary canvas image and look up the final
// value from gradient
j = pixels[i];
if (j) {
pixels[i] = gradient[j];
pixels[i + 1] = gradient[j + 1];
pixels[i + 2] = gradient[j + 2];
pixels[i + 3] = m_this.style('opacity') * gradient[j + 3];
m_typedBufferData[k] = grad[j];
}
}
pixels.set(m_typedClampedBuffer);
};

////////////////////////////////////////////////////////////////////////////
Expand All @@ -138,24 +151,52 @@ var canvas_heatmapFeature = function (arg) {
*/
////////////////////////////////////////////////////////////////////////////
this._renderOnCanvas = function (context2d, map) {
var data = m_this.data() || [],
radius = m_this.style('radius') + m_this.style('blurRadius'),
pos, intensity, canvas, pixelArray;
m_this._createCircle();
m_this._computeGradient();
data.forEach(function (d) {
pos = m_this.layer().map().gcsToDisplay(m_this.position()(d));
intensity = (m_this.intensity()(d) - m_this.minIntensity()) /
(m_this.maxIntensity() - m_this.minIntensity());
// Small values are not visible because globalAlpha < .01
// cannot be read from imageData
context2d.globalAlpha = intensity < 0.01 ? 0.01 : intensity;
context2d.drawImage(m_this._circle, pos.x - radius, pos.y - radius);
});
canvas = m_this.layer().canvas()[0];
pixelArray = context2d.getImageData(0, 0, canvas.width, canvas.height);
m_this._colorize(pixelArray.data, m_this._grad);
context2d.putImageData(pixelArray, 0, 0);

if (m_renderTime.getMTime() < m_this.buildTime().getMTime()) {
var data = m_this.data() || [],
radius = m_this.style('radius') + m_this.style('blurRadius'),
pos, intensity, canvas, pixelArray,
layer = m_this.layer(),
viewport = map.camera()._viewport;

context2d.setTransform(1, 0, 0, 1, 0, 0);
context2d.clearRect(0, 0, viewport.width, viewport.height);
layer.canvas().css({transform: '', 'transform-origin': '0px 0px'});

m_this._createCircle();
m_this._computeGradient();
var position = m_this.gcsPosition(),
intensityFunc = m_this.intensity(),
minIntensity = m_this.minIntensity(),
rangeIntensity = (m_this.maxIntensity() - minIntensity) || 1;
for (var idx = data.length - 1; idx >= 0; idx -= 1) {
pos = map.worldToDisplay(position[idx]);
intensity = (intensityFunc(data[idx]) - minIntensity) / rangeIntensity;
if (intensity <= 0) {
continue;
}
// Small values are not visible because globalAlpha < .01
// cannot be read from imageData
context2d.globalAlpha = intensity < 0.01 ? 0.01 : (intensity > 1 ? 1 : intensity);
context2d.drawImage(m_this._circle, pos.x - radius, pos.y - radius);
}
canvas = layer.canvas()[0];
pixelArray = context2d.getImageData(0, 0, canvas.width, canvas.height);
m_this._colorize(pixelArray.data, m_this._grad);
context2d.putImageData(pixelArray, 0, 0);

m_heatMapPosition = {
zoom: map.zoom(),
gcsOrigin: map.displayToGcs({x: 0, y: 0}, null),
rotation: map.rotation(),
lastScale: undefined,
lastOrigin: {x: 0, y: 0},
lastRotation: undefined
};
m_renderTime.modified();
layer.renderer().clearCanvas(false);
}

return m_this;
};

Expand All @@ -167,6 +208,9 @@ var canvas_heatmapFeature = function (arg) {
////////////////////////////////////////////////////////////////////////////
this._init = function () {
s_init.call(m_this, arg);

m_this.geoOn(geo_event.pan, m_this._animatePan);

return m_this;
};

Expand All @@ -186,6 +230,59 @@ var canvas_heatmapFeature = function (arg) {
return m_this;
};

////////////////////////////////////////////////////////////////////////////
/**
* Animate pan (and zoom)
* @protected
*/
////////////////////////////////////////////////////////////////////////////
this._animatePan = function (e) {

var map = m_this.layer().map(),
zoom = map.zoom(),
scale = Math.pow(2, (zoom - m_heatMapPosition.zoom)),
origin = map.gcsToDisplay(m_heatMapPosition.gcsOrigin, null),
rotation = map.rotation();

if (m_heatMapPosition.lastScale === scale &&
m_heatMapPosition.lastOrigin.x === origin.x &&
m_heatMapPosition.lastOrigin.y === origin.y &&
m_heatMapPosition.lastRotation === rotation) {
return;
}

var transform = '' +
' translate(' + origin.x + 'px' + ',' + origin.y + 'px' + ')' +
' scale(' + scale + ')' +
' rotate(' + ((rotation - m_heatMapPosition.rotation) * 180 / Math.PI) + 'deg)';

m_this.layer().canvas()[0].style.transform = transform;

m_heatMapPosition.lastScale = scale;
m_heatMapPosition.lastOrigin.x = origin.x;
m_heatMapPosition.lastOrigin.y = origin.y;
m_heatMapPosition.lastRotation = rotation;

if (m_heatMapPosition.timeout) {
window.clearTimeout(m_heatMapPosition.timeout);
m_heatMapPosition.timeout = undefined;
}
/* This conditional can change if we compute the heatmap beyond the visable
* viewport so that we don't have to update on pans as often. If we are
* close to where the heatmap was originally computed, don't bother
* updating it. */
if (parseFloat(scale.toFixed(4)) !== 1 ||
parseFloat((rotation - m_heatMapPosition.rotation).toFixed(4)) !== 0 ||
parseFloat(origin.x.toFixed(1)) !== 0 ||
parseFloat(origin.y.toFixed(1)) !== 0) {
m_heatMapPosition.timeout = window.setTimeout(function () {
m_heatMapPosition.timeout = undefined;
m_this.buildTime().modified();
m_this.layer().draw();
}, m_this.updateDelay());
}
};

////////////////////////////////////////////////////////////////////////////
/**
* Destroy
Expand Down
86 changes: 65 additions & 21 deletions src/heatmapFeature.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
var $ = require('jquery');
var inherit = require('./inherit');
var feature = require('./feature');
var transform = require('./transform');

//////////////////////////////////////////////////////////////////////////////
/**
Expand All @@ -12,25 +13,26 @@ var feature = require('./feature');
* @param {Object|string|Function} [color] Color transfer function that.
* will be used to evaluate color of each pixel using normalized intensity
* as the look up value.
* @param {number|Function} [opacity=1] Homogeneous opacity for each pixel.
* @param {Object|Function} [radius=10] Radius of a point in terms of number
* of pixels.
* @param {Object|Function} [blurRadius=10] Gaussian blur radius for each
* point in terms of number of pixels.
* @param {Object|Function} [position] Position of the data. Default is
* (data). The position is an Object which specifies the location of the
* data in geo-spatial context.
* @param {boolean} [intensity] Scalar value of each data point. Scalar
* @param {Object|Function} [intensity] Scalar value of each data point. Scalar
* value must be a positive real number and will be used to compute
* the weight for each data point.
* @param {boolean} [maxIntensity=null] Maximum intensity of the data. Maximum
* @param {number} [maxIntensity=null] Maximum intensity of the data. Maximum
* intensity must be a positive real number and will be used to normalize all
* intensities with a dataset. If no value is given, then a it will
* be computed.
* @param {boolean} [minIntensity=null] Minimum intensity of the data. Minimum
* @param {number} [minIntensity=null] Minimum intensity of the data. Minimum
* intensity must be a positive real number will be used to normalize all
* intensities with a dataset. If no value is given, then a it will
* be computed.
* @param {number} [updateDelay=1000] Delay in milliseconds after a zoom,
* rotate, or pan event before recomputing the heatmap.
* @returns {geo.heatmapFeature}
*/
//////////////////////////////////////////////////////////////////////////////
Expand All @@ -54,12 +56,15 @@ var heatmapFeature = function (arg) {
m_intensity,
m_maxIntensity,
m_minIntensity,
m_updateDelay,
m_gcsPosition,
s_init = this._init;

m_position = arg.position || function (d) { return d; };
m_intensity = arg.intensity || function (d) { return 1; };
m_maxIntensity = arg.maxIntensity || null;
m_minIntensity = arg.minIntensity ? arg.minIntensity : null;
m_maxIntensity = arg.maxIntensity !== undefined ? arg.maxIntensity : null;
m_minIntensity = arg.minIntensity !== undefined ? arg.minIntensity : null;
m_updateDelay = arg.updateDelay ? parseInt(arg.updateDelay, 10) : 1000;

////////////////////////////////////////////////////////////////////////////
/**
Expand Down Expand Up @@ -97,6 +102,22 @@ var heatmapFeature = function (arg) {
return m_this;
};

////////////////////////////////////////////////////////////////////////////
/**
* Get/Set updateDelay
*
* @returns {geo.heatmap}
*/
////////////////////////////////////////////////////////////////////////////
this.updateDelay = function (val) {
if (val === undefined) {
return m_updateDelay;
} else {
m_updateDelay = parseInt(val, 10);
}
return m_this;
};

////////////////////////////////////////////////////////////////////////////
/**
* Get/Set position accessor
Expand All @@ -115,6 +136,18 @@ var heatmapFeature = function (arg) {
return m_this;
};

////////////////////////////////////////////////////////////////////////////
/**
* Get pre-computed gcs position accessor
*
* @returns {geo.heatmap}
*/
////////////////////////////////////////////////////////////////////////////
this.gcsPosition = function () {
this._update();
return m_gcsPosition;
};

////////////////////////////////////////////////////////////////////////////
/**
* Get/Set intensity
Expand Down Expand Up @@ -144,7 +177,6 @@ var heatmapFeature = function (arg) {
var defaultStyle = $.extend(
{},
{
opacity: 0.1,
radius: 10,
blurRadius: 10,
color: {0: {r: 0, g: 0, b: 0.0, a: 0.0},
Expand All @@ -171,23 +203,35 @@ var heatmapFeature = function (arg) {
////////////////////////////////////////////////////////////////////////////
this._build = function () {
var data = m_this.data(),
intensity = null;

if (!m_maxIntensity || !m_minIntensity) {
data.forEach(function (d) {
intensity = null,
position = [],
setMax = (m_maxIntensity === null || m_maxIntensity === undefined),
setMin = (m_minIntensity === null || m_minIntensity === undefined);

data.forEach(function (d) {
position.push(m_this.position()(d));
if (setMax || setMin) {
intensity = m_this.intensity()(d);
if (!m_maxIntensity && !m_minIntensity) {
m_maxIntensity = m_minIntensity = intensity;
} else {
if (intensity > m_maxIntensity) {
m_maxIntensity = intensity;
}
if (intensity < m_minIntensity) {
m_minIntensity = intensity;
}
if (m_maxIntensity === null || m_maxIntensity === undefined) {
m_maxIntensity = intensity;
}
if (m_minIntensity === null || m_minIntensity === undefined) {
m_minIntensity = intensity;
}
});
if (setMax && intensity > m_maxIntensity) {
m_maxIntensity = intensity;
}
if (setMin && intensity < m_minIntensity) {
m_minIntensity = intensity;
}

}
});
if (setMin && setMax && m_minIntensity === m_maxIntensity) {
m_minIntensity -= 1;
}
m_gcsPosition = transform.transformCoordinates(
m_this.gcs(), m_this.layer().map().gcs(), position);

m_this.buildTime().modified();
return m_this;
Expand Down
3 changes: 3 additions & 0 deletions src/transform.js
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,9 @@ transform.transformCoordinatesArray = function (trans, coordinates, numberOfComp
output.length = coordinates.length;
count = coordinates.length;

if (!coordinates.length) {
return output;
}
if (coordinates[0] instanceof Array ||
coordinates[0] instanceof Object) {
offset = 1;
Expand Down
Loading