{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "camera-follow",
  "title": "Map Camera Follow",
  "description": "Animate camera along a path for immersive fly-through experiences.",
  "dependencies": ["mapbox-gl"],
  "devDependencies": ["@types/mapbox-gl"],
  "registryDependencies": ["https://www.terrae.dev/map.json"],
  "files": [
    {
      "path": "src/registry/map/camera-follow.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useRef, useState, type ReactNode } from \"react\"\nimport { createPortal } from \"react-dom\"\nimport type mapboxgl from \"mapbox-gl\"\nimport { mapgl } from \"./map-library\"\nimport { useMap } from \"./hooks\"\nimport type { MapPath } from \"./types\"\n\ntype MarkerState = {\n  position: [number, number]\n  bearing: number\n}\n\ntype MapCameraFollowProps = {\n  path: MapPath\n  duration?: number\n  zoom?: number\n  pitch?: number\n  autoStart?: boolean\n  loop?: boolean\n  loopDelay?: number\n  marker?: boolean | ReactNode\n  markerSize?: number\n  onComplete?: () => void\n}\n\nconst DEFAULT_DURATION = 20000\nconst DEFAULT_ZOOM = 14\nconst DEFAULT_PITCH = 60\nconst DEFAULT_LOOP_DELAY = 1000\nconst DEFAULT_MARKER_SIZE = 48\n\nconst calculateBearing = (from: [number, number], to: [number, number]): number => {\n  const dLon = ((to[0] - from[0]) * Math.PI) / 180\n  const lat1 = (from[1] * Math.PI) / 180\n  const lat2 = (to[1] * Math.PI) / 180\n\n  const y = Math.sin(dLon) * Math.cos(lat2)\n  const x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLon)\n\n  return ((Math.atan2(y, x) * 180) / Math.PI + 360) % 360\n}\n\nconst interpolatePosition = (path: MapPath, progress: number): [number, number] => {\n  const totalSegments = path.length - 1\n  const segmentProgress = progress * totalSegments\n  const segmentIndex = Math.min(Math.floor(segmentProgress), totalSegments - 1)\n  const segmentFraction = segmentProgress - segmentIndex\n\n  const start = path[segmentIndex]\n  const end = path[segmentIndex + 1]\n\n  return [start[0] + (end[0] - start[0]) * segmentFraction, start[1] + (end[1] - start[1]) * segmentFraction]\n}\n\nconst DefaultNavigationMarker = ({ size }: { size: number }) => {\n  return (\n    <svg width={size} height={size} viewBox=\"0 0 48 48\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n      <path d=\"M24 4L8 40L24 32L40 40L24 4Z\" fill=\"#3b82f6\" stroke=\"white\" strokeWidth=\"2\" strokeLinejoin=\"round\" />\n    </svg>\n  )\n}\n\nexport const MapCameraFollow = ({\n  path,\n  duration = DEFAULT_DURATION,\n  zoom = DEFAULT_ZOOM,\n  pitch = DEFAULT_PITCH,\n  autoStart = true,\n  loop = false,\n  loopDelay = DEFAULT_LOOP_DELAY,\n  marker,\n  markerSize = DEFAULT_MARKER_SIZE,\n  onComplete,\n}: MapCameraFollowProps) => {\n  const { map, isLoaded } = useMap()\n\n  const animationFrameRef = useRef<number | undefined>(undefined)\n  const loopTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)\n  const startTimeRef = useRef(0)\n  const pausedProgressRef = useRef(0)\n  const markerRef = useRef<mapboxgl.Marker | null>(null)\n  const propsRef = useRef({ path, duration, zoom, pitch, onComplete, autoStart })\n  propsRef.current = { path, duration, zoom, pitch, onComplete, autoStart }\n\n  const [markerState, setMarkerState] = useState<MarkerState | null>(null)\n  const [markerElement, setMarkerElement] = useState<HTMLDivElement | null>(null)\n\n  useEffect(() => {\n    if (!map || !isLoaded || marker === undefined) {\n      return\n    }\n\n    const el = document.createElement(\"div\")\n    el.style.display = \"flex\"\n    el.style.alignItems = \"center\"\n    el.style.justifyContent = \"center\"\n\n    const mapMarker = new mapgl.Marker({\n      element: el,\n      rotationAlignment: \"map\",\n      pitchAlignment: \"map\",\n    })\n\n    markerRef.current = mapMarker\n    setMarkerElement(el)\n\n    return () => {\n      mapMarker.remove()\n      markerRef.current = null\n      setMarkerElement(null)\n    }\n  }, [map, isLoaded, marker])\n\n  useEffect(() => {\n    if (!markerState || !markerRef.current || !map) {\n      return\n    }\n\n    markerRef.current.setLngLat(markerState.position).setRotation(markerState.bearing).addTo(map)\n  }, [map, markerState])\n\n  useEffect(() => {\n    if (!map || !isLoaded) {\n      return\n    }\n\n    const cancelAnimation = () => {\n      if (animationFrameRef.current) {\n        cancelAnimationFrame(animationFrameRef.current)\n        animationFrameRef.current = undefined\n      }\n      if (loopTimeoutRef.current) {\n        clearTimeout(loopTimeoutRef.current)\n        loopTimeoutRef.current = undefined\n      }\n    }\n\n    if (!autoStart) {\n      cancelAnimation()\n      return\n    }\n\n    if (propsRef.current.path.length < 2) {\n      return\n    }\n\n    startTimeRef.current = Date.now() - pausedProgressRef.current * propsRef.current.duration\n\n    const animate = () => {\n      if (!map || !propsRef.current.autoStart) {\n        cancelAnimation()\n        return\n      }\n\n      const { path, duration, zoom, pitch, onComplete } = propsRef.current\n      const elapsed = Date.now() - startTimeRef.current\n      const progress = Math.min(elapsed / duration, 1)\n\n      pausedProgressRef.current = progress\n\n      const currentPosition = interpolatePosition(path, progress)\n      const lookAheadPosition = interpolatePosition(path, Math.min(progress + 0.02, 1))\n      const bearing = calculateBearing(currentPosition, lookAheadPosition)\n\n      setMarkerState({ position: currentPosition, bearing })\n\n      try {\n        map.easeTo({\n          center: currentPosition,\n          bearing,\n          pitch,\n          zoom,\n          duration: 0,\n        })\n      } catch {\n        cancelAnimation()\n        return\n      }\n\n      if (progress < 1) {\n        animationFrameRef.current = requestAnimationFrame(animate)\n        return\n      }\n\n      pausedProgressRef.current = 0\n      onComplete?.()\n\n      if (loop) {\n        loopTimeoutRef.current = setTimeout(() => {\n          startTimeRef.current = Date.now()\n          animationFrameRef.current = requestAnimationFrame(animate)\n        }, loopDelay)\n      }\n    }\n\n    animationFrameRef.current = requestAnimationFrame(animate)\n\n    return cancelAnimation\n  }, [map, isLoaded, autoStart, loop, loopDelay])\n\n  if (marker !== undefined && markerElement) {\n    return createPortal(marker === true ? <DefaultNavigationMarker size={markerSize} /> : marker, markerElement)\n  }\n\n  return null\n}\n\nexport const useCameraFollowControl = () => {\n  const [isPlaying, setIsPlaying] = useState(false)\n\n  const start = () => {\n    setIsPlaying(true)\n  }\n\n  const stop = () => {\n    setIsPlaying(false)\n  }\n\n  const toggle = () => {\n    setIsPlaying((prev) => !prev)\n  }\n\n  return { isPlaying, start, stop, toggle }\n}\n",
      "type": "registry:ui",
      "target": "components/ui/map/camera-follow.tsx"
    }
  ],
  "type": "registry:ui"
}
