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); }); });