{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "animated-footprint",
  "title": "Map Animated Footprint",
  "description": "Animated footprint steps that walk along a path on the map.",
  "dependencies": ["mapbox-gl", "lucide-react"],
  "devDependencies": ["@types/mapbox-gl"],
  "registryDependencies": ["https://www.terrae.dev/map.json"],
  "files": [
    {
      "path": "src/registry/map/animated-footprint.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useMemo, useRef, useState } from \"react\"\nimport { createPortal } from \"react-dom\"\nimport type mapboxgl from \"mapbox-gl\"\nimport { Footprints } from \"lucide-react\"\nimport { mapgl } from \"./map-library\"\nimport { useMap } from \"./hooks\"\nimport type { MapPath, MapCoordinates } from \"./types\"\n\ntype FootprintStep = {\n  coordinates: MapCoordinates\n  bearing: number\n  isLeft: boolean\n}\n\ntype MarkerEntry = {\n  marker: mapboxgl.Marker\n  element: HTMLDivElement\n}\n\ntype FootprintControl = {\n  start: () => void\n  reset: () => void\n  isActive: boolean\n}\n\ntype FootprintProps = {\n  entry: MarkerEntry\n  step: FootprintStep\n  color: string\n  size: number\n  className?: string\n}\n\ntype MapAnimatedFootprintProps = {\n  path: MapPath\n  id?: string\n  color?: string\n  size?: number\n  stepSpacing?: number\n  staggerDelay?: number\n  duration?: number\n  autoStart?: boolean\n  loop?: boolean\n  className?: string\n}\n\nconst footprintControls = new Map<string, FootprintControl>()\n\nconst DEFAULT_COLOR = \"#000000\"\nconst DEFAULT_SIZE = 20\nconst DEFAULT_STEP_SPACING = 48\nconst DEFAULT_STAGGER_DELAY = 200\nconst DEFAULT_DURATION = 400\nconst METERS_PER_DEGREE_LATITUDE = 111320\nconst LATERAL_OFFSET = 0.4\n\nconst approximateDistance = (from: MapCoordinates, to: MapCoordinates) => {\n  const latitudeRadians = (from[1] * Math.PI) / 180\n  const deltaX = (to[0] - from[0]) * Math.cos(latitudeRadians) * METERS_PER_DEGREE_LATITUDE\n  const deltaY = (to[1] - from[1]) * METERS_PER_DEGREE_LATITUDE\n\n  return Math.sqrt(deltaX ** 2 + deltaY ** 2)\n}\n\nconst approximateBearing = (from: MapCoordinates, to: MapCoordinates) => {\n  const latitudeRadians = (from[1] * Math.PI) / 180\n  const deltaX = (to[0] - from[0]) * Math.cos(latitudeRadians)\n  const deltaY = to[1] - from[1]\n\n  return ((Math.atan2(deltaX, deltaY) * 180) / Math.PI + 360) % 360\n}\n\nconst interpolateCoordinate = (from: MapCoordinates, to: MapCoordinates, fraction: number): MapCoordinates => {\n  return [from[0] + (to[0] - from[0]) * fraction, from[1] + (to[1] - from[1]) * fraction]\n}\n\nconst generateSteps = (path: MapPath, stepSpacing: number): FootprintStep[] => {\n  if (path.length < 2) {\n    return []\n  }\n\n  const steps: FootprintStep[] = []\n  let distanceAccumulated = 0\n  let stepIndex = 0\n\n  for (let segmentIndex = 0; segmentIndex < path.length - 1; segmentIndex++) {\n    const fromCoordinate = path[segmentIndex]\n    const toCoordinate = path[segmentIndex + 1]\n    const segmentDistance = approximateDistance(fromCoordinate, toCoordinate)\n    const bearing = approximateBearing(fromCoordinate, toCoordinate)\n\n    let distanceIntoSegment = stepSpacing - distanceAccumulated\n\n    while (distanceIntoSegment <= segmentDistance) {\n      const fraction = distanceIntoSegment / segmentDistance\n      const coordinates = interpolateCoordinate(fromCoordinate, toCoordinate, fraction)\n\n      steps.push({\n        coordinates,\n        bearing,\n        isLeft: stepIndex % 2 === 0,\n      })\n\n      stepIndex++\n      distanceIntoSegment += stepSpacing\n    }\n\n    distanceAccumulated = segmentDistance - (distanceIntoSegment - stepSpacing)\n  }\n\n  return steps\n}\n\nconst createMarkers = (steps: FootprintStep[], map: mapboxgl.Map): MarkerEntry[] => {\n  const markers: MarkerEntry[] = []\n\n  for (const step of steps) {\n    const container = document.createElement(\"div\")\n    container.style.opacity = \"0\"\n    container.style.visibility = \"hidden\"\n\n    const marker = new mapgl.Marker({\n      element: container,\n      anchor: \"center\",\n    })\n      .setLngLat(step.coordinates)\n      .addTo(map)\n\n    markers.push({ marker, element: container })\n  }\n\n  return markers\n}\n\nconst removeMarkers = (markers: MarkerEntry[]) => {\n  for (const entry of markers) {\n    entry.marker.remove()\n  }\n}\n\nexport const MapAnimatedFootprint = ({\n  path,\n  id = \"footprint\",\n  color = DEFAULT_COLOR,\n  size = DEFAULT_SIZE,\n  stepSpacing = DEFAULT_STEP_SPACING,\n  staggerDelay = DEFAULT_STAGGER_DELAY,\n  duration = DEFAULT_DURATION,\n  autoStart = true,\n  loop = false,\n  className,\n}: MapAnimatedFootprintProps) => {\n  const { map, isLoaded } = useMap()\n  const [markers, setMarkers] = useState<MarkerEntry[]>([])\n  const autoStartRef = useRef(autoStart)\n  autoStartRef.current = autoStart\n\n  const steps = useMemo(() => {\n    return generateSteps(path, stepSpacing)\n  }, [path, stepSpacing])\n\n  useEffect(() => {\n    if (!map || !isLoaded) {\n      return\n    }\n\n    const createdMarkers = createMarkers(steps, map)\n    setMarkers([...createdMarkers])\n\n    return () => {\n      removeMarkers(createdMarkers)\n      setMarkers([])\n    }\n  }, [map, isLoaded, steps])\n\n  useEffect(() => {\n    if (markers.length === 0) {\n      return\n    }\n\n    let animations: Animation[] = []\n    let intervalId: ReturnType<typeof setInterval> | null = null\n    let active = false\n\n    const startAnimations = () => {\n      for (const animation of animations) {\n        animation.cancel()\n      }\n\n      if (intervalId) {\n        clearInterval(intervalId)\n        intervalId = null\n      }\n\n      for (const entry of markers) {\n        entry.element.style.visibility = \"visible\"\n      }\n\n      animations = markers.map((entry, index) => {\n        return entry.element.animate([{ opacity: 0 }, { opacity: 1 }], {\n          duration,\n          delay: index * staggerDelay,\n          fill: \"both\",\n          easing: \"ease-in\",\n        })\n      })\n\n      active = true\n\n      if (loop) {\n        const totalCycleDuration = markers.length * staggerDelay + duration\n        intervalId = setInterval(startAnimations, totalCycleDuration)\n      }\n    }\n\n    const resetAnimations = () => {\n      for (const animation of animations) {\n        animation.cancel()\n      }\n      animations = []\n      active = false\n\n      if (intervalId) {\n        clearInterval(intervalId)\n        intervalId = null\n      }\n\n      for (const entry of markers) {\n        entry.element.style.opacity = \"0\"\n        entry.element.style.visibility = \"hidden\"\n      }\n    }\n\n    const control: FootprintControl = {\n      start: startAnimations,\n      reset: resetAnimations,\n      get isActive() {\n        return active\n      },\n    }\n\n    footprintControls.set(id, control)\n\n    if (autoStartRef.current) {\n      startAnimations()\n    }\n\n    return () => {\n      resetAnimations()\n      footprintControls.delete(id)\n    }\n  }, [id, loop, markers, staggerDelay, duration])\n\n  return (\n    <>\n      {markers.map((entry, index) => {\n        const step = steps[index]\n        if (!step) {\n          return null\n        }\n        return (\n          <Footprint key={`${id}-${index}`} entry={entry} step={step} color={color} size={size} className={className} />\n        )\n      })}\n    </>\n  )\n}\n\nconst Footprint = ({ entry, step, color, size, className }: FootprintProps) => {\n  const lateralOffset = step.isLeft ? -LATERAL_OFFSET : LATERAL_OFFSET\n  const rotationDeg = step.bearing\n  const scaleX = step.isLeft ? 1 : -1\n\n  return createPortal(\n    <div\n      className={className}\n      style={{\n        transform: `rotate(${rotationDeg}deg) translateX(${lateralOffset}em) scaleX(${scaleX})`,\n        color,\n        display: \"flex\",\n        alignItems: \"center\",\n        justifyContent: \"center\",\n      }}\n    >\n      <Footprints size={size} />\n    </div>,\n    entry.element\n  )\n}\n\nconst CONTROL_UPDATE_INTERVAL = 100\n\nexport const useFootprintControl = (id: string): FootprintControl | null => {\n  const [, forceUpdate] = useState(0)\n\n  useEffect(() => {\n    const intervalId = setInterval(() => {\n      forceUpdate((previous) => {\n        return previous + 1\n      })\n    }, CONTROL_UPDATE_INTERVAL)\n\n    return () => {\n      clearInterval(intervalId)\n    }\n  }, [])\n\n  return footprintControls.get(id) || null\n}\n",
      "type": "registry:ui",
      "target": "components/ui/map/animated-footprint.tsx"
    }
  ],
  "type": "registry:ui"
}
