From 93ff941c533a1508557aadd22e1a3c194b665d07 Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Mon, 29 Jun 2026 15:54:52 -0600 Subject: [PATCH 1/3] Move getFitboundsLonRange to new location --- src/lib/geo_location_utils.js | 61 +++++++++++++++++-- src/plots/geo/geo.js | 2 +- src/plots/geo/get_fitbounds_lon_range.js | 53 ---------------- test/jasmine/tests/geo_test.js | 25 -------- .../tests/lib_geo_location_utils_test.js | 25 ++++++++ 5 files changed, 81 insertions(+), 85 deletions(-) delete mode 100644 src/plots/geo/get_fitbounds_lon_range.js create mode 100644 test/jasmine/tests/lib_geo_location_utils_test.js diff --git a/src/lib/geo_location_utils.js b/src/lib/geo_location_utils.js index 55338602fd8..b2fc5e60952 100644 --- a/src/lib/geo_location_utils.js +++ b/src/lib/geo_location_utils.js @@ -381,11 +381,60 @@ 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] + 360]; +} + module.exports = { - locationToFeature: locationToFeature, - feature2polygons: feature2polygons, - getTraceGeojson: getTraceGeojson, - extractTraceFeature: extractTraceFeature, - fetchTraceGeoData: fetchTraceGeoData, - computeBbox: computeBbox + locationToFeature, + feature2polygons, + getTraceGeojson, + extractTraceFeature, + fetchTraceGeoData, + computeBbox, + getFitboundsLonRange }; diff --git a/src/plots/geo/geo.js b/src/plots/geo/geo.js index 497196bfff5..49d6d872f19 100644 --- a/src/plots/geo/geo.js +++ b/src/plots/geo/geo.js @@ -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'); +var getFitboundsLonRange = geoUtils.getFitboundsLonRange; var topojsonUtils = require('../../lib/topojson_utils'); var topojsonFeature = require('topojson-client').feature; diff --git a/src/plots/geo/get_fitbounds_lon_range.js b/src/plots/geo/get_fitbounds_lon_range.js deleted file mode 100644 index 232010e8956..00000000000 --- a/src/plots/geo/get_fitbounds_lon_range.js +++ /dev/null @@ -1,53 +0,0 @@ -'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]; -}; diff --git a/test/jasmine/tests/geo_test.js b/test/jasmine/tests/geo_test.js index 403e30a55a2..ab6f7122686 100644 --- a/test/jasmine/tests/geo_test.js +++ b/test/jasmine/tests/geo_test.js @@ -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'); @@ -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; diff --git a/test/jasmine/tests/lib_geo_location_utils_test.js b/test/jasmine/tests/lib_geo_location_utils_test.js new file mode 100644 index 00000000000..98d8edf7360 --- /dev/null +++ b/test/jasmine/tests/lib_geo_location_utils_test.js @@ -0,0 +1,25 @@ +const { getFitboundsLonRange } = 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); + }); +}); From c53c82266d566d25d441c41a51b09577eaf49cc0 Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Mon, 29 Jun 2026 16:34:25 -0600 Subject: [PATCH 2/3] Move geo related helper functions --- src/lib/geo_location_utils.js | 57 ++++++++++++++----- src/plots/geo/geo.js | 7 +-- src/plots/geo/layout_defaults.js | 6 +- src/traces/choropleth/hover.js | 7 ++- .../tests/lib_geo_location_utils_test.js | 30 +++++++++- 5 files changed, 82 insertions(+), 25 deletions(-) diff --git a/src/lib/geo_location_utils.js b/src/lib/geo_location_utils.js index b2fc5e60952..323ef0f44ca 100644 --- a/src/lib/geo_location_utils.js +++ b/src/lib/geo_location_utils.js @@ -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>} 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; @@ -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 @@ -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]]; } } @@ -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) { @@ -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]; @@ -426,7 +441,20 @@ function getFitboundsLonRange(lons) { const antimeridianGap = 360 - naiveSpan; if (maxGap <= antimeridianGap) return null; - return [sorted[gapStart + 1], sorted[gapStart] + 360]; + 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 = { @@ -436,5 +464,8 @@ module.exports = { extractTraceFeature, fetchTraceGeoData, computeBbox, - getFitboundsLonRange + doesCrossAntiMeridian, + getFitboundsLonRange, + unwrapLonRange, + ANTIMERIDIAN_LON_SHIFT }; diff --git a/src/plots/geo/geo.js b/src/plots/geo/geo.js index 49d6d872f19..09d4532f785 100644 --- a/src/plots/geo/geo.js +++ b/src/plots/geo/geo.js @@ -27,6 +27,7 @@ var constants = require('./constants'); var geoUtils = require('../../lib/geo_location_utils'); var getFitboundsLonRange = geoUtils.getFitboundsLonRange; +var unwrapLonRange = geoUtils.unwrapLonRange; var topojsonUtils = require('../../lib/topojson_utils'); var topojsonFeature = require('topojson-client').feature; @@ -827,14 +828,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 { diff --git a/src/plots/geo/layout_defaults.js b/src/plots/geo/layout_defaults.js index cfb73a3c266..e1baa8a2725 100644 --- a/src/plots/geo/layout_defaults.js +++ b/src/plots/geo/layout_defaults.js @@ -3,6 +3,7 @@ var Lib = require('../../lib'); var handleSubplotDefaults = require('../subplot_defaults'); var getSubplotData = require('../get_data').getSubplotData; +var unwrapLonRange = require('../../lib/geo_location_utils').unwrapLonRange; var constants = require('./constants'); var layoutAttributes = require('./layout_attributes'); @@ -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; diff --git a/src/traces/choropleth/hover.js b/src/traces/choropleth/hover.js index 0f58dec902e..424628707e8 100644 --- a/src/traces/choropleth/hover.js +++ b/src/traces/choropleth/hover.js @@ -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; @@ -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]; @@ -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; } diff --git a/test/jasmine/tests/lib_geo_location_utils_test.js b/test/jasmine/tests/lib_geo_location_utils_test.js index 98d8edf7360..4cd05e425a5 100644 --- a/test/jasmine/tests/lib_geo_location_utils_test.js +++ b/test/jasmine/tests/lib_geo_location_utils_test.js @@ -1,4 +1,4 @@ -const { getFitboundsLonRange } = require('../../../src/lib/geo_location_utils'); +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', () => { @@ -23,3 +23,31 @@ describe('Test geo_location_utils.getFitboundsLonRange', () => { 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); + }); +}); From 680abd26f8cdbdd5045d555011248b88e2118ce6 Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Mon, 29 Jun 2026 16:59:01 -0600 Subject: [PATCH 3/3] Update syntax --- src/plots/geo/geo.js | 3 +-- src/plots/geo/layout_defaults.js | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/plots/geo/geo.js b/src/plots/geo/geo.js index 09d4532f785..693c64550d5 100644 --- a/src/plots/geo/geo.js +++ b/src/plots/geo/geo.js @@ -26,8 +26,7 @@ var createGeoZoom = require('./zoom'); var constants = require('./constants'); var geoUtils = require('../../lib/geo_location_utils'); -var getFitboundsLonRange = geoUtils.getFitboundsLonRange; -var unwrapLonRange = geoUtils.unwrapLonRange; +const { getFitboundsLonRange, unwrapLonRange } = geoUtils; var topojsonUtils = require('../../lib/topojson_utils'); var topojsonFeature = require('topojson-client').feature; diff --git a/src/plots/geo/layout_defaults.js b/src/plots/geo/layout_defaults.js index e1baa8a2725..2c83c8d360d 100644 --- a/src/plots/geo/layout_defaults.js +++ b/src/plots/geo/layout_defaults.js @@ -3,7 +3,7 @@ var Lib = require('../../lib'); var handleSubplotDefaults = require('../subplot_defaults'); var getSubplotData = require('../get_data').getSubplotData; -var unwrapLonRange = require('../../lib/geo_location_utils').unwrapLonRange; +const { unwrapLonRange } = require('../../lib/geo_location_utils'); var constants = require('./constants'); var layoutAttributes = require('./layout_attributes');