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 heatmap rendering performance when zsmooth is false #6574

Merged
merged 10 commits into from
May 12, 2023
1 change: 1 addition & 0 deletions draftlogs/6574_change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Improve heatmap rendering performance when zsmooth is false [[#6574](https://github.com/plotly/plotly.js/pull/6574)]
45 changes: 22 additions & 23 deletions src/traces/heatmap/calc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
72 changes: 64 additions & 8 deletions src/traces/heatmap/plot.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,50 @@ var LINE_SPACING = alignmentConstants.LINE_SPACING;

var labelClass = 'heatmap-label';

// Pixelated image rendering
// The actual declaration is prepended with fallbacks for older browsers.
// https://developer.mozilla.org/en-US/docs/Web/CSS/image-rendering
// https://caniuse.com/?search=image-rendering
var pixelatedImageCSS = [
'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'
];

var _supportsPixelated = null;
function supportsPixelatedImage() {
if(_supportsPixelated !== null) { // only run the feature detection once
return _supportsPixelated;
}
if(Lib.isIE()) {
// `-ms-interpolation-mode` works only with <img> not with SVG <image>
_supportsPixelated = false;
} else {
var declarations = Array.from(pixelatedImageCSS).reverse();
Copy link
Contributor

Choose a reason for hiding this comment

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

Could you please elaborate why we need to reverse the list here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The pixelatedImageCSS array is ordered so that the actual declaration image-rendering: pixelated, which targets modern browsers (most users) is the last one, and the oldest fallback comes first. Reversing the array is just for doing the testing on the actual declaration first, and let declarations.some returns early.

var supports = window.CSS && window.CSS.supports || window.supportsCSS;
if(typeof supports === 'function') {
_supportsPixelated = declarations.some(function(d) {
return supports.apply(null, d.split(': '));
});
} else {
var image3 = Drawing.tester.append('image');
var cStyles = window.getComputedStyle(image3.node());
image3.attr('style', pixelatedImageCSS.join('; ') + ';');
_supportsPixelated = declarations.some(function(d) {
var value = d.split(': ')[1];
return cStyles.imageRendering === value ||
cStyles.imageRendering === value.toLowerCase();
});
image3.remove();
}
}
return _supportsPixelated;
}

function selectLabels(plotGroup) {
return plotGroup.selectAll('g.' + labelClass);
}
Expand Down Expand Up @@ -109,11 +153,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);
Expand All @@ -140,7 +191,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 {
Expand All @@ -158,7 +209,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;
Expand Down Expand Up @@ -235,7 +286,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;

Expand All @@ -245,7 +296,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);
Expand Down Expand Up @@ -273,7 +324,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);
Expand All @@ -297,7 +348,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);
Expand Down Expand Up @@ -353,6 +405,10 @@ module.exports = function(gd, plotinfo, cdheatmaps, heatmapLayer) {
'xlink:href': canvas.toDataURL('image/png')
});

if(drawingMethod === 'fast' && !zsmooth) {
image3.attr('style', pixelatedImageCSS.join('; ') + ';');
}

removeLabels(plotGroup);

var texttemplate = trace.texttemplate;
Expand Down
2 changes: 1 addition & 1 deletion tasks/test_syntax.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 +
Expand Down
Binary file modified test/image/baselines/cmid-zmid.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified test/image/baselines/dendrogram.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified test/image/baselines/heatmap_multicategory.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified test/image/baselines/histogram2d_bingroup-coloraxis.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified test/image/baselines/updatemenus.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
50 changes: 23 additions & 27 deletions test/jasmine/tests/heatmap_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -683,7 +683,7 @@ describe('heatmap plot', function() {

return Plotly.relayout(gd, 'xaxis.range', [2, 3]);
}).then(function() {
assertImageCnt(2);
assertImageCnt(3);

return Plotly.relayout(gd, 'xaxis.autorange', true);
}).then(function() {
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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');

Expand Down Expand Up @@ -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');

Expand Down Expand Up @@ -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);

Expand All @@ -921,26 +925,18 @@ 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();
canvasStubs = [];

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