Introducing Cobe: A Minimalist WebGL Globe for Your Site
在为个人网站添加装饰元素时,一个旋转的 3D 地球往往能瞬间提升网站的质感。然而,传统的 3D 库(如 Three.js)虽然功能强大,但体积庞大。如果你正在寻找一个极其轻量、专注且美观的解决方案,那么 Cobe 绝对是最佳选择。
什么是 Cobe?
Cobe 是一个极简的 WebGL 地球库。它的核心优势在于:
- 极小体积:压缩后仅约 5KB。
- 高性能:基于原生 WebGL,即使在低端设备上也能保持流畅。
- 高度可定制:可以轻松添加标记点(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 元素的 left 和 top 属性。
在 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 就是那最后一块拼图。
• ... 次阅读