@@ -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+
82104function 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+
384466module . 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} ;
0 commit comments