{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "amap",
  "title": "Map",
  "description": "An AMap (高德地图)-powered map component with markers, popups, tooltips, routes, and controls.",
  "dependencies": [
    "@amap/amap-jsapi-loader",
    "next-themes",
    "lucide-react"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "src/map.tsx",
      "content": "\"use client\";\n\nimport { useTheme } from \"next-themes\";\nimport {\n  createContext,\n  forwardRef,\n  useContext,\n  useEffect,\n  useImperativeHandle,\n  useMemo,\n  useRef,\n  useState,\n  type ReactNode,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { X, Minus, Plus, Locate, Maximize, Loader2 } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\ntype AMapNS = any;\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\ntype AMapInstance = any;\n\ndeclare global {\n  interface Window {\n    _AMapSecurityConfig?: { securityJsCode?: string };\n  }\n}\n\n// ---- Shared hooks ----\n\nfunction useLatestRef<T>(value: T) {\n  const ref = useRef(value);\n  ref.current = value;\n  return ref;\n}\n\nfunction useOverlayEvents(\n  overlay: AMapInstance | null,\n  events: {\n    onClick?: () => void;\n    onMouseEnter?: () => void;\n    onMouseLeave?: () => void;\n  }\n) {\n  const clickRef = useLatestRef(events.onClick);\n  const enterRef = useLatestRef(events.onMouseEnter);\n  const leaveRef = useLatestRef(events.onMouseLeave);\n\n  useEffect(() => {\n    if (!overlay) return;\n    const handleClick = () => clickRef.current?.();\n    const handleOver = () => enterRef.current?.();\n    const handleOut = () => leaveRef.current?.();\n\n    overlay.on(\"click\", handleClick);\n    overlay.on(\"mouseover\", handleOver);\n    overlay.on(\"mouseout\", handleOut);\n\n    return () => {\n      overlay.off(\"click\", handleClick);\n      overlay.off(\"mouseover\", handleOver);\n      overlay.off(\"mouseout\", handleOut);\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [overlay]);\n}\n\n// ---- Map ----\n\nconst defaultStyles = {\n  dark: \"amap://styles/dark\",\n  light: \"amap://styles/light\",\n  normal: \"amap://styles/normal\",\n};\n\ntype MapContextValue = {\n  map: AMapInstance | null;\n  AMap: AMapNS | null;\n  isLoaded: boolean;\n};\n\nconst MapContext = createContext<MapContextValue | null>(null);\n\nfunction useMap() {\n  const context = useContext(MapContext);\n  if (!context) {\n    throw new Error(\"useMap must be used within a Map component\");\n  }\n  return context;\n}\n\ntype MapProps = {\n  children?: ReactNode;\n  /** Map center [longitude, latitude] in GCJ-02 */\n  center?: [number, number];\n  /** Map zoom level (3-18) */\n  zoom?: number;\n  /** Custom map styles for light and dark themes */\n  styles?: { light?: string; dark?: string };\n  /** Additional CSS class for the container */\n  className?: string;\n  /** AMap JS API key */\n  amapKey?: string;\n  /** AMap JS API security code (required for 2.0) */\n  securityJsCode?: string;\n  /** Fit the map to these bounds [[sw_lng, sw_lat], [ne_lng, ne_lat]] */\n  bounds?: [[number, number], [number, number]];\n  /** Map view mode. Note: only takes effect on initial mount */\n  viewMode?: \"2D\" | \"3D\";\n  /** Callback when map finishes loading */\n  onLoad?: () => void;\n  /** Callback when map is clicked */\n  onClick?: (lngLat: { lng: number; lat: number }) => void;\n  /** Callback when map pan/move ends */\n  onMoveEnd?: () => void;\n  /** Callback when map zoom ends */\n  onZoomEnd?: () => void;\n  /** Callback when AMap fails to load */\n  onError?: (error: Error) => void;\n};\n\ntype MapRef = AMapInstance;\n\nconst DefaultLoader = () => (\n  <div className=\"absolute inset-0 flex items-center justify-center\">\n    <div className=\"flex gap-1\">\n      <span className=\"size-1.5 rounded-full bg-muted-foreground/60 animate-pulse\" />\n      <span className=\"size-1.5 rounded-full bg-muted-foreground/60 animate-pulse [animation-delay:150ms]\" />\n      <span className=\"size-1.5 rounded-full bg-muted-foreground/60 animate-pulse [animation-delay:300ms]\" />\n    </div>\n  </div>\n);\n\nconst Map = forwardRef<MapRef, MapProps>(function Map(\n  {\n    children,\n    center = [116.397428, 39.90923],\n    zoom = 11,\n    styles,\n    className,\n    amapKey,\n    securityJsCode,\n    bounds,\n    viewMode = \"3D\",\n    onLoad,\n    onClick,\n    onMoveEnd,\n    onZoomEnd,\n    onError,\n  },\n  ref\n) {\n  const containerRef = useRef<HTMLDivElement>(null);\n  const [mapInstance, setMapInstance] = useState<AMapInstance>(null);\n  const [amapNS, setAmapNS] = useState<AMapNS>(null);\n  const [isLoaded, setIsLoaded] = useState(false);\n  const { resolvedTheme } = useTheme();\n  const currentStyleRef = useRef<string | null>(null);\n\n  const onLoadRef = useLatestRef(onLoad);\n  const onClickRef = useLatestRef(onClick);\n  const onMoveEndRef = useLatestRef(onMoveEnd);\n  const onZoomEndRef = useLatestRef(onZoomEnd);\n  const onErrorRef = useLatestRef(onError);\n\n  const mapStyles = useMemo(\n    () => ({\n      dark: styles?.dark ?? defaultStyles.dark,\n      light: styles?.light ?? defaultStyles.light,\n    }),\n    [styles]\n  );\n\n  useImperativeHandle(ref, () => mapInstance, [mapInstance]);\n\n  useEffect(() => {\n    if (!containerRef.current) return;\n\n    const key = amapKey ?? \"983f0d71329b83141e06427729399d5e\";\n    const code = securityJsCode ?? \"0105e3185f27b87d2aab3c2bad23fc86\";\n    if (code) {\n      window._AMapSecurityConfig = { securityJsCode: code };\n    }\n\n    // Use a ref to track mount state and avoid closure staleness in async callbacks\n    let isMounted = true;\n    let map: AMapInstance = null;\n\n    import(\"@amap/amap-jsapi-loader\").then(({ default: AMapLoader }) => {\n      if (!isMounted) return;\n      return AMapLoader.load({\n        key,\n        version: \"2.0\",\n        plugins: [],\n      });\n    })\n      .then((AMap: AMapNS) => {\n        if (!isMounted || !containerRef.current) return;\n\n        const initialStyle =\n          resolvedTheme === \"dark\" ? mapStyles.dark : mapStyles.light;\n        currentStyleRef.current = initialStyle;\n\n        map = new AMap.Map(containerRef.current, {\n          viewMode,\n          zoom,\n          center,\n          mapStyle: initialStyle,\n          resizeEnable: true,\n        });\n\n        map.on(\"complete\", () => {\n          if (!isMounted) return;\n          setIsLoaded(true);\n          onLoadRef.current?.();\n        });\n\n        setMapInstance(map);\n        setAmapNS(AMap);\n      })\n      .catch((err: unknown) => {\n        if (!isMounted) return;\n        const error = err instanceof Error ? err : new Error(String(err));\n        console.error(\"AMap load error:\", error);\n        onErrorRef.current?.(error);\n      });\n\n    return () => {\n      isMounted = false;\n      setIsLoaded(false);\n      setMapInstance(null);\n      setAmapNS(null);\n      // Defer map.destroy() so child components unmount first and\n      // don't call methods on a destroyed map instance.\n      if (map) {\n        queueMicrotask(() => {\n          try { map.destroy(); } catch { /* ignore */ }\n        });\n      }\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  useEffect(() => {\n    if (!mapInstance || !resolvedTheme) return;\n    const newStyle =\n      resolvedTheme === \"dark\" ? mapStyles.dark : mapStyles.light;\n    if (currentStyleRef.current === newStyle) return;\n    currentStyleRef.current = newStyle;\n    mapInstance.setMapStyle(newStyle);\n  }, [mapInstance, resolvedTheme, mapStyles]);\n\n  // Sync center — use serialized key to avoid effect storm from inline arrays\n  const centerKey = center ? `${center[0]},${center[1]}` : null;\n  useEffect(() => {\n    if (!mapInstance || !center) return;\n    mapInstance.panTo(center);\n  // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [mapInstance, centerKey]);\n\n  useEffect(() => {\n    if (!mapInstance) return;\n    mapInstance.setZoom(zoom);\n  }, [mapInstance, zoom]);\n\n  useEffect(() => {\n    if (!mapInstance) return;\n    const handleClick = (e: AMapInstance) => {\n      onClickRef.current?.({ lng: e.lnglat.getLng(), lat: e.lnglat.getLat() });\n    };\n    const handleMoveEnd = () => onMoveEndRef.current?.();\n    const handleZoomEnd = () => onZoomEndRef.current?.();\n\n    mapInstance.on(\"click\", handleClick);\n    mapInstance.on(\"moveend\", handleMoveEnd);\n    mapInstance.on(\"zoomend\", handleZoomEnd);\n    return () => {\n      mapInstance.off(\"click\", handleClick);\n      mapInstance.off(\"moveend\", handleMoveEnd);\n      mapInstance.off(\"zoomend\", handleZoomEnd);\n    };\n  }, [mapInstance]);\n\n  const boundsKey = bounds\n    ? `${bounds[0][0]},${bounds[0][1]},${bounds[1][0]},${bounds[1][1]}`\n    : null;\n  useEffect(() => {\n    if (!mapInstance || !amapNS || !bounds) return;\n    const [sw, ne] = bounds;\n    mapInstance.setBounds(new amapNS.Bounds(sw, ne));\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [mapInstance, amapNS, boundsKey]);\n\n  const contextValue = useMemo(\n    () => ({ map: mapInstance, AMap: amapNS, isLoaded }),\n    [mapInstance, amapNS, isLoaded]\n  );\n\n  return (\n    <MapContext.Provider value={contextValue}>\n      <div\n        ref={containerRef}\n        className={cn(\"relative w-full h-full\", className)}\n      >\n        {!isLoaded && <DefaultLoader />}\n        {mapInstance && children}\n      </div>\n    </MapContext.Provider>\n  );\n});\n\n// ---- Marker ----\n\ntype MarkerContextValue = {\n  marker: AMapInstance;\n  map: AMapInstance | null;\n};\n\nconst MarkerContext = createContext<MarkerContextValue | null>(null);\n\nfunction useMarkerContext() {\n  const context = useContext(MarkerContext);\n  if (!context) throw new Error(\"Marker components must be used within MapMarker\");\n  return context;\n}\n\ntype MapMarkerProps = {\n  /** Longitude (GCJ-02) */\n  longitude: number;\n  /** Latitude (GCJ-02) */\n  latitude: number;\n  children: ReactNode;\n  onClick?: () => void;\n  onMouseEnter?: () => void;\n  onMouseLeave?: () => void;\n  draggable?: boolean;\n  onDragStart?: (lngLat: { lng: number; lat: number }) => void;\n  onDragEnd?: (lngLat: { lng: number; lat: number }) => void;\n  zIndex?: number;\n  /** Show or hide the marker */\n  visible?: boolean;\n};\n\nfunction MapMarker({\n  longitude,\n  latitude,\n  children,\n  onClick,\n  onMouseEnter,\n  onMouseLeave,\n  draggable = false,\n  onDragStart,\n  onDragEnd,\n  zIndex,\n  visible = true,\n}: MapMarkerProps) {\n  const { map, AMap } = useMap();\n  const [marker, setMarker] = useState<AMapInstance>(null);\n\n  const onDragStartRef = useLatestRef(onDragStart);\n  const onDragEndRef = useLatestRef(onDragEnd);\n\n  // Create marker in effect to handle strict mode correctly\n  useEffect(() => {\n    if (!map || !AMap) return;\n\n    // Create fresh container element for each mount\n    const containerEl = document.createElement(\"div\");\n\n    const newMarker = new AMap.Marker({\n      position: [longitude, latitude],\n      content: containerEl,\n      offset: new AMap.Pixel(0, 0),\n      draggable,\n    });\n\n    newMarker.setMap(map);\n\n    setMarker(newMarker);\n\n    return () => {\n      newMarker.setMap(null);\n      setMarker(null);\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [map, AMap]);\n\n  useOverlayEvents(marker, { onClick, onMouseEnter, onMouseLeave });\n\n  useEffect(() => {\n    if (!marker) return;\n\n    const handleDragStart = () => {\n      const pos = marker.getPosition();\n      onDragStartRef.current?.({ lng: pos.getLng(), lat: pos.getLat() });\n    };\n    const handleDragEnd = () => {\n      const pos = marker.getPosition();\n      onDragEndRef.current?.({ lng: pos.getLng(), lat: pos.getLat() });\n    };\n\n    marker.on(\"dragstart\", handleDragStart);\n    marker.on(\"dragend\", handleDragEnd);\n\n    return () => {\n      marker.off(\"dragstart\", handleDragStart);\n      marker.off(\"dragend\", handleDragEnd);\n    };\n  }, [marker]);\n\n  useEffect(() => {\n    if (!marker) return;\n    marker.setPosition([longitude, latitude]);\n  }, [marker, longitude, latitude]);\n\n  useEffect(() => {\n    if (!marker) return;\n    marker.setDraggable(draggable);\n  }, [marker, draggable]);\n\n  useEffect(() => {\n    if (!marker) return;\n    marker.setzIndex(zIndex ?? 10);\n  }, [marker, zIndex]);\n\n  useEffect(() => {\n    if (!marker) return;\n    if (visible) {\n      marker.show();\n    } else {\n      marker.hide();\n    }\n  }, [marker, visible]);\n\n  if (!marker) return null;\n\n  return (\n    <MarkerContext.Provider value={{ marker, map }}>\n      {children}\n    </MarkerContext.Provider>\n  );\n}\n\n// MarkerContent - renders children into the marker element\ntype MarkerContentProps = {\n  children?: ReactNode;\n  className?: string;\n};\n\nfunction MarkerContent({ children, className }: MarkerContentProps) {\n  const { marker } = useMarkerContext();\n\n  const el = marker.getContent() as HTMLElement;\n  if (!el) return null;\n\n  return createPortal(\n    <div className={cn(\"relative -translate-x-1/2 -translate-y-1/2 cursor-pointer\", className)}>\n      {children || <DefaultMarkerIcon />}\n    </div>,\n    el\n  );\n}\n\nfunction DefaultMarkerIcon() {\n  return (\n    <div className=\"relative h-4 w-4 rounded-full border-2 border-white bg-blue-500 shadow-lg\" />\n  );\n}\n\n// MarkerPopup - click-activated info window\ntype MarkerPopupProps = {\n  children: ReactNode;\n  className?: string;\n  closeButton?: boolean;\n};\n\nfunction MarkerPopup({ children, className, closeButton = false }: MarkerPopupProps) {\n  const { marker, map } = useMarkerContext();\n  const { AMap } = useMap();\n  const container = useMemo(\n    () => (typeof document !== \"undefined\" ? document.createElement(\"div\") : null),\n    []\n  );\n  const infoWindowRef = useRef<AMapInstance>(null);\n\n  useEffect(() => {\n    if (!map || !AMap || !container || !marker) return;\n\n    let isMounted = true;\n\n    const infoWindow = new AMap.InfoWindow({\n      content: container,\n      offset: new AMap.Pixel(0, -30),\n      closeWhenClickMap: true,\n      isCustom: true,\n    });\n    infoWindowRef.current = infoWindow;\n\n    const handleClick = () => {\n      if (!isMounted) return;\n      if (infoWindow.getIsOpen()) {\n        infoWindow.close();\n      } else {\n        infoWindow.open(map, marker.getPosition());\n      }\n    };\n\n    marker.on(\"click\", handleClick);\n\n    return () => {\n      isMounted = false;\n      marker.off(\"click\", handleClick);\n      infoWindow.close();\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [map, AMap, marker]);\n\n  const handleClose = () => {\n    infoWindowRef.current?.close();\n  };\n\n  if (!container) return null;\n\n  return createPortal(\n    <div\n      className={cn(\n        \"relative w-max max-w-[22rem] rounded-2xl border border-slate-200/80 bg-white/95 p-4 text-slate-900 shadow-xl ring-1 ring-black/5 backdrop-blur-sm animate-in fade-in-0 zoom-in-95 dark:border-slate-800 dark:bg-slate-950/95 dark:text-slate-100 dark:ring-white/10\",\n        className\n      )}\n    >\n      {closeButton && (\n        <button\n          type=\"button\"\n          onClick={handleClose}\n          className=\"absolute right-2 top-2 z-10 inline-flex h-7 w-7 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70 dark:text-slate-500 dark:hover:bg-slate-900 dark:hover:text-slate-300\"\n          aria-label=\"Close popup\"\n        >\n          <X className=\"h-4 w-4\" />\n          <span className=\"sr-only\">Close popup</span>\n        </button>\n      )}\n      {children}\n    </div>,\n    container\n  );\n}\n\n// MarkerTooltip - hover tooltip\ntype MarkerTooltipProps = {\n  children: ReactNode;\n  className?: string;\n};\n\nfunction MarkerTooltip({ children, className }: MarkerTooltipProps) {\n  const { marker, map } = useMarkerContext();\n  const { AMap } = useMap();\n  const container = useMemo(\n    () => (typeof document !== \"undefined\" ? document.createElement(\"div\") : null),\n    []\n  );\n\n  useEffect(() => {\n    if (!map || !AMap || !container || !marker) return;\n\n    let isMounted = true;\n\n    const tooltip = new AMap.InfoWindow({\n      content: container,\n      offset: new AMap.Pixel(0, -30),\n      isCustom: true,\n      closeWhenClickMap: false,\n    });\n\n    const handleMouseOver = () => {\n      if (!isMounted) return;\n      tooltip.open(map, marker.getPosition());\n    };\n    const handleMouseOut = () => {\n      tooltip.close();\n    };\n\n    marker.on(\"mouseover\", handleMouseOver);\n    marker.on(\"mouseout\", handleMouseOut);\n\n    return () => {\n      isMounted = false;\n      marker.off(\"mouseover\", handleMouseOver);\n      marker.off(\"mouseout\", handleMouseOut);\n      tooltip.close();\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [map, AMap, marker]);\n\n  if (!container) return null;\n\n  return createPortal(\n    <div\n      className={cn(\n        \"rounded-md bg-foreground px-2 py-1 text-xs text-background shadow-md animate-in fade-in-0 zoom-in-95\",\n        className\n      )}\n    >\n      {children}\n    </div>,\n    container\n  );\n}\n\n// MarkerLabel\ntype MarkerLabelProps = {\n  children: ReactNode;\n  className?: string;\n  position?: \"top\" | \"bottom\";\n};\n\nfunction MarkerLabel({ children, className, position = \"top\" }: MarkerLabelProps) {\n  const positionClasses = { top: \"bottom-full mb-1\", bottom: \"top-full mt-1\" };\n  return (\n    <div\n      className={cn(\n        \"absolute left-1/2 -translate-x-1/2 whitespace-nowrap\",\n        \"text-[10px] font-medium text-foreground\",\n        positionClasses[position],\n        className\n      )}\n    >\n      {children}\n    </div>\n  );\n}\n\n// ---- Standalone MapPopup ----\n\ntype MapPopupProps = {\n  longitude: number;\n  latitude: number;\n  onClose?: () => void;\n  children: ReactNode;\n  className?: string;\n  closeButton?: boolean;\n};\n\nfunction MapPopup({\n  longitude,\n  latitude,\n  onClose,\n  children,\n  className,\n  closeButton = false,\n}: MapPopupProps) {\n  const { map, AMap } = useMap();\n  const container = useMemo(\n    () => (typeof document !== \"undefined\" ? document.createElement(\"div\") : null),\n    []\n  );\n  const infoWindowRef = useRef<AMapInstance>(null);\n  const onCloseRef = useLatestRef(onClose);\n\n  useEffect(() => {\n    if (!map || !AMap || !container) return;\n\n    let isMounted = true;\n\n    const infoWindow = new AMap.InfoWindow({\n      content: container,\n      offset: new AMap.Pixel(0, -10),\n      isCustom: true,\n      closeWhenClickMap: true,\n    });\n    infoWindowRef.current = infoWindow;\n    infoWindow.open(map, [longitude, latitude]);\n\n    infoWindow.on(\"close\", () => {\n      if (isMounted) onCloseRef.current?.();\n    });\n\n    return () => {\n      isMounted = false;\n      infoWindow.close();\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [map, AMap]);\n\n  // Use serialized key to avoid effect storm from inline arrays\n  const positionKey = `${longitude},${latitude}`;\n  useEffect(() => {\n    if (!infoWindowRef.current) return;\n    infoWindowRef.current.setPosition([longitude, latitude]);\n  // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [positionKey]);\n\n  const handleClose = () => {\n    infoWindowRef.current?.close();\n  };\n\n  if (!container) return null;\n\n  return createPortal(\n    <div\n      className={cn(\n        \"relative rounded-md bg-popover p-3 text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95\",\n        className\n      )}\n    >\n      {closeButton && (\n        <button\n          type=\"button\"\n          onClick={handleClose}\n          className=\"absolute top-1 right-1 z-10 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2\"\n          aria-label=\"Close popup\"\n        >\n          <X className=\"h-4 w-4\" />\n          <span className=\"sr-only\">Close popup</span>\n        </button>\n      )}\n      {children}\n    </div>,\n    container\n  );\n}\n\n// ---- MapControls ----\n\ntype MapControlsProps = {\n  position?: \"top-left\" | \"top-right\" | \"bottom-left\" | \"bottom-right\";\n  showZoom?: boolean;\n  showCompass?: boolean;\n  showLocate?: boolean;\n  showFullscreen?: boolean;\n  /** Show a scale bar at bottom-left of the map */\n  showScale?: boolean;\n  className?: string;\n  onLocate?: (coords: { longitude: number; latitude: number }) => void;\n};\n\nconst positionClasses = {\n  \"top-left\": \"top-2 left-2\",\n  \"top-right\": \"top-2 right-2\",\n  \"bottom-left\": \"bottom-2 left-2\",\n  \"bottom-right\": \"bottom-10 right-2\",\n};\n\nfunction ControlGroup({ children }: { children: React.ReactNode }) {\n  return (\n    <div className=\"flex flex-col rounded-md border border-border bg-background shadow-sm overflow-hidden [&>button:not(:last-child)]:border-b [&>button:not(:last-child)]:border-border\">\n      {children}\n    </div>\n  );\n}\n\nfunction ControlButton({\n  onClick,\n  label,\n  children,\n  disabled = false,\n}: {\n  onClick: () => void;\n  label: string;\n  children: React.ReactNode;\n  disabled?: boolean;\n}) {\n  return (\n    <button\n      onClick={onClick}\n      aria-label={label}\n      type=\"button\"\n      className={cn(\n        \"flex items-center justify-center size-8 hover:bg-accent dark:hover:bg-accent/40 transition-colors\",\n        disabled && \"opacity-50 pointer-events-none cursor-not-allowed\"\n      )}\n      disabled={disabled}\n    >\n      {children}\n    </button>\n  );\n}\n\nfunction niceNumber(n: number): number {\n  const exp = Math.floor(Math.log10(n));\n  const base = Math.pow(10, exp);\n  const normalized = n / base;\n  if (normalized < 1.5) return base;\n  if (normalized < 3.5) return 2 * base;\n  if (normalized < 7.5) return 5 * base;\n  return 10 * base;\n}\n\nfunction ScaleBar({ map }: { map: AMapInstance }) {\n  const [scaleInfo, setScaleInfo] = useState<{ width: number; label: string } | null>(null);\n\n  useEffect(() => {\n    if (!map) return;\n    const update = () => {\n      const res: number = map.getResolution?.();\n      if (!res || res <= 0) return;\n      const rawMeters = res * 80;\n      let width: number;\n      let label: string;\n      if (rawMeters >= 1000) {\n        const niceKm = niceNumber(rawMeters / 1000);\n        label = `${niceKm} km`;\n        width = (niceKm * 1000) / res;\n      } else {\n        const niceM = niceNumber(rawMeters);\n        label = `${niceM} m`;\n        width = niceM / res;\n      }\n      setScaleInfo({ width, label });\n    };\n    update();\n    map.on(\"zoomend\", update);\n    map.on(\"moveend\", update);\n    return () => {\n      try {\n        map.off(\"zoomend\", update);\n        map.off(\"moveend\", update);\n      } catch {\n        // map may be destroyed\n      }\n    };\n  }, [map]);\n\n  if (!scaleInfo) return null;\n\n  return (\n    <div className=\"flex flex-col items-start\">\n      <span className=\"text-[9px] leading-none mb-0.5 text-foreground/70 font-medium select-none\">\n        {scaleInfo.label}\n      </span>\n      <div\n        className=\"h-[3px] rounded-sm bg-foreground/60\"\n        style={{ width: `${scaleInfo.width}px` }}\n      />\n    </div>\n  );\n}\n\nfunction MapControls({\n  position = \"bottom-right\",\n  showZoom = true,\n  showCompass = false,\n  showLocate = false,\n  showFullscreen = false,\n  showScale = false,\n  className,\n  onLocate,\n}: MapControlsProps) {\n  const { map, isLoaded } = useMap();\n  const [waitingForLocation, setWaitingForLocation] = useState(false);\n  const onLocateRef = useLatestRef(onLocate);\n\n  const handleLocate = () => {\n    if (!(\"geolocation\" in navigator)) {\n      setWaitingForLocation(false);\n      return;\n    }\n    setWaitingForLocation(true);\n    navigator.geolocation.getCurrentPosition(\n      (pos) => {\n        const coords = {\n          longitude: pos.coords.longitude,\n          latitude: pos.coords.latitude,\n        };\n        map?.panTo([coords.longitude, coords.latitude]);\n        map?.setZoom(14);\n        onLocateRef.current?.(coords);\n        setWaitingForLocation(false);\n      },\n      (error) => {\n        console.error(\"Error getting location:\", error);\n        setWaitingForLocation(false);\n      }\n    );\n  };\n\n  if (!isLoaded) return null;\n\n  return (\n    <>\n      <div\n        className={cn(\n          \"absolute z-10 flex flex-col gap-1.5\",\n          positionClasses[position],\n          className\n        )}\n      >\n        {showZoom && (\n          <ControlGroup>\n            <ControlButton onClick={() => map?.zoomIn()} label=\"Zoom in\">\n              <Plus className=\"size-4\" />\n            </ControlButton>\n            <ControlButton onClick={() => map?.zoomOut()} label=\"Zoom out\">\n              <Minus className=\"size-4\" />\n            </ControlButton>\n          </ControlGroup>\n        )}\n        {showCompass && (\n          <ControlGroup>\n            <ControlButton onClick={() => { map?.setRotation(0); map?.setPitch(0); }} label=\"Reset bearing to north\">\n              <svg viewBox=\"0 0 24 24\" className=\"size-5\">\n                <path d=\"M12 2L16 12H12V2Z\" className=\"fill-red-500\" />\n                <path d=\"M12 2L8 12H12V2Z\" className=\"fill-red-300\" />\n                <path d=\"M12 22L16 12H12V22Z\" className=\"fill-muted-foreground/60\" />\n                <path d=\"M12 22L8 12H12V22Z\" className=\"fill-muted-foreground/30\" />\n              </svg>\n            </ControlButton>\n          </ControlGroup>\n        )}\n        {showLocate && (\n          <ControlGroup>\n            <ControlButton onClick={handleLocate} label=\"Find my location\" disabled={waitingForLocation}>\n              {waitingForLocation ? (\n                <Loader2 className=\"size-4 animate-spin\" />\n              ) : (\n                <Locate className=\"size-4\" />\n              )}\n            </ControlButton>\n          </ControlGroup>\n        )}\n        {showFullscreen && (\n          <ControlGroup>\n            <ControlButton\n              onClick={() => {\n                const container = map?.getContainer();\n                if (!container) return;\n                if (document.fullscreenElement) {\n                  document.exitFullscreen();\n                } else {\n                  container.requestFullscreen();\n                }\n              }}\n              label=\"Toggle fullscreen\"\n            >\n              <Maximize className=\"size-4\" />\n            </ControlButton>\n          </ControlGroup>\n        )}\n      </div>\n      {showScale && (\n        <div className=\"absolute z-10 bottom-2 left-2\">\n          <ScaleBar map={map} />\n        </div>\n      )}\n    </>\n  );\n}\n\n// ---- MapRoute (Polyline) ----\n\ntype MapRouteProps = {\n  coordinates: [number, number][];\n  color?: string;\n  width?: number;\n  opacity?: number;\n  onClick?: () => void;\n  /** Render the route as a dashed line */\n  dashed?: boolean;\n};\n\nfunction MapRoute({\n  coordinates,\n  color = \"#4285F4\",\n  width = 4,\n  opacity = 0.8,\n  onClick,\n  dashed = false,\n}: MapRouteProps) {\n  const { map, AMap, isLoaded } = useMap();\n  const polylineRef = useRef<AMapInstance>(null);\n\n  const onClickRef = useLatestRef(onClick);\n\n  const hasCoords = coordinates.length >= 2;\n\n  // Only recreate the polyline when it appears/disappears (length crosses 2).\n  // Path updates are handled by the setPath effect below.\n  useEffect(() => {\n    if (!isLoaded || !map || !AMap || !hasCoords) return;\n\n    let isMounted = true;\n\n    const polyline = new AMap.Polyline({\n      path: coordinates,\n      strokeColor: color,\n      strokeWeight: width,\n      strokeOpacity: opacity,\n      lineJoin: \"round\",\n      lineCap: \"round\",\n      strokeStyle: dashed ? \"dashed\" : \"solid\",\n      strokeDasharray: dashed ? [10, 5] : undefined,\n    });\n\n    polyline.setMap(map);\n    polylineRef.current = polyline;\n\n    const handleClick = () => {\n      if (isMounted) onClickRef.current?.();\n    };\n    polyline.on(\"click\", handleClick);\n\n    return () => {\n      isMounted = false;\n      polyline.off(\"click\", handleClick);\n      polyline.setMap(null);\n      polylineRef.current = null;\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [isLoaded, map, AMap, hasCoords]);\n\n  useEffect(() => {\n    if (!polylineRef.current) return;\n    if (!hasCoords) {\n      polylineRef.current.setMap(null);\n      polylineRef.current = null;\n      return;\n    }\n    polylineRef.current.setPath(coordinates);\n  }, [coordinates, hasCoords]);\n\n  useEffect(() => {\n    if (!polylineRef.current) return;\n    polylineRef.current.setOptions({\n      strokeColor: color,\n      strokeWeight: width,\n      strokeOpacity: opacity,\n      strokeStyle: dashed ? \"dashed\" : \"solid\",\n      strokeDasharray: dashed ? [10, 5] : undefined,\n    });\n  }, [color, width, opacity, dashed]);\n\n  return null;\n}\n\n// ---- MapClusterLayer ----\n\ntype MapClusterLayerProps<\n  P extends Record<string, unknown> = Record<string, unknown>\n> = {\n  data: GeoJSON.FeatureCollection<GeoJSON.Point, P> | string;\n  clusterColors?: [string, string, string];\n  pointColor?: string;\n  onPointClick?: (\n    feature: GeoJSON.Feature<GeoJSON.Point, P>,\n    coordinates: [number, number]\n  ) => void;\n};\n\nfunction MapClusterLayer<\n  P extends Record<string, unknown> = Record<string, unknown>\n>({\n  data,\n  clusterColors = [\"#51bbd6\", \"#f1f075\", \"#f28cb1\"],\n  pointColor = \"#3b82f6\",\n  onPointClick,\n}: MapClusterLayerProps<P>) {\n  const { map, AMap, isLoaded } = useMap();\n  const clusterRef = useRef<AMapInstance>(null);\n  const onPointClickRef = useLatestRef(onPointClick);\n\n  useEffect(() => {\n    if (!isLoaded || !map || !AMap) return;\n\n    let cancelled = false;\n\n    const resolveData = async () => {\n      let geojson: GeoJSON.FeatureCollection<GeoJSON.Point, P>;\n      if (typeof data === \"string\") {\n        const res = await fetch(data);\n        geojson = await res.json();\n      } else {\n        geojson = data;\n      }\n\n      if (cancelled) return;\n\n      AMap.plugin([\"AMap.MarkerCluster\"], () => {\n        if (cancelled) return;\n\n        const points = geojson.features.map((f) => ({\n          lnglat: f.geometry.coordinates as [number, number],\n          extData: f,\n        }));\n\n        const cluster = new AMap.MarkerCluster(map, points, {\n          gridSize: 60,\n          renderClusterMarker: (ctx: AMapInstance) => {\n            const count = ctx.count;\n            const color =\n              count > 750\n                ? clusterColors[2]\n                : count > 100\n                ? clusterColors[1]\n                : clusterColors[0];\n            const size = count > 750 ? 40 : count > 100 ? 30 : 20;\n            const div = document.createElement(\"div\");\n            div.style.cssText = `\n              width:${size}px;height:${size}px;border-radius:50%;\n              background:${color};display:flex;align-items:center;\n              justify-content:center;color:#fff;font-size:12px;font-weight:600;\n            `;\n            div.textContent = String(count);\n            ctx.marker.setContent(div);\n            ctx.marker.setOffset(new AMap.Pixel(-size / 2, -size / 2));\n          },\n          renderMarker: (ctx: AMapInstance) => {\n            const div = document.createElement(\"div\");\n            div.style.cssText = `\n              width:12px;height:12px;border-radius:50%;\n              background:${pointColor};border:2px solid white;box-shadow:0 1px 4px rgba(0,0,0,.3);\n              cursor:pointer;\n            `;\n            ctx.marker.setContent(div);\n            ctx.marker.setOffset(new AMap.Pixel(-6, -6));\n\n            ctx.marker.on(\"click\", () => {\n              const feature = ctx.data.extData as GeoJSON.Feature<GeoJSON.Point, P>;\n              onPointClickRef.current?.(feature, feature.geometry.coordinates as [number, number]);\n            });\n          },\n        });\n\n        clusterRef.current = cluster;\n      });\n    };\n\n    resolveData().catch(console.error);\n\n    return () => {\n      cancelled = true;\n      if (clusterRef.current) {\n        clusterRef.current.setMap(null);\n        clusterRef.current = null;\n      }\n    };\n  // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [isLoaded, map, AMap, data, clusterColors, pointColor]);\n\n  return null;\n}\n\n// ---- MapPolygon ----\n\ntype MapPolygonProps = {\n  /** Array of [lng, lat] coordinate pairs defining the polygon (minimum 3 points) */\n  coordinates: [number, number][];\n  fillColor?: string;\n  fillOpacity?: number;\n  strokeColor?: string;\n  strokeWidth?: number;\n  strokeOpacity?: number;\n  onClick?: () => void;\n  onMouseEnter?: () => void;\n  onMouseLeave?: () => void;\n};\n\nfunction MapPolygon({\n  coordinates,\n  fillColor = \"#3b82f6\",\n  fillOpacity = 0.3,\n  strokeColor = \"#3b82f6\",\n  strokeWidth = 2,\n  strokeOpacity = 0.8,\n  onClick,\n  onMouseEnter,\n  onMouseLeave,\n}: MapPolygonProps) {\n  const { map, AMap, isLoaded } = useMap();\n  const polygonRef = useRef<AMapInstance>(null);\n  const [polygonObj, setPolygonObj] = useState<AMapInstance>(null);\n\n  useOverlayEvents(polygonObj, { onClick, onMouseEnter, onMouseLeave });\n\n  const hasCoords = coordinates.length >= 3;\n\n  useEffect(() => {\n    if (!isLoaded || !map || !AMap || !hasCoords) return;\n\n    const polygon = new AMap.Polygon({\n      path: coordinates,\n      fillColor,\n      fillOpacity,\n      strokeColor,\n      strokeWeight: strokeWidth,\n      strokeOpacity,\n    });\n\n    polygon.setMap(map);\n    polygonRef.current = polygon;\n    setPolygonObj(polygon);\n\n    return () => {\n      polygon.setMap(null);\n      polygonRef.current = null;\n      setPolygonObj(null);\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [isLoaded, map, AMap, hasCoords]);\n\n  useEffect(() => {\n    if (!polygonRef.current || !hasCoords) return;\n    polygonRef.current.setPath(coordinates);\n  }, [coordinates, hasCoords]);\n\n  useEffect(() => {\n    if (!polygonRef.current) return;\n    polygonRef.current.setOptions({\n      fillColor,\n      fillOpacity,\n      strokeColor,\n      strokeWeight: strokeWidth,\n      strokeOpacity,\n    });\n  }, [fillColor, fillOpacity, strokeColor, strokeWidth, strokeOpacity]);\n\n  return null;\n}\n\n// ---- MapCircle ----\n\ntype MapCircleProps = {\n  /** Circle center [lng, lat] in GCJ-02 */\n  center: [number, number];\n  /** Radius in meters */\n  radius: number;\n  fillColor?: string;\n  fillOpacity?: number;\n  strokeColor?: string;\n  strokeWidth?: number;\n  strokeOpacity?: number;\n  onClick?: () => void;\n  onMouseEnter?: () => void;\n  onMouseLeave?: () => void;\n};\n\nfunction MapCircle({\n  center,\n  radius,\n  fillColor = \"#3b82f6\",\n  fillOpacity = 0.2,\n  strokeColor = \"#3b82f6\",\n  strokeWidth = 2,\n  strokeOpacity = 0.8,\n  onClick,\n  onMouseEnter,\n  onMouseLeave,\n}: MapCircleProps) {\n  const { map, AMap, isLoaded } = useMap();\n  const circleRef = useRef<AMapInstance>(null);\n  const [circleObj, setCircleObj] = useState<AMapInstance>(null);\n\n  useOverlayEvents(circleObj, { onClick, onMouseEnter, onMouseLeave });\n\n  // Use serialized key to avoid effect storm from inline center arrays\n  const centerKey = `${center[0]},${center[1]}`;\n\n  useEffect(() => {\n    if (!isLoaded || !map || !AMap) return;\n\n    const circle = new AMap.Circle({\n      center,\n      radius,\n      fillColor,\n      fillOpacity,\n      strokeColor,\n      strokeWeight: strokeWidth,\n      strokeOpacity,\n    });\n\n    circle.setMap(map);\n    circleRef.current = circle;\n    setCircleObj(circle);\n\n    return () => {\n      circle.setMap(null);\n      circleRef.current = null;\n      setCircleObj(null);\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [isLoaded, map, AMap]);\n\n  useEffect(() => {\n    if (!circleRef.current) return;\n    circleRef.current.setCenter(center);\n  // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [centerKey]);\n\n  useEffect(() => {\n    if (!circleRef.current) return;\n    circleRef.current.setRadius(radius);\n  }, [radius]);\n\n  useEffect(() => {\n    if (!circleRef.current) return;\n    circleRef.current.setOptions({\n      fillColor,\n      fillOpacity,\n      strokeColor,\n      strokeWeight: strokeWidth,\n      strokeOpacity,\n    });\n  }, [fillColor, fillOpacity, strokeColor, strokeWidth, strokeOpacity]);\n\n  return null;\n}\n\n// ---- MapHeatmap ----\n\ntype HeatmapPoint = {\n  lng: number;\n  lat: number;\n  /** Relative weight/intensity for this point */\n  count?: number;\n};\n\ntype MapHeatmapProps = {\n  /** Array of points or a GeoJSON FeatureCollection<Point> (uses properties.count/weight) */\n  data: HeatmapPoint[] | GeoJSON.FeatureCollection<GeoJSON.Point, Record<string, unknown>>;\n  /** Point radius in pixels */\n  radius?: number;\n  /** Heatmap opacity (0-1) */\n  opacity?: number;\n  /** Color gradient, keys are 0-1 positions e.g. { \"0\": \"blue\", \"1\": \"red\" } */\n  gradient?: Record<string, string>;\n  /** Maximum value used to normalize counts */\n  max?: number;\n};\n\nfunction MapHeatmap({\n  data,\n  radius = 30,\n  opacity = 0.8,\n  gradient,\n  max = 100,\n}: MapHeatmapProps) {\n  const { map, AMap, isLoaded } = useMap();\n  const heatmapRef = useRef<AMapInstance>(null);\n\n  const normalizedData = useMemo<HeatmapPoint[]>(() => {\n    if (!Array.isArray(data)) {\n      return data.features.map((f) => ({\n        lng: f.geometry.coordinates[0],\n        lat: f.geometry.coordinates[1],\n        count:\n          (f.properties?.count as number) ??\n          (f.properties?.weight as number) ??\n          1,\n      }));\n    }\n    return data;\n  }, [data]);\n\n  useEffect(() => {\n    if (!isLoaded || !map || !AMap) return;\n    let cancelled = false;\n\n    AMap.plugin([\"AMap.HeatMap\"], () => {\n      if (cancelled) return;\n      const heatmap = new AMap.HeatMap(map, {\n        radius,\n        opacity: [0, opacity],\n        gradient: gradient ?? {\n          \"0\": \"#3b82f6\",\n          \"0.4\": \"#06b6d4\",\n          \"0.65\": \"#22c55e\",\n          \"0.85\": \"#eab308\",\n          \"1\": \"#ef4444\",\n        },\n      });\n      heatmap.setDataSet({ data: normalizedData, max });\n      heatmapRef.current = heatmap;\n    });\n\n    return () => {\n      cancelled = true;\n      if (heatmapRef.current) {\n        try {\n          heatmapRef.current.setMap(null);\n        } catch {\n          // ignore\n        }\n        heatmapRef.current = null;\n      }\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [isLoaded, map, AMap]);\n\n  useEffect(() => {\n    if (!heatmapRef.current) return;\n    heatmapRef.current.setDataSet({ data: normalizedData, max });\n  }, [normalizedData, max]);\n\n  useEffect(() => {\n    if (!heatmapRef.current) return;\n    heatmapRef.current.setOptions({ radius, opacity: [0, opacity], ...(gradient ? { gradient } : {}) });\n  }, [radius, opacity, gradient]);\n\n  return null;\n}\n\n// ---- Shared TileLayer (Traffic / Satellite) ----\n\ntype TileLayerProps = {\n  ctor: \"Traffic\" | \"Satellite\";\n  visible?: boolean;\n  opacity?: number;\n};\n\nfunction TileLayer({ ctor, visible = true, opacity = 1 }: TileLayerProps) {\n  const { map, AMap, isLoaded } = useMap();\n  const layerRef = useRef<AMapInstance>(null);\n\n  useEffect(() => {\n    if (!isLoaded || !map || !AMap) return;\n\n    const layer = ctor === \"Traffic\"\n      ? new AMap.TileLayer.Traffic({ opacity })\n      : new AMap.TileLayer.Satellite({ opacity });\n    layer.setMap(map);\n    layerRef.current = layer;\n\n    return () => {\n      if (layerRef.current) {\n        try {\n          layerRef.current.setMap(null);\n        } catch {\n          // ignore teardown errors\n        }\n        layerRef.current = null;\n      }\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [isLoaded, map, AMap]);\n\n  useEffect(() => {\n    if (!layerRef.current) return;\n    if (visible) {\n      layerRef.current.show();\n    } else {\n      layerRef.current.hide();\n    }\n  }, [visible]);\n\n  useEffect(() => {\n    if (!layerRef.current) return;\n    layerRef.current.setOpacity(opacity);\n  }, [opacity]);\n\n  return null;\n}\n\ntype MapTrafficLayerProps = {\n  /** Show or hide the traffic layer */\n  visible?: boolean;\n  /** Layer opacity (0-1) */\n  opacity?: number;\n};\n\nfunction MapTrafficLayer({ visible, opacity }: MapTrafficLayerProps) {\n  return <TileLayer ctor=\"Traffic\" visible={visible} opacity={opacity} />;\n}\n\ntype MapSatelliteLayerProps = {\n  /** Show or hide the satellite layer */\n  visible?: boolean;\n  /** Layer opacity (0-1) */\n  opacity?: number;\n};\n\nfunction MapSatelliteLayer({ visible, opacity }: MapSatelliteLayerProps) {\n  return <TileLayer ctor=\"Satellite\" visible={visible} opacity={opacity} />;\n}\n\n// ---- useMapEvent ----\n\n/**\n * Subscribe to a map event. Must be called inside a `<Map>` component.\n * Automatically cleans up when the component unmounts or the event name changes.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nfunction useMapEvent(event: string, handler: (e: any) => void): void {\n  const { map } = useMap();\n  const handlerRef = useLatestRef(handler);\n\n  useEffect(() => {\n    if (!map) return;\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    const fn = (e: any) => handlerRef.current(e);\n    map.on(event, fn);\n    return () => map.off(event, fn);\n  }, [map, event]);\n}\n\n// ---- useMapBounds ----\n\ntype MapBounds = {\n  north: number;\n  south: number;\n  east: number;\n  west: number;\n};\n\n/**\n * Returns the current map viewport bounds, updated on every move/zoom.\n * Must be called inside a `<Map>` component.\n */\nfunction useMapBounds(): MapBounds | null {\n  const { map, isLoaded } = useMap();\n  const [bounds, setBounds] = useState<MapBounds | null>(null);\n\n  useEffect(() => {\n    if (!map || !isLoaded) return;\n\n    const update = () => {\n      const b = map.getBounds?.();\n      if (!b) return;\n      setBounds({\n        north: b.getNorthEast().getLat(),\n        south: b.getSouthWest().getLat(),\n        east: b.getNorthEast().getLng(),\n        west: b.getSouthWest().getLng(),\n      });\n    };\n\n    update();\n    map.on(\"moveend\", update);\n    map.on(\"zoomend\", update);\n    return () => {\n      try {\n        map.off(\"moveend\", update);\n        map.off(\"zoomend\", update);\n      } catch {\n        // map may be destroyed\n      }\n    };\n  }, [map, isLoaded]);\n\n  return bounds;\n}\n\nexport {\n  Map,\n  useMap,\n  useMapEvent,\n  useMapBounds,\n  MapMarker,\n  MarkerContent,\n  MarkerPopup,\n  MarkerTooltip,\n  MarkerLabel,\n  MapPopup,\n  MapControls,\n  MapRoute,\n  MapClusterLayer,\n  MapPolygon,\n  MapCircle,\n  MapHeatmap,\n  MapTrafficLayer,\n  MapSatelliteLayer,\n};\n\nexport type { MapRef, HeatmapPoint, MapBounds };\n",
      "type": "registry:ui",
      "target": "components/ui/map.tsx"
    }
  ],
  "type": "registry:ui"
}