From 04d340cd098431244ad1991011c0561431d874a7 Mon Sep 17 00:00:00 2001 From: Nicolas Riesco Date: Wed, 13 Apr 2016 20:59:29 +0100 Subject: [PATCH 1/2] Split Legend.draw() into smaller functions --- src/components/legend/draw.js | 647 ++++++++++++++++++---------------- 1 file changed, 349 insertions(+), 298 deletions(-) diff --git a/src/components/legend/draw.js b/src/components/legend/draw.js index 9334fc7eba0..828d9165b3f 100644 --- a/src/components/legend/draw.js +++ b/src/components/legend/draw.js @@ -47,365 +47,415 @@ module.exports = function draw(gd) { if(typeof gd.firstRender === 'undefined') gd.firstRender = true; else if(gd.firstRender) gd.firstRender = false; - var legend = fullLayout._infolayer.selectAll('g.legend') - .data([0]); + var legend = createRootNode(), // root element (sets legend position) + clipPath = createClipPath(), // legend clip path + bg = createBackground(), // legend background and border + scrollBox = createScrollBox(), // contains all the legend traces + scrollBar = createScrollBar(), // scroll bar (visible only if needed) + groups = createGroups(), // legend groups + traces = createLegendTraces(); // legend traces + + positionLegend(); + + function createRootNode() { + var legend = fullLayout._infolayer.selectAll('g.legend') + .data([0]); + + legend.enter().append('g') + .attr({ + 'class': 'legend', + 'pointer-events': 'all' + }); - legend.enter().append('g') - .attr({ - 'class': 'legend', - 'pointer-events': 'all' - }); + return legend; + } - var clipPath = fullLayout._topdefs.selectAll('#' + clipId) - .data([0]); + function createClipPath() { + var clipPath = fullLayout._topdefs.selectAll('#' + clipId) + .data([0]); - clipPath.enter().append('clipPath') - .attr('id', clipId) - .append('rect'); + clipPath.enter().append('clipPath') + .attr('id', clipId) + .append('rect'); - var bg = legend.selectAll('rect.bg') - .data([0]); + return clipPath; + } - 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'); + function createBackground() { + var bg = legend.selectAll('rect.bg') + .data([0]); - var groups = scrollBox.selectAll('g.groups') - .data(legendData); + 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'); - groups.enter().append('g') - .attr('class', 'groups'); + return bg; + } - groups.exit().remove(); + function createScrollBox() { + var scrollBox = legend.selectAll('g.scrollbox') + .data([0]); - if(helpers.isGrouped(opts)) { - groups.attr('transform', function(d, i) { - return 'translate(0,' + i * opts.tracegroupgap + ')'; - }); + scrollBox.enter().append('g') + .attr('class', 'scrollbox'); + + return scrollBox; } - var traces = groups.selectAll('g.traces') - .data(Lib.identity); + function createScrollBar() { + 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'); + + return scrollBar; + } - traces.enter().append('g').attr('class', 'traces'); - traces.exit().remove(); + function createGroups() { + var groups = scrollBox.selectAll('g.groups') + .data(legendData); - 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]); + groups.enter().append('g') + .attr('class', 'groups'); - traceToggle.enter().append('rect') - .classed('legendtoggle', true) - .style('cursor', 'pointer') - .attr('pointer-events', 'all') - .call(Color.fill, 'rgba(0,0,0,0)'); + groups.exit().remove(); - traceToggle.on('click', function() { - if(gd._dragged) return; + if(helpers.isGrouped(opts)) { + groups.attr('transform', function(d, i) { + return 'translate(0,' + i * opts.tracegroupgap + ')'; + }); + } - var fullData = gd._fullData, - trace = d[0].trace, - legendgroup = trace.legendgroup, - traceIndicesInGroup = [], - tracei, - newVisible; + return groups; + } - if(Plots.traceIs(trace, 'pie')) { - var thisLabel = d[0].label, - newHiddenSlices = hiddenSlices.slice(), - thisLabelIndex = newHiddenSlices.indexOf(thisLabel); + function createLegendTraces() { + var traces = groups.selectAll('g.traces') + .data(Lib.identity); - if(thisLabelIndex === -1) newHiddenSlices.push(thisLabel); - else newHiddenSlices.splice(thisLabelIndex, 1); + traces.enter().append('g').attr('class', 'traces'); + traces.exit().remove(); - Plotly.relayout(gd, 'hiddenlabels', newHiddenSlices); + 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 { - 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); + return trace.visible === 'legendonly' ? 0.5 : 1; } + }) + .each(function(d, i) { + drawLegendTraces(this, gd, d, i, traces); + setupTraceToggle(this, gd, d); }); - }); - // Position and size the legend - var lyMin = 0, - lyMax = fullLayout.height; + return traces; + } - computeLegendDimensions(gd, traces); + function drawLegendTraces(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) { + computeLegendSize(gd, traces); + expandMargin(gd); + } + }); + s.selectAll('tspan.line').attr({x: s.attr('x')}); + } - if(opts.height > lyMax) { - // If the legend doesn't fit in the plot area, - // do not expand the vertical margins. - expandHorizontalMargin(gd); - } else { - expandMargin(gd); + 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); } - // 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); + function setupTraceToggle(context, gd, d) { + var traceToggle = d3.select(context).selectAll('rect') + .data([0]); - if(anchorUtils.isRightAnchor(opts)) { - lx -= opts.width; - } - else if(anchorUtils.isCenterAnchor(opts)) { - lx -= opts.width / 2; - } + traceToggle.enter().append('rect') + .classed('legendtoggle', true) + .style('cursor', 'pointer') + .attr('pointer-events', 'all') + .call(Color.fill, 'rgba(0,0,0,0)'); - if(anchorUtils.isBottomAnchor(opts)) { - ly -= opts.height; - } - else if(anchorUtils.isMiddleAnchor(opts)) { - ly -= opts.height / 2; - } + traceToggle.on('click', function() { + if(gd._dragged) return; - // Make sure the legend top and bottom are visible - // (legends with a scroll bar are not allowed to stretch beyond the extended - // margins) - var legendHeight = opts.height, - legendHeightMax = gs.h; + var fullData = gd._fullData, + trace = d[0].trace, + legendgroup = trace.legendgroup, + traceIndicesInGroup = [], + tracei, + newVisible; - if(legendHeight > legendHeightMax) { - ly = gs.t; - legendHeight = legendHeightMax; - } - else { - if(ly > lyMax) ly = lyMax - legendHeight; - if(ly < lyMin) ly = lyMin; - legendHeight = Math.min(lyMax - ly, opts.height); - } + if(Plots.traceIs(trace, 'pie')) { + var thisLabel = d[0].label, + newHiddenSlices = hiddenSlices.slice(), + thisLabelIndex = newHiddenSlices.indexOf(thisLabel); - // Deal with scrolling - var scrollPosition = scrollBox.attr('data-scroll') || 0; + if(thisLabelIndex === -1) newHiddenSlices.push(thisLabel); + else newHiddenSlices.splice(thisLabelIndex, 1); - scrollBox.attr('transform', 'translate(0, ' + scrollPosition + ')'); + 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); + } + } + } - bg.attr({ - width: opts.width - 2 * opts.borderwidth, - height: legendHeight - 2 * opts.borderwidth, - x: opts.borderwidth, - y: opts.borderwidth - }); + newVisible = trace.visible === true ? 'legendonly' : true; + Plotly.restyle(gd, 'visible', newVisible, traceIndicesInGroup); + } + }); + } - legend.attr('transform', 'translate(' + lx + ',' + ly + ')'); + function positionLegend() { + computeLegendSize(gd, traces); // updates opts - clipPath.select('rect').attr({ - width: opts.width, - height: legendHeight, - x: 0, - y: 0 - }); + // If the legend height doesn't fit in the plot area, + // expand only the horizontal margin. + if(opts.height > fullLayout.height) { + expandHorizontalMargin(gd); + } else { + expandMargin(gd); + } - legend.call(Drawing.setClipUrl, clipId); + var lx, // legend x coordinate + ly, // legend y coordinate + legendHeight; // legend visible height + computeLegendPosition(); // updates lx, ly and legendHeight - // If scrollbar should be shown. - if(opts.height - legendHeight > 0 && !gd._context.staticPlot) { + // Set size and position of all the elements that make up a legend: + // legend, background and border, scroll box and scroll bar + legend.attr('transform', 'translate(' + lx + ',' + ly + ')'); // increase the background and clip-path width // by the scrollbar width and margin bg.attr({ - width: opts.width - - 2 * opts.borderwidth + - constants.scrollBarWidth + - constants.scrollBarMargin + width: opts.width - 2 * opts.borderwidth, + height: legendHeight - 2 * opts.borderwidth, + x: opts.borderwidth, + y: opts.borderwidth }); clipPath.select('rect').attr({ - width: opts.width + - constants.scrollBarWidth + - constants.scrollBarMargin + width: opts.width, + height: legendHeight, + x: 0, + y: 0 }); - if(gd.firstRender) { - // Move scrollbar to starting position - scrollHandler(constants.scrollBarMargin, 0); + legend.call(Drawing.setClipUrl, clipId); + + var dataScroll = scrollBox.attr('data-scroll') || 0; + scrollBox.attr('transform', 'translate(0, ' + dataScroll + ')'); + + if(opts.height - legendHeight > 0 && !gd._context.staticPlot) { + // Show the scrollbar only if needed and requested + positionScrollBar(legendHeight); } - var scrollBarYMax = legendHeight - - constants.scrollBarHeight - - 2 * constants.scrollBarMargin, - scrollBoxYMax = opts.height - legendHeight, - scrollBarY = constants.scrollBarMargin, - scrollBoxY = 0; - - scrollHandler(scrollBarY, scrollBoxY); - - legend.on('wheel',null); - legend.on('wheel', function() { - scrollBoxY = Lib.constrain( - scrollBox.attr('data-scroll') - - d3.event.deltaY / scrollBarYMax * scrollBoxYMax, - -scrollBoxYMax, 0); - scrollBarY = constants.scrollBarMargin - - scrollBoxY / scrollBoxYMax * scrollBarYMax; - scrollHandler(scrollBarY, scrollBoxY); - d3.event.preventDefault(); - }); + if(gd._context.editable) setupDragElement(); - scrollBar.on('.drag',null); - scrollBox.on('.drag',null); - var drag = d3.behavior.drag().on('drag', function() { - scrollBarY = Lib.constrain( - d3.event.y - constants.scrollBarHeight / 2, - constants.scrollBarMargin, - constants.scrollBarMargin + scrollBarYMax); - scrollBoxY = - (scrollBarY - constants.scrollBarMargin) / - scrollBarYMax * scrollBoxYMax; - scrollHandler(scrollBarY, scrollBoxY); - }); + function positionScrollBar(legendHeight) { + bg.attr({ + width: opts.width - + 2 * opts.borderwidth + + constants.scrollBarWidth + + constants.scrollBarMargin + }); - scrollBar.call(drag); - scrollBox.call(drag); + clipPath.select('rect').attr({ + width: opts.width + + constants.scrollBarWidth + + constants.scrollBarMargin + }); - } + if(gd.firstRender) { + // Move scrollbar to starting position + scrollHandler(constants.scrollBarMargin, 0); + } + // Handle wheel and drag events + var scrollBarYMax = legendHeight - + constants.scrollBarHeight - + 2 * constants.scrollBarMargin, + scrollBoxYMax = opts.height - legendHeight, + scrollBarY = constants.scrollBarMargin, + scrollBoxY = 0; - function scrollHandler(scrollBarY, scrollBoxY) { - scrollBox.attr('data-scroll', scrollBoxY); - scrollBox.attr('transform', 'translate(0, ' + scrollBoxY + ')'); - scrollBar.call( - Drawing.setRect, - opts.width, - scrollBarY, - constants.scrollBarWidth, - constants.scrollBarHeight - ); - } + scrollHandler(scrollBarY, scrollBoxY); + + legend.on('wheel',null); + legend.on('wheel', function() { + scrollBoxY = Lib.constrain( + scrollBox.attr('data-scroll') - + d3.event.deltaY / scrollBarYMax * scrollBoxYMax, + -scrollBoxYMax, 0); + scrollBarY = constants.scrollBarMargin - + scrollBoxY / scrollBoxYMax * scrollBarYMax; + scrollHandler(scrollBarY, scrollBoxY); + d3.event.preventDefault(); + }); + + scrollBar.on('.drag',null); + scrollBox.on('.drag',null); + var drag = d3.behavior.drag().on('drag', function() { + scrollBarY = Lib.constrain( + d3.event.y - constants.scrollBarHeight / 2, + constants.scrollBarMargin, + constants.scrollBarMargin + scrollBarYMax); + scrollBoxY = - (scrollBarY - constants.scrollBarMargin) / + scrollBarYMax * scrollBoxYMax; + scrollHandler(scrollBarY, scrollBoxY); + }); + + scrollBar.call(drag); + scrollBox.call(drag); + + function scrollHandler(scrollBarY, scrollBoxY) { + scrollBox.attr('data-scroll', scrollBoxY); + scrollBox.attr('transform', 'translate(0, ' + scrollBoxY + ')'); + scrollBar.call( + Drawing.setRect, + opts.width - 2 * opts.borderwidth, + scrollBarY, + constants.scrollBarWidth, + constants.scrollBarHeight + ); + } + } - if(gd._context.editable) { - var xf, - yf, - x0, - y0, - lw, - lh; - - dragElement.init({ - 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')); - setCursor(legend); - }, - moveFn: function(dx, dy) { - var gs = gd._fullLayout._size; - - legend.call(Drawing.setPosition, x0+dx, y0+dy); - - xf = dragElement.align(x0+dx, lw, gs.l, gs.l+gs.w, - opts.xanchor); - yf = dragElement.align(y0+dy+lh, -lh, gs.t+gs.h, gs.t, - opts.yanchor); - - var csr = dragElement.getCursor(xf, yf, - opts.xanchor, opts.yanchor); - setCursor(legend, csr); - }, - doneFn: function(dragged) { - setCursor(legend); - if(dragged && xf !== undefined && yf !== undefined) { - Plotly.relayout(gd, {'legend.x': xf, 'legend.y': yf}); + function setupDragElement() { + var xf, + yf, + x0, + y0, + lw, + lh; + + 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')); + setCursor(legend); + }, + moveFn: function(dx, dy) { + var gs = gd._fullLayout._size; + + legend.call(Drawing.setPosition, x0+dx, y0+dy); + + xf = dragElement.align(x0+dx, lw, gs.l, gs.l+gs.w, + opts.xanchor); + yf = dragElement.align(y0+dy+lh, -lh, gs.t+gs.h, gs.t, + opts.yanchor); + + var csr = dragElement.getCursor(xf, yf, + opts.xanchor, opts.yanchor); + setCursor(legend, csr); + }, + doneFn: function(dragged) { + setCursor(legend); + if(dragged && xf !== undefined && yf !== undefined) { + Plotly.relayout(gd, {'legend.x': xf, 'legend.y': yf}); + } } + }); + } + + function computeLegendPosition() { + 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; + } + else if(anchorUtils.isCenterAnchor(opts)) { + lx -= opts.width / 2; } - }); - } -}; -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) { - computeLegendDimensions(gd, traces); - expandMargin(gd); + if(anchorUtils.isBottomAnchor(opts)) { + ly -= opts.height; + } + else if(anchorUtils.isMiddleAnchor(opts)) { + ly -= opts.height / 2; } - }); - 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); - }); + // Make sure the legend top and bottom are visible + // (legends with a scroll bar are not allowed to stretch beyond the + // extended margins) + legendHeight = opts.height; + + if(legendHeight > gs.h) { + ly = gs.t; + legendHeight = gs.h; + } + else { + if(ly > fullLayout.height) { + ly = fullLayout.height - legendHeight; + } + if(ly < 0) ly = 0; + legendHeight = Math.min(fullLayout.height - ly, opts.height); + } + } + } - else text.call(textLayout); -} +}; -function computeLegendDimensions(gd, traces) { +function computeLegendSize(gd, traces) { var fullLayout = gd._fullLayout, opts = fullLayout.legend, borderwidth = opts.borderwidth; @@ -435,7 +485,8 @@ function computeLegendDimensions(gd, traces) { var mathjaxBB = Drawing.bBox(mathjaxGroup.node()); tHeight = mathjaxBB.height; tWidth = mathjaxBB.width; - mathjaxGroup.attr('transform','translate(0,' + (tHeight / 4) + ')'); + mathjaxGroup.attr('transform', + 'translate(0,' + (tHeight / 4) + ')'); } else { // approximation to height offset to center the font From 35de775f04ba3b6e5e81d5533701034a84be3534 Mon Sep 17 00:00:00 2001 From: Nicolas Riesco Date: Tue, 26 Apr 2016 13:39:28 +0100 Subject: [PATCH 2/2] Move clip-path from legend to scrollbox * Moved clip-path from legend to scrollbox, to ensure the scrollbox doesn't overlap with the legend border. * Some jasmine tests failed because they used the legend clip-path to determine the legend height. Added attribute `data-height` to legend to help these jasmine tests. * The baseline image for `legend_scroll.json` needed updating because the scrollbox in that mock overlapped with the legend border. --- src/components/legend/draw.js | 29 +++++++++++++---------- test/image/baselines/legend_scroll.png | Bin 54601 -> 54479 bytes test/jasmine/tests/legend_scroll_test.js | 15 +++++++----- 3 files changed, 26 insertions(+), 18 deletions(-) diff --git a/src/components/legend/draw.js b/src/components/legend/draw.js index 828d9165b3f..99c271d0c20 100644 --- a/src/components/legend/draw.js +++ b/src/components/legend/draw.js @@ -277,8 +277,9 @@ module.exports = function draw(gd) { // legend, background and border, scroll box and scroll bar legend.attr('transform', 'translate(' + lx + ',' + ly + ')'); - // increase the background and clip-path width - // by the scrollbar width and margin + // This attribute is only needed to help the jasmine tests + legend.attr('data-height', legendHeight); + bg.attr({ width: opts.width - 2 * opts.borderwidth, height: legendHeight - 2 * opts.borderwidth, @@ -286,17 +287,17 @@ module.exports = function draw(gd) { y: opts.borderwidth }); + var dataScroll = scrollBox.attr('data-scroll') || 0; + scrollBox.attr('transform', 'translate(0, ' + dataScroll + ')'); + clipPath.select('rect').attr({ - width: opts.width, - height: legendHeight, - x: 0, - y: 0 + width: opts.width - 2 * opts.borderwidth, + height: legendHeight - 2 * opts.borderwidth, + x: opts.borderwidth, + y: opts.borderwidth - dataScroll }); - legend.call(Drawing.setClipUrl, clipId); - - var dataScroll = scrollBox.attr('data-scroll') || 0; - scrollBox.attr('transform', 'translate(0, ' + dataScroll + ')'); + scrollBox.call(Drawing.setClipUrl, clipId); if(opts.height - legendHeight > 0 && !gd._context.staticPlot) { // Show the scrollbar only if needed and requested @@ -314,7 +315,8 @@ module.exports = function draw(gd) { }); clipPath.select('rect').attr({ - width: opts.width + + width: opts.width - + 2 * opts.borderwidth + constants.scrollBarWidth + constants.scrollBarMargin }); @@ -366,11 +368,14 @@ module.exports = function draw(gd) { scrollBox.attr('transform', 'translate(0, ' + scrollBoxY + ')'); scrollBar.call( Drawing.setRect, - opts.width - 2 * opts.borderwidth, + opts.width, scrollBarY, constants.scrollBarWidth, constants.scrollBarHeight ); + clipPath.select('rect').attr({ + y: opts.borderwidth - scrollBoxY + }); } } diff --git a/test/image/baselines/legend_scroll.png b/test/image/baselines/legend_scroll.png index b26cd5db0bc39dce0251b40cd975a90cf5479187..c2543d1743845fb8d0d24ee5ce2e31b869ebeb7b 100644 GIT binary patch delta 8090 zcmch6XH=8hwzeQjJ0L}1OA{#~ARtH+1XOD1y-Gl(NDzXd1%kXr6hx#q0RicqC=hB0 zD2mdhgwTo7B!CGehR)4Cd+)QyxZ{53$GzXZ`H_*aM%Fv$eCD&}eCE5lD^3oUpL`pE z1`vYBAzsfC^~4{D?#Hvry}3$X@chyn`b`cYuZ8XnW<{p?fs1NPwQ=kP^jG6WU--M9 zIHhMH*7=xTy+DM8QI*6)44NeBV(V3A}H%@})%)jqwukZu>Bo2Gmof{Y2 zYZ&lBvQC4_w@@TvAggYeo~S0*^It|XB!JmFx53n$jmhlPm#+Y?8IPe=fE&(+ZV(ic zvPPITR5qwBP^CwCoZ*1W#(c2isOb<$?@EOwny#vJV9vV=xxM$R>przd2<7$=$|f#3Ev;CG^!9TU0IEyx zzt!-h!#Q0uJYFcf!)GR+0qi3-&q9y7{#rvfeIZ0j)Y@3VP%yOqQ}b4rlYUD*i%=j)^T#@Nq}{f)}dv{<dob9QaSGWODDCG|hkf~K*v(g0Z-w5(yG+!c5s7ap zWX6o?ck$R>As->;jFY!#AezyXdjKNzBDH3usb6wcg&3H8SqftYBN#$7-$81ZRnQ-lVp6t zmjZa1FvZ~pSEBJh3{C~Waij#{YrJZn3wlEn%pCB+0o2`JTHDoMegYHf)DW>{NzdlHHAx`GcNXfm~|e{e)EPE910*}BqckGZ18W{Q3|#@tS*A^ z0LtfM&dm*5)ox@TFipVVuD4@Bz4N_%-K-49HU4Hk-;OJ@n+5$IVjqCMXqIQR4I6_O z-9h1g{+X3<^jey{@!i4Ll+p3)F;We=l`I&qX?VT=ec=)%2XMKG_girmV>+bj<(K-5 z7s&hywzUPFQ+hUU-oAU(SW&%jUVc}+_UgJB0o}DY^ug;2P(kQerNXxhZ?i7fpN5Pj zMQ#LrDqVi3|ALfyts2r@^+BLfFrgvrAo;8zKEUpzFe;pc3gQ3SmQ-038xos4Pwa1Z zP|Sd)>`a?%@K?2_O%)}Dzj6y;If=~Za2Dx^l&k_uR$my=qQ7G-RVQ_WfM&Ln>S4y{ z&vY=MIIV$+v(Kc+OS-xsr@+?{MU}aOPFET!7TLkcS@S7^(0cZU_u2J#S&p=w!okbN zZhFLo<1Gov{HPsK43|Z(F7a9wc*6j`ok#RJq$Y9hEy*emQbxq&K`Eo3^l~G^6S~_E z)?=|hiHAl@dgd~F>Vm4(j={n--;4?90~IMtfacKhVgZC66{}uZR?Yw}1dGv7OEVpk zLfZE`+OF0LXQ+W>98k8X5hC-^;ZchPnD6459Blp3l)psC&x&!rR(02ax>C#_$`k87D4%Lg}WZjMfTql zdya2+^I(tJ;Zxqrpn~y~ciV-ol$3Xy3p;0AyJK#nB7S?1vg+IsEamN5Sf;@Qk*f@Q z>f;~33W})-zzX(Ww+alfGrh!du=TGhh+_s z6Xl3?gUiCFSC6!Gu6fGu#`mg-B+ZrnkF6evD_?mTZUa1lwgfixVVH~8 zg#UxQhuKURPn)V?j>#AU)@&E;ytz?5cGB^uDhFMMVx%qqOZoIGXSv{=j~#wSi;($~ z@_Lb~bKHWOxCEH&Yf%y91>G$z*uj&U0(~&Wu#z9yzumo**9Uc||KJO5jiRK~+Oek< z%}=2N>C@sjk-8TJ@lzc5XOZ`KwL={ui9!Ue@QFVB>R`WaBss2bEc;}S*@zc1&1rZa~>a<_{ zOc=X1XEKQaOb`)m|oy-+;^9xx%1D<-RPaA2mj&{O(Fq zmp+km;sb#n;Q59|u z1YFzO0pJ~$6&x24Oz}%Xh-zON(g;3Dnw?ec21*{a8wNjhT9R&>P6K~dF7VgH|2C1u zF>5(yEnj+|j$;vCtVqXh_Uv%W6wPkP7A#>d8bz@;Kn5zaxCLOVdruAVmOMZj7hHD# zafc7cCioOB6z4zaDvcnpG0-b6y;s=$IAU&i>+UTFa9vL1gMCOgX7hs#?9MzZQ0y_4 zEgU@hsLkU!qeK{pJujqU^=D{~b$Ng_k%sVHC=Np8F>!5=pC#b+tlYPZ!+l*UvhmIHVD5idfL3YcBD9?#5c_W zi2qpOfjT(V>$)3I6kHeu`zVmFQ(fw|ZdW;fROsW{jULjXhu*iOG0VmK)bW7e>{kmK zwZJ$2;Mbd4MgU9^jh?=yThgCtr}Uwc9;)vY_42o?w}@k?N}*qCvtnonKDmkiq55=% z$aVOR+qH1=+m}KLc3q}Il5G6SiS^vxdujq>eu-!7!bJ)t6Vfr5>n>Lp@-GY*n8qg7 z#DZPKH)1wu2~K@y?LIgAC^;`rUfBnis&RVe0v}rb5U_TjhdRhjz_gf<_F?-MdsTpW z4l*&HF5keEaqYI)!!TLANetD4=b-#ZyJ=u`BfBO|o*Dt2&NLI2y}mrD)%Dr_+3%+{ z|3m_B1Eo4(=`M|XOU)c`pQYSHsCFA}6PY6nG+z1;o>$i!%LD*{)~s9aDSq{A~^S^#W|j%wJx4^_`-4cnQnt4LVeWf+)hPhA`})!%?1jDMag!A zPeugvNBM+~?Aaxw-r%$Hm$t59i0<5G;}Gl2v)pi5M`q0LozP#0AQXnT(1ba`N3ybp zzk0cy_YU#a@O`uN15257-dxz0q=Zk&t3+HlkKNB_$H?%a5(}dfa#cQPtap2d;hy!X zgFP31gBW#|YiMa~IIaW&k0e*%rUvw=qzZu-Oc=ek?MH90MaDsv#sN7koNyWP-HT+W z?=%l$dTMC(#hWG@X!;5%Ku;>9YF8}JH(<-q5&fm7!9ksqT>x~H&OUB*AWP9077#$e zLqPAC&zL7NU%hD9cABy{P6ddx%rd@Bkj;g z`ii}86sbuYLGg)!t~iU zy)A&ZyL&q&EG??L?&4~@{T9Mr-c{Do(NTSy9r56mlF!8D1!X>I^1!PrI#%tVv@d0b zpqiA+K-2M=VNB4L70QtwRSjJ>vbwEzl7vL#Cy9>(E|4c5^?#^!zko^#xZ_yc_v6y8 zeRfm>mhsiF1q&Y%zo`!6g4=`n)vHp2Yk`Ft)!v_x(Xvxk4g{aMn_v*8qbavTAh*Nk zAK-3QW=t9;@|H?MhtH^whY)))oUH-D6Nni>VMA@Q$>RV+F2B*W}iv*7cxYvSa|9H_4J~j|f+dS& zPEc-Ctk~W^RJ{LHB$WJ~aj{1RmFm%(-o4bH?eYC%jQ+j=&ow4YaOYfO+Rkz1| z*{bH>zklDJD+jc*t38iyt{_Hd6|6EUmR+JF7W~Q@!bcmD7%<>@&%NYiiPDDMnYw5n z@t)Ro9c{Ma`jnCylJ`8%^R?Sn&Ih?n^LVmOKYYR#XitCm2>c+Z8!|rJ1m?j`mN!=K z39TYx?E54qpmD3>d7o$8Rs}sQukUt{-KMF)4TPa`?38}-hsQmL64-Yypk`r#u)mr{Q2jD{i8IWuo(V_iZefF+i&L{ zM8tbH12y{wz6g9&b)*1qh}v$_Z5XWW4%+b7d&7w>Q0>Du7R-6H&)wUu!jFPx-kB;= zr%GQ=Eeuk=4Db*ib%yVkx!hwOZ;BYkDTxV?u1b?@e1*ct6T3^6$bQp-r66z1{@ShR zo_9=Jd9wNJT7f?-<^vQz_?QHZJ!6=yyLdVDF_4;iRi}U>v z3rPkGKU+!Zzt*RscOM9{(=l+|I6=oSEq#L^+ny^%j+)q;PtZ;u)Y>=6g^TVygf4flaJmp-c+-ExP|4&m)A{H?WOqfOdIfmIQ&ZOyP>s-XoYoRN}8QV!)TU*7z z@sT2M6dse1aAs>lP4Ex@n3`-m*pMN;K!ag+?37`kN#L_9q+^eE6CqXG7V1yAwmpjU zP!viPkY#msxzsLvXbZtt%L3+_&U@E`=%KG#&1Z^K;AFQ@0+R8Co%hc6h`Gn!_R1YC z?r0ra6u*a}%KB7>d|Gi+9O|3}hn?gas*QpZKR$h?=aHpwCO%0rH{uo-7xxF4i3a1u zFPuu~;xqe&4_$mly$!O)46`m8!QE@^ed(Nq28Nv%#}Zs5$&*QdYSl~|qR}K<#gtu0 zU7SroWpqSEKkMAdsI)*FYSob8^IAtxB8Bi62HjwWoTZGgC0%q z))LokVK+Nji}b_U&S~GnnD8ru{8oTi%uyp=LPN&cvp-M5#}1XW2$kh?jW_2SG)_&r zM;ig`0krRIy(2FMb7MHZ6a<@b&)hx`Z{zQ)eb(!~9_=GRIAfSF6=pby)in3%nmtc7 zqBIecYZdjrf1fvEm_5hsY1o)MvrrqKagL%z8w+DZ|HwJV4G|1|K0WlOLG*(hap)@Z z#`zwpX7eih0*y@hy7FK0 z!ku#(cdK4ztDEsFK}5VIhqoW9RLzK-uiCkxZ!Rnw8XDT-`9(*kNH3fBg6n>PJs&9i zZg@LysM8sR1h}^e5LjZoGMNiV4FrP4`;( z@1+N{C6CqKp}+V2{wSXzI$|z>ov8DQ4eRr_u}Pf{!n?qFWgA~N;8o|s#m;CSyseR% z=9P64fB5hr&-ACCYLD#f(oJ&IZBwb%=4nTPJRz8@P2XZg=eKUi%Z|0W>9Wqen+p{s zUW2(@=gv89lkRoBOsYg-TfzlaKh%|b1kZ+VnL4(Gp(7^m&UeL8s9`VwR?F&Yx~nn~ zpQ9KtAEzoVDtb$`)_bHxF<_~0)Audw;1YFzaVBIAH@BaenVFoJC_J@@g+o?b;5;9&8$)(+Y=YhD$w+hp26$wFjLddblO#=Fdjxx&_%2m7h`wN-C z;yqa>&7k$8eUkjU(ml5QosD@I4WicLic8D(Z}cT>9)IcY$6;TRCs!X2Y9HU$-fMx* zVfW(XM;k&&U?B8pcRBQOgXUx+ZVotZi`e@<-5k*AUtbaQ{OW{hmJ-OXjbbMhHZfrK zhF11AsqM!u0~#buorVR^v@@Gu(9dbdmf-as#KE<(V+L?6d)sm@VmsS8M?EO8n1xT; zrtJK{CsoQ+J-FO69EYYN)lHrlaI z%l3+89!*#f6Bbb|MCoSgEP{gW@icea-CQhqlFhH+RVV3xvGv`(&oA~qnYiSkA-Lq5 zk*7iXzt-1t-n_8qW9KhRSizlDPzj(@wyK$p9PN0#C$FH8M1==v+1t=&^%ieJ2U5{% zKoB^zOqy))n~~mH>QC{?d(`kT7b2WCxI994cMkhVG2#Gjd8gUB)U2k7Lt00S88d)* zFHNoxt4s={B!%ni>kpmTzS9|XP1~Di+S;@|^kCz2&?`4~R2yqYkDR2W!Qh9k+xh$+76Ia`coa%T^}+ZXD2EztIvVZ7&>%_9%91{#xt+L=N`{5e-a0IQy@+hw z83K-Wy(~w9z=(C_Wy$Khh+MUYx1wh*=tN!FykAGk886RkC8j*L_}00!Qfbl*uy{;0 zLh&SXVoLXEHh!B-yl^~U#p%it^E^3P5vZGn&&PRP7bk6e$JsE5Uu#3{Ulh*Z`fkU)sLZy- zNz1nH_Cx!E3yfIR(AHuYyej~--45tiHk>>QU&{2NT_+sWx^bK7+SS~a(g58M+NDvx4ciQ%6|CWWo#+B7HtQ0Tq4va2pFdA&c=J@+ z6)qbj2;+L2%1sXyEA7r%jQH`ZJaG7=wAMb2YCm$CMEgjBQTC`|i=EhX{>4RFJYF8N zw2}F%21H5{;0eu$uT`DMU=4h*} z9p^^BM}F-)b>Tr?wLYU|N!W*0arzY!`4>%Ii9j@g0CakD3&^4RX(TF$vCLdBEq_nf z(xs5-Svhz3Gvol~X<8~XDsKnrFzOew*A{3EA<2czhL%ROFKfH_~Mq-erTj#Gr(QdD^V;-5N6U->^l_kVKU{sw6W Z*gL@z%mHg~>^edFfb`6CYi_wc`470tA>9A~ delta 8213 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