当前位置:首页 > 关卡攻略

免费3d地图能看见人(3d地图教程)

2023-09-04 16:16:38

地图下钻是前端开发中常见的开发需求。通常会使用高德、百度等第三方地图实现,不过这些都不是3d的。echarts倒是提供了map3D,以及常用的点位、飞线等功能,就是有一些小bug,而且如果领导比较可爱,提一些奇奇怪怪的需求,可能就不好搞了……

这篇文章我会用three.js实现一个geojson下钻地图。

免费3d地图能看见人(3d地图教程)

地图预览

一、搭建环境

我这里用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占据了窗口。

免费3d地图能看见人(3d地图教程)

啥也没有

接下来需要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大概是下图这样的数据格式:

免费3d地图能看见人(3d地图教程)

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对象上。

免费3d地图能看见人(3d地图教程)

地图结构

创建地图后的完整代码:

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()

免费3d地图能看见人(3d地图教程)

简单地图

这时,已经生成一个完整的地图,但是当我们试着去交互时还不能旋转,只需要添加一个控制器

// 引入构造器

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;

}

免费3d地图能看见人(3d地图教程)

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))

}

免费3d地图能看见人(3d地图教程)

高亮

四、还差一个tooltip

引入 CSS2DRenderer CSS2DObject,创建一个2D渲染器,用2D渲染器生成一个tooltip。在此之前,需要在 loadData方法创建area时把地区属性添加到Mesh对象上。确保lastPick对象上能取到地域名称。

// 把地区属性存到area对象中

area.properties = elem.properties

免费3d地图能看见人(3d地图教程)

把地区属性存到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);

}

}

免费3d地图能看见人(3d地图教程)

label样式

免费3d地图能看见人(3d地图教程)

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();

标签 地图   能看   见人   教程   免费