ThreeJS实现GeoJSON地图

发布于:2024-05-07 ⋅ 阅读:(24) ⋅ 点赞:(0)

介绍

本人两年前端,第一次写文章,有许多不足的地方,希望大佬们能多多包涵。在这里记录一下最近在项目中实现的3D地图,加强一下记忆,也能够给大家在用ThreeJS生成区域地图方面提供一种自己的解决思路,如果有什么缺点,也希望大佬们指点一二。我也会把项目上传到

效果展示

QQ202457-93825.gif

(自贡市地图例子)

获取地图数据

主要通过阿里的来获得,但是数据更新于2021-5,而且仅供学习交流使用,要商用就自行考虑了。

当然也可以去Bigemap、水经微图等专业软件去下载边界kml文件,并在去转换成json格式,主要为了后续我们生成地图。

1715044671665.jpg

注意

如果需要自定义区域,需要自己手动去绘制,DataV、GeoJson.io以及其他专业软件都提供了绘制图形的工具,这方面就不展开细说了。

项目准备

这里为了快速实现效果,我直接在HTML内用CDN引入所需要的依赖,单个HTML就能完成所需要的效果,当然,为了工程化,实现逻辑大差不差,这个就需要自己去合理划分模块了。下面的代码块,我也会一一解释。

  • 这里我用到了importmap去映射依赖关系,在后面script type=module标签中import导入importmap声明的模块。
  • three:我们的主角。
  • three/examples/jsm/:这里是为了引入OrbitControls,在工程化中就不需要设置映射。
  • d3:主要用到的是其中墨卡托投影去转换GeoJson中的坐标,如果不想引入此依赖,也可以自己写公式转换。
  • gsap:一个非常强大的动画库,说实话,作为一个前端,要是掌握了这个库,不需要使用像AE、AN等专业软件生产动画再转成Web端能够使用的格式,这个库就能在JS中设计出酷炫的动画。
<script type="importmap">
    {
      "imports": {
        "three": "https://cdn.jsdelivr.net/npm/three@0.163.0/build/three.module.js",
        "three/examples/jsm/": "https://cdn.jsdelivr.net/npm/three@0.163.0/examples/jsm/",
        "d3": "https://cdn.jsdelivr.net/npm/d3@7.9.0/+esm",
        "gsap": "https://cdn.jsdelivr.net/npm/gsap@3.12.5/+esm"
      }
    }
  </script>
  <script type="module">
    import * as THREE from 'three';
    import * as d3 from 'd3';
    import gsap from 'gsap';
    import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
    import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js';
  </script>
  • 创建场景,ThreeJS基础三大要素,Scene、Renderer、Camera,为了更好的观察场景,我这里用到了OrbitControls去控制相机。注意:实例出来的renderer,需要不停调用renderer.render(scene, camera);去渲染整个场景。
  • 这里因为要实现标签,用到CSS2DRenderer去渲染HTML标签,虽然可以用Sprite去绘制,但是那样还得写上不少代码去实现,CSS2DRenderer实现起来方便快捷。
  • 在ThreeJS中,旋转是通过设置弧度,需要用Math.PI去计算弧度,其实ThreeJS有个工具函数THREE.MathUtils.degToRad()传递角度会转换成弧度,自己在其他教程或者资料没怎么看到过,所有在这里也提一嘴。
  • 随便提醒一下在实例WebGLRenderer时,我设置了logarithmicDepthBuffer:true,是否使用对数缓冲,这个配置是为了防止网格模型距离太近,导致材质闪烁,也叫Z-Fighting,因为地图分区紧密挨着,可能会产生这种问题,所有这里我就开启了这一选项,能让渲染看起来达到了自己的预期,但是设置后会有额外的开销,需要注意。
    // 容器
    const container = document.querySelector('.container');
    // 场景
    const scene = new THREE.Scene();
    // 相机
    const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 200);
    camera.position.set(0, 45, 45);
    // 渲染器
    const renderer = new THREE.WebGLRenderer({
      antialias: true,
      alpha: true,
      logarithmicDepthBuffer: true
    });
    renderer.setSize(window.innerWidth, window.innerHeight);

    // 2d渲染器
    const css2Renderer = new CSS2DRenderer();
    css2Renderer.setSize(window.innerWidth, window.innerHeight);
    css2Renderer.domElement.style.position = 'absolute';
    css2Renderer.domElement.style.top = '0px';
    css2Renderer.domElement.style.left = '0px';
    css2Renderer.domElement.style.pointerEvents = 'none';

    container.appendChild(css2Renderer.domElement);
    container.appendChild(renderer.domElement);

    // 控制器
    const controls = new OrbitControls(camera, renderer.domElement);
    controls.enableDamping = true;
    controls.maxDistance = 80;
    controls.minDistance = 20;
    controls.target.set(0, 0, 5);
    controls.maxPolarAngle = THREE.MathUtils.degToRad(80);

    // 渲染
    const animate = function () {
      requestAnimationFrame(animate);
      renderer.render(scene, camera);
      css2Renderer.render(scene, camera);
      controls.update();
    };
    animate();

绘制地图

当上面步骤完成后,一个基础的场景就已经搭建好了,现在是如何绘制地图各个分区,并加上一定的交互。下面的代码有点长,需要花点时间。

  • 这里我们就先初始化一些变量,为后面生成地图好使用,我也为每行代码添加了注释。
  • d3.geoMercator().center([104.779307, 29.33924]).translate([0, 0, 0]);需要传递你当前地图的中心点经纬度,使地图位于世界中心位置。
// 高度
    const MAP_DEPTH = 0.2;
    // 转换坐标函数
    const projection = d3.geoMercator().center([104.779307, 29.33924]).translate([0, 0, 0]);
    // 光线投射
    const raycaster = new THREE.Raycaster();
    // 材质加载器
    const textureLoader = new THREE.TextureLoader();
    // 区域网格列表
    const provinceMeshList = [];
    // 标签列表
    const labelList = [];
    // map Group容器,能统一规划区域
    let map = null;
    // 顶部材质
    let topFaceMaterial = null;
    // 侧面材质
    let sideMaterial = null;
    // 鼠标事件
    let mouseEvent = null;
  • 因为是在单HTML文件中实现效果,我这边需要用到请求去获取到json数据,在工程化的项目中就不需要,直接import

    getMapData();

    // 请求JSON数据
    function getMapData() {
      fetch('./map/zigong.json')
        .then(response => response.json())
        .then(data => {
          setTexture();
          setSunLight();
          operationData(data);
        })
        .catch(error => console.error('Error fetching JSON:', error));
    }
  • 这一步是在设置灯光、材质,材质与灯光的关系太复杂,我也是摸摸皮毛,这里不过多阐述,以实现效果为主。
function setSunLight() {
      //   平行光1
      const directionalLight1 = new THREE.DirectionalLight(0xffffff, 0.9);
      directionalLight1.position.set(0, 57, 33);
      //   平行光2
      const directionalLight2 = new THREE.DirectionalLight(0xffffff, 0.6);
      directionalLight2.position.set(-95, 28, -33);
      // 环境光
      const ambientLight = new THREE.AmbientLight(0xffffff, 0.8);

      scene.add(directionalLight1);
      scene.add(directionalLight2);
      scene.add(ambientLight);
    }

    //设置材质
    function setTexture() {
      const scale = 0.2;
      const textureMap = textureLoader.load('/map/texture/gz-map.jpg');
      const textureMapFx = textureLoader.load('/map/texture/gz-map-fx.jpg');
      textureMap.wrapS = textureMapFx.wrapS = THREE.RepeatWrapping;
      textureMap.wrapT = textureMapFx.wrapT = THREE.RepeatWrapping;
      textureMap.flipY = textureMapFx.flipY = false;
      textureMap.rotation = textureMapFx.rotation = THREE.MathUtils.degToRad(45);
      textureMap.repeat.set(scale, scale);
      textureMapFx.repeat.set(scale, scale);
      topFaceMaterial = new THREE.MeshPhongMaterial({
        map: textureMap,
        color: 0xb3fffa,
        combine: THREE.MultiplyOperation,
        transparent: true,
        opacity: 1,
      });
      sideMaterial = new THREE.MeshLambertMaterial({
        color: 0x123024,
        transparent: true,
        opacity: 0.9,
      });
    }
  • 终于来到了绘制各个区域这一步。
  • 其实最主要的是通过THREE.Shape绘制一个二维的形状路径,再用THREE.ExtrudeGeometry去生成挤压缓冲几何体,这样就能绘制出每个区域网格模型。
  • 各个区域的边界线的话,用的是THREE.BufferGeometry生成顶点信息,再用THREE.Line去生成网格。
  • 但需要注意的是,GeoJSON有不同的type,比如MultiPolygon,包含多个路径,需要多次绘制当前的区域。
    /**
    * 解析json数据,并绘制地图多边形
    * @param {*} jsondata 地图数据
    */
    function operationData(jsondata) {
      map = new THREE.Group();

      // geo信息
      const features = jsondata.features;
      features.forEach((feature) => {
        // 单个区域 对象
        const province = new THREE.Object3D();
        // 地址
        province.properties = feature.properties.name;
        province.isHover = false;
        // 多个情况
        if (feature.geometry.type === "MultiPolygon") {
          feature.geometry.coordinates.forEach((coordinate) => {
            coordinate.forEach((rows) => {
              const line = drawBoundary(rows);
              const mesh = drawExtrudeMesh(rows);
              province.add(line);
              province.add(mesh);
              provinceMeshList.push(mesh);
            });
          });
        }

        // 单个情况
        if (feature.geometry.type === "Polygon") {
          feature.geometry.coordinates.forEach((coordinate) => {
            const line = drawBoundary(coordinate);
            const mesh = drawExtrudeMesh(coordinate);
            province.add(line);
            province.add(mesh);
            provinceMeshList.push(mesh);
          });
        }
        const label = drawLabelText(feature);
        labelList.push({ name: feature.properties.name, label });
        province.add(label);
        map.add(province);
      });
      map.position.set(0, 1, -1.5);
      map.scale.set(10, 10, 10);
      map.rotation.set(THREE.MathUtils.degToRad(-90), 0, THREE.MathUtils.degToRad(20));
      scene.add(map);
      setMouseEvent();
    }

    /**
     * 画区域分界线
     * @param {*} polygon 区域坐标点数组
     * @returns 区域分界线
     */
    function drawBoundary(polygon) {
      const points = [];
      for (let i = 0; i < polygon.length; i++) {
        const [x, y] = projection(polygon[i]);
        points.push(new THREE.Vector3(x, -y, 0));
      }
      const lineGeometry = new THREE.BufferGeometry().setFromPoints(points);
      const lineMaterial = new THREE.LineBasicMaterial({
        color: 0xffffff,
        linewidth: 2,
        transparent: true,
        depthTest: false,
      });
      const line = new THREE.Line(lineGeometry, lineMaterial);
      line.translateZ(MAP_DEPTH + 0.001);
      return line;
    }
    /**
     * 绘制区域多边形
     * @param {*} polygon 区域坐标点数组
     * @returns 区域多边形
     */
    function drawExtrudeMesh(polygon) {
      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: MAP_DEPTH,
        bevelEnabled: false,
        bevelSegments: 1,
        bevelThickness: 0.1,
      });
      return new THREE.Mesh(geometry, [
        topFaceMaterial,
        sideMaterial,
      ]);
    }

    /**
     * 绘制2d区域标签
     * @param {*} province 区域对象
     * @returns 区域标签
     */
    function drawLabelText(province) {
      const [x, y] = projection(province.properties.center);
      const div = document.createElement('div');
      div.innerHTML = province.properties.name;
      div.style.padding = '4px 10px';
      div.style.color = '#fff';
      div.style.fontSize = '16px';
      div.style.position = 'absolute';
      div.style.backgroundColor = 'rgba(25,25,25,0.5)';
      div.style.borderRadius = '5px';
      const label = new CSS2DObject(div);
      div.style.pointerEvents = 'none';
      label.position.set(x, y, MAP_DEPTH + 0.05);
      return label;
    }
  • 这一步是为了添加交互,也算是锦上添花。
  • 值得注意的是射线拾取问题,拾取可能不精准问题,官方的案例是以canvas为整个屏幕宽高为基础去换算,如果当前的容器不是以整个屏幕,鼠标的坐标要减去当前容器的左边距离以及上边距离,这样的换算才能够准确。
  • raycaster.intersectObjects需要接收检测和射线相交的一组物体,官方的例子是传递的scene.children,这会导致检测到很多物体,当然,这个方法还能传递第二个参数truefalse,表示检测所有的后代,所以上面绘制地图的步骤时,保存了一份各个区域网格模型的列表,这样射线相交只会是其中物体。
  • 还有一点,网格材质设置了transparent,射线会穿透物体,也会检测到很多物体,但是离屏幕越近的,在数组中越靠前,所以不想与后面的物体交互,只需要数组第一个就可以。
    function setMouseEvent() {
      mouseEvent = handleEvent.bind(this);
      container.addEventListener("mousemove", mouseEvent);
    }

    function removeMouseEvent() {
      container.removeEventListener("mousemove", mouseEvent);
    }


    function handleEvent(e) {
      if (map) {
        let mouse = new THREE.Vector2();
        let getBoundingClientRect = container.getBoundingClientRect();
        let x = ((e.clientX - getBoundingClientRect.left) / getBoundingClientRect.width) * 2 - 1;
        let y = -((e.clientY - getBoundingClientRect.top) / getBoundingClientRect.height) * 2 + 1;
        mouse.x = x;
        mouse.y = y;

        raycaster.setFromCamera(mouse, camera);

        let intersects = raycaster.intersectObjects(provinceMeshList, false);

        if (intersects.length) {
          let temp = intersects[0].object;
          animation(temp.parent);
        } else {
          animation();
        }
      }
    }


    function animation(province) {
      if (province) {
        if (!province.isHover) {
          province.isHover = true;
          map.children.forEach((item) => {
            if (item.properties === province.properties) {
              gsap.to(province.position, {
                z: 0.12,
                duration: 0.6
              })
            } else {
              resetAnimation(item);
            }
          })
        }
      } else {
        resetAllAnimation();
      }

    }

    function resetAnimation(province) {
      gsap.to(province.position, {
        z: 0,
        duration: 0.6,
        onComplete: () => {
          province.isHover = false;
        }
      })
    }

    function resetAllAnimation() {
      map.children.forEach((item) => {
        resetAnimation(item);
      })
    }

优化问题

  • 地图数据庞大问题,往往获取到的地图坐标点数量很多,对于设备会造成不小的负担,这里可以使用去简化,还有就是QGIS这个免费开源软件也可以简化。
  • 深度问题,上面提到过材质闪烁,主要是物体间距离太过靠近,建议不要用logarithmicDepthBuffer,而是去调整物体间的距离,物体太小,调整距离可能不起作用,放大物体再调整距离。

最后

按照上面的步骤,基本可以实现一个ThreeJS的3D区域地图,这其中,自己也踩了不少的坑😭😭😭😭😭😭

希望通过这篇文章,能减小大家踩坑几率吧。