diff --git a/src/components/drawing/index.js b/src/components/drawing/index.js index c97bfb1189b..3aa65a573d6 100644 --- a/src/components/drawing/index.js +++ b/src/components/drawing/index.js @@ -354,6 +354,185 @@ drawing.gradient = function(sel, gd, gradientID, type, colorscale, prop) { fullLayout._gradientUrlQueryParts[k] = 1; }; +/** + * pattern: create and apply a pattern fill + * + * @param {object} sel: d3 selection to apply this pattern to + * You can use `selection.call(Drawing.pattern, ...)` + * @param {DOM element} gd: the graph div `sel` is part of + * @param {string} patternID: a unique (within this plot) identifier + * for this pattern, so that we don't create unnecessary definitions + * @param {string} bgcolor: background color for this pattern + * @param {string} fgcolor: foreground color for this pattern + * @param {number} size: size of unit squares for repetition of this pattern + * @param {number} solidity: how solid lines of this pattern are + * @param {string} prop: the property to apply to, 'fill' or 'stroke' + */ +drawing.pattern = function(sel, gd, patternID, shape, bgcolor, fgcolor, size, solidity, prop) { + var fullLayout = gd._fullLayout; + var fullID = 'p' + fullLayout._uid + '-' + patternID; + var width, height; + + // linear interpolation + var linearFn = function(x, x0, x1, y0, y1) { + return y0 + (y1 - y0) * (x - x0) / (x1 - x0); + }; + + var path, linewidth, radius; + var patternTag; + var patternAttrs = {}; + switch(shape) { + case '/': + width = size * Math.sqrt(2); + height = size * Math.sqrt(2); + path = 'M-' + (width / 4) + ',' + (height / 4) + 'l' + (width / 2) + ',-' + (height / 2) + + 'M0,' + height + 'L' + width + ',0' + + 'M' + (width / 4 * 3) + ',' + (height / 4 * 5) + 'l' + (width / 2) + ',-' + (height / 2); + linewidth = solidity * size; + patternTag = 'path'; + patternAttrs = { + 'd': path, + 'stroke': fgcolor, + 'stroke-width': linewidth + 'px' + }; + break; + case '\\': + width = size * Math.sqrt(2); + height = size * Math.sqrt(2); + path = 'M' + (width / 4 * 3) + ',-' + (height / 4) + 'l' + (width / 2) + ',' + (height / 2) + + 'M0,0L' + width + ',' + height + + 'M-' + (width / 4) + ',' + (height / 4 * 3) + 'l' + (width / 2) + ',' + (height / 2); + linewidth = solidity * size; + patternTag = 'path'; + patternAttrs = { + 'd': path, + 'stroke': fgcolor, + 'stroke-width': linewidth + 'px' + }; + break; + case 'x': + width = size * Math.sqrt(2); + height = size * Math.sqrt(2); + path = 'M-' + (width / 4) + ',' + (height / 4) + 'l' + (width / 2) + ',-' + (height / 2) + + 'M0,' + height + 'L' + width + ',0' + + 'M' + (width / 4 * 3) + ',' + (height / 4 * 5) + 'l' + (width / 2) + ',-' + (height / 2) + + 'M' + (width / 4 * 3) + ',-' + (height / 4) + 'l' + (width / 2) + ',' + (height / 2) + + 'M0,0L' + width + ',' + height + + 'M-' + (width / 4) + ',' + (height / 4 * 3) + 'l' + (width / 2) + ',' + (height / 2); + linewidth = size - size * Math.sqrt(1.0 - solidity); + patternTag = 'path'; + patternAttrs = { + 'd': path, + 'stroke': fgcolor, + 'stroke-width': linewidth + 'px' + }; + break; + case '|': + width = size; + height = size; + patternTag = 'path'; + path = 'M' + (width / 2) + ',0L' + (width / 2) + ',' + height; + linewidth = solidity * size; + patternTag = 'path'; + patternAttrs = { + 'd': path, + 'stroke': fgcolor, + 'stroke-width': linewidth + 'px' + }; + break; + case '-': + width = size; + height = size; + patternTag = 'path'; + path = 'M0,' + (height / 2) + 'L' + width + ',' + (height / 2); + linewidth = solidity * size; + patternTag = 'path'; + patternAttrs = { + 'd': path, + 'stroke': fgcolor, + 'stroke-width': linewidth + 'px' + }; + break; + case '+': + width = size; + height = size; + patternTag = 'path'; + path = 'M' + (width / 2) + ',0L' + (width / 2) + ',' + height + + 'M0,' + (height / 2) + 'L' + width + ',' + (height / 2); + linewidth = size - size * Math.sqrt(1.0 - solidity); + patternTag = 'path'; + patternAttrs = { + 'd': path, + 'stroke': fgcolor, + 'stroke-width': linewidth + 'px' + }; + break; + case '.': + width = size; + height = size; + if(solidity < Math.PI / 4) { + radius = Math.sqrt(solidity * size * size / Math.PI); + } else { + radius = linearFn(solidity, Math.PI / 4, 1.0, size / 2, size / Math.sqrt(2)); + } + patternTag = 'circle'; + patternAttrs = { + 'cx': width / 2, + 'cy': height / 2, + 'r': radius, + 'fill': fgcolor + }; + break; + } + + var pattern = fullLayout._defs.select('.patterns') + .selectAll('#' + fullID) + .data([shape + ';' + bgcolor + ';' + fgcolor + ';' + size + ';' + solidity], Lib.identity); + + pattern.exit().remove(); + + pattern.enter() + .append('pattern') + .each(function() { + var el = d3.select(this); + + el.attr({ + 'id': fullID, + 'width': width + 'px', + 'height': height + 'px', + 'patternUnits': 'userSpaceOnUse' + }); + + if(bgcolor) { + var rects = el.selectAll('rect').data([0]); + rects.exit().remove(); + rects.enter() + .append('rect') + .attr({ + 'width': width + 'px', + 'height': height + 'px', + 'fill': bgcolor + }); + } + + var patterns = el.selectAll(patternTag).data([0]); + patterns.exit().remove(); + patterns.enter() + .append(patternTag) + .attr(patternAttrs); + }); + + sel.style(prop, getFullUrl(fullID, gd)) + .style(prop + '-opacity', null); + + sel.classed('pattern_filled', true); + var className2query = function(s) { + return '.' + s.attr('class').replace(/\s/g, '.'); + }; + var k = className2query(d3.select(sel.node().parentNode)) + '>.pattern_filled'; + fullLayout._patternUrlQueryParts[k] = 1; +}; + /* * Make the gradients container and clear out any previous gradients. * We never collect all the gradients we need in one place, @@ -372,6 +551,23 @@ drawing.initGradients = function(gd) { fullLayout._gradientUrlQueryParts = {}; }; +drawing.initPatterns = function(gd) { + var fullLayout = gd._fullLayout; + + var patternsGroup = Lib.ensureSingle(fullLayout._defs, 'g', 'patterns'); + patternsGroup.selectAll('pattern').remove(); + + // initialize stash of query parts filled in Drawing.pattern, + // used to fix URL strings during image exports + fullLayout._patternUrlQueryParts = {}; +}; + +drawing.getPatternAttr = function(mp, i, dflt) { + if(mp && Lib.isArrayOrTypedArray(mp)) { + return i < mp.length ? mp[i] : dflt; + } + return mp; +}; drawing.pointStyle = function(s, trace, gd) { if(!s.size()) return; @@ -477,11 +673,14 @@ drawing.singlePointStyle = function(d, sel, trace, fns, gd) { // for legend - arrays will propagate through here, but we don't need // to treat it as per-point. - if(Array.isArray(gradientType)) { + if(Lib.isArrayOrTypedArray(gradientType)) { gradientType = gradientType[0]; if(!gradientInfo[gradientType]) gradientType = 0; } + var markerPattern = marker.pattern; + var patternShape = markerPattern && drawing.getPatternAttr(markerPattern.shape, d.i, ''); + if(gradientType && gradientType !== 'none') { var gradientColor = d.mgc; if(gradientColor) perPointGradient = true; @@ -492,6 +691,20 @@ drawing.singlePointStyle = function(d, sel, trace, fns, gd) { drawing.gradient(sel, gd, gradientID, gradientType, [[0, gradientColor], [1, fillColor]], 'fill'); + } else if(patternShape) { + var patternBGColor = drawing.getPatternAttr(markerPattern.bgcolor, d.i, null); + var patternSize = drawing.getPatternAttr(markerPattern.size, d.i, 8); + var patternSolidity = drawing.getPatternAttr(markerPattern.solidity, d.i, 0.3); + var perPointPattern = Lib.isArrayOrTypedArray(markerPattern.shape) || + Lib.isArrayOrTypedArray(markerPattern.bgcolor) || + Lib.isArrayOrTypedArray(markerPattern.size) || + Lib.isArrayOrTypedArray(markerPattern.solidity); + + var patternID = trace.uid; + if(perPointPattern) patternID += '-' + d.i; + + drawing.pattern(sel, gd, patternID, patternShape, patternBGColor, fillColor, + patternSize, patternSolidity, 'fill'); } else { Color.fill(sel, fillColor); } diff --git a/src/components/legend/style.js b/src/components/legend/style.js index 0ef881e9af4..43fb10ffaa1 100644 --- a/src/components/legend/style.js +++ b/src/components/legend/style.js @@ -357,8 +357,23 @@ module.exports = function style(s, gd, legend) { var d0 = d[0]; var w = boundLineWidth(d0.mlw, marker.line, MAX_MARKER_LINE_WIDTH, CST_MARKER_LINE_WIDTH); - p.style('stroke-width', w + 'px') - .call(Color.fill, d0.mc || marker.color); + p.style('stroke-width', w + 'px'); + + var fillColor = d0.mc || marker.color; + + var markerPattern = marker.pattern; + var patternShape = markerPattern && Drawing.getPatternAttr(markerPattern.shape, 0, ''); + + if(patternShape) { + var patternBGColor = Drawing.getPatternAttr(markerPattern.bgcolor, 0, null); + var patternSize = Math.min(12, Drawing.getPatternAttr(markerPattern.size, 0, 8)); + var patternSolidity = Drawing.getPatternAttr(markerPattern.solidity, 0, 0.3); + var patternID = 'legend-' + trace.uid; + p.call(Drawing.pattern, gd, patternID, patternShape, patternBGColor, + fillColor, patternSize, patternSolidity, 'fill'); + } else { + p.call(Color.fill, fillColor); + } if(w) Color.stroke(p, d0.mlc || markerLine.color); }); diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 5707d741951..03397d85096 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -144,8 +144,9 @@ function _doPlot(gd, data, layout, config) { } } - // clear gradient defs on each .plot call, because we know we'll loop through all traces + // clear gradient and pattern defs on each .plot call, because we know we'll loop through all traces Drawing.initGradients(gd); + Drawing.initPatterns(gd); // save initial show spikes once per graph if(graphWasEmpty) Axes.saveShowSpikeInitial(gd); diff --git a/src/snapshot/tosvg.js b/src/snapshot/tosvg.js index c391cc02dfd..34ebfdc5f8d 100644 --- a/src/snapshot/tosvg.js +++ b/src/snapshot/tosvg.js @@ -33,7 +33,7 @@ module.exports = function toSVG(gd, format, scale) { var toppaper = fullLayout._toppaper; var width = fullLayout.width; var height = fullLayout.height; - var i; + var i, k; // make background color a rect in the svg, then revert after scraping // all other alterations have been dealt with by properly preparing the svg @@ -106,28 +106,31 @@ module.exports = function toSVG(gd, format, scale) { } }); - + var queryParts = []; if(fullLayout._gradientUrlQueryParts) { - var queryParts = []; - for(var k in fullLayout._gradientUrlQueryParts) queryParts.push(k); - - if(queryParts.length) { - svg.selectAll(queryParts.join(',')).each(function() { - var pt = d3.select(this); - - // similar to font family styles above, - // we must remove " after the SVG DOM has been serialized - var fill = this.style.fill; - if(fill && fill.indexOf('url(') !== -1) { - pt.style('fill', fill.replace(DOUBLEQUOTE_REGEX, DUMMY_SUB)); - } - - var stroke = this.style.stroke; - if(stroke && stroke.indexOf('url(') !== -1) { - pt.style('stroke', stroke.replace(DOUBLEQUOTE_REGEX, DUMMY_SUB)); - } - }); - } + for(k in fullLayout._gradientUrlQueryParts) queryParts.push(k); + } + + if(fullLayout._patternUrlQueryParts) { + for(k in fullLayout._patternUrlQueryParts) queryParts.push(k); + } + + if(queryParts.length) { + svg.selectAll(queryParts.join(',')).each(function() { + var pt = d3.select(this); + + // similar to font family styles above, + // we must remove " after the SVG DOM has been serialized + var fill = this.style.fill; + if(fill && fill.indexOf('url(') !== -1) { + pt.style('fill', fill.replace(DOUBLEQUOTE_REGEX, DUMMY_SUB)); + } + + var stroke = this.style.stroke; + if(stroke && stroke.indexOf('url(') !== -1) { + pt.style('stroke', stroke.replace(DOUBLEQUOTE_REGEX, DUMMY_SUB)); + } + }); } if(format === 'pdf' || format === 'eps') { diff --git a/src/traces/bar/attributes.js b/src/traces/bar/attributes.js index 01b0b752d18..3e32060afd2 100644 --- a/src/traces/bar/attributes.js +++ b/src/traces/bar/attributes.js @@ -39,6 +39,54 @@ var marker = extendFlat({ max: 1, editType: 'style', description: 'Sets the opacity of the bars.' + }, + pattern: { + shape: { + valType: 'enumerated', + values: ['', '/', '\\', 'x', '-', '|', '+', '.'], + dflt: '', + arrayOk: true, + editType: 'style', + description: [ + 'Sets the shape of the pattern fill.', + 'By default, no pattern is used for filling the area.', + ].join(' ') + }, + bgcolor: { + valType: 'color', + arrayOk: true, + editType: 'style', + description: [ + 'Sets the background color of the pattern fill.', + 'Defaults to a transparent background.', + ].join(' ') + }, + size: { + valType: 'number', + min: 0, + dflt: 8, + arrayOk: true, + editType: 'style', + description: [ + 'Sets the size of unit squares of the pattern fill in pixels,', + 'which corresponds to the interval of repetition of the pattern.', + ].join(' ') + }, + solidity: { + valType: 'number', + min: 0, + max: 1, + dflt: 0.3, + arrayOk: true, + editType: 'style', + description: [ + 'Sets the solidity of the pattern fill.', + 'Solidity is roughly proportional to the ratio of the area filled by the pattern.', + 'Solidity of 0 shows only the background color without pattern', + 'and solidty of 1 shows only the foreground color without pattern.', + ].join(' ') + }, + editType: 'style' } }); diff --git a/src/traces/bar/style_defaults.js b/src/traces/bar/style_defaults.js index 20378dc7126..b9c20eb7d59 100644 --- a/src/traces/bar/style_defaults.js +++ b/src/traces/bar/style_defaults.js @@ -23,6 +23,12 @@ module.exports = function handleStyleDefaults(traceIn, traceOut, coerce, default coerce('marker.line.width'); coerce('marker.opacity'); + var patternShape = coerce('marker.pattern.shape'); + if(patternShape) { + coerce('marker.pattern.bgcolor'); + coerce('marker.pattern.size'); + coerce('marker.pattern.solidity'); + } coerce('selected.marker.color'); coerce('unselected.marker.color'); }; diff --git a/test/image/baselines/bar_patternfill.png b/test/image/baselines/bar_patternfill.png new file mode 100644 index 00000000000..0edc8b1dc6e Binary files /dev/null and b/test/image/baselines/bar_patternfill.png differ diff --git a/test/image/mocks/bar_patternfill.json b/test/image/mocks/bar_patternfill.json new file mode 100644 index 00000000000..b25cd4f3e0c --- /dev/null +++ b/test/image/mocks/bar_patternfill.json @@ -0,0 +1,125 @@ +{ + "data": [ + { + "x": [ + "Area 1", + "Area 2", + "Area 3", + "Area 4", + "Area 5" + ], + "y": [ + 20, + 14, + 23, + 30, + 5 + ], + "name": "Product A", + "type": "bar", + "marker": { + "color": "#4477AA", + "line": { + "color": "#4477AA", + "width": 1 + }, + "pattern": { + "shape": "/" + } + } + }, + { + "x": [ + "Area 1", + "Area 2", + "Area 3", + "Area 4", + "Area 5" + ], + "y": [ + 12, + 18, + 29, + 10, + 23 + ], + "name": "Product B", + "type": "bar", + "marker": { + "color": "#CCBB44", + "line": { + "color": "#CCBB44", + "width": 1 + }, + "pattern": { + "shape": "\\" + } + } + }, + { + "x": [ + "Area 1", + "Area 2", + "Area 3", + "Area 4", + "Area 5" + ], + "y": [ + 30, + 23, + 22, + 33, + 15 + ], + "name": "Product C", + "type": "bar", + "marker": { + "color": "#EE6677", + "line": { + "color": "#EE6677", + "width": 1 + }, + "pattern": { + "shape": ["|", "-", "+", "x", "."] + } + } + }, + { + "x": [ + "Area 1", + "Area 2", + "Area 3", + "Area 4", + "Area 5" + ], + "y": [ + 18, + 29, + 31, + 20, + 23 + ], + "name": "Product D", + "type": "bar", + "marker": { + "color": ["#061a0a", "#124d1d", "#1d802f", "#29b342", "#35e655"], + "line": { + "color": ["#061a0a", "#124d1d", "#1d802f", "#29b342", "#35e655"], + "width": 1 + }, + "pattern": { + "shape": ["/", "\\", "x", ".", "+"], + "bgcolor": "#c6eff5", + "size": [4, 6, 8, 10, 12], + "solidity": [0.1, 0.3, 0.5, 0.7, 0.9] + } + } + } + ], + "layout": { + "xaxis": { + "type": "category" + }, + "barmode": "stack" + } +} diff --git a/test/jasmine/tests/mock_test.js b/test/jasmine/tests/mock_test.js index 44811150551..3898625f1b0 100644 --- a/test/jasmine/tests/mock_test.js +++ b/test/jasmine/tests/mock_test.js @@ -126,6 +126,7 @@ var list = [ 'bar_annotation_max_range_eq_category', 'bar_multiline_labels', 'bar_nonnumeric_sizes', + 'bar_patternfill', 'bar_show_narrow', 'bar_stack-with-gaps', 'bar_stackrelative_negative', @@ -1214,6 +1215,7 @@ figs['bar_marker_array'] = require('@mocks/bar_marker_array'); figs['bar_annotation_max_range_eq_category'] = require('@mocks/bar_annotation_max_range_eq_category'); figs['bar_multiline_labels'] = require('@mocks/bar_multiline_labels'); figs['bar_nonnumeric_sizes'] = require('@mocks/bar_nonnumeric_sizes'); +figs['bar_patternfill'] = require('@mocks/bar_patternfill'); figs['bar_show_narrow'] = require('@mocks/bar_show_narrow'); figs['bar_stack-with-gaps'] = require('@mocks/bar_stack-with-gaps'); figs['bar_stackrelative_negative'] = require('@mocks/bar_stackrelative_negative');