您当前的位置:首页 > 电脑百科 > 程序开发 > 语言 > javascript

three.js实现3D地图

时间:2022-11-14 11:43:45  来源:网易号  作者:

地图下钻是前端开发中常见的开发需求。通常会使用高德、百度等第三方地图实现,不过这些都不是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.l.NETo(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(光线投射)。 光线投射用于进行鼠标拾取(在三维空间中计算出鼠标移过了什么物体)。

这个类有两个方法,
第一个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)) }

高亮

四、还差一个tooltip

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

// 把地区属性存到area对象中 area.properties = elem.properties

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

label样式

3D中国地图

此时的完整代码:

import * as THREE from 'three' import * as d3 from 'd3-geo' import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' import { CSS2DObject, CSS2DRenderer } from 'three/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 => { 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); // 把地区属性存到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) { const fnName = `${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(); map.on('click', listener) function listener(e, data) { // Mesh对象 console.log(data) // 区域编码 console.log(data.object.parent.properties.adcode) }

在上面的 listener 回调方法中打印可以获取到当前点击区域。
先忍住调用loadData()方法,在此之前,要先抹掉之前一番操作搞出来的地图。

在Map3D类中再创建一个dispose方法,用来移除地图以及释放内存

class Map3D { // 省略代码...... dispose (o) { // 可以遍历该父场景中的所有子物体来执行回调函数 o.traverse(child => { if (child.geometry) { child.geometry.dispose() } if (child.material) { if (Array.isArray(child.material)) { child.material.forEach(material => { material.dispose() }) } else { child.material.dispose() } } }) o.parent.remove(o) } // 省略代码...... } const map = new Map3D() map.on('click', listener) function listener(e, data) { // 区域编码 const adcode = data.object.parent.properties.adcode if(adcode) { map.dispose(map.map) map.loadData(adcode) } }

下钻

现在已经可以下钻了,但是又出现了一个新问题[吐血]。到省份一级后,地图太小了,而且位置也没有在中间。这是由于我们的墨卡托投影 变换的中心点和缩放比例是写死的,我们需要让这些参数根据地理数据的不同而生成相对应的值。

在geojson中,coordinates数组中的坐标就是这块区域的边界线上的点,以浙江省为例,只要找出浙江省边界线上点位的最大横向坐标(maxX)和最小横向坐标(minX),它们的和 / 2 就能得到X轴上的中心点。同理Y轴中心点也是如此。

缩放倍数只需要根据画布的宽与浙江省横向长度比值和画布的高与浙江省纵向长度比值中取一个最小值再乘以一个系数(待定)。

开始动手,在Map3D类中添加getCenter方法:

class Map3D{ // 省略代码..... // 获取中心点和缩放倍数 getCenter() { let maxX = undefined; let maxY = undefined; let minX = undefined; let minY = undefined; this.geoJson.features.forEach((elem) => { const coordinates = elem.geometry.coordinates; const type = elem.geometry.type; function compare(point) { maxX === undefined ? (maxX = point[0]) : (maxX = point[0] > maxX ? point[0] : maxX); maxY === undefined ? (maxY = point[1]) : (maxY = point[1] > maxY ? point[1] : maxY); minX === undefined ? (minX = point[0]) : (minX = point[0] > minX ? minX : point[0]); minY === undefined ? (minY = point[1]) : (minY = point[1] > minY ? minY : point[1]); } if (type === "MultiPolygon") { coordinates.forEach((multiPolygon) => { multiPolygon.forEach((polygon) => { polygon.forEach((point) => { compare(point); }); }); }); } else { coordinates.forEach((polygon) => { polygon.forEach((point) => { compare(point); }); }); } }); const xScale = window.innerWidth / (maxX - minX); const yScale = window.innerHeight / (maxY - minY); return { center: [(maxX + minX) / 2, (maxY + minY) / 2], scale: Math.min(xScale, yScale), }; } async loadData(adcode) { // 获取geojson数据 this.geojson = await this.getGeoJson(adcode) const { center, scale } = this.getCenter() // 创建墨卡托投影 this.projection = d3 .geoMercator() .center(center) .translate([0, 0]) .scale(scale * 7) // 根据实测,系数7差不多刚好 } // 省略代码..... }

看效果:

下钻地图2

完整代码:

import * as THREE from 'three' import * as d3 from 'd3-geo' import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' import { CSS2DObject, CSS2DRenderer } from 'three/examples/jsm/renderers/CSS2DRenderer' const MATERIAL_COLOR1 = "#2887ee"; const MATERIAL_COLOR2 = "#2887d9"; class Map3D { constructor() { // 监听回调事件存储区 this.callbackStack = new Map(); 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; // 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); } } 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()) } // 获取中心点和缩放倍数 getCenter () { let maxX, maxY, minX, minY; this.geojson.features.forEach((elem) => { const coordinates = elem.geometry.coordinates; const type = elem.geometry.type; function compare (point) { maxX === undefined ? (maxX = point[0]) : (maxX = point[0] > maxX ? point[0] : maxX); maxY === undefined ? (maxY = point[1]) : (maxY = point[1] > maxY ? point[1] : maxY); minX === undefined ? (minX = point[0]) : (minX = point[0] > minX ? minX : point[0]); minY === undefined ? (minY = point[1]) : (minY = point[1] > minY ? minY : point[1]); } if (type === "MultiPolygon") { coordinates.forEach((multiPolygon) => { multiPolygon.forEach((polygon) => { polygon.forEach((point) => { compare(point); }); }); }); } else { coordinates.forEach((polygon) => { polygon.forEach((point) => { compare(point); }); }); } }); const xScale = window.innerWidth / (maxX - minX); const yScale = window.innerHeight / (maxY - minY); return { center: [(maxX + minX) / 2, (maxY + minY) / 2], scale: Math.min(xScale, yScale), }; } async loadData(adcode) { // 获取geojson数据 this.geojson = await this.getGeoJson(adcode) const { center, scale } = this.getCenter() // 创建墨卡托投影 this.projection = d3 .geoMercator() .center(center) .translate([0, 0]) .scale(scale * 7) // 根据实测,系数7差不多刚好 // 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); // 把地区属性存到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) } dispose (o) { // 可以遍历该父场景中的所有子物体来执行回调函数 o.traverse(child => { if (child.geometry) { child.geometry.dispose() } if (child.material) { if (Array.isArray(child.material)) { child.material.forEach(material => { material.dispose() }) } else { child.material.dispose() } } }) o.parent.remove(o) } // 添加监听事件 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) { const fnName = `${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() map.on('click', listener) function listener(e, data) { // 区域编码 const adcode = data.object.parent.properties.adcode if(adcode) { map.dispose(map.map) map.loadData(adcode) } }



Tags:three.js   点击:()  评论:()
声明:本站部分内容及图片来自互联网,转载是出于传递更多信息之目的,内容观点仅代表作者本人,不构成投资建议。投资者据此操作,风险自担。如有任何标注错误或版权侵犯请与我们联系,我们将及时更正、删除。
▌相关推荐
three.js实现3D地图
地图下钻是前端开发中常见的开发需求。通常会使用高德、百度等第三方地图实现,不过这些都不是3d的。echarts倒是提供了map3D,以及常用的点位、飞线等功能,就是有一些小bug[泪奔...【详细内容】
2022-11-14  Search: three.js  点击:(2622)  评论:(0)  加入收藏
基于 Three.js 的 vue3 三维可视化库
TroisJS 是一个基于 Three.js 的 vue3 三维可视化库,TroisJS对桌面和移动端都有良好的支持。使用 TroisJS 可以在网站上添加一个 3D 渲染器,并在你的 vue文件 的部分中使用...【详细内容】
2022-10-08  Search: three.js  点击:(653)  评论:(0)  加入收藏
▌简易百科推荐
JavaScript的异步编程常见模式
在JavaScript中,异步编程是一种处理长时间运行操作(如网络请求或I/O操作)的常见方式。它允许程序在等待这些操作完成时继续执行其他任务,从而提高应用程序的响应性和性能。JavaS...【详细内容】
2024-04-12  靳国梁    Tags:JavaScript   点击:(2)  评论:(0)  加入收藏
17 个你需要知道的 JavaScript 优化技巧
你可能一直在使用JavaScript搞开发,但很多时候你可能对它提供的最新功能并不感冒,尽管这些功能在无需编写额外代码的情况下就可以解决你的问题。作为前端开发人员,我们必须了解...【详细内容】
2024-04-03  前端新世界  微信公众号  Tags:JavaScript   点击:(6)  评论:(0)  加入收藏
你不可不知的 15 个 JavaScript 小贴士
在掌握如何编写JavaScript代码之后,那么就进阶到实践&mdash;&mdash;如何真正地解决问题。我们需要更改JS代码使其更简单、更易于阅读,因为这样的程序更易于团队成员之间紧密协...【详细内容】
2024-03-21  前端新世界  微信公众号  Tags:JavaScript   点击:(27)  评论:(0)  加入收藏
又出新JS运行时了!JS运行时大盘点
Node.js是基于Google V8引擎的JavaScript运行时,以非阻塞I/O和事件驱动架构为特色,实现全栈开发。它跨平台且拥有丰富的生态系统,但也面临安全性、TypeScript支持和性能等挑战...【详细内容】
2024-03-21  前端充电宝  微信公众号  Tags:JS   点击:(26)  评论:(0)  加入收藏
构建一个通用灵活的JavaScript插件系统?看完你也会!
在软件开发中,插件系统为应用程序提供了巨大的灵活性和可扩展性。它们允许开发者在不修改核心代码的情况下扩展和定制应用程序的功能。本文将详细介绍如何构建一个灵活的Java...【详细内容】
2024-03-20  前端历险记  微信公众号  Tags:JavaScript   点击:(20)  评论:(0)  加入收藏
对JavaScript代码压缩有什么好处?
对JavaScript代码进行压缩主要带来以下好处: 减小文件大小:通过移除代码中的空白符、换行符、注释,以及缩短变量名等方式,可以显著减小JavaScript文件的大小。这有助于减少网页...【详细内容】
2024-03-13  WangLiwen    Tags:JavaScript   点击:(2)  评论:(0)  加入收藏
跨端轻量JavaScript引擎的实现与探索
一、JavaScript 1.JavaScript语言JavaScript是ECMAScript的实现,由ECMA 39(欧洲计算机制造商协会39号技术委员会)负责制定ECMAScript标准。ECMAScript发展史: 2.JavaScript...【详细内容】
2024-03-12  京东云开发者    Tags:JavaScript   点击:(2)  评论:(0)  加入收藏
面向AI工程的五大JavaScript工具
令许多人惊讶的是,一向在Web开发领域中大放异彩的JavaScript在开发使用大语言模型(LLM)的应用程序方面同样大有价值。我们在本文中将介绍面向AI工程的五大工具,并为希望将LLM...【详细内容】
2024-02-06    51CTO  Tags:JavaScript   点击:(53)  评论:(0)  加入收藏
JS小知识,使用这6个小技巧,避免过多的使用 if 语句
最近在重构我的代码时,我注意到早期的代码使用了太多的 if 语句,达到了我以前从未见过的程度。这就是为什么我认为分享这些可以帮助我们避免使用过多 if 语句的简单技巧很重要...【详细内容】
2024-01-30  前端达人  今日头条  Tags:JS   点击:(58)  评论:(0)  加入收藏
18个JavaScript技巧:编写简洁高效的代码
本文翻译自 18 JavaScript Tips : You Should Know for Clean and Efficient Code,作者:Shefali, 略有删改。在这篇文章中,我将分享18个JavaScript技巧,以及一些你应该知道的示例...【详细内容】
2024-01-30  南城大前端  微信公众号  Tags:JavaScript   点击:(71)  评论:(0)  加入收藏
站内最新
站内热门
站内头条