Skip to content
Merged
1 change: 1 addition & 0 deletions draftlogs/7837_fix.md
Original file line number Diff line number Diff line change
@@ -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)], with thanks to @SharadhNaidu for the contribution!
47 changes: 47 additions & 0 deletions src/plots/geo/geo.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -233,6 +234,52 @@ 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. Restricted to fitbounds='locations' with no
// region-bearing traces: choropleth, scattergeo `locations`, and the
// geojson-bbox path used by fitbounds='geojson' + locationmode='geojson-id'
// all carry region extents that per-point lonlat centroids don't capture.
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?.length) {
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) {
// 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];
}
}
}

var midLon = (axLon.range[0] + axLon.range[1]) / 2;
var midLat = (axLat.range[0] + axLat.range[1]) / 2;

Expand Down
53 changes: 53 additions & 0 deletions src/plots/geo/get_fitbounds_lon_range.js
Original file line number Diff line number Diff line change
@@ -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];
};
77 changes: 77 additions & 0 deletions test/jasmine/tests/geo_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -36,6 +37,82 @@ 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 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;
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.44, 1);
})
.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;
Expand Down
Loading