Skip to content

Commit c53c822

Browse files
committed
Move geo related helper functions
1 parent 93ff941 commit c53c822

5 files changed

Lines changed: 82 additions & 25 deletions

File tree

src/lib/geo_location_utils.js

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,28 @@ function locationToFeature(locationmode, location, features) {
7979
return false;
8080
}
8181

82+
// Offset used to lift negative longitudes (-180..0) into a continuous frame
83+
// (180..360) so polygons and points that straddle the antimeridian can be
84+
// compared with linear math. Shared between polygon stitching and hover
85+
// hit-testing so both sides stay in sync.
86+
const ANTIMERIDIAN_LON_SHIFT = 360;
87+
88+
/**
89+
* Find the first index where a polygon ring crosses the antimeridian
90+
* (a transition from positive to negative longitude between consecutive
91+
* points). Returns null when no crossing is found.
92+
*
93+
* @param {Array<Array<number>>} pts - polygon points as [lon, lat] pairs
94+
* @return {number|null} index of the segment that crosses, or null
95+
*/
96+
function doesCrossAntiMeridian(pts) {
97+
for (let l = 0; l < pts.length - 1; l++) {
98+
if (pts[l][0] > 0 && pts[l + 1][0] < 0) return l;
99+
}
100+
101+
return null;
102+
}
103+
82104
function feature2polygons(feature) {
83105
var geometry = feature.geometry;
84106
var coords = geometry.coordinates;
@@ -87,13 +109,6 @@ function feature2polygons(feature) {
87109
var polygons = [];
88110
var appendPolygon, j, k, m;
89111

90-
function doesCrossAntiMerdian(pts) {
91-
for (var l = 0; l < pts.length - 1; l++) {
92-
if (pts[l][0] > 0 && pts[l + 1][0] < 0) return l;
93-
}
94-
return null;
95-
}
96-
97112
if (loc === 'RUS' || loc === 'FJI') {
98113
// Russia and Fiji have landmasses that cross the antimeridian,
99114
// we need to add +360 to their longitude coordinates, so that
@@ -105,13 +120,13 @@ function feature2polygons(feature) {
105120
appendPolygon = function (_pts) {
106121
var pts;
107122

108-
if (doesCrossAntiMerdian(_pts) === null) {
123+
if (doesCrossAntiMeridian(_pts) === null) {
109124
pts = _pts;
110125
} else {
111126
pts = new Array(_pts.length);
112127
for (m = 0; m < _pts.length; m++) {
113128
// do not mutate calcdata[i][j].geojson !!
114-
pts[m] = [_pts[m][0] < 0 ? _pts[m][0] + 360 : _pts[m][0], _pts[m][1]];
129+
pts[m] = [_pts[m][0] < 0 ? _pts[m][0] + ANTIMERIDIAN_LON_SHIFT : _pts[m][0], _pts[m][1]];
115130
}
116131
}
117132

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

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

140155
for (m = 0; m < pts.length; m++) {
141156
if (m > crossAntiMeridianIndex) {
142-
stitch[si++] = [pts[m][0] + 360, pts[m][1]];
157+
stitch[si++] = [pts[m][0] + ANTIMERIDIAN_LON_SHIFT, pts[m][1]];
143158
} else if (m === crossAntiMeridianIndex) {
144159
stitch[si++] = pts[m];
145160
stitch[si++] = [pts[m][0], -90];
@@ -426,7 +441,20 @@ function getFitboundsLonRange(lons) {
426441
const antimeridianGap = 360 - naiveSpan;
427442
if (maxGap <= antimeridianGap) return null;
428443

429-
return [sorted[gapStart + 1], sorted[gapStart] + 360];
444+
return [sorted[gapStart + 1], sorted[gapStart] + ANTIMERIDIAN_LON_SHIFT];
445+
}
446+
447+
/**
448+
* Return a monotonic version of a `[lon0, lon1]` longitude range so its
449+
* midpoint and span can be computed as if longitude were a regular linear
450+
* coordinate. When the range crosses the antimeridian (`lon0 > 0`, `lon1 < 0`)
451+
* `lon1` is shifted by +360°; otherwise the input pair is returned unchanged.
452+
*
453+
* @param {[number, number]} lonRange - `[lon0, lon1]`, each in [-180, 180]
454+
* @return {[number, number]} the unwrapped range
455+
*/
456+
function unwrapLonRange([lon0, lon1]) {
457+
return [lon0, lon0 > 0 && lon1 < 0 ? lon1 + ANTIMERIDIAN_LON_SHIFT : lon1];
430458
}
431459

432460
module.exports = {
@@ -436,5 +464,8 @@ module.exports = {
436464
extractTraceFeature,
437465
fetchTraceGeoData,
438466
computeBbox,
439-
getFitboundsLonRange
467+
doesCrossAntiMeridian,
468+
getFitboundsLonRange,
469+
unwrapLonRange,
470+
ANTIMERIDIAN_LON_SHIFT
440471
};

src/plots/geo/geo.js

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ var constants = require('./constants');
2727

2828
var geoUtils = require('../../lib/geo_location_utils');
2929
var getFitboundsLonRange = geoUtils.getFitboundsLonRange;
30+
var unwrapLonRange = geoUtils.unwrapLonRange;
3031
var topojsonUtils = require('../../lib/topojson_utils');
3132
var topojsonFeature = require('topojson-client').feature;
3233

@@ -827,14 +828,10 @@ function makeGraticule(axisName, geoLayout, fullLayout) {
827828
// Note that clipPad padding is added around range to avoid aliasing.
828829
function makeRangeBox(lon, lat) {
829830
var clipPad = constants.clipPad;
830-
var lon0 = lon[0] + clipPad;
831-
var lon1 = lon[1] - clipPad;
831+
const [lon0, lon1] = unwrapLonRange([lon[0] + clipPad, lon[1] - clipPad]);
832832
var lat0 = lat[0] + clipPad;
833833
var lat1 = lat[1] - clipPad;
834834

835-
// to cross antimeridian w/o ambiguity
836-
if(lon0 > 0 && lon1 < 0) lon1 += 360;
837-
838835
var dlon4 = (lon1 - lon0) / 4;
839836

840837
return {

src/plots/geo/layout_defaults.js

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
var Lib = require('../../lib');
44
var handleSubplotDefaults = require('../subplot_defaults');
55
var getSubplotData = require('../get_data').getSubplotData;
6+
var unwrapLonRange = require('../../lib/geo_location_utils').unwrapLonRange;
67

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

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

116114
var centerLon = (lon0 + lon1) / 2;
117115
var projLon;

src/traces/choropleth/hover.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
var Axes = require('../../plots/cartesian/axes');
44
var attributes = require('./attributes');
55
var fillText = require('../../lib').fillText;
6+
const { ANTIMERIDIAN_LON_SHIFT } = require('../../lib/geo_location_utils');
67

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

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

1721
for(i = 0; i < cd.length; i++) {
1822
pt = cd[i];
@@ -23,7 +27,6 @@ module.exports = function hoverPoints(pointData, xval, yval) {
2327
if(pt._polygons[j].contains(xy)) {
2428
isInside = !isInside;
2529
}
26-
// for polygons that cross antimeridian as xval is in [-180, 180]
2730
if(pt._polygons[j].contains(altXy)) {
2831
isInside = !isInside;
2932
}

test/jasmine/tests/lib_geo_location_utils_test.js

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const { getFitboundsLonRange } = require('../../../src/lib/geo_location_utils');
1+
const { getFitboundsLonRange, unwrapLonRange, doesCrossAntiMeridian } = require('../../../src/lib/geo_location_utils');
22

33
describe('Test geo_location_utils.getFitboundsLonRange', () => {
44
it('returns the compact crossing range when point data straddles the antimeridian', () => {
@@ -23,3 +23,31 @@ describe('Test geo_location_utils.getFitboundsLonRange', () => {
2323
expect(getFitboundsLonRange([])).toBe(null);
2424
});
2525
});
26+
27+
describe('Test geo_location_utils.unwrapLonRange', () => {
28+
it('shifts lon1 by +360 when the range crosses the antimeridian', () => {
29+
expect(unwrapLonRange([170, -170])).toEqual([170, 190]);
30+
expect(unwrapLonRange([1, -1])).toEqual([1, 359]);
31+
});
32+
33+
it('leaves the pair unchanged when the range does not cross the antimeridian', () => {
34+
expect(unwrapLonRange([-170, 170])).toEqual([-170, 170]);
35+
expect(unwrapLonRange([-10, 10])).toEqual([-10, 10]);
36+
expect(unwrapLonRange([-170, -10])).toEqual([-170, -10]);
37+
expect(unwrapLonRange([10, 170])).toEqual([10, 170]);
38+
});
39+
});
40+
41+
describe('Test geo_location_utils.doesCrossAntiMeridian', () => {
42+
it('returns the index of the first positive-to-negative longitude transition', () => {
43+
expect(doesCrossAntiMeridian([[170, 0], [179, 0], [-179, 0], [-170, 0]])).toBe(1);
44+
expect(doesCrossAntiMeridian([[1, 0], [-1, 0]])).toBe(0);
45+
});
46+
47+
it('returns null when no segment crosses the antimeridian', () => {
48+
expect(doesCrossAntiMeridian([[-179, 0], [-170, 0], [170, 0]])).toBe(null);
49+
expect(doesCrossAntiMeridian([[10, 0], [20, 0], [30, 0]])).toBe(null);
50+
expect(doesCrossAntiMeridian([])).toBe(null);
51+
expect(doesCrossAntiMeridian([[10, 0]])).toBe(null);
52+
});
53+
});

0 commit comments

Comments
 (0)