From ba2020620add956866f60f56f0a5f0b86df31393 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Fri, 22 Jul 2016 17:13:30 -0400 Subject: [PATCH 01/16] introduce UpdateMenus module --- src/components/updatemenus/attributes.js | 144 ++++++++ src/components/updatemenus/constants.js | 73 ++++ src/components/updatemenus/defaults.js | 86 +++++ src/components/updatemenus/draw.js | 431 +++++++++++++++++++++++ src/components/updatemenus/index.js | 16 + 5 files changed, 750 insertions(+) create mode 100644 src/components/updatemenus/attributes.js create mode 100644 src/components/updatemenus/constants.js create mode 100644 src/components/updatemenus/defaults.js create mode 100644 src/components/updatemenus/draw.js create mode 100644 src/components/updatemenus/index.js diff --git a/src/components/updatemenus/attributes.js b/src/components/updatemenus/attributes.js new file mode 100644 index 00000000000..2622e77a225 --- /dev/null +++ b/src/components/updatemenus/attributes.js @@ -0,0 +1,144 @@ +/** +* 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', + description: 'Sets the text label to appear on the button.' + } +}; + +module.exports = { + _isLinkedToArray: true, + + // add more global settings? + // + // width + // height (instead of being inferred by the label dimensions) + // title (above header) + // header borderwidth + // active bgcolor + // hover bgcolor + // gap between buttons + // gap between header & buttons + + 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', + dflt: colorAttrs.lightLine, + role: 'style', + description: 'Sets the background color of the update menu buttons.' + }, + bordercolor: { + valType: 'color', + dflt: colorAttrs.defaultLine, + role: 'style', + description: 'Sets the color of the border enclosing the update menu.' + }, + borderwidth: { + valType: 'number', + min: 0, + dflt: 0, + 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..822595f50cb --- /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: 10, + + // item text y offset (w.r.t. middle) + textOffsetY: 3, + + // arrow offset off right edge + arrowOffsetX: 2, + + // gap between header and buttons + gapButtonHeader: 5, + + // gap between between buttons + gapButton: 2, + + // color given to active buttons + activeColor: '#d3d3d3', + + // color given to hovered buttons + hoverColor: '#d3d3d3' +}; diff --git a/src/components/updatemenus/defaults.js b/src/components/updatemenus/defaults.js new file mode 100644 index 00000000000..f29dbbf006e --- /dev/null +++ b/src/components/updatemenus/defaults.js @@ -0,0 +1,86 @@ +/** +* 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); + menuOut._input = menuIn; + 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'); + 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 = {}; + + // Should we do some validation for 'args' depending on `method` + // or just let Plotly[method] error out? + + 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..fd2590c16e2 --- /dev/null +++ b/src/components/updatemenus/draw.js @@ -0,0 +1,431 @@ +/** +* 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(); + + // 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, + if(headerGroups.enter().size()) { + + // attach 'state' variable to node to keep track of the active menu + // '-1' means no menu is active + gButton.attr(constants.menuIndexAttrName, '-1'); + + // remove all dropped buttons (if any) + gButton.selectAll('g.' + constants.buttonClassName).remove(); + } + + // remove exiting header, remove dropped buttons and reset margins + headerGroups.exit().each(function(menuOpts) { + d3.select(this).remove(); + gButton.selectAll('g.' + constants.buttonClassName).remove(); + 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]; + + // often more convenient than playing with two arguments + menuOpts._index = 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 = []; + + for(var i = 0; i < contOpts.length; i++) { + var item = contOpts[i]; + + if(item.visible) menuData.push(item); + } + + return menuData; +} + +function keyFunction(opts, i) { + return opts.visible + i; +} + +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.selectAll('g.' + constants.buttonClassName).remove(); + + // 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; +} 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'); From 2ac6dd40df5e3524356e8b899fb30b10c1082f94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Fri, 22 Jul 2016 17:16:58 -0400 Subject: [PATCH 02/16] plugin UpdateMenus into plot code --- src/plot_api/plot_api.js | 3 +++ src/plotly.js | 1 + src/plots/layout_attributes.js | 1 + src/plots/plots.js | 5 ++++- 4 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 9dc8c7b1e57..741203acd6a 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'); @@ -180,6 +181,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]; @@ -303,6 +305,7 @@ Plotly.plot = function(gd, data, layout, config) { Legend.draw(gd); RangeSlider.draw(gd); RangeSelector.draw(gd); + UpdateMenus.draw(gd); } function cleanUp() { diff --git a/src/plotly.js b/src/plotly.js index 32f552a45a4..0dd94594e2b 100644 --- a/src/plotly.js +++ b/src/plotly.js @@ -50,6 +50,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 2e0a4d28e85..7db5e924396 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]; From a02c225101435794b036d4a2ce76689218bc741f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Fri, 22 Jul 2016 17:49:27 -0400 Subject: [PATCH 03/16] make 'updatemenus' relayout calls work --- src/plot_api/plot_api.js | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 741203acd6a..934ccbad1b3 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2173,7 +2173,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; @@ -2314,12 +2315,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 From c9141e0e902556ce9d5364e284e189215b6e06fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Mon, 25 Jul 2016 10:33:13 -0400 Subject: [PATCH 04/16] fixup update menu header drop/fold toggle --- src/components/updatemenus/draw.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/updatemenus/draw.js b/src/components/updatemenus/draw.js index fd2590c16e2..fab43cf27c2 100644 --- a/src/components/updatemenus/draw.js +++ b/src/components/updatemenus/draw.js @@ -139,7 +139,7 @@ function keyFunction(opts, i) { } function areMenuButtonsDropped(gButton, menuOpts) { - var droppedIndex = gButton.attr(constants.menuIndexAttrName); + var droppedIndex = +gButton.attr(constants.menuIndexAttrName); return droppedIndex === menuOpts._index; } From 606c43e6ffcd7954208fb72c56f48c230e42403f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Fri, 29 Jul 2016 18:03:12 -0400 Subject: [PATCH 05/16] color: add borderLine color def - used in update menus (but possible more layout components down the road). --- src/components/color/attributes.js | 2 ++ 1 file changed, 2 insertions(+) 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 From 5f99dbfaf147c02db11072d7ac6a58fe68a37024 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Fri, 29 Jul 2016 18:04:17 -0400 Subject: [PATCH 06/16] make update menu style Derek-proof --- src/components/updatemenus/attributes.js | 5 ++--- src/components/updatemenus/constants.js | 8 ++++---- src/components/updatemenus/defaults.js | 2 +- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/components/updatemenus/attributes.js b/src/components/updatemenus/attributes.js index 2622e77a225..68c1c1d9814 100644 --- a/src/components/updatemenus/attributes.js +++ b/src/components/updatemenus/attributes.js @@ -124,20 +124,19 @@ module.exports = { bgcolor: { valType: 'color', - dflt: colorAttrs.lightLine, role: 'style', description: 'Sets the background color of the update menu buttons.' }, bordercolor: { valType: 'color', - dflt: colorAttrs.defaultLine, + dflt: colorAttrs.borderLine, role: 'style', description: 'Sets the color of the border enclosing the update menu.' }, borderwidth: { valType: 'number', min: 0, - dflt: 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 index 822595f50cb..d9bfa1a9a11 100644 --- a/src/components/updatemenus/constants.js +++ b/src/components/updatemenus/constants.js @@ -51,13 +51,13 @@ module.exports = { ry: 2, // item text x offset off left edge - textOffsetX: 10, + textOffsetX: 12, // item text y offset (w.r.t. middle) textOffsetY: 3, // arrow offset off right edge - arrowOffsetX: 2, + arrowOffsetX: 4, // gap between header and buttons gapButtonHeader: 5, @@ -66,8 +66,8 @@ module.exports = { gapButton: 2, // color given to active buttons - activeColor: '#d3d3d3', + activeColor: '#F4FAFF', // color given to hovered buttons - hoverColor: '#d3d3d3' + hoverColor: '#F4FAFF' }; diff --git a/src/components/updatemenus/defaults.js b/src/components/updatemenus/defaults.js index f29dbbf006e..90aa99f57e0 100644 --- a/src/components/updatemenus/defaults.js +++ b/src/components/updatemenus/defaults.js @@ -53,7 +53,7 @@ function menuDefaults(menuIn, menuOut, layoutOut) { Lib.coerceFont(coerce, 'font', layoutOut.font); - coerce('bgcolor'); + coerce('bgcolor', layoutOut.paper_bgcolor); coerce('bordercolor'); coerce('borderwidth'); } From a1265a9bd48b139c8a386fd1fc849f9e0cf262cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Mon, 1 Aug 2016 18:39:21 -0400 Subject: [PATCH 07/16] add update menu button label default --- src/components/updatemenus/attributes.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/updatemenus/attributes.js b/src/components/updatemenus/attributes.js index 68c1c1d9814..adac64a2048 100644 --- a/src/components/updatemenus/attributes.js +++ b/src/components/updatemenus/attributes.js @@ -40,6 +40,7 @@ var buttonsAttrs = { label: { valType: 'string', role: 'info', + dflt: '', description: 'Sets the text label to appear on the button.' } }; From 06e81408b50d8bc063b12b43ca5ad2cb1f36f48f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Mon, 1 Aug 2016 18:40:02 -0400 Subject: [PATCH 08/16] skip over non-object buttons and button w/o 'args' --- src/components/updatemenus/defaults.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/updatemenus/defaults.js b/src/components/updatemenus/defaults.js index 90aa99f57e0..591d88f10f6 100644 --- a/src/components/updatemenus/defaults.js +++ b/src/components/updatemenus/defaults.js @@ -72,6 +72,10 @@ function buttonsDefaults(menuIn, menuOut) { buttonIn = buttonsIn[i]; buttonOut = {}; + if(!Lib.isPlainObject(buttonIn) || !Array.isArray(buttonIn.args)) { + continue; + } + // Should we do some validation for 'args' depending on `method` // or just let Plotly[method] error out? From ca26892e61c92d6b825048c8177b5bfec05dfa22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Mon, 1 Aug 2016 18:40:33 -0400 Subject: [PATCH 09/16] DRY up button remove routine --- src/components/updatemenus/draw.js | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/components/updatemenus/draw.js b/src/components/updatemenus/draw.js index fab43cf27c2..9db0da57a19 100644 --- a/src/components/updatemenus/draw.js +++ b/src/components/updatemenus/draw.js @@ -82,21 +82,23 @@ module.exports = function draw(gd) { .classed(constants.buttonGroupClassName, true) .style('pointer-events', 'all'); - // whenever we add new menu, + // 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()) { - - // attach 'state' variable to node to keep track of the active menu - // '-1' means no menu is active - gButton.attr(constants.menuIndexAttrName, '-1'); - - // remove all dropped buttons (if any) - gButton.selectAll('g.' + constants.buttonClassName).remove(); + 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.selectAll('g.' + constants.buttonClassName).remove(); + + gButton + .call(removeAllButtons) + .attr(constants.menuIndexAttrName, '-1'); + Plots.autoMargin(gd, constants.autoMarginIdRoot + menuOpts._index); }); @@ -177,7 +179,7 @@ function drawHeader(gd, gHeader, gButton, menuOpts) { }); header.on('click', function() { - gButton.selectAll('g.' + constants.buttonClassName).remove(); + gButton.call(removeAllButtons); // if clicked index is same as dropped index => fold // otherwise => drop buttons associated with header @@ -429,3 +431,7 @@ function setItemPosition(item, menuOpts, posOpts) { posOpts.y += menuOpts.height1 + posOpts.yPad; } + +function removeAllButtons(gButton) { + gButton.selectAll('g.' + constants.buttonClassName).remove(); +} From b47008ff85a79d9ac55bc34a6b62b10d1c0c0d1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Mon, 1 Aug 2016 19:17:44 -0400 Subject: [PATCH 10/16] clear all push margins objects if all menus get removed --- src/components/updatemenus/draw.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/components/updatemenus/draw.js b/src/components/updatemenus/draw.js index 9db0da57a19..dc0e148e272 100644 --- a/src/components/updatemenus/draw.js +++ b/src/components/updatemenus/draw.js @@ -64,6 +64,9 @@ module.exports = function draw(gd) { 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; @@ -435,3 +438,16 @@ function setItemPosition(item, menuOpts, posOpts) { 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); + } + } +} From a33aa46a68e5b3369f288a934c579a8c999b68c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Mon, 1 Aug 2016 19:24:26 -0400 Subject: [PATCH 11/16] add updatemenu image test --- test/image/baselines/updatemenus.png | Bin 0 -> 28801 bytes test/image/mocks/updatemenus.json | 194 +++++++++++++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 test/image/baselines/updatemenus.png create mode 100644 test/image/mocks/updatemenus.json diff --git a/test/image/baselines/updatemenus.png b/test/image/baselines/updatemenus.png new file mode 100644 index 0000000000000000000000000000000000000000..226e0e0ab5c855168d49d6ded089670be2cbb365 GIT binary patch literal 28801 zcmdSBXH-<%wl#{1L=g}H0VN73QHdf+BxlKz3KbBECAr9=1t{=b&mR@RN+LHXZSqO!p*KyBN#p@f&w|%xp7R?JBjt&p@LWob0-6A?k^Mr_) zohUf<+=tsRcV^Sj(|`Z^gZ38e4cQ+z{Qk9?=Lv4+Ci2YLKc6D}h_yZYS1TvTrjm(J zDT_Ff8-F)_0-hvA^mn7L98Z!#i<;Vhu>bud4P=Heh(B+-RYcB?tUfFJ?AhNxf`Od+ zp9jJ|fZCf|J-Q7)%X#ew-M1#KoasCbmwcv>*?v5n8(dFtHx=3H?!zT- zN^9Kyb4BmSfN7FgN8uh`d*s1%+=3l34a9CPQ}QKF#ZFcIc#XnUiLf2_zSCmPkVFb8 z{@K8fmqe(=GXWc+w3xZ2E9A#(^NWlf$@EeRmD2dO$M(2^RN9tET)JyBQ^|v`;VKF` zX*Z2JrB`L<(TEJuZXOmDn09ZL(DBV{I)vWf|JvIv7x?yf(XRNT{jQ_${S>PU0|nC+ zqb`nRT_zBp?2F!Gu91glu%27K@9rPBB%;PHiIRTQ%?=$!A%YGZhszz4{HOV?V%)oA z(Uj`DiJ`8O%`Xn64tFN&G7f$+HoWRck@P#tvF}J0cbiS~Zd)0ztrMiduNInDR-7BD z^V@F2VVkC>ldOtNf+p$%rW4Iw>`S!aXpI{m&i}cDz)-vwbf z7tJSPdooqq)p^s---R9Rb_Hp8dY1F1sIvNGeNUG)_uZJ&zBxK85cHyJ6} zrYeuugQH0vFTnpav$v0l-MEnvjKYaUmbJbL`@>yZOMa$ z=Dgb&Kg5{-ddDD7b7KKA!PvW;>m$yLl9c}V-MvygS$z%@#_FqPR_-tzVn2{?wDyHg zx24R!FPR=9JB$UZRZ!`P-5P}^fgviLmEE|5i1MBYK=17pR$?5)H%7velbI2bDK0hY za%(ZsVxz1mNAZh>XOGRGlWa-@#wciZS@ohTobi3e{uFOXz}^-KKa!3|>y=)nih71z z0MdVZBB%km*2bTXJKV^XPe_yYZu`n;mHO!Qfe~a}1sef%zjO;mM@1c}@Tb+ias?xY z1Uz1j)fD=*h0{sBp(j+kR6^nh^R#kV#nerIEbUxl)iOA7*c~P}MUHT>tJ+^{pRQRf zic6REYXg_fx2Q|Kh31lr_Xmw2&bhq{MP9_%a@MoVzx@mtKf5Zi8yB)>b!($fcRPw_ zv>~Wr?z*k7!?T<<17?WtJog)M$HcNnJ-5$FP0{TBAeEmhT)^jweYC6{936T4z93iB z|6tK7NjP9$b5OA!Iw6P9RXB%YfuHmF+k?CS)?BG}LKzlu-nsdBx8Je=Thij`*)}fZ z*|&X{j~&h%?k8ZAdVt;fOeKlqPbc(Pg$U~zD(Blo2KDfY|I0#P1P9Q35$vDtIg#TA zI>DCg>{f+dz(#gP#U8tb^U=T)HooRUGM!XMmh>MFGJppe zu=?W1>q7J7iQh>w#NLJOQ~!7{0z4S;IFI?*C6e9xEDIJT;LO~Ke>|uR9#pO}r25m| z!Sek`2mVlwWaBtufxmp_1P^jre9&=IODJz+r0-r{_$plooH^sN@;UR>Xcxj%r|O|U*fHwVE259NwKHFiZ{iW z9otun{0X>Z=5>|-fA0-e3fTreFNP58q8vc*u^lSD?>6T8&FJ#M&j{hR`hdMG)o51w z_2v%T&SZ3ZUT*rxBk^cC{09Nk5+#V=vbfYzann>uyHFgRuFQ3pxOeSb$agV%$L1RR z^qiUnELS^)N7ipEz7pfB83?>sJ1T_Ic57+a?fdm_Vk}RkSE>|!w^sx+cE8(w3|)wn z#JpPdpOqE~I^0(o)f+w-o0a2T4OsQFJ-~0ejeDY7Pf^RT8yOkJ;LCkC9jccrMwtcu zrV~sgcJC8mBjG8xkKgY3S5HK-o2CV~T!xAjit4t^paUB@DK6(MJH~6h5viVorfwLY z*%w9mfk%52NoOzJR0CdoI-I}gl_IrlSr3x*8}3~gYpiX1VypBjaxx)9%01_z&s5CX zcEge7OlKNDq8^{mi-_aVj_pj7j-c2D2w=AG^7bNHw|=(+Dj_XMLdAR;gw`!QJ@@>L zFlsg-;f8&;x}P=pfOf%3F;i|7ONfN+u}NIvJTZ%^^;|T5bM5|90m~Z0tVc9Z zh;QC@X>`#3B8^UPQ{F2G7OLp6^ZDfgn z5;7!TnO|D{4H6_7Oev0Sp6CSaEULhqtR6prXyb~O>1Ai-L)EAnZ||@QAW7|nx)zVL zw)Ynn=BsDZ`K|VUGG-eU7-OOj@#qHM(JO&FO>DkLM=Q0fZEx(-uTsnJ7duVV%f7-b zv@K1w#!aWXcg7g#NO2EZ+=L}mg4ik1OaT>`I)Cip-rgckrCd!EG?#Jp$2F2pvmNb( zZQmM_KibuW>V9=r!xjV;Lk6xLY?>a*`*v|Ycq5|^9qt5LsIj_`3i*qb=QjQSOr zv+QS$UUg|W@B)y{L>W6k|8q5PyB1SFx!xr|(c)qH=|M<;e?QD~{kc-o z9qF?^HE0fXepTL^NRluvhPVwFnGg`p6nL+hm1_m^2G{`uIuBvQOi#ZBME+r| z_v)5$#Kh6zw%?xDW^j2|;8rOH@&iCbKhL>5YJ6$K!7>!W<`MA~-Eim$&E9>bc-EdK zFk#ATdBkTgNSoJkdKnM0MG~gh;$a0 zCK?(l!^hq$Q?6pg5=_SR-=X&MbOSV+quEyJ2aIMD_)UDN%d}H(d=lE?`^u&A5{HMR zZ%lXyL&vhL`|3}tlR;@Trh@+j7ept?<;zGOyMqAhE|5ol3@-fX)I35YC$7X@x`av* zfSMg=RqVCE9lvZoi|u&yV@~2Y_Pzi?k51JkM*)sLqv~+nH2cx-doTQ-c<=Me$PrCA z|F63Pjx>iZyL~s0r!vezV+sqH@94aa!FN%PdAf#_e`5@y z6V_nytgqye{f$o8Bf-?gR6^vAgCW2V7eLC#tf;R1S7Ssc?Ex~#xz4w7yzB&casj}T z3mSaknm(^NWC)fmaIK8+p%$Y8ddyW|5pt*W#RB1I?Q1j;$uU}pH{n_$ToD)%l=%kJtM|XS zkS{hS~dQaR(qRc3kA zBF_cFc*zjbgbR#adP^C~gcZb}QR~^Eg-A966!sW2;tH-$giF}BoebJzcPqRZjFOxL zwHPW5rb@VE|KCkHC6lSxbyDSxs9n_3&?iOT%>{0QT%81v^+{a7igw9xt-Ys!QF^fl1Q*yGcs&irxcoLBxOOF_U~Ca$|MEDpAOi&kM0MFIaT)6aQ`s?jolooQBvb83bPWtk3vx^-6AV zD2BEGz>uvNcGfe~qV!?p{(?zEtbJe3z;rDRMLA7KC!;P&`yN$zuR?eYd34hSMv?#; z=~3TPb~cn3m=}Jx=A&=OpdRgqF0$+`vDLI6ukq~FW~`A72m<-6 z{QUeZ?Gy>}%G84yki{k(DBQRR21--knqyLAF<&^RlPs23Gy*dG)}c?<+1{%d&!er0 zqk{=TrrX~x>Ck^=^(b0u=7R9VBBH^i#4&iW2ER}st1gd))16G>u=YQRZ@0=e`5F-~ zaKh3O+Vehymnh4Ie2&zQnA3D-B)J~_sMklALiH_pH&}{SwX6Up^3nl6m$a7{w0w_a$~snM`&$D ze!(RHg6b!&`R3ZKDs`W+&IDcUMeP;w%}o z1ZlraL>PPN{k|M+OnzZ{{abbJhqxb&C69VI?N=OMIgeJfCYV63dSiTY|7mD0RY9}T zoTYY2BZHPQ?z7>BCbjdLwB{hIy|C%=k_oG=dhx;b?S}U(Qf^r;|hD&VU`+2ta6(H*Q&{jcpsem3@tL_RA@?!gG`~=s`eW6eW#S)qFLf^^$n!>>&9AkC=2>WgY3l4XapFkdT?C_cNptp;%Il7TjHiOOzjW zBy*2eLX!vqaFnISbCG8ejYz6?pY`RHm}|Q3YxpL8;c*K`7>xQw0G859?duS5K=%pT z8Es69u^A598_lL4f2$tiz4JKce$%Uox8M1u6=D3eKW8gIPF>NW4}AoD+#?$m3U;K* zHI;l~0BQyG-vu3LWYFF|aJ)#=}8@c8$Se@2`yjR4AnF=Qp%{78N2DXcvlz6nR6z;^LKm$#`I4h;Vib^H+ zpN|9W(%t*=lMJf#m?WG7Ny2XY;lY>Zy?ljjMMXpTA5~<-wnG?|0F3w?j$m~S?2_fQ zRZSaZyVGnASOeD2XS~_49glHqgPy>}D*=Z&W(o!sFf0;N+Ql$GR$Z3XDDTOUkHgCv zyAI0Mff#eBY!WCnB5+rR7IQDP#TF#ij_Fs$RX_w&A!aPSKp4);pC56}PfXr@a8bO0 ze?)r#)z-GBe46o-0x_7mx!|yh^vO0edQyJLQh`TY8*sp0Rh%HD=G3jF0x!;m=QQwW zwH4vG5o^TL0+IaL`Gs&N9}x~@U_edy$C3g+zu}*Eq|(WtwklJ_iXicBN-*OBNh8-S z(L2OgaiZ%F=n1PqA3hH;w;UhX*k7lLJ|}t=ITGrUbZ6eOvZxGIu;)ukRU%sP5gm?u zDEoAHSss-#J~Ul43;gseP7sVrQI65IZMo@R-ZHApopL1Q?L!r8E;5ihd+)C4JMz`x zkFxL}*gu5xgZIF{RyO(s@Cy(1|8VhMRVHTR1IEr|2zlmlw6wL#{E;Ed$X}fl%c`c7_+T3|QL1%h5jCP0i~9bFy{XEcehRGKgM z2SKlc-4f>4f5!>O^xB4!{3NZpXExf|m*FO(gK{4{7Rk@zL2FVKFX*>tTfRR0O zZDQc0#jjpf+DJNkW%;bk*8pX zJS*kQwFPTp`&rwtivmREkGSON75R@jmHG1%DRFI=L$>eD3*xs(;OHe}Zp9DK!;g4Jr*T&By`!gZuQFxZ8l|NjD9aFVuSTuO zG4Ka=A!vc^I9;LTHv<7B2|@vnIgo15s~9rGu#c{DBjA-9O=J2*0GtxJ2W}^N7=pgJ zy|#SD_qJj-;6Y;`@XFq?so5>CPFq9z?rOkksND-K0VY6Z19uqer8!~7rHpd2>;5T3 z7<^$7L(d^2_a~Hjyjm_al?;l1pg+XQfpobI00p+FkQ4cEC~Ua%6$y4gZqELkZ$3On zBUj_~yZ9(G8jQ!6chBo?5nOP56cfNj7yiISXS~NzZ8p4K>_9!FHnP+7oJbgaj8yNOu0Mh`%dRVnsU9pYf*Eir2xqrL(W*U8A= zdrR$#U2U#|F28=5&g>$C%KdyDp$DuQ_59B+i$<9|%$wG`NEyGDkt1Te3&Pm^K9-?U zW=Da07F|y_Ljfab63v8j10&!rrq5UY9o#0}x7qd=dNMs~&AQ`NutxSKgpKGCRlG9a zVSO96eqq4rB{{Z3C6%H_6F8#R1V==R*%>=HK-`bs(#XW|QPtg0%&x33w!v@o9%7K( za7id_M<eiyk2n7vKwk6C{;INg-$L~26mP*v1)iD;> zbS#hOSK!;4QH<5sP%KQAOHTnM4ACZD|JHwi!%8wItkSB z+IzD#ZI?&NML^HKTw?@I2Y`vSy43*PsGAE{d}iL*O@Z9MehlE3PIX`nw}H0?V83~i z)$_o*6@MIL-1t3Ep1%T|tH&u}ysIGEYzY(a zgO@kMq9*Ewrd4NgHpfV6uAx70d;Xz$AMS&4Q^~^)<~Jg?8kRvG1-CstJ?lt&JvNPx zoB>AzJcKTw?o_85N!d0k6z5C|gy=wEk^3E7Skm@-RFhce!fSHHBT%)r5iNk2wDDRl z=QOdTPqXdirp08v=t~DIdq=CqP?>!kpov=cch~D^^S+#6mAT6xXl4k7&SXZ*@#)I# z+SCB^mL-Il@PV;#w+jGXL45yawYutC)vDpPJ-ZYk`Nq>VVSPm!l|$2 zER~K}xo!1eo~b4l9Bxfio2vWmR^k4?BJ{3^`<9m$Zu&$ZT(ad1cQn<> zkGPSy8fh^gFvb*r6@ufMRW4MYtPq2%&dL|(Sp6zkQdT!*z^wgRI3cq*_~MJ{auiBf zhY2ouR<^B~19-i6s-~G>2$?rdnS|m-y1Bao;^OMg zLkAB+Tm&H%#6@g%3hbUB6jdt-f(GONP0}D!5d;~^-$90| zOLm3IF)+esr1wX(x~=!3ag5)}RcCiK$J?+z<~RG>l-Q1-m!hJ#K`i_5n>WEc9@8Ex zPPD^XR^71y_nHgY8U-qSY! zK1qRy;-@S?l0k7&x;mv`MN4J!1WCX`ksvQ9(_${*9xy1wo}EHYGsL{0Qx2t~>lyfd z<0=AG>-45&*e=&Efe{-i>VB0a41lLFR@+Dbo+5vqv0y^6TuAu!k_`Gd>+qMkxHu;r z53w-%ZYPog%3$n6i-EZ({prc|danWabglcz00;7ff@uqQn_>CioMpFOzX1fQ0MdS< zJpxCPu|6dRK&N=7beV-$7w~IwOrrXxRQjA(n z%g$ep$<2uBYPX>xL#QYB(}QTv63XCH2xeCJ)49a$&-99_854A2k+4_GLaVN>qRi{t zQYuE#Ag?P;TGInTO0QL~^9GpOjow@IoXFs7rmb)sNt45Z#{GtJPBKK7*0x;1Mh-Uz z(*A|gFMKeLL3k$b5CVuXnM5l1q+Vs+JNf#DOl8Pfl5c5QGlQRsblLoNq+XqT+rN_WzV&3N0*`~tF|Aa~i!x>%vq~`Sn5^_-UoJWFvSAWzJMMPsd#Lm6IG1Wl||J*W~ESgGldK1i72gJY%ghymIK5?!X)X zci7`RzHJGNEK!KQry)g6nGQovr!><+r{2`$NQQX9r1c+W^aN+DvY63*&&{*s5h^7o z47|`b_w%k7f&=wQOP+W5I*Z-KtJEzwL-EOkaeX&0yIrGy{39H@S0cKF`qos`n zgTlgMMt@hE#~$XZ{Kmul?v<{$pH-MCVFz6IBoW#fLhqd{FT>HNJx2^rk_oS~ixXV; zyFbWFe)nzNN7n-EZ|jAan6#3fp_v~p^;l1ifdBS<1V?W~xcm2-s=|KX4cqAHRW!-b z2T$zgExC*@tY|^t=zf75U|x6sOLn+X8V=Pp9GwLHq(6h@u&Yx0{W(3UBG1$C6) zVDzEtPBRO%MY3U6TlXmt(=5gVzLo@#zJRkGmGKU0$OGmUE`+=pSO$w zfRv#4j0KfNb*0Pr$ev=p?!7P1`Ao|`Xt>n-G#HvB$z6D@!Dor6+e$uf#O_fGAXpjA z_#kefptXJipx*YqC$o4KWdrxCt-fYuk78`xs~scg0mk(BuNaeJN*#Y-#5Lce%9b#3 zxK;U{zrbG2$^J3j=?bwbkmkDmQtDeeD0r-$(fwACVfUSQMpc0`6}VwL(11Ywr+$9| z0D5b(xT8&5{8djbW?A1dLlYBCPdeHWa zHf}WXS-uc^Lj#F1ccFcAL(gXD8f~4|jXXGxM>*N+EI~+UqvJGErtaY2U~{;?i+OJN zfP|V=Ac8^AVWVKdE+fFWe!Fh9)wyy!kr^bSDR=&0X0+W+dW^7wh0V#))F}R+8Bb$2 z>_G(C-PMY01pBn{Mz&ni{{k5ET`v0iK1-j$O$!G}iCYse0l;dYKH;2P+d;>yajwp4 z13T8FC48wiMI*+{>r>$gY~*=93MO^{Xr8=IB|SHOP#G?cvoQmr@Yf2>a5m~+s06fSXSbY|=>9F$;ICI4Nm zBV{JAL!49e;q$rmGQ4*mIaZL?)0O-*%F$QC!0Hb0jd%2?Nt^#Q&$l1V#F0d$~nC5D$4Op5b0i`dgYJWl7I z^rh6Y7_Dsjca__)MT1Ln7E>6%W!cNmaU-w4_A~uEJ{Df-x>BM0s2kAB_@x7CrWz|V z6CYq2fDZv#H#c6|3pjVEV?OO+zbIwTBr9iWKoC-lcI0tbAt{hBxj z^8TYY9TB*}**wVr?bJs{gTnC2Up`CgY!X^Zl{bcfQz=$RxcLm2$+J70x|{(1C|#3$ zL<}}n8a*a-?7-0_SC_G8vek(jyr2^Gv6@iaNb*3x`3oYH`3o9(T_AN$xv=0%^#_cn zs^%p`)^MDJ?5y(yxVOfy;p)Gs`HjA+l@rUR)*Y6kWWYN6cJ5Uc4fH>s3$LArG_-9bMj|Ceol1^S!eYnqKi>J3lngq(yRw>H;aVq(GJhj4wFK5<`Em9j8CVap?lb2VU{tqW-aY*- z_7YKUuE_V-91r&3B=E{Ln{po+rvK>)lpj%QL;j=n-ct?YtY(gZDEzm$`MQfWg^f11 z3DVx8YoLc~Wi5`N&f!Q8jY}W^PEZM6J+bART?`T2-{|A?olNS0MY27n;w1_yuH0BhR%&m=v0>JhzfLtu8p zs1CRhRT0Gt>NlmlVV8i*zEsrD91KwXiR55_txr6sEw}~K{NZ2Ce4}{7dz2hos5SJz zw{H>4EB45b3|dU?IH_v#Q;2ZjyFtD3o595oKfU_m8xLrXnc&2LC1rOF!@?wx%3eW^3 zTdQmTv3XPkqChdl?(GFs%Kq;gUpW5P8|RTZkt~vJsZ#F01+xVv!f$SR=6e9x)DtKO z6i=*yJNJi6?V?k?M(jbHO}ep4sFeR^2|wZzt9S!$pF**?)C*f&5qSWAo<}DJ^6ULp zXF#qyMrHRU|0^I1IR<1T2l!UqK+eU%LOS~IK&vIa?Cmr8cX;JqQO^L27n-s<{Vi^J zg00BOx*D*!XYx3hf%9Xo>`xdezW+?%Zt<;|j+EI=?dWkP5u4`%7FBm{dd%HHThUKK z{U=!fN9HfJ(XGgG=^Lp1uGsjSSOt`$XX$iqH2y9{&xUJ23ycfH!#@vKCHNT`B>=fE z%9PDQ(*JNbtfeamqmd}!W8Nv_4t+2_TP6`5WY7;G8kW`G@D{sGYe1E~0q^K(%_QlP`tp*rF;LW5ZbQ4|kB|f@xDKGD zd=~O@vj6~g`}LI(9jA4m{DV_FUB*-hqg2PTn3ex4y1Fsf}pz;3t= z1rVH#phpw2Z9Bi)pDV`=)NJQ0P3vaU{bY7M-wc6DwwyWh7$~FqJQCccV~&?*)P<+4 zH|$2XJUPLMFHnk(iiiVc55N>m0w*-tt*JNsCzO+k+3cI^$;_^)F~e`n8*D>sC;(e^ zbQjcejlh5S{CkucTS?`26mq#hnEL_r7C?KU>>sm$MtGPh>XT!sbn&2+3AmWqUkiG+ zdUU@lt3N^3K6y?{-N!PoFfZ@d){KMx3nm#@v4B~bTHE#ka-&yM#Cb#vs*KhJ`gB}{ z+(o~oPdciA=lV0p2!Abig8k8DTFl$5vs3HPrar4nzJ{j#aGVOaYIPNGZ&kzw`jCmw zs!$hp9BeE88mL2xH(jSMcN#AFI+|Y`u;(?8sjIE5 zduLwhQWALJ`$pQk$os)FIf1tYNIn#PqKR65Y;@baez|er9>8EzzY89am_A)bpQYvHC8}ufT?-dH6h^218SP#!e8g z=xAB6p}S}C$Ix)Dii-5 z8|=`oPlLH%GPSN2SaDC#$A~xcLni@$dqbclw zKct{Qc_aA4W$^MNy>`W$YNAcH*F&|*pm!fkNh*W;m1BJ$05Zs$>!sld6bpGu%xz@` zh`~1tWpZL5^fb$RdGr?Bo6X7GR16FXR5%GZGe=Ed*9o{=CwL4WC^@o zZs*yUDJp4ak9=HcNDctQoj&>PXkHeHj~-EOLturuRRRsb{0uJV@k)R(OQ6)~33MmZ zRYMzWVLl(nSi=Na+a1E39}l#DWYgbh3Qr$;o9hVwp9f42F)^XoQ&5CWYES%LlwU&z z|AE^K=R>+SFhsLZz$IA~>dba9e##CGN*fu zd=hgZ`t;7d{IL_f#@qV~SnKsOmzqER_a_{BAC1XRzD3O zr&uCcvIT&D5WGco2MBP9VzUL5&qL7EA32Z@c}jy^)rLUx5YhcSZW*wyGNKxHf#I2* zG=Depkz%#9EBt%vRRZ_Sc39jpofP4};_h-m#ku@BY7rkS4wqbTDzp+>0No*&4*Smm zkpW9O6E}e`5De~khg%uvciaC()pg=vJwqA4;o5XvqX2Y%6kP7NLfg|&3ozw1MN0%j zQ6ZJL4;jiaRJI%r;(n-|3b`hD*_!vIJ?S=@pvcD!nXo$r$@SrO>nVJ@lAwtnHy24BD^*-aW#W!-|`bW5rbuH~KUYm2U|f zb!N!G;0Caj8<44r-zOL<-{hB;pcaDy@)fT(6#rNaV)U8VJA3AiN!mtIaXM{2KQdXe zaFivS2YG+GWsv_QXij1&j|E6kw*I9w-%*9j|FY1eBaCDRh35DCbsgDm^8@r&mH`^p ze91VkdCvErd=7npL}*+Yh&H}q$(hl#9fo~ zn?qS|tty#6Gv$W^vQbz?);C_RstSm5O!0WE?c(4)!t@>v8<;-^(|c?@Ri*_X*qN#q z3w=UJK2=pV%Z2?u-7r5IZ8n#_urZHK_4XPIBd0S#F>rLW@=!{(6Os?G?1l%_SqfVZ zLCySMTe^{nTFlsFGK%-mY+9Sn4jb;RD1WDhVwwJKY>ps1|9?!ekjyfat!`fqM9esXcX5;W97wTv%hP=|RYK&)xK5-=*MG>~<}19LQAoenKz zed!-c5F)HLksFtGmXD_5*nnkF)uHR%nrww2j$CefW!M3=kZ)?LT-nS`@P4^|1jXsk zzte}KW&9k@fKy3S%`Ys-!KS5qlFETKy54J~V4%CXM?E+v=)~7+PvC}wj#hxC0KZwe z2=bulTiqA9kTb&u*4Mx~UelS<06LcxqNy|Fh@g7A*KthATysaab^7?Afa2HQ^Y)Bl zN6t(*KE;5%E7?x>Pf{g@{{(pY_fk8w}D11KXqu_y=PtOTuXJICu=4_4DLM zlAiKiEUZku2H;&XU>$eeP~^Nf>>EBrE?5wRh6Md(-mPMoEC23{p$>x@Up^z+;Z8p<-KVG*@0tr@9>4~kh86&mS&E6(#h)EIn1OlQuvz+Y1EtzL^)r9=#%*0(qZN7A?DVmr2|q9@It)g`y8R zAIEr5hPLg5$?xC)&3qns-Rnx1?FES1@Y;>p)WJ1eli*a8^i~_t-xnpw9n7f;PRDAR z`JAk9wQjO%GiG+q(w{DB*jdN7A5}Iub6D-%b_CG;pyXksbSG&hn%bwxXUpBmXur}xk8LscBF;)YLGCZ)qowf#lLFK%mbJ*;k5h$ zWuU}2J7>Ghr;nukrZ&Aknw4t|el+DW$kEDU>G>OR_bpukhfFMX8>s`? z31>GAm_bUpZ+{N+HVmD@J}s~1iJ#Br53|fdQm(f0M#!vd-7KOLiUtQTyelNP-*TcH zlVr=ekkuKdfYHQ)L!xiwgX$%X6J8U}!@gIJ$O0KrN0in2JY)h$fp1O&%Wcfy!D_Fa z;I-5qG{TISdEW*#OzlZmn?wFH*z!F{-(X z>PDm^?*pU344xh@*jp$-_y$GEAHr4}D@fV=?;PU7-39FJsE%E;go91r6uvwYnrf>ct; zwB{8~F{Y&tTS4xc?YF&x*&+$J;0#VWiFmJAKqVwJYAC_HdKjBKp-u`+v{lsO1^bqj# z54UI!gHJXg=tBYM!YOJ%T+05XDZ0e~H`(SAvJ!eP89YDXX|(pkqhF6R=*Z8Z*my|| zwA$eGK1W5#1QMqqAzb}IEEJSllPg6ZY0+RbL`4s~e$T4ePLrw!_`aShy|$CVK*pr8 zT(_N=E`pZ)#cton6Js4s726k(UPcB@63V8gQAd>F(HA7WrQ-{)OdG+jqTG~^wE%s{ zv=i8J@f4Q9(V+?wxTMr!nIXsm+Fz{OvgKe~ zQt885mhK^&2d9O@-6ZeJRH#MG+%+ovJJe;ZBgS>vZNQR2^+9<#WoG#d8C1Wtb^{~j z`d)39)#rB?aGVIKz6QHCs^DY}b;j;m_MXP3qgTTC8pz`~87J87B=2l^x*Aof09jK-AZwiL$+}*iOC+lTng*ls&NSr>M(@tq2BSm^C z`g}1SG%6*LUV%|Hep(MkV8A<`w^IuzKri#pIS5beU?4^fEN-d6^gbWkVZ11SmF)${ihj` zbEopafxS_EBO`7=?L=5fiQi%;24I?4pk##m$8FeV$Ieoz=C_yj$#QjNC60V~N_iU# zCe!wozuW|o>X;(qt3`oUT3yd_Mmkb(H8MX7^_|*-qHv+_=;fii$k#GV*ht-Y=Jx;( zWZubqNq7{IMr1;f z%CL^lIS4ul27f^hitonz$Z15uY&ytz=(0nv0K|Ny|B7|Utyok+tn|HG^HtXau4&t% zzc7U6eBLo_!2I110!+1QpQ>fmRwd4cLKf=1snUSPPST&WM^sE0?$Y zVHKm4bk~!B?dp4O2Fv!}$GdPL8aU-2{|N58=~R~K3EDqM@I7GQTAtPALRLKV=-b32 zHs2{uBuKBoNU_>-#34{xtl<86HR>mq;oJ9y2aa_4=Do;e5t7<&xz^rSk#cdDjSZeP zo0=RLVZ?^(HwA@Ji|sGvaPzBS)NP~6a&SqX1@nsM7u~h;#-lDN*Z-o5VcTcl4eWm4 z3i5!jkpT-TF6OXy-3MxT6~U0SLD?vwkjnggF{%T_`96`W2VqW5K{r~T;TA}VWy7_O zQU*yysnbo!plgV-2w{fm#UlLjg5DEx@z(7Sh6r^ctl+~j11X0TQ~`DFZb7w>4VpB) zId{mC9MRCBxP504f)V2mf_`$5)nlqmSYt<;nvW0M<@{sj6)u7$-q3@LqlP2i_mxJm z?5_xRM&4FN`}~O@Ry$&e5AGJD(GG!_pi-I-YNzQ2g1WnBNV5be5v=9g+YJ$^z77?} z-n5uo&~MG6<<9&icDe@+6GwqDj8VdL09hW$<6B$Cd)_3W3(UlzRedeGE z2QwX~$J|K!u?F+Q2RC*?udoQmgNUHa_+pOqgnYn~HK*g?5W7vTjxnIaor?zBh3MYM zHv~pqHBfS?;@GY4en8+%zrOV2Q*}0j^pw=y!TwlN1K?soR;^~+(m-<=J1Bh=nG#EG>ipk}t{u(T;t zM8e4>u+he>>##zRd*1Cn^NQl@GYL_MQrGvYK>hWFPWf=>TWG09L-HWvL+Pu#SWm|6 z%85X8p}Mk20mzt$C+}cY=IwTMu*u@yYOw^9y*>xA(l66X*%LlRmH$HP&=i3$ua_HM z5vitu$g5Q7Dm-r=kT+FigOq-ny%~X8geptDE-ZS!IQUKg2m~zS4Pi$+-xAke=$8HJ z6B}mq&zgLuu`5{3T{Da1h*j4 zUM{FIuhqe^%X1J!VU~=oI&=cY0|ZF;(!m%b22jy2j%wOalCoRR@3`v&Ka-`_r=xrl zC>^>gz+sFsZFWJ`0HcdbN49EBEP=gXr0d=+5oryxzCUi!TK*DGC;@3mSew|TUyS15 zfQ_Nsul58=#1Z5k-jlu|ssEKK$W>S~lY6d%xw{0d;B4)O% zOzh=9Wv{ig#CU;Wjc^&hJq)46tXZLIGVW}LESwTE)l#>(KYB6ga)@Nao_Bzkgj}kP z0<3*|`Qfgo@gXy)TGbhVv&HsEm+?!N%qoDFC|6IaQKTi~Li*YVuVjC(S>j_4f@T#M zE&S0$JMX7?uC`tw}=t3=Y=ka8Pp#>{IJ_SL4RaJMMn+IHiDJ< zuc>G67RjrEDq%@n?se=yqE>y;(nhKop_bj@OYGi%qyYq8KW%PH^ujx*D?+n?`4r?b zGRy@IedLih<|zu%$;8e4;wXPM%ZyrF89>M5lKm~KSuP3Yg5w7%Gjs5z(dI}N+s;&} z66NkMeBODA+1+p4V)l(}i-6hVTp5}}qA85GWL}FTeFZg?kWS67f zM0eO3CQeF5p?Gj)Uu^Cx;Uhe|eHn+BLA4#c31lvZy?Y`znKk$(S zrv}~EXI((jp>1ON0Z$N5`%SkcV8T~`n0!Gc%bdPB>JmTrSWuB<8>lN86(QF&2;BUb zC)$#bK=;!@EH^!4a=@n+W;257(QNVo+qH$$_(k*fcVR0@;Fz0bb>OLwg@v)1QG5p{ z1Lw0Vv)yNHFiV?B4TtA?spTtTHh(HcLIMX_u~ zB7#6dEcYhVZG-}q68E|2?x))lP|T9EJuYtL#ig4bA5uNK^+-%CJrn$w9j(?64+!Vw z*^x!BE+WG!_mx7c{J1S6uR7HVRfu2yl-(%rHCNWBkp!ROcymKXh{~L~&C1Tl??PFg zPje!PH{$b5h1es6lLhXlOIcYe4{$xn?`boILDel*+;Ola{;I)(1JmDvo`u8Z7**Do zhW%+_OdBGni~_o==-@N7&LmbQq~oY|8+K3{m#Zth+}Exd?l)fSi0xQ!u+?UoM=fUhGOxl`6hHh306*f6i|F+)ak*q?5E)%NQ6HxiM!vPC@A7M zC;-TlT8~xC_$a0ud;DcOGK1eJkD+8!0`RnMBQnw2-`*Cd$nM zN`i0bb;xCQvs%8%4Hi?{rE!<|SN9c3Im@&fP&rW&a$iK(reiA-GnO6qbt3%SLtDA4 zSiz)m&NScN{s?dwXnTu61(q53%0u#|nT{y1tB#(lY7_V^dY_eQ{Ij~rvoR6ELS#== zLgzEg3#eV@_z)d72CxHzGakAXhih$qYiwoYzNz}afUFolF8L6B5IY4lSBNjyPz7}_ zYb;yF$?T;H2{B$J=hi{55yY~`f8RA`Yk9l1vVP$9!%hL3Cp@9~s%vut&J>6U z_Ylj@)V0a@jb|F!^v(dNvt8StJlHrDR&;Bzx)Gqe>5F9i$ms%P#RR3L+dJnyfW7u! z+Hl_Uf|D@~Z3mS%Kyn#52{bGWU}7=7yVmS01~!{y*)Vc|4SD+s9jo zNQk(*3AeQkMGR$$EZMUq+o0^*l&vxLkR)U$Mb^kNMY0VZDp?a^5K|*$iN>D&Jucn# z+|T`apZBl#{p)@HOEc$O*IaX+$9bIR@%w$xM3_s@_GX1)->e_@AYaL_u!3oqKHxIB zCvEi`pR)hEF7sD&-C_-?>6dN&`DrYKAFS+r&3R6*AWJfIc_dClHD+|WA$&~Hw3gRU zZNY^Wv*K@F(_(C6R42@H802eJsSLUy3t2rWi7M)Lg6EXwCJ zK_?`={Nt2kX z{oyw3n&KxX!{~>)E>2igs&Bkq>ww>sIqDel)D?6TuA|gVwE3W~MCu;3PiG?C3R*T& zbK%ml)xa)h6useMF|INo&`|Cq8(fg^g5_#$Y|qvD7JUP95AI9*@rQ&*0YpRdjlc}< zdlwR5SS1|lZ(&x2C7wcO4WV#u;EtZ$lrnby5K%LVj_e}i{32SYQ~>I)EXrG}y4(Z$ zD@49AgLOmPhQ)gw*~J%}UYCzck0tj8p-X8ZirGN=!e3iBxkqu_MiGpbVh_cmD2^8E zj9rCu_oFm7r_N*Nxj(nvWr9yOKtA~o!ZB~0VlY>Dj?cF{X)WNE_h?~_p{7^rEjk`^ zNaixk(y6)Gqn^q}+!y9WDetD~tDY3>I!6a)G=?{=)-x3it;g`q_N)aWA?256wHNJ~ zNsTYBu7R2fhD2$r+D@7)PloZ_Xy0a8sez9xQB7K?giS+gqxlKZ8yotY845-oo>RLr zIX0&E0o+W)wYdTj;G0hL)m8ZA_}0P}*UBBcT&Uwqa>;E?Sa1ApR!)hRl z)VjKqzEplaD42mPyE@+W#M<5NouF90USAUj<~B?pzHdCLZ*GbaE2f^DjCGQ{huG4s z9L4`245ZZxE?8Y0V(Ccex8l7x2xg1xSJSIv_y}5Iuf}Grb3@CU*tMzrbj`g|z_PUH zE^5r{wz;C<#ACq;9heh0+j9Q^I-r2PfG|6(o83i=iOGC8_l%dIMbot1oLFa5n1_5q zN_z@liSJ6aTc4GonDb@&Z}k#-Vh~2GrBJi01oB2Vj);G>ty}2m&$%C_`@y zE||~dh7v6z$NC<%h<4?DpL+y-l$PZO5W&6w~1Qns%>X6P%8rzL%wYniO zjB~hJcb$|A4lTjVZ2{5~we5>=miEC#z~}iKe4g_`WuEVo9LS4CnGybErF%(7fr%Hp zB14P!&Q)+aElk)vhWCHh%h=CPdm2jgHm-;q^n>pa=fgU-df1Jc#*Nj4x3K~e2tZBz zJR&M}0ih9oZmuY{y23UC?u%ac*adEog#|4|51;ZLOj0BFq}JKCG36KU59oCs?ffE2 z19zB<+~EtTfPS=s=U`U)4sJ2@$ieJrkjI@{V?UGZ%hQ%k85poLOVN~chx*Vk;)QjE z(RoD4d|zIXu#3|tHpLsWTJAvkxSyr}wDu1|!fw~kO`>rPA95mYZ_b_ai&e4_>u&>e zMmY52Th+oac8y0p*}Ds$-=)|J=uwXgHJD06P5MkS?nqlaWio9jPKT}2>6&NVseEt| zLv@lvnhDKe5ZvRinjukVc*#3>R@4o~NK>aye;>Hwcg{v{m{dLYn06ktR>!(LmUMh`0)M61=|mzkpO!7I1)g^4R)@EQ})o8Z1oI1wc^F`>mY45HeQ1r2e(4?_X|3O z8kyQ9pf2ybbDy3p>^Pz?EK!5yklOwIQ~x4HQ~cNEP=6^q8oLcU12jYR)ZEvhYGU&R1d0OxpYZ@Y z9Iidkp_jFK%YP)Mh#*oouT8*Cgyp~rMQv?qnvYLvsV}j_(Z(;!g5$V=T5(X_lz81^3^QXHJPJDJiUJsQ!a&@{V4(iCEVI*aW$!I;F4a#yc}~h zB#_Y-j_P=`$}ZE+ho>F%%+q1HDs=TtR034rPy-zU116PVsjZo2geswec^ZH_hMFsi zYgZk0x^$$-R6^5sMV&vDaKz)ay$y5gymjvHWy9KUerzKGCLQc=- z*9ry~lLBDPQgSAwZOK%=Pxaw^%3%+>GsWCbCHm9mS8LywV`#jm%Z@?5M~a^r(wMfD z_MCiwv~}nP2}nQQv~3=i|Kck6MO-1mu0h?2BnYN?6#uVRxmnDOrwesBq_trWo_)s_ zwq(+gS#~$d@lJJ4ed9Dn_f2e>8)7~<^}~E12um35hg{Kl7P$Lo8afY4m`Eys)7rAr|N{FPFP8x|PV*X@pO?dEw_F+9YN+{wu zVN*0=qFaOL6Z?Z=AeO>HmQWKnGgqU-2jK25-K!yxhvVN2(qgnTtDp*#pPwJIGXKSg z@1Ih&V}1+>2d^2Zc7D>sxA`7)ffN2S>bi`v8M$XF#ITmz&p-(i#77NEls$GfVnSdK zx2>p(WkpY?Y@LH@_^%5*!F=LVjfw|!?E8mZPr+T9BJ1V7CcosYJ#?@w(GT zYJ&2Taam=v`s0Q+f`)4uejQO$69Rr*jfZdB?I0(rOPFt$Xh!6|ctlJ!<+FL)hKATqYHp6t*hL-dVj+NKbM z2YB^9ZGr_V82={Db3*S}x%{w$<@FHaD?0b@x?dgx&O$H|8KcD^NSj%0eI#&a>QZW< z045m7)$q*qVH1eGg7<{t`JyP3?7LT6M^j>EKx!nMum9U!-B@VlSfAA-U$ z`7E*aS4j-?-pp$#_#l)h$_{L%u1@)E3SzVBXj6ixLiO6-vw{_3e%9*~qB-FDR}!Q7 z3?&zuQ*TEZVcU{Bu{&%`>YXfZkO^ST!|_TWxr{LC&7qXEaEN*ti$)@d66s0#1`gTe z&BfZ)PswuU*aQ)&28VJqS5D3KY<5Pv6+uRM7fmWdTFOGp@Lw%OlXWrXpjqAw zcI)ZkEgw99|EMei&e?FpxLy5X(ga(^xQV)=%e~+U2K*ng&v&YCh(Cv5?2`xPv zbfXm`P+jZsU}1Z80Oa|y!oV_owon;1VDiGcGIeh=Fuepj9$YA_?OG&bgC}us`HU}!S$3eX) z&x{sV*zaR<{x?{qi;s`8;hL$BBK)eq4B}MeCzj7O&fiIHSI?$!(z@5rbuJ*DjgD?1ZD{eSp_K*^G2x`Pr#i4|iepv79 z%!EEG6Wn`;vPZy1&cXz_xp@c~AXfY{WY`7=vZP2~ipY79!XnHHLpWwib-U!62icET) ztZ}`JDdHamJ3wl*)YuhP@QF&_q2HO zZm$Jnyb!;q(Q_K_aUz@0B$6&mbj1J82fH17Hw!Lm8Xe*C(ag^O(0QK)y%m{WdSm+4 z9*`ZC=@lUr<|Y3$YI0BK8si2D97q28DlLf0cI{MKJ1soRj0Gd_MFum>g|h(vlqUCN zwJ%gi7klm4!NiZ85pY|awD9*&JBTa+Kd$Lp z?}OQxVP~u5r4S-d+dV@YHE76iXfWPR6|2wnh}V;mT*fiIT`pkn+?upu^>@_7ZixH# zS398uK}}Mt)dLtoa$4DTrrgfL0Xs2lQ&bw*s+k%%IIKqKN`UngY@L8|%hNYvB5Rpn$h=|y-)qE*jL48h z*)+!r%vo$LT4ZK+1L>j?!IL-fMc*Yo&4{JN|8cL~;B%e#RIt;r(!*!(0N>ZYpWu!6 z-JCBPU7yz|Dd=jgtX}#YL>aIfyiAXDIb`aWn^LR?gOu|(Hxxz_))@C6CEq;rH;aYM zL|0CU*R)BBw7YE$sb+I^3N%iPt&bFf5f4ELcJ_+*=E|4RCk4fBa-b7VVfvn=%ZbZt zs`cZ=5UxKO5;0c-e9Z(=M`?LO(#sViR&zq-IEdS%5HpV*2!HK(2A(qmTuixkK_CVb z@Zs=zlPJ&wMm7q2=KPfu=-2%(q(H5uWrQ}5AS_&~r(aabnDi>&KkX2^@9-Ie)qIzj z!UMmZ%2c1JDW4U@Ewin>7G3iBaxwPn0W2ufvOTAIN!)sFR131&^6T7B(pN8pALs+h zzk#8l8l>(lE-3A*(elNariS=C^dd~g1gZXy2`!PV+du&GC+fx9nXaM3LibPMm?=YU zI5^aW6 zeOuR>MN3A=Q{`8L!CFvDzFD69T3X)A*{AYbtt10)x!1Il+d}8sfrxP{-%+**i3SdO z3mhH(FMI8-%)hjYTT7Uk%Uy0iB)IwQHI6;UK*AKMnIe`` z9F350CMO-C4x|(8Pwef*m7IlrXTeaZiTaW-l*2GDAcE((x8TQLnO9bc)~@ANc43BW z$Q!buWvHM(kmj|qNW)Qp3=@-N;q@ja@g<1rUy5tkgo83rO-n1X&FCugthVKEe_Ach zcupGNJ1~UVTQ_dLqz0i5axQ?rIFuu`j-=rn)165K4Y{@crP!M<&kUZs_8t(Sdt@Me z@VOLfwB|<+<<&@xugL!jvMye`^yw%*;jnGVbz;kb|7kOw$jL$ z?1|$90Hq;*8QhTWS*F~voV*RG{L{N#5WC<%fv3jo=o|0t`$6?Uq*9(dsR)7j<|z*W zgdf-l{J_SGx1P|F8A4m_$4J2q>i_Lm z4xIf(;SPELKbkw2_D^xivALV{|O0Z z0?Kp<>dVfz4HQQwCmWD#mOzOWu!*H+{wt#pk3OF8knQ$^y_Lr>Ssb1liPKSsBHGAR z?zQB*9H_TzXOAA*g){j_If#b<>hKs0@&yhgAK^>BGPIiV}o8s!3 zGawkMMtE2EN-umu2(k#p8ew|AU86qjx9S115B(81prfSkm|+sjcf{w1Y^{Ke-j5ce zsQ(${n~7JWq*V}~o7|wlE3;ID+cgU3tt^dIa)n}FA2e!0u;d7S{=ipit%FchHys&& zZ27gSVX+7AZwOT@ZsOh<38ERF#cEfQ%nGl7>L)w-wG?D!3x2gYvn*;?HF@Vjk8Uk7 zsQ4k=G2jo5HNdKuVAu%7aEYK^`^*P{{;3|Xm{oynlRmvs+W4vOYIK5Vz)EZ2&2vA@ z!`&D3snFh|2e8^EB;M@7*ctV${aR5MXM{m@v zJskJZ20`&xNzG}d@A4oa(yu=nRJXP9Ez{!EB-V&ckKpBb347bAH%@NAw`zvZHb-n^GBD@O$#2ceyKO8I zh294bOJ;G;#n-)E2F!4~>G*HJA-f)Qa_$x6W8r*{>)m!CK>hND%(d$Eg_qo$7ppub zV7BlPd3u275n~K;_)MlxKBN3Ph05fF7fV%%6wp18B6xWiD_P4`ZV>qU2w4r2pd07^ zv=`s!Ihqvq_-ms=f5G@<@UtzT(T@x`;TM-E{!oZ5|8TPGeBeR!*ty?n#~*Lv{idA| zH0?y+vn*l`;DwtH**&O_>)uOsp^nt!mo0AFMyVwU^b0+;Za#qNz`^zsp%JRyQ?jq- zgBX3!Azb;j!$+>s*L{C2N}##w>Dg%)@K*W@YyxP|YYL=fME`cGVeR4uX}Ol@M1g}8 z<}URu)`TIQ|0$5HZ3gTU1B*o_@65nqKs6RBRj;iO-W*8Mn~eFN!l3})3d}}}9#h>L zWU>rJNzWkD-Ca)UuGD3m-b>^_1n5W>BammQ7qd{is2!T>48zgg74bk!tH_Zuy3B$K zJbNJzJrM7ZWxQ)u<3b17Z>is|#*hT~l2(NHpJy!isBXbi{_0Aa`9_sd9Z5g{Dk@r0 zWiPVVdJJEXa8tt4GZmi+?)MgVqnbr_%Eo^6`M8#b7%3?s%jdaQ?=7P{X5+jn_1C~R zAzHfgFF0``#3Zw@r21bmX(tT%wqH>Uj^Z*yLfc{Vo_~fm$m+#tej@1MWD++(uC{w( z4{`pAVqofM<%Cy$zNtw9q8gEGbM03!+yOV@{_`{nxE#-aZsb2t Date: Mon, 1 Aug 2016 19:24:39 -0400 Subject: [PATCH 12/16] add updatemenus jasmine test suite --- test/jasmine/tests/updatemenus_test.js | 413 +++++++++++++++++++++++++ 1 file changed, 413 insertions(+) create mode 100644 test/jasmine/tests/updatemenus_test.js diff --git a/test/jasmine/tests/updatemenus_test.js b/test/jasmine/tests/updatemenus_test.js new file mode 100644 index 00000000000..f17a60869f1 --- /dev/null +++ b/test/jasmine/tests/updatemenus_test.js @@ -0,0 +1,413 @@ +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 getRectCenter = require('../assets/get_rect_center'); +var mouseEvent = require('../assets/mouse_event'); +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']).toBeDefined(); + expect(gd._fullLayout._pushmargin['updatemenu-1']).toBeUndefined(); + + 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(); + + done(); + }); + }); + + it('should drop/fold buttons when clicking on header', function(done) { + var pos0 = getHeaderPos(0), + pos1 = getHeaderPos(1); + + click(pos0).then(function() { + assertMenus([3, 0]); + return click(pos0); + }).then(function() { + assertMenus([0, 0]); + return click(pos1); + }).then(function() { + assertMenus([0, 4]); + return click(pos1); + }).then(function() { + assertMenus([0, 0]); + return click(pos0); + }).then(function() { + assertMenus([3, 0]); + return click(pos1); + }).then(function() { + assertMenus([0, 4]); + return click(pos0); + }).then(function() { + assertMenus([3, 0]); + done(); + }); + }); + + it('should apply update on button click', function(done) { + var pos0 = getHeaderPos(0), + pos1 = getHeaderPos(1); + + assertActive(gd, [1, 2]); + + click(pos0).then(function() { + assertItemColor(selectButton(1), activeColor); + + return click(getButtonPos(0)); + }).then(function() { + assertActive(gd, [0, 2]); + + return click(pos1); + }).then(function() { + assertItemColor(selectButton(2), activeColor); + + return click(getButtonPos(0)); + }).then(function() { + assertActive(gd, [0, 0]); + + done(); + }); + }); + + it('should change color on mouse over', function(done) { + var header0 = selectHeader(0), + pos0 = getHeaderPos(0); + + assertItemColor(header0, bgColor); + mouseEvent('mouseover', pos0[0], pos0[1]); + assertItemColor(header0, activeColor); + mouseEvent('mouseout', pos0[0], pos0[1]); + assertItemColor(header0, bgColor); + + click(pos0).then(function() { + var index = 2, + button = selectButton(index), + pos = getButtonPos(index); + + assertItemColor(button, bgColor); + mouseEvent('mouseover', pos[0], pos[1]); + assertItemColor(button, activeColor); + mouseEvent('mouseout', pos[0], pos[1]); + assertItemColor(button, bgColor); + + var pos1 = getHeaderPos(1); + return click(pos1); + }).then(function() { + var index = gd.layout.updatemenus[1].active, + button = selectButton(index), + pos = getButtonPos(index); + + assertItemColor(button, activeColor); + mouseEvent('mouseover', pos[0], pos[1]); + assertItemColor(button, activeColor); + mouseEvent('mouseout', pos[0], pos[1]); + 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(getHeaderPos(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(getHeaderPos(1)); + }).then(function() { + assertMenus([0, 4]); + + return Plotly.relayout(gd, 'updatemenus[1].visible', false); + }).then(function() { + // and delete buttons + assertMenus([0]); + + return click(getHeaderPos(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'); + expect(+rect.attr('width')).toEqual(width); + expect(+rect.attr('height')).toEqual(height); + } + + function click(pos) { + return new Promise(function(resolve) { + setTimeout(function() { + mouseEvent('click', pos[0], pos[1]); + resolve(); + }, TRANSITION_DELAY); + }); + } + + function selectHeader(menuIndex) { + var headers = d3.selectAll('.' + constants.headerClassName), + header = d3.select(headers[0][menuIndex]); + return header; + } + + function getHeaderPos(menuIndex) { + var header = selectHeader(menuIndex); + return getRectCenter(header.select('rect').node()); + } + + function selectButton(buttonIndex) { + var buttons = d3.selectAll('.' + constants.buttonClassName), + button = d3.select(buttons[0][buttonIndex]); + return button; + } + + function getButtonPos(buttonIndex) { + var button = selectButton(buttonIndex); + return getRectCenter(button.select('rect').node()); + } +}); From b412398eb073b028f7f742d4f54a12c3920750a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Tue, 2 Aug 2016 15:48:08 -0400 Subject: [PATCH 13/16] test: modif update menu test suite to pass on CircleCI - 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 - must compare item width with a tolerance as the exact result is browser/font dependent (via getBBox) --- test/jasmine/tests/updatemenus_test.js | 102 ++++++++++++------------- 1 file changed, 51 insertions(+), 51 deletions(-) diff --git a/test/jasmine/tests/updatemenus_test.js b/test/jasmine/tests/updatemenus_test.js index f17a60869f1..a657da19ffa 100644 --- a/test/jasmine/tests/updatemenus_test.js +++ b/test/jasmine/tests/updatemenus_test.js @@ -6,8 +6,6 @@ var Plotly = require('@lib'); var Lib = require('@src/lib'); var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); -var getRectCenter = require('../assets/get_rect_center'); -var mouseEvent = require('../assets/mouse_event'); var TRANSITION_DELAY = 100; describe('update menus defaults', function() { @@ -201,27 +199,27 @@ describe('update menus interactions', function() { }); it('should drop/fold buttons when clicking on header', function(done) { - var pos0 = getHeaderPos(0), - pos1 = getHeaderPos(1); + var header0 = selectHeader(0), + header1 = selectHeader(1); - click(pos0).then(function() { + click(header0).then(function() { assertMenus([3, 0]); - return click(pos0); + return click(header0); }).then(function() { assertMenus([0, 0]); - return click(pos1); + return click(header1); }).then(function() { assertMenus([0, 4]); - return click(pos1); + return click(header1); }).then(function() { assertMenus([0, 0]); - return click(pos0); + return click(header0); }).then(function() { assertMenus([3, 0]); - return click(pos1); + return click(header1); }).then(function() { assertMenus([0, 4]); - return click(pos0); + return click(header0); }).then(function() { assertMenus([3, 0]); done(); @@ -229,23 +227,23 @@ describe('update menus interactions', function() { }); it('should apply update on button click', function(done) { - var pos0 = getHeaderPos(0), - pos1 = getHeaderPos(1); + var header0 = selectHeader(0), + header1 = selectHeader(1); assertActive(gd, [1, 2]); - click(pos0).then(function() { + click(header0).then(function() { assertItemColor(selectButton(1), activeColor); - return click(getButtonPos(0)); + return click(selectButton(0)); }).then(function() { assertActive(gd, [0, 2]); - return click(pos1); + return click(header1); }).then(function() { assertItemColor(selectButton(2), activeColor); - return click(getButtonPos(0)); + return click(selectButton(0)); }).then(function() { assertActive(gd, [0, 0]); @@ -254,37 +252,34 @@ describe('update menus interactions', function() { }); it('should change color on mouse over', function(done) { - var header0 = selectHeader(0), - pos0 = getHeaderPos(0); + var INDEX_0 = 2, + INDEX_1 = gd.layout.updatemenus[1].active; + + var header0 = selectHeader(0); assertItemColor(header0, bgColor); - mouseEvent('mouseover', pos0[0], pos0[1]); + mouseEvent('mouseover', header0); assertItemColor(header0, activeColor); - mouseEvent('mouseout', pos0[0], pos0[1]); + mouseEvent('mouseout', header0); assertItemColor(header0, bgColor); - click(pos0).then(function() { - var index = 2, - button = selectButton(index), - pos = getButtonPos(index); + click(header0).then(function() { + var button = selectButton(INDEX_0); assertItemColor(button, bgColor); - mouseEvent('mouseover', pos[0], pos[1]); + mouseEvent('mouseover', button); assertItemColor(button, activeColor); - mouseEvent('mouseout', pos[0], pos[1]); + mouseEvent('mouseout', button); assertItemColor(button, bgColor); - var pos1 = getHeaderPos(1); - return click(pos1); + return click(selectHeader(1)); }).then(function() { - var index = gd.layout.updatemenus[1].active, - button = selectButton(index), - pos = getButtonPos(index); + var button = selectButton(INDEX_1); assertItemColor(button, activeColor); - mouseEvent('mouseover', pos[0], pos[1]); + mouseEvent('mouseover', button); assertItemColor(button, activeColor); - mouseEvent('mouseout', pos[0], pos[1]); + mouseEvent('mouseout', button); assertItemColor(button, activeColor); done(); @@ -298,7 +293,7 @@ describe('update menus interactions', function() { Plotly.relayout(gd, 'updatemenus[0].bgcolor', 'red').then(function() { assertItemColor(selectHeader(0), 'rgb(255, 0, 0)'); - return click(getHeaderPos(0)); + return click(selectHeader(0)); }).then(function() { assertMenus([3, 0]); @@ -312,7 +307,7 @@ describe('update menus interactions', function() { }).then(function() { assertItemDims(selectHeader(1), 179, 34.2); - return click(getHeaderPos(1)); + return click(selectHeader(1)); }).then(function() { assertMenus([0, 4]); @@ -321,7 +316,7 @@ describe('update menus interactions', function() { // and delete buttons assertMenus([0]); - return click(getHeaderPos(0)); + return click(selectHeader(0)); }).then(function() { assertMenus([3]); @@ -375,39 +370,44 @@ describe('update menus interactions', function() { } function assertItemDims(node, width, height) { - var rect = node.select('rect'); - expect(+rect.attr('width')).toEqual(width); + 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(pos) { + function click(selection) { return new Promise(function(resolve) { setTimeout(function() { - mouseEvent('click', pos[0], pos[1]); + 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 getHeaderPos(menuIndex) { - var header = selectHeader(menuIndex); - return getRectCenter(header.select('rect').node()); - } - function selectButton(buttonIndex) { var buttons = d3.selectAll('.' + constants.buttonClassName), button = d3.select(buttons[0][buttonIndex]); return button; } - - function getButtonPos(buttonIndex) { - var button = selectButton(buttonIndex); - return getRectCenter(button.select('rect').node()); - } }); From b8e80fd63ca2b9e5e4b75c02c2be4d72b40c6d89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Tue, 2 Aug 2016 17:24:23 -0400 Subject: [PATCH 14/16] improve 'object constancy' for update menu full opts objects --- src/components/updatemenus/draw.js | 22 +++++++++++++++------- test/jasmine/tests/updatemenus_test.js | 4 ++-- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/components/updatemenus/draw.js b/src/components/updatemenus/draw.js index dc0e148e272..f59a7a05119 100644 --- a/src/components/updatemenus/draw.js +++ b/src/components/updatemenus/draw.js @@ -108,9 +108,6 @@ module.exports = function draw(gd) { // find dimensions before plotting anything (this mutates menuOpts) for(var i = 0; i < menuData.length; i++) { var menuOpts = menuData[i]; - - // often more convenient than playing with two arguments - menuOpts._index = i; findDimenstions(gd, menuOpts); } @@ -128,19 +125,30 @@ module.exports = function draw(gd) { function makeMenuData(fullLayout) { var contOpts = fullLayout[constants.name], - menuData = []; + menuData = [], + cnt = 0; + + // Filter visible dropdowns and attach '_index' to each + // fullLayout options object to be used for 'object constancy' + // in the data join key function. + // + // Note that '_index' is relinked from update to update via + // Plots.supplyDefaults. for(var i = 0; i < contOpts.length; i++) { var item = contOpts[i]; - if(item.visible) menuData.push(item); + if(item.visible) { + if(!item._index) item._index = cnt++; + menuData.push(item); + } } return menuData; } -function keyFunction(opts, i) { - return opts.visible + i; +function keyFunction(opts) { + return opts._index; } function areMenuButtonsDropped(gButton, menuOpts) { diff --git a/test/jasmine/tests/updatemenus_test.js b/test/jasmine/tests/updatemenus_test.js index a657da19ffa..72531cb094d 100644 --- a/test/jasmine/tests/updatemenus_test.js +++ b/test/jasmine/tests/updatemenus_test.js @@ -167,8 +167,8 @@ describe('update menus interactions', function() { Plotly.relayout(gd, 'updatemenus[0].visible', false).then(function() { assertMenus([0]); - expect(gd._fullLayout._pushmargin['updatemenu-0']).toBeDefined(); - expect(gd._fullLayout._pushmargin['updatemenu-1']).toBeUndefined(); + expect(gd._fullLayout._pushmargin['updatemenu-0']).toBeUndefined(); + expect(gd._fullLayout._pushmargin['updatemenu-1']).toBeDefined(); return Plotly.relayout(gd, 'updatemenus[1]', null); }).then(function() { From 361025385ff53a21d79b5d6961529f36b4d1ccaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Wed, 3 Aug 2016 10:56:43 -0400 Subject: [PATCH 15/16] improve object constancy approach: - the '_index' field now 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. - the '_index' field is set at the default step -> no need to rely on relinkPrivateKeys. --- src/components/updatemenus/defaults.js | 6 ++++++ src/components/updatemenus/draw.js | 14 +++++--------- test/jasmine/tests/updatemenus_test.js | 21 +++++++++++++++++++++ 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/src/components/updatemenus/defaults.js b/src/components/updatemenus/defaults.js index 591d88f10f6..3c0a8a6e070 100644 --- a/src/components/updatemenus/defaults.js +++ b/src/components/updatemenus/defaults.js @@ -26,7 +26,13 @@ module.exports = function updateMenusDefaults(layoutIn, layoutOut) { 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); } }; diff --git a/src/components/updatemenus/draw.js b/src/components/updatemenus/draw.js index f59a7a05119..cbab8c534b6 100644 --- a/src/components/updatemenus/draw.js +++ b/src/components/updatemenus/draw.js @@ -125,28 +125,24 @@ module.exports = function draw(gd) { function makeMenuData(fullLayout) { var contOpts = fullLayout[constants.name], - menuData = [], - cnt = 0; + menuData = []; // Filter visible dropdowns and attach '_index' to each // fullLayout options object to be used for 'object constancy' // in the data join key function. - // - // Note that '_index' is relinked from update to update via - // Plots.supplyDefaults. for(var i = 0; i < contOpts.length; i++) { var item = contOpts[i]; - if(item.visible) { - if(!item._index) item._index = cnt++; - menuData.push(item); - } + 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; } diff --git a/test/jasmine/tests/updatemenus_test.js b/test/jasmine/tests/updatemenus_test.js index 72531cb094d..8d9f639db33 100644 --- a/test/jasmine/tests/updatemenus_test.js +++ b/test/jasmine/tests/updatemenus_test.js @@ -194,6 +194,27 @@ describe('update menus interactions', function() { 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(); }); }); From 42d40ee08600f3df20a45b701478e04dc4f75257 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Wed, 3 Aug 2016 10:57:10 -0400 Subject: [PATCH 16/16] rm comments (moved to #810) --- src/components/updatemenus/attributes.js | 11 ----------- src/components/updatemenus/defaults.js | 3 --- 2 files changed, 14 deletions(-) diff --git a/src/components/updatemenus/attributes.js b/src/components/updatemenus/attributes.js index adac64a2048..a27157200b1 100644 --- a/src/components/updatemenus/attributes.js +++ b/src/components/updatemenus/attributes.js @@ -48,17 +48,6 @@ var buttonsAttrs = { module.exports = { _isLinkedToArray: true, - // add more global settings? - // - // width - // height (instead of being inferred by the label dimensions) - // title (above header) - // header borderwidth - // active bgcolor - // hover bgcolor - // gap between buttons - // gap between header & buttons - visible: { valType: 'boolean', role: 'info', diff --git a/src/components/updatemenus/defaults.js b/src/components/updatemenus/defaults.js index 3c0a8a6e070..52c79efa5e1 100644 --- a/src/components/updatemenus/defaults.js +++ b/src/components/updatemenus/defaults.js @@ -82,9 +82,6 @@ function buttonsDefaults(menuIn, menuOut) { continue; } - // Should we do some validation for 'args' depending on `method` - // or just let Plotly[method] error out? - coerce('method'); coerce('args'); coerce('label');