shadimage
amap
uiamapsource: ok · hit

Map

An AMap (高德地图)-powered map component with markers, popups, tooltips, routes, and controls.

npx shadcn add "https://map.ling.pub/r/amap.json"
free

Interaction

modaltooltip

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

Dependencies

Bundle weight · light
iconslucide-react

Files1 · source cached 10/8/2026, 7:44:17 PM

src/map.tsx
"use client";

import { useTheme } from "next-themes";
import {
  createContext,
  forwardRef,
  useContext,
  useEffect,
  useImperativeHandle,
  useMemo,
  useRef,
  useState,
  type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { X, Minus, Plus, Locate, Maximize, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AMapNS = any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AMapInstance = any;

declare global {
  interface Window {
    _AMapSecurityConfig?: { securityJsCode?: string };
  }
}

// ---- Shared hooks ----

function useLatestRef<T>(value: T) {
  const ref = useRef(value);
  ref.current = value;
  return ref;
}

function useOverlayEvents(
  overlay: AMapInstance | null,
  events: {
    onClick?: () => void;
    onMouseEnter?: () => void;
    onMouseLeave?: () => void;
  }
) {
  const clickRef = useLatestRef(events.onClick);
  const enterRef = useLatestRef(events.onMouseEnter);
  const leaveRef = useLatestRef(events.onMouseLeave);

  useEffect(() => {
    if (!overlay) return;
    const handleClick = () => clickRef.current?.();
    const handleOver = () => enterRef.current?.();
    const handleOut = () => leaveRef.current?.();

    overlay.on("click", handleClick);
    overlay.on("mouseover", handleOver);
    overlay.on("mouseout", handleOut);

    return () => {
      overlay.off("click", handleClick);
      overlay.off("mouseover", handleOver);
      overlay.off("mouseout", handleOut);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [overlay]);
}

// ---- Map ----

const defaultStyles = {
  dark: "amap://styles/dark",
  light: "amap://styles/light",
  normal: "amap://styles/normal",
};

type MapContextValue = {
  map: AMapInstance | null;
  AMap: AMapNS | null;
  isLoaded: boolean;
};

const MapContext = createContext<MapContextValue | null>(null);

function useMap() {
  const context = useContext(MapContext);
  if (!context) {
    throw new Error("useMap must be used within a Map component");
  }
  return context;
}

type MapProps = {
  children?: ReactNode;
  /** Map center [longitude, latitude] in GCJ-02 */
  center?: [number, number];
  /** Map zoom level (3-18) */
  zoom?: number;
  /** Custom map styles for light and dark themes */
  styles?: { light?: string; dark?: string };
  /** Additional CSS class for the container */
  className?: string;
  /** AMap JS API key */
  amapKey?: string;
  /** AMap JS API security code (required for 2.0) */
  securityJsCode?: string;
  /** Fit the map to these bounds [[sw_lng, sw_lat], [ne_lng, ne_lat]] */
  bounds?: [[number, number], [number, number]];
  /** Map view mode. Note: only takes effect on initial mount */
  viewMode?: "2D" | "3D";
  /** Callback when map finishes loading */
  onLoad?: () => void;
  /** Callback when map is clicked */
  onClick?: (lngLat: { lng: number; lat: number }) => void;
  /** Callback when map pan/move ends */
  onMoveEnd?: () => void;
  /** Callback when map zoom ends */
  onZoomEnd?: () => void;
  /** Callback when AMap fails to load */
  onError?: (error: Error) => void;
};

type MapRef = AMapInstance;

const DefaultLoader = () => (
  <div className="absolute inset-0 flex items-center justify-center">
    <div className="flex gap-1">
      <span className="size-1.5 rounded-full bg-muted-foreground/60 animate-pulse" />
      <span className="size-1.5 rounded-full bg-muted-foreground/60 animate-pulse [animation-delay:150ms]" />
      <span className="size-1.5 rounded-full bg-muted-foreground/60 animate-pulse [animation-delay:300ms]" />
    </div>
  </div>
);

const Map = forwardRef<MapRef, MapProps>(function Map(
  {
    children,
    center = [116.397428, 39.90923],
    zoom = 11,
    styles,
    className,
    amapKey,
    securityJsCode,
    bounds,
    viewMode = "3D",
    onLoad,
    onClick,
    onMoveEnd,
    onZoomEnd,
    onError,
  },
  ref
) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [mapInstance, setMapInstance] = useState<AMapInstance>(null);
  const [amapNS, setAmapNS] = useState<AMapNS>(null);
  const [isLoaded, setIsLoaded] = useState(false);
  const { resolvedTheme } = useTheme();
  const currentStyleRef = useRef<string | null>(null);

  const onLoadRef = useLatestRef(onLoad);
  const onClickRef = useLatestRef(onClick);
  const onMoveEndRef = useLatestRef(onMoveEnd);
  const onZoomEndRef = useLatestRef(onZoomEnd);
  const onErrorRef = useLatestRef(onError);

  const mapStyles = useMemo(
    () => ({
      dark: styles?.dark ?? defaultStyles.dark,
      light: styles?.light ?? defaultStyles.light,
    }),
    [styles]
  );

  useImperativeHandle(ref, () => mapInstance, [mapInstance]);

  useEffect(() => {
    if (!containerRef.current) return;

    const key = amapKey ?? "983f0d71329b83141e06427729399d5e";
    const code = securityJsCode ?? "0105e3185f27b87d2aab3c2bad23fc86";
    if (code) {
      window._AMapSecurityConfig = { securityJsCode: code };
    }

    // Use a ref to track mount state and avoid closure staleness in async callbacks
    let isMounted = true;
    let map: AMapInstance = null;

    import("@amap/amap-jsapi-loader").then(({ default: AMapLoader }) => {
      if (!isMounted) return;
      return AMapLoader.load({
        key,
        version: "2.0",
        plugins: [],
      });
    })
      .then((AMap: AMapNS) => {
        if (!isMounted || !containerRef.current) return;

        const initialStyle =
          resolvedTheme === "dark" ? mapStyles.dark : mapStyles.light;
        currentStyleRef.current = initialStyle;

        map = new AMap.Map(containerRef.current, {
          viewMode,
          zoom,
          center,
          mapStyle: initialStyle,
          resizeEnable: true,
        });

        map.on("complete", () => {
          if (!isMounted) return;
          setIsLoaded(true);
          onLoadRef.current?.();
        });

        setMapInstance(map);
        setAmapNS(AMap);
      })
      .catch((err: unknown) => {
        if (!isMounted) return;
        const error = err instanceof Error ? err : new Error(String(err));
        console.error("AMap load error:", error);
        onErrorRef.current?.(error);
      });

    return () => {
      isMounted = false;
      setIsLoaded(false);
      setMapInstance(null);
      setAmapNS(null);
      // Defer map.destroy() so child components unmount first and
      // don't call methods on a destroyed map instance.
      if (map) {
        queueMicrotask(() => {
          try { map.destroy(); } catch { /* ignore */ }
        });
      }
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  useEffect(() => {
    if (!mapInstance || !resolvedTheme) return;
    const newStyle =
      resolvedTheme === "dark" ? mapStyles.dark : mapStyles.light;
    if (currentStyleRef.current === newStyle) return;
    currentStyleRef.current = newStyle;
    mapInstance.setMapStyle(newStyle);
  }, [mapInstance, resolvedTheme, mapStyles]);

  // Sync center — use serialized key to avoid effect storm from inline arrays
  const centerKey = center ? `${center[0]},${center[1]}` : null;
  useEffect(() => {
    if (!mapInstance || !center) return;
    mapInstance.panTo(center);
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [mapInstance, centerKey]);

  useEffect(() => {
    if (!mapInstance) return;
    mapInstance.setZoom(zoom);
  }, [mapInstance, zoom]);

  useEffect(() => {
    if (!mapInstance) return;
    const handleClick = (e: AMapInstance) => {
      onClickRef.current?.({ lng: e.lnglat.getLng(), lat: e.lnglat.getLat() });
    };
    const handleMoveEnd = () => onMoveEndRef.current?.();
    const handleZoomEnd = () => onZoomEndRef.current?.();

    mapInstance.on("click", handleClick);
    mapInstance.on("moveend", handleMoveEnd);
    mapInstance.on("zoomend", handleZoomEnd);
    return () => {
      mapInstance.off("click", handleClick);
      mapInstance.off("moveend", handleMoveEnd);
      mapInstance.off("zoomend", handleZoomEnd);
    };
  }, [mapInstance]);

  const boundsKey = bounds
    ? `${bounds[0][0]},${bounds[0][1]},${bounds[1][0]},${bounds[1][1]}`
    : null;
  useEffect(() => {
    if (!mapInstance || !amapNS || !bounds) return;
    const [sw, ne] = bounds;
    mapInstance.setBounds(new amapNS.Bounds(sw, ne));
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [mapInstance, amapNS, boundsKey]);

  const contextValue = useMemo(
    () => ({ map: mapInstance, AMap: amapNS, isLoaded }),
    [mapInstance, amapNS, isLoaded]
  );

  return (
    <MapContext.Provider value={contextValue}>
      <div
        ref={containerRef}
        className={cn("relative w-full h-full", className)}
      >
        {!isLoaded && <DefaultLoader />}
        {mapInstance && children}
      </div>
    </MapContext.Provider>
  );
});

// ---- Marker ----

type MarkerContextValue = {
  marker: AMapInstance;
  map: AMapInstance | null;
};

const MarkerContext = createContext<MarkerContextValue | null>(null);

function useMarkerContext() {
  const context = useContext(MarkerContext);
  if (!context) throw new Error("Marker components must be used within MapMarker");
  return context;
}

type MapMarkerProps = {
  /** Longitude (GCJ-02) */
  longitude: number;
  /** Latitude (GCJ-02) */
  latitude: number;
  children: ReactNode;
  onClick?: () => void;
  onMouseEnter?: () => void;
  onMouseLeave?: () => void;
  draggable?: boolean;
  onDragStart?: (lngLat: { lng: number; lat: number }) => void;
  onDragEnd?: (lngLat: { lng: number; lat: number }) => void;
  zIndex?: number;
  /** Show or hide the marker */
  visible?: boolean;
};

function MapMarker({
  longitude,
  latitude,
  children,
  onClick,
  onMouseEnter,
  onMouseLeave,
  draggable = false,
  onDragStart,
  onDragEnd,
  zIndex,
  visible = true,
}: MapMarkerProps) {
  const { map, AMap } = useMap();
  const [marker, setMarker] = useState<AMapInstance>(null);

  const onDragStartRef = useLatestRef(onDragStart);
  const onDragEndRef = useLatestRef(onDragEnd);

  // Create marker in effect to handle strict mode correctly
  useEffect(() => {
    if (!map || !AMap) return;

    // Create fresh container element for each mount
    const containerEl = document.createElement("div");

    const newMarker = new AMap.Marker({
      position: [longitude, latitude],
      content: containerEl,
      offset: new AMap.Pixel(0, 0),
      draggable,
    });

    newMarker.setMap(map);

    setMarker(newMarker);

    return () => {
      newMarker.setMap(null);
      setMarker(null);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [map, AMap]);

  useOverlayEvents(marker, { onClick, onMouseEnter, onMouseLeave });

  useEffect(() => {
    if (!marker) return;

    const handleDragStart = () => {
      const pos = marker.getPosition();
      onDragStartRef.current?.({ lng: pos.getLng(), lat: pos.getLat() });
    };
    const handleDragEnd = () => {
      const pos = marker.getPosition();
      onDragEndRef.current?.({ lng: pos.getLng(), lat: pos.getLat() });
    };

    marker.on("dragstart", handleDragStart);
    marker.on("dragend", handleDragEnd);

    return () => {
      marker.off("dragstart", handleDragStart);
      marker.off("dragend", handleDragEnd);
    };
  }, [marker]);

  useEffect(() => {
    if (!marker) return;
    marker.setPosition([longitude, latitude]);
  }, [marker, longitude, latitude]);

  useEffect(() => {
    if (!marker) return;
    marker.setDraggable(draggable);
  }, [marker, draggable]);

  useEffect(() => {
    if (!marker) return;
    marker.setzIndex(zIndex ?? 10);
  }, [marker, zIndex]);

  useEffect(() => {
    if (!marker) return;
    if (visible) {
      marker.show();
    } else {
      marker.hide();
    }
  }, [marker, visible]);

  if (!marker) return null;

  return (
    <MarkerContext.Provider value={{ marker, map }}>
      {children}
    </MarkerContext.Provider>
  );
}

// MarkerContent - renders children into the marker element
type MarkerContentProps = {
  children?: ReactNode;
  className?: string;
};

function MarkerContent({ children, className }: MarkerContentProps) {
  const { marker } = useMarkerContext();

  const el = marker.getContent() as HTMLElement;
  if (!el) return null;

  return createPortal(
    <div className={cn("relative -translate-x-1/2 -translate-y-1/2 cursor-pointer", className)}>
      {children || <DefaultMarkerIcon />}
    </div>,
    el
  );
}

function DefaultMarkerIcon() {
  return (
    <div className="relative h-4 w-4 rounded-full border-2 border-white bg-blue-500 shadow-lg" />
  );
}

// MarkerPopup - click-activated info window
type MarkerPopupProps = {
  children: ReactNode;
  className?: string;
  closeButton?: boolean;
};

function MarkerPopup({ children, className, closeButton = false }: MarkerPopupProps) {
  const { marker, map } = useMarkerContext();
  const { AMap } = useMap();
  const container = useMemo(
    () => (typeof document !== "undefined" ? document.createElement("div") : null),
    []
  );
  const infoWindowRef = useRef<AMapInstance>(null);

  useEffect(() => {
    if (!map || !AMap || !container || !marker) return;

    let isMounted = true;

    const infoWindow = new AMap.InfoWindow({
      content: container,
      offset: new AMap.Pixel(0, -30),
      closeWhenClickMap: true,
      isCustom: true,
    });
    infoWindowRef.current = infoWindow;

    const handleClick = () => {
      if (!isMounted) return;
      if (infoWindow.getIsOpen()) {
        infoWindow.close();
      } else {
        infoWindow.open(map, marker.getPosition());
      }
    };

    marker.on("click", handleClick);

    return () => {
      isMounted = false;
      marker.off("click", handleClick);
      infoWindow.close();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [map, AMap, marker]);

  const handleClose = () => {
    infoWindowRef.current?.close();
  };

  if (!container) return null;

  return createPortal(
    <div
      className={cn(
        "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",
        className
      )}
    >
      {closeButton && (
        <button
          type="button"
          onClick={handleClose}
          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"
          aria-label="Close popup"
        >
          <X className="h-4 w-4" />
          <span className="sr-only">Close popup</span>
        </button>
      )}
      {children}
    </div>,
    container
  );
}

// MarkerTooltip - hover tooltip
type MarkerTooltipProps = {
  children: ReactNode;
  className?: string;
};

function MarkerTooltip({ children, className }: MarkerTooltipProps) {
  const { marker, map } = useMarkerContext();
  const { AMap } = useMap();
  const container = useMemo(
    () => (typeof document !== "undefined" ? document.createElement("div") : null),
    []
  );

  useEffect(() => {
    if (!map || !AMap || !container || !marker) return;

    let isMounted = true;

    const tooltip = new AMap.InfoWindow({
      content: container,
      offset: new AMap.Pixel(0, -30),
      isCustom: true,
      closeWhenClickMap: false,
    });

    const handleMouseOver = () => {
      if (!isMounted) return;
      tooltip.open(map, marker.getPosition());
    };
    const handleMouseOut = () => {
      tooltip.close();
    };

    marker.on("mouseover", handleMouseOver);
    marker.on("mouseout", handleMouseOut);

    return () => {
      isMounted = false;
      marker.off("mouseover", handleMouseOver);
      marker.off("mouseout", handleMouseOut);
      tooltip.close();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [map, AMap, marker]);

  if (!container) return null;

  return createPortal(
    <div
      className={cn(
        "rounded-md bg-foreground px-2 py-1 text-xs text-background shadow-md animate-in fade-in-0 zoom-in-95",
        className
      )}
    >
      {children}
    </div>,
    container
  );
}

// MarkerLabel
type MarkerLabelProps = {
  children: ReactNode;
  className?: string;
  position?: "top" | "bottom";
};

function MarkerLabel({ children, className, position = "top" }: MarkerLabelProps) {
  const positionClasses = { top: "bottom-full mb-1", bottom: "top-full mt-1" };
  return (
    <div
      className={cn(
        "absolute left-1/2 -translate-x-1/2 whitespace-nowrap",
        "text-[10px] font-medium text-foreground",
        positionClasses[position],
        className
      )}
    >
      {children}
    </div>
  );
}

// ---- Standalone MapPopup ----

type MapPopupProps = {
  longitude: number;
  latitude: number;
  onClose?: () => void;
  children: ReactNode;
  className?: string;
  closeButton?: boolean;
};

function MapPopup({
  longitude,
  latitude,
  onClose,
  children,
  className,
  closeButton = false,
}: MapPopupProps) {
  const { map, AMap } = useMap();
  const container = useMemo(
    () => (typeof document !== "undefined" ? document.createElement("div") : null),
    []
  );
  const infoWindowRef = useRef<AMapInstance>(null);
  const onCloseRef = useLatestRef(onClose);

  useEffect(() => {
    if (!map || !AMap || !container) return;

    let isMounted = true;

    const infoWindow = new AMap.InfoWindow({
      content: container,
      offset: new AMap.Pixel(0, -10),
      isCustom: true,
      closeWhenClickMap: true,
    });
    infoWindowRef.current = infoWindow;
    infoWindow.open(map, [longitude, latitude]);

    infoWindow.on("close", () => {
      if (isMounted) onCloseRef.current?.();
    });

    return () => {
      isMounted = false;
      infoWindow.close();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [map, AMap]);

  // Use serialized key to avoid effect storm from inline arrays
  const positionKey = `${longitude},${latitude}`;
  useEffect(() => {
    if (!infoWindowRef.current) return;
    infoWindowRef.current.setPosition([longitude, latitude]);
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [positionKey]);

  const handleClose = () => {
    infoWindowRef.current?.close();
  };

  if (!container) return null;

  return createPortal(
    <div
      className={cn(
        "relative rounded-md bg-popover p-3 text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95",
        className
      )}
    >
      {closeButton && (
        <button
          type="button"
          onClick={handleClose}
          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"
          aria-label="Close popup"
        >
          <X className="h-4 w-4" />
          <span className="sr-only">Close popup</span>
        </button>
      )}
      {children}
    </div>,
    container
  );
}

// ---- MapControls ----

type MapControlsProps = {
  position?: "top-left" | "top-right" | "bottom-left" | "bottom-right";
  showZoom?: boolean;
  showCompass?: boolean;
  showLocate?: boolean;
  showFullscreen?: boolean;
  /** Show a scale bar at bottom-left of the map */
  showScale?: boolean;
  className?: string;
  onLocate?: (coords: { longitude: number; latitude: number }) => void;
};

const positionClasses = {
  "top-left": "top-2 left-2",
  "top-right": "top-2 right-2",
  "bottom-left": "bottom-2 left-2",
  "bottom-right": "bottom-10 right-2",
};

function ControlGroup({ children }: { children: React.ReactNode }) {
  return (
    <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">
      {children}
    </div>
  );
}

function ControlButton({
  onClick,
  label,
  children,
  disabled = false,
}: {
  onClick: () => void;
  label: string;
  children: React.ReactNode;
  disabled?: boolean;
}) {
  return (
    <button
      onClick={onClick}
      aria-label={label}
      type="button"
      className={cn(
        "flex items-center justify-center size-8 hover:bg-accent dark:hover:bg-accent/40 transition-colors",
        disabled && "opacity-50 pointer-events-none cursor-not-allowed"
      )}
      disabled={disabled}
    >
      {children}
    </button>
  );
}

function niceNumber(n: number): number {
  const exp = Math.floor(Math.log10(n));
  const base = Math.pow(10, exp);
  const normalized = n / base;
  if (normalized < 1.5) return base;
  if (normalized < 3.5) return 2 * base;
  if (normalized < 7.5) return 5 * base;
  return 10 * base;
}

function ScaleBar({ map }: { map: AMapInstance }) {
  const [scaleInfo, setScaleInfo] = useState<{ width: number; label: string } | null>(null);

  useEffect(() => {
    if (!map) return;
    const update = () => {
      const res: number = map.getResolution?.();
      if (!res || res <= 0) return;
      const rawMeters = res * 80;
      let width: number;
      let label: string;
      if (rawMeters >= 1000) {
        const niceKm = niceNumber(rawMeters / 1000);
        label = `${niceKm} km`;
        width = (niceKm * 1000) / res;
      } else {
        const niceM = niceNumber(rawMeters);
        label = `${niceM} m`;
        width = niceM / res;
      }
      setScaleInfo({ width, label });
    };
    update();
    map.on("zoomend", update);
    map.on("moveend", update);
    return () => {
      try {
        map.off("zoomend", update);
        map.off("moveend", update);
      } catch {
        // map may be destroyed
      }
    };
  }, [map]);

  if (!scaleInfo) return null;

  return (
    <div className="flex flex-col items-start">
      <span className="text-[9px] leading-none mb-0.5 text-foreground/70 font-medium select-none">
        {scaleInfo.label}
      </span>
      <div
        className="h-[3px] rounded-sm bg-foreground/60"
        style={{ width: `${scaleInfo.width}px` }}
      />
    </div>
  );
}

function MapControls({
  position = "bottom-right",
  showZoom = true,
  showCompass = false,
  showLocate = false,
  showFullscreen = false,
  showScale = false,
  className,
  onLocate,
}: MapControlsProps) {
  const { map, isLoaded } = useMap();
  const [waitingForLocation, setWaitingForLocation] = useState(false);
  const onLocateRef = useLatestRef(onLocate);

  const handleLocate = () => {
    if (!("geolocation" in navigator)) {
      setWaitingForLocation(false);
      return;
    }
    setWaitingForLocation(true);
    navigator.geolocation.getCurrentPosition(
      (pos) => {
        const coords = {
          longitude: pos.coords.longitude,
          latitude: pos.coords.latitude,
        };
        map?.panTo([coords.longitude, coords.latitude]);
        map?.setZoom(14);
        onLocateRef.current?.(coords);
        setWaitingForLocation(false);
      },
      (error) => {
        console.error("Error getting location:", error);
        setWaitingForLocation(false);
      }
    );
  };

  if (!isLoaded) return null;

  return (
    <>
      <div
        className={cn(
          "absolute z-10 flex flex-col gap-1.5",
          positionClasses[position],
          className
        )}
      >
        {showZoom && (
          <ControlGroup>
            <ControlButton onClick={() => map?.zoomIn()} label="Zoom in">
              <Plus className="size-4" />
            </ControlButton>
            <ControlButton onClick={() => map?.zoomOut()} label="Zoom out">
              <Minus className="size-4" />
            </ControlButton>
          </ControlGroup>
        )}
        {showCompass && (
          <ControlGroup>
            <ControlButton onClick={() => { map?.setRotation(0); map?.setPitch(0); }} label="Reset bearing to north">
              <svg viewBox="0 0 24 24" className="size-5">
                <path d="M12 2L16 12H12V2Z" className="fill-red-500" />
                <path d="M12 2L8 12H12V2Z" className="fill-red-300" />
                <path d="M12 22L16 12H12V22Z" className="fill-muted-foreground/60" />
                <path d="M12 22L8 12H12V22Z" className="fill-muted-foreground/30" />
              </svg>
            </ControlButton>
          </ControlGroup>
        )}
        {showLocate && (
          <ControlGroup>
            <ControlButton onClick={handleLocate} label="Find my location" disabled={waitingForLocation}>
              {waitingForLocation ? (
                <Loader2 className="size-4 animate-spin" />
              ) : (
                <Locate className="size-4" />
              )}
            </ControlButton>
          </ControlGroup>
        )}
        {showFullscreen && (
          <ControlGroup>
            <ControlButton
              onClick={() => {
                const container = map?.getContainer();
                if (!container) return;
                if (document.fullscreenElement) {
                  document.exitFullscreen();
                } else {
                  container.requestFullscreen();
                }
              }}
              label="Toggle fullscreen"
            >
              <Maximize className="size-4" />
            </ControlButton>
          </ControlGroup>
        )}
      </div>
      {showScale && (
        <div className="absolute z-10 bottom-2 left-2">
          <ScaleBar map={map} />
        </div>
      )}
    </>
  );
}

// ---- MapRoute (Polyline) ----

type MapRouteProps = {
  coordinates: [number, number][];
  color?: string;
  width?: number;
  opacity?: number;
  onClick?: () => void;
  /** Render the route as a dashed line */
  dashed?: boolean;
};

function MapRoute({
  coordinates,
  color = "#4285F4",
  width = 4,
  opacity = 0.8,
  onClick,
  dashed = false,
}: MapRouteProps) {
  const { map, AMap, isLoaded } = useMap();
  const polylineRef = useRef<AMapInstance>(null);

  const onClickRef = useLatestRef(onClick);

  const hasCoords = coordinates.length >= 2;

  // Only recreate the polyline when it appears/disappears (length crosses 2).
  // Path updates are handled by the setPath effect below.
  useEffect(() => {
    if (!isLoaded || !map || !AMap || !hasCoords) return;

    let isMounted = true;

    const polyline = new AMap.Polyline({
      path: coordinates,
      strokeColor: color,
      strokeWeight: width,
      strokeOpacity: opacity,
      lineJoin: "round",
      lineCap: "round",
      strokeStyle: dashed ? "dashed" : "solid",
      strokeDasharray: dashed ? [10, 5] : undefined,
    });

    polyline.setMap(map);
    polylineRef.current = polyline;

    const handleClick = () => {
      if (isMounted) onClickRef.current?.();
    };
    polyline.on("click", handleClick);

    return () => {
      isMounted = false;
      polyline.off("click", handleClick);
      polyline.setMap(null);
      polylineRef.current = null;
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [isLoaded, map, AMap, hasCoords]);

  useEffect(() => {
    if (!polylineRef.current) return;
    if (!hasCoords) {
      polylineRef.current.setMap(null);
      polylineRef.current = null;
      return;
    }
    polylineRef.current.setPath(coordinates);
  }, [coordinates, hasCoords]);

  useEffect(() => {
    if (!polylineRef.current) return;
    polylineRef.current.setOptions({
      strokeColor: color,
      strokeWeight: width,
      strokeOpacity: opacity,
      strokeStyle: dashed ? "dashed" : "solid",
      strokeDasharray: dashed ? [10, 5] : undefined,
    });
  }, [color, width, opacity, dashed]);

  return null;
}

// ---- MapClusterLayer ----

type MapClusterLayerProps<
  P extends Record<string, unknown> = Record<string, unknown>
> = {
  data: GeoJSON.FeatureCollection<GeoJSON.Point, P> | string;
  clusterColors?: [string, string, string];
  pointColor?: string;
  onPointClick?: (
    feature: GeoJSON.Feature<GeoJSON.Point, P>,
    coordinates: [number, number]
  ) => void;
};

function MapClusterLayer<
  P extends Record<string, unknown> = Record<string, unknown>
>({
  data,
  clusterColors = ["#51bbd6", "#f1f075", "#f28cb1"],
  pointColor = "#3b82f6",
  onPointClick,
}: MapClusterLayerProps<P>) {
  const { map, AMap, isLoaded } = useMap();
  const clusterRef = useRef<AMapInstance>(null);
  const onPointClickRef = useLatestRef(onPointClick);

  useEffect(() => {
    if (!isLoaded || !map || !AMap) return;

    let cancelled = false;

    const resolveData = async () => {
      let geojson: GeoJSON.FeatureCollection<GeoJSON.Point, P>;
      if (typeof data === "string") {
        const res = await fetch(data);
        geojson = await res.json();
      } else {
        geojson = data;
      }

      if (cancelled) return;

      AMap.plugin(["AMap.MarkerCluster"], () => {
        if (cancelled) return;

        const points = geojson.features.map((f) => ({
          lnglat: f.geometry.coordinates as [number, number],
          extData: f,
        }));

        const cluster = new AMap.MarkerCluster(map, points, {
          gridSize: 60,
          renderClusterMarker: (ctx: AMapInstance) => {
            const count = ctx.count;
            const color =
              count > 750
                ? clusterColors[2]
                : count > 100
                ? clusterColors[1]
                : clusterColors[0];
            const size = count > 750 ? 40 : count > 100 ? 30 : 20;
            const div = document.createElement("div");
            div.style.cssText = `
              width:${size}px;height:${size}px;border-radius:50%;
              background:${color};display:flex;align-items:center;
              justify-content:center;color:#fff;font-size:12px;font-weight:600;
            `;
            div.textContent = String(count);
            ctx.marker.setContent(div);
            ctx.marker.setOffset(new AMap.Pixel(-size / 2, -size / 2));
          },
          renderMarker: (ctx: AMapInstance) => {
            const div = document.createElement("div");
            div.style.cssText = `
              width:12px;height:12px;border-radius:50%;
              background:${pointColor};border:2px solid white;box-shadow:0 1px 4px rgba(0,0,0,.3);
              cursor:pointer;
            `;
            ctx.marker.setContent(div);
            ctx.marker.setOffset(new AMap.Pixel(-6, -6));

            ctx.marker.on("click", () => {
              const feature = ctx.data.extData as GeoJSON.Feature<GeoJSON.Point, P>;
              onPointClickRef.current?.(feature, feature.geometry.coordinates as [number, number]);
            });
          },
        });

        clusterRef.current = cluster;
      });
    };

    resolveData().catch(console.error);

    return () => {
      cancelled = true;
      if (clusterRef.current) {
        clusterRef.current.setMap(null);
        clusterRef.current = null;
      }
    };
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [isLoaded, map, AMap, data, clusterColors, pointColor]);

  return null;
}

// ---- MapPolygon ----

type MapPolygonProps = {
  /** Array of [lng, lat] coordinate pairs defining the polygon (minimum 3 points) */
  coordinates: [number, number][];
  fillColor?: string;
  fillOpacity?: number;
  strokeColor?: string;
  strokeWidth?: number;
  strokeOpacity?: number;
  onClick?: () => void;
  onMouseEnter?: () => void;
  onMouseLeave?: () => void;
};

function MapPolygon({
  coordinates,
  fillColor = "#3b82f6",
  fillOpacity = 0.3,
  strokeColor = "#3b82f6",
  strokeWidth = 2,
  strokeOpacity = 0.8,
  onClick,
  onMouseEnter,
  onMouseLeave,
}: MapPolygonProps) {
  const { map, AMap, isLoaded } = useMap();
  const polygonRef = useRef<AMapInstance>(null);
  const [polygonObj, setPolygonObj] = useState<AMapInstance>(null);

  useOverlayEvents(polygonObj, { onClick, onMouseEnter, onMouseLeave });

  const hasCoords = coordinates.length >= 3;

  useEffect(() => {
    if (!isLoaded || !map || !AMap || !hasCoords) return;

    const polygon = new AMap.Polygon({
      path: coordinates,
      fillColor,
      fillOpacity,
      strokeColor,
      strokeWeight: strokeWidth,
      strokeOpacity,
    });

    polygon.setMap(map);
    polygonRef.current = polygon;
    setPolygonObj(polygon);

    return () => {
      polygon.setMap(null);
      polygonRef.current = null;
      setPolygonObj(null);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [isLoaded, map, AMap, hasCoords]);

  useEffect(() => {
    if (!polygonRef.current || !hasCoords) return;
    polygonRef.current.setPath(coordinates);
  }, [coordinates, hasCoords]);

  useEffect(() => {
    if (!polygonRef.current) return;
    polygonRef.current.setOptions({
      fillColor,
      fillOpacity,
      strokeColor,
      strokeWeight: strokeWidth,
      strokeOpacity,
    });
  }, [fillColor, fillOpacity, strokeColor, strokeWidth, strokeOpacity]);

  return null;
}

// ---- MapCircle ----

type MapCircleProps = {
  /** Circle center [lng, lat] in GCJ-02 */
  center: [number, number];
  /** Radius in meters */
  radius: number;
  fillColor?: string;
  fillOpacity?: number;
  strokeColor?: string;
  strokeWidth?: number;
  strokeOpacity?: number;
  onClick?: () => void;
  onMouseEnter?: () => void;
  onMouseLeave?: () => void;
};

function MapCircle({
  center,
  radius,
  fillColor = "#3b82f6",
  fillOpacity = 0.2,
  strokeColor = "#3b82f6",
  strokeWidth = 2,
  strokeOpacity = 0.8,
  onClick,
  onMouseEnter,
  onMouseLeave,
}: MapCircleProps) {
  const { map, AMap, isLoaded } = useMap();
  const circleRef = useRef<AMapInstance>(null);
  const [circleObj, setCircleObj] = useState<AMapInstance>(null);

  useOverlayEvents(circleObj, { onClick, onMouseEnter, onMouseLeave });

  // Use serialized key to avoid effect storm from inline center arrays
  const centerKey = `${center[0]},${center[1]}`;

  useEffect(() => {
    if (!isLoaded || !map || !AMap) return;

    const circle = new AMap.Circle({
      center,
      radius,
      fillColor,
      fillOpacity,
      strokeColor,
      strokeWeight: strokeWidth,
      strokeOpacity,
    });

    circle.setMap(map);
    circleRef.current = circle;
    setCircleObj(circle);

    return () => {
      circle.setMap(null);
      circleRef.current = null;
      setCircleObj(null);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [isLoaded, map, AMap]);

  useEffect(() => {
    if (!circleRef.current) return;
    circleRef.current.setCenter(center);
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [centerKey]);

  useEffect(() => {
    if (!circleRef.current) return;
    circleRef.current.setRadius(radius);
  }, [radius]);

  useEffect(() => {
    if (!circleRef.current) return;
    circleRef.current.setOptions({
      fillColor,
      fillOpacity,
      strokeColor,
      strokeWeight: strokeWidth,
      strokeOpacity,
    });
  }, [fillColor, fillOpacity, strokeColor, strokeWidth, strokeOpacity]);

  return null;
}

// ---- MapHeatmap ----

type HeatmapPoint = {
  lng: number;
  lat: number;
  /** Relative weight/intensity for this point */
  count?: number;
};

type MapHeatmapProps = {
  /** Array of points or a GeoJSON FeatureCollection<Point> (uses properties.count/weight) */
  data: HeatmapPoint[] | GeoJSON.FeatureCollection<GeoJSON.Point, Record<string, unknown>>;
  /** Point radius in pixels */
  radius?: number;
  /** Heatmap opacity (0-1) */
  opacity?: number;
  /** Color gradient, keys are 0-1 positions e.g. { "0": "blue", "1": "red" } */
  gradient?: Record<string, string>;
  /** Maximum value used to normalize counts */
  max?: number;
};

function MapHeatmap({
  data,
  radius = 30,
  opacity = 0.8,
  gradient,
  max = 100,
}: MapHeatmapProps) {
  const { map, AMap, isLoaded } = useMap();
  const heatmapRef = useRef<AMapInstance>(null);

  const normalizedData = useMemo<HeatmapPoint[]>(() => {
    if (!Array.isArray(data)) {
      return data.features.map((f) => ({
        lng: f.geometry.coordinates[0],
        lat: f.geometry.coordinates[1],
        count:
          (f.properties?.count as number) ??
          (f.properties?.weight as number) ??
          1,
      }));
    }
    return data;
  }, [data]);

  useEffect(() => {
    if (!isLoaded || !map || !AMap) return;
    let cancelled = false;

    AMap.plugin(["AMap.HeatMap"], () => {
      if (cancelled) return;
      const heatmap = new AMap.HeatMap(map, {
        radius,
        opacity: [0, opacity],
        gradient: gradient ?? {
          "0": "#3b82f6",
          "0.4": "#06b6d4",
          "0.65": "#22c55e",
          "0.85": "#eab308",
          "1": "#ef4444",
        },
      });
      heatmap.setDataSet({ data: normalizedData, max });
      heatmapRef.current = heatmap;
    });

    return () => {
      cancelled = true;
      if (heatmapRef.current) {
        try {
          heatmapRef.current.setMap(null);
        } catch {
          // ignore
        }
        heatmapRef.current = null;
      }
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [isLoaded, map, AMap]);

  useEffect(() => {
    if (!heatmapRef.current) return;
    heatmapRef.current.setDataSet({ data: normalizedData, max });
  }, [normalizedData, max]);

  useEffect(() => {
    if (!heatmapRef.current) return;
    heatmapRef.current.setOptions({ radius, opacity: [0, opacity], ...(gradient ? { gradient } : {}) });
  }, [radius, opacity, gradient]);

  return null;
}

// ---- Shared TileLayer (Traffic / Satellite) ----

type TileLayerProps = {
  ctor: "Traffic" | "Satellite";
  visible?: boolean;
  opacity?: number;
};

function TileLayer({ ctor, visible = true, opacity = 1 }: TileLayerProps) {
  const { map, AMap, isLoaded } = useMap();
  const layerRef = useRef<AMapInstance>(null);

  useEffect(() => {
    if (!isLoaded || !map || !AMap) return;

    const layer = ctor === "Traffic"
      ? new AMap.TileLayer.Traffic({ opacity })
      : new AMap.TileLayer.Satellite({ opacity });
    layer.setMap(map);
    layerRef.current = layer;

    return () => {
      if (layerRef.current) {
        try {
          layerRef.current.setMap(null);
        } catch {
          // ignore teardown errors
        }
        layerRef.current = null;
      }
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [isLoaded, map, AMap]);

  useEffect(() => {
    if (!layerRef.current) return;
    if (visible) {
      layerRef.current.show();
    } else {
      layerRef.current.hide();
    }
  }, [visible]);

  useEffect(() => {
    if (!layerRef.current) return;
    layerRef.current.setOpacity(opacity);
  }, [opacity]);

  return null;
}

type MapTrafficLayerProps = {
  /** Show or hide the traffic layer */
  visible?: boolean;
  /** Layer opacity (0-1) */
  opacity?: number;
};

function MapTrafficLayer({ visible, opacity }: MapTrafficLayerProps) {
  return <TileLayer ctor="Traffic" visible={visible} opacity={opacity} />;
}

type MapSatelliteLayerProps = {
  /** Show or hide the satellite layer */
  visible?: boolean;
  /** Layer opacity (0-1) */
  opacity?: number;
};

function MapSatelliteLayer({ visible, opacity }: MapSatelliteLayerProps) {
  return <TileLayer ctor="Satellite" visible={visible} opacity={opacity} />;
}

// ---- useMapEvent ----

/**
 * Subscribe to a map event. Must be called inside a `<Map>` component.
 * Automatically cleans up when the component unmounts or the event name changes.
 */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function useMapEvent(event: string, handler: (e: any) => void): void {
  const { map } = useMap();
  const handlerRef = useLatestRef(handler);

  useEffect(() => {
    if (!map) return;
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const fn = (e: any) => handlerRef.current(e);
    map.on(event, fn);
    return () => map.off(event, fn);
  }, [map, event]);
}

// ---- useMapBounds ----

type MapBounds = {
  north: number;
  south: number;
  east: number;
  west: number;
};

/**
 * Returns the current map viewport bounds, updated on every move/zoom.
 * Must be called inside a `<Map>` component.
 */
function useMapBounds(): MapBounds | null {
  const { map, isLoaded } = useMap();
  const [bounds, setBounds] = useState<MapBounds | null>(null);

  useEffect(() => {
    if (!map || !isLoaded) return;

    const update = () => {
      const b = map.getBounds?.();
      if (!b) return;
      setBounds({
        north: b.getNorthEast().getLat(),
        south: b.getSouthWest().getLat(),
        east: b.getNorthEast().getLng(),
        west: b.getSouthWest().getLng(),
      });
    };

    update();
    map.on("moveend", update);
    map.on("zoomend", update);
    return () => {
      try {
        map.off("moveend", update);
        map.off("zoomend", update);
      } catch {
        // map may be destroyed
      }
    };
  }, [map, isLoaded]);

  return bounds;
}

export {
  Map,
  useMap,
  useMapEvent,
  useMapBounds,
  MapMarker,
  MarkerContent,
  MarkerPopup,
  MarkerTooltip,
  MarkerLabel,
  MapPopup,
  MapControls,
  MapRoute,
  MapClusterLayer,
  MapPolygon,
  MapCircle,
  MapHeatmap,
  MapTrafficLayer,
  MapSatelliteLayer,
};

export type { MapRef, HeatmapPoint, MapBounds };