地圖下鉆是前端開發中常見的開發需求。通常會使用高德、百度等第三方地圖實現,不過這些都不是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) } }