shadimage
Terrae
uichoroplethsource: ok · hit

Map Choropleth

Thematic choropleth map with color-coded regions based on data values.

npx shadcn add "https://www.terrae.dev/choropleth.json"
free

Visual style

terminal-mono

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
mapbox-gl

Registry dependencies

https://www.terrae.dev/map.json

Files1 · source cached 10/9/2026, 6:08:45 AM

src/registry/map/choropleth.tsx
"use client"

import type mapboxgl from "mapbox-gl"

import { useEffect, useId, useRef } from "react"
import { useMap } from "./hooks"

type ChoroplethColorStop = {
  value: number
  color: string
}

type ChoroplethColorScale = {
  stops: ChoroplethColorStop[]
  interpolation?: "linear" | "step"
  nullColor?: string
}

type ChoroplethFeature<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties> = GeoJSON.Feature<
  GeoJSON.Polygon | GeoJSON.MultiPolygon,
  P
>

type ChoroplethData<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties> =
  | string
  | GeoJSON.FeatureCollection<GeoJSON.Polygon | GeoJSON.MultiPolygon, P>

type ChoroplethClickEvent<P extends GeoJSON.GeoJsonProperties> = {
  feature: ChoroplethFeature<P>
  value: number | null
  coordinates: [number, number]
}

type ChoroplethHoverEvent<P extends GeoJSON.GeoJsonProperties> = {
  feature: ChoroplethFeature<P> | null
  value: number | null
  coordinates: [number, number] | null
}

type MapChoroplethProps<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties> = {
  data: ChoroplethData<P>
  valueProperty: keyof P | string
  colorScale: ChoroplethColorScale
  fillOpacity?: number
  strokeColor?: string
  strokeWidth?: number
  strokeOpacity?: number
  hoverEnabled?: boolean
  hoverFillOpacity?: number
  hoverStrokeColor?: string
  hoverStrokeWidth?: number
  neonAnimated?: boolean
  neonColors?: string[]
  neonDuration?: number
  onClick?: (event: ChoroplethClickEvent<P>) => void
  onHover?: (event: ChoroplethHoverEvent<P>) => void
}

const DEFAULT_FILL_OPACITY = 0.8
const DEFAULT_STROKE_COLOR = "#ffffff"
const DEFAULT_STROKE_WIDTH = 2
const DEFAULT_STROKE_OPACITY = 1
const DEFAULT_NULL_COLOR = "#cccccc"
const DEFAULT_HOVER_FILL_OPACITY = 1
const DEFAULT_HOVER_STROKE_COLOR = "#000000"
const DEFAULT_HOVER_STROKE_WIDTH = 4
const DEFAULT_NEON_COLORS = ["#00ffff", "#a855f7", "#ec4899", "#f97316", "#00ffff"]
const DEFAULT_NEON_DURATION = 8000

const buildColorExpression = (
  valueProperty: string,
  colorScale: ChoroplethColorScale
): mapboxgl.Expression | string => {
  const { stops, interpolation = "linear", nullColor = DEFAULT_NULL_COLOR } = colorScale

  if (stops.length === 0) {
    return nullColor
  }

  const sortedStops = [...stops].sort((a, b) => {
    return a.value - b.value
  })

  if (interpolation === "step") {
    const stepArgs: (string | number | mapboxgl.Expression)[] = [["get", valueProperty], nullColor]

    sortedStops.forEach((stop) => {
      stepArgs.push(stop.value, stop.color)
    })

    return ["step", ...stepArgs] as mapboxgl.Expression
  }

  const interpArgs: (string | number | mapboxgl.Expression)[] = []
  sortedStops.forEach((stop) => {
    interpArgs.push(stop.value, stop.color)
  })

  return [
    "case",
    ["==", ["get", valueProperty], null],
    nullColor,
    ["!", ["has", valueProperty]],
    nullColor,
    ["interpolate", ["linear"], ["get", valueProperty], ...interpArgs],
  ] as mapboxgl.Expression
}

const hexToRgb = (hex: string): [number, number, number] => {
  const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
  if (!result) {
    return [0, 0, 0]
  }
  return [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)]
}

const rgbToHex = (r: number, g: number, b: number): string => {
  return `#${[r, g, b]
    .map((x) => {
      const hex = Math.round(x).toString(16)
      return hex.length === 1 ? "0" + hex : hex
    })
    .join("")}`
}

const interpolateColor = (color1: string, color2: string, factor: number): string => {
  const rgb1 = hexToRgb(color1)
  const rgb2 = hexToRgb(color2)
  const r = rgb1[0] + (rgb2[0] - rgb1[0]) * factor
  const g = rgb1[1] + (rgb2[1] - rgb1[1]) * factor
  const b = rgb1[2] + (rgb2[2] - rgb1[2]) * factor
  return rgbToHex(r, g, b)
}

export const MapChoropleth = <P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties>({
  data,
  valueProperty,
  colorScale,
  fillOpacity = DEFAULT_FILL_OPACITY,
  strokeColor = DEFAULT_STROKE_COLOR,
  strokeWidth = DEFAULT_STROKE_WIDTH,
  strokeOpacity = DEFAULT_STROKE_OPACITY,
  hoverEnabled = false,
  hoverFillOpacity = DEFAULT_HOVER_FILL_OPACITY,
  hoverStrokeColor = DEFAULT_HOVER_STROKE_COLOR,
  hoverStrokeWidth = DEFAULT_HOVER_STROKE_WIDTH,
  neonAnimated = false,
  neonColors = DEFAULT_NEON_COLORS,
  neonDuration = DEFAULT_NEON_DURATION,
  onClick,
  onHover,
}: MapChoroplethProps<P>) => {
  const { map, isLoaded } = useMap()
  const id = useId()
  const sourceId = `choropleth-source-${id}`
  const fillLayerId = `choropleth-fill-layer-${id}`
  const strokeLayerId = `choropleth-stroke-layer-${id}`
  const initializedRef = useRef(false)
  const hoveredFeatureIdRef = useRef<string | number | null>(null)

  const dataRef = useRef(data)
  const valuePropertyRef = useRef(valueProperty)
  const colorScaleRef = useRef(colorScale)
  const fillOpacityRef = useRef(fillOpacity)
  const strokeColorRef = useRef(strokeColor)
  const strokeWidthRef = useRef(strokeWidth)
  const strokeOpacityRef = useRef(strokeOpacity)
  const hoverEnabledRef = useRef(hoverEnabled)
  const hoverFillOpacityRef = useRef(hoverFillOpacity)
  const hoverStrokeColorRef = useRef(hoverStrokeColor)
  const hoverStrokeWidthRef = useRef(hoverStrokeWidth)
  const onClickRef = useRef(onClick)
  const onHoverRef = useRef(onHover)
  const neonAnimatedRef = useRef(neonAnimated)
  const neonColorsRef = useRef(neonColors)
  const neonDurationRef = useRef(neonDuration)
  const animationFrameRef = useRef<number | undefined>(undefined)

  dataRef.current = data
  valuePropertyRef.current = valueProperty
  colorScaleRef.current = colorScale
  fillOpacityRef.current = fillOpacity
  strokeColorRef.current = strokeColor
  strokeWidthRef.current = strokeWidth
  strokeOpacityRef.current = strokeOpacity
  hoverEnabledRef.current = hoverEnabled
  hoverFillOpacityRef.current = hoverFillOpacity
  hoverStrokeColorRef.current = hoverStrokeColor
  hoverStrokeWidthRef.current = hoverStrokeWidth
  onClickRef.current = onClick
  onHoverRef.current = onHover
  neonAnimatedRef.current = neonAnimated
  neonColorsRef.current = neonColors
  neonDurationRef.current = neonDuration

  useEffect(() => {
    if (!map) {
      return
    }

    const addLayers = (mapInstance: mapboxgl.Map) => {
      if (mapInstance.getSource(sourceId)) {
        return
      }

      mapInstance.addSource(sourceId, {
        type: "geojson",
        data: dataRef.current,
        generateId: true,
      })

      const layers = mapInstance.getStyle().layers
      let firstSymbolId: string | undefined
      if (layers) {
        for (const layer of layers) {
          if (layer.type === "symbol") {
            firstSymbolId = layer.id
            break
          }
        }
      }

      const colorExpression = buildColorExpression(String(valuePropertyRef.current), colorScaleRef.current)

      const initialFillColor = neonAnimatedRef.current ? neonColorsRef.current[0] || "#00ffff" : colorExpression

      mapInstance.addLayer(
        {
          id: fillLayerId,
          type: "fill",
          source: sourceId,
          paint: {
            "fill-color": initialFillColor,
            "fill-opacity": [
              "case",
              ["boolean", ["feature-state", "hover"], false],
              hoverFillOpacityRef.current,
              fillOpacityRef.current,
            ],
          },
        },
        firstSymbolId
      )

      mapInstance.addLayer(
        {
          id: strokeLayerId,
          type: "line",
          source: sourceId,
          layout: {
            "line-join": "round",
            "line-cap": "round",
          },
          paint: {
            "line-color": [
              "case",
              ["boolean", ["feature-state", "hover"], false],
              hoverStrokeColorRef.current,
              strokeColorRef.current,
            ],
            "line-width": [
              "case",
              ["boolean", ["feature-state", "hover"], false],
              hoverStrokeWidthRef.current,
              strokeWidthRef.current,
            ],
            "line-opacity": strokeOpacityRef.current,
          },
        },
        firstSymbolId
      )

      initializedRef.current = true
    }

    const cleanupLayers = (mapInstance: mapboxgl.Map) => {
      try {
        if (mapInstance.getLayer(strokeLayerId)) {
          mapInstance.removeLayer(strokeLayerId)
        }
        if (mapInstance.getLayer(fillLayerId)) {
          mapInstance.removeLayer(fillLayerId)
        }
        if (mapInstance.getSource(sourceId)) {
          mapInstance.removeSource(sourceId)
        }
      } catch {
        // Layers may already be removed
      }
      initializedRef.current = false
    }

    const handleStyleLoad = () => {
      initializedRef.current = false
      addLayers(map)
    }

    if (isLoaded && !initializedRef.current) {
      addLayers(map)
    }

    map.on("style.load", handleStyleLoad)

    return () => {
      map.off("style.load", handleStyleLoad)
      cleanupLayers(map)
    }
  }, [map, isLoaded, sourceId, fillLayerId, strokeLayerId])

  useEffect(() => {
    if (!map || !initializedRef.current || typeof data === "string") {
      return
    }

    try {
      const source = map.getSource(sourceId) as mapboxgl.GeoJSONSource
      if (source) {
        source.setData(data)
      }
    } catch {
      // Source may not exist during style transition
    }
  }, [map, sourceId, data])

  useEffect(() => {
    if (!map || !initializedRef.current) {
      return
    }

    try {
      const colorExpression = buildColorExpression(String(valueProperty), colorScale)

      if (map.getLayer(fillLayerId)) {
        map.setPaintProperty(fillLayerId, "fill-color", colorExpression)
        map.setPaintProperty(fillLayerId, "fill-opacity", [
          "case",
          ["boolean", ["feature-state", "hover"], false],
          hoverFillOpacity,
          fillOpacity,
        ])
      }
    } catch {
      // Layer may not exist during style transition
    }
  }, [map, fillLayerId, valueProperty, colorScale, fillOpacity, hoverFillOpacity])

  useEffect(() => {
    if (!map || !initializedRef.current) {
      return
    }

    try {
      if (map.getLayer(strokeLayerId)) {
        map.setPaintProperty(strokeLayerId, "line-color", [
          "case",
          ["boolean", ["feature-state", "hover"], false],
          hoverStrokeColor,
          strokeColor,
        ])
        map.setPaintProperty(strokeLayerId, "line-width", [
          "case",
          ["boolean", ["feature-state", "hover"], false],
          hoverStrokeWidth,
          strokeWidth,
        ])
        map.setPaintProperty(strokeLayerId, "line-opacity", strokeOpacity)
      }
    } catch {
      // Layer may not exist during style transition
    }
  }, [map, strokeLayerId, strokeColor, strokeWidth, strokeOpacity, hoverStrokeColor, hoverStrokeWidth])

  useEffect(() => {
    if (!map || !initializedRef.current) {
      return
    }

    const handleClick = (e: mapboxgl.MapMouseEvent & { features?: mapboxgl.GeoJSONFeature[] }) => {
      if (!onClickRef.current || !e.features || e.features.length === 0) {
        return
      }

      const mapFeature = e.features[0]
      const properties = (mapFeature.properties ?? {}) as NonNullable<P>
      const value = properties[String(valuePropertyRef.current) as string] as number | null

      const feature: ChoroplethFeature<P> = {
        type: "Feature",
        geometry: mapFeature.geometry as GeoJSON.Polygon | GeoJSON.MultiPolygon,
        properties: properties as P,
      }

      onClickRef.current({
        feature,
        value: value ?? null,
        coordinates: [e.lngLat.lng, e.lngLat.lat],
      })
    }

    const handleMouseMove = (e: mapboxgl.MapMouseEvent & { features?: mapboxgl.GeoJSONFeature[] }) => {
      if (!e.features || e.features.length === 0) {
        if (hoveredFeatureIdRef.current !== null) {
          map.setFeatureState({ source: sourceId, id: hoveredFeatureIdRef.current }, { hover: false })
          hoveredFeatureIdRef.current = null

          if (onHoverRef.current) {
            onHoverRef.current({
              feature: null,
              value: null,
              coordinates: null,
            })
          }
        }
        return
      }

      const mapFeature = e.features[0]

      if (hoverEnabledRef.current && mapFeature.id !== undefined) {
        if (hoveredFeatureIdRef.current !== null && hoveredFeatureIdRef.current !== mapFeature.id) {
          map.setFeatureState({ source: sourceId, id: hoveredFeatureIdRef.current }, { hover: false })
        }

        map.setFeatureState({ source: sourceId, id: mapFeature.id }, { hover: true })
        hoveredFeatureIdRef.current = mapFeature.id
      }

      if (onHoverRef.current) {
        const properties = (mapFeature.properties ?? {}) as NonNullable<P>
        const value = properties[String(valuePropertyRef.current) as string] as number | null

        const feature: ChoroplethFeature<P> = {
          type: "Feature",
          geometry: mapFeature.geometry as GeoJSON.Polygon | GeoJSON.MultiPolygon,
          properties: properties as P,
        }

        onHoverRef.current({
          feature,
          value: value ?? null,
          coordinates: [e.lngLat.lng, e.lngLat.lat],
        })
      }
    }

    const handleMouseLeave = () => {
      if (hoveredFeatureIdRef.current !== null) {
        map.setFeatureState({ source: sourceId, id: hoveredFeatureIdRef.current }, { hover: false })
        hoveredFeatureIdRef.current = null
      }

      if (onHoverRef.current) {
        onHoverRef.current({
          feature: null,
          value: null,
          coordinates: null,
        })
      }
    }

    const handleMouseEnter = () => {
      map.getCanvas().style.cursor = "pointer"
    }

    const handleMouseLeaveCanvas = () => {
      map.getCanvas().style.cursor = ""
    }

    map.on("click", fillLayerId, handleClick)
    map.on("mousemove", fillLayerId, handleMouseMove)
    map.on("mouseleave", fillLayerId, handleMouseLeave)
    map.on("mouseenter", fillLayerId, handleMouseEnter)
    map.on("mouseleave", fillLayerId, handleMouseLeaveCanvas)

    return () => {
      map.off("click", fillLayerId, handleClick)
      map.off("mousemove", fillLayerId, handleMouseMove)
      map.off("mouseleave", fillLayerId, handleMouseLeave)
      map.off("mouseenter", fillLayerId, handleMouseEnter)
      map.off("mouseleave", fillLayerId, handleMouseLeaveCanvas)
    }
  }, [map, fillLayerId, sourceId])

  useEffect(() => {
    if (!map || !isLoaded || !initializedRef.current || !neonAnimated) {
      return
    }

    const colors = neonColorsRef.current
    if (colors.length < 2) {
      return
    }

    let startTime: number | null = null

    const animate = (timestamp: number) => {
      if (!startTime) {
        startTime = timestamp
      }

      const elapsed = timestamp - startTime
      const duration = neonDurationRef.current
      const progress = (elapsed % duration) / duration
      const totalSegments = colors.length - 1
      const segmentProgress = progress * totalSegments
      const currentSegment = Math.floor(segmentProgress)
      const segmentFactor = segmentProgress - currentSegment
      const colorIndex = Math.min(currentSegment, colors.length - 2)
      const currentColor = interpolateColor(colors[colorIndex], colors[colorIndex + 1], segmentFactor)

      try {
        if (map.getLayer(fillLayerId)) {
          map.setPaintProperty(fillLayerId, "fill-color", currentColor)
        }
      } catch {
        // Layer may not exist
      }

      animationFrameRef.current = requestAnimationFrame(animate)
    }

    animationFrameRef.current = requestAnimationFrame(animate)

    return () => {
      if (animationFrameRef.current) {
        cancelAnimationFrame(animationFrameRef.current)
      }
    }
  }, [map, isLoaded, fillLayerId, neonAnimated])

  return null
}

export type {
  ChoroplethColorStop,
  ChoroplethColorScale,
  ChoroplethFeature,
  ChoroplethData,
  ChoroplethClickEvent,
  ChoroplethHoverEvent,
  MapChoroplethProps,
}

More from Terrae

Browse all components in this library →