diff --git a/dot_claude/skills/mapbox-heatmap-small-dataset-crash/SKILL.md b/dot_claude/skills/mapbox-heatmap-small-dataset-crash/SKILL.md new file mode 100644 index 0000000..461d19e --- /dev/null +++ b/dot_claude/skills/mapbox-heatmap-small-dataset-crash/SKILL.md @@ -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)