5 KiB
| name | description | author | version | date |
|---|---|---|---|---|
| mapbox-heatmap-small-dataset-crash | Fix for Mapbox GL JS "ExpressionEvaluationError: Input is not a number" when using data-driven paint properties (legacy `{ property, stops }` format or interpolate expressions). Use when: (1) heatmap or fill layer crashes after filtering reduces the dataset to fewer than ~20 features, (2) switching metrics or filter parameters triggers the error intermittently, (3) color stops produce NaN values from percentile calculations. Root causes: out-of-bounds percentile index on small arrays, non-monotonic stops when min === max, and stale color stops from a previous metric remaining active when no valid values exist for the new metric. | Claude Code | 1.0.0 | 2026-02-08 |
Mapbox Heatmap Crash on Small Datasets
Problem
Mapbox GL JS throws ExpressionEvaluationError: Input is not a number when a data-driven
paint property (e.g., fill-color based on a feature property) encounters non-numeric
values in color stops or feature properties. This commonly surfaces when dynamic filtering
reduces the visible dataset to a small number of features.
Context / Trigger Conditions
- Using legacy Mapbox property functions:
{ property: "count", stops: [[val, color], ...] } - Mapbox GL JS v2+ internally converts legacy format to
["interpolate", ["linear"], ["number", ["get", "count"]], ...] - The
["number", ...]assertion throws if the input is not a number (includingNaN) - Error appears in stack as:
vi → zs → evaluate → populatePaintArray → ... → reloadTile
Common triggers:
- Filtering data down to 1-20 features (percentile index goes out of bounds)
- Switching metrics where the new metric has sparse/missing data
- Color stops from a previous metric remaining active for new data
Solution
1. Clamp percentile indices to array bounds
The most common cause: Math.round(values.length * 0.95) exceeds values.length - 1
for small arrays.
// BAD: out-of-bounds when values.length < 20
const maxIndex = Math.round(values.length * 0.95);
const max = values[maxIndex]; // undefined!
// GOOD: clamp to valid range
const maxIndex = Math.min(
Math.round(values.length * PERCENTILE_MAX),
values.length - 1
);
const max = values[maxIndex]; // always valid
Why: Math.round(1 * 0.95) = 1 but a 1-element array's max index is 0. The returned
undefined cascades: Math.max(undefined, x) = NaN, then calculateColorStops(scheme, min, NaN)
produces [[NaN, color], ...], and Mapbox's expression evaluator throws.
2. Ensure strictly monotonic stops (guard min === max)
const max = Math.max(values[maxIndex], min + 1);
When all features have the same metric value, min === max. The legacy format's internal
conversion to ["interpolate", ...] requires strictly increasing stop inputs.
3. Always set color stops (never leave stale stops)
if (values.length > 0) {
const stops = calculateColorStops(scheme, min, max);
heatmap.setColorStops(stops);
} else {
// Don't skip — stale stops from a previous metric cause range mismatches
const stops = calculateColorStops(scheme, 0, 1);
heatmap.setColorStops(stops);
}
4. Filter Infinity from metric values
.filter(v => typeof v === 'number' && isFinite(v) && v > 0)
isNaN(Infinity) is false, so Infinity passes NaN checks but can produce
non-monotonic or degenerate stops.
5. Don't inject undefined into feature properties
When injecting synthetic properties for heatmap coloring, skip features without data
rather than setting the property to undefined:
// BAD: Mapbox sees { poi_travel_7_WALK: undefined }
return { ...feature, properties: { ...feature.properties, [prop]: match?.value } };
// GOOD: feature unchanged, property simply absent
if (!match) return feature;
return { ...feature, properties: { ...feature.properties, [prop]: match.value } };
Verification
- Set a tight filter that reduces data to 1-5 features — no crash
- Switch between metrics rapidly — no crash
- Select a metric with no data (e.g., uncomputed travel mode) — graceful empty map
- All features have identical metric value — colors still render
Notes
- The legacy
{ property, stops }format still works for colors in Mapbox GL JS v2+ but is internally converted to expressions. Don't "fix" it by switching to expression format manually — color interpolation withrgba()strings works in legacy format but can break if the expression is constructed incorrectly. - The
["number", value, fallback]syntax is NOT a valid Mapbox expression."number"is a type assertion that throws on non-numbers. For fallbacks, use["coalesce", ["to-number", ["get", "prop"]], 0]instead. - HexgridHeatmap's reduce function handles
undefinedandNaNviaisNaN()checks, butisNaN(null) === false, so null values pass through as 0.
See also: react-hooks-order-early-return (hooks after early returns cause similar intermittent crashes during UI interactions)