diff --git a/src/components/color/attributes.js b/src/components/color/attributes.js index fe9b763e80f..76b92497b24 100644 --- a/src/components/color/attributes.js +++ b/src/components/color/attributes.js @@ -29,6 +29,8 @@ exports.lightLine = '#eee'; exports.background = '#fff'; +exports.borderLine = '#BEC8D9'; + // with axis.color and Color.interp we aren't using lightLine // itself anymore, instead interpolating between axis.color // and the background color using tinycolor.mix. lightFraction diff --git a/src/components/updatemenus/attributes.js b/src/components/updatemenus/attributes.js new file mode 100644 index 00000000000..a27157200b1 --- /dev/null +++ b/src/components/updatemenus/attributes.js @@ -0,0 +1,133 @@ +/** +* 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 fontAttrs = require('../../plots/font_attributes'); +var colorAttrs = require('../color/attributes'); +var extendFlat = require('../../lib/extend').extendFlat; + +var buttonsAttrs = { + _isLinkedToArray: true, + + method: { + valType: 'enumerated', + values: ['restyle', 'relayout'], + dflt: 'restyle', + role: 'info', + description: [ + 'Sets the Plotly method to be called on click.' + ].join(' ') + }, + args: { + valType: 'info_array', + role: 'info', + items: [ + { valType: 'any' }, + { valType: 'any' }, + { valType: 'any' } + ], + description: [ + 'Sets the arguments values to be passed to the Plotly', + 'method set in `method` on click.' + ].join(' ') + }, + label: { + valType: 'string', + role: 'info', + dflt: '', + description: 'Sets the text label to appear on the button.' + } +}; + +module.exports = { + _isLinkedToArray: true, + + visible: { + valType: 'boolean', + role: 'info', + description: [ + 'Determines whether or not the update menu is visible.' + ].join(' ') + }, + + active: { + valType: 'integer', + role: 'info', + min: -1, + dflt: 0, + description: [ + 'Determines which button (by index starting from 0) is', + 'considered active.' + ].join(' ') + }, + + buttons: buttonsAttrs, + + x: { + valType: 'number', + min: -2, + max: 3, + dflt: -0.05, + role: 'style', + description: 'Sets the x position (in normalized coordinates) of the update menu.' + }, + xanchor: { + valType: 'enumerated', + values: ['auto', 'left', 'center', 'right'], + dflt: 'right', + role: 'info', + description: [ + 'Sets the update menu\'s horizontal position anchor.', + 'This anchor binds the `x` position to the *left*, *center*', + 'or *right* of the range selector.' + ].join(' ') + }, + y: { + valType: 'number', + min: -2, + max: 3, + dflt: 1, + role: 'style', + description: 'Sets the y position (in normalized coordinates) of the update menu.' + }, + yanchor: { + valType: 'enumerated', + values: ['auto', 'top', 'middle', 'bottom'], + dflt: 'bottom', + role: 'info', + description: [ + 'Sets the update menu\'s vertical position anchor', + 'This anchor binds the `y` position to the *top*, *middle*', + 'or *bottom* of the range selector.' + ].join(' ') + }, + + font: extendFlat({}, fontAttrs, { + description: 'Sets the font of the update menu button text.' + }), + + bgcolor: { + valType: 'color', + role: 'style', + description: 'Sets the background color of the update menu buttons.' + }, + bordercolor: { + valType: 'color', + dflt: colorAttrs.borderLine, + role: 'style', + description: 'Sets the color of the border enclosing the update menu.' + }, + borderwidth: { + valType: 'number', + min: 0, + dflt: 1, + role: 'style', + description: 'Sets the width (in px) of the border enclosing the update menu.' + } +}; diff --git a/src/components/updatemenus/constants.js b/src/components/updatemenus/constants.js new file mode 100644 index 00000000000..d9bfa1a9a11 --- /dev/null +++ b/src/components/updatemenus/constants.js @@ -0,0 +1,73 @@ +/** +* 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'; + + +module.exports = { + + // layout attribute names + name: 'updatemenus', + itemName: 'updatemenu', + + // class names + containerClassName: 'updatemenu-container', + headerGroupClassName: 'updatemenu-header-group', + headerClassName: 'updatemenu-header', + headerArrowClassName: 'updatemenu-header-arrow', + buttonGroupClassName: 'updatemenu-button-group', + buttonClassName: 'updatemenu-button', + itemRectClassName: 'updatemenu-item-rect', + itemTextClassName: 'updatemenu-item-text', + + // DOM attribute name in button group keeping track + // of active update menu + menuIndexAttrName: 'updatemenu-active-index', + + // id root pass to Plots.autoMargin + autoMarginIdRoot: 'updatemenu-', + + // options when 'active: -1' + blankHeaderOpts: { label: ' ' }, + + // min item width / height + minWidth: 30, + minHeight: 30, + + // padding around item text + textPadX: 40, + + // font size to height scale + fontSizeToHeight: 1.3, + + // item rect radii + rx: 2, + ry: 2, + + // item text x offset off left edge + textOffsetX: 12, + + // item text y offset (w.r.t. middle) + textOffsetY: 3, + + // arrow offset off right edge + arrowOffsetX: 4, + + // gap between header and buttons + gapButtonHeader: 5, + + // gap between between buttons + gapButton: 2, + + // color given to active buttons + activeColor: '#F4FAFF', + + // color given to hovered buttons + hoverColor: '#F4FAFF' +}; diff --git a/src/components/updatemenus/defaults.js b/src/components/updatemenus/defaults.js new file mode 100644 index 00000000000..52c79efa5e1 --- /dev/null +++ b/src/components/updatemenus/defaults.js @@ -0,0 +1,93 @@ +/** +* 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 attributes = require('./attributes'); +var contants = require('./constants'); + +var name = contants.name; +var buttonAttrs = attributes.buttons; + + +module.exports = function updateMenusDefaults(layoutIn, layoutOut) { + var contIn = Array.isArray(layoutIn[name]) ? layoutIn[name] : [], + contOut = layoutOut[name] = []; + + for(var i = 0; i < contIn.length; i++) { + var menuIn = contIn[i] || {}, + menuOut = {}; + + menuDefaults(menuIn, menuOut, layoutOut); + + // used on button click to update the 'active' field + menuOut._input = menuIn; + + // used to determine object constancy + menuOut._index = i; + + contOut.push(menuOut); + } +}; + +function menuDefaults(menuIn, menuOut, layoutOut) { + + function coerce(attr, dflt) { + return Lib.coerce(menuIn, menuOut, attributes, attr, dflt); + } + + var buttons = buttonsDefaults(menuIn, menuOut); + + var visible = coerce('visible', buttons.length > 0); + if(!visible) return; + + coerce('active'); + + coerce('x'); + coerce('y'); + Lib.noneOrAll(menuIn, menuOut, ['x', 'y']); + + coerce('xanchor'); + coerce('yanchor'); + + Lib.coerceFont(coerce, 'font', layoutOut.font); + + coerce('bgcolor', layoutOut.paper_bgcolor); + coerce('bordercolor'); + coerce('borderwidth'); +} + +function buttonsDefaults(menuIn, menuOut) { + var buttonsIn = menuIn.buttons || [], + buttonsOut = menuOut.buttons = []; + + var buttonIn, buttonOut; + + function coerce(attr, dflt) { + return Lib.coerce(buttonIn, buttonOut, buttonAttrs, attr, dflt); + } + + for(var i = 0; i < buttonsIn.length; i++) { + buttonIn = buttonsIn[i]; + buttonOut = {}; + + if(!Lib.isPlainObject(buttonIn) || !Array.isArray(buttonIn.args)) { + continue; + } + + coerce('method'); + coerce('args'); + coerce('label'); + + buttonsOut.push(buttonOut); + } + + return buttonsOut; +} diff --git a/src/components/updatemenus/draw.js b/src/components/updatemenus/draw.js new file mode 100644 index 00000000000..cbab8c534b6 --- /dev/null +++ b/src/components/updatemenus/draw.js @@ -0,0 +1,457 @@ +/** +* 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 Plots = require('../../plots/plots'); +var Lib = require('../../lib'); +var Color = require('../color'); +var Drawing = require('../drawing'); +var svgTextUtils = require('../../lib/svg_text_utils'); +var anchorUtils = require('../legend/anchor_utils'); + +var constants = require('./constants'); + + +module.exports = function draw(gd) { + var fullLayout = gd._fullLayout, + menuData = makeMenuData(fullLayout); + + /* Update menu data is bound to the header-group. + * The items in the header group are always present. + * + * Upon clicking on a header its corresponding button + * data is bound to the button-group. + * + * We draw all headers in one group before all buttons + * so that the buttons *always* appear above the headers. + * + * Note that only one set of buttons are visible at once. + * + * + * + * + * + * + * + * + * + * ... + * + * + * + * + * ... + */ + + // draw update menu container + var menus = fullLayout._infolayer + .selectAll('g.' + constants.containerClassName) + .data(menuData.length > 0 ? [0] : []); + + menus.enter().append('g') + .classed(constants.containerClassName, true) + .style('cursor', 'pointer'); + + menus.exit().remove(); + + // remove push margin object(s) + if(menus.exit().size()) clearPushMargins(gd); + + // return early if no update menus are visible + if(menuData.length === 0) return; + + // join header group + var headerGroups = menus.selectAll('g.' + constants.headerGroupClassName) + .data(menuData, keyFunction); + + headerGroups.enter().append('g') + .classed(constants.headerGroupClassName, true); + + // draw button container + var gButton = menus.selectAll('g.' + constants.buttonGroupClassName) + .data([0]); + + gButton.enter().append('g') + .classed(constants.buttonGroupClassName, 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) + 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(); + + gButton + .call(removeAllButtons) + .attr(constants.menuIndexAttrName, '-1'); + + 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]; + findDimenstions(gd, menuOpts); + } + + // draw headers! + headerGroups.each(function(menuOpts) { + var gHeader = d3.select(this); + drawHeader(gd, gHeader, gButton, menuOpts); + + // update buttons if they are dropped + if(areMenuButtonsDropped(gButton, menuOpts)) { + drawButtons(gd, gHeader, gButton, menuOpts); + } + }); +}; + +function makeMenuData(fullLayout) { + var contOpts = fullLayout[constants.name], + menuData = []; + + // Filter visible dropdowns and attach '_index' to each + // fullLayout options object to be used for 'object constancy' + // in the data join key function. + + for(var i = 0; i < contOpts.length; i++) { + var item = contOpts[i]; + + if(item.visible) menuData.push(item); + } + + return menuData; +} + +// 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; +} + +function areMenuButtonsDropped(gButton, menuOpts) { + var droppedIndex = +gButton.attr(constants.menuIndexAttrName); + + return droppedIndex === menuOpts._index; +} + +function drawHeader(gd, gHeader, gButton, menuOpts) { + var header = gHeader.selectAll('g.' + constants.headerClassName) + .data([0]); + + header.enter().append('g') + .classed(constants.headerClassName, true) + .style('pointer-events', 'all'); + + var active = menuOpts.active, + headerOpts = menuOpts.buttons[active] || constants.blankHeaderOpts, + posOpts = { y: 0, yPad: 0 }; + + header + .call(drawItem, menuOpts, headerOpts) + .call(setItemPosition, menuOpts, posOpts); + + // draw drop arrow at the right edge + var arrow = gHeader.selectAll('text.' + constants.headerArrowClassName) + .data([0]); + + arrow.enter().append('text') + .classed(constants.headerArrowClassName, true) + .classed('user-select-none', true) + .attr('text-anchor', 'end') + .call(Drawing.font, menuOpts.font) + .text('▼'); + + arrow.attr({ + x: menuOpts.width - constants.arrowOffsetX, + y: menuOpts.height1 / 2 + constants.textOffsetY + }); + + header.on('click', function() { + gButton.call(removeAllButtons); + + // if clicked index is same as dropped index => fold + // otherwise => drop buttons associated with header + gButton.attr( + constants.menuIndexAttrName, + areMenuButtonsDropped(gButton, menuOpts) ? '-1' : String(menuOpts._index) + ); + + drawButtons(gd, gHeader, gButton, menuOpts); + }); + + header.on('mouseover', function() { + header.call(styleOnMouseOver); + }); + + header.on('mouseout', function() { + header.call(styleOnMouseOut, menuOpts); + }); + + // translate header group + Lib.setTranslate(gHeader, menuOpts.lx, menuOpts.ly); +} + +function drawButtons(gd, gHeader, gButton, menuOpts) { + var buttonData = gButton.attr(constants.menuIndexAttrName) !== '-1' ? + menuOpts.buttons : + []; + + var buttons = gButton.selectAll('g.' + constants.buttonClassName) + .data(buttonData); + + buttons.enter().append('g') + .classed(constants.buttonClassName, true) + .attr('opacity', '0') + .transition() + .attr('opacity', '1'); + + buttons.exit() + .transition() + .attr('opacity', '0') + .remove(); + + var posOpts = { + y: menuOpts.height1 + constants.gapButtonHeader, + yPad: constants.gapButton + }; + + buttons.each(function(buttonOpts, buttonIndex) { + var button = d3.select(this); + + button + .call(drawItem, menuOpts, buttonOpts) + .call(setItemPosition, menuOpts, posOpts); + + button.on('click', function() { + // update 'active' attribute in menuOpts + menuOpts._input.active = menuOpts.active = buttonIndex; + + // fold up buttons and redraw header + gButton.attr(constants.menuIndexAttrName, '-1'); + drawHeader(gd, gHeader, gButton, menuOpts); + drawButtons(gd, gHeader, gButton, menuOpts); + + // call button method + var args = buttonOpts.args; + Plotly[buttonOpts.method](gd, args[0], args[1], args[2]); + }); + + button.on('mouseover', function() { + button.call(styleOnMouseOver); + }); + + button.on('mouseout', function() { + button.call(styleOnMouseOut, menuOpts); + buttons.call(styleButtons, menuOpts); + }); + }); + + buttons.call(styleButtons, menuOpts); + + // translate button group + Lib.setTranslate(gButton, menuOpts.lx, menuOpts.ly); +} + +function drawItem(item, menuOpts, itemOpts) { + item.call(drawItemRect, menuOpts) + .call(drawItemText, menuOpts, itemOpts); +} + +function drawItemRect(item, menuOpts) { + var rect = item.selectAll('rect') + .data([0]); + + rect.enter().append('rect') + .classed(constants.itemRectClassName, true) + .attr({ + rx: constants.rx, + ry: constants.ry, + 'shape-rendering': 'crispEdges' + }); + + rect.call(Color.stroke, menuOpts.bordercolor) + .call(Color.fill, menuOpts.bgcolor) + .style('stroke-width', menuOpts.borderwidth + 'px'); +} + +function drawItemText(item, menuOpts, itemOpts) { + var text = item.selectAll('text') + .data([0]); + + text.enter().append('text') + .classed(constants.itemTextClassName, true) + .classed('user-select-none', true) + .attr('text-anchor', 'start'); + + text.call(Drawing.font, menuOpts.font) + .text(itemOpts.label) + .call(svgTextUtils.convertToTspans); +} + +function styleButtons(buttons, menuOpts) { + var active = menuOpts.active; + + buttons.each(function(buttonOpts, i) { + var button = d3.select(this); + + if(i === active) { + button.select('rect.' + constants.itemRectClassName) + .call(Color.fill, constants.activeColor); + } + }); +} + +function styleOnMouseOver(item) { + item.select('rect.' + constants.itemRectClassName) + .call(Color.fill, constants.hoverColor); +} + +function styleOnMouseOut(item, menuOpts) { + item.select('rect.' + constants.itemRectClassName) + .call(Color.fill, menuOpts.bgcolor); +} + +// find item dimensions (this mutates menuOpts) +function findDimenstions(gd, menuOpts) { + menuOpts.width = 0; + menuOpts.height = 0; + menuOpts.height1 = 0; + menuOpts.lx = 0; + menuOpts.ly = 0; + + var fakeButtons = gd._tester.selectAll('g.' + constants.buttonClassName) + .data(menuOpts.buttons); + + fakeButtons.enter().append('g') + .classed(constants.buttonClassName, true); + + // loop over fake buttons to find width / height + fakeButtons.each(function(buttonOpts) { + var button = d3.select(this); + + button.call(drawItem, menuOpts, buttonOpts); + + var text = button.select('.' + constants.itemTextClassName), + tspans = text.selectAll('tspan'); + + // width is given by max width of all buttons + var tWidth = text.node() && Drawing.bBox(text.node()).width, + wEff = Math.max(tWidth + constants.textPadX, constants.minWidth); + + // height is determined by item text + var tHeight = menuOpts.font.size * constants.fontSizeToHeight, + tLines = tspans[0].length || 1, + hEff = Math.max(tHeight * tLines, constants.minHeight) + constants.textOffsetY; + + menuOpts.width = Math.max(menuOpts.width, wEff); + menuOpts.height1 = Math.max(menuOpts.height1, hEff); + menuOpts.height += menuOpts.height1; + }); + + fakeButtons.remove(); + + var graphSize = gd._fullLayout._size; + menuOpts.lx = graphSize.l + graphSize.w * menuOpts.x; + menuOpts.ly = graphSize.t + graphSize.h * (1 - menuOpts.y); + + var xanchor = 'left'; + if(anchorUtils.isRightAnchor(menuOpts)) { + menuOpts.lx -= menuOpts.width; + xanchor = 'right'; + } + if(anchorUtils.isCenterAnchor(menuOpts)) { + menuOpts.lx -= menuOpts.width / 2; + xanchor = 'center'; + } + + var yanchor = 'top'; + if(anchorUtils.isBottomAnchor(menuOpts)) { + menuOpts.ly -= menuOpts.height; + yanchor = 'bottom'; + } + if(anchorUtils.isMiddleAnchor(menuOpts)) { + menuOpts.ly -= menuOpts.height / 2; + yanchor = 'middle'; + } + + menuOpts.width = Math.ceil(menuOpts.width); + menuOpts.height = Math.ceil(menuOpts.height); + menuOpts.lx = Math.round(menuOpts.lx); + menuOpts.ly = Math.round(menuOpts.ly); + + Plots.autoMargin(gd, constants.autoMarginIdRoot + menuOpts._index, { + x: menuOpts.x, + y: menuOpts.y, + l: menuOpts.width * ({right: 1, center: 0.5}[xanchor] || 0), + r: menuOpts.width * ({left: 1, center: 0.5}[xanchor] || 0), + b: menuOpts.height * ({top: 1, middle: 0.5}[yanchor] || 0), + t: menuOpts.height * ({bottom: 1, middle: 0.5}[yanchor] || 0) + }); +} + +// set item positions (mutates posOpts) +function setItemPosition(item, menuOpts, posOpts) { + var rect = item.select('.' + constants.itemRectClassName), + text = item.select('.' + constants.itemTextClassName), + tspans = text.selectAll('tspan'), + borderWidth = menuOpts.borderwidth; + + Lib.setTranslate(item, borderWidth, borderWidth + posOpts.y); + + rect.attr({ + x: 0, + y: 0, + width: menuOpts.width, + height: menuOpts.height1 + }); + + var tHeight = menuOpts.font.size * constants.fontSizeToHeight, + tLines = tspans[0].length || 1, + spanOffset = ((tLines - 1) * tHeight / 4); + + var textAttrs = { + x: constants.textOffsetX, + y: menuOpts.height1 / 2 - spanOffset + constants.textOffsetY + }; + + text.attr(textAttrs); + tspans.attr(textAttrs); + + posOpts.y += menuOpts.height1 + posOpts.yPad; +} + +function removeAllButtons(gButton) { + gButton.selectAll('g.' + constants.buttonClassName).remove(); +} + +function clearPushMargins(gd) { + var pushMargins = gd._fullLayout._pushmargin || {}, + keys = Object.keys(pushMargins); + + for(var i = 0; i < keys.length; i++) { + var k = keys[i]; + + if(k.indexOf(constants.autoMarginIdRoot) !== -1) { + Plots.autoMargin(gd, k); + } + } +} diff --git a/src/components/updatemenus/index.js b/src/components/updatemenus/index.js new file mode 100644 index 00000000000..fc2bdc4436f --- /dev/null +++ b/src/components/updatemenus/index.js @@ -0,0 +1,16 @@ +/** +* 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'; + + +exports.layoutAttributes = require('./attributes'); + +exports.supplyLayoutDefaults = require('./defaults'); + +exports.draw = require('./draw'); diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 95f1edd1076..fc99b9d616c 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -28,6 +28,7 @@ var Images = require('../components/images'); var Legend = require('../components/legend'); var RangeSlider = require('../components/rangeslider'); var RangeSelector = require('../components/rangeselector'); +var UpdateMenus = require('../components/updatemenus'); var Shapes = require('../components/shapes'); var Titles = require('../components/titles'); var manageModeBar = require('../components/modebar/manage'); @@ -188,6 +189,7 @@ Plotly.plot = function(gd, data, layout, config) { Legend.draw(gd); RangeSelector.draw(gd); + UpdateMenus.draw(gd); for(i = 0; i < calcdata.length; i++) { cd = calcdata[i]; @@ -311,6 +313,7 @@ Plotly.plot = function(gd, data, layout, config) { Legend.draw(gd); RangeSlider.draw(gd); RangeSelector.draw(gd); + UpdateMenus.draw(gd); } function cleanUp() { @@ -2182,7 +2185,8 @@ Plotly.relayout = function relayout(gd, astr, val) { // trunk nodes (everything except the leaf) ptrunk = p.parts.slice(0, pend).join('.'), parentIn = Lib.nestedProperty(gd.layout, ptrunk).get(), - parentFull = Lib.nestedProperty(fullLayout, ptrunk).get(); + parentFull = Lib.nestedProperty(fullLayout, ptrunk).get(), + diff; redoit[ai] = vi; @@ -2323,12 +2327,21 @@ Plotly.relayout = function relayout(gd, astr, val) { // so that relinkPrivateKeys does not complain var fullLayers = (gd._fullLayout.mapbox || {}).layers || []; - var diff = (p.parts[2] + 1) - fullLayers.length; + diff = (p.parts[2] + 1) - fullLayers.length; for(i = 0; i < diff; i++) fullLayers.push({}); doplot = true; } + else if(p.parts[0] === 'updatemenus') { + Lib.extendDeepAll(gd.layout, Lib.objectFromPath(ai, vi)); + + var menus = gd._fullLayout.updatemenus || []; + diff = (p.parts[2] + 1) - menus.length; + + for(i = 0; i < diff; i++) menus.push({}); + doplot = true; + } // alter gd.layout else { // check whether we can short-circuit a full redraw diff --git a/src/plotly.js b/src/plotly.js index 3f405bb39e0..2d225557499 100644 --- a/src/plotly.js +++ b/src/plotly.js @@ -47,6 +47,7 @@ exports.Annotations = require('./components/annotations'); exports.Shapes = require('./components/shapes'); exports.Legend = require('./components/legend'); exports.Images = require('./components/images'); +exports.UpdateMenus = require('./components/updatemenus'); exports.ModeBar = require('./components/modebar'); exports.register = function register(_modules) { diff --git a/src/plots/layout_attributes.js b/src/plots/layout_attributes.js index 1ba041eb0fd..05a0e598535 100644 --- a/src/plots/layout_attributes.js +++ b/src/plots/layout_attributes.js @@ -183,6 +183,7 @@ module.exports = { 'annotations': 'Annotations', 'shapes': 'Shapes', 'images': 'Images', + 'updatemenus': 'UpdateMenus', 'ternary': 'ternary', 'mapbox': 'mapbox' } diff --git a/src/plots/plots.js b/src/plots/plots.js index 9900e06af0f..d7a45a44a21 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -867,7 +867,10 @@ plots.supplyLayoutModuleDefaults = function(layoutIn, layoutOut, fullData) { // TODO register these // Legend must come after traces (e.g. it depends on 'barmode') - var moduleLayoutDefaults = ['Fx', 'Annotations', 'Shapes', 'Legend', 'Images']; + var moduleLayoutDefaults = [ + 'Fx', 'Annotations', 'Shapes', 'Legend', 'Images', 'UpdateMenus' + ]; + for(i = 0; i < moduleLayoutDefaults.length; i++) { _module = moduleLayoutDefaults[i]; diff --git a/test/image/baselines/updatemenus.png b/test/image/baselines/updatemenus.png new file mode 100644 index 00000000000..226e0e0ab5c Binary files /dev/null and b/test/image/baselines/updatemenus.png differ diff --git a/test/image/mocks/updatemenus.json b/test/image/mocks/updatemenus.json new file mode 100644 index 00000000000..707150551db --- /dev/null +++ b/test/image/mocks/updatemenus.json @@ -0,0 +1,194 @@ +{ + "data": [ + { + "y": [ + 0.8894873976401985, + 0.014899350293371638, + 0.6973412552835649, + 0.4322110719369108, + 0.6435204579331169, + 0.7913368293479852, + 0.45608188941724737, + 0.8773020090286707, + 0.7857950507320299, + 0.1565801184767599 + ], + "line": { + "shape": "spline", + "color": "blue" + }, + "visible": false, + "name": "Data set 0" + }, + { + "y": [ + 0.9351499287297007, + 0.700778614078541, + 0.7689119583929467, + 0.4902017818752995, + 0.47690322880123315, + 0.7023096460658049, + 0.684735948009966, + 0.005709757371773261, + 0.2184467476442955, + 0.4081284199526569 + ], + "line": { + "shape": "spline", + "color": "blue" + }, + "visible": false, + "name": "Data set 1" + }, + { + "y": [ + 0.3795534309237285, + 0.48132628030867997, + 0.20021856641930635, + 0.5429433932874588, + 0.34709608300933814, + 0.4388656146011727, + 0.8859918512360843, + 0.7921704515742529, + 0.4487943073874723, + 0.1978381397790716 + ], + "line": { + "shape": "spline", + "color": "blue" + }, + "visible": true, + "name": "Data set 2" + }, + { + "y": [ + 0.6485026667443128, + 0.150419147834701, + 0.9484267018735058, + 0.7694920172667608, + 0.13303711181474798, + 0.8763890868570736, + 0.12203888678244112, + 0.7980275682208424, + 0.14953415395023817, + 0.4831367265254263 + ], + "line": { + "shape": "spline", + "color": "blue" + }, + "visible": false, + "name": "Data set 3" + } + ], + "layout": { + "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" + } + ], + "active": 1 + }, + { + "y": 0.7, + "buttons": [ + { + "method": "restyle", + "args": [ + "visible", + [ + true, + false, + false, + false + ] + ], + "label": "Data set 0" + }, + { + "method": "restyle", + "args": [ + "visible", + [ + false, + true, + false, + false + ] + ], + "label": "Data set 1" + }, + { + "method": "restyle", + "args": [ + "visible", + [ + false, + false, + true, + false + ] + ], + "label": "Data set 2" + }, + { + "method": "restyle", + "args": [ + "visible", + [ + false, + false, + false, + true + ] + ], + "label": "Data set 3" + } + ], + "x": -0.05, + "active": 2 + } + ], + "xaxis": { + "range": [ + -0.5484683986041102, + 9.54846839860411 + ], + "autorange": true + }, + "yaxis": { + "type": "linear", + "range": [ + 0.14689768330153566, + 0.9369323077136202 + ], + "autorange": true + }, + "height": 450, + "width": 1100, + "autosize": true + } +} diff --git a/test/jasmine/tests/updatemenus_test.js b/test/jasmine/tests/updatemenus_test.js new file mode 100644 index 00000000000..8d9f639db33 --- /dev/null +++ b/test/jasmine/tests/updatemenus_test.js @@ -0,0 +1,434 @@ +var UpdateMenus = require('@src/components/updatemenus'); +var constants = require('@src/components/updatemenus/constants'); + +var d3 = require('d3'); +var Plotly = require('@lib'); +var Lib = require('@src/lib'); +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); +var TRANSITION_DELAY = 100; + +describe('update menus defaults', function() { + 'use strict'; + + var supply = UpdateMenus.supplyLayoutDefaults; + + var layoutIn, layoutOut; + + beforeEach(function() { + layoutIn = {}; + layoutOut = {}; + }); + + it('should set \'visible\' to false when no buttons are present', function() { + layoutIn.updatemenus = [{ + buttons: [{ + method: 'relayout', + args: ['title', 'Hello World'] + }] + }, { + bgcolor: 'red' + }, { + visible: false, + buttons: [{ + method: 'relayout', + args: ['title', 'Hello World'] + }] + }]; + + supply(layoutIn, layoutOut); + + expect(layoutOut.updatemenus[0].visible).toBe(true); + expect(layoutOut.updatemenus[0].active).toEqual(0); + expect(layoutOut.updatemenus[1].visible).toBe(false); + expect(layoutOut.updatemenus[1].active).toBeUndefined(); + expect(layoutOut.updatemenus[2].visible).toBe(false); + expect(layoutOut.updatemenus[2].active).toBeUndefined(); + }); + + it('should skip over non-object buttons', function() { + layoutIn.updatemenus = [{ + buttons: [ + null, + { + method: 'relayout', + args: ['title', 'Hello World'] + }, + 'remove' + ] + }]; + + supply(layoutIn, layoutOut); + + expect(layoutOut.updatemenus[0].buttons.length).toEqual(1); + expect(layoutOut.updatemenus[0].buttons[0]).toEqual({ + method: 'relayout', + args: ['title', 'Hello World'], + label: '' + }); + }); + + it('should skip over buttons with array \'args\' field', function() { + layoutIn.updatemenus = [{ + buttons: [{ + method: 'restyle', + }, { + method: 'relayout', + args: ['title', 'Hello World'] + }, { + method: 'relayout', + args: null + }, {}] + }]; + + supply(layoutIn, layoutOut); + + expect(layoutOut.updatemenus[0].buttons.length).toEqual(1); + expect(layoutOut.updatemenus[0].buttons[0]).toEqual({ + method: 'relayout', + args: ['title', 'Hello World'], + label: '' + }); + }); + + it('should keep ref to input update menu container', function() { + layoutIn.updatemenus = [{ + buttons: [{ + method: 'relayout', + args: ['title', 'Hello World'] + }] + }, { + bgcolor: 'red' + }, { + visible: false, + buttons: [{ + method: 'relayout', + args: ['title', 'Hello World'] + }] + }]; + + supply(layoutIn, layoutOut); + + expect(layoutOut.updatemenus[0]._input).toBe(layoutIn.updatemenus[0]); + expect(layoutOut.updatemenus[1]._input).toBe(layoutIn.updatemenus[1]); + expect(layoutOut.updatemenus[2]._input).toBe(layoutIn.updatemenus[2]); + }); + + it('should default \'bgcolor\' to layout \'paper_bgcolor\'', function() { + var buttons = [{ + method: 'relayout', + args: ['title', 'Hello World'] + }]; + + layoutIn.updatemenus = [{ + buttons: buttons, + }, { + bgcolor: 'red', + buttons: buttons + }]; + + layoutOut.paper_bgcolor = 'blue'; + + supply(layoutIn, layoutOut); + + expect(layoutOut.updatemenus[0].bgcolor).toEqual('blue'); + expect(layoutOut.updatemenus[1].bgcolor).toEqual('red'); + }); +}); + +describe('update menus interactions', function() { + 'use strict'; + + var mock = require('@mocks/updatemenus.json'), + bgColor = 'rgb(255, 255, 255)', + activeColor = 'rgb(244, 250, 255)'; + + var gd; + + beforeEach(function(done) { + gd = createGraphDiv(); + + // move update menu #2 to click on them separately + var mockCopy = Lib.extendDeep({}, mock); + mockCopy.layout.updatemenus[1].x = 1; + + Plotly.plot(gd, mockCopy.data, mockCopy.layout).then(done); + }); + + afterEach(function() { + Plotly.purge(gd); + destroyGraphDiv(); + }); + + it('should draw only visible menus', function(done) { + assertMenus([0, 0]); + expect(gd._fullLayout._pushmargin['updatemenu-0']).toBeDefined(); + expect(gd._fullLayout._pushmargin['updatemenu-1']).toBeDefined(); + + Plotly.relayout(gd, 'updatemenus[0].visible', false).then(function() { + assertMenus([0]); + expect(gd._fullLayout._pushmargin['updatemenu-0']).toBeUndefined(); + expect(gd._fullLayout._pushmargin['updatemenu-1']).toBeDefined(); + + return Plotly.relayout(gd, 'updatemenus[1]', null); + }).then(function() { + assertNodeCount('.' + constants.containerClassName, 0); + expect(gd._fullLayout._pushmargin['updatemenu-0']).toBeUndefined(); + expect(gd._fullLayout._pushmargin['updatemenu-1']).toBeUndefined(); + + return Plotly.relayout(gd, { + 'updatemenus[0].visible': true, + 'updatemenus[1].visible': true + }); + }).then(function() { + assertMenus([0, 0]); + expect(gd._fullLayout._pushmargin['updatemenu-0']).toBeDefined(); + expect(gd._fullLayout._pushmargin['updatemenu-1']).toBeDefined(); + + return Plotly.relayout(gd, { + 'updatemenus[0].visible': false, + 'updatemenus[1].visible': false + }); + }).then(function() { + assertNodeCount('.' + constants.containerClassName, 0); + expect(gd._fullLayout._pushmargin['updatemenu-0']).toBeUndefined(); + expect(gd._fullLayout._pushmargin['updatemenu-1']).toBeUndefined(); + + return Plotly.relayout(gd, { + 'updatemenus[2]': { + buttons: [{ + method: 'relayout', + args: ['title', 'new title'] + }] + } + }); + }).then(function() { + assertMenus([0]); + expect(gd._fullLayout._pushmargin['updatemenu-0']).toBeUndefined(); + expect(gd._fullLayout._pushmargin['updatemenu-1']).toBeUndefined(); + expect(gd._fullLayout._pushmargin['updatemenu-2']).toBeDefined(); + + return Plotly.relayout(gd, 'updatemenus[0].visible', true); + }).then(function() { + assertMenus([0, 0]); + expect(gd._fullLayout._pushmargin['updatemenu-0']).toBeDefined(); + expect(gd._fullLayout._pushmargin['updatemenu-1']).toBeUndefined(); + expect(gd._fullLayout._pushmargin['updatemenu-2']).toBeDefined(); + + done(); + }); + }); + + it('should drop/fold buttons when clicking on header', function(done) { + var header0 = selectHeader(0), + header1 = selectHeader(1); + + click(header0).then(function() { + assertMenus([3, 0]); + return click(header0); + }).then(function() { + assertMenus([0, 0]); + return click(header1); + }).then(function() { + assertMenus([0, 4]); + return click(header1); + }).then(function() { + assertMenus([0, 0]); + return click(header0); + }).then(function() { + assertMenus([3, 0]); + return click(header1); + }).then(function() { + assertMenus([0, 4]); + return click(header0); + }).then(function() { + assertMenus([3, 0]); + done(); + }); + }); + + it('should apply update on button click', function(done) { + var header0 = selectHeader(0), + header1 = selectHeader(1); + + assertActive(gd, [1, 2]); + + click(header0).then(function() { + assertItemColor(selectButton(1), activeColor); + + return click(selectButton(0)); + }).then(function() { + assertActive(gd, [0, 2]); + + return click(header1); + }).then(function() { + assertItemColor(selectButton(2), activeColor); + + return click(selectButton(0)); + }).then(function() { + assertActive(gd, [0, 0]); + + done(); + }); + }); + + it('should change color on mouse over', function(done) { + var INDEX_0 = 2, + INDEX_1 = gd.layout.updatemenus[1].active; + + var header0 = selectHeader(0); + + assertItemColor(header0, bgColor); + mouseEvent('mouseover', header0); + assertItemColor(header0, activeColor); + mouseEvent('mouseout', header0); + assertItemColor(header0, bgColor); + + click(header0).then(function() { + var button = selectButton(INDEX_0); + + assertItemColor(button, bgColor); + mouseEvent('mouseover', button); + assertItemColor(button, activeColor); + mouseEvent('mouseout', button); + assertItemColor(button, bgColor); + + return click(selectHeader(1)); + }).then(function() { + var button = selectButton(INDEX_1); + + assertItemColor(button, activeColor); + mouseEvent('mouseover', button); + assertItemColor(button, activeColor); + mouseEvent('mouseout', button); + assertItemColor(button, activeColor); + + done(); + }); + }); + + it('should relayout', function(done) { + assertItemColor(selectHeader(0), 'rgb(255, 255, 255)'); + assertItemDims(selectHeader(1), 95, 33); + + Plotly.relayout(gd, 'updatemenus[0].bgcolor', 'red').then(function() { + assertItemColor(selectHeader(0), 'rgb(255, 0, 0)'); + + return click(selectHeader(0)); + }).then(function() { + assertMenus([3, 0]); + + return Plotly.relayout(gd, 'updatemenus[0].bgcolor', 'blue'); + }).then(function() { + // and keep menu dropped + assertMenus([3, 0]); + assertItemColor(selectHeader(0), 'rgb(0, 0, 255)'); + + return Plotly.relayout(gd, 'updatemenus[1].buttons[1].label', 'a looooooooooooong
label'); + }).then(function() { + assertItemDims(selectHeader(1), 179, 34.2); + + return click(selectHeader(1)); + }).then(function() { + assertMenus([0, 4]); + + return Plotly.relayout(gd, 'updatemenus[1].visible', false); + }).then(function() { + // and delete buttons + assertMenus([0]); + + return click(selectHeader(0)); + }).then(function() { + assertMenus([3]); + + return Plotly.relayout(gd, 'updatemenus[1].visible', true); + }).then(function() { + // fold up buttons whenever new menus are added + assertMenus([0, 0]); + + done(); + }); + }); + + function assertNodeCount(query, cnt) { + expect(d3.selectAll(query).size()).toEqual(cnt); + } + + // call assertMenus([0, 3]); to check that the 2nd update menu is dropped + // and showing 3 buttons. + function assertMenus(expectedMenus) { + assertNodeCount('.' + constants.containerClassName, 1); + assertNodeCount('.' + constants.headerClassName, expectedMenus.length); + + var gButton = d3.select('.' + constants.buttonGroupClassName), + actualActiveIndex = +gButton.attr(constants.menuIndexAttrName), + hasActive = false; + + expectedMenus.forEach(function(expected, i) { + if(expected) { + expect(actualActiveIndex).toEqual(i); + assertNodeCount('.' + constants.buttonClassName, expected); + hasActive = true; + } + }); + + if(!hasActive) { + expect(actualActiveIndex).toEqual(-1); + assertNodeCount('.' + constants.buttonClassName, 0); + } + } + + function assertActive(gd, expectedMenus) { + expectedMenus.forEach(function(expected, i) { + expect(gd.layout.updatemenus[i].active).toEqual(expected); + expect(gd._fullLayout.updatemenus[i].active).toEqual(expected); + }); + } + + function assertItemColor(node, color) { + var rect = node.select('rect'); + expect(rect.style('fill')).toEqual(color); + } + + function assertItemDims(node, width, height) { + var rect = node.select('rect'), + actualWidth = +rect.attr('width'); + + // must compare with a tolerance as the exact result + // is browser/font dependent (via getBBox) + expect(Math.abs(actualWidth - width)).toBeLessThan(11); + + // height is determined by 'fontsize', + // so no such tolerance is needed + expect(+rect.attr('height')).toEqual(height); + } + + function click(selection) { + return new Promise(function(resolve) { + setTimeout(function() { + mouseEvent('click', selection); + resolve(); + }, TRANSITION_DELAY); + }); + } + + // For some reason, ../assets/mouse_event.js fails + // to detect the button elements in FF38 (like on CircleCI 2016/08/02), + // so dispatch the mouse event directly about the nodes instead. + function mouseEvent(type, selection) { + var ev = new window.MouseEvent(type, { bubbles: true }); + selection.node().dispatchEvent(ev); + } + + function selectHeader(menuIndex) { + var headers = d3.selectAll('.' + constants.headerClassName), + header = d3.select(headers[0][menuIndex]); + return header; + } + + function selectButton(buttonIndex) { + var buttons = d3.selectAll('.' + constants.buttonClassName), + button = d3.select(buttons[0][buttonIndex]); + return button; + } +});