Skip to content

Commit 44f3d84

Browse files
committed
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.
1 parent 8965c3d commit 44f3d84

3 files changed

Lines changed: 155 additions & 0 deletions

File tree

src/plots/geo/geo.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ var selectOnClick = require('../../components/selections').selectOnClick;
2424

2525
var createGeoZoom = require('./zoom');
2626
var constants = require('./constants');
27+
var getFitboundsLonRange = require('./get_fitbounds_lon_range');
2728

2829
var geoUtils = require('../../lib/geo_location_utils');
2930
var topojsonUtils = require('../../lib/topojson_utils');
@@ -233,6 +234,37 @@ proto.updateProjection = function(geoCalcData, fullLayout) {
233234
axLon.range = getAutoRange(gd, axLon);
234235
axLat.range = getAutoRange(gd, axLat);
235236

237+
// For point data straddling the antimeridian (±180°), the naive [min, max]
238+
// longitude range above can include a large empty span; prefer the compact
239+
// crossing range instead. Skipped when a trace contributes region extents
240+
// (choropleth or location-based scattergeo), whose geographic bounds are not
241+
// captured by the point longitudes gathered here.
242+
if(!this.hasChoropleth && geoLayout.fitbounds === 'locations') {
243+
var lons = [];
244+
var hasLocationData = false;
245+
246+
for(var i = 0; i < geoCalcData.length; i++) {
247+
var calcTrace = geoCalcData[i];
248+
var fitTrace = calcTrace[0].trace;
249+
250+
// only visible traces contribute to the autorange above
251+
if(fitTrace.visible !== true) continue;
252+
if(fitTrace.locations) {
253+
hasLocationData = true;
254+
break;
255+
}
256+
for(var j = 0; j < calcTrace.length; j++) {
257+
var lonlat = calcTrace[j].lonlat;
258+
if(lonlat) lons.push(lonlat[0]);
259+
}
260+
}
261+
262+
if(!hasLocationData) {
263+
var fitLonRange = getFitboundsLonRange(lons);
264+
if(fitLonRange) axLon.range = fitLonRange;
265+
}
266+
}
267+
236268
var midLon = (axLon.range[0] + axLon.range[1]) / 2;
237269
var midLat = (axLat.range[0] + axLat.range[1]) / 2;
238270

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
'use strict';
2+
3+
/**
4+
* Pick a compact longitude range for `fitbounds` when the data straddles the
5+
* antimeridian (±180°).
6+
*
7+
* Longitude is cyclic, so the naive [min, max] range used by the autorange
8+
* machinery can include a large empty span when points sit on both sides of
9+
* ±180° (e.g. lon = [131.8855, -179] spans ~311° the long way round, when the
10+
* compact view spans ~49° across the antimeridian). This finds the largest gap
11+
* between consecutive longitudes and, when that gap is wider than the gap across
12+
* the antimeridian, returns the complementary range so the map shows the dense
13+
* cluster of points rather than the empty ocean between them.
14+
*
15+
* The returned upper bound may exceed 180°; downstream `makeRangeBox` already
16+
* handles longitudes that cross the antimeridian without ambiguity.
17+
*
18+
* @param {Array} lons : longitude values (may contain non-finite entries)
19+
* @return {Array|null} [lonStart, lonEnd] when an antimeridian-crossing range is
20+
* more compact, otherwise null (caller keeps the autorange result).
21+
*/
22+
module.exports = function getFitboundsLonRange(lons) {
23+
var sorted = [];
24+
for(var k = 0; k < lons.length; k++) {
25+
if(isFinite(lons[k])) sorted.push(lons[k]);
26+
}
27+
if(sorted.length < 2) return null;
28+
29+
sorted.sort(function(a, b) { return a - b; });
30+
31+
var n = sorted.length;
32+
var naiveSpan = sorted[n - 1] - sorted[0];
33+
// Data already wraps the whole globe; there is nothing to compact.
34+
if(naiveSpan >= 360) return null;
35+
36+
// Widest gap between consecutive longitudes.
37+
var maxGap = -Infinity;
38+
var gapStart = -1;
39+
for(var i = 0; i < n - 1; i++) {
40+
var gap = sorted[i + 1] - sorted[i];
41+
if(gap > maxGap) {
42+
maxGap = gap;
43+
gapStart = i;
44+
}
45+
}
46+
47+
// Only worth wrapping when an interior gap is wider than the gap that the
48+
// naive [min, max] range already leaves open across the antimeridian.
49+
var antimeridianGap = 360 - naiveSpan;
50+
if(maxGap <= antimeridianGap) return null;
51+
52+
return [sorted[gapStart + 1], sorted[gapStart] + 360];
53+
};

test/jasmine/tests/geo_test.js

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ var Lib = require('../../../src/lib');
44
var Geo = require('../../../src/plots/geo');
55
var GeoAssets = require('../../../src/assets/geo_assets');
66
var constants = require('../../../src/plots/geo/constants');
7+
var getFitboundsLonRange = require('../../../src/plots/geo/get_fitbounds_lon_range');
78
var geoLocationUtils = require('../../../src/lib/geo_location_utils');
89
var topojsonUtils = require('../../../src/lib/topojson_utils');
910

@@ -36,6 +37,75 @@ function move(fromX, fromY, toX, toY, delay) {
3637
});
3738
}
3839

40+
describe('Test geo fitbounds longitude range', function() {
41+
it('returns the compact crossing range when point data straddles the antimeridian', function() {
42+
expect(getFitboundsLonRange([131.8855, -179])).toEqual([131.8855, 181]);
43+
expect(getFitboundsLonRange([170, 175, -170])).toEqual([170, 190]);
44+
});
45+
46+
it('keeps the naive range (null) when the data does not straddle the antimeridian', function() {
47+
expect(getFitboundsLonRange([131.8855, 179])).toBe(null);
48+
expect(getFitboundsLonRange([-10, 0, 20])).toBe(null);
49+
});
50+
51+
it('keeps the naive range (null) when the data spans the whole globe', function() {
52+
var lons = [];
53+
for(var lon = 0; lon <= 360; lon += 2.5) lons.push(lon);
54+
expect(getFitboundsLonRange(lons)).toBe(null);
55+
});
56+
57+
it('returns null when fewer than two finite longitudes are available', function() {
58+
expect(getFitboundsLonRange([10])).toBe(null);
59+
expect(getFitboundsLonRange([NaN, 5])).toBe(null);
60+
expect(getFitboundsLonRange([])).toBe(null);
61+
});
62+
});
63+
64+
describe('Test geo fitbounds with antimeridian-straddling points', function() {
65+
var gd;
66+
67+
beforeEach(function() { gd = createGraphDiv(); });
68+
69+
afterEach(destroyGraphDiv);
70+
71+
function _plot(lons) {
72+
return Plotly.newPlot(gd, [{
73+
type: 'scattergeo',
74+
mode: 'markers',
75+
lat: [43.1155, 32.7157],
76+
lon: lons
77+
}], {
78+
geo: {fitbounds: 'locations', projection: {type: 'equirectangular'}},
79+
width: 700,
80+
height: 500
81+
});
82+
}
83+
84+
it('centers on the compact crossing view when points straddle the antimeridian', function(done) {
85+
// lon = [131.8855, -179] spans ~311deg the naive way; the compact view
86+
// crosses the antimeridian, giving range [131.8855, 181] and a projection
87+
// rotated to its mid-longitude (~156.4deg), not to the naive mid (~-24deg).
88+
_plot([131.8855, -179]).then(function() {
89+
var geoLayout = gd._fullLayout.geo;
90+
expect(geoLayout.lonaxis._ax.range).toEqual([131.8855, 181]);
91+
expect(geoLayout._subplot.projection.rotate()[0]).toBeCloseTo(-156.4, 0);
92+
})
93+
.then(done, done.fail);
94+
});
95+
96+
it('keeps the naive centering when points do not straddle the antimeridian', function(done) {
97+
_plot([131.8855, 179]).then(function() {
98+
var geoLayout = gd._fullLayout.geo;
99+
// projection rotated to the naive mid-longitude (~155.4deg); the range is
100+
// not wrapped across the antimeridian (which would rotate near -24deg or +156deg)
101+
var rotateLon = geoLayout._subplot.projection.rotate()[0];
102+
expect(rotateLon).toBeLessThan(-150);
103+
expect(rotateLon).toBeGreaterThan(-160);
104+
})
105+
.then(done, done.fail);
106+
});
107+
});
108+
39109
describe('Test Geo layout defaults', function() {
40110
var layoutAttributes = Geo.layoutAttributes;
41111
var supplyLayoutDefaults = Geo.supplyLayoutDefaults;

0 commit comments

Comments
 (0)