{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "animated-pulse",
  "title": "Map Animated Pulse",
  "description": "Pulsing dot animation for map markers.",
  "dependencies": ["mapbox-gl"],
  "devDependencies": ["@types/mapbox-gl"],
  "registryDependencies": ["https://www.terrae.dev/map.json"],
  "files": [
    {
      "path": "src/registry/map/animated-pulse.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { useMap } from \"./hooks\"\nimport type { MapCoordinates } from \"./types\"\n\nconst DEFAULT_SIZE = 100\nconst DEFAULT_COLOR = \"rgba(0, 100, 255, 1)\"\nconst DEFAULT_PULSE_COLOR = \"rgba(0, 100, 255, 0.8)\"\nconst DEFAULT_DURATION = 1000\n\ntype MapAnimatedPulseProps = {\n  id: string\n  coordinates: MapCoordinates\n  size?: number\n  color?: string\n  pulseColor?: string\n  duration?: number\n}\n\ntype PulsingDot = {\n  width: number\n  height: number\n  data: Uint8ClampedArray\n  context?: CanvasRenderingContext2D\n  onAdd: () => void\n  render: () => boolean\n}\n\nconst createPulsingDot = (size: number, color: string, pulseColor: string, duration: number): PulsingDot => {\n  const dot: PulsingDot = {\n    width: size,\n    height: size,\n    data: new Uint8ClampedArray(size * size * 4),\n\n    onAdd() {\n      const canvas = document.createElement(\"canvas\")\n      canvas.width = this.width\n      canvas.height = this.height\n      this.context = canvas.getContext(\"2d\", { willReadFrequently: true }) || undefined\n    },\n\n    render() {\n      if (!this.context) {\n        return false\n      }\n\n      const t = (performance.now() % duration) / duration\n      const radius = (size / 2) * 0.3\n      const outerRadius = (size / 2) * 0.7 * t + radius\n\n      this.context.clearRect(0, 0, this.width, this.height)\n\n      this.context.beginPath()\n      this.context.arc(this.width / 2, this.height / 2, outerRadius, 0, Math.PI * 2)\n      this.context.fillStyle = pulseColor\n      this.context.globalAlpha = 1 - t\n      this.context.fill()\n\n      this.context.beginPath()\n      this.context.arc(this.width / 2, this.height / 2, radius, 0, Math.PI * 2)\n      this.context.fillStyle = color\n      this.context.globalAlpha = 1\n      this.context.fill()\n\n      this.data = this.context.getImageData(0, 0, this.width, this.height).data\n\n      return true\n    },\n  }\n\n  return dot\n}\n\nexport const MapAnimatedPulse = ({\n  id,\n  coordinates,\n  size = DEFAULT_SIZE,\n  color = DEFAULT_COLOR,\n  pulseColor = DEFAULT_PULSE_COLOR,\n  duration = DEFAULT_DURATION,\n}: MapAnimatedPulseProps) => {\n  const { map, isLoaded } = useMap()\n  const animationFrameRef = useRef<number | null>(null)\n  const sourceId = `${id}-source`\n  const layerId = `${id}-layer`\n\n  useEffect(() => {\n    if (!isLoaded || !map) {\n      return\n    }\n\n    const pulsingDot = createPulsingDot(size, color, pulseColor, duration)\n\n    const addImage = () => {\n      if (!map.hasImage(id)) {\n        map.addImage(id, pulsingDot, { pixelRatio: 2 })\n      }\n    }\n\n    addImage()\n\n    const animate = () => {\n      map.triggerRepaint()\n      animationFrameRef.current = requestAnimationFrame(animate)\n    }\n    animationFrameRef.current = requestAnimationFrame(animate)\n\n    const handleStyleLoad = () => {\n      addImage()\n    }\n\n    map.on(\"style.load\", handleStyleLoad)\n\n    return () => {\n      map.off(\"style.load\", handleStyleLoad)\n      if (animationFrameRef.current) {\n        cancelAnimationFrame(animationFrameRef.current)\n      }\n      try {\n        if (map.hasImage(id)) {\n          map.removeImage(id)\n        }\n      } catch {\n        // Map may already be destroyed during unmount\n      }\n    }\n  }, [map, isLoaded, id, size, color, pulseColor, duration])\n\n  useEffect(() => {\n    if (!isLoaded || !map) {\n      return\n    }\n\n    const addLayers = () => {\n      if (!map.isStyleLoaded() || !map.hasImage(id)) {\n        requestAnimationFrame(addLayers)\n        return\n      }\n\n      if (!map.getSource(sourceId)) {\n        map.addSource(sourceId, {\n          type: \"geojson\",\n          data: {\n            type: \"FeatureCollection\",\n            features: [\n              {\n                type: \"Feature\",\n                geometry: { type: \"Point\", coordinates },\n                properties: {},\n              },\n            ],\n          },\n        })\n      }\n\n      if (!map.getLayer(layerId)) {\n        map.addLayer({\n          id: layerId,\n          type: \"symbol\",\n          source: sourceId,\n          layout: {\n            \"icon-image\": id,\n            \"icon-allow-overlap\": true,\n          },\n        })\n      }\n    }\n\n    addLayers()\n\n    const handleStyleLoad = () => {\n      addLayers()\n    }\n\n    map.on(\"style.load\", handleStyleLoad)\n\n    return () => {\n      map.off(\"style.load\", handleStyleLoad)\n      try {\n        if (map.isStyleLoaded()) {\n          if (map.getLayer(layerId)) {\n            map.removeLayer(layerId)\n          }\n          if (map.getSource(sourceId)) {\n            map.removeSource(sourceId)\n          }\n        }\n      } catch {\n        // Map may already be destroyed during unmount\n      }\n    }\n  }, [map, isLoaded, coordinates, id, sourceId, layerId])\n\n  return null\n}\n",
      "type": "registry:ui",
      "target": "components/ui/map/animated-pulse.tsx"
    }
  ],
  "type": "registry:ui"
}
