{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"globe","type":"registry:ui","title":"Globe","description":"A rotating dotted globe drawn on canvas, with pulsing city markers, great-circle arcs carrying travelling light, drag-to-spin inertia and a soft atmosphere.","dependencies":[],"registryDependencies":["utils"],"files":[{"path":"registry/ui/globe.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface GlobeMarker {\n  lat: number;\n  lon: number;\n  label?: string;\n}\n\nexport interface GlobeProps {\n  /** Glowing city markers. */\n  markers?: GlobeMarker[];\n  /** Arcs as index pairs into `markers`. */\n  arcs?: [number, number][];\n  /** Land dot color. */\n  dotColor?: string;\n  /** Marker, arc and atmosphere color. */\n  accent?: string;\n  /** Auto-rotation speed (degrees per second). 0 stops it. */\n  speed?: number;\n  /** Axial tilt in degrees. */\n  tilt?: number;\n  /** Number of dots sampled over the sphere (land dots are a share of these). */\n  density?: number;\n  /** Draw city labels on the front side. */\n  showLabels?: boolean;\n  className?: string;\n}\n\n// Coarse continent outlines [lon, lat]. Just enough for a recognisable dotted globe.\nconst LAND: number[][][] = [\n  // North America\n  [[-168,66],[-162,70],[-150,71],[-128,70],[-115,68],[-95,72],[-80,73],[-75,68],[-64,60],[-56,52],[-66,45],[-70,42],[-76,35],[-81,31],[-80,25],[-84,30],[-90,30],[-97,27],[-97,22],[-94,18],[-88,21],[-87,16],[-83,10],[-78,8],[-80,7],[-85,11],[-92,15],[-105,20],[-110,24],[-112,29],[-117,32],[-120,35],[-124,40],[-124,48],[-130,55],[-140,60],[-150,59],[-158,56],[-165,60]],\n  // Greenland\n  [[-73,78],[-60,82],[-30,83],[-20,75],[-22,70],[-40,65],[-43,60],[-50,64],[-55,70]],\n  // Iceland\n  [[-24,64],[-14,66],[-13,65],[-18,63]],\n  // South America\n  [[-80,9],[-75,11],[-62,11],[-52,5],[-50,0],[-35,-5],[-35,-9],[-39,-15],[-41,-22],[-48,-26],[-53,-34],[-58,-38],[-62,-40],[-65,-45],[-68,-50],[-69,-55],[-74,-52],[-75,-45],[-73,-37],[-71,-28],[-70,-18],[-76,-14],[-81,-6],[-80,-1],[-78,2],[-77,7]],\n  // Eurasia\n  [[-10,36],[-9,43],[-2,44],[-5,48],[2,51],[8,54],[8,57],[5,61],[10,64],[15,69],[25,71],[40,67],[45,68],[60,69],[70,73],[80,73],[100,78],[115,74],[130,71],[140,72],[160,70],[180,68],[180,65],[170,60],[163,60],[155,59],[143,59],[137,54],[141,48],[135,43],[129,41],[127,35],[122,40],[121,37],[122,30],[120,24],[111,21],[108,18],[106,10],[103,9],[100,13],[103,1],[100,4],[98,10],[98,16],[94,17],[92,22],[88,22],[80,15],[77,8],[73,17],[72,21],[67,24],[62,25],[57,26],[56,24],[59,22],[52,16],[43,12],[39,21],[35,28],[34,31],[36,36],[30,36],[27,37],[26,40],[24,40],[22,37],[20,40],[18,40],[16,38],[13,45],[12,44],[16,41],[10,44],[7,43],[3,43],[0,39],[-5,36]],\n  // Great Britain\n  [[-5,50],[1,51],[2,53],[-2,56],[-3,58],[-6,58],[-5,55],[-3,54],[-5,52]],\n  // Ireland\n  [[-10,52],[-6,52],[-6,55],[-8,55],[-10,54]],\n  // Africa\n  [[-17,21],[-16,28],[-10,30],[-6,36],[10,37],[11,33],[20,31],[32,31],[34,28],[38,18],[43,12],[51,12],[50,9],[44,2],[40,-3],[40,-10],[35,-20],[33,-26],[27,-34],[20,-35],[18,-32],[12,-18],[13,-12],[9,-1],[9,4],[5,4],[-8,5],[-13,8],[-17,14]],\n  // Madagascar\n  [[44,-25],[47,-25],[50,-15],[49,-12],[44,-17]],\n  // Japan\n  [[130,31],[135,34],[140,35],[142,40],[141,45],[145,44],[140,41],[137,37],[132,35]],\n  // Philippines\n  [[120,18],[122,18],[124,13],[126,7],[125,6],[122,7],[123,11],[120,14]],\n  // Sumatra, Borneo, New Guinea\n  [[95,5],[98,4],[106,-6],[102,-4]],\n  [[109,1],[117,7],[119,1],[116,-4],[110,-3]],\n  [[131,-1],[141,-3],[150,-10],[141,-9],[137,-5]],\n  // Australia\n  [[114,-22],[114,-34],[118,-35],[124,-34],[131,-31],[138,-35],[141,-38],[147,-38],[150,-37],[153,-30],[153,-25],[146,-19],[142,-11],[141,-17],[136,-12],[131,-11],[126,-14],[122,-18]],\n  // New Zealand\n  [[166,-46],[172,-41],[175,-37],[178,-38],[174,-42],[170,-46]],\n];\n\nfunction isLand(lon: number, lat: number) {\n  if (lat < -72) return true; // Antarctica\n  for (const poly of LAND) {\n    let inside = false;\n    for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {\n      const [xi, yi] = poly[i];\n      const [xj, yj] = poly[j];\n      if (yi > lat !== yj > lat && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) inside = !inside;\n    }\n    if (inside) return true;\n  }\n  return false;\n}\n\nconst DEFAULT_MARKERS: GlobeMarker[] = [\n  { lat: 37.77, lon: -122.42, label: \"San Francisco\" },\n  { lat: 40.71, lon: -74.0, label: \"New York\" },\n  { lat: -23.55, lon: -46.63, label: \"São Paulo\" },\n  { lat: 51.5, lon: -0.12, label: \"London\" },\n  { lat: 6.52, lon: 3.38, label: \"Lagos\" },\n  { lat: 25.2, lon: 55.27, label: \"Dubai\" },\n  { lat: 1.35, lon: 103.82, label: \"Singapore\" },\n  { lat: 14.6, lon: 120.98, label: \"Manila\" },\n  { lat: 35.68, lon: 139.69, label: \"Tokyo\" },\n  { lat: -33.87, lon: 151.21, label: \"Sydney\" },\n];\nconst DEFAULT_ARCS: [number, number][] = [[0, 8], [0, 3], [1, 3], [1, 2], [3, 5], [3, 4], [5, 6], [6, 9], [7, 8], [6, 7]];\n\nconst RAD = Math.PI / 180;\nconst toVec = (lat: number, lon: number): [number, number, number] => [\n  Math.cos(lat * RAD) * Math.sin(lon * RAD),\n  Math.sin(lat * RAD),\n  Math.cos(lat * RAD) * Math.cos(lon * RAD),\n];\n\nfunction hexToRgb(hex: string) {\n  const n = parseInt(hex.replace(\"#\", \"\").padEnd(6, \"0\").slice(0, 6), 16);\n  return `${(n >> 16) & 255},${(n >> 8) & 255},${n & 255}`;\n}\n\nexport function Globe({\n  markers = DEFAULT_MARKERS,\n  arcs = DEFAULT_ARCS,\n  dotColor = \"#94a3b8\",\n  accent = \"#c6ff3d\",\n  speed = 8,\n  tilt = 18,\n  density = 12000,\n  showLabels = false,\n  className,\n}: GlobeProps) {\n  const ref = useRef<HTMLCanvasElement>(null);\n  // Rotation survives prop changes so the globe does not jump when a control moves.\n  const rot = useRef({ yaw: -20 * RAD, pitch: 0 });\n\n  useEffect(() => {\n    const canvas = ref.current!;\n    const ctx = canvas.getContext(\"2d\")!;\n    const reduce = matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    const dot = hexToRgb(dotColor);\n    const acc = hexToRgb(accent);\n\n    // Fibonacci sphere, kept only where there is land.\n    const land: number[] = [];\n    const golden = Math.PI * (3 - Math.sqrt(5));\n    for (let i = 0; i < density; i++) {\n      const y = 1 - (2 * (i + 0.5)) / density;\n      const r = Math.sqrt(1 - y * y);\n      const th = golden * i;\n      const x = Math.cos(th) * r;\n      const z = Math.sin(th) * r;\n      const lat = Math.asin(y) / RAD;\n      const lon = Math.atan2(x, z) / RAD;\n      if (isLand(lon, lat)) land.push(x, y, z);\n    }\n    const pts = new Float32Array(land);\n    const mk = markers.map((m) => toVec(m.lat, m.lon));\n    // Great-circle arcs lifted off the surface, sampled once.\n    const arcPaths = arcs\n      .filter(([a, b]) => mk[a] && mk[b])\n      .map(([a, b]) => {\n        const A = mk[a];\n        const B = mk[b];\n        const dotAB = Math.min(1, Math.max(-1, A[0] * B[0] + A[1] * B[1] + A[2] * B[2]));\n        const om = Math.acos(dotAB);\n        const lift = 0.08 + om * 0.14;\n        const n = 48;\n        const out: number[] = [];\n        for (let i = 0; i <= n; i++) {\n          const t = i / n;\n          const s = Math.sin(om) || 1;\n          const k1 = Math.sin((1 - t) * om) / s;\n          const k2 = Math.sin(t * om) / s;\n          const h = 1 + lift * Math.sin(Math.PI * t);\n          out.push((A[0] * k1 + B[0] * k2) * h, (A[1] * k1 + B[1] * k2) * h, (A[2] * k1 + B[2] * k2) * h);\n        }\n        return out;\n      });\n\n    let w = 0;\n    let h = 0;\n    let R = 0;\n    let raf = 0;\n    let visible = true;\n    let last = performance.now();\n    let vel = 0; // drag inertia, rad/s\n    const drag = { on: false, x: 0, y: 0, t: 0 };\n    const tiltRad = tilt * RAD;\n\n    const resize = () => {\n      const dpr = Math.min(window.devicePixelRatio || 1, 2);\n      w = canvas.offsetWidth;\n      h = canvas.offsetHeight;\n      canvas.width = w * dpr;\n      canvas.height = h * dpr;\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      R = Math.min(w, h) * 0.4;\n      draw(last);\n    };\n\n    // rotate (yaw about Y, then tilt about X) and return [sx, sy, depth]\n    const proj = (x: number, y: number, z: number, cy: number, sy: number, ct: number, st: number): [number, number, number] => {\n      const x1 = x * cy + z * sy;\n      const z1 = -x * sy + z * cy;\n      const y2 = y * ct - z1 * st;\n      const z2 = y * st + z1 * ct;\n      return [w / 2 + x1 * R, h / 2 - y2 * R, z2];\n    };\n\n    const draw = (now: number) => {\n      const { yaw } = rot.current;\n      const pitch = tiltRad + rot.current.pitch;\n      const cY = Math.cos(yaw);\n      const sY = Math.sin(yaw);\n      const cT = Math.cos(pitch);\n      const sT = Math.sin(pitch);\n      const cx = w / 2;\n      const cy = h / 2;\n      ctx.clearRect(0, 0, w, h);\n\n      // atmosphere + body\n      const halo = ctx.createRadialGradient(cx, cy, R * 0.8, cx, cy, R * 1.24);\n      halo.addColorStop(0, `rgba(${acc},0)`);\n      halo.addColorStop(0.45, `rgba(${acc},0.18)`); // peaks right at the rim\n      halo.addColorStop(1, `rgba(${acc},0)`);\n      ctx.fillStyle = halo;\n      ctx.fillRect(0, 0, w, h);\n      const body = ctx.createRadialGradient(cx - R * 0.35, cy - R * 0.4, R * 0.1, cx, cy, R);\n      body.addColorStop(0, \"rgba(28,32,38,0.96)\");\n      body.addColorStop(1, \"rgba(8,9,11,0.96)\");\n      ctx.fillStyle = body;\n      ctx.beginPath();\n      ctx.arc(cx, cy, R, 0, Math.PI * 2);\n      ctx.fill();\n      ctx.strokeStyle = `rgba(${acc},0.25)`;\n      ctx.lineWidth = 1;\n      ctx.stroke();\n\n      // land dots, bucketed by depth so fillStyle changes only a few times\n      const size = Math.max(1.2, R / 105);\n      for (let b = 0; b < 5; b++) {\n        const lo = b / 5;\n        const hi = (b + 1) / 5;\n        ctx.fillStyle = `rgba(${dot},${0.18 + hi * 0.82})`;\n        for (let i = 0; i < pts.length; i += 3) {\n          const [sx, sy, z] = proj(pts[i], pts[i + 1], pts[i + 2], cY, sY, cT, sT);\n          if (z <= lo || z > hi) continue;\n          const s = size * (0.6 + z * 0.5);\n          ctx.fillRect(sx - s / 2, sy - s / 2, s, s);\n        }\n      }\n\n      // arcs: faint full path + a travelling comet\n      ctx.lineCap = \"round\";\n      arcPaths.forEach((p, ai) => {\n        const pr: [number, number, number][] = [];\n        for (let i = 0; i < p.length; i += 3) pr.push(proj(p[i], p[i + 1], p[i + 2], cY, sY, cT, sT));\n        ctx.lineWidth = 1.2;\n        for (let i = 1; i < pr.length; i++) {\n          const z = (pr[i][2] + pr[i - 1][2]) / 2;\n          if (z < -0.15) continue;\n          ctx.strokeStyle = `rgba(${acc},${0.12 + Math.max(0, z) * 0.3})`;\n          ctx.beginPath();\n          ctx.moveTo(pr[i - 1][0], pr[i - 1][1]);\n          ctx.lineTo(pr[i][0], pr[i][1]);\n          ctx.stroke();\n        }\n        const head = reduce ? 0.6 : ((now / 2600 + ai * 0.37) % 1.4) / 1.1; // 0..~1.27, pause at the end\n        const n = pr.length - 1;\n        const tail = 0.28;\n        ctx.lineWidth = 2;\n        for (let i = 1; i <= n; i++) {\n          const t = i / n;\n          if (t > head || t < head - tail) continue;\n          const z = pr[i][2];\n          if (z < -0.1) continue;\n          const a = (1 - (head - t) / tail) * (0.4 + Math.max(0, z) * 0.6);\n          ctx.strokeStyle = `rgba(${acc},${a})`;\n          ctx.beginPath();\n          ctx.moveTo(pr[i - 1][0], pr[i - 1][1]);\n          ctx.lineTo(pr[i][0], pr[i][1]);\n          ctx.stroke();\n        }\n      });\n\n      // markers\n      ctx.font = `500 ${Math.max(10, R / 22)}px ui-sans-serif, system-ui, sans-serif`;\n      mk.forEach((m, i) => {\n        const [sx, sy, z] = proj(m[0], m[1], m[2], cY, sY, cT, sT);\n        if (z < 0) return;\n        const pulse = reduce ? 0.5 : ((now / 1600 + i * 0.23) % 1);\n        const r = Math.max(2.2, R / 95);\n        ctx.strokeStyle = `rgba(${acc},${(1 - pulse) * 0.8 * z})`;\n        ctx.lineWidth = 1.4;\n        ctx.beginPath();\n        ctx.arc(sx, sy, r + pulse * r * 4, 0, Math.PI * 2);\n        ctx.stroke();\n        const g = ctx.createRadialGradient(sx, sy, 0, sx, sy, r * 3.5);\n        g.addColorStop(0, `rgba(${acc},${0.6 * z})`);\n        g.addColorStop(1, `rgba(${acc},0)`);\n        ctx.fillStyle = g;\n        ctx.beginPath();\n        ctx.arc(sx, sy, r * 3.5, 0, Math.PI * 2);\n        ctx.fill();\n        ctx.fillStyle = `rgba(255,255,255,${0.5 + z * 0.5})`;\n        ctx.beginPath();\n        ctx.arc(sx, sy, r * 0.7, 0, Math.PI * 2);\n        ctx.fill();\n        if (showLabels && markers[i].label && z > 0.25) {\n          ctx.fillStyle = `rgba(255,255,255,${Math.min(1, (z - 0.25) * 2.5) * 0.85})`;\n          ctx.fillText(markers[i].label!, sx + r * 2.2, sy + r * 0.8);\n        }\n      });\n    };\n\n    const loop = (now: number) => {\n      const dt = Math.min(0.05, (now - last) / 1000);\n      last = now;\n      if (!drag.on) {\n        rot.current.yaw += vel * dt + (reduce ? 0 : speed * RAD * dt);\n        vel *= Math.pow(0.04, dt); // inertia decays\n        rot.current.pitch *= Math.pow(0.2, dt); // tilt eases back\n      }\n      draw(now);\n      raf = visible ? requestAnimationFrame(loop) : 0;\n    };\n    const kick = () => {\n      if (!raf && visible) {\n        last = performance.now();\n        raf = requestAnimationFrame(loop);\n      }\n    };\n\n    const down = (e: PointerEvent) => {\n      drag.on = true;\n      drag.x = e.clientX;\n      drag.y = e.clientY;\n      drag.t = performance.now();\n      vel = 0;\n      canvas.setPointerCapture(e.pointerId);\n      canvas.style.cursor = \"grabbing\";\n    };\n    const move = (e: PointerEvent) => {\n      if (!drag.on) return;\n      const now = performance.now();\n      const dx = e.clientX - drag.x;\n      const dy = e.clientY - drag.y;\n      const d = dx / R;\n      rot.current.yaw += d;\n      rot.current.pitch = Math.max(-0.6, Math.min(0.6, rot.current.pitch + dy / R));\n      vel = (d / Math.max(1, now - drag.t)) * 1000;\n      drag.x = e.clientX;\n      drag.y = e.clientY;\n      drag.t = now;\n      kick();\n    };\n    const up = () => {\n      drag.on = false;\n      canvas.style.cursor = \"grab\";\n      kick();\n    };\n    const key = (e: KeyboardEvent) => {\n      if (e.key === \"ArrowLeft\" || e.key === \"ArrowRight\") {\n        e.preventDefault();\n        vel += (e.key === \"ArrowLeft\" ? -1 : 1) * 1.6;\n        kick();\n      }\n    };\n\n    const ro = new ResizeObserver(resize);\n    ro.observe(canvas);\n    const io = new IntersectionObserver(([e]) => {\n      visible = e.isIntersecting;\n      if (visible) kick();\n    });\n    io.observe(canvas);\n    canvas.addEventListener(\"pointerdown\", down);\n    canvas.addEventListener(\"pointermove\", move);\n    canvas.addEventListener(\"pointerup\", up);\n    canvas.addEventListener(\"pointercancel\", up);\n    canvas.addEventListener(\"keydown\", key);\n    kick();\n    return () => {\n      cancelAnimationFrame(raf);\n      ro.disconnect();\n      io.disconnect();\n      canvas.removeEventListener(\"pointerdown\", down);\n      canvas.removeEventListener(\"pointermove\", move);\n      canvas.removeEventListener(\"pointerup\", up);\n      canvas.removeEventListener(\"pointercancel\", up);\n      canvas.removeEventListener(\"keydown\", key);\n    };\n  }, [markers, arcs, dotColor, accent, speed, tilt, density, showLabels]);\n\n  return (\n    <canvas\n      ref={ref}\n      tabIndex={0}\n      role=\"img\"\n      aria-label={`Rotating globe with ${markers.length} locations${markers.length ? `: ${markers.map((m) => m.label).filter(Boolean).join(\", \")}` : \"\"}. Drag or use arrow keys to spin.`}\n      className={cn(\"aspect-square w-full max-w-[520px] cursor-grab touch-none rounded-3xl outline-none focus-visible:ring-2 focus-visible:ring-ring/50\", className)}\n    />\n  );\n}\n"}]}