手摸手 Threejs 地圖3D可視化
大廠技術(shù) 高級前端 技術(shù)進階
點擊上方 前端下午茶,關(guān)注公眾號
回復(fù)1,加入前端交流群
可以直接去github github.com/1023byte/3Dmap
前言
threejs小練習,從頭實現(xiàn)如何加載地理數(shù)據(jù),并將其映射到三維場景中的對象上。
獲取數(shù)據(jù)
在開始繪制圖形前,需要一份包含地理信息數(shù)據(jù),我們可以從阿里云提供的小工具獲取datav.aliyun.com/portal/school/atlas/area_selector
在范圍選擇器中,可以選擇整個或者各個省份的地理信息數(shù)據(jù)。
生成圖形
獲取數(shù)據(jù)后,先分析一下JSON的結(jié)構(gòu)
properties 中包含了名字、中心、質(zhì)心等信息, geometry.coordinates 則是地理的坐標點,我們需要做的是將這些點連成線。
THREE.Shpae
const createMap = (data) => {
const map = new THREE.Object3D();
data.features.forEach((feature) => {
const unit = new THREE.Object3D();
const { coordinates, type } = feature.geometry;
coordinates.forEach((coordinate) => {
if (type === "MultiPolygon") coordinate.forEach((item) => fn(item));
if (type === "Polygon") fn(coordinate);
function fn(coordinate) {
const mesh = createMesh(coordinate);
unit.add(mesh);
}
});
map.add(unit);
});
return map;
};
這里需要注意在geometry中的type分為MultiPolygon和Polygon,需要分別處理,不然會造成個別區(qū)域缺失,二者區(qū)別是MultiPolygon的坐標多一層嵌套數(shù)據(jù),所以這里多做一次遍歷。
const createMesh = (data, color, depth) => {
const shape = new THREE.Shape();
data.forEach((item, idx) => {
cosnt [x,y] =item
if (idx === 0) shape.moveTo(x, -y);
else shape.lineTo(x, -y);
});
const shapeGeometry = new THREE.ShapeGeometry(shape);
const shapematerial = new THREE.MeshStandardMaterial({
color: 0xfff,
side: THREE.DoubleSide
});
const mesh = new THREE.Mesh(shapeGeometry, shapematerial);
return mesh;
};
通過THREE.Shape繪制一個二維的形狀平面后,但是打開網(wǎng)頁后會發(fā)現(xiàn)頁面中并沒有出現(xiàn)圖形,這是因為是json中的坐標非常大,在縮小后才能勉強看到,所以我們需要對坐標進行相應(yīng)的處理。
坐標矯正1
這里先介紹第一種矯正的方法
import * as d3 from "d3";
...
const offsetXY = d3.geoMercator();
在createMap中新增獲取第一個子數(shù)據(jù)的centroid以及偏移代碼,這里的centroid也就是杭州的質(zhì)心。
d3.geoMercator()是一個地理投影函數(shù),用于將地球表面的經(jīng)緯度坐標映射到二維平面上。
在代碼中,.center(center)是用于指定投影的中心點,這個中心點決定了投影的中心位置,地圖上的所有要素都將以該點為中心進行投影轉(zhuǎn)換。
.translate([0, 0])是指定投影的平移量。這里的 [0, 0] 表示在平面坐標系中的 x 和 y 方向上都沒有平移,也就是將地圖的投影結(jié)果放置在平面坐標系的原點位置。
這份數(shù)據(jù)是浙江省的地理信息,所以根據(jù)以上代碼,圖形的中心點已經(jīng)以到杭州的質(zhì)心上,并且坐標為[0,0]
THREE.ExtrudeGeometry
接著再通過 THREE.ExtrudeGeometry將shape從二維擠出成三維。為了方便查看剛才代碼使用了new THREE.ShapeGeometry(shape);我們替換成ExtrudeGeometry
const shapeGeometry = new THREE.ExtrudeGeometry(shape, {
depth: 1,
bevelEnabled: false,
});
depth:圖形擠出的深度,默認值為1
bevelEnabled:對擠出的形狀應(yīng)用是否斜角,默認值為true
區(qū)域劃分
現(xiàn)在的圖形全都是一個顏色,看不出區(qū)域
const color = new THREE.Color(`hsl(
${233},
${Math.random() * 30 + 55}%,
${Math.random() * 30 + 55}%)`).getHex();
const depth = Math.random() * 0.3 + 0.3;
...
...
const mesh = createMesh(coordinate, color, depth);
我們寫一個隨機顏色和隨機的深度,在data.features中寫入,確保每一個子區(qū)域一個顏色,如果在createMesh中實現(xiàn)會產(chǎn)生以下區(qū)別,舟山、寧波、溫州的島嶼會產(chǎn)生不同的顏色。

繪制描邊
繪制描邊的方法和之前的shape有所不同
創(chuàng)建一個THREE.BufferGeometry對象,并通過一組給定的點來設(shè)置其幾何形狀,再通過LineBasicMaterial材質(zhì)渲染基本的線條
const createLine = (data, depth) => {
const points = [];
data.forEach((item) => {
const [x, y] = offsetXY(item);
points.push(new THREE.Vector3(x, -y, 0));
});
const lineGeometry = new THREE.BufferGeometry().setFromPoints(points);
const uplineMaterial = new THREE.LineBasicMaterial({ color: 0xffffff });
const downlineMaterial = new THREE.LineBasicMaterial({ color: 0xffffff });
const upLine = new THREE.Line(lineGeometry, uplineMaterial);
const downLine = new THREE.Line(lineGeometry, downlineMaterial);
downLine.position.z = -0.0001;
upLine.position.z = depth + 0.0001;
return [upLine, downLine];
};
繪制標簽信息
接下來我們通過css2d的方式向圖形中添加城市名稱
使用css2d需要相應(yīng)的引用以及設(shè)置
import {
CSS2DRenderer,
CSS2DObject,
} from "three/examples/jsm/renderers/CSS2DRenderer.js";
...
...
const labelRenderer = new CSS2DRenderer();
labelRenderer.domElement.style.position = "absolute";
labelRenderer.domElement.style.top = "0px";
labelRenderer.domElement.style.pointerEvents = "none";
labelRenderer.setSize(window.innerWidth, window.innerHeight);
document.getElementById("map").appendChild(labelRenderer.domElement);
除了能使用css的樣式,通過new CSS2DObject() 這一步后可以操作threejs元素一樣操作div,其實原理是仍是使用transform屬性進行3d變換操作。
const createLabel = (name, point, depth) => {
const div = document.createElement("div");
div.style.color = "#fff";
div.style.fontSize = "12px";
div.style.textShadow = "1px 1px 2px #047cd6";
div.textContent = name;
const label = new CSS2DObject(div);
label.scale.set(0.01, 0.01, 0.01);
const [x, y] = offsetXY(point);
label.position.set(x, -y, depth);
return label;
};
繪制圖標
繪制圖標也可以使用css2d的方式,但是除了css2d,我們還有多種方式:css3d,svg,Sprite。這里我們使用Sprite。
const createIcon = (point, depth) => {
const url = new URL("../assets/icon.png", import.meta.url).href;
const map = new THREE.TextureLoader().load(url);
const material = new THREE.SpriteMaterial({
map: map,
transparent: true,
});
const sprite = new THREE.Sprite(material);
const [x, y] = offsetXY(point);
sprite.scale.set(0.3, 0.3, 0.3);
sprite.position.set(x, -y, depth + 0.2);
sprite.renderOrder = 1;
return sprite;
};
SPrite是一個總是面朝著攝像機的平面,這一點似乎和css2d的效果一樣,不過二者還略有不同。
圖中我們可以看到,SPrite會隨著相機的距離而改變大小。
坐標矯正2
之前的坐標矯正我們可以將中心移到某個點上,那如果想把中心移到整個圖形的中心該如何實現(xiàn)?通過已有的數(shù)據(jù)我們只能將中心移到某個區(qū)域的中心或者質(zhì)心,并不知道圖形的中心在哪里,當然我們可以手動調(diào)試,不過換一份地理數(shù)據(jù)又的重新調(diào)試。
對此,我們可以使用threejs中的包圍盒
const box = new THREE.Box3().setFromObject(map);
const boxHelper = new THREE.Box3Helper(box, 0xffff00);
scene.add(boxHelper);
創(chuàng)建一個Box3對象,并通過調(diào)用setFromObject(map)方法,將map的包圍盒信息存儲在box變量中。,box變量現(xiàn)在包含了map對象的邊界范圍。為了便于觀察再加一個輔助器。
接著通過const center = box.getCenter(new THREE.Vector3());獲取包圍盒的中心點坐標。
map.position.x = map.position.x - center.x ;
map.position.y = map.position.y - center.y ;
對中心點進行計算后便是一個相對中心的位置,因為有的地形涉及島嶼海域或者形狀不太規(guī)整,得出的中心點可能不是理想效果。
鼠標交互
最后我們來實現(xiàn)圖形與鼠標的交互, THREE.Raycaster可以從指定的原點(起點)沿著指定的方向(射線)發(fā)射一條射線。這條射線可以與場景中的對象進行相交檢測,以確定射線是否與對象相交,從而獲取與射線相交的對象或交點信息,常用于用戶交互、拾取物體、碰撞檢測等場景。
const mouse = new THREE.Vector2();
//將鼠標位置歸一化為設(shè)備坐標。x 和 y 方向的取值范圍是 (-1 to +1)
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
const raycaster = new THREE.Raycaster();
// 通過攝像機和鼠標位置更新射線
raycaster.setFromCamera(mouse, camera);
// 計算物體和射線的焦點
const intersects = raycaster.intersectObjects(map.children)
通過以上代碼我們可以在intersects里獲取到鼠標都觸發(fā)了哪些對象。
可以看到我們觸發(fā)很多對象,但是大部分type都是Line,也就是之前繪制的描邊,這些線段會干擾到正常的點擊,所以我們要將它過濾掉。
const intersects = raycaster
.intersectObjects(map.children)
.filter((item) => item.object.type !== "Line");
這里簡單處理一下,點擊Mesh使其透明,點擊Sprite打印對象。
if (intersects.length > 0) {
if (intersects[0].object.type === "Mesh") {
if (intersect) isAplha(intersect, 1);
intersect = intersects[0].object.parent;
isAplha(intersect, 0.4);
}
if (intersects[0].object.type === "Sprite") {
console.log(intersects[0].object);
}
} else {
if (intersect) isAplha(intersect, 1);
}
function isAplha(intersect, opacity) {
intersect.children.forEach((item) => {
if (item.type === "Mesh") {
item.material.opacity = opacity;
}
});
}
有一點需要注意在獲取Mesh對象時,我們使用的是intersects[0].object.parent;,拿到了觸發(fā)對象的的父級對象。以舟山為例,我們點擊了其中一個島嶼,但是想要整個區(qū)域都發(fā)生變化,所以需要獲取父級對象再遍歷處理。


其他設(shè)置
大致的功能都實現(xiàn)完成了,我們還可以在視覺上增加一些風格。
const ambientLight = new THREE.AmbientLight(0xd4e7fd, 4);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xe8eaeb, 0.2);
directionalLight.position.set(0, 10, 5);
const directionalLight2 = directionalLight.clone();
directionalLight2.position.set(0, 10, -5);
const directionalLight3 = directionalLight.clone();
directionalLight3.position.set(5, 10, 0);
const directionalLight4 = directionalLight.clone();
directionalLight4.position.set(-5, 10, 0);
scene.add(directionalLight);
scene.add(directionalLight2);
scene.add(directionalLight3);
scene.add(directionalLight4);
...
...
THREE.MeshStandardMaterial({
color: color,
emissive: 0x000000,
roughness: 0.45,
metalness: 0.8,
transparent: true,
side: THREE.DoubleSide,
});
配合燈光以及MeshStandardMaterial材質(zhì)實現(xiàn)反光效果。
結(jié)尾
代碼寫的有些匆忙,功能也還有沒寫的,本來是打算加上飛線、熱力、柱狀圖這類的功能。但是最近剛?cè)胧至恕冬F(xiàn)代JavaScript庫開發(fā):原理、技術(shù)與實戰(zhàn)》,想著到時候讀完看看能不能試著寫一個相關(guān)的庫,給自己畫個大餅先
假如有后續(xù)的話可以前往github.com/1023byte/3Dmap
關(guān)于本文
作者:Defineee
https://juejin.cn/post/7247027696822304827
最后
如果你覺得這篇內(nèi)容對你挺有啟發(fā),我想邀請你幫我個小忙:
點個「喜歡」或「在看」,讓更多的人也能看到這篇內(nèi)容
我組建了個氛圍非常好的前端群,里面有很多前端小伙伴,歡迎加我微信「sherlocked_93」拉你加群,一起交流和學習
關(guān)注公眾號「前端下午茶」,持續(xù)為你推送精選好文,也可以加我為好友,隨時聊騷。
