diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index d8c7b07a1cc..7046e3adcc7 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -1620,6 +1620,7 @@ Plotly.restyle = function restyle(gd, astr, val, traces) { // objects need to be made) but not a recalc var replotAttrs = [ 'zmin', 'zmax', 'zauto', + 'xgap', 'ygap', 'marker.cmin', 'marker.cmax', 'marker.cauto', 'line.cmin', 'line.cmax', 'marker.line.cmin', 'marker.line.cmax', diff --git a/src/traces/heatmap/attributes.js b/src/traces/heatmap/attributes.js index 112656e0368..8155a2f8ea2 100644 --- a/src/traces/heatmap/attributes.js +++ b/src/traces/heatmap/attributes.js @@ -76,7 +76,20 @@ module.exports = extendFlat({}, 'in the `z` data are filled in.' ].join(' ') }, - + xgap: { + valType: 'number', + dflt: 0, + min: 0, + role: 'style', + description: 'Sets the horizontal gap (in pixels) between bricks.' + }, + ygap: { + valType: 'number', + dflt: 0, + min: 0, + role: 'style', + description: 'Sets the vertical gap (in pixels) between bricks.' + }, _nestedModules: { 'colorbar': 'Colorbar' } diff --git a/src/traces/heatmap/defaults.js b/src/traces/heatmap/defaults.js index f0fdfb1aafd..f41624cf57e 100644 --- a/src/traces/heatmap/defaults.js +++ b/src/traces/heatmap/defaults.js @@ -29,7 +29,14 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout } coerce('text'); - coerce('zsmooth'); + + var zsmooth = coerce('zsmooth'); + if(zsmooth === false) { + // ensure that xgap and ygap are coerced only when zsmooth allows them to have an effect. + coerce('xgap'); + coerce('ygap'); + } + coerce('connectgaps', hasColumns(traceOut) && (traceOut.zsmooth !== false)); colorscaleDefaults(traceIn, traceOut, layout, coerce, {prefix: '', cLetter: 'z'}); diff --git a/src/traces/heatmap/plot.js b/src/traces/heatmap/plot.js index bbaf993b299..d8632d945dc 100644 --- a/src/traces/heatmap/plot.js +++ b/src/traces/heatmap/plot.js @@ -238,6 +238,7 @@ function plotOne(gd, plotinfo, cd) { rcount = 0, gcount = 0, bcount = 0, + brickWithPadding, xb, j, xi, @@ -245,6 +246,47 @@ function plotOne(gd, plotinfo, cd) { row, c; + function applyBrickPadding(trace, x0, x1, y0, y1, xIndex, xLength, yIndex, yLength) { + var padding = { + x0: x0, + x1: x1, + y0: y0, + y1: y1 + }, + xEdgeGap = trace.xgap * 2 / 3, + yEdgeGap = trace.ygap * 2 / 3, + xCenterGap = trace.xgap / 3, + yCenterGap = trace.ygap / 3; + + if(yIndex === yLength - 1) { // top edge brick + padding.y1 = y1 - yEdgeGap; + } + + if(xIndex === xLength - 1) { // right edge brick + padding.x0 = x0 + xEdgeGap; + } + + if(yIndex === 0) { // bottom edge brick + padding.y0 = y0 + yEdgeGap; + } + + if(xIndex === 0) { // left edge brick + padding.x1 = x1 - xEdgeGap; + } + + if(xIndex > 0 && xIndex < xLength - 1) { // brick in the center along x + padding.x0 = x0 + xCenterGap; + padding.x1 = x1 - xCenterGap; + } + + if(yIndex > 0 && yIndex < yLength - 1) { // brick in the center along y + padding.y0 = y0 + yCenterGap; + padding.y1 = y1 - yCenterGap; + } + + return padding; + } + function setColor(v, pixsize) { if(v !== undefined) { var c = s((v - min) / (max - min)); @@ -364,7 +406,21 @@ function plotOne(gd, plotinfo, cd) { v = row[i]; c = setColor(v, (xb[1] - xb[0]) * (yb[1] - yb[0])); context.fillStyle = 'rgba(' + c.join(',') + ')'; - context.fillRect(xb[0], yb[0], (xb[1] - xb[0]), (yb[1] - yb[0])); + + brickWithPadding = applyBrickPadding(trace, + xb[0], + xb[1], + yb[0], + yb[1], + i, + n, + j, + m); + + context.fillRect(brickWithPadding.x0, + brickWithPadding.y0, + (brickWithPadding.x1 - brickWithPadding.x0), + (brickWithPadding.y1 - brickWithPadding.y0)); } } } diff --git a/src/traces/histogram2d/attributes.js b/src/traces/histogram2d/attributes.js index 37d19b8d2c1..6421274e54b 100644 --- a/src/traces/histogram2d/attributes.js +++ b/src/traces/histogram2d/attributes.js @@ -39,6 +39,8 @@ module.exports = extendFlat({}, nbinsy: histogramAttrs.nbinsy, ybins: histogramAttrs.ybins, + xgap: heatmapAttrs.xgap, + ygap: heatmapAttrs.ygap, zsmooth: heatmapAttrs.zsmooth, _nestedModules: { diff --git a/src/traces/histogram2d/defaults.js b/src/traces/histogram2d/defaults.js index 5b11521aa0a..563c59563d1 100644 --- a/src/traces/histogram2d/defaults.js +++ b/src/traces/histogram2d/defaults.js @@ -23,7 +23,12 @@ module.exports = function supplyDefaults(traceIn, traceOut, layout) { handleSampleDefaults(traceIn, traceOut, coerce); - coerce('zsmooth'); + var zsmooth = coerce('zsmooth'); + if(zsmooth === false) { + // ensure that xgap and ygap are coerced only when zsmooth allows them to have an effect. + coerce('xgap'); + coerce('ygap'); + } colorscaleDefaults( traceIn, traceOut, layout, coerce, {prefix: '', cLetter: 'z'} diff --git a/test/image/baselines/heatmap_brick_padding.png b/test/image/baselines/heatmap_brick_padding.png new file mode 100644 index 00000000000..d0a398c67e5 Binary files /dev/null and b/test/image/baselines/heatmap_brick_padding.png differ diff --git a/test/image/mocks/heatmap_brick_padding.json b/test/image/mocks/heatmap_brick_padding.json new file mode 100644 index 00000000000..10cd480c5d6 --- /dev/null +++ b/test/image/mocks/heatmap_brick_padding.json @@ -0,0 +1,26 @@ +{ + "data": [ + { + "z": [ + [ + 1, + 20, + 30 + ], + [ + 20, + 1, + 60 + ], + [ + 30, + 60, + 1 + ] + ], + "xgap": 9, + "ygap": 6, + "type": "heatmap" + } + ] +} diff --git a/test/jasmine/tests/heatmap_test.js b/test/jasmine/tests/heatmap_test.js index 1889016a1a6..34b828ef6b3 100644 --- a/test/jasmine/tests/heatmap_test.js +++ b/test/jasmine/tests/heatmap_test.js @@ -89,6 +89,42 @@ describe('heatmap supplyDefaults', function() { expect(traceOut.visible).toBe(false); }); + it('should set paddings to 0 when not defined', function() { + traceIn = { + type: 'heatmap', + z: [[1, 2], [3, 4]] + }; + + supplyDefaults(traceIn, traceOut, defaultColor, layout); + expect(traceOut.xgap).toBe(0); + expect(traceOut.ygap).toBe(0); + }); + + it('should not step on defined paddings', function() { + traceIn = { + xgap: 10, + type: 'heatmap', + z: [[1, 2], [3, 4]] + }; + + supplyDefaults(traceIn, traceOut, defaultColor, layout); + expect(traceOut.xgap).toBe(10); + expect(traceOut.ygap).toBe(0); + }); + + it('should not coerce gap if zsmooth is set', function() { + traceIn = { + xgap: 10, + zsmooth: 'best', + type: 'heatmap', + z: [[1, 2], [3, 4]] + }; + + supplyDefaults(traceIn, traceOut, defaultColor, layout); + expect(traceOut.xgap).toBe(undefined); + expect(traceOut.ygap).toBe(undefined); + }); + }); describe('heatmap convertColumnXYZ', function() { @@ -381,7 +417,80 @@ describe('heatmap plot', function() { done(); }); + }); + + it('draws canvas with correct margins', function(done) { + var mockWithPadding = require('@mocks/heatmap_brick_padding.json'), + mockWithoutPadding = Lib.extendDeep({}, mockWithPadding), + gd = createGraphDiv(), + getContextStub = { + fillRect: jasmine.createSpy() + }, + originalCreateElement = document.createElement; + + mockWithoutPadding.data[0].xgap = 0; + mockWithoutPadding.data[0].ygap = 0; + + spyOn(document, 'createElement').and.callFake(function(elementType) { + var element = originalCreateElement.call(document, elementType); + if(elementType === 'canvas') { + spyOn(element, 'getContext').and.returnValue(getContextStub); + } + return element; + }); + var argumentsWithoutPadding = [], + argumentsWithPadding = []; + Plotly.plot(gd, mockWithoutPadding.data, mockWithoutPadding.layout).then(function() { + argumentsWithoutPadding = getContextStub.fillRect.calls.allArgs().slice(0); + return Plotly.plot(gd, mockWithPadding.data, mockWithPadding.layout); + }).then(function() { + var centerXGap = mockWithPadding.data[0].xgap / 3; + var centerYGap = mockWithPadding.data[0].ygap / 3; + var edgeXGap = mockWithPadding.data[0].xgap * 2 / 3; + var edgeYGap = mockWithPadding.data[0].ygap * 2 / 3; + + argumentsWithPadding = getContextStub.fillRect.calls.allArgs().slice(getContextStub.fillRect.calls.allArgs().length - 9); + expect(argumentsWithPadding).toEqual([ + [argumentsWithoutPadding[0][0], + argumentsWithoutPadding[0][1] + edgeYGap, + argumentsWithoutPadding[0][2] - edgeXGap, + argumentsWithoutPadding[0][3] - edgeYGap], + [argumentsWithoutPadding[1][0] + centerXGap, + argumentsWithoutPadding[1][1] + edgeYGap, + argumentsWithoutPadding[1][2] - edgeXGap, + argumentsWithoutPadding[1][3] - edgeYGap], + [argumentsWithoutPadding[2][0] + edgeXGap, + argumentsWithoutPadding[2][1] + edgeYGap, + argumentsWithoutPadding[2][2] - edgeXGap, + argumentsWithoutPadding[2][3] - edgeYGap], + [argumentsWithoutPadding[3][0], + argumentsWithoutPadding[3][1] + centerYGap, + argumentsWithoutPadding[3][2] - edgeXGap, + argumentsWithoutPadding[3][3] - edgeYGap], + [argumentsWithoutPadding[4][0] + centerXGap, + argumentsWithoutPadding[4][1] + centerYGap, + argumentsWithoutPadding[4][2] - edgeXGap, + argumentsWithoutPadding[4][3] - edgeYGap], + [argumentsWithoutPadding[5][0] + edgeXGap, + argumentsWithoutPadding[5][1] + centerYGap, + argumentsWithoutPadding[5][2] - edgeXGap, + argumentsWithoutPadding[5][3] - edgeYGap], + [argumentsWithoutPadding[6][0], + argumentsWithoutPadding[6][1], + argumentsWithoutPadding[6][2] - edgeXGap, + argumentsWithoutPadding[6][3] - edgeYGap], + [argumentsWithoutPadding[7][0] + centerXGap, + argumentsWithoutPadding[7][1], + argumentsWithoutPadding[7][2] - edgeXGap, + argumentsWithoutPadding[7][3] - edgeYGap], + [argumentsWithoutPadding[8][0] + edgeXGap, + argumentsWithoutPadding[8][1], + argumentsWithoutPadding[8][2] - edgeXGap, + argumentsWithoutPadding[8][3] - edgeYGap + ]]); + done(); + }); }); }); diff --git a/test/jasmine/tests/histogram2d_test.js b/test/jasmine/tests/histogram2d_test.js new file mode 100644 index 00000000000..b2dd0ebd28e --- /dev/null +++ b/test/jasmine/tests/histogram2d_test.js @@ -0,0 +1,59 @@ +var supplyDefaults = require('@src/traces/histogram2d/defaults'); + + +describe('Test histogram2d', function() { + 'use strict'; + + describe('supplyDefaults', function() { + var traceIn, + traceOut; + + beforeEach(function() { + traceOut = {}; + }); + + it('should set zsmooth to false when zsmooth is empty', function() { + traceIn = {}; + supplyDefaults(traceIn, traceOut, {}); + expect(traceOut.zsmooth).toBe(false); + }); + + it('doesnt step on zsmooth when zsmooth is set', function() { + traceIn = { + zsmooth: 'fast' + }; + supplyDefaults(traceIn, traceOut, {}); + expect(traceOut.zsmooth).toBe('fast'); + }); + + it('should set xgap and ygap to 0 when xgap and ygap are empty', function() { + traceIn = {}; + supplyDefaults(traceIn, traceOut, {}); + expect(traceOut.xgap).toBe(0); + expect(traceOut.ygap).toBe(0); + }); + + it('shouldnt step on xgap and ygap when xgap and ygap are set', function() { + traceIn = { + xgap: 10, + ygap: 5 + }; + supplyDefaults(traceIn, traceOut, {}); + expect(traceOut.xgap).toBe(10); + expect(traceOut.ygap).toBe(5); + }); + + it('shouldnt coerce gap when zsmooth is set', function() { + traceIn = { + xgap: 10, + ygap: 5, + zsmooth: 'best' + }; + supplyDefaults(traceIn, traceOut, {}); + expect(traceOut.xgap).toBe(undefined); + expect(traceOut.ygap).toBe(undefined); + }); + + }); + +}); diff --git a/test/jasmine/tests/plot_api_test.js b/test/jasmine/tests/plot_api_test.js index 78fefeb75da..ac938f8358e 100644 --- a/test/jasmine/tests/plot_api_test.js +++ b/test/jasmine/tests/plot_api_test.js @@ -86,7 +86,7 @@ describe('Test plot api', function() { describe('Plotly.restyle', function() { beforeEach(function() { - spyOn(Plotly, 'plot'); + spyOn(PlotlyInternal, 'plot'); spyOn(Plots, 'previousPromises'); spyOn(Scatter, 'arraysToCalcdata'); spyOn(Bar, 'arraysToCalcdata'); @@ -111,7 +111,7 @@ describe('Test plot api', function() { expect(Scatter.arraysToCalcdata).toHaveBeenCalled(); expect(Bar.arraysToCalcdata).not.toHaveBeenCalled(); expect(Plots.style).toHaveBeenCalled(); - expect(Plotly.plot).not.toHaveBeenCalled(); + expect(PlotlyInternal.plot).not.toHaveBeenCalled(); // "docalc" deletes gd.calcdata - make sure this didn't happen expect(gd.calcdata).toBeDefined(); }); @@ -126,10 +126,24 @@ describe('Test plot api', function() { expect(Scatter.arraysToCalcdata).not.toHaveBeenCalled(); expect(Bar.arraysToCalcdata).toHaveBeenCalled(); expect(Plots.style).toHaveBeenCalled(); - expect(Plotly.plot).not.toHaveBeenCalled(); + expect(PlotlyInternal.plot).not.toHaveBeenCalled(); expect(gd.calcdata).toBeDefined(); }); + it('calls plot on xgap and ygap styling', function() { + var gd = { + data: [{z: [[1, 2, 3], [4, 5, 6], [7, 8, 9]], showscale: false, type: 'heatmap'}], + layout: {} + }; + + mockDefaultsAndCalc(gd); + Plotly.restyle(gd, {'xgap': 2}); + expect(PlotlyInternal.plot).toHaveBeenCalled(); + + Plotly.restyle(gd, {'ygap': 2}); + expect(PlotlyInternal.plot.calls.count()).toEqual(2); + }); + it('ignores undefined values', function() { var gd = { data: [{x: [1, 2, 3], y: [1, 2, 3], type: 'scatter'}],