shadimage
Terrae
uiarc-animatedsource: ok · refreshed

Map Arc Animated

Animated curved arc between two points for flights, deliveries, and connections.

npx shadcn add "https://www.terrae.dev/arc-animated.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
mapbox-gl

Registry dependencies

https://www.terrae.dev/map.json

Files1 · source cached 10/8/2026, 5:34:22 AM

src/registry/map/arc-animated.tsx
"use client"

import { useEffect, useRef, useState } from "react"
import { createPortal } from "react-dom"
import type mapboxgl from "mapbox-gl"
import { mapgl } from "./map-library"
import { useMap } from "./hooks"
import type { MapCoordinates, MapPath } from "./types"

type HeadType = "none" | "circle" | "square" | "arrow"

type MapArcAnimatedProps = {
  id: string
  origin: MapCoordinates
  destination: MapCoordinates
  color?: string
  width?: number
  opacity?: number
  dashArray?: [number, number]
  height?: number
  segments?: number
  duration?: number
  autoStart?: boolean
  loop?: boolean
  loopDelay?: number
  headType?: HeadType
  headSize?: number
  showOriginMarker?: boolean
  originMarkerColor?: string
  showDestinationMarker?: boolean
  destinationMarkerColor?: string
  onComplete?: () => void
}

const DEFAULT_COLOR = "#3b82f6"
const DEFAULT_WIDTH = 4
const DEFAULT_OPACITY = 1
const DEFAULT_HEIGHT = 0.3
const DEFAULT_SEGMENTS = 50
const DEFAULT_DURATION = 2000
const DEFAULT_AUTO_START = true
const DEFAULT_LOOP = false
const DEFAULT_LOOP_DELAY = 500
const DEFAULT_HEAD_TYPE: HeadType = "circle"
const DEFAULT_HEAD_SIZE = 16
const DEFAULT_SHOW_ORIGIN_MARKER = false
const DEFAULT_SHOW_DESTINATION_MARKER = false
const MARKER_RADIUS = 8

type HeadSvgProps = {
  type: HeadType
  size: number
  color: string
}

const HeadSvg = ({ type, size, color }: HeadSvgProps) => {
  if (type === "none") {
    return null
  }

  if (type === "arrow") {
    return (
      <svg width={size} height={size} viewBox="0 0 32 32" fill="none">
        <polygon points="28,16 8,4 8,28" fill={color} />
      </svg>
    )
  }

  if (type === "square") {
    return (
      <svg width={size} height={size} viewBox="0 0 32 32" fill="none">
        <rect x="4" y="4" width="24" height="24" fill={color} />
      </svg>
    )
  }

  return (
    <svg width={size} height={size} viewBox="0 0 32 32" fill="none">
      <circle cx="16" cy="16" r="12" fill={color} stroke="white" strokeWidth="3" />
    </svg>
  )
}

const calculateBearing = (from: MapCoordinates, to: MapCoordinates): number => {
  const dLon = ((to[0] - from[0]) * Math.PI) / 180
  const lat1 = (from[1] * Math.PI) / 180
  const lat2 = (to[1] * Math.PI) / 180

  const y = Math.sin(dLon) * Math.cos(lat2)
  const x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLon)

  return ((Math.atan2(y, x) * 180) / Math.PI + 360) % 360
}

const generateArcPath = (
  origin: MapCoordinates,
  destination: MapCoordinates,
  height: number,
  segments: number
): MapPath => {
  const path: MapPath = []

  const dx = destination[0] - origin[0]
  const dy = destination[1] - origin[1]
  const distance = Math.sqrt(dx * dx + dy * dy)

  const perpX = -dy / distance
  const perpY = dx / distance

  for (let i = 0; i <= segments; i++) {
    const t = i / segments
    const offset = Math.sin(t * Math.PI) * distance * height

    path.push([origin[0] + dx * t + perpX * offset, origin[1] + dy * t + perpY * offset])
  }

  return path
}

export const MapArcAnimated = ({
  id,
  origin,
  destination,
  color = DEFAULT_COLOR,
  width = DEFAULT_WIDTH,
  opacity = DEFAULT_OPACITY,
  dashArray,
  height = DEFAULT_HEIGHT,
  segments = DEFAULT_SEGMENTS,
  duration = DEFAULT_DURATION,
  autoStart = DEFAULT_AUTO_START,
  loop = DEFAULT_LOOP,
  loopDelay = DEFAULT_LOOP_DELAY,
  headType = DEFAULT_HEAD_TYPE,
  headSize = DEFAULT_HEAD_SIZE,
  showOriginMarker = DEFAULT_SHOW_ORIGIN_MARKER,
  originMarkerColor,
  showDestinationMarker = DEFAULT_SHOW_DESTINATION_MARKER,
  destinationMarkerColor,
  onComplete,
}: MapArcAnimatedProps) => {
  const { map, isLoaded } = useMap()

  const initializedRef = useRef(false)
  const styleLoadedRef = useRef(false)
  const animationFrameRef = useRef<number | undefined>(undefined)
  const loopTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
  const startTimeRef = useRef<number>(0)
  const htmlMarkerRef = useRef<mapboxgl.Marker | null>(null)
  const markerElementRef = useRef<HTMLDivElement | null>(null)

  const originRef = useRef(origin)
  const destinationRef = useRef(destination)
  const heightRef = useRef(height)
  const segmentsRef = useRef(segments)
  const durationRef = useRef(duration)
  const headTypeRef = useRef(headType)
  const showOriginMarkerRef = useRef(showOriginMarker)
  const showDestinationMarkerRef = useRef(showDestinationMarker)
  const onCompleteRef = useRef(onComplete)
  const autoStartRef = useRef(autoStart)

  originRef.current = origin
  destinationRef.current = destination
  heightRef.current = height
  segmentsRef.current = segments
  durationRef.current = duration
  headTypeRef.current = headType
  showOriginMarkerRef.current = showOriginMarker
  showDestinationMarkerRef.current = showDestinationMarker
  onCompleteRef.current = onComplete
  autoStartRef.current = autoStart

  const [isAnimating, setIsAnimating] = useState(false)
  const [isMarkerMounted, setIsMarkerMounted] = useState(false)

  const sourceId = `${id}-source`
  const layerId = `${id}-layer`
  const originMarkerSourceId = `${id}-origin-marker-source`
  const originMarkerLayerId = `${id}-origin-marker`
  const destinationMarkerSourceId = `${id}-destination-marker-source`
  const destinationMarkerLayerId = `${id}-destination-marker`

  useEffect(() => {
    if (!map) {
      return
    }

    const addLineSource = (mapInstance: mapboxgl.Map) => {
      if (mapInstance.getSource(sourceId)) {
        return
      }

      mapInstance.addSource(sourceId, {
        type: "geojson",
        data: { type: "Feature", properties: {}, geometry: { type: "LineString", coordinates: [] } },
      })

      mapInstance.addLayer({
        id: layerId,
        type: "line",
        source: sourceId,
        layout: { "line-join": "round", "line-cap": "round" },
        paint: {
          "line-color": color,
          "line-width": width,
          "line-opacity": opacity,
          ...(dashArray && { "line-dasharray": dashArray }),
        },
      })
    }

    const addOriginMarker = (mapInstance: mapboxgl.Map) => {
      if (!showOriginMarkerRef.current || mapInstance.getSource(originMarkerSourceId)) {
        return
      }

      mapInstance.addSource(originMarkerSourceId, {
        type: "geojson",
        data: { type: "Feature", properties: {}, geometry: { type: "Point", coordinates: originRef.current } },
      })

      mapInstance.addLayer({
        id: originMarkerLayerId,
        type: "circle",
        source: originMarkerSourceId,
        paint: {
          "circle-radius": MARKER_RADIUS,
          "circle-color": originMarkerColor || color,
          "circle-stroke-width": 3,
          "circle-stroke-color": "#ffffff",
        },
      })
    }

    const addDestinationMarker = (mapInstance: mapboxgl.Map) => {
      if (!showDestinationMarkerRef.current || mapInstance.getSource(destinationMarkerSourceId)) {
        return
      }

      mapInstance.addSource(destinationMarkerSourceId, {
        type: "geojson",
        data: { type: "FeatureCollection", features: [] },
      })

      mapInstance.addLayer({
        id: destinationMarkerLayerId,
        type: "circle",
        source: destinationMarkerSourceId,
        paint: {
          "circle-radius": MARKER_RADIUS,
          "circle-color": destinationMarkerColor || color,
          "circle-stroke-width": 3,
          "circle-stroke-color": "#ffffff",
        },
      })
    }

    const addHeadMarker = (mapInstance: mapboxgl.Map) => {
      if (headTypeRef.current === "none" || htmlMarkerRef.current) {
        return
      }

      const el = document.createElement("div")
      el.style.display = "flex"
      el.style.alignItems = "center"
      el.style.justifyContent = "center"

      markerElementRef.current = el
      htmlMarkerRef.current = new mapgl.Marker({
        element: el,
        rotationAlignment: "map",
        pitchAlignment: "map",
        anchor: "center",
      })
        .setLngLat(originRef.current)
        .addTo(mapInstance)
      setIsMarkerMounted(true)
    }

    const addSources = (mapInstance: mapboxgl.Map) => {
      try {
        addLineSource(mapInstance)
        addOriginMarker(mapInstance)
        addDestinationMarker(mapInstance)
        addHeadMarker(mapInstance)
        initializedRef.current = true
      } catch (error) {
        console.error("Error adding arc animated:", error)
      }
    }

    const cleanupResources = (mapInstance: mapboxgl.Map) => {
      if (animationFrameRef.current) {
        cancelAnimationFrame(animationFrameRef.current)
      }
      if (loopTimeoutRef.current) {
        clearTimeout(loopTimeoutRef.current)
      }

      if (htmlMarkerRef.current) {
        htmlMarkerRef.current.remove()
        htmlMarkerRef.current = null
      }

      try {
        if (mapInstance.getLayer(layerId)) {
          mapInstance.removeLayer(layerId)
        }
        if (mapInstance.getLayer(originMarkerLayerId)) {
          mapInstance.removeLayer(originMarkerLayerId)
        }
        if (mapInstance.getLayer(destinationMarkerLayerId)) {
          mapInstance.removeLayer(destinationMarkerLayerId)
        }
        if (mapInstance.getSource(sourceId)) {
          mapInstance.removeSource(sourceId)
        }
        if (mapInstance.getSource(originMarkerSourceId)) {
          mapInstance.removeSource(originMarkerSourceId)
        }
        if (mapInstance.getSource(destinationMarkerSourceId)) {
          mapInstance.removeSource(destinationMarkerSourceId)
        }
      } catch {
        // Already removed
      }

      markerElementRef.current = null
      setIsMarkerMounted(false)
      initializedRef.current = false
    }

    const handleStyleLoad = () => {
      styleLoadedRef.current = true
      initializedRef.current = false
      addSources(map)

      if (autoStartRef.current) {
        setIsAnimating(true)
      }
    }

    const handleStyleDataLoading = () => {
      styleLoadedRef.current = false
    }

    if (isLoaded && !initializedRef.current) {
      addSources(map)
      styleLoadedRef.current = true
    }

    map.on("style.load", handleStyleLoad)
    map.on("styledataloading", handleStyleDataLoading)

    return () => {
      map.off("style.load", handleStyleLoad)
      map.off("styledataloading", handleStyleDataLoading)
      cleanupResources(map)
    }
  }, [
    map,
    isLoaded,
    id,
    sourceId,
    layerId,
    originMarkerSourceId,
    originMarkerLayerId,
    destinationMarkerSourceId,
    destinationMarkerLayerId,
    color,
    width,
    opacity,
    dashArray,
    originMarkerColor,
    destinationMarkerColor,
  ])

  useEffect(() => {
    if (!map || !isLoaded || !initializedRef.current) {
      return
    }
    if (!autoStart && !isAnimating) {
      return
    }

    startTimeRef.current = Date.now()

    const arcPath = generateArcPath(originRef.current, destinationRef.current, heightRef.current, segmentsRef.current)

    const updateLineSource = (coordinates: MapPath) => {
      if (!map || !map.getStyle()) {
        return
      }
      try {
        const lineSource = map.getSource(sourceId) as mapboxgl.GeoJSONSource
        if (lineSource) {
          lineSource.setData({
            type: "Feature",
            properties: {},
            geometry: { type: "LineString", coordinates },
          })
        }
      } catch {
        // Map may be in an invalid state
      }
    }

    const updateHeadPosition = (position: MapCoordinates, nextPosition?: MapCoordinates) => {
      if (!htmlMarkerRef.current) {
        return
      }

      htmlMarkerRef.current.setLngLat(position)

      if (nextPosition && headTypeRef.current === "arrow") {
        const bearing = calculateBearing(position, nextPosition)
        htmlMarkerRef.current.setRotation(bearing - 90)
      }
    }

    const updateDestinationMarker = (visible: boolean) => {
      if (!map || !map.getStyle()) {
        return
      }
      try {
        const markerSource = map.getSource(destinationMarkerSourceId) as mapboxgl.GeoJSONSource
        if (markerSource) {
          markerSource.setData({
            type: "FeatureCollection",
            features: visible
              ? [{ type: "Feature", properties: {}, geometry: { type: "Point", coordinates: destinationRef.current } }]
              : [],
          })
        }
      } catch {
        // Map may be in an invalid state
      }
    }

    const stopAnimation = () => {
      if (animationFrameRef.current) {
        cancelAnimationFrame(animationFrameRef.current)
        animationFrameRef.current = undefined
      }
      setIsAnimating(false)
    }

    const animate = () => {
      if (!map || !styleLoadedRef.current) {
        stopAnimation()
        return
      }

      const elapsed = Date.now() - startTimeRef.current
      const progress = Math.min(elapsed / durationRef.current, 1)
      const pointIndex = Math.floor(progress * (arcPath.length - 1)) + 1
      const visiblePath = arcPath.slice(0, pointIndex)

      const segmentProgress = (progress * (arcPath.length - 1)) % 1
      if (pointIndex < arcPath.length && segmentProgress > 0) {
        const start = arcPath[pointIndex - 1]
        const end = arcPath[pointIndex]
        visiblePath.push([
          start[0] + (end[0] - start[0]) * segmentProgress,
          start[1] + (end[1] - start[1]) * segmentProgress,
        ])
      }

      try {
        updateLineSource(visiblePath)

        if (headTypeRef.current !== "none" && visiblePath.length > 0) {
          const currentPosition = visiblePath[visiblePath.length - 1]
          const nextPointIndex = Math.min(pointIndex + 1, arcPath.length - 1)
          const nextPosition = arcPath[nextPointIndex]
          updateHeadPosition(currentPosition, nextPosition)
        }

        if (progress >= 1 && showDestinationMarkerRef.current) {
          updateDestinationMarker(true)
        }
      } catch {
        stopAnimation()
        return
      }

      if (progress < 1) {
        animationFrameRef.current = requestAnimationFrame(animate)
        return
      }

      setIsAnimating(false)
      onCompleteRef.current?.()

      if (loop) {
        loopTimeoutRef.current = setTimeout(() => {
          updateLineSource([])
          updateDestinationMarker(false)
          setIsAnimating(true)
        }, loopDelay)
      }
    }

    setIsAnimating(true)
    animationFrameRef.current = requestAnimationFrame(animate)

    return () => {
      if (animationFrameRef.current) {
        cancelAnimationFrame(animationFrameRef.current)
      }
      if (loopTimeoutRef.current) {
        clearTimeout(loopTimeoutRef.current)
      }
    }
  }, [map, isLoaded, id, sourceId, destinationMarkerSourceId, autoStart, loop, loopDelay, isAnimating])

  if (headType !== "none" && isMarkerMounted && markerElementRef.current) {
    return createPortal(<HeadSvg type={headType} size={headSize} color={color} />, markerElementRef.current)
  }

  return null
}

export const useArcAnimatedControl = () => {
  const [isPlaying, setIsPlaying] = useState(false)

  return {
    isPlaying,
    start: () => setIsPlaying(true),
    stop: () => setIsPlaying(false),
    toggle: () => setIsPlaying((prev) => !prev),
  }
}

More from Terrae

Browse all components in this library →