diff --git a/draftlogs/7921_fix.md b/draftlogs/7921_fix.md new file mode 100644 index 00000000000..62cb0bc2125 --- /dev/null +++ b/draftlogs/7921_fix.md @@ -0,0 +1 @@ +- Prevent MultiPolygon features with no positive-area polygon from aborting choropleth rendering [[#7921](https://github.com/plotly/plotly.js/pull/7921)], with thanks to @swjturay for the contribution! diff --git a/src/lib/geo_location_utils.js b/src/lib/geo_location_utils.js index 004100f72e5..156ff9f4efc 100644 --- a/src/lib/geo_location_utils.js +++ b/src/lib/geo_location_utils.js @@ -242,11 +242,18 @@ function extractTraceFeature(calcTrace) { properties: {} }; - // Compute centroid, add it to the properties - if (fOut.geometry.coordinates.length > 0) { - fOut.properties.ct = findCentroid(fOut); - } else { - fOut.properties.ct = [NaN, NaN]; + fOut.properties.ct = findCentroid(fOut); + + if (isNaN(fOut.properties.ct[0])) { + loggers.log( + [ + 'Location', + cdi.loc, + 'has no polygon with positive area.', + 'Its centroid could not be computed,', + 'so hover and selection will not work for it.' + ].join(' ') + ); } // Mutate in in/out features into calcdata @@ -331,6 +338,11 @@ function findCentroid(feature) { poly = geometry; } + // Guard against MultiPolygons that don't contain a positive-area polygon + // (collapsed rings measure zero, malformed ring ordering measures negative) + // and when either geometry type has rings holding no points at all. + if (!poly || !poly.coordinates.some((ring) => ring.length > 0)) return [NaN, NaN]; + return turfCentroid(poly).geometry.coordinates; } diff --git a/test/jasmine/tests/lib_geo_location_utils_test.js b/test/jasmine/tests/lib_geo_location_utils_test.js index 4cd05e425a5..c94602b16b5 100644 --- a/test/jasmine/tests/lib_geo_location_utils_test.js +++ b/test/jasmine/tests/lib_geo_location_utils_test.js @@ -1,4 +1,130 @@ -const { getFitboundsLonRange, unwrapLonRange, doesCrossAntiMeridian } = require('../../../src/lib/geo_location_utils'); +const { + extractTraceFeature, + getFitboundsLonRange, + unwrapLonRange, + doesCrossAntiMeridian +} = require('../../../src/lib/geo_location_utils'); +const loggers = require('../../../src/lib/loggers'); + +function extractSingleFeature(geometry) { + const trace = { + _length: 1, + geojson: { type: 'Feature', id: 'Omicron Persei 8', geometry } + }; + + return extractTraceFeature([{ loc: 'Omicron Persei 8', trace }])[0]; +} + +describe('Test geo_location_utils.extractTraceFeature', () => { + it('keeps MultiPolygons with no positive-area polygon without affecting valid features', () => { + const trace = { + _length: 2, + geojson: { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + id: 'zero-area', + geometry: { + type: 'MultiPolygon', + coordinates: [ + [ + [ + [0, 0], + [1, 1], + [0, 0], + [0, 0] + ] + ] + ] + } + }, + { + type: 'Feature', + id: 'valid', + geometry: { + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [0, 1], + [1, 1], + [1, 0], + [0, 0] + ] + ] + } + } + ] + } + }; + const calcTrace = [ + { loc: 'zero-area', trace }, + { loc: 'valid', trace } + ]; + + const features = extractTraceFeature(calcTrace); + + expect(features.length).toBe(2); + expect(features[0].id).toBe('zero-area'); + expect(features[0].properties.ct.every(Number.isNaN)).toBe(true); + expect(features[1].id).toBe('valid'); + expect(features[1].properties.ct.every(Number.isFinite)).toBe(true); + }); + + it('keeps Polygons and MultiPolygons whose rings hold no points', () => { + const polygon = extractSingleFeature({ type: 'Polygon', coordinates: [[]] }); + const multiPolygon = extractSingleFeature({ type: 'MultiPolygon', coordinates: [[[]]] }); + + expect(polygon.properties.ct.every(Number.isNaN)).toBe(true); + expect(multiPolygon.properties.ct.every(Number.isNaN)).toBe(true); + }); + + it('logs the locations whose centroid could not be computed', () => { + spyOn(loggers, 'log'); + + extractSingleFeature({ + type: 'MultiPolygon', + coordinates: [ + [ + [ + [0, 0], + [1, 1], + [0, 0], + [0, 0] + ] + ] + ] + }); + + expect(loggers.log).toHaveBeenCalledWith( + [ + 'Location Omicron Persei 8 has no polygon with positive area.', + 'Its centroid could not be computed,', + 'so hover and selection will not work for it.' + ].join(' ') + ); + }); + + it('does not log for features with a computable centroid', () => { + spyOn(loggers, 'log'); + + extractSingleFeature({ + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [0, 1], + [1, 1], + [1, 0], + [0, 0] + ] + ] + }); + + expect(loggers.log).not.toHaveBeenCalled(); + }); +}); describe('Test geo_location_utils.getFitboundsLonRange', () => { it('returns the compact crossing range when point data straddles the antimeridian', () => {