{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "choropleth",
  "title": "Map Choropleth",
  "description": "Thematic choropleth map with color-coded regions based on data values.",
  "dependencies": ["mapbox-gl"],
  "devDependencies": ["@types/mapbox-gl"],
  "registryDependencies": ["https://www.terrae.dev/map.json"],
  "files": [
    {
      "path": "src/registry/map/choropleth.tsx",
      "content": "\"use client\"\n\nimport type mapboxgl from \"mapbox-gl\"\n\nimport { useEffect, useId, useRef } from \"react\"\nimport { useMap } from \"./hooks\"\n\ntype ChoroplethColorStop = {\n  value: number\n  color: string\n}\n\ntype ChoroplethColorScale = {\n  stops: ChoroplethColorStop[]\n  interpolation?: \"linear\" | \"step\"\n  nullColor?: string\n}\n\ntype ChoroplethFeature<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties> = GeoJSON.Feature<\n  GeoJSON.Polygon | GeoJSON.MultiPolygon,\n  P\n>\n\ntype ChoroplethData<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties> =\n  | string\n  | GeoJSON.FeatureCollection<GeoJSON.Polygon | GeoJSON.MultiPolygon, P>\n\ntype ChoroplethClickEvent<P extends GeoJSON.GeoJsonProperties> = {\n  feature: ChoroplethFeature<P>\n  value: number | null\n  coordinates: [number, number]\n}\n\ntype ChoroplethHoverEvent<P extends GeoJSON.GeoJsonProperties> = {\n  feature: ChoroplethFeature<P> | null\n  value: number | null\n  coordinates: [number, number] | null\n}\n\ntype MapChoroplethProps<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties> = {\n  data: ChoroplethData<P>\n  valueProperty: keyof P | string\n  colorScale: ChoroplethColorScale\n  fillOpacity?: number\n  strokeColor?: string\n  strokeWidth?: number\n  strokeOpacity?: number\n  hoverEnabled?: boolean\n  hoverFillOpacity?: number\n  hoverStrokeColor?: string\n  hoverStrokeWidth?: number\n  neonAnimated?: boolean\n  neonColors?: string[]\n  neonDuration?: number\n  onClick?: (event: ChoroplethClickEvent<P>) => void\n  onHover?: (event: ChoroplethHoverEvent<P>) => void\n}\n\nconst DEFAULT_FILL_OPACITY = 0.8\nconst DEFAULT_STROKE_COLOR = \"#ffffff\"\nconst DEFAULT_STROKE_WIDTH = 2\nconst DEFAULT_STROKE_OPACITY = 1\nconst DEFAULT_NULL_COLOR = \"#cccccc\"\nconst DEFAULT_HOVER_FILL_OPACITY = 1\nconst DEFAULT_HOVER_STROKE_COLOR = \"#000000\"\nconst DEFAULT_HOVER_STROKE_WIDTH = 4\nconst DEFAULT_NEON_COLORS = [\"#00ffff\", \"#a855f7\", \"#ec4899\", \"#f97316\", \"#00ffff\"]\nconst DEFAULT_NEON_DURATION = 8000\n\nconst buildColorExpression = (\n  valueProperty: string,\n  colorScale: ChoroplethColorScale\n): mapboxgl.Expression | string => {\n  const { stops, interpolation = \"linear\", nullColor = DEFAULT_NULL_COLOR } = colorScale\n\n  if (stops.length === 0) {\n    return nullColor\n  }\n\n  const sortedStops = [...stops].sort((a, b) => {\n    return a.value - b.value\n  })\n\n  if (interpolation === \"step\") {\n    const stepArgs: (string | number | mapboxgl.Expression)[] = [[\"get\", valueProperty], nullColor]\n\n    sortedStops.forEach((stop) => {\n      stepArgs.push(stop.value, stop.color)\n    })\n\n    return [\"step\", ...stepArgs] as mapboxgl.Expression\n  }\n\n  const interpArgs: (string | number | mapboxgl.Expression)[] = []\n  sortedStops.forEach((stop) => {\n    interpArgs.push(stop.value, stop.color)\n  })\n\n  return [\n    \"case\",\n    [\"==\", [\"get\", valueProperty], null],\n    nullColor,\n    [\"!\", [\"has\", valueProperty]],\n    nullColor,\n    [\"interpolate\", [\"linear\"], [\"get\", valueProperty], ...interpArgs],\n  ] as mapboxgl.Expression\n}\n\nconst hexToRgb = (hex: string): [number, number, number] => {\n  const result = /^#?([a-f\\d]{2})([a-f\\d]{2})([a-f\\d]{2})$/i.exec(hex)\n  if (!result) {\n    return [0, 0, 0]\n  }\n  return [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)]\n}\n\nconst rgbToHex = (r: number, g: number, b: number): string => {\n  return `#${[r, g, b]\n    .map((x) => {\n      const hex = Math.round(x).toString(16)\n      return hex.length === 1 ? \"0\" + hex : hex\n    })\n    .join(\"\")}`\n}\n\nconst interpolateColor = (color1: string, color2: string, factor: number): string => {\n  const rgb1 = hexToRgb(color1)\n  const rgb2 = hexToRgb(color2)\n  const r = rgb1[0] + (rgb2[0] - rgb1[0]) * factor\n  const g = rgb1[1] + (rgb2[1] - rgb1[1]) * factor\n  const b = rgb1[2] + (rgb2[2] - rgb1[2]) * factor\n  return rgbToHex(r, g, b)\n}\n\nexport const MapChoropleth = <P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties>({\n  data,\n  valueProperty,\n  colorScale,\n  fillOpacity = DEFAULT_FILL_OPACITY,\n  strokeColor = DEFAULT_STROKE_COLOR,\n  strokeWidth = DEFAULT_STROKE_WIDTH,\n  strokeOpacity = DEFAULT_STROKE_OPACITY,\n  hoverEnabled = false,\n  hoverFillOpacity = DEFAULT_HOVER_FILL_OPACITY,\n  hoverStrokeColor = DEFAULT_HOVER_STROKE_COLOR,\n  hoverStrokeWidth = DEFAULT_HOVER_STROKE_WIDTH,\n  neonAnimated = false,\n  neonColors = DEFAULT_NEON_COLORS,\n  neonDuration = DEFAULT_NEON_DURATION,\n  onClick,\n  onHover,\n}: MapChoroplethProps<P>) => {\n  const { map, isLoaded } = useMap()\n  const id = useId()\n  const sourceId = `choropleth-source-${id}`\n  const fillLayerId = `choropleth-fill-layer-${id}`\n  const strokeLayerId = `choropleth-stroke-layer-${id}`\n  const initializedRef = useRef(false)\n  const hoveredFeatureIdRef = useRef<string | number | null>(null)\n\n  const dataRef = useRef(data)\n  const valuePropertyRef = useRef(valueProperty)\n  const colorScaleRef = useRef(colorScale)\n  const fillOpacityRef = useRef(fillOpacity)\n  const strokeColorRef = useRef(strokeColor)\n  const strokeWidthRef = useRef(strokeWidth)\n  const strokeOpacityRef = useRef(strokeOpacity)\n  const hoverEnabledRef = useRef(hoverEnabled)\n  const hoverFillOpacityRef = useRef(hoverFillOpacity)\n  const hoverStrokeColorRef = useRef(hoverStrokeColor)\n  const hoverStrokeWidthRef = useRef(hoverStrokeWidth)\n  const onClickRef = useRef(onClick)\n  const onHoverRef = useRef(onHover)\n  const neonAnimatedRef = useRef(neonAnimated)\n  const neonColorsRef = useRef(neonColors)\n  const neonDurationRef = useRef(neonDuration)\n  const animationFrameRef = useRef<number | undefined>(undefined)\n\n  dataRef.current = data\n  valuePropertyRef.current = valueProperty\n  colorScaleRef.current = colorScale\n  fillOpacityRef.current = fillOpacity\n  strokeColorRef.current = strokeColor\n  strokeWidthRef.current = strokeWidth\n  strokeOpacityRef.current = strokeOpacity\n  hoverEnabledRef.current = hoverEnabled\n  hoverFillOpacityRef.current = hoverFillOpacity\n  hoverStrokeColorRef.current = hoverStrokeColor\n  hoverStrokeWidthRef.current = hoverStrokeWidth\n  onClickRef.current = onClick\n  onHoverRef.current = onHover\n  neonAnimatedRef.current = neonAnimated\n  neonColorsRef.current = neonColors\n  neonDurationRef.current = neonDuration\n\n  useEffect(() => {\n    if (!map) {\n      return\n    }\n\n    const addLayers = (mapInstance: mapboxgl.Map) => {\n      if (mapInstance.getSource(sourceId)) {\n        return\n      }\n\n      mapInstance.addSource(sourceId, {\n        type: \"geojson\",\n        data: dataRef.current,\n        generateId: true,\n      })\n\n      const layers = mapInstance.getStyle().layers\n      let firstSymbolId: string | undefined\n      if (layers) {\n        for (const layer of layers) {\n          if (layer.type === \"symbol\") {\n            firstSymbolId = layer.id\n            break\n          }\n        }\n      }\n\n      const colorExpression = buildColorExpression(String(valuePropertyRef.current), colorScaleRef.current)\n\n      const initialFillColor = neonAnimatedRef.current ? neonColorsRef.current[0] || \"#00ffff\" : colorExpression\n\n      mapInstance.addLayer(\n        {\n          id: fillLayerId,\n          type: \"fill\",\n          source: sourceId,\n          paint: {\n            \"fill-color\": initialFillColor,\n            \"fill-opacity\": [\n              \"case\",\n              [\"boolean\", [\"feature-state\", \"hover\"], false],\n              hoverFillOpacityRef.current,\n              fillOpacityRef.current,\n            ],\n          },\n        },\n        firstSymbolId\n      )\n\n      mapInstance.addLayer(\n        {\n          id: strokeLayerId,\n          type: \"line\",\n          source: sourceId,\n          layout: {\n            \"line-join\": \"round\",\n            \"line-cap\": \"round\",\n          },\n          paint: {\n            \"line-color\": [\n              \"case\",\n              [\"boolean\", [\"feature-state\", \"hover\"], false],\n              hoverStrokeColorRef.current,\n              strokeColorRef.current,\n            ],\n            \"line-width\": [\n              \"case\",\n              [\"boolean\", [\"feature-state\", \"hover\"], false],\n              hoverStrokeWidthRef.current,\n              strokeWidthRef.current,\n            ],\n            \"line-opacity\": strokeOpacityRef.current,\n          },\n        },\n        firstSymbolId\n      )\n\n      initializedRef.current = true\n    }\n\n    const cleanupLayers = (mapInstance: mapboxgl.Map) => {\n      try {\n        if (mapInstance.getLayer(strokeLayerId)) {\n          mapInstance.removeLayer(strokeLayerId)\n        }\n        if (mapInstance.getLayer(fillLayerId)) {\n          mapInstance.removeLayer(fillLayerId)\n        }\n        if (mapInstance.getSource(sourceId)) {\n          mapInstance.removeSource(sourceId)\n        }\n      } catch {\n        // Layers may already be removed\n      }\n      initializedRef.current = false\n    }\n\n    const handleStyleLoad = () => {\n      initializedRef.current = false\n      addLayers(map)\n    }\n\n    if (isLoaded && !initializedRef.current) {\n      addLayers(map)\n    }\n\n    map.on(\"style.load\", handleStyleLoad)\n\n    return () => {\n      map.off(\"style.load\", handleStyleLoad)\n      cleanupLayers(map)\n    }\n  }, [map, isLoaded, sourceId, fillLayerId, strokeLayerId])\n\n  useEffect(() => {\n    if (!map || !initializedRef.current || typeof data === \"string\") {\n      return\n    }\n\n    try {\n      const source = map.getSource(sourceId) as mapboxgl.GeoJSONSource\n      if (source) {\n        source.setData(data)\n      }\n    } catch {\n      // Source may not exist during style transition\n    }\n  }, [map, sourceId, data])\n\n  useEffect(() => {\n    if (!map || !initializedRef.current) {\n      return\n    }\n\n    try {\n      const colorExpression = buildColorExpression(String(valueProperty), colorScale)\n\n      if (map.getLayer(fillLayerId)) {\n        map.setPaintProperty(fillLayerId, \"fill-color\", colorExpression)\n        map.setPaintProperty(fillLayerId, \"fill-opacity\", [\n          \"case\",\n          [\"boolean\", [\"feature-state\", \"hover\"], false],\n          hoverFillOpacity,\n          fillOpacity,\n        ])\n      }\n    } catch {\n      // Layer may not exist during style transition\n    }\n  }, [map, fillLayerId, valueProperty, colorScale, fillOpacity, hoverFillOpacity])\n\n  useEffect(() => {\n    if (!map || !initializedRef.current) {\n      return\n    }\n\n    try {\n      if (map.getLayer(strokeLayerId)) {\n        map.setPaintProperty(strokeLayerId, \"line-color\", [\n          \"case\",\n          [\"boolean\", [\"feature-state\", \"hover\"], false],\n          hoverStrokeColor,\n          strokeColor,\n        ])\n        map.setPaintProperty(strokeLayerId, \"line-width\", [\n          \"case\",\n          [\"boolean\", [\"feature-state\", \"hover\"], false],\n          hoverStrokeWidth,\n          strokeWidth,\n        ])\n        map.setPaintProperty(strokeLayerId, \"line-opacity\", strokeOpacity)\n      }\n    } catch {\n      // Layer may not exist during style transition\n    }\n  }, [map, strokeLayerId, strokeColor, strokeWidth, strokeOpacity, hoverStrokeColor, hoverStrokeWidth])\n\n  useEffect(() => {\n    if (!map || !initializedRef.current) {\n      return\n    }\n\n    const handleClick = (e: mapboxgl.MapMouseEvent & { features?: mapboxgl.GeoJSONFeature[] }) => {\n      if (!onClickRef.current || !e.features || e.features.length === 0) {\n        return\n      }\n\n      const mapFeature = e.features[0]\n      const properties = (mapFeature.properties ?? {}) as NonNullable<P>\n      const value = properties[String(valuePropertyRef.current) as string] as number | null\n\n      const feature: ChoroplethFeature<P> = {\n        type: \"Feature\",\n        geometry: mapFeature.geometry as GeoJSON.Polygon | GeoJSON.MultiPolygon,\n        properties: properties as P,\n      }\n\n      onClickRef.current({\n        feature,\n        value: value ?? null,\n        coordinates: [e.lngLat.lng, e.lngLat.lat],\n      })\n    }\n\n    const handleMouseMove = (e: mapboxgl.MapMouseEvent & { features?: mapboxgl.GeoJSONFeature[] }) => {\n      if (!e.features || e.features.length === 0) {\n        if (hoveredFeatureIdRef.current !== null) {\n          map.setFeatureState({ source: sourceId, id: hoveredFeatureIdRef.current }, { hover: false })\n          hoveredFeatureIdRef.current = null\n\n          if (onHoverRef.current) {\n            onHoverRef.current({\n              feature: null,\n              value: null,\n              coordinates: null,\n            })\n          }\n        }\n        return\n      }\n\n      const mapFeature = e.features[0]\n\n      if (hoverEnabledRef.current && mapFeature.id !== undefined) {\n        if (hoveredFeatureIdRef.current !== null && hoveredFeatureIdRef.current !== mapFeature.id) {\n          map.setFeatureState({ source: sourceId, id: hoveredFeatureIdRef.current }, { hover: false })\n        }\n\n        map.setFeatureState({ source: sourceId, id: mapFeature.id }, { hover: true })\n        hoveredFeatureIdRef.current = mapFeature.id\n      }\n\n      if (onHoverRef.current) {\n        const properties = (mapFeature.properties ?? {}) as NonNullable<P>\n        const value = properties[String(valuePropertyRef.current) as string] as number | null\n\n        const feature: ChoroplethFeature<P> = {\n          type: \"Feature\",\n          geometry: mapFeature.geometry as GeoJSON.Polygon | GeoJSON.MultiPolygon,\n          properties: properties as P,\n        }\n\n        onHoverRef.current({\n          feature,\n          value: value ?? null,\n          coordinates: [e.lngLat.lng, e.lngLat.lat],\n        })\n      }\n    }\n\n    const handleMouseLeave = () => {\n      if (hoveredFeatureIdRef.current !== null) {\n        map.setFeatureState({ source: sourceId, id: hoveredFeatureIdRef.current }, { hover: false })\n        hoveredFeatureIdRef.current = null\n      }\n\n      if (onHoverRef.current) {\n        onHoverRef.current({\n          feature: null,\n          value: null,\n          coordinates: null,\n        })\n      }\n    }\n\n    const handleMouseEnter = () => {\n      map.getCanvas().style.cursor = \"pointer\"\n    }\n\n    const handleMouseLeaveCanvas = () => {\n      map.getCanvas().style.cursor = \"\"\n    }\n\n    map.on(\"click\", fillLayerId, handleClick)\n    map.on(\"mousemove\", fillLayerId, handleMouseMove)\n    map.on(\"mouseleave\", fillLayerId, handleMouseLeave)\n    map.on(\"mouseenter\", fillLayerId, handleMouseEnter)\n    map.on(\"mouseleave\", fillLayerId, handleMouseLeaveCanvas)\n\n    return () => {\n      map.off(\"click\", fillLayerId, handleClick)\n      map.off(\"mousemove\", fillLayerId, handleMouseMove)\n      map.off(\"mouseleave\", fillLayerId, handleMouseLeave)\n      map.off(\"mouseenter\", fillLayerId, handleMouseEnter)\n      map.off(\"mouseleave\", fillLayerId, handleMouseLeaveCanvas)\n    }\n  }, [map, fillLayerId, sourceId])\n\n  useEffect(() => {\n    if (!map || !isLoaded || !initializedRef.current || !neonAnimated) {\n      return\n    }\n\n    const colors = neonColorsRef.current\n    if (colors.length < 2) {\n      return\n    }\n\n    let startTime: number | null = null\n\n    const animate = (timestamp: number) => {\n      if (!startTime) {\n        startTime = timestamp\n      }\n\n      const elapsed = timestamp - startTime\n      const duration = neonDurationRef.current\n      const progress = (elapsed % duration) / duration\n      const totalSegments = colors.length - 1\n      const segmentProgress = progress * totalSegments\n      const currentSegment = Math.floor(segmentProgress)\n      const segmentFactor = segmentProgress - currentSegment\n      const colorIndex = Math.min(currentSegment, colors.length - 2)\n      const currentColor = interpolateColor(colors[colorIndex], colors[colorIndex + 1], segmentFactor)\n\n      try {\n        if (map.getLayer(fillLayerId)) {\n          map.setPaintProperty(fillLayerId, \"fill-color\", currentColor)\n        }\n      } catch {\n        // Layer may not exist\n      }\n\n      animationFrameRef.current = requestAnimationFrame(animate)\n    }\n\n    animationFrameRef.current = requestAnimationFrame(animate)\n\n    return () => {\n      if (animationFrameRef.current) {\n        cancelAnimationFrame(animationFrameRef.current)\n      }\n    }\n  }, [map, isLoaded, fillLayerId, neonAnimated])\n\n  return null\n}\n\nexport type {\n  ChoroplethColorStop,\n  ChoroplethColorScale,\n  ChoroplethFeature,\n  ChoroplethData,\n  ChoroplethClickEvent,\n  ChoroplethHoverEvent,\n  MapChoroplethProps,\n}\n",
      "type": "registry:ui",
      "target": "components/ui/map/choropleth.tsx"
    }
  ],
  "type": "registry:ui"
}
