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;
+ }
+});