本文转载自微信公众号「神光的北京编程秘籍」,作者神说要有光zxg。到上转载本文请联系神光的旅行编程秘籍公众号。
最近从北京搬到了上海,轨迹开始了一段新的视化生活,算是北京人生中一个比较大的事件,于是到上特地用 Three.js 做了下可视化。
在这个地理信息相关的旅行可视化的案例中,我们能学到地图怎么画、轨迹经纬度如何转成坐标值,视化这些是北京地理可视化的通用技术。
那我们就开始吧。到上
Three.js 画立方体、旅行画圆柱、轨迹画不规则图形我们都画过,视化但是如何画一个地图呢?
其实地图也是由线、由多边形构成的,有了数据我们就能画出来,缺少的只是数据。云服务器提供商
地图信息的描述是一个通用需求,所以有相应的国际标准,就是 GeoJson,它是通过点、线、多边形来描述地理信息的。
通过指定点、线、多边形的类型、然后指定几个坐标位置,就可以描述出相应的形状。
geojson 的数据可以通过 geojson.io 这个网站做下预览。
比如中国地图的 geojson:
有了这个 json,只要用 Three.js 画出来就行,通过线和多边形两种方式。
但是还有一个问题,geojson 中记录的是经纬度信息,应该如何转成二维坐标来画呢?
这就涉及到了墨卡托转换,它就是做经纬度转二维坐标的云南idc服务商事情。
这个转换也不用我们自己实现,可以用 d3 内置的墨卡托坐标转换函数来做。
这样,我们就用 Three.js 根据 geojson 来画出地图。
我们还要画一条北京到上海的曲线,这个用贝塞尔曲线画就行,知道两个端点的坐标,控制点放在中间的位置。
那怎么知道两个端点,也就是上海和北京的坐标呢?
这个可以用“百度坐标拾取系统”这个工具,点击地图的某个位置,就可以直接拿到那个位置的经纬度。然后我们做一次墨卡托转换,就拿到坐标了。
地图画出来了,旅行的曲线也画出来了,接下来调整下相机位置,从北京慢慢移动到上海就可以了。
思路理清了,我们来写下代码。
我们要引入 d3,然后使用 d3 的墨卡托转换功能,云服务器
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]);先不着急画旅行的曲线,先来画地图吧。
先加载 geojson:
const loader = new THREE.FileLoader(); loader.load(./data/china.json, (data) => { const jsondata = JSON.parse(data); generateGeometry(jsondata); })然后根据 json 的信息画地图。
遍历 geojson 的数据,把每个经纬度通过墨卡托转换变成坐标,然后分别用线和多边形画出来。
画多边形的时候遇到北京和上海用黄色,其他城市用蓝色。
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); }然后分别实现画轮廓线和画多边形:
轮廓线(Line)就是指定一系列顶点来构成几何体(Geometry),然后指定材质(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); }现在的效果是这样的:
多边形是 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); }第一个点用 moveTo,后面的点用 lineTo,这样连成一个多边形,然后指定厚度为 0,指定侧面不需要多出一块斜面(bevel)。
这样,我们就给每个省都填充上了颜色,北京和上海是黄色,其余省是蓝色。
接下来,在北京和上海之间画一条贝塞尔曲线:
const line = drawLine(beijingPosition, shanghaiPosition); scene.add(line);贝塞尔曲线用 QuadraticBezierCurve3 来画,控制点指定中间位置的点。
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; }这样,地图和旅行轨迹就都画完了:
当然,还有渲染器、相机、灯光的初始化代码:
渲染器:
const renderer = new THREE.WebGLRenderer(); renderer.setClearColor(0x000000); renderer.setSize(window.innerWidth, window.innerHeight); document.body.appendChild(renderer.domElement);渲染器设置背景颜色为黑色,画布大小为窗口大小。
灯光:
let ambientLight = new THREE.AmbientLight(0xffffff); scene.add(ambientLight);灯光用环境光,也就是每个方向的明暗都一样。
相机:
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); camera.position.set(0, 0, 10); camera.lookAt(scene.position);相机用透视相机,特点是近大远小,需要指定看的角度,宽高比,和远近的范围这样四个参数。
位置设置在 0 0 10 的位置,在这个位置去观察 0 0 0,就是北京上方的俯视图(我们做墨卡托转换的时候指定了北京为中心)。
修改了相机位置之后,看到的地图大了许多:
接下来就是一帧帧的渲染,在每帧渲染的时候移动下相机位置,这样就是从北京到上海的一个移动的效果:
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>地图形状的表示是基于 geojson 的规范,它是由点、线、多边形等信息构成的。
用 Three.js 或者其他绘制方式来画地图只需要加载 geojson 的数据,然后通过线和多边型把每一部分画出来。
画之前还要把经纬度转成坐标,这需要用到墨卡托转换。
我们用 Three.js 画线是通过指定一系列顶点构成 Geometry,而画多边形是通过绘制一个形状,然后用 ExtrudeGeometry(挤压几何体) 拉伸成三维。墨卡托转换直接使用了 d3 的内置函数。旅行的效果是通过一帧帧的移动相机位置来实现的。
熟悉了 geojson 和墨卡托转换,就算是入门地理相关的可视化了。
你是否也想做一些和地理相关的可视化或者交互呢?不妨来尝试下吧。