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
114 changes: 97 additions & 17 deletions src/lib/geo_location_utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,28 @@ function locationToFeature(locationmode, location, features) {
return false;
}

// Offset used to lift negative longitudes (-180..0) into a continuous frame
// (180..360) so polygons and points that straddle the antimeridian can be
// compared with linear math. Shared between polygon stitching and hover
// hit-testing so both sides stay in sync.
const ANTIMERIDIAN_LON_SHIFT = 360;

/**
* Find the first index where a polygon ring crosses the antimeridian
* (a transition from positive to negative longitude between consecutive
* points). Returns null when no crossing is found.
*
* @param {Array<Array<number>>} pts - polygon points as [lon, lat] pairs
* @return {number|null} index of the segment that crosses, or null
*/
function doesCrossAntiMeridian(pts) {
for (let l = 0; l < pts.length - 1; l++) {
if (pts[l][0] > 0 && pts[l + 1][0] < 0) return l;
}

return null;
}

function feature2polygons(feature) {
var geometry = feature.geometry;
var coords = geometry.coordinates;
Expand All @@ -87,13 +109,6 @@ function feature2polygons(feature) {
var polygons = [];
var appendPolygon, j, k, m;

function doesCrossAntiMerdian(pts) {
for (var l = 0; l < pts.length - 1; l++) {
if (pts[l][0] > 0 && pts[l + 1][0] < 0) return l;
}
return null;
}

if (loc === 'RUS' || loc === 'FJI') {
// Russia and Fiji have landmasses that cross the antimeridian,
// we need to add +360 to their longitude coordinates, so that
Expand All @@ -105,13 +120,13 @@ function feature2polygons(feature) {
appendPolygon = function (_pts) {
var pts;

if (doesCrossAntiMerdian(_pts) === null) {
if (doesCrossAntiMeridian(_pts) === null) {
pts = _pts;
} else {
pts = new Array(_pts.length);
for (m = 0; m < _pts.length; m++) {
// do not mutate calcdata[i][j].geojson !!
pts[m] = [_pts[m][0] < 0 ? _pts[m][0] + 360 : _pts[m][0], _pts[m][1]];
pts[m] = [_pts[m][0] < 0 ? _pts[m][0] + ANTIMERIDIAN_LON_SHIFT : _pts[m][0], _pts[m][1]];
}
}

Expand All @@ -121,7 +136,7 @@ function feature2polygons(feature) {
// Antarctica has a landmass that wraps around every longitudes which
// confuses the 'contains' methods.
appendPolygon = function (pts) {
var crossAntiMeridianIndex = doesCrossAntiMerdian(pts);
var crossAntiMeridianIndex = doesCrossAntiMeridian(pts);

// polygon that do not cross anti-meridian need no special handling
if (crossAntiMeridianIndex === null) {
Expand All @@ -139,7 +154,7 @@ function feature2polygons(feature) {

for (m = 0; m < pts.length; m++) {
if (m > crossAntiMeridianIndex) {
stitch[si++] = [pts[m][0] + 360, pts[m][1]];
stitch[si++] = [pts[m][0] + ANTIMERIDIAN_LON_SHIFT, pts[m][1]];
} else if (m === crossAntiMeridianIndex) {
stitch[si++] = pts[m];
stitch[si++] = [pts[m][0], -90];
Expand Down Expand Up @@ -381,11 +396,76 @@ function computeBbox(d) {
return turfBbox(d);
}

/**
* Pick a compact longitude range for `fitbounds`-style auto-framing 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` (and
* MapLibre's `LngLatBounds`) handle ranges 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).
*/
function getFitboundsLonRange(lons) {
const sorted = lons.filter(isFinite).sort((a, b) => a - b);
if (sorted.length < 2) return null;

const n = sorted.length;
const 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.
let maxGap = -Infinity;
let gapStart = -1;
for (let i = 0; i < n - 1; i++) {
const 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.
const antimeridianGap = 360 - naiveSpan;
if (maxGap <= antimeridianGap) return null;

return [sorted[gapStart + 1], sorted[gapStart] + ANTIMERIDIAN_LON_SHIFT];
}

/**
* Return a monotonic version of a `[lon0, lon1]` longitude range so its
* midpoint and span can be computed as if longitude were a regular linear
* coordinate. When the range crosses the antimeridian (`lon0 > 0`, `lon1 < 0`)
* `lon1` is shifted by +360°; otherwise the input pair is returned unchanged.
*
* @param {[number, number]} lonRange - `[lon0, lon1]`, each in [-180, 180]
* @return {[number, number]} the unwrapped range
*/
function unwrapLonRange([lon0, lon1]) {
return [lon0, lon0 > 0 && lon1 < 0 ? lon1 + ANTIMERIDIAN_LON_SHIFT : lon1];
}

module.exports = {
locationToFeature: locationToFeature,
feature2polygons: feature2polygons,
getTraceGeojson: getTraceGeojson,
extractTraceFeature: extractTraceFeature,
fetchTraceGeoData: fetchTraceGeoData,
computeBbox: computeBbox
locationToFeature,
feature2polygons,
getTraceGeojson,
extractTraceFeature,
fetchTraceGeoData,
computeBbox,
doesCrossAntiMeridian,
getFitboundsLonRange,
unwrapLonRange,
ANTIMERIDIAN_LON_SHIFT
};
8 changes: 2 additions & 6 deletions src/plots/geo/geo.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ 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');
const { getFitboundsLonRange, unwrapLonRange } = geoUtils;
var topojsonUtils = require('../../lib/topojson_utils');
var topojsonFeature = require('topojson-client').feature;

Expand Down Expand Up @@ -827,14 +827,10 @@ function makeGraticule(axisName, geoLayout, fullLayout) {
// Note that clipPad padding is added around range to avoid aliasing.
function makeRangeBox(lon, lat) {
var clipPad = constants.clipPad;
var lon0 = lon[0] + clipPad;
var lon1 = lon[1] - clipPad;
const [lon0, lon1] = unwrapLonRange([lon[0] + clipPad, lon[1] - clipPad]);
var lat0 = lat[0] + clipPad;
var lat1 = lat[1] - clipPad;

// to cross antimeridian w/o ambiguity
if(lon0 > 0 && lon1 < 0) lon1 += 360;

var dlon4 = (lon1 - lon0) / 4;

return {
Expand Down
53 changes: 0 additions & 53 deletions src/plots/geo/get_fitbounds_lon_range.js

This file was deleted.

6 changes: 2 additions & 4 deletions src/plots/geo/layout_defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
var Lib = require('../../lib');
var handleSubplotDefaults = require('../subplot_defaults');
var getSubplotData = require('../get_data').getSubplotData;
const { unwrapLonRange } = require('../../lib/geo_location_utils');

var constants = require('./constants');
var layoutAttributes = require('./layout_attributes');
Expand Down Expand Up @@ -108,10 +109,7 @@ function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce, opts) {
var lonRange = geoLayoutOut.lonaxis.range;
var latRange = geoLayoutOut.lataxis.range;

// to cross antimeridian w/o ambiguity
var lon0 = lonRange[0];
var lon1 = lonRange[1];
if(lon0 > 0 && lon1 < 0) lon1 += 360;
const [lon0, lon1] = unwrapLonRange(lonRange);

var centerLon = (lon0 + lon1) / 2;
var projLon;
Expand Down
7 changes: 5 additions & 2 deletions src/traces/choropleth/hover.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
var Axes = require('../../plots/cartesian/axes');
var attributes = require('./attributes');
var fillText = require('../../lib').fillText;
const { ANTIMERIDIAN_LON_SHIFT } = require('../../lib/geo_location_utils');

module.exports = function hoverPoints(pointData, xval, yval) {
var cd = pointData.cd;
Expand All @@ -12,7 +13,10 @@ module.exports = function hoverPoints(pointData, xval, yval) {
var pt, i, j, isInside;

var xy = [xval, yval];
var altXy = [xval + 360, yval];
// Polygons that cross the antimerdian are shifted by
// ANTIMERIDIAN_LON_SHIFT in feature2polygons (src/lib/geo_location_utils.js),
// so test the hover point in both the original and shifted frames.
const altXy = [xval + ANTIMERIDIAN_LON_SHIFT, yval];

for(i = 0; i < cd.length; i++) {
pt = cd[i];
Expand All @@ -23,7 +27,6 @@ module.exports = function hoverPoints(pointData, xval, yval) {
if(pt._polygons[j].contains(xy)) {
isInside = !isInside;
}
// for polygons that cross antimeridian as xval is in [-180, 180]
if(pt._polygons[j].contains(altXy)) {
isInside = !isInside;
}
Expand Down
25 changes: 0 additions & 25 deletions test/jasmine/tests/geo_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ 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 @@ -37,30 +36,6 @@ 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;

Expand Down
53 changes: 53 additions & 0 deletions test/jasmine/tests/lib_geo_location_utils_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
const { getFitboundsLonRange, unwrapLonRange, doesCrossAntiMeridian } = require('../../../src/lib/geo_location_utils');

describe('Test geo_location_utils.getFitboundsLonRange', () => {
it('returns the compact crossing range when point data straddles the antimeridian', () => {
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', () => {
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', () => {
const lons = [];
for (let 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', () => {
expect(getFitboundsLonRange([10])).toBe(null);
expect(getFitboundsLonRange([NaN, 5])).toBe(null);
expect(getFitboundsLonRange([])).toBe(null);
});
});

describe('Test geo_location_utils.unwrapLonRange', () => {
it('shifts lon1 by +360 when the range crosses the antimeridian', () => {
expect(unwrapLonRange([170, -170])).toEqual([170, 190]);
expect(unwrapLonRange([1, -1])).toEqual([1, 359]);
});

it('leaves the pair unchanged when the range does not cross the antimeridian', () => {
expect(unwrapLonRange([-170, 170])).toEqual([-170, 170]);
expect(unwrapLonRange([-10, 10])).toEqual([-10, 10]);
expect(unwrapLonRange([-170, -10])).toEqual([-170, -10]);
expect(unwrapLonRange([10, 170])).toEqual([10, 170]);
});
});

describe('Test geo_location_utils.doesCrossAntiMeridian', () => {
it('returns the index of the first positive-to-negative longitude transition', () => {
expect(doesCrossAntiMeridian([[170, 0], [179, 0], [-179, 0], [-170, 0]])).toBe(1);
expect(doesCrossAntiMeridian([[1, 0], [-1, 0]])).toBe(0);
});

it('returns null when no segment crosses the antimeridian', () => {
expect(doesCrossAntiMeridian([[-179, 0], [-170, 0], [170, 0]])).toBe(null);
expect(doesCrossAntiMeridian([[10, 0], [20, 0], [30, 0]])).toBe(null);
expect(doesCrossAntiMeridian([])).toBe(null);
expect(doesCrossAntiMeridian([[10, 0]])).toBe(null);
});
});
Loading