{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "circle-cluster",
  "title": "Map Circle Cluster",
  "description": "Marker clustering with circle visualization.",
  "dependencies": ["mapbox-gl"],
  "devDependencies": ["@types/mapbox-gl"],
  "registryDependencies": ["https://www.terrae.dev/map.json"],
  "files": [
    {
      "path": "src/registry/map/circle-cluster.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useId, useRef } from \"react\"\nimport type mapboxgl from \"mapbox-gl\"\nimport { useMap } from \"./hooks\"\nimport type { MapCoordinates } from \"./types\"\n\ntype MapCircleClusterProps<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties> = {\n  /** GeoJSON FeatureCollection data or URL to fetch GeoJSON from */\n  data: string | GeoJSON.FeatureCollection<GeoJSON.Point, P>\n  /** Maximum zoom level to cluster points on (default: 14) */\n  clusterMaxZoom?: number\n  /** Radius of each cluster when clustering points in pixels (default: 50) */\n  clusterRadius?: number\n  /** Colors for cluster circles: [small, medium, large] based on point count (default: [\"#51bbd6\", \"#f1f075\", \"#f28cb1\"]) */\n  clusterColors?: [string, string, string]\n  /** Point count thresholds for color/size steps: [medium, large] (default: [100, 750]) */\n  clusterThresholds?: [number, number]\n  /** Color for unclustered individual points (default: \"#3b82f6\") */\n  pointColor?: string\n  /** Callback when an unclustered point is clicked */\n  onPointClick?: (feature: GeoJSON.Feature<GeoJSON.Point, P>, coordinates: MapCoordinates) => void\n  /** Callback when a cluster is clicked. If not provided, zooms into the cluster */\n  onClusterClick?: (clusterId: number, coordinates: MapCoordinates, pointCount: number) => void\n}\n\nexport function MapCircleCluster<P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties>({\n  data,\n  clusterMaxZoom = 14,\n  clusterRadius = 50,\n  clusterColors = [\"#be123c\", \"#1d4ed8\", \"#f28cb1\"],\n  clusterThresholds = [100, 750],\n  pointColor = \"#3b82f6\",\n  onPointClick,\n  onClusterClick,\n}: MapCircleClusterProps<P>) {\n  const { map, isLoaded } = useMap()\n  const id = useId()\n  const sourceId = `cluster-source-${id}`\n  const clusterLayerId = `cluster-layer-${id}`\n  const clusterCountLayerId = `cluster-count-${id}`\n  const unclusteredLayerId = `unclustered-point-${id}`\n\n  const initializedRef = useRef(false)\n  const stylePropsRef = useRef({\n    clusterColors,\n    clusterThresholds,\n    pointColor,\n  })\n\n  // Add source and layers on mount\n  useEffect(() => {\n    if (!isLoaded || !map || initializedRef.current) return\n    if (!map.getContainer?.() || !map.getCanvasContainer?.() || !map.isStyleLoaded()) {\n      return\n    }\n\n    // Add clustered GeoJSON source\n    map.addSource(sourceId, {\n      type: \"geojson\",\n      data: typeof data === \"string\" ? data : data,\n      cluster: true,\n      clusterMaxZoom,\n      clusterRadius,\n    })\n\n    // Add cluster circles layer\n    map.addLayer({\n      id: clusterLayerId,\n      type: \"circle\",\n      source: sourceId,\n      filter: [\"has\", \"point_count\"],\n      paint: {\n        \"circle-color\": [\n          \"step\",\n          [\"get\", \"point_count\"],\n          clusterColors[0],\n          clusterThresholds[0],\n          clusterColors[1],\n          clusterThresholds[1],\n          clusterColors[2],\n        ],\n        \"circle-radius\": [\"step\", [\"get\", \"point_count\"], 20, clusterThresholds[0], 30, clusterThresholds[1], 40],\n      },\n    })\n\n    // Add cluster count text layer\n    map.addLayer({\n      id: clusterCountLayerId,\n      type: \"symbol\",\n      source: sourceId,\n      filter: [\"has\", \"point_count\"],\n      layout: {\n        \"text-field\": \"{point_count_abbreviated}\",\n        \"text-size\": 12,\n      },\n      paint: {\n        \"text-color\": \"#fff\",\n      },\n    })\n\n    // Add unclustered point layer\n    map.addLayer({\n      id: unclusteredLayerId,\n      type: \"circle\",\n      source: sourceId,\n      filter: [\"!\", [\"has\", \"point_count\"]],\n      paint: {\n        \"circle-color\": pointColor,\n        \"circle-radius\": 6,\n      },\n    })\n\n    initializedRef.current = true\n\n    return () => {\n      try {\n        if (!map || !map.isStyleLoaded()) return\n\n        const style = map.getStyle()\n        if (!style) return\n\n        const hasLayer = (id: string) => style.layers?.some((l) => l.id === id)\n\n        if (hasLayer(clusterCountLayerId)) {\n          map.removeLayer(clusterCountLayerId)\n        }\n        if (hasLayer(unclusteredLayerId)) {\n          map.removeLayer(unclusteredLayerId)\n        }\n        if (hasLayer(clusterLayerId)) {\n          map.removeLayer(clusterLayerId)\n        }\n\n        if (map.getSource(sourceId)) {\n          map.removeSource(sourceId)\n        }\n      } catch {\n        // Map or style already destroyed — safe to ignore\n      }\n      initializedRef.current = false\n    }\n  }, [isLoaded, map, sourceId])\n\n  // Update source data when data prop changes (only for non-URL data )\n  useEffect(() => {\n    if (!isLoaded || !map || typeof data === \"string\") return\n\n    try {\n      // Double-check map is valid and has the method\n      if (!map || !map.isStyleLoaded()) return\n\n      // Check if source exists before trying to update it\n      const source = map.getSource(sourceId)\n      if (source && \"setData\" in source) {\n        ;(source as mapboxgl.GeoJSONSource).setData(data)\n      }\n    } catch {\n      // Silently ignore errors if source doesn't exist yet\n      // This can happen during rapid re-renders or navigation\n    }\n  }, [isLoaded, map, data, sourceId])\n\n  // Update layer styles when props change\n  useEffect(() => {\n    if (!isLoaded || !map || !map.isStyleLoaded()) return\n\n    const prev = stylePropsRef.current\n    const colorsChanged = prev.clusterColors !== clusterColors || prev.clusterThresholds !== clusterThresholds\n\n    try {\n      // Update circle cluster colors and sizes\n      if (map && map.getLayer(clusterLayerId) && colorsChanged) {\n        map.setPaintProperty(clusterLayerId, \"circle-color\", [\n          \"step\",\n          [\"get\", \"point_count\"],\n          clusterColors[0],\n          clusterThresholds[0],\n          clusterColors[1],\n          clusterThresholds[1],\n          clusterColors[2],\n        ])\n        map.setPaintProperty(clusterLayerId, \"circle-radius\", [\n          \"step\",\n          [\"get\", \"point_count\"],\n          20,\n          clusterThresholds[0],\n          30,\n          clusterThresholds[1],\n          40,\n        ])\n      }\n\n      // Update unclustered point layer color\n      if (map && map.getLayer(unclusteredLayerId) && prev.pointColor !== pointColor) {\n        map.setPaintProperty(unclusteredLayerId, \"circle-color\", pointColor)\n      }\n\n      stylePropsRef.current = { clusterColors, clusterThresholds, pointColor }\n    } catch (error) {\n      console.error(\"Error updating circle cluster styles:\", error)\n    }\n  }, [isLoaded, map, clusterLayerId, unclusteredLayerId, clusterColors, clusterThresholds, pointColor])\n\n  // Handle click events\n  useEffect(() => {\n    if (!isLoaded || !map) return\n\n    // Cluster click handler - zoom into cluster\n    const handleClusterClick = async (\n      e: mapboxgl.MapMouseEvent & {\n        features?: mapboxgl.GeoJSONFeature[]\n      }\n    ) => {\n      const features = map.queryRenderedFeatures(e.point, {\n        layers: [clusterLayerId],\n      })\n      if (!features.length) return\n\n      const feature = features[0]\n      const clusterId = feature.properties?.cluster_id as number\n      const pointCount = feature.properties?.point_count as number\n      const coordinates = (feature.geometry as GeoJSON.Point).coordinates as [number, number]\n\n      if (onClusterClick) {\n        onClusterClick(clusterId, coordinates, pointCount)\n      } else {\n        // Default behavior: zoom to cluster expansion zoom\n        const source = map.getSource(sourceId)\n        if (source && \"getClusterExpansionZoom\" in source) {\n          ;(source as mapboxgl.GeoJSONSource).getClusterExpansionZoom(clusterId, (err, zoom) => {\n            if (err || zoom === null || zoom === undefined) return\n            map.easeTo({\n              center: coordinates,\n              zoom,\n            })\n          })\n        }\n      }\n    }\n\n    // Unclustered point click handler\n    const handlePointClick = (\n      e: mapboxgl.MapMouseEvent & {\n        features?: mapboxgl.GeoJSONFeature[]\n      }\n    ) => {\n      if (!onPointClick || !e.features?.length) return\n\n      const feature = e.features[0]\n      const coordinates = (feature.geometry as GeoJSON.Point).coordinates.slice() as [number, number]\n\n      // Handle world copies\n      while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) {\n        coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360\n      }\n\n      onPointClick(feature as unknown as GeoJSON.Feature<GeoJSON.Point, P>, coordinates)\n    }\n\n    // Cursor style handlers\n    const handleMouseEnterCluster = () => {\n      map.getCanvas().style.cursor = \"pointer\"\n    }\n    const handleMouseLeaveCluster = () => {\n      map.getCanvas().style.cursor = \"\"\n    }\n    const handleMouseEnterPoint = () => {\n      if (onPointClick) {\n        map.getCanvas().style.cursor = \"pointer\"\n      }\n    }\n    const handleMouseLeavePoint = () => {\n      map.getCanvas().style.cursor = \"\"\n    }\n\n    map.on(\"click\", clusterLayerId, handleClusterClick)\n    map.on(\"click\", unclusteredLayerId, handlePointClick)\n    map.on(\"mouseenter\", clusterLayerId, handleMouseEnterCluster)\n    map.on(\"mouseleave\", clusterLayerId, handleMouseLeaveCluster)\n    map.on(\"mouseenter\", unclusteredLayerId, handleMouseEnterPoint)\n    map.on(\"mouseleave\", unclusteredLayerId, handleMouseLeavePoint)\n\n    return () => {\n      map.off(\"click\", clusterLayerId, handleClusterClick)\n      map.off(\"click\", unclusteredLayerId, handlePointClick)\n      map.off(\"mouseenter\", clusterLayerId, handleMouseEnterCluster)\n      map.off(\"mouseleave\", clusterLayerId, handleMouseLeaveCluster)\n      map.off(\"mouseenter\", unclusteredLayerId, handleMouseEnterPoint)\n      map.off(\"mouseleave\", unclusteredLayerId, handleMouseLeavePoint)\n    }\n  }, [isLoaded, map, clusterLayerId, unclusteredLayerId, sourceId, onClusterClick, onPointClick])\n\n  return null\n}\n",
      "type": "registry:ui",
      "target": "components/ui/map/circle-cluster.tsx"
    }
  ],
  "type": "registry:ui"
}
