Skip to content

Commit b633ef4

Browse files
authored
Merge pull request #7881 from plotly/cam/7879/move-geo-utilities
refactor: Move geo utilities
2 parents 34355d5 + ffdf2ab commit b633ef4

7 files changed

Lines changed: 165 additions & 107 deletions

File tree

src/lib/geo_location_utils.js

Lines changed: 103 additions & 17 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];
@@ -381,11 +396,82 @@ function computeBbox(d) {
381396
return turfBbox(d);
382397
}
383398

399+
/**
400+
* Pick a compact longitude range for `fitbounds`-style auto-framing when the
401+
* data straddles the antimeridian (±180°).
402+
*
403+
* Longitude is cyclic, so the naive [min, max] range used by the autorange
404+
* machinery can include a large empty span when points sit on both sides of
405+
* ±180° (e.g. lon = [131.8855, -179] spans ~311° the long way round, when the
406+
* compact view spans ~49° across the antimeridian). This finds the largest gap
407+
* between consecutive longitudes and, when that gap is wider than the gap across
408+
* the antimeridian, returns the complementary range so the map shows the dense
409+
* cluster of points rather than the empty ocean between them.
410+
*
411+
* The returned upper bound may exceed 180°; downstream `makeRangeBox` (and
412+
* MapLibre's `LngLatBounds`) handle ranges that cross the antimeridian without
413+
* ambiguity.
414+
*
415+
* @param {Array} lons - longitude values (may contain non-finite entries)
416+
* @return {Array|null} [lonStart, lonEnd] when an antimeridian-crossing range is
417+
* more compact, otherwise null (caller keeps the autorange result).
418+
*/
419+
function getFitboundsLonRange(lons) {
420+
const sorted = lons.filter(isFinite).sort((a, b) => a - b);
421+
if (sorted.length < 2) return null;
422+
423+
const n = sorted.length;
424+
const naiveSpan = sorted[n - 1] - sorted[0];
425+
// Data already wraps the whole globe; there is nothing to compact.
426+
if (naiveSpan >= 360) return null;
427+
428+
// Widest gap between consecutive longitudes.
429+
let maxGap = -Infinity;
430+
let gapStart = -1;
431+
for (let i = 0; i < n - 1; i++) {
432+
const gap = sorted[i + 1] - sorted[i];
433+
if (gap > maxGap) {
434+
maxGap = gap;
435+
gapStart = i;
436+
}
437+
}
438+
439+
// Only worth wrapping when an interior gap is wider than the gap that the
440+
// naive [min, max] range already leaves open across the antimeridian.
441+
const antimeridianGap = 360 - naiveSpan;
442+
if (maxGap <= antimeridianGap) return null;
443+
444+
return [sorted[gapStart + 1], sorted[gapStart] + ANTIMERIDIAN_LON_SHIFT];
445+
}
446+
447+
/**
448+
* Return an unwrapped version of a `[lon0, lon1]` longitude range.
449+
* When the range crosses the antimeridian (`lon0 > 0`, `lon1 < 0`),
450+
* 360 is added to `lon1` to produce a continuous range;
451+
* otherwise the input pair is returned unchanged. Function assumes
452+
* `lon0` is west of `lon1`.
453+
*
454+
* @example
455+
* unwrapLonRange([170, -170]) // → [170, 190] (span = 20°, midpoint = 180°)
456+
* unwrapLonRange([-10, 20]) // → [-10, 20] (no crossing, passthrough)
457+
*
458+
* @param {[number, number]} lonRange - `[lon0, lon1]`, each in the range [-180, 180]
459+
* @return {[number, number]} The unwrapped range; when the input contract is
460+
* respected, `lon1` falls in the range `[lon0, lon0 + 360)`.
461+
*/
462+
function unwrapLonRange([lon0, lon1]) {
463+
return [lon0, lon0 > 0 && lon1 < 0 ? lon1 + ANTIMERIDIAN_LON_SHIFT : lon1];
464+
}
465+
384466
module.exports = {
385-
locationToFeature: locationToFeature,
386-
feature2polygons: feature2polygons,
387-
getTraceGeojson: getTraceGeojson,
388-
extractTraceFeature: extractTraceFeature,
389-
fetchTraceGeoData: fetchTraceGeoData,
390-
computeBbox: computeBbox
467+
locationToFeature,
468+
feature2polygons,
469+
getTraceGeojson,
470+
extractTraceFeature,
471+
fetchTraceGeoData,
472+
computeBbox,
473+
doesCrossAntiMeridian,
474+
getFitboundsLonRange,
475+
unwrapLonRange,
476+
ANTIMERIDIAN_LON_SHIFT
391477
};

src/plots/geo/geo.js

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ 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');
2827

2928
var geoUtils = require('../../lib/geo_location_utils');
29+
const { getFitboundsLonRange, unwrapLonRange } = geoUtils;
3030
var topojsonUtils = require('../../lib/topojson_utils');
3131
var topojsonFeature = require('topojson-client').feature;
3232

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

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

840836
return {

src/plots/geo/get_fitbounds_lon_range.js

Lines changed: 0 additions & 53 deletions
This file was deleted.

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+
const { unwrapLonRange } = require('../../lib/geo_location_utils');
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/geo_test.js

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ 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');
87
var geoLocationUtils = require('../../../src/lib/geo_location_utils');
98
var topojsonUtils = require('../../../src/lib/topojson_utils');
109

@@ -37,30 +36,6 @@ function move(fromX, fromY, toX, toY, delay) {
3736
});
3837
}
3938

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-
6439
describe('Test geo fitbounds with antimeridian-straddling points', function() {
6540
var gd;
6641

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
const { getFitboundsLonRange, unwrapLonRange, doesCrossAntiMeridian } = require('../../../src/lib/geo_location_utils');
2+
3+
describe('Test geo_location_utils.getFitboundsLonRange', () => {
4+
it('returns the compact crossing range when point data straddles the antimeridian', () => {
5+
expect(getFitboundsLonRange([131.8855, -179])).toEqual([131.8855, 181]);
6+
expect(getFitboundsLonRange([170, 175, -170])).toEqual([170, 190]);
7+
});
8+
9+
it('keeps the naive range (null) when the data does not straddle the antimeridian', () => {
10+
expect(getFitboundsLonRange([131.8855, 179])).toBe(null);
11+
expect(getFitboundsLonRange([-10, 0, 20])).toBe(null);
12+
});
13+
14+
it('keeps the naive range (null) when the data spans the whole globe', () => {
15+
const lons = [];
16+
for (let lon = 0; lon <= 360; lon += 2.5) lons.push(lon);
17+
expect(getFitboundsLonRange(lons)).toBe(null);
18+
});
19+
20+
it('returns null when fewer than two finite longitudes are available', () => {
21+
expect(getFitboundsLonRange([10])).toBe(null);
22+
expect(getFitboundsLonRange([NaN, 5])).toBe(null);
23+
expect(getFitboundsLonRange([])).toBe(null);
24+
});
25+
});
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)