{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "lightning",
  "title": "Map Lightning",
  "description": "Animated lightning bolt strikes with branching and flash effects.",
  "dependencies": ["mapbox-gl"],
  "devDependencies": ["@types/mapbox-gl"],
  "registryDependencies": ["https://www.terrae.dev/map.json"],
  "files": [
    {
      "path": "src/registry/map/lightning.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport { useMap } from \"./hooks\"\nimport type { MapCoordinates } from \"./types\"\n\ntype RgbColor = {\n  red: number\n  green: number\n  blue: number\n}\n\ntype LightningBranch = {\n  startX: number\n  startY: number\n  endX: number\n  endY: number\n  width: number\n  opacity: number\n}\n\ntype LightningBolt = {\n  segments: LightningBranch[]\n  flashOpacity: number\n  life: number\n  maxLife: number\n}\n\ntype LightningControl = {\n  strike: () => void\n  isActive: boolean\n}\n\ntype LightningRenderer = {\n  width: number\n  height: number\n  data: Uint8ClampedArray\n  context?: CanvasRenderingContext2D\n  bolts: LightningBolt[]\n  isActive: boolean\n  lastStrikeTime: number\n  onAdd: () => void\n  render: () => boolean\n  strike: () => void\n}\n\ntype MapLightningProps = {\n  id: string\n  coordinates: MapCoordinates\n  size?: number\n  boltColor?: string\n  flashColor?: string\n  flashIntensity?: number\n  autoStrike?: boolean\n  strikeInterval?: number\n  boltWidth?: number\n  branchProbability?: number\n}\n\nconst DEFAULT_SIZE = 200\nconst DEFAULT_BOLT_COLOR = \"#ffffff\"\nconst DEFAULT_FLASH_COLOR = \"#e0e8ff\"\nconst DEFAULT_FLASH_INTENSITY = 0.6\nconst DEFAULT_STRIKE_INTERVAL = 4000\nconst DEFAULT_BOLT_WIDTH = 4\nconst DEFAULT_BRANCH_PROBABILITY = 0.3\n\nconst MIN_STRIKE_INTERVAL = 500\nconst BOLT_LIFETIME = 8\nconst FLASH_DECAY_RATE = 0.5\nconst SEGMENT_MIN_LENGTH = 20\nconst SEGMENT_MAX_LENGTH = 40\nconst SEGMENT_OFFSET_RANGE = 30\nconst BRANCH_LENGTH_FACTOR = 0.5\nconst BRANCH_WIDTH_FACTOR = 0.5\nconst BRANCH_ANGLE_RANGE = 60\nconst GLOW_BLUR_AMOUNT = 20\nconst CORE_GLOW_BLUR = 10\nconst DEGREES_TO_RADIANS = Math.PI / 180\nconst PIXEL_RATIO = 2\nconst CONTROL_UPDATE_INTERVAL = 50\nconst MAX_BRANCH_DEPTH = 3\nconst BRANCH_OPACITY_DECAY = 0.2\nconst WIDTH_DECAY_FACTOR = 0.05\nconst FLASH_THRESHOLD = 0.01\nconst OUTER_GLOW_WIDTH_MULTIPLIER = 3\nconst OUTER_GLOW_OPACITY_MULTIPLIER = 0.5\n\nconst lightningControls = new Map<string, LightningControl>()\n\nexport const useLightningControl = (id: string): LightningControl | 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 lightningControls.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 { red: 255, green: 255, blue: 255 }\n  }\n\n  return {\n    red: parseInt(result[1], 16),\n    green: parseInt(result[2], 16),\n    blue: parseInt(result[3], 16),\n  }\n}\n\nconst createLightningSegments = (\n  startX: number,\n  startY: number,\n  endX: number,\n  endY: number,\n  width: number,\n  branchProbability: number,\n  depth: number = 0\n): LightningBranch[] => {\n  const segments: LightningBranch[] = []\n  const deltaX = endX - startX\n  const deltaY = endY - startY\n  const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY)\n\n  if (distance < SEGMENT_MIN_LENGTH || depth > MAX_BRANCH_DEPTH) {\n    segments.push({\n      startX,\n      startY,\n      endX,\n      endY,\n      width,\n      opacity: 1 - depth * BRANCH_OPACITY_DECAY,\n    })\n    return segments\n  }\n\n  const segmentCount = Math.floor(distance / (SEGMENT_MIN_LENGTH + Math.random() * SEGMENT_MAX_LENGTH))\n  const points: { x: number; y: number }[] = [{ x: startX, y: startY }]\n\n  for (let segmentIndex = 1; segmentIndex < segmentCount; segmentIndex++) {\n    const ratio = segmentIndex / segmentCount\n    const baseX = startX + deltaX * ratio\n    const baseY = startY + deltaY * ratio\n\n    const perpendicularX = -deltaY / distance\n    const perpendicularY = deltaX / distance\n    const offset = (Math.random() - 0.5) * SEGMENT_OFFSET_RANGE\n\n    points.push({\n      x: baseX + perpendicularX * offset,\n      y: baseY + perpendicularY * offset,\n    })\n  }\n\n  points.push({ x: endX, y: endY })\n\n  for (let pointIndex = 0; pointIndex < points.length - 1; pointIndex++) {\n    const currentPoint = points[pointIndex]\n    const nextPoint = points[pointIndex + 1]\n\n    segments.push({\n      startX: currentPoint.x,\n      startY: currentPoint.y,\n      endX: nextPoint.x,\n      endY: nextPoint.y,\n      width: width * (1 - pointIndex * WIDTH_DECAY_FACTOR),\n      opacity: 1 - depth * BRANCH_OPACITY_DECAY,\n    })\n\n    const shouldBranch = depth < 2 && Math.random() < branchProbability && pointIndex > 0\n\n    if (shouldBranch) {\n      const branchSegments = createBranchSegments(currentPoint, nextPoint, distance, width, branchProbability, depth)\n      segments.push(...branchSegments)\n    }\n  }\n\n  return segments\n}\n\nconst createBranchSegments = (\n  currentPoint: { x: number; y: number },\n  nextPoint: { x: number; y: number },\n  distance: number,\n  width: number,\n  branchProbability: number,\n  depth: number\n): LightningBranch[] => {\n  const branchAngle = (Math.random() - 0.5) * BRANCH_ANGLE_RANGE * DEGREES_TO_RADIANS\n  const mainAngle = Math.atan2(nextPoint.y - currentPoint.y, nextPoint.x - currentPoint.x)\n  const branchDirection = mainAngle + branchAngle + (Math.random() > 0.5 ? Math.PI / 4 : -Math.PI / 4)\n\n  const branchLength = distance * BRANCH_LENGTH_FACTOR * (0.5 + Math.random() * 0.5)\n  const branchEndX = currentPoint.x + Math.cos(branchDirection) * branchLength\n  const branchEndY = currentPoint.y + Math.sin(branchDirection) * branchLength\n\n  return createLightningSegments(\n    currentPoint.x,\n    currentPoint.y,\n    branchEndX,\n    branchEndY,\n    width * BRANCH_WIDTH_FACTOR,\n    branchProbability * 0.5,\n    depth + 1\n  )\n}\n\nconst createLightningBolt = (\n  canvasWidth: number,\n  canvasHeight: number,\n  boltWidth: number,\n  branchProbability: number\n): LightningBolt => {\n  const startX = canvasWidth * (0.3 + Math.random() * 0.4)\n  const startY = 0\n  const endX = canvasWidth * (0.3 + Math.random() * 0.4)\n  const endY = canvasHeight * (0.7 + Math.random() * 0.3)\n\n  const segments = createLightningSegments(startX, startY, endX, endY, boltWidth, branchProbability)\n\n  return {\n    segments,\n    flashOpacity: 1,\n    life: 0,\n    maxLife: BOLT_LIFETIME,\n  }\n}\n\nconst drawFlashEffect = (\n  context: CanvasRenderingContext2D,\n  bolt: LightningBolt,\n  flashColor: RgbColor,\n  flashIntensity: number,\n  canvasWidth: number,\n  canvasHeight: number\n): void => {\n  if (bolt.flashOpacity <= FLASH_THRESHOLD) {\n    return\n  }\n\n  const centerX = canvasWidth / 2\n  const centerY = canvasHeight / 2\n  const flashGradient = context.createRadialGradient(centerX, centerY, 0, centerX, centerY, canvasWidth)\n  const adjustedFlashOpacity = bolt.flashOpacity * flashIntensity\n\n  flashGradient.addColorStop(\n    0,\n    `rgba(${flashColor.red}, ${flashColor.green}, ${flashColor.blue}, ${adjustedFlashOpacity})`\n  )\n  flashGradient.addColorStop(\n    0.5,\n    `rgba(${flashColor.red}, ${flashColor.green}, ${flashColor.blue}, ${adjustedFlashOpacity * 0.3})`\n  )\n  flashGradient.addColorStop(1, `rgba(${flashColor.red}, ${flashColor.green}, ${flashColor.blue}, 0)`)\n\n  context.fillStyle = flashGradient\n  context.fillRect(0, 0, canvasWidth, canvasHeight)\n}\n\nconst drawBoltSegments = (\n  context: CanvasRenderingContext2D,\n  bolt: LightningBolt,\n  boltColor: RgbColor,\n  fadeOpacity: number\n): void => {\n  context.save()\n  context.shadowColor = `rgba(${boltColor.red}, ${boltColor.green}, ${boltColor.blue}, ${fadeOpacity})`\n  context.shadowBlur = GLOW_BLUR_AMOUNT\n  context.lineCap = \"round\"\n  context.lineJoin = \"round\"\n\n  for (const segment of bolt.segments) {\n    const segmentOpacity = segment.opacity * fadeOpacity\n\n    context.beginPath()\n    context.moveTo(segment.startX, segment.startY)\n    context.lineTo(segment.endX, segment.endY)\n    context.strokeStyle = `rgba(${boltColor.red}, ${boltColor.green}, ${boltColor.blue}, ${segmentOpacity * OUTER_GLOW_OPACITY_MULTIPLIER})`\n    context.lineWidth = segment.width * OUTER_GLOW_WIDTH_MULTIPLIER\n    context.stroke()\n  }\n\n  context.shadowBlur = CORE_GLOW_BLUR\n\n  for (const segment of bolt.segments) {\n    const segmentOpacity = segment.opacity * fadeOpacity\n\n    context.beginPath()\n    context.moveTo(segment.startX, segment.startY)\n    context.lineTo(segment.endX, segment.endY)\n    context.strokeStyle = `rgba(255, 255, 255, ${segmentOpacity})`\n    context.lineWidth = segment.width\n    context.stroke()\n  }\n\n  context.restore()\n}\n\nconst drawLightningBolt = (\n  context: CanvasRenderingContext2D,\n  bolt: LightningBolt,\n  boltColor: RgbColor,\n  canvasWidth: number,\n  canvasHeight: number,\n  flashColor: RgbColor,\n  flashIntensity: number\n): void => {\n  const lifeRatio = bolt.life / bolt.maxLife\n  const fadeOpacity = 1 - lifeRatio\n\n  drawFlashEffect(context, bolt, flashColor, flashIntensity, canvasWidth, canvasHeight)\n  drawBoltSegments(context, bolt, boltColor, fadeOpacity)\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 createLightningRenderer = (\n  size: number,\n  boltColor: string,\n  flashColor: string,\n  flashIntensity: number,\n  autoStrike: boolean,\n  strikeInterval: number,\n  boltWidth: number,\n  branchProbability: number\n): LightningRenderer => {\n  const boltRgb = hexToRgb(boltColor)\n  const flashRgb = hexToRgb(flashColor)\n\n  const renderer: LightningRenderer = {\n    width: size,\n    height: size,\n    data: new Uint8ClampedArray(size * size * 4),\n    bolts: [],\n    isActive: false,\n    lastStrikeTime: 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    strike() {\n      const bolt = createLightningBolt(this.width, this.height, boltWidth, branchProbability)\n      this.bolts.push(bolt)\n      this.isActive = true\n      this.lastStrikeTime = performance.now()\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      const now = performance.now()\n\n      if (autoStrike && now - this.lastStrikeTime > strikeInterval) {\n        this.strike()\n      }\n\n      for (let boltIndex = this.bolts.length - 1; boltIndex >= 0; boltIndex--) {\n        const bolt = this.bolts[boltIndex]\n        bolt.life++\n        bolt.flashOpacity *= FLASH_DECAY_RATE\n\n        if (bolt.life >= bolt.maxLife) {\n          this.bolts.splice(boltIndex, 1)\n          continue\n        }\n\n        drawLightningBolt(this.context, bolt, boltRgb, this.width, this.height, flashRgb, flashIntensity)\n      }\n\n      this.isActive = this.bolts.length > 0\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 initializeRenderer = (map: mapboxgl.Map, id: string, renderer: LightningRenderer, strike: () => void): void => {\n  const control: LightningControl = {\n    strike,\n    get isActive() {\n      return renderer.isActive\n    },\n  }\n  lightningControls.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  lightningControls.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 MapLightning = ({\n  id,\n  coordinates,\n  size = DEFAULT_SIZE,\n  boltColor = DEFAULT_BOLT_COLOR,\n  flashColor = DEFAULT_FLASH_COLOR,\n  flashIntensity = DEFAULT_FLASH_INTENSITY,\n  autoStrike = true,\n  strikeInterval = DEFAULT_STRIKE_INTERVAL,\n  boltWidth = DEFAULT_BOLT_WIDTH,\n  branchProbability = DEFAULT_BRANCH_PROBABILITY,\n}: MapLightningProps) => {\n  const { map, isLoaded } = useMap()\n  const animationFrameRef = useRef<number | null>(null)\n  const rendererRef = useRef<LightningRenderer | null>(null)\n\n  const sourceId = `${id}-source`\n  const layerId = `${id}-layer`\n  const clampedInterval = Math.max(MIN_STRIKE_INTERVAL, strikeInterval)\n\n  const strikeRef = useRef(() => {\n    if (rendererRef.current) {\n      rendererRef.current.strike()\n    }\n  })\n\n  useEffect(() => {\n    if (!isLoaded || !map) {\n      return\n    }\n\n    const renderer = createLightningRenderer(\n      size,\n      boltColor,\n      flashColor,\n      flashIntensity,\n      autoStrike,\n      clampedInterval,\n      boltWidth,\n      branchProbability\n    )\n    rendererRef.current = renderer\n\n    initializeRenderer(map, id, renderer, strikeRef.current)\n\n    const animate = () => {\n      const shouldRepaint = renderer.bolts.length > 0 || autoStrike\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, renderer, { 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    size,\n    boltColor,\n    flashColor,\n    flashIntensity,\n    autoStrike,\n    clampedInterval,\n    boltWidth,\n    branchProbability,\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/lightning.tsx"
    }
  ],
  "type": "registry:ui"
}
