{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "region-globe",
  "title": "RegionGlobe",
  "description": "Tilted 3D globe (globe.gl) with a monochrome relief surface, clickable region markers, and arc travel effects between regions.",
  "dependencies": [
    "globe.gl"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.neon.com/r/neon-tokens.json",
    "https://ui.neon.com/r/region-select.json",
    "https://ui.neon.com/r/region-card.json"
  ],
  "files": [
    {
      "path": "src/components/region-globe/region-globe.tsx",
      "content": "\"use client\";\n\nimport type { GlobeInstance } from \"globe.gl\";\nimport type { ComponentProps } from \"react\";\nimport { useEffect, useRef } from \"react\";\n\nimport { pingTone } from \"@/components/region-card/region-card\";\nimport {\n  decodeRow,\n  MAP_BITS,\n  MAP_COLS,\n  MAP_LAT_MAX,\n  MAP_ROWS,\n} from \"@/components/region-select/map-dots\";\nimport type { ServerRegion } from \"@/components/region-select/region-select\";\nimport { cn } from \"@/lib/utils\";\n\nexport type RegionGlobeVariant = \"relief\" | \"dots\" | \"outlines\";\n\nexport type RegionGlobeProps = Omit<ComponentProps<\"div\">, \"children\"> & {\n  /** Regions rendered as clickable marker dots on the globe. */\n  regions: ServerRegion[];\n  /** Region id the camera flies to; its marker renders larger. */\n  value?: string;\n  /** Called with a region id when its marker dot is clicked. */\n  onValueChange?: (value: string) => void;\n  /** Measured round-trips by region id; shown in the marker popover. */\n  latencies?: Record<string, number | null | undefined>;\n  /** Keep the globe slowly turning, e.g. until the user picks. */\n  spin?: boolean;\n  /** Camera latitude lead in degrees; a slight downward tilt. */\n  tilt?: number;\n  /** Grayscale surface texture; defaults to the Natural Earth topology map. */\n  textureUrl?: string;\n  /** Height map for the relief; defaults to the same topology map. */\n  bumpUrl?: string;\n  /**\n   * Land treatment: \"relief\" (monochrome height map), \"dots\"\n   * (the house dot-matrix bitmap), or \"outlines\" (country borders).\n   */\n  variant?: RegionGlobeVariant;\n  /** Set false to remove the atmosphere glow entirely. */\n  glow?: boolean;\n  /** Glow color; defaults to the theme's primary token. */\n  glowColor?: string;\n  /** Glow reach as a fraction of the globe radius. */\n  glowAltitude?: number;\n  /** Country borders GeoJSON for the outlines variant. */\n  countriesUrl?: string;\n};\n\nconst CAMERA_ALTITUDE = 2.1;\nconst FLY_MS = 900;\nconst IDLE_SPIN_SPEED = 0.9;\nconst DARK_LUMA_SUM = 382;\n\n/* Travel arcs (after globe.gl's emit-arcs-on-click example): an arc\n * dashes from the previous region to the new one while rings ripple\n * out of both endpoints. */\nconst ARC_REL_LEN = 0.4;\nconst TRAVEL_RINGS_MAX_R = 4;\nconst TRAVEL_RING_SPEED = 4;\nconst TRAVEL_RING_REPEAT_MS = (FLY_MS * ARC_REL_LEN) / 3;\n\n/** Grayscale Natural Earth topology, doubling as texture + bump. */\nconst TOPOLOGY_URL =\n  \"https://cdn.jsdelivr.net/npm/three-globe/example/img/earth-topology.png\";\n\n/** Natural Earth 110m country borders (globe.gl's example dataset). */\nconst COUNTRIES_URL =\n  \"https://globe.gl/example/datasets/ne_110m_admin_0_countries.geojson\";\n\nconst LAND_DOT_RADIUS = 0.38;\nconst OUTLINE_ALTITUDE = 0.004;\nconst DEFAULT_GLOW_ALTITUDE = 0.12;\n\ninterface LandDot {\n  lat: number;\n  lng: number;\n}\n\nlet landDotsCache: LandDot[] | null = null;\n\n/** The RegionSelect land bitmap, lifted onto the sphere as dots. */\nconst landDots = (): LandDot[] => {\n  if (landDotsCache) {\n    return landDotsCache;\n  }\n\n  const step = 360 / MAP_COLS;\n  const dots: LandDot[] = [];\n\n  for (let row = 0; row < MAP_ROWS; row += 1) {\n    const flags = decodeRow(MAP_BITS[row] ?? \"\");\n    const lat = MAP_LAT_MAX - (row + 0.5) * step;\n\n    for (let col = 0; col < MAP_COLS; col += 1) {\n      if (flags[col]) {\n        dots.push({ lat, lng: -180 + (col + 0.5) * step });\n      }\n    }\n  }\n\n  landDotsCache = dots;\n  return dots;\n};\n\nconst countriesCache = new Map<string, Promise<object[]>>();\n\n/** Fetches (and caches) the country features for the outlines variant. */\nconst loadCountries = (url: string): Promise<object[]> => {\n  const cached = countriesCache.get(url);\n\n  if (cached) {\n    return cached;\n  }\n\n  const load = async (): Promise<object[]> => {\n    try {\n      const response = await fetch(url);\n      const geojson = (await response.json()) as { features?: object[] };\n\n      return geojson.features ?? [];\n    } catch {\n      return [];\n    }\n  };\n\n  const promise = load();\n\n  countriesCache.set(url, promise);\n  return promise;\n};\n\n/** Parses any CSS color to 0-255 RGB via a canvas round-trip. */\nconst parseRgb = (raw: string): [number, number, number] => {\n  const probe = document.createElement(\"canvas\");\n  probe.width = 1;\n  probe.height = 1;\n  const context = probe.getContext(\"2d\", { willReadFrequently: true });\n\n  if (!context) {\n    return [128, 128, 128];\n  }\n\n  context.fillStyle = raw;\n  context.fillRect(0, 0, 1, 1);\n  const [r, g, b] = context.getImageData(0, 0, 1, 1).data;\n\n  return [r ?? 0, g ?? 0, b ?? 0];\n};\n\nconst lumaSum = (raw: string) => {\n  const [r, g, b] = parseRgb(raw);\n  return r + g + b;\n};\n\n/** Resolved theme colors, probed from hidden token spans. */\nconst probeTheme = (root: HTMLElement) => {\n  const colorOf = (selector: string) => {\n    const element = root.querySelector(selector);\n    return element ? getComputedStyle(element).color : \"\";\n  };\n  const glow = colorOf(\".probe-glow\");\n\n  return {\n    dark: lumaSum(glow) < DARK_LUMA_SUM,\n    dot: colorOf(\".probe-base\"),\n    marker: colorOf(\".probe-marker\"),\n    sphere: glow,\n  };\n};\n\n/* ─────────────────────────────────────────────────────────\n * GLOBE STORYBOARD\n *\n * A Three.js globe (globe.gl) with a monochrome relief — the\n * topology map as texture + bump, tinted by muted-foreground.\n * Region markers are real DOM buttons pinned to the surface\n * (same altitude as arc/ring endpoints, so travel effects\n * land exactly on the dots). Picking a region flies the\n * camera on a tilt while a dashed arc + rings travel from\n * the previous region. Colors re-probe on theme flips, and\n * reduced motion disables spin, flight, and travel.\n * ───────────────────────────────────────────────────────── */\nexport const RegionGlobe = ({\n  bumpUrl = TOPOLOGY_URL,\n  className,\n  countriesUrl = COUNTRIES_URL,\n  glow = true,\n  glowAltitude = DEFAULT_GLOW_ALTITUDE,\n  glowColor,\n  latencies,\n  onValueChange,\n  regions,\n  spin = false,\n  textureUrl = TOPOLOGY_URL,\n  tilt = 12,\n  value,\n  variant = \"relief\",\n  ...props\n}: RegionGlobeProps) => {\n  const containerRef = useRef<HTMLDivElement>(null);\n  const stageRef = useRef<HTMLDivElement>(null);\n  const probeRef = useRef<HTMLDivElement>(null);\n  const globeRef = useRef<GlobeInstance | null>(null);\n  const markerRefs = useRef(new Map<string, HTMLButtonElement>());\n  const sceneRef = useRef({\n    bumpUrl,\n    countriesUrl,\n    glow,\n    glowAltitude,\n    glowColor,\n    latencies,\n    onValueChange,\n    regions,\n    spin,\n    textureUrl,\n    tilt,\n    value,\n    variant,\n  });\n  const syncRef = useRef<((transitionMs: number) => void) | null>(null);\n  const pingSyncRef = useRef<(() => void) | null>(null);\n  const themeSyncRef = useRef<(() => void) | null>(null);\n\n  useEffect(() => {\n    sceneRef.current = {\n      bumpUrl,\n      countriesUrl,\n      glow,\n      glowAltitude,\n      glowColor,\n      latencies,\n      onValueChange,\n      regions,\n      spin,\n      textureUrl,\n      tilt,\n      value,\n      variant,\n    };\n  });\n\n  // Build once (dynamic import keeps SSR clean), then keep the\n  // instance and mutate it from the sync effects below.\n  useEffect(() => {\n    const stage = stageRef.current;\n    const probe = probeRef.current;\n\n    if (!(stage && probe)) {\n      return;\n    }\n\n    const reduceMotion = window.matchMedia(\n      \"(prefers-reduced-motion: reduce)\"\n    ).matches;\n    let disposed = false;\n    let resizeObserver: ResizeObserver | null = null;\n    let themeObserver: MutationObserver | null = null;\n    let markerRgb: [number, number, number] = [128, 128, 128];\n    let lastTravelId = sceneRef.current.value;\n    const timeouts = new Set<number>();\n\n    const schedule = (run: () => void, delay: number) => {\n      const timeout = window.setTimeout(() => {\n        timeouts.delete(timeout);\n        run();\n      }, delay);\n      timeouts.add(timeout);\n    };\n\n    /** Nearest ancestor that would clip the pill. */\n    const clipAncestorOf = (element: HTMLElement) => {\n      let node = element.parentElement;\n\n      while (node && node !== document.body) {\n        const { overflow, overflowX, overflowY } = getComputedStyle(node);\n\n        if (/hidden|clip|auto|scroll/u.test(overflow + overflowX + overflowY)) {\n          return node;\n        }\n\n        node = node.parentElement;\n      }\n\n      return null;\n    };\n\n    /** Flips the pill below the dot and shifts it inward when the\n     * default above-centered spot would clip at a panel edge. */\n    const placePill = (button: HTMLButtonElement) => {\n      const pill = button.querySelector(\"[data-pill]\");\n      const clip = clipAncestorOf(button);\n\n      if (!(pill instanceof HTMLElement && clip)) {\n        return;\n      }\n\n      requestAnimationFrame(() => {\n        const pillRect = pill.getBoundingClientRect();\n        const clipRect = clip.getBoundingClientRect();\n        const PAD = 6;\n\n        if (pillRect.top < clipRect.top + PAD) {\n          pill.style.bottom = \"auto\";\n          pill.style.top = \"100%\";\n          pill.style.marginBottom = \"0\";\n          pill.style.marginTop = \"0.375rem\";\n        }\n\n        let shift = 0;\n\n        if (pillRect.left < clipRect.left + PAD) {\n          shift = clipRect.left + PAD - pillRect.left;\n        } else if (pillRect.right > clipRect.right - PAD) {\n          shift = clipRect.right - PAD - pillRect.right;\n        }\n\n        if (shift !== 0) {\n          pill.style.transform = `translateX(calc(-50% + ${shift}px))`;\n        }\n      });\n    };\n\n    const makeMarker = (region: ServerRegion) => {\n      const button = document.createElement(\"button\");\n\n      button.type = \"button\";\n      button.tabIndex = -1;\n      button.setAttribute(\"aria-hidden\", \"true\");\n      button.dataset.slot = \"region-globe-marker\";\n      // No self-centering transform: globe.gl's CSS2D layer already\n      // anchors the element's center on the coordinate — adding our\n      // own -50% translate double-shifts dots off arcs and rings.\n      button.className =\n        \"group pointer-events-auto grid size-5 cursor-pointer place-items-center rounded-full disabled:pointer-events-none disabled:opacity-40\";\n      button.disabled = Boolean(region.disabled);\n      const label = region.provider\n        ? `${region.provider} ${region.name}`\n        : region.name;\n      button.innerHTML =\n        '<span data-halo class=\"absolute hidden size-3 rounded-full bg-primary/60 [animation-iteration-count:3] motion-safe:animate-ping\"></span>' +\n        '<span data-dot class=\"relative size-2 rounded-full bg-primary transition-transform duration-150 group-hover:scale-125\"></span>' +\n        '<span data-pill class=\"pointer-events-none absolute bottom-full left-1/2 z-10 mb-1.5 hidden -translate-x-1/2 whitespace-nowrap rounded-md bg-popover px-2 py-1 text-left text-popover-foreground text-xs ring-1 ring-border/60 group-hover:block\">' +\n        `${label}` +\n        `<span class=\"block font-mono text-[10px] text-muted-foreground\">${region.id}` +\n        '<span data-ping class=\"ml-1 font-mono text-[10px]\"></span>' +\n        \"</span></span>\";\n      button.addEventListener(\"click\", () => {\n        sceneRef.current.onValueChange?.(region.id);\n      });\n      button.addEventListener(\"mouseenter\", () => {\n        placePill(button);\n\n        // Freeze the idle spin while aiming — targets must not slide\n        // out from under the pointer.\n        const controls = globeRef.current?.controls();\n\n        if (controls) {\n          controls.autoRotate = false;\n        }\n      });\n      button.addEventListener(\"mouseleave\", () => {\n        const pill = button.querySelector(\"[data-pill]\");\n\n        if (pill instanceof HTMLElement) {\n          pill.removeAttribute(\"style\");\n        }\n\n        const controls = globeRef.current?.controls();\n\n        if (controls) {\n          controls.autoRotate = sceneRef.current.spin && !reduceMotion;\n        }\n      });\n      markerRefs.current.set(region.id, button);\n\n      return button;\n    };\n\n    const applyTheme = () => {\n      const globe = globeRef.current;\n\n      if (!globe) {\n        return;\n      }\n\n      const scene = sceneRef.current;\n      const theme = probeTheme(probe);\n      const material = globe.globeMaterial() as {\n        color?: { set: (color: string) => void };\n        opacity?: number;\n        transparent?: boolean;\n      };\n\n      material.transparent = true;\n\n      if (scene.variant === \"relief\") {\n        // The grayscale relief is tinted by muted-foreground: light\n        // relief on dark themes, dark relief on light — always B&W.\n        material.opacity = theme.dark ? 0.9 : 0.96;\n        material.color?.set(theme.dot);\n      } else {\n        // Dots and outlines sit on a plain sphere in the surface tone.\n        material.opacity = theme.dark ? 0.72 : 0.94;\n        material.color?.set(theme.sphere);\n      }\n\n      markerRgb = parseRgb(theme.marker);\n      globe\n        .showAtmosphere(scene.glow)\n        .atmosphereColor(scene.glowColor ?? theme.marker)\n        .atmosphereAltitude(scene.glowAltitude)\n        .pointColor(() => theme.dot)\n        .polygonStrokeColor(() => theme.dot);\n    };\n\n    const applySize = () => {\n      const globe = globeRef.current;\n      const width = stage.clientWidth;\n\n      if (globe && width) {\n        globe.width(width).height(width);\n      }\n    };\n\n    /** One dashed arc + ripple rings from the previous region. */\n    const emitTravel = (from: ServerRegion, to: ServerRegion) => {\n      const globe = globeRef.current;\n\n      if (!globe || reduceMotion) {\n        return;\n      }\n\n      const arc = {\n        endLat: to.lat,\n        endLng: to.lng,\n        startLat: from.lat,\n        startLng: from.lng,\n      };\n\n      globe.arcsData([...globe.arcsData(), arc]);\n      // Die right as the pulse lands — a lingering arc restarts its\n      // dash cycle and reads as a second, phantom trip.\n      schedule(\n        () => {\n          globe.arcsData(globe.arcsData().filter((d) => d !== arc));\n        },\n        FLY_MS * (1 + ARC_REL_LEN)\n      );\n\n      const emitRings = (lat: number, lng: number) => {\n        const ring = { lat, lng };\n\n        globe.ringsData([...globe.ringsData(), ring]);\n        schedule(() => {\n          globe.ringsData(globe.ringsData().filter((d) => d !== ring));\n        }, FLY_MS * ARC_REL_LEN);\n      };\n\n      emitRings(from.lat, from.lng);\n      schedule(() => emitRings(to.lat, to.lng), FLY_MS);\n    };\n\n    const syncSelection = (transitionMs: number) => {\n      const globe = globeRef.current;\n      const scene = sceneRef.current;\n      const selected = scene.regions.find(\n        (region) => region.id === scene.value\n      );\n\n      if (selected && scene.value !== lastTravelId) {\n        const from = scene.regions.find((region) => region.id === lastTravelId);\n\n        lastTravelId = scene.value;\n\n        if (from && transitionMs > 0) {\n          emitTravel(from, selected);\n        }\n      }\n\n      for (const [id, element] of markerRefs.current) {\n        const isSelected = id === scene.value;\n        const halo = element.querySelector(\"[data-halo]\");\n        const dot = element.querySelector(\"[data-dot]\");\n\n        halo?.classList.toggle(\"hidden\", !isSelected);\n        dot?.classList.toggle(\"scale-125\", isSelected);\n        dot?.classList.toggle(\"bg-primary\", isSelected);\n        dot?.classList.toggle(\"bg-muted-foreground\", !isSelected);\n      }\n\n      if (globe && selected && !scene.spin) {\n        globe.pointOfView(\n          {\n            altitude: CAMERA_ALTITUDE,\n            lat: selected.lat - scene.tilt,\n            lng: selected.lng,\n          },\n          reduceMotion ? 0 : transitionMs\n        );\n      }\n    };\n\n    const build = async () => {\n      const { default: Globe } = await import(\"globe.gl\");\n\n      if (disposed) {\n        return;\n      }\n\n      const globe = new Globe(stage, {\n        animateIn: false,\n        rendererConfig: { alpha: true, antialias: true },\n      });\n\n      globeRef.current = globe;\n      globe\n        .backgroundColor(\"rgba(0,0,0,0)\")\n        .showGlobe(true)\n        .showGraticules(false)\n        .arcColor(() => `rgb(${markerRgb.join(\",\")})`)\n        .arcDashLength(ARC_REL_LEN)\n        .arcDashGap(2)\n        .arcDashInitialGap(1)\n        .arcDashAnimateTime(FLY_MS)\n        .arcsTransitionDuration(0)\n        .arcStroke(0.35)\n        .ringColor(() => (t: number) => `rgba(${markerRgb.join(\",\")},${1 - t})`)\n        .ringMaxRadius(TRAVEL_RINGS_MAX_R)\n        .ringPropagationSpeed(TRAVEL_RING_SPEED)\n        .ringRepeatPeriod(TRAVEL_RING_REPEAT_MS)\n        .htmlElementsData(sceneRef.current.regions)\n        .htmlLat((d) => (d as ServerRegion).lat)\n        .htmlLng((d) => (d as ServerRegion).lng)\n        .htmlAltitude(0.002)\n        .htmlTransitionDuration(0)\n        // Occlusion: markers on the far hemisphere fade out instead of\n        // floating ghost-like over the dark side of the sphere.\n        .htmlElementVisibilityModifier((element, isVisible) => {\n          element.style.opacity = isVisible ? \"1\" : \"0\";\n          element.style.pointerEvents = isVisible ? \"\" : \"none\";\n          element.style.transition = \"opacity 150ms ease\";\n        })\n        .htmlElement((d) => makeMarker(d as ServerRegion));\n\n      // Land treatment per variant.\n      const { variant: landVariant } = sceneRef.current;\n\n      if (landVariant === \"dots\") {\n        globe\n          .pointsData(landDots())\n          .pointsMerge(true)\n          .pointAltitude(0.002)\n          .pointRadius(LAND_DOT_RADIUS);\n      } else if (landVariant === \"outlines\") {\n        globe\n          .polygonCapColor(() => \"rgba(0,0,0,0)\")\n          .polygonSideColor(() => \"rgba(0,0,0,0)\")\n          .polygonAltitude(OUTLINE_ALTITUDE)\n          .polygonsTransitionDuration(0);\n        void (async () => {\n          const features = await loadCountries(sceneRef.current.countriesUrl);\n\n          if (!disposed && globeRef.current === globe) {\n            globe.polygonsData(features);\n          }\n        })();\n      } else {\n        globe\n          .globeImageUrl(sceneRef.current.textureUrl)\n          .bumpImageUrl(sceneRef.current.bumpUrl);\n      }\n\n      const controls = globe.controls();\n\n      controls.enableZoom = false;\n      controls.enablePan = false;\n      controls.autoRotate = sceneRef.current.spin && !reduceMotion;\n      controls.autoRotateSpeed = IDLE_SPIN_SPEED;\n\n      applySize();\n      applyTheme();\n      syncSelection(0);\n      pingSyncRef.current?.();\n\n      resizeObserver = new ResizeObserver(applySize);\n      resizeObserver.observe(stage);\n      themeObserver = new MutationObserver(applyTheme);\n      themeObserver.observe(document.documentElement, {\n        attributeFilter: [\"class\", \"data-theme\", \"style\"],\n        attributes: true,\n      });\n    };\n\n    const syncPings = () => {\n      for (const [id, element] of markerRefs.current) {\n        const ping = sceneRef.current.latencies?.[id];\n        const target = element.querySelector(\"[data-ping]\");\n\n        if (target instanceof HTMLElement) {\n          if (typeof ping === \"number\") {\n            target.textContent = `· ping: ${ping} ms`;\n            target.className = `ml-1 font-mono text-[10px] ${pingTone(ping)}`;\n          } else {\n            target.textContent = \"\";\n          }\n        }\n      }\n    };\n\n    syncRef.current = syncSelection;\n    pingSyncRef.current = syncPings;\n    themeSyncRef.current = applyTheme;\n\n    void build();\n\n    const markers = markerRefs.current;\n\n    return () => {\n      disposed = true;\n\n      for (const timeout of timeouts) {\n        window.clearTimeout(timeout);\n      }\n\n      timeouts.clear();\n      resizeObserver?.disconnect();\n      themeObserver?.disconnect();\n      markers.clear();\n      globeRef.current?._destructor();\n      globeRef.current = null;\n    };\n  }, []);\n\n  // Selection changed: restyle markers and fly the camera.\n  useEffect(() => {\n    syncRef.current?.(FLY_MS);\n  }, [value, regions]);\n\n  // Glow knobs changed: re-apply theme-driven config in place.\n  useEffect(() => {\n    themeSyncRef.current?.();\n  }, [glow, glowColor, glowAltitude]);\n\n  // Fresh latencies: rewrite the popover ping lines in place.\n  useEffect(() => {\n    pingSyncRef.current?.();\n  }, [latencies]);\n\n  // Spin toggled: hand control between idle rotation and the camera.\n  useEffect(() => {\n    const controls = globeRef.current?.controls();\n    const reduceMotion = window.matchMedia(\n      \"(prefers-reduced-motion: reduce)\"\n    ).matches;\n\n    if (controls) {\n      controls.autoRotate = spin && !reduceMotion;\n    }\n\n    if (!spin) {\n      syncRef.current?.(FLY_MS);\n    }\n  }, [spin]);\n\n  return (\n    <div\n      className={cn(\"relative w-full\", className)}\n      data-slot=\"region-globe\"\n      ref={containerRef}\n      {...props}\n    >\n      <div aria-hidden=\"true\" className=\"hidden\" ref={probeRef}>\n        <span className=\"probe-base text-muted-foreground\" />\n        <span className=\"probe-glow text-background\" />\n        <span className=\"probe-marker text-primary\" />\n      </div>\n      <div\n        aria-hidden=\"true\"\n        className=\"aspect-square w-full [&_canvas]:!h-full [&_canvas]:!w-full [&_div]:!overflow-visible\"\n        ref={stageRef}\n      />\n    </div>\n  );\n};\n",
      "type": "registry:component"
    }
  ],
  "type": "registry:component"
}