北京到上海,Three.js 旅行軌跡的可視化
本文轉(zhuǎn)載自微信公眾號(hào)「神光的編程秘籍」,作者神說要有光zxg。轉(zhuǎn)載本文請(qǐng)聯(lián)系神光的編程秘籍公眾號(hào)。
最近從北京搬到了上海,開始了一段新的生活,算是人生中一個(gè)比較大的事件,于是特地用 Three.js 做了下可視化。
在這個(gè)地理信息相關(guān)的可視化的案例中,我們能學(xué)到地圖怎么畫、經(jīng)緯度如何轉(zhuǎn)成坐標(biāo)值,這些是地理可視化的通用技術(shù)。
那我們就開始吧。
思路分析
Three.js 畫立方體、畫圓柱、畫不規(guī)則圖形我們都畫過,但是如何畫一個(gè)地圖呢?
其實(shí)地圖也是由線、由多邊形構(gòu)成的,有了數(shù)據(jù)我們就能畫出來,缺少的只是數(shù)據(jù)。
地圖信息的描述是一個(gè)通用需求,所以有相應(yīng)的國際標(biāo)準(zhǔn),就是 GeoJson,它是通過點(diǎn)、線、多邊形來描述地理信息的。
通過指定點(diǎn)、線、多邊形的類型、然后指定幾個(gè)坐標(biāo)位置,就可以描述出相應(yīng)的形狀。
geojson 的數(shù)據(jù)可以通過 geojson.io 這個(gè)網(wǎng)站做下預(yù)覽。
比如中國地圖的 geojson:
有了這個(gè) json,只要用 Three.js 畫出來就行,通過線和多邊形兩種方式。
但是還有一個(gè)問題,geojson 中記錄的是經(jīng)緯度信息,應(yīng)該如何轉(zhuǎn)成二維坐標(biāo)來畫呢?
這就涉及到了墨卡托轉(zhuǎn)換,它就是做經(jīng)緯度轉(zhuǎn)二維坐標(biāo)的事情。
這個(gè)轉(zhuǎn)換也不用我們自己實(shí)現(xiàn),可以用 d3 內(nèi)置的墨卡托坐標(biāo)轉(zhuǎn)換函數(shù)來做。
這樣,我們就用 Three.js 根據(jù) geojson 來畫出地圖。
我們還要畫一條北京到上海的曲線,這個(gè)用貝塞爾曲線畫就行,知道兩個(gè)端點(diǎn)的坐標(biāo),控制點(diǎn)放在中間的位置。
那怎么知道兩個(gè)端點(diǎn),也就是上海和北京的坐標(biāo)呢?
這個(gè)可以用“百度坐標(biāo)拾取系統(tǒng)”這個(gè)工具,點(diǎn)擊地圖的某個(gè)位置,就可以直接拿到那個(gè)位置的經(jīng)緯度。然后我們做一次墨卡托轉(zhuǎn)換,就拿到坐標(biāo)了。
地圖畫出來了,旅行的曲線也畫出來了,接下來調(diào)整下相機(jī)位置,從北京慢慢移動(dòng)到上海就可以了。
思路理清了,我們來寫下代碼。
代碼實(shí)現(xiàn)
我們要引入 d3,然后使用 d3 的墨卡托轉(zhuǎn)換功能,
- const projection = d3.geoMercator()
- .center([116.412318,39.909843])
- .translate([0, 0]);
中間點(diǎn)的坐標(biāo)就是北京的經(jīng)緯度,就是我們通過“百度坐標(biāo)拾取工具”那里拿到的。
北京和上海的坐標(biāo)位置也可以把經(jīng)緯度做墨卡托轉(zhuǎn)換得到:
- let beijingPosition= projection([116.412318,39.909843]);
- let shanghaiPosition = projection([121.495721,31.236797]);
先不著急畫旅行的曲線,先來畫地圖吧。
先加載 geojson:
- const loader = new THREE.FileLoader();
- loader.load('./data/china.json', (data) => {
- const jsondata = JSON.parse(data);
- generateGeometry(jsondata);
- })
然后根據(jù) json 的信息畫地圖。
遍歷 geojson 的數(shù)據(jù),把每個(gè)經(jīng)緯度通過墨卡托轉(zhuǎn)換變成坐標(biāo),然后分別用線和多邊形畫出來。
畫多邊形的時(shí)候遇到北京和上海用黃色,其他城市用藍(lán)色。
- function generateGeometry(jsondata) {
- const map = new THREE.Group();
- jsondata.features.forEach((elem) => {
- const province = new THREE.Group();
- // 經(jīng)緯度信息
- const coordinates = elem.geometry.coordinates;
- coordinates.forEach((multiPolygon) => {
- multiPolygon.forEach((polygon) => {
- // 畫輪廓線
- const line = drawBoundary(polygon);
- // 畫多邊形
- const provinceColor = ['北京市', '上海市'].includes(elem.properties.name) ? 'yellow' : 'blue';
- const mesh = drawExtrudeMesh(polygon, provinceColor);
- province.add(line);
- province.add(mesh);
- });
- });
- map.add(province);
- })
- scene.add(map);
- }
然后分別實(shí)現(xiàn)畫輪廓線和畫多邊形:
輪廓線(Line)就是指定一系列頂點(diǎn)來構(gòu)成幾何體(Geometry),然后指定材質(zhì)(Material)顏色為黃色:
- function drawBoundary(polygon) {
- const lineGeometry = new THREE.Geometry();
- for (let i = 0; i < polygon.length; i++) {
- const [x, y] = projection(polygon[i]);
- lineGeometry.vertices.push(new THREE.Vector3(x, -y, 0));
- }
- const lineMaterial = new THREE.LineBasicMaterial({
- color: 'yellow'
- });
- return new THREE.Line(lineGeometry, lineMaterial);
- }
現(xiàn)在的效果是這樣的:
多邊形是 ExtrudeGeometry,也就是可以先畫出形狀(shape),然后通過拉伸變成三維的。
- function drawExtrudeMesh(polygon, color) {
- const shape = new THREE.Shape();
- 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);
- }
- const geometry = new THREE.ExtrudeGeometry(shape, {
- depth: 0,
- bevelEnabled: false
- });
- const material = new THREE.MeshBasicMaterial({
- color,
- transparent: true,
- opacity: 0.2,
- })
- return new THREE.Mesh(geometry, material);
- }
第一個(gè)點(diǎn)用 moveTo,后面的點(diǎn)用 lineTo,這樣連成一個(gè)多邊形,然后指定厚度為 0,指定側(cè)面不需要多出一塊斜面(bevel)。
這樣,我們就給每個(gè)省都填充上了顏色,北京和上海是黃色,其余省是藍(lán)色。
接下來,在北京和上海之間畫一條貝塞爾曲線:
- const line = drawLine(beijingPosition, shanghaiPosition);
- scene.add(line);
貝塞爾曲線用 QuadraticBezierCurve3 來畫,控制點(diǎn)指定中間位置的點(diǎn)。
- function drawLine(pos1, pos2) {
- const [x0, y0, z0] = [...pos1, 0];
- const [x1, y1, z1] = [...pos2, 0];
- const geomentry = new THREE.Geometry();
- geomentry.vertices = new THREE.QuadraticBezierCurve3(
- new THREE.Vector3(-x0, -y0, z0),
- new THREE.Vector3(-(x0 + x1) / 2, -(y0 + y1) / 2, -10),
- new THREE.Vector3(-x1, -y1, z1),
- ).getPoints();
- const material = new THREE.LineBasicMaterial({color: 'white'});
- const line = new THREE.Line(geomentry, material);
- line.rotation.y = Math.PI;
- return line;
- }
這樣,地圖和旅行軌跡就都畫完了:
當(dāng)然,還有渲染器、相機(jī)、燈光的初始化代碼:
渲染器:
- const renderer = new THREE.WebGLRenderer();
- renderer.setClearColor(0x000000);
- renderer.setSize(window.innerWidth, window.innerHeight);
- document.body.appendChild(renderer.domElement);
渲染器設(shè)置背景顏色為黑色,畫布大小為窗口大小。
燈光:
- let ambientLight = new THREE.AmbientLight(0xffffff);
- scene.add(ambientLight);
燈光用環(huán)境光,也就是每個(gè)方向的明暗都一樣。
相機(jī):
- const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
- camera.position.set(0, 0, 10);
- camera.lookAt(scene.position);
相機(jī)用透視相機(jī),特點(diǎn)是近大遠(yuǎn)小,需要指定看的角度,寬高比,和遠(yuǎn)近的范圍這樣四個(gè)參數(shù)。
位置設(shè)置在 0 0 10 的位置,在這個(gè)位置去觀察 0 0 0,就是北京上方的俯視圖(我們做墨卡托轉(zhuǎn)換的時(shí)候指定了北京為中心)。
修改了相機(jī)位置之后,看到的地圖大了許多:
接下來就是一幀幀的渲染,在每幀渲染的時(shí)候移動(dòng)下相機(jī)位置,這樣就是從北京到上海的一個(gè)移動(dòng)的效果:
- function render() {
- if(camera.position.x < shanghaiPosition[0]) {
- camera.position.x += 0.1;
- }
- if(camera.position.y > -shanghaiPosition[1]) {
- camera.position.y -= 0.2;
- }
- renderer.render(scene, camera);
- requestAnimationFrame(render);
- }
大功告成!我們來看下最終的效果吧:
代碼上傳到了 github: https://github.com/QuarkGluonPlasma/threejs-exercize
也在這里貼一份:
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8" />
- <meta http-equiv="X-UA-Compatible" content="IE=edge" />
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
- <title>map-travel</title>
- <style>
- html body {
- height: 100%;
- width: 100%;
- margin: 0;
- padding: 0;
- overflow: hidden;
- }
- </style>
- </head>
- <body>
- <script src="./js/three.js"></script>
- <script src="./js/d3.js"></script>
- <script>
- const scene = new THREE.Scene();
- const renderer = new THREE.WebGLRenderer();
- renderer.setClearColor(0x000000);
- renderer.setSize(window.innerWidth, window.innerHeight);
- document.body.appendChild(renderer.domElement);
- const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
- camera.position.set(0, 0, 10);
- camera.lookAt(scene.position);
- let ambientLight = new THREE.AmbientLight(0xffffff);
- scene.add(ambientLight);
- function create() {
- const loader = new THREE.FileLoader();
- loader.load('./data/china.json', (data) => {
- const jsondata = JSON.parse(data);
- generateGeometry(jsondata);
- })
- }
- const projection = d3.geoMercator()
- .center([116.412318,39.909843])
- .translate([0, 0]);
- let beijingPosition= projection([116.412318,39.909843]);
- let shanghaiPosition = projection([121.495721,31.236797]);
- function drawBoundary(polygon) {
- const lineGeometry = new THREE.Geometry();
- for (let i = 0; i < polygon.length; i++) {
- const [x, y] = projection(polygon[i]);
- lineGeometry.vertices.push(new THREE.Vector3(x, -y, 0));
- }
- const lineMaterial = new THREE.LineBasicMaterial({
- color: 'yellow'
- });
- return new THREE.Line(lineGeometry, lineMaterial);
- }
- function drawExtrudeMesh(polygon, color) {
- const shape = new THREE.Shape();
- 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);
- }
- const geometry = new THREE.ExtrudeGeometry(shape, {
- depth: 0,
- bevelEnabled: false
- });
- const material = new THREE.MeshBasicMaterial({
- color,
- transparent: true,
- opacity: 0.2,
- })
- return new THREE.Mesh(geometry, material);
- }
- function generateGeometry(jsondata) {
- const map = new THREE.Group();
- jsondata.features.forEach((elem) => {
- const province = new THREE.Group();
- const coordinates = elem.geometry.coordinates;
- coordinates.forEach((multiPolygon) => {
- multiPolygon.forEach((polygon) => {
- const line = drawBoundary(polygon);
- const provinceColor = ['北京市', '上海市'].includes(elem.properties.name) ? 'yellow' : 'blue';
- const mesh = drawExtrudeMesh(polygon, provinceColor);
- province.add(line);
- province.add(mesh);
- });
- });
- map.add(province);
- })
- scene.add(map);
- const line = drawLine(beijingPosition, shanghaiPosition);
- scene.add(line);
- }
- function render() {
- if(camera.position.x < shanghaiPosition[0]) {
- camera.position.x += 0.1;
- }
- if(camera.position.y > -shanghaiPosition[1]) {
- camera.position.y -= 0.2;
- }
- renderer.render(scene, camera);
- requestAnimationFrame(render);
- }
- function drawLine(pos1, pos2) {
- const [x0, y0, z0] = [...pos1, 0];
- const [x1, y1, z1] = [...pos2, 0];
- const geomentry = new THREE.Geometry();
- geomentry.vertices = new THREE.QuadraticBezierCurve3(
- new THREE.Vector3(-x0, -y0, z0),
- new THREE.Vector3(-(x0 + x1) / 2, -(y0 + y1) / 2, -10),
- new THREE.Vector3(-x1, -y1, z1),
- ).getPoints();
- const material = new THREE.LineBasicMaterial({color: 'white'});
- const line = new THREE.Line(geomentry, material);
- line.rotation.y = Math.PI;
- return line;
- }
- create();
- render();
- </script>
- </body>
- </html>
總結(jié)
地圖形狀的表示是基于 geojson 的規(guī)范,它是由點(diǎn)、線、多邊形等信息構(gòu)成的。
用 Three.js 或者其他繪制方式來畫地圖只需要加載 geojson 的數(shù)據(jù),然后通過線和多邊型把每一部分畫出來。
畫之前還要把經(jīng)緯度轉(zhuǎn)成坐標(biāo),這需要用到墨卡托轉(zhuǎn)換。
我們用 Three.js 畫線是通過指定一系列頂點(diǎn)構(gòu)成 Geometry,而畫多邊形是通過繪制一個(gè)形狀,然后用 ExtrudeGeometry(擠壓幾何體) 拉伸成三維。墨卡托轉(zhuǎn)換直接使用了 d3 的內(nèi)置函數(shù)。旅行的效果是通過一幀幀的移動(dòng)相機(jī)位置來實(shí)現(xiàn)的。
熟悉了 geojson 和墨卡托轉(zhuǎn)換,就算是入門地理相關(guān)的可視化了。
你是否也想做一些和地理相關(guān)的可視化或者交互呢?不妨來嘗試下吧。