{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "meteor",
  "title": "Map Meteor",
  "description": "Animated meteor falling from sky with fiery trail and impact effect.",
  "dependencies": ["mapbox-gl"],
  "devDependencies": ["@types/mapbox-gl"],
  "registryDependencies": ["https://www.terrae.dev/map.json"],
  "files": [
    {
      "path": "src/registry/map/meteor.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport { useMap } from \"./hooks\"\nimport type { MapCoordinates } from \"./types\"\n\ntype TrailParticle = {\n  positionX: number\n  positionY: number\n  radius: number\n  opacity: number\n  velocityX: number\n  velocityY: number\n  life: number\n  maxLife: number\n}\n\ntype ImpactParticle = {\n  positionX: number\n  positionY: number\n  radius: number\n  opacity: number\n  velocityX: number\n  velocityY: number\n  color: string\n}\n\ntype MeteorInstance = {\n  startX: number\n  startY: number\n  currentX: number\n  currentY: number\n  progress: number\n  trailParticles: TrailParticle[]\n  delay: number\n  active: boolean\n}\n\ntype RgbColor = {\n  red: number\n  green: number\n  blue: number\n}\n\ntype MeteorPhase = \"falling\" | \"impact\" | \"fading\" | \"idle\"\n\ntype MeteorRenderer = {\n  width: number\n  height: number\n  data: Uint8ClampedArray\n  context?: CanvasRenderingContext2D\n  meteors: MeteorInstance[]\n  impactParticles: ImpactParticle[]\n  isActive: boolean\n  startTime: number\n  progress: number\n  phase: MeteorPhase\n  flashOpacity: number\n  restartTimerId: number\n  onAdd: () => void\n  render: () => boolean\n  start: () => void\n  stop: () => void\n  reset: () => void\n}\n\ntype MeteorControl = {\n  start: () => void\n  stop: () => void\n  reset: () => void\n  isActive: boolean\n  progress: number\n  phase: MeteorPhase\n}\n\ntype MapMeteorProps = {\n  id: string\n  target: MapCoordinates\n  size?: number\n  angle?: number\n  speed?: number\n  intensity?: number\n  meteorColor?: string\n  trailColor?: string\n  impactColor?: string\n  tailLength?: number\n  meteorSize?: number\n  impactSize?: number\n  autoStart?: boolean\n  loop?: boolean\n  loopDelay?: number\n  shower?: boolean\n  showerCount?: number\n}\n\nconst DEFAULT_SIZE = 300\nconst DEFAULT_ANGLE = 45\nconst DEFAULT_SPEED = 2000\nconst DEFAULT_INTENSITY = 1\nconst DEFAULT_METEOR_COLOR = \"#ffaa00\"\nconst DEFAULT_TRAIL_COLOR = \"#ff6600\"\nconst DEFAULT_IMPACT_COLOR = \"#ffdd00\"\nconst DEFAULT_TAIL_LENGTH = 0.4\nconst DEFAULT_METEOR_SIZE = 8\nconst DEFAULT_IMPACT_SIZE = 0.3\nconst DEFAULT_LOOP_DELAY = 2000\nconst DEFAULT_SHOWER_COUNT = 4\n\nconst DEGREES_TO_RADIANS = Math.PI / 180\nconst TRAIL_SPAWN_RATE = 3\nconst TRAIL_MIN_RADIUS = 2\nconst TRAIL_MAX_RADIUS = 6\nconst TRAIL_MIN_LIFE = 20\nconst TRAIL_MAX_LIFE = 40\nconst TRAIL_VELOCITY_SPREAD = 0.5\nconst TRAIL_POSITION_SPREAD = 4\nconst TRAIL_VELOCITY_DAMPING = 0.1\nconst TRAIL_BASE_OPACITY = 0.8\nconst TRAIL_OPACITY_VARIANCE = 0.2\nconst TRAIL_GRAVITY_MULTIPLIER = 0.3\nconst TRAIL_LIFE_DECAY_FACTOR = 0.5\nconst IMPACT_PARTICLE_COUNT = 30\nconst IMPACT_MIN_VELOCITY = 2\nconst IMPACT_MAX_VELOCITY = 8\nconst IMPACT_MIN_RADIUS = 2\nconst IMPACT_MAX_RADIUS = 6\nconst IMPACT_COLOR_OFFSET_RED = 50\nconst IMPACT_COLOR_OFFSET_GREEN = 30\nconst IMPACT_UPWARD_BIAS = 2\nconst GRAVITY = 0.15\nconst FRICTION = 0.98\nconst OPACITY_DECAY = 0.96\nconst MIN_VISIBLE_OPACITY = 0.02\nconst FLASH_DURATION = 200\nconst FLASH_OPACITY_MULTIPLIER = 0.7\nconst PIXEL_RATIO = 2\nconst CONTROL_UPDATE_INTERVAL = 100\nconst SHOWER_DELAY_SPREAD = 500\nconst SHOWER_ANGLE_SPREAD = 30\nconst SHOWER_DISTANCE_SPREAD = 0.2\nconst VELOCITY_SCALE = 10\nconst START_DISTANCE_RATIO = 0.6\nconst TAIL_WIDTH_RATIO = 0.5\nconst TAIL_END_WIDTH_RATIO = 0.2\nconst METEOR_TIP_RATIO = 0.5\nconst CORE_GRADIENT_INNER_STOP = 0.3\nconst EASE_POWER = 2\nconst TRAIL_SPAWN_PROBABILITY = 0.5\nconst FRAME_RATE = 60\nconst MS_PER_SECOND = 1000\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: 170, blue: 0 }\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 createTrailParticle = (originX: number, originY: number, velocityX: number, velocityY: number): TrailParticle => {\n  return {\n    positionX: originX + (Math.random() - 0.5) * TRAIL_POSITION_SPREAD,\n    positionY: originY + (Math.random() - 0.5) * TRAIL_POSITION_SPREAD,\n    radius: TRAIL_MIN_RADIUS + Math.random() * TRAIL_MAX_RADIUS,\n    opacity: TRAIL_BASE_OPACITY + Math.random() * TRAIL_OPACITY_VARIANCE,\n    velocityX: -velocityX * TRAIL_VELOCITY_DAMPING + (Math.random() - 0.5) * TRAIL_VELOCITY_SPREAD,\n    velocityY: -velocityY * TRAIL_VELOCITY_DAMPING + (Math.random() - 0.5) * TRAIL_VELOCITY_SPREAD,\n    life: 0,\n    maxLife: TRAIL_MIN_LIFE + Math.random() * TRAIL_MAX_LIFE,\n  }\n}\n\nconst createImpactParticle = (originX: number, originY: number, impactRgb: RgbColor): ImpactParticle => {\n  const spreadAngle = Math.random() * Math.PI * 2\n  const velocity = IMPACT_MIN_VELOCITY + Math.random() * IMPACT_MAX_VELOCITY\n  const useLighterColor = Math.random() > 0.5\n  const particleColor = useLighterColor\n    ? `${impactRgb.red}, ${impactRgb.green}, ${impactRgb.blue}`\n    : `${Math.min(255, impactRgb.red + IMPACT_COLOR_OFFSET_RED)}, ${Math.min(255, impactRgb.green + IMPACT_COLOR_OFFSET_GREEN)}, ${impactRgb.blue}`\n\n  return {\n    positionX: originX,\n    positionY: originY,\n    radius: IMPACT_MIN_RADIUS + Math.random() * IMPACT_MAX_RADIUS,\n    opacity: 1,\n    velocityX: Math.cos(spreadAngle) * velocity,\n    velocityY: Math.sin(spreadAngle) * velocity - Math.random() * IMPACT_UPWARD_BIAS,\n    color: particleColor,\n  }\n}\n\nconst renderMeteorBody = (\n  context: CanvasRenderingContext2D,\n  meteorX: number,\n  meteorY: number,\n  meteorSize: number,\n  velocityX: number,\n  velocityY: number,\n  tailLengthPx: number,\n  meteorRgb: RgbColor,\n  trailRgb: RgbColor\n): void => {\n  const velocityMagnitude = Math.sqrt(velocityX * velocityX + velocityY * velocityY)\n  const normalizedVelocityX = velocityX / velocityMagnitude\n  const normalizedVelocityY = velocityY / velocityMagnitude\n\n  const tailEndX = meteorX - normalizedVelocityX * tailLengthPx\n  const tailEndY = meteorY - normalizedVelocityY * tailLengthPx\n\n  const tailGradient = context.createLinearGradient(tailEndX, tailEndY, meteorX, meteorY)\n  tailGradient.addColorStop(0, `rgba(${trailRgb.red}, ${trailRgb.green}, ${trailRgb.blue}, 0)`)\n  tailGradient.addColorStop(0.3, `rgba(${trailRgb.red}, ${trailRgb.green}, ${trailRgb.blue}, 0.3)`)\n  tailGradient.addColorStop(0.7, `rgba(${meteorRgb.red}, ${meteorRgb.green}, ${meteorRgb.blue}, 0.7)`)\n  tailGradient.addColorStop(1, `rgba(${meteorRgb.red}, ${meteorRgb.green}, ${meteorRgb.blue}, 1)`)\n\n  context.beginPath()\n  context.moveTo(tailEndX, tailEndY)\n\n  const perpendicularX = -normalizedVelocityY\n  const perpendicularY = normalizedVelocityX\n  const tailWidth = meteorSize * TAIL_WIDTH_RATIO\n\n  context.lineTo(\n    tailEndX + perpendicularX * tailWidth * TAIL_END_WIDTH_RATIO,\n    tailEndY + perpendicularY * tailWidth * TAIL_END_WIDTH_RATIO\n  )\n  context.lineTo(meteorX + perpendicularX * tailWidth, meteorY + perpendicularY * tailWidth)\n  context.lineTo(\n    meteorX + normalizedVelocityX * meteorSize * METEOR_TIP_RATIO,\n    meteorY + normalizedVelocityY * meteorSize * METEOR_TIP_RATIO\n  )\n  context.lineTo(meteorX - perpendicularX * tailWidth, meteorY - perpendicularY * tailWidth)\n  context.lineTo(\n    tailEndX - perpendicularX * tailWidth * TAIL_END_WIDTH_RATIO,\n    tailEndY - perpendicularY * tailWidth * TAIL_END_WIDTH_RATIO\n  )\n  context.closePath()\n\n  context.fillStyle = tailGradient\n  context.fill()\n\n  const coreGradient = context.createRadialGradient(meteorX, meteorY, 0, meteorX, meteorY, meteorSize)\n  coreGradient.addColorStop(0, `rgba(255, 255, 255, 1)`)\n  coreGradient.addColorStop(\n    CORE_GRADIENT_INNER_STOP,\n    `rgba(${meteorRgb.red}, ${meteorRgb.green}, ${meteorRgb.blue}, 1)`\n  )\n  coreGradient.addColorStop(1, `rgba(${meteorRgb.red}, ${meteorRgb.green}, ${meteorRgb.blue}, 0)`)\n\n  context.beginPath()\n  context.arc(meteorX, meteorY, meteorSize, 0, Math.PI * 2)\n  context.fillStyle = coreGradient\n  context.fill()\n}\n\nconst updateAndRenderTrailParticles = (\n  context: CanvasRenderingContext2D,\n  particles: TrailParticle[],\n  trailRgb: RgbColor\n): void => {\n  for (let particleIndex = particles.length - 1; particleIndex >= 0; particleIndex--) {\n    const particle = particles[particleIndex]\n    particle.positionX += particle.velocityX\n    particle.positionY += particle.velocityY\n    particle.velocityY += GRAVITY * TRAIL_GRAVITY_MULTIPLIER\n    particle.velocityX *= FRICTION\n    particle.life++\n\n    if (particle.life >= particle.maxLife) {\n      particles.splice(particleIndex, 1)\n      continue\n    }\n\n    const lifeRatio = particle.life / particle.maxLife\n    const alpha = particle.opacity * (1 - lifeRatio)\n    const currentRadius = particle.radius * (1 - lifeRatio * TRAIL_LIFE_DECAY_FACTOR)\n\n    const gradient = context.createRadialGradient(\n      particle.positionX,\n      particle.positionY,\n      0,\n      particle.positionX,\n      particle.positionY,\n      currentRadius\n    )\n    gradient.addColorStop(0, `rgba(${trailRgb.red}, ${trailRgb.green}, ${trailRgb.blue}, ${alpha})`)\n    gradient.addColorStop(1, `rgba(${trailRgb.red}, ${trailRgb.green}, ${trailRgb.blue}, 0)`)\n\n    context.beginPath()\n    context.arc(particle.positionX, particle.positionY, currentRadius, 0, Math.PI * 2)\n    context.fillStyle = gradient\n    context.fill()\n  }\n}\n\nconst updateAndRenderImpactParticles = (context: CanvasRenderingContext2D, particles: ImpactParticle[]): void => {\n  for (let particleIndex = particles.length - 1; particleIndex >= 0; particleIndex--) {\n    const particle = particles[particleIndex]\n    particle.positionX += particle.velocityX\n    particle.positionY += particle.velocityY\n    particle.velocityY += GRAVITY\n    particle.velocityX *= FRICTION\n    particle.opacity *= OPACITY_DECAY\n\n    if (particle.opacity < MIN_VISIBLE_OPACITY) {\n      particles.splice(particleIndex, 1)\n      continue\n    }\n\n    const gradient = context.createRadialGradient(\n      particle.positionX,\n      particle.positionY,\n      0,\n      particle.positionX,\n      particle.positionY,\n      particle.radius\n    )\n    gradient.addColorStop(0, `rgba(${particle.color}, ${particle.opacity})`)\n    gradient.addColorStop(1, `rgba(${particle.color}, 0)`)\n\n    context.beginPath()\n    context.arc(particle.positionX, particle.positionY, particle.radius, 0, Math.PI * 2)\n    context.fillStyle = gradient\n    context.fill()\n  }\n}\n\nconst renderImpactFlash = (\n  context: CanvasRenderingContext2D,\n  centerX: number,\n  centerY: number,\n  flashOpacity: number,\n  impactSizePx: number,\n  impactRgb: RgbColor\n): void => {\n  if (flashOpacity <= 0) {\n    return\n  }\n\n  const flashGradient = context.createRadialGradient(centerX, centerY, 0, centerX, centerY, impactSizePx)\n  flashGradient.addColorStop(0, `rgba(255, 255, 255, ${flashOpacity})`)\n  flashGradient.addColorStop(\n    CORE_GRADIENT_INNER_STOP,\n    `rgba(${impactRgb.red}, ${impactRgb.green}, ${impactRgb.blue}, ${flashOpacity * FLASH_OPACITY_MULTIPLIER})`\n  )\n  flashGradient.addColorStop(1, `rgba(${impactRgb.red}, ${impactRgb.green}, ${impactRgb.blue}, 0)`)\n\n  context.beginPath()\n  context.arc(centerX, centerY, impactSizePx, 0, Math.PI * 2)\n  context.fillStyle = flashGradient\n  context.fill()\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 createMeteorRenderer = (\n  size: number,\n  angle: number,\n  speed: number,\n  intensity: number,\n  meteorColor: string,\n  trailColor: string,\n  impactColor: string,\n  tailLength: number,\n  meteorSize: number,\n  impactSize: number,\n  loop: boolean,\n  loopDelay: number,\n  shower: boolean,\n  showerCount: number\n): MeteorRenderer => {\n  const meteorRgb = hexToRgb(meteorColor)\n  const trailRgb = hexToRgb(trailColor)\n  const impactRgb = hexToRgb(impactColor)\n  const centerX = size / 2\n  const centerY = size / 2\n  const scaledMeteorSize = meteorSize * intensity\n  const scaledTailLength = tailLength * intensity\n  const scaledImpactSize = impactSize * intensity\n  const tailLengthPx = size * scaledTailLength\n  const impactSizePx = size * scaledImpactSize\n  const trailSpawnRate = Math.ceil(TRAIL_SPAWN_RATE * intensity)\n  const impactParticleCount = Math.ceil(IMPACT_PARTICLE_COUNT * intensity)\n  const radians = angle * DEGREES_TO_RADIANS\n  const startDistance = size * START_DISTANCE_RATIO\n  const directionVelocityX = Math.cos(radians) * VELOCITY_SCALE\n  const directionVelocityY = Math.sin(radians) * VELOCITY_SCALE\n\n  const createMeteorInstance = (delayMs: number, offsetAngle: number, offsetDistance: number): MeteorInstance => {\n    const instanceRadians = (angle + offsetAngle) * DEGREES_TO_RADIANS\n    const startX = centerX - Math.cos(instanceRadians) * (startDistance + offsetDistance)\n    const startY = centerY - Math.sin(instanceRadians) * (startDistance + offsetDistance)\n\n    return {\n      startX,\n      startY,\n      currentX: startX,\n      currentY: startY,\n      progress: 0,\n      trailParticles: [],\n      delay: delayMs,\n      active: false,\n    }\n  }\n\n  const spawnTrailParticles = (meteor: MeteorInstance): void => {\n    if (Math.random() < TRAIL_SPAWN_PROBABILITY) {\n      for (let trailIndex = 0; trailIndex < trailSpawnRate; trailIndex++) {\n        meteor.trailParticles.push(\n          createTrailParticle(meteor.currentX, meteor.currentY, directionVelocityX, directionVelocityY)\n        )\n      }\n    }\n  }\n\n  const spawnImpactParticles = (renderer: MeteorRenderer): void => {\n    renderer.flashOpacity = 1\n    for (let impactIndex = 0; impactIndex < impactParticleCount; impactIndex++) {\n      renderer.impactParticles.push(createImpactParticle(centerX, centerY, impactRgb))\n    }\n  }\n\n  const updateMeteorInstances = (\n    renderer: MeteorRenderer,\n    elapsed: number\n  ): { allComplete: boolean; anyFalling: boolean } => {\n    let allComplete = true\n    let anyFalling = false\n\n    for (const meteor of renderer.meteors) {\n      if (elapsed < meteor.delay) {\n        allComplete = false\n        continue\n      }\n\n      const meteorElapsed = elapsed - meteor.delay\n\n      if (!meteor.active && meteorElapsed >= 0) {\n        meteor.active = true\n      }\n\n      if (!meteor.active || meteor.progress >= 1) {\n        continue\n      }\n\n      meteor.progress = Math.min(meteorElapsed / speed, 1)\n      allComplete = false\n      anyFalling = true\n\n      const easeProgress = 1 - Math.pow(1 - meteor.progress, EASE_POWER)\n      meteor.currentX = meteor.startX + (centerX - meteor.startX) * easeProgress\n      meteor.currentY = meteor.startY + (centerY - meteor.startY) * easeProgress\n\n      spawnTrailParticles(meteor)\n\n      if (meteor.progress >= 1) {\n        spawnImpactParticles(renderer)\n      }\n    }\n\n    return { allComplete, anyFalling }\n  }\n\n  const updateRendererState = (renderer: MeteorRenderer, allComplete: boolean, anyFalling: boolean): void => {\n    if (anyFalling) {\n      renderer.phase = \"falling\"\n    } else if (renderer.impactParticles.length > 0 || renderer.flashOpacity > 0) {\n      renderer.phase = \"impact\"\n    }\n\n    if (renderer.flashOpacity > 0) {\n      renderer.flashOpacity -= MS_PER_SECOND / FRAME_RATE / FLASH_DURATION\n      if (renderer.flashOpacity < 0) {\n        renderer.flashOpacity = 0\n      }\n    }\n\n    const hasActiveParticles =\n      renderer.impactParticles.length > 0 || renderer.meteors.some((meteor) => meteor.trailParticles.length > 0)\n\n    if (allComplete && !hasActiveParticles && renderer.flashOpacity <= 0) {\n      if (loop) {\n        renderer.phase = \"idle\"\n        renderer.restartTimerId = window.setTimeout(() => {\n          if (renderer.isActive) {\n            renderer.start()\n          }\n        }, loopDelay)\n      } else {\n        renderer.phase = \"idle\"\n        renderer.isActive = false\n      }\n    }\n  }\n\n  const renderMeteorScene = (context: CanvasRenderingContext2D, renderer: MeteorRenderer): void => {\n    for (const meteor of renderer.meteors) {\n      updateAndRenderTrailParticles(context, meteor.trailParticles, trailRgb)\n\n      if (meteor.active && meteor.progress < 1) {\n        renderMeteorBody(\n          context,\n          meteor.currentX,\n          meteor.currentY,\n          scaledMeteorSize,\n          directionVelocityX,\n          directionVelocityY,\n          tailLengthPx,\n          meteorRgb,\n          trailRgb\n        )\n      }\n    }\n\n    renderImpactFlash(context, centerX, centerY, renderer.flashOpacity, impactSizePx, impactRgb)\n    updateAndRenderImpactParticles(context, renderer.impactParticles)\n  }\n\n  const renderer: MeteorRenderer = {\n    width: size,\n    height: size,\n    data: new Uint8ClampedArray(size * size * 4),\n    meteors: [],\n    impactParticles: [],\n    isActive: false,\n    startTime: 0,\n    progress: 0,\n    phase: \"idle\",\n    flashOpacity: 0,\n    restartTimerId: 0,\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 && this.phase !== \"idle\") {\n        return\n      }\n\n      this.isActive = true\n      this.startTime = performance.now()\n      this.progress = 0\n      this.phase = \"falling\"\n      this.flashOpacity = 0\n      this.meteors = []\n      this.impactParticles = []\n\n      if (shower) {\n        for (let meteorIndex = 0; meteorIndex < showerCount; meteorIndex++) {\n          const delay = meteorIndex * SHOWER_DELAY_SPREAD + Math.random() * SHOWER_DELAY_SPREAD\n          const offsetAngle = (Math.random() - 0.5) * SHOWER_ANGLE_SPREAD\n          const offsetDistance = Math.random() * size * SHOWER_DISTANCE_SPREAD\n          this.meteors.push(createMeteorInstance(delay, offsetAngle, offsetDistance))\n        }\n      } else {\n        this.meteors.push(createMeteorInstance(0, 0, 0))\n      }\n    },\n\n    stop() {\n      this.isActive = false\n      this.phase = \"idle\"\n      clearTimeout(this.restartTimerId)\n    },\n\n    reset() {\n      this.isActive = false\n      this.progress = 0\n      this.phase = \"idle\"\n      this.flashOpacity = 0\n      this.meteors = []\n      this.impactParticles = []\n      this.startTime = 0\n      clearTimeout(this.restartTimerId)\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.phase === \"idle\") {\n        this.data = new Uint8ClampedArray(this.width * this.height * 4)\n        return true\n      }\n\n      const elapsed = performance.now() - this.startTime\n      const { allComplete, anyFalling } = updateMeteorInstances(this, elapsed)\n      updateRendererState(this, allComplete, anyFalling)\n      renderMeteorScene(this.context, this)\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 meteorControls = new Map<string, MeteorControl>()\n\nexport const MapMeteor = ({\n  id,\n  target,\n  size = DEFAULT_SIZE,\n  angle = DEFAULT_ANGLE,\n  speed = DEFAULT_SPEED,\n  intensity = DEFAULT_INTENSITY,\n  meteorColor = DEFAULT_METEOR_COLOR,\n  trailColor = DEFAULT_TRAIL_COLOR,\n  impactColor = DEFAULT_IMPACT_COLOR,\n  tailLength = DEFAULT_TAIL_LENGTH,\n  meteorSize = DEFAULT_METEOR_SIZE,\n  impactSize = DEFAULT_IMPACT_SIZE,\n  autoStart = true,\n  loop = false,\n  loopDelay = DEFAULT_LOOP_DELAY,\n  shower = false,\n  showerCount = DEFAULT_SHOWER_COUNT,\n}: MapMeteorProps) => {\n  const { map, isLoaded } = useMap()\n  const animationFrameRef = useRef<number | null>(null)\n  const rendererRef = useRef<MeteorRenderer | null>(null)\n\n  useEffect(() => {\n    if (!isLoaded || !map) {\n      return\n    }\n\n    const meteorRenderer = createMeteorRenderer(\n      size,\n      angle,\n      speed,\n      intensity,\n      meteorColor,\n      trailColor,\n      impactColor,\n      tailLength,\n      meteorSize,\n      impactSize,\n      loop,\n      loopDelay,\n      shower,\n      showerCount\n    )\n    rendererRef.current = meteorRenderer\n\n    const control: MeteorControl = {\n      start: () => {\n        rendererRef.current?.start()\n      },\n      stop: () => {\n        rendererRef.current?.stop()\n      },\n      reset: () => {\n        rendererRef.current?.reset()\n      },\n      get isActive() {\n        return rendererRef.current?.isActive || false\n      },\n      get progress() {\n        return rendererRef.current?.progress || 0\n      },\n      get phase() {\n        return rendererRef.current?.phase || \"idle\"\n      },\n    }\n    meteorControls.set(id, control)\n\n    if (!map.hasImage(id)) {\n      map.addImage(id, meteorRenderer, { pixelRatio: PIXEL_RATIO })\n    }\n\n    if (autoStart) {\n      meteorRenderer.start()\n    }\n\n    const animate = () => {\n      const renderer = rendererRef.current\n      const shouldRepaint = renderer?.isActive || (renderer?.impactParticles?.length ?? 0) > 0\n      if (shouldRepaint) {\n        map.triggerRepaint()\n      }\n      animationFrameRef.current = requestAnimationFrame(animate)\n    }\n    animationFrameRef.current = requestAnimationFrame(animate)\n\n    const handleStyleLoad = () => {\n      if (!map.hasImage(id)) {\n        map.addImage(id, meteorRenderer, { pixelRatio: PIXEL_RATIO })\n      }\n    }\n\n    map.on(\"style.load\", handleStyleLoad)\n\n    return () => {\n      map.off(\"style.load\", handleStyleLoad)\n      meteorControls.delete(id)\n      if (animationFrameRef.current) {\n        cancelAnimationFrame(animationFrameRef.current)\n      }\n      if (rendererRef.current) {\n        clearTimeout(rendererRef.current.restartTimerId)\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  }, [\n    map,\n    isLoaded,\n    id,\n    size,\n    angle,\n    speed,\n    intensity,\n    meteorColor,\n    trailColor,\n    impactColor,\n    tailLength,\n    meteorSize,\n    impactSize,\n    autoStart,\n    loop,\n    loopDelay,\n    shower,\n    showerCount,\n  ])\n\n  useEffect(() => {\n    if (!isLoaded || !map) {\n      return\n    }\n\n    const sourceId = `${id}-source`\n    const layerId = `${id}-layer`\n    let pollFrameId: number\n\n    const addSourceAndLayer = () => {\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: target },\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    const cleanupSourceAndLayer = () => {\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\n    const pollAndAddLayers = () => {\n      if (!map.isStyleLoaded() || !map.hasImage(id)) {\n        pollFrameId = requestAnimationFrame(pollAndAddLayers)\n        return\n      }\n\n      addSourceAndLayer()\n    }\n\n    pollFrameId = requestAnimationFrame(pollAndAddLayers)\n    map.on(\"style.load\", pollAndAddLayers)\n\n    return () => {\n      cancelAnimationFrame(pollFrameId)\n      map.off(\"style.load\", pollAndAddLayers)\n      cleanupSourceAndLayer()\n    }\n  }, [map, isLoaded, target, id])\n\n  return null\n}\n\nexport const useMeteorControl = (id: string): MeteorControl | 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 meteorControls.get(id) || null\n}\n",
      "type": "registry:ui",
      "target": "components/ui/map/meteor.tsx"
    }
  ],
  "type": "registry:ui"
}
