当我们需要在网页上展示地理数据时,传统的2D地图已经不能满足所有需求。想象一下,如果能将地理信息以立体形式呈现,让用户可以从任意角度查看、与地图进行互动,那会是怎样的体验?这正是Three.js结合d3.js能够实现的强大功能。
在开始之前,我们需要准备好开发环境和必要的工具。这个项目将使用现代前端技术栈,包括:
首先创建一个新的项目目录并初始化:
bash复制mkdir 3d-map-project && cd 3d-map-project
npm init -y
npm install three d3 @types/three @types/d3
对于开发服务器,我强烈推荐使用Vite,它能提供极快的热更新:
bash复制npm install vite --save-dev
创建基本的项目结构:
code复制/public
/assets
/textures
/src
/js
main.js
index.html
在index.html中设置基础HTML结构:
html复制<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>3D中国地图</title>
<style>
body { margin: 0; overflow: hidden; }
canvas { display: block; }
</style>
</head>
<body>
<script type="module" src="/src/js/main.js"></script>
</body>
</html>
GeoJSON是表示地理空间数据的标准格式,我们需要获取高质量的中国地图数据。阿里云DataV提供了免费的GeoJSON数据源,非常适合我们的项目。
访问阿里云DataV的地理小工具平台,我们可以获取到不同精度的中国地图数据:
javascript复制const GEOJSON_URL = "https://geo.datav.aliyun.com/areas_v3/bound/100000_full.json";
这个URL提供了完整的中国省级行政区划数据,包含各省、自治区、直辖市和特别行政区的边界信息。
GeoJSON数据通常具有以下结构:
json复制{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {
"name": "北京市"
},
"geometry": {
"type": "Polygon",
"coordinates": [[...]]
}
},
// 其他省份...
]
}
关键点:
features数组包含所有地理特征properties(如名称)和geometry(几何数据)Polygon或MultiPolygon我们使用Three.js的FileLoader来加载GeoJSON:
javascript复制import * as THREE from 'three';
const loader = new THREE.FileLoader();
loader.load(GEOJSON_URL, (data) => {
const geoData = JSON.parse(data);
processGeoData(geoData);
});
地理坐标(经纬度)需要转换为Three.js的3D坐标系,这是项目中最关键的一步。
d3.js提供了强大的地理投影功能:
javascript复制import * as d3 from 'd3';
// 创建墨卡托投影
const projection = d3.geoMercator()
.center([116.4, 39.9]) // 以北京为中心
.scale(800)
.translate([0, 0]);
这里有几个重要参数需要调整:
center:地图中心点的经纬度scale:缩放比例,影响地图大小translate:在3D空间中的位置偏移对于每个GeoJSON特征,我们需要创建对应的3D几何体:
javascript复制function createProvinceMesh(feature) {
const coordinates = feature.geometry.coordinates;
const province = new THREE.Object3D();
province.name = feature.properties.name;
if (feature.geometry.type === 'MultiPolygon') {
coordinates.forEach((polygon) => {
polygon.forEach((ring) => {
const mesh = createExtrudedShape(ring, projection);
province.add(mesh);
});
});
} else if (feature.geometry.type === 'Polygon') {
coordinates.forEach((ring) => {
const mesh = createExtrudedShape(ring, projection);
province.add(mesh);
});
}
return province;
}
function createExtrudedShape(coords, projection) {
const shape = new THREE.Shape();
coords.forEach((point, idx) => {
const [x, y] = projection(point);
if (idx === 0) {
shape.moveTo(x, -y); // 注意y轴需要翻转
} else {
shape.lineTo(x, -y);
}
});
const extrudeSettings = {
depth: 10,
bevelEnabled: false
};
const geometry = new THREE.ExtrudeGeometry(shape, extrudeSettings);
const material = new THREE.MeshPhongMaterial({
color: 0x449944,
transparent: true,
opacity: 0.8,
side: THREE.DoubleSide
});
return new THREE.Mesh(geometry, material);
}
有了地图几何体后,我们需要设置完整的3D场景,包括相机、灯光和控制器。
javascript复制// 场景
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xf0f0f0);
// 相机
const camera = new THREE.PerspectiveCamera(
60,
window.innerWidth / window.innerHeight,
0.1,
10000
);
camera.position.set(0, 0, 800);
// 渲染器
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// 控制器
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
适当的灯光设置能让3D地图更有层次感:
javascript复制// 环境光
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambientLight);
// 平行光1
const directionalLight1 = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight1.position.set(1, 1, 1);
scene.add(directionalLight1);
// 平行光2
const directionalLight2 = new THREE.DirectionalLight(0xffffff, 0.4);
directionalLight2.position.set(-1, -1, -1);
scene.add(directionalLight2);
这是Three.js应用的核心,负责持续渲染场景:
javascript复制function animate() {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}
animate();
静态的3D地图已经不错,但交互功能能让它真正活起来。
我们可以使用光线投射(Raycasting)来检测鼠标点击:
javascript复制const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
function onMouseClick(event) {
// 将鼠标位置归一化为-1到1的坐标
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
// 更新射线
raycaster.setFromCamera(mouse, camera);
// 检测相交物体
const intersects = raycaster.intersectObjects(scene.children, true);
if (intersects.length > 0) {
const clickedObject = intersects[0].object;
// 查找父级省份对象
let province = clickedObject;
while (province.parent && !province.name) {
province = province.parent;
}
if (province.name) {
// 高亮处理
highlightProvince(province);
}
}
}
window.addEventListener('click', onMouseClick, false);
let highlightedProvince = null;
function highlightProvince(province) {
// 恢复之前高亮的省份
if (highlightedProvince) {
highlightedProvince.traverse(child => {
if (child.isMesh) {
child.material.color.setHex(0x449944);
}
});
}
// 高亮新省份
province.traverse(child => {
if (child.isMesh) {
child.material.color.setHex(0xff0000);
}
});
highlightedProvince = province;
}
除了点击高亮,我们还可以添加悬浮提示:
javascript复制const tooltip = document.createElement('div');
tooltip.style.position = 'absolute';
tooltip.style.backgroundColor = 'rgba(0,0,0,0.7)';
tooltip.style.color = 'white';
tooltip.style.padding = '5px 10px';
tooltip.style.borderRadius = '3px';
tooltip.style.display = 'none';
document.body.appendChild(tooltip);
function onMouseMove(event) {
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(scene.children, true);
if (intersects.length > 0) {
let province = intersects[0].object;
while (province.parent && !province.name) {
province = province.parent;
}
if (province.name) {
tooltip.style.display = 'block';
tooltip.style.left = `${event.clientX + 10}px`;
tooltip.style.top = `${event.clientY + 10}px`;
tooltip.textContent = province.name;
return;
}
}
tooltip.style.display = 'none';
}
window.addEventListener('mousemove', onMouseMove, false);
随着地图细节增加,性能可能成为问题。以下是几个优化建议:
javascript复制const geometry = new THREE.ExtrudeGeometry(shape, {
depth: 10,
bevelEnabled: false,
curveSegments: 12 // 减少曲线分段数
});
// 应用简化修改器
const simplifiedGeo = simplifyModifier.modify(geometry, 0.5);
对于远距离观察,可以使用简化版的几何体:
javascript复制const lod = new THREE.LOD();
// 高细节版本
const highDetailMesh = createDetailedMesh();
highDetailMesh.updateMatrix();
highDetailMesh.matrixAutoUpdate = false;
lod.addLevel(highDetailMesh, 0);
// 低细节版本
const lowDetailMesh = createSimplifiedMesh();
lowDetailMesh.updateMatrix();
lowDetailMesh.matrixAutoUpdate = false;
lod.addLevel(lowDetailMesh, 200); // 200单位距离后切换
scene.add(lod);
根据数据值着色各省份:
javascript复制function colorByValue(province, value) {
const colorScale = d3.scaleSequential(d3.interpolateYlOrRd)
.domain([0, 100]); // 假设值范围0-100
province.traverse(child => {
if (child.isMesh) {
child.material.color.setStyle(colorScale(value));
}
});
}
将所有部分整合成一个完整的解决方案:
javascript复制import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
import * as d3 from 'd3';
// 初始化场景
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xf0f0f0);
// 相机
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 10000);
camera.position.set(0, 0, 800);
// 渲染器
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// 控制器
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
// 灯光
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambientLight);
const directionalLight1 = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight1.position.set(1, 1, 1);
scene.add(directionalLight1);
const directionalLight2 = new THREE.DirectionalLight(0xffffff, 0.4);
directionalLight2.position.set(-1, -1, -1);
scene.add(directionalLight2);
// 坐标投影
const projection = d3.geoMercator()
.center([116.4, 39.9])
.scale(800)
.translate([0, 0]);
// 加载GeoJSON数据
const loader = new THREE.FileLoader();
loader.load('https://geo.datav.aliyun.com/areas_v3/bound/100000_full.json', (data) => {
const geoData = JSON.parse(data);
const map = createMap(geoData);
scene.add(map);
});
function createMap(geoData) {
const map = new THREE.Group();
geoData.features.forEach(feature => {
const province = createProvinceMesh(feature);
map.add(province);
});
return map;
}
function createProvinceMesh(feature) {
const coordinates = feature.geometry.coordinates;
const province = new THREE.Object3D();
province.name = feature.properties.name;
if (feature.geometry.type === 'MultiPolygon') {
coordinates.forEach(polygon => {
polygon.forEach(ring => {
const mesh = createExtrudedShape(ring, projection);
province.add(mesh);
});
});
} else if (feature.geometry.type === 'Polygon') {
coordinates.forEach(ring => {
const mesh = createExtrudedShape(ring, projection);
province.add(mesh);
});
}
return province;
}
function createExtrudedShape(coords, projection) {
const shape = new THREE.Shape();
coords.forEach((point, idx) => {
const [x, y] = projection(point);
if (idx === 0) {
shape.moveTo(x, -y);
} else {
shape.lineTo(x, -y);
}
});
const extrudeSettings = {
depth: 10,
bevelEnabled: false,
curveSegments: 12
};
const geometry = new THREE.ExtrudeGeometry(shape, extrudeSettings);
const material = new THREE.MeshPhongMaterial({
color: 0x449944,
transparent: true,
opacity: 0.8,
side: THREE.DoubleSide
});
return new THREE.Mesh(geometry, material);
}
// 交互功能
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
let highlightedProvince = null;
// 工具提示
const tooltip = document.createElement('div');
tooltip.style.position = 'absolute';
tooltip.style.backgroundColor = 'rgba(0,0,0,0.7)';
tooltip.style.color = 'white';
tooltip.style.padding = '5px 10px';
tooltip.style.borderRadius = '3px';
tooltip.style.display = 'none';
document.body.appendChild(tooltip);
window.addEventListener('click', onMouseClick, false);
window.addEventListener('mousemove', onMouseMove, false);
function onMouseClick(event) {
updateMousePosition(event);
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(scene.children, true);
if (intersects.length > 0) {
const clickedObject = intersects[0].object;
let province = clickedObject;
while (province.parent && !province.name) {
province = province.parent;
}
if (province.name) {
highlightProvince(province);
}
}
}
function onMouseMove(event) {
updateMousePosition(event);
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(scene.children, true);
if (intersects.length > 0) {
let province = intersects[0].object;
while (province.parent && !province.name) {
province = province.parent;
}
if (province.name) {
tooltip.style.display = 'block';
tooltip.style.left = `${event.clientX + 10}px`;
tooltip.style.top = `${event.clientY + 10}px`;
tooltip.textContent = province.name;
return;
}
}
tooltip.style.display = 'none';
}
function updateMousePosition(event) {
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
}
function highlightProvince(province) {
if (highlightedProvince) {
highlightedProvince.traverse(child => {
if (child.isMesh) {
child.material.color.setHex(0x449944);
}
});
}
province.traverse(child => {
if (child.isMesh) {
child.material.color.setHex(0xff0000);
}
});
highlightedProvince = province;
}
// 响应窗口大小变化
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// 动画循环
function animate() {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}
animate();