diff --git a/draftlogs/7891_fix.md b/draftlogs/7891_fix.md new file mode 100644 index 00000000000..2ef4f7ad5bf --- /dev/null +++ b/draftlogs/7891_fix.md @@ -0,0 +1 @@ + - Fix GeoJSON bounding-box computation for `choropleth` and `scattergeo` traces whose geometry crosses the antimeridian [[#7891](https://github.com/plotly/plotly.js/pull/7891)] diff --git a/package-lock.json b/package-lock.json index 33b6740c070..75a317c25cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,8 +14,8 @@ "@plotly/d3-sankey-circular": "0.33.1", "@plotly/regl": "^2.1.2", "@turf/area": "^7.1.0", - "@turf/bbox": "^7.1.0", "@turf/centroid": "^7.1.0", + "@turf/meta": "^7.1.0", "base64-arraybuffer": "^1.0.2", "color": "^5.0.0", "color-normalize": "1.5.0", @@ -868,21 +868,6 @@ "url": "https://opencollective.com/turf" } }, - "node_modules/@turf/bbox": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-7.1.0.tgz", - "integrity": "sha512-PdWPz9tW86PD78vSZj2fiRaB8JhUHy6piSa/QXb83lucxPK+HTAdzlDQMTKj5okRCU8Ox/25IR2ep9T8NdopRA==", - "license": "MIT", - "dependencies": { - "@turf/helpers": "^7.1.0", - "@turf/meta": "^7.1.0", - "@types/geojson": "^7946.0.10", - "tslib": "^2.6.2" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, "node_modules/@turf/centroid": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/@turf/centroid/-/centroid-7.1.0.tgz", diff --git a/package.json b/package.json index 5b3d9044e75..d2c5a1b0087 100644 --- a/package.json +++ b/package.json @@ -75,8 +75,8 @@ "@plotly/d3-sankey-circular": "0.33.1", "@plotly/regl": "^2.1.2", "@turf/area": "^7.1.0", - "@turf/bbox": "^7.1.0", "@turf/centroid": "^7.1.0", + "@turf/meta": "^7.1.0", "base64-arraybuffer": "^1.0.2", "color": "^5.0.0", "color-normalize": "1.5.0", diff --git a/src/lib/geo_location_utils.js b/src/lib/geo_location_utils.js index 5da1891ec59..a3f8a60c634 100644 --- a/src/lib/geo_location_utils.js +++ b/src/lib/geo_location_utils.js @@ -4,7 +4,8 @@ var d3 = require('@plotly/d3'); const { COUNTRIES, createLookup } = require('country-iso-search'); var { area: turfArea } = require('@turf/area'); var { centroid: turfCentroid } = require('@turf/centroid'); -var { bbox: turfBbox } = require('@turf/bbox'); +const { coordAll } = require('@turf/meta'); +const { geoBounds } = require('d3-geo'); var identity = require('./identity'); var loggers = require('./loggers'); @@ -385,10 +386,46 @@ function fetchTraceGeoData(calcData) { return promises; } -// TODO `turf/bbox` gives wrong result when the input feature/geometry -// crosses the anti-meridian. We should try to implement our own bbox logic. +/** + * Compute a `[west, south, east, north]` bounding box for a GeoJSON object + * (Feature, Geometry, FeatureCollection, or GeometryCollection). This function + * handles geometry that crosses the antimeridian. `north`/`south` will be in the + * range `[-90, 90]`; `west` will typically be in the range `[-180, 180]`; `east` + * will typically be in the range `[-180, 180]`, but when the input crosses the + * antimeridian, it will be shifted by +360° so the range will be `[180, west + 360)`. + * + * @param {object} d - a GeoJSON Feature, Geometry, FeatureCollection, or + * GeometryCollection. + * @return {[number, number, number, number]|null} `[west, south, east, north]` + * in degrees; `east` may exceed 180° when the input crosses ±180°. + * Returns `null` for input with no extractable coordinates (e.g. `Sphere`, + * empty FeatureCollection). + */ function computeBbox(d) { - return turfBbox(d); + // Extract an array containing all points contained in the GeoJSON object. + // coordAll throws an error on Sphere, malformed inputs, and nullish values. + // Treat any failure as "no bounds" so callers can null-guard uniformly. + let points; + try { + points = coordAll(d); + } catch (_) { + return null; + } + if (points.length === 0) return null; + if (points.length === 1) { + const [lon, lat] = points[0]; + return [lon, lat, lon, lat]; + } + // Pass as MultiPoint (just a bunch of vertices) to avoid + // geobounds treating collection as polygons + const [[west, south], [east, north]] = geoBounds({ type: 'MultiPoint', coordinates: points }); + + return [ + west, + south, + unwrapLonRange([west, east])[1], // Unwrap antimeridian crossing; east may exceed 180° + north + ]; } /** @@ -441,7 +478,7 @@ function getFitboundsLonRange(lons) { /** * Return an unwrapped version of a `[lon0, lon1]` longitude range. - * When the range crosses the antimeridian (`lon0 > 0`, `lon1 < 0`), + * When the range crosses the antimeridian (`lon0 > lon1`), * 360 is added to `lon1` to produce a continuous range; * otherwise the input pair is returned unchanged. Function assumes * `lon0` is west of `lon1`. @@ -449,13 +486,14 @@ function getFitboundsLonRange(lons) { * @example * unwrapLonRange([170, -170]) // → [170, 190] (span = 20°, midpoint = 180°) * unwrapLonRange([-10, 20]) // → [-10, 20] (no crossing, passthrough) + * unwrapLonRange([-5, -170]) // → [-5, 190] (mixed-sign crossing, e.g. from geoBounds) * * @param {[number, number]} lonRange - `[lon0, lon1]`, each in the range [-180, 180] * @return {[number, number]} The unwrapped range; when the input contract is * respected, `lon1` falls in the range `[lon0, lon0 + 360)`. */ function unwrapLonRange([lon0, lon1]) { - return [lon0, lon0 > 0 && lon1 < 0 ? lon1 + ANTIMERIDIAN_LON_SHIFT : lon1]; + return [lon0, lon0 > lon1 ? lon1 + ANTIMERIDIAN_LON_SHIFT : lon1]; } module.exports = { diff --git a/src/traces/choropleth/plot.js b/src/traces/choropleth/plot.js index da27bf17d78..d9ef853b5c0 100644 --- a/src/traces/choropleth/plot.js +++ b/src/traces/choropleth/plot.js @@ -12,14 +12,12 @@ var style = require('./style').style; function plot(gd, geo, calcData) { var choroplethLayer = geo.layers.backplot.select('.choroplethlayer'); - Lib.makeTraceGroups(choroplethLayer, calcData, 'trace choropleth').each(function(calcTrace) { + Lib.makeTraceGroups(choroplethLayer, calcData, 'trace choropleth').each(function (calcTrace) { var sel = d3.select(this); - var paths = sel.selectAll('path.choroplethlocation') - .data(Lib.identity); + var paths = sel.selectAll('path.choroplethlocation').data(Lib.identity); - paths.enter().append('path') - .classed('choroplethlocation', true); + paths.enter().append('path').classed('choroplethlocation', true); paths.exit().remove(); @@ -35,44 +33,59 @@ function calcGeoJSON(calcTrace, fullLayout) { var locationmode = trace.locationmode; var len = trace._length; - var features = locationmode === 'geojson-id' ? - geoUtils.extractTraceFeature(calcTrace) : - getTopojsonFeatures(trace, geo.topojson); + var features = + locationmode === 'geojson-id' + ? geoUtils.extractTraceFeature(calcTrace) + : getTopojsonFeatures(trace, geo.topojson); + + // A falsy result (Sphere feature or malformed/empty geojson) here + // falls back to per-feature bounds — effectively the same as + // fitbounds === 'locations' behavior. + const bboxGeojson = + geoLayout.fitbounds === 'geojson' && locationmode === 'geojson-id' + ? geoUtils.computeBbox(geoUtils.getTraceGeojson(trace)) + : null; var lonArray = []; var latArray = []; - for(var i = 0; i < len; i++) { + for (var i = 0; i < len; i++) { var calcPt = calcTrace[i]; - var feature = locationmode === 'geojson-id' ? - calcPt.fOut : - geoUtils.locationToFeature(locationmode, calcPt.loc, features); + var feature = + locationmode === 'geojson-id' + ? calcPt.fOut + : geoUtils.locationToFeature(locationmode, calcPt.loc, features); - if(feature) { + if (feature) { calcPt.geojson = feature; calcPt.ct = feature.properties.ct; calcPt._polygons = geoUtils.feature2polygons(feature); - var bboxFeature = geoUtils.computeBbox(feature); - lonArray.push(bboxFeature[0], bboxFeature[2]); - latArray.push(bboxFeature[1], bboxFeature[3]); + if (!bboxGeojson) { + const bboxFeature = geoUtils.computeBbox(feature); + if (bboxFeature) { + const [west, south, east, north] = bboxFeature; + lonArray.push(west, east); + latArray.push(south, north); + } + } } else { calcPt.geojson = null; } } - if(geoLayout.fitbounds === 'geojson' && locationmode === 'geojson-id') { - var bboxGeojson = geoUtils.computeBbox(geoUtils.getTraceGeojson(trace)); - lonArray = [bboxGeojson[0], bboxGeojson[2]]; - latArray = [bboxGeojson[1], bboxGeojson[3]]; + if (bboxGeojson) { + const [west, south, east, north] = bboxGeojson; + lonArray = [west, east]; + latArray = [south, north]; } - var opts = {padded: true}; + var opts = { padded: true }; trace._extremes.lon = findExtremes(geoLayout.lonaxis._ax, lonArray, opts); trace._extremes.lat = findExtremes(geoLayout.lataxis._ax, latArray, opts); } module.exports = { - calcGeoJSON: calcGeoJSON, - plot: plot + calcGeoJSON, + plot }; diff --git a/src/traces/scattergeo/plot.js b/src/traces/scattergeo/plot.js index 9124900e8b3..4fdd96d8be4 100644 --- a/src/traces/scattergeo/plot.js +++ b/src/traces/scattergeo/plot.js @@ -18,7 +18,7 @@ function plot(gd, geo, calcData) { var gTraces = Lib.makeTraceGroups(scatterLayer, calcData, 'trace scattergeo'); function removeBADNUM(d, node) { - if(d.lonlat[0] === BADNUM) { + if (d.lonlat[0] === BADNUM) { d3.select(node).remove(); } } @@ -26,38 +26,44 @@ function plot(gd, geo, calcData) { // TODO find a way to order the inner nodes on update gTraces.selectAll('*').remove(); - gTraces.each(function(calcTrace) { + gTraces.each(function (calcTrace) { var s = d3.select(this); var trace = calcTrace[0].trace; - if(subTypes.hasLines(trace) || trace.fill !== 'none') { + if (subTypes.hasLines(trace) || trace.fill !== 'none') { var lineCoords = geoJsonUtils.calcTraceToLineCoords(calcTrace); - var lineData = (trace.fill !== 'none') ? - geoJsonUtils.makePolygon(lineCoords) : - geoJsonUtils.makeLine(lineCoords); + var lineData = + trace.fill !== 'none' ? geoJsonUtils.makePolygon(lineCoords) : geoJsonUtils.makeLine(lineCoords); s.selectAll('path.js-line') - .data([{geojson: lineData, trace: trace}]) - .enter().append('path') + .data([{ geojson: lineData, trace: trace }]) + .enter() + .append('path') .classed('js-line', true) .style('stroke-miterlimit', 2); } - if(subTypes.hasMarkers(trace)) { + if (subTypes.hasMarkers(trace)) { s.selectAll('path.point') .data(Lib.identity) - .enter().append('path') + .enter() + .append('path') .classed('point', true) - .each(function(calcPt) { removeBADNUM(calcPt, this); }); + .each(function (calcPt) { + removeBADNUM(calcPt, this); + }); } - if(subTypes.hasText(trace)) { + if (subTypes.hasText(trace)) { s.selectAll('g') .data(Lib.identity) - .enter().append('g') + .enter() + .append('g') .append('text') - .each(function(calcPt) { removeBADNUM(calcPt, this); }); + .each(function (calcPt) { + removeBADNUM(calcPt, this); + }); } // call style here within topojson request callback @@ -72,35 +78,42 @@ function calcGeoJSON(calcTrace, fullLayout) { var len = trace._length; var i, calcPt; - if(Lib.isArrayOrTypedArray(trace.locations)) { + if (Lib.isArrayOrTypedArray(trace.locations)) { var locationmode = trace.locationmode; - var features = locationmode === 'geojson-id' ? - geoUtils.extractTraceFeature(calcTrace) : - getTopojsonFeatures(trace, geo.topojson); + var features = + locationmode === 'geojson-id' + ? geoUtils.extractTraceFeature(calcTrace) + : getTopojsonFeatures(trace, geo.topojson); - for(i = 0; i < len; i++) { + for (i = 0; i < len; i++) { calcPt = calcTrace[i]; - var feature = locationmode === 'geojson-id' ? - calcPt.fOut : - geoUtils.locationToFeature(locationmode, calcPt.loc, features); + var feature = + locationmode === 'geojson-id' + ? calcPt.fOut + : geoUtils.locationToFeature(locationmode, calcPt.loc, features); calcPt.lonlat = feature ? feature.properties.ct : [BADNUM, BADNUM]; } } - var opts = {padded: true}; + var opts = { padded: true }; var lonArray; var latArray; - if(geoLayout.fitbounds === 'geojson' && trace.locationmode === 'geojson-id') { - var bboxGeojson = geoUtils.computeBbox(geoUtils.getTraceGeojson(trace)); - lonArray = [bboxGeojson[0], bboxGeojson[2]]; - latArray = [bboxGeojson[1], bboxGeojson[3]]; + const bboxGeojson = + geoLayout.fitbounds === 'geojson' && trace.locationmode === 'geojson-id' + ? geoUtils.computeBbox(geoUtils.getTraceGeojson(trace)) + : null; + + if (bboxGeojson) { + const [west, south, east, north] = bboxGeojson; + lonArray = [west, east]; + latArray = [south, north]; } else { lonArray = new Array(len); latArray = new Array(len); - for(i = 0; i < len; i++) { + for (i = 0; i < len; i++) { calcPt = calcTrace[i]; lonArray[i] = calcPt.lonlat[0]; latArray[i] = calcPt.lonlat[1]; diff --git a/test/jasmine/tests/geo_test.js b/test/jasmine/tests/geo_test.js index 9ed9bbbbeb6..ada6fbb6cf4 100644 --- a/test/jasmine/tests/geo_test.js +++ b/test/jasmine/tests/geo_test.js @@ -39,59 +39,69 @@ function move(fromX, fromY, toX, toY, delay) { }); } -describe('Test geo fitbounds with antimeridian-straddling points', function() { +describe('Test geo fitbounds with antimeridian-straddling points', function () { var gd; - beforeEach(function() { gd = createGraphDiv(); }); + beforeEach(function () { + gd = createGraphDiv(); + }); afterEach(destroyGraphDiv); function _plot(lons) { - return Plotly.newPlot(gd, [{ - type: 'scattergeo', - mode: 'markers', - lat: [43.1155, 32.7157], - lon: lons - }], { - geo: {fitbounds: 'locations', projection: {type: 'equirectangular'}}, - width: 700, - height: 500 - }); + return Plotly.newPlot( + gd, + [ + { + type: 'scattergeo', + mode: 'markers', + lat: [43.1155, 32.7157], + lon: lons + } + ], + { + geo: { fitbounds: 'locations', projection: { type: 'equirectangular' } }, + width: 700, + height: 500 + } + ); } - it('centers on the compact crossing view when points straddle the antimeridian', function(done) { + it('centers on the compact crossing view when points straddle the antimeridian', function (done) { // lon = [131.8855, -179] spans ~311deg the naive way; the compact view // crosses the antimeridian, giving a range around [131.8855, 181] (padded // for markers like any fitbounds map) and a projection rotated to its // mid-longitude (~156.4deg), not to the naive mid (~-24deg). - _plot([131.8855, -179]).then(function() { - var geoLayout = gd._fullLayout.geo; - var lonRange = geoLayout.lonaxis._ax.range; - // crosses the antimeridian (upper bound past 180) and stays compact - // (~49deg plus a little padding), nowhere near the naive ~311deg. - expect(lonRange[0]).toBeLessThan(131.8855); - expect(lonRange[1]).toBeGreaterThan(181); - expect(lonRange[1] - lonRange[0]).toBeGreaterThan(49); - expect(lonRange[1] - lonRange[0]).toBeLessThan(70); - expect(geoLayout._subplot.projection.rotate()[0]).toBeCloseTo(-156.44, 1); - }) - .then(done, done.fail); + _plot([131.8855, -179]) + .then(function () { + var geoLayout = gd._fullLayout.geo; + var lonRange = geoLayout.lonaxis._ax.range; + // crosses the antimeridian (upper bound past 180) and stays compact + // (~49deg plus a little padding), nowhere near the naive ~311deg. + expect(lonRange[0]).toBeLessThan(131.8855); + expect(lonRange[1]).toBeGreaterThan(181); + expect(lonRange[1] - lonRange[0]).toBeGreaterThan(49); + expect(lonRange[1] - lonRange[0]).toBeLessThan(70); + expect(geoLayout._subplot.projection.rotate()[0]).toBeCloseTo(-156.44, 1); + }) + .then(done, done.fail); }); - it('keeps the naive centering when points do not straddle the antimeridian', function(done) { - _plot([131.8855, 179]).then(function() { - var geoLayout = gd._fullLayout.geo; - // projection rotated to the naive mid-longitude (~155.4deg); the range is - // not wrapped across the antimeridian (which would rotate near -24deg or +156deg) - var rotateLon = geoLayout._subplot.projection.rotate()[0]; - expect(rotateLon).toBeLessThan(-150); - expect(rotateLon).toBeGreaterThan(-160); - }) - .then(done, done.fail); + it('keeps the naive centering when points do not straddle the antimeridian', function (done) { + _plot([131.8855, 179]) + .then(function () { + var geoLayout = gd._fullLayout.geo; + // projection rotated to the naive mid-longitude (~155.4deg); the range is + // not wrapped across the antimeridian (which would rotate near -24deg or +156deg) + var rotateLon = geoLayout._subplot.projection.rotate()[0]; + expect(rotateLon).toBeLessThan(-150); + expect(rotateLon).toBeGreaterThan(-160); + }) + .then(done, done.fail); }); }); -describe('Test Geo layout defaults', function() { +describe('Test Geo layout defaults', function () { var layoutAttributes = Geo.layoutAttributes; var supplyLayoutDefaults = Geo.supplyLayoutDefaults; @@ -871,7 +881,7 @@ describe('geojson / topojson utils', function () { it('with *country names* locationmode and a custom country code', () => { const out = _locationToFeature(topojson, 'Aksai Chin', 'country names'); - + expect(out.id).toEqual('XAC'); }); }); @@ -2651,7 +2661,7 @@ describe('Test geo zoom/pan/drag interactions:', function () { newPlot(fig) .then(function () { - _assert('base', [[undefined, undefined], undefined], [[0.252, -19.8], 160], undefined); + _assert('base', [[undefined, undefined], undefined], [[-76.014, -19.8], 160], undefined); return drag({ path: [ [250, 250], @@ -2663,8 +2673,8 @@ describe('Test geo zoom/pan/drag interactions:', function () { .then(function () { _assert( 'after east-west drag', - [[-20.32, 21.226], 1], - [[20.32, -21.226], 160], + [[55.99, 21.103], 1], + [[-55.99, -21.103], 160], [ 'geo.projection.rotation.lon', 'geo.projection.rotation.lat', @@ -2677,8 +2687,8 @@ describe('Test geo zoom/pan/drag interactions:', function () { .then(function () { _assert( 'after scroll', - [[-17.5597, 18.862], 1.1488], - [[17.5597, -18.862], 183.818], + [[58.694, 18.759], 1.1488], + [[-58.694, -18.759], 183.818], ['geo.projection.rotation.lon', 'geo.projection.rotation.lat', 'geo.projection.scale'] ); return Plotly.relayout(gd, 'geo.showocean', false); @@ -2686,8 +2696,8 @@ describe('Test geo zoom/pan/drag interactions:', function () { .then(function () { _assert( 'after some relayout call that causes a replot', - [[-17.5597, 18.862], 1.1488], - [[17.5597, -18.862], 183.818], + [[58.694, 18.759], 1.1488], + [[-58.694, -18.759], 183.818], ['geo.showocean'] ); return dblClick([350, 250]); @@ -2697,7 +2707,7 @@ describe('Test geo zoom/pan/drag interactions:', function () { _assert( 'after double click', [[undefined, undefined], undefined], - [[0.252, -19.8], 160], + [[-76.014, -19.8], 160], 'dblclick' ); }) @@ -3152,11 +3162,6 @@ describe('Test geo interactions update marker angles:', function () { expect(newPath).toEqual( 'M0,0L18.27769005891461,8.119485581627321L19.559475756661865,-4.174554841483899Z' ); - - expect(newPath).not.toEqual(initialPath); - expect(newPath).toEqual( - 'M0,0L18.27769005891461,8.119485581627321L19.559475756661865,-4.174554841483899Z' - ); expect(initialPath).toEqual( 'M0,0L-1.5094067529528923,19.942960945008643L10.501042615957648,17.021401351764233Z' ); diff --git a/test/jasmine/tests/lib_geo_location_utils_test.js b/test/jasmine/tests/lib_geo_location_utils_test.js index 4cd05e425a5..b2c486ed81a 100644 --- a/test/jasmine/tests/lib_geo_location_utils_test.js +++ b/test/jasmine/tests/lib_geo_location_utils_test.js @@ -1,4 +1,9 @@ -const { getFitboundsLonRange, unwrapLonRange, doesCrossAntiMeridian } = require('../../../src/lib/geo_location_utils'); +const { + computeBbox, + 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', () => { @@ -36,17 +41,160 @@ describe('Test geo_location_utils.unwrapLonRange', () => { expect(unwrapLonRange([-170, -10])).toEqual([-170, -10]); expect(unwrapLonRange([10, 170])).toEqual([10, 170]); }); + + it('unwraps same-sign descending pairs', () => { + expect(unwrapLonRange([-5, -170])).toEqual([-5, 190]); + expect(unwrapLonRange([170, 5])).toEqual([170, 365]); + }); +}); + +describe('Test geo_location_utils.computeBbox', () => { + const franceCCW = { + type: 'Polygon', + coordinates: [ + [ + [-5, 41], + [10, 41], + [10, 51], + [-5, 51], + [-5, 41] + ] + ] + }; + const franceCW = { + type: 'Polygon', + coordinates: [ + [ + [-5, 41], + [-5, 51], + [10, 51], + [10, 41], + [-5, 41] + ] + ] + }; + // Fiji-ish narrow band crossing the antimeridian. + const fiji = { + type: 'Polygon', + coordinates: [ + [ + [176, -19], + [180, -19], + [-178, -19], + [-178, -16], + [180, -16], + [176, -16], + [176, -19] + ] + ] + }; + // Russia-ish MultiPolygon with parts on both sides of ±180. + const russia = { + type: 'MultiPolygon', + coordinates: [ + [ + [ + [30, 55], + [170, 55], + [170, 75], + [30, 75], + [30, 55] + ] + ], + [ + [ + [-180, 65], + [-170, 65], + [-170, 72], + [-180, 72], + [-180, 65] + ] + ] + ] + }; + + it('returns a degenerate bbox for a single Point', () => { + expect(computeBbox({ type: 'Point', coordinates: [10, 45] })).toEqual([10, 45, 10, 45]); + }); + + it('returns a normal bbox for a non-antimeridian polygon', () => { + expect(computeBbox(franceCCW)).toEqual([-5, 41, 10, 51]); + }); + + it('is winding-agnostic (CCW and CW polygons yield the same bbox)', () => { + expect(computeBbox(franceCW)).toEqual(computeBbox(franceCCW)); + }); + + it('unwraps east past 180° for a polygon that crosses the antimeridian', () => { + expect(computeBbox(fiji)).toEqual([176, -19, 182, -16]); + }); + + it('unwraps east past 180° for a MultiPolygon that crosses the antimeridian', () => { + expect(computeBbox(russia)).toEqual([30, 55, 190, 75]); + }); + + it('handles a FeatureCollection mixing antimeridian and non-antimeridian features', () => { + const fc = { + type: 'FeatureCollection', + features: [ + { type: 'Feature', geometry: russia, properties: {} }, + { type: 'Feature', geometry: franceCCW, properties: {} } + ] + }; + expect(computeBbox(fc)).toEqual([-5, 41, 190, 75]); + }); + + it('unwraps identically whether the input is a raw Geometry or wrapped in a Feature', () => { + const raw = computeBbox(russia); + const wrapped = computeBbox({ type: 'Feature', geometry: russia, properties: {} }); + expect(wrapped).toEqual(raw); + }); + + it('returns null for inputs with no extractable coordinates', () => { + expect(computeBbox({ type: 'Sphere' })).toBe(null); + expect(computeBbox({ type: 'FeatureCollection', features: [] })).toBe(null); + }); + + it('returns null for nullish or malformed inputs instead of throwing', () => { + expect(computeBbox(null)).toBe(null); + expect(computeBbox(undefined)).toBe(null); + expect(computeBbox({})).toBe(null); + }); }); 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); + 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([ + [-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); });