From ad1ba118e0452faf9ed8fef32fb5f21c23887f8c Mon Sep 17 00:00:00 2001 From: RayWang Date: Sun, 26 Jul 2026 20:59:34 +0800 Subject: [PATCH 1/5] Fix degenerate MultiPolygon centroids --- src/lib/geo_location_utils.js | 3 + .../tests/lib_geo_location_utils_test.js | 65 ++++++++++++++++++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/lib/geo_location_utils.js b/src/lib/geo_location_utils.js index 004100f72e5..68d495f13c3 100644 --- a/src/lib/geo_location_utils.js +++ b/src/lib/geo_location_utils.js @@ -331,6 +331,9 @@ function findCentroid(feature) { poly = geometry; } + // Degenerate MultiPolygons may not contain a positive-area polygon. + if (!poly) 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..3465cee32a8 100644 --- a/test/jasmine/tests/lib_geo_location_utils_test.js +++ b/test/jasmine/tests/lib_geo_location_utils_test.js @@ -1,4 +1,67 @@ -const { getFitboundsLonRange, unwrapLonRange, doesCrossAntiMeridian } = require('../../../src/lib/geo_location_utils'); +const { + extractTraceFeature, + getFitboundsLonRange, + unwrapLonRange, + doesCrossAntiMeridian +} = require('../../../src/lib/geo_location_utils'); + +describe('Test geo_location_utils.extractTraceFeature', () => { + it('keeps degenerate MultiPolygons without affecting valid features', () => { + const trace = { + _length: 2, + geojson: { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + id: 'degenerate', + 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: 'degenerate', trace }, + { loc: 'valid', trace } + ]; + + const features = extractTraceFeature(calcTrace); + + expect(features.length).toBe(2); + expect(features[0].id).toBe('degenerate'); + 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); + }); +}); describe('Test geo_location_utils.getFitboundsLonRange', () => { it('returns the compact crossing range when point data straddles the antimeridian', () => { From 23a9eb8263f72dc1aaf8f5631ab6339f9d7a2f0c Mon Sep 17 00:00:00 2001 From: RayWang Date: Sun, 26 Jul 2026 21:00:55 +0800 Subject: [PATCH 2/5] Add draft log for #7921 --- draftlogs/7921_fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 draftlogs/7921_fix.md diff --git a/draftlogs/7921_fix.md b/draftlogs/7921_fix.md new file mode 100644 index 00000000000..abf160f7c7f --- /dev/null +++ b/draftlogs/7921_fix.md @@ -0,0 +1 @@ +- Prevent degenerate MultiPolygon features from aborting choropleth rendering [[#7921](https://github.com/plotly/plotly.js/pull/7921)] From 28883950d7472611720f467a855d86ca42425899 Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Fri, 31 Jul 2026 09:40:32 -0600 Subject: [PATCH 3/5] Add guard against rings with no points, log message --- src/lib/geo_location_utils.js | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/lib/geo_location_utils.js b/src/lib/geo_location_utils.js index 68d495f13c3..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,8 +338,10 @@ function findCentroid(feature) { poly = geometry; } - // Degenerate MultiPolygons may not contain a positive-area polygon. - if (!poly) return [NaN, NaN]; + // 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; } From e2efd58e65b66f90f5c794556278931630daa95a Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Fri, 31 Jul 2026 09:41:06 -0600 Subject: [PATCH 4/5] Add more tests, update language --- .../tests/lib_geo_location_utils_test.js | 71 +++++++++++++++++-- 1 file changed, 67 insertions(+), 4 deletions(-) diff --git a/test/jasmine/tests/lib_geo_location_utils_test.js b/test/jasmine/tests/lib_geo_location_utils_test.js index 3465cee32a8..c94602b16b5 100644 --- a/test/jasmine/tests/lib_geo_location_utils_test.js +++ b/test/jasmine/tests/lib_geo_location_utils_test.js @@ -4,9 +4,19 @@ const { 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 degenerate MultiPolygons without affecting valid features', () => { + it('keeps MultiPolygons with no positive-area polygon without affecting valid features', () => { const trace = { _length: 2, geojson: { @@ -14,7 +24,7 @@ describe('Test geo_location_utils.extractTraceFeature', () => { features: [ { type: 'Feature', - id: 'degenerate', + id: 'zero-area', geometry: { type: 'MultiPolygon', coordinates: [ @@ -49,18 +59,71 @@ describe('Test geo_location_utils.extractTraceFeature', () => { } }; const calcTrace = [ - { loc: 'degenerate', trace }, + { loc: 'zero-area', trace }, { loc: 'valid', trace } ]; const features = extractTraceFeature(calcTrace); expect(features.length).toBe(2); - expect(features[0].id).toBe('degenerate'); + 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', () => { From 7454ee02097a3d817d7cfaf439f462ca451536ed Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Fri, 31 Jul 2026 09:41:48 -0600 Subject: [PATCH 5/5] Update draftlog --- draftlogs/7921_fix.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/draftlogs/7921_fix.md b/draftlogs/7921_fix.md index abf160f7c7f..62cb0bc2125 100644 --- a/draftlogs/7921_fix.md +++ b/draftlogs/7921_fix.md @@ -1 +1 @@ -- Prevent degenerate MultiPolygon features from aborting choropleth rendering [[#7921](https://github.com/plotly/plotly.js/pull/7921)] +- 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!