From 35e356eddee68df653af89b8b5c6d108a0cc8130 Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Thu, 9 Jul 2026 14:41:17 -0600 Subject: [PATCH 01/15] Linting/formatting --- src/plots/geo/layout_defaults.js | 65 ++++++++++++++++---------------- 1 file changed, 32 insertions(+), 33 deletions(-) diff --git a/src/plots/geo/layout_defaults.js b/src/plots/geo/layout_defaults.js index 2b02ce07de8..5d4cdabac4b 100644 --- a/src/plots/geo/layout_defaults.js +++ b/src/plots/geo/layout_defaults.js @@ -22,24 +22,26 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce, opts) { var subplotData = getSubplotData(opts.fullData, 'geo', opts.id); - var traceIndices = subplotData.map(function(t) { return t.index; }); + var traceIndices = subplotData.map(function (t) { + return t.index; + }); var resolution = coerce('resolution'); var scope = coerce('scope'); var scopeParams = constants.scopeDefaults[scope]; var projType = coerce('projection.type', scopeParams.projType); - var isAlbersUsa = geoLayoutOut._isAlbersUsa = projType === 'albers usa'; + var isAlbersUsa = (geoLayoutOut._isAlbersUsa = projType === 'albers usa'); // no other scopes are allowed for 'albers usa' projection - if(isAlbersUsa) scope = geoLayoutOut.scope = 'usa'; + if (isAlbersUsa) scope = geoLayoutOut.scope = 'usa'; - var isScoped = geoLayoutOut._isScoped = (scope !== 'world'); - var isSatellite = geoLayoutOut._isSatellite = projType === 'satellite'; - var isConic = geoLayoutOut._isConic = projType.indexOf('conic') !== -1 || projType === 'albers'; - var isClipped = geoLayoutOut._isClipped = !!constants.lonaxisSpan[projType]; + var isScoped = (geoLayoutOut._isScoped = scope !== 'world'); + var isSatellite = (geoLayoutOut._isSatellite = projType === 'satellite'); + var isConic = (geoLayoutOut._isConic = projType.indexOf('conic') !== -1 || projType === 'albers'); + var isClipped = (geoLayoutOut._isClipped = !!constants.lonaxisSpan[projType]); - if(geoLayoutIn.visible === false) { + if (geoLayoutIn.visible === false) { // should override template.layout.geo.show* - see issue 4482 // make a copy @@ -54,8 +56,8 @@ function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce, opts) { newTemplate.showocean = false; newTemplate.showrivers = false; newTemplate.showsubunits = false; - if(newTemplate.lonaxis) newTemplate.lonaxis.showgrid = false; - if(newTemplate.lataxis) newTemplate.lataxis.showgrid = false; + if (newTemplate.lonaxis) newTemplate.lonaxis.showgrid = false; + if (newTemplate.lataxis) newTemplate.lataxis.showgrid = false; // set ref to copy geoLayoutOut._template = newTemplate; @@ -63,20 +65,17 @@ function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce, opts) { var visible = coerce('visible'); var show; - for(var i = 0; i < axesNames.length; i++) { + for (var i = 0; i < axesNames.length; i++) { var axisName = axesNames[i]; var dtickDflt = [30, 10][i]; var rangeDflt; - if(isScoped) { + if (isScoped) { rangeDflt = scopeParams[axisName + 'Range']; } else { var dfltSpans = constants[axisName + 'Span']; var hSpan = (dfltSpans[projType] || dfltSpans['*']) / 2; - var rot = coerce( - 'projection.rotation.' + axisName.slice(0, 3), - scopeParams.projRotate[i] - ); + var rot = coerce('projection.rotation.' + axisName.slice(0, 3), scopeParams.projRotate[i]); rangeDflt = [rot - hSpan, rot + hSpan]; } @@ -85,7 +84,7 @@ function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce, opts) { coerce(axisName + '.dtick', dtickDflt); show = coerce(axisName + '.showgrid', !visible ? false : undefined); - if(show) { + if (show) { coerce(axisName + '.gridcolor'); coerce(axisName + '.gridwidth'); coerce(axisName + '.griddash'); @@ -114,7 +113,7 @@ function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce, opts) { var centerLon = (lon0 + lon1) / 2; var projLon; - if(!isAlbersUsa) { + if (!isAlbersUsa) { var dfltProjRotate = isScoped ? scopeParams.projRotate : [centerLon, 0, 0]; projLon = coerce('projection.rotation.lon', dfltProjRotate[0]); @@ -122,19 +121,19 @@ function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce, opts) { coerce('projection.rotation.roll', dfltProjRotate[2]); show = coerce('showcoastlines', !isScoped && visible); - if(show) { + if (show) { coerce('coastlinecolor'); coerce('coastlinewidth'); } show = coerce('showocean', !visible ? false : undefined); - if(show) coerce('oceancolor'); + if (show) coerce('oceancolor'); } var centerLonDflt; var centerLatDflt; - if(isAlbersUsa) { + if (isAlbersUsa) { // 'albers usa' does not have a 'center', // these values were found using via: // projection.invert([geoLayout.center.lon, geoLayoutIn.center.lat]) @@ -148,12 +147,12 @@ function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce, opts) { coerce('center.lon', centerLonDflt); coerce('center.lat', centerLatDflt); - if(isSatellite) { + if (isSatellite) { coerce('projection.tilt'); coerce('projection.distance'); } - if(isConic) { + if (isConic) { var dfltProjParallels = scopeParams.projParallels || [0, 60]; coerce('projection.parallels', dfltProjParallels); } @@ -163,24 +162,24 @@ function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce, opts) { coerce('projection.maxscale'); show = coerce('showland', !visible ? false : undefined); - if(show) coerce('landcolor'); + if (show) coerce('landcolor'); show = coerce('showlakes', !visible ? false : undefined); - if(show) coerce('lakecolor'); + if (show) coerce('lakecolor'); show = coerce('showrivers', !visible ? false : undefined); - if(show) { + if (show) { coerce('rivercolor'); coerce('riverwidth'); } show = coerce('showcountries', isScoped && scope !== 'usa' && visible); - if(show) { + if (show) { coerce('countrycolor'); coerce('countrywidth'); } - if(scope === 'usa' || (scope === 'north america' && resolution === 50)) { + if (scope === 'usa' || (scope === 'north america' && resolution === 50)) { // Only works for: // USA states at 110m // USA states + Canada provinces at 50m @@ -189,10 +188,10 @@ function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce, opts) { coerce('subunitwidth'); } - if(!isScoped) { + if (!isScoped) { // Does not work in non-world scopes show = coerce('showframe', visible); - if(show) { + if (show) { coerce('framecolor'); coerce('framewidth'); } @@ -203,13 +202,13 @@ function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce, opts) { var fitBounds = coerce('fitbounds'); // clear attributes that will get auto-filled later - if(fitBounds) { + if (fitBounds) { delete geoLayoutOut.projection.scale; - if(isScoped) { + if (isScoped) { delete geoLayoutOut.center.lon; delete geoLayoutOut.center.lat; - } else if(isClipped) { + } else if (isClipped) { delete geoLayoutOut.center.lon; delete geoLayoutOut.center.lat; delete geoLayoutOut.projection.rotation.lon; From 2d1a490169b36ae420e68a288e7c03bc2976ef5f Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Thu, 9 Jul 2026 15:09:08 -0600 Subject: [PATCH 02/15] Switch default fitbounds to 'locations' for geo traces --- src/plots/geo/layout_attributes.js | 8 ++++---- src/types/generated/schema.d.ts | 4 ++-- test/plot-schema.json | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/plots/geo/layout_attributes.js b/src/plots/geo/layout_attributes.js index 287c8304018..59ee7b3c65d 100644 --- a/src/plots/geo/layout_attributes.js +++ b/src/plots/geo/layout_attributes.js @@ -57,7 +57,7 @@ var attrs = (module.exports = overrideAll( fitbounds: { valType: 'enumerated', values: [false, 'locations', 'geojson'], - dflt: false, + dflt: 'locations', editType: 'plot', description: [ "Determines if this subplot's view settings are auto-computed to fit trace data.", @@ -74,9 +74,9 @@ var attrs = (module.exports = overrideAll( // TODO we should auto-fill `projection.parallels` for maps // with conic projection, but how? - "If *locations*, only the trace's visible locations are considered in the `fitbounds` computations.", - 'If *geojson*, the entire trace input `geojson` (if provided) is considered in the `fitbounds` computations,', - 'Defaults to *false*.' + "If *locations* (default), only the trace's visible locations are considered in the `fitbounds` computations.", + 'If *geojson*, the entire trace input `geojson` (if provided) is considered in the `fitbounds` computations.', + 'If *false*, the view settings are used as-is; set this to opt out of auto-fitting.' ].join(' ') }, diff --git a/src/types/generated/schema.d.ts b/src/types/generated/schema.d.ts index 92fdc3e79d7..3f4d96b013e 100644 --- a/src/types/generated/schema.d.ts +++ b/src/types/generated/schema.d.ts @@ -11694,8 +11694,8 @@ export interface GeoLayout { countrywidth?: number; domain?: Domain; /** - * Determines if this subplot's view settings are auto-computed to fit trace data. On scoped maps, setting `fitbounds` leads to `center.lon` and `center.lat` getting auto-filled. On maps with a non-clipped projection, setting `fitbounds` leads to `center.lon`, `center.lat`, and `projection.rotation.lon` getting auto-filled. On maps with a clipped projection, setting `fitbounds` leads to `center.lon`, `center.lat`, `projection.rotation.lon`, `projection.rotation.lat`, `lonaxis.range` and `lataxis.range` getting auto-filled. If *locations*, only the trace's visible locations are considered in the `fitbounds` computations. If *geojson*, the entire trace input `geojson` (if provided) is considered in the `fitbounds` computations, Defaults to *false*. - * @default false + * Determines if this subplot's view settings are auto-computed to fit trace data. On scoped maps, setting `fitbounds` leads to `center.lon` and `center.lat` getting auto-filled. On maps with a non-clipped projection, setting `fitbounds` leads to `center.lon`, `center.lat`, and `projection.rotation.lon` getting auto-filled. On maps with a clipped projection, setting `fitbounds` leads to `center.lon`, `center.lat`, `projection.rotation.lon`, `projection.rotation.lat`, `lonaxis.range` and `lataxis.range` getting auto-filled. If *locations* (default), only the trace's visible locations are considered in the `fitbounds` computations. If *geojson*, the entire trace input `geojson` (if provided) is considered in the `fitbounds` computations. If *false*, the view settings are used as-is; set this to opt out of auto-fitting. + * @default 'locations' */ fitbounds?: false | 'locations' | 'geojson'; /** diff --git a/test/plot-schema.json b/test/plot-schema.json index f4286815758..0565b9539bf 100644 --- a/test/plot-schema.json +++ b/test/plot-schema.json @@ -2169,8 +2169,8 @@ }, "editType": "plot", "fitbounds": { - "description": "Determines if this subplot's view settings are auto-computed to fit trace data. On scoped maps, setting `fitbounds` leads to `center.lon` and `center.lat` getting auto-filled. On maps with a non-clipped projection, setting `fitbounds` leads to `center.lon`, `center.lat`, and `projection.rotation.lon` getting auto-filled. On maps with a clipped projection, setting `fitbounds` leads to `center.lon`, `center.lat`, `projection.rotation.lon`, `projection.rotation.lat`, `lonaxis.range` and `lataxis.range` getting auto-filled. If *locations*, only the trace's visible locations are considered in the `fitbounds` computations. If *geojson*, the entire trace input `geojson` (if provided) is considered in the `fitbounds` computations, Defaults to *false*.", - "dflt": false, + "description": "Determines if this subplot's view settings are auto-computed to fit trace data. On scoped maps, setting `fitbounds` leads to `center.lon` and `center.lat` getting auto-filled. On maps with a non-clipped projection, setting `fitbounds` leads to `center.lon`, `center.lat`, and `projection.rotation.lon` getting auto-filled. On maps with a clipped projection, setting `fitbounds` leads to `center.lon`, `center.lat`, `projection.rotation.lon`, `projection.rotation.lat`, `lonaxis.range` and `lataxis.range` getting auto-filled. If *locations* (default), only the trace's visible locations are considered in the `fitbounds` computations. If *geojson*, the entire trace input `geojson` (if provided) is considered in the `fitbounds` computations. If *false*, the view settings are used as-is; set this to opt out of auto-fitting.", + "dflt": "locations", "editType": "plot", "valType": "enumerated", "values": [ From 03c1e43eff9acb43cc6870b4e4ec50ceb0512030 Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Thu, 9 Jul 2026 15:12:15 -0600 Subject: [PATCH 03/15] Save fitbounds computed values to layout --- src/plots/geo/geo.js | 11 +++++++++++ src/plots/geo/layout_defaults.js | 24 +----------------------- 2 files changed, 12 insertions(+), 23 deletions(-) diff --git a/src/plots/geo/geo.js b/src/plots/geo/geo.js index 649cd0725fe..0ff38e79eee 100644 --- a/src/plots/geo/geo.js +++ b/src/plots/geo/geo.js @@ -287,8 +287,11 @@ proto.updateProjection = function (geoCalcData, fullLayout) { var midLon = (axLon.range[0] + axLon.range[1]) / 2; var midLat = (axLat.range[0] + axLat.range[1]) / 2; + // Overwrite the coerced view attrs on geoLayout with the fit-computed + // values so they can be used in downstream calculations if (geoLayout._isScoped) { center = { lon: midLon, lat: midLat }; + geoLayout.center = center; } else if (geoLayout._isClipped) { center = { lon: midLon, lat: midLat }; rotation = { lon: midLon, lat: midLat, roll: rotation.roll }; @@ -299,9 +302,15 @@ proto.updateProjection = function (geoCalcData, fullLayout) { lonaxisRange = [midLon - lonHalfSpan, midLon + lonHalfSpan]; lataxisRange = [midLat - latHalfSpan, midLat + latHalfSpan]; + geoLayout.center = center; + projLayout.rotation = rotation; + lonaxis.range = lonaxisRange; + lataxis.range = lataxisRange; } else { center = { lon: midLon, lat: midLat }; rotation = { lon: midLon, lat: rotation.lat, roll: rotation.roll }; + geoLayout.center = center; + projLayout.rotation = rotation; } } @@ -335,6 +344,8 @@ proto.updateProjection = function (geoCalcData, fullLayout) { if (isFinite(k2)) { projection.scale(k2 * s); + // Write the fitted scale back to the layout so it can be used in downstream calculations + projLayout.scale = k2; } else { Lib.warn('Something went wrong during' + this.id + 'fitbounds computations.'); } diff --git a/src/plots/geo/layout_defaults.js b/src/plots/geo/layout_defaults.js index 5d4cdabac4b..f3d6b286714 100644 --- a/src/plots/geo/layout_defaults.js +++ b/src/plots/geo/layout_defaults.js @@ -198,27 +198,5 @@ function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce, opts) { } coerce('bgcolor'); - - var fitBounds = coerce('fitbounds'); - - // clear attributes that will get auto-filled later - if (fitBounds) { - delete geoLayoutOut.projection.scale; - - if (isScoped) { - delete geoLayoutOut.center.lon; - delete geoLayoutOut.center.lat; - } else if (isClipped) { - delete geoLayoutOut.center.lon; - delete geoLayoutOut.center.lat; - delete geoLayoutOut.projection.rotation.lon; - delete geoLayoutOut.projection.rotation.lat; - delete geoLayoutOut.lonaxis.range; - delete geoLayoutOut.lataxis.range; - } else { - delete geoLayoutOut.center.lon; - delete geoLayoutOut.center.lat; - delete geoLayoutOut.projection.rotation.lon; - } - } + coerce('fitbounds'); } From a8742c860142d8dc6ad4e0d03a4fd478ce38fa1e Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Thu, 9 Jul 2026 15:13:31 -0600 Subject: [PATCH 04/15] Persist view attributes during manual zoom operations --- src/components/modebar/buttons.js | 13 ++++++++++++- src/plots/geo/zoom.js | 8 +++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/components/modebar/buttons.js b/src/components/modebar/buttons.js index 41f11041b61..65a78cee5b4 100644 --- a/src/components/modebar/buttons.js +++ b/src/components/modebar/buttons.js @@ -617,7 +617,18 @@ function handleGeo(gd, ev) { else if (newScale < min) newScale = min; if (newScale !== scale) { - Registry.call('_guiRelayout', gd, id + '.projection.scale', newScale); + // Persist the currently-effective view attrs with the new scale; turn + // off fitbounds so auto-fit doesn't overwrite them on the ensuing replot + const { rotation } = geoLayout.projection; + const { center } = geoLayout; + Registry.call('_guiRelayout', gd, { + [id + '.projection.scale']: newScale, + [id + '.projection.rotation.lon']: rotation.lon, + [id + '.projection.rotation.lat']: rotation.lat, + [id + '.center.lon']: center.lon, + [id + '.center.lat']: center.lat, + [id + '.fitbounds']: false + }); } } } diff --git a/src/plots/geo/zoom.js b/src/plots/geo/zoom.js index 63892a4e85d..ef852e10286 100644 --- a/src/plots/geo/zoom.js +++ b/src/plots/geo/zoom.js @@ -63,6 +63,8 @@ function sync(geo, projection, cb) { cb(set); set('projection.scale', projection.scale() / geo.fitScale); + // Turn off fitbounds so subsequent replays don't re-run the auto-fit and + // overwrite the user's dragged/scrolled position set('fitbounds', false); gd.emit('plotly_relayout', eventData); } @@ -272,7 +274,9 @@ function zoomClipped(geo, projection) { geo.graphDiv.emit('plotly_relayouting', { 'geo.projection.scale': projection.scale() / geo.fitScale, 'geo.projection.rotation.lon': -_rotate[0], - 'geo.projection.rotation.lat': -_rotate[1] + 'geo.projection.rotation.lat': -_rotate[1], + 'geo.center.lon': -_rotate[0], + 'geo.center.lat': -_rotate[1] }); }); @@ -292,6 +296,8 @@ function zoomClipped(geo, projection) { var _rotate = projection.rotate(); set('projection.rotation.lon', -_rotate[0]); set('projection.rotation.lat', -_rotate[1]); + set('center.lon', -_rotate[0]); + set('center.lat', -_rotate[1]); } return d3.rebind(zoom, event, 'on'); From 9894fdb6c863e6e7ab0d547332e5bfe5b1866f37 Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Thu, 9 Jul 2026 15:17:28 -0600 Subject: [PATCH 05/15] Update tests per new default, view attrs saved to layout --- test/jasmine/tests/geo_test.js | 132 +++++++++++++++++++-------------- 1 file changed, 78 insertions(+), 54 deletions(-) diff --git a/test/jasmine/tests/geo_test.js b/test/jasmine/tests/geo_test.js index ada6fbb6cf4..465e47d7a02 100644 --- a/test/jasmine/tests/geo_test.js +++ b/test/jasmine/tests/geo_test.js @@ -103,7 +103,15 @@ describe('Test geo fitbounds with antimeridian-straddling points', function () { describe('Test Geo layout defaults', function () { var layoutAttributes = Geo.layoutAttributes; - var supplyLayoutDefaults = Geo.supplyLayoutDefaults; + // Tests here were written against `fitbounds` defaulting to `false`. Shim + // the helper to inject that default when a test doesn't set fitbounds itself. + const supplyLayoutDefaults = (layoutIn, layoutOut, fullData) => { + if (layoutIn.geo && !('fitbounds' in layoutIn.geo)) { + layoutIn.geo.fitbounds = false; + } + + return Geo.supplyLayoutDefaults(layoutIn, layoutOut, fullData); + }; var layoutIn, layoutOut, fullData; @@ -546,7 +554,7 @@ describe('Test Geo layout defaults', function () { }); }); - describe('should clear attributes that get auto-filled under *fitbounds*', function () { + describe('should retain coerced attributes under *fitbounds*', function () { var vals = ['locations', 'geojson']; function _assert(exp) { @@ -599,9 +607,9 @@ describe('Test Geo layout defaults', function () { }; supplyLayoutDefaults(layoutIn, layoutOut, fullData); _assert({ - 'projection.scale': undefined, - 'center.lon': undefined, - 'center.lat': undefined, + 'projection.scale': 1, + 'center.lon': 15, + 'center.lat': 57.5, 'projection.rotation.lon': 15, 'projection.rotation.lat': 0, 'lonaxis.range': [-30, 60], @@ -649,13 +657,13 @@ describe('Test Geo layout defaults', function () { }; supplyLayoutDefaults(layoutIn, layoutOut, fullData); _assert({ - 'projection.scale': undefined, - 'center.lon': undefined, - 'center.lat': undefined, - 'projection.rotation.lon': undefined, - 'projection.rotation.lat': undefined, - 'lonaxis.range': undefined, - 'lataxis.range': undefined + 'projection.scale': 2, + 'center.lon': 20, + 'center.lat': 20, + 'projection.rotation.lon': 20, + 'projection.rotation.lat': 20, + 'lonaxis.range': [-70, 110], + 'lataxis.range': [-70, 110] }); }); }); @@ -703,10 +711,10 @@ describe('Test Geo layout defaults', function () { }; supplyLayoutDefaults(layoutIn, layoutOut, fullData); _assert({ - 'projection.scale': undefined, - 'center.lon': undefined, - 'center.lat': undefined, - 'projection.rotation.lon': undefined, + 'projection.scale': 2, + 'center.lon': 20, + 'center.lat': 40, + 'projection.rotation.lon': 20, 'projection.rotation.lat': 0, 'lonaxis.range': [-90, 90], 'lataxis.range': [0, 80] @@ -933,6 +941,10 @@ describe('Test geo interactions', function () { gd = createGraphDiv(); var mockCopy = Lib.extendDeep({}, mock); + // These tests check hover/click coordinates assuming fitbounds is 'false', + // so set it here (v4 changed the default to 'locations') + mockCopy.layout.geo ||= {}; + mockCopy.layout.geo.fitbounds = false; Plotly.newPlot(gd, mockCopy.data, mockCopy.layout).then(done); }); @@ -1519,6 +1531,7 @@ describe('Test geo interactions', function () { 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')); + fig.layout.geo.fitbounds = false; function _assert(msg, hoverLabelCnt) { expect(d3SelectAll('g.hovertext').size()).toBe(hoverLabelCnt, msg); @@ -1564,6 +1577,7 @@ describe('Test geo interactions', function () { fig.data[0].visible = false; fig.layout.geo.projection.rotation = { lon: -75, lat: 90 }; + fig.layout.geo.fitbounds = false; function check(p, hoverLabelCnt) { mouseEvent('mousemove', p[0], p[1]); @@ -1666,7 +1680,7 @@ describe('Test geo interactions', function () { z: ['10', '20', '15'] } ], - layout: { geo: { scope: 'world' } } + layout: { geo: { scope: 'world', fitbounds: false } } }; var figUSA = { data: [ @@ -1677,7 +1691,7 @@ describe('Test geo interactions', function () { z: ['10', '20', '15'] } ], - layout: { geo: { scope: 'usa' } } + layout: { geo: { scope: 'usa', fitbounds: false } } }; var figNA = { data: [ @@ -1688,7 +1702,7 @@ describe('Test geo interactions', function () { z: ['10', '20', '15'] } ], - layout: { geo: { scope: 'north america' } } + layout: { geo: { scope: 'north america', fitbounds: false } } }; Plotly.react(gd, figWorld) @@ -2392,6 +2406,7 @@ describe('Test geo zoom/pan/drag interactions:', function () { fig.layout.width = 700; fig.layout.height = 500; fig.layout.dragmode = 'pan'; + fig.layout.geo.fitbounds = false; }); function _assert(step, attr, proj, eventKeys) { @@ -2500,12 +2515,7 @@ describe('Test geo zoom/pan/drag interactions:', function () { newPlot(fig) .then(function () { - _assert( - 'base', - [[undefined, 0], [undefined, undefined], undefined], - [[-180, -0], [350, 260], [0, 0], 114.59], - undefined - ); + _assert('base', [[180, 0], [180, 0], 1.1249], [[-180, -0], [350, 260], [0, 0], 114.59], undefined); return drag({ path: [ [350, 250], @@ -2519,13 +2529,7 @@ describe('Test geo zoom/pan/drag interactions:', function () { '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' - ] + ['geo.projection.rotation.lon', 'geo.center.lon', 'geo.fitbounds'] ); return scroll([200, 250], [-200, -200]); }) @@ -2555,7 +2559,7 @@ describe('Test geo zoom/pan/drag interactions:', function () { .then(function () { _assert( 'after double click', - [[undefined, 0], [undefined, undefined], undefined], + [[180, 0], [180, 0], 1.1249], [[-180, -0], [350, 260], [0, 0], 114.59], 'dblclick' ); @@ -2570,6 +2574,7 @@ describe('Test geo zoom/pan/drag interactions:', function () { beforeEach(function () { fig = Lib.extendDeep({}, require('../../image/mocks/geo_orthographic')); fig.layout.dragmode = 'pan'; + fig.layout.geo.fitbounds = false; // of layout width = height = 500 }); @@ -2612,7 +2617,12 @@ describe('Test geo zoom/pan/drag interactions:', function () { 'after east-west drag', [[-103.7, 49.3], 1], [[103.7, -49.3], 160], - ['geo.projection.rotation.lon', 'geo.projection.rotation.lat'] + [ + 'geo.projection.rotation.lon', + 'geo.projection.rotation.lat', + 'geo.center.lon', + 'geo.center.lat' + ] ); return drag({ path: [ @@ -2627,7 +2637,12 @@ describe('Test geo zoom/pan/drag interactions:', function () { 'after NW-SE drag', [[-135.5, 73.8], 1], [[135.5, -73.8], 160], - ['geo.projection.rotation.lon', 'geo.projection.rotation.lat'] + [ + 'geo.projection.rotation.lon', + 'geo.projection.rotation.lat', + 'geo.center.lon', + 'geo.center.lat' + ] ); return scroll([300, 300], [-200, -200]); }) @@ -2636,7 +2651,13 @@ describe('Test geo zoom/pan/drag interactions:', function () { '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'] + [ + 'geo.projection.rotation.lon', + 'geo.projection.rotation.lat', + 'geo.center.lon', + 'geo.center.lat', + 'geo.projection.scale' + ] ); return Plotly.relayout(gd, 'geo.showocean', false); }) @@ -2661,7 +2682,7 @@ describe('Test geo zoom/pan/drag interactions:', function () { newPlot(fig) .then(function () { - _assert('base', [[undefined, undefined], undefined], [[-76.014, -19.8], 160], undefined); + _assert('base', [[76.014, 19.735], 1], [[-76.014, -19.735], 160], undefined); return drag({ path: [ [250, 250], @@ -2678,7 +2699,8 @@ describe('Test geo zoom/pan/drag interactions:', function () { [ 'geo.projection.rotation.lon', 'geo.projection.rotation.lat', - 'geo.projection.scale', + 'geo.center.lon', + 'geo.center.lat', 'geo.fitbounds' ] ); @@ -2689,7 +2711,13 @@ describe('Test geo zoom/pan/drag interactions:', function () { 'after scroll', [[58.694, 18.759], 1.1488], [[-58.694, -18.759], 183.818], - ['geo.projection.rotation.lon', 'geo.projection.rotation.lat', 'geo.projection.scale'] + [ + 'geo.projection.rotation.lon', + 'geo.projection.rotation.lat', + 'geo.center.lon', + 'geo.center.lat', + 'geo.projection.scale' + ] ); return Plotly.relayout(gd, 'geo.showocean', false); }) @@ -2703,13 +2731,8 @@ describe('Test geo zoom/pan/drag interactions:', function () { return dblClick([350, 250]); }) .then(function () { - // resets to initial view - _assert( - 'after double click', - [[undefined, undefined], undefined], - [[-76.014, -19.8], 160], - 'dblclick' - ); + // resets to initial view (viewInitial captured the fit values) + _assert('after double click', [[76.014, 19.735], 1], [[-76.014, -19.8], 160], 'dblclick'); }) .then(done, done.fail); }); @@ -2722,6 +2745,7 @@ describe('Test geo zoom/pan/drag interactions:', function () { fig = Lib.extendDeep({}, require('../../image/mocks/geo_europe-bubbles')); fig.layout.geo.resolution = 110; fig.layout.dragmode = 'pan'; + fig.layout.geo.fitbounds = false; // of layout width = height = 500 }); @@ -2810,12 +2834,7 @@ describe('Test geo zoom/pan/drag interactions:', function () { newPlot(fig) .then(function () { - _assert( - 'base', - [[undefined, undefined], undefined], - [[247, 260], [5.7998, 49.29], 504.8559], - undefined - ); + _assert('base', [[20.87, 49.33], 1.7303], [[247, 260], [5.7998, 49.29], 504.8559], undefined); return drag({ path: [ [250, 250], @@ -2829,7 +2848,7 @@ describe('Test geo zoom/pan/drag interactions:', function () { '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'] + ['geo.center.lon', 'geo.center.lat', 'geo.fitbounds'] ); return scroll([300, 300], [-200, -200]); }) @@ -2859,7 +2878,7 @@ describe('Test geo zoom/pan/drag interactions:', function () { .then(function () { _assert( 'after double click', - [[undefined, undefined], undefined], + [[20.87, 49.33], 1.7303], [[247, 260], [5.7998, 49.29], 504.8559], 'dblclick' ); @@ -2871,6 +2890,7 @@ describe('Test geo zoom/pan/drag interactions:', function () { it('should work for *albers usa* projections', function (done) { var fig = Lib.extendDeep({}, require('../../image/mocks/geo_choropleth-usa')); fig.layout.dragmode = 'pan'; + fig.layout.geo.fitbounds = false; // layout width = 870 // layout height = 598 @@ -2954,6 +2974,7 @@ describe('Test geo zoom/pan/drag interactions:', function () { fig.data = [fig.data[0]]; fig.layout.width = 700; fig.layout.height = 500; + fig.layout.geo.fitbounds = false; newPlot(fig) .then(function () { @@ -2972,6 +2993,7 @@ describe('Test geo zoom/pan/drag interactions:', function () { fig.layout.width = 700; fig.layout.height = 500; fig.layout.dragmode = 'pan'; + fig.layout.geo.fitbounds = false; function _assert(step, attr, proj, eventKeys) { var msg = '[' + step + '] '; @@ -3141,6 +3163,7 @@ describe('Test geo interactions update marker angles:', function () { fig.layout.width = 700; fig.layout.height = 500; fig.layout.dragmode = 'pan'; + fig.layout.geo.fitbounds = false; var initialPath, newPath; @@ -3214,6 +3237,7 @@ describe('plotly_relayouting', function () { fig.layout.dragmode = dragmode; fig.layout.width = 700; fig.layout.height = 500; + fig.layout.geo.fitbounds = false; newPlot(fig) .then(function () { From 82acb12816216eda79c3470bebec86209bc33c50 Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Thu, 9 Jul 2026 16:43:47 -0600 Subject: [PATCH 06/15] Add user view attribute gate for fitbounds --- src/plots/geo/layout_defaults.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/plots/geo/layout_defaults.js b/src/plots/geo/layout_defaults.js index f3d6b286714..c0732dbf63d 100644 --- a/src/plots/geo/layout_defaults.js +++ b/src/plots/geo/layout_defaults.js @@ -198,5 +198,18 @@ function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce, opts) { } coerce('bgcolor'); - coerce('fitbounds'); + + // Only use fitbounds if user hasn't set any view attributes. This prevents + // user specified view info from being ignored. + const fitBounds = coerce('fitbounds'); + if (fitBounds) { + const projectionIn = geoLayoutIn.projection || {}; + const hasUserView = + geoLayoutIn.center !== undefined || + projectionIn.rotation !== undefined || + projectionIn.scale !== undefined || + geoLayoutIn.lonaxis?.range !== undefined || + geoLayoutIn.lataxis?.range !== undefined; + if (hasUserView) geoLayoutOut.fitbounds = false; + } } From 0f9af806846a9559fb576fdd818bbc05aefb500b Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Thu, 9 Jul 2026 16:44:06 -0600 Subject: [PATCH 07/15] Save center lat/lon for clipped projections --- src/plots/geo/geo.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/plots/geo/geo.js b/src/plots/geo/geo.js index 0ff38e79eee..715da66ebea 100644 --- a/src/plots/geo/geo.js +++ b/src/plots/geo/geo.js @@ -655,7 +655,9 @@ proto.saveViewInitial = function (geoLayout) { } else if (geoLayout._isClipped) { extra = { 'projection.rotation.lon': rotation.lon, - 'projection.rotation.lat': rotation.lat + 'projection.rotation.lat': rotation.lat, + 'center.lon': center.lon, + 'center.lat': center.lat }; } else { extra = { From 02b0c924c3c8a825fb57d856cf7d421d2fc0341f Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Thu, 9 Jul 2026 16:45:23 -0600 Subject: [PATCH 08/15] Update tests per gate addition --- test/jasmine/tests/geo_test.js | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/test/jasmine/tests/geo_test.js b/test/jasmine/tests/geo_test.js index 465e47d7a02..05e2717b9ec 100644 --- a/test/jasmine/tests/geo_test.js +++ b/test/jasmine/tests/geo_test.js @@ -1531,7 +1531,6 @@ describe('Test geo interactions', function () { 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')); - fig.layout.geo.fitbounds = false; function _assert(msg, hoverLabelCnt) { expect(d3SelectAll('g.hovertext').size()).toBe(hoverLabelCnt, msg); @@ -1577,7 +1576,6 @@ describe('Test geo interactions', function () { fig.data[0].visible = false; fig.layout.geo.projection.rotation = { lon: -75, lat: 90 }; - fig.layout.geo.fitbounds = false; function check(p, hoverLabelCnt) { mouseEvent('mousemove', p[0], p[1]); @@ -2406,7 +2404,6 @@ describe('Test geo zoom/pan/drag interactions:', function () { fig.layout.width = 700; fig.layout.height = 500; fig.layout.dragmode = 'pan'; - fig.layout.geo.fitbounds = false; }); function _assert(step, attr, proj, eventKeys) { @@ -2511,6 +2508,8 @@ describe('Test geo zoom/pan/drag interactions:', function () { }); it('- fitbounds case', function (done) { + // Clear the mock's user-set rotation so the fitbounds gate doesn't opt out + delete fig.layout.geo.projection.rotation; fig.layout.geo.fitbounds = 'locations'; newPlot(fig) @@ -2574,7 +2573,6 @@ describe('Test geo zoom/pan/drag interactions:', function () { beforeEach(function () { fig = Lib.extendDeep({}, require('../../image/mocks/geo_orthographic')); fig.layout.dragmode = 'pan'; - fig.layout.geo.fitbounds = false; // of layout width = height = 500 }); @@ -2678,6 +2676,8 @@ describe('Test geo zoom/pan/drag interactions:', function () { }); it('- fitbounds case', function (done) { + // Clear the mock's user-set rotation so the fitbounds gate doesn't opt out + delete fig.layout.geo.projection.rotation; fig.layout.geo.fitbounds = 'locations'; newPlot(fig) @@ -2974,7 +2974,6 @@ describe('Test geo zoom/pan/drag interactions:', function () { fig.data = [fig.data[0]]; fig.layout.width = 700; fig.layout.height = 500; - fig.layout.geo.fitbounds = false; newPlot(fig) .then(function () { @@ -2993,7 +2992,6 @@ describe('Test geo zoom/pan/drag interactions:', function () { fig.layout.width = 700; fig.layout.height = 500; fig.layout.dragmode = 'pan'; - fig.layout.geo.fitbounds = false; function _assert(step, attr, proj, eventKeys) { var msg = '[' + step + '] '; @@ -3163,7 +3161,6 @@ describe('Test geo interactions update marker angles:', function () { fig.layout.width = 700; fig.layout.height = 500; fig.layout.dragmode = 'pan'; - fig.layout.geo.fitbounds = false; var initialPath, newPath; From fe45f65ff8df0e0001b478fe3868c89fe266f134 Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Thu, 9 Jul 2026 16:49:31 -0600 Subject: [PATCH 09/15] Add draftlog --- draftlogs/7895_change.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 draftlogs/7895_change.md diff --git a/draftlogs/7895_change.md b/draftlogs/7895_change.md new file mode 100644 index 00000000000..94ca30a148c --- /dev/null +++ b/draftlogs/7895_change.md @@ -0,0 +1,3 @@ +- **Breaking**: Change `layout.geo.fitbounds` default from `false` to `'locations'` [[#7895](https://github.com/plotly/plotly.js/pull/7895)] + - `geo` subplots will now auto-fit the initial view to the trace data + - Set `fitbounds: false` explicitly to opt out From 4f6f12209d56c7ce0b871b4671e68a26023aa960 Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Fri, 10 Jul 2026 12:17:04 -0600 Subject: [PATCH 10/15] Replace undefined with null for fitbounds computed values --- src/plots/geo/geo.js | 11 -------- src/plots/geo/layout_defaults.js | 47 ++++++++++++++++++++++++++------ src/plots/geo/zoom.js | 6 +--- 3 files changed, 39 insertions(+), 25 deletions(-) diff --git a/src/plots/geo/geo.js b/src/plots/geo/geo.js index 715da66ebea..50069d30fb5 100644 --- a/src/plots/geo/geo.js +++ b/src/plots/geo/geo.js @@ -287,11 +287,8 @@ proto.updateProjection = function (geoCalcData, fullLayout) { var midLon = (axLon.range[0] + axLon.range[1]) / 2; var midLat = (axLat.range[0] + axLat.range[1]) / 2; - // Overwrite the coerced view attrs on geoLayout with the fit-computed - // values so they can be used in downstream calculations if (geoLayout._isScoped) { center = { lon: midLon, lat: midLat }; - geoLayout.center = center; } else if (geoLayout._isClipped) { center = { lon: midLon, lat: midLat }; rotation = { lon: midLon, lat: midLat, roll: rotation.roll }; @@ -302,15 +299,9 @@ proto.updateProjection = function (geoCalcData, fullLayout) { lonaxisRange = [midLon - lonHalfSpan, midLon + lonHalfSpan]; lataxisRange = [midLat - latHalfSpan, midLat + latHalfSpan]; - geoLayout.center = center; - projLayout.rotation = rotation; - lonaxis.range = lonaxisRange; - lataxis.range = lataxisRange; } else { center = { lon: midLon, lat: midLat }; rotation = { lon: midLon, lat: rotation.lat, roll: rotation.roll }; - geoLayout.center = center; - projLayout.rotation = rotation; } } @@ -344,8 +335,6 @@ proto.updateProjection = function (geoCalcData, fullLayout) { if (isFinite(k2)) { projection.scale(k2 * s); - // Write the fitted scale back to the layout so it can be used in downstream calculations - projLayout.scale = k2; } else { Lib.warn('Something went wrong during' + this.id + 'fitbounds computations.'); } diff --git a/src/plots/geo/layout_defaults.js b/src/plots/geo/layout_defaults.js index c0732dbf63d..363ea132030 100644 --- a/src/plots/geo/layout_defaults.js +++ b/src/plots/geo/layout_defaults.js @@ -200,16 +200,45 @@ function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce, opts) { coerce('bgcolor'); // Only use fitbounds if user hasn't set any view attributes. This prevents - // user specified view info from being ignored. - const fitBounds = coerce('fitbounds'); - if (fitBounds) { + // user-specified view info from being ignored. + const fitbounds = coerce('fitbounds'); + if (fitbounds) { + const centerIn = geoLayoutIn.center || {}; const projectionIn = geoLayoutIn.projection || {}; - const hasUserView = - geoLayoutIn.center !== undefined || - projectionIn.rotation !== undefined || - projectionIn.scale !== undefined || - geoLayoutIn.lonaxis?.range !== undefined || - geoLayoutIn.lataxis?.range !== undefined; + const rotationIn = projectionIn.rotation || {}; + const lonaxisIn = geoLayoutIn.lonaxis || {}; + const lataxisIn = geoLayoutIn.lataxis || {}; + const hasUserView = [ + centerIn.lon, + centerIn.lat, + rotationIn.lon, + rotationIn.lat, + projectionIn.scale, + lonaxisIn.range, + lataxisIn.range + ].some((d) => d !== undefined); if (hasUserView) geoLayoutOut.fitbounds = false; } + + // Set auto-filled view attributes to null so updateProjection can + // compute the fit from scratch and fullLayout matches user input + if (geoLayoutOut.fitbounds) { + geoLayoutOut.projection.scale = null; + + if (isScoped) { + geoLayoutOut.center.lon = null; + geoLayoutOut.center.lat = null; + } else if (isClipped) { + geoLayoutOut.center.lon = null; + geoLayoutOut.center.lat = null; + geoLayoutOut.projection.rotation.lon = null; + geoLayoutOut.projection.rotation.lat = null; + geoLayoutOut.lonaxis.range = null; + geoLayoutOut.lataxis.range = null; + } else { + geoLayoutOut.center.lon = null; + geoLayoutOut.center.lat = null; + geoLayoutOut.projection.rotation.lon = null; + } + } } diff --git a/src/plots/geo/zoom.js b/src/plots/geo/zoom.js index ef852e10286..c3865e48e95 100644 --- a/src/plots/geo/zoom.js +++ b/src/plots/geo/zoom.js @@ -274,9 +274,7 @@ function zoomClipped(geo, projection) { geo.graphDiv.emit('plotly_relayouting', { 'geo.projection.scale': projection.scale() / geo.fitScale, 'geo.projection.rotation.lon': -_rotate[0], - 'geo.projection.rotation.lat': -_rotate[1], - 'geo.center.lon': -_rotate[0], - 'geo.center.lat': -_rotate[1] + 'geo.projection.rotation.lat': -_rotate[1] }); }); @@ -296,8 +294,6 @@ function zoomClipped(geo, projection) { var _rotate = projection.rotate(); set('projection.rotation.lon', -_rotate[0]); set('projection.rotation.lat', -_rotate[1]); - set('center.lon', -_rotate[0]); - set('center.lat', -_rotate[1]); } return d3.rebind(zoom, event, 'on'); From c2b23eac297581e124e367e2be7609e5ad3932f5 Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Fri, 10 Jul 2026 12:19:15 -0600 Subject: [PATCH 11/15] Use local variables for calculating zoom button actions --- src/components/modebar/buttons.js | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/components/modebar/buttons.js b/src/components/modebar/buttons.js index 65a78cee5b4..c6df270b79f 100644 --- a/src/components/modebar/buttons.js +++ b/src/components/modebar/buttons.js @@ -594,6 +594,8 @@ modeBarButtons.hoverClosestGeo = { click: toggleHover }; +const ZOOM_STEP_GEO = 2; + function handleGeo(gd, ev) { const button = ev.currentTarget; const attr = button.getAttribute('data-attr'); @@ -603,30 +605,36 @@ function handleGeo(gd, ev) { for (const id of geoIds) { const geoLayout = fullLayout[id]; + const geoSubplot = geoLayout._subplot; if (attr === 'zoom') { - const { minscale, scale } = geoLayout.projection; + // Under fitbounds, geoLayout.projection.scale is undefined; read the + // effective view from the D3 projection state instead + const projection = geoSubplot.projection; + const effectiveScale = projection.scale() / geoSubplot.fitScale; // Convert to schema format (multiples of original scale) + const [rotationLon, rotationLat] = projection.rotate().map((d) => -d); // Flip sign because D3 rotation is opposite of ours + const [centerLon, centerLat] = projection.invert(geoSubplot.midPt); + + const { minscale } = 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; + let newScale = val === 'in' ? ZOOM_STEP_GEO * effectiveScale : (1 / ZOOM_STEP_GEO) * effectiveScale; // clamp to [min, max] if (newScale > max) newScale = max; else if (newScale < min) newScale = min; - if (newScale !== scale) { + if (newScale !== effectiveScale) { // Persist the currently-effective view attrs with the new scale; turn // off fitbounds so auto-fit doesn't overwrite them on the ensuing replot - const { rotation } = geoLayout.projection; - const { center } = geoLayout; Registry.call('_guiRelayout', gd, { [id + '.projection.scale']: newScale, - [id + '.projection.rotation.lon']: rotation.lon, - [id + '.projection.rotation.lat']: rotation.lat, - [id + '.center.lon']: center.lon, - [id + '.center.lat']: center.lat, + [id + '.projection.rotation.lon']: rotationLon, + [id + '.projection.rotation.lat']: rotationLat, + [id + '.center.lon']: centerLon, + [id + '.center.lat']: centerLat, [id + '.fitbounds']: false }); } From 93055f00cd30d73ab9e1689d3642cb7e5b4fba98 Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Fri, 10 Jul 2026 15:07:42 -0600 Subject: [PATCH 12/15] Update tests --- test/jasmine/tests/choropleth_test.js | 4 ++ test/jasmine/tests/geo_test.js | 62 ++++++++--------------- test/jasmine/tests/plot_api_react_test.js | 7 +-- test/jasmine/tests/scattergeo_test.js | 4 +- test/jasmine/tests/select_test.js | 10 ++-- 5 files changed, 39 insertions(+), 48 deletions(-) diff --git a/test/jasmine/tests/choropleth_test.js b/test/jasmine/tests/choropleth_test.js index d26468b8fcb..d1e0444ed82 100644 --- a/test/jasmine/tests/choropleth_test.js +++ b/test/jasmine/tests/choropleth_test.js @@ -177,6 +177,10 @@ describe('Test choropleth hover:', function() { fontFamily: 'Arial' }; + // Tests here were written for `fitbounds: false`, so set that explicitly + fig.layout ||= {}; + fig.layout.geo = Lib.extendDeep({}, fig.layout.geo, { fitbounds: false }); + return Plotly.newPlot(gd, fig) .then(function() { if(hasCssTransform) { diff --git a/test/jasmine/tests/geo_test.js b/test/jasmine/tests/geo_test.js index 05e2717b9ec..2f38d4a2b3f 100644 --- a/test/jasmine/tests/geo_test.js +++ b/test/jasmine/tests/geo_test.js @@ -607,9 +607,9 @@ describe('Test Geo layout defaults', function () { }; supplyLayoutDefaults(layoutIn, layoutOut, fullData); _assert({ - 'projection.scale': 1, - 'center.lon': 15, - 'center.lat': 57.5, + 'projection.scale': null, + 'center.lon': null, + 'center.lat': null, 'projection.rotation.lon': 15, 'projection.rotation.lat': 0, 'lonaxis.range': [-30, 60], @@ -2514,7 +2514,7 @@ describe('Test geo zoom/pan/drag interactions:', function () { newPlot(fig) .then(function () { - _assert('base', [[180, 0], [180, 0], 1.1249], [[-180, -0], [350, 260], [0, 0], 114.59], undefined); + _assert('base', [[null, 0], [null, null], null], [[-180, -0], [350, 260], [0, 0], 114.59], undefined); return drag({ path: [ [350, 250], @@ -2528,7 +2528,13 @@ describe('Test geo zoom/pan/drag interactions:', function () { '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.fitbounds'] + [ + 'geo.projection.rotation.lon', + 'geo.center.lon', + 'geo.center.lat', + 'geo.projection.scale', + 'geo.fitbounds' + ] ); return scroll([200, 250], [-200, -200]); }) @@ -2558,7 +2564,7 @@ describe('Test geo zoom/pan/drag interactions:', function () { .then(function () { _assert( 'after double click', - [[180, 0], [180, 0], 1.1249], + [[null, 0], [null, null], null], [[-180, -0], [350, 260], [0, 0], 114.59], 'dblclick' ); @@ -2615,12 +2621,7 @@ describe('Test geo zoom/pan/drag interactions:', function () { 'after east-west drag', [[-103.7, 49.3], 1], [[103.7, -49.3], 160], - [ - 'geo.projection.rotation.lon', - 'geo.projection.rotation.lat', - 'geo.center.lon', - 'geo.center.lat' - ] + ['geo.projection.rotation.lon', 'geo.projection.rotation.lat'] ); return drag({ path: [ @@ -2635,12 +2636,7 @@ describe('Test geo zoom/pan/drag interactions:', function () { 'after NW-SE drag', [[-135.5, 73.8], 1], [[135.5, -73.8], 160], - [ - 'geo.projection.rotation.lon', - 'geo.projection.rotation.lat', - 'geo.center.lon', - 'geo.center.lat' - ] + ['geo.projection.rotation.lon', 'geo.projection.rotation.lat'] ); return scroll([300, 300], [-200, -200]); }) @@ -2649,13 +2645,7 @@ describe('Test geo zoom/pan/drag interactions:', function () { 'after scroll', [[-126.2, 67.1], 1.3], [[126.2, -67.1], 211.1], - [ - 'geo.projection.rotation.lon', - 'geo.projection.rotation.lat', - 'geo.center.lon', - 'geo.center.lat', - 'geo.projection.scale' - ] + ['geo.projection.rotation.lon', 'geo.projection.rotation.lat', 'geo.projection.scale'] ); return Plotly.relayout(gd, 'geo.showocean', false); }) @@ -2682,7 +2672,7 @@ describe('Test geo zoom/pan/drag interactions:', function () { newPlot(fig) .then(function () { - _assert('base', [[76.014, 19.735], 1], [[-76.014, -19.735], 160], undefined); + _assert('base', [[null, null], null], [[-76.014, -19.735], 160], undefined); return drag({ path: [ [250, 250], @@ -2699,8 +2689,7 @@ describe('Test geo zoom/pan/drag interactions:', function () { [ 'geo.projection.rotation.lon', 'geo.projection.rotation.lat', - 'geo.center.lon', - 'geo.center.lat', + 'geo.projection.scale', 'geo.fitbounds' ] ); @@ -2711,13 +2700,7 @@ describe('Test geo zoom/pan/drag interactions:', function () { 'after scroll', [[58.694, 18.759], 1.1488], [[-58.694, -18.759], 183.818], - [ - 'geo.projection.rotation.lon', - 'geo.projection.rotation.lat', - 'geo.center.lon', - 'geo.center.lat', - 'geo.projection.scale' - ] + ['geo.projection.rotation.lon', 'geo.projection.rotation.lat', 'geo.projection.scale'] ); return Plotly.relayout(gd, 'geo.showocean', false); }) @@ -2731,8 +2714,7 @@ describe('Test geo zoom/pan/drag interactions:', function () { return dblClick([350, 250]); }) .then(function () { - // resets to initial view (viewInitial captured the fit values) - _assert('after double click', [[76.014, 19.735], 1], [[-76.014, -19.8], 160], 'dblclick'); + _assert('after double click', [[null, null], null], [[-76.014, -19.8], 160], 'dblclick'); }) .then(done, done.fail); }); @@ -2834,7 +2816,7 @@ describe('Test geo zoom/pan/drag interactions:', function () { newPlot(fig) .then(function () { - _assert('base', [[20.87, 49.33], 1.7303], [[247, 260], [5.7998, 49.29], 504.8559], undefined); + _assert('base', [[null, null], null], [[247, 260], [5.7998, 49.29], 504.8559], undefined); return drag({ path: [ [250, 250], @@ -2848,7 +2830,7 @@ describe('Test geo zoom/pan/drag interactions:', function () { 'after SW-NE drag', [[29.059, 42.38], 1.727], [[197, 210], [5.7988, 49.29], 504.8559], - ['geo.center.lon', 'geo.center.lat', 'geo.fitbounds'] + ['geo.center.lon', 'geo.center.lat', 'geo.projection.scale', 'geo.fitbounds'] ); return scroll([300, 300], [-200, -200]); }) @@ -2878,7 +2860,7 @@ describe('Test geo zoom/pan/drag interactions:', function () { .then(function () { _assert( 'after double click', - [[20.87, 49.33], 1.7303], + [[null, null], null], [[247, 260], [5.7998, 49.29], 504.8559], 'dblclick' ); diff --git a/test/jasmine/tests/plot_api_react_test.js b/test/jasmine/tests/plot_api_react_test.js index 8eb8ffbef7f..11d0dcc097c 100644 --- a/test/jasmine/tests/plot_api_react_test.js +++ b/test/jasmine/tests/plot_api_react_test.js @@ -1411,7 +1411,7 @@ describe('Plotly.react and uirevision attributes', function() { }], layout: { uirevision: mainRev, - geo: {uirevision: geoRev} + geo: {uirevision: geoRev, fitbounds: false} } }; } @@ -2017,7 +2017,8 @@ describe('Test Plotly.react + interactions under uirevision:', function() { }], { width: 500, height: 500, - uirevision: true + uirevision: true, + geo: {fitbounds: false} }); } @@ -2044,7 +2045,7 @@ describe('Test Plotly.react + interactions under uirevision:', function() { _react() .then(function() { - expect(gd.layout.geo).toEqual({}); + expect(gd.layout.geo).toEqual({fitbounds: false}); var fullGeo = gd._fullLayout.geo; expect(fullGeo.projection.rotation.lon).toBe(0); diff --git a/test/jasmine/tests/scattergeo_test.js b/test/jasmine/tests/scattergeo_test.js index 155a2f3cc06..4e98aed34fc 100644 --- a/test/jasmine/tests/scattergeo_test.js +++ b/test/jasmine/tests/scattergeo_test.js @@ -314,7 +314,9 @@ describe('Test scattergeo hover', function() { lon: [10, 20, 30], lat: [10, 20, 30], text: ['A', 'B', 'C'] - }]) + }], { + geo: { fitbounds: false } + }) .then(done); }); diff --git a/test/jasmine/tests/select_test.js b/test/jasmine/tests/select_test.js index 2261a72b24b..8c10b7a8793 100644 --- a/test/jasmine/tests/select_test.js +++ b/test/jasmine/tests/select_test.js @@ -654,8 +654,8 @@ describe('Click-to-select', function() { { width: 1100, height: 450 }), testCase('ohlc', require('../../image/mocks/ohlc_first.json'), 669, 165, [9]), testCase('candlestick', require('../../image/mocks/finance_style.json'), 331, 162, [[], [5]]), - testCase('choropleth', require('../../image/mocks/geo_choropleth-text.json'), 440, 163, [6]), - testCase('scattergeo', require('../../image/mocks/geo_scattergeo-locations.json'), 285, 240, [1]), + testCase('choropleth', require('../../image/mocks/geo_choropleth-text.json'), 440, 163, [6], {geo: {fitbounds: false}}), + testCase('scattergeo', require('../../image/mocks/geo_scattergeo-locations.json'), 285, 240, [1], {geo: {fitbounds: false}}), testCase('scatterternary', require('../../image/mocks/ternary_markers.json'), 485, 335, [7]), // Note that first trace (carpet) in mock doesn't support selection, @@ -770,7 +770,7 @@ describe('Click-to-select', function() { describe('triggers \'plotly_selected\' before \'plotly_click\'', function() { [ testCase('cartesian', require('../../image/mocks/14.json'), 270, 160, [7]), - testCase('geo', require('../../image/mocks/geo_scattergeo-locations.json'), 285, 240, [1]), + testCase('geo', require('../../image/mocks/geo_scattergeo-locations.json'), 285, 240, [1], {geo: {fitbounds: false}}), testCase('ternary', require('../../image/mocks/ternary_markers.json'), 485, 335, [7]), testCase('polar', require('../../image/mocks/polar_scatter.json'), 130, 290, [[], [], [], [19], [], []], { dragmode: 'zoom' }) @@ -2359,7 +2359,8 @@ describe('Test select box and lasso per trace:', function() { showlegend: false, dragmode: 'select', width: 800, - height: 600 + height: 600, + geo: {fitbounds: false} } }; addInvisible(fig); @@ -2583,6 +2584,7 @@ describe('Test select box and lasso per trace:', function() { fig.layout.height = 450; fig.layout.dragmode = 'select'; fig.layout.geo.scope = 'europe'; + fig.layout.geo.fitbounds = false; addInvisible(fig, false); // add a trace with no locations which will then make trace invisible, lacking DOM elements From 5aa17cf37df14499c2cbc72fecd2289cde25afe6 Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Fri, 10 Jul 2026 15:08:41 -0600 Subject: [PATCH 13/15] Update draftlog --- draftlogs/7895_change.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/draftlogs/7895_change.md b/draftlogs/7895_change.md index 94ca30a148c..2ded72c5d5f 100644 --- a/draftlogs/7895_change.md +++ b/draftlogs/7895_change.md @@ -1,3 +1,3 @@ -- **Breaking**: Change `layout.geo.fitbounds` default from `false` to `'locations'` [[#7895](https://github.com/plotly/plotly.js/pull/7895)] +- **Breaking:** Change `layout.geo.fitbounds` default from `false` to `'locations'` [[#7895](https://github.com/plotly/plotly.js/pull/7895)] - `geo` subplots will now auto-fit the initial view to the trace data - Set `fitbounds: false` explicitly to opt out From 195345ca796240a56c36cd0e4f6ac69688ce2ba9 Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Fri, 10 Jul 2026 16:11:42 -0600 Subject: [PATCH 14/15] Add test image generation timeout --- test/image/make_baseline.py | 36 ++++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/test/image/make_baseline.py b/test/image/make_baseline.py index 0c0934adc2c..bb36add0186 100644 --- a/test/image/make_baseline.py +++ b/test/image/make_baseline.py @@ -87,13 +87,17 @@ async def make_baselines_async(): if mathjax is not None: kopts["mathjax"] = mathjax + # Per-mock timeout so a runaway render surfaces as a named failure instead + # of hanging the whole shard until the runner times out. + PER_MOCK_TIMEOUT_SECONDS = 60 + async with kaleido.Kaleido(n=1, **kopts) as k: for name in allNames: outName = name if mathjax_version == 3: outName = "mathjax3___" + name - print(outName) + print(outName, flush=True) created = False @@ -124,23 +128,35 @@ async def make_baselines_async(): print(json.dumps(fig, indent=2)) try: - bytes = await k.calc_fig( - fig, - opts=dict( - format="png", - width=width, - height=height, + bytes = await asyncio.wait_for( + k.calc_fig( + fig, + opts=dict( + format="png", + width=width, + height=height, + ), + topojson=topojson, ), - topojson=topojson, + timeout=PER_MOCK_TIMEOUT_SECONDS, ) filename = os.path.join(dirOut, outName + ".png") with open(filename, "wb") as f: f.write(bytes) created = True + except asyncio.TimeoutError: + print( + f"timed out after {PER_MOCK_TIMEOUT_SECONDS}s", + flush=True, + ) + if attempt < MAX_RETRY: + print("retry", attempt + 1, "/", MAX_RETRY, flush=True) + else: + failed.append(outName) except Exception as e: - print(e) + print(e, flush=True) if attempt < MAX_RETRY: - print("retry", attempt + 1, "/", MAX_RETRY) + print("retry", attempt + 1, "/", MAX_RETRY, flush=True) else: failed.append(outName) From f04bc146ef63e693c6dbcea38616a82e0dd20ae8 Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Fri, 10 Jul 2026 17:33:28 -0600 Subject: [PATCH 15/15] Add special handling for Albers USA --- src/plots/geo/geo.js | 9 ++++++--- src/plots/geo/layout_defaults.js | 7 ++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/plots/geo/geo.js b/src/plots/geo/geo.js index 50069d30fb5..cba421464af 100644 --- a/src/plots/geo/geo.js +++ b/src/plots/geo/geo.js @@ -353,9 +353,12 @@ proto.updateProjection = function (geoCalcData, fullLayout) { // so here's this hack to make it respond to 'geoLayout.center' 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])]); + // If center isn't within the Albers USA bounds (clipped to the USA), + // `projection(...)` returns null so skip the recentering + if (centerPx) { + var tt = projection.translate(); + projection.translate([tt[0] - (centerPx[0] - tt[0]), tt[1] - (centerPx[1] - tt[1])]); + } } }; diff --git a/src/plots/geo/layout_defaults.js b/src/plots/geo/layout_defaults.js index 363ea132030..bfedd939d4a 100644 --- a/src/plots/geo/layout_defaults.js +++ b/src/plots/geo/layout_defaults.js @@ -201,8 +201,8 @@ function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce, opts) { // Only use fitbounds if user hasn't set any view attributes. This prevents // user-specified view info from being ignored. - const fitbounds = coerce('fitbounds'); - if (fitbounds) { + coerce('fitbounds'); + if (geoLayoutOut.fitbounds) { const centerIn = geoLayoutIn.center || {}; const projectionIn = geoLayoutIn.projection || {}; const rotationIn = projectionIn.rotation || {}; @@ -217,7 +217,8 @@ function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce, opts) { lonaxisIn.range, lataxisIn.range ].some((d) => d !== undefined); - if (hasUserView) geoLayoutOut.fitbounds = false; + // The Albers projection doesn't need a fit, so skip here + if (hasUserView || isAlbersUsa) geoLayoutOut.fitbounds = false; } // Set auto-filled view attributes to null so updateProjection can