Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)]
32 changes: 32 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,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;

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];
};
70 changes: 70 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,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;
Expand Down