import { Globe, type GlobeMarker } from "@/components/ui/globe/globe"
// 八个节点都有标记;东亚的几座城市挨得很近,只给上海和新加坡留标签,免得胶囊叠在一起
const NODES: GlobeMarker[] = [
{ label: "上海", location: [31.23, 121.47], size: 0.06, showLabel: true },
{ label: "杭州", location: [30.27, 120.16], size: 0.045 },
{ label: "北京", location: [39.9, 116.4], size: 0.05 },
{ label: "深圳", location: [22.54, 114.06], size: 0.05 },
{ label: "新加坡", location: [1.35, 103.82], size: 0.05, showLabel: true },
{ label: "东京", location: [35.68, 139.69], size: 0.05 },
{ label: "旧金山", location: [37.77, -122.42], size: 0.05, showLabel: true },
{ label: "法兰克福", location: [50.11, 8.68], size: 0.05, showLabel: true },
]
export default function GlobeDemo() {
return (
<div className="[container-type:size] relative h-[26rem] w-full max-w-4xl overflow-clip rounded-lg border border-edge bg-card sm:h-[23rem]">
{/* 文字层不接收指针,地球被压住的部分也能拖 */}
<div className="pointer-events-none relative z-10 flex flex-col gap-4 p-5 sm:p-6">
<span className="flex items-center gap-2 font-mono text-xs tracking-wider text-muted-foreground uppercase">
<span
aria-hidden="true"
className="size-1.5 rounded-full bg-(--flow-green) motion-safe:animate-pulse"
/>
Edge network
</span>
<div className="flex flex-col gap-1.5">
<p className="flex items-baseline gap-2">
<span className="text-4xl font-semibold tracking-tight tabular-nums">
128
</span>
<span className="text-sm text-muted-foreground">个在线节点</span>
</p>
<p className="max-w-[18rem] text-sm leading-6 text-balance text-muted-foreground">
遍布 6 个大洲,就近接入,平均延迟 38 ms。
</p>
</div>
<dl className="flex gap-6 text-sm">
<div className="flex flex-col gap-0.5">
<dt className="text-xs text-muted-foreground">可用性</dt>
<dd className="font-medium tabular-nums">99.99%</dd>
</div>
<div className="flex flex-col gap-0.5">
<dt className="text-xs text-muted-foreground">峰值带宽</dt>
<dd className="font-medium tabular-nums">2.4 Tbps</dd>
</div>
</dl>
</div>
<Globe
label="亚洲、欧洲和北美分布着 128 个边缘节点,其中上海、杭州、北京、深圳、新加坡、东京、旧金山和法兰克福为主要城市"
markers={NODES}
markerTone="amber"
draggable
className="absolute bottom-0 left-1/2 w-[min(140cqw,115cqh)] -translate-x-1/2 translate-y-[40%] @lg:right-[-16cqh] @lg:bottom-[-44cqh] @lg:left-auto @lg:w-[124cqh] @lg:translate-x-0 @lg:translate-y-0"
/>
</div>
)
}
安装
pnpm dlx shadcn@latest add https://yiui.dev/r/globe.json用法
Globe 自身是一个正方形外框,球体占其中约 80%,外框的大小、位置完全由 className 决定:放大并推到卡片角落,就得到「半露出」的构图。markers 传入城市,label 是读屏时朗读的城市名,labels 在标记旁显示城市名胶囊。页首的预览把放大的地球推到卡片右下角,只露出亚洲一侧,左上放节点指标。
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 并排放置:图例是标记的文字版本,列出城市与经纬度,不依赖颜色与动画。
import {
Globe,
GlobeLegend,
type GlobeMarker,
} from "@/components/ui/globe/globe"
// 城市之间保持距离,开启 `labels` 后标签胶囊不会叠在一起
const CITIES: GlobeMarker[] = [
{ label: "上海", location: [31.23, 121.47] },
{ label: "新加坡", location: [1.35, 103.82] },
{ label: "法兰克福", location: [50.11, 8.68] },
{ label: "旧金山", location: [37.77, -122.42] },
{ label: "悉尼", location: [-33.87, 151.21] },
]
export default function GlobeLegendDemo() {
return (
<div className="grid w-full max-w-xl items-center gap-6 sm:grid-cols-[minmax(0,1fr)_11rem]">
<Globe
label="分布在亚洲、欧洲、北美和大洋洲的节点"
markers={CITIES}
markerTone="amber"
labels
draggable
/>
{/* 文字版本:不依赖颜色与动画,读屏也能获取 */}
<GlobeLegend markers={CITIES} markerTone="amber" coordinates />
</div>
)
}
深色发光
外层加 dark 强制暗色,dotTone="cyan" 让点阵取 --flow-cyan,球体边缘自动带一圈同色光晕。arcs 的两端与标记重合,每条弧线可以用自己的 tone。phi、theta 把视角转到大西洋一侧。
import {
Globe,
type GlobeArc,
type GlobeMarker,
} from "@/components/ui/globe/globe"
const NODES: GlobeMarker[] = [
{ label: "上海", location: [31.23, 121.47], size: 0.06 },
{ label: "新加坡", location: [1.35, 103.82] },
{ label: "旧金山", location: [37.77, -122.42] },
{ label: "法兰克福", location: [50.11, 8.68] },
{ label: "圣保罗", location: [-23.55, -46.63] },
]
// 弧线两端必须落在标记上;跨洋的用蓝色,亚欧之间用紫色
const LINKS: GlobeArc[] = [
{ from: [31.23, 121.47], to: [37.77, -122.42], tone: "blue" },
{ from: [31.23, 121.47], to: [50.11, 8.68], tone: "violet" },
{ from: [1.35, 103.82], to: [50.11, 8.68], tone: "violet" },
{ from: [50.11, 8.68], to: [-23.55, -46.63], tone: "blue" },
]
// 外层加 `dark` 强制暗色:背景、前景和 --flow-* 都取暗色取值,亮暗模式下外观一致
export default function GlobeDarkDemo() {
return (
<div className="dark w-full max-w-sm rounded-xl bg-background p-3 text-foreground">
<Globe
label="上海、新加坡、旧金山、法兰克福和圣保罗之间的链路"
markers={NODES}
arcs={LINKS}
dotTone="cyan"
markerTone="amber"
speed={0.1}
phi={5.3}
theta={0.35}
draggable
/>
</div>
)
}
拖拽旋转
draggable 打开后可以用鼠标或手指转动地球,松手带惯性,俯仰角缓缓回到初始值,然后恢复自转。focus 让地球缓动转向并停在某个地点,示例用一组 aria-pressed 按钮驱动它;再次点击传 null 就恢复自转。键盘聚焦后用左右方向键也能转。
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Globe, type GlobeMarker } from "@/components/ui/globe/globe"
const CITIES = [
{ label: "上海", location: [31.23, 121.47] },
{ label: "新加坡", location: [1.35, 103.82] },
{ label: "法兰克福", location: [50.11, 8.68] },
{ label: "旧金山", location: [37.77, -122.42] },
{ label: "圣保罗", location: [-23.55, -46.63] },
] satisfies GlobeMarker[]
export default function GlobeDraggableDemo() {
// 点城市就转过去并停住;再点一次取消聚焦,地球恢复自转。拖拽随时可以打断聚焦
const [active, setActive] = React.useState<string | null>(null)
const focus = CITIES.find((city) => city.label === active)?.location ?? null
return (
<div className="grid w-full max-w-lg items-center gap-6 sm:grid-cols-[minmax(0,1fr)_9rem]">
<Globe
draggable
labels
markers={CITIES}
markerTone="primary"
focus={focus}
speed={0.15}
/>
<div className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground">拖动地球,或点击城市定位</p>
<ul className="flex flex-wrap gap-2 sm:flex-col">
{CITIES.map(({ label }) => (
<li key={label} className="sm:w-full">
<Button
size="sm"
variant={active === label ? "default" : "outline"}
aria-pressed={active === label}
onClick={() => setActive(active === label ? null : label)}
className="sm:w-full sm:justify-start"
>
{label}
</Button>
</li>
))}
</ul>
</div>
</div>
)
}
API
Globe
允许拖拽旋转,松手后带惯性并恢复自转;键盘左右方向键也可旋转
类型boolean | undefinedfalse标记点;label 填城市名,读屏会逐个朗读,开启 labels 后显示为胶囊
GlobeMarker[] | undefined[]两点之间的弧线
类型GlobeArc[] | undefined[]点阵(陆地)的色调
类型FlowTone | undefined"foreground"标记的默认色调
类型FlowTone | undefined"primary"弧线的默认色调
类型FlowTone | undefined"primary"自转速度,单位弧度 / 秒;负值反向
类型number | undefined0.12初始水平角(弧度),默认正对东亚;globeAngles([纬度, 经度]) 可以算出对准某个地点的角度
number | undefined初始俯仰角(弧度);拖拽松手后会回到这个角度
类型number | undefined暂停自转;拖拽和聚焦不受影响
类型boolean | undefinedfalse陆地点阵的采样数,越大越密,也越吃显卡
类型number | undefined16000在标记旁显示城市名胶囊(背面时自动隐藏);单个标记的 showLabel 优先
boolean | undefinedfalse让地球缓动转向并停在 [纬度, 经度],传 null 恢复自转
[number, number] | null | undefined图示说明;不传时作为装饰隐藏(可拖拽时回落到默认说明)
类型string | undefinedGlobeMarker 的字段:
| 字段 | 说明 |
|---|---|
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 同时列出经纬度。放在地球旁边,给出不依赖颜色与动画的文字版本。
GlobeMarker[]默认色调,与 Globe 的 markerTone 保持一致
FlowTone | undefined"primary"同时列出经纬度(等宽字体)
类型boolean | undefinedfalse可访问性
- 默认作为装饰隐藏;传入
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条目,安装时会一并提供。
源码
"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,
}
"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 }
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 }
/**
* 经纬度 → 屏幕坐标。公式与 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 }
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 }