{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "globe",
  "title": "Globe",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "An interactive WebGL dot globe (cobe) with markers, drag-to-spin momentum and arrow-key control. Colors come from theme tokens and repaint on theme change; starts lazily and pauses off-screen.",
  "dependencies": [
    "cobe@^0.6"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/color.json",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/globe.tsx",
      "content": "// Ballmac UI: Globe. https://ui.ballmac.com/components/globe\n\"use client\"\n\nimport * as React from \"react\"\nimport createGlobe from \"cobe\"\n\nimport { cn } from \"@/lib/utils\"\nimport { cssColorToRgba, observeTheme } from \"@/lib/ballmac/color\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\n\ntype GlobeMarker = {\n  /** [latitude, longitude] in degrees. */\n  location: [number, number]\n  /** Dot size, roughly 0.02–0.1. */\n  size: number\n}\n\ntype GlobeProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** Points drawn on the globe, such as regions or offices. */\n  markers?: GlobeMarker[]\n  /** Color of the land dots. A CSS variable name (\"--muted-foreground\") or any CSS color. */\n  baseColor?: string\n  /** Color of the markers. */\n  markerColor?: string\n  /** Color of the atmosphere glow around the edge. */\n  glowColor?: string\n  /** Radians per frame of automatic rotation. Off under reduced motion. */\n  speed?: number\n  /** Starting longitude rotation in radians. */\n  phi?: number\n  /** Tilt toward the viewer in radians. */\n  theta?: number\n  /** Brightness of land dots (cobe mapBrightness). */\n  mapBrightness?: number\n  /** Number of land dots sampled on the sphere; higher is finer and slower. */\n  mapSamples?: number\n  /** Let people rotate the globe by dragging or with the arrow keys. */\n  interactive?: boolean\n  /** Accessible description of what the globe shows. */\n  label?: string\n}\n\nconst TAU = Math.PI * 2\n\n/** True when the page background is dark, so the globe switches to its dark shading. */\nfunction isDarkSurface(element: Element) {\n  const [r, g, b] = cssColorToRgba(element, \"--background\")\n  return 0.2126 * r + 0.7152 * g + 0.0722 * b < 0.5\n}\n\nfunction Globe({\n  markers = [],\n  baseColor,\n  markerColor = \"--chart-1\",\n  glowColor,\n  speed = 0.0032,\n  phi = 0,\n  theta = 0.28,\n  mapBrightness,\n  mapSamples = 16000,\n  interactive = true,\n  label,\n  className,\n  ...props\n}: GlobeProps) {\n  const msg = useMessages()\n  label ??= msg(\"globe.label\", \"Rotating globe\")\n  const canvasRef = React.useRef<HTMLCanvasElement>(null)\n  // Rotation lives in refs so pointer input never re-renders the component.\n  const motion = React.useRef({ phi, theta, velocity: 0, thetaVelocity: 0, dragging: false, lastX: 0, lastY: 0 })\n  const [ready, setReady] = React.useState(false)\n  const [themeKey, setThemeKey] = React.useState(0)\n  const markersKey = JSON.stringify(markers)\n\n  React.useEffect(() => observeTheme(() => setThemeKey((k) => k + 1)), [])\n\n  React.useEffect(() => {\n    const canvas = canvasRef.current\n    if (!canvas) return\n    const reduceMotion = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n    const dark = isDarkSurface(canvas)\n    const rgb = (color: string) => cssColorToRgba(canvas, color).slice(0, 3) as [number, number, number]\n    const base = rgb(baseColor ?? (dark ? \"--muted-foreground\" : \"--background\"))\n    // Default glow: a soft tint of --chart-1, dimmed on dark surfaces and lifted toward the page on light ones.\n    const tint = rgb(\"--chart-1\")\n    const page = rgb(\"--background\")\n    const glow = glowColor\n      ? rgb(glowColor)\n      : (tint.map((c, i) => (dark ? c * 0.6 : page[i]! * 0.75 + c * 0.25)) as [number, number, number])\n    const state = motion.current\n    const dpr = Math.min(window.devicePixelRatio || 1, 2)\n    let width = canvas.offsetWidth\n\n    let globe: ReturnType<typeof createGlobe> | null = null\n    let started = false\n    let visible = false\n    let firstFrame = true\n\n    const start = () => {\n      if (started) return\n      started = true\n      width = canvas.offsetWidth\n      try {\n        globe = createGlobe(canvas, {\n          devicePixelRatio: dpr,\n          width: width * dpr,\n          height: width * dpr,\n          phi: state.phi,\n          theta: state.theta,\n          dark: dark ? 1 : 0,\n          diffuse: dark ? 1.4 : 1.1,\n          mapSamples,\n          mapBrightness: mapBrightness ?? (dark ? 5 : 1.6),\n          mapBaseBrightness: dark ? 0.02 : 0,\n          baseColor: dark ? base.map((c) => c * 0.55) as [number, number, number] : base,\n          // cobe renders markers darker on light globes; lift them toward white to keep the token hue readable.\n          markerColor: dark ? rgb(markerColor) : (rgb(markerColor).map((c) => c * 0.85 + 0.15) as [number, number, number]),\n          glowColor: glow,\n          opacity: dark ? 0.9 : 1,\n          markers: JSON.parse(markersKey) as GlobeMarker[],\n          onRender: (frame) => {\n            if (!state.dragging) {\n              if (!reduceMotion) state.phi += speed\n              state.phi += state.velocity\n              state.velocity *= 0.94\n              // Tilt springs back toward its resting angle.\n              state.thetaVelocity += (theta - state.theta) * 0.02\n              state.thetaVelocity *= 0.82\n              state.theta += state.thetaVelocity\n            }\n            state.phi %= TAU\n            frame.phi = state.phi\n            frame.theta = state.theta\n            frame.width = width * dpr\n            frame.height = width * dpr\n            if (firstFrame) {\n              firstFrame = false\n              setReady(true)\n            }\n          },\n        })\n        globe.toggle(visible && !document.hidden)\n      } catch {\n        // No WebGL (old browsers, tests): leave the empty, labelled canvas.\n      }\n    }\n\n    const sync = () => globe?.toggle(visible && !document.hidden)\n    const io = new IntersectionObserver(\n      ([entry]) => {\n        visible = entry?.isIntersecting ?? true\n        if (visible) start()\n        sync()\n      },\n      { rootMargin: \"120px\" }\n    )\n    io.observe(canvas)\n    const ro = new ResizeObserver(() => {\n      width = canvas.offsetWidth\n    })\n    ro.observe(canvas)\n    document.addEventListener(\"visibilitychange\", sync)\n    return () => {\n      io.disconnect()\n      ro.disconnect()\n      document.removeEventListener(\"visibilitychange\", sync)\n      globe?.destroy()\n    }\n  }, [themeKey, markersKey, baseColor, markerColor, glowColor, speed, theta, mapBrightness, mapSamples])\n\n  const onPointerDown = (event: React.PointerEvent<HTMLCanvasElement>) => {\n    if (!interactive) return\n    const state = motion.current\n    state.dragging = true\n    state.velocity = 0\n    state.lastX = event.clientX\n    state.lastY = event.clientY\n    event.currentTarget.setPointerCapture(event.pointerId)\n  }\n  const onPointerMove = (event: React.PointerEvent<HTMLCanvasElement>) => {\n    const state = motion.current\n    if (!state.dragging) return\n    const size = event.currentTarget.offsetWidth || 1\n    const dx = ((event.clientX - state.lastX) / size) * Math.PI\n    const dy = ((event.clientY - state.lastY) / size) * Math.PI\n    state.lastX = event.clientX\n    state.lastY = event.clientY\n    state.phi += dx\n    // Blend in the latest movement so a flick carries momentum after release.\n    state.velocity = state.velocity * 0.5 + dx * 0.5\n    state.theta = Math.min(Math.max(state.theta + dy * 0.5, -0.6), 0.9)\n  }\n  const onPointerUp = (event: React.PointerEvent<HTMLCanvasElement>) => {\n    motion.current.dragging = false\n    if (event.currentTarget.hasPointerCapture(event.pointerId)) {\n      event.currentTarget.releasePointerCapture(event.pointerId)\n    }\n  }\n  const onKeyDown = (event: React.KeyboardEvent<HTMLCanvasElement>) => {\n    if (!interactive) return\n    const state = motion.current\n    if (event.key === \"ArrowLeft\") state.velocity -= 0.04\n    else if (event.key === \"ArrowRight\") state.velocity += 0.04\n    else if (event.key === \"ArrowUp\") state.thetaVelocity -= 0.04\n    else if (event.key === \"ArrowDown\") state.thetaVelocity += 0.04\n    else return\n    event.preventDefault()\n  }\n\n  return (\n    <div data-slot=\"globe\" className={cn(\"relative mx-auto aspect-square w-full max-w-[600px]\", className)} {...props}>\n      <canvas\n        ref={canvasRef}\n        data-slot=\"globe-canvas\"\n        role=\"img\"\n        aria-label={interactive ? `${label}. Drag or use the arrow keys to rotate.` : label}\n        tabIndex={interactive ? 0 : undefined}\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={onPointerUp}\n        onPointerCancel={onPointerUp}\n        onKeyDown={onKeyDown}\n        className={cn(\n          \"size-full rounded-full opacity-0 outline-none transition-opacity duration-700 ease-out [contain:layout_paint_size] focus-visible:ring-[3px] focus-visible:ring-ring/50\",\n          ready && \"opacity-100\",\n          interactive && \"cursor-grab touch-pan-y active:cursor-grabbing\"\n        )}\n      />\n    </div>\n  )\n}\n\nexport { Globe, type GlobeProps, type GlobeMarker }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/globe.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "globe",
      "webgl",
      "map",
      "regions",
      "3d",
      "hero",
      "interactive"
    ],
    "version": "1.0.0",
    "updated": "2026-09-29",
    "ai": {
      "summary": "Render <Globe markers={[{ location: [lat, lng], size: 0.06 }]} /> in a sized container (it is a square, w-full, max 600px). The canvas has role=img and an aria-label; pass label to describe what it shows. It starts WebGL only when scrolled near the viewport.",
      "whenToUse": [
        "Showing global reach: regions, edge locations, offices, customers by country",
        "A hero or feature-card visual for infrastructure, networking or travel products"
      ],
      "whenNotToUse": [
        "Precise geographic data or choropleths (use a real map library)",
        "Several globes on one page; each is a WebGL context"
      ],
      "composesWith": [
        "number-ticker",
        "spotlight-card",
        "badge"
      ],
      "a11y": [
        {
          "keys": "Arrow Left / Arrow Right",
          "action": "Spins the globe"
        },
        {
          "keys": "Arrow Up / Arrow Down",
          "action": "Tilts the globe; it settles back"
        }
      ],
      "customization": [
        "markers: [{ location: [lat, lng], size }]",
        "markerColor (default --chart-1), baseColor and glowColor: CSS variable names or any CSS color; defaults adapt to light and dark",
        "speed (radians per frame, 0 to stop), phi, theta, mapBrightness, mapSamples",
        "interactive={false} for a purely decorative globe"
      ]
    },
    "examples": [
      "globe-demo",
      "globe-hero"
    ],
    "url": "https://ui.ballmac.com/components/globe"
  },
  "categories": [
    "backgrounds"
  ],
  "type": "registry:ui"
}