前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >北京到上海,Three.js 旅行轨迹的可视化

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

作者头像
神说要有光zxg
发布2021-12-17 15:03:49
1.6K0
发布2021-12-17 15:03:49
举报
文章被收录于专栏:神光的编程秘籍

最近从北京搬到了上海,开始了一段新的生活,算是人生中一个比较大的事件,于是特地用 Three.js 做了下可视化。

在这个地理信息相关的可视化的案例中,我们能学到地图怎么画、经纬度如何转成坐标值,这些是地理可视化的通用技术。

那我们就开始吧。

思路分析

Three.js 画立方体、画圆柱、画不规则图形我们都画过,但是如何画一个地图呢?

其实地图也是由线、由多边形构成的,有了数据我们就能画出来,缺少的只是数据。

地图信息的描述是一个通用需求,所以有相应的国际标准,就是 GeoJson,它是通过点、线、多边形来描述地理信息的。

通过指定点、线、多边形的类型、然后指定几个坐标位置,就可以描述出相应的形状。

geojson 的数据可以通过 geojson.io 这个网站做下预览。

比如中国地图的 geojson:

有了这个 json,只要用 Three.js 画出来就行,通过线和多边形两种方式。

但是还有一个问题,geojson 中记录的是经纬度信息,应该如何转成二维坐标来画呢?

这就涉及到了墨卡托转换,它就是做经纬度转二维坐标的事情。

这个转换也不用我们自己实现,可以用 d3 内置的墨卡托坐标转换函数来做。

这样,我们就用 Three.js 根据 geojson 来画出地图。

我们还要画一条北京到上海的曲线,这个用贝塞尔曲线画就行,知道两个端点的坐标,控制点放在中间的位置。

那怎么知道两个端点,也就是上海和北京的坐标呢?

这个可以用“百度坐标拾取系统”这个工具,点击地图的某个位置,就可以直接拿到那个位置的经纬度。然后我们做一次墨卡托转换,就拿到坐标了。

地图画出来了,旅行的曲线也画出来了,接下来调整下相机位置,从北京慢慢移动到上海就可以了。

思路理清了,我们来写下代码。

代码实现

我们要引入 d3,然后使用 d3 的墨卡托转换功能,

代码语言:javascript
复制
const projection = d3.geoMercator()
    .center([116.412318,39.909843])
    .translate([0, 0]);

中间点的坐标就是北京的经纬度,就是我们通过“百度坐标拾取工具”那里拿到的。

北京和上海的坐标位置也可以把经纬度做墨卡托转换得到:

代码语言:javascript
复制
let beijingPosition= projection([116.412318,39.909843]);
let shanghaiPosition = projection([121.495721,31.236797]);

先不着急画旅行的曲线,先来画地图吧。

先加载 geojson:

代码语言:javascript
复制
const loader = new THREE.FileLoader();
loader.load('./data/china.json', (data) => {
    const jsondata = JSON.parse(data);
    generateGeometry(jsondata);
})

然后根据 json 的信息画地图。

遍历 geojson 的数据,把每个经纬度通过墨卡托转换变成坐标,然后分别用线和多边形画出来。

画多边形的时候遇到北京和上海用黄色,其他城市用蓝色。

代码语言:javascript
复制
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)颜色为黄色:

代码语言:javascript
复制
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),然后通过拉伸变成三维的。

代码语言:javascript
复制
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)。

这样,我们就给每个省都填充上了颜色,北京和上海是黄色,其余省是蓝色。

接下来,在北京和上海之间画一条贝塞尔曲线:

代码语言:javascript
复制
const line = drawLine(beijingPosition, shanghaiPosition);
scene.add(line);

贝塞尔曲线用 QuadraticBezierCurve3 来画,控制点指定中间位置的点。

代码语言:javascript
复制
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;
}

这样,地图和旅行轨迹就都画完了:

当然,还有渲染器、相机、灯光的初始化代码:

渲染器:

代码语言:javascript
复制
const renderer = new THREE.WebGLRenderer();
renderer.setClearColor(0x000000);
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

渲染器设置背景颜色为黑色,画布大小为窗口大小。

灯光:

代码语言:javascript
复制
let ambientLight = new THREE.AmbientLight(0xffffff);
scene.add(ambientLight);

灯光用环境光,也就是每个方向的明暗都一样。

相机:

代码语言:javascript
复制
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,就是北京上方的俯视图(我们做墨卡托转换的时候指定了北京为中心)。

修改了相机位置之后,看到的地图大了许多:

接下来就是一帧帧的渲染,在每帧渲染的时候移动下相机位置,这样就是从北京到上海的一个移动的效果:

代码语言:javascript
复制
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

也在这里贴一份:

代码语言:javascript
复制
<!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 和墨卡托转换,就算是入门地理相关的可视化了。

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

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2021-12-14,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 神光的编程秘籍 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 思路分析
  • 代码实现
  • 总结
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档