diff --git a/draftlogs/6574_change.md b/draftlogs/6574_change.md
new file mode 100644
index 00000000000..fb1fd40e1ec
--- /dev/null
+++ b/draftlogs/6574_change.md
@@ -0,0 +1 @@
+ - Improve heatmap rendering performance when `zsmooth` is set to false [[#6574](https://github.com/plotly/plotly.js/pull/6574)], with thanks to @lvlte for the contribution!
diff --git a/src/constants/pixelated_image.js b/src/constants/pixelated_image.js
new file mode 100644
index 00000000000..ca4f18ed3db
--- /dev/null
+++ b/src/constants/pixelated_image.js
@@ -0,0 +1,22 @@
+'use strict';
+
+// Pixelated image rendering
+// The actual CSS declaration is prepended with fallbacks for older browsers.
+// NB. IE's `-ms-interpolation-mode` works only with
not with SVG
+// https://developer.mozilla.org/en-US/docs/Web/CSS/image-rendering
+// https://caniuse.com/?search=image-rendering
+// http://phrogz.net/tmp/canvas_image_zoom.html
+
+exports.CSS_DECLARATIONS = [
+ ['image-rendering', 'optimizeSpeed'],
+ ['image-rendering', '-moz-crisp-edges'],
+ ['image-rendering', '-o-crisp-edges'],
+ ['image-rendering', '-webkit-optimize-contrast'],
+ ['image-rendering', 'optimize-contrast'],
+ ['image-rendering', 'crisp-edges'],
+ ['image-rendering', 'pixelated']
+];
+
+exports.STYLE = exports.CSS_DECLARATIONS.map(function(d) {
+ return d.join(': ') + '; ';
+}).join('');
diff --git a/src/lib/supports_pixelated_image.js b/src/lib/supports_pixelated_image.js
new file mode 100644
index 00000000000..0b5d2e258ee
--- /dev/null
+++ b/src/lib/supports_pixelated_image.js
@@ -0,0 +1,42 @@
+'use strict';
+
+var constants = require('../constants/pixelated_image');
+var Drawing = require('../components/drawing');
+var Lib = require('../lib');
+
+var _supportsPixelated = null;
+
+/**
+ * Check browser support for pixelated image rendering
+ *
+ * @return {boolean}
+ */
+function supportsPixelatedImage() {
+ if(_supportsPixelated !== null) { // only run the feature detection once
+ return _supportsPixelated;
+ }
+ if(Lib.isIE()) {
+ _supportsPixelated = false;
+ } else {
+ var declarations = Array.from(constants.CSS_DECLARATIONS).reverse();
+ var supports = window.CSS && window.CSS.supports || window.supportsCSS;
+ if(typeof supports === 'function') {
+ _supportsPixelated = declarations.some(function(d) {
+ return supports.apply(null, d);
+ });
+ } else {
+ var image3 = Drawing.tester.append('image');
+ var cStyles = window.getComputedStyle(image3.node());
+ image3.attr('style', constants.STYLE);
+ _supportsPixelated = declarations.some(function(d) {
+ var value = d[1];
+ return cStyles.imageRendering === value ||
+ cStyles.imageRendering === value.toLowerCase();
+ });
+ image3.remove();
+ }
+ }
+ return _supportsPixelated;
+}
+
+module.exports = supportsPixelatedImage;
diff --git a/src/traces/heatmap/calc.js b/src/traces/heatmap/calc.js
index a190c388284..e477abbb332 100644
--- a/src/traces/heatmap/calc.js
+++ b/src/traces/heatmap/calc.js
@@ -90,32 +90,31 @@ module.exports = function calc(gd, trace) {
Lib.warn('cannot use zsmooth: "fast": ' + msg);
}
- // check whether we really can smooth (ie all boxes are about the same size)
- if(zsmooth === 'fast') {
- if(xa.type === 'log' || ya.type === 'log') {
- noZsmooth('log axis found');
- } else if(!isHist) {
- if(x.length) {
- var avgdx = (x[x.length - 1] - x[0]) / (x.length - 1);
- var maxErrX = Math.abs(avgdx / 100);
- for(i = 0; i < x.length - 1; i++) {
- if(Math.abs(x[i + 1] - x[i] - avgdx) > maxErrX) {
- noZsmooth('x scale is not linear');
- break;
- }
- }
- }
- if(y.length && zsmooth === 'fast') {
- var avgdy = (y[y.length - 1] - y[0]) / (y.length - 1);
- var maxErrY = Math.abs(avgdy / 100);
- for(i = 0; i < y.length - 1; i++) {
- if(Math.abs(y[i + 1] - y[i] - avgdy) > maxErrY) {
- noZsmooth('y scale is not linear');
- break;
- }
+ function scaleIsLinear(s) {
+ if(s.length > 1) {
+ var avgdx = (s[s.length - 1] - s[0]) / (s.length - 1);
+ var maxErrX = Math.abs(avgdx / 100);
+ for(i = 0; i < s.length - 1; i++) {
+ if(Math.abs(s[i + 1] - s[i] - avgdx) > maxErrX) {
+ return false;
}
}
}
+ return true;
+ }
+
+ // Check whether all brick are uniform
+ trace._islinear = false;
+ if(xa.type === 'log' || ya.type === 'log') {
+ if(zsmooth === 'fast') {
+ noZsmooth('log axis found');
+ }
+ } else if(!scaleIsLinear(x)) {
+ if(zsmooth === 'fast') noZsmooth('x scale is not linear');
+ } else if(!scaleIsLinear(y)) {
+ if(zsmooth === 'fast') noZsmooth('y scale is not linear');
+ } else {
+ trace._islinear = true;
}
// create arrays of brick boundaries, to be used by autorange and heatmap.plot
diff --git a/src/traces/heatmap/plot.js b/src/traces/heatmap/plot.js
index e4e3bfd840e..e0afbf5cb5d 100644
--- a/src/traces/heatmap/plot.js
+++ b/src/traces/heatmap/plot.js
@@ -15,6 +15,8 @@ var makeColorScaleFuncFromTrace = require('../../components/colorscale').makeCol
var xmlnsNamespaces = require('../../constants/xmlns_namespaces');
var alignmentConstants = require('../../constants/alignment');
var LINE_SPACING = alignmentConstants.LINE_SPACING;
+var supportsPixelatedImage = require('../../lib/supports_pixelated_image');
+var PIXELATED_IMAGE_STYLE = require('../../constants/pixelated_image').STYLE;
var labelClass = 'heatmap-label';
@@ -109,11 +111,18 @@ module.exports = function(gd, plotinfo, cdheatmaps, heatmapLayer) {
y = cd0.yfill;
}
+ var drawingMethod = 'default';
+ if(zsmooth) {
+ drawingMethod = zsmooth === 'best' ? 'smooth' : 'fast';
+ } else if(trace._islinear && xGap === 0 && yGap === 0 && supportsPixelatedImage()) {
+ drawingMethod = 'fast';
+ }
+
// make an image that goes at most half a screen off either side, to keep
- // time reasonable when you zoom in. if zsmooth is true/fast, don't worry
+ // time reasonable when you zoom in. if drawingMethod is fast, don't worry
// about this, because zooming doesn't increase number of pixels
// if zsmooth is best, don't include anything off screen because it takes too long
- if(zsmooth !== 'fast') {
+ if(drawingMethod !== 'fast') {
var extra = zsmooth === 'best' ? 0 : 0.5;
left = Math.max(-extra * xa._length, left);
right = Math.min((1 + extra) * xa._length, right);
@@ -127,7 +136,9 @@ module.exports = function(gd, plotinfo, cdheatmaps, heatmapLayer) {
// setup image nodes
// if image is entirely off-screen, don't even draw it
- var isOffScreen = (imageWidth <= 0 || imageHeight <= 0);
+ var isOffScreen = (
+ left >= xa._length || right <= 0 || top >= ya._length || bottom <= 0
+ );
if(isOffScreen) {
var noImage = plotGroup.selectAll('image').data([]);
@@ -140,7 +151,7 @@ module.exports = function(gd, plotinfo, cdheatmaps, heatmapLayer) {
// generate image data
var canvasW, canvasH;
- if(zsmooth === 'fast') {
+ if(drawingMethod === 'fast') {
canvasW = n;
canvasH = m;
} else {
@@ -158,7 +169,7 @@ module.exports = function(gd, plotinfo, cdheatmaps, heatmapLayer) {
// map brick boundaries to image pixels
var xpx,
ypx;
- if(zsmooth === 'fast') {
+ if(drawingMethod === 'fast') {
xpx = xrev ?
function(index) { return n - 1 - index; } :
Lib.identity;
@@ -235,7 +246,7 @@ module.exports = function(gd, plotinfo, cdheatmaps, heatmapLayer) {
return setColor(z00 + xinterp.frac * dx + yinterp.frac * (dy + xinterp.frac * dxy));
}
- if(zsmooth) { // best or fast, works fastest with imageData
+ if(drawingMethod !== 'default') { // works fastest with imageData
var pxIndex = 0;
var pixels;
@@ -245,7 +256,7 @@ module.exports = function(gd, plotinfo, cdheatmaps, heatmapLayer) {
pixels = new Array(canvasW * canvasH * 4);
}
- if(zsmooth === 'best') {
+ if(drawingMethod === 'smooth') { // zsmooth="best"
var xForPx = xc || x;
var yForPx = yc || y;
var xPixArray = new Array(xForPx.length);
@@ -273,7 +284,7 @@ module.exports = function(gd, plotinfo, cdheatmaps, heatmapLayer) {
putColor(pixels, pxIndex, c);
}
}
- } else { // zsmooth = fast
+ } else { // drawingMethod = "fast" (zsmooth = "fast"|false)
for(j = 0; j < m; j++) {
row = z[j];
yb = ypx(j);
@@ -297,7 +308,8 @@ module.exports = function(gd, plotinfo, cdheatmaps, heatmapLayer) {
}
context.putImageData(imageData, 0, 0);
- } else { // zsmooth = false -> filling potentially large bricks works fastest with fillRect
+ } else { // rawingMethod = "default" (zsmooth = false)
+ // filling potentially large bricks works fastest with fillRect
// gaps do not need to be exact integers, but if they *are* we will get
// cleaner edges by rounding at least one edge
var xGapLeft = Math.floor(xGap / 2);
@@ -353,6 +365,10 @@ module.exports = function(gd, plotinfo, cdheatmaps, heatmapLayer) {
'xlink:href': canvas.toDataURL('image/png')
});
+ if(drawingMethod === 'fast' && !zsmooth) {
+ image3.attr('style', PIXELATED_IMAGE_STYLE);
+ }
+
removeLabels(plotGroup);
var texttemplate = trace.texttemplate;
diff --git a/src/traces/image/constants.js b/src/traces/image/constants.js
index 3eefa7649e3..29cc62b9128 100644
--- a/src/traces/image/constants.js
+++ b/src/traces/image/constants.js
@@ -47,18 +47,5 @@ module.exports = {
},
suffix: ['°', '%', '%', '']
}
- },
- // For pixelated image rendering
- // http://phrogz.net/tmp/canvas_image_zoom.html
- // https://developer.mozilla.org/en-US/docs/Web/CSS/image-rendering
- pixelatedStyle: [
- 'image-rendering: optimizeSpeed',
- 'image-rendering: -moz-crisp-edges',
- 'image-rendering: -o-crisp-edges',
- 'image-rendering: -webkit-optimize-contrast',
- 'image-rendering: optimize-contrast',
- 'image-rendering: crisp-edges',
- 'image-rendering: pixelated',
- ''
- ].join('; ')
+ }
};
diff --git a/src/traces/image/plot.js b/src/traces/image/plot.js
index a8a9e182fc7..1b6ab3d5fd9 100644
--- a/src/traces/image/plot.js
+++ b/src/traces/image/plot.js
@@ -5,21 +5,21 @@ var Lib = require('../../lib');
var strTranslate = Lib.strTranslate;
var xmlnsNamespaces = require('../../constants/xmlns_namespaces');
var constants = require('./constants');
-
-var unsupportedBrowsers = Lib.isIOS() || Lib.isSafari() || Lib.isIE();
+var supportsPixelatedImage = require('../../lib/supports_pixelated_image');
+var PIXELATED_IMAGE_STYLE = require('../../constants/pixelated_image').STYLE;
module.exports = function plot(gd, plotinfo, cdimage, imageLayer) {
var xa = plotinfo.xaxis;
var ya = plotinfo.yaxis;
- var supportsPixelatedImage = !(unsupportedBrowsers || gd._context._exportedPlot);
+ var supportsPixelated = !gd._context._exportedPlot && supportsPixelatedImage();
Lib.makeTraceGroups(imageLayer, cdimage, 'im').each(function(cd) {
var plotGroup = d3.select(this);
var cd0 = cd[0];
var trace = cd0.trace;
var realImage = (
- ((trace.zsmooth === 'fast') || (trace.zsmooth === false && supportsPixelatedImage)) &&
+ ((trace.zsmooth === 'fast') || (trace.zsmooth === false && supportsPixelated)) &&
!trace._hasZ && trace._hasSource && xa.type === 'linear' && ya.type === 'linear'
);
trace._realImage = realImage;
@@ -131,7 +131,7 @@ module.exports = function plot(gd, plotinfo, cdimage, imageLayer) {
image3.exit().remove();
- var style = (trace.zsmooth === false) ? constants.pixelatedStyle : '';
+ var style = (trace.zsmooth === false) ? PIXELATED_IMAGE_STYLE : '';
if(realImage) {
var xRange = Lib.simpleMap(xa.range, xa.r2l);
diff --git a/tasks/test_syntax.js b/tasks/test_syntax.js
index 63343fb7de5..f948c2892bb 100644
--- a/tasks/test_syntax.js
+++ b/tasks/test_syntax.js
@@ -193,7 +193,7 @@ function assertSrcContents() {
* - If you use conforms to these rules, you may update
* KNOWN_GET_COMPUTED_STYLE_CALLS to count the new use.
*/
- var KNOWN_GET_COMPUTED_STYLE_CALLS = 6;
+ var KNOWN_GET_COMPUTED_STYLE_CALLS = 7;
if(getComputedStyleCnt !== KNOWN_GET_COMPUTED_STYLE_CALLS) {
logs.push('Expected ' + KNOWN_GET_COMPUTED_STYLE_CALLS +
' window.getComputedStyle calls, found ' + getComputedStyleCnt +
diff --git a/test/image/baselines/cmid-zmid.png b/test/image/baselines/cmid-zmid.png
index 18c3d58bf66..3f43339b815 100644
Binary files a/test/image/baselines/cmid-zmid.png and b/test/image/baselines/cmid-zmid.png differ
diff --git a/test/image/baselines/dendrogram.png b/test/image/baselines/dendrogram.png
index 001f60da080..fdb42f84549 100644
Binary files a/test/image/baselines/dendrogram.png and b/test/image/baselines/dendrogram.png differ
diff --git a/test/image/baselines/heatmap_multicategory.png b/test/image/baselines/heatmap_multicategory.png
index 044579557ce..17bd9f3b79a 100644
Binary files a/test/image/baselines/heatmap_multicategory.png and b/test/image/baselines/heatmap_multicategory.png differ
diff --git a/test/image/baselines/histogram2d_bingroup-coloraxis.png b/test/image/baselines/histogram2d_bingroup-coloraxis.png
index 9fed38448d2..038f7f463ed 100644
Binary files a/test/image/baselines/histogram2d_bingroup-coloraxis.png and b/test/image/baselines/histogram2d_bingroup-coloraxis.png differ
diff --git a/test/image/baselines/updatemenus.png b/test/image/baselines/updatemenus.png
index 771f7c45427..26297477298 100644
Binary files a/test/image/baselines/updatemenus.png and b/test/image/baselines/updatemenus.png differ
diff --git a/test/jasmine/tests/heatmap_test.js b/test/jasmine/tests/heatmap_test.js
index 3866ff6402e..1f85965cdbd 100644
--- a/test/jasmine/tests/heatmap_test.js
+++ b/test/jasmine/tests/heatmap_test.js
@@ -75,7 +75,7 @@ describe('heatmap supplyDefaults', function() {
expect(traceOut.visible).toBe(false);
});
- it('should set visible to false when z isn\'t column not a 2d array', function() {
+ it('should set visible to false when z isn\'t column nor a 2d array', function() {
traceIn = {
x: [1, 1, 1, 2, 2],
y: [1, 2, 3, 1, 2],
@@ -763,8 +763,12 @@ describe('heatmap plot', function() {
};
var originalCreateElement = document.createElement;
- mockWithoutPadding.data[0].xgap = 0;
- mockWithoutPadding.data[0].ygap = 0;
+ // We actually need to set a non-zero gap to ensure both mockWithPadding
+ // and mockWithoutPadding relies on the same drawing method (ie. default
+ // method using fillRect)
+ var nearZeroGap = 0.1;
+ mockWithoutPadding.data[0].xgap = nearZeroGap;
+ mockWithoutPadding.data[0].ygap = nearZeroGap;
spyOn(document, 'createElement').and.callFake(function(elementType) {
var element = originalCreateElement.call(document, elementType);
@@ -782,8 +786,8 @@ describe('heatmap plot', function() {
}).then(function() {
var xGap = mockWithPadding.data[0].xgap;
var yGap = mockWithPadding.data[0].ygap;
- var xGapLeft = xGap / 2;
- var yGapTop = yGap / 2;
+ var xGapLeft = Math.floor(xGap / 2);
+ var yGapTop = Math.floor(yGap / 2);
argumentsWithPadding = getContextStub.fillRect.calls.allArgs()
.slice(getContextStub.fillRect.calls.allArgs().length - 25);
@@ -793,8 +797,8 @@ describe('heatmap plot', function() {
argumentsWithPadding.forEach(function(args, i) {
expect(args[0]).toBe(argumentsWithoutPadding[i][0] + xGapLeft, i);
expect(args[1]).toBe(argumentsWithoutPadding[i][1] + yGapTop, i);
- expect(args[2]).toBe(argumentsWithoutPadding[i][2] - xGap, i);
- expect(args[3]).toBe(argumentsWithoutPadding[i][3] - yGap, i);
+ expect(args[2]).toBe(argumentsWithoutPadding[i][2] + nearZeroGap - xGap, i);
+ expect(args[3]).toBe(argumentsWithoutPadding[i][3] + nearZeroGap - yGap, i);
});
})
.then(done, done.fail);
@@ -827,7 +831,7 @@ describe('heatmap plot', function() {
.then(done, done.fail);
});
- it('should set canvas dimensions according to z data shape if `zsmooth` is fast', function(done) {
+ it('should set canvas dimensions according to z data shape when using fast drawing method', function(done) {
var mock1 = require('../../image/mocks/zsmooth_methods.json');
var mock2 = require('../../image/mocks/heatmap_small_layout_zsmooth_fast.json');
@@ -863,7 +867,7 @@ describe('heatmap plot', function() {
}).then(done, done.fail);
});
- it('should create imageData that fits the canvas dimensions if zsmooth is set', function(done) {
+ it('should create imageData that fits the canvas dimensions if zsmooth is set and/or drawing method is fast', function(done) {
var mock1 = require('../../image/mocks/zsmooth_methods.json');
var mock2 = require('../../image/mocks/heatmap_small_layout_zsmooth_fast.json');
@@ -905,11 +909,11 @@ describe('heatmap plot', function() {
return element;
});
- Plotly.newPlot(gd, mock1.data, mock1.layout).then(function() {
- expect(getContextStub.createImageData.calls.count()).toBe(2);
- expect(imageDataStub.data.set.calls.count()).toBe(2);
+ function assertImageData(traceIndices) {
+ expect(getContextStub.createImageData.calls.count()).toBe(traceIndices.length);
+ expect(imageDataStub.data.set.calls.count()).toBe(traceIndices.length);
- [0, 1].forEach(function(i) {
+ traceIndices.forEach(function(i) {
var createImageDataArgs = getContextStub.createImageData.calls.argsFor(i);
var setImageDataArgs = imageDataStub.data.set.calls.argsFor(i);
@@ -921,6 +925,10 @@ describe('heatmap plot', function() {
expect(pixels.length).toBe(canvasW * canvasH * 4);
expect(checkPixels(pixels)).toBe(true);
});
+ }
+
+ Plotly.newPlot(gd, mock1.data, mock1.layout).then(function() {
+ assertImageData([0, 1, 2]);
getContextStub.createImageData.calls.reset();
imageDataStub.data.set.calls.reset();
@@ -928,19 +936,7 @@ describe('heatmap plot', function() {
return Plotly.newPlot(gd, mock2.data, mock2.layout);
}).then(function() {
- expect(getContextStub.createImageData.calls.count()).toBe(1);
- expect(imageDataStub.data.set.calls.count()).toBe(1);
-
- var canvasW = canvasStubs[0].width.calls.argsFor(0)[0];
- var canvasH = canvasStubs[0].height.calls.argsFor(0)[0];
-
- var createImageDataArgs = getContextStub.createImageData.calls.argsFor(0);
- expect(createImageDataArgs).toEqual([canvasW, canvasH]);
-
- var setImageDataArgs = imageDataStub.data.set.calls.argsFor(0);
- var pixels = setImageDataArgs[0];
- expect(pixels.length).toBe(canvasW * canvasH * 4);
- expect(checkPixels(pixels)).toBe(true);
+ assertImageData([0]);
}).then(done, done.fail);
});
});