{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "globe",
  "title": "点阵地球",
  "description": "用点阵绘制的 WebGL 地球，颜色跟随主题，支持城市标记、标签、弧线、拖拽旋转与离屏暂停。",
  "dependencies": [
    "cobe",
    "motion"
  ],
  "registryDependencies": [
    "https://yiui.dev/r/flow-tone.json"
  ],
  "files": [
    {
      "path": "src/registry/ui/globe/globe.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { flowToneStyle } from \"@/lib/flow-tone\"\nimport { anglesFor } from \"./projection\"\nimport type { GlobeArc, GlobeMarker, GlobeTone } from \"./types\"\nimport { useGlobe } from \"./use-globe\"\n\n// 默认视角：正对东亚，略微俯视\nconst DEFAULT_VIEW = anglesFor([24, 104])\n\ntype GlobeProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /**\n   * 标记点；`label` 填城市名，读屏会逐个朗读，开启 `labels` 后显示为胶囊\n   * @default []\n   */\n  markers?: GlobeMarker[]\n  /**\n   * 两点之间的弧线\n   * @default []\n   */\n  arcs?: GlobeArc[]\n  /**\n   * 点阵（陆地）的色调\n   * @default \"foreground\"\n   */\n  dotTone?: GlobeTone\n  /**\n   * 标记的默认色调\n   * @default \"primary\"\n   */\n  markerTone?: GlobeTone\n  /**\n   * 弧线的默认色调\n   * @default \"primary\"\n   */\n  arcTone?: GlobeTone\n  /**\n   * 自转速度，单位弧度 / 秒；负值反向\n   * @default 0.12\n   */\n  speed?: number\n  /** 初始水平角（弧度），默认正对东亚；`globeAngles([纬度, 经度])` 可以算出对准某个地点的角度 */\n  phi?: number\n  /** 初始俯仰角（弧度）；拖拽松手后会回到这个角度 */\n  theta?: number\n  /**\n   * 允许拖拽旋转，松手后带惯性并恢复自转；键盘左右方向键也可旋转\n   * @default false\n   */\n  draggable?: boolean\n  /**\n   * 暂停自转；拖拽和聚焦不受影响\n   * @default false\n   */\n  paused?: boolean\n  /**\n   * 陆地点阵的采样数，越大越密，也越吃显卡\n   * @default 16000\n   */\n  mapSamples?: number\n  /**\n   * 在标记旁显示城市名胶囊（背面时自动隐藏）；单个标记的 `showLabel` 优先\n   * @default false\n   */\n  labels?: boolean\n  /** 让地球缓动转向并停在 `[纬度, 经度]`，传 `null` 恢复自转 */\n  focus?: [number, number] | null\n  /** 图示说明；不传时作为装饰隐藏（可拖拽时回落到默认说明） */\n  label?: string\n}\n\n/**\n * 调整用的覆盖项：子树里所有 `Globe` 的对应属性被这里的值替换。\n * 用于调试面板一类的场景；不包 `GlobeTuningContext.Provider` 时组件行为不变。\n */\ntype GlobeTuning = {\n  speed?: number\n  phi?: number\n  theta?: number\n  dotTone?: GlobeTone\n  markerTone?: GlobeTone\n  mapSamples?: number\n  labels?: boolean\n  draggable?: boolean\n  paused?: boolean\n  /** 回报示例自己的实际取值（未被覆盖前），面板据此显示当前值。 */\n  onReport?: (values: GlobeReport) => void\n}\n\ntype GlobeReport = {\n  speed: number\n  phi: number\n  theta: number\n  dotTone: GlobeTone\n  markerTone: GlobeTone\n  mapSamples: number\n  labels: boolean\n  draggable: boolean\n  paused: boolean\n}\n\nconst GlobeTuningContext = React.createContext<GlobeTuning>({})\n\n/**\n * 点阵地球：WebGL 绘制陆地点阵、标记与弧线，颜色取自主题变量，\n * 亮暗模式和颜色主题切换时自动更新。尺寸由外框决定，外框是正方形，球体占其中约 80%。\n */\nfunction Globe({\n  className,\n  markers = [],\n  arcs = [],\n  dotTone: dotToneProp = \"foreground\",\n  markerTone: markerToneProp = \"primary\",\n  arcTone = \"primary\",\n  speed: speedProp = 0.12,\n  phi: phiProp = DEFAULT_VIEW.phi,\n  theta: thetaProp = DEFAULT_VIEW.theta,\n  draggable: draggableProp = false,\n  paused: pausedProp = false,\n  mapSamples: mapSamplesProp = 16000,\n  labels: labelsProp = false,\n  focus,\n  label: labelProp,\n  ...props\n}: GlobeProps) {\n  const tuning = React.useContext(GlobeTuningContext)\n  const { onReport } = tuning\n  React.useEffect(() => {\n    onReport?.({\n      speed: speedProp,\n      phi: phiProp,\n      theta: thetaProp,\n      dotTone: dotToneProp,\n      markerTone: markerToneProp,\n      mapSamples: mapSamplesProp,\n      labels: labelsProp,\n      draggable: draggableProp,\n      paused: pausedProp,\n    })\n  }, [\n    onReport,\n    speedProp,\n    phiProp,\n    thetaProp,\n    dotToneProp,\n    markerToneProp,\n    mapSamplesProp,\n    labelsProp,\n    draggableProp,\n    pausedProp,\n  ])\n  const speed = tuning.speed ?? speedProp\n  const phi = tuning.phi ?? phiProp\n  const theta = tuning.theta ?? thetaProp\n  const dotTone = tuning.dotTone ?? dotToneProp\n  const markerTone = tuning.markerTone ?? markerToneProp\n  const mapSamples = tuning.mapSamples ?? mapSamplesProp\n  const labels = tuning.labels ?? labelsProp\n  const draggable = tuning.draggable ?? draggableProp\n  const paused = tuning.paused ?? pausedProp\n  const label = labelProp ?? (draggable ? \"可旋转的地球\" : undefined)\n  const { rootRef, hostRef, labelRefs, ready, dragging, handlers } = useGlobe({\n    markers,\n    arcs,\n    dotTone,\n    markerTone,\n    arcTone,\n    speed,\n    phi,\n    theta,\n    draggable,\n    paused,\n    mapSamples,\n    labels,\n    focus,\n  })\n  const cities = markers.flatMap((marker) =>\n    marker.label ? [marker.label] : [],\n  )\n  const description = label\n    ? cities.length\n      ? `${label}，标注：${cities.join(\"、\")}`\n      : label\n    : undefined\n  return (\n    <div\n      {...props}\n      {...handlers}\n      ref={rootRef}\n      data-slot=\"globe\"\n      data-ready={ready || undefined}\n      data-dragging={dragging || undefined}\n      role={draggable && label ? \"group\" : label ? \"img\" : undefined}\n      aria-roledescription={draggable && label ? \"可旋转的地球\" : undefined}\n      aria-label={description}\n      aria-hidden={label ? undefined : true}\n      tabIndex={draggable && label ? 0 : undefined}\n      className={cn(\n        \"group/globe relative aspect-square w-full select-none\",\n        draggable &&\n          \"cursor-grab touch-pan-y rounded-full outline-none focus-visible:ring-3 focus-visible:ring-ring/50 data-dragging:cursor-grabbing\",\n        className,\n      )}\n    >\n      {/* 画布出来之前（含服务端输出）先占位，尺寸与球体一致，避免布局跳动 */}\n      <div\n        data-slot=\"globe-placeholder\"\n        aria-hidden=\"true\"\n        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\"\n      />\n      <div\n        ref={hostRef}\n        data-slot=\"globe-canvas\"\n        className=\"absolute inset-0\"\n      />\n      <div\n        data-slot=\"globe-labels\"\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-0\"\n      >\n        {markers.map((marker, index) =>\n          (marker.showLabel ?? labels) && marker.label ? (\n            <div\n              key={`${marker.label}-${index}`}\n              ref={(node) => {\n                labelRefs.current[index] = node\n              }}\n              data-slot=\"globe-label\"\n              className=\"absolute top-0 left-0 opacity-0 will-change-transform\"\n              style={flowToneStyle(marker.tone ?? markerTone)}\n            >\n              <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\">\n                <span className=\"size-1.5 rounded-full bg-(--flow-tone)\" />\n                {marker.label}\n              </span>\n            </div>\n          ) : null,\n        )}\n      </div>\n    </div>\n  )\n}\n\ntype GlobeLegendProps = React.ComponentProps<\"ul\"> & {\n  markers: GlobeMarker[]\n  /**\n   * 默认色调，与 `Globe` 的 `markerTone` 保持一致\n   * @default \"primary\"\n   */\n  markerTone?: GlobeTone\n  /**\n   * 同时列出经纬度（等宽字体）\n   * @default false\n   */\n  coordinates?: boolean\n}\n\n/** 标记图例：名称配彩色圆点，可选经纬度；放在地球旁边，给不依赖颜色与动画的文字版本。 */\nfunction GlobeLegend({\n  className,\n  markers,\n  markerTone = \"primary\",\n  coordinates = false,\n  ...props\n}: GlobeLegendProps) {\n  return (\n    <ul\n      data-slot=\"globe-legend\"\n      className={cn(\"flex flex-col gap-1.5 text-sm\", className)}\n      {...props}\n    >\n      {markers.map((marker, index) =>\n        marker.label ? (\n          <li\n            key={`${marker.label}-${index}`}\n            data-slot=\"globe-legend-item\"\n            className=\"flex items-center gap-2\"\n            style={flowToneStyle(marker.tone ?? markerTone)}\n          >\n            <span\n              aria-hidden=\"true\"\n              className=\"size-2 shrink-0 rounded-full bg-(--flow-tone)\"\n            />\n            <span className=\"truncate\">{marker.label}</span>\n            {coordinates ? (\n              <span className=\"ml-auto font-mono text-xs text-muted-foreground tabular-nums\">\n                {marker.location[0].toFixed(1)}°,{\" \"}\n                {marker.location[1].toFixed(1)}°\n              </span>\n            ) : null}\n          </li>\n        ) : null,\n      )}\n    </ul>\n  )\n}\n\nexport { Globe, GlobeLegend, GlobeTuningContext, anglesFor as globeAngles }\nexport type {\n  GlobeArc,\n  GlobeLegendProps,\n  GlobeMarker,\n  GlobeProps,\n  GlobeReport,\n  GlobeTone,\n  GlobeTuning,\n}\n",
      "type": "registry:ui",
      "target": "@ui/globe/globe.tsx"
    },
    {
      "path": "src/registry/ui/globe/use-globe.ts",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport createGlobe, { type Globe as CobeGlobe } from \"cobe\"\nimport { useInView, useReducedMotion } from \"motion/react\"\n\nimport { luminance, resolveColor, toneToCss, type GlobeRgb } from \"./color\"\nimport { anglesFor, MARKER_ELEVATION, project } from \"./projection\"\nimport type { GlobeArc, GlobeMarker, GlobeTone } from \"./types\"\n\ntype UseGlobeOptions = {\n  markers: GlobeMarker[]\n  arcs: GlobeArc[]\n  dotTone: GlobeTone\n  markerTone: GlobeTone\n  arcTone: GlobeTone\n  speed: number\n  phi: number\n  theta: number\n  draggable: boolean\n  paused: boolean\n  mapSamples: number\n  /** 标签层开关变化时重画一次，让新出现的标签立即就位 */\n  labels?: boolean\n  focus?: [number, number] | null\n}\n\n// 俯仰角的活动范围，再大地球会翻过去\nconst MAX_TILT = 0.9\nlet webglSupport: boolean | undefined\n\nfunction supportsWebGL() {\n  if (webglSupport === undefined) {\n    const canvas = document.createElement(\"canvas\")\n    webglSupport = !!(canvas.getContext(\"webgl2\") ?? canvas.getContext(\"webgl\"))\n  }\n  return webglSupport\n}\n\n/** 把角度差折到 (-π, π]，聚焦时沿最短方向转过去。 */\nfunction shortestAngle(delta: number) {\n  const turn = Math.PI * 2\n  return ((((delta + Math.PI) % turn) + turn) % turn) - Math.PI\n}\n\nconst mix = (a: GlobeRgb, b: GlobeRgb, t: number): GlobeRgb => [\n  a[0] + (b[0] - a[0]) * t,\n  a[1] + (b[1] - a[1]) * t,\n  a[2] + (b[2] - a[2]) * t,\n]\nconst scale = (a: GlobeRgb, k: number): GlobeRgb => [\n  a[0] * k,\n  a[1] * k,\n  a[2] * k,\n]\n\nconst clamp = (value: number, min: number, max: number) =>\n  Math.min(max, Math.max(min, value))\n\n/**\n * 地球的渲染循环：创建 cobe 实例、跟随主题取色、自转、拖拽惯性、聚焦缓动，\n * 并把标签层的位置直接写进 DOM（不经过 React，每帧不触发渲染）。\n */\nfunction useGlobe(options: UseGlobeOptions) {\n  const rootRef = React.useRef<HTMLDivElement>(null)\n  const hostRef = React.useRef<HTMLDivElement>(null)\n  const labelRefs = React.useRef<(HTMLElement | null)[]>([])\n  const [ready, setReady] = React.useState(false)\n  const [dragging, setDragging] = React.useState(false)\n  const inView = useInView(rootRef, { amount: 0 })\n  const reduced = !!useReducedMotion()\n\n  // 回调里要读最新的 props，但又不想因此重建 cobe，所以放进 ref，在 effect 里同步\n  const latest = React.useRef({ ...options, inView, reduced })\n  const engine = React.useRef({\n    globe: null as CobeGlobe | null,\n    size: 0,\n    phi: options.phi,\n    theta: options.theta,\n    vPhi: 0,\n    vTheta: 0,\n    dragging: false,\n    pointerX: 0,\n    pointerY: 0,\n    pointerTime: 0,\n    frame: 0,\n    last: 0,\n    requestFrame: () => {},\n    draw: () => {},\n    applyAppearance: () => {},\n  })\n\n  React.useEffect(() => {\n    latest.current = { ...options, inView, reduced }\n  })\n\n  React.useEffect(() => {\n    const root = rootRef.current\n    const host = hostRef.current\n    if (!root || !host || !supportsWebGL()) return\n    const state = engine.current\n    const canvas = document.createElement(\"canvas\")\n    canvas.style.cssText =\n      \"display:block;width:100%;height:100%;opacity:0;transition:opacity .6s\"\n    host.append(canvas)\n    const dpr = Math.min(window.devicePixelRatio || 1, 2)\n    state.size = host.clientWidth || 1\n    const globe = createGlobe(canvas, {\n      width: state.size,\n      height: state.size,\n      devicePixelRatio: dpr,\n      phi: state.phi,\n      theta: state.theta,\n      mapSamples: latest.current.mapSamples,\n      mapBrightness: 6,\n      baseColor: [0.5, 0.5, 0.5],\n      markerColor: [1, 0.5, 0],\n      glowColor: [1, 1, 1],\n      diffuse: 1.2,\n      dark: 0,\n      markerElevation: MARKER_ELEVATION,\n    })\n    state.globe = globe\n\n    const draw = () => {\n      globe.update({ phi: state.phi, theta: state.theta })\n      const { markers } = latest.current\n      markers.forEach((marker, index) => {\n        const el = labelRefs.current[index]\n        if (!el) return\n        const p = project(marker.location, state.phi, state.theta)\n        el.style.transform = `translate3d(${p.x * state.size}px, ${p.y * state.size}px, 0)`\n        el.style.opacity = String(clamp((p.facing - 0.08) / 0.3, 0, 1))\n      })\n    }\n\n    const applyAppearance = () => {\n      const o = latest.current\n      const dot = resolveColor(root, toneToCss(o.dotTone))\n      const bg = resolveColor(root, \"var(--background)\")\n      const dark = luminance(bg) < 0.45\n      // cobe 的着色：亮色模式下 baseColor 是球面底色，陆地点阵是它压暗后的样子；\n      // 暗色模式下 baseColor 是点阵的亮度，球面底色是它的 10%。\n      // 所以亮色取「背景向点阵色靠一点」做底色，暗色直接取点阵色。\n      globe.update({\n        baseColor: dark ? dot : mix(bg, dot, 0.16),\n        glowColor: dark ? scale(dot, 0.35) : bg,\n        mapBrightness: dark ? 6 : 1.8,\n        diffuse: dark ? 1.2 : 0.9,\n        markerColor: resolveColor(root, toneToCss(o.markerTone)),\n        arcColor: resolveColor(root, toneToCss(o.arcTone)),\n        dark: dark ? 1 : 0,\n        markers: o.markers.map((marker) => ({\n          location: marker.location,\n          size: marker.size ?? 0.045,\n          color: resolveColor(root, toneToCss(marker.tone ?? o.markerTone)),\n        })),\n        arcs: o.arcs.map((arc) => ({\n          from: arc.from,\n          to: arc.to,\n          color: resolveColor(root, toneToCss(arc.tone ?? o.arcTone)),\n        })),\n        mapSamples: o.mapSamples,\n      })\n      draw()\n    }\n\n    const frame = (now: number) => {\n      state.frame = 0\n      const o = latest.current\n      const dt = Math.min((now - state.last) / 1000, 0.05)\n      state.last = now\n      let busy = false\n      if (!state.dragging) {\n        const target = o.focus ? anglesFor(o.focus) : null\n        if (target) {\n          const ease = 1 - Math.exp(-dt * 4)\n          const delta = shortestAngle(target.phi - state.phi)\n          state.phi += delta * ease\n          state.theta += (target.theta - state.theta) * ease\n          state.vPhi = 0\n          busy = Math.abs(delta) > 0.002\n        } else {\n          const rotate = o.inView && !o.paused && !o.reduced\n          state.phi += ((rotate ? o.speed : 0) + state.vPhi) * dt\n          state.vPhi *= Math.exp(-dt * 2.2)\n          if (Math.abs(state.vPhi) < 0.002) state.vPhi = 0\n          if (!o.reduced) {\n            state.theta += (o.theta - state.theta) * (1 - Math.exp(-dt * 3))\n            state.theta = clamp(state.theta, -MAX_TILT, MAX_TILT)\n          }\n          busy =\n            rotate ||\n            state.vPhi !== 0 ||\n            (!o.reduced && Math.abs(state.theta - o.theta) > 0.002)\n        }\n      }\n      draw()\n      if (busy && o.inView) state.frame = requestAnimationFrame(frame)\n    }\n    state.requestFrame = () => {\n      if (state.frame) return\n      state.last = performance.now()\n      state.frame = requestAnimationFrame(frame)\n    }\n    state.draw = draw\n    state.applyAppearance = applyAppearance\n\n    const resize = new ResizeObserver(() => {\n      const size = host.clientWidth\n      if (!size || size === state.size) return\n      state.size = size\n      globe.update({ width: size, height: size })\n      draw()\n    })\n    resize.observe(host)\n\n    // 亮暗模式、颜色主题都是改 <html> 的 class / data-theme，重新取色即可\n    const theme = new MutationObserver(applyAppearance)\n    theme.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\", \"data-theme\", \"style\"],\n    })\n\n    applyAppearance()\n    canvas.style.opacity = \"1\"\n    setReady(true)\n    state.requestFrame()\n\n    return () => {\n      cancelAnimationFrame(state.frame)\n      state.frame = 0\n      resize.disconnect()\n      theme.disconnect()\n      globe.destroy()\n      state.globe = null\n      state.draw = () => {}\n      state.requestFrame = () => {}\n      state.applyAppearance = () => {}\n      host.replaceChildren()\n      setReady(false)\n    }\n  }, [])\n\n  // 标记、色调、采样数变化：重新取色并重画\n  const markersKey = JSON.stringify([options.markers, options.arcs])\n  React.useEffect(() => {\n    engine.current.applyAppearance()\n    engine.current.requestFrame()\n  }, [\n    markersKey,\n    options.dotTone,\n    options.markerTone,\n    options.arcTone,\n    options.mapSamples,\n    options.labels,\n  ])\n\n  // 运行中改初始水平角：直接转到新的角度，之后继续自转\n  const lastPhi = React.useRef(options.phi)\n  React.useEffect(() => {\n    if (lastPhi.current === options.phi) return\n    lastPhi.current = options.phi\n    engine.current.phi = options.phi\n    engine.current.draw()\n    engine.current.requestFrame()\n  }, [options.phi])\n\n  // 暂停、进入视口、聚焦目标变化：重新启动渲染循环\n  React.useEffect(() => {\n    engine.current.requestFrame()\n  }, [\n    inView,\n    reduced,\n    options.paused,\n    options.focus,\n    options.speed,\n    options.theta,\n  ])\n\n  const { draggable } = options\n  const handlers = React.useMemo(() => {\n    if (!draggable) return {}\n    const state = engine.current\n    const radius = () => Math.max(1, state.size * 0.4)\n    return {\n      onPointerDown(event: React.PointerEvent<HTMLElement>) {\n        if (event.pointerType === \"mouse\" && event.button !== 0) return\n        event.currentTarget.setPointerCapture(event.pointerId)\n        state.dragging = true\n        state.vPhi = 0\n        state.pointerX = event.clientX\n        state.pointerY = event.clientY\n        state.pointerTime = event.timeStamp\n        setDragging(true)\n      },\n      onPointerMove(event: React.PointerEvent<HTMLElement>) {\n        if (!state.dragging) return\n        const dx = event.clientX - state.pointerX\n        const dy = event.clientY - state.pointerY\n        const dt = Math.max(1, event.timeStamp - state.pointerTime) / 1000\n        state.pointerX = event.clientX\n        state.pointerY = event.clientY\n        state.pointerTime = event.timeStamp\n        state.phi += dx / radius()\n        // 触屏上纵向留给页面滚动，只有鼠标 / 手写笔才改俯仰角\n        if (event.pointerType !== \"touch\")\n          state.theta = clamp(state.theta + dy / radius(), -MAX_TILT, MAX_TILT)\n        // 速度做指数平滑，松手时的惯性不会被最后一次抖动带偏\n        state.vPhi = state.vPhi * 0.6 + (dx / radius() / dt) * 0.4\n        state.draw()\n      },\n      onPointerUp(event: React.PointerEvent<HTMLElement>) {\n        if (!state.dragging) return\n        state.dragging = false\n        if (event.currentTarget.hasPointerCapture(event.pointerId))\n          event.currentTarget.releasePointerCapture(event.pointerId)\n        if (latest.current.reduced) state.vPhi = 0\n        state.vPhi = clamp(state.vPhi, -6, 6)\n        setDragging(false)\n        state.requestFrame()\n      },\n      onKeyDown(event: React.KeyboardEvent<HTMLElement>) {\n        const step = { ArrowLeft: -1, ArrowRight: 1 }[event.key]\n        if (!step) return\n        event.preventDefault()\n        // 键盘按一下给一个速度，之后沿用拖拽的惯性衰减，reduced-motion 下直接转一小步\n        if (latest.current.reduced) state.phi += step * 0.2\n        else state.vPhi = clamp(state.vPhi + step * 1.6, -6, 6)\n        state.requestFrame()\n        state.draw()\n      },\n    }\n  }, [draggable])\n\n  return { rootRef, hostRef, labelRefs, ready, dragging, handlers }\n}\n\nexport { useGlobe }\nexport type { UseGlobeOptions }\n",
      "type": "registry:ui",
      "target": "@ui/globe/use-globe.ts"
    },
    {
      "path": "src/registry/ui/globe/color.ts",
      "content": "import {\n  FLOW_TONES,\n  type FlowTone,\n  type FlowToneName,\n} from \"@/lib/flow-tone\"\n\n/** cobe 接受的颜色：三个 0–1 的分量。 */\ntype GlobeRgb = [number, number, number]\n\nlet probe: CanvasRenderingContext2D | null | undefined\n\nfunction probeContext() {\n  if (probe === undefined) {\n    const canvas = document.createElement(\"canvas\")\n    canvas.width = canvas.height = 1\n    probe = canvas.getContext(\"2d\", { willReadFrequently: true })\n  }\n  return probe\n}\n\n/** 色调名换成 CSS 颜色：内置名走 `--flow-*` 变量，其余原样当作 CSS 颜色。 */\nfunction toneToCss(tone: FlowTone): string {\n  return Object.hasOwn(FLOW_TONES, tone)\n    ? FLOW_TONES[tone as FlowToneName]\n    : tone\n}\n\n/**\n * 在 `scope` 元素的上下文里解析一个 CSS 颜色（可以含 `var()`、`oklch()`、`color-mix()`），\n * 返回 sRGB 分量。必须从元素本身解析：局部的 `.dark` / `data-theme-scope` 只改变该元素内的变量。\n * 解析失败时回落到中灰，避免 WebGL 拿到 NaN。\n */\nfunction resolveColor(scope: Element, css: string): GlobeRgb {\n  const ctx = probeContext()\n  const swatch = document.createElement(\"span\")\n  swatch.style.color = css\n  swatch.style.display = \"none\"\n  scope.append(swatch)\n  const computed = getComputedStyle(swatch).color\n  swatch.remove()\n  if (!ctx) return [0.5, 0.5, 0.5]\n  // 先填一个已知色，若 fillStyle 不认识 computed，会保留它，据此判断解析是否成功\n  ctx.clearRect(0, 0, 1, 1)\n  ctx.fillStyle = \"rgba(0, 0, 0, 0)\"\n  ctx.fillStyle = computed\n  ctx.fillRect(0, 0, 1, 1)\n  const [r = 0, g = 0, b = 0, a] = ctx.getImageData(0, 0, 1, 1).data\n  if (a === 0) return [0.5, 0.5, 0.5]\n  return [r / 255, g / 255, b / 255]\n}\n\n/** 简单的相对亮度，用来判断当前上下文是亮色还是暗色。 */\nfunction luminance([r, g, b]: GlobeRgb) {\n  return 0.2126 * r + 0.7152 * g + 0.0722 * b\n}\n\nexport { luminance, resolveColor, toneToCss }\nexport type { GlobeRgb }\n",
      "type": "registry:ui",
      "target": "@ui/globe/color.ts"
    },
    {
      "path": "src/registry/ui/globe/projection.ts",
      "content": "/**\n * 经纬度 → 屏幕坐标。公式与 cobe 内部一致（见 cobe 的 markers 投影），\n * 用来把 HTML 标签贴在地球表面的标记上，并判断标记是否转到了背面。\n */\n\n/** 球体半径占画布半宽的比例（cobe 固定为 0.8）。 */\nconst GLOBE_RADIUS = 0.8\n/** 标记抬离球面的高度，与传给 cobe 的 `markerElevation` 保持一致。 */\nconst MARKER_ELEVATION = 0.02\n\ntype Projected = {\n  /** 0–1，画布左上为原点 */\n  x: number\n  y: number\n  /** 面向相机的程度：1 正对，0 在球的边缘，负数在背面 */\n  facing: number\n}\n\n/** [纬度, 经度]（角度）换成单位球面上的点。 */\nfunction toUnit([lat, lng]: [number, number]): [number, number, number] {\n  const phi = (lat * Math.PI) / 180\n  const lambda = (lng * Math.PI) / 180 - Math.PI\n  const cosLat = Math.cos(phi)\n  return [-cosLat * Math.cos(lambda), Math.sin(phi), cosLat * Math.sin(lambda)]\n}\n\nfunction project(\n  location: [number, number],\n  phi: number,\n  theta: number,\n  aspect = 1,\n): Projected {\n  const [ux, uy, uz] = toUnit(location)\n  const r = GLOBE_RADIUS + MARKER_ELEVATION\n  const x = ux * r\n  const y = uy * r\n  const z = uz * r\n  const cosPhi = Math.cos(phi)\n  const sinPhi = Math.sin(phi)\n  const cosTheta = Math.cos(theta)\n  const sinTheta = Math.sin(theta)\n  const screenX = cosPhi * x + sinPhi * z\n  const screenY = sinPhi * sinTheta * x + cosTheta * y - cosPhi * sinTheta * z\n  const depth = -sinPhi * cosTheta * x + sinTheta * y + cosPhi * cosTheta * z\n  return {\n    x: (screenX / aspect + 1) / 2,\n    y: (-screenY + 1) / 2,\n    facing: depth / r,\n  }\n}\n\n/** 让 `location` 正对相机所需的 phi / theta（弧度）。 */\nfunction anglesFor([lat, lng]: [number, number]) {\n  return { phi: ((-90 - lng) * Math.PI) / 180, theta: (lat * Math.PI) / 180 }\n}\n\nexport { anglesFor, MARKER_ELEVATION, project }\nexport type { Projected }\n",
      "type": "registry:ui",
      "target": "@ui/globe/projection.ts"
    },
    {
      "path": "src/registry/ui/globe/types.ts",
      "content": "import type { FlowTone } from \"@/lib/flow-tone\"\n\n/** 地球色调：与 flow-diagram 共用，内置名或任意 CSS 颜色（可含 `var()`）。 */\ntype GlobeTone = FlowTone\n\n/** 地球上的一个标记点。 */\ntype GlobeMarker = {\n  /** `[纬度, 经度]`，单位为度 */\n  location: [number, number]\n  /** 城市或地点名：作为读屏描述，开启标签层时显示为胶囊 */\n  label?: string\n  /**\n   * 标记大小，约 0.02（小）到 0.1（大）\n   * @default 0.045\n   */\n  size?: number\n  /** 这个标记的色调，不传则用地球的 `markerTone` */\n  tone?: GlobeTone\n  /** 单独控制这个标记是否显示标签，不传则跟随地球的 `labels` */\n  showLabel?: boolean\n}\n\n/** 两点之间的弧线。 */\ntype GlobeArc = {\n  from: [number, number]\n  to: [number, number]\n  /** 弧线色调，不传则用地球的 `arcTone` */\n  tone?: GlobeTone\n}\n\nexport type { GlobeArc, GlobeMarker, GlobeTone }\n",
      "type": "registry:ui",
      "target": "@ui/globe/types.ts"
    }
  ],
  "cssVars": {
    "theme": {
      "color-edge": "var(--edge)"
    },
    "light": {
      "edge": "color-mix(in oklch, var(--foreground) 12%, transparent)"
    },
    "dark": {
      "edge": "color-mix(in oklch, var(--foreground) 18%, transparent)"
    }
  },
  "type": "registry:ui"
}
