Add mapbox-heatmap-small-dataset-crash skill

This commit is contained in:
Viktor Barzin 2026-02-08 17:49:12 +00:00
parent dc73ebb535
commit 4d0b752fa3

View file

@ -0,0 +1,127 @@
---
name: mapbox-heatmap-small-dataset-crash
description: |
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.
author: Claude Code
version: 1.0.0
date: 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 (including `NaN`)
- Error appears in stack as: `vi → zs → evaluate → populatePaintArray → ... → reloadTile`
Common triggers:
1. Filtering data down to 1-20 features (percentile index goes out of bounds)
2. Switching metrics where the new metric has sparse/missing data
3. 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.
```typescript
// 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)
```typescript
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)
```typescript
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
```typescript
.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`:
```typescript
// 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
1. Set a tight filter that reduces data to 1-5 features — no crash
2. Switch between metrics rapidly — no crash
3. Select a metric with no data (e.g., uncomputed travel mode) — graceful empty map
4. 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 with `rgba()` 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 `undefined` and `NaN` via `isNaN()` checks,
but `isNaN(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)