北京到上海,Three.js 旅行轨迹的可视化

最近从北京搬到了上海,开始了一段新的生活,算是人生中一个比较大的事件,于是特地用 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: github.com/QuarkGluonP…

也在这里贴一份:

<!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 和墨卡托转换,就算是入门地理相关的可视化了。

你是否也想做一些和地理相关的可视化或者交互呢?不妨来尝试下吧。

Guess you like

Origin juejin.im/post/7041580850261000222