{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "site-map",
  "title": "Site Map",
  "description": "A clustered site map: point markers that group into brand-coloured cluster bubbles you can click to expand.",
  "dependencies": [
    "react-map-gl@^8.1.1"
  ],
  "registryDependencies": [
    "@nx-ui/map-marker",
    "@nx-ui/map-view",
    "@nx-ui/utils"
  ],
  "files": [
    {
      "path": "components/blocks/maps/site-map.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport type { FeatureCollection, Point } from \"geojson\"\nimport {\n  Layer,\n  type MapMouseEvent,\n  type MapRef,\n  Source,\n} from \"react-map-gl/mapbox\"\n\nimport { cn } from \"@/lib/utils\"\nimport { MapPopupCard } from \"@/components/blocks/maps/map-marker\"\nimport { MapView, type MapViewProps } from \"@/components/blocks/maps/map-view\"\n\n/**\n * site-map — a clustered site map for nx-ui.\n *\n * Uses Mapbox's native GL cluster source (via react-map-gl <Source cluster>):\n * points aggregate into brand-coloured cluster bubbles that you click to zoom\n * in and expand; individual site points render as white-ringed navy dots and\n * open a popup site card on click.\n *\n * Colours in GL paint expressions must be literal hex (CSS vars aren't\n * available inside the GL context), so the brand tokens are mirrored here:\n *   brand-600  #121541 (navy)   — points + cluster bubbles\n *   accent-400 #F7EC33 (yellow) — cluster count contrast is handled via halo\n */\n\nconst BRAND_NAVY = \"#121541\"\nconst BRAND_NAVY_500 = \"#2A2D78\"\nconst ACCENT_YELLOW = \"#F7EC33\"\n\nexport interface Site {\n  id: string\n  name: string\n  /** [longitude, latitude] */\n  coordinates: [number, number]\n  /** Optional supporting line in the popup (e.g. town, status). */\n  detail?: string\n}\n\nconst CLUSTER_LAYER = \"site-clusters\"\nconst CLUSTER_COUNT_LAYER = \"site-cluster-count\"\nconst POINT_LAYER = \"site-points\"\n\nexport interface SiteMapProps\n  extends Omit<\n    MapViewProps,\n    \"onClick\" | \"interactiveLayerIds\" | \"onMouseMove\" | \"onMouseLeave\"\n  > {\n  sites: Site[]\n  /** Render the popup body for a site (defaults to the site's `detail`). */\n  renderPopup?: (site: Site) => React.ReactNode\n  className?: string\n  /** Overlay content rendered above the map (e.g. a map-stat-overlay panel). */\n  children?: React.ReactNode\n}\n\nexport function SiteMap({\n  sites,\n  renderPopup,\n  className,\n  children,\n  overlay,\n  ...mapViewProps\n}: SiteMapProps) {\n  const mapRef = React.useRef<MapRef>(null)\n  const [activeSite, setActiveSite] = React.useState<Site | null>(null)\n\n  const geojson = React.useMemo<FeatureCollection<Point>>(\n    () => ({\n      type: \"FeatureCollection\",\n      features: sites.map((site) => ({\n        type: \"Feature\",\n        geometry: { type: \"Point\", coordinates: site.coordinates },\n        properties: { id: site.id, name: site.name, detail: site.detail ?? \"\" },\n      })),\n    }),\n    [sites]\n  )\n\n  // Cluster bubbles and site points are GL layer features, not DOM elements,\n  // so the pointer affordance is set on the map canvas while hovering them.\n  const handleMouseMove = React.useCallback((event: MapMouseEvent) => {\n    const map = mapRef.current?.getMap()\n    if (map) map.getCanvas().style.cursor = event.features?.length ? \"pointer\" : \"\"\n  }, [])\n\n  const handleMouseLeave = React.useCallback(() => {\n    const map = mapRef.current?.getMap()\n    if (map) map.getCanvas().style.cursor = \"\"\n  }, [])\n\n  const handleClick = React.useCallback(\n    (event: MapMouseEvent) => {\n      const feature = event.features?.[0]\n      if (!feature) {\n        setActiveSite(null)\n        return\n      }\n      const map = mapRef.current?.getMap()\n\n      // Cluster click → zoom into the cluster to expand it.\n      if (feature.properties?.cluster) {\n        const clusterId = feature.properties.cluster_id as number\n        const source = map?.getSource(\"sites\") as\n          | { getClusterExpansionZoom: (id: number, cb: (err: unknown, zoom: number) => void) => void }\n          | undefined\n        source?.getClusterExpansionZoom(clusterId, (err, zoom) => {\n          if (err || !map) return\n          map.easeTo({\n            center: (feature.geometry as Point).coordinates as [number, number],\n            zoom,\n            duration: 500,\n          })\n        })\n        setActiveSite(null)\n        return\n      }\n\n      // Individual point → open its popup card.\n      const id = feature.properties?.id as string\n      const site = sites.find((s) => s.id === id) ?? null\n      setActiveSite(site)\n    },\n    [sites]\n  )\n\n  return (\n    <MapView\n      ref={mapRef}\n      className={className}\n      interactiveLayerIds={[CLUSTER_LAYER, POINT_LAYER]}\n      onClick={handleClick}\n      onMouseMove={handleMouseMove}\n      onMouseLeave={handleMouseLeave}\n      overlay={\n        <>\n          {children}\n          {overlay}\n        </>\n      }\n      {...mapViewProps}\n    >\n      <Source\n        id=\"sites\"\n        type=\"geojson\"\n        data={geojson}\n        cluster\n        clusterMaxZoom={14}\n        clusterRadius={50}\n      >\n        {/* Cluster bubbles — brand navy, growing with point_count. */}\n        <Layer\n          id={CLUSTER_LAYER}\n          type=\"circle\"\n          filter={[\"has\", \"point_count\"]}\n          paint={{\n            \"circle-color\": [\n              \"step\",\n              [\"get\", \"point_count\"],\n              BRAND_NAVY_500,\n              10,\n              BRAND_NAVY,\n              30,\n              BRAND_NAVY,\n            ],\n            \"circle-radius\": [\n              \"step\",\n              [\"get\", \"point_count\"],\n              18,\n              10,\n              24,\n              30,\n              32,\n            ],\n            \"circle-stroke-width\": 3,\n            \"circle-stroke-color\": \"#ffffff\",\n            \"circle-opacity\": 0.96,\n          }}\n        />\n        {/* Count label inside the cluster bubble — yellow accent on navy. */}\n        <Layer\n          id={CLUSTER_COUNT_LAYER}\n          type=\"symbol\"\n          filter={[\"has\", \"point_count\"]}\n          layout={{\n            \"text-field\": [\"get\", \"point_count_abbreviated\"],\n            \"text-font\": [\"DIN Offc Pro Medium\", \"Arial Unicode MS Bold\"],\n            \"text-size\": 13,\n            \"text-allow-overlap\": true,\n          }}\n          paint={{\n            \"text-color\": ACCENT_YELLOW,\n          }}\n        />\n        {/* Unclustered points — white-ringed navy dots. */}\n        <Layer\n          id={POINT_LAYER}\n          type=\"circle\"\n          filter={[\"!\", [\"has\", \"point_count\"]]}\n          paint={{\n            \"circle-color\": BRAND_NAVY,\n            \"circle-radius\": 7,\n            \"circle-stroke-width\": 2.5,\n            \"circle-stroke-color\": \"#ffffff\",\n          }}\n        />\n      </Source>\n\n      {activeSite ? (\n        <MapPopupCard\n          longitude={activeSite.coordinates[0]}\n          latitude={activeSite.coordinates[1]}\n          title={activeSite.name}\n          onClose={() => setActiveSite(null)}\n          offset={16}\n        >\n          {renderPopup ? (\n            renderPopup(activeSite)\n          ) : activeSite.detail ? (\n            <p className={cn(\"text-secondary-foreground\")}>{activeSite.detail}</p>\n          ) : null}\n        </MapPopupCard>\n      ) : null}\n    </MapView>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/blocks/maps/site-map.tsx"
    }
  ],
  "type": "registry:block"
}