diff --git a/draftlogs/7371_add.md b/draftlogs/7371_add.md new file mode 100644 index 00000000000..d9b756dbda5 --- /dev/null +++ b/draftlogs/7371_add.md @@ -0,0 +1 @@ + - Add `minscale`, `maxscale` geo plot attributes [[#7371](https://github.com/plotly/plotly.js/pull/7371)], with thanks to @mojoaxel for the contribution! diff --git a/src/components/modebar/buttons.js b/src/components/modebar/buttons.js index a67fd18b0f0..c8e946db927 100644 --- a/src/components/modebar/buttons.js +++ b/src/components/modebar/buttons.js @@ -8,7 +8,7 @@ var eraseActiveShape = require('../shapes/draw').eraseActiveShape; var Lib = require('../../lib'); var _ = Lib._; -var modeBarButtons = module.exports = {}; +var modeBarButtons = (module.exports = {}); /** * ModeBar buttons configuration @@ -37,31 +37,32 @@ var modeBarButtons = module.exports = {}; */ modeBarButtons.toImage = { name: 'toImage', - title: function(gd) { + title: function (gd) { var opts = gd._context.toImageButtonOptions || {}; var format = opts.format || 'png'; - return format === 'png' ? - _(gd, 'Download plot as a PNG') : // legacy text - _(gd, 'Download plot'); // generic non-PNG text + return format === 'png' + ? _(gd, 'Download plot as a PNG') + : // legacy text + _(gd, 'Download plot'); // generic non-PNG text }, icon: Icons.camera, - click: function(gd) { + click: function (gd) { var toImageButtonOptions = gd._context.toImageButtonOptions; - var opts = {format: toImageButtonOptions.format || 'png'}; + var opts = { format: toImageButtonOptions.format || 'png' }; Lib.notifier(_(gd, 'Taking snapshot - this may take a few seconds'), 'long', gd); - ['filename', 'width', 'height', 'scale'].forEach(function(key) { - if(key in toImageButtonOptions) { + ['filename', 'width', 'height', 'scale'].forEach(function (key) { + if (key in toImageButtonOptions) { opts[key] = toImageButtonOptions[key]; } }); Registry.call('downloadImage', gd, opts) - .then(function(filename) { + .then(function (filename) { Lib.notifier(_(gd, 'Snapshot succeeded') + ' - ' + filename, 'long', gd); }) - .catch(function() { + .catch(function () { Lib.notifier(_(gd, 'Sorry, there was a problem downloading your snapshot!'), 'long', gd); }); } @@ -69,18 +70,22 @@ modeBarButtons.toImage = { modeBarButtons.sendDataToCloud = { name: 'sendDataToCloud', - title: function(gd) { return _(gd, 'Edit in Chart Studio'); }, + title: function (gd) { + return _(gd, 'Edit in Chart Studio'); + }, icon: Icons.disk, - click: function(gd) { + click: function (gd) { Plots.sendDataToCloud(gd); } }; modeBarButtons.editInChartStudio = { name: 'editInChartStudio', - title: function(gd) { return _(gd, 'Edit in Chart Studio'); }, + title: function (gd) { + return _(gd, 'Edit in Chart Studio'); + }, icon: Icons.pencil, - click: function(gd) { + click: function (gd) { Plots.sendDataToCloud(gd); } }; @@ -88,7 +93,9 @@ modeBarButtons.editInChartStudio = { modeBarButtons.zoom2d = { name: 'zoom2d', _cat: 'zoom', - title: function(gd) { return _(gd, 'Zoom'); }, + title: function (gd) { + return _(gd, 'Zoom'); + }, attr: 'dragmode', val: 'zoom', icon: Icons.zoombox, @@ -98,7 +105,9 @@ modeBarButtons.zoom2d = { modeBarButtons.pan2d = { name: 'pan2d', _cat: 'pan', - title: function(gd) { return _(gd, 'Pan'); }, + title: function (gd) { + return _(gd, 'Pan'); + }, attr: 'dragmode', val: 'pan', icon: Icons.pan, @@ -108,7 +117,9 @@ modeBarButtons.pan2d = { modeBarButtons.select2d = { name: 'select2d', _cat: 'select', - title: function(gd) { return _(gd, 'Box Select'); }, + title: function (gd) { + return _(gd, 'Box Select'); + }, attr: 'dragmode', val: 'select', icon: Icons.selectbox, @@ -118,7 +129,9 @@ modeBarButtons.select2d = { modeBarButtons.lasso2d = { name: 'lasso2d', _cat: 'lasso', - title: function(gd) { return _(gd, 'Lasso Select'); }, + title: function (gd) { + return _(gd, 'Lasso Select'); + }, attr: 'dragmode', val: 'lasso', icon: Icons.lasso, @@ -127,7 +140,9 @@ modeBarButtons.lasso2d = { modeBarButtons.drawclosedpath = { name: 'drawclosedpath', - title: function(gd) { return _(gd, 'Draw closed freeform'); }, + title: function (gd) { + return _(gd, 'Draw closed freeform'); + }, attr: 'dragmode', val: 'drawclosedpath', icon: Icons.drawclosedpath, @@ -136,7 +151,9 @@ modeBarButtons.drawclosedpath = { modeBarButtons.drawopenpath = { name: 'drawopenpath', - title: function(gd) { return _(gd, 'Draw open freeform'); }, + title: function (gd) { + return _(gd, 'Draw open freeform'); + }, attr: 'dragmode', val: 'drawopenpath', icon: Icons.drawopenpath, @@ -145,7 +162,9 @@ modeBarButtons.drawopenpath = { modeBarButtons.drawline = { name: 'drawline', - title: function(gd) { return _(gd, 'Draw line'); }, + title: function (gd) { + return _(gd, 'Draw line'); + }, attr: 'dragmode', val: 'drawline', icon: Icons.drawline, @@ -154,7 +173,9 @@ modeBarButtons.drawline = { modeBarButtons.drawrect = { name: 'drawrect', - title: function(gd) { return _(gd, 'Draw rectangle'); }, + title: function (gd) { + return _(gd, 'Draw rectangle'); + }, attr: 'dragmode', val: 'drawrect', icon: Icons.drawrect, @@ -163,7 +184,9 @@ modeBarButtons.drawrect = { modeBarButtons.drawcircle = { name: 'drawcircle', - title: function(gd) { return _(gd, 'Draw circle'); }, + title: function (gd) { + return _(gd, 'Draw circle'); + }, attr: 'dragmode', val: 'drawcircle', icon: Icons.drawcircle, @@ -172,7 +195,9 @@ modeBarButtons.drawcircle = { modeBarButtons.eraseshape = { name: 'eraseshape', - title: function(gd) { return _(gd, 'Erase active shape'); }, + title: function (gd) { + return _(gd, 'Erase active shape'); + }, icon: Icons.eraseshape, click: eraseActiveShape }; @@ -180,7 +205,9 @@ modeBarButtons.eraseshape = { modeBarButtons.zoomIn2d = { name: 'zoomIn2d', _cat: 'zoomin', - title: function(gd) { return _(gd, 'Zoom in'); }, + title: function (gd) { + return _(gd, 'Zoom in'); + }, attr: 'zoom', val: 'in', icon: Icons.zoom_plus, @@ -190,7 +217,9 @@ modeBarButtons.zoomIn2d = { modeBarButtons.zoomOut2d = { name: 'zoomOut2d', _cat: 'zoomout', - title: function(gd) { return _(gd, 'Zoom out'); }, + title: function (gd) { + return _(gd, 'Zoom out'); + }, attr: 'zoom', val: 'out', icon: Icons.zoom_minus, @@ -200,7 +229,9 @@ modeBarButtons.zoomOut2d = { modeBarButtons.autoScale2d = { name: 'autoScale2d', _cat: 'autoscale', - title: function(gd) { return _(gd, 'Autoscale'); }, + title: function (gd) { + return _(gd, 'Autoscale'); + }, attr: 'zoom', val: 'auto', icon: Icons.autoscale, @@ -210,7 +241,9 @@ modeBarButtons.autoScale2d = { modeBarButtons.resetScale2d = { name: 'resetScale2d', _cat: 'resetscale', - title: function(gd) { return _(gd, 'Reset axes'); }, + title: function (gd) { + return _(gd, 'Reset axes'); + }, attr: 'zoom', val: 'reset', icon: Icons.home, @@ -220,7 +253,9 @@ modeBarButtons.resetScale2d = { modeBarButtons.hoverClosestCartesian = { name: 'hoverClosestCartesian', _cat: 'hoverclosest', - title: function(gd) { return _(gd, 'Show closest data on hover'); }, + title: function (gd) { + return _(gd, 'Show closest data on hover'); + }, attr: 'hovermode', val: 'closest', icon: Icons.tooltip_basic, @@ -231,9 +266,11 @@ modeBarButtons.hoverClosestCartesian = { modeBarButtons.hoverCompareCartesian = { name: 'hoverCompareCartesian', _cat: 'hoverCompare', - title: function(gd) { return _(gd, 'Compare data on hover'); }, + title: function (gd) { + return _(gd, 'Compare data on hover'); + }, attr: 'hovermode', - val: function(gd) { + val: function (gd) { return gd._fullLayout._isHoriz ? 'y' : 'x'; }, icon: Icons.tooltip_compare, @@ -252,29 +289,29 @@ function handleCartesian(gd, ev) { var ax, i; - if(astr === 'zoom') { - var mag = (val === 'in') ? 0.5 : 2; + if (astr === 'zoom') { + var mag = val === 'in' ? 0.5 : 2; var r0 = (1 + mag) / 2; var r1 = (1 - mag) / 2; var axName, allowed; - for(i = 0; i < axList.length; i++) { + for (i = 0; i < axList.length; i++) { ax = axList[i]; - allowed = ax.modebardisable === 'none' || ax.modebardisable.indexOf( - (val === 'auto' || val === 'reset') ? 'autoscale' : 'zoominout' - ) === -1; + allowed = + ax.modebardisable === 'none' || + ax.modebardisable.indexOf(val === 'auto' || val === 'reset' ? 'autoscale' : 'zoominout') === -1; - if(allowed && !ax.fixedrange) { + if (allowed && !ax.fixedrange) { axName = ax._name; - if(val === 'auto') { + if (val === 'auto') { aobj[axName + '.autorange'] = true; - } else if(val === 'reset') { - if(ax._rangeInitial0 === undefined && ax._rangeInitial1 === undefined) { + } else if (val === 'reset') { + if (ax._rangeInitial0 === undefined && ax._rangeInitial1 === undefined) { aobj[axName + '.autorange'] = true; - } else if(ax._rangeInitial0 === undefined) { + } else if (ax._rangeInitial0 === undefined) { aobj[axName + '.autorange'] = ax._autorangeInitial; aobj[axName + '.range'] = [null, ax._rangeInitial1]; - } else if(ax._rangeInitial1 === undefined) { + } else if (ax._rangeInitial1 === undefined) { aobj[axName + '.range'] = [ax._rangeInitial0, null]; aobj[axName + '.autorange'] = ax._autorangeInitial; } else { @@ -282,22 +319,16 @@ function handleCartesian(gd, ev) { } // N.B. "reset" also resets showspikes - if(ax._showSpikeInitial !== undefined) { + if (ax._showSpikeInitial !== undefined) { aobj[axName + '.showspikes'] = ax._showSpikeInitial; - if(allSpikesEnabled === 'on' && !ax._showSpikeInitial) { + if (allSpikesEnabled === 'on' && !ax._showSpikeInitial) { allSpikesEnabled = 'off'; } } } else { - var rangeNow = [ - ax.r2l(ax.range[0]), - ax.r2l(ax.range[1]), - ]; + var rangeNow = [ax.r2l(ax.range[0]), ax.r2l(ax.range[1])]; - var rangeNew = [ - r0 * rangeNow[0] + r1 * rangeNow[1], - r0 * rangeNow[1] + r1 * rangeNow[0] - ]; + var rangeNew = [r0 * rangeNow[0] + r1 * rangeNow[1], r0 * rangeNow[1] + r1 * rangeNow[0]]; aobj[axName + '.range[0]'] = ax.l2r(rangeNew[0]); aobj[axName + '.range[1]'] = ax.l2r(rangeNew[1]); @@ -306,7 +337,7 @@ function handleCartesian(gd, ev) { } } else { // if ALL traces have orientation 'h', 'hovermode': 'x' otherwise: 'y' - if(astr === 'hovermode' && (val === 'x' || val === 'y')) { + if (astr === 'hovermode' && (val === 'x' || val === 'y')) { val = fullLayout._isHoriz ? 'y' : 'x'; button.setAttribute('data-val', val); } @@ -322,7 +353,9 @@ function handleCartesian(gd, ev) { modeBarButtons.zoom3d = { name: 'zoom3d', _cat: 'zoom', - title: function(gd) { return _(gd, 'Zoom'); }, + title: function (gd) { + return _(gd, 'Zoom'); + }, attr: 'scene.dragmode', val: 'zoom', icon: Icons.zoombox, @@ -332,7 +365,9 @@ modeBarButtons.zoom3d = { modeBarButtons.pan3d = { name: 'pan3d', _cat: 'pan', - title: function(gd) { return _(gd, 'Pan'); }, + title: function (gd) { + return _(gd, 'Pan'); + }, attr: 'scene.dragmode', val: 'pan', icon: Icons.pan, @@ -341,7 +376,9 @@ modeBarButtons.pan3d = { modeBarButtons.orbitRotation = { name: 'orbitRotation', - title: function(gd) { return _(gd, 'Orbital rotation'); }, + title: function (gd) { + return _(gd, 'Orbital rotation'); + }, attr: 'scene.dragmode', val: 'orbit', icon: Icons['3d_rotate'], @@ -350,7 +387,9 @@ modeBarButtons.orbitRotation = { modeBarButtons.tableRotation = { name: 'tableRotation', - title: function(gd) { return _(gd, 'Turntable rotation'); }, + title: function (gd) { + return _(gd, 'Turntable rotation'); + }, attr: 'scene.dragmode', val: 'turntable', icon: Icons['z-axis'], @@ -366,12 +405,12 @@ function handleDrag3d(gd, ev) { var parts = attr.split('.'); - for(var i = 0; i < sceneIds.length; i++) { + for (var i = 0; i < sceneIds.length; i++) { layoutUpdate[sceneIds[i] + '.' + parts[1]] = val; } // for multi-type subplots - var val2d = (val === 'pan') ? val : 'zoom'; + var val2d = val === 'pan' ? val : 'zoom'; layoutUpdate.dragmode = val2d; Registry.call('_guiRelayout', gd, layoutUpdate); @@ -380,7 +419,9 @@ function handleDrag3d(gd, ev) { modeBarButtons.resetCameraDefault3d = { name: 'resetCameraDefault3d', _cat: 'resetCameraDefault', - title: function(gd) { return _(gd, 'Reset camera to default'); }, + title: function (gd) { + return _(gd, 'Reset camera to default'); + }, attr: 'resetDefault', icon: Icons.home, click: handleCamera3d @@ -389,7 +430,9 @@ modeBarButtons.resetCameraDefault3d = { modeBarButtons.resetCameraLastSave3d = { name: 'resetCameraLastSave3d', _cat: 'resetCameraLastSave', - title: function(gd) { return _(gd, 'Reset camera to last save'); }, + title: function (gd) { + return _(gd, 'Reset camera to last save'); + }, attr: 'resetLastSave', icon: Icons.movie, click: handleCamera3d @@ -405,7 +448,7 @@ function handleCamera3d(gd, ev) { var sceneIds = fullLayout._subplots.gl3d || []; var aobj = {}; - for(var i = 0; i < sceneIds.length; i++) { + for (var i = 0; i < sceneIds.length; i++) { var sceneId = sceneIds[i]; var camera = sceneId + '.camera'; var aspectratio = sceneId + '.aspectratio'; @@ -413,19 +456,19 @@ function handleCamera3d(gd, ev) { var scene = fullLayout[sceneId]._scene; var didUpdate; - if(resetLastSave) { + if (resetLastSave) { aobj[camera + '.up'] = scene.viewInitial.up; aobj[camera + '.eye'] = scene.viewInitial.eye; aobj[camera + '.center'] = scene.viewInitial.center; didUpdate = true; - } else if(resetDefault) { + } else if (resetDefault) { aobj[camera + '.up'] = null; aobj[camera + '.eye'] = null; aobj[camera + '.center'] = null; didUpdate = true; } - if(didUpdate) { + if (didUpdate) { aobj[aspectratio + '.x'] = scene.viewInitial.aspectratio.x; aobj[aspectratio + '.y'] = scene.viewInitial.aspectratio.y; aobj[aspectratio + '.z'] = scene.viewInitial.aspectratio.z; @@ -439,7 +482,9 @@ function handleCamera3d(gd, ev) { modeBarButtons.hoverClosest3d = { name: 'hoverClosest3d', _cat: 'hoverclosest', - title: function(gd) { return _(gd, 'Toggle show closest data on hover'); }, + title: function (gd) { + return _(gd, 'Toggle show closest data on hover'); + }, attr: 'hovermode', val: null, toggle: true, @@ -460,11 +505,11 @@ function getNextHover3d(gd, ev) { var currentSpikes = {}; var layoutUpdate = {}; - if(val) { + if (val) { layoutUpdate = val; button._previousVal = null; } else { - for(var i = 0; i < sceneIds.length; i++) { + for (var i = 0; i < sceneIds.length; i++) { var sceneId = sceneIds[i]; var sceneLayout = fullLayout[sceneId]; @@ -473,7 +518,7 @@ function getNextHover3d(gd, ev) { layoutUpdate[hovermodeAStr] = false; // copy all the current spike attrs - for(var j = 0; j < 3; j++) { + for (var j = 0; j < 3; j++) { var axis = axes[j]; var spikeAStr = sceneId + '.' + axis + '.showspikes'; layoutUpdate[spikeAStr] = false; @@ -494,7 +539,9 @@ function handleHover3d(gd, ev) { modeBarButtons.zoomInGeo = { name: 'zoomInGeo', _cat: 'zoomin', - title: function(gd) { return _(gd, 'Zoom in'); }, + title: function (gd) { + return _(gd, 'Zoom in'); + }, attr: 'zoom', val: 'in', icon: Icons.zoom_plus, @@ -504,7 +551,9 @@ modeBarButtons.zoomInGeo = { modeBarButtons.zoomOutGeo = { name: 'zoomOutGeo', _cat: 'zoomout', - title: function(gd) { return _(gd, 'Zoom out'); }, + title: function (gd) { + return _(gd, 'Zoom out'); + }, attr: 'zoom', val: 'out', icon: Icons.zoom_minus, @@ -514,7 +563,9 @@ modeBarButtons.zoomOutGeo = { modeBarButtons.resetGeo = { name: 'resetGeo', _cat: 'reset', - title: function(gd) { return _(gd, 'Reset'); }, + title: function (gd) { + return _(gd, 'Reset'); + }, attr: 'reset', val: null, icon: Icons.autoscale, @@ -524,7 +575,9 @@ modeBarButtons.resetGeo = { modeBarButtons.hoverClosestGeo = { name: 'hoverClosestGeo', _cat: 'hoverclosest', - title: function(gd) { return _(gd, 'Toggle show closest data on hover'); }, + title: function (gd) { + return _(gd, 'Toggle show closest data on hover'); + }, attr: 'hovermode', val: null, toggle: true, @@ -534,33 +587,42 @@ modeBarButtons.hoverClosestGeo = { }; function handleGeo(gd, ev) { - var button = ev.currentTarget; - var attr = button.getAttribute('data-attr'); - var val = button.getAttribute('data-val') || true; - var fullLayout = gd._fullLayout; - var geoIds = fullLayout._subplots.geo || []; - - for(var i = 0; i < geoIds.length; i++) { - var id = geoIds[i]; - var geoLayout = fullLayout[id]; - - if(attr === 'zoom') { - var scale = geoLayout.projection.scale; - var newScale = (val === 'in') ? 2 * scale : 0.5 * scale; - - Registry.call('_guiRelayout', gd, id + '.projection.scale', newScale); + const button = ev.currentTarget; + const attr = button.getAttribute('data-attr'); + const val = button.getAttribute('data-val') || true; + const fullLayout = gd._fullLayout; + const geoIds = fullLayout._subplots.geo || []; + + for (const id of geoIds) { + const geoLayout = fullLayout[id]; + + if (attr === 'zoom') { + const { minscale, scale } = geoLayout.projection; + const maxscale = geoLayout.projection.maxscale ?? Infinity; + // swap if user supplied min > max so clamping is well-defined + const min = Math.min(minscale, maxscale); + const max = Math.max(minscale, maxscale); + let newScale = val === 'in' ? 2 * scale : 0.5 * scale; + + // clamp to [min, max] + if (newScale > max) newScale = max; + else if (newScale < min) newScale = min; + + if (newScale !== scale) { + Registry.call('_guiRelayout', gd, id + '.projection.scale', newScale); + } } } - if(attr === 'reset') { - resetView(gd, 'geo'); - } + if (attr === 'reset') resetView(gd, 'geo'); } modeBarButtons.hoverClosestPie = { name: 'hoverClosestPie', _cat: 'hoverclosest', - title: function(gd) { return _(gd, 'Toggle show closest data on hover'); }, + title: function (gd) { + return _(gd, 'Toggle show closest data on hover'); + }, attr: 'hovermode', val: 'closest', icon: Icons.tooltip_basic, @@ -571,9 +633,9 @@ modeBarButtons.hoverClosestPie = { function getNextHover(gd) { var fullLayout = gd._fullLayout; - if(fullLayout.hovermode) return false; + if (fullLayout.hovermode) return false; - if(fullLayout._has('cartesian')) { + if (fullLayout._has('cartesian')) { return fullLayout._isHoriz ? 'y' : 'x'; } return 'closest'; @@ -586,15 +648,17 @@ function toggleHover(gd) { modeBarButtons.resetViewSankey = { name: 'resetSankeyGroup', - title: function(gd) { return _(gd, 'Reset view'); }, + title: function (gd) { + return _(gd, 'Reset view'); + }, icon: Icons.home, - click: function(gd) { + click: function (gd) { var aObj = { 'node.groups': [], 'node.x': [], 'node.y': [] }; - for(var i = 0; i < gd._fullData.length; i++) { + for (var i = 0; i < gd._fullData.length; i++) { var viewInitial = gd._fullData[i]._viewInitial; aObj['node.groups'].push(viewInitial.node.groups.slice()); aObj['node.x'].push(viewInitial.node.x.slice()); @@ -608,13 +672,15 @@ modeBarButtons.resetViewSankey = { modeBarButtons.toggleHover = { name: 'toggleHover', - title: function(gd) { return _(gd, 'Toggle show closest data on hover'); }, + title: function (gd) { + return _(gd, 'Toggle show closest data on hover'); + }, attr: 'hovermode', val: null, toggle: true, icon: Icons.tooltip_basic, gravity: 'ne', - click: function(gd, ev) { + click: function (gd, ev) { var layoutUpdate = getNextHover3d(gd, ev); layoutUpdate.hovermode = getNextHover(gd); @@ -624,9 +690,11 @@ modeBarButtons.toggleHover = { modeBarButtons.resetViews = { name: 'resetViews', - title: function(gd) { return _(gd, 'Reset views'); }, + title: function (gd) { + return _(gd, 'Reset views'); + }, icon: Icons.home, - click: function(gd, ev) { + click: function (gd, ev) { var button = ev.currentTarget; button.setAttribute('data-attr', 'zoom'); @@ -644,11 +712,13 @@ modeBarButtons.resetViews = { modeBarButtons.toggleSpikelines = { name: 'toggleSpikelines', - title: function(gd) { return _(gd, 'Toggle Spike Lines'); }, + title: function (gd) { + return _(gd, 'Toggle Spike Lines'); + }, icon: Icons.spikeline, attr: '_cartesianSpikesEnabled', val: 'on', - click: function(gd) { + click: function (gd) { var fullLayout = gd._fullLayout; var allSpikesEnabled = fullLayout._cartesianSpikesEnabled; @@ -663,7 +733,7 @@ function setSpikelineVisibility(gd) { var axList = axisIds.list(gd, null, true); var aobj = {}; - for(var i = 0; i < axList.length; i++) { + for (var i = 0; i < axList.length; i++) { var ax = axList[i]; aobj[ax._name + '.showspikes'] = areSpikesOn ? true : ax._showSpikeInitial; } @@ -674,10 +744,12 @@ function setSpikelineVisibility(gd) { modeBarButtons.resetViewMapbox = { name: 'resetViewMapbox', _cat: 'resetView', - title: function(gd) { return _(gd, 'Reset view'); }, + title: function (gd) { + return _(gd, 'Reset view'); + }, attr: 'reset', icon: Icons.home, - click: function(gd) { + click: function (gd) { resetView(gd, 'mapbox'); } }; @@ -685,10 +757,12 @@ modeBarButtons.resetViewMapbox = { modeBarButtons.resetViewMap = { name: 'resetViewMap', _cat: 'resetView', - title: function(gd) { return _(gd, 'Reset view'); }, + title: function (gd) { + return _(gd, 'Reset view'); + }, attr: 'reset', icon: Icons.home, - click: function(gd) { + click: function (gd) { resetView(gd, 'map'); } }; @@ -696,7 +770,9 @@ modeBarButtons.resetViewMap = { modeBarButtons.zoomInMapbox = { name: 'zoomInMapbox', _cat: 'zoomin', - title: function(gd) { return _(gd, 'Zoom in'); }, + title: function (gd) { + return _(gd, 'Zoom in'); + }, attr: 'zoom', val: 'in', icon: Icons.zoom_plus, @@ -706,7 +782,9 @@ modeBarButtons.zoomInMapbox = { modeBarButtons.zoomInMap = { name: 'zoomInMap', _cat: 'zoomin', - title: function(gd) { return _(gd, 'Zoom in'); }, + title: function (gd) { + return _(gd, 'Zoom in'); + }, attr: 'zoom', val: 'in', icon: Icons.zoom_plus, @@ -716,7 +794,9 @@ modeBarButtons.zoomInMap = { modeBarButtons.zoomOutMapbox = { name: 'zoomOutMapbox', _cat: 'zoomout', - title: function(gd) { return _(gd, 'Zoom out'); }, + title: function (gd) { + return _(gd, 'Zoom out'); + }, attr: 'zoom', val: 'out', icon: Icons.zoom_minus, @@ -726,7 +806,9 @@ modeBarButtons.zoomOutMapbox = { modeBarButtons.zoomOutMap = { name: 'zoomOutMap', _cat: 'zoomout', - title: function(gd) { return _(gd, 'Zoom out'); }, + title: function (gd) { + return _(gd, 'Zoom out'); + }, attr: 'zoom', val: 'out', icon: Icons.zoom_minus, @@ -749,10 +831,10 @@ function _handleMapZoom(gd, ev, mapType) { var scalar = 1.05; var aObj = {}; - for(var i = 0; i < subplotIds.length; i++) { + for (var i = 0; i < subplotIds.length; i++) { var id = subplotIds[i]; var current = fullLayout[id].zoom; - var next = (val === 'in') ? scalar * current : current / scalar; + var next = val === 'in' ? scalar * current : current / scalar; aObj[id + '.zoom'] = next; } @@ -764,13 +846,13 @@ function resetView(gd, subplotType) { var subplotIds = fullLayout._subplots[subplotType] || []; var aObj = {}; - for(var i = 0; i < subplotIds.length; i++) { + for (var i = 0; i < subplotIds.length; i++) { var id = subplotIds[i]; var subplotObj = fullLayout[id]._subplot; var viewInitial = subplotObj.viewInitial; var viewKeys = Object.keys(viewInitial); - for(var j = 0; j < viewKeys.length; j++) { + for (var j = 0; j < viewKeys.length; j++) { var key = viewKeys[j]; aObj[id + '.' + key] = viewInitial[key]; } diff --git a/src/plots/geo/geo.js b/src/plots/geo/geo.js index 797ab8373b6..aa9d73eccd5 100644 --- a/src/plots/geo/geo.js +++ b/src/plots/geo/geo.js @@ -67,9 +67,9 @@ module.exports = function createGeo(opts) { return new Geo(opts); }; -proto.plot = function(geoCalcData, fullLayout, promises, replot) { +proto.plot = function (geoCalcData, fullLayout, promises, replot) { var _this = this; - if(replot) return _this.update(geoCalcData, fullLayout, true); + if (replot) return _this.update(geoCalcData, fullLayout, true); _this._geoCalcData = geoCalcData; _this._fullLayout = fullLayout; @@ -78,37 +78,37 @@ proto.plot = function(geoCalcData, fullLayout, promises, replot) { var geoPromises = []; var needsTopojson = false; - for(var k in constants.layerNameToAdjective) { - if(k !== 'frame' && geoLayout['show' + k]) { + for (var k in constants.layerNameToAdjective) { + if (k !== 'frame' && geoLayout['show' + k]) { needsTopojson = true; break; } } var hasMarkerAngles = false; - for(var i = 0; i < geoCalcData.length; i++) { + for (var i = 0; i < geoCalcData.length; i++) { var trace = geoCalcData[0][0].trace; trace._geo = _this; - if(trace.locationmode) { + if (trace.locationmode) { needsTopojson = true; } var marker = trace.marker; - if(marker) { + if (marker) { var angle = marker.angle; var angleref = marker.angleref; - if(angle || angleref === 'north' || angleref === 'previous') hasMarkerAngles = true; + if (angle || angleref === 'north' || angleref === 'previous') hasMarkerAngles = true; } } this._hasMarkerAngles = hasMarkerAngles; - if(needsTopojson) { + if (needsTopojson) { var topojsonNameNew = topojsonUtils.getTopojsonName(geoLayout); - if(_this.topojson === null || topojsonNameNew !== _this.topojsonName) { + if (_this.topojson === null || topojsonNameNew !== _this.topojsonName) { _this.topojsonName = topojsonNameNew; - if(PlotlyGeoAssets.topojson[_this.topojsonName] === undefined) { + if (PlotlyGeoAssets.topojson[_this.topojsonName] === undefined) { geoPromises.push(_this.fetchTopojson()); } } @@ -116,35 +116,41 @@ proto.plot = function(geoCalcData, fullLayout, promises, replot) { geoPromises = geoPromises.concat(geoUtils.fetchTraceGeoData(geoCalcData)); - promises.push(new Promise(function(resolve, reject) { - Promise.all(geoPromises).then(function() { - _this.topojson = PlotlyGeoAssets.topojson[_this.topojsonName]; - _this.update(geoCalcData, fullLayout); - resolve(); + promises.push( + new Promise(function (resolve, reject) { + Promise.all(geoPromises) + .then(function () { + _this.topojson = PlotlyGeoAssets.topojson[_this.topojsonName]; + _this.update(geoCalcData, fullLayout); + resolve(); + }) + .catch(reject); }) - .catch(reject); - })); + ); }; -proto.fetchTopojson = function() { +proto.fetchTopojson = function () { var _this = this; var topojsonPath = topojsonUtils.getTopojsonPath(_this.topojsonURL, _this.topojsonName); - return new Promise(function(resolve, reject) { - d3.json(topojsonPath, function(err, topojson) { - if(err) { - if(err.status === 404) { - return reject(new Error([ - 'plotly.js could not find topojson file at', - topojsonPath + '.', - 'Make sure the *topojsonURL* plot config option', - 'is set properly.' - ].join(' '))); + return new Promise(function (resolve, reject) { + d3.json(topojsonPath, function (err, topojson) { + if (err) { + if (err.status === 404) { + return reject( + new Error( + [ + 'plotly.js could not find topojson file at', + topojsonPath + '.', + 'Make sure the *topojsonURL* plot config option', + 'is set properly.' + ].join(' ') + ) + ); } else { - return reject(new Error([ - 'unexpected error while fetching topojson file at', - topojsonPath - ].join(' '))); + return reject( + new Error(['unexpected error while fetching topojson file at', topojsonPath].join(' ')) + ); } } @@ -154,29 +160,29 @@ proto.fetchTopojson = function() { }); }; -proto.update = function(geoCalcData, fullLayout, replot) { +proto.update = function (geoCalcData, fullLayout, replot) { var geoLayout = fullLayout[this.id]; // important: maps with choropleth traces have a different layer order this.hasChoropleth = false; - for(var i = 0; i < geoCalcData.length; i++) { + for (var i = 0; i < geoCalcData.length; i++) { var calcTrace = geoCalcData[i]; var trace = calcTrace[0].trace; - if(trace.type === 'choropleth') { + if (trace.type === 'choropleth') { this.hasChoropleth = true; } - if(trace.visible === true && trace._length > 0) { + if (trace.visible === true && trace._length > 0) { trace._module.calcGeoJSON(calcTrace, fullLayout); } } - if(!replot) { + if (!replot) { var hasInvalidBounds = this.updateProjection(geoCalcData, fullLayout); - if(hasInvalidBounds) return; + if (hasInvalidBounds) return; - if(!this.viewInitial || this.scope !== geoLayout.scope) { + if (!this.viewInitial || this.scope !== geoLayout.scope) { this.saveViewInitial(geoLayout); } } @@ -199,7 +205,7 @@ proto.update = function(geoCalcData, fullLayout, replot) { this._render(); }; -proto.updateProjection = function(geoCalcData, fullLayout) { +proto.updateProjection = function (geoCalcData, fullLayout) { var gd = this.graphDiv; var geoLayout = fullLayout[this.id]; var gs = fullLayout._size; @@ -211,23 +217,20 @@ proto.updateProjection = function(geoCalcData, fullLayout) { var axLon = lonaxis._ax; var axLat = lataxis._ax; - var projection = this.projection = getProjection(geoLayout); + var projection = (this.projection = getProjection(geoLayout)); // setup subplot extent [[x0,y0], [x1,y1]] - var extent = [[ - gs.l + gs.w * domain.x[0], - gs.t + gs.h * (1 - domain.y[1]) - ], [ - gs.l + gs.w * domain.x[1], - gs.t + gs.h * (1 - domain.y[0]) - ]]; + var extent = [ + [gs.l + gs.w * domain.x[0], gs.t + gs.h * (1 - domain.y[1])], + [gs.l + gs.w * domain.x[1], gs.t + gs.h * (1 - domain.y[0])] + ]; var center = geoLayout.center || {}; var rotation = projLayout.rotation || {}; var lonaxisRange = lonaxis.range || []; var lataxisRange = lataxis.range || []; - if(geoLayout.fitbounds) { + if (geoLayout.fitbounds) { axLon._length = extent[1][0] - extent[0][0]; axLat._length = extent[1][1] - extent[0][1]; axLon.range = getAutoRange(gd, axLon); @@ -236,21 +239,21 @@ proto.updateProjection = function(geoCalcData, fullLayout) { var midLon = (axLon.range[0] + axLon.range[1]) / 2; var midLat = (axLat.range[0] + axLat.range[1]) / 2; - if(geoLayout._isScoped) { - center = {lon: midLon, lat: midLat}; - } else if(geoLayout._isClipped) { - center = {lon: midLon, lat: midLat}; - rotation = {lon: midLon, lat: midLat, roll: rotation.roll}; + if (geoLayout._isScoped) { + center = { lon: midLon, lat: midLat }; + } else if (geoLayout._isClipped) { + center = { lon: midLon, lat: midLat }; + rotation = { lon: midLon, lat: midLat, roll: rotation.roll }; var projType = projLayout.type; - var lonHalfSpan = (constants.lonaxisSpan[projType] / 2) || 180; - var latHalfSpan = (constants.lataxisSpan[projType] / 2) || 90; + var lonHalfSpan = constants.lonaxisSpan[projType] / 2 || 180; + var latHalfSpan = constants.lataxisSpan[projType] / 2 || 90; lonaxisRange = [midLon - lonHalfSpan, midLon + lonHalfSpan]; lataxisRange = [midLat - latHalfSpan, midLat + latHalfSpan]; } else { - center = {lon: midLon, lat: midLat}; - rotation = {lon: midLon, lat: rotation.lat, roll: rotation.roll}; + center = { lon: midLon, lat: midLat }; + rotation = { lon: midLon, lat: rotation.lat, roll: rotation.roll }; } } @@ -264,18 +267,25 @@ proto.updateProjection = function(geoCalcData, fullLayout) { var rangeBox = makeRangeBox(lonaxisRange, lataxisRange); projection.fitExtent(extent, rangeBox); - var b = this.bounds = projection.getBounds(rangeBox); - var s = this.fitScale = projection.scale(); + var b = (this.bounds = projection.getBounds(rangeBox)); + var s = (this.fitScale = projection.scale()); var t = projection.translate(); - if(geoLayout.fitbounds) { + // scaleExtent uses fitScale so min/maxscale are relative to the + // user-facing projection.scale (where 1 == fits lon/lat ranges). + // https://d3js.org/d3-zoom#zoom_scaleExtent + projection.scaleExtent = () => { + const { minscale } = projLayout; + const maxscale = projLayout.maxscale ?? Infinity; + // swap if user supplied min > max so d3 receives a valid range + return [s * Math.min(minscale, maxscale), s * Math.max(minscale, maxscale)]; + }; + + if (geoLayout.fitbounds) { var b2 = projection.getBounds(makeRangeBox(axLon.range, axLat.range)); - var k2 = Math.min( - (b[1][0] - b[0][0]) / (b2[1][0] - b2[0][0]), - (b[1][1] - b[0][1]) / (b2[1][1] - b2[0][1]) - ); + var k2 = Math.min((b[1][0] - b[0][0]) / (b2[1][0] - b2[0][0]), (b[1][1] - b[0][1]) / (b2[1][1] - b2[0][1])); - if(isFinite(k2)) { + if (isFinite(k2)) { projection.scale(k2 * s); } else { Lib.warn('Something went wrong during' + this.id + 'fitbounds computations.'); @@ -287,36 +297,28 @@ proto.updateProjection = function(geoCalcData, fullLayout) { // px coordinates of view mid-point, // useful to update `geo.center` after interactions - var midPt = this.midPt = [ - (b[0][0] + b[1][0]) / 2, - (b[0][1] + b[1][1]) / 2 - ]; + var midPt = (this.midPt = [(b[0][0] + b[1][0]) / 2, (b[0][1] + b[1][1]) / 2]); - projection - .translate([t[0] + (midPt[0] - t[0]), t[1] + (midPt[1] - t[1])]) - .clipExtent(b); + projection.translate([t[0] + (midPt[0] - t[0]), t[1] + (midPt[1] - t[1])]).clipExtent(b); // the 'albers usa' projection does not expose a 'center' method // so here's this hack to make it respond to 'geoLayout.center' - if(geoLayout._isAlbersUsa) { + if (geoLayout._isAlbersUsa) { var centerPx = projection([center.lon, center.lat]); var tt = projection.translate(); - projection.translate([ - tt[0] - (centerPx[0] - tt[0]), - tt[1] - (centerPx[1] - tt[1]) - ]); + projection.translate([tt[0] - (centerPx[0] - tt[0]), tt[1] - (centerPx[1] - tt[1])]); } }; -proto.updateBaseLayers = function(fullLayout, geoLayout) { +proto.updateBaseLayers = function (fullLayout, geoLayout) { var _this = this; var topojson = _this.topojson; var layers = _this.layers; var basePaths = _this.basePaths; function isAxisLayer(d) { - return (d === 'lonaxis' || d === 'lataxis'); + return d === 'lonaxis' || d === 'lataxis'; } function isLineLayer(d) { @@ -327,78 +329,68 @@ proto.updateBaseLayers = function(fullLayout, geoLayout) { return Boolean(constants.fillLayers[d]); } - var allLayers = this.hasChoropleth ? - constants.layersForChoropleth : - constants.layers; + var allLayers = this.hasChoropleth ? constants.layersForChoropleth : constants.layers; - var layerData = allLayers.filter(function(d) { - return (isLineLayer(d) || isFillLayer(d)) ? geoLayout['show' + d] : - isAxisLayer(d) ? geoLayout[d].showgrid : - true; + var layerData = allLayers.filter(function (d) { + return isLineLayer(d) || isFillLayer(d) ? geoLayout['show' + d] : isAxisLayer(d) ? geoLayout[d].showgrid : true; }); - var join = _this.framework.selectAll('.layer') - .data(layerData, String); + var join = _this.framework.selectAll('.layer').data(layerData, String); - join.exit().each(function(d) { + join.exit().each(function (d) { delete layers[d]; delete basePaths[d]; d3.select(this).remove(); }); - join.enter().append('g') - .attr('class', function(d) { return 'layer ' + d; }) - .each(function(d) { - var layer = layers[d] = d3.select(this); - - if(d === 'bg') { - _this.bgRect = layer.append('rect') - .style('pointer-events', 'all'); - } else if(isAxisLayer(d)) { - basePaths[d] = layer.append('path') - .style('fill', 'none'); - } else if(d === 'backplot') { - layer.append('g') - .classed('choroplethlayer', true); - } else if(d === 'frontplot') { - layer.append('g') - .classed('scatterlayer', true); - } else if(isLineLayer(d)) { - basePaths[d] = layer.append('path') - .style('fill', 'none') - .style('stroke-miterlimit', 2); - } else if(isFillLayer(d)) { - basePaths[d] = layer.append('path') - .style('stroke', 'none'); + join.enter() + .append('g') + .attr('class', function (d) { + return 'layer ' + d; + }) + .each(function (d) { + var layer = (layers[d] = d3.select(this)); + + if (d === 'bg') { + _this.bgRect = layer.append('rect').style('pointer-events', 'all'); + } else if (isAxisLayer(d)) { + basePaths[d] = layer.append('path').style('fill', 'none'); + } else if (d === 'backplot') { + layer.append('g').classed('choroplethlayer', true); + } else if (d === 'frontplot') { + layer.append('g').classed('scatterlayer', true); + } else if (isLineLayer(d)) { + basePaths[d] = layer.append('path').style('fill', 'none').style('stroke-miterlimit', 2); + } else if (isFillLayer(d)) { + basePaths[d] = layer.append('path').style('stroke', 'none'); } }); join.order(); - join.each(function(d) { + join.each(function (d) { var path = basePaths[d]; var adj = constants.layerNameToAdjective[d]; - if(d === 'frame') { + if (d === 'frame') { path.datum(constants.sphereSVG); - } else if(isLineLayer(d) || isFillLayer(d)) { + } else if (isLineLayer(d) || isFillLayer(d)) { path.datum(topojsonFeature(topojson, topojson.objects[d])); - } else if(isAxisLayer(d)) { + } else if (isAxisLayer(d)) { path.datum(makeGraticule(d, geoLayout, fullLayout)) .call(Color.stroke, geoLayout[d].gridcolor) .call(Drawing.dashLine, geoLayout[d].griddash, geoLayout[d].gridwidth); } - if(isLineLayer(d)) { - path.call(Color.stroke, geoLayout[adj + 'color']) - .call(Drawing.dashLine, '', geoLayout[adj + 'width']); - } else if(isFillLayer(d)) { + if (isLineLayer(d)) { + path.call(Color.stroke, geoLayout[adj + 'color']).call(Drawing.dashLine, '', geoLayout[adj + 'width']); + } else if (isFillLayer(d)) { path.call(Color.fill, geoLayout[adj + 'color']); } }); }; -proto.updateDims = function(fullLayout, geoLayout) { +proto.updateDims = function (fullLayout, geoLayout) { var b = this.bounds; var hFrameWidth = (geoLayout.framewidth || 0) / 2; @@ -409,9 +401,7 @@ proto.updateDims = function(fullLayout, geoLayout) { Drawing.setRect(this.clipRect, l, t, w, h); - this.bgRect - .call(Drawing.setRect, l, t, w, h) - .call(Color.fill, geoLayout.bgcolor); + this.bgRect.call(Drawing.setRect, l, t, w, h).call(Color.fill, geoLayout.bgcolor); this.xaxis._offset = l; this.xaxis._length = w; @@ -420,20 +410,20 @@ proto.updateDims = function(fullLayout, geoLayout) { this.yaxis._length = h; }; -proto.updateFx = function(fullLayout, geoLayout) { +proto.updateFx = function (fullLayout, geoLayout) { var _this = this; var gd = _this.graphDiv; var bgRect = _this.bgRect; var dragMode = fullLayout.dragmode; var clickMode = fullLayout.clickmode; - if(_this.isStatic) return; + if (_this.isStatic) return; function zoomReset() { var viewInitial = _this.viewInitial; var updateObj = {}; - for(var k in viewInitial) { + for (var k in viewInitial) { updateObj[_this.id + '.' + k] = viewInitial[k]; } @@ -442,21 +432,15 @@ proto.updateFx = function(fullLayout, geoLayout) { } function invert(lonlat) { - return _this.projection.invert([ - lonlat[0] + _this.xaxis._offset, - lonlat[1] + _this.yaxis._offset - ]); + return _this.projection.invert([lonlat[0] + _this.xaxis._offset, lonlat[1] + _this.yaxis._offset]); } - var fillRangeItems = function(eventData, poly) { - if(poly.isRect) { - var ranges = eventData.range = {}; - ranges[_this.id] = [ - invert([poly.xmin, poly.ymin]), - invert([poly.xmax, poly.ymax]) - ]; + var fillRangeItems = function (eventData, poly) { + if (poly.isRect) { + var ranges = (eventData.range = {}); + ranges[_this.id] = [invert([poly.xmin, poly.ymin]), invert([poly.xmax, poly.ymax])]; } else { - var dataPts = eventData.lassoPoints = {}; + var dataPts = (eventData.lassoPoints = {}); dataPts[_this.id] = poly.map(invert); } }; @@ -475,57 +459,69 @@ proto.updateFx = function(fullLayout, geoLayout) { xaxes: [_this.xaxis], yaxes: [_this.yaxis], subplot: _this.id, - clickFn: function(numClicks) { - if(numClicks === 2) { + clickFn: function (numClicks) { + if (numClicks === 2) { clearOutline(gd); } } }; - if(dragMode === 'pan') { + if (dragMode === 'pan') { bgRect.node().onmousedown = null; - bgRect.call(createGeoZoom(_this, geoLayout)); + const zoom = createGeoZoom(_this, geoLayout); + bgRect.call(zoom); + // If the initial projection.scale lies outside [minscale, maxscale], + // dispatch a synthetic zoom event to clamp it. Skip when re-entered + // from inside a real zoom handler to avoid recursion. + if (!d3.event) { + const currScale = _this.projection.scale(); + const [minExtent, maxExtent] = _this.projection.scaleExtent(); + if (currScale < minExtent || currScale > maxExtent) zoom.event(bgRect); + } bgRect.on('dblclick.zoom', zoomReset); - if(!gd._context._scrollZoom.geo) { + if (!gd._context._scrollZoom.geo) { bgRect.on('wheel.zoom', null); } - } else if(dragMode === 'select' || dragMode === 'lasso') { + } else if (dragMode === 'select' || dragMode === 'lasso') { bgRect.on('.zoom', null); - dragOptions.prepFn = function(e, startX, startY) { + dragOptions.prepFn = function (e, startX, startY) { prepSelect(e, startX, startY, dragOptions, dragMode); }; dragElement.init(dragOptions); } - bgRect.on('mousemove', function() { + bgRect.on('mousemove', function () { var lonlat = _this.projection.invert(Lib.getPositionFromD3Event()); - if(!lonlat) { + if (!lonlat) { return dragElement.unhover(gd, d3.event); } - _this.xaxis.p2c = function() { return lonlat[0]; }; - _this.yaxis.p2c = function() { return lonlat[1]; }; + _this.xaxis.p2c = function () { + return lonlat[0]; + }; + _this.yaxis.p2c = function () { + return lonlat[1]; + }; Fx.hover(gd, d3.event, _this.id); }); - bgRect.on('mouseout', function() { - if(gd._dragging) return; + bgRect.on('mouseout', function () { + if (gd._dragging) return; dragElement.unhover(gd, d3.event); }); - bgRect.on('click', function() { + bgRect.on('click', function () { // For select and lasso the dragElement is handling clicks - if(dragMode !== 'select' && dragMode !== 'lasso') { - if(clickMode.indexOf('select') > -1) { - selectOnClick(d3.event, gd, [_this.xaxis], [_this.yaxis], - _this.id, dragOptions); + if (dragMode !== 'select' && dragMode !== 'lasso') { + if (clickMode.indexOf('select') > -1) { + selectOnClick(d3.event, gd, [_this.xaxis], [_this.yaxis], _this.id, dragOptions); } - if(clickMode.indexOf('event') > -1) { + if (clickMode.indexOf('event') > -1) { // TODO: like pie and maps, this doesn't support right-click // actually this one is worse, as right-click starts a pan, or leaves // select in a weird state. @@ -536,37 +532,40 @@ proto.updateFx = function(fullLayout, geoLayout) { }); }; -proto.makeFramework = function() { +proto.makeFramework = function () { var _this = this; var gd = _this.graphDiv; var fullLayout = gd._fullLayout; var clipId = 'clip' + fullLayout._uid + _this.id; - _this.clipDef = fullLayout._clips.append('clipPath') - .attr('id', clipId); + _this.clipDef = fullLayout._clips.append('clipPath').attr('id', clipId); _this.clipRect = _this.clipDef.append('rect'); - _this.framework = d3.select(_this.container).append('g') + _this.framework = d3 + .select(_this.container) + .append('g') .attr('class', 'geo ' + _this.id) .call(Drawing.setClipUrl, clipId, gd); // sane lonlat to px - _this.project = function(v) { + _this.project = function (v) { var px = _this.projection(v); - return px ? - [px[0] - _this.xaxis._offset, px[1] - _this.yaxis._offset] : - [null, null]; + return px ? [px[0] - _this.xaxis._offset, px[1] - _this.yaxis._offset] : [null, null]; }; _this.xaxis = { _id: 'x', - c2p: function(v) { return _this.project(v)[0]; } + c2p: function (v) { + return _this.project(v)[0]; + } }; _this.yaxis = { _id: 'y', - c2p: function(v) { return _this.project(v)[1]; } + c2p: function (v) { + return _this.project(v)[1]; + } }; // mock axis for hover formatting @@ -578,7 +577,7 @@ proto.makeFramework = function() { Axes.setConvert(_this.mockAxis, fullLayout); }; -proto.saveViewInitial = function(geoLayout) { +proto.saveViewInitial = function (geoLayout) { var center = geoLayout.center || {}; var projLayout = geoLayout.projection; var rotation = projLayout.rotation || {}; @@ -589,12 +588,12 @@ proto.saveViewInitial = function(geoLayout) { }; var extra; - if(geoLayout._isScoped) { + if (geoLayout._isScoped) { extra = { 'center.lon': center.lon, - 'center.lat': center.lat, + 'center.lat': center.lat }; - } else if(geoLayout._isClipped) { + } else if (geoLayout._isClipped) { extra = { 'projection.rotation.lon': rotation.lon, 'projection.rotation.lat': rotation.lat @@ -610,8 +609,8 @@ proto.saveViewInitial = function(geoLayout) { Lib.extendFlat(this.viewInitial, extra); }; -proto.render = function(mayRedrawOnUpdates) { - if(this._hasMarkerAngles && mayRedrawOnUpdates) { +proto.render = function (mayRedrawOnUpdates) { + if (this._hasMarkerAngles && mayRedrawOnUpdates) { this.plot(this._geoCalcData, this._fullLayout, [], true); } else { this._render(); @@ -619,34 +618,32 @@ proto.render = function(mayRedrawOnUpdates) { }; // [hot code path] (re)draw all paths which depend on the projection -proto._render = function() { +proto._render = function () { var projection = this.projection; var pathFn = projection.getPath(); var k; function translatePoints(d) { var lonlatPx = projection(d.lonlat); - return lonlatPx ? - strTranslate(lonlatPx[0], lonlatPx[1]) : - null; + return lonlatPx ? strTranslate(lonlatPx[0], lonlatPx[1]) : null; } function hideShowPoints(d) { return projection.isLonLatOverEdges(d.lonlat) ? 'none' : null; } - for(k in this.basePaths) { + for (k in this.basePaths) { this.basePaths[k].attr('d', pathFn); } - for(k in this.dataPaths) { - this.dataPaths[k].attr('d', function(d) { return pathFn(d.geojson); }); + for (k in this.dataPaths) { + this.dataPaths[k].attr('d', function (d) { + return pathFn(d.geojson); + }); } - for(k in this.dataPoints) { - this.dataPoints[k] - .attr('display', hideShowPoints) - .attr('transform', translatePoints); // TODO: need to redraw points with marker angle instead of calling translatePoints + for (k in this.dataPoints) { + this.dataPoints[k].attr('display', hideShowPoints).attr('transform', translatePoints); // TODO: need to redraw points with marker angle instead of calling translatePoints } }; @@ -670,50 +667,54 @@ function getProjection(geoLayout) { var projFn = geo[projName] || geoProjection[projName]; var projection = projFn(); - var clipAngle = - geoLayout._isSatellite ? Math.acos(1 / projLayout.distance) * 180 / Math.PI : - geoLayout._isClipped ? constants.lonaxisSpan[projType] / 2 : null; + var clipAngle = geoLayout._isSatellite + ? (Math.acos(1 / projLayout.distance) * 180) / Math.PI + : geoLayout._isClipped + ? constants.lonaxisSpan[projType] / 2 + : null; var methods = ['center', 'rotate', 'parallels', 'clipExtent']; - var dummyFn = function(_) { return _ ? projection : []; }; + var dummyFn = function (_) { + return _ ? projection : []; + }; - for(var i = 0; i < methods.length; i++) { + for (var i = 0; i < methods.length; i++) { var m = methods[i]; - if(typeof projection[m] !== 'function') { + if (typeof projection[m] !== 'function') { projection[m] = dummyFn; } } - projection.isLonLatOverEdges = function(lonlat) { - if(projection(lonlat) === null) { + projection.isLonLatOverEdges = function (lonlat) { + if (projection(lonlat) === null) { return true; } - if(clipAngle) { + if (clipAngle) { var r = projection.rotate(); var angle = geoDistance(lonlat, [-r[0], -r[1]]); - var maxAngle = clipAngle * Math.PI / 180; + var maxAngle = (clipAngle * Math.PI) / 180; return angle > maxAngle; } else { return false; } }; - projection.getPath = function() { + projection.getPath = function () { return geoPath().projection(projection); }; - projection.getBounds = function(object) { + projection.getBounds = function (object) { return projection.getPath().bounds(object); }; projection.precision(constants.precision); - if(geoLayout._isSatellite) { + if (geoLayout._isSatellite) { projection.tilt(projLayout.tilt).distance(projLayout.distance); } - if(clipAngle) { + if (clipAngle) { projection.clipAngle(clipAngle - constants.clipPad); } @@ -732,14 +733,18 @@ function makeGraticule(axisName, geoLayout, fullLayout) { var oppRng; var coordFn; - if(axisName === 'lonaxis') { + if (axisName === 'lonaxis') { rng = scopeDefaults.lonaxisRange; oppRng = scopeDefaults.lataxisRange; - coordFn = function(v, l) { return [v, l]; }; - } else if(axisName === 'lataxis') { + coordFn = function (v, l) { + return [v, l]; + }; + } else if (axisName === 'lataxis') { rng = scopeDefaults.lataxisRange; oppRng = scopeDefaults.lonaxisRange; - coordFn = function(v, l) { return [l, v]; }; + coordFn = function (v, l) { + return [l, v]; + }; } var dummyAx = { @@ -753,17 +758,17 @@ function makeGraticule(axisName, geoLayout, fullLayout) { var vals = Axes.calcTicks(dummyAx); // remove duplicate on antimeridian - if(!geoLayout.isScoped && axisName === 'lonaxis') { + if (!geoLayout.isScoped && axisName === 'lonaxis') { vals.pop(); } var len = vals.length; var coords = new Array(len); - for(var i = 0; i < len; i++) { + for (var i = 0; i < len; i++) { var v = vals[i].x; - var line = coords[i] = []; - for(var l = oppRng[0]; l < oppRng[1] + precision; l += precision) { + var line = (coords[i] = []); + for (var l = oppRng[0]; l < oppRng[1] + precision; l += precision) { line.push(coordFn(v, l)); } } @@ -786,24 +791,26 @@ function makeRangeBox(lon, lat) { var lat1 = lat[1] - clipPad; // to cross antimeridian w/o ambiguity - if(lon0 > 0 && lon1 < 0) lon1 += 360; + if (lon0 > 0 && lon1 < 0) lon1 += 360; var dlon4 = (lon1 - lon0) / 4; return { type: 'Polygon', - coordinates: [[ - [lon0, lat0], - [lon0, lat1], - [lon0 + dlon4, lat1], - [lon0 + 2 * dlon4, lat1], - [lon0 + 3 * dlon4, lat1], - [lon1, lat1], - [lon1, lat0], - [lon1 - dlon4, lat0], - [lon1 - 2 * dlon4, lat0], - [lon1 - 3 * dlon4, lat0], - [lon0, lat0] - ]] + coordinates: [ + [ + [lon0, lat0], + [lon0, lat1], + [lon0 + dlon4, lat1], + [lon0 + 2 * dlon4, lat1], + [lon0 + 3 * dlon4, lat1], + [lon1, lat1], + [lon1, lat0], + [lon1 - dlon4, lat0], + [lon1 - 2 * dlon4, lat0], + [lon1 - 3 * dlon4, lat0], + [lon0, lat0] + ] + ] }; } diff --git a/src/plots/geo/layout_attributes.js b/src/plots/geo/layout_attributes.js index b94cca3bec5..fe5e9284bc7 100644 --- a/src/plots/geo/layout_attributes.js +++ b/src/plots/geo/layout_attributes.js @@ -177,6 +177,28 @@ var attrs = module.exports = overrideAll({ 'that fits the map\'s lon and lat ranges. ' ].join(' ') }, + minscale: { + valType: 'number', + min: 0, + dflt: 0, + description: [ + 'Sets the minimum zoom level of the map view, relative to', + '`projection.scale`. A `minscale` of *0.5* (50%) prevents the', + 'user from zooming out beyond half the base zoom level.', + 'The default of *0* imposes no lower bound.' + ].join(' ') + }, + maxscale: { + valType: 'number', + min: 0, + dflt: null, + description: [ + 'Sets the maximum zoom level of the map view, relative to', + '`projection.scale`. A `maxscale` of *2* (200%) prevents the', + 'user from zooming in beyond twice the base zoom level.', + 'Defaults to *null* for no upper bound.' + ].join(' ') + }, }, center: { lon: { diff --git a/src/plots/geo/layout_defaults.js b/src/plots/geo/layout_defaults.js index cfb73a3c266..17fae0b90f1 100644 --- a/src/plots/geo/layout_defaults.js +++ b/src/plots/geo/layout_defaults.js @@ -161,6 +161,8 @@ function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce, opts) { } coerce('projection.scale'); + coerce('projection.minscale'); + coerce('projection.maxscale'); show = coerce('showland', !visible ? false : undefined); if(show) coerce('landcolor'); diff --git a/src/plots/geo/zoom.js b/src/plots/geo/zoom.js index 2d79d69f581..63892a4e85d 100644 --- a/src/plots/geo/zoom.js +++ b/src/plots/geo/zoom.js @@ -6,16 +6,16 @@ var Registry = require('../../registry'); var radians = Math.PI / 180; var degrees = 180 / Math.PI; -var zoomstartStyle = {cursor: 'pointer'}; -var zoomendStyle = {cursor: 'auto'}; +var zoomstartStyle = { cursor: 'pointer' }; +var zoomendStyle = { cursor: 'auto' }; function createGeoZoom(geo, geoLayout) { var projection = geo.projection; var zoomConstructor; - if(geoLayout._isScoped) { + if (geoLayout._isScoped) { zoomConstructor = zoomScoped; - } else if(geoLayout._isClipped) { + } else if (geoLayout._isClipped) { zoomConstructor = zoomClipped; } else { zoomConstructor = zoomNonClipped; @@ -30,8 +30,10 @@ module.exports = createGeoZoom; // common to all zoom types function initZoom(geo, projection) { - return d3.behavior.zoom() + return d3.behavior + .zoom() .translate(projection.translate()) + .scaleExtent(projection.scaleExtent()) .scale(projection.scale()); } @@ -52,7 +54,7 @@ function sync(geo, projection, cb) { Registry.call('_storeDirectGUIEdit', layout, fullLayout._preGUI, preGUI); var fullNp = Lib.nestedProperty(fullOpts, propStr); - if(fullNp.get() !== val) { + if (fullNp.get() !== val) { fullNp.set(val); Lib.nestedProperty(userOpts, propStr).set(val); eventData[id + '.' + propStr] = val; @@ -74,9 +76,7 @@ function zoomScoped(geo, projection) { } function handleZoom() { - projection - .scale(d3.event.scale) - .translate(d3.event.translate); + projection.scale(d3.event.scale).translate(d3.event.translate); geo.render(true); var center = projection.invert(geo.midPt); @@ -99,10 +99,7 @@ function zoomScoped(geo, projection) { sync(geo, projection, syncCb); } - zoom - .on('zoomstart', handleZoomstart) - .on('zoom', handleZoom) - .on('zoomend', handleZoomend); + zoom.on('zoomstart', handleZoomstart).on('zoom', handleZoom).on('zoomend', handleZoomend); return zoom; } @@ -113,26 +110,28 @@ function zoomNonClipped(geo, projection) { var INSIDETOLORANCEPXS = 2; - var mouse0, rotate0, translate0, lastRotate, zoomPoint, - mouse1, rotate1, point1, didZoom; + var mouse0, rotate0, translate0, lastRotate, zoomPoint, mouse1, rotate1, point1, didZoom; - function position(x) { return projection.invert(x); } + function position(x) { + return projection.invert(x); + } function outside(x) { var pos = position(x); - if(!pos) return true; + if (!pos) return true; var pt = projection(pos); - return ( - Math.abs(pt[0] - x[0]) > INSIDETOLORANCEPXS || - Math.abs(pt[1] - x[1]) > INSIDETOLORANCEPXS - ); + return Math.abs(pt[0] - x[0]) > INSIDETOLORANCEPXS || Math.abs(pt[1] - x[1]) > INSIDETOLORANCEPXS; } function handleZoomstart() { d3.select(this).style(zoomstartStyle); - mouse0 = d3.mouse(this); + // Fallback to bbox center when there's no source event + // (e.g. synthetic zoom.event dispatched on initial render + // to enforce minscale/maxscale). + const { x, y, width, height } = this.getBBox(); + mouse0 = d3.event.sourceEvent ? d3.mouse(this) : [x + width / 2, y + height / 2]; rotate0 = projection.rotate(); translate0 = projection.translate(); lastRotate = rotate0; @@ -140,9 +139,9 @@ function zoomNonClipped(geo, projection) { } function handleZoom() { - mouse1 = d3.mouse(this); - - if(outside(mouse0)) { + const { x, y, width, height } = this.getBBox(); + mouse1 = d3.event.sourceEvent ? d3.mouse(this) : [x + width / 2, y + height / 2]; + if (outside(mouse0)) { zoom.scale(projection.scale()); zoom.translate(projection.translate()); return; @@ -151,10 +150,10 @@ function zoomNonClipped(geo, projection) { projection.scale(d3.event.scale); projection.translate([translate0[0], d3.event.translate[1]]); - if(!zoomPoint) { + if (!zoomPoint) { mouse0 = mouse1; zoomPoint = position(mouse0); - } else if(position(mouse1)) { + } else if (position(mouse1)) { point1 = position(mouse1); rotate1 = [lastRotate[0] + (point1[0] - zoomPoint[0]), rotate0[1], rotate0[2]]; projection.rotate(rotate1); @@ -176,7 +175,7 @@ function zoomNonClipped(geo, projection) { function handleZoomend() { d3.select(this).style(zoomendStyle); - if(didZoom) sync(geo, projection, syncCb); + if (didZoom) sync(geo, projection, syncCb); } function syncCb(set) { @@ -188,10 +187,7 @@ function zoomNonClipped(geo, projection) { set('center.lat', center[1]); } - zoom - .on('zoomstart', handleZoomstart) - .on('zoom', handleZoom) - .on('zoomend', handleZoomend); + zoom.on('zoomstart', handleZoomstart).on('zoom', handleZoom).on('zoomend', handleZoomend); return zoom; } @@ -199,7 +195,7 @@ function zoomNonClipped(geo, projection) { // zoom for clipped projections // inspired by https://www.jasondavies.com/maps/d3.geo.zoom.js function zoomClipped(geo, projection) { - var view = {r: projection.rotate(), k: projection.scale()}; + var view = { r: projection.rotate(), k: projection.scale() }; var zoom = initZoom(geo, projection); var event = d3eventDispatch(zoom, 'zoomstart', 'zoom', 'zoomend'); var zooming = 0; @@ -207,28 +203,33 @@ function zoomClipped(geo, projection) { var zoomPoint; - zoom.on('zoomstart', function() { + zoom.on('zoomstart', function () { d3.select(this).style(zoomstartStyle); - var mouse0 = d3.mouse(this); - var rotate0 = projection.rotate(); - var lastRotate = rotate0; - var translate0 = projection.translate(); - var q = quaternionFromEuler(rotate0); + // Fallback to bbox center when there's no source event + // (e.g. synthetic zoom.event dispatched on initial render + // to enforce minscale/maxscale). + const { x, y, width, height } = this.getBBox(); + let mouse0 = d3.event.sourceEvent ? d3.mouse(this) : [x + width / 2, y + height / 2]; + const rotate0 = projection.rotate(); + let lastRotate = rotate0; + const translate0 = projection.translate(); + const q = quaternionFromEuler(rotate0); zoomPoint = position(projection, mouse0); - zoomOn.call(zoom, 'zoom', function() { - var mouse1 = d3.mouse(this); + zoomOn.call(zoom, 'zoom', function () { + const { x, y, width, height } = this.getBBox(); + const mouse1 = d3.event.sourceEvent ? d3.mouse(this) : [x + width / 2, y + height / 2]; - projection.scale(view.k = d3.event.scale); + projection.scale((view.k = d3.event.scale)); - if(!zoomPoint) { + if (!zoomPoint) { // if no zoomPoint, the mouse wasn't over the actual geography yet // maybe this point is the start... we'll find out next time! mouse0 = mouse1; zoomPoint = position(projection, mouse0); - } else if(position(projection, mouse1)) { + } else if (position(projection, mouse1)) { // check if the point is on the map // if not, don't do anything new but scale // if it is, then we can assume between will exist below @@ -236,18 +237,15 @@ function zoomClipped(geo, projection) { // go back to original projection temporarily // except for scale... that's kind of independent? - projection - .rotate(rotate0) - .translate(translate0); + projection.rotate(rotate0).translate(translate0); // calculate the new params var point1 = position(projection, mouse1); var between = rotateBetween(zoomPoint, point1); var newEuler = eulerFromQuaternion(multiply(q, between)); - var rotateAngles = view.r = unRoll(newEuler, zoomPoint, lastRotate); + var rotateAngles = (view.r = unRoll(newEuler, zoomPoint, lastRotate)); - if(!isFinite(rotateAngles[0]) || !isFinite(rotateAngles[1]) || - !isFinite(rotateAngles[2])) { + if (!isFinite(rotateAngles[0]) || !isFinite(rotateAngles[1]) || !isFinite(rotateAngles[2])) { rotateAngles = lastRotate; } @@ -261,33 +259,33 @@ function zoomClipped(geo, projection) { zoomstarted(event.of(this, arguments)); }) - .on('zoomend', function() { - d3.select(this).style(zoomendStyle); - zoomOn.call(zoom, 'zoom', null); - zoomended(event.of(this, arguments)); - sync(geo, projection, syncCb); - }) - .on('zoom.redraw', function() { - geo.render(true); - - var _rotate = projection.rotate(); - geo.graphDiv.emit('plotly_relayouting', { - 'geo.projection.scale': projection.scale() / geo.fitScale, - 'geo.projection.rotation.lon': -_rotate[0], - 'geo.projection.rotation.lat': -_rotate[1] + .on('zoomend', function () { + d3.select(this).style(zoomendStyle); + zoomOn.call(zoom, 'zoom', null); + zoomended(event.of(this, arguments)); + sync(geo, projection, syncCb); + }) + .on('zoom.redraw', function () { + geo.render(true); + + var _rotate = projection.rotate(); + geo.graphDiv.emit('plotly_relayouting', { + 'geo.projection.scale': projection.scale() / geo.fitScale, + 'geo.projection.rotation.lon': -_rotate[0], + 'geo.projection.rotation.lat': -_rotate[1] + }); }); - }); function zoomstarted(dispatch) { - if(!zooming++) dispatch({type: 'zoomstart'}); + if (!zooming++) dispatch({ type: 'zoomstart' }); } function zoomed(dispatch) { - dispatch({type: 'zoom'}); + dispatch({ type: 'zoom' }); } function zoomended(dispatch) { - if(!--zooming) dispatch({type: 'zoomend'}); + if (!--zooming) dispatch({ type: 'zoomend' }); } function syncCb(set) { @@ -342,7 +340,7 @@ function multiply(a, b) { } function rotateBetween(a, b) { - if(!a || !b) return; + if (!a || !b) return; var axis = cross(a, b); var norm = Math.sqrt(dot(axis, axis)); var halfgamma = 0.5 * Math.acos(Math.max(-1, Math.min(1, dot(a, b)))); @@ -381,7 +379,7 @@ function unRoll(rotateAngles, pt, lastRotate) { var b; var newYaw1; - if(Math.abs(g) > a) { + if (Math.abs(g) > a) { newYaw1 = (g > 0 ? 90 : -90) - theta; b = 0; } else { @@ -397,7 +395,7 @@ function unRoll(rotateAngles, pt, lastRotate) { var dist1 = angleDistance(lastRotate[0], lastRotate[1], newYaw1, newPitch1); var dist2 = angleDistance(lastRotate[0], lastRotate[1], newYaw2, newPitch2); - if(dist1 <= dist2) return [newYaw1, newPitch1, lastRotate[2]]; + if (dist1 <= dist2) return [newYaw1, newPitch1, lastRotate[2]]; else return [newYaw2, newPitch2, lastRotate[2]]; } @@ -409,7 +407,7 @@ function angleDistance(yaw0, pitch0, yaw1, pitch1) { // reduce an angle in degrees to [-180,180] function angleMod(angle) { - return (angle % 360 + 540) % 360 - 180; + return (((angle % 360) + 540) % 360) - 180; } // rotate a cartesian vector @@ -418,8 +416,8 @@ function angleMod(angle) { function rotateCartesian(vector, axis, angle) { var angleRads = angle * radians; var vectorOut = vector.slice(); - var ax1 = (axis === 0) ? 1 : 0; - var ax2 = (axis === 2) ? 1 : 2; + var ax1 = axis === 0 ? 1 : 0; + var ax2 = axis === 2 ? 1 : 2; var cosa = Math.cos(angleRads); var sina = Math.sin(angleRads); @@ -440,25 +438,17 @@ function cartesian(spherical) { var lambda = spherical[0] * radians; var phi = spherical[1] * radians; var cosPhi = Math.cos(phi); - return [ - cosPhi * Math.cos(lambda), - cosPhi * Math.sin(lambda), - Math.sin(phi) - ]; + return [cosPhi * Math.cos(lambda), cosPhi * Math.sin(lambda), Math.sin(phi)]; } function dot(a, b) { var s = 0; - for(var i = 0, n = a.length; i < n; ++i) s += a[i] * b[i]; + for (var i = 0, n = a.length; i < n; ++i) s += a[i] * b[i]; return s; } function cross(a, b) { - return [ - a[1] * b[2] - a[2] * b[1], - a[2] * b[0] - a[0] * b[2], - a[0] * b[1] - a[1] * b[0] - ]; + return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; } // Like d3.dispatch, but for custom events abstracting native UI events. These @@ -470,7 +460,7 @@ function d3eventDispatch(target) { var n = arguments.length; var argumentz = []; - while(++i < n) argumentz.push(arguments[i]); + while (++i < n) argumentz.push(arguments[i]); var dispatch = d3.dispatch.apply(null, argumentz); @@ -483,8 +473,8 @@ function d3eventDispatch(target) { // constructor. This context will automatically populate the "sourceEvent" and // "target" attributes of the event, as well as setting the `d3.event` global // for the duration of the notification. - dispatch.of = function(thiz, argumentz) { - return function(e1) { + dispatch.of = function (thiz, argumentz) { + return function (e1) { var e0; try { e0 = e1.sourceEvent = d3.event; diff --git a/test/jasmine/tests/geo_test.js b/test/jasmine/tests/geo_test.js index 6faddf3c630..498a71b560a 100644 --- a/test/jasmine/tests/geo_test.js +++ b/test/jasmine/tests/geo_test.js @@ -26,43 +26,39 @@ var HOVERMINTIME = require('../../../src/components/fx').constants.HOVERMINTIME; Plotly.setPlotConfig({ topojsonURL: '/base/topojson/dist' }); function move(fromX, fromY, toX, toY, delay) { - return new Promise(function(resolve) { + return new Promise(function (resolve) { mouseEvent('mousemove', fromX, fromY); - setTimeout(function() { - mouseEvent('mousemove', toX, toY); - resolve(); - }, delay || DBLCLICKDELAY / 4); + setTimeout( + function () { + mouseEvent('mousemove', toX, toY); + resolve(); + }, + delay || DBLCLICKDELAY / 4 + ); }); } -describe('Test Geo layout defaults', function() { +describe('Test Geo layout defaults', function () { var layoutAttributes = Geo.layoutAttributes; var supplyLayoutDefaults = Geo.supplyLayoutDefaults; var layoutIn, layoutOut, fullData; - beforeEach(function() { - layoutOut = {_subplots: {geo: ['geo']}}; + beforeEach(function () { + layoutOut = { _subplots: { geo: ['geo'] } }; // needs a geo-ref in a trace in order to be detected fullData = [{ type: 'scattergeo', geo: 'geo' }]; }); - var seaFields = [ - 'showcoastlines', 'coastlinecolor', 'coastlinewidth', - 'showocean', 'oceancolor' - ]; + var seaFields = ['showcoastlines', 'coastlinecolor', 'coastlinewidth', 'showocean', 'oceancolor']; - var subunitFields = [ - 'showsubunits', 'subunitcolor', 'subunitwidth' - ]; + var subunitFields = ['showsubunits', 'subunitcolor', 'subunitwidth']; - var frameFields = [ - 'showframe', 'framecolor', 'framewidth' - ]; + var frameFields = ['showframe', 'framecolor', 'framewidth']; - it('should not coerce projection.rotation if type is albers usa', function() { + it('should not coerce projection.rotation if type is albers usa', function () { layoutIn = { geo: { projection: { @@ -80,7 +76,7 @@ describe('Test Geo layout defaults', function() { expect(layoutOut.geo.scope).toEqual('usa'); }); - it('should not coerce projection.rotation if type is albers usa (converse)', function() { + it('should not coerce projection.rotation if type is albers usa (converse)', function () { layoutIn = { geo: { projection: { @@ -97,7 +93,7 @@ describe('Test Geo layout defaults', function() { expect(layoutOut.geo.scope).toEqual('world'); }); - it('should not coerce coastlines and ocean if type is albers usa', function() { + it('should not coerce coastlines and ocean if type is albers usa', function () { layoutIn = { geo: { projection: { @@ -109,12 +105,12 @@ describe('Test Geo layout defaults', function() { }; supplyLayoutDefaults(layoutIn, layoutOut, fullData); - seaFields.forEach(function(field) { + seaFields.forEach(function (field) { expect(layoutOut.geo[field]).toBeUndefined(); }); }); - it('should not coerce coastlines and ocean if type is albers usa (converse)', function() { + it('should not coerce coastlines and ocean if type is albers usa (converse)', function () { layoutIn = { geo: { showcoastlines: true, @@ -123,12 +119,12 @@ describe('Test Geo layout defaults', function() { }; supplyLayoutDefaults(layoutIn, layoutOut, fullData); - seaFields.forEach(function(field) { + seaFields.forEach(function (field) { expect(layoutOut.geo[field]).toBeDefined(); }); }); - it('should only coerce projection.tilt and projection.distance if type is satellite', function() { + it('should only coerce projection.tilt and projection.distance if type is satellite', function () { var projTypes = layoutAttributes.projection.type.values; function testOne(projType) { @@ -145,9 +141,9 @@ describe('Test Geo layout defaults', function() { supplyLayoutDefaults(layoutIn, layoutOut, fullData); } - projTypes.forEach(function(projType) { + projTypes.forEach(function (projType) { testOne(projType); - if(projType === 'satellite') { + if (projType === 'satellite') { expect(layoutOut.geo.projection.tilt).toBeDefined(); expect(layoutOut.geo.projection.distance).toBeDefined(); } else { @@ -157,7 +153,7 @@ describe('Test Geo layout defaults', function() { }); }); - it('should only coerce projection.parallels if type is conic', function() { + it('should only coerce projection.parallels if type is conic', function () { var projTypes = layoutAttributes.projection.type.values; function testOne(projType) { @@ -173,9 +169,9 @@ describe('Test Geo layout defaults', function() { supplyLayoutDefaults(layoutIn, layoutOut, fullData); } - projTypes.forEach(function(projType) { + projTypes.forEach(function (projType) { testOne(projType); - if(projType.indexOf('conic') !== -1 || projType === 'albers') { + if (projType.indexOf('conic') !== -1 || projType === 'albers') { expect(layoutOut.geo.projection.parallels).toBeDefined(); } else { expect(layoutOut.geo.projection.parallels).toBeUndefined(); @@ -183,27 +179,27 @@ describe('Test Geo layout defaults', function() { }); }); - it('should coerce subunits only when available (usa case)', function() { + it('should coerce subunits only when available (usa case)', function () { layoutIn = { geo: { scope: 'usa' } }; supplyLayoutDefaults(layoutIn, layoutOut, fullData); - subunitFields.forEach(function(field) { + subunitFields.forEach(function (field) { expect(layoutOut.geo[field]).toBeDefined(); }); }); - it('should coerce subunits only when available (default case)', function() { + it('should coerce subunits only when available (default case)', function () { layoutIn = { geo: {} }; supplyLayoutDefaults(layoutIn, layoutOut, fullData); - subunitFields.forEach(function(field) { + subunitFields.forEach(function (field) { expect(layoutOut.geo[field]).toBeUndefined(); }); }); - it('should coerce subunits only when available (NA case)', function() { + it('should coerce subunits only when available (NA case)', function () { layoutIn = { geo: { scope: 'north america', @@ -212,12 +208,12 @@ describe('Test Geo layout defaults', function() { }; supplyLayoutDefaults(layoutIn, layoutOut, fullData); - subunitFields.forEach(function(field) { + subunitFields.forEach(function (field) { expect(layoutOut.geo[field]).toBeDefined(); }); }); - it('should coerce subunits only when available (NA case 2)', function() { + it('should coerce subunits only when available (NA case 2)', function () { layoutIn = { geo: { scope: 'north america', @@ -226,12 +222,12 @@ describe('Test Geo layout defaults', function() { }; supplyLayoutDefaults(layoutIn, layoutOut, fullData); - subunitFields.forEach(function(field) { + subunitFields.forEach(function (field) { expect(layoutOut.geo[field]).toBeDefined(); }); }); - it('should coerce subunits only when available (NA case 2)', function() { + it('should coerce subunits only when available (NA case 2)', function () { layoutIn = { geo: { scope: 'north america' @@ -239,12 +235,12 @@ describe('Test Geo layout defaults', function() { }; supplyLayoutDefaults(layoutIn, layoutOut, fullData); - subunitFields.forEach(function(field) { + subunitFields.forEach(function (field) { expect(layoutOut.geo[field]).toBeUndefined(); }); }); - it('should not coerce frame unless for world scope', function() { + it('should not coerce frame unless for world scope', function () { var scopes = layoutAttributes.scope.values; function testOne(scope) { @@ -255,21 +251,21 @@ describe('Test Geo layout defaults', function() { supplyLayoutDefaults(layoutIn, layoutOut, fullData); } - scopes.forEach(function(scope) { + scopes.forEach(function (scope) { testOne(scope); - if(scope === 'world') { - frameFields.forEach(function(field) { + if (scope === 'world') { + frameFields.forEach(function (field) { expect(layoutOut.geo[field]).toBeDefined(); }); } else { - frameFields.forEach(function(field) { + frameFields.forEach(function (field) { expect(layoutOut.geo[field]).toBeUndefined(); }); } }); }); - it('should add geo data-only geos into layoutIn', function() { + it('should add geo data-only geos into layoutIn', function () { layoutIn = {}; fullData = [{ type: 'scattergeo', geo: 'geo' }]; @@ -277,7 +273,7 @@ describe('Test Geo layout defaults', function() { expect(layoutIn.geo).toEqual({}); }); - it('should add geo data-only geos into layoutIn (converse)', function() { + it('should add geo data-only geos into layoutIn (converse)', function () { layoutOut._subplots.geo = []; layoutIn = {}; fullData = [{ type: 'scatter' }]; @@ -286,17 +282,17 @@ describe('Test Geo layout defaults', function() { expect(layoutIn.geo).toBe(undefined); }); - describe('should default to lon(lat)range to params non-world scopes', function() { + describe('should default to lon(lat)range to params non-world scopes', function () { var scopeDefaults = constants.scopeDefaults; var scopes = Object.keys(scopeDefaults); var customLonaxisRange = [-42.21313312, 40.321321]; var customLataxisRange = [-42.21313312, 40.321321]; - scopes.forEach(function(s) { - if(s === 'world') return; + scopes.forEach(function (s) { + if (s === 'world') return; - it('base case for ' + s, function() { - layoutIn = {geo: {scope: s}}; + it('base case for ' + s, function () { + layoutIn = { geo: { scope: s } }; supplyLayoutDefaults(layoutIn, layoutOut, fullData); var dfltLonaxisRange = scopeDefaults[s].lonaxisRange; @@ -308,12 +304,12 @@ describe('Test Geo layout defaults', function() { expect(layoutOut.geo.lataxis.tick0).toEqual(0); }); - it('custom case for ' + s, function() { + it('custom case for ' + s, function () { layoutIn = { geo: { scope: s, - lonaxis: {range: customLonaxisRange}, - lataxis: {range: customLataxisRange} + lonaxis: { range: customLonaxisRange }, + lataxis: { range: customLataxisRange } } }; supplyLayoutDefaults(layoutIn, layoutOut, fullData); @@ -326,43 +322,47 @@ describe('Test Geo layout defaults', function() { }); }); - describe('should adjust default lon(lat)range to projection.rotation in world scopes', function() { - var specs = [{ - geo: { - scope: 'world', - projection: { - type: 'equirectangular', - rotation: {lon: -75, lat: 45} - } - }, - // => -75 +/- 180 - lonRange: [-255, 105], - // => 45 +/- 90 - latRange: [-45, 135] - }, { - geo: { - scope: 'world', - projection: { - type: 'orthographic', - rotation: {lon: -75, lat: 45} - } + describe('should adjust default lon(lat)range to projection.rotation in world scopes', function () { + var specs = [ + { + geo: { + scope: 'world', + projection: { + type: 'equirectangular', + rotation: { lon: -75, lat: 45 } + } + }, + // => -75 +/- 180 + lonRange: [-255, 105], + // => 45 +/- 90 + latRange: [-45, 135] }, - // => -75 +/- 90 - lonRange: [-165, 15], - // => 45 +/- 90 - latRange: [-45, 135] - }, { - geo: { - lonaxis: {range: [-42.21313312, 40.321321]}, - lataxis: {range: [-42.21313312, 40.321321]} + { + geo: { + scope: 'world', + projection: { + type: 'orthographic', + rotation: { lon: -75, lat: 45 } + } + }, + // => -75 +/- 90 + lonRange: [-165, 15], + // => 45 +/- 90 + latRange: [-45, 135] }, - lonRange: [-42.21313312, 40.321321], - latRange: [-42.21313312, 40.321321] - }]; + { + geo: { + lonaxis: { range: [-42.21313312, 40.321321] }, + lataxis: { range: [-42.21313312, 40.321321] } + }, + lonRange: [-42.21313312, 40.321321], + latRange: [-42.21313312, 40.321321] + } + ]; - specs.forEach(function(s, i) { - it('- case ' + i, function() { - layoutIn = {geo: s.geo}; + specs.forEach(function (s, i) { + it('- case ' + i, function () { + layoutIn = { geo: s.geo }; supplyLayoutDefaults(layoutIn, layoutOut, fullData); expect(layoutOut.geo.lonaxis.range).toEqual(s.lonRange); @@ -371,7 +371,7 @@ describe('Test Geo layout defaults', function() { }); }); - describe('should default projection.rotation.lon to lon-center of world-scope maps', function() { + describe('should default projection.rotation.lon to lon-center of world-scope maps', function () { var specs = [ { lonRange: [10, 80], projLon: 45 }, { lonRange: [-45, -10], projLon: -27.5 }, @@ -381,42 +381,38 @@ describe('Test Geo layout defaults', function() { { lonRange: [140, -140], projLon: 180 } ]; - specs.forEach(function(s, i) { - it('- case ' + i, function() { + specs.forEach(function (s, i) { + it('- case ' + i, function () { layoutIn = { - geo: { lonaxis: {range: s.lonRange} } + geo: { lonaxis: { range: s.lonRange } } }; supplyLayoutDefaults(layoutIn, layoutOut, fullData); - expect(layoutOut.geo.lonaxis.range) - .toEqual(s.lonRange, 'lonaxis.range'); - expect(layoutOut.geo.projection.rotation.lon) - .toEqual(s.projLon, 'computed projection rotation lon'); + expect(layoutOut.geo.lonaxis.range).toEqual(s.lonRange, 'lonaxis.range'); + expect(layoutOut.geo.projection.rotation.lon).toEqual(s.projLon, 'computed projection rotation lon'); }); }); var scope = 'europe'; var dflt = constants.scopeDefaults[scope].projRotate[0]; - specs.forEach(function(s, i) { - it('- converse ' + i, function() { + specs.forEach(function (s, i) { + it('- converse ' + i, function () { layoutIn = { geo: { scope: 'europe', - lonaxis: {range: s.lonRange} + lonaxis: { range: s.lonRange } } }; supplyLayoutDefaults(layoutIn, layoutOut, fullData); - expect(layoutOut.geo.lonaxis.range) - .toEqual(s.lonRange, 'lonaxis.range'); - expect(layoutOut.geo.projection.rotation.lon) - .toEqual(dflt, 'scope dflt projection rotation lon'); + expect(layoutOut.geo.lonaxis.range).toEqual(s.lonRange, 'lonaxis.range'); + expect(layoutOut.geo.projection.rotation.lon).toEqual(dflt, 'scope dflt projection rotation lon'); }); }); }); - describe('should default center.lon', function() { + describe('should default center.lon', function () { var specs = [ { lonRange: [10, 80], projLon: 0, centerLon: 45 }, { lonRange: [-45, -10], projLon: -20, centerLon: -27.5 }, @@ -425,89 +421,94 @@ describe('Test Geo layout defaults', function() { { lonRange: [140, -140], projLon: 160, centerLon: 180 } ]; - specs.forEach(function(s, i) { - it('to projection.rotation.lon on world maps - case ' + i, function() { + specs.forEach(function (s, i) { + it('to projection.rotation.lon on world maps - case ' + i, function () { layoutIn = { geo: { - lonaxis: {range: s.lonRange}, + lonaxis: { range: s.lonRange }, projection: { - rotation: {lon: s.projLon} + rotation: { lon: s.projLon } } } }; supplyLayoutDefaults(layoutIn, layoutOut, fullData); - expect(layoutOut.geo.lonaxis.range) - .toEqual(s.lonRange, 'lonaxis.range'); - expect(layoutOut.geo.projection.rotation.lon) - .toEqual(s.projLon, 'projection.rotation.lon'); - expect(layoutOut.geo.center.lon) - .toEqual(s.projLon, 'center lon (inherited from projection.rotation.lon'); + expect(layoutOut.geo.lonaxis.range).toEqual(s.lonRange, 'lonaxis.range'); + expect(layoutOut.geo.projection.rotation.lon).toEqual(s.projLon, 'projection.rotation.lon'); + expect(layoutOut.geo.center.lon).toEqual( + s.projLon, + 'center lon (inherited from projection.rotation.lon' + ); }); }); var scope = 'africa'; - specs.forEach(function(s, i) { - it('to lon-center on scoped maps - case ' + i, function() { + specs.forEach(function (s, i) { + it('to lon-center on scoped maps - case ' + i, function () { layoutIn = { geo: { scope: scope, - lonaxis: {range: s.lonRange}, + lonaxis: { range: s.lonRange }, projection: { - rotation: {lon: s.projLon} + rotation: { lon: s.projLon } } } }; supplyLayoutDefaults(layoutIn, layoutOut, fullData); - expect(layoutOut.geo.lonaxis.range) - .toEqual(s.lonRange, 'lonaxis.range'); - expect(layoutOut.geo.projection.rotation.lon) - .toEqual(s.projLon, 'projection.rotation.lon'); - expect(layoutOut.geo.center.lon) - .toEqual(s.centerLon, 'computed center lon'); + expect(layoutOut.geo.lonaxis.range).toEqual(s.lonRange, 'lonaxis.range'); + expect(layoutOut.geo.projection.rotation.lon).toEqual(s.projLon, 'projection.rotation.lon'); + expect(layoutOut.geo.center.lon).toEqual(s.centerLon, 'computed center lon'); }); }); }); - describe('should default center.lat', function() { + describe('should default center.lat', function () { var specs = [ { latRange: [-90, 90], centerLat: 0 }, { latRange: [0, 30], centerLat: 15 }, { latRange: [-25, -5], centerLat: -15 } ]; - specs.forEach(function(s, i) { - it('- case ' + i, function() { + specs.forEach(function (s, i) { + it('- case ' + i, function () { layoutIn = { - geo: { lataxis: {range: s.latRange} } + geo: { lataxis: { range: s.latRange } } }; supplyLayoutDefaults(layoutIn, layoutOut, fullData); - expect(layoutOut.geo.lataxis.range) - .toEqual(s.latRange, 'lataxis.range'); - expect(layoutOut.geo.center.lat) - .toEqual(s.centerLat, 'computed center lat'); + expect(layoutOut.geo.lataxis.range).toEqual(s.latRange, 'lataxis.range'); + expect(layoutOut.geo.center.lat).toEqual(s.centerLat, 'computed center lat'); }); }); }); - describe('should clear attributes that get auto-filled under *fitbounds*', function() { + describe('should clear attributes that get auto-filled under *fitbounds*', function () { var vals = ['locations', 'geojson']; function _assert(exp) { expect(layoutOut.geo.projection.scale).toBe(exp['projection.scale'], 'projection.scale'); expect(layoutOut.geo.center.lon).toBe(exp['center.lon'], 'center.lon'); expect(layoutOut.geo.center.lat).toBe(exp['center.lat'], 'center.lat'); - expect(layoutOut.geo.projection.rotation.lon).toBe(exp['projection.rotation.lon'], 'projection.rotation.lon'); - expect(layoutOut.geo.projection.rotation.lat).toBe(exp['projection.rotation.lat'], 'projection.rotation.lat'); - expect(layoutOut.geo.lonaxis.range).withContext('lonaxis.range').toEqual(exp['lonaxis.range'], 'lonaxis.range'); - expect(layoutOut.geo.lataxis.range).withContext('lataxis.range').toEqual(exp['lataxis.range'], 'lataxis.range'); + expect(layoutOut.geo.projection.rotation.lon).toBe( + exp['projection.rotation.lon'], + 'projection.rotation.lon' + ); + expect(layoutOut.geo.projection.rotation.lat).toBe( + exp['projection.rotation.lat'], + 'projection.rotation.lat' + ); + expect(layoutOut.geo.lonaxis.range) + .withContext('lonaxis.range') + .toEqual(exp['lonaxis.range'], 'lonaxis.range'); + expect(layoutOut.geo.lataxis.range) + .withContext('lataxis.range') + .toEqual(exp['lataxis.range'], 'lataxis.range'); } - describe('- for scoped maps', function() { - it('fitbounds:false (base case)', function() { + describe('- for scoped maps', function () { + it('fitbounds:false (base case)', function () { layoutIn = { geo: { scope: 'europe', @@ -526,8 +527,8 @@ describe('Test Geo layout defaults', function() { }); }); - vals.forEach(function(v) { - it('fitbounds:' + v, function() { + vals.forEach(function (v) { + it('fitbounds:' + v, function () { layoutIn = { geo: { scope: 'europe', @@ -548,16 +549,16 @@ describe('Test Geo layout defaults', function() { }); }); - describe('- for clipped projections', function() { - it('fitbounds:false (base case)', function() { + describe('- for clipped projections', function () { + it('fitbounds:false (base case)', function () { layoutIn = { geo: { projection: { type: 'orthographic', - rotation: {lon: 20, lat: 20}, + rotation: { lon: 20, lat: 20 }, scale: 2 }, - fitbounds: false, + fitbounds: false } }; supplyLayoutDefaults(layoutIn, layoutOut, fullData); @@ -572,13 +573,13 @@ describe('Test Geo layout defaults', function() { }); }); - vals.forEach(function(v) { - it('fitbounds:' + v, function() { + vals.forEach(function (v) { + it('fitbounds:' + v, function () { layoutIn = { geo: { projection: { type: 'orthographic', - rotation: {lon: 20, lat: 20}, + rotation: { lon: 20, lat: 20 }, scale: 2 }, fitbounds: v @@ -598,18 +599,18 @@ describe('Test Geo layout defaults', function() { }); }); - describe('- for non-clipped projections', function() { - it('fitbounds:false (base case)', function() { + describe('- for non-clipped projections', function () { + it('fitbounds:false (base case)', function () { layoutIn = { geo: { projection: { type: 'natural earth', - rotation: {lon: 20}, + rotation: { lon: 20 }, scale: 2 }, - lonaxis: {range: [-90, 90]}, - lataxis: {range: [0, 80]}, - fitbounds: false, + lonaxis: { range: [-90, 90] }, + lataxis: { range: [0, 80] }, + fitbounds: false } }; supplyLayoutDefaults(layoutIn, layoutOut, fullData); @@ -624,18 +625,18 @@ describe('Test Geo layout defaults', function() { }); }); - vals.forEach(function(v) { - it('fitbounds:' + v, function() { + vals.forEach(function (v) { + it('fitbounds:' + v, function () { layoutIn = { geo: { projection: { type: 'natural earth', - rotation: {lon: 20}, + rotation: { lon: 20 }, scale: 2 }, - lonaxis: {range: [-90, 90]}, - lataxis: {range: [0, 80]}, - fitbounds: v, + lonaxis: { range: [-90, 90] }, + lataxis: { range: [0, 80] }, + fitbounds: v } }; supplyLayoutDefaults(layoutIn, layoutOut, fullData); @@ -653,7 +654,7 @@ describe('Test Geo layout defaults', function() { }); }); - describe('geo.visible should override show* defaults even with template any show* is true', function() { + describe('geo.visible should override show* defaults even with template any show* is true', function () { var keys = [ 'lonaxis.showgrid', 'lataxis.showgrid', @@ -669,9 +670,9 @@ describe('Test Geo layout defaults', function() { function _assert(extra) { var geo = layoutOut.geo; - keys.forEach(function(k) { + keys.forEach(function (k) { var actual = Lib.nestedProperty(geo, k).get(); - if(extra && k in extra) { + if (extra && k in extra) { expect(actual).toBe(extra[k], k); } else { expect(actual).toBe(false, k); @@ -679,8 +680,8 @@ describe('Test Geo layout defaults', function() { }); } - [true, false, undefined].forEach(function(q) { - it('- base case | ' + q, function() { + [true, false, undefined].forEach(function (q) { + it('- base case | ' + q, function () { layoutIn = { template: { layout: { @@ -708,8 +709,8 @@ describe('Test Geo layout defaults', function() { }); }); - [true, false, undefined].forEach(function(q) { - it('- scoped case', function() { + [true, false, undefined].forEach(function (q) { + it('- scoped case', function () { layoutIn = { template: { layout: { @@ -738,8 +739,8 @@ describe('Test Geo layout defaults', function() { }); }); - [true, false, undefined].forEach(function(q) { - it('- scope:usa case', function() { + [true, false, undefined].forEach(function (q) { + it('- scope:usa case', function () { layoutIn = { template: { layout: { @@ -771,7 +772,7 @@ describe('Test Geo layout defaults', function() { }); }); -describe('geojson / topojson utils', function() { +describe('geojson / topojson utils', function () { function _locationToFeature(topojson, loc, locationmode) { var trace = { locationmode: locationmode }; var features = topojsonUtils.getTopojsonFeatures(trace, topojson); @@ -780,49 +781,46 @@ describe('geojson / topojson utils', function() { return feature; } - describe('should be able to extract topojson feature from *locations* items', function() { + describe('should be able to extract topojson feature from *locations* items', function () { var topojsonName = 'world_110m'; var topojson = GeoAssets.topojson[topojsonName]; - it('with *ISO-3* locationmode', function() { + it('with *ISO-3* locationmode', function () { var out = _locationToFeature(topojson, 'CAN', 'ISO-3'); expect(Object.keys(out)).toEqual(['type', 'id', 'properties', 'geometry']); expect(out.id).toEqual('CAN'); }); - it('with *ISO-3* locationmode (not-found case)', function() { + it('with *ISO-3* locationmode (not-found case)', function () { var out = _locationToFeature(topojson, 'XXX', 'ISO-3'); expect(out).toEqual(false); }); - it('with *country names* locationmode', function() { + it('with *country names* locationmode', function () { var out = _locationToFeature(topojson, 'United States', 'country names'); expect(Object.keys(out)).toEqual(['type', 'id', 'properties', 'geometry']); expect(out.id).toEqual('USA'); }); - it('with *country names* locationmode (not-found case)', function() { + it('with *country names* locationmode (not-found case)', function () { var out = _locationToFeature(topojson, 'XXX', 'country names'); expect(out).toEqual(false); }); }); - describe('should distinguish between US and US Virgin Island', function() { + describe('should distinguish between US and US Virgin Island', function () { // N.B. Virgin Island don't appear at the 'world_110m' resolution var topojsonName = 'world_50m'; var topojson = GeoAssets.topojson[topojsonName]; - var shouldPass = [ - 'Virgin Islands (U.S.)', - ' Virgin Islands (U.S.) ' - ]; + var shouldPass = ['Virgin Islands (U.S.)', ' Virgin Islands (U.S.) ']; - shouldPass.forEach(function(str) { - it('(case ' + str + ')', function() { + shouldPass.forEach(function (str) { + it('(case ' + str + ')', function () { var out = _locationToFeature(topojson, str, 'country names'); expect(out.id).toEqual('VIR'); }); @@ -830,10 +828,10 @@ describe('geojson / topojson utils', function() { }); }); -describe('Test geo interactions', function() { +describe('Test geo interactions', function () { afterEach(destroyGraphDiv); - describe('mock geo_first.json', function() { + describe('mock geo_first.json', function () { var mock = require('../../image/mocks/geo_first.json'); var gd; @@ -857,7 +855,7 @@ describe('Test geo interactions', function() { return d3Select('g.infolayer').selectAll('.cbbg').size(); } - beforeEach(function(done) { + beforeEach(function (done) { gd = createGraphDiv(); var mockCopy = Lib.extendDeep({}, mock); @@ -865,13 +863,13 @@ describe('Test geo interactions', function() { Plotly.newPlot(gd, mockCopy.data, mockCopy.layout).then(done); }); - describe('scattergeo hover events', function() { + describe('scattergeo hover events', function () { var ptData, cnt; - beforeEach(function() { + beforeEach(function () { cnt = 0; - gd.on('plotly_hover', function(eventData) { + gd.on('plotly_hover', function (eventData) { ptData = eventData.points[0]; cnt++; }); @@ -879,15 +877,27 @@ describe('Test geo interactions', function() { mouseEventScatterGeo('mousemove'); }); - it('should contain the correct fields', function() { - expect(Object.keys(ptData).sort()).toEqual([ - 'data', 'fullData', 'curveNumber', 'pointNumber', 'pointIndex', 'bbox', - 'lon', 'lat', 'location', 'marker.size', 'xPixel', 'yPixel' - ].sort()); + it('should contain the correct fields', function () { + expect(Object.keys(ptData).sort()).toEqual( + [ + 'data', + 'fullData', + 'curveNumber', + 'pointNumber', + 'pointIndex', + 'bbox', + 'lon', + 'lat', + 'location', + 'marker.size', + 'xPixel', + 'yPixel' + ].sort() + ); expect(cnt).toEqual(1); }); - it('should show the correct point data', function() { + it('should show the correct point data', function () { expect(ptData.lon).toEqual(0); expect(ptData.lat).toEqual(0); expect(ptData.location).toBe(null); @@ -897,15 +907,14 @@ describe('Test geo interactions', function() { expect(cnt).toEqual(1); }); - it('should not be triggered when pt over on the other side of the globe', function(done) { + it('should not be triggered when pt over on the other side of the globe', function (done) { var update = { 'geo.projection.type': 'orthographic', 'geo.projection.rotation': { lon: 82, lat: -19 } }; - Plotly.relayout(gd, update) - .then(function() { - setTimeout(function() { + Plotly.relayout(gd, update).then(function () { + setTimeout(function () { mouseEvent('mousemove', 288, 170); expect(cnt).toEqual(1); @@ -915,13 +924,13 @@ describe('Test geo interactions', function() { }); }); - it('should not be triggered when pt *location* does not have matching feature', function(done) { + it('should not be triggered when pt *location* does not have matching feature', function (done) { var update = { locations: [['CAN', 'AAA', 'USA']] }; - Plotly.restyle(gd, update).then(function() { - setTimeout(function() { + Plotly.restyle(gd, update).then(function () { + setTimeout(function () { mouseEvent('mousemove', 300, 230); expect(cnt).toEqual(1); @@ -932,11 +941,11 @@ describe('Test geo interactions', function() { }); }); - describe('scattergeo click events', function() { + describe('scattergeo click events', function () { var ptData; - beforeEach(function() { - gd.on('plotly_click', function(eventData) { + beforeEach(function () { + gd.on('plotly_click', function (eventData) { ptData = eventData.points[0]; }); @@ -944,14 +953,26 @@ describe('Test geo interactions', function() { mouseEventScatterGeo('click'); }); - it('should contain the correct fields', function() { - expect(Object.keys(ptData).sort()).toEqual([ - 'data', 'fullData', 'curveNumber', 'pointNumber', 'pointIndex', 'bbox', - 'lon', 'lat', 'location', 'marker.size', 'xPixel', 'yPixel' - ].sort()); - }); - - it('should show the correct point data', function() { + it('should contain the correct fields', function () { + expect(Object.keys(ptData).sort()).toEqual( + [ + 'data', + 'fullData', + 'curveNumber', + 'pointNumber', + 'pointIndex', + 'bbox', + 'lon', + 'lat', + 'location', + 'marker.size', + 'xPixel', + 'yPixel' + ].sort() + ); + }); + + it('should show the correct point data', function () { expect(ptData.lon).toEqual(0); expect(ptData.lat).toEqual(0); expect(ptData.location).toBe(null); @@ -961,29 +982,41 @@ describe('Test geo interactions', function() { }); }); - describe('scattergeo unhover events', function() { + describe('scattergeo unhover events', function () { var ptData; - beforeEach(function(done) { - gd.on('plotly_unhover', function(eventData) { + beforeEach(function (done) { + gd.on('plotly_unhover', function (eventData) { ptData = eventData.points[0]; }); mouseEventScatterGeo('mousemove'); - setTimeout(function() { + setTimeout(function () { mouseEvent('mousemove', 400, 200); done(); }, HOVERMINTIME + 10); }); - it('should contain the correct fields', function() { - expect(Object.keys(ptData).sort()).toEqual([ - 'data', 'fullData', 'curveNumber', 'pointNumber', 'pointIndex', 'bbox', - 'lon', 'lat', 'location', 'marker.size', 'xPixel', 'yPixel' - ].sort()); - }); - - it('should show the correct point data', function() { + it('should contain the correct fields', function () { + expect(Object.keys(ptData).sort()).toEqual( + [ + 'data', + 'fullData', + 'curveNumber', + 'pointNumber', + 'pointIndex', + 'bbox', + 'lon', + 'lat', + 'location', + 'marker.size', + 'xPixel', + 'yPixel' + ].sort() + ); + }); + + it('should show the correct point data', function () { expect(ptData.lon).toEqual(0); expect(ptData.lat).toEqual(0); expect(ptData.location).toBe(null); @@ -993,11 +1026,11 @@ describe('Test geo interactions', function() { }); }); - describe('choropleth hover events', function() { + describe('choropleth hover events', function () { var ptData; - beforeEach(function() { - gd.on('plotly_hover', function(eventData) { + beforeEach(function () { + gd.on('plotly_hover', function (eventData) { ptData = eventData.points[0]; }); @@ -1005,14 +1038,25 @@ describe('Test geo interactions', function() { mouseEventChoropleth('mousemove'); }); - it('should contain the correct fields', function() { - expect(Object.keys(ptData).sort()).toEqual([ - 'data', 'fullData', 'curveNumber', 'pointNumber', 'pointIndex', 'bbox', - 'location', 'z', 'ct', 'xPixel', 'yPixel' - ].sort()); - }); - - it('should show the correct point data', function() { + it('should contain the correct fields', function () { + expect(Object.keys(ptData).sort()).toEqual( + [ + 'data', + 'fullData', + 'curveNumber', + 'pointNumber', + 'pointIndex', + 'bbox', + 'location', + 'z', + 'ct', + 'xPixel', + 'yPixel' + ].sort() + ); + }); + + it('should show the correct point data', function () { expect(ptData.location).toBe('RUS'); expect(ptData.z).toEqual(10); expect(ptData.curveNumber).toEqual(1); @@ -1020,11 +1064,11 @@ describe('Test geo interactions', function() { }); }); - describe('choropleth click events', function() { + describe('choropleth click events', function () { var ptData; - beforeEach(function() { - gd.on('plotly_click', function(eventData) { + beforeEach(function () { + gd.on('plotly_click', function (eventData) { ptData = eventData.points[0]; }); @@ -1033,14 +1077,25 @@ describe('Test geo interactions', function() { mouseEventChoropleth('click'); }); - it('should contain the correct fields', function() { - expect(Object.keys(ptData).sort()).toEqual([ - 'data', 'fullData', 'curveNumber', 'pointNumber', 'pointIndex', 'bbox', - 'location', 'z', 'ct', 'xPixel', 'yPixel' - ].sort()); - }); - - it('should show the correct point data', function() { + it('should contain the correct fields', function () { + expect(Object.keys(ptData).sort()).toEqual( + [ + 'data', + 'fullData', + 'curveNumber', + 'pointNumber', + 'pointIndex', + 'bbox', + 'location', + 'z', + 'ct', + 'xPixel', + 'yPixel' + ].sort() + ); + }); + + it('should show the correct point data', function () { expect(ptData.location).toBe('RUS'); expect(ptData.z).toEqual(10); expect(ptData.curveNumber).toEqual(1); @@ -1048,31 +1103,42 @@ describe('Test geo interactions', function() { }); }); - describe('choropleth unhover events', function() { + describe('choropleth unhover events', function () { var ptData; - beforeEach(function(done) { - gd.on('plotly_unhover', function(eventData) { + beforeEach(function (done) { + gd.on('plotly_unhover', function (eventData) { ptData = eventData.points[0]; }); mouseEventChoropleth('mouseover'); mouseEventChoropleth('mousemove'); mouseEventChoropleth('mouseout'); - setTimeout(function() { + setTimeout(function () { mouseEvent('mousemove', 300, 235); done(); }, HOVERMINTIME + 100); }); - it('should contain the correct fields', function() { - expect(Object.keys(ptData).sort()).toEqual([ - 'data', 'fullData', 'curveNumber', 'pointNumber', 'pointIndex', 'bbox', - 'location', 'z', 'ct', 'xPixel', 'yPixel' - ].sort()); - }); - - it('should show the correct point data', function() { + it('should contain the correct fields', function () { + expect(Object.keys(ptData).sort()).toEqual( + [ + 'data', + 'fullData', + 'curveNumber', + 'pointNumber', + 'pointIndex', + 'bbox', + 'location', + 'z', + 'ct', + 'xPixel', + 'yPixel' + ].sort() + ); + }); + + it('should show the correct point data', function () { expect(ptData.location).toBe('RUS'); expect(ptData.z).toEqual(10); expect(ptData.curveNumber).toEqual(1); @@ -1080,78 +1146,82 @@ describe('Test geo interactions', function() { }); }); - describe('trace visibility toggle', function() { - it('should toggle scattergeo elements', function(done) { + describe('trace visibility toggle', function () { + it('should toggle scattergeo elements', function (done) { expect(countTraces('scattergeo')).toBe(1); expect(countTraces('choropleth')).toBe(1); - Plotly.restyle(gd, 'visible', false, [0]).then(function() { - expect(countTraces('scattergeo')).toBe(0); - expect(countTraces('choropleth')).toBe(1); + Plotly.restyle(gd, 'visible', false, [0]) + .then(function () { + expect(countTraces('scattergeo')).toBe(0); + expect(countTraces('choropleth')).toBe(1); - return Plotly.restyle(gd, 'visible', true, [0]); - }).then(function() { - expect(countTraces('scattergeo')).toBe(1); - expect(countTraces('choropleth')).toBe(1); - }) - .then(done, done.fail); + return Plotly.restyle(gd, 'visible', true, [0]); + }) + .then(function () { + expect(countTraces('scattergeo')).toBe(1); + expect(countTraces('choropleth')).toBe(1); + }) + .then(done, done.fail); }); - it('should toggle choropleth elements', function(done) { + it('should toggle choropleth elements', function (done) { expect(countTraces('scattergeo')).toBe(1); expect(countTraces('choropleth')).toBe(1); - Plotly.restyle(gd, 'visible', false, [1]).then(function() { - expect(countTraces('scattergeo')).toBe(1); - expect(countTraces('choropleth')).toBe(0); + Plotly.restyle(gd, 'visible', false, [1]) + .then(function () { + expect(countTraces('scattergeo')).toBe(1); + expect(countTraces('choropleth')).toBe(0); - return Plotly.restyle(gd, 'visible', true, [1]); - }).then(function() { - expect(countTraces('scattergeo')).toBe(1); - expect(countTraces('choropleth')).toBe(1); - }) - .then(done, done.fail); + return Plotly.restyle(gd, 'visible', true, [1]); + }) + .then(function () { + expect(countTraces('scattergeo')).toBe(1); + expect(countTraces('choropleth')).toBe(1); + }) + .then(done, done.fail); }); }); - describe('deleting traces and geos', function() { - it('should delete traces in succession', function(done) { + describe('deleting traces and geos', function () { + it('should delete traces in succession', function (done) { expect(countTraces('scattergeo')).toBe(1); expect(countTraces('choropleth')).toBe(1); expect(countGeos()).toBe(1); expect(countColorBars()).toBe(1); - Plotly.deleteTraces(gd, [0]).then(function() { - expect(countTraces('scattergeo')).toBe(0); - expect(countTraces('choropleth')).toBe(1); - expect(countGeos()).toBe(1); - expect(countColorBars()).toBe(1); - - return Plotly.deleteTraces(gd, [0]); - }).then(function() { - expect(countTraces('scattergeo')).toBe(0); - expect(countTraces('choropleth')).toBe(0); - expect(countGeos()).toBe(0, '- trace-less geo subplot are deleted'); - expect(countColorBars()).toBe(0); - - return Plotly.relayout(gd, 'geo', null); - }).then(function() { - expect(countTraces('scattergeo')).toBe(0); - expect(countTraces('choropleth')).toBe(0); - expect(countGeos()).toBe(0); - expect(countColorBars()).toBe(0); - }) - .then(done, done.fail); - }); - }); - - describe('streaming calls', function() { + Plotly.deleteTraces(gd, [0]) + .then(function () { + expect(countTraces('scattergeo')).toBe(0); + expect(countTraces('choropleth')).toBe(1); + expect(countGeos()).toBe(1); + expect(countColorBars()).toBe(1); + + return Plotly.deleteTraces(gd, [0]); + }) + .then(function () { + expect(countTraces('scattergeo')).toBe(0); + expect(countTraces('choropleth')).toBe(0); + expect(countGeos()).toBe(0, '- trace-less geo subplot are deleted'); + expect(countColorBars()).toBe(0); + + return Plotly.relayout(gd, 'geo', null); + }) + .then(function () { + expect(countTraces('scattergeo')).toBe(0); + expect(countTraces('choropleth')).toBe(0); + expect(countGeos()).toBe(0); + expect(countColorBars()).toBe(0); + }) + .then(done, done.fail); + }); + }); + + describe('streaming calls', function () { var INTERVAL = 10; - var N_MARKERS_AT_START = Math.min( - mock.data[0].lat.length, - mock.data[0].lon.length - ); + var N_MARKERS_AT_START = Math.min(mock.data[0].lat.length, mock.data[0].lon.length); var N_LOCATIONS_AT_START = mock.data[1].locations.length; @@ -1161,7 +1231,7 @@ describe('Test geo interactions', function() { var locationsQueue = ['AUS', 'FRA', 'DEU', 'MEX']; var zQueue = [100, 20, 30, 12]; - beforeEach(function(done) { + beforeEach(function (done) { var update = { mode: 'lines+markers+text', text: [['a', 'b']], @@ -1172,43 +1242,36 @@ describe('Test geo interactions', function() { }); function countScatterGeoLines() { - return d3SelectAll('g.trace.scattergeo') - .selectAll('path.js-line') - .size(); + return d3SelectAll('g.trace.scattergeo').selectAll('path.js-line').size(); } function countScatterGeoMarkers() { - return d3SelectAll('g.trace.scattergeo') - .selectAll('path.point') - .size(); + return d3SelectAll('g.trace.scattergeo').selectAll('path.point').size(); } function countScatterGeoTextGroups() { - return d3SelectAll('g.trace.scattergeo') - .selectAll('g') - .size(); + return d3SelectAll('g.trace.scattergeo').selectAll('g').size(); } function countScatterGeoTextNodes() { - return d3SelectAll('g.trace.scattergeo') - .selectAll('g') - .select('text') - .size(); + return d3SelectAll('g.trace.scattergeo').selectAll('g').select('text').size(); } function checkScatterGeoOrder() { var order = ['js-path', 'point', null]; var nodes = d3SelectAll('g.trace.scattergeo'); - nodes.each(function() { + nodes.each(function () { var list = []; - d3Select(this).selectAll('*').each(function() { - var className = d3Select(this).attr('class'); - list.push(className); - }); + d3Select(this) + .selectAll('*') + .each(function () { + var className = d3Select(this).attr('class'); + list.push(className); + }); - var listSorted = list.slice().sort(function(a, b) { + var listSorted = list.slice().sort(function (a, b) { return order.indexOf(a) - order.indexOf(b); }); @@ -1217,15 +1280,13 @@ describe('Test geo interactions', function() { } function countChoroplethPaths() { - return d3SelectAll('g.trace.choropleth') - .selectAll('path.choroplethlocation') - .size(); + return d3SelectAll('g.trace.choropleth').selectAll('path.choroplethlocation').size(); } - it('should be able to add line/marker/text nodes', function(done) { + it('should be able to add line/marker/text nodes', function (done) { var i = 0; - var interval = setInterval(function() { + var interval = setInterval(function () { expect(countTraces('scattergeo')).toBe(1); expect(countTraces('choropleth')).toBe(1); expect(countScatterGeoLines()).toBe(1); @@ -1239,7 +1300,7 @@ describe('Test geo interactions', function() { trace.lat.push(latQueue[i]); trace.text.push(textQueue[i]); - if(i === lonQueue.length - 1) { + if (i === lonQueue.length - 1) { clearInterval(interval); done(); } @@ -1250,10 +1311,10 @@ describe('Test geo interactions', function() { }, INTERVAL); }); - it('should be able to shift line/marker/text nodes', function(done) { + it('should be able to shift line/marker/text nodes', function (done) { var i = 0; - var interval = setInterval(function() { + var interval = setInterval(function () { expect(countTraces('scattergeo')).toBe(1); expect(countTraces('choropleth')).toBe(1); expect(countScatterGeoLines()).toBe(1); @@ -1270,7 +1331,7 @@ describe('Test geo interactions', function() { trace.lat.shift(); trace.text.shift(); - if(i === lonQueue.length - 1) { + if (i === lonQueue.length - 1) { clearInterval(interval); done(); } @@ -1281,10 +1342,10 @@ describe('Test geo interactions', function() { }, INTERVAL); }); - it('should be able to update line/marker/text nodes', function(done) { + it('should be able to update line/marker/text nodes', function (done) { var i = 0; - var interval = setInterval(function() { + var interval = setInterval(function () { expect(countTraces('scattergeo')).toBe(1); expect(countTraces('choropleth')).toBe(1); expect(countScatterGeoLines()).toBe(1); @@ -1301,7 +1362,7 @@ describe('Test geo interactions', function() { trace.lat.shift(); trace.text.shift(); - if(i === lonQueue.length - 1) { + if (i === lonQueue.length - 1) { clearInterval(interval); done(); } @@ -1312,7 +1373,7 @@ describe('Test geo interactions', function() { }, INTERVAL); }); - it('should be able to delete line/marker/text nodes and choropleth paths', function(done) { + it('should be able to delete line/marker/text nodes and choropleth paths', function (done) { var trace0 = gd.data[0]; trace0.lon.shift(); trace0.lat.shift(); @@ -1323,22 +1384,22 @@ describe('Test geo interactions', function() { gd.layout.datarevision = '0'; Plotly.react(gd, gd.data, gd.layout) - .then(function() { - expect(countTraces('scattergeo')).toBe(1); - expect(countTraces('choropleth')).toBe(1); + .then(function () { + expect(countTraces('scattergeo')).toBe(1); + expect(countTraces('choropleth')).toBe(1); - expect(countScatterGeoLines()).toBe(1); - expect(countScatterGeoMarkers()).toBe(N_MARKERS_AT_START - 1); - expect(countScatterGeoTextGroups()).toBe(N_MARKERS_AT_START - 1); - expect(countScatterGeoTextNodes()).toBe(N_MARKERS_AT_START - 1); - checkScatterGeoOrder(); + expect(countScatterGeoLines()).toBe(1); + expect(countScatterGeoMarkers()).toBe(N_MARKERS_AT_START - 1); + expect(countScatterGeoTextGroups()).toBe(N_MARKERS_AT_START - 1); + expect(countScatterGeoTextNodes()).toBe(N_MARKERS_AT_START - 1); + checkScatterGeoOrder(); - expect(countChoroplethPaths()).toBe(N_LOCATIONS_AT_START - 1); - }) - .then(done, done.fail); + expect(countChoroplethPaths()).toBe(N_LOCATIONS_AT_START - 1); + }) + .then(done, done.fail); }); - it('should be able to update line/marker/text nodes and choropleth paths', function(done) { + it('should be able to update line/marker/text nodes and choropleth paths', function (done) { var trace0 = gd.data[0]; trace0.lon = lonQueue; trace0.lat = latQueue; @@ -1350,82 +1411,85 @@ describe('Test geo interactions', function() { gd.layout.datarevision = '0'; Plotly.react(gd, gd.data, gd.layout) - .then(function() { - expect(countTraces('scattergeo')).toBe(1); - expect(countTraces('choropleth')).toBe(1); + .then(function () { + expect(countTraces('scattergeo')).toBe(1); + expect(countTraces('choropleth')).toBe(1); - expect(countScatterGeoLines()).toBe(1); - expect(countScatterGeoMarkers()).toBe(lonQueue.length); - expect(countScatterGeoTextGroups()).toBe(textQueue.length); - expect(countScatterGeoTextNodes()).toBe(textQueue.length); - checkScatterGeoOrder(); + expect(countScatterGeoLines()).toBe(1); + expect(countScatterGeoMarkers()).toBe(lonQueue.length); + expect(countScatterGeoTextGroups()).toBe(textQueue.length); + expect(countScatterGeoTextNodes()).toBe(textQueue.length); + checkScatterGeoOrder(); - expect(countChoroplethPaths()).toBe(locationsQueue.length); - }) - .then(done, done.fail); + expect(countChoroplethPaths()).toBe(locationsQueue.length); + }) + .then(done, done.fail); }); }); }); - it('should not throw during hover when out-of-range pts are present in *albers usa* map', function(done) { + it('should not throw during hover when out-of-range pts are present in *albers usa* map', function (done) { var gd = createGraphDiv(); var fig = Lib.extendDeep({}, require('../../image/mocks/geo_scattergeo-out-of-usa.json')); fig.layout.width = 700; fig.layout.height = 500; - Plotly.newPlot(gd, fig).then(function() { - mouseEvent('mousemove', 350, 250); - expect(d3SelectAll('g.hovertext').size()).toEqual(1); - }) - .then(done, done.fail); + Plotly.newPlot(gd, fig) + .then(function () { + mouseEvent('mousemove', 350, 250); + expect(d3SelectAll('g.hovertext').size()).toEqual(1); + }) + .then(done, done.fail); }); - it('should clear hover label when cursor slips off subplot', function(done) { + it('should clear hover label when cursor slips off subplot', function (done) { var gd = createGraphDiv(); var fig = Lib.extendDeep({}, require('../../image/mocks/geo_orthographic.json')); function _assert(msg, hoverLabelCnt) { - expect(d3SelectAll('g.hovertext').size()) - .toBe(hoverLabelCnt, msg); + expect(d3SelectAll('g.hovertext').size()).toBe(hoverLabelCnt, msg); } var px = 200; var py = 200; var cnt = 0; - Plotly.newPlot(gd, fig).then(function() { - gd.on('plotly_unhover', function() { cnt++; }); + Plotly.newPlot(gd, fig) + .then(function () { + gd.on('plotly_unhover', function () { + cnt++; + }); - mouseEvent('mousemove', px, py); - _assert('base state', 1); + mouseEvent('mousemove', px, py); + _assert('base state', 1); - return new Promise(function(resolve) { - var interval = setInterval(function() { - py -= 2; - mouseEvent('mousemove', px, py); + return new Promise(function (resolve) { + var interval = setInterval(function () { + py -= 2; + mouseEvent('mousemove', px, py); - if(py > 176) { - _assert('- py ' + py, 1); - expect(cnt).toBe(0, 'no plotly_unhover event so far'); - } else { - _assert('- py ' + py, 0); - expect(cnt).toBe(1, 'plotly_unhover event count'); + if (py > 176) { + _assert('- py ' + py, 1); + expect(cnt).toBe(0, 'no plotly_unhover event so far'); + } else { + _assert('- py ' + py, 0); + expect(cnt).toBe(1, 'plotly_unhover event count'); - clearInterval(interval); - resolve(); - } - }, 100); - }); - }) - .then(done, done.fail); + clearInterval(interval); + resolve(); + } + }, 100); + }); + }) + .then(done, done.fail); }); - it('should not confuse positions on either side of the globe', function(done) { + it('should not confuse positions on either side of the globe', function (done) { var gd = createGraphDiv(); var fig = Lib.extendDeep({}, require('../../image/mocks/geo_orthographic.json')); fig.data[0].visible = false; - fig.layout.geo.projection.rotation = {lon: -75, lat: 90}; + fig.layout.geo.projection.rotation = { lon: -75, lat: 90 }; function check(p, hoverLabelCnt) { mouseEvent('mousemove', p[0], p[1]); @@ -1433,22 +1497,22 @@ describe('Test geo interactions', function() { var invert = gd._fullLayout.geo._subplot.projection.invert; var lonlat = invert(p); - expect(d3SelectAll('g.hovertext').size()) - .toBe(hoverLabelCnt, 'for ' + lonlat); + expect(d3SelectAll('g.hovertext').size()).toBe(hoverLabelCnt, 'for ' + lonlat); Lib.clearThrottle(); } - Plotly.newPlot(gd, fig).then(function() { - var px = 255; + Plotly.newPlot(gd, fig) + .then(function () { + var px = 255; - check([px, 163], 0); - check([px, 360], 1); - }) - .then(done, done.fail); + check([px, 163], 0); + check([px, 360], 1); + }) + .then(done, done.fail); }); - it('should get hover right for choropleths involving landmasses that cross antimeridian', function(done) { + it('should get hover right for choropleths involving landmasses that cross antimeridian', function (done) { var gd = createGraphDiv(); function check(lonlat, hoverLabelCnt, msg) { @@ -1461,220 +1525,252 @@ describe('Test geo interactions', function() { Lib.clearThrottle(); } - Plotly.newPlot(gd, [{ - type: 'choropleth', - locations: ['RUS', 'FJI', 'ATA'], - z: [0, 1, 2] - }]) - .then(function() { - check([81, 66], 1, 'spot in north-central Russia that polygon.contains gets wrong before +360 shift'); - check([-80, 66], 0, 'spot north of Hudson bay that polygon.contains believe is in Russia before before +360 shift'); - - return Plotly.relayout(gd, 'geo.projection.rotation.lon', 180); - }) - .then(function() { - check([-174, 65], 1, 'spot in Russia mainland beyond antimeridian'); + Plotly.newPlot(gd, [ + { + type: 'choropleth', + locations: ['RUS', 'FJI', 'ATA'], + z: [0, 1, 2] + } + ]) + .then(function () { + check([81, 66], 1, 'spot in north-central Russia that polygon.contains gets wrong before +360 shift'); + check( + [-80, 66], + 0, + 'spot north of Hudson bay that polygon.contains believe is in Russia before before +360 shift' + ); + + return Plotly.relayout(gd, 'geo.projection.rotation.lon', 180); + }) + .then(function () { + check([-174, 65], 1, 'spot in Russia mainland beyond antimeridian'); - return Plotly.relayout(gd, { - 'geo.center.lat': -16, - 'geo.projection.scale': 17 - }); - }) - .then(function() { - check([179, -16.6], 1, 'spot on Fiji island that cross antimeridian west of antimeridian'); - // This island no longer crosses the antimeridian due to differences in the simplification process. - // Commenting out for now in the event that we update the simplification and the test is needed again. - // check([-179.9, -16.7], 1, 'spot on Fiji island that cross antimeridian east of antimeridian'); - - return Plotly.relayout(gd, { - 'geo.center.lat': null, - 'geo.projection': { - type: 'orthographic', - rotation: {lat: -90} - } - }); - }) - .then(function() { - check([-150, -89], 1, 'spot in Antarctica that requires *stitching*'); - }) - .then(done, done.fail); + return Plotly.relayout(gd, { + 'geo.center.lat': -16, + 'geo.projection.scale': 17 + }); + }) + .then(function () { + check([179, -16.6], 1, 'spot on Fiji island that cross antimeridian west of antimeridian'); + // This island no longer crosses the antimeridian due to differences in the simplification process. + // Commenting out for now in the event that we update the simplification and the test is needed again. + // check([-179.9, -16.7], 1, 'spot on Fiji island that cross antimeridian east of antimeridian'); + + return Plotly.relayout(gd, { + 'geo.center.lat': null, + 'geo.projection': { + type: 'orthographic', + rotation: { lat: -90 } + } + }); + }) + .then(function () { + check([-150, -89], 1, 'spot in Antarctica that requires *stitching*'); + }) + .then(done, done.fail); }); - it('should reset viewInitial when updating *scope*', function(done) { + it('should reset viewInitial when updating *scope*', function (done) { var gd = createGraphDiv(); function _assertViewInitial(msg, exp) { var viewInitial = gd._fullLayout.geo._subplot.viewInitial; - expect(Object.keys(viewInitial).length) - .toBe(Object.keys(exp).length, 'same # of viewInitial keys |' + msg); + expect(Object.keys(viewInitial).length).toBe(Object.keys(exp).length, 'same # of viewInitial keys |' + msg); - for(var k in viewInitial) { + for (var k in viewInitial) { expect(viewInitial[k]).toBe(exp[k], k + ' |' + msg); } } var figWorld = { - data: [{ - type: 'choropleth', - locationmode: 'country names', - locations: ['canada', 'china', 'russia'], - z: ['10', '20', '15'] - }], - layout: {geo: {scope: 'world'}} + data: [ + { + type: 'choropleth', + locationmode: 'country names', + locations: ['canada', 'china', 'russia'], + z: ['10', '20', '15'] + } + ], + layout: { geo: { scope: 'world' } } }; var figUSA = { - data: [{ - type: 'choropleth', - locationmode: 'USA-states', - locations: ['CA', 'CO', 'NY'], - z: ['10', '20', '15'] - }], - layout: {geo: {scope: 'usa'}} + data: [ + { + type: 'choropleth', + locationmode: 'USA-states', + locations: ['CA', 'CO', 'NY'], + z: ['10', '20', '15'] + } + ], + layout: { geo: { scope: 'usa' } } }; var figNA = { - data: [{ - type: 'choropleth', - locationmode: 'country names', - locations: ['Canada', 'USA', 'Mexico'], - z: ['10', '20', '15'] - }], - layout: {geo: {scope: 'north america'}} + data: [ + { + type: 'choropleth', + locationmode: 'country names', + locations: ['Canada', 'USA', 'Mexico'], + z: ['10', '20', '15'] + } + ], + layout: { geo: { scope: 'north america' } } }; Plotly.react(gd, figWorld) - .then(function() { - _assertViewInitial('world scope', { - fitbounds: false, - 'center.lon': 0, - 'center.lat': 0, - 'projection.scale': 1, - 'projection.rotation.lon': 0 - }); - }) - .then(function() { return Plotly.react(gd, figUSA); }) - .then(function() { - _assertViewInitial('react to usa scope', { - fitbounds: false, - 'center.lon': -96.6, - 'center.lat': 38.7, - 'projection.scale': 1 - }); - }) - .then(function() { return Plotly.react(gd, figNA); }) - .then(function() { - _assertViewInitial('react to NA scope', { - fitbounds: false, - 'center.lon': -112.5, - 'center.lat': 45, - 'projection.scale': 1 - }); - }) - .then(function() { return Plotly.react(gd, figWorld); }) - .then(function() { - _assertViewInitial('react back to world scope', { - fitbounds: false, - 'center.lon': 0, - 'center.lat': 0, - 'projection.scale': 1, - 'projection.rotation.lon': 0 - }); - }) - .then(done, done.fail); + .then(function () { + _assertViewInitial('world scope', { + fitbounds: false, + 'center.lon': 0, + 'center.lat': 0, + 'projection.scale': 1, + 'projection.rotation.lon': 0 + }); + }) + .then(function () { + return Plotly.react(gd, figUSA); + }) + .then(function () { + _assertViewInitial('react to usa scope', { + fitbounds: false, + 'center.lon': -96.6, + 'center.lat': 38.7, + 'projection.scale': 1 + }); + }) + .then(function () { + return Plotly.react(gd, figNA); + }) + .then(function () { + _assertViewInitial('react to NA scope', { + fitbounds: false, + 'center.lon': -112.5, + 'center.lat': 45, + 'projection.scale': 1 + }); + }) + .then(function () { + return Plotly.react(gd, figWorld); + }) + .then(function () { + _assertViewInitial('react back to world scope', { + fitbounds: false, + 'center.lon': 0, + 'center.lat': 0, + 'projection.scale': 1, + 'projection.rotation.lon': 0 + }); + }) + .then(done, done.fail); }); - it([ - 'geo.visible should honor template.layout.geo.show* defaults', - 'when template.layout.geo.visible is set to false,', - 'and does NOT set layout.geo.visible template' - ].join(' '), function(done) { - var gd = createGraphDiv(); - - Plotly.react(gd, [{ - type: 'scattergeo', - lat: [0], - lon: [0], - marker: { size: 100 } - }], { - template: { - layout: { - geo: { - visible: false, - showcoastlines: true, - showcountries: true, - showframe: true, - showland: true, - showlakes: true, - showocean: true, - showrivers: true, - showsubunits: true, - lonaxis: { showgrid: true }, - lataxis: { showgrid: true } + it( + [ + 'geo.visible should honor template.layout.geo.show* defaults', + 'when template.layout.geo.visible is set to false,', + 'and does NOT set layout.geo.visible template' + ].join(' '), + function (done) { + var gd = createGraphDiv(); + + Plotly.react( + gd, + [ + { + type: 'scattergeo', + lat: [0], + lon: [0], + marker: { size: 100 } } - } - }, - geo: {} - }) - .then(function() { - expect(gd._fullLayout.geo.showcoastlines).toBe(true); - expect(gd._fullLayout.geo.showcountries).toBe(true); - expect(gd._fullLayout.geo.showframe).toBe(true); - expect(gd._fullLayout.geo.showland).toBe(true); - expect(gd._fullLayout.geo.showlakes).toBe(true); - expect(gd._fullLayout.geo.showocean).toBe(true); - expect(gd._fullLayout.geo.showrivers).toBe(true); - expect(gd._fullLayout.geo.showsubunits).toBe(undefined); - expect(gd._fullLayout.geo.lonaxis.showgrid).toBe(true); - expect(gd._fullLayout.geo.lataxis.showgrid).toBe(true); - }) - .then(function() { - return Plotly.react(gd, [{ - type: 'scattergeo', - lat: [0], - lon: [0], - marker: {size: 100} - }], { - template: { - layout: { - geo: { - showcoastlines: true, - showcountries: true, - showframe: true, - showland: true, - showlakes: true, - showocean: true, - showrivers: true, - showsubunits: true, - lonaxis: { showgrid: true }, - lataxis: { showgrid: true } + ], + { + template: { + layout: { + geo: { + visible: false, + showcoastlines: true, + showcountries: true, + showframe: true, + showland: true, + showlakes: true, + showocean: true, + showrivers: true, + showsubunits: true, + lonaxis: { showgrid: true }, + lataxis: { showgrid: true } + } } - } - }, - geo: { - visible: false + }, + geo: {} } - }); - }) - .then(function() { - expect(gd._fullLayout.geo.showcoastlines).toBe(false); - expect(gd._fullLayout.geo.showcountries).toBe(false); - expect(gd._fullLayout.geo.showframe).toBe(false); - expect(gd._fullLayout.geo.showland).toBe(false); - expect(gd._fullLayout.geo.showlakes).toBe(false); - expect(gd._fullLayout.geo.showocean).toBe(false); - expect(gd._fullLayout.geo.showrivers).toBe(false); - expect(gd._fullLayout.geo.showsubunits).toBe(undefined); - expect(gd._fullLayout.geo.lonaxis.showgrid).toBe(false); - expect(gd._fullLayout.geo.lataxis.showgrid).toBe(false); - }) - .then(done, done.fail); - }); + ) + .then(function () { + expect(gd._fullLayout.geo.showcoastlines).toBe(true); + expect(gd._fullLayout.geo.showcountries).toBe(true); + expect(gd._fullLayout.geo.showframe).toBe(true); + expect(gd._fullLayout.geo.showland).toBe(true); + expect(gd._fullLayout.geo.showlakes).toBe(true); + expect(gd._fullLayout.geo.showocean).toBe(true); + expect(gd._fullLayout.geo.showrivers).toBe(true); + expect(gd._fullLayout.geo.showsubunits).toBe(undefined); + expect(gd._fullLayout.geo.lonaxis.showgrid).toBe(true); + expect(gd._fullLayout.geo.lataxis.showgrid).toBe(true); + }) + .then(function () { + return Plotly.react( + gd, + [ + { + type: 'scattergeo', + lat: [0], + lon: [0], + marker: { size: 100 } + } + ], + { + template: { + layout: { + geo: { + showcoastlines: true, + showcountries: true, + showframe: true, + showland: true, + showlakes: true, + showocean: true, + showrivers: true, + showsubunits: true, + lonaxis: { showgrid: true }, + lataxis: { showgrid: true } + } + } + }, + geo: { + visible: false + } + } + ); + }) + .then(function () { + expect(gd._fullLayout.geo.showcoastlines).toBe(false); + expect(gd._fullLayout.geo.showcountries).toBe(false); + expect(gd._fullLayout.geo.showframe).toBe(false); + expect(gd._fullLayout.geo.showland).toBe(false); + expect(gd._fullLayout.geo.showlakes).toBe(false); + expect(gd._fullLayout.geo.showocean).toBe(false); + expect(gd._fullLayout.geo.showrivers).toBe(false); + expect(gd._fullLayout.geo.showsubunits).toBe(undefined); + expect(gd._fullLayout.geo.lonaxis.showgrid).toBe(false); + expect(gd._fullLayout.geo.lataxis.showgrid).toBe(false); + }) + .then(done, done.fail); + } + ); - describe('should not make request for topojson when not needed', function() { + describe('should not make request for topojson when not needed', function () { var gd; - beforeEach(function() { - if(window.PlotlyGeoAssets && window.PlotlyGeoAssets.topojson) { + beforeEach(function () { + if (window.PlotlyGeoAssets && window.PlotlyGeoAssets.topojson) { delete window.PlotlyGeoAssets.topojson.world_110m; } gd = createGraphDiv(); @@ -1682,61 +1778,83 @@ describe('Test geo interactions', function() { }); function _assert(cnt) { - return function() { + return function () { expect(d3.json).toHaveBeenCalledTimes(cnt); }; } - it('- no base layers + lon/lat traces', function(done) { + it('- no base layers + lon/lat traces', function (done) { var fig = Lib.extendDeep({}, require('../../image/mocks/geo_skymap.json')); Plotly.newPlot(gd, fig) - .then(_assert(0)) - .then(function() { return Plotly.relayout(gd, 'geo.showcoastlines', true); }) - .then(_assert(1)) - .then(done, done.fail); + .then(_assert(0)) + .then(function () { + return Plotly.relayout(gd, 'geo.showcoastlines', true); + }) + .then(_assert(1)) + .then(done, done.fail); }); - it('- no base layers + choropleth', function(done) { - Plotly.newPlot(gd, [{ - type: 'choropleth', - locations: ['CAN'], - z: [10] - }], { - geo: {showcoastlines: false} - }) - .then(_assert(1)) - .then(done, done.fail); + it('- no base layers + choropleth', function (done) { + Plotly.newPlot( + gd, + [ + { + type: 'choropleth', + locations: ['CAN'], + z: [10] + } + ], + { + geo: { showcoastlines: false } + } + ) + .then(_assert(1)) + .then(done, done.fail); }); - it('- no base layers + location scattergeo', function(done) { - Plotly.newPlot(gd, [{ - type: 'scattergeo', - locations: ['CAN'], - }], { - geo: {showcoastlines: false} - }) - .then(_assert(1)) - .then(done, done.fail); + it('- no base layers + location scattergeo', function (done) { + Plotly.newPlot( + gd, + [ + { + type: 'scattergeo', + locations: ['CAN'] + } + ], + { + geo: { showcoastlines: false } + } + ) + .then(_assert(1)) + .then(done, done.fail); }); - it('- geo.visible:false', function(done) { - Plotly.newPlot(gd, [{ - type: 'scattergeo', - lon: [0], - lat: [0] - }], { - geo: {visible: false} - }) - .then(_assert(0)) - .then(function() { return Plotly.relayout(gd, 'geo.visible', true); }) - .then(_assert(1)) - .then(done, done.fail); + it('- geo.visible:false', function (done) { + Plotly.newPlot( + gd, + [ + { + type: 'scattergeo', + lon: [0], + lat: [0] + } + ], + { + geo: { visible: false } + } + ) + .then(_assert(0)) + .then(function () { + return Plotly.relayout(gd, 'geo.visible', true); + }) + .then(_assert(1)) + .then(done, done.fail); }); }); }); -describe('Test event property of interactions on a geo plot:', function() { +describe('Test event property of interactions on a geo plot:', function () { var mock = require('../../image/mocks/geo_scattergeo-locations.json'); var mockCopy, gd; @@ -1745,10 +1863,10 @@ describe('Test event property of interactions on a geo plot:', function() { var pointPos; var nearPos; - beforeAll(function(done) { + beforeAll(function (done) { gd = createGraphDiv(); mockCopy = Lib.extendDeep({}, mock); - Plotly.newPlot(gd, mockCopy.data, mockCopy.layout).then(function() { + Plotly.newPlot(gd, mockCopy.data, mockCopy.layout).then(function () { pointPos = getClientPosition('path.point'); nearPos = [pointPos[0] - 30, pointPos[1] - 30]; destroyGraphDiv(); @@ -1756,44 +1874,56 @@ describe('Test event property of interactions on a geo plot:', function() { }); }); - beforeEach(function() { + beforeEach(function () { gd = createGraphDiv(); mockCopy = Lib.extendDeep({}, mock); }); afterEach(destroyGraphDiv); - describe('click events', function() { + describe('click events', function () { var futureData; - beforeEach(function(done) { + beforeEach(function (done) { Plotly.newPlot(gd, mockCopy.data, mockCopy.layout) - .then(function() { - futureData = null; + .then(function () { + futureData = null; - gd.on('plotly_click', function(data) { - futureData = data; - }); - }) - .then(done); + gd.on('plotly_click', function (data) { + futureData = data; + }); + }) + .then(done); }); - it('should not be trigged when not on data points', function() { + it('should not be trigged when not on data points', function () { click(blankPos[0], blankPos[1]); expect(futureData).toBe(null); }); - it('should contain the correct fields', function() { + it('should contain the correct fields', function () { click(pointPos[0], pointPos[1]); var pt = futureData.points[0]; var evt = futureData.event; - expect(Object.keys(pt).sort()).toEqual([ - 'data', 'fullData', 'curveNumber', 'pointNumber', 'pointIndex', 'bbox', - 'lon', 'lat', - 'location', 'text', 'marker.size', 'xPixel', 'yPixel' - ].sort()); + expect(Object.keys(pt).sort()).toEqual( + [ + 'data', + 'fullData', + 'curveNumber', + 'pointNumber', + 'pointIndex', + 'bbox', + 'lon', + 'lat', + 'location', + 'text', + 'marker.size', + 'xPixel', + 'yPixel' + ].sort() + ); expect(pt.curveNumber).toEqual(0, 'points[0].curveNumber'); expect(typeof pt.data).toEqual(typeof {}, 'points[0].data'); @@ -1803,14 +1933,14 @@ describe('Test event property of interactions on a geo plot:', function() { expect(pt.location).toEqual('CAN', 'points[0].location'); expect(pt.pointNumber).toEqual(0, 'points[0].pointNumber'); expect(pt.text).toEqual(20, 'points[0].text'); - expect(pt['marker.size']).toEqual(20, 'points[0][\'marker.size\']'); + expect(pt['marker.size']).toEqual(20, "points[0]['marker.size']"); expect(evt.clientX).toEqual(pointPos[0], 'event.clientX'); expect(evt.clientY).toEqual(pointPos[1], 'event.clientY'); }); }); - describe('modified click events', function() { + describe('modified click events', function () { var clickOpts = { altKey: true, ctrlKey: true, @@ -1819,24 +1949,24 @@ describe('Test event property of interactions on a geo plot:', function() { }; var futureData; - beforeEach(function(done) { + beforeEach(function (done) { Plotly.newPlot(gd, mockCopy.data, mockCopy.layout) - .then(function() { - futureData = null; + .then(function () { + futureData = null; - gd.on('plotly_click', function(data) { - futureData = data; - }); - }) - .then(done); + gd.on('plotly_click', function (data) { + futureData = data; + }); + }) + .then(done); }); - it('should not be trigged when not on data points', function() { + it('should not be trigged when not on data points', function () { click(blankPos[0], blankPos[1], clickOpts); expect(futureData).toBe(null); }); - it('does not support right-click', function() { + it('does not support right-click', function () { click(pointPos[0], pointPos[1], clickOpts); expect(futureData).toBe(null); @@ -1871,33 +2001,45 @@ describe('Test event property of interactions on a geo plot:', function() { }); }); - describe('hover events', function() { + describe('hover events', function () { var futureData; - beforeEach(function(done) { + beforeEach(function (done) { Plotly.newPlot(gd, mockCopy.data, mockCopy.layout) - .then(function() { - futureData = null; + .then(function () { + futureData = null; - gd.on('plotly_hover', function(data) { - futureData = data; - }); - }) - .then(done); + gd.on('plotly_hover', function (data) { + futureData = data; + }); + }) + .then(done); }); - it('should contain the correct fields', function() { + it('should contain the correct fields', function () { mouseEvent('mousemove', blankPos[0], blankPos[1]); mouseEvent('mousemove', pointPos[0], pointPos[1]); var pt = futureData.points[0]; var evt = futureData.event; - expect(Object.keys(pt).sort()).toEqual([ - 'data', 'fullData', 'curveNumber', 'pointNumber', 'pointIndex', 'bbox', - 'lon', 'lat', - 'location', 'text', 'marker.size', 'xPixel', 'yPixel' - ].sort()); + expect(Object.keys(pt).sort()).toEqual( + [ + 'data', + 'fullData', + 'curveNumber', + 'pointNumber', + 'pointIndex', + 'bbox', + 'lon', + 'lat', + 'location', + 'text', + 'marker.size', + 'xPixel', + 'yPixel' + ].sort() + ); expect(pt.curveNumber).toEqual(0, 'points[0].curveNumber'); expect(typeof pt.data).toEqual(typeof {}, 'points[0].data'); @@ -1907,64 +2049,80 @@ describe('Test event property of interactions on a geo plot:', function() { expect(pt.location).toEqual('CAN', 'points[0].location'); expect(pt.pointNumber).toEqual(0, 'points[0].pointNumber'); expect(pt.text).toEqual(20, 'points[0].text'); - expect(pt['marker.size']).toEqual(20, 'points[0][\'marker.size\']'); + expect(pt['marker.size']).toEqual(20, "points[0]['marker.size']"); expect(evt.clientX).toEqual(pointPos[0], 'event.clientX'); expect(evt.clientY).toEqual(pointPos[1], 'event.clientY'); }); }); - describe('unhover events', function() { + describe('unhover events', function () { var futureData; - beforeEach(function(done) { + beforeEach(function (done) { Plotly.newPlot(gd, mockCopy.data, mockCopy.layout) - .then(function() { - futureData = null; + .then(function () { + futureData = null; - gd.on('plotly_unhover', function(data) { - futureData = data; - }); - }) - .then(done); - }); - - it('should contain the correct fields', function(done) { - move(pointPos[0], pointPos[1], nearPos[0], nearPos[1], HOVERMINTIME + 10).then(function() { - var pt = futureData.points[0]; - var evt = futureData.event; - - expect(Object.keys(pt).sort()).toEqual([ - 'data', 'fullData', 'curveNumber', 'pointNumber', 'pointIndex', 'bbox', - 'lon', 'lat', - 'location', 'text', 'marker.size', 'xPixel', 'yPixel' - ].sort()); - - expect(pt.curveNumber).toEqual(0, 'points[0].curveNumber'); - expect(typeof pt.data).toEqual(typeof {}, 'points[0].data'); - expect(typeof pt.fullData).toEqual(typeof {}, 'points[0].fullData'); - expect(pt.lat).toEqual(57.72, 'points[0].lat'); - expect(pt.lon).toEqual(-101.67, 'points[0].lon'); - expect(pt.location).toEqual('CAN', 'points[0].location'); - expect(pt.pointNumber).toEqual(0, 'points[0].pointNumber'); - expect(pt.text).toEqual(20, 'points[0].text'); - expect(pt['marker.size']).toEqual(20, 'points[0][\'marker.size\']'); - - expect(evt.clientX).toEqual(nearPos[0], 'event.clientX'); - expect(evt.clientY).toEqual(nearPos[1], 'event.clientY'); - }).then(done); + gd.on('plotly_unhover', function (data) { + futureData = data; + }); + }) + .then(done); + }); + + it('should contain the correct fields', function (done) { + move(pointPos[0], pointPos[1], nearPos[0], nearPos[1], HOVERMINTIME + 10) + .then(function () { + var pt = futureData.points[0]; + var evt = futureData.event; + + expect(Object.keys(pt).sort()).toEqual( + [ + 'data', + 'fullData', + 'curveNumber', + 'pointNumber', + 'pointIndex', + 'bbox', + 'lon', + 'lat', + 'location', + 'text', + 'marker.size', + 'xPixel', + 'yPixel' + ].sort() + ); + + expect(pt.curveNumber).toEqual(0, 'points[0].curveNumber'); + expect(typeof pt.data).toEqual(typeof {}, 'points[0].data'); + expect(typeof pt.fullData).toEqual(typeof {}, 'points[0].fullData'); + expect(pt.lat).toEqual(57.72, 'points[0].lat'); + expect(pt.lon).toEqual(-101.67, 'points[0].lon'); + expect(pt.location).toEqual('CAN', 'points[0].location'); + expect(pt.pointNumber).toEqual(0, 'points[0].pointNumber'); + expect(pt.text).toEqual(20, 'points[0].text'); + expect(pt['marker.size']).toEqual(20, "points[0]['marker.size']"); + + expect(evt.clientX).toEqual(nearPos[0], 'event.clientX'); + expect(evt.clientY).toEqual(nearPos[1], 'event.clientY'); + }) + .then(done); }); }); }); -describe('Test geo base layers', function() { +describe('Test geo base layers', function () { var gd; - beforeEach(function() { gd = createGraphDiv(); }); + beforeEach(function () { + gd = createGraphDiv(); + }); afterEach(destroyGraphDiv); - it('should clear obsolete features and layers on *geo.scope* relayout calls', function(done) { + it('should clear obsolete features and layers on *geo.scope* relayout calls', function (done) { function _assert(geojson, layers) { var cd0 = gd.calcdata[0]; var subplot = gd._fullLayout.geo._subplot; @@ -1974,54 +2132,55 @@ describe('Test geo base layers', function() { expect(Object.keys(subplot.layers).length).toEqual(layers.length, '# of layers'); - d3Select(gd).selectAll('.geo > .layer').each(function(d, i) { - expect(d).toBe(layers[i], 'layer ' + d + ' at position ' + i); - }); + d3Select(gd) + .selectAll('.geo > .layer') + .each(function (d, i) { + expect(d).toBe(layers[i], 'layer ' + d + ' at position ' + i); + }); } - Plotly.newPlot(gd, [{ - type: 'choropleth', - locations: ['CAN', 'FRA'], - z: [10, 20] - }], { - geo: {showframe: true} - }) - .then(function() { - _assert( - [true, true], - ['bg', 'coastlines', 'frame', 'backplot', 'frontplot'] - ); - return Plotly.relayout(gd, 'geo.scope', 'europe'); - }) - .then(function() { - _assert( - // 'CAN' is not drawn on 'europe' scope - [false, true], - // 'frame' is not drawn on scoped maps - // 'countries' are there by default on scoped maps - ['bg', 'countries', 'backplot', 'frontplot'] - ); - return Plotly.relayout(gd, 'geo.scope', 'africa'); - }) - .then(function() { - _assert( - [false, false], - ['bg', 'countries', 'backplot', 'frontplot'] - ); - return Plotly.relayout(gd, 'geo.scope', 'world'); - }) - .then(function() { - _assert( - [true, true], - ['bg', 'coastlines', 'frame', 'backplot', 'frontplot'] - ); - }) - .then(done, done.fail); + Plotly.newPlot( + gd, + [ + { + type: 'choropleth', + locations: ['CAN', 'FRA'], + z: [10, 20] + } + ], + { + geo: { showframe: true } + } + ) + .then(function () { + _assert([true, true], ['bg', 'coastlines', 'frame', 'backplot', 'frontplot']); + return Plotly.relayout(gd, 'geo.scope', 'europe'); + }) + .then(function () { + _assert( + // 'CAN' is not drawn on 'europe' scope + [false, true], + // 'frame' is not drawn on scoped maps + // 'countries' are there by default on scoped maps + ['bg', 'countries', 'backplot', 'frontplot'] + ); + return Plotly.relayout(gd, 'geo.scope', 'africa'); + }) + .then(function () { + _assert([false, false], ['bg', 'countries', 'backplot', 'frontplot']); + return Plotly.relayout(gd, 'geo.scope', 'world'); + }) + .then(function () { + _assert([true, true], ['bg', 'coastlines', 'frame', 'backplot', 'frontplot']); + }) + .then(done, done.fail); }); - it('should be able to relayout axis grid *tick0* / *dtick*', function(done) { + it('should be able to relayout axis grid *tick0* / *dtick*', function (done) { function findGridPath(axisName) { - return d3Select(gd).select(axisName + ' > path').attr('d'); + return d3Select(gd) + .select(axisName + ' > path') + .attr('d'); } function first(parts) { @@ -2039,70 +2198,92 @@ describe('Test geo base layers', function() { expect(first(latParts)).toBeCloseToArray(exp.lat0, 1, msg + ' - first lataxis grid pt'); } - Plotly.newPlot(gd, [{type: 'scattergeo'}], { + Plotly.newPlot(gd, [{ type: 'scattergeo' }], { geo: { - lonaxis: {showgrid: true}, - lataxis: {showgrid: true} + lonaxis: { showgrid: true }, + lataxis: { showgrid: true } } }) - .then(function() { - _assert('base', { - lonCnt: 12, lon0: [124.99, 369.99], - latCnt: 18, lat0: [80, 355] - }); - }) - .then(function() { return Plotly.relayout(gd, 'geo.lonaxis.tick0', 25); }) - .then(function() { - _assert('w/ lonaxis.tick0:25', { - lonCnt: 12, lon0: [117.49, 369.99], - latCnt: 18, lat0: [80, 355] - }); - }) - .then(function() { return Plotly.relayout(gd, 'geo.lataxis.tick0', 41); }) - .then(function() { - _assert('w/ lataxis.tick0:41', { - lonCnt: 12, lon0: [117.49, 369.99], - latCnt: 19, lat0: [80, 368.5] - }); - }) - .then(function() { return Plotly.relayout(gd, 'geo.lataxis.dtick', 45); }) - .then(function() { - _assert('w/ lataxis.dtick0:45', { - lonCnt: 12, lon0: [117.49, 369.99], - latCnt: 5, lat0: [80, 308.5] - }); - }) - .then(done, done.fail); + .then(function () { + _assert('base', { + lonCnt: 12, + lon0: [124.99, 369.99], + latCnt: 18, + lat0: [80, 355] + }); + }) + .then(function () { + return Plotly.relayout(gd, 'geo.lonaxis.tick0', 25); + }) + .then(function () { + _assert('w/ lonaxis.tick0:25', { + lonCnt: 12, + lon0: [117.49, 369.99], + latCnt: 18, + lat0: [80, 355] + }); + }) + .then(function () { + return Plotly.relayout(gd, 'geo.lataxis.tick0', 41); + }) + .then(function () { + _assert('w/ lataxis.tick0:41', { + lonCnt: 12, + lon0: [117.49, 369.99], + latCnt: 19, + lat0: [80, 368.5] + }); + }) + .then(function () { + return Plotly.relayout(gd, 'geo.lataxis.dtick', 45); + }) + .then(function () { + _assert('w/ lataxis.dtick0:45', { + lonCnt: 12, + lon0: [117.49, 369.99], + latCnt: 5, + lat0: [80, 308.5] + }); + }) + .then(done, done.fail); }); }); -describe('Test geo zoom/pan/drag interactions:', function() { +describe('Test geo zoom/pan/drag interactions:', function () { var gd; var eventData; var dblClickCnt = 0; - beforeEach(function() { gd = createGraphDiv(); }); + beforeEach(function () { + gd = createGraphDiv(); + }); afterEach(destroyGraphDiv); - var newPlot = function(fig) { - return Plotly.newPlot(gd, fig).then(function() { - gd.on('plotly_relayout', function(d) { eventData = d; }); - gd.on('plotly_doubleclick', function() { dblClickCnt++; }); + var newPlot = function (fig) { + return Plotly.newPlot(gd, fig).then(function () { + gd.on('plotly_relayout', function (d) { + eventData = d; + }); + gd.on('plotly_doubleclick', function () { + dblClickCnt++; + }); }); }; function assertEventData(msg, eventKeys) { - if(eventKeys === 'dblclick') { + if (eventKeys === 'dblclick') { expect(dblClickCnt).toBe(1, msg + 'double click got fired'); expect(eventData).toBeDefined(msg + 'relayout is fired on double clicks'); } else { expect(dblClickCnt).toBe(0, 'double click not fired'); - if(Array.isArray(eventKeys)) { - expect(Object.keys(eventData || {}).length) - .toBe(Object.keys(eventKeys).length, msg + '# of event data keys'); - eventKeys.forEach(function(k) { + if (Array.isArray(eventKeys)) { + expect(Object.keys(eventData || {}).length).toBe( + Object.keys(eventKeys).length, + msg + '# of event data keys' + ); + eventKeys.forEach(function (k) { expect((eventData || {})[k]).toBeDefined(msg + 'event data key ' + k); }); } else { @@ -2115,24 +2296,24 @@ describe('Test geo zoom/pan/drag interactions:', function() { } function scroll(pos, delta) { - return new Promise(function(resolve) { + return new Promise(function (resolve) { mouseEvent('mousemove', pos[0], pos[1]); - mouseEvent('scroll', pos[0], pos[1], {deltaX: delta[0], deltaY: delta[1]}); + mouseEvent('scroll', pos[0], pos[1], { deltaX: delta[0], deltaY: delta[1] }); setTimeout(resolve, 100); }); } function dblClick(pos) { - return new Promise(function(resolve) { + return new Promise(function (resolve) { mouseEvent('dblclick', pos[0], pos[1]); setTimeout(resolve, 100); }); } - describe('should work for non-clipped projections', function() { + describe('should work for non-clipped projections', function () { var fig; - beforeEach(function() { + beforeEach(function () { fig = Lib.extendDeep({}, require('../../image/mocks/geo_winkel-tripel')); fig.layout.width = 700; fig.layout.height = 500; @@ -2168,128 +2349,151 @@ describe('Test geo zoom/pan/drag interactions:', function() { assertEventData(msg, eventKeys); } - it('- base case', function(done) { - newPlot(fig).then(function() { - _assert('base', [ - [-90, 0], [-90, 0], 1 - ], [ - [90, 0], [350, 260], [0, 0], 101.9 - ], undefined); - return drag({path: [[350, 250], [400, 250]], noCover: true}); - }) - .then(function() { - _assert('after east-west drag', [ - [-124.4, 0], [-124.4, 0], 1 - ], [ - [124.4, 0], [350, 260], [0, 0], 101.9 - ], [ - 'geo.projection.rotation.lon', 'geo.center.lon' - ]); - return drag({path: [[400, 250], [400, 300]], noCover: true}); - }) - .then(function() { - _assert('after north-south drag', [ - [-124.4, 0], [-124.4, 28.1], 1 - ], [ - [124.4, 0], [350, 310], [0, 0], 101.9 - ], [ - 'geo.center.lat' - ]); - return scroll([200, 250], [-200, -200]); - }) - .then(function() { - _assert('after off-center scroll', [ - [-151.2, 0], [-151.2, 29.5], 1.3 - ], [ - [151.2, 0], [350, 329.2], [0, 0], 134.4 - ], [ - 'geo.projection.rotation.lon', - 'geo.center.lon', 'geo.center.lat', - 'geo.projection.scale' - ]); - return Plotly.relayout(gd, 'geo.showocean', false); - }) - .then(function() { - _assert('after some relayout call that causes a replot', [ - [-151.2, 0], [-151.2, 29.5], 1.3 - ], [ - // converts translate (px) to center (lonlat) - [151.2, 0], [350, 260], [0, 29.5], 134.4 - ], [ - 'geo.showocean' - ]); - return dblClick([350, 250]); - }) - .then(function() { - // resets to initial view - _assert('after double click', [ - [-90, 0], [-90, 0], 1 - ], [ - [90, 0], [350, 260], [0, 0], 101.9 - ], 'dblclick'); - }) - .then(done, done.fail); + it('- base case', function (done) { + newPlot(fig) + .then(function () { + _assert('base', [[-90, 0], [-90, 0], 1], [[90, 0], [350, 260], [0, 0], 101.9], undefined); + return drag({ + path: [ + [350, 250], + [400, 250] + ], + noCover: true + }); + }) + .then(function () { + _assert( + 'after east-west drag', + [[-124.4, 0], [-124.4, 0], 1], + [[124.4, 0], [350, 260], [0, 0], 101.9], + ['geo.projection.rotation.lon', 'geo.center.lon'] + ); + return drag({ + path: [ + [400, 250], + [400, 300] + ], + noCover: true + }); + }) + .then(function () { + _assert( + 'after north-south drag', + [[-124.4, 0], [-124.4, 28.1], 1], + [[124.4, 0], [350, 310], [0, 0], 101.9], + ['geo.center.lat'] + ); + return scroll([200, 250], [-200, -200]); + }) + .then(function () { + _assert( + 'after off-center scroll', + [[-151.2, 0], [-151.2, 29.5], 1.3], + [[151.2, 0], [350, 329.2], [0, 0], 134.4], + ['geo.projection.rotation.lon', 'geo.center.lon', 'geo.center.lat', 'geo.projection.scale'] + ); + return Plotly.relayout(gd, 'geo.showocean', false); + }) + .then(function () { + _assert( + 'after some relayout call that causes a replot', + [[-151.2, 0], [-151.2, 29.5], 1.3], + [ + // converts translate (px) to center (lonlat) + [151.2, 0], + [350, 260], + [0, 29.5], + 134.4 + ], + ['geo.showocean'] + ); + return dblClick([350, 250]); + }) + .then(function () { + // resets to initial view + _assert( + 'after double click', + [[-90, 0], [-90, 0], 1], + [[90, 0], [350, 260], [0, 0], 101.9], + 'dblclick' + ); + }) + .then(done, done.fail); }); - it('- fitbounds case', function(done) { + it('- fitbounds case', function (done) { fig.layout.geo.fitbounds = 'locations'; - newPlot(fig).then(function() { - _assert('base', [ - [undefined, 0], [undefined, undefined], undefined - ], [ - [-180, -0], [350, 260], [0, 0], 114.59 - ], undefined); - return drag({path: [[350, 250], [400, 250]], noCover: true}); - }) - .then(function() { - _assert('after east-west drag', [ - [149.40, 0], [149.40, 0], 1.1249 - ], [ - [-149.40, 0], [350, 260], [0, 0], 114.59 - ], [ - 'geo.projection.rotation.lon', 'geo.center.lon', 'geo.center.lat', - 'geo.projection.scale', 'geo.fitbounds' - ]); - return scroll([200, 250], [-200, -200]); - }) - .then(function() { - _assert('after off-center scroll', [ - [127.176, 0], [127.176, 1.21], 1.484 - ], [ - [-127.176, 0], [350, 263.195], [0, 0], 151.20 - ], [ - 'geo.projection.rotation.lon', 'geo.center.lon', - 'geo.center.lat', 'geo.projection.scale' - ]); - return Plotly.relayout(gd, 'geo.showocean', false); - }) - .then(function() { - _assert('after some relayout call that causes a replot', [ - // converts translate (px) to center (lonlat) - [127.176, 0], [127.176, 1.21], 1.484 - ], [ - [-127.176, 0], [350, 260], [0, 1.21], 151.20 - ], [ - 'geo.showocean' - ]); - return dblClick([350, 250]); - }) - .then(function() { - _assert('after double click', [ - [undefined, 0], [undefined, undefined], undefined - ], [ - [-180, -0], [350, 260], [0, 0], 114.59 - ], 'dblclick'); - }) - .then(done, done.fail); + newPlot(fig) + .then(function () { + _assert( + 'base', + [[undefined, 0], [undefined, undefined], undefined], + [[-180, -0], [350, 260], [0, 0], 114.59], + undefined + ); + return drag({ + path: [ + [350, 250], + [400, 250] + ], + noCover: true + }); + }) + .then(function () { + _assert( + 'after east-west drag', + [[149.4, 0], [149.4, 0], 1.1249], + [[-149.4, 0], [350, 260], [0, 0], 114.59], + [ + 'geo.projection.rotation.lon', + 'geo.center.lon', + 'geo.center.lat', + 'geo.projection.scale', + 'geo.fitbounds' + ] + ); + return scroll([200, 250], [-200, -200]); + }) + .then(function () { + _assert( + 'after off-center scroll', + [[127.176, 0], [127.176, 1.21], 1.484], + [[-127.176, 0], [350, 263.195], [0, 0], 151.2], + ['geo.projection.rotation.lon', 'geo.center.lon', 'geo.center.lat', 'geo.projection.scale'] + ); + return Plotly.relayout(gd, 'geo.showocean', false); + }) + .then(function () { + _assert( + 'after some relayout call that causes a replot', + [ + // converts translate (px) to center (lonlat) + [127.176, 0], + [127.176, 1.21], + 1.484 + ], + [[-127.176, 0], [350, 260], [0, 1.21], 151.2], + ['geo.showocean'] + ); + return dblClick([350, 250]); + }) + .then(function () { + _assert( + 'after double click', + [[undefined, 0], [undefined, undefined], undefined], + [[-180, -0], [350, 260], [0, 0], 114.59], + 'dblclick' + ); + }) + .then(done, done.fail); }); }); - describe('should work for clipped projections', function() { + describe('should work for clipped projections', function () { var fig; - beforeEach(function() { + beforeEach(function () { fig = Lib.extendDeep({}, require('../../image/mocks/geo_orthographic')); fig.layout.dragmode = 'pan'; @@ -2317,126 +2521,130 @@ describe('Test geo zoom/pan/drag interactions:', function() { assertEventData(msg, eventKeys); } - it('- base case', function(done) { - newPlot(fig).then(function() { - _assert('base', [ - [-75, 45], 1 - ], [ - [75, -45], 160 - ], undefined); - return drag({path: [[250, 250], [300, 250]], noCover: true}); - }) - .then(function() { - _assert('after east-west drag', [ - [-103.7, 49.3], 1 - ], [ - [103.7, -49.3], 160 - ], [ - 'geo.projection.rotation.lon', 'geo.projection.rotation.lat' - ]); - return drag({path: [[250, 250], [300, 300]], noCover: true}); - }) - .then(function() { - _assert('after NW-SE drag', [ - [-135.5, 73.8], 1 - ], [ - [135.5, -73.8], 160 - ], [ - 'geo.projection.rotation.lon', 'geo.projection.rotation.lat' - ]); - return scroll([300, 300], [-200, -200]); - }) - .then(function() { - _assert('after scroll', [ - [-126.2, 67.1], 1.3 - ], [ - [126.2, -67.1], 211.1 - ], [ - 'geo.projection.rotation.lon', 'geo.projection.rotation.lat', - 'geo.projection.scale' - ]); - return Plotly.relayout(gd, 'geo.showocean', false); - }) - .then(function() { - _assert('after some relayout call that causes a replot', [ - [-126.2, 67.1], 1.3 - ], [ - [126.2, -67.1], 211.1 - ], [ - 'geo.showocean' - ]); - return dblClick([350, 250]); - }) - .then(function() { - // resets to initial view - _assert('after double click', [ - [-75, 45], 1 - ], [ - [75, -45], 160 - ], 'dblclick'); - }) - .then(done, done.fail); + it('- base case', function (done) { + newPlot(fig) + .then(function () { + _assert('base', [[-75, 45], 1], [[75, -45], 160], undefined); + return drag({ + path: [ + [250, 250], + [300, 250] + ], + noCover: true + }); + }) + .then(function () { + _assert( + 'after east-west drag', + [[-103.7, 49.3], 1], + [[103.7, -49.3], 160], + ['geo.projection.rotation.lon', 'geo.projection.rotation.lat'] + ); + return drag({ + path: [ + [250, 250], + [300, 300] + ], + noCover: true + }); + }) + .then(function () { + _assert( + 'after NW-SE drag', + [[-135.5, 73.8], 1], + [[135.5, -73.8], 160], + ['geo.projection.rotation.lon', 'geo.projection.rotation.lat'] + ); + return scroll([300, 300], [-200, -200]); + }) + .then(function () { + _assert( + 'after scroll', + [[-126.2, 67.1], 1.3], + [[126.2, -67.1], 211.1], + ['geo.projection.rotation.lon', 'geo.projection.rotation.lat', 'geo.projection.scale'] + ); + return Plotly.relayout(gd, 'geo.showocean', false); + }) + .then(function () { + _assert( + 'after some relayout call that causes a replot', + [[-126.2, 67.1], 1.3], + [[126.2, -67.1], 211.1], + ['geo.showocean'] + ); + return dblClick([350, 250]); + }) + .then(function () { + // resets to initial view + _assert('after double click', [[-75, 45], 1], [[75, -45], 160], 'dblclick'); + }) + .then(done, done.fail); }); - it('- fitbounds case', function(done) { + it('- fitbounds case', function (done) { fig.layout.geo.fitbounds = 'locations'; - newPlot(fig).then(function() { - _assert('base', [ - [undefined, undefined], undefined - ], [ - [0.252, -19.8], 160 - ], undefined); - return drag({path: [[250, 250], [300, 250]], noCover: true}); - }) - .then(function() { - _assert('after east-west drag', [ - [-20.32, 21.226], 1 - ], [ - [20.32, -21.226], 160 - ], [ - 'geo.projection.rotation.lon', 'geo.projection.rotation.lat', - 'geo.projection.scale', 'geo.fitbounds' - ]); - return scroll([300, 300], [-100, -100]); - }) - .then(function() { - _assert('after scroll', [ - [-17.5597, 18.862], 1.1488 - ], [ - [17.5597, -18.862], 183.818 - ], [ - 'geo.projection.rotation.lon', 'geo.projection.rotation.lat', - 'geo.projection.scale' - ]); - return Plotly.relayout(gd, 'geo.showocean', false); - }) - .then(function() { - _assert('after some relayout call that causes a replot', [ - [-17.5597, 18.862], 1.1488 - ], [ - [17.5597, -18.862], 183.818 - ], [ - 'geo.showocean' - ]); - return dblClick([350, 250]); - }) - .then(function() { - // resets to initial view - _assert('after double click', [ - [undefined, undefined], undefined - ], [ - [0.252, -19.8], 160 - ], 'dblclick'); - }) - .then(done, done.fail); + newPlot(fig) + .then(function () { + _assert('base', [[undefined, undefined], undefined], [[0.252, -19.8], 160], undefined); + return drag({ + path: [ + [250, 250], + [300, 250] + ], + noCover: true + }); + }) + .then(function () { + _assert( + 'after east-west drag', + [[-20.32, 21.226], 1], + [[20.32, -21.226], 160], + [ + 'geo.projection.rotation.lon', + 'geo.projection.rotation.lat', + 'geo.projection.scale', + 'geo.fitbounds' + ] + ); + return scroll([300, 300], [-100, -100]); + }) + .then(function () { + _assert( + 'after scroll', + [[-17.5597, 18.862], 1.1488], + [[17.5597, -18.862], 183.818], + ['geo.projection.rotation.lon', 'geo.projection.rotation.lat', 'geo.projection.scale'] + ); + return Plotly.relayout(gd, 'geo.showocean', false); + }) + .then(function () { + _assert( + 'after some relayout call that causes a replot', + [[-17.5597, 18.862], 1.1488], + [[17.5597, -18.862], 183.818], + ['geo.showocean'] + ); + return dblClick([350, 250]); + }) + .then(function () { + // resets to initial view + _assert( + 'after double click', + [[undefined, undefined], undefined], + [[0.252, -19.8], 160], + 'dblclick' + ); + }) + .then(done, done.fail); }); }); - describe('should work for scoped projections', function() { + describe('should work for scoped projections', function () { var fig; - beforeEach(function() { + beforeEach(function () { fig = Lib.extendDeep({}, require('../../image/mocks/geo_europe-bubbles')); fig.layout.geo.resolution = 110; fig.layout.dragmode = 'pan'; @@ -2468,113 +2676,125 @@ describe('Test geo zoom/pan/drag interactions:', function() { assertEventData(msg, eventKeys); } - it('- base case', function(done) { - newPlot(fig).then(function() { - _assert('base', [ - [15, 57.5], 1, - ], [ - [247, 260], [0, 57.5], 292.2 - ], undefined); - return drag({path: [[250, 250], [200, 200]], noCover: true}); - }) - .then(function() { - _assert('after SW-NE drag', [ - [30.9, 46.2], 1 - ], [ - // changes translate(), but not center() - [197, 210], [0, 57.5], 292.2 - ], [ - 'geo.center.lon', 'geo.center.lon' - ]); - return scroll([300, 300], [-200, -200]); - }) - .then(function() { - _assert('after scroll', [ - [34.3, 43.6], 1.3 - ], [ - [164.1, 181.2], [0, 57.5], 385.5 - ], [ - 'geo.center.lon', 'geo.center.lon', 'geo.projection.scale' - ]); - return Plotly.relayout(gd, 'geo.showlakes', true); - }) - .then(function() { - _assert('after some relayout call that causes a replot', [ - [34.3, 43.6], 1.3 - ], [ - // changes are now reflected in 'center' - [247, 260], [19.3, 43.6], 385.5 - ], [ - 'geo.showlakes' - ]); - return dblClick([250, 250]); - }) - .then(function() { - _assert('after double click', [ - [15, 57.5], 1, - ], [ - [247, 260], [0, 57.5], 292.2 - ], 'dblclick'); - }) - .then(done, done.fail); + it('- base case', function (done) { + newPlot(fig) + .then(function () { + _assert('base', [[15, 57.5], 1], [[247, 260], [0, 57.5], 292.2], undefined); + return drag({ + path: [ + [250, 250], + [200, 200] + ], + noCover: true + }); + }) + .then(function () { + _assert( + 'after SW-NE drag', + [[30.9, 46.2], 1], + [ + // changes translate(), but not center() + [197, 210], + [0, 57.5], + 292.2 + ], + ['geo.center.lon', 'geo.center.lon'] + ); + return scroll([300, 300], [-200, -200]); + }) + .then(function () { + _assert( + 'after scroll', + [[34.3, 43.6], 1.3], + [[164.1, 181.2], [0, 57.5], 385.5], + ['geo.center.lon', 'geo.center.lon', 'geo.projection.scale'] + ); + return Plotly.relayout(gd, 'geo.showlakes', true); + }) + .then(function () { + _assert( + 'after some relayout call that causes a replot', + [[34.3, 43.6], 1.3], + [ + // changes are now reflected in 'center' + [247, 260], + [19.3, 43.6], + 385.5 + ], + ['geo.showlakes'] + ); + return dblClick([250, 250]); + }) + .then(function () { + _assert('after double click', [[15, 57.5], 1], [[247, 260], [0, 57.5], 292.2], 'dblclick'); + }) + .then(done, done.fail); }); - it('- fitbounds case', function(done) { + it('- fitbounds case', function (done) { fig.layout.geo.fitbounds = 'locations'; - newPlot(fig).then(function() { - _assert('base', [ - [undefined, undefined], undefined, - ], [ - [247, 260], [5.7998, 49.29], 504.8559 - ], undefined); - return drag({path: [[250, 250], [200, 200]], noCover: true}); - }) - .then(function() { - _assert('after SW-NE drag', [ - [29.059, 42.38], 1.727 - ], [ - [197, 210], [5.7988, 49.29], 504.8559 - ], [ - 'geo.center.lon', 'geo.center.lon', - 'geo.projection.scale', 'geo.fitbounds' - ]); - return scroll([300, 300], [-200, -200]); - }) - .then(function() { - _assert('after scroll', [ - [31.027, 40.91], 2.28 - ], [ - [164.09, 181.24], [5.7988, 49.29], 666.16 - ], [ - 'geo.center.lon', 'geo.center.lon', - 'geo.projection.scale' - ]); - return Plotly.relayout(gd, 'geo.showlakes', true); - }) - .then(function() { - _assert('after some relayout call that causes a replot', [ - [31.027, 40.91], 2.28 - ], [ - // changes are now reflected in 'center' - [247, 260], [16.027, 40.91], 666.16 - ], [ - 'geo.showlakes' - ]); - return dblClick([250, 250]); - }) - .then(function() { - _assert('after double click', [ - [undefined, undefined], undefined, - ], [ - [247, 260], [5.7998, 49.29], 504.8559 - ], 'dblclick'); - }) - .then(done, done.fail); + newPlot(fig) + .then(function () { + _assert( + 'base', + [[undefined, undefined], undefined], + [[247, 260], [5.7998, 49.29], 504.8559], + undefined + ); + return drag({ + path: [ + [250, 250], + [200, 200] + ], + noCover: true + }); + }) + .then(function () { + _assert( + 'after SW-NE drag', + [[29.059, 42.38], 1.727], + [[197, 210], [5.7988, 49.29], 504.8559], + ['geo.center.lon', 'geo.center.lon', 'geo.projection.scale', 'geo.fitbounds'] + ); + return scroll([300, 300], [-200, -200]); + }) + .then(function () { + _assert( + 'after scroll', + [[31.027, 40.91], 2.28], + [[164.09, 181.24], [5.7988, 49.29], 666.16], + ['geo.center.lon', 'geo.center.lon', 'geo.projection.scale'] + ); + return Plotly.relayout(gd, 'geo.showlakes', true); + }) + .then(function () { + _assert( + 'after some relayout call that causes a replot', + [[31.027, 40.91], 2.28], + [ + // changes are now reflected in 'center' + [247, 260], + [16.027, 40.91], + 666.16 + ], + ['geo.showlakes'] + ); + return dblClick([250, 250]); + }) + .then(function () { + _assert( + 'after double click', + [[undefined, undefined], undefined], + [[247, 260], [5.7998, 49.29], 504.8559], + 'dblclick' + ); + }) + .then(done, done.fail); }); }); - it('should work for *albers usa* projections', function(done) { + it('should work for *albers usa* projections', function (done) { var fig = Lib.extendDeep({}, require('../../image/mocks/geo_choropleth-usa')); fig.layout.dragmode = 'pan'; @@ -2604,56 +2824,55 @@ describe('Test geo zoom/pan/drag interactions:', function() { assertEventData(msg, eventKeys); } - newPlot(fig).then(function() { - _assert('base', [ - [-96.6, 38.7], 1, - ], [ - [410, 309], 738.5 - ], undefined); - return drag({path: [[250, 250], [200, 200]], noCover: true}); - }) - .then(function() { - _assert('after NW-SE drag', [ - [-91.8, 34.8], 1, - ], [ - [366, 259], 738.5 - ], [ - 'geo.center.lon', 'geo.center.lon' - ]); - return scroll([300, 300], [-200, -200]); - }) - .then(function() { - _assert('after scroll', [ - [-94.5, 35.0], 1.3 - ], [ - [380, 245.9], 974.4 - ], [ - 'geo.center.lon', 'geo.center.lon', 'geo.projection.scale' - ]); - return Plotly.relayout(gd, 'geo.showlakes', true); - }) - .then(function() { - _assert('after some relayout call that causes a replot', [ - [-94.5, 35.0], 1.3 - ], [ - // new center values are reflected in translate() - [380, 245.9], 974.4 - ], [ - 'geo.showlakes' - ]); - return dblClick([250, 250]); - }) - .then(function() { - _assert('after double click', [ - [-96.6, 38.7], 1, - ], [ - [416, 309], 738.5 - ], 'dblclick'); - }) - .then(done, done.fail); + newPlot(fig) + .then(function () { + _assert('base', [[-96.6, 38.7], 1], [[410, 309], 738.5], undefined); + return drag({ + path: [ + [250, 250], + [200, 200] + ], + noCover: true + }); + }) + .then(function () { + _assert( + 'after NW-SE drag', + [[-91.8, 34.8], 1], + [[366, 259], 738.5], + ['geo.center.lon', 'geo.center.lon'] + ); + return scroll([300, 300], [-200, -200]); + }) + .then(function () { + _assert( + 'after scroll', + [[-94.5, 35.0], 1.3], + [[380, 245.9], 974.4], + ['geo.center.lon', 'geo.center.lon', 'geo.projection.scale'] + ); + return Plotly.relayout(gd, 'geo.showlakes', true); + }) + .then(function () { + _assert( + 'after some relayout call that causes a replot', + [[-94.5, 35.0], 1.3], + [ + // new center values are reflected in translate() + [380, 245.9], + 974.4 + ], + ['geo.showlakes'] + ); + return dblClick([250, 250]); + }) + .then(function () { + _assert('after double click', [[-96.6, 38.7], 1], [[416, 309], 738.5], 'dblclick'); + }) + .then(done, done.fail); }); - it('should guard against undefined projection.invert result in some projections', function(done) { + it('should guard against undefined projection.invert result in some projections', function (done) { // e.g. aitoff var fig = Lib.extendDeep({}, require('../../image/mocks/geo_aitoff-sinusoidal.json')); fig.layout.dragmode = 'pan'; @@ -2663,16 +2882,18 @@ describe('Test geo zoom/pan/drag interactions:', function() { fig.layout.height = 500; newPlot(fig) - .then(function() { return scroll([131, 159], [-200, 200]); }) - .then(function() { - // scrolling outside subplot frame should log errors, - // nor emit events - expect(eventData).toBeUndefined(); - }) - .then(done, done.fail); + .then(function () { + return scroll([131, 159], [-200, 200]); + }) + .then(function () { + // scrolling outside subplot frame should log errors, + // nor emit events + expect(eventData).toBeUndefined(); + }) + .then(done, done.fail); }); - it('should respect scrollZoom config option', function(done) { + it('should respect scrollZoom config option', function (done) { var fig = Lib.extendDeep({}, require('../../image/mocks/geo_winkel-tripel')); fig.layout.width = 700; fig.layout.height = 500; @@ -2693,49 +2914,147 @@ describe('Test geo zoom/pan/drag interactions:', function() { } newPlot(fig) - .then(function() { - _assert('base', [1], [101.9], undefined); - }) - .then(function() { return scroll([200, 250], [-200, -200]); }) - .then(function() { - _assert('with scroll enable (by default)', - [1.3], [134.4], - ['geo.projection.rotation.lon', 'geo.center.lon', 'geo.center.lat', 'geo.projection.scale'] - ); - }) - .then(function() { - return newPlot({ - data: gd.data, - layout: gd.layout, - config: {scrollZoom: false} - }); - }) - .then(function() { return scroll([200, 250], [-200, -200]); }) - .then(function() { - _assert('with scrollZoom:false', [1.3], [134.4], undefined); - }) - .then(function() { - return newPlot({ - data: gd.data, - layout: gd.layout, - config: {scrollZoom: 'geo'} + .then(function () { + _assert('base', [1], [101.9], undefined); + }) + .then(function () { + return scroll([200, 250], [-200, -200]); + }) + .then(function () { + _assert( + 'with scroll enable (by default)', + [1.3], + [134.4], + ['geo.projection.rotation.lon', 'geo.center.lon', 'geo.center.lat', 'geo.projection.scale'] + ); + }) + .then(function () { + return newPlot({ + data: gd.data, + layout: gd.layout, + config: { scrollZoom: false } + }); + }) + .then(function () { + return scroll([200, 250], [-200, -200]); + }) + .then(function () { + _assert('with scrollZoom:false', [1.3], [134.4], undefined); + }) + .then(function () { + return newPlot({ + data: gd.data, + layout: gd.layout, + config: { scrollZoom: 'geo' } + }); + }) + .then(function () { + return scroll([200, 250], [-200, -200]); + }) + .then(function () { + _assert( + 'with scrollZoom:geo', + [1.74], + [177.34], + ['geo.projection.rotation.lon', 'geo.center.lon', 'geo.center.lat', 'geo.projection.scale'] + ); + }) + .then(done, done.fail); + }); + + describe('minscale and maxscale', () => { + const defaultConfig = { + layout: { + dragmode: 'pan', + geo: { projection: {} }, + height: 500, + width: 700 + } + }; + let gd; + + beforeEach(() => { + gd = createGraphDiv(); + }); + + afterEach(destroyGraphDiv); + + const allTests = [ + { name: 'non-clipped', mock: require('../../image/mocks/geo_winkel-tripel') }, + { name: 'clipped', mock: require('../../image/mocks/geo_orthographic') }, + { name: 'scoped', mock: require('../../image/mocks/geo_europe-bubbles') } + ]; + + allTests.forEach(({ name, mock }) => { + it(`${name} maxscale`, (done) => { + const fig = Lib.extendDeep({}, mock, defaultConfig); + fig.layout.geo.projection.maxscale = 1.2; + + Plotly.newPlot(gd, fig) + // Zoom in far enough to hit limit + .then(() => scroll([200, 250], [-200, -200])) + .then(() => { + const subplot = gd._fullLayout.geo._subplot; + const maxScale = subplot.projection.scaleExtent()[1]; + expect(subplot.projection.scale()).toEqual(maxScale); + expect(maxScale).toEqual(1.2 * subplot.fitScale); + }) + .then(done, done.fail); + }); + + it(`${name} minscale`, (done) => { + const fig = Lib.extendDeep({}, mock, defaultConfig); + fig.layout.geo.projection.minscale = 0.8; + + Plotly.newPlot(gd, fig) + // Zoom out far enough to hit limit + .then(() => scroll([200, 250], [1000, 1000])) + .then(() => { + const subplot = gd._fullLayout.geo._subplot; + const minScale = subplot.projection.scaleExtent()[0]; + expect(subplot.projection.scale()).toEqual(minScale); + expect(minScale).toEqual(0.8 * subplot.fitScale); + }) + .then(done, done.fail); + }); + + it(`${name} minscale greater than 1 clamps at init`, (done) => { + const fig = Lib.extendDeep({}, mock, defaultConfig); + fig.layout.geo.projection.minscale = 3; + + Plotly.newPlot(gd, fig) + // The limit should already be hit during plot creation + .then(() => { + const subplot = gd._fullLayout.geo._subplot; + const minScale = subplot.projection.scaleExtent()[0]; + expect(subplot.projection.scale()).toEqual(minScale); + }) + .then(done, done.fail); + }); + + it(`${name} maxscale less than 1 clamps at init`, (done) => { + const fig = Lib.extendDeep({}, mock, defaultConfig); + fig.layout.geo.projection.scale = 1; + fig.layout.geo.projection.maxscale = 0.5; + + Plotly.newPlot(gd, fig) + .then(() => { + const subplot = gd._fullLayout.geo._subplot; + const maxScale = subplot.projection.scaleExtent()[1]; + expect(subplot.projection.scale()).toEqual(maxScale); + }) + .then(done, done.fail); }); - }) - .then(function() { return scroll([200, 250], [-200, -200]); }) - .then(function() { - _assert('with scrollZoom:geo', - [1.74], [177.34], - ['geo.projection.rotation.lon', 'geo.center.lon', 'geo.center.lat', 'geo.projection.scale'] - ); - }) - .then(done, done.fail); + }); }); }); -describe('Test geo interactions update marker angles:', function() { +describe('Test geo interactions update marker angles:', function () { var gd; - beforeEach(function() { gd = createGraphDiv(); }); + beforeEach(function () { + gd = createGraphDiv(); + }); afterEach(destroyGraphDiv); @@ -2743,7 +3062,7 @@ describe('Test geo interactions update marker angles:', function() { return d3Select('.scattergeo .point').node().getAttribute('d'); } - it('update angles when panning', function(done) { + it('update angles when panning', function (done) { var fig = Lib.extendDeep({}, require('../../image/mocks/geo_conic-conformal')); fig.layout.width = 700; fig.layout.height = 500; @@ -2752,43 +3071,58 @@ describe('Test geo interactions update marker angles:', function() { var initialPath, newPath; Plotly.newPlot(gd, fig) - .then(function() { - initialPath = getPath(); - - return drag({path: [[300, 200], [350, 250], [400, 300]], noCover: true}); - }) - .then(function() { - newPath = getPath(); - expect(newPath).toEqual('M0,0L18.27769005891461,8.119485581627321L19.559475756661865,-4.174554841483899Z'); - - expect(newPath).not.toEqual(initialPath); - expect(newPath).toEqual('M0,0L18.27769005891461,8.119485581627321L19.559475756661865,-4.174554841483899Z'); - expect(initialPath).toEqual('M0,0L-1.5094067529528923,19.942960945008643L10.501042615957648,17.021401351764233Z'); - }) - .then(done, done.fail); + .then(function () { + initialPath = getPath(); + + return drag({ + path: [ + [300, 200], + [350, 250], + [400, 300] + ], + noCover: true + }); + }) + .then(function () { + newPath = getPath(); + expect(newPath).toEqual( + 'M0,0L18.27769005891461,8.119485581627321L19.559475756661865,-4.174554841483899Z' + ); + + expect(newPath).not.toEqual(initialPath); + expect(newPath).toEqual( + 'M0,0L18.27769005891461,8.119485581627321L19.559475756661865,-4.174554841483899Z' + ); + expect(initialPath).toEqual( + 'M0,0L-1.5094067529528923,19.942960945008643L10.501042615957648,17.021401351764233Z' + ); + }) + .then(done, done.fail); }); }); -describe('plotly_relayouting', function() { +describe('plotly_relayouting', function () { var gd; var events; var relayoutCnt; var relayoutEvent; - beforeEach(function() { gd = createGraphDiv(); }); + beforeEach(function () { + gd = createGraphDiv(); + }); afterEach(destroyGraphDiv); - var newPlot = function(fig) { + var newPlot = function (fig) { events = []; relayoutCnt = 0; - return Plotly.newPlot(gd, fig).then(function() { - gd.on('plotly_relayout', function(e) { + return Plotly.newPlot(gd, fig).then(function () { + gd.on('plotly_relayout', function (e) { relayoutCnt++; relayoutEvent = e; }); - gd.on('plotly_relayouting', function(e) { + gd.on('plotly_relayouting', function (e) { events.push(e); }); }); @@ -2799,27 +3133,33 @@ describe('plotly_relayouting', function() { clipped: require('../../image/mocks/geo_orthographic'), scoped: require('../../image/mocks/geo_europe-bubbles') }; - ['non-clipped', 'clipped', 'scoped'].forEach(function(zoomHandler) { - ['pan'].forEach(function(dragmode) { - it('should emit events on ' + dragmode + ' for ' + zoomHandler, function(done) { - var path = [[300, 300], [350, 300], [350, 400]]; + ['non-clipped', 'clipped', 'scoped'].forEach(function (zoomHandler) { + ['pan'].forEach(function (dragmode) { + it('should emit events on ' + dragmode + ' for ' + zoomHandler, function (done) { + var path = [ + [300, 300], + [350, 300], + [350, 400] + ]; var fig = Lib.extendDeep({}, mocks[zoomHandler]); fig.layout.dragmode = dragmode; fig.layout.width = 700; fig.layout.height = 500; newPlot(fig) - .then(function() { - return drag({path: path, noCover: true}); - }) - .then(function() { - expect(events.length).toEqual(path.length - 1); - expect(relayoutCnt).toEqual(1); - Object.keys(relayoutEvent).sort().forEach(function(key) { - expect(Object.keys(events[0])).toContain(key); - }); - }) - .then(done, done.fail); + .then(function () { + return drag({ path: path, noCover: true }); + }) + .then(function () { + expect(events.length).toEqual(path.length - 1); + expect(relayoutCnt).toEqual(1); + Object.keys(relayoutEvent) + .sort() + .forEach(function (key) { + expect(Object.keys(events[0])).toContain(key); + }); + }) + .then(done, done.fail); }); }); }); diff --git a/test/plot-schema.json b/test/plot-schema.json index 318c0578d92..6dd64944f3c 100644 --- a/test/plot-schema.json +++ b/test/plot-schema.json @@ -2387,6 +2387,20 @@ "valType": "number" }, "editType": "plot", + "maxscale": { + "description": "Sets the maximum zoom level of the map view, relative to `projection.scale`. A `maxscale` of *2* (200%) prevents the user from zooming in beyond twice the base zoom level. Defaults to *null* for no upper bound.", + "dflt": null, + "editType": "plot", + "min": 0, + "valType": "number" + }, + "minscale": { + "description": "Sets the minimum zoom level of the map view, relative to `projection.scale`. A `minscale` of *0.5* (50%) prevents the user from zooming out beyond half the base zoom level. The default of *0* imposes no lower bound.", + "dflt": 0, + "editType": "plot", + "min": 0, + "valType": "number" + }, "parallels": { "description": "For conic projection types only. Sets the parallels (tangent, secant) where the cone intersects the sphere.", "editType": "plot",