{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tsunami",
  "title": "Map Tsunami",
  "description": "Animated tsunami wave effect with incoming wave, crashing foam, and debris.",
  "dependencies": ["mapbox-gl"],
  "devDependencies": ["@types/mapbox-gl"],
  "registryDependencies": ["https://www.terrae.dev/map.json"],
  "files": [
    {
      "path": "src/registry/map/tsunami.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\"\nimport { useMap } from \"./hooks\"\nimport type { MapCoordinates } from \"./types\"\n\ntype FoamParticle = {\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 DebrisParticle = {\n  positionX: number\n  positionY: number\n  size: number\n  rotation: number\n  rotationSpeed: number\n  opacity: number\n  velocityX: number\n  velocityY: number\n}\n\ntype RgbColor = {\n  red: number\n  green: number\n  blue: number\n}\n\ntype TsunamiPhase = \"approaching\" | \"crashing\" | \"receding\" | \"idle\"\n\ntype TsunamiRenderer = {\n  width: number\n  height: number\n  data: Uint8ClampedArray\n  context?: CanvasRenderingContext2D\n  foamParticles: FoamParticle[]\n  debrisParticles: DebrisParticle[]\n  isActive: boolean\n  startTime: number\n  waveProgress: number\n  phase: TsunamiPhase\n  restartTimerId: number\n  onAdd: () => void\n  render: () => boolean\n  start: () => void\n  stop: () => void\n  reset: () => void\n}\n\ntype TsunamiControl = {\n  start: () => void\n  stop: () => void\n  reset: () => void\n  isActive: boolean\n  progress: number\n  phase: TsunamiPhase\n}\n\ntype MapTsunamiProps = {\n  id?: string\n  origin: MapCoordinates\n  target: MapCoordinates\n  size?: number\n  waveHeight?: number\n  waveWidth?: number\n  speed?: number\n  waterColor?: string\n  foamColor?: string\n  particleCount?: number\n  autoStart?: boolean\n  loop?: boolean\n  loopDelay?: number\n}\n\nconst DEFAULT_SIZE = 300\nconst DEFAULT_WAVE_HEIGHT = 0.4\nconst DEFAULT_WAVE_WIDTH = 0.8\nconst DEFAULT_SPEED = 3000\nconst DEFAULT_WATER_COLOR = \"#0077be\"\nconst DEFAULT_FOAM_COLOR = \"#ffffff\"\nconst DEFAULT_PARTICLE_COUNT = 40\nconst DEFAULT_LOOP_DELAY = 2000\n\nconst PARTICLE_SPREAD_ANGLE = 60\nconst DEGREES_TO_RADIANS = Math.PI / 180\nconst HALF_CIRCLE_DEGREES = 180\nconst FULL_CIRCLE_DEGREES = 360\nconst CRASH_DURATION_MULTIPLIER = 0.5\nconst WAVE_START_DISTANCE = 0.45\nconst CRASH_OFFSET = 0.1\nconst TRAIL_LENGTH = 0.5\nconst TRAIL_WIDTH = 0.6\nconst FOAM_HEIGHT_RATIO = 0.8\nconst WAVE_SEGMENTS = 20\nconst FOAM_PARTICLE_MIN_RADIUS = 3\nconst FOAM_PARTICLE_MAX_RADIUS = 8\nconst FOAM_MIN_OPACITY = 0.6\nconst FOAM_MAX_OPACITY = 0.4\nconst FOAM_MIN_LIFE = 30\nconst FOAM_MAX_LIFE = 30\nconst DEBRIS_MIN_SIZE = 2\nconst DEBRIS_MAX_SIZE = 4\nconst DEBRIS_MIN_OPACITY = 0.4\nconst DEBRIS_MAX_OPACITY = 0.3\nconst DEBRIS_COLOR = \"101, 67, 33\"\nconst GRAVITY = 0.1\nconst DEBRIS_GRAVITY = 0.15\nconst FRICTION = 0.98\nconst DEBRIS_FRICTION = 0.97\nconst OPACITY_DECAY = 0.98\nconst MIN_VISIBLE_OPACITY = 0.05\nconst PIXEL_RATIO = 2\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: 0, green: 119, blue: 190 }\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 calculateDirection = (origin: MapCoordinates, target: MapCoordinates): number => {\n  const deltaLongitude = target[0] - origin[0]\n  const deltaLatitude = target[1] - origin[1]\n  const radians = Math.atan2(deltaLongitude, deltaLatitude)\n  const degrees = radians / DEGREES_TO_RADIANS\n\n  return (degrees + FULL_CIRCLE_DEGREES) % FULL_CIRCLE_DEGREES\n}\n\nconst calculateMidpoint = (origin: MapCoordinates, target: MapCoordinates): MapCoordinates => {\n  return [(origin[0] + target[0]) / 2, (origin[1] + target[1]) / 2]\n}\n\nconst createFoamParticle = (originX: number, originY: number, direction: number): FoamParticle => {\n  const spread = (Math.random() - 0.5) * PARTICLE_SPREAD_ANGLE\n  const radians = (direction + HALF_CIRCLE_DEGREES) * DEGREES_TO_RADIANS\n  const perpendicularAngle = radians + Math.PI / 2\n\n  return {\n    positionX: originX + spread * Math.cos(perpendicularAngle),\n    positionY: originY + spread * Math.sin(perpendicularAngle),\n    radius: FOAM_PARTICLE_MIN_RADIUS + Math.random() * FOAM_PARTICLE_MAX_RADIUS,\n    opacity: FOAM_MIN_OPACITY + Math.random() * FOAM_MAX_OPACITY,\n    velocityX: Math.cos(radians) * (2 + Math.random() * 4) + (Math.random() - 0.5) * 2,\n    velocityY: Math.sin(radians) * (2 + Math.random() * 4) - Math.random() * 3,\n    life: 0,\n    maxLife: FOAM_MIN_LIFE + Math.random() * FOAM_MAX_LIFE,\n  }\n}\n\nconst createDebrisParticle = (originX: number, originY: number, direction: number): DebrisParticle => {\n  const radians = (direction + HALF_CIRCLE_DEGREES) * DEGREES_TO_RADIANS\n\n  return {\n    positionX: originX,\n    positionY: originY,\n    size: DEBRIS_MIN_SIZE + Math.random() * DEBRIS_MAX_SIZE,\n    rotation: Math.random() * Math.PI * 2,\n    rotationSpeed: (Math.random() - 0.5) * 0.3,\n    opacity: DEBRIS_MIN_OPACITY + Math.random() * DEBRIS_MAX_OPACITY,\n    velocityX: Math.cos(radians) * (1 + Math.random() * 3),\n    velocityY: Math.sin(radians) * (1 + Math.random() * 3) - Math.random() * 2,\n  }\n}\n\nconst renderWaterTrail = (\n  context: CanvasRenderingContext2D,\n  waveX: number,\n  waveY: number,\n  radians: number,\n  size: number,\n  waveWidthPx: number,\n  easeProgress: number,\n  waterRgb: RgbColor\n): void => {\n  const perpendicularX = Math.cos(radians + Math.PI / 2)\n  const perpendicularY = Math.sin(radians + Math.PI / 2)\n\n  const trailGradient = context.createLinearGradient(\n    waveX - Math.cos(radians) * size * TRAIL_LENGTH,\n    waveY - Math.sin(radians) * size * TRAIL_LENGTH,\n    waveX,\n    waveY\n  )\n  trailGradient.addColorStop(0, `rgba(${waterRgb.red}, ${waterRgb.green}, ${waterRgb.blue}, 0)`)\n  trailGradient.addColorStop(0.5, `rgba(${waterRgb.red}, ${waterRgb.green}, ${waterRgb.blue}, ${0.3 * easeProgress})`)\n  trailGradient.addColorStop(1, `rgba(${waterRgb.red}, ${waterRgb.green}, ${waterRgb.blue}, ${0.5 * easeProgress})`)\n\n  context.beginPath()\n  context.moveTo(\n    waveX - Math.cos(radians) * size * TRAIL_LENGTH + perpendicularX * waveWidthPx * TRAIL_WIDTH,\n    waveY - Math.sin(radians) * size * TRAIL_LENGTH + perpendicularY * waveWidthPx * TRAIL_WIDTH\n  )\n  context.lineTo(waveX + perpendicularX * waveWidthPx * 0.5, waveY + perpendicularY * waveWidthPx * 0.5)\n  context.lineTo(waveX - perpendicularX * waveWidthPx * 0.5, waveY - perpendicularY * waveWidthPx * 0.5)\n  context.lineTo(\n    waveX - Math.cos(radians) * size * TRAIL_LENGTH - perpendicularX * waveWidthPx * TRAIL_WIDTH,\n    waveY - Math.sin(radians) * size * TRAIL_LENGTH - perpendicularY * waveWidthPx * TRAIL_WIDTH\n  )\n  context.closePath()\n  context.fillStyle = trailGradient\n  context.fill()\n}\n\nconst renderWaveBody = (\n  context: CanvasRenderingContext2D,\n  waveX: number,\n  waveY: number,\n  radians: number,\n  waveWidthPx: number,\n  currentHeight: number,\n  easeProgress: number,\n  waterRgb: RgbColor\n): void => {\n  const perpendicularX = Math.cos(radians + Math.PI / 2)\n  const perpendicularY = Math.sin(radians + Math.PI / 2)\n  const time = performance.now() * 0.003\n  const points: { pointX: number; pointY: number }[] = []\n\n  for (let segmentIndex = 0; segmentIndex <= WAVE_SEGMENTS; segmentIndex++) {\n    const normalizedPosition = segmentIndex / WAVE_SEGMENTS - 0.5\n    const baseX = waveX + perpendicularX * waveWidthPx * normalizedPosition\n    const baseY = waveY + perpendicularY * waveWidthPx * normalizedPosition\n\n    const waveOffset = Math.sin(normalizedPosition * Math.PI * 3 + time) * 5 * easeProgress\n    const heightVariation = Math.cos(normalizedPosition * Math.PI) * 0.3 + 0.7\n\n    points.push({\n      pointX: baseX + Math.cos(radians) * (currentHeight * heightVariation + waveOffset),\n      pointY: baseY + Math.sin(radians) * (currentHeight * heightVariation + waveOffset),\n    })\n  }\n\n  context.beginPath()\n  context.moveTo(waveX + perpendicularX * waveWidthPx * -0.5, waveY + perpendicularY * waveWidthPx * -0.5)\n\n  for (let pointIndex = 0; pointIndex < points.length; pointIndex++) {\n    if (pointIndex === 0) {\n      context.lineTo(points[pointIndex].pointX, points[pointIndex].pointY)\n    } else {\n      const previousPoint = points[pointIndex - 1]\n      const currentPoint = points[pointIndex]\n      const controlPointX = (previousPoint.pointX + currentPoint.pointX) / 2\n      const controlPointY = (previousPoint.pointY + currentPoint.pointY) / 2\n      context.quadraticCurveTo(previousPoint.pointX, previousPoint.pointY, controlPointX, controlPointY)\n    }\n  }\n\n  context.lineTo(waveX + perpendicularX * waveWidthPx * 0.5, waveY + perpendicularY * waveWidthPx * 0.5)\n  context.closePath()\n\n  const waveGradient = context.createLinearGradient(\n    waveX - Math.cos(radians) * currentHeight,\n    waveY - Math.sin(radians) * currentHeight,\n    waveX + Math.cos(radians) * currentHeight,\n    waveY + Math.sin(radians) * currentHeight\n  )\n  const lighterRed = Math.min(255, waterRgb.red + 40)\n  const lighterGreen = Math.min(255, waterRgb.green + 40)\n  const lighterBlue = Math.min(255, waterRgb.blue + 40)\n  const darkerRed = Math.max(0, waterRgb.red - 20)\n  const darkerGreen = Math.max(0, waterRgb.green - 20)\n\n  waveGradient.addColorStop(0, `rgba(${lighterRed}, ${lighterGreen}, ${lighterBlue}, ${0.9 * easeProgress})`)\n  waveGradient.addColorStop(0.4, `rgba(${waterRgb.red}, ${waterRgb.green}, ${waterRgb.blue}, ${0.85 * easeProgress})`)\n  waveGradient.addColorStop(1, `rgba(${darkerRed}, ${darkerGreen}, ${waterRgb.blue}, ${0.7 * easeProgress})`)\n\n  context.fillStyle = waveGradient\n  context.fill()\n}\n\nconst renderFoamCrest = (\n  context: CanvasRenderingContext2D,\n  waveX: number,\n  waveY: number,\n  radians: number,\n  waveWidthPx: number,\n  currentHeight: number,\n  easeProgress: number,\n  foamRgb: RgbColor\n): void => {\n  const perpendicularX = Math.cos(radians + Math.PI / 2)\n  const perpendicularY = Math.sin(radians + Math.PI / 2)\n  const time = performance.now() * 0.003\n  const foamOffsetY = currentHeight * FOAM_HEIGHT_RATIO\n\n  for (let foamIndex = 0; foamIndex <= WAVE_SEGMENTS; foamIndex++) {\n    const normalizedPosition = foamIndex / WAVE_SEGMENTS - 0.5\n    const baseX = waveX + perpendicularX * waveWidthPx * normalizedPosition\n    const baseY = waveY + perpendicularY * waveWidthPx * normalizedPosition\n    const foamX = baseX + Math.cos(radians) * foamOffsetY\n    const foamY = baseY + Math.sin(radians) * foamOffsetY\n\n    const foamSize = (8 + Math.sin(normalizedPosition * 10 + time * 2) * 3) * easeProgress\n    const foamOpacity = (0.7 + Math.sin(normalizedPosition * 8 + time * 3) * 0.3) * easeProgress\n\n    const foamGradient = context.createRadialGradient(foamX, foamY, 0, foamX, foamY, foamSize)\n    foamGradient.addColorStop(0, `rgba(${foamRgb.red}, ${foamRgb.green}, ${foamRgb.blue}, ${foamOpacity})`)\n    foamGradient.addColorStop(0.5, `rgba(${foamRgb.red}, ${foamRgb.green}, ${foamRgb.blue}, ${foamOpacity * 0.5})`)\n    foamGradient.addColorStop(1, `rgba(${foamRgb.red}, ${foamRgb.green}, ${foamRgb.blue}, 0)`)\n\n    context.beginPath()\n    context.arc(foamX, foamY, foamSize, 0, Math.PI * 2)\n    context.fillStyle = foamGradient\n    context.fill()\n  }\n}\n\nconst updateAndRenderFoamParticles = (\n  context: CanvasRenderingContext2D,\n  particles: FoamParticle[],\n  foamRgb: 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\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 * 0.5)\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(${foamRgb.red}, ${foamRgb.green}, ${foamRgb.blue}, ${alpha})`)\n    gradient.addColorStop(1, `rgba(${foamRgb.red}, ${foamRgb.green}, ${foamRgb.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 updateAndRenderDebrisParticles = (context: CanvasRenderingContext2D, particles: DebrisParticle[]): void => {\n  for (let debrisIndex = particles.length - 1; debrisIndex >= 0; debrisIndex--) {\n    const particle = particles[debrisIndex]\n    particle.positionX += particle.velocityX\n    particle.positionY += particle.velocityY\n    particle.velocityY += DEBRIS_GRAVITY\n    particle.velocityX *= DEBRIS_FRICTION\n    particle.rotation += particle.rotationSpeed\n    particle.opacity *= OPACITY_DECAY\n\n    if (particle.opacity < MIN_VISIBLE_OPACITY) {\n      particles.splice(debrisIndex, 1)\n      continue\n    }\n\n    context.save()\n    context.translate(particle.positionX, particle.positionY)\n    context.rotate(particle.rotation)\n    context.fillStyle = `rgba(${DEBRIS_COLOR}, ${particle.opacity})`\n    context.fillRect(-particle.size / 2, -particle.size / 2, particle.size, particle.size)\n    context.restore()\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 createTsunamiRenderer = (\n  size: number,\n  waveHeight: number,\n  waveWidth: number,\n  direction: number,\n  speed: number,\n  waterColor: string,\n  foamColor: string,\n  particleCount: number,\n  loop: boolean,\n  loopDelay: number\n): TsunamiRenderer => {\n  const waterRgb = hexToRgb(waterColor)\n  const foamRgb = hexToRgb(foamColor)\n  const centerX = size / 2\n  const centerY = size / 2\n  const waveHeightPx = size * waveHeight\n  const waveWidthPx = size * waveWidth\n  const radians = direction * DEGREES_TO_RADIANS\n\n  const renderer: TsunamiRenderer = {\n    width: size,\n    height: size,\n    data: new Uint8ClampedArray(size * size * 4),\n    foamParticles: [],\n    debrisParticles: [],\n    isActive: false,\n    startTime: 0,\n    waveProgress: 0,\n    phase: \"idle\",\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.waveProgress = 0\n      this.phase = \"approaching\"\n      this.foamParticles = []\n      this.debrisParticles = []\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.waveProgress = 0\n      this.phase = \"idle\"\n      this.foamParticles = []\n      this.debrisParticles = []\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 = this.context.getImageData(0, 0, this.width, this.height).data\n        return true\n      }\n\n      const elapsed = performance.now() - this.startTime\n\n      if (this.phase === \"approaching\") {\n        this.waveProgress = Math.min(elapsed / speed, 1)\n        if (this.waveProgress >= 1) {\n          this.phase = \"crashing\"\n          this.startTime = performance.now()\n\n          const crashX = centerX + Math.cos(radians) * (size * CRASH_OFFSET)\n          const crashY = centerY + Math.sin(radians) * (size * CRASH_OFFSET)\n\n          for (let index = 0; index < particleCount; index++) {\n            this.foamParticles.push(createFoamParticle(crashX, crashY, direction))\n          }\n          for (let index = 0; index < particleCount / 3; index++) {\n            this.debrisParticles.push(createDebrisParticle(crashX, crashY, direction))\n          }\n        }\n      } else if (this.phase === \"crashing\") {\n        const crashDuration = speed * CRASH_DURATION_MULTIPLIER\n        const crashProgress = Math.min((performance.now() - this.startTime) / crashDuration, 1)\n        if (crashProgress >= 1) {\n          this.phase = \"receding\"\n          this.startTime = performance.now()\n        }\n      } else if (this.phase === \"receding\") {\n        const recedeDuration = speed\n        const recedeProgress = Math.min((performance.now() - this.startTime) / recedeDuration, 1)\n        this.waveProgress = 1 - recedeProgress\n\n        if (recedeProgress >= 1) {\n          if (loop) {\n            this.phase = \"idle\"\n            this.startTime = performance.now()\n            this.restartTimerId = window.setTimeout(() => {\n              if (this.isActive) {\n                this.start()\n              }\n            }, loopDelay)\n          } else {\n            this.phase = \"idle\"\n            this.isActive = false\n          }\n        }\n      }\n\n      if (this.phase !== \"idle\" && this.waveProgress > 0) {\n        const easeProgress = 1 - Math.pow(1 - this.waveProgress, 3)\n        const startDistance = size * WAVE_START_DISTANCE\n        const waveDistance = startDistance * (1 - easeProgress)\n\n        const waveX = centerX - Math.cos(radians) * waveDistance\n        const waveY = centerY - Math.sin(radians) * waveDistance\n\n        this.context.save()\n\n        renderWaterTrail(this.context, waveX, waveY, radians, size, waveWidthPx, easeProgress, waterRgb)\n\n        const heightMultiplier = this.phase === \"crashing\" ? 0.7 : 1\n        const currentHeight = waveHeightPx * easeProgress * heightMultiplier\n\n        renderWaveBody(this.context, waveX, waveY, radians, waveWidthPx, currentHeight, easeProgress, waterRgb)\n        renderFoamCrest(this.context, waveX, waveY, radians, waveWidthPx, currentHeight, easeProgress, foamRgb)\n\n        this.context.restore()\n      }\n\n      updateAndRenderFoamParticles(this.context, this.foamParticles, foamRgb)\n      updateAndRenderDebrisParticles(this.context, this.debrisParticles)\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 tsunamiControls = new Map<string, TsunamiControl>()\n\nexport const MapTsunami = ({\n  id,\n  origin,\n  target,\n  size = DEFAULT_SIZE,\n  waveHeight = DEFAULT_WAVE_HEIGHT,\n  waveWidth = DEFAULT_WAVE_WIDTH,\n  speed = DEFAULT_SPEED,\n  waterColor = DEFAULT_WATER_COLOR,\n  foamColor = DEFAULT_FOAM_COLOR,\n  particleCount = DEFAULT_PARTICLE_COUNT,\n  autoStart = true,\n  loop = false,\n  loopDelay = DEFAULT_LOOP_DELAY,\n}: MapTsunamiProps) => {\n  const { map, isLoaded } = useMap()\n  const animationFrameRef = useRef<number | null>(null)\n  const rendererRef = useRef<TsunamiRenderer | null>(null)\n  const autoId = useId()\n  const controlId = id ?? autoId\n\n  const direction = calculateDirection(origin, target)\n  const canvasCenter = useMemo(() => {\n    return calculateMidpoint(origin, target)\n  }, [origin, target])\n  const sourceId = `${controlId}-source`\n  const layerId = `${controlId}-layer`\n\n  const startAnimation = () => {\n    if (rendererRef.current) {\n      rendererRef.current.start()\n    }\n  }\n\n  const stopAnimation = () => {\n    if (rendererRef.current) {\n      rendererRef.current.stop()\n    }\n  }\n\n  const resetAnimation = () => {\n    if (rendererRef.current) {\n      rendererRef.current.reset()\n    }\n  }\n\n  useEffect(() => {\n    if (!isLoaded || !map) {\n      return\n    }\n\n    const tsunamiRenderer = createTsunamiRenderer(\n      size,\n      waveHeight,\n      waveWidth,\n      direction,\n      speed,\n      waterColor,\n      foamColor,\n      particleCount,\n      loop,\n      loopDelay\n    )\n    rendererRef.current = tsunamiRenderer\n\n    const control: TsunamiControl = {\n      start: startAnimation,\n      stop: stopAnimation,\n      reset: resetAnimation,\n      get isActive() {\n        return rendererRef.current?.isActive || false\n      },\n      get progress() {\n        return rendererRef.current?.waveProgress || 0\n      },\n      get phase() {\n        return rendererRef.current?.phase || \"idle\"\n      },\n    }\n    tsunamiControls.set(controlId, control)\n\n    if (!map.hasImage(controlId)) {\n      map.addImage(controlId, tsunamiRenderer, { pixelRatio: PIXEL_RATIO })\n    }\n\n    if (autoStart) {\n      tsunamiRenderer.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      if (!map.hasImage(controlId)) {\n        map.addImage(controlId, tsunamiRenderer, { pixelRatio: PIXEL_RATIO })\n      }\n    }\n\n    map.on(\"style.load\", handleStyleLoad)\n\n    return () => {\n      map.off(\"style.load\", handleStyleLoad)\n      tsunamiControls.delete(controlId)\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(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    waveHeight,\n    waveWidth,\n    direction,\n    speed,\n    waterColor,\n    foamColor,\n    particleCount,\n    autoStart,\n    loop,\n    loopDelay,\n  ])\n\n  useEffect(() => {\n    if (!isLoaded || !map) {\n      return\n    }\n\n    let pollFrameId: number\n\n    const addSourceAndLayer = () => {\n      if (!map.isStyleLoaded() || !map.hasImage(controlId)) {\n        pollFrameId = requestAnimationFrame(addSourceAndLayer)\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: canvasCenter },\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            \"icon-pitch-alignment\": \"map\",\n            \"icon-rotation-alignment\": \"map\",\n          },\n        })\n      }\n    }\n\n    pollFrameId = requestAnimationFrame(addSourceAndLayer)\n\n    const handleStyleLoad = () => {\n      addSourceAndLayer()\n    }\n\n    map.on(\"style.load\", handleStyleLoad)\n\n    return () => {\n      cancelAnimationFrame(pollFrameId)\n      map.off(\"style.load\", handleStyleLoad)\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    }\n  }, [map, isLoaded, canvasCenter, controlId, sourceId, layerId])\n\n  return null\n}\n\nexport const useTsunamiControl = (id: string): TsunamiControl | 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 tsunamiControls.get(id) || null\n}\n",
      "type": "registry:ui",
      "target": "components/ui/map/tsunami.tsx"
    }
  ],
  "type": "registry:ui"
}
