https://juejin.im/post/5e344733e51d453ce13d2579
目前接触了一些室内地图的开发工作,二维的、三维的,数据源基本都是采用geojson格式
基于geojson的地图绘制目前已经有比较成熟的框架和解决方案了。
但是今天我们还是要在Threejs里来简单实现一下三维数据的展示。
主要实现了2个功能
- 三维地图展示
- POI信息显示
数据采集
首先我们需要一份中国省份的轮廓数据
在这份数据我们需要的字段有
-
properties.name
用于POI信息的展示
-
properties.centroid
用于POI信息的定位
-
geometry.coordinates
用于构建我们的三维模型
三维环境搭建
注意
-
以下仅是核心代码
-
绿色的线是y轴
-
红色的线是x轴
-
蓝色的线是z轴
1mounted () { 2 // 初始化3D环境 3 this.initEnvironment() 4 // 构建光照系统 5 this.buildLightSystem() 6 // 构建辅助系统 7 this.buildAuxSystem() 8}, 9methods: { 10 // 初始化3D环境 11 initEnvironment () { 12 this.scene = new THREE.Scene(); 13 this.scene.background = new THREE.Color(0xf0f0f0) 14 // 建一个空对象存放对象 15 this.map = new THREE.Object3D() 16 // 设置相机参数 17 this.setCamera(); 18 // 初始化 19 this.renderer = new THREE.WebGLRenderer({ 20 alpha: true, 21 canvas: document.querySelector('canvas') 22 }) 23 this.renderer.setPixelRatio(window.devicePixelRatio) 24 this.renderer.setSize(window.innerWidth, window.innerHeight - 10) 25 document.addEventListener('mousemove', this.onDocumentMouseMove, false) 26 window.addEventListener('resize', this.onWindowResize, false) 27 }, 28 setCamera () { 29 this.camera = new THREE.PerspectiveCamera(35, window.innerWidth / window.innerHeight, 1, 10000); 30 this.camera.position.set(0, -70, 90); 31 this.camera.lookAt(0, 0, 0); 32 }, 33 // 构建辅助系统: 网格和坐标 34 buildAuxSystem () { 35 let axisHelper = new THREE.AxesHelper(2000) 36 this.scene.add(axisHelper) 37 let gridHelper = new THREE.GridHelper(600, 60) 38 this.scene.add(gridHelper) 39 let controls = new THREE.OrbitControls(this.camera, this.renderer.domElement) 40 controls.enableDamping = true 41 controls.dampingFactor = 0.25 42 controls.rotateSpeed = 0.35 43 }, 44 // 光照系统 45 buildLightSystem () { 46 let directionalLight = new THREE.DirectionalLight(0xffffff, 1.1); 47 directionalLight.position.set(300, 1000, 500); 48 directionalLight.target.position.set(0, 0, 0); 49 directionalLight.castShadow = true; 50 51 let d = 300; 52 const fov = 45 //拍摄距离 视野角值越大,场景中的物体越小 53 const near = 1 //相机离视体积最近的距离 54 const far = 1000//相机离视体积最远的距离 55 const aspect = window.innerWidth / window.innerHeight; //纵横比 56 directionalLight.shadow.camera = new THREE.PerspectiveCamera(fov, aspect, near, far); 57 directionalLight.shadow.bias = 0.0001; 58 directionalLight.shadow.mapSize.width = directionalLight.shadow.mapSize.height = 1024; 59 this.scene.add(directionalLight) 60 61 let light = new THREE.AmbientLight(0xffffff, 0.6) 62 this.scene.add(light) 63 64 }, 65 // 根据浏览器窗口变化动态更新尺寸 66 onWindowResize () { 67 this.camera.aspect = window.innerWidth / window.innerHeight; 68 this.camera.updateProjectionMatrix(); 69 this.renderer.setSize(window.innerWidth, window.innerHeight); 70 }, 71 onDocumentMouseMove (event) { 72 event.preventDefault(); 73 } 74}复制代码
绘制地图模型
数据分析
接下来我们需要根据 geometry.coordinates 来绘制地图
1 "geometry": { 2 "type": "MultiPolygon", 3 "coordinates": [ 4 [ 5 [ 6 [ 7 117.210024, 8 40.082262 9 ], 10 [ 11 117.105315, 12 40.074479 13 ], 14 [ 15 117.105315, 16 40.074479 17 ], 18 ... 19 ] 20 ] 21 ] 22 } 23 24复制代码
坐标转化
我们的坐标数据是经纬度坐标,我们需要把它转化成平面坐标 这里用到了 d3-geo 的坐标转化方法
多面绘制
注意这里的类型是 MultiPolygon(多面),我们的坐标点是嵌套在多层数组里面的。
因为我们的数据中,
有的省份轮廓是闭合的
有的省份是多个部分组成的
代码实现
我们的模型分成2部分
-
主体部分:我们用THREE.Shape() + THREE.ExtrudeGeometry()来实现
-
轮廓线部分:我们用THREE.Line()来实现
1 initMap () { 2 // d3-geo转化坐标 3 const projection = d3geo.geoMercator().center([104.0, 37.5]).scale(80).translate([0, 0]); 4 // 遍历省份构建模型 5 chinaJson.features.forEach(elem => { 6 // 新建一个省份容器:用来存放省份对应的模型和轮廓线 7 const province = new THREE.Object3D() 8 const coordinates = elem.geometry.coordinates 9 coordinates.forEach(multiPolygon => { 10 multiPolygon.forEach(polygon => { 11 // 这里的坐标要做2次使用:1次用来构建模型,1次用来构建轮廓线 12 const shape = new THREE.Shape() 13 const lineMaterial = new THREE.LineBasicMaterial({ color: 0xffffff }) 14 const linGeometry = new THREE.Geometry() 15 for (let i = 0; i < polygon.length; i++) { 16 const [x, y] = projection(polygon[i]) 17 if (i === 0) { 18 shape.moveTo(x, -y) 19 } 20 shape.lineTo(x, -y); 21 linGeometry.vertices.push(new THREE.Vector3(x, -y, 4.01)) 22 } 23 const extrudeSettings = { 24 depth: 4, 25 bevelEnabled: false 26 }; 27 const geometry = new THREE.ExtrudeGeometry(shape, extrudeSettings) 28 const material = new THREE.MeshBasicMaterial({ color: '#d13a34', transparent: true, opacity: 0.6 }) 29 const mesh = new THREE.Mesh(geometry, material) 30 const line = new THREE.Line(linGeometry, lineMaterial) 31 province.add(mesh) 32 province.add(line) 33 }) 34 }) 35 // 将geojson的properties放到模型中,后面会用到 36 province.properties = elem.properties 37 if (elem.properties.centroid) { 38 const [x, y] = projection(elem.properties.centroid) 39 province.properties._centroid = [x, y] 40 } 41 this.map.add(province) 42 }) 43 this.scene.add(this.map) 44 }复制代码
实现后的效果是这样
POI信息显示
如果是在室内地图的开发,我们通常会需要显示模块的一些信息,比如名称、图标之类的。这里我们就简单显示一下省份的名称就好。
我的做法是:
-
获取每个省份模块的中心点坐标,并转化成屏幕坐标
-
新建一个canvas,将省份名称根据坐标绘制到canvas上
-
解决坐标的碰撞问题
代码实现
1 showName () { 2 const width = window.innerWidth 3 const height = window.innerHeight 4 let canvas = document.querySelector('#name') 5 if (!canvas) return 6 canvas.width = width; 7 canvas.height = height; 8 const ctx = canvas.getContext('2d'); 9 // 新建一个离屏canvas 10 const offCanvas = document.createElement('canvas') 11 offCanvas.width = width 12 offCanvas.height = height 13 const ctxOffCanvas = canvas.getContext('2d'); 14 // 设置canvas字体样式 15 ctxOffCanvas.font = '16.5px Arial'; 16 ctxOffCanvas.strokeStyle = '#FFFFFF'; 17 ctxOffCanvas.fillStyle = '#000000'; 18 // texts用来存储显示的名称,重叠的部分就不会放到里面 19 const texts = []; 20 /** 21 * 遍历省份数据,有2个核心功能 22 * 1. 将3维坐标转化成2维坐标 23 * 2. 后面遍历到的数据,要和前面的数据做碰撞对比,重叠的就不绘制 24 * */ 25 this.map.children.forEach((elem, index) => { 26 if (!elem.properties._centroid) return 27 // 找到中心点 28 const y = -elem.properties._centroid[1] 29 const x = elem.properties._centroid[0] 30 const z = 4 31 // 转化为二维坐标 32 const vector = new THREE.Vector3(x, y, z) 33 const position = vector.project(this.camera) 34 // 构建文本的基本属性:名称,left, top, width, height -> 碰撞对比需要这些坐标数据 35 const name = elem.properties.name 36 const left = (vector.x + 1) / 2 * width 37 const top = -(vector.y - 1) / 2 * height 38 const text = { 39 name, 40 left, 41 top, 42 width: ctxOffCanvas.measureText(name).width, 43 height: 16.5 44 } 45 // 碰撞对比 46 let show = true 47 for (let i = 0; i < texts.length; i++) { 48 if ( 49 (text.left + text.width) < texts[i].left || 50 (text.top + text.height) < texts[i].top || 51 (texts[i].left + texts[i].width) < text.left || 52 (texts[i].top + texts[i].height) < text.top 53 ) { 54 show = true 55 } else { 56 show = false 57 break 58 } 59 } 60 if (show) { 61 texts.push(text) 62 ctxOffCanvas.strokeText(name, left, top) 63 ctxOffCanvas.fillText(name, left, top) 64 } 65 }) 66 // 离屏canvas绘制到canvas中 67 ctx.drawImage(offCanvas, 0, 0) 68 } 69复制代码
注意,因为我们的canvas是叠在threejs的canvas上仅作为展示的,所以需要加个样式 pointer-events: none;
谢谢阅读