diff --git a/src/components/shapes/attributes.js b/src/components/shapes/attributes.js index 1958f8d8efc..39ba21ba3a1 100644 --- a/src/components/shapes/attributes.js +++ b/src/components/shapes/attributes.js @@ -38,6 +38,14 @@ module.exports = { ].join(' ') }, + layer: { + valType: 'enumerated', + values: ['below', 'above'], + dflt: 'above', + role: 'info', + description: 'Specifies whether shapes are drawn below or above traces.' + }, + xref: extendFlat({}, annAttrs.xref, { description: [ 'Sets the shape\'s x coordinate axis.', diff --git a/src/components/shapes/index.js b/src/components/shapes/index.js index 0f02067b20f..b0097ac5665 100644 --- a/src/components/shapes/index.js +++ b/src/components/shapes/index.js @@ -38,6 +38,7 @@ function handleShapeDefaults(shapeIn, fullLayout) { return Lib.coerce(shapeIn, shapeOut, shapes.layoutAttributes, attr, dflt); } + coerce('layer'); coerce('opacity'); coerce('fillcolor'); coerce('line.color'); @@ -171,7 +172,8 @@ function updateAllShapes(gd, opt, value) { } function deleteShape(gd, index) { - gd._fullLayout._shapelayer.selectAll('[data-index="' + index + '"]') + getShapeLayer(gd, index) + .selectAll('[data-index="' + index + '"]') .remove(); gd._fullLayout.shapes.splice(index, 1); @@ -181,9 +183,9 @@ function deleteShape(gd, index) { for(var i = index; i < gd._fullLayout.shapes.length; i++) { // redraw all shapes past the removed one, // so they bind to the right events - gd._fullLayout._shapelayer - .selectAll('[data-index="' + (i+1) + '"]') - .attr('data-index', String(i)); + getShapeLayer(gd, i) + .selectAll('[data-index="' + (i + 1) + '"]') + .attr('data-index', i); shapes.draw(gd, i); } } @@ -201,19 +203,23 @@ function insertShape(gd, index, newShape) { gd.layout.shapes = [rule]; } + // there is no need to call shapes.draw(gd, index), + // because updateShape() is called from within shapes.draw() + for(var i = gd._fullLayout.shapes.length - 1; i > index; i--) { - gd._fullLayout._shapelayer + getShapeLayer(gd, i) .selectAll('[data-index="' + (i - 1) + '"]') - .attr('data-index', String(i)); + .attr('data-index', i); shapes.draw(gd, i); } } function updateShape(gd, index, opt, value) { - var i; + var i, n; // remove the existing shape if there is one - gd._fullLayout._shapelayer.selectAll('[data-index="' + index + '"]') + getShapeLayer(gd, index) + .selectAll('[data-index="' + index + '"]') .remove(); // remember a few things about what was already there, @@ -232,7 +238,7 @@ function updateShape(gd, index, opt, value) { else if(Lib.isPlainObject(opt)) optionsEdit = opt; var optionKeys = Object.keys(optionsEdit); - for(i = 0; i < optionsEdit.length; i++) { + for(i = 0; i < optionKeys.length; i++) { var k = optionKeys[i]; Lib.nestedProperty(optionsIn, k).set(optionsEdit[k]); } @@ -288,24 +294,72 @@ function updateShape(gd, index, opt, value) { gd._fullLayout.shapes[index] = options; var attrs = { - 'data-index': String(index), + 'data-index': index, 'fill-rule': 'evenodd', d: shapePath(gd, options) }, - clipAxes = (options.xref + options.yref).replace(/paper/g, ''); + clipAxes; var lineColor = options.line.width ? options.line.color : 'rgba(0,0,0,0)'; - var path = gd._fullLayout._shapelayer.append('path') - .attr(attrs) - .style('opacity', options.opacity) - .call(Color.stroke, lineColor) - .call(Color.fill, options.fillcolor) - .call(Drawing.dashLine, options.line.dash, options.line.width); + if(options.layer !== 'below') { + clipAxes = (options.xref + options.yref).replace(/paper/g, ''); + drawShape(gd._fullLayout._shapeUpperLayer); + } + else if(options.xref === 'paper' && options.yref === 'paper') { + clipAxes = ''; + drawShape(gd._fullLayout._shapeLowerLayer); + } + else { + var plots = gd._fullLayout._plots || {}, + subplots = Object.keys(plots), + plotinfo; + + for(i = 0, n = subplots.length; i < n; i++) { + plotinfo = plots[subplots[i]]; + clipAxes = subplots[i]; + + if(isShapeInSubplot(gd, options, plotinfo.id)) { + drawShape(plotinfo.shapelayer); + } + } + } + + function drawShape(shapeLayer) { + var path = shapeLayer.append('path') + .attr(attrs) + .style('opacity', options.opacity) + .call(Color.stroke, lineColor) + .call(Color.fill, options.fillcolor) + .call(Drawing.dashLine, options.line.dash, options.line.width); + + if(clipAxes) { + path.call(Drawing.setClipUrl, + 'clip' + gd._fullLayout._uid + clipAxes); + } + } +} + +function getShapeLayer(gd, index) { + var shape = gd._fullLayout.shapes[index], + shapeLayer = gd._fullLayout._shapeUpperLayer; - if(clipAxes) { - path.call(Drawing.setClipUrl, 'clip' + gd._fullLayout._uid + clipAxes); + if(!shape) { + console.log('getShapeLayer: undefined shape: index', index); + } + else if(shape.layer === 'below') { + shapeLayer = (shape.xref === 'paper' && shape.yref === 'paper') ? + gd._fullLayout._shapeLowerLayer : + gd._fullLayout._subplotShapeLayer; } + + return shapeLayer; +} + +function isShapeInSubplot(gd, shape, subplot) { + var xa = Plotly.Axes.getFromId(gd, subplot, 'x')._id, + ya = Plotly.Axes.getFromId(gd, subplot, 'y')._id; + return shape.layer === 'below' && (xa === shape.xref || ya === shape.yref); } function decodeDate(convertToPx) { diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index b283399f3ca..f97e613973e 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2615,6 +2615,11 @@ function makePlotFramework(gd) { fullLayout._draggers = fullLayout._paper.append('g') .classed('draglayer', true); + // lower shape layer + // (only for shapes to be drawn below the whole plot) + fullLayout._shapeLowerLayer = fullLayout._paper.append('g') + .classed('shapelayer shapelayer-below', true); + var subplots = Plotly.Axes.getSubplots(gd); if(subplots.join('') !== Object.keys(gd._fullLayout._plots || {}).join('')) { makeSubplots(gd, subplots); @@ -2622,9 +2627,15 @@ function makePlotFramework(gd) { if(fullLayout._hasCartesian) makeCartesianPlotFramwork(gd, subplots); - // single ternary, shape and pie layers for the whole plot + // single ternary layer for the whole plot fullLayout._ternarylayer = fullLayout._paper.append('g').classed('ternarylayer', true); - fullLayout._shapelayer = fullLayout._paper.append('g').classed('shapelayer', true); + + // upper shape layer + // (only for shapes to be drawn above the whole plot, including subplots) + fullLayout._shapeUpperLayer = fullLayout._paper.append('g') + .classed('shapelayer shapelayer-above', true); + + // single pie layer for the whole plot fullLayout._pielayer = fullLayout._paper.append('g').classed('pielayer', true); // fill in image server scrape-svg @@ -2752,6 +2763,10 @@ function makeCartesianPlotFramwork(gd, subplots) { // the plot and containers for overlays plotinfo.bg = plotgroup.append('rect') .style('stroke-width', 0); + // shape layer + // (only for shapes to be drawn below a subplot) + plotinfo.shapelayer = plotgroup.append('g') + .classed('shapelayer shapelayer-subplot', true); plotinfo.gridlayer = plotgroup.append('g'); plotinfo.overgrid = plotgroup.append('g'); plotinfo.zerolinelayer = plotgroup.append('g'); @@ -2800,6 +2815,10 @@ function makeCartesianPlotFramwork(gd, subplots) { .style('fill', 'none') .classed('crisp', true); }); + + // shape layers in subplots + fullLayout._subplotShapeLayer = fullLayout._paper + .selectAll('.shapelayer-subplot'); } // layoutStyles: styling for plot layout elements diff --git a/test/image/baselines/shapes_below_traces.png b/test/image/baselines/shapes_below_traces.png new file mode 100644 index 00000000000..77b62206fbf Binary files /dev/null and b/test/image/baselines/shapes_below_traces.png differ diff --git a/test/image/mocks/shapes.json b/test/image/mocks/shapes.json index 8ad07392f4c..86241c110d9 100644 --- a/test/image/mocks/shapes.json +++ b/test/image/mocks/shapes.json @@ -21,18 +21,18 @@ "margin": {"l":20,"r":20,"top":10,"bottom":10,"pad":0}, "showlegend":false, "shapes":[ - {"xref":"paper","yref":"paper","x0":0,"x1":0.1,"y0":0,"y1":0.1}, + {"layer":"below","xref":"paper","yref":"paper","x0":0,"x1":0.1,"y0":0,"y1":0.1}, {"xref":"paper","yref":"paper","path":"M0,0.2V0.3H0.05L0,0.4Q0.1,0.4 0.1,0.3T0.15,0.3C0.1,0.4 0.2,0.4 0.2,0.3S0.15,0.3 0.15,0.2Z","fillcolor":"#4c0"}, {"xref":"paper","yref":"paper","type":"circle","x0":0.23,"x1":0.3,"y0":0.2,"y1":0.4}, {"xref":"paper","yref":"paper","type":"line","x0":0.2,"x1":0.3,"y0":0,"y1":0.1}, - {"x0":0.1,"x1":0.4,"y0":1.5,"y1":20,"opacity":0.5,"fillcolor":"#f00","line":{"width":8,"color":"#008","dash":"dashdot"}}, + {"layer":"below","x0":0.1,"x1":0.4,"y0":1.5,"y1":20,"opacity":0.5,"fillcolor":"#f00","line":{"width":8,"color":"#008","dash":"dashdot"}}, {"path":"M0.5,3C0.5,9 0.9,9 0.9,3C0.9,1 0.5,1 0.5,3ZM0.6,4C0.6,5 0.66,5 0.66,4ZM0.74,4C0.74,5 0.8,5 0.8,4ZM0.6,3C0.63,2 0.77,2 0.8,3Z","fillcolor":"#fd2","line":{"width":1,"color":"black"}}, - {"xref":"x2","yref":"y2","type":"circle","x0":"2000-01-01 02","x1":"2000-01-01 08:30:33.456","y0":0.1,"y1":0.9,"fillcolor":"rgba(0,0,0,0.5)","line":{"color":"rgba(0,255,0,0.5)", "width":5}}, + {"layer":"below","xref":"x2","yref":"y2","type":"circle","x0":"2000-01-01 02","x1":"2000-01-01 08:30:33.456","y0":0.1,"y1":0.9,"fillcolor":"rgba(0,0,0,0.5)","line":{"color":"rgba(0,255,0,0.5)", "width":5}}, {"xref":"x2","yref":"y2","path":"M2000-01-01_11:20:45.6,0.2Q2000-01-01_10:00,0.85 2000-01-01_21,0.8Q2000-01-01_22:20,0.15 2000-01-01_11:20:45.6,0.2Z","fillcolor":"rgb(151,73,58)"}, {"yref":"paper","type":"line","x0":0.1,"x1":0.4,"y0":0,"y1":0.4,"line":{"color":"#009","dash":"dot","width":1}}, {"yref":"paper","path":"M0.5,0H1.1L0.8,0.4Z","line":{"width":0},"fillcolor":"#ccd3ff"}, {"xref":"paper","x0":0.1,"x1":0.2,"y0":-1,"y1":3,"fillcolor":"#ccc"}, - {"xref":"paper","path":"M0.05,4C0.4,12 -0.1,12 0.25,4Z","fillcolor":"#a66"} + {"layer":"above","xref":"paper","path":"M0.05,4C0.4,12 -0.1,12 0.25,4Z","fillcolor":"#a66"} ] } } diff --git a/test/image/mocks/shapes_below_traces.json b/test/image/mocks/shapes_below_traces.json new file mode 100644 index 00000000000..9758896af57 --- /dev/null +++ b/test/image/mocks/shapes_below_traces.json @@ -0,0 +1,156 @@ +{ + "data": [ + { + "line": { + "shape": "spline" + }, + "y": [ + 1, + 2, + 1, + 0, + -1, + 2, + 3, + 5 + ] + }, + { + "line": { + "shape": "spline" + }, + "xaxis": "x2", + "y": [ + 7.071067811865475, + 10, + 7.071067811865475, + 0, + -7.071067811865475, + 10, + 7.0710678118654755, + -7.071067811865475 + ] + }, + { + "line": { + "shape": "spline" + }, + "y": [ + 7.0710678118654755, + 6.123233995736766e-16, + 7.0710678118654755, + 10, + 7.0710678118654755, + 6.123233995736766e-16, + -7.071067811865475, + -7.071067811865477 + ], + "yaxis": "y2" + }, + { + "line": { + "shape": "spline" + }, + "xaxis": "x2", + "y": [ + 2, + 1.6666666666666667, + 2, + 2.5, + 3.3333333333333335, + 1.6666666666666667, + 1.4285714285714286, + 1.1111111111111112 + ], + "yaxis": "y2" + } + ], + "layout": { + "dragmode": "pan", + "shapes": [ + { + "fillcolor": "#c7eae5", + "layer": "below", + "type": "rect", + "x0": 3.5, + "x1": 4.5, + "xref": "x", + "y0": 0, + "y1": 1, + "yref": "paper" + }, + { + "fillcolor": "#c7eae5", + "layer": "above", + "opacity": 0.5, + "type": "rect", + "x0": 5.5, + "x1": 6.5, + "xref": "x2", + "y0": 0, + "y1": 1, + "yref": "paper" + }, + { + "fillcolor": "#f6e8c3", + "layer": "below", + "type": "rect", + "x0": 0, + "x1": 1, + "xref": "paper", + "y0": 0, + "y1": 3, + "yref": "y" + }, + { + "fillcolor": "#f6e8c3", + "layer": "above", + "opacity": 0.5, + "type": "rect", + "x0": 0, + "x1": 1, + "xref": "paper", + "y0": 1, + "y1": 4, + "yref": "y2" + }, + { + "fillcolor": "#d3d3d3", + "layer": "below", + "type": "rect", + "x0": 0.3, + "x1": 0.7, + "xref": "paper", + "y0": 0.3, + "y1": 0.7, + "yref": "paper" + } + ], + "showlegend": false, + "title": "shape shading a region", + "xaxis": { + "domain": [ + 0, + 0.45 + ] + }, + "xaxis2": { + "domain": [ + 0.55, + 1 + ] + }, + "yaxis": { + "domain": [ + 0, + 0.45 + ] + }, + "yaxis2": { + "domain": [ + 0.55, + 1 + ] + } + } +} diff --git a/test/jasmine/tests/shapes_test.js b/test/jasmine/tests/shapes_test.js index 05ab310d9bb..13511fb3699 100644 --- a/test/jasmine/tests/shapes_test.js +++ b/test/jasmine/tests/shapes_test.js @@ -24,30 +24,114 @@ describe('Test shapes:', function() { afterEach(destroyGraphDiv); - function countShapeLayers() { - return d3.selectAll('.shapelayer').size(); + function countShapesInLowerLayer() { + return gd._fullLayout.shapes.filter(isShapeInLowerLayer).length; } - function countShapePaths() { - return d3.selectAll('.shapelayer > path').size(); + function countShapesInUpperLayer() { + return gd._fullLayout.shapes.filter(isShapeInUpperLayer).length; } - describe('DOM', function() { - it('has one *shapelayer* node', function() { - expect(countShapeLayers()).toEqual(1); + function countShapesInSubplots() { + return gd._fullLayout.shapes.filter(isShapeInSubplot).length; + } + + function isShapeInUpperLayer(shape) { + return shape.layer !== 'below'; + } + + function isShapeInLowerLayer(shape) { + return (shape.xref === 'paper' && shape.yref === 'paper') && + !isShapeInUpperLayer(shape); + } + + function isShapeInSubplot(shape) { + return !isShapeInUpperLayer(shape) && !isShapeInLowerLayer(shape); + } + + function countShapeLowerLayerNodes() { + return d3.selectAll('.shapelayer-below').size(); + } + + function countShapeUpperLayerNodes() { + return d3.selectAll('.shapelayer-above').size(); + } + + function countShapeLayerNodesInSubplots() { + return d3.selectAll('.shapelayer-subplot').size(); + } + + function countSubplots(gd) { + return Object.keys(gd._fullLayout._plots || {}).length; + } + + function countShapePathsInLowerLayer() { + return d3.selectAll('.shapelayer-below > path').size(); + } + + function countShapePathsInUpperLayer() { + return d3.selectAll('.shapelayer-above > path').size(); + } + + function countShapePathsInSubplots() { + return d3.selectAll('.shapelayer-subplot > path').size(); + } + + describe('*shapeLowerLayer*', function() { + it('has one node', function() { + expect(countShapeLowerLayerNodes()).toEqual(1); + }); + + it('has as many *path* nodes as shapes in the lower layer', function() { + expect(countShapePathsInLowerLayer()) + .toEqual(countShapesInLowerLayer()); + }); + + it('should be able to get relayout', function(done) { + Plotly.relayout(gd, {height: 200, width: 400}).then(function() { + expect(countShapeLowerLayerNodes()).toEqual(1); + expect(countShapePathsInLowerLayer()) + .toEqual(countShapesInLowerLayer()); + }).then(done); }); + }); - it('has as many *path* nodes as there are shapes', function() { - expect(countShapePaths()).toEqual(mock.layout.shapes.length); + describe('*shapeUpperLayer*', function() { + it('has one node', function() { + expect(countShapeUpperLayerNodes()).toEqual(1); + }); + + it('has as many *path* nodes as shapes in the upper layer', function() { + expect(countShapePathsInUpperLayer()) + .toEqual(countShapesInUpperLayer()); }); it('should be able to get relayout', function(done) { - expect(countShapeLayers()).toEqual(1); - expect(countShapePaths()).toEqual(mock.layout.shapes.length); + Plotly.relayout(gd, {height: 200, width: 400}).then(function() { + expect(countShapeUpperLayerNodes()).toEqual(1); + expect(countShapePathsInUpperLayer()) + .toEqual(countShapesInUpperLayer()); + }).then(done); + }); + }); + + describe('each *subplot*', function() { + it('has one *shapelayer*', function() { + expect(countShapeLayerNodesInSubplots()) + .toEqual(countSubplots(gd)); + }); + it('has as many *path* nodes as shapes in the subplot', function() { + expect(countShapePathsInSubplots()) + .toEqual(countShapesInSubplots()); + }); + + it('should be able to get relayout', function(done) { Plotly.relayout(gd, {height: 200, width: 400}).then(function() { - expect(countShapeLayers()).toEqual(1); - expect(countShapePaths()).toEqual(mock.layout.shapes.length); + expect(countShapeLayerNodesInSubplots()) + .toEqual(countSubplots(gd)); + expect(countShapePathsInSubplots()) + .toEqual(countShapesInSubplots()); }).then(done); }); }); @@ -75,35 +159,72 @@ describe('Test shapes:', function() { describe('Plotly.relayout', function() { it('should be able to add a shape', function(done) { - var pathCount = countShapePaths(); + var pathCount = countShapePathsInUpperLayer(); var index = countShapes(gd); var shape = getRandomShape(); Plotly.relayout(gd, 'shapes[' + index + ']', shape).then(function() { - expect(countShapeLayers()).toEqual(1); - expect(countShapePaths()).toEqual(pathCount + 1); + expect(countShapePathsInUpperLayer()).toEqual(pathCount + 1); expect(getLastShape(gd)).toEqual(shape); expect(countShapes(gd)).toEqual(index + 1); }).then(done); }); it('should be able to remove a shape', function(done) { - var pathCount = countShapePaths(); + var pathCount = countShapePathsInUpperLayer(); var index = countShapes(gd); var shape = getRandomShape(); Plotly.relayout(gd, 'shapes[' + index + ']', shape).then(function() { - expect(countShapeLayers()).toEqual(1); - expect(countShapePaths()).toEqual(pathCount + 1); + expect(countShapePathsInUpperLayer()).toEqual(pathCount + 1); expect(getLastShape(gd)).toEqual(shape); expect(countShapes(gd)).toEqual(index + 1); }).then(function() { Plotly.relayout(gd, 'shapes[' + index + ']', 'remove'); }).then(function() { - expect(countShapeLayers()).toEqual(1); - expect(countShapePaths()).toEqual(pathCount); + expect(countShapePathsInUpperLayer()).toEqual(pathCount); expect(countShapes(gd)).toEqual(index); }).then(done); }); + + it('should be able to update a shape layer', function(done) { + var index = countShapes(gd), + astr = 'shapes[' + index + ']', + shape = getRandomShape(), + shapesInLowerLayer = countShapePathsInLowerLayer(), + shapesInUpperLayer = countShapePathsInUpperLayer(); + + shape.xref = 'paper'; + shape.yref = 'paper'; + + Plotly.relayout(gd, astr, shape).then(function() { + expect(countShapePathsInLowerLayer()) + .toEqual(shapesInLowerLayer); + expect(countShapePathsInUpperLayer()) + .toEqual(shapesInUpperLayer + 1); + expect(getLastShape(gd)).toEqual(shape); + expect(countShapes(gd)).toEqual(index + 1); + }).then(function() { + shape.layer = 'below'; + Plotly.relayout(gd, astr + '.layer', shape.layer); + }).then(function() { + expect(countShapePathsInLowerLayer()) + .toEqual(shapesInLowerLayer + 1); + expect(countShapePathsInUpperLayer()) + .toEqual(shapesInUpperLayer); + expect(getLastShape(gd)).toEqual(shape); + expect(countShapes(gd)).toEqual(index + 1); + }).then(function() { + shape.layer = 'above'; + Plotly.relayout(gd, astr + '.layer', shape.layer); + }).then(function() { + expect(countShapePathsInLowerLayer()) + .toEqual(shapesInLowerLayer); + expect(countShapePathsInUpperLayer()) + .toEqual(shapesInUpperLayer + 1); + expect(getLastShape(gd)).toEqual(shape); + expect(countShapes(gd)).toEqual(index + 1); + }).then(done); + }); }); });