diff --git a/src/components/drawing/index.js b/src/components/drawing/index.js index 5cf67cf8dbc..fa995b0641e 100644 --- a/src/components/drawing/index.js +++ b/src/components/drawing/index.js @@ -580,7 +580,7 @@ drawing.setClipUrl = function(s, localId) { drawing.getTranslate = function(element) { // Note the separator [^\d] between x and y in this regex // We generally use ',' but IE will convert it to ' ' - var re = /.*\btranslate\((\d*\.?\d*)[^\d]*(\d*\.?\d*)[^\d].*/, + var re = /.*\btranslate\((-?\d*\.?\d*)[^-\d]*(-?\d*\.?\d*)[^\d].*/, getter = element.attr ? 'attr' : 'getAttribute', transform = element[getter]('transform') || ''; diff --git a/src/components/updatemenus/draw.js b/src/components/updatemenus/draw.js index e5996da3dad..3c9c30968b2 100644 --- a/src/components/updatemenus/draw.js +++ b/src/components/updatemenus/draw.js @@ -18,6 +18,7 @@ var svgTextUtils = require('../../lib/svg_text_utils'); var anchorUtils = require('../legend/anchor_utils'); var constants = require('./constants'); +var ScrollBox = require('./scrollbox'); module.exports = function draw(gd) { var fullLayout = gd._fullLayout, @@ -82,16 +83,23 @@ module.exports = function draw(gd) { .classed(constants.dropdownButtonGroupClassName, true) .style('pointer-events', 'all'); - // whenever we add new menu, attach 'state' variable to node - // to keep track of the active menu ('-1' means no menu is active) - // and remove all dropped buttons (if any) + // find dimensions before plotting anything (this mutates menuOpts) + for(var i = 0; i < menuData.length; i++) { + var menuOpts = menuData[i]; + findDimensions(gd, menuOpts); + } + + // setup scrollbox + var scrollBoxId = 'updatemenus' + fullLayout._uid, + scrollBox = new ScrollBox(gd, gButton, scrollBoxId); + + // remove exiting header, remove dropped buttons and reset margins if(headerGroups.enter().size()) { gButton .call(removeAllButtons) .attr(constants.menuIndexAttrName, '-1'); } - // remove exiting header, remove dropped buttons and reset margins headerGroups.exit().each(function(menuOpts) { d3.select(this).remove(); @@ -102,30 +110,24 @@ module.exports = function draw(gd) { Plots.autoMargin(gd, constants.autoMarginIdRoot + menuOpts._index); }); - // find dimensions before plotting anything (this mutates menuOpts) - for(var i = 0; i < menuData.length; i++) { - var menuOpts = menuData[i]; - findDimensions(gd, menuOpts); - } - // draw headers! headerGroups.each(function(menuOpts) { var gHeader = d3.select(this); var _gButton = menuOpts.type === 'dropdown' ? gButton : null; Plots.manageCommandObserver(gd, menuOpts, menuOpts.buttons, function(data) { - setActive(gd, menuOpts, menuOpts.buttons[data.index], gHeader, _gButton, data.index, true); + setActive(gd, menuOpts, menuOpts.buttons[data.index], gHeader, _gButton, scrollBox, data.index, true); }); if(menuOpts.type === 'dropdown') { - drawHeader(gd, gHeader, gButton, menuOpts); + drawHeader(gd, gHeader, gButton, scrollBox, menuOpts); - // update buttons if they are dropped - if(areMenuButtonsDropped(gButton, menuOpts)) { - drawButtons(gd, gHeader, gButton, menuOpts); + // if this menu is active, update the dropdown container + if(isActive(gButton, menuOpts)) { + drawButtons(gd, gHeader, gButton, scrollBox, menuOpts); } } else { - drawButtons(gd, gHeader, null, menuOpts); + drawButtons(gd, gHeader, null, null, menuOpts); } }); @@ -150,18 +152,40 @@ function makeMenuData(fullLayout) { // Note that '_index' is set at the default step, // it corresponds to the menu index in the user layout update menu container. -// This is a more 'consistent' field than e.g. the index in the menuData. -function keyFunction(opts) { - return opts._index; +// Because a menu can b set invisible, +// this is a more 'consistent' field than the index in the menuData. +function keyFunction(menuOpts) { + return menuOpts._index; } -function areMenuButtonsDropped(gButton, menuOpts) { - var droppedIndex = +gButton.attr(constants.menuIndexAttrName); +function isFolded(gButton) { + return +gButton.attr(constants.menuIndexAttrName) === -1; +} - return droppedIndex === menuOpts._index; +function isActive(gButton, menuOpts) { + return +gButton.attr(constants.menuIndexAttrName) === menuOpts._index; } -function drawHeader(gd, gHeader, gButton, menuOpts) { +function setActive(gd, menuOpts, buttonOpts, gHeader, gButton, scrollBox, buttonIndex, isSilentUpdate) { + // update 'active' attribute in menuOpts + menuOpts._input.active = menuOpts.active = buttonIndex; + + if(menuOpts.type === 'buttons') { + drawButtons(gd, gHeader, null, null, menuOpts); + } + else if(menuOpts.type === 'dropdown') { + // fold up buttons and redraw header + gButton.attr(constants.menuIndexAttrName, '-1'); + + drawHeader(gd, gHeader, gButton, scrollBox, menuOpts); + + if(!isSilentUpdate) { + drawButtons(gd, gHeader, gButton, scrollBox, menuOpts); + } + } +} + +function drawHeader(gd, gHeader, gButton, scrollBox, menuOpts) { var header = gHeader.selectAll('g.' + constants.headerClassName) .data([0]); @@ -200,14 +224,17 @@ function drawHeader(gd, gHeader, gButton, menuOpts) { header.on('click', function() { gButton.call(removeAllButtons); - // if clicked index is same as dropped index => fold - // otherwise => drop buttons associated with header + + // if this menu is active, fold the dropdown container + // otherwise, make this menu active gButton.attr( constants.menuIndexAttrName, - areMenuButtonsDropped(gButton, menuOpts) ? '-1' : String(menuOpts._index) + isActive(gButton, menuOpts) ? + -1 : + String(menuOpts._index) ); - drawButtons(gd, gHeader, gButton, menuOpts); + drawButtons(gd, gHeader, gButton, scrollBox, menuOpts); }); header.on('mouseover', function() { @@ -222,7 +249,7 @@ function drawHeader(gd, gHeader, gButton, menuOpts) { Drawing.setTranslate(gHeader, menuOpts.lx, menuOpts.ly); } -function drawButtons(gd, gHeader, gButton, menuOpts) { +function drawButtons(gd, gHeader, gButton, scrollBox, menuOpts) { // If this is a set of buttons, set pointer events = all since we play // some minor games with which container is which in order to simplify // the drawing of *either* buttons or menus @@ -231,7 +258,7 @@ function drawButtons(gd, gHeader, gButton, menuOpts) { gButton.attr('pointer-events', 'all'); } - var buttonData = (gButton.attr(constants.menuIndexAttrName) !== '-1' || menuOpts.type === 'buttons') ? + var buttonData = (!isFolded(gButton) || menuOpts.type === 'buttons') ? menuOpts.buttons : []; @@ -257,7 +284,6 @@ function drawButtons(gd, gHeader, gButton, menuOpts) { exit.remove(); } - var x0 = 0; var y0 = 0; @@ -280,13 +306,18 @@ function drawButtons(gd, gHeader, gButton, menuOpts) { } var posOpts = { - x: x0 + menuOpts.pad.l, - y: y0 + menuOpts.pad.t, + x: menuOpts.lx + x0 + menuOpts.pad.l, + y: menuOpts.ly + y0 + menuOpts.pad.t, yPad: constants.gapButton, xPad: constants.gapButton, index: 0, }; + var scrollBoxPosition = { + l: posOpts.x + menuOpts.borderwidth, + t: posOpts.y + menuOpts.borderwidth + }; + buttons.each(function(buttonOpts, buttonIndex) { var button = d3.select(this); @@ -295,7 +326,10 @@ function drawButtons(gd, gHeader, gButton, menuOpts) { .call(setItemPosition, menuOpts, posOpts); button.on('click', function() { - setActive(gd, menuOpts, buttonOpts, gHeader, gButton, buttonIndex); + // skip `dragend` events + if(d3.event.defaultPrevented) return; + + setActive(gd, menuOpts, buttonOpts, gHeader, gButton, scrollBox, buttonIndex); Plots.executeAPICommand(gd, buttonOpts.method, buttonOpts.args); @@ -314,23 +348,87 @@ function drawButtons(gd, gHeader, gButton, menuOpts) { buttons.call(styleButtons, menuOpts); - // translate button group - Drawing.setTranslate(gButton, menuOpts.lx, menuOpts.ly); + if(isVertical) { + scrollBoxPosition.w = Math.max(menuOpts.openWidth, menuOpts.headerWidth); + scrollBoxPosition.h = posOpts.y - scrollBoxPosition.t; + } + else { + scrollBoxPosition.w = posOpts.x - scrollBoxPosition.l; + scrollBoxPosition.h = Math.max(menuOpts.openHeight, menuOpts.headerHeight); + } + + scrollBoxPosition.direction = menuOpts.direction; + + if(scrollBox) { + if(buttons.size()) { + drawScrollBox(gd, gHeader, gButton, scrollBox, menuOpts, scrollBoxPosition); + } + else { + hideScrollBox(scrollBox); + } + } } -function setActive(gd, menuOpts, buttonOpts, gHeader, gButton, buttonIndex, isSilentUpdate) { - // update 'active' attribute in menuOpts - menuOpts._input.active = menuOpts.active = buttonIndex; +function drawScrollBox(gd, gHeader, gButton, scrollBox, menuOpts, position) { + // enable the scrollbox + var direction = menuOpts.direction, + isVertical = (direction === 'up' || direction === 'down'); - if(menuOpts.type === 'dropdown') { - // fold up buttons and redraw header - gButton.attr(constants.menuIndexAttrName, '-1'); + var active = menuOpts.active, + translateX, translateY, + i; + if(isVertical) { + translateY = 0; + for(i = 0; i < active; i++) { + translateY += menuOpts.heights[i] + constants.gapButton; + } + } + else { + translateX = 0; + for(i = 0; i < active; i++) { + translateX += menuOpts.widths[i] + constants.gapButton; + } + } - drawHeader(gd, gHeader, gButton, menuOpts); + scrollBox.enable(position, translateX, translateY); + + if(scrollBox.hbar) { + scrollBox.hbar + .attr('opacity', '0') + .transition() + .attr('opacity', '1'); } - if(!isSilentUpdate || menuOpts.type === 'buttons') { - drawButtons(gd, gHeader, gButton, menuOpts); + if(scrollBox.vbar) { + scrollBox.vbar + .attr('opacity', '0') + .transition() + .attr('opacity', '1'); + } +} + +function hideScrollBox(scrollBox) { + var hasHBar = !!scrollBox.hbar, + hasVBar = !!scrollBox.vbar; + + if(hasHBar) { + scrollBox.hbar + .transition() + .attr('opacity', '0') + .each('end', function() { + hasHBar = false; + if(!hasVBar) scrollBox.disable(); + }); + } + + if(hasVBar) { + scrollBox.vbar + .transition() + .attr('opacity', '0') + .each('end', function() { + hasVBar = false; + if(!hasHBar) scrollBox.disable(); + }); } } diff --git a/src/components/updatemenus/scrollbox.js b/src/components/updatemenus/scrollbox.js new file mode 100644 index 00000000000..6c3431536cf --- /dev/null +++ b/src/components/updatemenus/scrollbox.js @@ -0,0 +1,469 @@ +/** +* Copyright 2012-2017, 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'; + +module.exports = ScrollBox; + +var d3 = require('d3'); + +var Color = require('../color'); +var Drawing = require('../drawing'); + +var Lib = require('../../lib'); + +/** + * Helper class to setup a scroll box + * + * @class + * @param gd Plotly's graph div + * @param container Container to be scroll-boxed (as a D3 selection) + * @param {string} id Id for the clip path to implement the scroll box + */ +function ScrollBox(gd, container, id) { + this.gd = gd; + this.container = container; + this.id = id; + + // See ScrollBox.prototype.enable for further definition + this.position = null; // scrollbox position + this.translateX = null; // scrollbox horizontal translation + this.translateY = null; // scrollbox vertical translation + this.hbar = null; // horizontal scrollbar D3 selection + this.vbar = null; // vertical scrollbar D3 selection + + // element to capture pointer events + this.bg = this.container.selectAll('rect.scrollbox-bg').data([0]); + + this.bg.exit() + .on('.drag', null) + .on('wheel', null) + .remove(); + + this.bg.enter().append('rect') + .classed('scrollbox-bg', true) + .style('pointer-events', 'all') + .attr({ + opacity: 0, + x: 0, + y: 0, + width: 0, + height: 0 + }); +} + +// scroll bar dimensions +ScrollBox.barWidth = 2; +ScrollBox.barLength = 20; +ScrollBox.barRadius = 2; +ScrollBox.barPad = 1; +ScrollBox.barColor = '#808BA4'; + +/** + * If needed, setup a clip path and scrollbars + * + * @method + * @param {Object} position + * @param {number} position.l Left side position (in pixels) + * @param {number} position.t Top side (in pixels) + * @param {number} position.w Width (in pixels) + * @param {number} position.h Height (in pixels) + * @param {string} [position.direction='down'] + * Either 'down', 'left', 'right' or 'up' + * @param {number} [translateX=0] Horizontal offset (in pixels) + * @param {number} [translateY=0] Vertical offset (in pixels) + */ +ScrollBox.prototype.enable = function enable(position, translateX, translateY) { + var fullLayout = this.gd._fullLayout, + fullWidth = fullLayout.width, + fullHeight = fullLayout.height; + + // compute position of scrollbox + this.position = position; + + var l = this.position.l, + w = this.position.w, + t = this.position.t, + h = this.position.h, + direction = this.position.direction, + isDown = (direction === 'down'), + isLeft = (direction === 'left'), + isRight = (direction === 'right'), + isUp = (direction === 'up'), + boxW = w, + boxH = h, + boxL, boxR, + boxT, boxB; + + if(!isDown && !isLeft && !isRight && !isUp) { + this.position.direction = 'down'; + isDown = true; + } + + var isVertical = isDown || isUp; + if(isVertical) { + boxL = l; + boxR = boxL + boxW; + + if(isDown) { + // anchor to top side + boxT = t; + boxB = Math.min(boxT + boxH, fullHeight); + boxH = boxB - boxT; + } + else { + // anchor to bottom side + boxB = t + boxH; + boxT = Math.max(boxB - boxH, 0); + boxH = boxB - boxT; + } + } + else { + boxT = t; + boxB = boxT + boxH; + + if(isLeft) { + // anchor to right side + boxR = l + boxW; + boxL = Math.max(boxR - boxW, 0); + boxW = boxR - boxL; + } + else { + // anchor to left side + boxL = l; + boxR = Math.min(boxL + boxW, fullWidth); + boxW = boxR - boxL; + } + } + + this._box = { + l: boxL, + t: boxT, + w: boxW, + h: boxH + }; + + // compute position of horizontal scroll bar + var needsHorizontalScrollBar = (w > boxW), + hbarW = ScrollBox.barLength + 2 * ScrollBox.barPad, + hbarH = ScrollBox.barWidth + 2 * ScrollBox.barPad, + // draw horizontal scrollbar on the bottom side + hbarL = l, + hbarT = t + h; + + if(hbarT + hbarH > fullHeight) hbarT = fullHeight - hbarH; + + var hbar = this.container.selectAll('rect.scrollbar-horizontal').data( + (needsHorizontalScrollBar) ? [0] : []); + + hbar.exit() + .on('.drag', null) + .remove(); + + hbar.enter().append('rect') + .classed('scrollbar-horizontal', true) + .call(Color.fill, ScrollBox.barColor); + + if(needsHorizontalScrollBar) { + this.hbar = hbar.attr({ + 'rx': ScrollBox.barRadius, + 'ry': ScrollBox.barRadius, + 'x': hbarL, + 'y': hbarT, + 'width': hbarW, + 'height': hbarH + }); + + // hbar center moves between hbarXMin and hbarXMin + hbarTranslateMax + this._hbarXMin = hbarL + hbarW / 2; + this._hbarTranslateMax = boxW - hbarW; + } + else { + delete this.hbar; + delete this._hbarXMin; + delete this._hbarTranslateMax; + } + + // compute position of vertical scroll bar + var needsVerticalScrollBar = (h > boxH), + vbarW = ScrollBox.barWidth + 2 * ScrollBox.barPad, + vbarH = ScrollBox.barLength + 2 * ScrollBox.barPad, + // draw vertical scrollbar on the right side + vbarL = l + w, + vbarT = t; + + if(vbarL + vbarW > fullWidth) vbarL = fullWidth - vbarW; + + var vbar = this.container.selectAll('rect.scrollbar-vertical').data( + (needsVerticalScrollBar) ? [0] : []); + + vbar.exit() + .on('.drag', null) + .remove(); + + vbar.enter().append('rect') + .classed('scrollbar-vertical', true) + .call(Color.fill, ScrollBox.barColor); + + if(needsVerticalScrollBar) { + this.vbar = vbar.attr({ + 'rx': ScrollBox.barRadius, + 'ry': ScrollBox.barRadius, + 'x': vbarL, + 'y': vbarT, + 'width': vbarW, + 'height': vbarH + }); + + // vbar center moves between vbarYMin and vbarYMin + vbarTranslateMax + this._vbarYMin = vbarT + vbarH / 2; + this._vbarTranslateMax = boxH - vbarH; + } + else { + delete this.vbar; + delete this._vbarYMin; + delete this._vbarTranslateMax; + } + + // setup a clip path (if scroll bars are needed) + var clipId = this.id, + clipL = boxL - 0.5, + clipR = (needsVerticalScrollBar) ? boxR + vbarW + 0.5 : boxR + 0.5, + clipT = boxT - 0.5, + clipB = (needsHorizontalScrollBar) ? boxB + hbarH + 0.5 : boxB + 0.5; + + var clipPath = fullLayout._topdefs.selectAll('#' + clipId) + .data((needsHorizontalScrollBar || needsVerticalScrollBar) ? [0] : []); + + clipPath.exit().remove(); + + clipPath.enter() + .append('clipPath').attr('id', clipId) + .append('rect'); + + if(needsHorizontalScrollBar || needsVerticalScrollBar) { + this._clipRect = clipPath.select('rect').attr({ + x: Math.floor(clipL), + y: Math.floor(clipT), + width: Math.ceil(clipR) - Math.floor(clipL), + height: Math.ceil(clipB) - Math.floor(clipT) + }); + + this.container.call(Drawing.setClipUrl, clipId); + + this.bg.attr({ + x: l, + y: t, + width: w, + height: h + }); + } + else { + this.bg.attr({ + width: 0, + height: 0 + }); + this.container + .on('wheel', null) + .on('.drag', null) + .call(Drawing.setClipUrl, null); + delete this._clipRect; + } + + // set up drag listeners (if scroll bars are needed) + if(needsHorizontalScrollBar || needsVerticalScrollBar) { + var onBoxDrag = d3.behavior.drag() + .on('dragstart', function() { + d3.event.sourceEvent.preventDefault(); + }) + .on('drag', this._onBoxDrag.bind(this)); + + this.container + .on('wheel', null) + .on('wheel', this._onBoxWheel.bind(this)) + .on('.drag', null) + .call(onBoxDrag); + + var onBarDrag = d3.behavior.drag() + .on('dragstart', function() { + d3.event.sourceEvent.preventDefault(); + d3.event.sourceEvent.stopPropagation(); + }) + .on('drag', this._onBarDrag.bind(this)); + + if(needsHorizontalScrollBar) { + this.hbar + .on('.drag', null) + .call(onBarDrag); + } + + if(needsVerticalScrollBar) { + this.vbar + .on('.drag', null) + .call(onBarDrag); + } + } + + // set scrollbox translation + this.setTranslate(translateX, translateY); +}; + +/** + * If present, remove clip-path and scrollbars + * + * @method + */ +ScrollBox.prototype.disable = function disable() { + if(this.hbar || this.vbar) { + this.bg.attr({ + width: 0, + height: 0 + }); + this.container + .on('wheel', null) + .on('.drag', null) + .call(Drawing.setClipUrl, null); + delete this._clipRect; + } + + if(this.hbar) { + this.hbar.on('.drag', null); + this.hbar.remove(); + delete this.hbar; + delete this._hbarXMin; + delete this._hbarTranslateMax; + } + + if(this.vbar) { + this.vbar.on('.drag', null); + this.vbar.remove(); + delete this.vbar; + delete this._vbarYMin; + delete this._vbarTranslateMax; + } +}; + +/** + * Handles scroll box drag events + * + * @method + */ +ScrollBox.prototype._onBoxDrag = function onBarDrag() { + var translateX = this.translateX, + translateY = this.translateY; + + if(this.hbar) { + translateX -= d3.event.dx; + } + + if(this.vbar) { + translateY -= d3.event.dy; + } + + this.setTranslate(translateX, translateY); +}; + +/** + * Handles scroll box wheel events + * + * @method + */ +ScrollBox.prototype._onBoxWheel = function onBarWheel() { + var translateX = this.translateX, + translateY = this.translateY; + + if(this.hbar) { + translateX += d3.event.deltaY; + } + + if(this.vbar) { + translateY += d3.event.deltaY; + } + + this.setTranslate(translateX, translateY); +}; + +/** + * Handles scroll bar drag events + * + * @method + */ +ScrollBox.prototype._onBarDrag = function onBarDrag() { + var translateX = this.translateX, + translateY = this.translateY; + + if(this.hbar) { + var xMin = translateX + this._hbarXMin, + xMax = xMin + this._hbarTranslateMax, + x = Lib.constrain(d3.event.x, xMin, xMax), + xf = (x - xMin) / (xMax - xMin); + + var translateXMax = this.position.w - this._box.w; + + translateX = xf * translateXMax; + } + + if(this.vbar) { + var yMin = translateY + this._vbarYMin, + yMax = yMin + this._vbarTranslateMax, + y = Lib.constrain(d3.event.y, yMin, yMax), + yf = (y - yMin) / (yMax - yMin); + + var translateYMax = this.position.h - this._box.h; + + translateY = yf * translateYMax; + } + + this.setTranslate(translateX, translateY); +}; + +/** + * Set clip path and scroll bar translate transform + * + * @method + * @param {number} [translateX=0] Horizontal offset (in pixels) + * @param {number} [translateY=0] Vertical offset (in pixels) + */ +ScrollBox.prototype.setTranslate = function setTranslate(translateX, translateY) { + // store translateX and translateY (needed by mouse event handlers) + var translateXMax = this.position.w - this._box.w, + translateYMax = this.position.h - this._box.h; + + translateX = Lib.constrain(translateX || 0, 0, translateXMax); + translateY = Lib.constrain(translateY || 0, 0, translateYMax); + + this.translateX = translateX; + this.translateY = translateY; + + this.container.call(Drawing.setTranslate, + this._box.l - this.position.l - translateX, + this._box.t - this.position.t - translateY); + + if(this._clipRect) { + this._clipRect.attr({ + x: Math.floor(this.position.l + translateX - 0.5), + y: Math.floor(this.position.t + translateY - 0.5) + }); + } + + if(this.hbar) { + var xf = translateX / translateXMax; + + this.hbar.call(Drawing.setTranslate, + translateX + xf * this._hbarTranslateMax, + translateY); + } + + if(this.vbar) { + var yf = translateY / translateYMax; + + this.vbar.call(Drawing.setTranslate, + translateX, + translateY + yf * this._vbarTranslateMax); + } +}; diff --git a/test/jasmine/tests/cartesian_test.js b/test/jasmine/tests/cartesian_test.js index a4f87c68242..d3977090fd2 100644 --- a/test/jasmine/tests/cartesian_test.js +++ b/test/jasmine/tests/cartesian_test.js @@ -298,7 +298,7 @@ describe('relayout', function() { return Plotly.relayout(gd, 'xaxis.range', [2, 3]); }) .then(function() { - assertPointTranslate([0, 540], [-540, 135]); + assertPointTranslate([-540, 135], [-540, 135]); }) .then(done); }); diff --git a/test/jasmine/tests/drawing_test.js b/test/jasmine/tests/drawing_test.js index 61671060119..3c20e719a1a 100644 --- a/test/jasmine/tests/drawing_test.js +++ b/test/jasmine/tests/drawing_test.js @@ -113,6 +113,42 @@ describe('Drawing', function() { el.attr('transform', 'rotate(20)'); expect(Drawing.getTranslate(el)).toEqual({ x: 0, y: 0 }); }); + + it('should work with negative values', function() { + var el = document.createElement('div'), + el3 = d3.select(document.createElement('div')); + + expect(Drawing.getTranslate(el)).toEqual({ x: 0, y: 0 }); + + var testCases = [ + { transform: 'translate(-123.45px, -67)', x: -123.45, y: -67 }, + { transform: 'translate(-123.45px, 67)', x: -123.45, y: 67 }, + { transform: 'translate(123.45px, -67)', x: 123.45, y: -67 }, + { transform: 'translate(-123.45)', x: -123.45, y: 0 }, + { transform: 'translate(-1 -2)', x: -1, y: -2 }, + { transform: 'translate(-1 2)', x: -1, y: 2 }, + { transform: 'translate(1 -2)', x: 1, y: -2 }, + { transform: 'translate(-1 -2); rotate(20deg)', x: -1, y: -2 }, + { transform: 'translate(-1 2); rotate(20deg)', x: -1, y: 2 }, + { transform: 'translate(1 -2); rotate(20deg)', x: 1, y: -2 }, + { transform: 'rotate(20deg) translate(-1 -2);', x: -1, y: -2 }, + { transform: 'rotate(20deg) translate(-1 2);', x: -1, y: 2 }, + { transform: 'rotate(20deg) translate(1 -2);', x: 1, y: -2 } + ]; + + for(var i = 0; i < testCases.length; i++) { + var testCase = testCases[i], + transform = testCase.transform, + x = testCase.x, + y = testCase.y; + + el.setAttribute('transform', transform); + expect(Drawing.getTranslate(el)).toEqual({ x: x, y: y }); + + el3.attr('transform', transform); + expect(Drawing.getTranslate(el)).toEqual({ x: x, y: y }); + } + }); }); describe('setTranslate', function() { diff --git a/test/jasmine/tests/updatemenus_test.js b/test/jasmine/tests/updatemenus_test.js index a85947445f4..f57635e0d3f 100644 --- a/test/jasmine/tests/updatemenus_test.js +++ b/test/jasmine/tests/updatemenus_test.js @@ -5,10 +5,12 @@ var d3 = require('d3'); var Plotly = require('@lib'); var Lib = require('@src/lib'); var Events = require('@src/lib/events'); +var Drawing = require('@src/components/drawing'); var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); var TRANSITION_DELAY = 100; var fail = require('../assets/fail_test'); +var getBBox = require('../assets/get_bbox'); describe('update menus defaults', function() { 'use strict'; @@ -800,3 +802,406 @@ describe('update menus interaction with other components:', function() { .then(done); }); }); + + +describe('update menus interaction with scrollbox:', function() { + 'use strict'; + + var gd, + mock, + menuDown, + menuLeft, + menuRight, + menuUp; + + // Adapted from https://github.com/plotly/plotly.js/pull/770#issuecomment-234669383 + mock = { + data: [], + layout: { + width: 1100, + height: 450, + updatemenus: [{ + buttons: [{ + method: 'restyle', + args: ['line.color', 'red'], + label: 'red' + }, { + method: 'restyle', + args: ['line.color', 'blue'], + label: 'blue' + }, { + method: 'restyle', + args: ['line.color', 'green'], + label: 'green' + }] + }, { + x: 0.5, + xanchor: 'left', + y: 0.5, + yanchor: 'top', + direction: 'down', + buttons: [] + }, { + x: 0.5, + xanchor: 'right', + y: 0.5, + yanchor: 'top', + direction: 'left', + buttons: [] + }, { + x: 0.5, + xanchor: 'left', + y: 0.5, + yanchor: 'bottom', + direction: 'right', + buttons: [] + }, { + x: 0.5, + xanchor: 'right', + y: 0.5, + yanchor: 'bottom', + direction: 'up', + buttons: [] + }] + } + }; + + for(var i = 0, n = 20; i < n; i++) { + var j; + + var y; + for(j = 0, y = []; j < 10; j++) y.push(Math.random); + + mock.data.push({ + y: y, + line: { + shape: 'spline', + color: 'red' + }, + visible: i === 0, + name: 'Data set ' + i, + }); + + var visible; + for(j = 0, visible = []; j < n; j++) visible.push(i === j); + + for(j = 1; j <= 4; j++) { + mock.layout.updatemenus[j].buttons.push({ + method: 'restyle', + args: ['visible', visible], + label: 'Data set ' + i + }); + } + } + + beforeEach(function(done) { + gd = createGraphDiv(); + + var mockCopy = Lib.extendDeep({}, mock); + + Plotly.plot(gd, mockCopy.data, mockCopy.layout).then(function() { + var menus = document.getElementsByClassName('updatemenu-header'); + + expect(menus.length).toBe(5); + + menuDown = menus[1]; + menuLeft = menus[2]; + menuRight = menus[3]; + menuUp = menus[4]; + }).catch(fail).then(done); + }); + + afterEach(function() { + Plotly.purge(gd); + destroyGraphDiv(); + }); + + it('scrollbox can be dragged', function() { + var deltaX = -50, + deltaY = -100, + scrollBox, + scrollBar, + scrollBoxTranslate0, + scrollBarTranslate0, + scrollBoxTranslate1, + scrollBarTranslate1; + + scrollBox = getScrollBox(); + expect(scrollBox).toBeDefined(); + + // down menu + click(menuDown); + + scrollBar = getVerticalScrollBar(); + expect(scrollBar).toBeDefined(); + + scrollBoxTranslate0 = Drawing.getTranslate(scrollBox); + scrollBarTranslate0 = Drawing.getTranslate(scrollBar); + dragScrollBox(scrollBox, 0, deltaY); + scrollBoxTranslate1 = Drawing.getTranslate(scrollBox); + scrollBarTranslate1 = Drawing.getTranslate(scrollBar); + + expect(scrollBoxTranslate1.y).toEqual(scrollBoxTranslate0.y + deltaY); + expect(scrollBarTranslate1.y).toBeGreaterThan(scrollBarTranslate0.y); + + // left menu + click(menuLeft); + + scrollBar = getHorizontalScrollBar(); + expect(scrollBar).toBeDefined(); + + scrollBoxTranslate0 = Drawing.getTranslate(scrollBox); + scrollBarTranslate0 = Drawing.getTranslate(scrollBar); + dragScrollBox(scrollBox, deltaX, 0); + scrollBoxTranslate1 = Drawing.getTranslate(scrollBox); + scrollBarTranslate1 = Drawing.getTranslate(scrollBar); + + expect(scrollBoxTranslate1.x).toEqual(scrollBoxTranslate0.x + deltaX); + expect(scrollBarTranslate1.x).toBeGreaterThan(scrollBarTranslate0.x); + + // right menu + click(menuRight); + + scrollBar = getHorizontalScrollBar(); + expect(scrollBar).toBeDefined(); + + scrollBoxTranslate0 = Drawing.getTranslate(scrollBox); + scrollBarTranslate0 = Drawing.getTranslate(scrollBar); + dragScrollBox(scrollBox, deltaX, 0); + scrollBoxTranslate1 = Drawing.getTranslate(scrollBox); + scrollBarTranslate1 = Drawing.getTranslate(scrollBar); + + expect(scrollBoxTranslate1.x).toEqual(scrollBoxTranslate0.x + deltaX); + expect(scrollBarTranslate1.x).toBeGreaterThan(scrollBarTranslate0.x); + + // up menu + click(menuUp); + + scrollBar = getVerticalScrollBar(); + expect(scrollBar).toBeDefined(); + + scrollBoxTranslate0 = Drawing.getTranslate(scrollBox); + scrollBarTranslate0 = Drawing.getTranslate(scrollBar); + dragScrollBox(scrollBox, 0, deltaY); + scrollBoxTranslate1 = Drawing.getTranslate(scrollBox); + scrollBarTranslate1 = Drawing.getTranslate(scrollBar); + + expect(scrollBoxTranslate1.y).toEqual(scrollBoxTranslate0.y + deltaY); + expect(scrollBarTranslate1.y).toBeGreaterThan(scrollBarTranslate0.y); + }); + + it('scrollbox handles wheel events', function() { + var deltaY = 100, + scrollBox, + scrollBar, + scrollBoxTranslate0, + scrollBarTranslate0, + scrollBoxTranslate1, + scrollBarTranslate1; + + scrollBox = getScrollBox(); + expect(scrollBox).toBeDefined(); + + // down menu + click(menuDown); + + scrollBar = getVerticalScrollBar(); + expect(scrollBar).toBeDefined(); + + scrollBoxTranslate0 = Drawing.getTranslate(scrollBox); + scrollBarTranslate0 = Drawing.getTranslate(scrollBar); + wheel(scrollBox, deltaY); + scrollBoxTranslate1 = Drawing.getTranslate(scrollBox); + scrollBarTranslate1 = Drawing.getTranslate(scrollBar); + + expect(scrollBoxTranslate1.y).toEqual(scrollBoxTranslate0.y - deltaY); + expect(scrollBarTranslate1.y).toBeGreaterThan(scrollBarTranslate0.y); + + // left menu + click(menuLeft); + + scrollBar = getHorizontalScrollBar(); + expect(scrollBar).toBeDefined(); + + scrollBoxTranslate0 = Drawing.getTranslate(scrollBox); + scrollBarTranslate0 = Drawing.getTranslate(scrollBar); + wheel(scrollBox, deltaY); + scrollBoxTranslate1 = Drawing.getTranslate(scrollBox); + scrollBarTranslate1 = Drawing.getTranslate(scrollBar); + + expect(scrollBoxTranslate1.x).toEqual(scrollBoxTranslate0.x - deltaY); + expect(scrollBarTranslate1.x).toBeGreaterThan(scrollBarTranslate0.x); + + // right menu + click(menuRight); + + scrollBar = getHorizontalScrollBar(); + expect(scrollBar).toBeDefined(); + + scrollBoxTranslate0 = Drawing.getTranslate(scrollBox); + scrollBarTranslate0 = Drawing.getTranslate(scrollBar); + wheel(scrollBox, deltaY); + scrollBoxTranslate1 = Drawing.getTranslate(scrollBox); + scrollBarTranslate1 = Drawing.getTranslate(scrollBar); + + expect(scrollBoxTranslate1.x).toEqual(scrollBoxTranslate0.x - deltaY); + expect(scrollBarTranslate1.x).toBeGreaterThan(scrollBarTranslate0.x); + + // up menu + click(menuUp); + + scrollBar = getVerticalScrollBar(); + expect(scrollBar).toBeDefined(); + + scrollBoxTranslate0 = Drawing.getTranslate(scrollBox); + scrollBarTranslate0 = Drawing.getTranslate(scrollBar); + wheel(scrollBox, deltaY); + scrollBoxTranslate1 = Drawing.getTranslate(scrollBox); + scrollBarTranslate1 = Drawing.getTranslate(scrollBar); + + expect(scrollBoxTranslate1.y).toEqual(scrollBoxTranslate0.y - deltaY); + expect(scrollBarTranslate1.y).toBeGreaterThan(scrollBarTranslate0.y); + }); + + it('scrollbar can be dragged', function() { + var deltaX = 20, + deltaY = 10, + scrollBox, + scrollBar, + scrollBoxPosition0, + scrollBarPosition0, + scrollBoxPosition1, + scrollBarPosition1; + + scrollBox = getScrollBox(); + expect(scrollBox).toBeDefined(); + + // down menu + click(menuDown); + + scrollBar = getVerticalScrollBar(); + expect(scrollBar).toBeDefined(); + + scrollBoxPosition0 = Drawing.getTranslate(scrollBox); + scrollBarPosition0 = getScrollBarCenter(scrollBox, scrollBar); + dragScrollBar(scrollBar, scrollBarPosition0, 0, deltaY); + scrollBoxPosition1 = Drawing.getTranslate(scrollBox); + scrollBarPosition1 = getScrollBarCenter(scrollBox, scrollBar); + + expect(scrollBoxPosition1.y).toBeLessThan(scrollBoxPosition0.y); + expect(scrollBarPosition1.y).toEqual(scrollBarPosition0.y + deltaY); + + // left menu + click(menuLeft); + + scrollBar = getHorizontalScrollBar(); + expect(scrollBar).toBeDefined(); + + scrollBoxPosition0 = Drawing.getTranslate(scrollBox); + scrollBarPosition0 = getScrollBarCenter(scrollBox, scrollBar); + dragScrollBar(scrollBar, scrollBarPosition0, deltaX, 0); + scrollBoxPosition1 = Drawing.getTranslate(scrollBox); + scrollBarPosition1 = getScrollBarCenter(scrollBox, scrollBar); + + expect(scrollBoxPosition1.x).toBeLessThan(scrollBoxPosition0.x); + expect(scrollBarPosition1.x).toEqual(scrollBarPosition0.x + deltaX); + + // right menu + click(menuRight); + + scrollBar = getHorizontalScrollBar(); + expect(scrollBar).toBeDefined(); + + scrollBoxPosition0 = Drawing.getTranslate(scrollBox); + scrollBarPosition0 = getScrollBarCenter(scrollBox, scrollBar); + dragScrollBar(scrollBar, scrollBarPosition0, deltaX, 0); + scrollBoxPosition1 = Drawing.getTranslate(scrollBox); + scrollBarPosition1 = getScrollBarCenter(scrollBox, scrollBar); + + expect(scrollBoxPosition1.x).toBeLessThan(scrollBoxPosition0.x); + expect(scrollBarPosition1.x).toEqual(scrollBarPosition0.x + deltaX); + + // up menu + click(menuUp); + + scrollBar = getVerticalScrollBar(); + expect(scrollBar).toBeDefined(); + + scrollBoxPosition0 = Drawing.getTranslate(scrollBox); + scrollBarPosition0 = getScrollBarCenter(scrollBox, scrollBar); + dragScrollBar(scrollBar, scrollBarPosition0, 0, deltaY); + scrollBoxPosition1 = Drawing.getTranslate(scrollBox); + scrollBarPosition1 = getScrollBarCenter(scrollBox, scrollBar); + + expect(scrollBoxPosition1.y).toBeLessThan(scrollBoxPosition0.y); + expect(scrollBarPosition1.y).toEqual(scrollBarPosition0.y + deltaY); + }); + + function getScrollBox() { + return document.getElementsByClassName('updatemenu-dropdown-button-group')[0]; + } + + function getHorizontalScrollBar() { + return document.getElementsByClassName('scrollbar-horizontal')[0]; + } + + function getVerticalScrollBar() { + return document.getElementsByClassName('scrollbar-vertical')[0]; + } + + function getCenter(node) { + var bbox = getBBox(node), + x = bbox.x + 0.5 * bbox.width, + y = bbox.y + 0.5 * bbox.height; + + return { x: x, y: y }; + } + + function getScrollBarCenter(scrollBox, scrollBar) { + var scrollBoxTranslate = Drawing.getTranslate(scrollBox), + scrollBarTranslate = Drawing.getTranslate(scrollBar), + translateX = scrollBoxTranslate.x + scrollBarTranslate.x, + translateY = scrollBoxTranslate.y + scrollBarTranslate.y, + center = getCenter(scrollBar), + x = center.x + translateX, + y = center.y + translateY; + + return { x: x, y: y }; + } + + function click(node) { + node.dispatchEvent(new MouseEvent('click'), { + bubbles: true + }); + } + + function drag(node, x, y, deltaX, deltaY) { + node.dispatchEvent(new MouseEvent('mousedown', { + bubbles: true, + clientX: x, + clientY: y + })); + node.dispatchEvent(new MouseEvent('mousemove', { + bubbles: true, + clientX: x + deltaX, + clientY: y + deltaY + })); + } + + function dragScrollBox(node, deltaX, deltaY) { + var position = getCenter(node); + + drag(node, position.x, position.y, deltaX, deltaY); + } + + function dragScrollBar(node, position, deltaX, deltaY) { + drag(node, position.x, position.y, deltaX, deltaY); + } + + function wheel(node, deltaY) { + node.dispatchEvent(new WheelEvent('wheel', { + bubbles: true, + deltaY: deltaY + })); + } +});