• threejs三维地图大屏项目分享


    这是最近公司的一个项目。客户的需求是基于总公司和子公司的数据,开发一个数据展示大屏。 大屏两边都是一些图表展示数据,中间部分是一个三维中国地图,点击中国地图的某个省份,可以下钻到省份地图的展示。 地图上,会做一些数据的标注,信息标牌。 如下图所示:

    数据已脱敏
    数据已脱敏
    数据已脱敏

    本文将对一些技术原理进行分享。

    2d图表

    2d图表部分,主要通过echart图表进行开发,另外还会涉及到一些icon 文字的展示。 这个部分相信大部分前端人员都知道如何进行开发,可能需要的就是开发人员对于颜色,字体等有较好的敏感性,可以最大程度还原设计搞。

    鉴于大家都比较熟知,不再详细说明。

    三维地图的展示

    对于中间的三维地图部分。 我们一般有几种方式来实现。

    1. 建模人员对地图部分进行建模
    2. 通过json数据生成三维模型
    3. 通过svg图片生产三维模型。

    其中方式1能达到最好的效果,毕竟手动建模了,需要的效果都可以通过建模师智慧的双手进行调整。但是工作量相对来说较大,需要建立中国地图和各个省份的地图。 所以我们最终放弃了建模的这种思路。

    通过json数据生成三维地图

    首先要获取json数据。
    通过datav可以获取中国地图的json数据,参考如下连接
    http://datav.aliyun.com/portal/school/atlas/area_selector

    获取数据之后,通过解析json数据,然后通过threejs的ExtrudeGeometry生成地图模型。代码如下所示:

     let jsonData = await (await fetch(jsonUrl)).json();
      // console.log(jsonData);
      let map = new dt.Group();
      if (type && type === "world") {
        jsonData.features = jsonData.features.filter(
          (ele) => ele.properties.name === "China"
        );
      }
      jsonData.features.forEach((elem, index) => {
        if (filter && filter(elem) == false) {
          return;
        }
        if (!elem.properties.name) {
          return;
        }
        // 定一个省份3D对象
        const province = new dt.Group();
        // 每个的 坐标 数组
        const coordinates = elem.geometry.coordinates;
        const color = COLOR_ARR[index % COLOR_ARR.length];
        // 循环坐标数组
        coordinates.forEach((multiPolygon, index) => {
          if (elem.properties.name == "海南省" && index > 0) {
            return;
          }
          if (elem.properties.name == "台湾省" && index > 0) {
            return;
          }
          if (elem.properties.name == "广东省" && index > 0) {
            return;
          }
          multiPolygon.forEach((polygon) => {
            const shape = new dt.Shape();
    
            let positions = [];
            for (let i = 0; i < polygon.length; i++) {
              let [x, y] = projection(polygon[i]);
    
              if (i === 0) {
                shape.moveTo(x, -y);
              }
              shape.lineTo(x, -y);
    
              positions.push(x, -y, 4);
            }
    
            const lineMaterial = new dt.LineBasicMaterial({
              color: "white",
            });
            const lineGeometry = new dt.LineXGeometry();
            // let attribute = new dt.BufferAttribute(new Float32Array(positions), 3);
            // lineGeometry.setAttribute("position", attribute);
            lineGeometry.setPositions(positions);
    
            const extrudeSettings = {
              depth: 4,
              bevelEnabled: false,
              bevelSegments: 5,
              bevelThickness: 0.1,
            };
    
            const geometry = new dt.ExtrudeGeometry(shape, extrudeSettings);
            // console.log("geometyr", geometry);
            const material = new dt.StandardMaterial({
              metalness: 1,
              // color: color,
              map: texture,
              transparent: true,
            });
    
            let material1 = new dt.StandardMaterial({
              // polygonOffset: true,
              // polygonOffsetFactor: 1,
              // polygonOffsetUnits: 1,
              metalness: 1,
              roughness: 1,
              color: color, //"#3abcbd",
            });
    
            material1 = createSideShaderMaterial(material1);
    
            const mesh = new dt.Mesh(geometry, [material, material1]);
            if (index % 2 === 0) {
              // mesh.scale.set(1, 1, 1.2);
            }
    
            mesh.castShadow = true;
            mesh.receiveShadow = true;
            mesh._color = color;
            mesh.properties = elem.properties;
            if (!type) {
              province.add(mesh);
            }
    
            const matLine = new dt.LineXMaterial({
              polygonOffset: true,
              polygonOffsetFactor: -1,
              polygonOffsetUnits: -1,
              color: type === "world" ? "#00BBF4" : 0xffffff,
              linewidth: type === "world" ? 3.0 : 0.25, // in pixels
              vertexColors: false,
              dashed: false,
            });
            matLine.resolution.set(graph.width, graph.height);
            line = new dt.LineX(lineGeometry, matLine);
            line.computeLineDistances();
            province.add(line);
          });
        });
    
        // 将geo的属性放到省份模型中
        province.properties = elem.properties;
        if (elem.properties.centorid) {
          const [x, y] = projection(elem.properties.centorid);
          province.properties._centroid = [x, y];
        }
    
        map.add(province);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118

    中国地图的json数据,实际包括的是每个省份的数据。
    上述代码生成中国地图以及省之间的轮廓线。
    其中projection 是投影函数,转换经纬度坐标未平面坐标,用的是d3这个库:

    const projection = d3
      .geoMercator()
      .center([104.0, 37.5])
      .scale(80)
      .translate([0, 0]);
    
    • 1
    • 2
    • 3
    • 4
    • 5

    按照设计稿,还需生成整个中国地图的外轮廓。这种情况下,我们先获取world.json,然后只获取中国的部分,通过这个部分来生成轮廓线。

    最终效果如下:

    在这里插入图片描述

    可以看出,通过json的方式生产地图,世界地图的json数据和中国地图的json数据,边缘的贴合度并不高,因此外边缘轮廓和地图块不能很好的融合在一块。

    基于此,需要找新的方案。

    通过svg数据生成三维地图

    由于有设计师提供设计稿,所以设计师肯定可以提供中国地图的轮廓数据,以及内部的每个省份的轮廓数据。拿到设计的svg后,对svg路径进行解析,然后通过ExtrudeGeometry生成地图块对下,通过line生成轮廓线。

     let childNodes = svg.childNodes;
      childNodes.forEach((child) => {
        readSVGPath(child, graph, group);
      });
      if (svg.tagName == "path") {
        const shape = getShapeBySvg(svg);
        // let shape = $d3g.transformSVGPath(pathStr);
        const extrudeSettings = {
          depth: 15,
          bevelEnabled: false,
          bevelSegments: 5,
          bevelThickness: 0.1,
        };
    
        const color = COLOR_ARR[parseInt(Math.random() * 3) % COLOR_ARR.length];
        const geometry = new dt.ExtrudeGeometry(shape, extrudeSettings);
        let center = new dt.Vec3();
        // console.log(geometry.getBoundingBox().getCenter(center));
        // geometry.translate(-center.x, -center.y, -center.z);
        geometry.scale(1, -1, -1);
        geometry.computeVertexNormals();
        // console.log("geometry", geometry);
        const material = new dt.StandardMaterial({
          metalness: 1,
          // color: color,
          // visible: false,
          map: window.texture,
        });
    
        let material1 = new dt.StandardMaterial({
          polygonOffset: true,
          polygonOffsetFactor: 1,
          polygonOffsetUnits: 1,
          metalness: 1,
          roughness: 1,
          color: color, //"#3abcbd",
        });
    
        material1 = createSideShaderMaterial(material1);
    
        const mesh = new dt.Mesh(geometry, [material, material1]);
        group.add(mesh);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42

    其中解析svg路径的代码如下:

    function getShapeBySvg(svg) {
      let pathStr = svg.getAttribute("d");
      let province = svg.getAttribute("province");
      let commonds = new svgpathdata.SVGPathData(pathStr).commands;
    
      const shape = new dt.Shape();
      let lastC, cmd, c;
      for (let i = 0; i < commonds.length; i++) {
        cmd = commonds[i];
        let relative = cmd.relative;
    
        if (relative) {
          c = copy(cmd);
          let x = cmd.x || 0;
          let y = cmd.y || 0;
          let lx = lastC.x || 0;
          let ly = lastC.y || 0;
          c.x = x + lx;
          c.y = y + ly;
          c.x1 = c.x1 + lx;
          c.x2 = c.x2 + lx;
          c.y1 = c.y1 + ly;
          c.y2 = c.y2 + ly;
        } else {
          c = cmd;
        }
        if (lastC) {
          let lx = lastC.x,
            ly = lastC.y;
          if (
            Math.hypot(lx - c.x, ly - c.y) < 0.2 &&
            province == "内蒙" &&
            [16, 32, 128, 64, 512, 4, 8].includes(c.type)
          ) {
            console.log(c.type);
            continue;
          }
        }
        if (c.type == 2) {
          shape.moveTo(c.x, c.y);
        } else if (c.type == 16) {
          shape.lineTo(c.x, c.y);
        } else if (c.type == 32) {
          shape.bezierCurveTo(c.x1, c.y1, c.x2, c.y2, c.x, c.y);
          // shape.lineTo(c.x, c.y);
        } else if (c.type == 128 || c.type == 64) {
          shape.quadraticCurveTo(c.x1 || c.x2, c.y1 || c.y2, c.x, c.y);
          // shape.lineTo(c.x, c.y);
        } else if (c.type == 512) {
          // shape.absellipse(c.x, c.y, c.rX, c.rY, 0, Math.PI * 2, true);
          shape.lineTo(c.x, c.y);
        } else if (c.type == 4) {
          c.y = lastC.y;
          shape.lineTo(c.x, lastC.y);
        } else if (c.type == 8) {
          c.x = lastC.x;
          shape.lineTo(lastC.x, c.y);
        } else if (c.type == 1) {
          // shape.closePath();
        } else {
          // console.log(c);
        }
        lastC = c;
      }
      return shape;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66

    其中里面涉及到相对定位的概念,一个cmd的坐标是相对于上一个坐标的,而不是绝对定位。这就需要我们在解析的时候,通过累加的方式获取绝对定位坐标。

    另外cmd的type主要包括:

      //   ARC: 512
      // CLOSE_PATH: 1
      // CURVE_TO: 32
      // DRAWING_COMMANDS: 1020
      // HORIZ_LINE_TO: 4
      // LINE_COMMANDS: 28
      // LINE_TO: 16
      // MOVE_TO: 2
      // QUAD_TO: 128
      // SMOOTH_CURVE_TO: 64
      // SMOOTH_QUAD_TO: 256
      // VERT_LINE_TO: 8
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    通过Shape的moveTo,lineTo,bezierCurveTo,quadraticCurveTo等等与之对应。
    最终效果如下图:
    在这里插入图片描述
    可以看出线更加圆滑,外轮廓和地图块的贴合度更高。
    这是我们项目最终采用的技术方案。

    侧边渐变效果

    上述两种方案的效果图,可以看出侧边地图的侧面都有渐变效果,这种是通过定制threejs的材质的shader来实现的。大致代码如下:

    
    function createSideShaderMaterial(material) {
      material.onBeforeCompile = function (shader, renderer) {
        // console.log(shader.fragmentShader);
        shader.vertexShader = shader.vertexShader.replace(
          "void main() {",
          "varying vec4 vPosition;\nvoid main() {"
        );
        shader.vertexShader = shader.vertexShader.replace(
          "#include ",
          "#include \nvPosition=modelMatrix * vec4( transformed, 1.0 );"
        );
    
        shader.fragmentShader = shader.fragmentShader.replace(
          "void main() {",
          "varying vec4 vPosition;\nvoid main() {"
        );
    
        shader.fragmentShader = shader.fragmentShader.replace(
          "#include ",
          `
          #include 
          float z = vPosition.z;
          float s = step(2.0,z);
          vec3 bottomColor =  vec3(.0,1.,1.0);
        
          diffuseColor.rgb = mix(bottomColor,diffuseColor.rgb,s);
          // float r =  abs( 1.0 * (1.0 - s) + z  * (0.0  - s * 1.0) + s * 4.0) ;
          float r =  abs(z  * (1.0  - s * 2.0) + s * 4.0) ;
          diffuseColor.rgb *= pow(r, 0.5 + 2.0 * s);
          
          // float c = 
        `
        );
      };
    
      return material;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38

    通过material.onBeforeCompile方法实现材质的动态更改,然后通过z坐标的高度进行颜色的渐变差值运算。

    三维地图的贴图

    上面实现的效果,都是简单的颜色。没有贴图效果,而设计师提供的原型是有渐变效果的:

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-j7BuKd9p-1667965040240)(https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/5f0c6a260f3647b991609a440ae85002~tplv-k3u1fbpfcp-watermark.image?)]

    这需要我们的贴图来进行解决。 但是贴图并不简单,涉及到uv的offset和repeat的计算。 通过计算整个中国地图的boundingbox,通过bongdingbox的size 和min 值来设置uv 的offset和repeat,可以很好的对其贴图和模型,如下代码:

     let box = new dt.Box3();
     box.setFromObject(map);
     et size = new dt.Vec3(),
        center = new dt.Vec3();
    console.log(box.getSize(size));
    console.log(box.getCenter(center));
    console.log(box);
    
    texture.repeat.set(1 / size.x, 1 / size.y);
    texture.offset.set(box.min.x / size.x, box.min.y / size.y);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    通过这种方式,贴图可以很好的和模型对齐,最终效果和设计稿差别很小。

    三维地图icon标注定位

    图片上的图标定位数据是经纬度,所以需要把定位度转换为三维中的坐标。此处使用的是双线性差值。先获取模型左上,右上,左下,右下四个点的经纬度坐标和三维坐标,然后通过双线性差值,结合某个特定点的经纬度值 计算出三维坐标。 这种方式肯定不是最精确的,却是最简单的。如果对于定位的精确性要求不高,可以采用这种方式。

    icon动画(APNG)

    icon的动画是通过apng的图片实现的。 解析apng的每一帧,然后绘制到canvas上面,作为sprite的贴图,并不断刷新贴图的内容,实现了动效效果。 有关apng的解析,网上有开源的JavaScript的解析包。读者可以自行进行研究,下面是一个参考链接:

    https://github.com/movableink/three-gif-loader

    其他

    其他方面包括

    1. 点击省份下钻 技术实现就是隐藏其他省份模型,显示当前省份模型,并加载当前省份的点位数据。技术思路比较简单。
    2. 鼠标悬浮显示名称等信息 通过div实现信息标签,通过三维坐标转平面坐标的投影算法,计算标签位置,代码如下:
     getViewPosition(vector) {
        this.camera.updateMatrixWorld();
        var ret = new Vec3();
        // ret = this.projector.projectVector(vector, this._camera, ret);
        ret = vector.project(this.camera);
        ret.x = ret.x / 2 + 0.5;
        ret.y = -ret.y / 2 + 0.5;
        var point = {
          x: (this._canvas.width * ret.x) / this._pixelRatio,
          y: (this._canvas.height * ret.y) / this._pixelRatio,
          h: this._canvas.height,
        };
        return point;
      }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    总结

    上面分享的三维地图大屏。涉及到的技术点并不少,包括主要如下技术点:

    • echart使用
    • json解析生成地图projection投影
    • svg 解析生成三维地图模型
    • 动态材质修改
    • 贴图的offset和repeat算法等
    • 经纬度定位,双线性差值
    • 三维的三维坐标转平面坐标的投影算法

    最终多个技术的融合,做出了文章开头的效果。

    数据已脱敏
    其中比较难的是中间三维地图的生成和效果优化方案,如果有类似需求的读者可以参考。

    如果你有好的经验,也欢迎和我交流。关注公号“ITMan彪叔” 可以添加作者微信进行交流,及时收到更多有价值的文章。

  • 相关阅读:
    R语言ggplot2可视化:使用ggpubr包的ggpie函数可视化饼图(pie chart)、为饼图不同区域添加标签
    【面试题】http协议
    Java 正则表达式分组匹配
    JAVA:实现UnionFind联合查找算法(附完整源码)
    国外最近突然爆火的「Wordle」是什么鬼?
    Linux 系统移植(一)-- 系统组成
    前端面试题(四)
    法学行政法论文选题有哪些?
    架构与思维:互联网高性能Web架构
    前端碎知识点
  • 原文地址:https://blog.csdn.net/netcy/article/details/127766732