diff --git a/src/components/modebar/buttons.js b/src/components/modebar/buttons.js new file mode 100644 index 00000000000..f3a34ca6939 --- /dev/null +++ b/src/components/modebar/buttons.js @@ -0,0 +1,474 @@ +/** +* Copyright 2012-2015, 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 Plotly = require('../../plotly'); +var Lib = require('../../lib'); +var Snapshot = require('../../snapshot'); +var Icons = require('../../../build/ploticon'); + + +var modeBarButtons = module.exports = {}; + +/** + * ModeBar buttons configuration + * + * @param {string} name + * name / id of the buttons (for tracking) + * @param {string} title + * text that appears while hovering over the button, + * enter null, false or '' for no hover text + * @param {string} icon + * svg icon object associated with the button + * can be linked to Plotly.Icons to use the default plotly icons + * @param {string} [gravity] + * icon positioning + * @param {function} click + * click handler associated with the button, a function of + * 'gd' (the main graph object) and + * 'ev' (the event object) + * @param {string} [attr] + * attribute associated with button, + * use this with 'val' to keep track of the state + * @param {*} [val] + * initial 'attr' value, can be a function of gd + * @param {boolean} [toggle] + * is the button a toggle button? + */ + +modeBarButtons.toImage = { + name: 'toImage', + title: 'Download plot as a png', + icon: Icons.camera, + click: function(gd) { + var format = 'png'; + + if (Lib.isIE()) { + Lib.notifier('Snapshotting is unavailable in Internet Explorer. ' + + 'Consider exporting your images using the Plotly Cloud', 'long'); + return; + } + + if (gd._snapshotInProgress) { + Lib.notifier('Snapshotting is still in progress - please hold', 'long'); + return; + } + + gd._snapshotInProgress = true; + Lib.notifier('Taking snapshot - this may take a few seconds', 'long'); + + var ev = Snapshot.toImage(gd, {format: format}); + + var filename = gd.fn || 'newplot'; + filename += '.' + format; + + ev.once('success', function(result) { + gd._snapshotInProgress = false; + + var downloadLink = document.createElement('a'); + downloadLink.href = result; + downloadLink.download = filename; // only supported by FF and Chrome + + document.body.appendChild(downloadLink); + downloadLink.click(); + document.body.removeChild(downloadLink); + + ev.clean(); + }); + + ev.once('error', function (err) { + gd._snapshotInProgress = false; + + Lib.notifier('Sorry there was a problem downloading your ' + format, 'long'); + console.error(err); + + ev.clean(); + }); + } +}; + +modeBarButtons.sendDataToCloud = { + name: 'sendDataToCloud', + title: 'Save and edit plot in cloud', + icon: Icons.disk, + click: function(gd) { + Plotly.Plots.sendDataToCloud(gd); + } +}; + +modeBarButtons.zoom2d = { + name: 'zoom2d', + title: 'Zoom', + attr: 'dragmode', + val: 'zoom', + icon: Icons.zoombox, + click: handleCartesian +}; + +modeBarButtons.pan2d = { + name: 'pan2d', + title: 'Pan', + attr: 'dragmode', + val: 'pan', + icon: Icons.pan, + click: handleCartesian +}; + +modeBarButtons.zoomIn2d = { + name: 'zoomIn2d', + title: 'Zoom in', + attr: 'zoom', + val: 'in', + icon: Icons.zoom_plus, + click: handleCartesian +}; + +modeBarButtons.zoomOut2d = { + name: 'zoomOut2d', + title: 'Zoom out', + attr: 'zoom', + val: 'out', + icon: Icons.zoom_minus, + click: handleCartesian +}; + +modeBarButtons.autoScale2d = { + name: 'autoScale2d', + title: 'Autoscale', + attr: 'zoom', + val: 'auto', + icon: Icons.autoscale, + click: handleCartesian +}; + +modeBarButtons.resetScale2d = { + name: 'resetScale2d', + title: 'Reset axes', + attr: 'zoom', + val: 'reset', + icon: Icons.home, + click: handleCartesian +}; + +modeBarButtons.hoverClosestCartesian = { + name: 'hoverClosestCartesian', + title: 'Show closest data on hover', + attr: 'hovermode', + val: 'closest', + icon: Icons.tooltip_basic, + gravity: 'ne', + click: handleCartesian +}; + +modeBarButtons.hoverCompareCartesian = { + name: 'hoverCompareCartesian', + title: 'Compare data on hover', + attr: 'hovermode', + val: function(gd) { + return gd._fullLayout._isHoriz ? 'y' : 'x'; + }, + icon: Icons.tooltip_compare, + gravity: 'ne', + click: handleCartesian +}; + +function handleCartesian(gd, ev) { + var button = ev.currentTarget, + astr = button.getAttribute('data-attr'), + val = button.getAttribute('data-val') || true, + fullLayout = gd._fullLayout, + aobj = {}; + + if(astr === 'zoom') { + var mag = (val === 'in') ? 0.5 : 2, + r0 = (1 + mag) / 2, + r1 = (1 - mag) / 2, + axList = Plotly.Axes.list(gd, null, true); + + var ax, axName, initialRange; + + for(var i = 0; i < axList.length; i++) { + ax = axList[i]; + if(!ax.fixedrange) { + axName = ax._name; + if(val === 'auto') aobj[axName + '.autorange'] = true; + else if(val === 'reset') { + if(ax._rangeInitial === undefined) { + aobj[axName + '.autorange'] = true; + } + else aobj[axName + '.range'] = ax._rangeInitial.slice(); + } + else { + initialRange = ax.range; + aobj[axName + '.range'] = [ + r0 * initialRange[0] + r1 * initialRange[1], + r0 * initialRange[1] + r1 * initialRange[0] + ]; + } + } + } + } + else { + // if ALL traces have orientation 'h', 'hovermode': 'x' otherwise: 'y' + if (astr==='hovermode' && (val==='x' || val==='y')) { + val = fullLayout._isHoriz ? 'y' : 'x'; + button.setAttribute('data-val', val); + } + + aobj[astr] = val; + } + + Plotly.relayout(gd, aobj).then( function() { + if(astr === 'dragmode') { + if(fullLayout._hasCartesian) { + Plotly.Fx.setCursor( + fullLayout._paper.select('.nsewdrag'), + {pan:'move', zoom:'crosshair'}[val] + ); + } + Plotly.Fx.supplyLayoutDefaults(gd.layout, fullLayout, gd._fullData); + } + }); +} + +modeBarButtons.zoom3d = { + name: 'zoom3d', + title: 'Zoom', + attr: 'dragmode', + val: 'zoom', + icon: Icons.zoombox, + click: handleDrag3d +}; + +modeBarButtons.pan3d = { + name: 'pan3d', + title: 'Pan', + attr: 'dragmode', + val: 'pan', + icon: Icons.pan, + click: handleDrag3d +}; + +modeBarButtons.orbitRotation = { + name: 'orbitRotation', + title: 'orbital rotation', + attr: 'dragmode', + val: 'orbit', + icon: Icons['3d_rotate'], + click: handleDrag3d +}; + +modeBarButtons.tableRotation = { + name: 'tableRotation', + title: 'turntable rotation', + attr: 'dragmode', + val: 'turntable', + icon: Icons['z-axis'], + click: handleDrag3d +}; + +function handleDrag3d(gd, ev) { + var button = ev.currentTarget, + attr = button.getAttribute('data-attr'), + val = button.getAttribute('data-val') || true, + layoutUpdate = {}; + + layoutUpdate[attr] = val; + + /* + * Dragmode will go through the relayout -> doplot -> scene.plot() + * routine where the dragmode will be set in scene.plot() + */ + Plotly.relayout(gd, layoutUpdate); +} + +modeBarButtons.resetCameraDefault3d = { + name: 'resetCameraDefault3d', + title: 'Reset camera to default', + attr: 'resetDefault', + icon: Icons.home, + click: handleCamera3d +}; + +modeBarButtons.resetCameraLastSave3d = { + name: 'resetCameraLastSave3d', + title: 'Reset camera to last save', + attr: 'resetLastSave', + icon: Icons.movie, + click: handleCamera3d +}; + +function handleCamera3d(gd, ev) { + var button = ev.currentTarget, + attr = button.getAttribute('data-attr'), + layout = gd.layout, + fullLayout = gd._fullLayout, + sceneIds = Plotly.Plots.getSubplotIds(fullLayout, 'gl3d'); + + for(var i = 0; i < sceneIds.length; i++) { + var sceneId = sceneIds[i], + sceneLayout = layout[sceneId], + fullSceneLayout = fullLayout[sceneId], + scene = fullSceneLayout._scene; + + if(!sceneLayout || attr==='resetDefault') scene.setCameraToDefault(); + else if(attr === 'resetLastSave') { + + var cameraPos = sceneLayout.camera; + if(cameraPos) scene.setCamera(cameraPos); + else scene.setCameraToDefault(); + } + } + + /* + * TODO have a sceneLastTouched in _fullLayout to only + * update the camera of the scene last touched by the user + */ +} + +modeBarButtons.hoverClosest3d = { + name: 'hoverClosest3d', + title: 'Toggle show closest data on hover', + attr: 'hovermode', + val: null, + toggle: true, + icon: Icons.tooltip_basic, + gravity: 'ne', + click: function(gd, ev) { + var button = ev.currentTarget, + val = JSON.parse(button.getAttribute('data-val')) || false, + fullLayout = gd._fullLayout, + sceneIds = Plotly.Plots.getSubplotIds(fullLayout, 'gl3d'); + + var axes = ['xaxis', 'yaxis', 'zaxis'], + spikeAttrs = ['showspikes', 'spikesides', 'spikethickness', 'spikecolor']; + + // initialize 'current spike' object to be stored in the DOM + var currentSpikes = {}, + axisSpikes = {}, + layoutUpdate = {}; + + if(val) { + layoutUpdate = val; + button.setAttribute('data-val', JSON.stringify(null)); + } + else { + layoutUpdate = {'allaxes.showspikes': false}; + + for(var i = 0; i < sceneIds.length; i++) { + var sceneId = sceneIds[i], + sceneLayout = fullLayout[sceneId], + sceneSpikes = currentSpikes[sceneId] = {}; + + // copy all the current spike attrs + for(var j = 0; j < 3; j++) { + var axis = axes[j]; + axisSpikes = sceneSpikes[axis] = {}; + + for(var k = 0; k < spikeAttrs.length; k++) { + var spikeAttr = spikeAttrs[k]; + axisSpikes[spikeAttr] = sceneLayout[axis][spikeAttr]; + } + } + } + + button.setAttribute('data-val', JSON.stringify(currentSpikes)); + } + + Plotly.relayout(gd, layoutUpdate); + } +}; + +modeBarButtons.zoomInGeo = { + name: 'zoomInGeo', + title: 'Zoom in', + attr: 'zoom', + val: 'in', + icon: Icons.zoom_plus, + click: handleGeo +}; + +modeBarButtons.zoomOutGeo = { + name: 'zoomOutGeo', + title: 'Zoom in', + attr: 'zoom', + val: 'out', + icon: Icons.zoom_minus, + click: handleGeo +}; + +modeBarButtons.resetGeo = { + name: 'resetGeo', + title: 'Reset', + attr: 'reset', + val: null, + icon: Icons.autoscale, + click: handleGeo +}; + +modeBarButtons.hoverClosestGeo = { + name: 'hoverClosestGeo', + title: 'Toggle show closest data on hover', + attr: 'hovermode', + val: null, + toggle: true, + icon: Icons.tooltip_basic, + gravity: 'ne', + click: handleGeo +}; + +function handleGeo(gd, ev) { + var button = ev.currentTarget, + attr = button.getAttribute('data-attr'), + val = button.getAttribute('data-val') || true, + fullLayout = gd._fullLayout, + geoIds = Plotly.Plots.getSubplotIds(fullLayout, 'geo'); + + for(var i = 0; i < geoIds.length; i++) { + var geo = fullLayout[geoIds[i]]._geo; + + if(attr === 'zoom') { + var scale = geo.projection.scale(); + var newScale = (val === 'in') ? 2 * scale : 0.5 * scale; + geo.projection.scale(newScale); + geo.zoom.scale(newScale); + geo.render(); + } + else if(attr === 'reset') geo.zoomReset(); + else if(attr === 'hovermode') geo.showHover = !geo.showHover; + } +} + +modeBarButtons.hoverClosestGl2d = { + name: 'hoverClosestGl2d', + title: 'Toggle show closest data on hover', + attr: 'hovermode', + val: null, + toggle: true, + icon: Icons.tooltip_basic, + gravity: 'ne', + click: toggleHover +}; + +modeBarButtons.hoverClosestPie = { + name: 'hoverClosestPie', + title: 'Toggle show closest data on hover', + attr: 'hovermode', + val: 'closest', + icon: Icons.tooltip_basic, + gravity: 'ne', + click: toggleHover +}; + +function toggleHover(gd) { + var newHover = gd._fullLayout.hovermode ? false : 'closest'; + + Plotly.relayout(gd, 'hovermode', newHover); +} diff --git a/src/components/modebar/index.js b/src/components/modebar/index.js index a5987cf12b0..e0bdd3269de 100644 --- a/src/components/modebar/index.js +++ b/src/components/modebar/index.js @@ -12,63 +12,91 @@ var Plotly = require('../../plotly'); var d3 = require('d3'); +var Icons = require('../../../build/ploticon'); + + /** * UI controller for interactive plots * @Class - * @Param {object} config - * @Param {object} config.buttons nested arrays of grouped buttons to initialize - * @Param {object} config.container container div to append modebar - * @Param {object} config.Plotly main plotly namespace module - * @Param {object} config.graphInfo primary plot object containing data and layout + * @Param {object} opts + * @Param {object} opts.buttons nested arrays of grouped buttons config objects + * @Param {object} opts.container container div to append modeBar + * @Param {object} opts.graphInfo primary plot object containing data and layout */ -function ModeBar (config) { +function ModeBar(opts) { + this.container = opts.container; + this.element = document.createElement('div'); - if (!(this instanceof ModeBar)) return new ModeBar(); + this.update(opts.graphInfo, opts.buttons); - var _this = this; + this.container.appendChild(this.element); +} - this._snapshotInProgress = false; - this.graphInfo = config.graphInfo; - this.element = document.createElement('div'); +var proto = ModeBar.prototype; + +/** + * Update modeBar (buttons and logo) + * + * @param {object} graphInfo primary plot object containing data and layout + * @param {array of arrays} buttons nested arrays of grouped buttons to initialize + * + */ +proto.update = function(graphInfo, buttons) { + this.graphInfo = graphInfo; - if(this.graphInfo._context.displayModeBar === 'hover') { + var context = this.graphInfo._context; + + if(context.displayModeBar === 'hover') { this.element.className = 'modebar modebar--hover'; - } else { - this.element.className = 'modebar'; } + else this.element.className = 'modebar'; + + // if buttons or logo have changed, redraw modebar interior + var needsNewButtons = !this.hasButtons(buttons), + needsNewLogo = (this.hasLogo !== context.displaylogo); + + if(needsNewButtons || needsNewLogo) { + this.removeAllButtons(); + + this.updateButtons(buttons); + + if(context.displaylogo) { + this.element.appendChild(this.getLogo()); + this.hasLogo = true; + } + } + + this.updateActiveButton(); +}; + +proto.updateButtons = function(buttons) { + var _this = this; - this.buttons = config.buttons; + this.buttons = buttons; this.buttonElements = []; + this.buttonsNames = []; - this.buttons.forEach( function (buttonGroup) { + this.buttons.forEach(function(buttonGroup) { var group = _this.createGroup(); - buttonGroup.forEach( function (buttonName) { - var buttonConfig = _this.config()[buttonName]; - - if (!buttonConfig) { - throw new Error(buttonName + ' not specfied in modebar configuration'); + buttonGroup.forEach(function(buttonConfig) { + var buttonName = buttonConfig.name; + if(!buttonName) { + throw new Error('must provide button \'name\' in button config'); + } + if(_this.buttonsNames.indexOf(buttonName) !== -1) { + throw new Error('button name \'' + buttonName + '\' is taken'); } + _this.buttonsNames.push(buttonName); var button = _this.createButton(buttonConfig); - _this.buttonElements.push(button); group.appendChild(button); }); _this.element.appendChild(group); }); - - if (this.graphInfo._context.displaylogo) { - this.element.appendChild(this.getLogo()); - } - - config.container.appendChild(this.element); - - this.updateActiveButton(); -} - -var proto = ModeBar.prototype; +}; /** * Empty div for containing a group of buttons @@ -83,11 +111,7 @@ proto.createGroup = function () { /** * Create a new button div and set constant and configurable attributes - * @Param {object} config - * @Param {string} config.attr - * @Param {string} config.val - * @Param {string} config.title - * @Param {function} config.click + * @Param {object} config (see ./buttons.js for more info) * @Return {HTMLelement} */ proto.createButton = function (config) { @@ -97,18 +121,36 @@ proto.createButton = function (config) { button.setAttribute('rel', 'tooltip'); button.className = 'modebar-btn'; - if (config.attr !== undefined) button.setAttribute('data-attr', config.attr); - if (config.val !== undefined) button.setAttribute('data-val', config.val); - button.setAttribute('data-title', config.title); - button.setAttribute('data-gravity', config.gravity || 'n'); - button.addEventListener('click', function () { - config.click.apply(_this, arguments); + var title = config.title; + if(title === undefined) title = config.name; + if(title || title === 0) button.setAttribute('data-title', title); + + if(config.attr !== undefined) button.setAttribute('data-attr', config.attr); + + var val = config.val; + if(val !== undefined) { + if(typeof val === 'function') val = val(this.graphInfo); + button.setAttribute('data-val', val); + } + + var click = config.click; + if(typeof click !== 'function') { + throw new Error('must provide button \'click\' function in button config'); + } + else { + button.addEventListener('click', function(ev) { + config.click(_this.graphInfo, ev); + + // only needed for 'hoverClosestGeo' which does not call relayout + _this.updateActiveButton(ev.currentTarget); }); + } - button.setAttribute('data-toggle', config.toggle); + button.setAttribute('data-toggle', config.toggle || false); if(config.toggle) button.classList.add('active'); - button.appendChild(this.createIcon(Plotly.Icons[config.icon])); + button.appendChild(this.createIcon(config.icon || Icons.question)); + button.setAttribute('data-gravity', config.gravity || 'n'); return button; }; @@ -121,18 +163,17 @@ proto.createButton = function (config) { * @Return {HTMLelement} */ proto.createIcon = function (thisIcon) { - var iconDef = Plotly.Icons, - iconHeight = iconDef.ascent - iconDef.descent, + var iconHeight = thisIcon.ascent - thisIcon.descent, svgNS = 'http://www.w3.org/2000/svg', icon = document.createElementNS(svgNS, 'svg'), path = document.createElementNS(svgNS, 'path'); icon.setAttribute('height', '1em'); - icon.setAttribute('width', (thisIcon.width / iconHeight)+'em'); + icon.setAttribute('width', (thisIcon.width / iconHeight) + 'em'); icon.setAttribute('viewBox', [0, 0, thisIcon.width, iconHeight].join(' ')); path.setAttribute('d', thisIcon.path); - path.setAttribute('transform', 'matrix(1 0 0 -1 0 ' + iconDef.ascent + ')'); + path.setAttribute('transform', 'matrix(1 0 0 -1 0 ' + thisIcon.ascent + ')'); icon.appendChild(path); return icon; @@ -152,7 +193,7 @@ proto.updateActiveButton = function(buttonClicked) { this.buttonElements.forEach(function(button) { var thisval = button.getAttribute('data-val') || true, dataAttr = button.getAttribute('data-attr'), - isToggleButton = button.getAttribute('data-toggle')==='true', + isToggleButton = (button.getAttribute('data-toggle') === 'true'), button3 = d3.select(button); // Use 'data-toggle' and 'buttonClicked' to toggle buttons @@ -169,21 +210,23 @@ proto.updateActiveButton = function(buttonClicked) { }); }; - /** - * Check if modebar is configured as button configuration argument - * @Param {object} buttons 2d array of grouped button names + * Check if modeBar is configured as button configuration argument + * + * @Param {object} buttons 2d array of grouped button config objects * @Return {boolean} */ proto.hasButtons = function (buttons) { var currentButtons = this.buttons; + if(!currentButtons) return false; + if (buttons.length !== currentButtons.length) return false; for (var i = 0; i < buttons.length; ++i) { if (buttons[i].length !== currentButtons[i].length) return false; for (var j = 0; j < buttons[i].length; j++) { - if (buttons[i][j] !== currentButtons[i][j]) return false; + if (buttons[i][j].name !== currentButtons[i][j].name) return false; } } @@ -193,7 +236,7 @@ proto.hasButtons = function (buttons) { /** * @return {HTMLDivElement} The logo image wrapped in a group */ -proto.getLogo = function(){ +proto.getLogo = function() { var group = this.createGroup(), a = document.createElement('a'); @@ -202,471 +245,40 @@ proto.getLogo = function(){ a.setAttribute('data-title', 'Produced with Plotly'); a.className = 'modebar-btn plotlyjsicon modebar-btn--logo'; - a.appendChild(this.createIcon(Plotly.Icons.plotlylogo)); + a.appendChild(this.createIcon(Icons.plotlylogo)); group.appendChild(a); return group; }; -/** - * Apply D3 cartesian mode attributes to layout to update hover functionality - * @Param {object} ev event object - */ -proto.handleCartesian = function(ev) { - var button = ev.currentTarget, - astr = button.getAttribute('data-attr'), - val = button.getAttribute('data-val') || true, - _this = this, - graphInfo = this.graphInfo, - fullLayout = this.graphInfo._fullLayout, - aobj = {}; - - if(astr === 'zoom') { - var mag = (val === 'in') ? 0.5 : 2, - r0 = (1 + mag) / 2, - r1 = (1 - mag) / 2, - axList = Plotly.Axes.list(graphInfo, null, true); - - var ax, axName, initialRange; - - for(var i = 0; i < axList.length; i++) { - ax = axList[i]; - if(!ax.fixedrange) { - axName = ax._name; - if(val === 'auto') aobj[axName + '.autorange'] = true; - else if(val === 'reset') { - if(ax._rangeInitial === undefined) { - aobj[axName + '.autorange'] = true; - } - else aobj[axName + '.range'] = ax._rangeInitial.slice(); - } - else { - initialRange = ax.range; - aobj[axName + '.range'] = [ - r0 * initialRange[0] + r1 * initialRange[1], - r0 * initialRange[1] + r1 * initialRange[0] - ]; - } - } - } - } else { - // if ALL traces have orientation 'h', 'hovermode': 'x' otherwise: 'y' - if (astr==='hovermode' && (val==='x' || val==='y')) { - val = fullLayout._isHoriz ? 'y' : 'x'; - button.setAttribute('data-val', val); - } - - aobj[astr] = val; - } - - Plotly.relayout(graphInfo, aobj).then( function() { - _this.updateActiveButton(); - if(astr === 'dragmode') { - if(fullLayout._hasCartesian) { - Plotly.Fx.setCursor( - fullLayout._paper.select('.nsewdrag'), - {pan:'move', zoom:'crosshair'}[val] - ); - } - Plotly.Fx.supplyLayoutDefaults(graphInfo.layout, fullLayout, - graphInfo._fullData); - } - }); -}; - -/** - * Toggle the data hover mode - * @Param {object} ev event object - */ -proto.handleHover3d = function(ev) { - var button = ev.currentTarget, - val = JSON.parse(button.getAttribute('data-val')) || false, - _this = this, - graphInfo = this.graphInfo, - fullLayout = graphInfo._fullLayout, - sceneIds = Plotly.Plots.getSubplotIds(fullLayout, 'gl3d'), - layoutUpdate = {}, - - // initialize 'current spike' object to be stored in the DOM - currentSpikes = {}, - axes = ['xaxis', 'yaxis', 'zaxis'], - spikeAttrs = ['showspikes', 'spikesides', 'spikethickness', 'spikecolor']; - - var i, sceneId, sceneLayout, sceneSpikes; - var j, axis, axisSpikes; - var k, spikeAttr; - - if (val) { - layoutUpdate = val; - button.setAttribute('data-val', JSON.stringify(null)); - } - else { - layoutUpdate = {'allaxes.showspikes': false}; - - for (i = 0; i < sceneIds.length; i++) { - sceneId = sceneIds[i]; - sceneLayout = fullLayout[sceneId]; - sceneSpikes = currentSpikes[sceneId] = {}; - - // copy all the current spike attrs - for (j = 0; j < 3; j++) { - axis = axes[j]; - axisSpikes = sceneSpikes[axis] = {}; - for (k = 0; k < spikeAttrs.length; k++) { - spikeAttr = spikeAttrs[k]; - axisSpikes[spikeAttr] = sceneLayout[axis][spikeAttr]; - } - } - } - - button.setAttribute('data-val', JSON.stringify(currentSpikes)); - } - - Plotly.relayout(graphInfo, layoutUpdate).then( function() { - _this.updateActiveButton(button); - }); - -}; - -/** - * Reconfigure keyboard bindings for webgl3D camera control on drag - * @Param {object} ev event object - */ -proto.handleDrag3d = function(ev) { - var button = ev.currentTarget, - attr = button.getAttribute('data-attr'), - val = button.getAttribute('data-val') || true, - _this = this, - graphInfo = this.graphInfo, - layoutUpdate = {}; - - layoutUpdate[attr] = val; - - // Dragmode will go through the relayout->doplot->scene.plot() - // routine where the dragmode will be set in scene.plot() - Plotly.relayout(graphInfo, layoutUpdate).then( function() { - _this.updateActiveButton(); - }); -}; - - -/** - * Reset the position of the webgl3D camera - * @Param {object} ev event object - */ -proto.handleCamera3d = function(ev) { - var button = ev.currentTarget, - attr = button.getAttribute('data-attr'), - layout = this.graphInfo.layout, - fullLayout = this.graphInfo._fullLayout, - sceneIds = Plotly.Plots.getSubplotIds(fullLayout, 'gl3d'); - - var i, sceneId, sceneLayout, fullSceneLayout, scene, cameraPos; - - for (i = 0; i < sceneIds.length; i++) { - sceneId = sceneIds[i]; - sceneLayout = layout[sceneId]; - fullSceneLayout = fullLayout[sceneId]; - scene = fullSceneLayout._scene; - - if (!sceneLayout || attr==='resetDefault') scene.setCameraToDefault(); - else if (attr === 'resetLastSave') { - - cameraPos = sceneLayout.camera; - if (cameraPos) scene.setCamera(cameraPos); - else scene.setCameraToDefault(); - } +proto.removeAllButtons = function() { + while(this.element.firstChild) { + this.element.removeChild(this.element.firstChild); } - /* TODO have a sceneLastTouched in _fullLayout to only - * update the camera of the scene last touched by the user - */ + this.hasLogo = false; }; -proto.handleGeo = function(ev) { - var button = ev.currentTarget, - attr = button.getAttribute('data-attr'), - val = button.getAttribute('data-val') || true, - fullLayout = this.graphInfo._fullLayout, - geoIds = Plotly.Plots.getSubplotIds(fullLayout, 'geo'); - - var geo, scale, newScale; - - for(var i = 0; i < geoIds.length; i++) { - geo = fullLayout[geoIds[i]]._geo; - - if(attr === 'zoom') { - scale = geo.projection.scale(); - newScale = val==='in' ? 2 * scale : 0.5 * scale; - geo.projection.scale(newScale); - geo.zoom.scale(newScale); - geo.render(); - } - else if(attr === 'reset') geo.zoomReset(); - else if(attr === 'hovermode') geo.showHover = !geo.showHover; - } - - this.updateActiveButton(button); +proto.destroy = function() { + Plotly.Lib.removeElement(this.container.querySelector('.modebar')); }; -proto.handleHoverPie = function() { - var _this = this, - graphInfo = _this.graphInfo, - newHover = graphInfo._fullLayout.hovermode ? - false : - 'closest'; +function createModeBar(gd, buttons) { + var fullLayout = gd._fullLayout; - Plotly.relayout(graphInfo, 'hovermode', newHover).then(function() { - _this.updateActiveButton(); + var modeBar = new ModeBar({ + graphInfo: gd, + container: fullLayout._paperdiv.node(), + buttons: buttons }); -}; - -proto.handleHoverGl2d = function(ev) { - var _this = this, - button = ev.currentTarget, - graphInfo = _this.graphInfo, - newHover = graphInfo._fullLayout.hovermode ? false : 'closest'; - Plotly.relayout(graphInfo, 'hovermode', newHover).then(function() { - _this.updateActiveButton(button); - }); -}; - -proto.cleanup = function(){ - this.element.innerHTML = ''; - var modebarParent = this.element.parentNode; - if (modebarParent) modebarParent.removeChild(this.element); -}; - -proto.toImage = function() { - - var format = 'png'; - var _this = this; - - if ( Plotly.Lib.isIE() ) { - Plotly.Lib.notifier('Snapshotting is unavailable in Internet Explorer. ' + - 'Consider exporting your images using the Plotly Cloud', 'long'); - return; - } - - if (this._snapshotInProgress) { - Plotly.Lib.notifier('Snapshotting is still in progress - please hold', 'long'); - return; + if(fullLayout._privateplot) { + d3.select(modeBar.element).append('span') + .classed('badge-private float--left', true) + .text('PRIVATE'); } - this._snapshotInProgress = true; - Plotly.Lib.notifier('Taking snapshot - this may take a few seconds', 'long'); - - var ev = Plotly.Snapshot.toImage(this.graphInfo, {format: format}); - - var filename = this.graphInfo.fn || 'newplot'; - filename += '.' + format; - - ev.once('success', function(result) { - - _this._snapshotInProgress = false; - - var downloadLink = document.createElement('a'); - downloadLink.href = result; - downloadLink.download = filename; // only supported by FF and Chrome - - document.body.appendChild(downloadLink); - downloadLink.click(); - document.body.removeChild(downloadLink); - - ev.clean(); - }); - - ev.once('error', function (err) { - _this._snapshotInProgress = false; - - Plotly.Lib.notifier('Sorry there was a problem downloading your ' + format, 'long'); - console.error(err); - - ev.clean(); - }); -}; - -proto.sendDataToCloud = function() { - var gd = this.graphInfo; - Plotly.Plots.sendDataToCloud(gd) -}; - -/** - * - * @Property config specification hash of button parameters - */ -proto.config = function config() { - return { - zoom2d: { - title: 'Zoom', - attr: 'dragmode', - val: 'zoom', - icon: 'zoombox', - click: this.handleCartesian - }, - pan2d: { - title: 'Pan', - attr: 'dragmode', - val: 'pan', - icon: 'pan', - click: this.handleCartesian - }, - zoomIn2d: { - title: 'Zoom in', - attr: 'zoom', - val: 'in', - icon: 'zoom_plus', - click: this.handleCartesian - }, - zoomOut2d: { - title: 'Zoom out', - attr: 'zoom', - val: 'out', - icon: 'zoom_minus', - click: this.handleCartesian - }, - autoScale2d: { - title: 'Autoscale', - attr: 'zoom', - val: 'auto', - icon: 'autoscale', - click: this.handleCartesian - }, - resetScale2d: { - title: 'Reset axes', - attr: 'zoom', - val: 'reset', - icon: 'home', - click: this.handleCartesian - }, - hoverClosest2d: { - title: 'Show closest data on hover', - attr: 'hovermode', - val: 'closest', - icon: 'tooltip_basic', - gravity: 'ne', - click: this.handleCartesian - }, - hoverCompare2d: { - title: 'Compare data on hover', - attr: 'hovermode', - val: this.graphInfo._fullLayout._isHoriz ? 'y' : 'x', - icon: 'tooltip_compare', - gravity: 'ne', - click: this.handleCartesian - }, - toImage: { - title: 'download plot as a png', - icon: 'camera', - click: this.toImage - }, - sendDataToCloud: { - title: 'save and edit plot in cloud', - icon: 'disk', - click: this.sendDataToCloud - }, - // gl3d - zoom3d: { - title: 'Zoom', - attr: 'dragmode', - val: 'zoom', - icon: 'zoombox', - click: this.handleDrag3d - }, - pan3d: { - title: 'Pan', - attr: 'dragmode', - val: 'pan', - icon: 'pan', - click: this.handleDrag3d - }, - orbitRotation: { - title: 'orbital rotation', - attr: 'dragmode', - val: 'orbit', - icon: '3d_rotate', - click: this.handleDrag3d - }, - tableRotation: { - title: 'turntable rotation', - attr: 'dragmode', - val: 'turntable', - icon: 'z-axis', - click: this.handleDrag3d - }, - resetCameraDefault3d: { - title: 'Reset camera to default', - attr: 'resetDefault', - icon: 'home', - click: this.handleCamera3d - }, - resetCameraLastSave3d: { - title: 'Reset camera to last save', - attr: 'resetLastSave', - icon: 'movie', - click: this.handleCamera3d - }, - hoverClosest3d: { - title: 'Toggle show closest data on hover', - attr: 'hovermode', - val: null, - toggle: true, - icon: 'tooltip_basic', - gravity: 'ne', - click: this.handleHover3d - }, - // geo - zoomInGeo: { - title: 'Zoom in', - attr: 'zoom', - val: 'in', - icon: 'zoom_plus', - click: this.handleGeo - }, - zoomOutGeo: { - title: 'Zoom out', - attr: 'zoom', - val: 'out', - icon: 'zoom_minus', - click: this.handleGeo - }, - resetGeo: { - title: 'Reset', - attr: 'reset', - val: null, - icon: 'autoscale', - click: this.handleGeo - }, - hoverClosestGeo: { - title: 'Toggle show closest data on hover', - attr: 'hovermode', - val: null, - toggle: true, - icon: 'tooltip_basic', - gravity: 'ne', - click: this.handleGeo - }, - // pie - hoverClosestPie: { - title: 'Toggle show closest data on hover', - attr: 'hovermode', - val: 'closest', - icon: 'tooltip_basic', - gravity: 'ne', - click: this.handleHoverPie - }, - // gl2d - hoverClosestGl2d: { - title: 'Toggle show closest data on hover', - attr: 'hovermode', - val: null, - toggle: true, - icon: 'tooltip_basic', - gravity: 'ne', - click: this.handleHoverGl2d - } - }; -}; + return modeBar; +} -module.exports = ModeBar; +module.exports = createModeBar; diff --git a/src/components/modebar/manage.js b/src/components/modebar/manage.js new file mode 100644 index 00000000000..2a64c5efd53 --- /dev/null +++ b/src/components/modebar/manage.js @@ -0,0 +1,166 @@ +/** +* Copyright 2012-2015, 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 Plotly = require('../../plotly'); + +var createModeBar = require('./'); +var modeBarButtons = require('./buttons'); + +/** + * ModeBar wrapper around 'create' and 'update', + * chooses buttons to pass to ModeBar constructor based on + * plot type and plot config. + * + * @param {object} gd main plot object + * + */ +module.exports = function manageModeBar(gd) { + var fullLayout = gd._fullLayout, + context = gd._context, + modeBar = fullLayout._modeBar; + + if(!context.displayModeBar && modeBar) { + modeBar.destroy(); + delete fullLayout._modeBar; + return; + } + + if(!Array.isArray(context.modeBarButtonsToRemove)) { + throw new Error([ + '*modeBarButtonsToRemove* configuration options', + 'must be an array.' + ].join(' ')); + } + + if(!Array.isArray(context.modeBarButtonsToAdd)) { + throw new Error([ + '*modeBarButtonsToAdd* configuration options', + 'must be an array.' + ].join(' ')); + } + + var customButtons = context.modeBarButtons; + var buttonGroups; + + if(Array.isArray(customButtons) && customButtons.length) { + buttonGroups = fillCustomButton(customButtons); + } + else { + buttonGroups = getButtonGroups( + fullLayout, + context.modeBarButtonsToRemove, + context.modeBarButtonsToAdd + ); + } + + if(modeBar) modeBar.update(gd, buttonGroups); + else fullLayout._modeBar = createModeBar(gd, buttonGroups); +}; + +// logic behind which buttons are displayed by default +function getButtonGroups(fullLayout, buttonsToRemove, buttonsToAdd) { + var groups = []; + + function addGroup(newGroup) { + var out = []; + + for(var i = 0; i < newGroup.length; i++) { + var button = newGroup[i]; + if(buttonsToRemove.indexOf(button) !== -1) continue; + out.push(modeBarButtons[button]); + } + + groups.push(out); + } + + // buttons common to all plot types + addGroup(['toImage', 'sendDataToCloud']); + + if(fullLayout._hasGL3D) { + addGroup(['zoom3d', 'pan3d', 'orbitRotation', 'tableRotation']); + addGroup(['resetCameraDefault3d', 'resetCameraLastSave3d']); + addGroup(['hoverClosest3d']); + } + + if(fullLayout._hasGeo) { + addGroup(['zoomInGeo', 'zoomOutGeo', 'resetGeo']); + addGroup(['hoverClosestGeo']); + } + + var hasCartesian = fullLayout._hasCartesian, + hasGL2D = fullLayout._hasGL2D, + allAxesFixed = areAllAxesFixed(fullLayout); + + if((hasCartesian || hasGL2D) && !allAxesFixed) { + addGroup(['zoom2d', 'pan2d']); + addGroup(['zoomIn2d', 'zoomOut2d', 'autoScale2d', 'resetScale2d']); + } + + if(hasCartesian) { + addGroup(['hoverClosestCartesian', 'hoverCompareCartesian']); + } + if(hasGL2D) { + addGroup(['hoverClosestGl2d']); + } + if(fullLayout._hasPie) { + addGroup(['hoverClosestPie']); + } + + // append buttonsToAdd to the groups + if(buttonsToAdd.length) { + if(Array.isArray(buttonsToAdd[0])) { + for(var i = 0; i < buttonsToAdd.length; i++) { + groups.push(buttonsToAdd[i]); + } + } + else groups.push(buttonsToAdd); + } + + return groups; +} + +function areAllAxesFixed(fullLayout) { + var axList = Plotly.Axes.list({_fullLayout: fullLayout}, null, true); + var allFixed = true; + + for(var i = 0; i < axList.length; i++) { + if(!axList[i].fixedrange) { + allFixed = false; + break; + } + } + + return allFixed; +} + +// fill in custom buttons referring to default mode bar buttons +function fillCustomButton(customButtons) { + for(var i = 0; i < customButtons.length; i++) { + var buttonGroup = customButtons[i]; + + for(var j = 0; j < buttonGroup.length; j++) { + var button = buttonGroup[j]; + + if(typeof button === 'string') + if(modeBarButtons[button] !== undefined) { + customButtons[i][j] = modeBarButtons[button]; + } + else { + throw new Error([ + '*modeBarButtons* configuration options', + 'invalid button name' + ].join(' ')); + } + } + } + + return customButtons; +} diff --git a/src/index.js b/src/index.js index fadf663eb17..47f5fd3d388 100644 --- a/src/index.js +++ b/src/index.js @@ -32,6 +32,9 @@ exports.deleteTraces = Plotly.deleteTraces; exports.moveTraces = Plotly.moveTraces; exports.setPlotConfig = require('./plot_api/set_plot_config'); +// plot icons +exports.Icons = require('../build/ploticon'); + // unofficial 'beta' plot methods, use at your own risk exports.Plots = Plotly.Plots; exports.Fx = Plotly.Fx; diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index f95878fcb42..c59f677619d 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -11,6 +11,7 @@ var Plotly = require('../plotly'); var Events = require('../lib/events'); +var manageModeBar = require('../components/modebar/manage'); var d3 = require('d3'); var m4FromQuat = require('gl-mat4/fromQuat'); @@ -31,7 +32,7 @@ var plots = Plotly.Plots; * object describing the overall display of the plot, * all the stuff that doesn't pertain to any individual trace * @param {object} config - * configuration options + * configuration options (see ./plot_config.js for more info) * */ Plotly.plot = function(gd, data, layout, config) { @@ -399,11 +400,6 @@ function setPlotContext(gd, config) { } }); - // cause a remake of the modebar any time we change context - if(gd._fullLayout && gd._fullLayout._modebar) { - delete gd._fullLayout._modebar; - } - // map plot3dPixelRatio to plotGlPixelRatio for backward compatibility if(config.plot3dPixelRatio && !context.plotGlPixelRatio) { context.plotGlPixelRatio = context.plot3dPixelRatio; @@ -2176,7 +2172,6 @@ Plotly.relayout = function relayout(gd, astr, val) { doplot = false, docalc = false, domodebar = false, - doSceneDragmode = false, newkey, axes, keys, xyref, scene, axisAttr; if(typeof astr === 'string') aobj[astr] = val; @@ -2410,8 +2405,7 @@ Plotly.relayout = function relayout(gd, astr, val) { * height, width, autosize get dealt with below. Except for the case of * of subplots - scenes - which require scene.handleDragmode to be called. */ - else if(ai==='hovermode') domodebar = true; - else if (ai === 'dragmode') doSceneDragmode = true; + else if(['hovermode', 'dragmode'].indexOf(ai) !== -1) domodebar = true; else if(['hovermode','dragmode','height', 'width','autosize'].indexOf(ai)===-1) { doplot = true; @@ -2468,11 +2462,12 @@ Plotly.relayout = function relayout(gd, astr, val) { return plots.previousPromises(gd); }); } + // this is decoupled enough it doesn't need async regardless - if(domodebar) Plotly.Fx.modeBar(gd); + if(domodebar) { + manageModeBar(gd); - var subplotIds; - if(doSceneDragmode || domodebar) { + var subplotIds; subplotIds = plots.getSubplotIds(fullLayout, 'gl3d'); for(i = 0; i < subplotIds.length; i++) { scene = fullLayout[subplotIds[i]]._scene; @@ -3006,7 +3001,7 @@ function lsInner(gd) { Plotly.Titles.draw(gd, 'gtitle'); - Plotly.Fx.modeBar(gd); + manageModeBar(gd); return gd._promises.length && Promise.all(gd._promises); } diff --git a/src/plot_api/plot_config.js b/src/plot_api/plot_config.js index 0a5a321f828..d19c7ca828b 100644 --- a/src/plot_api/plot_config.js +++ b/src/plot_api/plot_config.js @@ -55,10 +55,24 @@ module.exports = { // false or function adding source(s) to linkText showSources: false, - // display the modebar (true, false, or 'hover') + // display the mode bar (true, false, or 'hover') displayModeBar: 'hover', - // add the plotly logo on the end of the modebar + // remove mode bar button by name + // (see ./components/modebar/buttons.js for the list of names) + modeBarButtonsToRemove: [], + + // add mode bar button using config objects + // (see ./components/modebar/buttons.js for list of arguments) + modeBarButtonsToAdd: [], + + // fully custom mode bar buttons as nested array, + // where the outer arrays represents button groups, and + // the inner arrays have buttons config objects or names of default buttons + // (see ./components/modebar/buttons.js for more info) + modeBarButtons: false, + + // add the plotly logo on the end of the mode bar displaylogo: true, // increase the pixel ratio for Gl plot images diff --git a/src/plotly.js b/src/plotly.js index ffff4245a39..368af2d31a9 100644 --- a/src/plotly.js +++ b/src/plotly.js @@ -25,8 +25,7 @@ exports.Lib = require('./lib'); exports.util = require('./lib/svg_text_utils'); exports.Queue = require('./lib/queue'); -// plot icons svg and plot css -exports.Icons = require('../build/ploticon'); +// plot css require('../build/plotcss'); // configuration diff --git a/src/plots/cartesian/axes.js b/src/plots/cartesian/axes.js index f35d298dc6c..c4a3e8148bd 100644 --- a/src/plots/cartesian/axes.js +++ b/src/plots/cartesian/axes.js @@ -935,7 +935,7 @@ axes.doAutoRange = function(ax) { }; // save a copy of the initial axis ranges in fullLayout -// use them in modebar and dblclick events +// use them in mode bar and dblclick events axes.saveRangeInitial = function(gd, overwrite) { var axList = axes.list(gd, '', true), hasOneAxisChanged = false; diff --git a/src/plots/cartesian/graph_interact.js b/src/plots/cartesian/graph_interact.js index 628d9f6ef5a..3af14739642 100644 --- a/src/plots/cartesian/graph_interact.js +++ b/src/plots/cartesian/graph_interact.js @@ -45,7 +45,7 @@ fx.supplyLayoutDefaults = function(layoutIn, layoutOut, fullData) { if(layoutOut._hasCartesian) { // flag for 'horizontal' plots: - // determines the state of the modebar 'compare' hovermode button + // determines the state of the mode bar 'compare' hovermode button isHoriz = layoutOut._isHoriz = fx.isHoriz(fullData); hovermodeDflt = isHoriz ? 'y' : 'x'; } @@ -1279,100 +1279,6 @@ fx.click = function(gd,evt){ } }; -// dragmode and hovermode toolbars -fx.modeBar = function(gd){ - - function initModebar(){ - - var modebar = new Plotly.ModeBar({ - buttons: buttons, - container: fullLayout._paperdiv.node(), - Plotly: Plotly, - graphInfo: gd - }); - - if(fullLayout._privateplot) { - d3.select(modebar.element).append('span') - .classed('badge-private float--left', true) - .text('PRIVATE'); - } - - return modebar; - } - - function deleteModebar() { - Plotly.Lib.removeElement(gd.querySelector('.modebar')); - } - - var modebar, - fullLayout = gd._fullLayout || {}; - - if (!gd._context.displayModeBar) return deleteModebar(); - - var buttons = chooseModebarButtons(fullLayout); - - if (!fullLayout._modebar){ - deleteModebar(); - fullLayout._modebar = initModebar(); - } - - modebar = fullLayout._modebar; - - //if the buttons are different, clean old and init new modebar - if (!modebar.hasButtons(buttons)) { - fullLayout._modebar.cleanup(); - fullLayout._modebar = initModebar(); - } -}; - -function chooseModebarButtons(fullLayout) { - if(fullLayout._hasGL3D) { - return [ - ['toImage', 'sendDataToCloud'], - ['orbitRotation', 'tableRotation', 'zoom3d', 'pan3d'], - ['resetCameraDefault3d', 'resetCameraLastSave3d'], - ['hoverClosest3d'] - ]; - } - else if(fullLayout._hasGeo) { - return [ - ['toImage', 'sendDataToCloud'], - ['zoomInGeo', 'zoomOutGeo', 'resetGeo'], - ['hoverClosestGeo'] - ]; - } - - var axList = Plotly.Axes.list({_fullLayout: fullLayout}, null, true), - allFixed = true, - i, - buttons; - - for(i = 0; i < axList.length; i++) { - if(!axList[i].fixedrange) { - allFixed = false; - break; - } - } - - if(allFixed) buttons = [['toImage', 'sendDataToCloud']]; - else buttons = [ - ['toImage', 'sendDataToCloud'], - ['zoom2d', 'pan2d'], - ['zoomIn2d', 'zoomOut2d', 'resetScale2d', 'autoScale2d'] - ]; - - if(fullLayout._hasCartesian) { - buttons.push(['hoverClosest2d', 'hoverCompare2d']); - } - else if(fullLayout._hasPie) { - buttons.push(['hoverClosestPie']); - } - else if(fullLayout._hasGL2D) { - buttons.push(['hoverClosestGl2d']); - } - - return buttons; -} // ---------------------------------------------------- // Axis dragging functions diff --git a/src/plots/gl3d/scene.js b/src/plots/gl3d/scene.js index bf4de0c23e7..e8e276a30f8 100644 --- a/src/plots/gl3d/scene.js +++ b/src/plots/gl3d/scene.js @@ -539,7 +539,7 @@ proto.destroy = function() { }; -// for reset camera button in modebar +// for reset camera button in mode bar proto.setCameraToDefault = function setCameraToDefault () { // as in Gl3dLayout.layoutAttributes this.glplot.camera.lookAt( diff --git a/tasks/util/pull_font_svg.js b/tasks/util/pull_font_svg.js index 51002ec9b2a..c67a049e4da 100644 --- a/tasks/util/pull_font_svg.js +++ b/tasks/util/pull_font_svg.js @@ -10,15 +10,16 @@ module.exports = function pullFontSVG(data, pathOut) { var font_obj = result.svg.defs[0].font[0], default_width = Number(font_obj.$['horiz-adv-x']), - chars = { - ascent: Number(font_obj['font-face'][0].$.ascent), - descent: Number(font_obj['font-face'][0].$.descent) - }; + ascent = Number(font_obj['font-face'][0].$.ascent), + descent = Number(font_obj['font-face'][0].$.descent), + chars = {}; font_obj.glyph.forEach(function(glyph) { chars[glyph.$['glyph-name']] = { width: Number(glyph.$['horiz-adv-x']) || default_width, - path: glyph.$.d + path: glyph.$.d, + ascent: ascent, + descent: descent }; }); diff --git a/test/jasmine/tests/modebar_test.js b/test/jasmine/tests/modebar_test.js index b1b70c7319e..86d65b3780c 100644 --- a/test/jasmine/tests/modebar_test.js +++ b/test/jasmine/tests/modebar_test.js @@ -1,97 +1,436 @@ -var Plotly = require('@src/plotly'); +var d3 = require('d3'); -describe('Test Modebar', function() { +var createModeBar = require('@src/components/modebar'); +var manageModeBar = require('@src/components/modebar/manage'); + + +describe('ModeBar', function() { 'use strict'; - var getMockGraphInfo = function() { - var graphInfo = { - _fullLayout: { - dragmode: 'zoom' - }, - _context: { - displayModeBar: true, - displaylogo: true - } - }; - return graphInfo; - }; + function noop() {} - var getMockContainerTree = function() { + function getMockContainerTree() { var root = document.createElement('div'); root.className = 'plot-container'; var parent = document.createElement('div'); parent.className = 'svg-container'; root.appendChild(parent); + return parent; - }; + } + + function getMockGraphInfo() { + return { + _fullLayout: { + dragmode: 'zoom', + _paperdiv: d3.select(getMockContainerTree()) + }, + _context: { + displaylogo: true, + displayModeBar: true, + modeBarButtonsToRemove: [], + modeBarButtonsToAdd: [] + } + }; + } + + function countGroups(modeBar) { + return d3.select(modeBar.element).selectAll('div.modebar-group')[0].length; + } + + function countButtons(modeBar) { + return d3.select(modeBar.element).selectAll('a.modebar-btn')[0].length; + } + + function countLogo(modeBar) { + return d3.select(modeBar.element).selectAll('a.plotlyjsicon')[0].length; + } + + function checkBtnAttr(modeBar, index, attr) { + var buttons = d3.select(modeBar.element).selectAll('a.modebar-btn'); + return d3.select(buttons[0][index]).attr(attr); + } + + var buttons = [[{ + name: 'button 1', + click: noop + }, { + name: 'button 2', + click: noop + }]]; + + var modeBar = createModeBar(getMockGraphInfo(), buttons); + + describe('createModebar', function() { + it('creates a mode bar', function() { + expect(countGroups(modeBar)).toEqual(2); + expect(countButtons(modeBar)).toEqual(3); + expect(countLogo(modeBar)).toEqual(1); + }); + + it('throws when button config does not have name', function() { + expect(function() { + createModeBar(getMockGraphInfo(), [[ + { click: function() { console.log('not gonna work'); } } + ]]); + }).toThrowError(); + }); + + it('throws when button name is not unique', function() { + expect(function() { + createModeBar(getMockGraphInfo(), [[ + { name: 'A', click: function() { console.log('not gonna'); } }, + { name: 'A', click: function() { console.log('... work'); } } + ]]); + }).toThrowError(); + }); - var createModebar = function(buttons) { + it('throws when button config does not have a click handler', function() { + expect(function() { + createModeBar(getMockGraphInfo(), [[ + { name: 'not gonna work' } + ]]); + }).toThrowError(); + }); + + it('defaults title to name when missing', function() { + var modeBar = createModeBar(getMockGraphInfo(), [[ + { name: 'the title too', click: noop } + ]]); + + expect(checkBtnAttr(modeBar, 0, 'data-title')).toEqual('the title too'); + }); + + it('hides title to when title is falsy but not 0', function() { + var modeBar; + + modeBar = createModeBar(getMockGraphInfo(), [[ + { name: 'button', title: null, click: noop } + ]]); + expect(checkBtnAttr(modeBar, 0, 'data-title')).toBe(null); + + modeBar = createModeBar(getMockGraphInfo(), [[ + { name: 'button', title: '', click: noop } + ]]); + expect(checkBtnAttr(modeBar, 0, 'data-title')).toBe(null); - var container = getMockContainerTree(), - graphInfo = getMockGraphInfo(); + modeBar = createModeBar(getMockGraphInfo(), [[ + { name: 'button', title: false, click: noop } + ]]); + expect(checkBtnAttr(modeBar, 0, 'data-title')).toBe(null); - var modebar = new Plotly.ModeBar({ - buttons: buttons, - container: container, - Plotly: Plotly, - graphInfo: graphInfo + modeBar = createModeBar(getMockGraphInfo(), [[ + { name: 'button', title: 0, click: noop } + ]]); + expect(checkBtnAttr(modeBar, 0, 'data-title')).toEqual('0'); }); - return modebar; - }; + }); - describe('Test modebarCleanup:', function() { + describe('modeBar.removeAllButtons', function() { + it('removes all mode bar buttons', function() { + modeBar.removeAllButtons(); - it('should make a cleanup.', function() { - var buttons = [['zoom2d']]; - var modebar = createModebar(buttons); - var modebarParent = modebar.element.parentNode; - modebar.cleanup(); - expect(modebar.element.innerHTML).toEqual(''); - expect(modebarParent.querySelector('.modebar')) - .toBeNull(); + expect(modeBar.element.innerHTML).toEqual(''); + expect(modeBar.hasLogo).toBe(false); }); }); - describe('Test modebarHasButtons:', function() { + describe('modeBar.destroy', function() { + it('removes the mode bar entirely', function() { + var modeBarParent = modeBar.element.parentNode; + + modeBar.destroy(); + + expect(modeBarParent.querySelector('.modebar')).toBeNull(); + }); + }); + + describe('manageModeBar', function() { + + function getButtons(list) { + for(var i = 0; i < list.length; i++) { + for(var j = 0; j < list[i].length; j++) { - var modeButtons2d, - modeButtons3d; + // minimal button config object + list[i][j] = { name: list[i][j], click: noop }; + } + } + return list; + } - // Same as in ../graph_interact.js - beforeEach( function() { - modeButtons2d = [ - ['toImage'], + it('creates mode bar (cartesian version)', function() { + var buttons = getButtons([ + ['toImage', 'sendDataToCloud'], ['zoom2d', 'pan2d'], - ['zoomIn2d', 'zoomOut2d', 'resetScale2d', 'autoScale2d'], - ['hoverClosest2d', 'hoverCompare2d'] - ]; + ['zoomIn2d', 'zoomOut2d', 'autoScale2d', 'resetScale2d'], + ['hoverClosestCartesian', 'hoverCompareCartesian'] + ]); + + var gd = getMockGraphInfo(); + gd._fullLayout._hasCartesian = true; + gd._fullLayout.xaxis = {fixedrange: false}; + + manageModeBar(gd); + var modeBar = gd._fullLayout._modeBar; + + expect(modeBar.hasButtons(buttons)).toBe(true); + expect(countGroups(modeBar)).toEqual(5); + expect(countButtons(modeBar)).toEqual(11); + expect(countLogo(modeBar)).toEqual(1); + }); + + it('creates mode bar (cartesian fixed-axes version)', function() { + var buttons = getButtons([ + ['toImage', 'sendDataToCloud'], + ['hoverClosestCartesian', 'hoverCompareCartesian'] + ]); - modeButtons3d = [ - ['toImage'], - ['orbitRotation', 'tableRotation', 'zoom3d', 'pan3d'], + var gd = getMockGraphInfo(); + gd._fullLayout._hasCartesian = true; + + manageModeBar(gd); + var modeBar = gd._fullLayout._modeBar; + + expect(modeBar.hasButtons(buttons)).toBe(true); + expect(countGroups(modeBar)).toEqual(3); + expect(countButtons(modeBar)).toEqual(5); + expect(countLogo(modeBar)).toEqual(1); + }); + + it('creates mode bar (gl3d version)', function() { + var buttons = getButtons([ + ['toImage', 'sendDataToCloud'], + ['zoom3d', 'pan3d', 'orbitRotation', 'tableRotation'], ['resetCameraDefault3d', 'resetCameraLastSave3d'], ['hoverClosest3d'] + ]); + + var gd = getMockGraphInfo(); + gd._fullLayout._hasGL3D = true; + + manageModeBar(gd); + var modeBar = gd._fullLayout._modeBar; + + expect(modeBar.hasButtons(buttons)).toBe(true); + expect(countGroups(modeBar)).toEqual(5); + expect(countButtons(modeBar)).toEqual(10); + expect(countLogo(modeBar)).toEqual(1); + }); + + it('creates mode bar (geo version)', function() { + var buttons = getButtons([ + ['toImage', 'sendDataToCloud'], + ['zoomInGeo', 'zoomOutGeo', 'resetGeo'], + ['hoverClosestGeo'] + ]); + + var gd = getMockGraphInfo(); + gd._fullLayout._hasGeo = true; + + manageModeBar(gd); + var modeBar = gd._fullLayout._modeBar; + + expect(modeBar.hasButtons(buttons)).toBe(true); + expect(countGroups(modeBar)).toEqual(4); + expect(countButtons(modeBar)).toEqual(7); + expect(countLogo(modeBar)).toEqual(1); + }); + + it('creates mode bar (gl2d version)', function() { + var buttons = getButtons([ + ['toImage', 'sendDataToCloud'], + ['zoom2d', 'pan2d'], + ['zoomIn2d', 'zoomOut2d', 'autoScale2d', 'resetScale2d'], + ['hoverClosestGl2d'] + ]); + + var gd = getMockGraphInfo(); + gd._fullLayout._hasGL2D = true; + gd._fullLayout.xaxis = {fixedrange: false}; + + manageModeBar(gd); + var modeBar = gd._fullLayout._modeBar; + + expect(modeBar.hasButtons(buttons)).toBe(true); + expect(countGroups(modeBar)).toEqual(5); + expect(countButtons(modeBar)).toEqual(10); + expect(countLogo(modeBar)).toEqual(1); + }); + + it('creates mode bar (pie version)', function() { + var buttons = getButtons([ + ['toImage', 'sendDataToCloud'], + ['hoverClosestPie'] + ]); + + var gd = getMockGraphInfo(); + gd._fullLayout._hasPie = true; + + manageModeBar(gd); + var modeBar = gd._fullLayout._modeBar; + + expect(modeBar.hasButtons(buttons)).toBe(true); + expect(countGroups(modeBar)).toEqual(3); + expect(countButtons(modeBar)).toEqual(4); + expect(countLogo(modeBar)).toEqual(1); + }); + + it('throws an error if modeBarButtonsToRemove isn\'t an array', function() { + var gd = getMockGraphInfo(); + gd._context.modeBarButtonsToRemove = 'not gonna work'; + + expect(function() { manageModeBar(gd); }).toThrowError(); + }); + + it('throws an error if modeBarButtonsToAdd isn\'t an array', function() { + var gd = getMockGraphInfo(); + gd._context.modeBarButtonsToAdd = 'not gonna work'; + + expect(function() { manageModeBar(gd); }).toThrowError(); + }); + + it('displays or not mode bar according to displayModeBar config arg', function() { + var gd = getMockGraphInfo(); + manageModeBar(gd); + expect(gd._fullLayout._modeBar).toBeDefined(); + + gd._context.displayModeBar = false; + manageModeBar(gd); + expect(gd._fullLayout._modeBar).not.toBeDefined(); + }); + + it('displays or not logo according to displaylogo config arg', function() { + var gd = getMockGraphInfo(); + manageModeBar(gd); + expect(countLogo(gd._fullLayout._modeBar)).toEqual(1); + + gd._context.displaylogo = false; + manageModeBar(gd); + expect(countLogo(gd._fullLayout._modeBar)).toEqual(0); + }); + + // gives 11 buttons in 5 groups by default + function setupGraphInfo() { + var gd = getMockGraphInfo(); + gd._fullLayout._hasCartesian = true; + gd._fullLayout.xaxis = {fixedrange: false}; + return gd; + } + + it('updates mode bar buttons if plot type changes', function() { + var gd = setupGraphInfo(); + manageModeBar(gd); + + gd._fullLayout._hasCartesian = false; + gd._fullLayout._hasGL3D = true; + manageModeBar(gd); + + expect(countButtons(gd._fullLayout._modeBar)).toEqual(10); + }); + + it('updates mode bar buttons if modeBarButtonsToRemove changes', function() { + var gd = setupGraphInfo(); + manageModeBar(gd); + + gd._context.modeBarButtonsToRemove = ['toImage', 'sendDataToCloud']; + manageModeBar(gd); + + expect(countButtons(gd._fullLayout._modeBar)).toEqual(9); + }); + + it('updates mode bar buttons if modeBarButtonsToAdd changes', function() { + var gd = setupGraphInfo(); + manageModeBar(gd); + + gd._context.modeBarButtonsToAdd = [{ + name: 'some button', + click: noop + }]; + manageModeBar(gd); + + expect(countGroups(gd._fullLayout._modeBar)).toEqual(6); + expect(countButtons(gd._fullLayout._modeBar)).toEqual(12); + }); + + it('sets up buttons with modeBarButtonsToAdd and modeBarButtonToRemove', function() { + var gd = setupGraphInfo(); + gd._context.modeBarButtonsToRemove = [ + 'toImage', 'pan2d', 'hoverCompareCartesian' + ]; + gd._context.modeBarButtonsToAdd = [ + { name: 'some button', click: noop }, + { name: 'some other button', click: noop } ]; + + manageModeBar(gd); + + var modeBar = gd._fullLayout._modeBar; + expect(countGroups(modeBar)).toEqual(6); + expect(countButtons(modeBar)).toEqual(10); }); - it('should return true going from 3D -> 3D buttons.', function() { - var modebar = createModebar(modeButtons3d); - expect(modebar.hasButtons(modeButtons3d)).toBe(true); + it('sets up buttons with modeBarButtonsToAdd and modeBarButtonToRemove (2)', function() { + var gd = setupGraphInfo(); + gd._context.modeBarButtonsToRemove = [ + 'toImage', 'pan2d', 'hoverCompareCartesian' + ]; + gd._context.modeBarButtonsToAdd = [[ + { name: 'some button', click: noop }, + { name: 'some other button', click: noop } + ], [ + { name: 'some button 2', click: noop }, + { name: 'some other button 2', click: noop } + ]]; + + manageModeBar(gd); + + var modeBar = gd._fullLayout._modeBar; + expect(countGroups(modeBar)).toEqual(7); + expect(countButtons(modeBar)).toEqual(12); }); - it('should return true going from 2D -> 2D buttons.', function() { - var modebar = createModebar(modeButtons2d); - expect(modebar.hasButtons(modeButtons2d)).toBe(true); + it('sets up buttons with fully custom modeBarButtons', function() { + var gd = setupGraphInfo(); + gd._context.modeBarButtons = [[ + { name: 'some button', click: noop }, + { name: 'some other button', click: noop } + ], [ + { name: 'some button in another group', click: noop }, + { name: 'some other button in another group', click: noop } + ]]; + + manageModeBar(gd); + + var modeBar = gd._fullLayout._modeBar; + expect(countGroups(modeBar)).toEqual(3); + expect(countButtons(modeBar)).toEqual(5); }); - it('should return false going from 2D -> 3D buttons.', function() { - var modebar = createModebar(modeButtons2d); - expect(modebar.hasButtons(modeButtons3d)).toBe(false); + it('sets up buttons with custom modeBarButtons + default name', function() { + var gd = setupGraphInfo(); + gd._context.modeBarButtons = [[ + { name: 'some button', click: noop }, + { name: 'some other button', click: noop } + ], [ + 'toImage', 'pan2d', 'hoverCompareCartesian' + ]]; + + manageModeBar(gd); + + var modeBar = gd._fullLayout._modeBar; + expect(countGroups(modeBar)).toEqual(3); + expect(countButtons(modeBar)).toEqual(6); }); - it('should return false going from 3D -> 2D buttons.', function() { - var modebar = createModebar(modeButtons3d); - expect(modebar.hasButtons(modeButtons2d)).toBe(false); + it('throw error when modeBarButtons contains invalid name', function() { + var gd = setupGraphInfo(); + gd._context.modeBarButtons = [[ + 'toImage', 'pan2d', 'no gonna work' + ]]; + + expect(function() { manageModeBar(gd); }).toThrowError(); }); + }); + });