免费3d地图能看见人(3d地图教程)
地图下钻是前端开发中常见的开发需求。通常会使用高德、百度等第三方地图实现,不过这些都不是3d的。echarts倒是提供了map3D,以及常用的点位、飞线等功能,就是有一些小bug,而且如果领导比较可爱,提一些奇奇怪怪的需求,可能就不好搞了……
这篇文章我会用three.js实现一个geojson下钻地图。
地图预览
一、搭建环境
我这里用parcel搭建一个简易的开发环境,安装依赖如下:
{
"name": "three",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "parcel src/index.html",
"build": "parcel build src/index.html"
},
"author": "",
"license": "ISC",
"devDependencies": {
"parcel-bundler": "^1.12.5"
},
"dependencies": {
"d3": "^7.6.1",
"d3-geo": "^3.0.1",
"three": "^0.142.0"
}
}二、创建场景、相机、渲染器以及地图import * as THREE from three
class Map3D {
constructor() {
this.scene = undefined // 场景
this.camera = undefined// 相机
this.renderer = undefined // 渲染器
this.init()
}
init() {
// 创建场景
this.scene = new THREE.Scene()// 创建相机
this.setCamera()
// 创建渲染器
this.setRender()
// 渲染函数
this.render()
}
/**
* 创建相机
*/
setCamera() {
// PerspectiveCamera(角度,长宽比,近端面,远端面) —— 透视相机
this.camera = new THREE.PerspectiveCamera(75,
window.innerWidth / window.innerHeight,
0.1,
1000
)
// 设置相机位置
this.camera.position.set(0,0, 120)
// 把相机添加到场景中
this.scene.add(this.camera)
}
/**
* 创建渲染器
*/
setRender() {
this.renderer = new THREE.WebGLRenderer()// 渲染器尺寸
this.renderer.setSize(window.innerWidth, window.innerHeight)//设置背景颜色
this.renderer.setClearColor(0x000000)
// 将渲染器追加到dom中
document.body.appendChild(this.renderer.domElement)
}
render() {this.renderer.render(this.scene, this.camera)
requestAnimationFrame(this.render.bind(this))
}
}
const map = new Map3D()
场景、相机、渲染器是threejs中必不可少的要素。以上代码运行起来后可以看到屏幕一片黑,审查元素是一个canvas占据了窗口。
啥也没有
接下来需要geojson数据了,阿里的datav免费提供区级以上的数据:
https://datav.aliyun.com/portal/school/atlas/area_selectorclass Map3D {
// 省略代码
// 以下为新增代码init() {
......
this.loadData()
}
getGeoJson (adcode =100000) {
return fetch(`https://geo.datav.aliyun.com/areas_v3/bound/${adcode}_full.json`)
.then(res => res.json())
}
async loadData(adcode) {
this.geojson = await this.getGeoJson(adcode)console.log(this.geojson)
}
}
const map = new Map3D()
得到的json大概是下图这样的数据格式:
geojson
然后,我们初始化一个地图 当然,咱们拿到的json数据中的所有坐标都是经纬度坐标,是不能直接在我们的threejs项目中使用的。需要 “墨卡托投影转换”把经纬度转换成画布中的坐标。在这里,我们使用现成的工具——d3中的墨卡托投影转换工具
import * as d3 from d3-geo
class Map3D {
......
async loadData(adcode) {
// 获取geojson数据
this.geojson =await this.getGeoJson(adcode)
// 墨卡托投影转换。将中心点设置成经纬度为 104.0, 37.5 的地点,且不平移
this.projection = d3
.geoMercator()
.center([104.0, 37.5])
.translate([0, 0])
}
}
接着就可以创建地图了。
创建地图的思路:以中国地图为例,创建一个Object3D对象,作为整个中国地图。再创建N个Object3D子对象,每个子对象都是一个省份,再将这些子对象add到中国地图这个父Object3D对象上。
地图结构
创建地图后的完整代码:
import * as THREE from three
import * as d3 from d3-geo
const MATERIAL_COLOR1 = "#2887ee";
constMATERIAL_COLOR2 ="#2887d9";
class Map3D {
constructor() {
this.scene = undefined // 场景
this.camera = undefined // 相机
this.renderer =undefined // 渲染器
this.geojson = undefined // 地图json数据
this.init()
}
init() {
// 创建场景
this.scene =new THREE.Scene()
// 创建相机
this.setCamera()
// 创建渲染器
this.setRender()
// 渲染函数
this.render()
// 加载数据
this.loadData()
}/**
* 创建相机
*/
setCamera() {
// PerspectiveCamera(角度,长宽比,近端面,远端面) —— 透视相机
this.camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
)
// 设置相机位置
this.camera.position.set(0, 0, 120)
// 把相机添加到场景中
this.scene.add(this.camera)
}
/**
* 创建渲染器
*/
setRender() {
this.renderer = new THREE.WebGLRenderer()
// 渲染器尺寸
this.renderer.setSize(window.innerWidth,window.innerHeight)
//设置背景颜色
this.renderer.setClearColor(0x000000)
// 将渲染器追加到dom中
document.body.appendChild(this.renderer.domElement)
}
render() {
this.renderer.render(this.scene, this.camera)
requestAnimationFrame(this.render.bind(this))
}
getGeoJson (adcode = 100000) {
returnfetch(`https://geo.datav.aliyun.com/areas_v3/bound/${adcode}_full.json`)
.then(res =>res.json())
}async loadData(adcode) {
// 获取geojson数据
this.geojson = await this.getGeoJson(adcode)
// 创建墨卡托投影
this.projection = d3
.geoMercator()
.center([104.0, 37.5])
.translate([0, 0])
// Object3D是Three.js中大部分对象的基类,提供了一系列的属性和方法来对三维空间中的物体进行操纵。
// 初始化一个地图
this.map = new THREE.Object3D();
this.geojson.features.forEach(elem => {
const area = new THREE.Object3D()
// 坐标系数组(为什么是数组,因为有地区不止一个几何体,比如河北被北京分开了,比如舟山群岛)
constcoordinates = elem.geometry.coordinatesconst type = elem.geometry.type
// 定义一个画几何体的方法
const drawPolygon = (polygon) => {
// Shape(形状)。使用路径以及可选的孔洞来定义一个二维形状平面。 它可以和ExtrudeGeometry、ShapeGeometry一起使用,获取点,或者获取三角面。
const shape = new THREE.Shape()
// 存放的点位,最后需要用THREE.Line将点位构成一条线,也就是地图上区域间的边界线
// 为什么两个数组,因为需要三维地图的两面都画线,且它们的z坐标不同
let points1 = [];
let points2 = [];
for (let i = 0; i < polygon.length; i++) {
// 将经纬度通过墨卡托投影转换成threejs中的坐标
const [x, y] = this.projection(polygon[i]);
// 画二维形状
if (i === 0) {
shape.moveTo(x, -y);
}
shape.lineTo(x, -y);
points1.push(new THREE.Vector3(x, -y, 10));
points2.push(newTHREE.Vector3(x, -y,0));
}
/**
* ExtrudeGeometry (挤压缓冲几何体)
* 文档链接:https://threejs.org/docs/index.html?q=ExtrudeGeometry#api/zh/geometries/ExtrudeGeometry
*/
const geometry = newTHREE.ExtrudeGeometry(shape, {
depth:10,
bevelEnabled: false,
});
/**
* 基础材质
*/
// 正反两面的材质
const material1 = new THREE.MeshBasicMaterial({
color: MATERIAL_COLOR1,
});
// 侧边材质
constmaterial2 =new THREE.MeshBasicMaterial({
color: MATERIAL_COLOR2,
});
// 生成一个几何物体(如果是中国地图,那么每一个mesh就是一个省份几何体)
const mesh = new THREE.Mesh(geometry, [material1, material2]);
area.add(mesh);
/**
* 画线
* link: https://threejs.org/docs/index.html?q=Line#api/zh/objects/Line
*/
const lineGeometry1 = new THREE.BufferGeometry().setFromPoints(points1);
const lineGeometry2 = newTHREE.BufferGeometry().setFromPoints(points2);const lineMaterial = new THREE.LineBasicMaterial({ color: 0xffffff });
const line1 = new THREE.Line(lineGeometry1, lineMaterial);
const line2 = newTHREE.Line(lineGeometry2, lineMaterial);
area.add(line1);
area.add(line2);
}// type可能是MultiPolygon 也可能是Polygon
if (type==="MultiPolygon") {
coordinates.forEach((multiPolygon) => {
multiPolygon.forEach((polygon) =>{
drawPolygon(polygon);
});
});
}else {
coordinates.forEach((polygon) =>{
drawPolygon(polygon);
});
}// 把区域添加到地图中
this.map.add(area);
})
// 把地图添加到场景中
this.scene.add(this.map)
}
}
const map = new Map3D()
简单地图
这时,已经生成一个完整的地图,但是当我们试着去交互时还不能旋转,只需要添加一个控制器
// 引入构造器
import{ OrbitControls } fromthree/examples/jsm/controls/OrbitControls
init() {
this.setControls()
}
setControls() {this.controls = new OrbitControls(this.camera, this.renderer.domElement)
// 太灵活了,来个阻尼
this.controls.enableDamping =true;
this.controls.dampingFactor = 0.1;
}
controls
好了,现在就可以想看哪儿就看哪儿了。
三、当鼠标移入地图时让对应的地区高亮
Raycaster —— 光线投射Raycaster
文档链接:
https://threejs.org/docs/index.html?q=Raycaster#api/zh/core/Raycaster
Raycaster用于进行raycasting(光线投射)。 光线投射用于进行鼠标拾取(在三维空间中计算出鼠标移过了什么物体)。
这个类有两个方法,
第一个setFromCamera(coords, camera)方法,它接收两个参数:
coords
—— 在标准化设备坐标中鼠标的二维坐标 —— X分量与Y分量应当在-1到1之间。
camera —— 射线所来源的摄像机。通过这个方法可以更新射线。
第二个intersectObjects: 检测所有在射线与这些物体之间,包括或不包括后代的相交部分。返回结果时,相交部分将按距离进行排序,最近的位于第一个)。
我们可以通过监听鼠标事件,实时更新鼠标的坐标,同时实时在渲染函数中更新射线,然后通过intersectObjects方法查找当前鼠标移过的物体。
// 以下是新添加的代码
init() {
// 创建场景
this.scene = new THREE.Scene()// 创建相机
this.setCamera()
// 创建渲染器
this.setRender()
// 创建控制器
this.setControls()
// 光线投射
this.setRaycaster()// 加载数据
this.loadData()
// 渲染函数
this.render()
}
setRaycaster() {
this.raycaster = new THREE.Raycaster();this.mouse = new THREE.Vector2();
const onMouse = (event) => {
// 将鼠标位置归一化为设备坐标。x 和 y 方向的取值范围是 (-1 to +1)
// threejs的三维坐标系是中间为原点,鼠标事件的获得的坐标是左上角为原点。因此需要在这里转换
this.mouse.x = (event.clientX / window.innerWidth) *2 - 1
this.mouse.y = -(event.clientY / window.innerHeight) * 2 + 1};
window.addEventListener("mousemove", onMouse, false);
}
render() {
this.raycaster.setFromCamera(this.mouse,this.camera)
const intersects = this.raycaster.intersectObjects(
this.scene.children,
true
)
// 如果this.lastPick存在,将材质颜色还原
if (this.lastPick) {
this.lastPick.object.material[0].color.set(MATERIAL_COLOR1);
this.lastPick.object.material[1].color.set(MATERIAL_COLOR2);
}
// 置空
this.lastPick = null;
// 查询当前鼠标移动所产生的射线与物体的焦点
// 有两个material的就是我们要找的对象
this.lastPick = intersects.find(
(item) => item.object.material && item.object.material.length === 2
);
// 找到后把颜色换成一个鲜艳的绿色
if (this.lastPick) {
this.lastPick.object.material[0].color.set("aquamarine");
this.lastPick.object.material[1].color.set("aquamarine");
}
this.renderer.render(this.scene, this.camera)
requestAnimationFrame(this.render.bind(this))
}
高亮
四、还差一个tooltip
引入 CSS2DRenderer 和 CSS2DObject,创建一个2D渲染器,用2D渲染器生成一个tooltip。在此之前,需要在 loadData方法创建area时把地区属性添加到Mesh对象上。确保lastPick对象上能取到地域名称。
// 把地区属性存到area对象中
area.properties = elem.properties
把地区属性存到Mash对象中
// 引入CSS2DObject, CSS2DRenderer
import { CSS2DObject, CSS2DRenderer } from three/examples/jsm/renderers/CSS2DRenderer
class Map3D {
setRender() {
......
// CSS2DRenderer 创建的是html的div元素
// 这里将div设置成绝对定位,盖住canvas画布
this.css2dRenderer = new CSS2DRenderer();
this.css2dRenderer.setSize(window.innerWidth, window.innerHeight);this.css2dRenderer.domElement.style.position = "absolute";
this.css2dRenderer.domElement.style.top ="0px";
this.css2dRenderer.domElement.style.pointerEvents = "none";
document.body.appendChild(this.css2dRenderer.domElement);
}
render() {
// 省略......
this.showTip()
this.css2dRenderer.render(this.scene, this.camera)
// 省略 ......
}
showTip () {
if (!this.dom) {
this.dom = document.createElement("div");
this.tip = new CSS2DObject(this.dom);
}
if (this.lastPick) {
const{ x, y, z } =this.lastPick.point;
const properties = this.lastPick.object.parent.properties;
// label的样式在直接用css写在样式表中
this.dom.className = "label";
this.dom.innerText = properties.name
this.tip.position.set(x + 10, y + 10, z);this.map && this.map.add(this.tip);
}
}
}
label样式
3D中国地图
此时的完整代码:
import * as THREE from three
import * as d3 from d3-geo
import { OrbitControls } from three/examples/jsm/controls/OrbitControls
import{ CSS2DObject, CSS2DRenderer } fromthree/examples/jsm/renderers/CSS2DRenderer
const MATERIAL_COLOR1 = "#2887ee";
const MATERIAL_COLOR2 = "#2887d9";
class Map3D {
constructor() {
this.scene = undefined // 场景
this.camera = undefined// 相机
this.renderer = undefined // 渲染器
this.css2dRenderer = undefined // html渲染器
this.geojson = undefined// 地图json数据
this.init()
}
init() {
// 创建场景
this.scene = new THREE.Scene()
// 创建相机
this.setCamera()// 创建渲染器
this.setRender()
// 创建控制器
this.setControls()
// 光线投射
this.setRaycaster()
// 加载数据
this.loadData()// 渲染函数
this.render()
}
/**
* 创建相机
*/
setCamera() {
// PerspectiveCamera(角度,长宽比,近端面,远端面) —— 透视相机
this.camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,0.1,
1000
)
// 设置相机位置
this.camera.position.set(0, 0, 120)
// 把相机添加到场景中
this.scene.add(this.camera)
}/**
* 创建渲染器
*/
setRender() {
this.renderer = new THREE.WebGLRenderer()
// 渲染器尺寸
this.renderer.setSize(window.innerWidth, window.innerHeight)
//设置背景颜色
this.renderer.setClearColor(0x000000)
// 将渲染器追加到dom中
document.body.appendChild(this.renderer.domElement)
// CSS2DRenderer 创建的是html的div元素
// 这里将div设置成绝对定位,盖住canvas画布
this.css2dRenderer = new CSS2DRenderer();
this.css2dRenderer.setSize(window.innerWidth, window.innerHeight);this.css2dRenderer.domElement.style.position = "absolute";
this.css2dRenderer.domElement.style.top ="0px";
this.css2dRenderer.domElement.style.pointerEvents = "none";
document.body.appendChild(this.css2dRenderer.domElement);
}
setRaycaster() {
this.raycaster = new THREE.Raycaster();this.mouse = new THREE.Vector2();
const onMouse = (event) => {
// 将鼠标位置归一化为设备坐标。x 和 y 方向的取值范围是 (-1 to +1)
// threejs的三维坐标系是中间为原点,鼠标事件的获得的坐标是左上角为原点。因此需要在这里转换
this.mouse.x = (event.clientX / window.innerWidth) *2 - 1
this.mouse.y = -(event.clientY / window.innerHeight) * 2 + 1};
window.addEventListener("mousemove", onMouse, false);
}
showTip () {
if (!this.dom) {
this.dom = document.createElement("div");
this.tip = new CSS2DObject(this.dom);
}
if (this.lastPick) {
const{ x, y, z } =this.lastPick.point;
const properties = this.lastPick.object.parent.properties;
this.dom.className ="label";
this.dom.innerText = properties.name
this.tip.position.set(x + 10, y + 10, z);
this.map && this.map.add(this.tip);
}
}
render() {
this.raycaster.setFromCamera(this.mouse, this.camera)
const intersects = this.raycaster.intersectObjects(
this.scene.children,
true
)
// 如果this.lastPick存在,将材质颜色还原
if (this.lastPick) {
this.lastPick.object.material[0].color.set(MATERIAL_COLOR1);
this.lastPick.object.material[1].color.set(MATERIAL_COLOR2);
}
// 置空
this.lastPick = null;
// 查询当前鼠标移动所产生的射线与物体的焦点
// 有两个material的就是我们要找的对象
this.lastPick = intersects.find(
(item) => item.object.material && item.object.material.length ===2
);
// 找到后把颜色换成一个鲜艳的绿色
if (this.lastPick) {
this.lastPick.object.material[0].color.set("aquamarine");
this.lastPick.object.material[1].color.set("aquamarine");
}
this.showTip()
this.renderer.render(this.scene, this.camera)
this.css2dRenderer.render(this.scene, this.camera)
requestAnimationFrame(this.render.bind(this))
}
setControls() {
this.controls = new OrbitControls(this.camera, this.renderer.domElement)
// 太灵活了,来个阻尼
this.controls.enableDamping = true;
this.controls.dampingFactor =0.1;
}
getGeoJson (adcode = 100000) {
return fetch(`https://geo.datav.aliyun.com/areas_v3/bound/${adcode}_full.json`)
.then(res => res.json())
}
async loadData(adcode) {
// 获取geojson数据
this.geojson = await this.getGeoJson(adcode)
// 创建墨卡托投影
this.projection = d3
.geoMercator()
.center([104.0, 37.5])
.translate([0, 0])
// Object3D是Three.js中大部分对象的基类,提供了一系列的属性和方法来对三维空间中的物体进行操纵。
// 初始化一个地图
this.map = new THREE.Object3D();
this.geojson.features.forEach(elem => {
constarea = new THREE.Object3D()// 坐标系数组(为什么是数组,因为有地区不止一个几何体,比如河北被北京分开了,比如舟山群岛)
constcoordinates = elem.geometry.coordinatesconst type = elem.geometry.type
// 定义一个画几何体的方法
const drawPolygon = (polygon) => {
// Shape(形状)。使用路径以及可选的孔洞来定义一个二维形状平面。 它可以和ExtrudeGeometry、ShapeGeometry一起使用,获取点,或者获取三角面。
const shape = new THREE.Shape()
// 存放的点位,最后需要用THREE.Line将点位构成一条线,也就是地图上区域间的边界线
// 为什么两个数组,因为需要三维地图的两面都画线,且它们的z坐标不同let points1 = [];
let points2 = [];for (let i = 0; i < polygon.length; i++) {
// 将经纬度通过墨卡托投影转换成threejs中的坐标
const [x, y] = this.projection(polygon[i]);
// 画二维形状
if (i === 0) {
shape.moveTo(x, -y);
}
shape.lineTo(x, -y);
points1.push(new THREE.Vector3(x, -y,10));
points2.push(new THREE.Vector3(x, -y, 0));
}/**
* ExtrudeGeometry (挤压缓冲几何体)
* 文档链接:https://threejs.org/docs/index.html?q=ExtrudeGeometry#api/zh/geometries/ExtrudeGeometry
*/
constgeometry = new THREE.ExtrudeGeometry(shape, {
depth:10,
bevelEnabled: false,
});
/**
* 基础材质
*/
// 正反两面的材质
constmaterial1 = new THREE.MeshBasicMaterial({
color: MATERIAL_COLOR1,
});// 侧边材质
constmaterial2 = new THREE.MeshBasicMaterial({
color: MATERIAL_COLOR2,
});// 生成一个几何物体(如果是中国地图,那么每一个mesh就是一个省份几何体)
const mesh = new THREE.Mesh(geometry, [material1, material2]);
area.add(mesh);
/**
* 画线
* link: https://threejs.org/docs/index.html?q=Line#api/zh/objects/Line
*/
constlineGeometry1 = new THREE.BufferGeometry().setFromPoints(points1);constlineGeometry2 = new THREE.BufferGeometry().setFromPoints(points2);const lineMaterial = new THREE.LineBasicMaterial({ color: 0xffffff });
const line1 = new THREE.Line(lineGeometry1, lineMaterial);
constline2 = new THREE.Line(lineGeometry2, lineMaterial);
area.add(line1);
area.add(line2);// 把地区属性存到area对象中area.properties = elem.properties
}// type可能是MultiPolygon 也可能是Polygon
if (type === "MultiPolygon") {
coordinates.forEach((multiPolygon) => {
multiPolygon.forEach((polygon) => {
drawPolygon(polygon);
});
});
}else {
coordinates.forEach((polygon) => {
drawPolygon(polygon);
});
}
// 把区域添加到地图中
this.map.add(area);
})
// 把地图添加到场景中
this.scene.add(this.map)
}
}
const map = new Map3D()五、地图下钻
现在除了地图下钻,都已经完成了。地图下钻其实就是把当前地图清空,然后再次调用一下 loadData 方法,传入adcode就可以创建对应地区的3D地图了。
思路非常简单,先绑定点击事件,这里就不需要光线投射了,因为已经监听mousever事件了,并且数据已经存在this.lastPick这个变量中了。只需要在监听点击时获取选中的lastPick对象就可以了。
然后调用this.loadData(areaId),不过...在调用loadData方法前需要将创建的地图清空,并且释放几何体和材质对象,防止内存泄露。
理清思路后开始动手。
首先绑定点击事件。我们在调用点击事件时,例如高德地图、echarts,会以 obj.on(click, callback)的形式调用,这样就不会局限于click事件了,双击事件以及其它的事件都可以监听和移除,那我们也试着这么做一个。在Map3D类中创建一个on 监听事件的方法和一个off 移除事件的方法。
class Map3D{
constructor() {
// 监听回调事件存储区
this.callbackStack = new Map();
}
// 省略代码......
// 添加监听事件
on(eventName, callback) {
const fnName = `${eventName}_fn`;
if (!this.callbackStack.get(eventName)) {this.callbackStack.set(eventName, new Set());
}
if (!this.callbackStack.get(eventName).has(callback)) {this.callbackStack.get(eventName).add(callback);
}
if (!this.callbackStack.get(fnName)) {this.callbackStack.set(fnName, (e) => {
this.callbackStack.get(eventName).forEach((cb) => {
if (this.lastPick) cb(e, this.lastPick);
});
});
}
window.addEventListener(eventName, this.callbackStack.get(fnName));
}
// 移除监听事件
off(eventName, callback) {
constfnName = `${eventName}_fn`;if (!this.callbackStack.get(eventName)) return;
if (this.callbackStack.get(eventName).has(callback)) {this.callbackStack.get(eventName).delete(callback);
}
if (this.callbackStack.get(eventName).size <1) {
window.removeEventListener(eventName, this.callbackStack.get(fnName));
}
}
}
const map = new Map3D();