Css2d Label
Runnable three.js example: Css2d Label. Open the live demo, read the complete source, and copy the file into your project. No build step or API key.
single HTML file · CDN dependencies onlyinterface may contain Chinese labels
Source
<!--
说明:把 DOM 元素当作三维标注挂在球面上,主要由浏览器排版,画面里没有贴图文字
核心逻辑:CSS2DObject 绑到经纬度换算的位置,CSS2DRenderer 与 WebGL 共用容器
依赖:three.js 0.160.0、OrbitControls、CSS2DRenderer · jsDelivr CDN(ESM + importmap)
-->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta name="robots" content="noindex,follow">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>HTML 标注</title>
<style>
html,body{height:100%;margin:0;background:#060b12}
body{display:flex;flex-direction:column;overflow:hidden;
font:13px/1.6 -apple-system,"Segoe UI","PingFang SC",sans-serif;color:#e6e8ec}
.bar{flex:0 0 auto;padding:8px 12px;background:#161b22;border-bottom:1px solid #252c36;
display:flex;gap:14px;align-items:center;font-size:12px;color:#8b93a1;flex-wrap:wrap}
.bar b{color:#58a6ff;font-weight:600}
.bar button{font:inherit;font-size:12px;padding:4px 10px;border-radius:5px;cursor:pointer;
border:1px solid #303845;background:#1d2531;color:#c8d2e0}
.bar button:hover{background:#253040}
.bar .sp{flex:1 1 auto}
#stage{flex:1 1 auto;min-height:0;position:relative;overflow:hidden}
#stage canvas{display:block}
.label{position:absolute;left:0;top:0;transform:translate(-50%,-50%);
padding:2px 7px;border-radius:4px;font-size:11px;white-space:nowrap;
background:rgba(9,16,24,.78);border:1px solid #2c4054;color:#cfe2f5;
pointer-events:none;will-change:transform}
.label.major{font-size:12px;color:#9fe6ff;border-color:#3f6a8a;background:rgba(10,24,36,.86)}
.label .dot{display:inline-block;width:5px;height:5px;margin-right:5px;border-radius:50%;
background:#5ad1ff;vertical-align:middle}
</style>
</head>
<body>
<div class="bar">
<span>标注 <b id="cnt">0</b> 个 DOM 节点</span>
<button id="toggle">隐藏标注</button>
<span class="sp"></span>
<span>拖拽旋转地球 · 滚轮缩放</span>
</div>
<div id="stage"></div>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { CSS2DRenderer, CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js';
const stage = document.getElementById('stage');
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(stage.clientWidth, stage.clientHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
stage.appendChild(renderer.domElement);
const labelRenderer = new CSS2DRenderer();
labelRenderer.setSize(stage.clientWidth, stage.clientHeight);
labelRenderer.domElement.style.position = 'absolute';
labelRenderer.domElement.style.top = '0';
labelRenderer.domElement.style.left = '0';
labelRenderer.domElement.style.pointerEvents = 'none';
stage.appendChild(labelRenderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x060b12);
const camera = new THREE.PerspectiveCamera(42, stage.clientWidth / stage.clientHeight, 0.1, 2000);
camera.position.set(0, 6, 13);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.minDistance = 6.5;
controls.maxDistance = 26;
controls.update();
scene.add(new THREE.HemisphereLight(0xbcd8ff, 0x0a1218, 1.2));
const sun = new THREE.DirectionalLight(0xffffff, 2.2);
sun.position.set(8, 6, 10);
scene.add(sun);
const R = 4;
/* 球体本体 */
const globe = new THREE.Mesh(
new THREE.SphereGeometry(R, 64, 48),
new THREE.MeshStandardMaterial({ color: 0x1b3b52, roughness: 0.85, metalness: 0.05 })
);
scene.add(globe);
/* 经纬网:每隔 30° 一条线 */
const gratMat = new THREE.LineBasicMaterial({ color: 0x2f5b78, transparent: true, opacity: 0.55 });
for (let lat = -60; lat <= 60; lat += 30) {
const pts = [];
const phi = THREE.MathUtils.degToRad(lat);
for (let lon = 0; lon <= 360; lon += 4) {
const th = THREE.MathUtils.degToRad(lon);
pts.push(new THREE.Vector3(
R * Math.cos(phi) * Math.cos(th),
R * Math.sin(phi),
R * Math.cos(phi) * Math.sin(th)
));
}
scene.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), gratMat));
}
for (let lon = 0; lon < 360; lon += 30) {
const pts = [];
const th = THREE.MathUtils.degToRad(lon);
for (let lat = -90; lat <= 90; lat += 4) {
const phi = THREE.MathUtils.degToRad(lat);
pts.push(new THREE.Vector3(
R * Math.cos(phi) * Math.cos(th),
R * Math.sin(phi),
R * Math.cos(phi) * Math.sin(th)
));
}
scene.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), gratMat));
}
/* 经纬度转球面坐标 */
function latLonToVec(lat, lon, radius) {
const phi = THREE.MathUtils.degToRad(lat);
const theta = THREE.MathUtils.degToRad(lon);
return new THREE.Vector3(
radius * Math.cos(phi) * Math.cos(theta),
radius * Math.sin(phi),
radius * Math.cos(phi) * Math.sin(theta)
);
}
const CITIES = [
{ name: '北京', lat: 39.9, lon: 116.4, major: true },
{ name: '上海', lat: 31.2, lon: 121.5, major: true },
{ name: '广州', lat: 23.1, lon: 113.3 },
{ name: '乌鲁木齐', lat: 43.8, lon: 87.6 },
{ name: '拉萨', lat: 29.7, lon: 91.1 },
{ name: '哈尔滨', lat: 45.8, lon: 126.6 },
{ name: '成都', lat: 30.7, lon: 104.1 },
{ name: '昆明', lat: 25.0, lon: 102.7 },
{ name: '新加坡', lat: 1.35, lon: 103.8 },
{ name: '悉尼', lat: -33.9, lon: 151.2 },
{ name: '莫斯科', lat: 55.8, lon: 37.6 },
{ name: '开罗', lat: 30.0, lon: 31.2 },
{ name: '内罗毕', lat: -1.3, lon: 36.8 },
{ name: '纽约', lat: 40.7, lon: -74.0, major: true },
{ name: '圣保罗', lat: -23.6, lon: -46.6 },
{ name: '洛杉矶', lat: 34.1, lon: -118.2 }
];
const labels = [];
const labelGroup = new THREE.Group();
scene.add(labelGroup);
CITIES.forEach(function (city) {
const p = latLonToVec(city.lat, city.lon, R * 1.005);
const dot = new THREE.Mesh(
new THREE.SphereGeometry(city.major ? 0.075 : 0.05, 10, 8),
new THREE.MeshBasicMaterial({ color: city.major ? 0x9fe6ff : 0x5ad1ff })
);
dot.position.copy(p);
labelGroup.add(dot);
const div = document.createElement('div');
div.className = 'label' + (city.major ? ' major' : '');
div.innerHTML = '<span class="dot"></span>' + city.name;
const obj = new CSS2DObject(div);
obj.position.copy(p).multiplyScalar(1.02);
labelGroup.add(obj);
labels.push(obj);
});
document.getElementById('cnt').textContent = String(labels.length);
let visible = true;
const toggleBtn = document.getElementById('toggle');
toggleBtn.addEventListener('click', function () {
visible = !visible;
labelGroup.visible = visible;
labelRenderer.domElement.style.display = visible ? 'block' : 'none';
toggleBtn.textContent = visible ? '隐藏标注' : '显示标注';
});
const clock = new THREE.Clock();
renderer.setAnimationLoop(function () {
globe.rotation.y += 0.0012;
labelGroup.rotation.y = globe.rotation.y;
controls.update();
renderer.render(scene, camera);
labelRenderer.render(scene, camera);
});
window.addEventListener('resize', function () {
const w = stage.clientWidth;
const h = stage.clientHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
labelRenderer.setSize(w, h);
});
</script>
</body>
</html>
Language: the code and this page are in English; the full tutorial and API notes for this example are in Chinese. The demo itself is the authoritative version — it runs exactly as shown.