可视化地图——three.js实现
场景的搭建
我先不管地图不地图的,地图的这些形状肯定是放置到场景中的。跟着我的脚步一步一步去搭建一个场景。场景的搭建就照相机,渲染器。我用一个map类来表示代码如下:
class chinaMap { constructor() { this.init() } init() { // 第一步新建一个场景 this.scene = new THREE.Scene() this.setCamera() this.setRenderer() } // 新建透视相机 setCamera() { // 第二参数就是 长度和宽度比 默认采用浏览器 返回以像素为单位的窗口的内部宽度和高度 this.camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000 ) } // 设置渲染器 setRenderer() { this.renderer = new THREE.WebGLRenderer() // 设置画布的大小 this.renderer.setSize(window.innerWidth, window.innerHeight) //这里 其实就是canvas 画布 renderer.domElement document.body.appendChild(this.renderer.domElement) } // 设置环境光 setLight() { this.ambientLight = new THREE.AmbientLight(0xffffff) // 环境光 this.scene.add(ambientLight) } }
上面我做了一一的解释,现在场景有了,灯光也有了, 我们看下样子。
image-20210703140701037.png
对场景黑乎乎的什么都没有, 接下来我们我随便随便加一个长方体并且调用renderer的render方法。代码如下:
init() { //第一步新建一个场景 this.scene = new THREE.Scene() this.setCamera() this.setRenderer() const geometry = new THREE.BoxGeometry() const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 }) const cube = new THREE.Mesh(geometry, material) this.scene.add(cube) this.render() } //render 方法 render() { this.renderer.render(this.scene, this.camera) }
按照上面👆去做你会页面还是明明都已经加了,为什么呢?
「默认情况下,当我们调用scene.add()的时候,物体将会被添加到(0,0,0)坐标。但将使得摄像机和立方体彼此在一起。为了防止这种情况的发生,我们只需要将摄像机稍微向外移动一些即可」
所以只要将照相机的位置z轴属性调整一下就可以到图片了
// 新建透视相机 setCamera() { // 第二参数就是 长度和宽度比 默认采用浏览器 返回以像素为单位的窗口的内部宽度和高度 this.camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000 ) this.camera.position.z = 5 }
图片如下:
image-20210703142305435.png
这时候有同学就会问,嗯搞半天不和canvas 2d 一样嘛,有什么区别?看不出立体的感觉?OK 接下来我就让这个立方体动起来。其实就是不停的去调用 我们render 函数。我们用reqestanimationframe。尽量还是不要用setInterval,有一个很简单的优化。
「requestAnimationFrame」有很多的优点。最重要的一点或许就是当用户切换到其它的标签页时,它会暂停,因此不会浪费用户宝贵的处理器资源,也不会损耗电池的使用寿命。
我这里做的让立方体的x,y 不断的+0.1。先看下代码:
render() { this.renderer.render(this.scene, this.camera) } animate() { requestAnimationFrame(this.animate.bind(this)) this.cube.rotation.x += 0.01 this.cube.rotation.y += 0.01 this.render() }
效果图如下:
立方体的旋转.gif
是不是有那个那个感觉了, 我是以最简单的立方体的旋转,带大家从头入门下three.js。如果看到这里觉得这里,觉得对你有帮助的话,希望你能给我点个赞👍哦,感谢各位老铁了!下面正式地图需求分析。
地图数据的获得
其实最重要的是获取地图数据, 大家可以了解下openStreetMap
这个是一个可供自由编辑的世界地图。OpenStreetMap允许你查看,编辑或者使用世界各地的地理数据来帮助你。
这里我自己把中国地图的数据json拷贝下来了,代码如下:
// 加载地图数据 loadMapData() { const loader = new THREE.FileLoader() loader.load('../json/china.json', (data) => { const jsondata = JSON.parse(JSON.stringify(data)) }) }
我给大家先看下json 数据的格式
![image-20210703154646470](/Users/wangzhengfei/Library/Application
Support/typora-user-images/image-20210703154646470.png)
其实主要的是下面有个经纬度坐标, 其实这个才是我关心的,有了点才能生成线,最后才能生成平面。这里涉及到一个知识点, 「墨卡托投影转换」。 墨卡托投影转换可以把我们经纬度坐标转换成我们对应平面的2d坐标。大家对这个推导过程的感性的可以看下这篇文章:传送门
这里我直接用可视化框架——「d3」 它里面有自带的墨卡托投影转换。
// 墨卡托投影转换 const projection = d3 .geoMercator() .center([104.0, 37.5]) .scale(80) .translate([0, 0])
由于中国有很多省,每个省都对应一个Object3d。
Object3d是three.js 所有的基类, 提供了一系列的属性和方法来对三维空间中的物体进行操纵。可以通过.add( object )方法来将对象进行组合,该方法将对象添加为子对象
我这里的整个中国是一个大的Object3d,每一个省是一个Object3d,省是挂在中国下的。然后中国这个Map挂在scene这个Object3d下。很明显,在three.js 是一个很典型的树形数据结构,我画了张图给大家看下。
image-20210704115145494.png
Scence场景下挂了很多东西, 其中有一个就是Map, 整个地图, 然后每个省份, 每个省份又是由Mesh和lLine 组成的。
我们看下代码:
generateGeometry(jsondata) { // 初始化一个地图对象 this.map = new THREE.Object3D() // 墨卡托投影转换 const projection = d3 .geoMercator() .center([104.0, 37.5]) .scale(80) .translate([0, 0]) jsondata.features.forEach((elem) => { // 定一个省份3D对象 const province = new THREE.Object3D() this.map.add(province) }) this.scene.add(this.map) }
看到这里我想你可能没有什么问题,我们整体框架定下来了,接下来我们进入核心环节
生成地图几何体
这里用到了 Three.shape() 和 THREE.ExtrudeGeometry() 为什么会用到这个呢? 我给大家解释下, 「首先每一个省份轮廓组成的下标是一个 2d坐标,但是我们要生成立方体,shape() 可以定义一个二维形状平面。它可以和ExtrudeGeometry一起使用,获取点,或者获取三角面。」
代码如下:
// 每个的 坐标 数组 const coordinates = elem.geometry.coordinates // 循环坐标数组 coordinates.forEach((multiPolygon) => { multiPolygon.forEach((polygon) => { const shape = new THREE.Shape() const lineMaterial = new THREE.LineBasicMaterial({ color: 'white', }) const lineGeometry = new THREE.Geometry() for (let i = 0; i < polygon.length; i++) { const [x, y] = projection(polygon[i]) if (i === 0) { shape.moveTo(x, -y) } shape.lineTo(x, -y) lineGeometry.vertices.push(new THREE.Vector3(x, -y, 4.01)) } const extrudeSettings = { depth: 10, bevelEnabled: false, } const geometry = new THREE.ExtrudeGeometry( shape, extrudeSettings ) const material = new THREE.MeshBasicMaterial({ color: '#2defff', transparent: true, opacity: 0.6, }) const material1 = new THREE.MeshBasicMaterial({ color: '#3480C4', transparent: true, opacity: 0.5, }) const mesh = new THREE.Mesh(geometry, [material, material1]) const line = new THREE.Line(lineGeometry, lineMaterial) province.add(mesh) province.add(line) }) })
遍历第一个点的的和canvas2d画图其实是一模一样的, 移动起点, 然后后面在划线, 画出轮廓。然后我们在这里可以设置拉伸的深度, 然后接下来就是设置材质了。
lineGeometry 其实 对应的是轮廓的边线。我们看下图片吧:
image-20210704142519856.png
相机辅助视图
为了方便调相机位置, 我增加了辅助视图, cameraHelper。 然后你回看下屏幕会出现一个十字架,然后我们就可以不断地调整相机的位置,让我们地地图处于画面的中央:
addHelper() { const helper = new THREE.CameraHelper(this.camera) this.scene.add(helper) }
经过辅助的视图地不断调整:
哈哈哈哈,是不是有那个味道了。到这里我们的中国地图已经在画布的中央了就已经实现了。
增加交互控制器
现在地图是已经生成了,但是用户交互感比较差,这里我们引入three的OrbitControls 可以用鼠标在画面随意转动,就可以看到立方体的每一个部分了。但是这个方法不在three 的包里面, 得单独引入一个文件代码如下:
setController() { this.controller = new THREE.OrbitControls( this.camera, document.getElementById('canvas') ) }
我们看下效果:
轨道控制器.gif
射线追踪
但是对于我自己而言还是不满意, 我怎么知道的我点击的是哪一个省份呢,OK这时候就要引入我们three中非常重要的一个类了,Raycaster 。
这个类用于进行raycasting(光线投射)。光线投射用于进行鼠标拾取(在三维空间中计算出鼠标移过了什么物体)。
我们可以对canvas监听的onmouseMove 事件,然后 我们就可以知道当前移动的鼠标是选择的哪一个mesh。但是在这之前,我们先对每一个province这个对象上增加一个属性来表示他是哪一个省份的。
// 将省份的属性 加进来 province.properties = elem.properties
Ok, 我们可以引入射线追踪了带入如下:
setRaycaster() { this.raycaster = new THREE.Raycaster() this.mouse = new THREE.Vector2() const onMouseMove = (event) => { // 将鼠标位置归一化为设备坐标。x 和 y 方向的取值范围是 (-1 to +1) this.mouse.x = (event.clientX / window.innerWidth) * 2 - 1 this.mouse.y = -(event.clientY / window.innerHeight) * 2 + 1 } window.addEventListener('mousemove', onMouseMove, false) } animate() { requestAnimationFrame(this.animate.bind(this)) // 通过摄像机和鼠标位置更新射线 this.raycaster.setFromCamera(this.mouse, this.camera) this.render() }
由于我们不停地在在画布移动, 所以需要不停的的射线位置。现在有了射线, 那我们需要场景的所有东西去比较了,rayCaster 也提供了方法代码如下:
const intersects = this.raycaster.intersectObjects( this.scene.children, // 场景的 true // 若为true,则同时也会检测所有物体的后代。否则将只会检测对象本身的相交部分 )
这个intersects得到的交叉很多,但是呢我们只选择其中一个,那就是物体材质个数有两个的, 因为我们上面就是用对mesh用两个材质
const mesh = new THREE.Mesh(geometry, [material, material1])
所以过滤代码如下
animate() { requestAnimationFrame(this.animate.bind(this)) // 通过摄像机和鼠标位置更新射线 this.raycaster.setFromCamera(this.mouse, this.camera) // 算出射线 与当场景相交的对象有那些 const intersects = this.raycaster.intersectObjects( this.scene.children, true ) const find = intersects.find( (item) => item.object.material && item.object.material.length === 2 ) this.render() }
我怎么知道我到底找到没,我们对找到的mesh将它的表面变成灰色,但是这样会导致一个问题,我们鼠标再一次移动的时候要把上一次的材质给他恢复过来。
代码如下:
animate() { requestAnimationFrame(this.animate.bind(this)) // 通过摄像机和鼠标位置更新射线 this.raycaster.setFromCamera(this.mouse, this.camera) // 算出射线 与当场景相交的对象有那些 const intersects = this.raycaster.intersectObjects( this.scene.children, true ) // 恢复上一次清空的 if (this.lastPick) { this.lastPick.object.material[0].color.set('#2defff') this.lastPick.object.material[1].color.set('#3480C4') } this.lastPick = null this.lastPick = intersects.find( (item) => item.object.material && item.object.material.length === 2 ) if (this.lastPick) { this.lastPick.object.material[0].color.set(0xff0000) this.lastPick.object.material[1].color.set(0xff0000) } this.render() }
看下效果图:
鼠标pick.gif
增加tooltip
为了让交互更加完美,找到了同时在鼠标右下方显示个tooltip,那这个肯定是一个div默认是影藏的,然后根据鼠标的移动移动相应的位置。
第一步新建div
<div id="tooltip"></div>
第二步设置样式 默认是影藏的
#tooltip { position: absolute; z-index: 2; background: white; padding: 10px; border-radius: 2px; visibility: hidden; }
第三步更改div的位置:
setRaycaster() { this.raycaster = new THREE.Raycaster() this.mouse = new THREE.Vector2() this.tooltip = document.getElementById('tooltip') const onMouseMove = (event) => { this.mouse.x = (event.clientX / window.innerWidth) * 2 - 1 this.mouse.y = -(event.clientY / window.innerHeight) * 2 + 1 // 更改div位置 this.tooltip.style.left = event.clientX + 2 + 'px' this.tooltip.style.top = event.clientY + 2 + 'px' } window.addEventListener('mousemove', onMouseMove, false) }
最后一步设置tooltip的名字:
showTip() { // 显示省份的信息 if (this.lastPick) { const properties = this.lastPick.object.parent.properties this.tooltip.textContent = properties.name this.tooltip.style.visibility = 'visible' } else { this.tooltip.style.visibility = 'hidden' } }
到这里,整个3d可视化地球项目已经完成了, 我们一起看下效果吧。
最终效果.gif