From 57307ee8ab72d4e8555e97af8168c30dfd9f0b8d Mon Sep 17 00:00:00 2001 From: Alexander Wunschik Date: Mon, 5 Oct 2020 17:15:12 +0200 Subject: [PATCH 01/15] feat(geo): support min/max scale limits fixes #5191 --- src/components/modebar/buttons.js | 10 ++++++++++ src/plots/geo/geo.js | 8 ++++++++ src/plots/geo/layout_attributes.js | 20 ++++++++++++++++++++ src/plots/geo/layout_defaults.js | 4 ++++ src/plots/geo/zoom.js | 1 + 5 files changed, 43 insertions(+) diff --git a/src/components/modebar/buttons.js b/src/components/modebar/buttons.js index 82c5dff9a48..ee5452beb55 100644 --- a/src/components/modebar/buttons.js +++ b/src/components/modebar/buttons.js @@ -543,8 +543,18 @@ function handleGeo(gd, ev) { if(attr === 'zoom') { var scale = geoLayout.projection.scale; + var minScale = geoLayout.projection.minScale; + var maxScale = geoLayout.projection.maxScale; + var newScale = (val === 'in') ? 2 * scale : 0.5 * scale; + // make sure the scale is within the min/max bounds + if(newScale > maxScale) { + newScale = maxScale; + } else if(newScale < minScale) { + newScale = minScale; + } + Registry.call('_guiRelayout', gd, id + '.projection.scale', newScale); } } diff --git a/src/plots/geo/geo.js b/src/plots/geo/geo.js index 797ab8373b6..1ad80e5e30f 100644 --- a/src/plots/geo/geo.js +++ b/src/plots/geo/geo.js @@ -709,6 +709,14 @@ function getProjection(geoLayout) { projection.precision(constants.precision); + // https://github.com/d3/d3-zoom/blob/master/README.md#zoom_scaleExtent + projection.scaleExtent = function() { + var minscale = projLayout.minscale; + var maxscale = projLayout.maxscale; + if(maxscale === -1) maxscale = Infinity; + return [100 * minscale, 100 * maxscale]; + }; + if(geoLayout._isSatellite) { projection.tilt(projLayout.tilt).distance(projLayout.distance); } diff --git a/src/plots/geo/layout_attributes.js b/src/plots/geo/layout_attributes.js index b94cca3bec5..bdfa0118219 100644 --- a/src/plots/geo/layout_attributes.js +++ b/src/plots/geo/layout_attributes.js @@ -177,6 +177,26 @@ var attrs = module.exports = overrideAll({ 'that fits the map\'s lon and lat ranges. ' ].join(' ') }, + minScale: { + valType: 'number', + min: 0, + dflt: 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, + dflt: Infinity, + 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..35f1fe804e3 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'); @@ -205,6 +207,8 @@ function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce, opts) { // clear attributes that will get auto-filled later if(fitBounds) { delete geoLayoutOut.projection.scale; + delete geoLayoutOut.projection.minScale; + delete geoLayoutOut.projection.maxScale; if(isScoped) { delete geoLayoutOut.center.lon; diff --git a/src/plots/geo/zoom.js b/src/plots/geo/zoom.js index 2d79d69f581..824ba39a0ef 100644 --- a/src/plots/geo/zoom.js +++ b/src/plots/geo/zoom.js @@ -32,6 +32,7 @@ module.exports = createGeoZoom; function initZoom(geo, projection) { return d3.behavior.zoom() .translate(projection.translate()) + .scaleExtent(projection.scaleExtent()) .scale(projection.scale()); } From d870252e3e2d2838f39b6ca996f704fba106fefe Mon Sep 17 00:00:00 2001 From: Alexander Wunschik Date: Mon, 26 Oct 2020 20:11:21 +0100 Subject: [PATCH 02/15] refactor: rename min/max scale limits --- src/components/modebar/buttons.js | 12 ++++++------ src/plots/geo/layout_attributes.js | 6 +++--- src/plots/geo/layout_defaults.js | 8 ++++---- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/components/modebar/buttons.js b/src/components/modebar/buttons.js index ee5452beb55..6a112726f22 100644 --- a/src/components/modebar/buttons.js +++ b/src/components/modebar/buttons.js @@ -543,16 +543,16 @@ function handleGeo(gd, ev) { if(attr === 'zoom') { var scale = geoLayout.projection.scale; - var minScale = geoLayout.projection.minScale; - var maxScale = geoLayout.projection.maxScale; + var minscale = geoLayout.projection.minscale; + var maxscale = geoLayout.projection.maxscale; var newScale = (val === 'in') ? 2 * scale : 0.5 * scale; // make sure the scale is within the min/max bounds - if(newScale > maxScale) { - newScale = maxScale; - } else if(newScale < minScale) { - newScale = minScale; + if(newScale > maxscale) { + newScale = maxscale; + } else if(newScale < minscale) { + newScale = minscale; } Registry.call('_guiRelayout', gd, id + '.projection.scale', newScale); diff --git a/src/plots/geo/layout_attributes.js b/src/plots/geo/layout_attributes.js index bdfa0118219..00421328516 100644 --- a/src/plots/geo/layout_attributes.js +++ b/src/plots/geo/layout_attributes.js @@ -177,7 +177,7 @@ var attrs = module.exports = overrideAll({ 'that fits the map\'s lon and lat ranges. ' ].join(' ') }, - minScale: { + minscale: { valType: 'number', min: 0, dflt: 0, @@ -187,10 +187,10 @@ var attrs = module.exports = overrideAll({ 'where the map has half the size of base zoom level.' ].join(' ') }, - maxScale: { + maxscale: { valType: 'number', min: 0, - dflt: Infinity, + dflt: null, description: [ 'Maximal zoom level of the map view.', 'A maxScale of *2* (200%) corresponds to a zoom level', diff --git a/src/plots/geo/layout_defaults.js b/src/plots/geo/layout_defaults.js index 35f1fe804e3..8848f5b7f05 100644 --- a/src/plots/geo/layout_defaults.js +++ b/src/plots/geo/layout_defaults.js @@ -161,8 +161,8 @@ function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce, opts) { } coerce('projection.scale'); - coerce('projection.minScale'); - coerce('projection.maxScale'); + coerce('projection.minscale'); + coerce('projection.maxscale'); show = coerce('showland', !visible ? false : undefined); if(show) coerce('landcolor'); @@ -207,8 +207,8 @@ function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce, opts) { // clear attributes that will get auto-filled later if(fitBounds) { delete geoLayoutOut.projection.scale; - delete geoLayoutOut.projection.minScale; - delete geoLayoutOut.projection.maxScale; + delete geoLayoutOut.projection.minscale; + delete geoLayoutOut.projection.maxscale; if(isScoped) { delete geoLayoutOut.center.lon; From 2d9e5f2726383a3cb6aa5dce4635733fd2d6f881 Mon Sep 17 00:00:00 2001 From: Alexander Wunschik Date: Mon, 26 Oct 2020 20:43:18 +0100 Subject: [PATCH 03/15] chore: use dflt: -1 instad of null --- src/components/modebar/buttons.js | 1 + src/plots/geo/layout_attributes.js | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/modebar/buttons.js b/src/components/modebar/buttons.js index 6a112726f22..b0340856f68 100644 --- a/src/components/modebar/buttons.js +++ b/src/components/modebar/buttons.js @@ -546,6 +546,7 @@ function handleGeo(gd, ev) { var minscale = geoLayout.projection.minscale; var maxscale = geoLayout.projection.maxscale; + if(maxscale < 0) maxscale = Infinity; var newScale = (val === 'in') ? 2 * scale : 0.5 * scale; // make sure the scale is within the min/max bounds diff --git a/src/plots/geo/layout_attributes.js b/src/plots/geo/layout_attributes.js index 00421328516..69a49acaa44 100644 --- a/src/plots/geo/layout_attributes.js +++ b/src/plots/geo/layout_attributes.js @@ -190,7 +190,7 @@ var attrs = module.exports = overrideAll({ maxscale: { valType: 'number', min: 0, - dflt: null, + dflt: -1, description: [ 'Maximal zoom level of the map view.', 'A maxScale of *2* (200%) corresponds to a zoom level', From dd635ee36c1435fb50a1839348f546721bd1192a Mon Sep 17 00:00:00 2001 From: Alexander Wunschik Date: Mon, 26 Oct 2020 21:26:40 +0100 Subject: [PATCH 04/15] feat: handle maxscale default in scaleExtent --- src/components/modebar/buttons.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/modebar/buttons.js b/src/components/modebar/buttons.js index b0340856f68..6d7930063cc 100644 --- a/src/components/modebar/buttons.js +++ b/src/components/modebar/buttons.js @@ -546,7 +546,7 @@ function handleGeo(gd, ev) { var minscale = geoLayout.projection.minscale; var maxscale = geoLayout.projection.maxscale; - if(maxscale < 0) maxscale = Infinity; + if(maxscale === -1) maxscale = Infinity; var newScale = (val === 'in') ? 2 * scale : 0.5 * scale; // make sure the scale is within the min/max bounds From 8a2c0c557b6a96f55be47fe3395ffe07e0030129 Mon Sep 17 00:00:00 2001 From: Alexander Wunschik Date: Mon, 26 Oct 2020 22:39:43 +0100 Subject: [PATCH 05/15] chore: only rerender on zoom if scale changes --- src/components/modebar/buttons.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/modebar/buttons.js b/src/components/modebar/buttons.js index 6d7930063cc..2bf8ad6b940 100644 --- a/src/components/modebar/buttons.js +++ b/src/components/modebar/buttons.js @@ -556,7 +556,9 @@ function handleGeo(gd, ev) { newScale = minscale; } - Registry.call('_guiRelayout', gd, id + '.projection.scale', newScale); + if(newScale !== scale) { + Registry.call('_guiRelayout', gd, id + '.projection.scale', newScale); + } } } From f882191bfcbdd15d91cac7206e146978fbff5d43 Mon Sep 17 00:00:00 2001 From: Alexander Wunschik Date: Mon, 26 Oct 2020 22:40:26 +0100 Subject: [PATCH 06/15] chore: fix typo in min/maxscale attributes --- src/plots/geo/layout_attributes.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plots/geo/layout_attributes.js b/src/plots/geo/layout_attributes.js index 69a49acaa44..aa9e6e59acc 100644 --- a/src/plots/geo/layout_attributes.js +++ b/src/plots/geo/layout_attributes.js @@ -183,7 +183,7 @@ var attrs = module.exports = overrideAll({ dflt: 0, description: [ 'Minimal zoom level of the map view.', - 'A minScale of *0.5* (50%) corresponds to a zoom level', + 'A minscale of *0.5* (50%) corresponds to a zoom level', 'where the map has half the size of base zoom level.' ].join(' ') }, @@ -193,7 +193,7 @@ var attrs = module.exports = overrideAll({ dflt: -1, description: [ 'Maximal zoom level of the map view.', - 'A maxScale of *2* (200%) corresponds to a zoom level', + 'A maxscale of *2* (200%) corresponds to a zoom level', 'where the map is twice as big as the base layer.' ].join(' ') }, From 8ed949fe4cf76028c12435f624e9947b040b12a2 Mon Sep 17 00:00:00 2001 From: Alexander Wunschik Date: Mon, 10 Jun 2024 23:52:30 +0200 Subject: [PATCH 07/15] test: adopt test/plot-schema.json to new plot-schema --- test/plot-schema.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test/plot-schema.json b/test/plot-schema.json index 6aa77cf3338..3eca5b100d7 100644 --- a/test/plot-schema.json +++ b/test/plot-schema.json @@ -2373,6 +2373,20 @@ "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.", + "dflt": -1, + "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.", + "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", From 29b4316ed781bf93f17a9b34ff529e7acbfd2781 Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Tue, 18 Feb 2025 07:05:32 -0700 Subject: [PATCH 08/15] Trigger initial zoom event to set minscale --- src/plots/geo/geo.js | 21 +++++++++++++-------- src/plots/geo/zoom.js | 21 ++++++++++++++++----- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/src/plots/geo/geo.js b/src/plots/geo/geo.js index 1ad80e5e30f..1171ce5c722 100644 --- a/src/plots/geo/geo.js +++ b/src/plots/geo/geo.js @@ -484,7 +484,11 @@ proto.updateFx = function(fullLayout, geoLayout) { if(dragMode === 'pan') { bgRect.node().onmousedown = null; - bgRect.call(createGeoZoom(_this, geoLayout)); + const zoom = createGeoZoom(_this, geoLayout) + bgRect.call(zoom); + // TODO: Figure out how to restrict when this transition occurs. Or is it a no-op if nothing has changed? + // Trigger transition to handle if minscale attribute isn't 0 + zoom.event(bgRect.transition()) bgRect.on('dblclick.zoom', zoomReset); if(!gd._context._scrollZoom.geo) { bgRect.on('wheel.zoom', null); @@ -709,14 +713,15 @@ function getProjection(geoLayout) { projection.precision(constants.precision); - // https://github.com/d3/d3-zoom/blob/master/README.md#zoom_scaleExtent - projection.scaleExtent = function() { - var minscale = projLayout.minscale; - var maxscale = projLayout.maxscale; - if(maxscale === -1) maxscale = Infinity; - return [100 * minscale, 100 * maxscale]; + // https://d3js.org/d3-zoom#zoom_scaleExtent + projection.scaleExtent = () => { + let { minscale, maxscale } = projLayout; + maxscale = maxscale === -1 ? Infinity : maxscale; + const max = Math.max(minscale, maxscale); + const min = Math.min(minscale, maxscale); + return [100 * min, 100 * max]; }; - + if(geoLayout._isSatellite) { projection.tilt(projLayout.tilt).distance(projLayout.distance); } diff --git a/src/plots/geo/zoom.js b/src/plots/geo/zoom.js index 824ba39a0ef..f5768063fc3 100644 --- a/src/plots/geo/zoom.js +++ b/src/plots/geo/zoom.js @@ -133,7 +133,10 @@ function zoomNonClipped(geo, projection) { function handleZoomstart() { d3.select(this).style(zoomstartStyle); - mouse0 = d3.mouse(this); + const { bottom, left, right, top } = this.getBoundingClientRect() + mouse0 = d3.event.sourceEvent + ? d3.mouse(this) + : [(left + right) / 2, (bottom + top) / 2]; rotate0 = projection.rotate(); translate0 = projection.translate(); lastRotate = rotate0; @@ -141,8 +144,10 @@ function zoomNonClipped(geo, projection) { } function handleZoom() { - mouse1 = d3.mouse(this); - + const { bottom, left, right, top } = this.getBoundingClientRect() + mouse1 = d3.event.sourceEvent + ? d3.mouse(this) + : [(left + right) / 2, (bottom + top) / 2]; if(outside(mouse0)) { zoom.scale(projection.scale()); zoom.translate(projection.translate()); @@ -211,7 +216,10 @@ function zoomClipped(geo, projection) { zoom.on('zoomstart', function() { d3.select(this).style(zoomstartStyle); - var mouse0 = d3.mouse(this); + const { bottom, left, right, top } = this.getBoundingClientRect() + let mouse0 = d3.event.sourceEvent + ? d3.mouse(this) + : [(left + right) / 2, (bottom + top) / 2]; var rotate0 = projection.rotate(); var lastRotate = rotate0; var translate0 = projection.translate(); @@ -220,7 +228,10 @@ function zoomClipped(geo, projection) { zoomPoint = position(projection, mouse0); zoomOn.call(zoom, 'zoom', function() { - var mouse1 = d3.mouse(this); + const { bottom, left, right, top } = this.getBoundingClientRect() + let mouse1 = d3.event.sourceEvent + ? d3.mouse(this) + : [(left + right) / 2, (bottom + top) / 2]; projection.scale(view.k = d3.event.scale); From 048f1786e5781208b4e118a27e8826482b3d71d3 Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Fri, 21 Feb 2025 06:52:51 -0700 Subject: [PATCH 09/15] Switch to ES5 syntax --- src/components/modebar/buttons.js | 16 ++++++++-------- src/plots/geo/geo.js | 10 +++++----- src/plots/geo/zoom.js | 20 ++++++++++---------- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/components/modebar/buttons.js b/src/components/modebar/buttons.js index 2bf8ad6b940..8ebe360ebc0 100644 --- a/src/components/modebar/buttons.js +++ b/src/components/modebar/buttons.js @@ -544,19 +544,19 @@ function handleGeo(gd, ev) { if(attr === 'zoom') { var scale = geoLayout.projection.scale; var minscale = geoLayout.projection.minscale; - var maxscale = geoLayout.projection.maxscale; - - if(maxscale === -1) maxscale = Infinity; + var maxscale = geoLayout.projection.maxscale === -1 ? Infinity : geoLayout.projection.maxscale; + var max = Math.max(minscale, maxscale); + var min = Math.min(minscale, maxscale); var newScale = (val === 'in') ? 2 * scale : 0.5 * scale; // make sure the scale is within the min/max bounds - if(newScale > maxscale) { - newScale = maxscale; - } else if(newScale < minscale) { - newScale = minscale; + if (newScale > max) { + newScale = max; + } else if (newScale < min) { + newScale = min; } - if(newScale !== scale) { + if (newScale !== scale) { Registry.call('_guiRelayout', gd, id + '.projection.scale', newScale); } } diff --git a/src/plots/geo/geo.js b/src/plots/geo/geo.js index 1171ce5c722..555f031ca11 100644 --- a/src/plots/geo/geo.js +++ b/src/plots/geo/geo.js @@ -484,7 +484,7 @@ proto.updateFx = function(fullLayout, geoLayout) { if(dragMode === 'pan') { bgRect.node().onmousedown = null; - const zoom = createGeoZoom(_this, geoLayout) + var zoom = createGeoZoom(_this, geoLayout) bgRect.call(zoom); // TODO: Figure out how to restrict when this transition occurs. Or is it a no-op if nothing has changed? // Trigger transition to handle if minscale attribute isn't 0 @@ -715,10 +715,10 @@ function getProjection(geoLayout) { // https://d3js.org/d3-zoom#zoom_scaleExtent projection.scaleExtent = () => { - let { minscale, maxscale } = projLayout; - maxscale = maxscale === -1 ? Infinity : maxscale; - const max = Math.max(minscale, maxscale); - const min = Math.min(minscale, maxscale); + var minscale = projLayout.minscale; + var maxscale = projLayout.maxscale === -1 ? Infinity : projLayout.maxscale; + var max = Math.max(minscale, maxscale); + var min = Math.min(minscale, maxscale); return [100 * min, 100 * max]; }; diff --git a/src/plots/geo/zoom.js b/src/plots/geo/zoom.js index f5768063fc3..2013b6a7eae 100644 --- a/src/plots/geo/zoom.js +++ b/src/plots/geo/zoom.js @@ -133,10 +133,10 @@ function zoomNonClipped(geo, projection) { function handleZoomstart() { d3.select(this).style(zoomstartStyle); - const { bottom, left, right, top } = this.getBoundingClientRect() + var rect = this.getBoundingClientRect() mouse0 = d3.event.sourceEvent ? d3.mouse(this) - : [(left + right) / 2, (bottom + top) / 2]; + : [(rect.left + rect.right) / 2, (rect.bottom + rect.top) / 2]; rotate0 = projection.rotate(); translate0 = projection.translate(); lastRotate = rotate0; @@ -144,10 +144,10 @@ function zoomNonClipped(geo, projection) { } function handleZoom() { - const { bottom, left, right, top } = this.getBoundingClientRect() + var rect = this.getBoundingClientRect() mouse1 = d3.event.sourceEvent ? d3.mouse(this) - : [(left + right) / 2, (bottom + top) / 2]; + : [(rect.left + rect.right) / 2, (rect.bottom + rect.top) / 2]; if(outside(mouse0)) { zoom.scale(projection.scale()); zoom.translate(projection.translate()); @@ -216,10 +216,10 @@ function zoomClipped(geo, projection) { zoom.on('zoomstart', function() { d3.select(this).style(zoomstartStyle); - const { bottom, left, right, top } = this.getBoundingClientRect() - let mouse0 = d3.event.sourceEvent + var rect = this.getBoundingClientRect() + var mouse0 = d3.event.sourceEvent ? d3.mouse(this) - : [(left + right) / 2, (bottom + top) / 2]; + : [(rect.left + rect.right) / 2, (rect.bottom + rect.top) / 2]; var rotate0 = projection.rotate(); var lastRotate = rotate0; var translate0 = projection.translate(); @@ -228,10 +228,10 @@ function zoomClipped(geo, projection) { zoomPoint = position(projection, mouse0); zoomOn.call(zoom, 'zoom', function() { - const { bottom, left, right, top } = this.getBoundingClientRect() - let mouse1 = d3.event.sourceEvent + var rect = this.getBoundingClientRect() + var mouse1 = d3.event.sourceEvent ? d3.mouse(this) - : [(left + right) / 2, (bottom + top) / 2]; + : [(rect.left + rect.right) / 2, (rect.bottom + rect.top) / 2]; projection.scale(view.k = d3.event.scale); From 84cd037acf7138f9a6fa684626dc2acf6c57ec9c Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Sat, 22 Feb 2025 10:30:52 -0700 Subject: [PATCH 10/15] Switch to getBBox and fix zoom.event call --- src/plots/geo/geo.js | 2 +- src/plots/geo/zoom.js | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/plots/geo/geo.js b/src/plots/geo/geo.js index 555f031ca11..d3b7ee4ec13 100644 --- a/src/plots/geo/geo.js +++ b/src/plots/geo/geo.js @@ -488,7 +488,7 @@ proto.updateFx = function(fullLayout, geoLayout) { bgRect.call(zoom); // TODO: Figure out how to restrict when this transition occurs. Or is it a no-op if nothing has changed? // Trigger transition to handle if minscale attribute isn't 0 - zoom.event(bgRect.transition()) + zoom.event(bgRect) bgRect.on('dblclick.zoom', zoomReset); if(!gd._context._scrollZoom.geo) { bgRect.on('wheel.zoom', null); diff --git a/src/plots/geo/zoom.js b/src/plots/geo/zoom.js index 2013b6a7eae..6cfe1e8f0df 100644 --- a/src/plots/geo/zoom.js +++ b/src/plots/geo/zoom.js @@ -133,10 +133,10 @@ function zoomNonClipped(geo, projection) { function handleZoomstart() { d3.select(this).style(zoomstartStyle); - var rect = this.getBoundingClientRect() + var rect = this.getBBox() mouse0 = d3.event.sourceEvent ? d3.mouse(this) - : [(rect.left + rect.right) / 2, (rect.bottom + rect.top) / 2]; + : [rect.x + rect.width / 2, rect.y + rect.height / 2]; rotate0 = projection.rotate(); translate0 = projection.translate(); lastRotate = rotate0; @@ -144,10 +144,10 @@ function zoomNonClipped(geo, projection) { } function handleZoom() { - var rect = this.getBoundingClientRect() + var rect = this.getBBox() mouse1 = d3.event.sourceEvent ? d3.mouse(this) - : [(rect.left + rect.right) / 2, (rect.bottom + rect.top) / 2]; + : [rect.x + rect.width / 2, rect.y + rect.height / 2]; if(outside(mouse0)) { zoom.scale(projection.scale()); zoom.translate(projection.translate()); @@ -216,10 +216,10 @@ function zoomClipped(geo, projection) { zoom.on('zoomstart', function() { d3.select(this).style(zoomstartStyle); - var rect = this.getBoundingClientRect() + var rect = this.getBBox() var mouse0 = d3.event.sourceEvent ? d3.mouse(this) - : [(rect.left + rect.right) / 2, (rect.bottom + rect.top) / 2]; + : [rect.x + rect.width / 2, rect.y + rect.height / 2]; var rotate0 = projection.rotate(); var lastRotate = rotate0; var translate0 = projection.translate(); @@ -228,10 +228,10 @@ function zoomClipped(geo, projection) { zoomPoint = position(projection, mouse0); zoomOn.call(zoom, 'zoom', function() { - var rect = this.getBoundingClientRect() + var rect = this.getBBox() var mouse1 = d3.event.sourceEvent ? d3.mouse(this) - : [(rect.left + rect.right) / 2, (rect.bottom + rect.top) / 2]; + : [rect.x + rect.width / 2, rect.y + rect.height / 2]; projection.scale(view.k = d3.event.scale); From d9fca8b2cd1117d78b13248a1cb477e2c3a89766 Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Sat, 22 Feb 2025 11:33:18 -0700 Subject: [PATCH 11/15] Add draft log --- draftlogs/7371_add.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 draftlogs/7371_add.md diff --git a/draftlogs/7371_add.md b/draftlogs/7371_add.md new file mode 100644 index 00000000000..f134f571ff9 --- /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)] From ee8597f865048f7e6ede3d9c7b8a8924d5dea105 Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Thu, 24 Jul 2025 14:56:42 -0600 Subject: [PATCH 12/15] Add event check before triggering zoom transition --- src/plots/geo/geo.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/plots/geo/geo.js b/src/plots/geo/geo.js index d3b7ee4ec13..fe69712c2ed 100644 --- a/src/plots/geo/geo.js +++ b/src/plots/geo/geo.js @@ -486,9 +486,8 @@ proto.updateFx = function(fullLayout, geoLayout) { bgRect.node().onmousedown = null; var zoom = createGeoZoom(_this, geoLayout) bgRect.call(zoom); - // TODO: Figure out how to restrict when this transition occurs. Or is it a no-op if nothing has changed? - // Trigger transition to handle if minscale attribute isn't 0 - zoom.event(bgRect) + // Trigger zoom transition to account for min/max scale values + if (geoLayout.projection.minscale > 0 && !d3.event) zoom.event(bgRect); bgRect.on('dblclick.zoom', zoomReset); if(!gd._context._scrollZoom.geo) { bgRect.on('wheel.zoom', null); From 9b047376b54ff7e3f08ec463f46ba83373c70cca Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Thu, 24 Jul 2025 17:19:52 -0600 Subject: [PATCH 13/15] Don't delete default scales for fitbounds --- src/plots/geo/layout_defaults.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/plots/geo/layout_defaults.js b/src/plots/geo/layout_defaults.js index 8848f5b7f05..088dbd3c8e5 100644 --- a/src/plots/geo/layout_defaults.js +++ b/src/plots/geo/layout_defaults.js @@ -207,8 +207,6 @@ function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce, opts) { // clear attributes that will get auto-filled later if(fitBounds) { delete geoLayoutOut.projection.scale; - delete geoLayoutOut.projection.minscale; - delete geoLayoutOut.projection.maxscale; if(isScoped) { delete geoLayoutOut.center.lon; From 32feb89fbdd8fa60ee9f13215fe02093c7c7219b Mon Sep 17 00:00:00 2001 From: Mojtaba Samimi Date: Mon, 21 Jul 2025 16:53:31 -0400 Subject: [PATCH 14/15] add tests for various cases --- test/jasmine/tests/geo_test.js | 78 ++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) 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); + }); + }); +}); From 65665fe91a0b7f7190098babb1165adbeccd279c Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Fri, 25 Jul 2025 10:45:33 -0600 Subject: [PATCH 15/15] Update tests to check scale against scaleExtent --- src/plots/geo/geo.js | 2 +- test/jasmine/tests/geo_test.js | 155 ++++++++++++++++----------------- 2 files changed, 78 insertions(+), 79 deletions(-) diff --git a/src/plots/geo/geo.js b/src/plots/geo/geo.js index fe69712c2ed..56a8e13a25a 100644 --- a/src/plots/geo/geo.js +++ b/src/plots/geo/geo.js @@ -486,7 +486,7 @@ proto.updateFx = function(fullLayout, geoLayout) { bgRect.node().onmousedown = null; var zoom = createGeoZoom(_this, geoLayout) bgRect.call(zoom); - // Trigger zoom transition to account for min/max scale values + // Trigger zoom transition to account for initial min/max scale values if (geoLayout.projection.minscale > 0 && !d3.event) zoom.event(bgRect); bgRect.on('dblclick.zoom', zoomReset); if(!gd._context._scrollZoom.geo) { diff --git a/test/jasmine/tests/geo_test.js b/test/jasmine/tests/geo_test.js index 43d08e3bc74..aeb4b516a7c 100644 --- a/test/jasmine/tests/geo_test.js +++ b/test/jasmine/tests/geo_test.js @@ -2730,6 +2730,83 @@ describe('Test geo zoom/pan/drag interactions:', function() { }) .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(({ mock, name }) => { + 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 maxScale = gd._fullLayout.geo._subplot.projection.scaleExtent()[1]; + expect(gd._fullLayout.geo._subplot.projection.scale()).toEqual(maxScale); + }) + .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 minScale = gd._fullLayout.geo._subplot.projection.scaleExtent()[0]; + expect(gd._fullLayout.geo._subplot.projection.scale()).toEqual(minScale); + }) + .then(done, done.fail); + }); + + it(`${name} minscale greater than 1`, 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 minScale = gd._fullLayout.geo._subplot.projection.scaleExtent()[0]; + expect(gd._fullLayout.geo._subplot.projection.scale()).toEqual(minScale); + }) + .then(done, done.fail); + }); + }); + }); }); describe('Test geo interactions update marker angles:', function() { @@ -2824,81 +2901,3 @@ 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); - }); - }); -});