diff --git a/build/plotcss.js b/build/plotcss.js index 4b412c9eee6..169edfce295 100644 --- a/build/plotcss.js +++ b/build/plotcss.js @@ -8,6 +8,7 @@ var rules = { "X a": "text-decoration:none;", "X a:hover": "text-decoration:none;", "X .crisp": "shape-rendering:crispEdges;", + "X .user-select-none": "-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;-o-user-select:none;user-select:none;", "X svg": "overflow:hidden;", "X svg a": "fill:#447adb;", "X svg a:hover": "fill:#3c6dc5;", diff --git a/src/components/legend/anchor_utils.js b/src/components/legend/anchor_utils.js new file mode 100644 index 00000000000..7b5bde268f7 --- /dev/null +++ b/src/components/legend/anchor_utils.js @@ -0,0 +1,47 @@ +/** +* Copyright 2012-2016, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + + +/** + * Determine the position anchor property of x/y xanchor/yanchor components. + * + * - values < 1/3 align the low side at that fraction, + * - values [1/3, 2/3] align the center at that fraction, + * - values > 2/3 align the right at that fraction. + */ + +exports.isRightAnchor = function isRightAnchor(opts) { + return ( + opts.xanchor === 'right' || + (opts.xanchor === 'auto' && opts.x >= 2 / 3) + ); +}; + +exports.isCenterAnchor = function isCenterAnchor(opts) { + return ( + opts.xanchor === 'center' || + (opts.xanchor === 'auto' && opts.x > 1 / 3 && opts.x < 2 / 3) + ); +}; + +exports.isBottomAnchor = function isBottomAnchor(opts) { + return ( + opts.yanchor === 'bottom' || + (opts.yanchor === 'auto' && opts.y <= 1 / 3) + ); +}; + +exports.isMiddleAnchor = function isMiddleAnchor(opts) { + return ( + opts.yanchor === 'middle' || + (opts.yanchor === 'auto' && opts.y > 1 / 3 && opts.y < 2 / 3) + ); +}; diff --git a/src/components/legend/defaults.js b/src/components/legend/defaults.js new file mode 100644 index 00000000000..eb95148ce13 --- /dev/null +++ b/src/components/legend/defaults.js @@ -0,0 +1,69 @@ +/** +* Copyright 2012-2016, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + +var Lib = require('../../lib'); +var Plots = require('../../plots/plots'); + +var attributes = require('./attributes'); +var helpers = require('./helpers'); + + +module.exports = function legendDefaults(layoutIn, layoutOut, fullData) { + var containerIn = layoutIn.legend || {}, + containerOut = layoutOut.legend = {}; + + var visibleTraces = 0, + defaultOrder = 'normal'; + + for(var i = 0; i < fullData.length; i++) { + var trace = fullData[i]; + + if(helpers.legendGetsTrace(trace)) { + visibleTraces++; + // always show the legend by default if there's a pie + if(Plots.traceIs(trace, 'pie')) visibleTraces++; + } + + if((Plots.traceIs(trace, 'bar') && layoutOut.barmode==='stack') || + ['tonextx','tonexty'].indexOf(trace.fill)!==-1) { + defaultOrder = helpers.isGrouped({traceorder: defaultOrder}) ? + 'grouped+reversed' : 'reversed'; + } + + if(trace.legendgroup !== undefined && trace.legendgroup !== '') { + defaultOrder = helpers.isReversed({traceorder: defaultOrder}) ? + 'reversed+grouped' : 'grouped'; + } + } + + function coerce(attr, dflt) { + return Lib.coerce(containerIn, containerOut, attributes, attr, dflt); + } + + var showLegend = Lib.coerce(layoutIn, layoutOut, + Plots.layoutAttributes, 'showlegend', visibleTraces > 1); + + if(showLegend === false) return; + + coerce('bgcolor', layoutOut.paper_bgcolor); + coerce('bordercolor'); + coerce('borderwidth'); + Lib.coerceFont(coerce, 'font', layoutOut.font); + + coerce('traceorder', defaultOrder); + if(helpers.isGrouped(layoutOut.legend)) coerce('tracegroupgap'); + + coerce('x'); + coerce('xanchor'); + coerce('y'); + coerce('yanchor'); + Lib.noneOrAll(containerIn, containerOut, ['x', 'y']); +}; diff --git a/src/components/legend/draw.js b/src/components/legend/draw.js new file mode 100644 index 00000000000..8fe9f62d35f --- /dev/null +++ b/src/components/legend/draw.js @@ -0,0 +1,474 @@ +/** +* Copyright 2012-2016, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + +var d3 = require('d3'); + +var Plotly = require('../../plotly'); +var Lib = require('../../lib'); +var Plots = require('../../plots/plots'); +var Fx = require('../../plots/cartesian/graph_interact'); +var Drawing = require('../drawing'); +var Color = require('../color'); + +var constants = require('./constants'); +var getLegendData = require('./get_legend_data'); +var style = require('./style'); +var helpers = require('./helpers'); +var anchorUtils = require('./anchor_utils'); + + +module.exports = function draw(gd) { + var fullLayout = gd._fullLayout; + var clipId = 'legend' + fullLayout._uid; + + if(!fullLayout._infolayer || !gd.calcdata) return; + + var opts = fullLayout.legend, + legendData = fullLayout.showlegend && getLegendData(gd.calcdata, opts), + hiddenSlices = fullLayout.hiddenlabels || []; + + if(!fullLayout.showlegend || !legendData.length) { + fullLayout._infolayer.selectAll('.legend').remove(); + fullLayout._topdefs.select('#' + clipId).remove(); + + Plots.autoMargin(gd, 'legend'); + return; + } + + if(typeof gd.firstRender === 'undefined') gd.firstRender = true; + else if(gd.firstRender) gd.firstRender = false; + + var legend = fullLayout._infolayer.selectAll('g.legend') + .data([0]); + + legend.enter().append('g') + .attr({ + 'class': 'legend', + 'pointer-events': 'all' + }); + + var clipPath = fullLayout._topdefs.selectAll('#' + clipId) + .data([0]) + .enter().append('clipPath') + .attr('id', clipId) + .append('rect'); + + var bg = legend.selectAll('rect.bg') + .data([0]); + + bg.enter().append('rect') + .attr({ + 'class': 'bg', + 'shape-rendering': 'crispEdges' + }) + .call(Color.stroke, opts.bordercolor) + .call(Color.fill, opts.bgcolor) + .style('stroke-width', opts.borderwidth + 'px'); + + var scrollBox = legend.selectAll('g.scrollbox') + .data([0]); + + scrollBox.enter().append('g') + .attr('class', 'scrollbox'); + + var scrollBar = legend.selectAll('rect.scrollbar') + .data([0]); + + scrollBar.enter().append('rect') + .attr({ + 'class': 'scrollbar', + 'rx': 20, + 'ry': 2, + 'width': 0, + 'height': 0 + }) + .call(Color.fill, '#808BA4'); + + var groups = scrollBox.selectAll('g.groups') + .data(legendData); + + groups.enter().append('g') + .attr('class', 'groups'); + + groups.exit().remove(); + + if(helpers.isGrouped(opts)) { + groups.attr('transform', function(d, i) { + return 'translate(0,' + i * opts.tracegroupgap + ')'; + }); + } + + var traces = groups.selectAll('g.traces') + .data(Lib.identity); + + traces.enter().append('g').attr('class', 'traces'); + traces.exit().remove(); + + traces.call(style) + .style('opacity', function(d) { + var trace = d[0].trace; + if(Plots.traceIs(trace, 'pie')) { + return hiddenSlices.indexOf(d[0].label) !== -1 ? 0.5 : 1; + } else { + return trace.visible === 'legendonly' ? 0.5 : 1; + } + }) + .each(function(d, i) { + drawTexts(this, gd, d, i, traces); + + var traceToggle = d3.select(this).selectAll('rect') + .data([0]); + + traceToggle.enter().append('rect') + .classed('legendtoggle', true) + .style('cursor', 'pointer') + .attr('pointer-events', 'all') + .call(Color.fill, 'rgba(0,0,0,0)'); + + traceToggle.on('click', function() { + if(gd._dragged) return; + + var fullData = gd._fullData, + trace = d[0].trace, + legendgroup = trace.legendgroup, + traceIndicesInGroup = [], + tracei, + newVisible; + + if(Plots.traceIs(trace, 'pie')) { + var thisLabel = d[0].label, + newHiddenSlices = hiddenSlices.slice(), + thisLabelIndex = newHiddenSlices.indexOf(thisLabel); + + if(thisLabelIndex === -1) newHiddenSlices.push(thisLabel); + else newHiddenSlices.splice(thisLabelIndex, 1); + + Plotly.relayout(gd, 'hiddenlabels', newHiddenSlices); + } else { + if(legendgroup === '') { + traceIndicesInGroup = [trace.index]; + } else { + for(var i = 0; i < fullData.length; i++) { + tracei = fullData[i]; + if(tracei.legendgroup === legendgroup) { + traceIndicesInGroup.push(tracei.index); + } + } + } + + newVisible = trace.visible === true ? 'legendonly' : true; + Plotly.restyle(gd, 'visible', newVisible, traceIndicesInGroup); + } + }); + }); + + // Position and size the legend + repositionLegend(gd, traces); + + // Scroll section must be executed after repositionLegend. + // It requires the legend width, height, x and y to position the scrollbox + // and these values are mutated in repositionLegend. + var gs = fullLayout._size, + lx = gs.l + gs.w * opts.x, + ly = gs.t + gs.h * (1-opts.y); + + if(anchorUtils.isRightAnchor(opts)) { + lx -= opts.width; + } + if(anchorUtils.isCenterAnchor(opts)) { + lx -= opts.width / 2; + } + + if(anchorUtils.isBottomAnchor(opts)) { + ly -= opts.height; + } + if(anchorUtils.isMiddleAnchor(opts)) { + ly -= opts.height / 2; + } + + // Deal with scrolling + var plotHeight = fullLayout.height - fullLayout.margin.b, + scrollheight = Math.min(plotHeight - ly, opts.height), + scrollPosition = scrollBox.attr('data-scroll') ? scrollBox.attr('data-scroll') : 0; + + scrollBox.attr('transform', 'translate(0, ' + scrollPosition + ')'); + + bg.attr({ + width: opts.width - 2 * opts.borderwidth, + height: scrollheight - 2 * opts.borderwidth, + x: opts.borderwidth, + y: opts.borderwidth + }); + + legend.attr('transform', 'translate(' + lx + ',' + ly + ')'); + + clipPath.attr({ + width: opts.width, + height: scrollheight, + x: 0, + y: 0 + }); + + legend.call(Drawing.setClipUrl, clipId); + + // If scrollbar should be shown. + if(gd.firstRender && opts.height - scrollheight > 0 && !gd._context.staticPlot) { + bg.attr({ + width: opts.width - 2 * opts.borderwidth + constants.scrollBarWidth + }); + + clipPath.attr({ + width: opts.width + constants.scrollBarWidth + }); + + legend.node().addEventListener('wheel', function(e) { + e.preventDefault(); + scrollHandler(e.deltaY / 20); + }); + + scrollBar.node().addEventListener('mousedown', function(e) { + e.preventDefault(); + + function mMove(e) { + if(e.buttons === 1) { + scrollHandler(e.movementY); + } + } + + function mUp() { + scrollBar.node().removeEventListener('mousemove', mMove); + window.removeEventListener('mouseup', mUp); + } + + window.addEventListener('mousemove', mMove); + window.addEventListener('mouseup', mUp); + }); + + // Move scrollbar to starting position on the first render + scrollBar.call( + Drawing.setRect, + opts.width - (constants.scrollBarWidth + constants.scrollBarMargin), + constants.scrollBarMargin, + constants.scrollBarWidth, + constants.scrollBarHeight + ); + } + + function scrollHandler(delta) { + + var scrollBarTrack = scrollheight - constants.scrollBarHeight - 2 * constants.scrollBarMargin, + translateY = scrollBox.attr('data-scroll'), + scrollBoxY = Lib.constrain(translateY - delta, Math.min(scrollheight - opts.height, 0), 0), + scrollBarY = -scrollBoxY / (opts.height - scrollheight) * scrollBarTrack + constants.scrollBarMargin; + + scrollBox.attr('data-scroll', scrollBoxY); + scrollBox.attr('transform', 'translate(0, ' + scrollBoxY + ')'); + scrollBar.call( + Drawing.setRect, + opts.width - (constants.scrollBarWidth + constants.scrollBarMargin), + scrollBarY, + constants.scrollBarWidth, + constants.scrollBarHeight + ); + } + + if(gd._context.editable) { + var xf, + yf, + x0, + y0, + lw, + lh; + + Fx.dragElement({ + element: legend.node(), + prepFn: function() { + x0 = Number(legend.attr('x')); + y0 = Number(legend.attr('y')); + lw = Number(legend.attr('width')); + lh = Number(legend.attr('height')); + Fx.setCursor(legend); + }, + moveFn: function(dx, dy) { + var gs = gd._fullLayout._size; + + legend.call(Drawing.setPosition, x0+dx, y0+dy); + + xf = Fx.dragAlign(x0+dx, lw, gs.l, gs.l+gs.w, + opts.xanchor); + yf = Fx.dragAlign(y0+dy+lh, -lh, gs.t+gs.h, gs.t, + opts.yanchor); + + var csr = Fx.dragCursors(xf, yf, + opts.xanchor, opts.yanchor); + Fx.setCursor(legend, csr); + }, + doneFn: function(dragged) { + Fx.setCursor(legend); + if(dragged && xf !== undefined && yf !== undefined) { + Plotly.relayout(gd, {'legend.x': xf, 'legend.y': yf}); + } + } + }); + } +}; + +function drawTexts(context, gd, d, i, traces) { + var fullLayout = gd._fullLayout, + trace = d[0].trace, + isPie = Plots.traceIs(trace, 'pie'), + traceIndex = trace.index, + name = isPie ? d[0].label : trace.name; + + var text = d3.select(context).selectAll('text.legendtext') + .data([0]); + text.enter().append('text').classed('legendtext', true); + text.attr({ + x: 40, + y: 0, + 'data-unformatted': name + }) + .style('text-anchor', 'start') + .classed('user-select-none', true) + .call(Drawing.font, fullLayout.legend.font) + .text(name); + + function textLayout(s) { + Plotly.util.convertToTspans(s, function() { + if(gd.firstRender) repositionLegend(gd, traces); + }); + s.selectAll('tspan.line').attr({x: s.attr('x')}); + } + + if(gd._context.editable && !isPie) { + text.call(Plotly.util.makeEditable) + .call(textLayout) + .on('edit', function(text) { + this.attr({'data-unformatted': text}); + this.text(text) + .call(textLayout); + if(!this.text()) text = ' \u0020\u0020 '; + Plotly.restyle(gd, 'name', text, traceIndex); + }); + } + else text.call(textLayout); +} + +function repositionLegend(gd, traces) { + var fullLayout = gd._fullLayout, + gs = fullLayout._size, + opts = fullLayout.legend, + borderwidth = opts.borderwidth; + + opts.width = 0; + opts.height = 0; + + traces.each(function(d) { + var trace = d[0].trace, + g = d3.select(this), + bg = g.selectAll('.legendtoggle'), + text = g.selectAll('.legendtext'), + tspans = g.selectAll('.legendtext>tspan'), + tHeight = opts.font.size * 1.3, + tLines = tspans[0].length || 1, + tWidth = text.node() && Drawing.bBox(text.node()).width, + mathjaxGroup = g.select('g[class*=math-group]'), + textY, + tHeightFull; + + if(!trace.showlegend) { + g.remove(); + return; + } + + if(mathjaxGroup.node()) { + var mathjaxBB = Drawing.bBox(mathjaxGroup.node()); + tHeight = mathjaxBB.height; + tWidth = mathjaxBB.width; + mathjaxGroup.attr('transform','translate(0,' + (tHeight / 4) + ')'); + } + else { + // approximation to height offset to center the font + // to avoid getBoundingClientRect + textY = tHeight * (0.3 + (1-tLines) / 2); + text.attr('y',textY); + tspans.attr('y',textY); + } + + tHeightFull = Math.max(tHeight*tLines, 16) + 3; + + g.attr('transform', + 'translate(' + borderwidth + ',' + + (5 + borderwidth + opts.height + tHeightFull / 2) + + ')' + ); + bg.attr({x: 0, y: -tHeightFull / 2, height: tHeightFull}); + + opts.height += tHeightFull; + opts.width = Math.max(opts.width, tWidth || 0); + }); + + + opts.width += 45 + borderwidth * 2; + opts.height += 10 + borderwidth * 2; + + if(helpers.isGrouped(opts)) { + opts.height += (opts._lgroupsLength-1) * opts.tracegroupgap; + } + + traces.selectAll('.legendtoggle') + .attr('width', (gd._context.editable ? 0 : opts.width) + 40); + + // now position the legend. for both x,y the positions are recorded as + // fractions of the plot area (left, bottom = 0,0). Outside the plot + // area is allowed but position will be clipped to the page. + // values <1/3 align the low side at that fraction, 1/3-2/3 align the + // center at that fraction, >2/3 align the right at that fraction + + var lx = gs.l + gs.w * opts.x, + ly = gs.t + gs.h * (1-opts.y); + + var xanchor = 'left'; + if(anchorUtils.isRightAnchor(opts)) { + lx -= opts.width; + xanchor = 'right'; + } + if(anchorUtils.isCenterAnchor(opts)) { + lx -= opts.width / 2; + xanchor = 'center'; + } + + var yanchor = 'top'; + if(anchorUtils.isBottomAnchor(opts)) { + ly -= opts.height; + yanchor = 'bottom'; + } + if(anchorUtils.isMiddleAnchor(opts)) { + ly -= opts.height / 2; + yanchor = 'middle'; + } + + // make sure we're only getting full pixels + opts.width = Math.ceil(opts.width); + opts.height = Math.ceil(opts.height); + lx = Math.round(lx); + ly = Math.round(ly); + + // lastly check if the margin auto-expand has changed + Plots.autoMargin(gd, 'legend', { + x: opts.x, + y: opts.y, + l: opts.width * ({right: 1, center: 0.5}[xanchor] || 0), + r: opts.width * ({left: 1, center: 0.5}[xanchor] || 0), + b: opts.height * ({top: 1, middle: 0.5}[yanchor] || 0), + t: opts.height * ({bottom: 1, middle: 0.5}[yanchor] || 0) + }); +} diff --git a/src/components/legend/get_legend_data.js b/src/components/legend/get_legend_data.js new file mode 100644 index 00000000000..a8698f140fe --- /dev/null +++ b/src/components/legend/get_legend_data.js @@ -0,0 +1,104 @@ +/** +* Copyright 2012-2016, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + +var Plots = require('../../plots/plots'); + +var helpers = require('./helpers'); + + +module.exports = function getLegendData(calcdata, opts) { + var lgroupToTraces = {}, + lgroups = [], + hasOneNonBlankGroup = false, + slicesShown = {}, + lgroupi = 0; + + var i, j; + + function addOneItem(legendGroup, legendItem) { + // each '' legend group is treated as a separate group + if(legendGroup === '' || !helpers.isGrouped(opts)) { + var uniqueGroup = '~~i' + lgroupi; // TODO: check this against fullData legendgroups? + + lgroups.push(uniqueGroup); + lgroupToTraces[uniqueGroup] = [[legendItem]]; + lgroupi++; + } + else if(lgroups.indexOf(legendGroup) === -1) { + lgroups.push(legendGroup); + hasOneNonBlankGroup = true; + lgroupToTraces[legendGroup] = [[legendItem]]; + } + else lgroupToTraces[legendGroup].push([legendItem]); + } + + // build an { legendgroup: [cd0, cd0], ... } object + for(i = 0; i < calcdata.length; i++) { + var cd = calcdata[i], + cd0 = cd[0], + trace = cd0.trace, + lgroup = trace.legendgroup; + + if(!helpers.legendGetsTrace(trace) || !trace.showlegend) continue; + + if(Plots.traceIs(trace, 'pie')) { + if(!slicesShown[lgroup]) slicesShown[lgroup] = {}; + + for(j = 0; j < cd.length; j++) { + var labelj = cd[j].label; + + if(!slicesShown[lgroup][labelj]) { + addOneItem(lgroup, { + label: labelj, + color: cd[j].color, + i: cd[j].i, + trace: trace + }); + + slicesShown[lgroup][labelj] = true; + } + } + } + + else addOneItem(lgroup, cd0); + } + + // won't draw a legend in this case + if(!lgroups.length) return []; + + // rearrange lgroupToTraces into a d3-friendly array of arrays + var lgroupsLength = lgroups.length, + ltraces, + legendData; + + if(hasOneNonBlankGroup && helpers.isGrouped(opts)) { + legendData = new Array(lgroupsLength); + + for(i = 0; i < lgroupsLength; i++) { + ltraces = lgroupToTraces[lgroups[i]]; + legendData[i] = helpers.isReversed(opts) ? ltraces.reverse() : ltraces; + } + } + else { + // collapse all groups into one if all groups are blank + legendData = [new Array(lgroupsLength)]; + + for(i = 0; i < lgroupsLength; i++) { + ltraces = lgroupToTraces[lgroups[i]][0]; + legendData[0][helpers.isReversed(opts) ? lgroupsLength-i-1 : i] = ltraces; + } + lgroupsLength = 1; + } + + // needed in repositionLegend + opts._lgroupsLength = lgroupsLength; + return legendData; +}; diff --git a/src/components/legend/helpers.js b/src/components/legend/helpers.js new file mode 100644 index 00000000000..cf300a4a9da --- /dev/null +++ b/src/components/legend/helpers.js @@ -0,0 +1,25 @@ +/** +* Copyright 2012-2016, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + +var Plots = require('../../plots/plots'); + + +exports.legendGetsTrace = function legendGetsTrace(trace) { + return trace.visible && Plots.traceIs(trace, 'showLegend'); +}; + +exports.isGrouped = function isGrouped(legendLayout) { + return (legendLayout.traceorder || '').indexOf('grouped') !== -1; +}; + +exports.isReversed = function isReversed(legendLayout) { + return (legendLayout.traceorder || '').indexOf('reversed') !== -1; +}; diff --git a/src/components/legend/index.js b/src/components/legend/index.js index 80257f445ef..4b033d1bad9 100644 --- a/src/components/legend/index.js +++ b/src/components/legend/index.js @@ -9,797 +9,13 @@ 'use strict'; -var Plotly = require('../../plotly'); -var d3 = require('d3'); - -var Lib = require('../../lib'); - -var Plots = require('../../plots/plots'); -var Fx = require('../../plots/cartesian/graph_interact'); - -var Color = require('../color'); -var Drawing = require('../drawing'); - -var subTypes = require('../../traces/scatter/subtypes'); -var styleOne = require('../../traces/pie/style_one'); var legend = module.exports = {}; -var constants = require('./constants'); legend.layoutAttributes = require('./attributes'); -legend.supplyLayoutDefaults = function(layoutIn, layoutOut, fullData) { - var containerIn = layoutIn.legend || {}, - containerOut = layoutOut.legend = {}; - - var visibleTraces = 0, - defaultOrder = 'normal', - trace; - - for(var i = 0; i < fullData.length; i++) { - trace = fullData[i]; - - if(legendGetsTrace(trace)) { - visibleTraces++; - // always show the legend by default if there's a pie - if(Plots.traceIs(trace, 'pie')) visibleTraces++; - } - - if((Plots.traceIs(trace, 'bar') && layoutOut.barmode==='stack') || - ['tonextx','tonexty'].indexOf(trace.fill)!==-1) { - defaultOrder = isGrouped({traceorder: defaultOrder}) ? - 'grouped+reversed' : 'reversed'; - } - - if(trace.legendgroup !== undefined && trace.legendgroup !== '') { - defaultOrder = isReversed({traceorder: defaultOrder}) ? - 'reversed+grouped' : 'grouped'; - } - } - - function coerce(attr, dflt) { - return Lib.coerce(containerIn, containerOut, - legend.layoutAttributes, attr, dflt); - } - - var showLegend = Lib.coerce(layoutIn, layoutOut, - Plots.layoutAttributes, 'showlegend', visibleTraces > 1); - - if(showLegend === false) return; - - coerce('bgcolor', layoutOut.paper_bgcolor); - coerce('bordercolor'); - coerce('borderwidth'); - Lib.coerceFont(coerce, 'font', layoutOut.font); - - coerce('traceorder', defaultOrder); - if(isGrouped(layoutOut.legend)) coerce('tracegroupgap'); - - coerce('x'); - coerce('xanchor'); - coerce('y'); - coerce('yanchor'); - Lib.noneOrAll(containerIn, containerOut, ['x', 'y']); -}; - -// ----------------------------------------------------- -// styling functions for traces in legends. -// same functions for styling traces in the popovers -// ----------------------------------------------------- - -legend.lines = function(d) { - var trace = d[0].trace, - showFill = trace.visible && trace.fill && trace.fill!=='none', - showLine = subTypes.hasLines(trace); - - var fill = d3.select(this).select('.legendfill').selectAll('path') - .data(showFill ? [d] : []); - fill.enter().append('path').classed('js-fill',true); - fill.exit().remove(); - fill.attr('d', 'M5,0h30v6h-30z') - .call(Drawing.fillGroupStyle); - - var line = d3.select(this).select('.legendlines').selectAll('path') - .data(showLine ? [d] : []); - line.enter().append('path').classed('js-line',true) - .attr('d', 'M5,0h30'); - line.exit().remove(); - line.call(Drawing.lineGroupStyle); -}; - -legend.points = function(d) { - var d0 = d[0], - trace = d0.trace, - showMarkers = subTypes.hasMarkers(trace), - showText = subTypes.hasText(trace), - showLines = subTypes.hasLines(trace); - - var dMod, tMod; - - // 'scatter3d' and 'scattergeo' don't use gd.calcdata yet; - // use d0.trace to infer arrayOk attributes - - function boundVal(attrIn, arrayToValFn, bounds) { - var valIn = Lib.nestedProperty(trace, attrIn).get(), - valToBound = (Array.isArray(valIn) && arrayToValFn) ? - arrayToValFn(valIn) : valIn; - - if(bounds) { - if(valToBound < bounds[0]) return bounds[0]; - else if(valToBound > bounds[1]) return bounds[1]; - } - return valToBound; - } - - function pickFirst(array) { return array[0]; } - - // constrain text, markers, etc so they'll fit on the legend - if(showMarkers || showText || showLines) { - var dEdit = {}, - tEdit = {}; - - if(showMarkers) { - dEdit.mc = boundVal('marker.color', pickFirst); - dEdit.mo = boundVal('marker.opacity', Lib.mean, [0.2, 1]); - dEdit.ms = boundVal('marker.size', Lib.mean, [2, 16]); - dEdit.mlc = boundVal('marker.line.color', pickFirst); - dEdit.mlw = boundVal('marker.line.width', Lib.mean, [0, 5]); - tEdit.marker = { - sizeref: 1, - sizemin: 1, - sizemode: 'diameter' - }; - } - - if(showLines) { - tEdit.line = { - width: boundVal('line.width', pickFirst, [0, 10]) - }; - } - - if(showText) { - dEdit.tx = 'Aa'; - dEdit.tp = boundVal('textposition', pickFirst); - dEdit.ts = 10; - dEdit.tc = boundVal('textfont.color', pickFirst); - dEdit.tf = boundVal('textfont.family', pickFirst); - } - - dMod = [Lib.minExtend(d0, dEdit)]; - tMod = Lib.minExtend(trace, tEdit); - } - - var ptgroup = d3.select(this).select('g.legendpoints'); - - var pts = ptgroup.selectAll('path.scatterpts') - .data(showMarkers ? dMod : []); - pts.enter().append('path').classed('scatterpts', true) - .attr('transform', 'translate(20,0)'); - pts.exit().remove(); - pts.call(Drawing.pointStyle, tMod); - - // 'mrc' is set in pointStyle and used in textPointStyle: - // constrain it here - if(showMarkers) dMod[0].mrc = 3; - - var txt = ptgroup.selectAll('g.pointtext') - .data(showText ? dMod : []); - txt.enter() - .append('g').classed('pointtext',true) - .append('text').attr('transform', 'translate(20,0)'); - txt.exit().remove(); - txt.selectAll('text').call(Drawing.textPointStyle, tMod); - -}; - -legend.bars = function(d) { - var trace = d[0].trace, - marker = trace.marker||{}, - markerLine = marker.line||{}, - barpath = d3.select(this).select('g.legendpoints') - .selectAll('path.legendbar') - .data(Plots.traceIs(trace, 'bar') ? [d] : []); - barpath.enter().append('path').classed('legendbar',true) - .attr('d','M6,6H-6V-6H6Z') - .attr('transform','translate(20,0)'); - barpath.exit().remove(); - barpath.each(function(d) { - var w = (d.mlw+1 || markerLine.width+1) - 1, - p = d3.select(this); - p.style('stroke-width',w+'px') - .call(Color.fill, d.mc || marker.color); - if(w) { - p.call(Color.stroke, d.mlc || markerLine.color); - } - }); -}; - -legend.boxes = function(d) { - var trace = d[0].trace, - pts = d3.select(this).select('g.legendpoints') - .selectAll('path.legendbox') - .data(Plots.traceIs(trace, 'box') && trace.visible ? [d] : []); - pts.enter().append('path').classed('legendbox', true) - // if we want the median bar, prepend M6,0H-6 - .attr('d', 'M6,6H-6V-6H6Z') - .attr('transform', 'translate(20,0)'); - pts.exit().remove(); - pts.each(function(d) { - var w = (d.lw+1 || trace.line.width+1) - 1, - p = d3.select(this); - p.style('stroke-width', w+'px') - .call(Color.fill, d.fc || trace.fillcolor); - if(w) { - p.call(Color.stroke, d.lc || trace.line.color); - } - }); -}; - -legend.pie = function(d) { - var trace = d[0].trace, - pts = d3.select(this).select('g.legendpoints') - .selectAll('path.legendpie') - .data(Plots.traceIs(trace, 'pie') && trace.visible ? [d] : []); - pts.enter().append('path').classed('legendpie', true) - .attr('d', 'M6,6H-6V-6H6Z') - .attr('transform', 'translate(20,0)'); - pts.exit().remove(); - - if(pts.size()) pts.call(styleOne, d[0], trace); -}; - -legend.style = function(s) { - s.each(function(d) { - var traceGroup = d3.select(this); - - var fill = traceGroup - .selectAll('g.legendfill') - .data([d]); - fill.enter().append('g') - .classed('legendfill',true); - - var line = traceGroup - .selectAll('g.legendlines') - .data([d]); - line.enter().append('g') - .classed('legendlines',true); - - var symbol = traceGroup - .selectAll('g.legendsymbols') - .data([d]); - symbol.enter().append('g') - .classed('legendsymbols',true); - symbol.style('opacity', d[0].trace.opacity); - - symbol.selectAll('g.legendpoints') - .data([d]) - .enter().append('g') - .classed('legendpoints',true); - }) - .each(legend.bars) - .each(legend.boxes) - .each(legend.pie) - .each(legend.lines) - .each(legend.points); -}; - -legend.texts = function(context, td, d, i, traces) { - var fullLayout = td._fullLayout, - trace = d[0].trace, - isPie = Plots.traceIs(trace, 'pie'), - traceIndex = trace.index, - name = isPie ? d[0].label : trace.name; - - var text = d3.select(context).selectAll('text.legendtext') - .data([0]); - text.enter().append('text').classed('legendtext', true); - text.attr({ - x: 40, - y: 0, - 'data-unformatted': name - }) - .style({ - 'text-anchor': 'start', - '-webkit-user-select': 'none', - '-moz-user-select': 'none', - '-ms-user-select': 'none', - 'user-select': 'none' - }) - .call(Drawing.font, fullLayout.legend.font) - .text(name); - - function textLayout(s) { - Plotly.util.convertToTspans(s, function() { - if(td.firstRender) legend.repositionLegend(td, traces); - }); - s.selectAll('tspan.line').attr({x: s.attr('x')}); - } - - if(td._context.editable && !isPie) { - text.call(Plotly.util.makeEditable) - .call(textLayout) - .on('edit', function(text) { - this.attr({'data-unformatted': text}); - this.text(text) - .call(textLayout); - if(!this.text()) text = ' \u0020\u0020 '; - Plotly.restyle(td, 'name', text, traceIndex); - }); - } - else text.call(textLayout); -}; - -// ----------------------------------------------------- -// legend drawing -// ----------------------------------------------------- - -function legendGetsTrace(trace) { - return trace.visible && Plots.traceIs(trace, 'showLegend'); -} - -function isGrouped(legendLayout) { - return (legendLayout.traceorder || '').indexOf('grouped') !== -1; -} - -function isReversed(legendLayout) { - return (legendLayout.traceorder || '').indexOf('reversed') !== -1; -} - -legend.getLegendData = function(calcdata, opts) { - - // build an { legendgroup: [cd0, cd0], ... } object - var lgroupToTraces = {}, - lgroups = [], - hasOneNonBlankGroup = false, - slicesShown = {}, - lgroupi = 0; - - var cd, cd0, trace, lgroup, i, j, labelj; - - function addOneItem(legendGroup, legendItem) { - // each '' legend group is treated as a separate group - if(legendGroup==='' || !isGrouped(opts)) { - var uniqueGroup = '~~i' + lgroupi; // TODO: check this against fullData legendgroups? - lgroups.push(uniqueGroup); - lgroupToTraces[uniqueGroup] = [[legendItem]]; - lgroupi++; - } - else if(lgroups.indexOf(legendGroup) === -1) { - lgroups.push(legendGroup); - hasOneNonBlankGroup = true; - lgroupToTraces[legendGroup] = [[legendItem]]; - } - else lgroupToTraces[legendGroup].push([legendItem]); - } - - for(i = 0; i < calcdata.length; i++) { - cd = calcdata[i]; - cd0 = cd[0]; - trace = cd0.trace; - lgroup = trace.legendgroup; - - if(!legendGetsTrace(trace) || !trace.showlegend) continue; - - if(Plots.traceIs(trace, 'pie')) { - if(!slicesShown[lgroup]) slicesShown[lgroup] = {}; - for(j = 0; j < cd.length; j++) { - labelj = cd[j].label; - if(!slicesShown[lgroup][labelj]) { - addOneItem(lgroup, { - label: labelj, - color: cd[j].color, - i: cd[j].i, - trace: trace - }); - - slicesShown[lgroup][labelj] = true; - } - } - } - - else addOneItem(lgroup, cd0); - } - - // won't draw a legend in this case - if(!lgroups.length) return []; - - // rearrange lgroupToTraces into a d3-friendly array of arrays - var lgroupsLength = lgroups.length, - ltraces, - legendData; - - if(hasOneNonBlankGroup && isGrouped(opts)) { - legendData = new Array(lgroupsLength); - for(i = 0; i < lgroupsLength; i++) { - ltraces = lgroupToTraces[lgroups[i]]; - legendData[i] = isReversed(opts) ? ltraces.reverse() : ltraces; - } - } - else { - // collapse all groups into one if all groups are blank - legendData = [new Array(lgroupsLength)]; - for(i = 0; i < lgroupsLength; i++) { - ltraces = lgroupToTraces[lgroups[i]][0]; - legendData[0][isReversed(opts) ? lgroupsLength-i-1 : i] = ltraces; - } - lgroupsLength = 1; - } - - // needed in repositionLegend - opts._lgroupsLength = lgroupsLength; - return legendData; -}; - -legend.draw = function(td) { - var fullLayout = td._fullLayout; - - if(!fullLayout._infolayer || !td.calcdata) return; - - var opts = fullLayout.legend, - legendData = fullLayout.showlegend && legend.getLegendData(td.calcdata, opts), - hiddenSlices = fullLayout.hiddenlabels || []; - - if(!fullLayout.showlegend || !legendData.length) { - fullLayout._infolayer.selectAll('.legend').remove(); - Plots.autoMargin(td, 'legend'); - return; - } - - if(typeof td.firstRender === 'undefined') td.firstRender = true; - else if(td.firstRender) td.firstRender = false; - - var legendsvg = fullLayout._infolayer.selectAll('svg.legend') - .data([0]); - legendsvg.enter().append('svg') - .attr({ - 'class': 'legend', - 'pointer-events': 'all' - }); - - var bg = legendsvg.selectAll('rect.bg') - .data([0]); - bg.enter().append('rect') - .attr({ - 'class': 'bg', - 'shape-rendering': 'crispEdges' - }) - .call(Color.stroke, opts.bordercolor) - .call(Color.fill, opts.bgcolor) - .style('stroke-width', opts.borderwidth + 'px'); - - var scrollBox = legendsvg.selectAll('g.scrollbox') - .data([0]); - scrollBox.enter().append('g') - .attr('class', 'scrollbox'); - scrollBox.exit().remove(); - - var scrollBar = legendsvg.selectAll('rect.scrollbar') - .data([0]); - scrollBar.enter().append('rect') - .attr({ - 'class': 'scrollbar', - 'rx': 20, - 'ry': 2, - 'width': 0, - 'height': 0 - }) - .call(Color.fill, '#808BA4'); - - var groups = scrollBox.selectAll('g.groups') - .data(legendData); - groups.enter().append('g') - .attr('class', 'groups'); - groups.exit().remove(); - - if(isGrouped(opts)) { - groups.attr('transform', function(d, i) { - return 'translate(0,' + i * opts.tracegroupgap + ')'; - }); - } - - var traces = groups.selectAll('g.traces') - .data(Lib.identity); - - traces.enter().append('g').attr('class', 'traces'); - traces.exit().remove(); - - traces.call(legend.style) - .style('opacity', function(d) { - var trace = d[0].trace; - if(Plots.traceIs(trace, 'pie')) { - return hiddenSlices.indexOf(d[0].label) !== -1 ? 0.5 : 1; - } else { - return trace.visible === 'legendonly' ? 0.5 : 1; - } - }) - .each(function(d, i) { - legend.texts(this, td, d, i, traces); - - var traceToggle = d3.select(this).selectAll('rect') - .data([0]); - traceToggle.enter().append('rect') - .classed('legendtoggle', true) - .style('cursor', 'pointer') - .attr('pointer-events', 'all') - .call(Color.fill, 'rgba(0,0,0,0)'); - traceToggle.on('click', function() { - if(td._dragged) return; - - var fullData = td._fullData, - trace = d[0].trace, - legendgroup = trace.legendgroup, - traceIndicesInGroup = [], - tracei, - newVisible; - - if(Plots.traceIs(trace, 'pie')) { - var thisLabel = d[0].label, - newHiddenSlices = hiddenSlices.slice(), - thisLabelIndex = newHiddenSlices.indexOf(thisLabel); - - if(thisLabelIndex === -1) newHiddenSlices.push(thisLabel); - else newHiddenSlices.splice(thisLabelIndex, 1); - - Plotly.relayout(td, 'hiddenlabels', newHiddenSlices); - } else { - if(legendgroup === '') { - traceIndicesInGroup = [trace.index]; - } else { - for(var i = 0; i < fullData.length; i++) { - tracei = fullData[i]; - if(tracei.legendgroup === legendgroup) { - traceIndicesInGroup.push(tracei.index); - } - } - } - - newVisible = trace.visible === true ? 'legendonly' : true; - Plotly.restyle(td, 'visible', newVisible, traceIndicesInGroup); - } - }); - }); - - // Position and size the legend - legend.repositionLegend(td, traces); - - // Scroll section must be executed after repositionLegend. - // It requires the legend width, height, x and y to position the scrollbox - // and these values are mutated in repositionLegend. - var gs = fullLayout._size, - lx = gs.l + gs.w * opts.x, - ly = gs.t + gs.h * (1-opts.y); - - if(opts.xanchor === 'right' || (opts.xanchor === 'auto' && opts.x >= 2 / 3)) { - lx -= opts.width; - } - else if(opts.xanchor === 'center' || (opts.xanchor === 'auto' && opts.x > 1 / 3)) { - lx -= opts.width / 2; - } - - if(opts.yanchor === 'bottom' || (opts.yanchor === 'auto' && opts.y <= 1 / 3)) { - ly -= opts.height; - } - else if(opts.yanchor === 'middle' || (opts.yanchor === 'auto' && opts.y < 2 / 3)) { - ly -= opts.height / 2; - } - - // Deal with scrolling - var plotHeight = fullLayout.height - fullLayout.margin.b, - scrollheight = Math.min(plotHeight - ly, opts.height), - scrollPosition = scrollBox.attr('data-scroll') ? scrollBox.attr('data-scroll') : 0; - - scrollBox.attr('transform', 'translate(0, ' + scrollPosition + ')'); - bg.attr({ - width: opts.width - 2 * opts.borderwidth, - height: scrollheight - 2 * opts.borderwidth, - x: opts.borderwidth, - y: opts.borderwidth - }); - - legendsvg.call(Drawing.setRect, lx, ly, opts.width, scrollheight); - - // If scrollbar should be shown. - if(td.firstRender && opts.height - scrollheight > 0 && !td._context.staticPlot) { - - bg.attr({ width: opts.width - 2 * opts.borderwidth + constants.scrollBarWidth }); - - legendsvg.node().addEventListener('wheel', function(e) { - e.preventDefault(); - scrollHandler(e.deltaY / 20); - }); - - scrollBar.node().addEventListener('mousedown', function(e) { - e.preventDefault(); - - function mMove(e) { - if(e.buttons === 1) { - scrollHandler(e.movementY); - } - } - - function mUp() { - scrollBar.node().removeEventListener('mousemove', mMove); - window.removeEventListener('mouseup', mUp); - } - - window.addEventListener('mousemove', mMove); - window.addEventListener('mouseup', mUp); - }); - - // Move scrollbar to starting position on the first render - scrollBar.call( - Drawing.setRect, - opts.width - (constants.scrollBarWidth + constants.scrollBarMargin), - constants.scrollBarMargin, - constants.scrollBarWidth, - constants.scrollBarHeight - ); - } - - function scrollHandler(delta) { - - var scrollBarTrack = scrollheight - constants.scrollBarHeight - 2 * constants.scrollBarMargin, - translateY = scrollBox.attr('data-scroll'), - scrollBoxY = Lib.constrain(translateY - delta, Math.min(scrollheight - opts.height, 0), 0), - scrollBarY = -scrollBoxY / (opts.height - scrollheight) * scrollBarTrack + constants.scrollBarMargin; - - scrollBox.attr('data-scroll', scrollBoxY); - scrollBox.attr('transform', 'translate(0, ' + scrollBoxY + ')'); - scrollBar.call( - Drawing.setRect, - opts.width - (constants.scrollBarWidth + constants.scrollBarMargin), - scrollBarY, - constants.scrollBarWidth, - constants.scrollBarHeight - ); - } - - if(td._context.editable) { - var xf, - yf, - x0, - y0, - lw, - lh; - - Fx.dragElement({ - element: legendsvg.node(), - prepFn: function() { - x0 = Number(legendsvg.attr('x')); - y0 = Number(legendsvg.attr('y')); - lw = Number(legendsvg.attr('width')); - lh = Number(legendsvg.attr('height')); - Fx.setCursor(legendsvg); - }, - moveFn: function(dx, dy) { - var gs = td._fullLayout._size; - - legendsvg.call(Drawing.setPosition, x0+dx, y0+dy); - - xf = Fx.dragAlign(x0+dx, lw, gs.l, gs.l+gs.w, - opts.xanchor); - yf = Fx.dragAlign(y0+dy+lh, -lh, gs.t+gs.h, gs.t, - opts.yanchor); - - var csr = Fx.dragCursors(xf, yf, - opts.xanchor, opts.yanchor); - Fx.setCursor(legendsvg, csr); - }, - doneFn: function(dragged) { - Fx.setCursor(legendsvg); - if(dragged && xf !== undefined && yf !== undefined) { - Plotly.relayout(td, {'legend.x': xf, 'legend.y': yf}); - } - } - }); - } -}; - -legend.repositionLegend = function(td, traces) { - var fullLayout = td._fullLayout, - gs = fullLayout._size, - opts = fullLayout.legend, - borderwidth = opts.borderwidth; - - opts.width = 0, - opts.height = 0, - - traces.each(function(d) { - var trace = d[0].trace, - g = d3.select(this), - bg = g.selectAll('.legendtoggle'), - text = g.selectAll('.legendtext'), - tspans = g.selectAll('.legendtext>tspan'), - tHeight = opts.font.size * 1.3, - tLines = tspans[0].length || 1, - tWidth = text.node() && Drawing.bBox(text.node()).width, - mathjaxGroup = g.select('g[class*=math-group]'), - textY, - tHeightFull; - - if(!trace.showlegend) { - g.remove(); - return; - } - - if(mathjaxGroup.node()) { - var mathjaxBB = Drawing.bBox(mathjaxGroup.node()); - tHeight = mathjaxBB.height; - tWidth = mathjaxBB.width; - mathjaxGroup.attr('transform','translate(0,' + (tHeight / 4) + ')'); - } - else { - // approximation to height offset to center the font - // to avoid getBoundingClientRect - textY = tHeight * (0.3 + (1-tLines) / 2); - text.attr('y',textY); - tspans.attr('y',textY); - } - - tHeightFull = Math.max(tHeight*tLines, 16) + 3; - - g.attr('transform', - 'translate(' + borderwidth + ',' + - (5 + borderwidth + opts.height + tHeightFull / 2) + - ')' - ); - bg.attr({x: 0, y: -tHeightFull / 2, height: tHeightFull}); - - opts.height += tHeightFull; - opts.width = Math.max(opts.width, tWidth || 0); - }); - - - opts.width += 45 + borderwidth * 2; - opts.height += 10 + borderwidth * 2; - - if(isGrouped(opts)) opts.height += (opts._lgroupsLength-1) * opts.tracegroupgap; - - traces.selectAll('.legendtoggle') - .attr('width', (td._context.editable ? 0 : opts.width) + 40); - - // now position the legend. for both x,y the positions are recorded as - // fractions of the plot area (left, bottom = 0,0). Outside the plot - // area is allowed but position will be clipped to the page. - // values <1/3 align the low side at that fraction, 1/3-2/3 align the - // center at that fraction, >2/3 align the right at that fraction - - var lx = gs.l + gs.w * opts.x, - ly = gs.t + gs.h * (1-opts.y); - - var xanchor = 'left'; - if(opts.xanchor === 'right' || (opts.xanchor === 'auto' && opts.x >= 2 / 3)) { - lx -= opts.width; - xanchor = 'right'; - } - else if(opts.xanchor === 'center' || (opts.xanchor === 'auto' && opts.x > 1 / 3)) { - lx -= opts.width / 2; - xanchor = 'center'; - } - - var yanchor = 'top'; - if(opts.yanchor === 'bottom' || (opts.yanchor === 'auto' && opts.y <= 1 / 3)) { - ly -= opts.height; - yanchor = 'bottom'; - } - else if(opts.yanchor === 'middle' || (opts.yanchor === 'auto' && opts.y < 2 / 3)) { - ly -= opts.height / 2; - yanchor = 'middle'; - } +legend.supplyLayoutDefaults = require('./defaults'); - // make sure we're only getting full pixels - opts.width = Math.ceil(opts.width); - opts.height = Math.ceil(opts.height); - lx = Math.round(lx); - ly = Math.round(ly); +legend.draw = require('./draw'); - // lastly check if the margin auto-expand has changed - Plots.autoMargin(td,'legend',{ - x: opts.x, - y: opts.y, - l: opts.width * ({right: 1, center: 0.5}[xanchor] || 0), - r: opts.width * ({left: 1, center: 0.5}[xanchor] || 0), - b: opts.height * ({top: 1, middle: 0.5}[yanchor] || 0), - t: opts.height * ({bottom: 1, middle: 0.5}[yanchor] || 0) - }); -}; +legend.style = require('./style'); diff --git a/src/components/legend/style.js b/src/components/legend/style.js new file mode 100644 index 00000000000..8ff88fe3e2a --- /dev/null +++ b/src/components/legend/style.js @@ -0,0 +1,220 @@ +/** +* Copyright 2012-2016, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + +var d3 = require('d3'); + +var Lib = require('../../lib'); +var Plots = require('../../plots/plots'); +var Drawing = require('../drawing'); +var Color = require('../color'); + +var subTypes = require('../../traces/scatter/subtypes'); +var stylePie = require('../../traces/pie/style_one'); + + +module.exports = function style(s) { + s.each(function(d) { + var traceGroup = d3.select(this); + + var fill = traceGroup + .selectAll('g.legendfill') + .data([d]); + fill.enter().append('g') + .classed('legendfill', true); + + var line = traceGroup + .selectAll('g.legendlines') + .data([d]); + line.enter().append('g') + .classed('legendlines', true); + + var symbol = traceGroup + .selectAll('g.legendsymbols') + .data([d]); + symbol.enter().append('g') + .classed('legendsymbols', true); + symbol.style('opacity', d[0].trace.opacity); + + symbol.selectAll('g.legendpoints') + .data([d]) + .enter().append('g') + .classed('legendpoints', true); + }) + .each(styleBars) + .each(styleBoxes) + .each(stylePies) + .each(styleLines) + .each(stylePoints); +}; + +function styleLines(d) { + var trace = d[0].trace, + showFill = trace.visible && trace.fill && trace.fill!=='none', + showLine = subTypes.hasLines(trace); + + var fill = d3.select(this).select('.legendfill').selectAll('path') + .data(showFill ? [d] : []); + fill.enter().append('path').classed('js-fill', true); + fill.exit().remove(); + fill.attr('d', 'M5,0h30v6h-30z') + .call(Drawing.fillGroupStyle); + + var line = d3.select(this).select('.legendlines').selectAll('path') + .data(showLine ? [d] : []); + line.enter().append('path').classed('js-line', true) + .attr('d', 'M5,0h30'); + line.exit().remove(); + line.call(Drawing.lineGroupStyle); +} + +function stylePoints(d) { + var d0 = d[0], + trace = d0.trace, + showMarkers = subTypes.hasMarkers(trace), + showText = subTypes.hasText(trace), + showLines = subTypes.hasLines(trace); + + var dMod, tMod; + + // 'scatter3d' and 'scattergeo' don't use gd.calcdata yet; + // use d0.trace to infer arrayOk attributes + + function boundVal(attrIn, arrayToValFn, bounds) { + var valIn = Lib.nestedProperty(trace, attrIn).get(), + valToBound = (Array.isArray(valIn) && arrayToValFn) ? + arrayToValFn(valIn) : valIn; + + if(bounds) { + if(valToBound < bounds[0]) return bounds[0]; + else if(valToBound > bounds[1]) return bounds[1]; + } + return valToBound; + } + + function pickFirst(array) { return array[0]; } + + // constrain text, markers, etc so they'll fit on the legend + if(showMarkers || showText || showLines) { + var dEdit = {}, + tEdit = {}; + + if(showMarkers) { + dEdit.mc = boundVal('marker.color', pickFirst); + dEdit.mo = boundVal('marker.opacity', Lib.mean, [0.2, 1]); + dEdit.ms = boundVal('marker.size', Lib.mean, [2, 16]); + dEdit.mlc = boundVal('marker.line.color', pickFirst); + dEdit.mlw = boundVal('marker.line.width', Lib.mean, [0, 5]); + tEdit.marker = { + sizeref: 1, + sizemin: 1, + sizemode: 'diameter' + }; + } + + if(showLines) { + tEdit.line = { + width: boundVal('line.width', pickFirst, [0, 10]) + }; + } + + if(showText) { + dEdit.tx = 'Aa'; + dEdit.tp = boundVal('textposition', pickFirst); + dEdit.ts = 10; + dEdit.tc = boundVal('textfont.color', pickFirst); + dEdit.tf = boundVal('textfont.family', pickFirst); + } + + dMod = [Lib.minExtend(d0, dEdit)]; + tMod = Lib.minExtend(trace, tEdit); + } + + var ptgroup = d3.select(this).select('g.legendpoints'); + + var pts = ptgroup.selectAll('path.scatterpts') + .data(showMarkers ? dMod : []); + pts.enter().append('path').classed('scatterpts', true) + .attr('transform', 'translate(20,0)'); + pts.exit().remove(); + pts.call(Drawing.pointStyle, tMod); + + // 'mrc' is set in pointStyle and used in textPointStyle: + // constrain it here + if(showMarkers) dMod[0].mrc = 3; + + var txt = ptgroup.selectAll('g.pointtext') + .data(showText ? dMod : []); + txt.enter() + .append('g').classed('pointtext',true) + .append('text').attr('transform', 'translate(20,0)'); + txt.exit().remove(); + txt.selectAll('text').call(Drawing.textPointStyle, tMod); +} + +function styleBars(d) { + var trace = d[0].trace, + marker = trace.marker || {}, + markerLine = marker.line || {}, + barpath = d3.select(this).select('g.legendpoints') + .selectAll('path.legendbar') + .data(Plots.traceIs(trace, 'bar') ? [d] : []); + barpath.enter().append('path').classed('legendbar', true) + .attr('d','M6,6H-6V-6H6Z') + .attr('transform', 'translate(20,0)'); + barpath.exit().remove(); + barpath.each(function(d) { + var w = (d.mlw + 1 || markerLine.width + 1) - 1, + p = d3.select(this); + + p.style('stroke-width',w+'px') + .call(Color.fill, d.mc || marker.color); + + if(w) { + p.call(Color.stroke, d.mlc || markerLine.color); + } + }); +} + +function styleBoxes(d) { + var trace = d[0].trace, + pts = d3.select(this).select('g.legendpoints') + .selectAll('path.legendbox') + .data(Plots.traceIs(trace, 'box') && trace.visible ? [d] : []); + pts.enter().append('path').classed('legendbox', true) + // if we want the median bar, prepend M6,0H-6 + .attr('d', 'M6,6H-6V-6H6Z') + .attr('transform', 'translate(20,0)'); + pts.exit().remove(); + pts.each(function(d) { + var w = (d.lw + 1 || trace.line.width + 1) - 1, + p = d3.select(this); + + p.style('stroke-width', w+'px') + .call(Color.fill, d.fc || trace.fillcolor); + + if(w) { + p.call(Color.stroke, d.lc || trace.line.color); + } + }); +} + +function stylePies(d) { + var trace = d[0].trace, + pts = d3.select(this).select('g.legendpoints') + .selectAll('path.legendpie') + .data(Plots.traceIs(trace, 'pie') && trace.visible ? [d] : []); + pts.enter().append('path').classed('legendpie', true) + .attr('d', 'M6,6H-6V-6H6Z') + .attr('transform', 'translate(20,0)'); + pts.exit().remove(); + + if(pts.size()) pts.call(stylePie, d[0], trace); +} diff --git a/src/css/_base.scss b/src/css/_base.scss index b7c2b66aeaf..3551948be0a 100644 --- a/src/css/_base.scss +++ b/src/css/_base.scss @@ -22,6 +22,10 @@ a { .crisp { shape-rendering: crispEdges; } +.user-select-none { + @include vendor('user-select', none); +} + //Required for IE11. Other browsers set this by default. svg { overflow: hidden; } diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 1753264abf2..eeea7c0c1f5 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2585,6 +2585,9 @@ function makePlotFramework(gd) { fullLayout._defs = fullLayout._paper.append('defs') .attr('id', 'defs-' + fullLayout._uid); + fullLayout._topdefs = fullLayout._toppaper.append('defs') + .attr('id', 'topdefs-' + fullLayout._uid); + fullLayout._draggers = fullLayout._paper.append('g') .classed('draglayer', true); diff --git a/src/plots/plots.js b/src/plots/plots.js index 6d3693b754c..990b715edc6 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -840,6 +840,7 @@ plots.sanitizeMargins = function(fullLayout) { margin.b = Math.floor(correction * margin.b); } }; + // called by legend and colorbar routines to see if we need to // expand the margins to show them // o is {x,l,r,y,t,b} where x and y are plot fractions, @@ -874,17 +875,19 @@ plots.doAutoMargin = function(gd) { var fullLayout = gd._fullLayout; if(!fullLayout._size) fullLayout._size = {}; if(!fullLayout._pushmargin) fullLayout._pushmargin = {}; + var gs = fullLayout._size, oldmargins = JSON.stringify(gs); // adjust margins for outside legends and colorbars // fullLayout.margin is the requested margin, // fullLayout._size has margins and plotsize after adjustment - var ml = Math.max(fullLayout.margin.l||0,0), - mr = Math.max(fullLayout.margin.r||0,0), - mt = Math.max(fullLayout.margin.t||0,0), - mb = Math.max(fullLayout.margin.b||0,0), + var ml = Math.max(fullLayout.margin.l || 0, 0), + mr = Math.max(fullLayout.margin.r || 0, 0), + mt = Math.max(fullLayout.margin.t || 0, 0), + mb = Math.max(fullLayout.margin.b || 0, 0), pm = fullLayout._pushmargin; + if(fullLayout.margin.autoexpand!==false) { // fill in the requested margins pm.base = { diff --git a/src/snapshot/tosvg.js b/src/snapshot/tosvg.js index 8f312bdf870..14c36df1ce3 100644 --- a/src/snapshot/tosvg.js +++ b/src/snapshot/tosvg.js @@ -85,14 +85,18 @@ module.exports = function toSVG(gd, format) { .appendChild(geoFramework.node()); } - // now that we've got the 3d images in the right layer, add top items above them - // assumes everything in toppaper is a group, and if it's empty (like hoverlayer) - // we can ignore it + // now that we've got the 3d images in the right layer, + // add top items above them assumes everything in toppaper is either + // a group or a defs, and if it's empty (like hoverlayer) we can ignore it. if(fullLayout._toppaper) { - var topGroups = fullLayout._toppaper.node().childNodes, - topGroup; + var nodes = fullLayout._toppaper.node().childNodes; + + // make copy of nodes as childNodes prop gets mutated in loop below + var topGroups = Array.prototype.slice.call(nodes); + for(i = 0; i < topGroups.length; i++) { - topGroup = topGroups[i]; + var topGroup = topGroups[i]; + if(topGroup.childNodes.length) svg.node().appendChild(topGroup); } } @@ -103,12 +107,7 @@ module.exports = function toSVG(gd, format) { svg.node().style.background = ''; svg.selectAll('text') - .attr({'data-unformatted': null}) - .style({ - '-webkit-user-select': null, - '-moz-user-select': null, - '-ms-user-select': null - }) + .attr('data-unformatted', null) .each(function() { // hidden text is pre-formatting mathjax, the browser ignores it but it can still confuse batik var txt = d3.select(this); diff --git a/test/image/baselines/gl3d_projection-traces.png b/test/image/baselines/gl3d_projection-traces.png index 49470a45b25..1de40e957a8 100644 Binary files a/test/image/baselines/gl3d_projection-traces.png and b/test/image/baselines/gl3d_projection-traces.png differ diff --git a/test/jasmine/assets/get_bbox.js b/test/jasmine/assets/get_bbox.js new file mode 100644 index 00000000000..e651f414dd9 --- /dev/null +++ b/test/jasmine/assets/get_bbox.js @@ -0,0 +1,58 @@ +'use strict'; + +var d3 = require('d3'); + +var ATTRS = ['x', 'y', 'width', 'height']; + + +// In-house implementation of SVG getBBox that takes clip paths into account +module.exports = function getBBox(element) { + var elementBBox = element.getBBox(); + + var s = d3.select(element); + var clipPathAttr = s.attr('clip-path'); + + if(!clipPathAttr) return elementBBox; + + // only supports 'url(#)' at the moment + var clipPathId = clipPathAttr.substring(5, clipPathAttr.length-1); + var clipBBox = getClipBBox(clipPathId); + + return minBBox(elementBBox, clipBBox); +}; + +function getClipBBox(clipPathId) { + var clipPath = d3.select('#' + clipPathId); + var clipBBox; + + try { + // this line throws an error in FF (38 and 45 at least) + clipBBox = clipPath.node().getBBox(); + } + catch(e) { + // use DOM attributes as fallback + var path = d3.select(clipPath.node().firstChild); + + clipBBox = {}; + + ATTRS.forEach(function(attr) { + clipBBox[attr] = path.attr(attr); + }); + } + + return clipBBox; +} + +function minBBox(bbox1, bbox2) { + var out = {}; + + function min(attr) { + return Math.min(bbox1[attr], bbox2[attr]); + } + + ATTRS.forEach(function(attr) { + out[attr] = min(attr); + }); + + return out; +} diff --git a/test/jasmine/tests/legend_scroll_test.js b/test/jasmine/tests/legend_scroll_test.js index e8f02a1661e..5bdf05d8e62 100644 --- a/test/jasmine/tests/legend_scroll_test.js +++ b/test/jasmine/tests/legend_scroll_test.js @@ -1,11 +1,24 @@ var Plotly = require('@lib/index'); var createGraph = require('../assets/create_graph_div'); var destroyGraph = require('../assets/destroy_graph_div'); +var getBBox = require('../assets/get_bbox'); var mock = require('../../image/mocks/legend_scroll.json'); + describe('The legend', function() { - var gd, - legend; + 'use strict'; + + var gd, legend; + + function countLegendGroups(gd) { + return gd._fullLayout._toppaper.selectAll('g.legend').size(); + } + + function countLegendClipPaths(gd) { + var uid = gd._fullLayout._uid; + + return gd._fullLayout._topdefs.selectAll('#legend' + uid).size(); + } describe('when plotted with many traces', function() { beforeEach(function() { @@ -17,7 +30,7 @@ describe('The legend', function() { afterEach(destroyGraph); it('should not exceed plot height', function() { - var legendHeight = legend.getAttribute('height'), + var legendHeight = getBBox(legend).height, plotHeight = gd._fullLayout.height - gd._fullLayout.margin.t - gd._fullLayout.margin.b; expect(+legendHeight).toBe(plotHeight); @@ -53,7 +66,7 @@ describe('The legend', function() { it('should scale the scrollbar movement from top to bottom', function() { var scrollBar = legend.getElementsByClassName('scrollbar')[0], - legendHeight = legend.getAttribute('height'); + legendHeight = getBBox(legend).height; // The scrollbar is 20px tall and has 4px margins @@ -63,6 +76,18 @@ describe('The legend', function() { legend.dispatchEvent(scrollTo(10000)); expect(+scrollBar.getAttribute('y')).toBe(legendHeight - 4 - 20); }); + + it('should be removed from DOM when \'showlegend\' is relayout\'ed to false', function(done) { + expect(countLegendGroups(gd)).toBe(1); + expect(countLegendClipPaths(gd)).toBe(1); + + Plotly.relayout(gd, 'showlegend', false).then(function() { + expect(countLegendGroups(gd)).toBe(0); + expect(countLegendClipPaths(gd)).toBe(0); + + done(); + }); + }); }); describe('when plotted with few traces', function() { @@ -70,7 +95,11 @@ describe('The legend', function() { beforeEach(function() { gd = createGraph(); - Plotly.plot(gd, [{ x: [1,2,3], y: [2,3,4], name: 'Test' }], {}); + + var data = [{ x: [1,2,3], y: [2,3,4], name: 'Test' }]; + var layout = { showlegend: true }; + + Plotly.plot(gd, data, layout); }); afterEach(destroyGraph); @@ -78,7 +107,20 @@ describe('The legend', function() { it('should not display the scrollbar', function() { var scrollBar = document.getElementsByClassName('scrollbar')[0]; - expect(scrollBar).toBeUndefined(); + expect(+scrollBar.getAttribute('width')).toBe(0); + expect(+scrollBar.getAttribute('height')).toBe(0); + }); + + it('should be removed from DOM when \'showlegend\' is relayout\'ed to false', function(done) { + expect(countLegendGroups(gd)).toBe(1); + expect(countLegendClipPaths(gd)).toBe(1); + + Plotly.relayout(gd, 'showlegend', false).then(function() { + expect(countLegendGroups(gd)).toBe(0); + expect(countLegendClipPaths(gd)).toBe(0); + + done(); + }); }); }); }); diff --git a/test/jasmine/tests/legend_test.js b/test/jasmine/tests/legend_test.js index 8de626b6c83..34ef1e618a2 100644 --- a/test/jasmine/tests/legend_test.js +++ b/test/jasmine/tests/legend_test.js @@ -1,6 +1,11 @@ -var Legend = require('@src/components/legend'); var Plots = require('@src/plots/plots'); +var Legend = require('@src/components/legend'); +var getLegendData = require('@src/components/legend/get_legend_data'); +var helpers = require('@src/components/legend/helpers'); +var anchorUtils = require('@src/components/legend/anchor_utils'); + + describe('Test legend:', function() { 'use strict'; @@ -63,8 +68,6 @@ describe('Test legend:', function() { }); describe('getLegendData', function() { - var getLegendData = Legend.getLegendData; - var calcdata, opts, legendData, expected; it('should group legendgroup traces', function() { @@ -325,4 +328,125 @@ describe('Test legend:', function() { }); }); + describe('legendGetsTraces helper', function() { + var legendGetsTrace = helpers.legendGetsTrace; + + it('should return true when trace is visible and supports legend', function() { + expect(legendGetsTrace({ visible: true, type: 'bar' })).toBe(true); + expect(legendGetsTrace({ visible: false, type: 'bar' })).toBe(false); + expect(legendGetsTrace({ visible: true, type: 'contour' })).toBe(false); + expect(legendGetsTrace({ visible: false, type: 'contour' })).toBe(false); + }); + }); + + describe('isGrouped helper', function() { + var isGrouped = helpers.isGrouped; + + it('should return true when trace is visible and supports legend', function() { + expect(isGrouped({ traceorder: 'normal' })).toBe(false); + expect(isGrouped({ traceorder: 'grouped' })).toBe(true); + expect(isGrouped({ traceorder: 'reversed+grouped' })).toBe(true); + expect(isGrouped({ traceorder: 'grouped+reversed' })).toBe(true); + expect(isGrouped({ traceorder: 'reversed' })).toBe(false); + }); + }); + + describe('isReversed helper', function() { + var isReversed = helpers.isReversed; + + it('should return true when trace is visible and supports legend', function() { + expect(isReversed({ traceorder: 'normal' })).toBe(false); + expect(isReversed({ traceorder: 'grouped' })).toBe(false); + expect(isReversed({ traceorder: 'reversed+grouped' })).toBe(true); + expect(isReversed({ traceorder: 'grouped+reversed' })).toBe(true); + expect(isReversed({ traceorder: 'reversed' })).toBe(true); + }); + }); + + describe('isRightAnchor anchor util', function() { + var isRightAnchor = anchorUtils.isRightAnchor; + var threshold = 2/3; + + it('should return true when \'xanchor\' is set to \'right\'', function() { + expect(isRightAnchor({ xanchor: 'left' })).toBe(false); + expect(isRightAnchor({ xanchor: 'center' })).toBe(false); + expect(isRightAnchor({ xanchor: 'right' })).toBe(true); + }); + + it('should return true when \'xanchor\' is set to \'auto\' and \'x\' >= 2/3', function() { + var opts = { xanchor: 'auto' }; + + [0, 0.4, 0.7, 1].forEach(function(v) { + opts.x = v; + expect(isRightAnchor(opts)) + .toBe(v > threshold, 'case ' + v); + }); + }); + }); + + describe('isCenterAnchor anchor util', function() { + var isCenterAnchor = anchorUtils.isCenterAnchor; + var threshold0 = 1/3; + var threshold1 = 2/3; + + it('should return true when \'xanchor\' is set to \'center\'', function() { + expect(isCenterAnchor({ xanchor: 'left' })).toBe(false); + expect(isCenterAnchor({ xanchor: 'center' })).toBe(true); + expect(isCenterAnchor({ xanchor: 'right' })).toBe(false); + }); + + it('should return true when \'xanchor\' is set to \'auto\' and 1/3 < \'x\' < 2/3', function() { + var opts = { xanchor: 'auto' }; + + [0, 0.4, 0.7, 1].forEach(function(v) { + opts.x = v; + expect(isCenterAnchor(opts)) + .toBe(v > threshold0 && v < threshold1, 'case ' + v); + }); + }); + }); + + describe('isBottomAnchor anchor util', function() { + var isBottomAnchor = anchorUtils.isBottomAnchor; + var threshold = 1/3; + + it('should return true when \'yanchor\' is set to \'right\'', function() { + expect(isBottomAnchor({ yanchor: 'top' })).toBe(false); + expect(isBottomAnchor({ yanchor: 'middle' })).toBe(false); + expect(isBottomAnchor({ yanchor: 'bottom' })).toBe(true); + }); + + it('should return true when \'yanchor\' is set to \'auto\' and \'y\' <= 1/3', function() { + var opts = { yanchor: 'auto' }; + + [0, 0.4, 0.7, 1].forEach(function(v) { + opts.y = v; + expect(isBottomAnchor(opts)) + .toBe(v < threshold, 'case ' + v); + }); + }); + }); + + describe('isMiddleAnchor anchor util', function() { + var isMiddleAnchor = anchorUtils.isMiddleAnchor; + var threshold0 = 1/3; + var threshold1 = 2/3; + + it('should return true when \'yanchor\' is set to \'center\'', function() { + expect(isMiddleAnchor({ yanchor: 'top' })).toBe(false); + expect(isMiddleAnchor({ yanchor: 'middle' })).toBe(true); + expect(isMiddleAnchor({ yanchor: 'bottom' })).toBe(false); + }); + + it('should return true when \'yanchor\' is set to \'auto\' and 1/3 < \'y\' < 2/3', function() { + var opts = { yanchor: 'auto' }; + + [0, 0.4, 0.7, 1].forEach(function(v) { + opts.y = v; + expect(isMiddleAnchor(opts)) + .toBe(v > threshold0 && v < threshold1, 'case ' + v); + }); + }); + }); + });