{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sandstorm",
  "title": "Map Sandstorm",
  "description": "Atmospheric sandstorm effect with horizontal particle movement and reduced visibility.",
  "dependencies": ["mapbox-gl"],
  "devDependencies": ["@types/mapbox-gl"],
  "registryDependencies": ["https://www.terrae.dev/map.json"],
  "files": [
    {
      "path": "src/registry/map/sandstorm.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useId, useRef, useState, useCallback } from \"react\"\nimport { createPortal } from \"react-dom\"\nimport { useMap } from \"./hooks\"\n\ntype RgbColor = {\n  red: number\n  green: number\n  blue: number\n}\n\ntype SandParticle = {\n  x: number\n  y: number\n  size: number\n  speed: number\n  opacity: number\n  wobble: number\n  wobbleSpeed: number\n}\n\ntype SandstormControl = {\n  start: () => void\n  stop: () => void\n  setIntensity: (intensity: number) => void\n  isActive: boolean\n}\n\ntype SandstormOverlayProps = {\n  intensity: number\n  particleCount: number\n  color: string\n  windSpeed: number\n  windDirection: number\n  visibility: number\n  turbulence: number\n  isActive: boolean\n  onControlReady: (control: SandstormControl) => void\n}\n\ntype MapSandstormProps = {\n  id?: string\n  intensity?: number\n  particleCount?: number\n  color?: string\n  windSpeed?: number\n  windDirection?: number\n  visibility?: number\n  turbulence?: number\n  autoStart?: boolean\n}\n\nconst DEFAULT_INTENSITY = 1\nconst DEFAULT_PARTICLE_COUNT = 200\nconst DEFAULT_COLOR = \"#d4a574\"\nconst DEFAULT_WIND_SPEED = 4\nconst DEFAULT_WIND_DIRECTION = 0\nconst DEFAULT_VISIBILITY = 0.3\nconst DEFAULT_TURBULENCE = 0.5\n\nconst MIN_INTENSITY = 0.1\nconst MAX_INTENSITY = 3\nconst BOUNDS_PADDING = 10\nconst PARTICLE_MIN_SIZE = 1\nconst PARTICLE_SIZE_RANGE = 3\nconst PARTICLE_MIN_SPEED = 2\nconst PARTICLE_SPEED_RANGE = 4\nconst PARTICLE_MIN_OPACITY = 0.3\nconst PARTICLE_OPACITY_RANGE = 0.5\nconst PARTICLE_MIN_WOBBLE_SPEED = 0.02\nconst PARTICLE_WOBBLE_SPEED_RANGE = 0.04\nconst DEGREES_TO_RADIANS = Math.PI / 180\nconst TURBULENCE_MULTIPLIER = 20\nconst VERTICAL_WOBBLE_FACTOR = 0.1\nconst HAZE_OPACITY_FACTOR = 0.4\nconst CONTROL_UPDATE_INTERVAL = 100\nconst DEFAULT_SAND_RGB: RgbColor = { red: 212, green: 165, blue: 116 }\n\nconst sandstormControls = new Map<string, SandstormControl>()\n\nconst hexToRgb = (hex: string): RgbColor => {\n  const result = /^#?([a-f\\d]{2})([a-f\\d]{2})([a-f\\d]{2})$/i.exec(hex)\n\n  if (!result) {\n    return DEFAULT_SAND_RGB\n  }\n\n  return {\n    red: parseInt(result[1], 16),\n    green: parseInt(result[2], 16),\n    blue: parseInt(result[3], 16),\n  }\n}\n\nconst createParticle = (\n  canvasWidth: number,\n  canvasHeight: number,\n  windDirection: number,\n  intensity: number\n): SandParticle => {\n  const angle = windDirection * DEGREES_TO_RADIANS\n  const startFromLeft = Math.cos(angle) > 0\n\n  return {\n    x: startFromLeft ? -BOUNDS_PADDING : canvasWidth + BOUNDS_PADDING,\n    y: Math.random() * canvasHeight,\n    size: PARTICLE_MIN_SIZE + Math.random() * PARTICLE_SIZE_RANGE * intensity,\n    speed: PARTICLE_MIN_SPEED + Math.random() * PARTICLE_SPEED_RANGE,\n    opacity: PARTICLE_MIN_OPACITY + Math.random() * PARTICLE_OPACITY_RANGE,\n    wobble: Math.random() * Math.PI * 2,\n    wobbleSpeed: PARTICLE_MIN_WOBBLE_SPEED + Math.random() * PARTICLE_WOBBLE_SPEED_RANGE,\n  }\n}\n\nconst isParticleOutOfBounds = (\n  particle: SandParticle,\n  canvasWidth: number,\n  canvasHeight: number,\n  cosAngle: number\n): boolean => {\n  return (\n    (cosAngle > 0 && particle.x > canvasWidth + BOUNDS_PADDING) ||\n    (cosAngle < 0 && particle.x < -BOUNDS_PADDING) ||\n    particle.y < -BOUNDS_PADDING ||\n    particle.y > canvasHeight + BOUNDS_PADDING\n  )\n}\n\nconst updateParticlePosition = (\n  particle: SandParticle,\n  cosAngle: number,\n  sinAngle: number,\n  windSpeed: number,\n  turbulence: number\n): void => {\n  particle.wobble += particle.wobbleSpeed\n  const wobbleOffset = Math.sin(particle.wobble) * turbulence * TURBULENCE_MULTIPLIER\n\n  particle.x += cosAngle * particle.speed * windSpeed\n  particle.y += sinAngle * particle.speed * windSpeed + wobbleOffset * VERTICAL_WOBBLE_FACTOR\n}\n\nconst drawParticle = (\n  context: CanvasRenderingContext2D,\n  particle: SandParticle,\n  color: RgbColor,\n  intensity: number\n): void => {\n  const gradient = context.createRadialGradient(particle.x, particle.y, 0, particle.x, particle.y, particle.size)\n\n  const particleOpacity = particle.opacity * intensity\n  gradient.addColorStop(0, `rgba(${color.red}, ${color.green}, ${color.blue}, ${particleOpacity})`)\n  gradient.addColorStop(1, `rgba(${color.red}, ${color.green}, ${color.blue}, 0)`)\n\n  context.beginPath()\n  context.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2)\n  context.fillStyle = gradient\n  context.fill()\n}\n\nconst drawHaze = (\n  context: CanvasRenderingContext2D,\n  canvasWidth: number,\n  canvasHeight: number,\n  color: RgbColor,\n  visibility: number,\n  intensity: number\n): void => {\n  const hazeOpacity = visibility * intensity * HAZE_OPACITY_FACTOR\n  context.fillStyle = `rgba(${color.red}, ${color.green}, ${color.blue}, ${hazeOpacity})`\n  context.fillRect(0, 0, canvasWidth, canvasHeight)\n}\n\nconst adjustParticleCount = (\n  particles: SandParticle[],\n  targetCount: number,\n  canvasWidth: number,\n  canvasHeight: number,\n  windDirection: number,\n  intensity: number\n): SandParticle[] => {\n  if (particles.length < targetCount) {\n    const toAdd = targetCount - particles.length\n    for (let index = 0; index < toAdd; index++) {\n      particles.push(createParticle(canvasWidth, canvasHeight, windDirection, intensity))\n    }\n    return particles\n  }\n\n  if (particles.length > targetCount) {\n    return particles.slice(0, targetCount)\n  }\n\n  return particles\n}\n\nexport const MapSandstorm = ({\n  id,\n  intensity = DEFAULT_INTENSITY,\n  particleCount = DEFAULT_PARTICLE_COUNT,\n  color = DEFAULT_COLOR,\n  windSpeed = DEFAULT_WIND_SPEED,\n  windDirection = DEFAULT_WIND_DIRECTION,\n  visibility = DEFAULT_VISIBILITY,\n  turbulence = DEFAULT_TURBULENCE,\n  autoStart = true,\n}: MapSandstormProps) => {\n  const { map, isLoaded } = useMap()\n  const [container, setContainer] = useState<HTMLElement | null>(null)\n  const autoId = useId()\n  const controlId = id ?? autoId\n\n  const handleControlReady = (control: SandstormControl) => {\n    sandstormControls.set(controlId, control)\n  }\n\n  useEffect(() => {\n    if (!map || !isLoaded) {\n      return\n    }\n\n    const mapContainer = map.getContainer()\n    setContainer(mapContainer)\n\n    return () => {\n      sandstormControls.delete(controlId)\n    }\n  }, [map, isLoaded, controlId])\n\n  if (!container) {\n    return null\n  }\n\n  return createPortal(\n    <SandstormOverlay\n      intensity={intensity}\n      particleCount={particleCount}\n      color={color}\n      windSpeed={windSpeed}\n      windDirection={windDirection}\n      visibility={visibility}\n      turbulence={turbulence}\n      isActive={autoStart}\n      onControlReady={handleControlReady}\n    />,\n    container\n  )\n}\n\nconst SandstormOverlay = ({\n  intensity,\n  particleCount,\n  color,\n  windSpeed,\n  windDirection,\n  visibility,\n  turbulence,\n  isActive: initialActive,\n  onControlReady,\n}: SandstormOverlayProps) => {\n  const canvasRef = useRef<HTMLCanvasElement | null>(null)\n  const particlesRef = useRef<SandParticle[]>([])\n  const animationFrameRef = useRef<number | null>(null)\n  const isActiveRef = useRef(initialActive)\n  const intensityRef = useRef(intensity)\n  const [, forceUpdate] = useState(0)\n\n  const start = useCallback(() => {\n    isActiveRef.current = true\n    forceUpdate((previous) => {\n      return previous + 1\n    })\n  }, [])\n\n  const stop = useCallback(() => {\n    isActiveRef.current = false\n    forceUpdate((previous) => {\n      return previous + 1\n    })\n  }, [])\n\n  const setIntensity = useCallback((newIntensity: number) => {\n    intensityRef.current = Math.max(MIN_INTENSITY, Math.min(MAX_INTENSITY, newIntensity))\n  }, [])\n\n  useEffect(() => {\n    const control: SandstormControl = {\n      start,\n      stop,\n      setIntensity,\n      get isActive() {\n        return isActiveRef.current\n      },\n    }\n    onControlReady(control)\n  }, [start, stop, setIntensity, onControlReady])\n\n  useEffect(() => {\n    intensityRef.current = intensity\n  }, [intensity])\n\n  useEffect(() => {\n    const canvas = canvasRef.current\n\n    if (!canvas) {\n      return\n    }\n\n    const context = canvas.getContext(\"2d\")\n\n    if (!context) {\n      return\n    }\n\n    const updateCanvasSize = () => {\n      canvas.width = window.innerWidth\n      canvas.height = window.innerHeight\n    }\n\n    const initParticles = () => {\n      particlesRef.current = []\n      const count = Math.floor(particleCount * intensity)\n\n      for (let index = 0; index < count; index++) {\n        const particle = createParticle(canvas.width, canvas.height, windDirection, intensity)\n        particle.x = Math.random() * canvas.width\n        particlesRef.current.push(particle)\n      }\n    }\n\n    updateCanvasSize()\n    window.addEventListener(\"resize\", updateCanvasSize)\n    initParticles()\n\n    const particleColor = hexToRgb(color)\n    const angle = windDirection * DEGREES_TO_RADIANS\n    const cosAngle = Math.cos(angle)\n    const sinAngle = Math.sin(angle)\n\n    const animate = () => {\n      context.clearRect(0, 0, canvas.width, canvas.height)\n\n      if (!isActiveRef.current) {\n        animationFrameRef.current = requestAnimationFrame(animate)\n        return\n      }\n\n      const currentIntensity = intensityRef.current\n      const targetCount = Math.floor(particleCount * currentIntensity)\n\n      particlesRef.current = adjustParticleCount(\n        particlesRef.current,\n        targetCount,\n        canvas.width,\n        canvas.height,\n        windDirection,\n        currentIntensity\n      )\n\n      drawHaze(context, canvas.width, canvas.height, particleColor, visibility, currentIntensity)\n\n      for (let index = 0; index < particlesRef.current.length; index++) {\n        const particle = particlesRef.current[index]\n\n        updateParticlePosition(particle, cosAngle, sinAngle, windSpeed, turbulence)\n\n        if (isParticleOutOfBounds(particle, canvas.width, canvas.height, cosAngle)) {\n          particlesRef.current[index] = createParticle(canvas.width, canvas.height, windDirection, currentIntensity)\n          continue\n        }\n\n        drawParticle(context, particle, particleColor, currentIntensity)\n      }\n\n      animationFrameRef.current = requestAnimationFrame(animate)\n    }\n\n    animationFrameRef.current = requestAnimationFrame(animate)\n\n    return () => {\n      window.removeEventListener(\"resize\", updateCanvasSize)\n      if (animationFrameRef.current) {\n        cancelAnimationFrame(animationFrameRef.current)\n      }\n    }\n  }, [color, windSpeed, windDirection, visibility, turbulence, particleCount, intensity])\n\n  return (\n    <canvas\n      ref={canvasRef}\n      style={{\n        position: \"absolute\",\n        top: 0,\n        left: 0,\n        width: \"100%\",\n        height: \"100%\",\n        pointerEvents: \"none\",\n        zIndex: 10,\n      }}\n    />\n  )\n}\n\nexport const useSandstormControl = (id: string): SandstormControl | null => {\n  const [, forceUpdate] = useState(0)\n\n  useEffect(() => {\n    const interval = setInterval(() => {\n      forceUpdate((previous) => {\n        return previous + 1\n      })\n    }, CONTROL_UPDATE_INTERVAL)\n\n    return () => {\n      clearInterval(interval)\n    }\n  }, [])\n\n  return sandstormControls.get(id) || null\n}\n",
      "type": "registry:ui",
      "target": "components/ui/map/sandstorm.tsx"
    }
  ],
  "type": "registry:ui"
}
