three.js实现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_selector

class 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";const MATERIAL_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') {    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 => {      const area = new THREE.Object3D()      // 坐标系数组(为什么是数组,因为有地区不止一个几何体,比如河北被北京分开了,比如舟山群岛)      const coordinates = elem.geometry.coordinates      const 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         */        const geometry = new THREE.ExtrudeGeometry(shape, {          depth: 10,          bevelEnabled: false,        });        /**         * 基础材质         */        // 正反两面的材质        const material1 = new THREE.MeshBasicMaterial({          color: MATERIAL_COLOR1,        });        // 侧边材质        const material2 = 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 = new THREE.BufferGeometry().setFromPoints(points2);        const lineMaterial = new THREE.LineBasicMaterial({ color: 0xffffff });        const line1 = new THREE.Line(lineGeometry1, lineMaterial);        const line2 = new THREE.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  } from 'three/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(光线投射)。 光线投射用于进行鼠标拾取(在三维空间中计算出鼠标移过了什么物体)。

第二个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, CSS2DRendererimport { 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中国地图

此时的完整代码:

声明:本站部分文章及图片源自用户投稿,如本站任何资料有侵权请您尽早请联系jinwei@zod.com.cn进行处理,非常感谢!

上一篇 2022年10月3日
下一篇 2022年10月3日

相关推荐