{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "arc-animated",
  "title": "Map Arc Animated",
  "description": "Animated curved arc between two points for flights, deliveries, and connections.",
  "dependencies": ["mapbox-gl"],
  "devDependencies": ["@types/mapbox-gl"],
  "registryDependencies": ["https://www.terrae.dev/map.json"],
  "files": [
    {
      "path": "src/registry/map/arc-animated.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport { createPortal } from \"react-dom\"\nimport type mapboxgl from \"mapbox-gl\"\nimport { mapgl } from \"./map-library\"\nimport { useMap } from \"./hooks\"\nimport type { MapCoordinates, MapPath } from \"./types\"\n\ntype HeadType = \"none\" | \"circle\" | \"square\" | \"arrow\"\n\ntype MapArcAnimatedProps = {\n  id: string\n  origin: MapCoordinates\n  destination: MapCoordinates\n  color?: string\n  width?: number\n  opacity?: number\n  dashArray?: [number, number]\n  height?: number\n  segments?: number\n  duration?: number\n  autoStart?: boolean\n  loop?: boolean\n  loopDelay?: number\n  headType?: HeadType\n  headSize?: number\n  showOriginMarker?: boolean\n  originMarkerColor?: string\n  showDestinationMarker?: boolean\n  destinationMarkerColor?: string\n  onComplete?: () => void\n}\n\nconst DEFAULT_COLOR = \"#3b82f6\"\nconst DEFAULT_WIDTH = 4\nconst DEFAULT_OPACITY = 1\nconst DEFAULT_HEIGHT = 0.3\nconst DEFAULT_SEGMENTS = 50\nconst DEFAULT_DURATION = 2000\nconst DEFAULT_AUTO_START = true\nconst DEFAULT_LOOP = false\nconst DEFAULT_LOOP_DELAY = 500\nconst DEFAULT_HEAD_TYPE: HeadType = \"circle\"\nconst DEFAULT_HEAD_SIZE = 16\nconst DEFAULT_SHOW_ORIGIN_MARKER = false\nconst DEFAULT_SHOW_DESTINATION_MARKER = false\nconst MARKER_RADIUS = 8\n\ntype HeadSvgProps = {\n  type: HeadType\n  size: number\n  color: string\n}\n\nconst HeadSvg = ({ type, size, color }: HeadSvgProps) => {\n  if (type === \"none\") {\n    return null\n  }\n\n  if (type === \"arrow\") {\n    return (\n      <svg width={size} height={size} viewBox=\"0 0 32 32\" fill=\"none\">\n        <polygon points=\"28,16 8,4 8,28\" fill={color} />\n      </svg>\n    )\n  }\n\n  if (type === \"square\") {\n    return (\n      <svg width={size} height={size} viewBox=\"0 0 32 32\" fill=\"none\">\n        <rect x=\"4\" y=\"4\" width=\"24\" height=\"24\" fill={color} />\n      </svg>\n    )\n  }\n\n  return (\n    <svg width={size} height={size} viewBox=\"0 0 32 32\" fill=\"none\">\n      <circle cx=\"16\" cy=\"16\" r=\"12\" fill={color} stroke=\"white\" strokeWidth=\"3\" />\n    </svg>\n  )\n}\n\nconst calculateBearing = (from: MapCoordinates, to: MapCoordinates): 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 generateArcPath = (\n  origin: MapCoordinates,\n  destination: MapCoordinates,\n  height: number,\n  segments: number\n): MapPath => {\n  const path: MapPath = []\n\n  const dx = destination[0] - origin[0]\n  const dy = destination[1] - origin[1]\n  const distance = Math.sqrt(dx * dx + dy * dy)\n\n  const perpX = -dy / distance\n  const perpY = dx / distance\n\n  for (let i = 0; i <= segments; i++) {\n    const t = i / segments\n    const offset = Math.sin(t * Math.PI) * distance * height\n\n    path.push([origin[0] + dx * t + perpX * offset, origin[1] + dy * t + perpY * offset])\n  }\n\n  return path\n}\n\nexport const MapArcAnimated = ({\n  id,\n  origin,\n  destination,\n  color = DEFAULT_COLOR,\n  width = DEFAULT_WIDTH,\n  opacity = DEFAULT_OPACITY,\n  dashArray,\n  height = DEFAULT_HEIGHT,\n  segments = DEFAULT_SEGMENTS,\n  duration = DEFAULT_DURATION,\n  autoStart = DEFAULT_AUTO_START,\n  loop = DEFAULT_LOOP,\n  loopDelay = DEFAULT_LOOP_DELAY,\n  headType = DEFAULT_HEAD_TYPE,\n  headSize = DEFAULT_HEAD_SIZE,\n  showOriginMarker = DEFAULT_SHOW_ORIGIN_MARKER,\n  originMarkerColor,\n  showDestinationMarker = DEFAULT_SHOW_DESTINATION_MARKER,\n  destinationMarkerColor,\n  onComplete,\n}: MapArcAnimatedProps) => {\n  const { map, isLoaded } = useMap()\n\n  const initializedRef = useRef(false)\n  const styleLoadedRef = useRef(false)\n  const animationFrameRef = useRef<number | undefined>(undefined)\n  const loopTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)\n  const startTimeRef = useRef<number>(0)\n  const htmlMarkerRef = useRef<mapboxgl.Marker | null>(null)\n  const markerElementRef = useRef<HTMLDivElement | null>(null)\n\n  const originRef = useRef(origin)\n  const destinationRef = useRef(destination)\n  const heightRef = useRef(height)\n  const segmentsRef = useRef(segments)\n  const durationRef = useRef(duration)\n  const headTypeRef = useRef(headType)\n  const showOriginMarkerRef = useRef(showOriginMarker)\n  const showDestinationMarkerRef = useRef(showDestinationMarker)\n  const onCompleteRef = useRef(onComplete)\n  const autoStartRef = useRef(autoStart)\n\n  originRef.current = origin\n  destinationRef.current = destination\n  heightRef.current = height\n  segmentsRef.current = segments\n  durationRef.current = duration\n  headTypeRef.current = headType\n  showOriginMarkerRef.current = showOriginMarker\n  showDestinationMarkerRef.current = showDestinationMarker\n  onCompleteRef.current = onComplete\n  autoStartRef.current = autoStart\n\n  const [isAnimating, setIsAnimating] = useState(false)\n  const [isMarkerMounted, setIsMarkerMounted] = useState(false)\n\n  const sourceId = `${id}-source`\n  const layerId = `${id}-layer`\n  const originMarkerSourceId = `${id}-origin-marker-source`\n  const originMarkerLayerId = `${id}-origin-marker`\n  const destinationMarkerSourceId = `${id}-destination-marker-source`\n  const destinationMarkerLayerId = `${id}-destination-marker`\n\n  useEffect(() => {\n    if (!map) {\n      return\n    }\n\n    const addLineSource = (mapInstance: mapboxgl.Map) => {\n      if (mapInstance.getSource(sourceId)) {\n        return\n      }\n\n      mapInstance.addSource(sourceId, {\n        type: \"geojson\",\n        data: { type: \"Feature\", properties: {}, geometry: { type: \"LineString\", coordinates: [] } },\n      })\n\n      mapInstance.addLayer({\n        id: layerId,\n        type: \"line\",\n        source: sourceId,\n        layout: { \"line-join\": \"round\", \"line-cap\": \"round\" },\n        paint: {\n          \"line-color\": color,\n          \"line-width\": width,\n          \"line-opacity\": opacity,\n          ...(dashArray && { \"line-dasharray\": dashArray }),\n        },\n      })\n    }\n\n    const addOriginMarker = (mapInstance: mapboxgl.Map) => {\n      if (!showOriginMarkerRef.current || mapInstance.getSource(originMarkerSourceId)) {\n        return\n      }\n\n      mapInstance.addSource(originMarkerSourceId, {\n        type: \"geojson\",\n        data: { type: \"Feature\", properties: {}, geometry: { type: \"Point\", coordinates: originRef.current } },\n      })\n\n      mapInstance.addLayer({\n        id: originMarkerLayerId,\n        type: \"circle\",\n        source: originMarkerSourceId,\n        paint: {\n          \"circle-radius\": MARKER_RADIUS,\n          \"circle-color\": originMarkerColor || color,\n          \"circle-stroke-width\": 3,\n          \"circle-stroke-color\": \"#ffffff\",\n        },\n      })\n    }\n\n    const addDestinationMarker = (mapInstance: mapboxgl.Map) => {\n      if (!showDestinationMarkerRef.current || mapInstance.getSource(destinationMarkerSourceId)) {\n        return\n      }\n\n      mapInstance.addSource(destinationMarkerSourceId, {\n        type: \"geojson\",\n        data: { type: \"FeatureCollection\", features: [] },\n      })\n\n      mapInstance.addLayer({\n        id: destinationMarkerLayerId,\n        type: \"circle\",\n        source: destinationMarkerSourceId,\n        paint: {\n          \"circle-radius\": MARKER_RADIUS,\n          \"circle-color\": destinationMarkerColor || color,\n          \"circle-stroke-width\": 3,\n          \"circle-stroke-color\": \"#ffffff\",\n        },\n      })\n    }\n\n    const addHeadMarker = (mapInstance: mapboxgl.Map) => {\n      if (headTypeRef.current === \"none\" || htmlMarkerRef.current) {\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      markerElementRef.current = el\n      htmlMarkerRef.current = new mapgl.Marker({\n        element: el,\n        rotationAlignment: \"map\",\n        pitchAlignment: \"map\",\n        anchor: \"center\",\n      })\n        .setLngLat(originRef.current)\n        .addTo(mapInstance)\n      setIsMarkerMounted(true)\n    }\n\n    const addSources = (mapInstance: mapboxgl.Map) => {\n      try {\n        addLineSource(mapInstance)\n        addOriginMarker(mapInstance)\n        addDestinationMarker(mapInstance)\n        addHeadMarker(mapInstance)\n        initializedRef.current = true\n      } catch (error) {\n        console.error(\"Error adding arc animated:\", error)\n      }\n    }\n\n    const cleanupResources = (mapInstance: mapboxgl.Map) => {\n      if (animationFrameRef.current) {\n        cancelAnimationFrame(animationFrameRef.current)\n      }\n      if (loopTimeoutRef.current) {\n        clearTimeout(loopTimeoutRef.current)\n      }\n\n      if (htmlMarkerRef.current) {\n        htmlMarkerRef.current.remove()\n        htmlMarkerRef.current = null\n      }\n\n      try {\n        if (mapInstance.getLayer(layerId)) {\n          mapInstance.removeLayer(layerId)\n        }\n        if (mapInstance.getLayer(originMarkerLayerId)) {\n          mapInstance.removeLayer(originMarkerLayerId)\n        }\n        if (mapInstance.getLayer(destinationMarkerLayerId)) {\n          mapInstance.removeLayer(destinationMarkerLayerId)\n        }\n        if (mapInstance.getSource(sourceId)) {\n          mapInstance.removeSource(sourceId)\n        }\n        if (mapInstance.getSource(originMarkerSourceId)) {\n          mapInstance.removeSource(originMarkerSourceId)\n        }\n        if (mapInstance.getSource(destinationMarkerSourceId)) {\n          mapInstance.removeSource(destinationMarkerSourceId)\n        }\n      } catch {\n        // Already removed\n      }\n\n      markerElementRef.current = null\n      setIsMarkerMounted(false)\n      initializedRef.current = false\n    }\n\n    const handleStyleLoad = () => {\n      styleLoadedRef.current = true\n      initializedRef.current = false\n      addSources(map)\n\n      if (autoStartRef.current) {\n        setIsAnimating(true)\n      }\n    }\n\n    const handleStyleDataLoading = () => {\n      styleLoadedRef.current = false\n    }\n\n    if (isLoaded && !initializedRef.current) {\n      addSources(map)\n      styleLoadedRef.current = true\n    }\n\n    map.on(\"style.load\", handleStyleLoad)\n    map.on(\"styledataloading\", handleStyleDataLoading)\n\n    return () => {\n      map.off(\"style.load\", handleStyleLoad)\n      map.off(\"styledataloading\", handleStyleDataLoading)\n      cleanupResources(map)\n    }\n  }, [\n    map,\n    isLoaded,\n    id,\n    sourceId,\n    layerId,\n    originMarkerSourceId,\n    originMarkerLayerId,\n    destinationMarkerSourceId,\n    destinationMarkerLayerId,\n    color,\n    width,\n    opacity,\n    dashArray,\n    originMarkerColor,\n    destinationMarkerColor,\n  ])\n\n  useEffect(() => {\n    if (!map || !isLoaded || !initializedRef.current) {\n      return\n    }\n    if (!autoStart && !isAnimating) {\n      return\n    }\n\n    startTimeRef.current = Date.now()\n\n    const arcPath = generateArcPath(originRef.current, destinationRef.current, heightRef.current, segmentsRef.current)\n\n    const updateLineSource = (coordinates: MapPath) => {\n      if (!map || !map.getStyle()) {\n        return\n      }\n      try {\n        const lineSource = map.getSource(sourceId) as mapboxgl.GeoJSONSource\n        if (lineSource) {\n          lineSource.setData({\n            type: \"Feature\",\n            properties: {},\n            geometry: { type: \"LineString\", coordinates },\n          })\n        }\n      } catch {\n        // Map may be in an invalid state\n      }\n    }\n\n    const updateHeadPosition = (position: MapCoordinates, nextPosition?: MapCoordinates) => {\n      if (!htmlMarkerRef.current) {\n        return\n      }\n\n      htmlMarkerRef.current.setLngLat(position)\n\n      if (nextPosition && headTypeRef.current === \"arrow\") {\n        const bearing = calculateBearing(position, nextPosition)\n        htmlMarkerRef.current.setRotation(bearing - 90)\n      }\n    }\n\n    const updateDestinationMarker = (visible: boolean) => {\n      if (!map || !map.getStyle()) {\n        return\n      }\n      try {\n        const markerSource = map.getSource(destinationMarkerSourceId) as mapboxgl.GeoJSONSource\n        if (markerSource) {\n          markerSource.setData({\n            type: \"FeatureCollection\",\n            features: visible\n              ? [{ type: \"Feature\", properties: {}, geometry: { type: \"Point\", coordinates: destinationRef.current } }]\n              : [],\n          })\n        }\n      } catch {\n        // Map may be in an invalid state\n      }\n    }\n\n    const stopAnimation = () => {\n      if (animationFrameRef.current) {\n        cancelAnimationFrame(animationFrameRef.current)\n        animationFrameRef.current = undefined\n      }\n      setIsAnimating(false)\n    }\n\n    const animate = () => {\n      if (!map || !styleLoadedRef.current) {\n        stopAnimation()\n        return\n      }\n\n      const elapsed = Date.now() - startTimeRef.current\n      const progress = Math.min(elapsed / durationRef.current, 1)\n      const pointIndex = Math.floor(progress * (arcPath.length - 1)) + 1\n      const visiblePath = arcPath.slice(0, pointIndex)\n\n      const segmentProgress = (progress * (arcPath.length - 1)) % 1\n      if (pointIndex < arcPath.length && segmentProgress > 0) {\n        const start = arcPath[pointIndex - 1]\n        const end = arcPath[pointIndex]\n        visiblePath.push([\n          start[0] + (end[0] - start[0]) * segmentProgress,\n          start[1] + (end[1] - start[1]) * segmentProgress,\n        ])\n      }\n\n      try {\n        updateLineSource(visiblePath)\n\n        if (headTypeRef.current !== \"none\" && visiblePath.length > 0) {\n          const currentPosition = visiblePath[visiblePath.length - 1]\n          const nextPointIndex = Math.min(pointIndex + 1, arcPath.length - 1)\n          const nextPosition = arcPath[nextPointIndex]\n          updateHeadPosition(currentPosition, nextPosition)\n        }\n\n        if (progress >= 1 && showDestinationMarkerRef.current) {\n          updateDestinationMarker(true)\n        }\n      } catch {\n        stopAnimation()\n        return\n      }\n\n      if (progress < 1) {\n        animationFrameRef.current = requestAnimationFrame(animate)\n        return\n      }\n\n      setIsAnimating(false)\n      onCompleteRef.current?.()\n\n      if (loop) {\n        loopTimeoutRef.current = setTimeout(() => {\n          updateLineSource([])\n          updateDestinationMarker(false)\n          setIsAnimating(true)\n        }, loopDelay)\n      }\n    }\n\n    setIsAnimating(true)\n    animationFrameRef.current = requestAnimationFrame(animate)\n\n    return () => {\n      if (animationFrameRef.current) {\n        cancelAnimationFrame(animationFrameRef.current)\n      }\n      if (loopTimeoutRef.current) {\n        clearTimeout(loopTimeoutRef.current)\n      }\n    }\n  }, [map, isLoaded, id, sourceId, destinationMarkerSourceId, autoStart, loop, loopDelay, isAnimating])\n\n  if (headType !== \"none\" && isMarkerMounted && markerElementRef.current) {\n    return createPortal(<HeadSvg type={headType} size={headSize} color={color} />, markerElementRef.current)\n  }\n\n  return null\n}\n\nexport const useArcAnimatedControl = () => {\n  const [isPlaying, setIsPlaying] = useState(false)\n\n  return {\n    isPlaying,\n    start: () => setIsPlaying(true),\n    stop: () => setIsPlaying(false),\n    toggle: () => setIsPlaying((prev) => !prev),\n  }\n}\n",
      "type": "registry:ui",
      "target": "components/ui/map/arc-animated.tsx"
    }
  ],
  "type": "registry:ui"
}
