threejs三維地圖大屏項目分享

  • 2022 年 11 月 9 日
  • 筆記

這是最近公司的一個項目。客戶的需求是基於總公司和子公司的數據,開發一個數據展示大屏。 大屏兩邊都是一些圖表展示數據,中間部分是一個三維中國地圖,點擊中國地圖的某個省份,可以下鑽到省份地圖的展示。 地圖上,會做一些數據的標註,資訊標牌。 如下圖所示:

數據已脫敏

數據已脫敏

數據已脫敏

本文將對一些技術原理進行分享。

2d圖表

2d圖表部分,主要通過echart圖表進行開發,另外還會涉及到一些icon 文字的展示。 這個部分相信大部分前端人員都知道如何進行開發,可能需要的就是開發人員對於顏色,字體等有較好的敏感性,可以最大程度還原設計搞。

鑒於大家都比較熟知,不再詳細說明。

三維地圖的展示

對於中間的三維地圖部分。 我們一般有幾種方式來實現。

  1. 建模人員對地圖部分進行建模
  2. 通過json數據生成三維模型
  3. 通過svg圖片生產三維模型。

其中方式1能達到最好的效果,畢竟手動建模了,需要的效果都可以通過建模師智慧的雙手進行調整。但是工作量相對來說較大,需要建立中國地圖和各個省份的地圖。 所以我們最終放棄了建模的這種思路。

通過json數據生成三維地圖

首先要獲取json數據。
通過datav可以獲取中國地圖的json數據,參考如下連接
//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);

中國地圖的json數據,實際包括的是每個省份的數據。
上述程式碼生成中國地圖以及省之間的輪廓線。
其中projection 是投影函數,轉換經緯度坐標未平面坐標,用的是d3這個庫:

const projection = d3
  .geoMercator()
  .center([104.0, 37.5])
  .scale(80)
  .translate([0, 0]);

按照設計稿,還需生成整個中國地圖的外輪廓。這種情況下,我們先獲取world.json,然後只獲取中國的部分,通過這個部分來生成輪廓線。

最終效果如下:

image.png

可以看出,通過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);

其中解析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;
}

其中裡面涉及到相對定位的概念,一個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

通過Shape的moveTo,lineTo,bezierCurveTo,quadraticCurveTo等等與之對應。
最終效果如下圖:

image.png
可以看出輪廓線更加圓滑,外輪廓和地圖塊的貼合度更高。
這是我們項目最終採用的技術方案。

側邊漸變效果

上述兩種方案的效果圖,可以看出側邊地圖的側面都有漸變效果,這種是通過訂製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 <fog_vertex>",
      "#include <fog_vertex>\nvPosition=modelMatrix * vec4( transformed, 1.0 );"
    );

    shader.fragmentShader = shader.fragmentShader.replace(
      "void main() {",
      "varying vec4 vPosition;\nvoid main() {"
    );

    shader.fragmentShader = shader.fragmentShader.replace(
      "#include <transmissionmap_fragment>",
      `
      #include <transmissionmap_fragment>
      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;
}

通過material.onBeforeCompile方法實現材質的動態更改,然後通過z坐標的高度進行顏色的漸變差值運算。

三維地圖的貼圖

上面實現的效果,都是簡單的顏色。沒有貼圖效果,而設計師提供的原型是有漸變效果的:

數據已脫敏

這需要我們的貼圖來進行解決。 但是貼圖並不簡單,涉及到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);

通過這種方式,貼圖可以很好的和模型對齊,最終效果和設計稿差別很小。

三維地圖icon標註定位

圖片上的圖標定位數據是經緯度,所以需要把定位度轉換為三維中的坐標。此處使用的是雙線性差值。先獲取模型左上,右上,左下,右下四個點的經緯度坐標和三維坐標,然後通過雙線性差值,結合某個特定點的經緯度值 計算出三維坐標。 這種方式肯定不是最精確的,卻是最簡單的。如果對於定位的精確性要求不高,可以採用這種方式。

icon動畫(APNG)

icon的動畫是通過apng的圖片實現的。 解析apng的每一幀,然後繪製到canvas上面,作為sprite的貼圖,並不斷刷新貼圖的內容,實現了動效效果。 有關apng的解析,網上有開源的JavaScript的解析包。讀者可以自行進行研究,下面是一個參考鏈接:

//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;
  }

總結

上面分享的三維地圖大屏。涉及到的技術點並不少,包括主要如下技術點:

  • echart使用
  • json解析生成地圖projection投影
  • svg 解析生成三維地圖模型
  • 動態材質修改
  • 貼圖的offset和repeat演算法等
  • 經緯度定位,雙線性差值
  • 三維的三維坐標轉平面坐標的投影演算法

最終多個技術的融合,做出了文章開頭的效果。

數據已脫敏

其中比較難的是中間三維地圖的生成和效果優化方案,如果有類似需求的讀者可以參考。

如果你有好的經驗,也歡迎和我交流。關注公號「ITMan彪叔」 可以添加作者微信進行交流,及時收到更多有價值的文章。