{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "fire",
  "title": "Map Fire",
  "description": "Realistic animated fire effect with particle simulation.",
  "dependencies": ["mapbox-gl"],
  "devDependencies": ["@types/mapbox-gl"],
  "registryDependencies": ["https://www.terrae.dev/map.json"],
  "files": [
    {
      "path": "src/registry/map/fire.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useRef, useState, useCallback } from \"react\"\nimport { useMap } from \"./hooks\"\nimport type { MapCoordinates } from \"./types\"\n\ntype FireParticle = {\n  x: number\n  y: number\n  velocityX: number\n  velocityY: number\n  radius: number\n  life: number\n  maxLife: number\n}\n\ntype FireSource = {\n  x: number\n  y: number\n  particles: FireParticle[]\n  spawnTime: number\n  intensity: number\n}\n\ntype MapFireProps = {\n  id: string\n  coordinates: MapCoordinates\n  size?: number\n  intensity?: number\n  particleCount?: number\n  baseColor?: string\n  tipColor?: string\n  spread?: boolean\n  spreadSpeed?: number\n  spreadRadius?: number\n  maxSpreadPoints?: number\n  autoStart?: boolean\n}\n\ntype FireRenderer = {\n  width: number\n  height: number\n  data: Uint8ClampedArray\n  context?: CanvasRenderingContext2D\n  sources: FireSource[]\n  isActive: boolean\n  startTime: number\n  currentIntensity: number\n  onAdd: () => void\n  render: () => boolean\n  start: () => void\n  stop: () => void\n  addSource: (x: number, y: number) => void\n  setIntensity: (intensity: number) => void\n}\n\ntype FireControl = {\n  start: () => void\n  stop: () => void\n  setIntensity: (intensity: number) => void\n  isActive: boolean\n  spreadProgress: number\n}\n\ntype RgbColor = {\n  r: number\n  g: number\n  b: number\n}\n\nconst DEFAULT_SIZE = 120\nconst DEFAULT_INTENSITY = 1\nconst DEFAULT_PARTICLE_COUNT = 50\nconst DEFAULT_BASE_COLOR = \"#ffcc00\"\nconst DEFAULT_TIP_COLOR = \"#ff3300\"\nconst DEFAULT_SPREAD_SPEED = 2000\nconst DEFAULT_SPREAD_RADIUS = 0.4\nconst DEFAULT_MAX_SPREAD_POINTS = 8\n\nconst INITIAL_Y_POSITION = 0.85\nconst SPREAD_SCALE = 1.5\nconst MIN_PARTICLES_PER_SOURCE = 15\nconst VELOCITY_DECAY = 0.98\nconst FLICKER_BASE = 0.9\nconst FLICKER_RANGE = 0.2\nconst COLOR_GRADIENT_INNER = 0.3\nconst ALPHA_MULTIPLIER = 0.8\nconst INTENSITY_VARIATION_BASE = 0.7\nconst INTENSITY_VARIATION_RANGE = 0.3\nconst MIN_INTENSITY = 0.1\nconst MAX_INTENSITY = 3\nconst CANVAS_PADDING = 0.1\nconst VERTICAL_VARIATION = 0.1\nconst GLOW_SIZE_SPREAD = 0.08\nconst GLOW_SIZE_NORMAL = 0.15\nconst GLOW_OPACITY = 0.25\nconst PARTICLE_SPREAD_X = 20\nconst BASE_VELOCITY_Y = -1.5\nconst VELOCITY_X_RANGE = 0.8\nconst VELOCITY_Y_RANGE = -1\nconst PARTICLE_Y_OFFSET = 10\nconst BASE_RADIUS = 4\nconst RADIUS_RANGE = 8\nconst BASE_LIFE = 40\nconst LIFE_RANGE = 40\nconst LIFE_RADIUS_DECAY = 0.5\nconst COLOR_FACTOR_MULTIPLIER = 1.5\nconst PIXEL_RATIO = 2\nconst CONTROL_UPDATE_INTERVAL = 100\n\nconst fireControls = new Map<string, FireControl>()\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 { r: 255, g: 204, b: 0 }\n  }\n\n  return {\n    r: parseInt(result[1], 16),\n    g: parseInt(result[2], 16),\n    b: parseInt(result[3], 16),\n  }\n}\n\nconst interpolateColor = (color1: RgbColor, color2: RgbColor, factor: number): RgbColor => {\n  return {\n    r: Math.round(color1.r + (color2.r - color1.r) * factor),\n    g: Math.round(color1.g + (color2.g - color1.g) * factor),\n    b: Math.round(color1.b + (color2.b - color1.b) * factor),\n  }\n}\n\nconst createParticle = (sourceX: number, sourceY: number, intensity: number, scale: number = 1): FireParticle => {\n  const spreadX = PARTICLE_SPREAD_X * intensity * scale\n  const baseVelocityY = BASE_VELOCITY_Y * intensity * scale\n\n  return {\n    x: sourceX + (Math.random() - 0.5) * spreadX,\n    y: sourceY + Math.random() * PARTICLE_Y_OFFSET * scale,\n    velocityX: (Math.random() - 0.5) * VELOCITY_X_RANGE * scale,\n    velocityY: baseVelocityY + Math.random() * VELOCITY_Y_RANGE * scale,\n    radius: (BASE_RADIUS + Math.random() * RADIUS_RANGE * intensity) * scale,\n    life: 0,\n    maxLife: BASE_LIFE + Math.random() * LIFE_RANGE,\n  }\n}\n\nconst createFireSource = (\n  x: number,\n  y: number,\n  particleCount: number,\n  intensity: number,\n  scale: number = 1\n): FireSource => {\n  const particles: FireParticle[] = []\n\n  for (let index = 0; index < particleCount; index++) {\n    const particle = createParticle(x, y, intensity, scale)\n    particle.life = Math.random() * particle.maxLife\n    particles.push(particle)\n  }\n\n  return {\n    x,\n    y,\n    particles,\n    spawnTime: performance.now(),\n    intensity,\n  }\n}\n\nconst fadeCanvasEdges = (context: CanvasRenderingContext2D, width: number, height: number): void => {\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 updateParticle = (particle: FireParticle): boolean => {\n  particle.x += particle.velocityX + (Math.random() - 0.5) * 0.5\n  particle.y += particle.velocityY\n  particle.velocityX *= VELOCITY_DECAY\n  particle.life++\n\n  return particle.life >= particle.maxLife\n}\n\nconst drawParticle = (\n  context: CanvasRenderingContext2D,\n  particle: FireParticle,\n  baseRgb: RgbColor,\n  tipRgb: RgbColor,\n  flickerIntensity: number\n): void => {\n  const lifeRatio = particle.life / particle.maxLife\n  const alpha = (1 - lifeRatio) * flickerIntensity\n  const currentRadius = particle.radius * (1 - lifeRatio * LIFE_RADIUS_DECAY)\n\n  const colorFactor = Math.min(lifeRatio * COLOR_FACTOR_MULTIPLIER, 1)\n  const color = interpolateColor(baseRgb, tipRgb, colorFactor)\n\n  const gradient = context.createRadialGradient(particle.x, particle.y, 0, particle.x, particle.y, currentRadius)\n\n  gradient.addColorStop(0, `rgba(255, 255, 200, ${alpha})`)\n  gradient.addColorStop(COLOR_GRADIENT_INNER, `rgba(${color.r}, ${color.g}, ${color.b}, ${alpha * ALPHA_MULTIPLIER})`)\n  gradient.addColorStop(1, `rgba(${color.r}, ${color.g}, ${color.b}, 0)`)\n\n  context.beginPath()\n  context.arc(particle.x, particle.y, currentRadius, 0, Math.PI * 2)\n  context.fillStyle = gradient\n  context.fill()\n}\n\nconst drawSourceGlow = (\n  context: CanvasRenderingContext2D,\n  source: FireSource,\n  size: number,\n  spread: boolean,\n  flickerIntensity: number\n): void => {\n  const glowSize = spread ? size * GLOW_SIZE_SPREAD : size * GLOW_SIZE_NORMAL\n  const glowGradient = context.createRadialGradient(source.x, source.y, 0, source.x, source.y, glowSize)\n  glowGradient.addColorStop(0, `rgba(255, 150, 50, ${GLOW_OPACITY * flickerIntensity})`)\n  glowGradient.addColorStop(1, \"rgba(255, 150, 50, 0)\")\n\n  context.beginPath()\n  context.arc(source.x, source.y, glowSize, 0, Math.PI * 2)\n  context.fillStyle = glowGradient\n  context.fill()\n}\n\nconst createFireRenderer = (\n  size: number,\n  intensity: number,\n  particleCount: number,\n  baseColor: string,\n  tipColor: string,\n  spread: boolean,\n  spreadRadius: number,\n  maxSpreadPoints: number\n): FireRenderer => {\n  const baseRgb = hexToRgb(baseColor)\n  const tipRgb = hexToRgb(tipColor)\n  const centerX = size / 2\n  const scale = spread ? SPREAD_SCALE : 1\n  const particlesPerSource = spread\n    ? Math.max(MIN_PARTICLES_PER_SOURCE, Math.floor(particleCount / Math.max(maxSpreadPoints / 4, 1)))\n    : particleCount\n\n  const renderer: FireRenderer = {\n    width: size,\n    height: size,\n    data: new Uint8ClampedArray(size * size * 4),\n    sources: [],\n    isActive: false,\n    startTime: 0,\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.startTime = performance.now()\n      this.sources = []\n      const initialY = size * INITIAL_Y_POSITION\n      this.sources.push(createFireSource(centerX, initialY, particlesPerSource, this.currentIntensity, scale))\n    },\n\n    stop() {\n      this.isActive = false\n      this.sources = []\n    },\n\n    setIntensity(newIntensity: number) {\n      this.currentIntensity = Math.max(MIN_INTENSITY, Math.min(MAX_INTENSITY, newIntensity))\n      for (const source of this.sources) {\n        source.intensity =\n          this.currentIntensity * (INTENSITY_VARIATION_BASE + Math.random() * INTENSITY_VARIATION_RANGE)\n      }\n    },\n\n    addSource(x: number, y: number) {\n      if (this.sources.length < maxSpreadPoints) {\n        const sourceIntensity =\n          this.currentIntensity * (INTENSITY_VARIATION_BASE + Math.random() * INTENSITY_VARIATION_RANGE)\n        this.sources.push(createFireSource(x, y, particlesPerSource, sourceIntensity, scale))\n      }\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.sources.length === 0) {\n        this.data = this.context.getImageData(0, 0, this.width, this.height).data\n        return true\n      }\n\n      const flickerIntensity = FLICKER_BASE + Math.random() * FLICKER_RANGE\n\n      for (const source of this.sources) {\n        for (let particleIndex = 0; particleIndex < source.particles.length; particleIndex++) {\n          const particle = source.particles[particleIndex]\n          const shouldReset = updateParticle(particle)\n\n          if (shouldReset) {\n            source.particles[particleIndex] = createParticle(source.x, source.y, source.intensity, scale)\n            continue\n          }\n\n          drawParticle(this.context, particle, baseRgb, tipRgb, flickerIntensity)\n        }\n\n        drawSourceGlow(this.context, source, size, spread, flickerIntensity)\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 calculateSpreadPosition = (canvasSize: number, spreadRadius: number): { x: number; y: number } => {\n  const baseY = canvasSize * INITIAL_Y_POSITION\n  const horizontalSpread = (Math.random() - 0.5) * 2 * spreadRadius * canvasSize\n  const verticalVariation = Math.random() * canvasSize * VERTICAL_VARIATION\n\n  const padding = canvasSize * CANVAS_PADDING\n  const newX = Math.max(padding, Math.min(canvasSize - padding, canvasSize / 2 + horizontalSpread))\n  const newY = Math.max(padding, Math.min(canvasSize - padding, baseY + verticalVariation))\n\n  return { x: newX, y: newY }\n}\n\nconst initializeRenderer = (map: mapboxgl.Map, id: string, renderer: FireRenderer, control: FireControl): void => {\n  fireControls.set(id, control)\n\n  if (!map.hasImage(id)) {\n    map.addImage(id, renderer, { pixelRatio: PIXEL_RATIO })\n  }\n}\n\nconst cleanupRenderer = (map: mapboxgl.Map, id: string, animationFrameId: number | null): void => {\n  fireControls.delete(id)\n\n  if (animationFrameId !== null) {\n    cancelAnimationFrame(animationFrameId)\n  }\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\nconst addSourceAndLayer = (\n  map: mapboxgl.Map,\n  id: string,\n  sourceId: string,\n  layerId: string,\n  coordinates: MapCoordinates\n): void => {\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\nconst cleanupSourceAndLayer = (map: mapboxgl.Map, sourceId: string, layerId: string): void => {\n  try {\n    if (!map.isStyleLoaded()) {\n      return\n    }\n\n    if (map.getLayer(layerId)) {\n      map.removeLayer(layerId)\n    }\n\n    if (map.getSource(sourceId)) {\n      map.removeSource(sourceId)\n    }\n  } catch {\n    // Map may already be destroyed during unmount\n  }\n}\n\nexport const useFireControl = (id: string): FireControl | 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 fireControls.get(id) || null\n}\n\nexport const MapFire = ({\n  id,\n  coordinates,\n  size = DEFAULT_SIZE,\n  intensity = DEFAULT_INTENSITY,\n  particleCount = DEFAULT_PARTICLE_COUNT,\n  baseColor = DEFAULT_BASE_COLOR,\n  tipColor = DEFAULT_TIP_COLOR,\n  spread = false,\n  spreadSpeed = DEFAULT_SPREAD_SPEED,\n  spreadRadius = DEFAULT_SPREAD_RADIUS,\n  maxSpreadPoints = DEFAULT_MAX_SPREAD_POINTS,\n  autoStart = true,\n}: MapFireProps) => {\n  const { map, isLoaded } = useMap()\n  const animationFrameRef = useRef<number | null>(null)\n  const rendererRef = useRef<FireRenderer | null>(null)\n  const lastSpreadTimeRef = useRef<number>(0)\n  const spreadProgressRef = useRef<number>(0)\n\n  const sourceId = `${id}-source`\n  const layerId = `${id}-layer`\n  const canvasSize = spread ? size * 2 : size\n\n  const start = useCallback(() => {\n    if (rendererRef.current) {\n      rendererRef.current.start()\n      lastSpreadTimeRef.current = performance.now()\n      spreadProgressRef.current = 0\n    }\n  }, [])\n\n  const stop = useCallback(() => {\n    if (rendererRef.current) {\n      rendererRef.current.stop()\n      spreadProgressRef.current = 0\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 fireRenderer = createFireRenderer(\n      canvasSize,\n      intensity,\n      particleCount,\n      baseColor,\n      tipColor,\n      spread,\n      spreadRadius,\n      maxSpreadPoints\n    )\n    rendererRef.current = fireRenderer\n\n    const control: FireControl = {\n      start,\n      stop,\n      setIntensity,\n      get isActive() {\n        return rendererRef.current?.isActive || false\n      },\n      get spreadProgress() {\n        return spreadProgressRef.current\n      },\n    }\n\n    initializeRenderer(map, id, fireRenderer, control)\n\n    if (autoStart) {\n      fireRenderer.start()\n      lastSpreadTimeRef.current = performance.now()\n    }\n\n    const animate = () => {\n      if (spread && fireRenderer.isActive) {\n        const now = performance.now()\n        const elapsed = now - lastSpreadTimeRef.current\n\n        if (elapsed >= spreadSpeed && fireRenderer.sources.length < maxSpreadPoints) {\n          const position = calculateSpreadPosition(canvasSize, spreadRadius)\n          fireRenderer.addSource(position.x, position.y)\n          lastSpreadTimeRef.current = now\n          spreadProgressRef.current = fireRenderer.sources.length / maxSpreadPoints\n        }\n      }\n\n      map.triggerRepaint()\n      animationFrameRef.current = requestAnimationFrame(animate)\n    }\n    animationFrameRef.current = requestAnimationFrame(animate)\n\n    const handleStyleLoad = () => {\n      if (!map.hasImage(id)) {\n        map.addImage(id, fireRenderer, { pixelRatio: PIXEL_RATIO })\n      }\n    }\n\n    map.on(\"style.load\", handleStyleLoad)\n\n    return () => {\n      map.off(\"style.load\", handleStyleLoad)\n      cleanupRenderer(map, id, animationFrameRef.current)\n    }\n  }, [\n    map,\n    isLoaded,\n    id,\n    canvasSize,\n    intensity,\n    particleCount,\n    baseColor,\n    tipColor,\n    spread,\n    spreadSpeed,\n    spreadRadius,\n    maxSpreadPoints,\n    autoStart,\n    start,\n    stop,\n    setIntensity,\n  ])\n\n  useEffect(() => {\n    if (!isLoaded || !map) {\n      return\n    }\n\n    let addLayersFrameId: number\n\n    const addLayers = () => {\n      if (!map.isStyleLoaded() || !map.hasImage(id)) {\n        addLayersFrameId = requestAnimationFrame(addLayers)\n        return\n      }\n\n      addSourceAndLayer(map, id, sourceId, layerId, coordinates)\n    }\n\n    addLayers()\n\n    const handleStyleLoad = () => {\n      addLayers()\n    }\n\n    map.on(\"style.load\", handleStyleLoad)\n\n    return () => {\n      cancelAnimationFrame(addLayersFrameId)\n      map.off(\"style.load\", handleStyleLoad)\n      cleanupSourceAndLayer(map, sourceId, layerId)\n    }\n  }, [map, isLoaded, coordinates, id, sourceId, layerId])\n\n  return null\n}\n",
      "type": "registry:ui",
      "target": "components/ui/map/fire.tsx"
    }
  ],
  "type": "registry:ui"
}
