diff --git a/src/plots/geo/geo.js b/src/plots/geo/geo.js index 797ab8373b6..53df11db0cb 100644 --- a/src/plots/geo/geo.js +++ b/src/plots/geo/geo.js @@ -213,6 +213,21 @@ proto.updateProjection = function(geoCalcData, fullLayout) { var projection = this.projection = getProjection(geoLayout); + projection.getScale = function() { + return projection.scale(); + }; + + projection.setScale = function(scale) { + this._initalScale = this._initalScale || projection.scale(); + + var minscale = projLayout.minscale; + var maxscale = projLayout.maxscale; + if(minscale !== undefined) scale = Math.max(minscale, scale / this._initalScale) * this._initalScale; + if(maxscale !== undefined) scale = Math.min(maxscale, scale / this._initalScale) * this._initalScale; + + return projection.scale(scale); + }; + // setup subplot extent [[x0,y0], [x1,y1]] var extent = [[ gs.l + gs.w * domain.x[0], @@ -265,7 +280,7 @@ proto.updateProjection = function(geoCalcData, fullLayout) { projection.fitExtent(extent, rangeBox); var b = this.bounds = projection.getBounds(rangeBox); - var s = this.fitScale = projection.scale(); + var s = this.fitScale = projection.getScale(); var t = projection.translate(); if(geoLayout.fitbounds) { @@ -276,13 +291,13 @@ proto.updateProjection = function(geoCalcData, fullLayout) { ); if(isFinite(k2)) { - projection.scale(k2 * s); + projection.setScale(k2 * s); } else { Lib.warn('Something went wrong during' + this.id + 'fitbounds computations.'); } } else { // adjust projection to user setting - projection.scale(projLayout.scale * s); + projection.setScale(projLayout.scale * s); } // px coordinates of view mid-point, diff --git a/src/plots/geo/layout_attributes.js b/src/plots/geo/layout_attributes.js index b94cca3bec5..977d8405414 100644 --- a/src/plots/geo/layout_attributes.js +++ b/src/plots/geo/layout_attributes.js @@ -177,6 +177,24 @@ var attrs = module.exports = overrideAll({ 'that fits the map\'s lon and lat ranges. ' ].join(' ') }, + minscale: { + valType: 'number', + min: 0, + description: [ + 'Minimal zoom level of the map view.', + 'A minscale of *0.5* (50%) corresponds to a zoom level', + 'where the map has half the size of base zoom level.' + ].join(' ') + }, + maxscale: { + valType: 'number', + min: 0, + description: [ + 'Maximal zoom level of the map view.', + 'A maxscale of *2* (200%) corresponds to a zoom level', + 'where the map is twice as big as the base layer.' + ].join(' ') + }, }, center: { lon: { diff --git a/src/plots/geo/layout_defaults.js b/src/plots/geo/layout_defaults.js index a5d64e2e9f8..16a520f66cb 100644 --- a/src/plots/geo/layout_defaults.js +++ b/src/plots/geo/layout_defaults.js @@ -160,7 +160,16 @@ function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce, opts) { coerce('projection.parallels', dfltProjParallels); } - coerce('projection.scale'); + var minscale = coerce('projection.minscale'); + var maxscale = coerce('projection.maxscale'); + if(minscale !== undefined && maxscale !== undefined && minscale > maxscale) { + geoLayoutOut.projection.minscale = minscale = undefined; + geoLayoutOut.projection.maxscale = maxscale = undefined; + } + + var scale = coerce('projection.scale'); + if(minscale !== undefined) geoLayoutOut.projection.scale = scale = Math.max(minscale, scale); + if(maxscale !== undefined) geoLayoutOut.projection.scale = scale = Math.min(maxscale, scale); 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..df9f6040817 100644 --- a/src/plots/geo/zoom.js +++ b/src/plots/geo/zoom.js @@ -32,7 +32,7 @@ module.exports = createGeoZoom; function initZoom(geo, projection) { return d3.behavior.zoom() .translate(projection.translate()) - .scale(projection.scale()); + .scale(projection.getScale()); } // sync zoom updates with user & full layout @@ -60,7 +60,7 @@ function sync(geo, projection, cb) { } cb(set); - set('projection.scale', projection.scale() / geo.fitScale); + set('projection.scale', projection.getScale() / geo.fitScale); set('fitbounds', false); gd.emit('plotly_relayout', eventData); } @@ -75,13 +75,13 @@ function zoomScoped(geo, projection) { function handleZoom() { projection - .scale(d3.event.scale) + .setScale(d3.event.scale) .translate(d3.event.translate); geo.render(true); var center = projection.invert(geo.midPt); geo.graphDiv.emit('plotly_relayouting', { - 'geo.projection.scale': projection.scale() / geo.fitScale, + 'geo.projection.scale': projection.getScale() / geo.fitScale, 'geo.center.lon': center[0], 'geo.center.lat': center[1] }); @@ -143,12 +143,12 @@ function zoomNonClipped(geo, projection) { mouse1 = d3.mouse(this); if(outside(mouse0)) { - zoom.scale(projection.scale()); + zoom.scale(projection.getScale()); zoom.translate(projection.translate()); return; } - projection.scale(d3.event.scale); + projection.setScale(d3.event.scale); projection.translate([translate0[0], d3.event.translate[1]]); if(!zoomPoint) { @@ -167,7 +167,7 @@ function zoomNonClipped(geo, projection) { var rotate = projection.rotate(); var center = projection.invert(geo.midPt); geo.graphDiv.emit('plotly_relayouting', { - 'geo.projection.scale': projection.scale() / geo.fitScale, + 'geo.projection.scale': projection.getScale() / geo.fitScale, 'geo.center.lon': center[0], 'geo.center.lat': center[1], 'geo.projection.rotation.lon': -rotate[0] @@ -199,7 +199,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.getScale()}; var zoom = initZoom(geo, projection); var event = d3eventDispatch(zoom, 'zoomstart', 'zoom', 'zoomend'); var zooming = 0; @@ -221,7 +221,8 @@ function zoomClipped(geo, projection) { zoomOn.call(zoom, 'zoom', function() { var mouse1 = d3.mouse(this); - projection.scale(view.k = d3.event.scale); + projection.setScale(d3.event.scale); + view.k = projection.getScale(); if(!zoomPoint) { // if no zoomPoint, the mouse wasn't over the actual geography yet @@ -272,7 +273,7 @@ function zoomClipped(geo, projection) { var _rotate = projection.rotate(); geo.graphDiv.emit('plotly_relayouting', { - 'geo.projection.scale': projection.scale() / geo.fitScale, + 'geo.projection.scale': projection.getScale() / geo.fitScale, 'geo.projection.rotation.lon': -_rotate[0], 'geo.projection.rotation.lat': -_rotate[1] }); diff --git a/test/jasmine/tests/geo_test.js b/test/jasmine/tests/geo_test.js index ee512d56b64..43d08e3bc74 100644 --- a/test/jasmine/tests/geo_test.js +++ b/test/jasmine/tests/geo_test.js @@ -2824,3 +2824,81 @@ describe('plotly_relayouting', function() { }); }); }); + + +describe('minscale and maxscale', function() { + function scroll(pos, delta) { + return new Promise(function(resolve) { + mouseEvent('mousemove', pos[0], pos[1]); + mouseEvent('scroll', pos[0], pos[1], {deltaX: delta[0], deltaY: delta[1]}); + setTimeout(resolve, 100); + }); + } + + var gd; + + beforeEach(function() { gd = createGraphDiv(); }); + + afterEach(destroyGraphDiv); + + var 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(function(test) { + it(test.name + ' maxscale', function(done) { + var fig = Lib.extendDeep({}, test.mock); + fig.layout.width = 700; + fig.layout.height = 500; + fig.layout.dragmode = 'pan'; + if(!fig.layout.geo.projection) fig.layout.geo.projection = {}; + fig.layout.geo.projection.maxscale = 1.2; + + var initialScale; + + Plotly.newPlot(gd, fig) + .then(function() { + initialScale = gd._fullLayout.geo._subplot.projection.scale(); + + return scroll([200, 250], [-200, -200]); + }) + .then(function() { + expect(gd._fullLayout.geo._subplot.projection.scale()).toEqual(1.2 * initialScale); + }) + .then(done, done.fail); + }); + + it(test.name + ' minscale', function(done) { + var fig = Lib.extendDeep({}, test.mock); + fig.layout.width = 700; + fig.layout.height = 500; + fig.layout.dragmode = 'pan'; + if(!fig.layout.geo.projection) fig.layout.geo.projection = {}; + fig.layout.geo.projection.minscale = 0.8; + + var initialScale; + + Plotly.newPlot(gd, fig) + .then(function() { + initialScale = gd._fullLayout.geo._subplot.projection.scale(); + + return scroll([200, 250], [200, 200]); + }) + .then(function() { + expect(gd._fullLayout.geo._subplot.projection.scale()).toEqual(0.8 * initialScale); + }) + .then(done, done.fail); + }); + }); +}); diff --git a/test/plot-schema.json b/test/plot-schema.json index 5e1c74f3793..fa71552db1c 100644 --- a/test/plot-schema.json +++ b/test/plot-schema.json @@ -2373,6 +2373,18 @@ "valType": "number" }, "editType": "plot", + "maxscale": { + "description": "Maximal zoom level of the map view. A maxscale of *2* (200%) corresponds to a zoom level where the map is twice as big as the base layer.", + "editType": "plot", + "min": 0, + "valType": "number" + }, + "minscale": { + "description": "Minimal zoom level of the map view. A minscale of *0.5* (50%) corresponds to a zoom level where the map has half the size of base zoom level.", + "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",