{"$schema":"https:\/\/ui.particle.academy\/schema\/registry-item.json","name":"map","type":"registry:ui","title":"Map","description":"Controlled map surface \u2014 view\/markers\/selection, live tracking (follow + useGeolocationTrack), stable data-map-marker-id handles, SSR-safe. Swap providers (leafletProvider \/ googleProvider) with one prop.","package":"fancy-map","dependencies":[],"registryDependencies":[],"files":[{"path":"components\/fancy\/map\/Map.tsx","content":"import { useEffect, useRef, useState, type CSSProperties } from \"react\";\nimport type { LatLng, MapHandle, MapMarker, MapProvider, MapView } from \".\/types\";\n\nexport type MapProps = {\n  \/** The map engine to render with \u2014 `leafletProvider()` \/ `googleProvider({ apiKey })`. *\/\n  provider: MapProvider;\n  \/** Controlled camera. *\/\n  view: MapView;\n  \/** Fires when the user pans\/zooms (or a provider animation settles). *\/\n  onViewChange?: (view: MapView) => void;\n  \/** Controlled markers (JSON-friendly; an agent can emit these directly). *\/\n  markers?: MapMarker[];\n  \/** Controlled selection. *\/\n  selectedId?: string | null;\n  onSelect?: (id: string | null, marker?: MapMarker) => void;\n  \/** Fires when a draggable marker is released. *\/\n  onMarkerDragEnd?: (id: string, position: LatLng) => void;\n  \/** Fires when the map background (not a marker) is clicked. *\/\n  onMapClick?: (position: LatLng) => void;\n  \/**\n   * Live follow \u2014 keep this marker centered as its position updates. Set it to a\n   * moving marker's id (a vehicle, a delivery, the agent) for live tracking.\n   *\/\n  follow?: string | null;\n  \/** One-shot: fit the camera to enclose these points (re-runs on array identity change). *\/\n  fitTo?: LatLng[];\n  \/** Grab the imperative handle once the engine is live (e.g. for a bridge's fitBounds). *\/\n  onReady?: (handle: MapHandle) => void;\n  \/** Called if the provider fails to mount (e.g. Google SDK load error). *\/\n  onError?: (error: unknown) => void;\n  className?: string;\n  style?: CSSProperties;\n  \"aria-label\"?: string;\n};\n\nconst EMPTY_MARKERS: MapMarker[] = [];\n\n\/** Whether two cameras are close enough to treat as \"the same\" (echo guard). *\/\nfunction viewsClose(a: MapView, b: MapView): boolean {\n  return (\n    Math.abs(a.center.lat - b.center.lat) < 1e-7 &&\n    Math.abs(a.center.lng - b.center.lng) < 1e-7 &&\n    Math.abs(a.zoom - b.zoom) < 1e-3\n  );\n}\n\n\/**\n * The engine-agnostic map surface. Fully controlled (view + markers + selection),\n * SSR-safe (renders a sized placeholder on the server; the provider mounts inside\n * an effect, so there's no `window`\/engine access during render and no hydration\n * mismatch), and driveable by both a human and \u2014 via the Human+ map bridge in\n * `@particle-academy\/agent-integrations` \u2014 an agent, over the same controlled state.\n *\/\nexport function Map({\n  provider,\n  view,\n  onViewChange,\n  markers = EMPTY_MARKERS,\n  selectedId = null,\n  onSelect,\n  onMarkerDragEnd,\n  onMapClick,\n  follow = null,\n  fitTo,\n  onReady,\n  onError,\n  className,\n  style,\n  \"aria-label\": ariaLabel = \"Map\",\n}: MapProps) {\n  const hostRef = useRef<HTMLDivElement | null>(null);\n  const handleRef = useRef<MapHandle | null>(null);\n  \/\/ A bump counter that flips once the (async) provider handle is live, so the\n  \/\/ prop-sync effects below re-run and apply current props to the fresh engine.\n  const [ready, setReady] = useState(0);\n\n  \/\/ Latest callbacks + the initial mount snapshot, kept in refs so the mount\n  \/\/ effect stays mount-once (it only re-runs when the provider identity changes).\n  const cbRef = useRef({ onViewChange, onSelect, onMarkerDragEnd, onMapClick, onReady, onError });\n  cbRef.current = { onViewChange, onSelect, onMarkerDragEnd, onMapClick, onReady, onError };\n  const initialRef = useRef({ view, markers, selectedId });\n  \/\/ The last camera the map itself emitted \u2014 lets the view-sync effect ignore\n  \/\/ the echo of a user pan (which would otherwise fight the animation).\n  const lastEmitted = useRef<MapView | null>(null);\n\n  useEffect(() => {\n    const host = hostRef.current;\n    if (!host) {\n      return;\n    }\n    let disposed = false;\n    let handle: MapHandle | null = null;\n    const offs: Array<() => void> = [];\n\n    Promise.resolve(\n      provider.mount(host, {\n        view: initialRef.current.view,\n        markers: initialRef.current.markers,\n        selectedId: initialRef.current.selectedId,\n        interactive: true,\n      }),\n    )\n      .then((h) => {\n        if (disposed) {\n          h.destroy();\n          return;\n        }\n        handle = h;\n        handleRef.current = h;\n        offs.push(\n          h.on(\"viewchange\", (v) => {\n            lastEmitted.current = v;\n            cbRef.current.onViewChange?.(v);\n          }),\n          h.on(\"markerclick\", ({ id, marker }) => cbRef.current.onSelect?.(id, marker)),\n          h.on(\"markerdragend\", ({ id, position }) => cbRef.current.onMarkerDragEnd?.(id, position)),\n          h.on(\"click\", ({ position }) => cbRef.current.onMapClick?.(position)),\n        );\n        cbRef.current.onReady?.(h);\n        setReady((n) => n + 1);\n      })\n      .catch((err) => {\n        if (!disposed) {\n          cbRef.current.onError?.(err);\n        }\n      });\n\n    return () => {\n      disposed = true;\n      for (const off of offs) {\n        off();\n      }\n      handle?.destroy();\n      if (handleRef.current === handle) {\n        handleRef.current = null;\n      }\n    };\n  }, [provider]);\n\n  \/\/ Sync camera \u2014 skip the echo of the map's own emitted view.\n  useEffect(() => {\n    const h = handleRef.current;\n    if (!h) {\n      return;\n    }\n    if (lastEmitted.current && viewsClose(lastEmitted.current, view)) {\n      return;\n    }\n    h.setView(view, true);\n  }, [view, ready]);\n\n  \/\/ Sync markers (provider diffs internally \u2014 cheap on live-tracking updates).\n  useEffect(() => {\n    handleRef.current?.setMarkers(markers);\n  }, [markers, ready]);\n\n  \/\/ Sync selection.\n  useEffect(() => {\n    handleRef.current?.setSelected(selectedId);\n  }, [selectedId, ready]);\n\n  \/\/ Live follow \u2014 recenter on the followed marker whenever it moves.\n  useEffect(() => {\n    if (!follow) {\n      return;\n    }\n    const h = handleRef.current;\n    if (!h) {\n      return;\n    }\n    const m = markers.find((x) => x.id === follow);\n    if (m) {\n      h.setView({ center: m.position }, true);\n    }\n  }, [follow, markers, ready]);\n\n  \/\/ One-shot fit.\n  useEffect(() => {\n    if (fitTo && fitTo.length) {\n      handleRef.current?.fitBounds(fitTo);\n    }\n  }, [fitTo, ready]);\n\n  return (\n    <div\n      ref={hostRef}\n      className={className}\n      role=\"region\"\n      aria-label={ariaLabel}\n      data-fancy-map={provider.name}\n      style={{ position: \"relative\", width: \"100%\", height: \"100%\", minHeight: 240, ...style }}\n    \/>\n  );\n}\n","type":"registry:ui","target":"components\/fancy\/map\/Map.tsx"}]}