在为个人网站添加装饰元素时,一个旋转的 3D 地球往往能瞬间提升网站的质感。然而,传统的 3D 库(如 Three.js)虽然功能强大,但体积庞大。如果你正在寻找一个极其轻量、专注且美观的解决方案,那么 Cobe 绝对是最佳选择。

什么是 Cobe?

Cobe 是一个极简的 WebGL 地球库。它的核心优势在于:

  1. 极小体积:压缩后仅约 5KB。
  2. 高性能:基于原生 WebGL,即使在低端设备上也能保持流畅。
  3. 高度可定制:可以轻松添加标记点(Markers)、光晕、以及自定义的 HTML 标签。

如何在 HTML 中布局?

要在页面上展示地球,我们需要一个合理的 HTML 结构。由于 Cobe 是在 Canvas 上渲染的,建议使用一个相对定位的容器来包裹它,这样方便我们后续添加浮动的 HTML 标签。

<!-- 地球容器 -->
<div id="cobe-container" style="width: 100%; max-width: 300px; aspect-ratio: 1; position: relative;">
  <!-- 用于渲染地球的 Canvas -->
  <canvas id="cobe" style="width: 100%; height: 100%; cursor: grab;"></canvas>
  
  <!-- 用于存放自定义 HTML 标签的容器 -->
  <div id="cobe-labels" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none;"></div>
</div>

关键点说明:

  • aspect-ratio: 1: 确保容器是正方形,适配圆形的地球。
  • position: relative: 为内部的 cobe-labels 提供定位基准。
  • pointer-events: none: 确保标签容器不会干扰到 Canvas 的鼠标拖拽交互。

核心配置与交互

Cobe 的强大之处在于其 onRender 回调。我们可以通过它来实现自动旋转、交互反馈以及 2D/3D 坐标转换。

1. 基础配置

import createGlobe from 'https://esm.sh/cobe'

const globe = createGlobe(document.getElementById("cobe"), {
  devicePixelRatio: 2,
  width: 600,
  height: 600,
  phi: 0,
  theta: 0,
  dark: 1,
  diffuse: 1.2,
  mapSamples: 12000,
  mapBrightness: 6,
  baseColor: [1, 1, 1],
  markerColor: [1, 0.5, 0],
  glowColor: [1, 1, 1],
  markers: [
    { location: [23.1291, 113.2644], size: 0.05 }, // 广州经纬度
  ],
  onRender: (state) => {
    // 自动旋转逻辑
    if (!pointerInteracting) {
      state.phi += 0.005;
    }
  }
})

2. 坐标转换与 HTML 标签

Cobe 最酷的功能之一是在 3D 地球上显示 HTML 标签。这需要将经纬度转换为 Canvas 上的 2D 坐标。

onRender 回调中,state 会提供当前的旋转角度。你可以使用球面坐标公式计算出每个标记点的位置,并动态更新对应 HTML 元素的 lefttop 属性。

在 Jekyll 中的最佳实践

在 Jekyll 项目中,建议将上述代码提取到 _includes/cobe.html。这样你就可以在任何博文或页面中通过一行代码引用它:

<div id="cobe-container" style="width: 100%; max-width: 180px; aspect-ratio: 1; margin-top: 0.8rem; position: relative; margin-left: 0;">
  <canvas id="cobe" style="width: 100%; height: 100%; cursor: grab;" onpointerdown="this.style.cursor='grabbing'" onpointerup="this.style.cursor='grab'"></canvas>
  <div id="cobe-labels" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; overflow: hidden;"></div>
</div>

<script type="module">
  import createGlobe from 'https://esm.sh/cobe'

  let phi = -1.6;
  let theta = 0.3;
  let canvas = document.getElementById("cobe");
  let pointerInteracting = null;
  let pointerInteractingY = null;
  
  let currentMarkers = [
    { location: [23.1291, 113.2644], size: 0.05, color: [1, 0.5, 0] } // Guangzhou
  ];
  
  const weatherCache = JSON.parse(localStorage.getItem('weatherData'));
  if (weatherCache && weatherCache.lat && weatherCache.lon) {
    currentMarkers.push({ location: [weatherCache.lat, weatherCache.lon], size: 0.1, color: [1, 0, 0] });
  }

  const updatePointerInteraction = (value) => {
    pointerInteracting = value;
    canvas.style.cursor = value ? 'grabbing' : 'grab';
  };

  const globe = createGlobe(canvas, {
    devicePixelRatio: 2,
    width: 600,
    height: 600,
    phi: phi,
    theta: theta,
    dark: 0.1,
    diffuse: 1.5,
    mapSamples: 12000,
    mapBrightness: 8,
    mapBaseBrightness: 0.10,
    baseColor: [0.95, 0.98, 1],
    markerColor: [0, 1, 1],
    glowColor: [0, 0, 0],
    arcColor: [1, 0.2, 0.8],
    arcWidth: 1.2,
    arcHeight: 0.35,
    markers: currentMarkers,
    
    onRender: (state) => {
      const labelsContainer = document.getElementById("cobe-labels");
      const width = state.width / 2;
      const height = state.height / 2;
      
      const guangzhou = [23.1291, 113.2644];
      
      const updateLabel = (location, text, id) => {
        let label = document.getElementById(id);
        if (!label) {
          label = document.createElement("div");
          label.id = id;
          label.innerText = text;
          label.style.position = "absolute";
          label.style.color = "cyan";
          label.style.fontSize = "12px";
          label.style.fontWeight = "bold";
          label.style.pointerEvents = "none";
          label.style.textShadow = "0 0 4px black";
          labelsContainer.appendChild(label);
        }

        const r = 1;
        const lat = (location[0] * Math.PI) / 180;
        const lng = (location[1] * Math.PI) / 180;
        
        // Cobe base rotation logic
        const x = r * Math.cos(lat) * Math.sin(lng + state.phi);
        const y = r * Math.sin(lat) * Math.cos(state.theta) - r * Math.cos(lat) * Math.cos(lng + state.phi) * Math.sin(state.theta);
        const z = r * Math.sin(lat) * Math.sin(state.theta) + r * Math.cos(lat) * Math.cos(lng + state.phi) * Math.cos(state.theta);

        if (z > 0) {
          label.style.display = "block";
          const screenX = (x + 1) * (width / 2);
          const screenY = (1 - y) * (height / 2);
          label.style.left = `${screenX}px`;
          label.style.top = `${screenY}px`;
          label.style.transform = "translate(-50%, -100%)";
        } else {
          label.style.display = "none";
        }
      };

      updateLabel(guangzhou, "guangzhou", "label-guangzhou");
    },
  });

  window.addEventListener('visitorLocationFound', (e) => {
    const { lat, lon } = e.detail;
    if (lat && lon) {
      // Remove previous visitor marker if it exists
      currentMarkers = currentMarkers.filter(m => m.color.join(',') !== '1,0,0');
      currentMarkers.push({ location: [lat, lon], size: 0.1, color: [1, 0, 0] });
      globe.update({ markers: currentMarkers });
    }
  });

  // 鼠标按下,开始拖动
  canvas.addEventListener('pointerdown', (e) => {
    updatePointerInteraction(e.clientX);
    pointerInteractingY = e.clientY;
  });

  // 鼠标移动,更新旋转角度
  window.addEventListener('pointermove', (e) => {
    if (pointerInteracting !== null) {
      const deltaX = e.clientX - pointerInteracting;
      const deltaY = e.clientY - pointerInteractingY;
      pointerInteracting = e.clientX;
      pointerInteractingY = e.clientY;
      phi += deltaX / 200;
      theta += deltaY / 200;
    }
  });

  // 鼠标松开,停止拖动
  window.addEventListener('pointerup', () => {
    updatePointerInteraction(null);
  });

  // 鼠标离开窗口,停止拖动
  window.addEventListener('mouseout', () => {
    updatePointerInteraction(null);
  });

  // 窗口大小改变时更新
  window.addEventListener('resize', () => {
    const width = canvas.offsetWidth;
    globe.update({ width: width * 2, height: width * 2 });
  });

  // 启动动画循环
  function animate() {
    // 在非交互时自动旋转
    if (!pointerInteracting) {
      phi += 0.005; 
    }
    globe.update({ phi, theta });

    requestAnimationFrame(animate);
  }
  animate();
</script>

这种组件化的方式不仅保持了代码的整洁,也让样式的统一修改变得轻而易举。

结语

Cobe 完美地平衡了美观与性能。它不是一个复杂的 3D 游戏引擎,而是一个优雅的视觉组件。如果你的网站需要一点点“全球化”的氛围,Cobe 就是那最后一块拼图。

... 次阅读