Description
When a choropleth trace is given a custom GeoJSON FeatureCollection, a single feature whose geometry has a zero-area (degenerate) exterior ring causes Plotly.newPlot to throw:
TypeError: Cannot read properties of undefined (reading 'type')
The exception is uncaught and propagates out of calcGeoJSON, so the whole plot fails to render — not just the one bad region. Every other (valid) feature in the collection silently disappears.
This is a graceful-degradation gap: plotly already skips and warns for a sibling invalid-geometry case (a non-Polygon geometry logs "Location ... does not have a valid GeoJSON geometry." and is skipped), but the zero-area-ring case is allowed to throw instead.
Steps to reproduce
One location, one MultiPolygon whose single ring is degenerate (only 2 distinct vertices → zero area):
Plotly.newPlot('graph', [{
type: 'choropleth',
locationmode: 'geojson-id',
featureidkey: 'id',
locations: ['A'],
z: [1],
geojson: {
type: 'FeatureCollection',
features: [{
type: 'Feature',
id: 'A',
properties: {},
geometry: {
type: 'MultiPolygon',
// exterior ring has 4 positions but only 2 distinct points → zero area
coordinates: [[[[0, 0], [1, 1], [0, 0], [0, 0]]]]
}
}]
}
}]);
Actual: throws the TypeError above; nothing renders.
Expected: the malformed feature is skipped (ideally with a Lib.log warning, matching the existing invalid-geometry behavior), and any valid features still render.
Notes
Real-world trigger
This is not just synthetic. It occurs with real published GeoJSON whose ring ordering is malformed — e.g. a feature whose first ring (the GeoJSON "exterior") is a zero-area sliver while the true boundary is listed afterward as a "hole". Per the spec the first ring is the exterior, so the polygon's computed area is ~0, and the same code path is hit. A single such feature in a 188-feature NYC-neighborhoods collection blanked the entire choropleth.
Stack trace (minified v3.6.0, names mapped below)
TypeError: Cannot read properties of undefined (reading 'type')
at hk // interior-point / polylabel helper
at pDe
at Ezt // MultiPolygon centroid helper (the bug site)
at s // appendFeature (inner fn of extractTraceFeature)
at Object.Mzt [as extractTraceFeature] // lib/geojson_utils.js :: extractTraceFeature
at Object.e9t [as calcGeoJSON] // traces/choropleth/calc.js :: calcGeoJSON
at tm.update
Root cause
In src/lib/geojson_utils.js, extractTraceFeature computes each feature's representative point via a centroid helper (minified Ezt). Decompiled, that helper is:
function centroid(feature) {
var geometry = feature.geometry, repPoly; // repPoly starts undefined
if (geometry.type === 'MultiPolygon') {
var coords = geometry.coordinates, maxArea = 0;
for (var a = 0; a < coords.length; a++) {
var poly = { type: 'Polygon', coordinates: coords[a] };
var area = polygonArea(poly); // 0 for a zero-area exterior ring
if (area > maxArea) { maxArea = area; repPoly = poly; } // never runs when all areas are 0
}
} else {
repPoly = geometry;
}
return interiorPoint(repPoly).geometry.coordinates; // interiorPoint(undefined) → reads .type → throw
}
The MultiPolygon branch only assigns repPoly when a sub-polygon's area is strictly greater than the running max (initialized to 0). If every sub-polygon has area 0 — which happens for any MultiPolygon whose largest sub-polygon has a degenerate/zero-area exterior ring — repPoly remains undefined, and interiorPoint(undefined) then dereferences .type and throws. Because nothing upstream catches it, the entire calcGeoJSON pass dies and the plot renders blank.
(The single-Polygon branch is unaffected: repPoly = geometry is always defined. This matches the observed behavior that the same geometry as a Polygon renders fine but as a MultiPolygon crashes.)
Suggested fixes (any one resolves the crash; in increasing robustness)
-
Never leave repPoly undefined. Initialize it to the first sub-polygon (or fall back after the loop):
if (!repPoly) repPoly = { type: 'Polygon', coordinates: coords[0] };
-
Degrade gracefully to [NaN, NaN] — the value extractTraceFeature already uses for the empty-coordinates case (feature.properties.ct = coords.length > 0 ? centroid(feature) : [NaN, NaN]). When no positive-area sub-polygon exists, return [NaN, NaN] so the region still fills and merely lacks a hover centroid:
if (!repPoly) return [NaN, NaN];
-
Mirror the existing skip-and-warn path. Wrap the per-feature processing in extractTraceFeature so a feature whose centroid cannot be computed is logged and skipped, consistent with the current
Lib.log("Location " + ... + " does not have a valid GeoJSON geometry. ...")
branch — turning "one bad feature kills the chart" into "one bad feature is dropped with a warning."
Option 2 is the most targeted and preserves rendering of the affected region.
Miscellania
- The buggy area comparison
area > maxArea (strict, from maxArea = 0) is the crux: a zero-area largest polygon can never win it.
- A defensive guard in the centroid helper is preferable to validating input geometry, since the existing design intent (per the non-Polygon branch) is already "skip-and-warn, don't crash."
Description
When a
choroplethtrace is given a custom GeoJSONFeatureCollection, a single feature whose geometry has a zero-area (degenerate) exterior ring causesPlotly.newPlotto throw:The exception is uncaught and propagates out of
calcGeoJSON, so the whole plot fails to render — not just the one bad region. Every other (valid) feature in the collection silently disappears.This is a graceful-degradation gap: plotly already skips and warns for a sibling invalid-geometry case (a non-Polygon geometry logs
"Location ... does not have a valid GeoJSON geometry."and is skipped), but the zero-area-ring case is allowed to throw instead.Steps to reproduce
One location, one MultiPolygon whose single ring is degenerate (only 2 distinct vertices → zero area):
Actual: throws the
TypeErrorabove; nothing renders.Expected: the malformed feature is skipped (ideally with a
Lib.logwarning, matching the existing invalid-geometry behavior), and any valid features still render.Notes
Real-world trigger
This is not just synthetic. It occurs with real published GeoJSON whose ring ordering is malformed — e.g. a feature whose first ring (the GeoJSON "exterior") is a zero-area sliver while the true boundary is listed afterward as a "hole". Per the spec the first ring is the exterior, so the polygon's computed area is ~0, and the same code path is hit. A single such feature in a 188-feature NYC-neighborhoods collection blanked the entire choropleth.
Stack trace (minified v3.6.0, names mapped below)
Root cause
In
src/lib/geojson_utils.js,extractTraceFeaturecomputes each feature's representative point via a centroid helper (minifiedEzt). Decompiled, that helper is:The
MultiPolygonbranch only assignsrepPolywhen a sub-polygon's area is strictly greater than the running max (initialized to0). If every sub-polygon has area0— which happens for any MultiPolygon whose largest sub-polygon has a degenerate/zero-area exterior ring —repPolyremainsundefined, andinteriorPoint(undefined)then dereferences.typeand throws. Because nothing upstream catches it, the entirecalcGeoJSONpass dies and the plot renders blank.(The single-
Polygonbranch is unaffected:repPoly = geometryis always defined. This matches the observed behavior that the same geometry as aPolygonrenders fine but as aMultiPolygoncrashes.)Suggested fixes (any one resolves the crash; in increasing robustness)
Never leave
repPolyundefined. Initialize it to the first sub-polygon (or fall back after the loop):Degrade gracefully to
[NaN, NaN]— the valueextractTraceFeaturealready uses for the empty-coordinates case (feature.properties.ct = coords.length > 0 ? centroid(feature) : [NaN, NaN]). When no positive-area sub-polygon exists, return[NaN, NaN]so the region still fills and merely lacks a hover centroid:Mirror the existing skip-and-warn path. Wrap the per-feature processing in
extractTraceFeatureso a feature whose centroid cannot be computed is logged and skipped, consistent with the currentLib.log("Location " + ... + " does not have a valid GeoJSON geometry. ...")branch — turning "one bad feature kills the chart" into "one bad feature is dropped with a warning."
Option 2 is the most targeted and preserves rendering of the affected region.
Miscellania
area > maxArea(strict, frommaxArea = 0) is the crux: a zero-area largest polygon can never win it.