本文转载自微信公众号「神光的编程秘籍」,作者神说要有光zxg。转载本文请联系神光的编程秘籍公众号。
最近从北京搬到了上海,开始了一段新的生活,算是人生中一个比较大的事件,于是特地用 Three.js 做了下可视化。
在这个地理信息相关的可视化的案例中,我们能学到地图怎么画、经纬度如何转成坐标值,这些是地理可视化的通用技术。
那我们就开始吧。
思路分析
Three.js 画立方体、画圆柱、画不规则图形我们都画过,但是如何画一个地图呢?
其实地图也是由线、由多边形构成的,有了数据我们就能画出来,缺少的只是数据。
地图信息的描述是一个通用需求,所以有相应的国际标准,就是 GeoJson,它是通过点、线、多边形来描述地理信息的。
通过指定点、线、多边形的类型、然后指定几个坐标位置,就可以描述出相应的形状。
geojson 的数据可以通过 geojson.io 这个网站做下预览。
比如中国地图的 geojson:
有了这个 json,只要用 Three.js 画出来就行,通过线和多边形两种方式。
但是还有一个问题,geojson 中记录的是经纬度信息,应该如何转成二维坐标来画呢?
这就涉及到了墨卡托转换,它就是做经纬度转二维坐标的事情。
这个转换也不用我们自己实现,可以用 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 和墨卡托转换,就算是入门地理相关的可视化了。
你是否也想做一些和地理相关的可视化或者交互呢?不妨来尝试下吧。