{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "globe",
  "title": "Globe",
  "description": "Dotted canvas globe with markers, great-circle arcs, drag and keyboard rotation.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/components/velora/globe.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\n// Land mask derived from Natural Earth (public domain): 240×120 cells, run-length encoded.\nconst LAND = \"_0_0_0_0_0_0_0_0_0_0_0_0_0_0_0_0HDBH_0_0_0211r_0_0wI1ZI118D3R6_0Z143121D2ZK8m7_0Q42284184ZM322S1M412_0M128231B7Xp7EHF611w522ABFPn4EL21G141uE271ADMo3833Y76L1D4J31A252DANU4E46$D1BG3A2B162D8LSBH32_0416127h1814387ISH222G1_0D4u585EB1J_0m152v3A79A7G8181_0W615n14212688C5F_0pAo513413A6V92_0h9mB6D5V92_0R29H6116WA622j93_0P33P4BU9Bb375311_0FA5N3GV7Ba3663_0FC5L2KX2FW21352112_0HC5h11X1GU3142_0U83mmU2241_0V82mk12X_0byf44W_0aze44X_0W12zhdL13182v_02c12fA1931272v33yaf85318852u34yaf841141K2q61_Yh65232131G2j1572_Wi683322F3h4371_01Wi52621422_012343_02VkBF11u4226_04RkDGw55_08PkH337w52_0BOl_0P_0J11G32ld1n_0I21A92ig1l_0K218A2hX1A211g_0M217tY1A12231Z11_0M12693gZ2E5X21_0P66143ea1E6E1Fy1W7336212aa1D9A4921_0X7146123Za2BA95912_0Z9mb29C68982_0U9jc17E5911871_0Y7hjG5B872_0a4ifK4B87111_0a354Ze23G4B2158111_0a2211BUiH3B231B1_0cHUhH112A2E4_0eEUfK2B2E2_0fHS73UU22274_0jIeQW21264_0jKdPY545_0jKdOa3371321_0bNbMc4351352_0YQYMd425136213_0TTWKf3521321111941_0LVVIh2A19713_0LVVIi5H741_0KUVJk411113177_0PTWJq122E2_0PSXIz1_0bQXJ51s432_0UPYJ52o11533_0UOYJ34n923E1_0HMXJ34mHK1_0BLYG54mH_0WKZG54jL_0VKaF53iOA1_0JJbF53iP_0TGeE63hR_0RGgCsQ_0RGgCsQ_0RFiAtR_0QFiAuP_0REk8v92E_0RDl6x67B_0RB_0r1CAE1_0CB_0_057G1_0BB_0_065H3_098_0_0V2_098_0_0C3F4_098_0_0D2F2_0B7_0_0T3_0C5_0_0U3_0D6_0_0_0i7_0R1_0_0H5_0_0_0k452_0_0_0f4_0_0_0m4_0_0_0_0_0_0_0_0_0_0_0_0_0_0_0_0_0_0_0_0_0_0w2_0_0_0k2_0_0_0l3_0C6G82W_0Z2_08I6n_0S6l131135S2w_0K9Yr2_03v45398U_0zYI5SU_0yUtQ_0_01OvR_0_05M31sH55_0_09Up44477_0_03T_014_0_0JT_0_0_0Q7E141_0_0_0_0_0_0_0_0_0_0_0_0_0_0w\";\nconst RLE = \"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz$_\";\nlet mask: Uint8Array | undefined;\nconst isLand = (lat: number, lng: number) => {\n  if (!mask) {\n    mask = new Uint8Array(28800);\n    let p = 0;\n    let v = 0;\n    for (const c of LAND) {\n      mask.fill(v, p, (p += RLE.indexOf(c)));\n      v ^= 1;\n    }\n  }\n  return mask[((90 - lat) / 1.5 | 0) * 240 + ((lng + 180) / 1.5 | 0)];\n};\n\nconst RAD = Math.PI / 180;\nconst vec = (lat: number, lng: number) => {\n  const c = Math.cos(lat * RAD);\n  return [c * Math.sin(lng * RAD), Math.sin(lat * RAD), c * Math.cos(lng * RAD)];\n};\n\nexport interface GlobeMarker {\n  /** Latitude in degrees */\n  lat: number;\n  /** Longitude in degrees */\n  lng: number;\n  /** Name drawn beside the marker and included in the accessible name */\n  label?: string;\n}\n\nexport interface GlobeArc {\n  /** Start as [lat, lng] */\n  from: [number, number];\n  /** End as [lat, lng] */\n  to: [number, number];\n}\n\ninterface GlobeProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** Highlighted points, as { lat, lng, label? } */\n  markers?: GlobeMarker[];\n  /** Great-circle connections, as { from: [lat, lng], to: [lat, lng] } */\n  arcs?: GlobeArc[];\n  /** [lat, lng] facing the viewer on first paint */\n  center?: [number, number];\n  /** Auto-rotation in degrees per second (negative spins west); 0 turns it off */\n  speed?: number;\n  /** Points sampled over the sphere; roughly 3 in 10 land and become dots */\n  samples?: number;\n  /** Land dot color: any CSS color, var() tokens included */\n  dotColor?: string;\n  /** Marker and arc color: any CSS color, var() tokens included */\n  accentColor?: string;\n  /** Accessible name; generated from marker labels when omitted */\n  label?: string;\n}\n\n/**\n * Dotted 2D-canvas globe with markers and animated great-circle arcs.\n * Drag (with inertia) or use the arrow keys to rotate; auto-rotation pauses\n * while hovered or focused. Give it a width — height follows (it is square).\n */\nexport function Globe({\n  markers = [],\n  arcs = [],\n  center = [20, 10],\n  speed = 6,\n  samples = 12000,\n  dotColor = \"var(--muted-foreground)\",\n  accentColor = \"var(--brand)\",\n  label,\n  className,\n  ...props\n}: GlobeProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const view = useRef<{ lat: number; lng: number; vx: number; vy: number }>(null);\n  const [lat0, lng0] = center;\n\n  useEffect(() => {\n    const root = rootRef.current;\n    const canvas = canvasRef.current;\n    const ctx = canvas?.getContext(\"2d\");\n    if (!root || !canvas || !ctx) return;\n    const v = (view.current ??= { lat: lat0, lng: lng0, vx: 0, vy: 0 });\n    const reduced = matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n    // Fibonacci lattice: evenly spaced samples, kept where the mask says land.\n    const dots: number[] = [];\n    for (let i = 0; i < samples; i++) {\n      const lat = Math.asin(1 - (2 * i + 1) / samples) / RAD;\n      const lng = ((i * 137.508) % 360) - 180;\n      if (isLand(lat, lng)) dots.push(...vec(lat, lng));\n    }\n    const pins = markers.map((m) => vec(m.lat, m.lng));\n    const S = 40;\n    const paths = arcs.map(({ from, to }) => {\n      const a = vec(...from);\n      const b = vec(...to);\n      const w = Math.acos(Math.min(1, a[0] * b[0] + a[1] * b[1] + a[2] * b[2]));\n      const pts: number[][] = [];\n      for (let s = 0; s <= S && w > 1e-3; s++) {\n        const t = s / S;\n        const k = (1 + Math.min(w, 1.6) * 0.12 * Math.sin(Math.PI * t)) / Math.sin(w);\n        const p = Math.sin((1 - t) * w) * k;\n        const q = Math.sin(t * w) * k;\n        pts.push([a[0] * p + b[0] * q, a[1] * p + b[1] * q, a[2] * p + b[2] * q]);\n      }\n      return pts;\n    });\n\n    let w = 0, h = 0, R = 0, raf = 0, last = 0;\n    let inView = true, hover = false, focus = false;\n    let drag: { x: number; y: number } | null = null;\n    let dot = \"\", accent = \"\", text = \"\", font = \"\";\n\n    const resolve = () => {\n      const cs = getComputedStyle(root);\n      canvas.style.color = dotColor;\n      dot = getComputedStyle(canvas).color;\n      canvas.style.color = accentColor;\n      accent = getComputedStyle(canvas).color;\n      text = cs.color;\n      font = `500 11px ${cs.fontFamily}`;\n    };\n\n    const draw = (t: number) => {\n      const cl = Math.cos(v.lng * RAD), sl = Math.sin(v.lng * RAD);\n      const ct = Math.cos(v.lat * RAD), st = Math.sin(v.lat * RAD);\n      const cx = w / 2, cy = h / 2;\n      // Rotate so (v.lat, v.lng) faces the viewer; returns [x, y, depth].\n      const proj = (x: number, y: number, z: number) => {\n        const z0 = x * sl + z * cl;\n        return [x * cl - z * sl, y * ct - z0 * st, y * st + z0 * ct];\n      };\n      const disc = (x: number, y: number, r: number) => {\n        ctx.beginPath();\n        ctx.arc(cx + x * R, cy - y * R, r, 0, 7);\n      };\n      ctx.clearRect(0, 0, w, h);\n      ctx.fillStyle = dot;\n      const r = Math.max(0.8, R / 130);\n      for (let i = 0; i < dots.length; i += 3) {\n        const [x, y, z] = proj(dots[i], dots[i + 1], dots[i + 2]);\n        if (z <= 0) continue;\n        ctx.globalAlpha = 0.12 + 0.78 * z;\n        disc(x, y, r * (0.6 + 0.4 * z));\n        ctx.fill();\n      }\n\n      ctx.fillStyle = ctx.strokeStyle = accent;\n      ctx.lineWidth = 1.5;\n      ctx.lineCap = \"round\";\n      paths.forEach((pts, k) => {\n        const head = reduced ? 2 : ((t / 2.8 + k * 0.37) % 1.5) * 1.1;\n        const seen = pts.map((p) => {\n          const q = proj(p[0], p[1], p[2]);\n          return q[2] > 0 || q[0] * q[0] + q[1] * q[1] > 1 ? q : null;\n        });\n        for (let s = 0; s < S; s++) {\n          const a = seen[s], b = seen[s + 1];\n          if (!a || !b) continue;\n          const d = head - s / S;\n          ctx.globalAlpha = reduced ? 0.7 : d > 0 && d < 0.35 ? 1 - d / 0.35 : 0.2;\n          ctx.beginPath();\n          ctx.moveTo(cx + a[0] * R, cy - a[1] * R);\n          ctx.lineTo(cx + b[0] * R, cy - b[1] * R);\n          ctx.stroke();\n        }\n        const tip = seen[Math.round(head * S)];\n        if (tip && head <= 1) {\n          ctx.globalAlpha = 1;\n          disc(tip[0], tip[1], 2.2);\n          ctx.fill();\n        }\n        for (const e of [seen[0], seen[S]]) {\n          if (!e) continue;\n          ctx.globalAlpha = 0.9;\n          disc(e[0], e[1], 2);\n          ctx.fill();\n        }\n      });\n\n      ctx.font = font;\n      pins.forEach((p, k) => {\n        const [x, y, z] = proj(p[0], p[1], p[2]);\n        if (z <= 0) return;\n        const fade = Math.min(1, z * 4);\n        ctx.globalAlpha = fade;\n        disc(x, y, 3);\n        ctx.fill();\n        const ph = reduced ? 0.4 : (t * 0.6 + k * 0.29) % 1;\n        ctx.globalAlpha = fade * (1 - ph) * 0.7;\n        disc(x, y, 3 + ph * 11);\n        ctx.stroke();\n        const name = markers[k].label;\n        if (name && z > 0.3) {\n          ctx.globalAlpha = Math.min(1, (z - 0.3) * 4) * 0.85;\n          ctx.fillStyle = text;\n          ctx.fillText(name, cx + x * R + 8, cy - y * R + 4);\n          ctx.fillStyle = accent;\n        }\n      });\n      ctx.globalAlpha = 1;\n    };\n\n    const moving = () => drag || Math.abs(v.vx) + Math.abs(v.vy) > 0.01;\n    const frame = (now: number) => {\n      raf = 0;\n      const dt = last ? Math.min(0.05, (now - last) / 1000) : 0;\n      if (!drag) {\n        v.lng += v.vx;\n        v.lat += v.vy;\n        v.vx *= 0.9;\n        v.vy *= 0.9;\n        if (!reduced && !hover && !focus) v.lng -= speed * dt;\n      }\n      v.lat = Math.max(-70, Math.min(70, v.lat));\n      draw(now / 1000);\n      last = now;\n      if (inView && !document.hidden && (!reduced || moving())) {\n        raf = requestAnimationFrame(frame);\n      } else last = 0;\n    };\n    const kick = () => {\n      if (!raf) raf = requestAnimationFrame(frame);\n    };\n\n    const resize = () => {\n      const dpr = Math.min(devicePixelRatio || 1, 2);\n      w = root.clientWidth;\n      h = root.clientHeight;\n      R = Math.min(w, h) * 0.42;\n      canvas.width = w * dpr;\n      canvas.height = h * dpr;\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      kick();\n    };\n\n    const onDown = (e: PointerEvent) => {\n      drag = { x: e.clientX, y: e.clientY };\n      v.vx = v.vy = 0;\n      root.setPointerCapture(e.pointerId);\n      kick();\n    };\n    const onMove = (e: PointerEvent) => {\n      if (!drag) return;\n      const k = 57 / R;\n      v.vx = -(e.clientX - drag.x) * k;\n      v.vy = (e.clientY - drag.y) * k;\n      v.lng += v.vx;\n      v.lat += v.vy;\n      drag = { x: e.clientX, y: e.clientY };\n      kick();\n    };\n    const onUp = () => {\n      drag = null;\n      kick();\n    };\n    const onKey = (e: KeyboardEvent) => {\n      const d = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, 1], ArrowDown: [0, -1] }[e.key];\n      if (!d) return;\n      e.preventDefault();\n      if (reduced) {\n        v.lng += d[0] * 15;\n        v.lat += d[1] * 15;\n      } else {\n        v.vx += d[0];\n        v.vy += d[1];\n      }\n      kick();\n    };\n    const flag = (e: Event) => {\n      if (e.type.startsWith(\"pointer\")) hover = e.type === \"pointerenter\";\n      else focus = e.type === \"focus\";\n      kick();\n    };\n    const onTheme = () => {\n      resolve();\n      kick();\n    };\n\n    const events: [string, EventListener][] = [\n      [\"pointerdown\", onDown as EventListener],\n      [\"pointermove\", onMove as EventListener],\n      [\"pointerup\", onUp],\n      [\"pointercancel\", onUp],\n      [\"keydown\", onKey as EventListener],\n      [\"pointerenter\", flag],\n      [\"pointerleave\", flag],\n      [\"focus\", flag],\n      [\"blur\", flag],\n    ];\n    for (const [n, f] of events) root.addEventListener(n, f);\n    document.addEventListener(\"visibilitychange\", kick);\n    const ro = new ResizeObserver(resize);\n    ro.observe(root);\n    const io = new IntersectionObserver(([e]) => {\n      inView = e.isIntersecting;\n      kick();\n    });\n    io.observe(root);\n    const mo = new MutationObserver(onTheme);\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\", \"style\", \"data-theme\"] });\n    resolve();\n\n    return () => {\n      cancelAnimationFrame(raf);\n      for (const [n, f] of events) root.removeEventListener(n, f);\n      document.removeEventListener(\"visibilitychange\", kick);\n      ro.disconnect();\n      io.disconnect();\n      mo.disconnect();\n    };\n  }, [markers, arcs, samples, speed, dotColor, accentColor, lat0, lng0]);\n\n  const names = markers.flatMap((m) => m.label ?? []);\n\n  return (\n    <div\n      ref={rootRef}\n      role=\"img\"\n      tabIndex={0}\n      aria-label={\n        label ??\n        `Globe${names.length ? ` marking ${names.join(\", \")}` : \"\"}${\n          arcs.length ? `, ${arcs.length} connection${arcs.length > 1 ? \"s\" : \"\"}` : \"\"\n        }`\n      }\n      {...props}\n      data-slot=\"globe\"\n      className={cn(\n        \"relative aspect-square w-full max-w-md cursor-grab touch-pan-y select-none rounded-full outline-none active:cursor-grabbing focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n        className\n      )}\n    >\n      <div\n        aria-hidden\n        className=\"absolute inset-[8%] rounded-full bg-background bg-radial-[at_30%_25%] from-brand/12 to-transparent to-75% shadow-[0_0_70px_-12px_color-mix(in_oklab,var(--brand)_45%,transparent)] ring-1 ring-brand/15\"\n      />\n      <div\n        aria-hidden\n        className=\"absolute inset-[8%] rounded-full bg-radial from-transparent from-70% to-brand/20\"\n      />\n      <canvas ref={canvasRef} aria-hidden className=\"absolute inset-0 size-full\" />\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/velora/globe.tsx"
    }
  ],
  "type": "registry:ui"
}