地图下钻是前端开发中常见的开发需求。通常会使用高德、百度等第三方地图实现,不过这些都不是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进行处理,非常感谢!