跳至正文
组件

Dotted Globe点阵地球

用点阵绘制的 WebGL 地球,颜色跟随主题,支持城市标记、标签、弧线、拖拽旋转与离屏暂停。

正在加载…

安装

pnpm dlx shadcn@latest add https://yiui.dev/r/globe.json
会连带安装 本站条目 grid-layout、flow-diagram。已存在的文件会询问是否覆盖。

用法

Globe 自身是一个正方形外框,球体占其中约 80%,外框的大小、位置完全由 className 决定:放大并推到卡片角落,就得到「半露出」的构图。markers 传入城市,label 是读屏时朗读的城市名,labels 在标记旁显示城市名胶囊。页首的预览把放大的地球推到卡片右下角,只露出亚洲一侧,左上放节点指标。

components/globe-example.tsx
import { Globe, type GlobeMarker } from "@/components/ui/globe/globe"

const CITIES = [
  { label: "上海", location: [31.23, 121.47] },
  { label: "法兰克福", location: [50.11, 8.68] },
] satisfies GlobeMarker[]

export function GlobeExample() {
  return (
    <Globe
      label="分布在上海和法兰克福的节点"
      markers={CITIES}
      markerTone="amber"
      labels
      className="max-w-sm"
    />
  )
}

点阵、标记、弧线的颜色都从外框元素的 CSS 变量里取:点阵默认取 --foreground,标记默认取 --primary,也可以换成 blue / green / amber / rose / violet / cyan 或任意 CSS 颜色。切换亮暗模式或颜色主题时(<html> 的 class、data-theme 变化)会自动重新取色,不需要刷新。外框内部再套一层 .dark,该局部就按暗色渲染。

示例

每个示例的标题栏右侧都有「调整」开关:打开后可以实时调整自转速度(负值反向)、水平角、点阵密度、点阵与标记的色调,并开关城市标签与暂停;未改动的项显示示例自己的取值。调整时外框与球体大小不变。水平角是直接转到该角度,之后继续自转;标记挨得很近的示例打开标签会互相叠住。

图例

地球与 GlobeLegend 并排放置:图例是标记的文字版本,列出城市与经纬度,不依赖颜色与动画。

正在加载…

深色发光

外层加 dark 强制暗色,dotTone="cyan" 让点阵取 --flow-cyan,球体边缘自动带一圈同色光晕。arcs 的两端与标记重合,每条弧线可以用自己的 tone。phi、theta 把视角转到大西洋一侧。

正在加载…

拖拽旋转

draggable 打开后可以用鼠标或手指转动地球,松手带惯性,俯仰角缓缓回到初始值,然后恢复自转。focus 让地球缓动转向并停在某个地点,示例用一组 aria-pressed 按钮驱动它;再次点击传 null 就恢复自转。键盘聚焦后用左右方向键也能转。

正在加载…

API

Globe

GlobeMarker 的字段:

字段说明
location[纬度, 经度],单位为度
label城市名;读屏朗读,开启标签层时显示为胶囊
size标记大小,默认 0.045,常用 0.03–0.08
tone这个标记的色调,不传用 markerTone
showLabel单独控制标签显示,不传跟随 labels

GlobeArc 有 from、to(都是 [纬度, 经度])和可选的 tone。

phi、theta 是弧度。默认视角正对东亚;想对准某个地点,用导出的 globeAngles([纬度, 经度]) 换算:

import { Globe, globeAngles } from "@/components/ui/globe/globe"

const { phi, theta } = globeAngles([48.85, 2.35])

<Globe phi={phi} theta={theta} speed={0} />

GlobeLegend

标记图例:名称配彩色圆点,coordinates 同时列出经纬度。放在地球旁边,给出不依赖颜色与动画的文字版本。

可访问性

  • 默认作为装饰隐藏;传入 label 后以图片语义朗读,说明末尾自动附上所有带 label 的城市名。
  • draggable 时外框可聚焦,语义为「可旋转的地球」,左右方向键转动;没有传 label 会回落到默认说明。
  • 标签层只是视觉辅助,对读屏隐藏;需要文字版本时配合 GlobeLegend。
  • 系统开启「减少动态效果」时不自转,不播放惯性与俯仰回落;拖拽和键盘仍然可用,直接跟手。
  • 地球离开视口或 paused 时停止渲染循环,回到视口后继续。

注意事项

  • 调整面板通过 GlobeTuningContext 覆盖子树里 Globe 的 speed、phi、theta、dotTone、markerTone、mapSamples、labels、draggable 与 paused,并用 onReport 回报示例自己的取值。不包这一层时组件行为不变;它面向调试与文档,正常使用不需要。运行中改 phi 会让地球直接转到新角度。
  • 地球由 cobe 在 WebGL 里绘制,约 5KB,没有其他运行时依赖。服务端输出一个同尺寸的圆形占位,画布就绪后淡出占位,不会造成布局跳动;浏览器不支持 WebGL 时占位一直保留,标记仍可通过读屏获取。
  • mapSamples 越大点阵越密,也越吃显卡,默认 16000;小尺寸的缩略图可以降到 8000。设备像素比最高取 2。
  • 每个 Globe 占用一个 WebGL 上下文,同一页建议不超过 8 个。
  • 标签层的位置直接写进 DOM,每帧不触发 React 渲染;标签背面时随球体转过去自动淡出。
  • 色调变量依赖 flow-diagram 条目,安装时会一并提供。

源码

components/ui/globe/globe.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { flowToneStyle } from "@/components/ui/flow-diagram/tone"
import { anglesFor } from "./projection"
import type { GlobeArc, GlobeMarker, GlobeTone } from "./types"
import { useGlobe } from "./use-globe"

// 默认视角:正对东亚,略微俯视
const DEFAULT_VIEW = anglesFor([24, 104])

type GlobeProps = Omit<React.ComponentProps<"div">, "children"> & {
  /**
   * 标记点;`label` 填城市名,读屏会逐个朗读,开启 `labels` 后显示为胶囊
   * @default []
   */
  markers?: GlobeMarker[]
  /**
   * 两点之间的弧线
   * @default []
   */
  arcs?: GlobeArc[]
  /**
   * 点阵(陆地)的色调
   * @default "foreground"
   */
  dotTone?: GlobeTone
  /**
   * 标记的默认色调
   * @default "primary"
   */
  markerTone?: GlobeTone
  /**
   * 弧线的默认色调
   * @default "primary"
   */
  arcTone?: GlobeTone
  /**
   * 自转速度,单位弧度 / 秒;负值反向
   * @default 0.12
   */
  speed?: number
  /** 初始水平角(弧度),默认正对东亚;`globeAngles([纬度, 经度])` 可以算出对准某个地点的角度 */
  phi?: number
  /** 初始俯仰角(弧度);拖拽松手后会回到这个角度 */
  theta?: number
  /**
   * 允许拖拽旋转,松手后带惯性并恢复自转;键盘左右方向键也可旋转
   * @default false
   */
  draggable?: boolean
  /**
   * 暂停自转;拖拽和聚焦不受影响
   * @default false
   */
  paused?: boolean
  /**
   * 陆地点阵的采样数,越大越密,也越吃显卡
   * @default 16000
   */
  mapSamples?: number
  /**
   * 在标记旁显示城市名胶囊(背面时自动隐藏);单个标记的 `showLabel` 优先
   * @default false
   */
  labels?: boolean
  /** 让地球缓动转向并停在 `[纬度, 经度]`,传 `null` 恢复自转 */
  focus?: [number, number] | null
  /** 图示说明;不传时作为装饰隐藏(可拖拽时回落到默认说明) */
  label?: string
}

/**
 * 调整用的覆盖项:子树里所有 `Globe` 的对应属性被这里的值替换。
 * 用于调试面板一类的场景;不包 `GlobeTuningContext.Provider` 时组件行为不变。
 */
type GlobeTuning = {
  speed?: number
  phi?: number
  theta?: number
  dotTone?: GlobeTone
  markerTone?: GlobeTone
  mapSamples?: number
  labels?: boolean
  draggable?: boolean
  paused?: boolean
  /** 回报示例自己的实际取值(未被覆盖前),面板据此显示当前值。 */
  onReport?: (values: GlobeReport) => void
}

type GlobeReport = {
  speed: number
  phi: number
  theta: number
  dotTone: GlobeTone
  markerTone: GlobeTone
  mapSamples: number
  labels: boolean
  draggable: boolean
  paused: boolean
}

const GlobeTuningContext = React.createContext<GlobeTuning>({})

/**
 * 点阵地球:WebGL 绘制陆地点阵、标记与弧线,颜色取自主题变量,
 * 亮暗模式和颜色主题切换时自动更新。尺寸由外框决定,外框是正方形,球体占其中约 80%。
 */
function Globe({
  className,
  markers = [],
  arcs = [],
  dotTone: dotToneProp = "foreground",
  markerTone: markerToneProp = "primary",
  arcTone = "primary",
  speed: speedProp = 0.12,
  phi: phiProp = DEFAULT_VIEW.phi,
  theta: thetaProp = DEFAULT_VIEW.theta,
  draggable: draggableProp = false,
  paused: pausedProp = false,
  mapSamples: mapSamplesProp = 16000,
  labels: labelsProp = false,
  focus,
  label: labelProp,
  ...props
}: GlobeProps) {
  const tuning = React.useContext(GlobeTuningContext)
  const { onReport } = tuning
  React.useEffect(() => {
    onReport?.({
      speed: speedProp,
      phi: phiProp,
      theta: thetaProp,
      dotTone: dotToneProp,
      markerTone: markerToneProp,
      mapSamples: mapSamplesProp,
      labels: labelsProp,
      draggable: draggableProp,
      paused: pausedProp,
    })
  }, [
    onReport,
    speedProp,
    phiProp,
    thetaProp,
    dotToneProp,
    markerToneProp,
    mapSamplesProp,
    labelsProp,
    draggableProp,
    pausedProp,
  ])
  const speed = tuning.speed ?? speedProp
  const phi = tuning.phi ?? phiProp
  const theta = tuning.theta ?? thetaProp
  const dotTone = tuning.dotTone ?? dotToneProp
  const markerTone = tuning.markerTone ?? markerToneProp
  const mapSamples = tuning.mapSamples ?? mapSamplesProp
  const labels = tuning.labels ?? labelsProp
  const draggable = tuning.draggable ?? draggableProp
  const paused = tuning.paused ?? pausedProp
  const label = labelProp ?? (draggable ? "可旋转的地球" : undefined)
  const { rootRef, hostRef, labelRefs, ready, dragging, handlers } = useGlobe({
    markers,
    arcs,
    dotTone,
    markerTone,
    arcTone,
    speed,
    phi,
    theta,
    draggable,
    paused,
    mapSamples,
    labels,
    focus,
  })
  const cities = markers.flatMap((marker) =>
    marker.label ? [marker.label] : [],
  )
  const description = label
    ? cities.length
      ? `${label},标注:${cities.join("、")}`
      : label
    : undefined
  return (
    <div
      {...props}
      {...handlers}
      ref={rootRef}
      data-slot="globe"
      data-ready={ready || undefined}
      data-dragging={dragging || undefined}
      role={draggable && label ? "group" : label ? "img" : undefined}
      aria-roledescription={draggable && label ? "可旋转的地球" : undefined}
      aria-label={description}
      aria-hidden={label ? undefined : true}
      tabIndex={draggable && label ? 0 : undefined}
      className={cn(
        "group/globe relative aspect-square w-full select-none",
        draggable &&
          "cursor-grab touch-pan-y rounded-full outline-none focus-visible:ring-3 focus-visible:ring-ring/50 data-dragging:cursor-grabbing",
        className,
      )}
    >
      {/* 画布出来之前(含服务端输出)先占位,尺寸与球体一致,避免布局跳动 */}
      <div
        data-slot="globe-placeholder"
        aria-hidden="true"
        className="pointer-events-none absolute inset-[10%] rounded-full border border-edge bg-[radial-gradient(circle_at_35%_30%,var(--edge),transparent_70%)] transition-opacity duration-700 group-data-ready/globe:opacity-0"
      />
      <div
        ref={hostRef}
        data-slot="globe-canvas"
        className="absolute inset-0"
      />
      <div
        data-slot="globe-labels"
        aria-hidden="true"
        className="pointer-events-none absolute inset-0"
      >
        {markers.map((marker, index) =>
          (marker.showLabel ?? labels) && marker.label ? (
            <div
              key={`${marker.label}-${index}`}
              ref={(node) => {
                labelRefs.current[index] = node
              }}
              data-slot="globe-label"
              className="absolute top-0 left-0 opacity-0 will-change-transform"
              style={flowToneStyle(marker.tone ?? markerTone)}
            >
              <span className="absolute bottom-1.5 left-1.5 flex items-center gap-1.5 rounded-full border border-edge bg-background/80 py-0.5 pr-2 pl-1.5 text-[11px] leading-4 font-medium whitespace-nowrap shadow-xs backdrop-blur-sm">
                <span className="size-1.5 rounded-full bg-(--flow-tone)" />
                {marker.label}
              </span>
            </div>
          ) : null,
        )}
      </div>
    </div>
  )
}

type GlobeLegendProps = React.ComponentProps<"ul"> & {
  markers: GlobeMarker[]
  /**
   * 默认色调,与 `Globe` 的 `markerTone` 保持一致
   * @default "primary"
   */
  markerTone?: GlobeTone
  /**
   * 同时列出经纬度(等宽字体)
   * @default false
   */
  coordinates?: boolean
}

/** 标记图例:名称配彩色圆点,可选经纬度;放在地球旁边,给不依赖颜色与动画的文字版本。 */
function GlobeLegend({
  className,
  markers,
  markerTone = "primary",
  coordinates = false,
  ...props
}: GlobeLegendProps) {
  return (
    <ul
      data-slot="globe-legend"
      className={cn("flex flex-col gap-1.5 text-sm", className)}
      {...props}
    >
      {markers.map((marker, index) =>
        marker.label ? (
          <li
            key={`${marker.label}-${index}`}
            data-slot="globe-legend-item"
            className="flex items-center gap-2"
            style={flowToneStyle(marker.tone ?? markerTone)}
          >
            <span
              aria-hidden="true"
              className="size-2 shrink-0 rounded-full bg-(--flow-tone)"
            />
            <span className="truncate">{marker.label}</span>
            {coordinates ? (
              <span className="ml-auto font-mono text-xs text-muted-foreground tabular-nums">
                {marker.location[0].toFixed(1)}°,{" "}
                {marker.location[1].toFixed(1)}°
              </span>
            ) : null}
          </li>
        ) : null,
      )}
    </ul>
  )
}

export { Globe, GlobeLegend, GlobeTuningContext, anglesFor as globeAngles }
export type {
  GlobeArc,
  GlobeLegendProps,
  GlobeMarker,
  GlobeProps,
  GlobeReport,
  GlobeTone,
  GlobeTuning,
}
components/ui/globe/use-globe.ts
"use client"

import * as React from "react"
import createGlobe, { type Globe as CobeGlobe } from "cobe"
import { useInView, useReducedMotion } from "motion/react"

import { luminance, resolveColor, toneToCss, type GlobeRgb } from "./color"
import { anglesFor, MARKER_ELEVATION, project } from "./projection"
import type { GlobeArc, GlobeMarker, GlobeTone } from "./types"

type UseGlobeOptions = {
  markers: GlobeMarker[]
  arcs: GlobeArc[]
  dotTone: GlobeTone
  markerTone: GlobeTone
  arcTone: GlobeTone
  speed: number
  phi: number
  theta: number
  draggable: boolean
  paused: boolean
  mapSamples: number
  /** 标签层开关变化时重画一次,让新出现的标签立即就位 */
  labels?: boolean
  focus?: [number, number] | null
}

// 俯仰角的活动范围,再大地球会翻过去
const MAX_TILT = 0.9
let webglSupport: boolean | undefined

function supportsWebGL() {
  if (webglSupport === undefined) {
    const canvas = document.createElement("canvas")
    webglSupport = !!(canvas.getContext("webgl2") ?? canvas.getContext("webgl"))
  }
  return webglSupport
}

/** 把角度差折到 (-π, π],聚焦时沿最短方向转过去。 */
function shortestAngle(delta: number) {
  const turn = Math.PI * 2
  return ((((delta + Math.PI) % turn) + turn) % turn) - Math.PI
}

const mix = (a: GlobeRgb, b: GlobeRgb, t: number): GlobeRgb => [
  a[0] + (b[0] - a[0]) * t,
  a[1] + (b[1] - a[1]) * t,
  a[2] + (b[2] - a[2]) * t,
]
const scale = (a: GlobeRgb, k: number): GlobeRgb => [
  a[0] * k,
  a[1] * k,
  a[2] * k,
]

const clamp = (value: number, min: number, max: number) =>
  Math.min(max, Math.max(min, value))

/**
 * 地球的渲染循环:创建 cobe 实例、跟随主题取色、自转、拖拽惯性、聚焦缓动,
 * 并把标签层的位置直接写进 DOM(不经过 React,每帧不触发渲染)。
 */
function useGlobe(options: UseGlobeOptions) {
  const rootRef = React.useRef<HTMLDivElement>(null)
  const hostRef = React.useRef<HTMLDivElement>(null)
  const labelRefs = React.useRef<(HTMLElement | null)[]>([])
  const [ready, setReady] = React.useState(false)
  const [dragging, setDragging] = React.useState(false)
  const inView = useInView(rootRef, { amount: 0 })
  const reduced = !!useReducedMotion()

  // 回调里要读最新的 props,但又不想因此重建 cobe,所以放进 ref,在 effect 里同步
  const latest = React.useRef({ ...options, inView, reduced })
  const engine = React.useRef({
    globe: null as CobeGlobe | null,
    size: 0,
    phi: options.phi,
    theta: options.theta,
    vPhi: 0,
    vTheta: 0,
    dragging: false,
    pointerX: 0,
    pointerY: 0,
    pointerTime: 0,
    frame: 0,
    last: 0,
    requestFrame: () => {},
    draw: () => {},
    applyAppearance: () => {},
  })

  React.useEffect(() => {
    latest.current = { ...options, inView, reduced }
  })

  React.useEffect(() => {
    const root = rootRef.current
    const host = hostRef.current
    if (!root || !host || !supportsWebGL()) return
    const state = engine.current
    const canvas = document.createElement("canvas")
    canvas.style.cssText =
      "display:block;width:100%;height:100%;opacity:0;transition:opacity .6s"
    host.append(canvas)
    const dpr = Math.min(window.devicePixelRatio || 1, 2)
    state.size = host.clientWidth || 1
    const globe = createGlobe(canvas, {
      width: state.size,
      height: state.size,
      devicePixelRatio: dpr,
      phi: state.phi,
      theta: state.theta,
      mapSamples: latest.current.mapSamples,
      mapBrightness: 6,
      baseColor: [0.5, 0.5, 0.5],
      markerColor: [1, 0.5, 0],
      glowColor: [1, 1, 1],
      diffuse: 1.2,
      dark: 0,
      markerElevation: MARKER_ELEVATION,
    })
    state.globe = globe

    const draw = () => {
      globe.update({ phi: state.phi, theta: state.theta })
      const { markers } = latest.current
      markers.forEach((marker, index) => {
        const el = labelRefs.current[index]
        if (!el) return
        const p = project(marker.location, state.phi, state.theta)
        el.style.transform = `translate3d(${p.x * state.size}px, ${p.y * state.size}px, 0)`
        el.style.opacity = String(clamp((p.facing - 0.08) / 0.3, 0, 1))
      })
    }

    const applyAppearance = () => {
      const o = latest.current
      const dot = resolveColor(root, toneToCss(o.dotTone))
      const bg = resolveColor(root, "var(--background)")
      const dark = luminance(bg) < 0.45
      // cobe 的着色:亮色模式下 baseColor 是球面底色,陆地点阵是它压暗后的样子;
      // 暗色模式下 baseColor 是点阵的亮度,球面底色是它的 10%。
      // 所以亮色取「背景向点阵色靠一点」做底色,暗色直接取点阵色。
      globe.update({
        baseColor: dark ? dot : mix(bg, dot, 0.16),
        glowColor: dark ? scale(dot, 0.35) : bg,
        mapBrightness: dark ? 6 : 1.8,
        diffuse: dark ? 1.2 : 0.9,
        markerColor: resolveColor(root, toneToCss(o.markerTone)),
        arcColor: resolveColor(root, toneToCss(o.arcTone)),
        dark: dark ? 1 : 0,
        markers: o.markers.map((marker) => ({
          location: marker.location,
          size: marker.size ?? 0.045,
          color: resolveColor(root, toneToCss(marker.tone ?? o.markerTone)),
        })),
        arcs: o.arcs.map((arc) => ({
          from: arc.from,
          to: arc.to,
          color: resolveColor(root, toneToCss(arc.tone ?? o.arcTone)),
        })),
        mapSamples: o.mapSamples,
      })
      draw()
    }

    const frame = (now: number) => {
      state.frame = 0
      const o = latest.current
      const dt = Math.min((now - state.last) / 1000, 0.05)
      state.last = now
      let busy = false
      if (!state.dragging) {
        const target = o.focus ? anglesFor(o.focus) : null
        if (target) {
          const ease = 1 - Math.exp(-dt * 4)
          const delta = shortestAngle(target.phi - state.phi)
          state.phi += delta * ease
          state.theta += (target.theta - state.theta) * ease
          state.vPhi = 0
          busy = Math.abs(delta) > 0.002
        } else {
          const rotate = o.inView && !o.paused && !o.reduced
          state.phi += ((rotate ? o.speed : 0) + state.vPhi) * dt
          state.vPhi *= Math.exp(-dt * 2.2)
          if (Math.abs(state.vPhi) < 0.002) state.vPhi = 0
          if (!o.reduced) {
            state.theta += (o.theta - state.theta) * (1 - Math.exp(-dt * 3))
            state.theta = clamp(state.theta, -MAX_TILT, MAX_TILT)
          }
          busy =
            rotate ||
            state.vPhi !== 0 ||
            (!o.reduced && Math.abs(state.theta - o.theta) > 0.002)
        }
      }
      draw()
      if (busy && o.inView) state.frame = requestAnimationFrame(frame)
    }
    state.requestFrame = () => {
      if (state.frame) return
      state.last = performance.now()
      state.frame = requestAnimationFrame(frame)
    }
    state.draw = draw
    state.applyAppearance = applyAppearance

    const resize = new ResizeObserver(() => {
      const size = host.clientWidth
      if (!size || size === state.size) return
      state.size = size
      globe.update({ width: size, height: size })
      draw()
    })
    resize.observe(host)

    // 亮暗模式、颜色主题都是改 <html> 的 class / data-theme,重新取色即可
    const theme = new MutationObserver(applyAppearance)
    theme.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class", "data-theme", "style"],
    })

    applyAppearance()
    canvas.style.opacity = "1"
    setReady(true)
    state.requestFrame()

    return () => {
      cancelAnimationFrame(state.frame)
      state.frame = 0
      resize.disconnect()
      theme.disconnect()
      globe.destroy()
      state.globe = null
      state.draw = () => {}
      state.requestFrame = () => {}
      state.applyAppearance = () => {}
      host.replaceChildren()
      setReady(false)
    }
  }, [])

  // 标记、色调、采样数变化:重新取色并重画
  const markersKey = JSON.stringify([options.markers, options.arcs])
  React.useEffect(() => {
    engine.current.applyAppearance()
    engine.current.requestFrame()
  }, [
    markersKey,
    options.dotTone,
    options.markerTone,
    options.arcTone,
    options.mapSamples,
    options.labels,
  ])

  // 运行中改初始水平角:直接转到新的角度,之后继续自转
  const lastPhi = React.useRef(options.phi)
  React.useEffect(() => {
    if (lastPhi.current === options.phi) return
    lastPhi.current = options.phi
    engine.current.phi = options.phi
    engine.current.draw()
    engine.current.requestFrame()
  }, [options.phi])

  // 暂停、进入视口、聚焦目标变化:重新启动渲染循环
  React.useEffect(() => {
    engine.current.requestFrame()
  }, [
    inView,
    reduced,
    options.paused,
    options.focus,
    options.speed,
    options.theta,
  ])

  const { draggable } = options
  const handlers = React.useMemo(() => {
    if (!draggable) return {}
    const state = engine.current
    const radius = () => Math.max(1, state.size * 0.4)
    return {
      onPointerDown(event: React.PointerEvent<HTMLElement>) {
        if (event.pointerType === "mouse" && event.button !== 0) return
        event.currentTarget.setPointerCapture(event.pointerId)
        state.dragging = true
        state.vPhi = 0
        state.pointerX = event.clientX
        state.pointerY = event.clientY
        state.pointerTime = event.timeStamp
        setDragging(true)
      },
      onPointerMove(event: React.PointerEvent<HTMLElement>) {
        if (!state.dragging) return
        const dx = event.clientX - state.pointerX
        const dy = event.clientY - state.pointerY
        const dt = Math.max(1, event.timeStamp - state.pointerTime) / 1000
        state.pointerX = event.clientX
        state.pointerY = event.clientY
        state.pointerTime = event.timeStamp
        state.phi += dx / radius()
        // 触屏上纵向留给页面滚动,只有鼠标 / 手写笔才改俯仰角
        if (event.pointerType !== "touch")
          state.theta = clamp(state.theta + dy / radius(), -MAX_TILT, MAX_TILT)
        // 速度做指数平滑,松手时的惯性不会被最后一次抖动带偏
        state.vPhi = state.vPhi * 0.6 + (dx / radius() / dt) * 0.4
        state.draw()
      },
      onPointerUp(event: React.PointerEvent<HTMLElement>) {
        if (!state.dragging) return
        state.dragging = false
        if (event.currentTarget.hasPointerCapture(event.pointerId))
          event.currentTarget.releasePointerCapture(event.pointerId)
        if (latest.current.reduced) state.vPhi = 0
        state.vPhi = clamp(state.vPhi, -6, 6)
        setDragging(false)
        state.requestFrame()
      },
      onKeyDown(event: React.KeyboardEvent<HTMLElement>) {
        const step = { ArrowLeft: -1, ArrowRight: 1 }[event.key]
        if (!step) return
        event.preventDefault()
        // 键盘按一下给一个速度,之后沿用拖拽的惯性衰减,reduced-motion 下直接转一小步
        if (latest.current.reduced) state.phi += step * 0.2
        else state.vPhi = clamp(state.vPhi + step * 1.6, -6, 6)
        state.requestFrame()
        state.draw()
      },
    }
  }, [draggable])

  return { rootRef, hostRef, labelRefs, ready, dragging, handlers }
}

export { useGlobe }
export type { UseGlobeOptions }
components/ui/globe/color.ts
import {
  FLOW_TONES,
  type FlowTone,
  type FlowToneName,
} from "@/components/ui/flow-diagram/tone"

/** cobe 接受的颜色:三个 0–1 的分量。 */
type GlobeRgb = [number, number, number]

let probe: CanvasRenderingContext2D | null | undefined

function probeContext() {
  if (probe === undefined) {
    const canvas = document.createElement("canvas")
    canvas.width = canvas.height = 1
    probe = canvas.getContext("2d", { willReadFrequently: true })
  }
  return probe
}

/** 色调名换成 CSS 颜色:内置名走 `--flow-*` 变量,其余原样当作 CSS 颜色。 */
function toneToCss(tone: FlowTone): string {
  return Object.hasOwn(FLOW_TONES, tone)
    ? FLOW_TONES[tone as FlowToneName]
    : tone
}

/**
 * 在 `scope` 元素的上下文里解析一个 CSS 颜色(可以含 `var()`、`oklch()`、`color-mix()`),
 * 返回 sRGB 分量。必须从元素本身解析:局部的 `.dark` / `data-theme-scope` 只改变该元素内的变量。
 * 解析失败时回落到中灰,避免 WebGL 拿到 NaN。
 */
function resolveColor(scope: Element, css: string): GlobeRgb {
  const ctx = probeContext()
  const swatch = document.createElement("span")
  swatch.style.color = css
  swatch.style.display = "none"
  scope.append(swatch)
  const computed = getComputedStyle(swatch).color
  swatch.remove()
  if (!ctx) return [0.5, 0.5, 0.5]
  // 先填一个已知色,若 fillStyle 不认识 computed,会保留它,据此判断解析是否成功
  ctx.clearRect(0, 0, 1, 1)
  ctx.fillStyle = "rgba(0, 0, 0, 0)"
  ctx.fillStyle = computed
  ctx.fillRect(0, 0, 1, 1)
  const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data
  if (a === 0) return [0.5, 0.5, 0.5]
  return [r / 255, g / 255, b / 255]
}

/** 简单的相对亮度,用来判断当前上下文是亮色还是暗色。 */
function luminance([r, g, b]: GlobeRgb) {
  return 0.2126 * r + 0.7152 * g + 0.0722 * b
}

export { luminance, resolveColor, toneToCss }
export type { GlobeRgb }
components/ui/globe/projection.ts
/**
 * 经纬度 → 屏幕坐标。公式与 cobe 内部一致(见 cobe 的 markers 投影),
 * 用来把 HTML 标签贴在地球表面的标记上,并判断标记是否转到了背面。
 */

/** 球体半径占画布半宽的比例(cobe 固定为 0.8)。 */
const GLOBE_RADIUS = 0.8
/** 标记抬离球面的高度,与传给 cobe 的 `markerElevation` 保持一致。 */
const MARKER_ELEVATION = 0.02

type Projected = {
  /** 0–1,画布左上为原点 */
  x: number
  y: number
  /** 面向相机的程度:1 正对,0 在球的边缘,负数在背面 */
  facing: number
}

/** [纬度, 经度](角度)换成单位球面上的点。 */
function toUnit([lat, lng]: [number, number]): [number, number, number] {
  const phi = (lat * Math.PI) / 180
  const lambda = (lng * Math.PI) / 180 - Math.PI
  const cosLat = Math.cos(phi)
  return [-cosLat * Math.cos(lambda), Math.sin(phi), cosLat * Math.sin(lambda)]
}

function project(
  location: [number, number],
  phi: number,
  theta: number,
  aspect = 1,
): Projected {
  const [ux, uy, uz] = toUnit(location)
  const r = GLOBE_RADIUS + MARKER_ELEVATION
  const x = ux * r
  const y = uy * r
  const z = uz * r
  const cosPhi = Math.cos(phi)
  const sinPhi = Math.sin(phi)
  const cosTheta = Math.cos(theta)
  const sinTheta = Math.sin(theta)
  const screenX = cosPhi * x + sinPhi * z
  const screenY = sinPhi * sinTheta * x + cosTheta * y - cosPhi * sinTheta * z
  const depth = -sinPhi * cosTheta * x + sinTheta * y + cosPhi * cosTheta * z
  return {
    x: (screenX / aspect + 1) / 2,
    y: (-screenY + 1) / 2,
    facing: depth / r,
  }
}

/** 让 `location` 正对相机所需的 phi / theta(弧度)。 */
function anglesFor([lat, lng]: [number, number]) {
  return { phi: ((-90 - lng) * Math.PI) / 180, theta: (lat * Math.PI) / 180 }
}

export { anglesFor, MARKER_ELEVATION, project }
export type { Projected }
components/ui/globe/types.ts
import type { FlowTone } from "@/components/ui/flow-diagram/tone"

/** 地球色调:与 flow-diagram 共用,内置名或任意 CSS 颜色(可含 `var()`)。 */
type GlobeTone = FlowTone

/** 地球上的一个标记点。 */
type GlobeMarker = {
  /** `[纬度, 经度]`,单位为度 */
  location: [number, number]
  /** 城市或地点名:作为读屏描述,开启标签层时显示为胶囊 */
  label?: string
  /**
   * 标记大小,约 0.02(小)到 0.1(大)
   * @default 0.045
   */
  size?: number
  /** 这个标记的色调,不传则用地球的 `markerTone` */
  tone?: GlobeTone
  /** 单独控制这个标记是否显示标签,不传则跟随地球的 `labels` */
  showLabel?: boolean
}

/** 两点之间的弧线。 */
type GlobeArc = {
  from: [number, number]
  to: [number, number]
  /** 弧线色调,不传则用地球的 `arcTone` */
  tone?: GlobeTone
}

export type { GlobeArc, GlobeMarker, GlobeTone }