From 44f3d84377baf6333190e8a93cb03a088ae1682d Mon Sep 17 00:00:00 2001 From: SharadhNaidu Date: Thu, 11 Jun 2026 10:05:54 +0530 Subject: [PATCH 1/2] Fix geo fitbounds to pick compact range across the antimeridian When `fitbounds` point data straddles +/-180 degrees longitude, the naive [min, max] range from getAutoRange includes the large empty span the long way round the globe, so the map zooms out far more than necessary (plotly/plotly.py#5539). Add getFitboundsLonRange, which finds the widest gap between consecutive longitudes and returns the complementary, antimeridian-crossing range when it is more compact. The override is scoped to longitude point data: it is skipped when a choropleth or location-based scattergeo trace is present (whose region extents are not captured here) and when the data spans the whole globe. --- src/plots/geo/geo.js | 32 +++++++++++ src/plots/geo/get_fitbounds_lon_range.js | 53 ++++++++++++++++++ test/jasmine/tests/geo_test.js | 70 ++++++++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 src/plots/geo/get_fitbounds_lon_range.js diff --git a/src/plots/geo/geo.js b/src/plots/geo/geo.js index 797ab8373b6..fa9d641511d 100644 --- a/src/plots/geo/geo.js +++ b/src/plots/geo/geo.js @@ -24,6 +24,7 @@ var selectOnClick = require('../../components/selections').selectOnClick; var createGeoZoom = require('./zoom'); var constants = require('./constants'); +var getFitboundsLonRange = require('./get_fitbounds_lon_range'); var geoUtils = require('../../lib/geo_location_utils'); var topojsonUtils = require('../../lib/topojson_utils'); @@ -233,6 +234,37 @@ proto.updateProjection = function(geoCalcData, fullLayout) { axLon.range = getAutoRange(gd, axLon); axLat.range = getAutoRange(gd, axLat); + // For point data straddling the antimeridian (±180°), the naive [min, max] + // longitude range above can include a large empty span; prefer the compact + // crossing range instead. Skipped when a trace contributes region extents + // (choropleth or location-based scattergeo), whose geographic bounds are not + // captured by the point longitudes gathered here. + if(!this.hasChoropleth && geoLayout.fitbounds === 'locations') { + var lons = []; + var hasLocationData = false; + + for(var i = 0; i < geoCalcData.length; i++) { + var calcTrace = geoCalcData[i]; + var fitTrace = calcTrace[0].trace; + + // only visible traces contribute to the autorange above + if(fitTrace.visible !== true) continue; + if(fitTrace.locations) { + hasLocationData = true; + break; + } + for(var j = 0; j < calcTrace.length; j++) { + var lonlat = calcTrace[j].lonlat; + if(lonlat) lons.push(lonlat[0]); + } + } + + if(!hasLocationData) { + var fitLonRange = getFitboundsLonRange(lons); + if(fitLonRange) axLon.range = fitLonRange; + } + } + var midLon = (axLon.range[0] + axLon.range[1]) / 2; var midLat = (axLat.range[0] + axLat.range[1]) / 2; diff --git a/src/plots/geo/get_fitbounds_lon_range.js b/src/plots/geo/get_fitbounds_lon_range.js new file mode 100644 index 00000000000..232010e8956 --- /dev/null +++ b/src/plots/geo/get_fitbounds_lon_range.js @@ -0,0 +1,53 @@ +'use strict'; + +/** + * Pick a compact longitude range for `fitbounds` when the data straddles the + * antimeridian (±180°). + * + * Longitude is cyclic, so the naive [min, max] range used by the autorange + * machinery can include a large empty span when points sit on both sides of + * ±180° (e.g. lon = [131.8855, -179] spans ~311° the long way round, when the + * compact view spans ~49° across the antimeridian). This finds the largest gap + * between consecutive longitudes and, when that gap is wider than the gap across + * the antimeridian, returns the complementary range so the map shows the dense + * cluster of points rather than the empty ocean between them. + * + * The returned upper bound may exceed 180°; downstream `makeRangeBox` already + * handles longitudes that cross the antimeridian without ambiguity. + * + * @param {Array} lons : longitude values (may contain non-finite entries) + * @return {Array|null} [lonStart, lonEnd] when an antimeridian-crossing range is + * more compact, otherwise null (caller keeps the autorange result). + */ +module.exports = function getFitboundsLonRange(lons) { + var sorted = []; + for(var k = 0; k < lons.length; k++) { + if(isFinite(lons[k])) sorted.push(lons[k]); + } + if(sorted.length < 2) return null; + + sorted.sort(function(a, b) { return a - b; }); + + var n = sorted.length; + var naiveSpan = sorted[n - 1] - sorted[0]; + // Data already wraps the whole globe; there is nothing to compact. + if(naiveSpan >= 360) return null; + + // Widest gap between consecutive longitudes. + var maxGap = -Infinity; + var gapStart = -1; + for(var i = 0; i < n - 1; i++) { + var gap = sorted[i + 1] - sorted[i]; + if(gap > maxGap) { + maxGap = gap; + gapStart = i; + } + } + + // Only worth wrapping when an interior gap is wider than the gap that the + // naive [min, max] range already leaves open across the antimeridian. + var antimeridianGap = 360 - naiveSpan; + if(maxGap <= antimeridianGap) return null; + + return [sorted[gapStart + 1], sorted[gapStart] + 360]; +}; diff --git a/test/jasmine/tests/geo_test.js b/test/jasmine/tests/geo_test.js index 6faddf3c630..ae95d5bc7bc 100644 --- a/test/jasmine/tests/geo_test.js +++ b/test/jasmine/tests/geo_test.js @@ -4,6 +4,7 @@ var Lib = require('../../../src/lib'); var Geo = require('../../../src/plots/geo'); var GeoAssets = require('../../../src/assets/geo_assets'); var constants = require('../../../src/plots/geo/constants'); +var getFitboundsLonRange = require('../../../src/plots/geo/get_fitbounds_lon_range'); var geoLocationUtils = require('../../../src/lib/geo_location_utils'); var topojsonUtils = require('../../../src/lib/topojson_utils'); @@ -36,6 +37,75 @@ function move(fromX, fromY, toX, toY, delay) { }); } +describe('Test geo fitbounds longitude range', function() { + it('returns the compact crossing range when point data straddles the antimeridian', function() { + expect(getFitboundsLonRange([131.8855, -179])).toEqual([131.8855, 181]); + expect(getFitboundsLonRange([170, 175, -170])).toEqual([170, 190]); + }); + + it('keeps the naive range (null) when the data does not straddle the antimeridian', function() { + expect(getFitboundsLonRange([131.8855, 179])).toBe(null); + expect(getFitboundsLonRange([-10, 0, 20])).toBe(null); + }); + + it('keeps the naive range (null) when the data spans the whole globe', function() { + var lons = []; + for(var lon = 0; lon <= 360; lon += 2.5) lons.push(lon); + expect(getFitboundsLonRange(lons)).toBe(null); + }); + + it('returns null when fewer than two finite longitudes are available', function() { + expect(getFitboundsLonRange([10])).toBe(null); + expect(getFitboundsLonRange([NaN, 5])).toBe(null); + expect(getFitboundsLonRange([])).toBe(null); + }); +}); + +describe('Test geo fitbounds with antimeridian-straddling points', function() { + var gd; + + beforeEach(function() { gd = createGraphDiv(); }); + + afterEach(destroyGraphDiv); + + function _plot(lons) { + return Plotly.newPlot(gd, [{ + type: 'scattergeo', + mode: 'markers', + lat: [43.1155, 32.7157], + lon: lons + }], { + geo: {fitbounds: 'locations', projection: {type: 'equirectangular'}}, + width: 700, + height: 500 + }); + } + + it('centers on the compact crossing view when points straddle the antimeridian', function(done) { + // lon = [131.8855, -179] spans ~311deg the naive way; the compact view + // crosses the antimeridian, giving range [131.8855, 181] and a projection + // rotated to its mid-longitude (~156.4deg), not to the naive mid (~-24deg). + _plot([131.8855, -179]).then(function() { + var geoLayout = gd._fullLayout.geo; + expect(geoLayout.lonaxis._ax.range).toEqual([131.8855, 181]); + expect(geoLayout._subplot.projection.rotate()[0]).toBeCloseTo(-156.4, 0); + }) + .then(done, done.fail); + }); + + it('keeps the naive centering when points do not straddle the antimeridian', function(done) { + _plot([131.8855, 179]).then(function() { + var geoLayout = gd._fullLayout.geo; + // projection rotated to the naive mid-longitude (~155.4deg); the range is + // not wrapped across the antimeridian (which would rotate near -24deg or +156deg) + var rotateLon = geoLayout._subplot.projection.rotate()[0]; + expect(rotateLon).toBeLessThan(-150); + expect(rotateLon).toBeGreaterThan(-160); + }) + .then(done, done.fail); + }); +}); + describe('Test Geo layout defaults', function() { var layoutAttributes = Geo.layoutAttributes; var supplyLayoutDefaults = Geo.supplyLayoutDefaults; From f19bb264d5068d36b1b06d3c7adf4495a8b42be1 Mon Sep 17 00:00:00 2001 From: SharadhNaidu Date: Thu, 11 Jun 2026 10:13:33 +0530 Subject: [PATCH 2/2] Add draftlog for #7837 --- draftlogs/7837_fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 draftlogs/7837_fix.md diff --git a/draftlogs/7837_fix.md b/draftlogs/7837_fix.md new file mode 100644 index 00000000000..6ea5d4288b9 --- /dev/null +++ b/draftlogs/7837_fix.md @@ -0,0 +1 @@ + - Fix geo `fitbounds` to choose a compact longitude range when point data straddles the antimeridian [[#7837](https://github.com/plotly/plotly.js/pull/7837)]