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 9 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
187 changes: 157 additions & 30 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 = null,
m_typedClampedBuffer = null,
m_typedBufferData = null,
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 @@ -116,19 +124,55 @@ var canvas_heatmapFeature = function (arg) {
* @protected
*/
////////////////////////////////////////////////////////////////////////////
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;
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];
this._colorize = function (context2d, width, height, imageData, gradient) {
var isLittleEndian = true, i, j, index;

// Determine whether Uint32 is little- or big-endian.
if (!m_typedBuffer || (m_typedBuffer.length !== imageData.data.length)) {
m_typedBuffer = new ArrayBuffer(imageData.data.length);
m_typedClampedBuffer = new Uint8ClampedArray(m_typedBuffer);
m_typedBufferData = new Uint32Array(m_typedBuffer);
}

m_typedBufferData[1] = 0x0a0b0c0d;

isLittleEndian = true;
if (m_typedBuffer[4] === 0x0a &&
m_typedBuffer[5] === 0x0b &&
m_typedBuffer[6] === 0x0c &&
m_typedBuffer[7] === 0x0d) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When I try this out in the Chrome console, it doesn't work. m_typedBuffer references an ArrayBuffer, which doesn't give me index reference access to the array. I think this comparison needs to be on the m_typedClampedBuffer, not on m_typedBuffer. What is here works, but only because we've only tried in on little-endian machines.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. let me have a look at it.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@manthey yes, it should be on clamped buffer, thanks for report this. I pushed a fix.

isLittleEndian = false;
}

if (isLittleEndian) {
i = 0;
for (j = 0; j < (width * height * 4); j += 4) {
index = imageData.data[j + 3] * 4;
if (index) {
m_typedBufferData[i] =
(gradient[index + 3] << 24) |
(gradient[index + 2] << 16) |
(gradient[index + 1] << 8) |
gradient[index];
}
i += 1;
}
} else {
i = 0;
for (j = 0; j < (width * height * 4); j += 4) {
index = imageData.data[j + 3] * 4;
if (index) {
m_typedBufferData[i] =
(gradient[index] << 24) |
(gradient[index + 1] << 16) |
(gradient[index + 2] << 8) |
gradient[index + 3];
}
}
}

imageData.data.set(m_typedClampedBuffer);
context2d.putImageData(imageData, 0, 0);
};

////////////////////////////////////////////////////////////////////////////
Expand All @@ -138,24 +182,51 @@ 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(context2d, canvas.width, canvas.height, pixelArray, m_this._grad);

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 +238,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 +260,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
78 changes: 62 additions & 16 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 Down Expand Up @@ -31,6 +32,8 @@ var feature = require('./feature');
* 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 +57,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 +103,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 +137,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 @@ -171,23 +205,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