{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "steam",
  "title": "Map Steam",
  "description": "Rising steam effect with wispy particle animation.",
  "dependencies": ["mapbox-gl"],
  "devDependencies": ["@types/mapbox-gl"],
  "registryDependencies": ["https://www.terrae.dev/map.json"],
  "files": [
    {
      "path": "src/registry/map/steam.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useId, useRef, useState, useCallback } from \"react\"\nimport { useMap } from \"./hooks\"\nimport type { MapCoordinates } from \"./types\"\n\ntype SteamParticle = {\n  x: number\n  y: number\n  velocityX: number\n  velocityY: number\n  radius: number\n  life: number\n  maxLife: number\n  opacity: number\n}\n\ntype MapSteamProps = {\n  id?: string\n  coordinates: MapCoordinates\n  size?: number\n  intensity?: number\n  particleCount?: number\n  color?: string\n  drift?: number\n  riseSpeed?: number\n  autoStart?: boolean\n}\n\ntype SteamRenderer = {\n  width: number\n  height: number\n  data: Uint8ClampedArray\n  context?: CanvasRenderingContext2D\n  particles: SteamParticle[]\n  isActive: boolean\n  currentIntensity: number\n  onAdd: () => void\n  render: () => boolean\n  start: () => void\n  stop: () => void\n  setIntensity: (intensity: number) => void\n}\n\ntype RgbColor = {\n  red: number\n  green: number\n  blue: number\n}\n\ntype SteamControl = {\n  start: () => void\n  stop: () => void\n  setIntensity: (intensity: number) => void\n  isActive: boolean\n}\n\nconst DEFAULT_SIZE = 100\nconst DEFAULT_INTENSITY = 1\nconst DEFAULT_PARTICLE_COUNT = 40\nconst DEFAULT_COLOR = \"#ffffff\"\nconst DEFAULT_DRIFT = 0.4\nconst DEFAULT_RISE_SPEED = 1\nconst CONTROL_UPDATE_INTERVAL = 100\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 { red: 255, green: 255, blue: 255 }\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  centerX: number,\n  baseY: number,\n  intensity: number,\n  drift: number,\n  riseSpeed: number\n): SteamParticle => {\n  const spreadX = 20 * intensity\n\n  return {\n    x: centerX + (Math.random() - 0.5) * spreadX,\n    y: baseY + Math.random() * 10,\n    velocityX: (Math.random() - 0.5) * drift,\n    velocityY: -riseSpeed * (0.8 + Math.random() * 0.4),\n    radius: 6 + Math.random() * 10 * intensity,\n    life: 0,\n    maxLife: 60 + Math.random() * 40,\n    opacity: 0.3 + Math.random() * 0.4,\n  }\n}\n\nconst fadeCanvasEdges = (context: CanvasRenderingContext2D, width: number, height: number) => {\n  const edgeSize = Math.max(4, Math.floor(width * 0.06))\n  context.globalCompositeOperation = \"destination-out\"\n\n  const topGradient = context.createLinearGradient(0, 0, 0, edgeSize)\n  topGradient.addColorStop(0, \"rgba(0, 0, 0, 1)\")\n  topGradient.addColorStop(1, \"rgba(0, 0, 0, 0)\")\n  context.fillStyle = topGradient\n  context.fillRect(0, 0, width, edgeSize)\n\n  const bottomGradient = context.createLinearGradient(0, height - edgeSize, 0, height)\n  bottomGradient.addColorStop(0, \"rgba(0, 0, 0, 0)\")\n  bottomGradient.addColorStop(1, \"rgba(0, 0, 0, 1)\")\n  context.fillStyle = bottomGradient\n  context.fillRect(0, height - edgeSize, width, edgeSize)\n\n  const leftGradient = context.createLinearGradient(0, 0, edgeSize, 0)\n  leftGradient.addColorStop(0, \"rgba(0, 0, 0, 1)\")\n  leftGradient.addColorStop(1, \"rgba(0, 0, 0, 0)\")\n  context.fillStyle = leftGradient\n  context.fillRect(0, 0, edgeSize, height)\n\n  const rightGradient = context.createLinearGradient(width - edgeSize, 0, width, 0)\n  rightGradient.addColorStop(0, \"rgba(0, 0, 0, 0)\")\n  rightGradient.addColorStop(1, \"rgba(0, 0, 0, 1)\")\n  context.fillStyle = rightGradient\n  context.fillRect(width - edgeSize, 0, edgeSize, height)\n\n  context.globalCompositeOperation = \"source-over\"\n}\n\nconst createSteamRenderer = (\n  size: number,\n  intensity: number,\n  particleCount: number,\n  color: string,\n  drift: number,\n  riseSpeed: number\n): SteamRenderer => {\n  const rgb = hexToRgb(color)\n  const centerX = size / 2\n  const baseY = size * 0.85\n\n  const renderer: SteamRenderer = {\n    width: size,\n    height: size,\n    data: new Uint8ClampedArray(size * size * 4),\n    particles: [],\n    isActive: false,\n    currentIntensity: intensity,\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    start() {\n      if (this.isActive) {\n        return\n      }\n      this.isActive = true\n      this.particles = []\n\n      for (let index = 0; index < particleCount; index++) {\n        const particle = createParticle(centerX, baseY, this.currentIntensity, drift, riseSpeed)\n        particle.life = Math.random() * particle.maxLife\n        this.particles.push(particle)\n      }\n    },\n\n    stop() {\n      this.isActive = false\n      this.particles = []\n    },\n\n    setIntensity(newIntensity: number) {\n      this.currentIntensity = Math.max(0.1, Math.min(3, newIntensity))\n    },\n\n    render() {\n      if (!this.context) {\n        return false\n      }\n\n      this.context.clearRect(0, 0, this.width, this.height)\n\n      if (!this.isActive || this.particles.length === 0) {\n        this.data = this.context.getImageData(0, 0, this.width, this.height).data\n        return true\n      }\n\n      for (let index = 0; index < this.particles.length; index++) {\n        const particle = this.particles[index]\n\n        particle.x += particle.velocityX + (Math.random() - 0.5) * 0.3\n        particle.y += particle.velocityY\n        particle.velocityX *= 0.99\n        particle.radius += 0.1\n        particle.life++\n\n        if (particle.life >= particle.maxLife) {\n          this.particles[index] = createParticle(centerX, baseY, this.currentIntensity, drift, riseSpeed)\n          continue\n        }\n\n        const lifeRatio = particle.life / particle.maxLife\n        const fadeIn = Math.min(lifeRatio * 4, 1)\n        const fadeOut = 1 - Math.pow(lifeRatio, 2)\n        const alpha = particle.opacity * fadeIn * fadeOut * this.currentIntensity\n\n        const gradient = this.context.createRadialGradient(\n          particle.x,\n          particle.y,\n          0,\n          particle.x,\n          particle.y,\n          particle.radius\n        )\n\n        gradient.addColorStop(0, `rgba(${rgb.red}, ${rgb.green}, ${rgb.blue}, ${alpha * 0.8})`)\n        gradient.addColorStop(0.4, `rgba(${rgb.red}, ${rgb.green}, ${rgb.blue}, ${alpha * 0.5})`)\n        gradient.addColorStop(1, `rgba(${rgb.red}, ${rgb.green}, ${rgb.blue}, 0)`)\n\n        this.context.beginPath()\n        this.context.arc(particle.x, particle.y, particle.radius, 0, Math.PI * 2)\n        this.context.fillStyle = gradient\n        this.context.fill()\n      }\n\n      fadeCanvasEdges(this.context, this.width, this.height)\n      this.data = this.context.getImageData(0, 0, this.width, this.height).data\n\n      return true\n    },\n  }\n\n  return renderer\n}\n\nconst steamControls = new Map<string, SteamControl>()\n\nexport const MapSteam = ({\n  id,\n  coordinates,\n  size = DEFAULT_SIZE,\n  intensity = DEFAULT_INTENSITY,\n  particleCount = DEFAULT_PARTICLE_COUNT,\n  color = DEFAULT_COLOR,\n  drift = DEFAULT_DRIFT,\n  riseSpeed = DEFAULT_RISE_SPEED,\n  autoStart = true,\n}: MapSteamProps) => {\n  const { map, isLoaded } = useMap()\n  const animationFrameRef = useRef<number | null>(null)\n  const rendererRef = useRef<SteamRenderer | null>(null)\n  const autoId = useId()\n  const controlId = id ?? autoId\n  const sourceId = `${controlId}-source`\n  const layerId = `${controlId}-layer`\n\n  const start = useCallback(() => {\n    if (rendererRef.current) {\n      rendererRef.current.start()\n    }\n  }, [])\n\n  const stop = useCallback(() => {\n    if (rendererRef.current) {\n      rendererRef.current.stop()\n    }\n  }, [])\n\n  const setIntensity = useCallback((newIntensity: number) => {\n    if (rendererRef.current) {\n      rendererRef.current.setIntensity(newIntensity)\n    }\n  }, [])\n\n  useEffect(() => {\n    if (!isLoaded || !map) {\n      return\n    }\n\n    const steamRenderer = createSteamRenderer(size, intensity, particleCount, color, drift, riseSpeed)\n    rendererRef.current = steamRenderer\n\n    const control: SteamControl = {\n      start,\n      stop,\n      setIntensity,\n      get isActive() {\n        return rendererRef.current?.isActive || false\n      },\n    }\n    steamControls.set(controlId, control)\n\n    const addImage = () => {\n      if (!map.hasImage(controlId)) {\n        map.addImage(controlId, steamRenderer, { pixelRatio: 2 })\n      }\n    }\n\n    addImage()\n\n    if (autoStart) {\n      steamRenderer.start()\n    }\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      steamControls.delete(controlId)\n      if (animationFrameRef.current) {\n        cancelAnimationFrame(animationFrameRef.current)\n      }\n      try {\n        if (map.hasImage(controlId)) {\n          map.removeImage(controlId)\n        }\n      } catch {\n        // Map may already be destroyed during unmount\n      }\n    }\n  }, [\n    map,\n    isLoaded,\n    controlId,\n    size,\n    intensity,\n    particleCount,\n    color,\n    drift,\n    riseSpeed,\n    autoStart,\n    start,\n    stop,\n    setIntensity,\n  ])\n\n  useEffect(() => {\n    if (!isLoaded || !map) {\n      return\n    }\n\n    let pollFrameId: number\n\n    const addLayers = () => {\n      if (!map.isStyleLoaded() || !map.hasImage(controlId)) {\n        pollFrameId = 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\": controlId,\n            \"icon-allow-overlap\": true,\n          },\n        })\n      }\n    }\n\n    pollFrameId = requestAnimationFrame(addLayers)\n\n    const handleStyleLoad = () => {\n      addLayers()\n    }\n\n    map.on(\"style.load\", handleStyleLoad)\n\n    return () => {\n      cancelAnimationFrame(pollFrameId)\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, controlId, sourceId, layerId])\n\n  return null\n}\n\nexport const useSteamControl = (id: string): SteamControl | 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 steamControls.get(id) || null\n}\n",
      "type": "registry:ui",
      "target": "components/ui/map/steam.tsx"
    }
  ],
  "type": "registry:ui"
}
