From 44f3d84377baf6333190e8a93cb03a088ae1682d Mon Sep 17 00:00:00 2001 From: SharadhNaidu Date: Thu, 11 Jun 2026 10:05:54 +0530 Subject: [PATCH 1/3] 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/3] 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)] From 8575782a3bf1db2dced46b5eb6a0597d31bfe49f Mon Sep 17 00:00:00 2001 From: SharadhNaidu Date: Mon, 15 Jun 2026 22:08:57 +0530 Subject: [PATCH 3/3] Pad the compact fitbounds longitude range like getAutoRange The antimeridian fix replaced the padded naive range with a tight [min, max], so markers ended up flush against the frame on straddling maps while every other fitbounds map leaves a margin. Scale the padding getAutoRange already applied to the naive range down to the narrower crossing range and apply it. The padding is symmetric, so the mid-longitude the projection centers on is unchanged. Loosen the integration test's range assertion to match. --- src/plots/geo/geo.js | 16 +++++++++++++++- test/jasmine/tests/geo_test.js | 13 ++++++++++--- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/plots/geo/geo.js b/src/plots/geo/geo.js index fa9d641511d..dc94ecb48ec 100644 --- a/src/plots/geo/geo.js +++ b/src/plots/geo/geo.js @@ -261,7 +261,21 @@ proto.updateProjection = function(geoCalcData, fullLayout) { if(!hasLocationData) { var fitLonRange = getFitboundsLonRange(lons); - if(fitLonRange) axLon.range = fitLonRange; + if(fitLonRange) { + // getFitboundsLonRange returns a tight [min, max]. getAutoRange + // pads the naive range (for marker size and the standard + // margin), so scale that padding to the narrower crossing range + // and apply it, keeping markers off the frame edge as on any + // other fitbounds map. The padding is symmetric, so the + // mid-longitude the projection centers on is unchanged. + var lonDataSpan = Lib.aggNums(Math.max, null, lons) - + Lib.aggNums(Math.min, null, lons); + var lonPad = lonDataSpan > 0 ? + (axLon.range[1] - axLon.range[0] - lonDataSpan) / 2 * + (fitLonRange[1] - fitLonRange[0]) / lonDataSpan : + 0; + axLon.range = [fitLonRange[0] - lonPad, fitLonRange[1] + lonPad]; + } } } diff --git a/test/jasmine/tests/geo_test.js b/test/jasmine/tests/geo_test.js index ae95d5bc7bc..397fc32f533 100644 --- a/test/jasmine/tests/geo_test.js +++ b/test/jasmine/tests/geo_test.js @@ -83,11 +83,18 @@ describe('Test geo fitbounds with antimeridian-straddling points', function() { 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). + // crosses the antimeridian, giving a range around [131.8855, 181] (padded + // for markers like any fitbounds map) 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]); + var lonRange = geoLayout.lonaxis._ax.range; + // crosses the antimeridian (upper bound past 180) and stays compact + // (~49deg plus a little padding), nowhere near the naive ~311deg. + expect(lonRange[0]).toBeLessThan(131.8855); + expect(lonRange[1]).toBeGreaterThan(181); + expect(lonRange[1] - lonRange[0]).toBeGreaterThan(49); + expect(lonRange[1] - lonRange[0]).toBeLessThan(70); expect(geoLayout._subplot.projection.rotate()[0]).toBeCloseTo(-156.4, 0); }) .then(done, done.fail);