threejs渲染地图数据
一年合同到期公司不续约了,闲来无事,我写一下个人技术博客吧。之前也一直在掘金上学习threejs怎么渲染地图数据的,今天我把自己学到的给大家来个总结。
1.准备地理数据
网址:DataV.GeoAtlas地理小工具系列,这个是阿里的下载地图数据的网站,你可以去下载全中国或者省市县的数据。
2.用到的three.js的绘制地图的api
three.js是一个3d渲染引擎,它能够绘制3d的物体,也有丰富的api实现很多很炫很酷的效果。 渲染地图的数据就要依赖ExtrudeGeometry(shapes : Array, options : Object) (挤压缓冲几何体)。接受两个参数,shapes是形状,这个shapes就用到地理数据,划重点;options是几何体的一些配置。
ExtrudeGeometry(shapes : Array, options : Object)
shapes --- 形状或者一个包含形状的数组。
options --- 一个包含有下列参数的对象:
- curveSegments --- int,曲线上点的数量,默认值是12。
- steps --- int,用于沿着挤出样条的深度细分的点的数量,默认值为1。
- depth --- float,挤出的形状的深度,默认值为1。
- bevelEnabled --- bool,对挤出的形状应用是否斜角,默认值为true。
- bevelThickness --- float,设置原始形状上斜角的厚度。默认值为0.2。
- bevelSize --- float。斜角与原始形状轮廓之间的延伸距离,默认值为bevelThickness-0.1。
- bevelOffset --- float. Distance from the shape outline that the bevel starts. Default is 0.
- bevelSegments --- int。斜角的分段层数,默认值为3。
- extrudePath --- THREE.Curve对象。一条沿着被挤出形状的三维样条线。Bevels not supported for path extrusion.
- UVGenerator --- Object。提供了UV生成器函数的对象。
shapes要用到另一个api THREE.Shape (使用路径以及可选的孔洞来定义一个二维形状平面),需要用到两个它的方法。

3.坐标系的转换
因为我们是基于threejs渲染的,而threejs的坐标系肯定和经纬度的地球坐标系不同,这就要借助一个工具,把地球经纬度数据转变成一个threejs的数据了。这个工具是d3-geo这个库里的geoMercator 圆柱投影 | D3 中文网。球面墨卡托投影。将世界投影到一个正方形中。
4.接下来直接放代码吧,这段代码实现了展示全国的各个省份的区域、边界和名称,悬浮移动到每个省有悬浮效果。
js
<template>
<div id="map" ref="mapRef"></div>
</template>
<script setup>
import { ref, nextTick, onMounted } from 'vue'
import geoData from './data/geo.json'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import { geoMercator } from 'd3-geo'
import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js'
import * as turf from '@turf/turf'
import { throttle } from 'lodash-es'
const mapRef = ref(null)
const mapInfo = {
maxLng: -Infinity,
minLng: Infinity,
maxLat: -Infinity,
minLat: Infinity,
centerPos: []
}
let projection, renderer, camera, scene, labelRenderer;
let texture;
const createMap = () => {
const map = new THREE.Object3D()
geoData.features.forEach(item => {
const { coordinates, type } = item.geometry
coordinates.forEach(coord => {
if (type === 'MultiPolygon') {
coord.forEach(coordItem => {
const mesh = createMesh(coordItem)
})
} else {
const mesh = createMesh(coord)
}
})
})
mapInfo.centerPos = [(mapInfo.maxLng + mapInfo.minLng) / 2, (mapInfo.maxLat + mapInfo.minLat) / 2]
console.log(mapInfo);
projection = geoMercator().center(mapInfo.centerPos).translate([0, 0])
console.log(projection([(mapInfo.maxLng + mapInfo.minLng) / 2, (mapInfo.maxLat + mapInfo.minLat) / 2]));
// 墨卡托投影
geoData.features.forEach(item => {
const unit = new THREE.Object3D()
const unit2 = new THREE.Object3D()
const { coordinates, type } = item.geometry
console.log(item);
const depth = Math.random() * 6 + 0.3;
if (item.properties.center) {
let center;
if (type === 'MultiPolygon') {
center = turf.centroid(turf.polygon(coordinates[0]))
} else {
center = turf.centroid(turf.polygon(coordinates))
}
const label = createLabel(item.properties.name, item.properties.center, depth)
const icon = createIcon(item.properties.center, depth)
unit2.add(label, icon)
}
coordinates.forEach(coord => {
// console.log(coordinates);
if (type === 'MultiPolygon') {
coord.forEach(coordItem => {
const mesh = createMesh2(coordItem, depth,item.properties.name)
const line = createLine(coordItem, depth)
unit.add(mesh, ...line)
})
} else {
const mesh = createMesh2(coord, depth,item.properties.name)
const line = createLine(coord, depth)
unit.add(mesh, ...line)
}
})
map.add(unit,unit2)
})
return map
}
const createMesh = (data, depth, color) => {
data.forEach((item, idx) => {
const [x, y] = item
if (x > mapInfo.maxLng) {
mapInfo.maxLng = x
}
if (x < mapInfo.minLng) {
mapInfo.minLng = x
}
if (y > mapInfo.maxLat) {
mapInfo.maxLat = y
}
if (y < mapInfo.minLat) {
mapInfo.minLat = y
}
})
}
function randomHexColor() {
const color = new THREE.Color(`hsl(
${233},
${Math.random() * 30 + 55}%,
${Math.random() * 30 + 55}%)`).getHex()
return color;
}
const createMesh2 = (data, depth, name) => {
const shape = new THREE.Shape()
data.forEach((item, idx) => {
// 墨卡托投影导致y值变化,所以y值要取反
const [x, y] = projection(item)
if (idx == 0) {
shape.moveTo(x, -y)
} else {
shape.lineTo(x, -y)
}
})
// 平面没有厚度
// const shapeGeometry = new THREE.ShapeGeometry(shape)
// 厚度
const shapeGeometry = new THREE.ExtrudeGeometry(
shape,
{
depth,
// bevelThickness: 5, //倒角尺寸:拉伸方向
// bevelSize: 5, //倒角尺寸:垂直拉伸方向
// bevelSegments: 20, //倒圆角:倒角细分精度,默认3
}
);
const shapeMat = new THREE.MeshBasicMaterial({
color: randomHexColor(),
side: THREE.DoubleSide,
transparent: true,
opacity: 0.95
})
let mesh=new THREE.Mesh(shapeGeometry, shapeMat)
mesh.name=name
return mesh
}
const createLine = (data, depth) => {
const points = []
data.forEach(item => {
const [x, y] = projection(item)
points.push(new THREE.Vector3(x, -y, 0))
})
// BufferGeometry 是 Three.js 中一种高效的几何体表示方式,它通过使用 WebGL 的缓冲区(如顶点缓冲区)来存储几何数据
const lineGeometry = new THREE.BufferGeometry().setFromPoints(points)
const uplineMat = new THREE.LineBasicMaterial({
color: 0xffffff,
side: THREE.DoubleSide
})
const downlineMat = new THREE.LineBasicMaterial({
color: 0xffffff,
side: THREE.DoubleSide
})
const upline = new THREE.Line(lineGeometry, uplineMat)
const downline = new THREE.Line(lineGeometry, downlineMat)
upline.position.set(0, 0, depth + 0.5)
downline.position.set(0, 0, -0.5)
return [upline, downline]
}
const createLabel = (text, point, depth) => {
const element = document.createElement('div')
element.className = 'label'
element.textContent = text
element.style.cssText = `
color: #fff;
font-size: 10px;
text-shadow: 0 0 2px #000;
`
const label = new CSS2DObject(element)
const [x, y] = projection(point)
label.position.set(x, -y, depth + 1)
return label
}
const initCSS2DRenderer = () => {
labelRenderer = new CSS2DRenderer()
labelRenderer.domElement.style.position = 'absolute'
labelRenderer.domElement.style.top = '0px'
labelRenderer.domElement.style.left = '0px'
labelRenderer.domElement.style.pointerEvents = 'none'
labelRenderer.setSize(window.innerWidth, window.innerHeight)
mapRef.value?.appendChild(labelRenderer.domElement)
}
const createIcon = (point, depth) => {
const iconUrl = new URL('./img/pos.png', import.meta.url).href
const map = new THREE.TextureLoader().load(iconUrl)
const material = new THREE.SpriteMaterial({
map,
transparent: true,
})
const sprite = new THREE.Sprite(material)
const [x, y] = projection(point)
sprite.position.set(x, -y + 1, depth + 1)
// sprite.renderOrder = 1;
sprite.scale.set(2, 2, 2);
console.log(sprite);
return sprite
}
const hoverMap = () => {
function changeOpacity(obj,opacity) {
obj.children.forEach((item) => {
if (item.type === "Mesh") {
item.material.opacity = opacity;
}else{
if(item.children&&item.children.length){
changeOpacity(item,opacity)
}
}
});
}
// 实现鼠标的交互
mapRef.value.addEventListener('mousemove', throttle((e) => {
const mouse = new THREE.Vector2()
mouse.x = (e.clientX / window.innerWidth) * 2 - 1
mouse.y = -(e.clientY / window.innerHeight) * 2 + 1
const raycaster = new THREE.Raycaster()
raycaster.setFromCamera(mouse, camera)
const intersects = raycaster.intersectObjects(scene.children).filter(item=>item.object.type!='line')
if (intersects.length > 0) {
let intersect = intersects[0].object.parent.parent
console.log(intersect);
changeOpacity(intersect,1)
let intersect2=intersects[0].object.parent
changeOpacity(intersect2,.6)
}
},200))
}
const initMap = (fn) => {
scene = new THREE.Scene()
camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000)
camera.position.set(-10, -20, 40)
camera.lookAt(0, 0, 0)
renderer = new THREE.WebGLRenderer()
renderer.setSize(window.innerWidth, window.innerHeight)
renderer.render(scene, camera)
mapRef.value?.appendChild(renderer.domElement)
const gridHelper = new THREE.GridHelper(100, 10)
gridHelper.position.set(0, -100, 0)
scene.add(gridHelper)
const axisHelper = new THREE.AxesHelper(500)
scene.add(axisHelper)
const controls = new OrbitControls(camera, renderer.domElement)
const map = createMap()
scene.add(map)
initCSS2DRenderer()
hoverMap()
fn?.()
}
onMounted(() => {
initMap(() => {
function animate() {
requestAnimationFrame(animate)
renderer.render(scene, camera)
labelRenderer.render(scene, camera);
}
animate()
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight
camera.updateProjectionMatrix()
renderer.setSize(window.innerWidth, window.innerHeight)
})
})
})
</script>
<style scoped lang="scss">
#map {
width: 100%;
height: 100%;
}
</style>