{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cyclone",
  "title": "Map Cyclone",
  "description": "Animated cyclone funnel with swirling particles and debris.",
  "dependencies": ["mapbox-gl"],
  "devDependencies": ["@types/mapbox-gl"],
  "registryDependencies": ["https://www.terrae.dev/map.json"],
  "files": [
    {
      "path": "src/registry/map/cyclone.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport { useMap } from \"./hooks\"\nimport type { MapCoordinates, MapPath } from \"./types\"\n\ntype RgbColor = {\n  r: number\n  g: number\n  b: number\n}\n\ntype CycloneParticle = {\n  angle: number\n  radius: number\n  height: number\n  angularVelocity: number\n  verticalVelocity: number\n  size: number\n  opacity: number\n  type: \"funnel\" | \"debris\" | \"dust\"\n}\n\ntype MapCycloneProps = {\n  id: string\n  coordinates: MapCoordinates\n  path?: MapPath\n  duration?: number\n  loop?: boolean\n  size?: number\n  intensity?: number\n  scale?: number\n  particleCount?: number\n  funnelColor?: string\n  debrisColor?: string\n  rotationSpeed?: number\n  autoStart?: boolean\n}\n\ntype CycloneRenderer = {\n  width: number\n  height: number\n  data: Uint8ClampedArray\n  context?: CanvasRenderingContext2D\n  particles: CycloneParticle[]\n  isActive: boolean\n  startTime: number\n  currentIntensity: number\n  targetIntensity: number\n  currentScale: number\n  targetScale: number\n  rotationSpeed: number\n  onAdd: () => void\n  render: () => boolean\n  start: () => void\n  stop: () => void\n  setIntensity: (intensity: number) => void\n  setScale: (scale: number, instant?: boolean) => void\n  setRotationSpeed: (speed: number) => void\n}\n\ntype CycloneControl = {\n  start: () => void\n  stop: () => void\n  setIntensity: (intensity: number) => void\n  setScale: (scale: number, instant?: boolean) => void\n  setRotationSpeed: (speed: number) => void\n  isActive: boolean\n  isMoving: boolean\n  progress: number\n  scale: number\n  rotationSpeed: number\n}\n\nconst DEFAULT_SIZE = 200\nconst DEFAULT_INTENSITY = 1\nconst DEFAULT_SCALE = 1\nconst DEFAULT_PARTICLE_COUNT = 120\nconst DEFAULT_FUNNEL_COLOR = \"#8b9dc3\"\nconst DEFAULT_DEBRIS_COLOR = \"#5c4033\"\nconst DEFAULT_ROTATION_SPEED = 1\nconst DEFAULT_DURATION = 10000\n\nconst FUNNEL_BASE_RADIUS_RATIO = 0.05\nconst FUNNEL_TOP_RADIUS_RATIO = 0.25\nconst FUNNEL_RADIUS_VARIATION_MIN = 0.8\nconst FUNNEL_RADIUS_VARIATION_RANGE = 0.4\nconst FUNNEL_ANGULAR_VELOCITY_BASE = 0.08\nconst FUNNEL_ANGULAR_VELOCITY_RANGE = 0.04\nconst FUNNEL_VERTICAL_VELOCITY_BASE = 0.003\nconst FUNNEL_VERTICAL_VELOCITY_RANGE = 0.002\nconst FUNNEL_SIZE_BASE = 2\nconst FUNNEL_SIZE_RANGE = 3\nconst FUNNEL_OPACITY_BASE = 0.4\nconst FUNNEL_OPACITY_RANGE = 0.4\n\nconst DEBRIS_HEIGHT_MAX = 0.7\nconst DEBRIS_BASE_RADIUS_RATIO = 0.08\nconst DEBRIS_TOP_RADIUS_RATIO = 0.2\nconst DEBRIS_RADIUS_VARIATION_MIN = 1.2\nconst DEBRIS_RADIUS_VARIATION_RANGE = 0.8\nconst DEBRIS_ANGULAR_VELOCITY_BASE = 0.06\nconst DEBRIS_ANGULAR_VELOCITY_RANGE = 0.05\nconst DEBRIS_VERTICAL_VELOCITY_BASE = 0.004\nconst DEBRIS_VERTICAL_VELOCITY_RANGE = 0.003\nconst DEBRIS_SIZE_BASE = 3\nconst DEBRIS_SIZE_RANGE = 5\nconst DEBRIS_OPACITY_BASE = 0.6\nconst DEBRIS_OPACITY_RANGE = 0.4\nconst DEBRIS_RADIUS_MULTIPLIER = 1.3\n\nconst DUST_RADIUS_BASE_RATIO = 0.1\nconst DUST_RADIUS_RANGE_RATIO = 0.15\nconst DUST_HEIGHT_MAX = 0.15\nconst DUST_ANGULAR_VELOCITY_BASE = 0.04\nconst DUST_ANGULAR_VELOCITY_RANGE = 0.03\nconst DUST_VERTICAL_VELOCITY_BASE = 0.001\nconst DUST_VERTICAL_VELOCITY_RANGE = 0.002\nconst DUST_SIZE_BASE = 4\nconst DUST_SIZE_RANGE = 8\nconst DUST_OPACITY_BASE = 0.3\nconst DUST_OPACITY_RANGE = 0.3\nconst DUST_RADIUS_BASE = 0.12\nconst DUST_RADIUS_HEIGHT_FACTOR = 0.05\n\nconst INTENSITY_INCREASE_RATE = 0.02\nconst INTENSITY_DECREASE_RATE = 0.015\nconst SCALE_TRANSITION_RATE = 0.01\nconst MIN_SCALE = 0.2\nconst MAX_SCALE = 2\nconst MAX_INTENSITY = 2\nconst MIN_ROTATION_SPEED = 0\nconst MAX_ROTATION_SPEED = 4\n\nconst PARTICLE_HEIGHT_MULTIPLIER = 0.75\nconst BASE_Y_RATIO = 0.85\nconst WOBBLE_FACTOR = 0.1\nconst WOBBLE_FREQUENCY = 3\n\nconst FUNNEL_PARTICLE_RATIO = 0.5\nconst DEBRIS_PARTICLE_RATIO = 0.3\n\nconst DUST_CLOUD_RADIUS_RATIO = 0.2\nconst DUST_CLOUD_HEIGHT_RATIO = 0.08\n\nconst cycloneControls = new Map<string, CycloneControl>()\n\nexport const MapCyclone = ({\n  id,\n  coordinates,\n  path,\n  duration = DEFAULT_DURATION,\n  loop = false,\n  size = DEFAULT_SIZE,\n  intensity = DEFAULT_INTENSITY,\n  scale = DEFAULT_SCALE,\n  particleCount = DEFAULT_PARTICLE_COUNT,\n  funnelColor = DEFAULT_FUNNEL_COLOR,\n  debrisColor = DEFAULT_DEBRIS_COLOR,\n  rotationSpeed = DEFAULT_ROTATION_SPEED,\n  autoStart = true,\n}: MapCycloneProps) => {\n  const { map, isLoaded } = useMap()\n  const animationFrameRef = useRef<number | null>(null)\n  const retryFrameRef = useRef<number | null>(null)\n  const rendererRef = useRef<CycloneRenderer | null>(null)\n  const movementStartTimeRef = useRef<number>(0)\n  const isMovingRef = useRef<boolean>(false)\n  const progressRef = useRef<number>(0)\n  const pathRef = useRef(path)\n  const durationRef = useRef(duration)\n  const loopRef = useRef(loop)\n  const sourceId = `${id}-source`\n  const layerId = `${id}-layer`\n\n  pathRef.current = path\n  durationRef.current = duration\n  loopRef.current = loop\n\n  const registerControl = () => {\n    const control: CycloneControl = {\n      start: () => {\n        if (rendererRef.current) {\n          rendererRef.current.start()\n        }\n        if (pathRef.current && pathRef.current.length >= 2) {\n          movementStartTimeRef.current = performance.now()\n          isMovingRef.current = true\n          progressRef.current = 0\n        }\n      },\n      stop: () => {\n        if (rendererRef.current) {\n          rendererRef.current.stop()\n        }\n        isMovingRef.current = false\n      },\n      setIntensity: (newIntensity: number) => {\n        if (rendererRef.current) {\n          rendererRef.current.setIntensity(newIntensity)\n        }\n      },\n      setScale: (newScale: number, instant?: boolean) => {\n        if (rendererRef.current) {\n          rendererRef.current.setScale(newScale, instant)\n        }\n      },\n      setRotationSpeed: (speed: number) => {\n        if (rendererRef.current) {\n          rendererRef.current.setRotationSpeed(speed)\n        }\n      },\n      get isActive() {\n        return rendererRef.current?.isActive || false\n      },\n      get isMoving() {\n        return isMovingRef.current\n      },\n      get progress() {\n        return progressRef.current\n      },\n      get scale() {\n        return rendererRef.current?.currentScale || 1\n      },\n      get rotationSpeed() {\n        return rendererRef.current?.rotationSpeed || DEFAULT_ROTATION_SPEED\n      },\n    }\n    cycloneControls.set(id, control)\n  }\n\n  const updateMovementPosition = () => {\n    const currentPath = pathRef.current\n    if (!currentPath || currentPath.length < 2 || !isMovingRef.current || !map) {\n      return\n    }\n\n    const elapsed = performance.now() - movementStartTimeRef.current\n    let progress = Math.min(elapsed / durationRef.current, 1)\n\n    if (progress >= 1) {\n      if (loopRef.current) {\n        movementStartTimeRef.current = performance.now()\n        progress = 0\n      } else {\n        isMovingRef.current = false\n        progress = 1\n      }\n    }\n\n    progressRef.current = progress\n    const newPosition = getPositionOnPath(currentPath, progress)\n\n    const source = map.getSource(sourceId)\n    if (source && \"setData\" in source) {\n      ;(source as mapboxgl.GeoJSONSource).setData({\n        type: \"FeatureCollection\",\n        features: [\n          {\n            type: \"Feature\",\n            geometry: { type: \"Point\", coordinates: newPosition },\n            properties: {},\n          },\n        ],\n      })\n    }\n  }\n\n  const addCycloneLayers = (initialCoordinates: MapCoordinates) => {\n    if (!map) {\n      return\n    }\n\n    if (!map.isStyleLoaded() || !map.hasImage(id)) {\n      retryFrameRef.current = requestAnimationFrame(() => {\n        return addCycloneLayers(initialCoordinates)\n      })\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: initialCoordinates },\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  useEffect(() => {\n    if (!isLoaded || !map) {\n      return\n    }\n\n    const cycloneRenderer = createCycloneRenderer(\n      size,\n      intensity,\n      scale,\n      particleCount,\n      funnelColor,\n      debrisColor,\n      rotationSpeed\n    )\n    rendererRef.current = cycloneRenderer\n\n    if (!map.hasImage(id)) {\n      map.addImage(id, cycloneRenderer, { pixelRatio: 2 })\n    }\n\n    registerControl()\n\n    if (autoStart) {\n      cycloneRenderer.start()\n      if (pathRef.current && pathRef.current.length >= 2) {\n        movementStartTimeRef.current = performance.now()\n        isMovingRef.current = true\n      }\n    }\n\n    const animate = () => {\n      const renderer = rendererRef.current\n      const isActive = renderer?.isActive || false\n      const isTransitioning = renderer ? renderer.currentIntensity !== renderer.targetIntensity : false\n\n      if (isActive || isTransitioning) {\n        updateMovementPosition()\n        map.triggerRepaint()\n      }\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, cycloneRenderer, { pixelRatio: 2 })\n      }\n    }\n\n    map.on(\"style.load\", handleStyleLoad)\n\n    return () => {\n      map.off(\"style.load\", handleStyleLoad)\n      cycloneControls.delete(id)\n      if (animationFrameRef.current) {\n        cancelAnimationFrame(animationFrameRef.current)\n      }\n      if (!map || !map.getStyle()) {\n        return\n      }\n      if (map.hasImage(id)) {\n        map.removeImage(id)\n      }\n    }\n  }, [map, isLoaded, id, size, intensity, scale, particleCount, funnelColor, debrisColor, autoStart])\n\n  useEffect(() => {\n    rendererRef.current?.setRotationSpeed(rotationSpeed)\n  }, [rotationSpeed])\n\n  useEffect(() => {\n    if (!isLoaded || !map) {\n      return\n    }\n\n    const initialCoordinates = path && path.length >= 2 ? path[0] : coordinates\n    addCycloneLayers(initialCoordinates)\n\n    const handleStyleLoad = () => {\n      addCycloneLayers(initialCoordinates)\n    }\n\n    map.on(\"style.load\", handleStyleLoad)\n\n    return () => {\n      map.off(\"style.load\", handleStyleLoad)\n      if (retryFrameRef.current) {\n        cancelAnimationFrame(retryFrameRef.current)\n      }\n      if (!map || !map.getStyle()) {\n        return\n      }\n      if (map.getLayer(layerId)) {\n        map.removeLayer(layerId)\n      }\n      if (map.getSource(sourceId)) {\n        map.removeSource(sourceId)\n      }\n    }\n  }, [map, isLoaded, coordinates, path, id, sourceId, layerId])\n\n  return null\n}\n\nexport const useCycloneControl = (id: string): CycloneControl | null => {\n  const [, forceUpdate] = useState(0)\n\n  useEffect(() => {\n    const interval = setInterval(() => {\n      forceUpdate((previous) => {\n        return previous + 1\n      })\n    }, 100)\n\n    return () => {\n      clearInterval(interval)\n    }\n  }, [])\n\n  return cycloneControls.get(id) || null\n}\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: 139, g: 157, b: 195 }\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 createFunnelParticle = (size: number): CycloneParticle => {\n  const height = Math.random()\n  const baseRadius = size * FUNNEL_BASE_RADIUS_RATIO\n  const topRadius = size * FUNNEL_TOP_RADIUS_RATIO\n  const radius = baseRadius + (topRadius - baseRadius) * height\n\n  return {\n    angle: Math.random() * Math.PI * 2,\n    radius: radius * (FUNNEL_RADIUS_VARIATION_MIN + Math.random() * FUNNEL_RADIUS_VARIATION_RANGE),\n    height,\n    angularVelocity: FUNNEL_ANGULAR_VELOCITY_BASE + Math.random() * FUNNEL_ANGULAR_VELOCITY_RANGE,\n    verticalVelocity: FUNNEL_VERTICAL_VELOCITY_BASE + Math.random() * FUNNEL_VERTICAL_VELOCITY_RANGE,\n    size: FUNNEL_SIZE_BASE + Math.random() * FUNNEL_SIZE_RANGE,\n    opacity: FUNNEL_OPACITY_BASE + Math.random() * FUNNEL_OPACITY_RANGE,\n    type: \"funnel\",\n  }\n}\n\nconst createDebrisParticle = (size: number): CycloneParticle => {\n  const height = Math.random() * DEBRIS_HEIGHT_MAX\n  const baseRadius = size * DEBRIS_BASE_RADIUS_RATIO\n  const topRadius = size * DEBRIS_TOP_RADIUS_RATIO\n  const radius = baseRadius + (topRadius - baseRadius) * height\n\n  return {\n    angle: Math.random() * Math.PI * 2,\n    radius: radius * (DEBRIS_RADIUS_VARIATION_MIN + Math.random() * DEBRIS_RADIUS_VARIATION_RANGE),\n    height,\n    angularVelocity: DEBRIS_ANGULAR_VELOCITY_BASE + Math.random() * DEBRIS_ANGULAR_VELOCITY_RANGE,\n    verticalVelocity: DEBRIS_VERTICAL_VELOCITY_BASE + Math.random() * DEBRIS_VERTICAL_VELOCITY_RANGE,\n    size: DEBRIS_SIZE_BASE + Math.random() * DEBRIS_SIZE_RANGE,\n    opacity: DEBRIS_OPACITY_BASE + Math.random() * DEBRIS_OPACITY_RANGE,\n    type: \"debris\",\n  }\n}\n\nconst createDustParticle = (size: number): CycloneParticle => {\n  return {\n    angle: Math.random() * Math.PI * 2,\n    radius: size * DUST_RADIUS_BASE_RATIO + Math.random() * size * DUST_RADIUS_RANGE_RATIO,\n    height: Math.random() * DUST_HEIGHT_MAX,\n    angularVelocity: DUST_ANGULAR_VELOCITY_BASE + Math.random() * DUST_ANGULAR_VELOCITY_RANGE,\n    verticalVelocity: DUST_VERTICAL_VELOCITY_BASE + Math.random() * DUST_VERTICAL_VELOCITY_RANGE,\n    size: DUST_SIZE_BASE + Math.random() * DUST_SIZE_RANGE,\n    opacity: DUST_OPACITY_BASE + Math.random() * DUST_OPACITY_RANGE,\n    type: \"dust\",\n  }\n}\n\nconst calculatePathLength = (path: MapPath): number => {\n  let length = 0\n  for (let index = 1; index < path.length; index++) {\n    const deltaLng = path[index][0] - path[index - 1][0]\n    const deltaLat = path[index][1] - path[index - 1][1]\n    length += Math.sqrt(deltaLng * deltaLng + deltaLat * deltaLat)\n  }\n  return length\n}\n\nconst getPositionOnPath = (path: MapPath, progress: number): MapCoordinates => {\n  if (path.length < 2) {\n    return path[0]\n  }\n\n  const totalLength = calculatePathLength(path)\n  const targetDistance = totalLength * progress\n\n  let accumulatedDistance = 0\n  for (let index = 1; index < path.length; index++) {\n    const deltaLng = path[index][0] - path[index - 1][0]\n    const deltaLat = path[index][1] - path[index - 1][1]\n    const segmentLength = Math.sqrt(deltaLng * deltaLng + deltaLat * deltaLat)\n\n    if (accumulatedDistance + segmentLength >= targetDistance) {\n      const segmentProgress = (targetDistance - accumulatedDistance) / segmentLength\n      return [path[index - 1][0] + deltaLng * segmentProgress, path[index - 1][1] + deltaLat * segmentProgress]\n    }\n\n    accumulatedDistance += segmentLength\n  }\n\n  return path[path.length - 1]\n}\n\nconst drawFunnelShape = (\n  context: CanvasRenderingContext2D,\n  centerX: number,\n  baseY: number,\n  size: number,\n  funnelRgb: RgbColor,\n  effectiveIntensity: number\n) => {\n  const funnelGradient = context.createLinearGradient(centerX, baseY, centerX, size * 0.1)\n  funnelGradient.addColorStop(0, `rgba(${funnelRgb.r}, ${funnelRgb.g}, ${funnelRgb.b}, ${0.15 * effectiveIntensity})`)\n  funnelGradient.addColorStop(0.5, `rgba(${funnelRgb.r}, ${funnelRgb.g}, ${funnelRgb.b}, ${0.25 * effectiveIntensity})`)\n  funnelGradient.addColorStop(1, `rgba(${funnelRgb.r}, ${funnelRgb.g}, ${funnelRgb.b}, ${0.1 * effectiveIntensity})`)\n\n  context.beginPath()\n  context.moveTo(centerX - size * 0.03, baseY)\n  context.quadraticCurveTo(centerX - size * 0.15, size * 0.5, centerX - size * 0.2, size * 0.15)\n  context.lineTo(centerX + size * 0.2, size * 0.15)\n  context.quadraticCurveTo(centerX + size * 0.15, size * 0.5, centerX + size * 0.03, baseY)\n  context.closePath()\n  context.fillStyle = funnelGradient\n  context.fill()\n}\n\nconst updateParticle = (particle: CycloneParticle, size: number, rotationSpeed: number, effectiveIntensity: number) => {\n  particle.angle += particle.angularVelocity * rotationSpeed * effectiveIntensity\n  particle.height += particle.verticalVelocity * effectiveIntensity\n\n  if (particle.height > 1) {\n    particle.height = 0\n    if (particle.type === \"funnel\") {\n      const newParticle = createFunnelParticle(size)\n      particle.radius = newParticle.radius\n      particle.size = newParticle.size\n    } else if (particle.type === \"debris\") {\n      const newParticle = createDebrisParticle(size)\n      particle.radius = newParticle.radius\n      particle.size = newParticle.size\n    }\n  }\n\n  if (particle.type === \"dust\" && particle.height > DUST_HEIGHT_MAX) {\n    particle.height = 0\n    particle.angle = Math.random() * Math.PI * 2\n  }\n}\n\nconst calculateParticleRadius = (particle: CycloneParticle, size: number): number => {\n  const baseRadius = size * FUNNEL_BASE_RADIUS_RATIO\n  const topRadius = size * FUNNEL_TOP_RADIUS_RATIO\n  const currentRadius = baseRadius + (topRadius - baseRadius) * particle.height\n\n  if (particle.type === \"debris\") {\n    return currentRadius * DEBRIS_RADIUS_MULTIPLIER\n  }\n\n  if (particle.type === \"dust\") {\n    return size * DUST_RADIUS_BASE + particle.height * size * DUST_RADIUS_HEIGHT_FACTOR\n  }\n\n  return currentRadius\n}\n\nconst drawParticle = (\n  context: CanvasRenderingContext2D,\n  particle: CycloneParticle,\n  centerX: number,\n  baseY: number,\n  size: number,\n  funnelRgb: RgbColor,\n  debrisRgb: RgbColor,\n  effectiveIntensity: number\n) => {\n  const particleRadius = calculateParticleRadius(particle, size)\n  const wobble = Math.sin(particle.angle * WOBBLE_FREQUENCY) * WOBBLE_FACTOR * effectiveIntensity\n  const finalRadius = particleRadius * (1 + wobble)\n\n  const particleX = centerX + Math.cos(particle.angle) * finalRadius * effectiveIntensity\n  const particleY = baseY - particle.height * size * PARTICLE_HEIGHT_MULTIPLIER\n  const alpha = particle.opacity * effectiveIntensity\n  const color = particle.type === \"funnel\" ? funnelRgb : debrisRgb\n\n  const gradient = context.createRadialGradient(\n    particleX,\n    particleY,\n    0,\n    particleX,\n    particleY,\n    particle.size * effectiveIntensity\n  )\n\n  if (particle.type === \"dust\") {\n    gradient.addColorStop(0, `rgba(${debrisRgb.r}, ${debrisRgb.g}, ${debrisRgb.b}, ${alpha * 0.5})`)\n    gradient.addColorStop(1, `rgba(${debrisRgb.r}, ${debrisRgb.g}, ${debrisRgb.b}, 0)`)\n  } else {\n    gradient.addColorStop(0, `rgba(${color.r}, ${color.g}, ${color.b}, ${alpha})`)\n    gradient.addColorStop(0.6, `rgba(${color.r}, ${color.g}, ${color.b}, ${alpha * 0.5})`)\n    gradient.addColorStop(1, `rgba(${color.r}, ${color.g}, ${color.b}, 0)`)\n  }\n\n  context.beginPath()\n  context.arc(particleX, particleY, particle.size * effectiveIntensity, 0, Math.PI * 2)\n  context.fillStyle = gradient\n  context.fill()\n}\n\nconst drawDustCloud = (\n  context: CanvasRenderingContext2D,\n  centerX: number,\n  baseY: number,\n  size: number,\n  debrisRgb: RgbColor,\n  effectiveIntensity: number\n) => {\n  const dustGradient = context.createRadialGradient(\n    centerX,\n    baseY,\n    0,\n    centerX,\n    baseY,\n    size * DUST_CLOUD_RADIUS_RATIO * effectiveIntensity\n  )\n  dustGradient.addColorStop(0, `rgba(${debrisRgb.r}, ${debrisRgb.g}, ${debrisRgb.b}, ${0.4 * effectiveIntensity})`)\n  dustGradient.addColorStop(0.5, `rgba(${debrisRgb.r}, ${debrisRgb.g}, ${debrisRgb.b}, ${0.2 * effectiveIntensity})`)\n  dustGradient.addColorStop(1, `rgba(${debrisRgb.r}, ${debrisRgb.g}, ${debrisRgb.b}, 0)`)\n\n  context.beginPath()\n  context.ellipse(\n    centerX,\n    baseY,\n    size * DUST_CLOUD_RADIUS_RATIO * effectiveIntensity,\n    size * DUST_CLOUD_HEIGHT_RATIO * effectiveIntensity,\n    0,\n    0,\n    Math.PI * 2\n  )\n  context.fillStyle = dustGradient\n  context.fill()\n}\n\nconst transitionIntensity = (renderer: CycloneRenderer) => {\n  if (renderer.currentIntensity < renderer.targetIntensity) {\n    renderer.currentIntensity = Math.min(renderer.targetIntensity, renderer.currentIntensity + INTENSITY_INCREASE_RATE)\n  } else if (renderer.currentIntensity > renderer.targetIntensity) {\n    renderer.currentIntensity = Math.max(renderer.targetIntensity, renderer.currentIntensity - INTENSITY_DECREASE_RATE)\n  }\n}\n\nconst transitionScale = (renderer: CycloneRenderer) => {\n  if (renderer.currentScale < renderer.targetScale) {\n    renderer.currentScale = Math.min(renderer.targetScale, renderer.currentScale + SCALE_TRANSITION_RATE)\n  } else if (renderer.currentScale > renderer.targetScale) {\n    renderer.currentScale = Math.max(renderer.targetScale, renderer.currentScale - SCALE_TRANSITION_RATE)\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 createCycloneRenderer = (\n  size: number,\n  intensity: number,\n  initialScale: number,\n  particleCount: number,\n  funnelColor: string,\n  debrisColor: string,\n  rotationSpeed: number\n): CycloneRenderer => {\n  const funnelRgb = hexToRgb(funnelColor)\n  const debrisRgb = hexToRgb(debrisColor)\n  const centerX = size / 2\n  const baseY = size * BASE_Y_RATIO\n\n  const renderer: CycloneRenderer = {\n    width: size,\n    height: size,\n    data: new Uint8ClampedArray(size * size * 4),\n    particles: [],\n    isActive: false,\n    startTime: 0,\n    currentIntensity: 0,\n    targetIntensity: intensity,\n    currentScale: initialScale,\n    targetScale: initialScale,\n    rotationSpeed,\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\n      this.particles = []\n      const funnelCount = Math.floor(particleCount * FUNNEL_PARTICLE_RATIO)\n      const debrisCount = Math.floor(particleCount * DEBRIS_PARTICLE_RATIO)\n      const dustCount = particleCount - funnelCount - debrisCount\n\n      for (let index = 0; index < funnelCount; index++) {\n        this.particles.push(createFunnelParticle(size))\n      }\n      for (let index = 0; index < debrisCount; index++) {\n        this.particles.push(createDebrisParticle(size))\n      }\n      for (let index = 0; index < dustCount; index++) {\n        this.particles.push(createDustParticle(size))\n      }\n\n      this.isActive = true\n      this.startTime = performance.now()\n      this.targetIntensity = intensity\n    },\n\n    stop() {\n      this.targetIntensity = 0\n    },\n\n    setIntensity(newIntensity: number) {\n      this.targetIntensity = Math.max(0, Math.min(MAX_INTENSITY, newIntensity))\n    },\n\n    setScale(newScale: number, instant?: boolean) {\n      const clampedScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, newScale))\n      this.targetScale = clampedScale\n      if (instant) {\n        this.currentScale = clampedScale\n      }\n    },\n\n    setRotationSpeed(speed: number) {\n      this.rotationSpeed = Math.max(MIN_ROTATION_SPEED, Math.min(MAX_ROTATION_SPEED, speed))\n    },\n\n    render() {\n      if (!this.context) {\n        return false\n      }\n\n      this.context.clearRect(0, 0, this.width, this.height)\n      transitionIntensity(this)\n      transitionScale(this)\n\n      if (this.currentIntensity <= 0 && this.targetIntensity <= 0) {\n        this.isActive = false\n        this.data = this.context.getImageData(0, 0, this.width, this.height).data\n        return true\n      }\n\n      const effectiveIntensity = this.currentIntensity\n      const effectiveScale = this.currentScale\n      this.context.save()\n      this.context.translate(centerX, baseY)\n      this.context.scale(effectiveScale, effectiveScale)\n      this.context.translate(-centerX, -baseY)\n\n      drawFunnelShape(this.context, centerX, baseY, size, funnelRgb, effectiveIntensity)\n\n      for (let particleIndex = 0; particleIndex < this.particles.length; particleIndex++) {\n        const particle = this.particles[particleIndex]\n        updateParticle(particle, size, this.rotationSpeed, effectiveIntensity)\n        drawParticle(this.context, particle, centerX, baseY, size, funnelRgb, debrisRgb, effectiveIntensity)\n      }\n\n      drawDustCloud(this.context, centerX, baseY, size, debrisRgb, effectiveIntensity)\n\n      this.context.restore()\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",
      "type": "registry:ui",
      "target": "components/ui/map/cyclone.tsx"
    }
  ],
  "type": "registry:ui"
}
