Skip to content

Commit 5f78dde

Browse files
authored
Merge pull request #7837 from SharadhNaidu/fix-geo-fitbounds-antimeridian
Fix geo fitbounds to choose a compact range across the antimeridian
2 parents 06f8544 + 3597df2 commit 5f78dde

4 files changed

Lines changed: 178 additions & 0 deletions

File tree

draftlogs/7837_fix.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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)], with thanks to @SharadhNaidu for the contribution!

src/plots/geo/geo.js

Lines changed: 47 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,52 @@ 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. Restricted to fitbounds='locations' with no
240+
// region-bearing traces: choropleth, scattergeo `locations`, and the
241+
// geojson-bbox path used by fitbounds='geojson' + locationmode='geojson-id'
242+
// all carry region extents that per-point lonlat centroids don't capture.
243+
if(!this.hasChoropleth && geoLayout.fitbounds === 'locations') {
244+
var lons = [];
245+
var hasLocationData = false;
246+
247+
for(var i = 0; i < geoCalcData.length; i++) {
248+
var calcTrace = geoCalcData[i];
249+
var fitTrace = calcTrace[0].trace;
250+
251+
// only visible traces contribute to the autorange above
252+
if(fitTrace.visible !== true) continue;
253+
if(fitTrace.locations?.length) {
254+
hasLocationData = true;
255+
break;
256+
}
257+
for(var j = 0; j < calcTrace.length; j++) {
258+
var lonlat = calcTrace[j].lonlat;
259+
if(lonlat) lons.push(lonlat[0]);
260+
}
261+
}
262+
263+
if(!hasLocationData) {
264+
var fitLonRange = getFitboundsLonRange(lons);
265+
if(fitLonRange) {
266+
// getFitboundsLonRange returns a tight [min, max]. getAutoRange
267+
// pads the naive range (for marker size and the standard
268+
// margin), so scale that padding to the narrower crossing range
269+
// and apply it, keeping markers off the frame edge as on any
270+
// other fitbounds map. The padding is symmetric, so the
271+
// mid-longitude the projection centers on is unchanged.
272+
var lonDataSpan = Lib.aggNums(Math.max, null, lons) -
273+
Lib.aggNums(Math.min, null, lons);
274+
var lonPad = lonDataSpan > 0 ?
275+
(axLon.range[1] - axLon.range[0] - lonDataSpan) / 2 *
276+
(fitLonRange[1] - fitLonRange[0]) / lonDataSpan :
277+
0;
278+
axLon.range = [fitLonRange[0] - lonPad, fitLonRange[1] + lonPad];
279+
}
280+
}
281+
}
282+
236283
var midLon = (axLon.range[0] + axLon.range[1]) / 2;
237284
var midLat = (axLat.range[0] + axLat.range[1]) / 2;
238285

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: 77 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,82 @@ 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 a range around [131.8855, 181] (padded
87+
// for markers like any fitbounds map) and a projection rotated to its
88+
// mid-longitude (~156.4deg), not to the naive mid (~-24deg).
89+
_plot([131.8855, -179]).then(function() {
90+
var geoLayout = gd._fullLayout.geo;
91+
var lonRange = geoLayout.lonaxis._ax.range;
92+
// crosses the antimeridian (upper bound past 180) and stays compact
93+
// (~49deg plus a little padding), nowhere near the naive ~311deg.
94+
expect(lonRange[0]).toBeLessThan(131.8855);
95+
expect(lonRange[1]).toBeGreaterThan(181);
96+
expect(lonRange[1] - lonRange[0]).toBeGreaterThan(49);
97+
expect(lonRange[1] - lonRange[0]).toBeLessThan(70);
98+
expect(geoLayout._subplot.projection.rotate()[0]).toBeCloseTo(-156.44, 1);
99+
})
100+
.then(done, done.fail);
101+
});
102+
103+
it('keeps the naive centering when points do not straddle the antimeridian', function(done) {
104+
_plot([131.8855, 179]).then(function() {
105+
var geoLayout = gd._fullLayout.geo;
106+
// projection rotated to the naive mid-longitude (~155.4deg); the range is
107+
// not wrapped across the antimeridian (which would rotate near -24deg or +156deg)
108+
var rotateLon = geoLayout._subplot.projection.rotate()[0];
109+
expect(rotateLon).toBeLessThan(-150);
110+
expect(rotateLon).toBeGreaterThan(-160);
111+
})
112+
.then(done, done.fail);
113+
});
114+
});
115+
39116
describe('Test Geo layout defaults', function() {
40117
var layoutAttributes = Geo.layoutAttributes;
41118
var supplyLayoutDefaults = Geo.supplyLayoutDefaults;

0 commit comments

Comments
 (0)