three.js实现3D地图下钻

地图下钻是前端开发中常见的开发需求。通常会使用高德、百度等第三方地图实现,不过这些都不是3d的。echarts倒是提供了map3D,以及常用的点位、飞线等功能,就是有一些小bug[泪奔],而且如果领导比较可爱,提一些奇奇怪怪的需求,可能就不好搞了……

这篇文章我会用three.js实现一个geojson下钻地图。

地图预览

一、搭建环境

我这里用parcel搭建一个简易的开发环境,安装依赖如下:

{
  "name": "three",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "dev": "parcel src/index.html",
    "build": "parcel build src/index.html"
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "parcel-bundler": "^1.12.5"
  },
  "dependencies": {
    "d3": "^7.6.1",
    "d3-geo": "^3.0.1",
    "three": "^0.142.0"
  }
}

二、创建场景、相机、渲染器以及地图

import * as THREE from 'three'

class Map3D {
  constructor() {
    this.scene = undefined  // 场景
    this.camera = undefined // 相机
    this.renderer = undefined // 渲染器

    this.init()
  }
  init() {
    // 创建场景
    this.scene = new THREE.Scene()

    // 创建相机
    this.setCamera()

    // 创建渲染器
    this.setRender()

    // 渲染函数
    this.render()

  }
  /**
   * 创建相机
   */
  setCamera() {
    // PerspectiveCamera(角度,长宽比,近端面,远端面) —— 透视相机
    this.camera = new THREE.PerspectiveCamera(
      75,
      window.innerWidth / window.innerHeight,
      0.1,
      1000
    )
    // 设置相机位置
    this.camera.position.set(0, 0, 120)
    // 把相机添加到场景中
    this.scene.add(this.camera)
  }
  /**
   * 创建渲染器
   */
  setRender() {
    this.renderer = new THREE.WebGLRenderer()
    // 渲染器尺寸
    this.renderer.setSize(window.innerWidth, window.innerHeight)
    //设置背景颜色
    this.renderer.setClearColor(0x000000)
    // 将渲染器追加到dom中
    document.body.appendChild(this.renderer.domElement)
  }
  render() {
    this.renderer.render(this.scene, this.camera)
    requestAnimationFrame(this.render.bind(this))
  }
}

const map = new Map3D()

场景、相机、渲染器是threejs中必不可少的要素。以上代码运行起来后可以看到屏幕一片黑,审查元素是一个canvas占据了窗口。

啥也没有

接下来需要geojson数据了,阿里的datav免费提供区级以上的数据:https://datav.aliyun.com/portal/school/atlas/area_selector

class Map3D {
  // 省略代码
  
  // 以下为新增代码
  init() {
    
    ......
    
  	this.loadData()
  }
  getGeoJson (adcode = '100000') {
    return fetch(`https://geo.datav.aliyun.com/areas_v3/bound/${adcode}_full.json`)
    .then(res => res.json())
  }
  async loadData(adcode) {
    this.geojson = await this.getGeoJson(adcode)
    console.log(this.geojson)
  }
}

const map = new Map3D()

得到的json大概是下图这样的数据格式:

geojson

然后,我们初始化一个地图 当然,咱们拿到的json数据中的所有坐标都是经纬度坐标,是不能直接在我们的threejs项目中使用的。需要 “墨卡托投影转换”把经纬度转换成画布中的坐标。在这里,我们使用现成的工具——d3中的墨卡托投影转换工具

import * as d3 from 'd3-geo'
class Map3D {
  
  ......
  
  async loadData(adcode) {
    // 获取geojson数据
    this.geojson = await this.getGeoJson(adcode)
    
    // 墨卡托投影转换。将中心点设置成经纬度为 104.0, 37.5 的地点,且不平移
    this.projection = d3
    	.geoMercator()
      .center([104.0, 37.5])
      .translate([0, 0])
    
  }
}

接着就可以创建地图了。

创建地图的思路:以中国地图为例,创建一个Object3D对象,作为整个中国地图。再创建N个Object3D子对象,每个子对象都是一个省份,再将这些子对象add到中国地图这个父Object3D对象上。

地图结构

创建地图后的完整代码:

import * as THREE from 'three'
import * as d3 from 'd3-geo'

const MATERIAL_COLOR1 = "#2887ee";
const MATERIAL_COLOR2 = "#2887d9";

class Map3D {
  constructor() {
    this.scene = undefined  // 场景
    this.camera = undefined // 相机
    this.renderer = undefined // 渲染器
    this.geojson = undefined // 地图json数据

    this.init()
  }
  init() {
    // 创建场景
    this.scene = new THREE.Scene()

    // 创建相机
    this.setCamera()

    // 创建渲染器
    this.setRender()

    // 渲染函数
    this.render()

    // 加载数据
    this.loadData()

  }
  /**
   * 创建相机
   */
  setCamera() {
    // PerspectiveCamera(角度,长宽比,近端面,远端面) —— 透视相机
    this.camera = new THREE.PerspectiveCamera(
      75,
      window.innerWidth / window.innerHeight,
      0.1,
      1000
    )
    // 设置相机位置
    this.camera.position.set(0, 0, 120)
    // 把相机添加到场景中
    this.scene.add(this.camera)
  }
  /**
   * 创建渲染器
   */
  setRender() {
    this.renderer = new THREE.WebGLRenderer()
    // 渲染器尺寸
    this.renderer.setSize(window.innerWidth, window.innerHeight)
    //设置背景颜色
    this.renderer.setClearColor(0x000000)
    // 将渲染器追加到dom中
    document.body.appendChild(this.renderer.domElement)
  }
  render() {
    this.renderer.render(this.scene, this.camera)
    requestAnimationFrame(this.render.bind(this))
  }
  getGeoJson (adcode = '100000') {
    return fetch(`https://geo.datav.aliyun.com/areas_v3/bound/${adcode}_full.json`)
    .then(res => res.json())
  }
  async loadData(adcode) {
    // 获取geojson数据
    this.geojson = await this.getGeoJson(adcode)
    
    // 创建墨卡托投影
    this.projection = d3
      .geoMercator()
      .center([104.0, 37.5])
      .translate([0, 0])
    
    // Object3D是Three.js中大部分对象的基类,提供了一系列的属性和方法来对三维空间中的物体进行操纵。
    // 初始化一个地图
    this.map = new THREE.Object3D();
    this.geojson.features.forEach(elem => {
      const area = new THREE.Object3D()
      // 坐标系数组(为什么是数组,因为有地区不止一个几何体,比如河北被北京分开了,比如舟山群岛)
      const coordinates = elem.geometry.coordinates
      const type = elem.geometry.type

      // 定义一个画几何体的方法
      const drawPolygon = (polygon) => {
        // Shape(形状)。使用路径以及可选的孔洞来定义一个二维形状平面。 它可以和ExtrudeGeometry、ShapeGeometry一起使用,获取点,或者获取三角面。
        const shape = new THREE.Shape()
        // 存放的点位,最后需要用THREE.Line将点位构成一条线,也就是地图上区域间的边界线
        // 为什么两个数组,因为需要三维地图的两面都画线,且它们的z坐标不同
        let points1 = [];
        let points2 = [];

        for (let i = 0; i < polygon.length; i++) {
          // 将经纬度通过墨卡托投影转换成threejs中的坐标
          const [x, y] = this.projection(polygon[i]);
          // 画二维形状
          if (i === 0) {
            shape.moveTo(x, -y);
          }
          shape.lineTo(x, -y);

          points1.push(new THREE.Vector3(x, -y, 10));
          points2.push(new THREE.Vector3(x, -y, 0));
        }

        /**
         * ExtrudeGeometry (挤压缓冲几何体)
         * 文档链接:https://threejs.org/docs/index.html?q=ExtrudeGeometry#api/zh/geometries/ExtrudeGeometry
         */
        const geometry = new THREE.ExtrudeGeometry(shape, {
          depth: 10,
          bevelEnabled: false,
        });
        /**
         * 基础材质
         */
        // 正反两面的材质
        const material1 = new THREE.MeshBasicMaterial({
          color: MATERIAL_COLOR1,
        });
        // 侧边材质
        const material2 = new THREE.MeshBasicMaterial({
          color: MATERIAL_COLOR2,
        });
        // 生成一个几何物体(如果是中国地图,那么每一个mesh就是一个省份几何体)
        const mesh = new THREE.Mesh(geometry, [material1, material2]);
        area.add(mesh);

        /**
         * 画线
         * link: https://threejs.org/docs/index.html?q=Line#api/zh/objects/Line
         */
        const lineGeometry1 = new THREE.BufferGeometry().setFromPoints(points1);
        const lineGeometry2 = new THREE.BufferGeometry().setFromPoints(points2);
        const lineMaterial = new THREE.LineBasicMaterial({ color: 0xffffff });
        const line1 = new THREE.Line(lineGeometry1, lineMaterial);
        const line2 = new THREE.Line(lineGeometry2, lineMaterial);
        area.add(line1);
        area.add(line2);
      }

      // type可能是MultiPolygon 也可能是Polygon
      if (type === "MultiPolygon") {
        coordinates.forEach((multiPolygon) => {
          multiPolygon.forEach((polygon) => {
            drawPolygon(polygon);
          });
        });
      } else {
        coordinates.forEach((polygon) => {
          drawPolygon(polygon);
        });
      }

      // 把区域添加到地图中
      this.map.add(area);

    })

    // 把地图添加到场景中
    this.scene.add(this.map)
  }
}

const map = new Map3D()

简单地图

这时,已经生成一个完整的地图,但是当我们试着去交互时还不能旋转,只需要添加一个控制器

// 引入构造器
import { OrbitControls  } from 'three/examples/jsm/controls/OrbitControls'

init() {
  this.setControls()
}
setControls() {
    this.controls = new OrbitControls(this.camera, this.renderer.domElement)
    // 太灵活了,来个阻尼
    this.controls.enableDamping = true;
    this.controls.dampingFactor = 0.1;
}

controls

好了,现在就可以想看哪儿就看哪儿了。


三、当鼠标移入地图时让对应的地区高亮

Raycaster —— 光线投射Raycaster
文档链接:https://threejs.org/docs/index.html?q=Raycaster#api/zh/core/Raycaster

Raycaster用于进行raycasting(光线投射)。 光线投射用于进行鼠标拾取(在三维空间中计算出鼠标移过了什么物体)。

这个类有两个方法,
第一个setFromCamera(coords, camera)方法,它接收两个参数:
coords —— 在标准化设备坐标中鼠标的二维坐标 —— X分量与Y分量应当在-1到1之间。
camera —— 射线所来源的摄像机。
通过这个方法可以更新射线。

第二个intersectObjects: 检测所有在射线与这些物体之间,包括或不包括后代的相交部分。返回结果时,相交部分将按距离进行排序,最近的位于第一个)。

我们可以通过监听鼠标事件,实时更新鼠标的坐标,同时实时在渲染函数中更新射线,然后通过intersectObjects方法查找当前鼠标移过的物体。

// 以下是新添加的代码

init() {
    // 创建场景
    this.scene = new THREE.Scene()

    // 创建相机
    this.setCamera()

    // 创建渲染器
    this.setRender()
    
    // 创建控制器
    this.setControls()

    // 光线投射
    this.setRaycaster()
    
    // 加载数据
    this.loadData()

    // 渲染函数
    this.render()
}
setRaycaster() {
    this.raycaster = new THREE.Raycaster();
    this.mouse = new THREE.Vector2();
    const onMouse = (event) => {
      // 将鼠标位置归一化为设备坐标。x 和 y 方向的取值范围是 (-1 to +1)
      // threejs的三维坐标系是中间为原点,鼠标事件的获得的坐标是左上角为原点。因此需要在这里转换
      this.mouse.x = (event.clientX / window.innerWidth) * 2 - 1
      this.mouse.y = -(event.clientY / window.innerHeight) * 2 + 1
    };
    window.addEventListener("mousemove", onMouse, false);
}
render() {
    this.raycaster.setFromCamera(this.mouse, this.camera)

    const intersects = this.raycaster.intersectObjects(
      this.scene.children,
      true
    )
    
    // 如果this.lastPick存在,将材质颜色还原
    if (this.lastPick) {
      this.lastPick.object.material[0].color.set(MATERIAL_COLOR1);
      this.lastPick.object.material[1].color.set(MATERIAL_COLOR2);
    }
    // 置空
    this.lastPick = null;
    // 查询当前鼠标移动所产生的射线与物体的焦点
    // 有两个material的就是我们要找的对象
    this.lastPick = intersects.find(
      (item) => item.object.material && item.object.material.length === 2
    );
    // 找到后把颜色换成一个鲜艳的绿色
    if (this.lastPick) {
      this.lastPick.object.material[0].color.set("aquamarine");
      this.lastPick.object.material[1].color.set("aquamarine");
    }

    this.renderer.render(this.scene, this.camera)
    requestAnimationFrame(this.render.bind(this))
}

高亮

四、还差一个tooltip

引入 CSS2DRenderer CSS2DObject,创建一个2D渲染器,用2D渲染器生成一个tooltip。在此之前,需要在 loadData方法创建area时把地区属性添加到Mesh对象上。确保lastPick对象上能取到地域名称。

// 把地区属性存到area对象中
area.properties = elem.properties

把地区属性存到Mash对象中

// 引入CSS2DObject, CSS2DRenderer
import { CSS2DObject, CSS2DRenderer } from 'three/examples/jsm/renderers/CSS2DRenderer'
class Map3D {
  
  setRender() {
		......
    // CSS2DRenderer 创建的是html的p元素
    // 这里将p设置成绝对定位,盖住canvas画布
    this.css2dRenderer = new CSS2DRenderer();
    this.css2dRenderer.setSize(window.innerWidth, window.innerHeight);
    this.css2dRenderer.domElement.style.position = "absolute";
    this.css2dRenderer.domElement.style.top = "0px";
    this.css2dRenderer.domElement.style.pointerEvents = "none";
    document.body.appendChild(this.css2dRenderer.domElement);
  }
  render() {
    // 省略......
    this.showTip()
    this.css2dRenderer.render(this.scene, this.camera)
    // 省略 ......
  }
  showTip () {
    if (!this.dom) {
      this.dom = document.createElement("p");
      this.tip = new CSS2DObject(this.dom);
    }
    if (this.lastPick) {
      const { x, y, z } = this.lastPick.point;
      const properties = this.lastPick.object.parent.properties;
      // label的样式在直接用css写在样式表中
      this.dom.className = "label";
      this.dom.innerText = properties.name
      this.tip.position.set(x + 10, y + 10, z);
      this.map && this.map.add(this.tip);
    }
  }
  
}

label样式

3D中国地图

此时的完整代码:

import * as THREE from 'three'
import * as d3 from 'd3-geo'
import { OrbitControls  } from 'three/examples/jsm/controls/OrbitControls'
import { CSS2DObject, CSS2DRenderer } from 'three/examples/jsm/renderers/CSS2DRenderer'

const MATERIAL_COLOR1 = "#2887ee";
const MATERIAL_COLOR2 = "#2887d9";

class Map3D {
  constructor() {
    this.scene = undefined  // 场景
    this.camera = undefined // 相机
    this.renderer = undefined // 渲染器
    this.css2dRenderer = undefined // html渲染器
    this.geojson = undefined // 地图json数据

    this.init()
  }
  init() {
    // 创建场景
    this.scene = new THREE.Scene()

    // 创建相机
    this.setCamera()

    // 创建渲染器
    this.setRender()
    
    // 创建控制器
    this.setControls()

    // 光线投射
    this.setRaycaster()
    
    // 加载数据
    this.loadData()

    // 渲染函数
    this.render()

  }
  /**
   * 创建相机
   */
  setCamera() {
    // PerspectiveCamera(角度,长宽比,近端面,远端面) —— 透视相机
    this.camera = new THREE.PerspectiveCamera(
      75,
      window.innerWidth / window.innerHeight,
      0.1,
      1000
    )
    // 设置相机位置
    this.camera.position.set(0, 0, 120)
    // 把相机添加到场景中
    this.scene.add(this.camera)
  }
  /**
   * 创建渲染器
   */
  setRender() {
    this.renderer = new THREE.WebGLRenderer()
    // 渲染器尺寸
    this.renderer.setSize(window.innerWidth, window.innerHeight)
    //设置背景颜色
    this.renderer.setClearColor(0x000000)
    // 将渲染器追加到dom中
    document.body.appendChild(this.renderer.domElement)

    // CSS2DRenderer 创建的是html的p元素
    // 这里将p设置成绝对定位,盖住canvas画布
    this.css2dRenderer = new CSS2DRenderer();
    this.css2dRenderer.setSize(window.innerWidth, window.innerHeight);
    this.css2dRenderer.domElement.style.position = "absolute";
    this.css2dRenderer.domElement.style.top = "0px";
    this.css2dRenderer.domElement.style.pointerEvents = "none";
    document.body.appendChild(this.css2dRenderer.domElement);
  }
  setRaycaster() {
    this.raycaster = new THREE.Raycaster();
    this.mouse = new THREE.Vector2();
    const onMouse = (event) => {
      // 将鼠标位置归一化为设备坐标。x 和 y 方向的取值范围是 (-1 to +1)
      // threejs的三维坐标系是中间为原点,鼠标事件的获得的坐标是左上角为原点。因此需要在这里转换
      this.mouse.x = (event.clientX / window.innerWidth) * 2 - 1
      this.mouse.y = -(event.clientY / window.innerHeight) * 2 + 1
    };
    window.addEventListener("mousemove", onMouse, false);
  }
  showTip () {
    if (!this.dom) {
      this.dom = document.createElement("p");
      this.tip = new CSS2DObject(this.dom);
    }
    if (this.lastPick) {
      const { x, y, z } = this.lastPick.point;
      const properties = this.lastPick.object.parent.properties;
      this.dom.className = "label";
      this.dom.innerText = properties.name
      this.tip.position.set(x + 10, y + 10, z);
      this.map && this.map.add(this.tip);
    }
  }
  render() {
    this.raycaster.setFromCamera(this.mouse, this.camera)

    const intersects = this.raycaster.intersectObjects(
      this.scene.children,
      true
    )
    
    // 如果this.lastPick存在,将材质颜色还原
    if (this.lastPick) {
      this.lastPick.object.material[0].color.set(MATERIAL_COLOR1);
      this.lastPick.object.material[1].color.set(MATERIAL_COLOR2);
    }
    // 置空
    this.lastPick = null;
    // 查询当前鼠标移动所产生的射线与物体的焦点
    // 有两个material的就是我们要找的对象
    this.lastPick = intersects.find(
      (item) => item.object.material && item.object.material.length === 2
    );
    // 找到后把颜色换成一个鲜艳的绿色
    if (this.lastPick) {
      this.lastPick.object.material[0].color.set("aquamarine");
      this.lastPick.object.material[1].color.set("aquamarine");
    }

    this.showTip()

    this.renderer.render(this.scene, this.camera)
    this.css2dRenderer.render(this.scene, this.camera)
    requestAnimationFrame(this.render.bind(this))
  }
  setControls() {
    this.controls = new OrbitControls(this.camera, this.renderer.domElement)
    // 太灵活了,来个阻尼
    this.controls.enableDamping = true;
    this.controls.dampingFactor = 0.1;
  }
  getGeoJson (adcode = '100000') {
    return fetch(`https://geo.datav.aliyun.com/areas_v3/bound/${adcode}_full.json`)
    .then(res => res.json())
  }
  async loadData(adcode) {
    // 获取geojson数据
    this.geojson = await this.getGeoJson(adcode)
    
    // 创建墨卡托投影
    this.projection = d3
      .geoMercator()
      .center([104.0, 37.5])
      .translate([0, 0])
    
    // Object3D是Three.js中大部分对象的基类,提供了一系列的属性和方法来对三维空间中的物体进行操纵。
    // 初始化一个地图
    this.map = new THREE.Object3D();
    this.geojson.features.forEach(elem => {
      const area = new THREE.Object3D()
      // 坐标系数组(为什么是数组,因为有地区不止一个几何体,比如河北被北京分开了,比如舟山群岛)
      const coordinates = elem.geometry.coordinates
      const type = elem.geometry.type

      // 定义一个画几何体的方法
      const drawPolygon = (polygon) => {
        // Shape(形状)。使用路径以及可选的孔洞来定义一个二维形状平面。 它可以和ExtrudeGeometry、ShapeGeometry一起使用,获取点,或者获取三角面。
        const shape = new THREE.Shape()
        // 存放的点位,最后需要用THREE.Line将点位构成一条线,也就是地图上区域间的边界线
        // 为什么两个数组,因为需要三维地图的两面都画线,且它们的z坐标不同
        let points1 = [];
        let points2 = [];

        for (let i = 0; i < polygon.length; i++) {
          // 将经纬度通过墨卡托投影转换成threejs中的坐标
          const [x, y] = this.projection(polygon[i]);
          // 画二维形状
          if (i === 0) {
            shape.moveTo(x, -y);
          }
          shape.lineTo(x, -y);

          points1.push(new THREE.Vector3(x, -y, 10));
          points2.push(new THREE.Vector3(x, -y, 0));
        }

        /**
         * ExtrudeGeometry (挤压缓冲几何体)
         * 文档链接:https://threejs.org/docs/index.html?q=ExtrudeGeometry#api/zh/geometries/ExtrudeGeometry
         */
        const geometry = new THREE.ExtrudeGeometry(shape, {
          depth: 10,
          bevelEnabled: false,
        });
        /**
         * 基础材质
         */
        // 正反两面的材质
        const material1 = new THREE.MeshBasicMaterial({
          color: MATERIAL_COLOR1,
        });
        // 侧边材质
        const material2 = new THREE.MeshBasicMaterial({
          color: MATERIAL_COLOR2,
        });
        // 生成一个几何物体(如果是中国地图,那么每一个mesh就是一个省份几何体)
        const mesh = new THREE.Mesh(geometry, [material1, material2]);
        area.add(mesh);

        /**
         * 画线
         * link: https://threejs.org/docs/index.html?q=Line#api/zh/objects/Line
         */
        const lineGeometry1 = new THREE.BufferGeometry().setFromPoints(points1);
        const lineGeometry2 = new THREE.BufferGeometry().setFromPoints(points2);
        const lineMaterial = new THREE.LineBasicMaterial({ color: 0xffffff });
        const line1 = new THREE.Line(lineGeometry1, lineMaterial);
        const line2 = new THREE.Line(lineGeometry2, lineMaterial);
        area.add(line1);
        area.add(line2);

        // 把地区属性存到area对象中
        area.properties = elem.properties
      }

      // type可能是MultiPolygon 也可能是Polygon
      if (type === "MultiPolygon") {
        coordinates.forEach((multiPolygon) => {
          multiPolygon.forEach((polygon) => {
            drawPolygon(polygon);
          });
        });
      } else {
        coordinates.forEach((polygon) => {
          drawPolygon(polygon);
        });
      }

      // 把区域添加到地图中
      this.map.add(area);

    })

    // 把地图添加到场景中
    this.scene.add(this.map)
  }
}

const map = new Map3D()

五、地图下钻

现在除了地图下钻,都已经完成了。地图下钻其实就是把当前地图清空,然后再次调用一下 loadData 方法,传入adcode就可以创建对应地区的3D地图了。

思路非常简单,先绑定点击事件,这里就不需要光线投射了,因为已经监听mousever事件了,并且数据已经存在this.lastPick这个变量中了。只需要在监听点击时获取选中的lastPick对象就可以了。

然后调用this.loadData(areaId),不过...在调用loadData方法前需要将创建的地图清空,并且释放几何体和材质对象,防止内存泄露。

理清思路后开始动手。

首先绑定点击事件。我们在调用点击事件时,例如高德地图、echarts,会以 obj.on('click', callback)的形式调用,这样就不会局限于click事件了,双击事件以及其它的事件都可以监听和移除,那我们也试着这么做一个。在Map3D类中创建一个on 监听事件的方法和一个off 移除事件的方法。

class Map3D{
  
  constructor() {
  	// 监听回调事件存储区
    this.callbackStack = new Map();
  }
  
  // 省略代码......
  
  // 添加监听事件
  on(eventName, callback) {
    const fnName = `${eventName}_fn`;
    if (!this.callbackStack.get(eventName)) {
      this.callbackStack.set(eventName, new Set());
    }
    if (!this.callbackStack.get(eventName).has(callback)) {
      this.callbackStack.get(eventName).add(callback);
    }
    if (!this.callbackStack.get(fnName)) {
      this.callbackStack.set(fnName, (e) => {
        this.callbackStack.get(eventName).forEach((cb) => {
          if (this.lastPick) cb(e, this.lastPick);
        });
      });
    }
    window.addEventListener(eventName, this.callbackStack.get(fnName));
  }
  
  // 移除监听事件
  off(eventName, callback) {
    const fnName = `${eventName}_fn`;
    if (!this.callbackStack.get(eventName)) return;

    if (this.callbackStack.get(eventName).has(callback)) {
      this.callbackStack.get(eventName).delete(callback);
    }
    if (this.callbackStack.get(eventName).size < 1) {
      window.removeEventListener(eventName, this.callbackStack.get(fnName));
    }
  }
  
}

const map = new Map3D();

map.on('click', listener)

function listener(e, data) {
  // Mesh对象
  console.log(data)
  // 区域编码
  console.log(data.object.parent.properties.adcode)
}

在上面的 listener 回调方法中打印可以获取到当前点击区域。
先忍住调用loadData()方法,在此之前,要先抹掉之前一番操作搞出来的地图。

在Map3D类中再创建一个dispose方法,用来移除地图以及释放内存

class Map3D {
// 省略代码......
  
  dispose (o) {
      // 可以遍历该父场景中的所有子物体来执行回调函数
      o.traverse(child => {
        if (child.geometry) {
          child.geometry.dispose()
        }
        if (child.material) {
          if (Array.isArray(child.material)) {
            child.material.forEach(material => {
              material.dispose()
            })
          } else {
            child.material.dispose()
          }
        }
      })
      o.parent.remove(o)
  }  
  
  // 省略代码......
  
}
const map = new Map3D()
map.on('click', listener)
function listener(e, data) {
  // 区域编码
  const adcode = data.object.parent.properties.adcode

  if(adcode) {
    map.dispose(map.map)
    map.loadData(adcode)
  }
}


下钻

现在已经可以下钻了,但是又出现了一个新问题[吐血]。到省份一级后,地图太小了,而且位置也没有在中间。这是由于我们的墨卡托投影 变换的中心点和缩放比例是写死的,我们需要让这些参数根据地理数据的不同而生成相对应的值。

在geojson中,coordinates数组中的坐标就是这块区域的边界线上的点,以浙江省为例,只要找出浙江省边界线上点位的最大横向坐标(maxX)和最小横向坐标(minX),它们的和 / 2 就能得到X轴上的中心点。同理Y轴中心点也是如此。

缩放倍数只需要根据画布的宽与浙江省横向长度比值和画布的高与浙江省纵向长度比值中取一个最小值再乘以一个系数(待定)。

开始动手,在Map3D类中添加getCenter方法:

class Map3D{
  // 省略代码.....
  
	// 获取中心点和缩放倍数
  getCenter() {
    let maxX = undefined;
    let maxY = undefined;
    let minX = undefined;
    let minY = undefined;
    this.geoJson.features.forEach((elem) => {
      const coordinates = elem.geometry.coordinates;
      const type = elem.geometry.type;

      function compare(point) {
        maxX === undefined
          ? (maxX = point[0])
          : (maxX = point[0] > maxX ? point[0] : maxX);
        maxY === undefined
          ? (maxY = point[1])
          : (maxY = point[1] > maxY ? point[1] : maxY);
        minX === undefined
          ? (minX = point[0])
          : (minX = point[0] > minX ? minX : point[0]);
        minY === undefined
          ? (minY = point[1])
          : (minY = point[1] > minY ? minY : point[1]);
      }

      if (type === "MultiPolygon") {
        coordinates.forEach((multiPolygon) => {
          multiPolygon.forEach((polygon) => {
            polygon.forEach((point) => {
              compare(point);
            });
          });
        });
      } else {
        coordinates.forEach((polygon) => {
          polygon.forEach((point) => {
            compare(point);
          });
        });
      }
    });
    const xScale = window.innerWidth / (maxX - minX);
    const yScale = window.innerHeight / (maxY - minY);
    return {
      center: [(maxX + minX) / 2, (maxY + minY) / 2],
      scale: Math.min(xScale, yScale),
    };
  }
  
  async loadData(adcode) {
    // 获取geojson数据
    this.geojson = await this.getGeoJson(adcode)

    const { center, scale } = this.getCenter()
    
    // 创建墨卡托投影
    this.projection = d3
      .geoMercator()
      .center(center)
      .translate([0, 0])
      .scale(scale * 7) // 根据实测,系数7差不多刚好
  }
  
  // 省略代码.....
}

看效果:

下钻地图2

完整代码:

import * as THREE from 'three'
import * as d3 from 'd3-geo'
import { OrbitControls  } from 'three/examples/jsm/controls/OrbitControls'
import { CSS2DObject, CSS2DRenderer } from 'three/examples/jsm/renderers/CSS2DRenderer'

const MATERIAL_COLOR1 = "#2887ee";
const MATERIAL_COLOR2 = "#2887d9";

class Map3D {
  constructor() {
    // 监听回调事件存储区
    this.callbackStack = new Map();

    this.scene = undefined  // 场景
    this.camera = undefined // 相机
    this.renderer = undefined // 渲染器
    this.css2dRenderer = undefined // html渲染器
    this.geojson = undefined // 地图json数据

    this.init()
  }
  init() {
    // 创建场景
    this.scene = new THREE.Scene()

    // 创建相机
    this.setCamera()

    // 创建渲染器
    this.setRender()
    
    // 创建控制器
    this.setControls()

    // 光线投射
    this.setRaycaster()
    
    // 加载数据
    this.loadData()

    // 渲染函数
    this.render()

  }
  /**
   * 创建相机
   */
  setCamera() {
    // PerspectiveCamera(角度,长宽比,近端面,远端面) —— 透视相机
    this.camera = new THREE.PerspectiveCamera(
      75,
      window.innerWidth / window.innerHeight,
      0.1,
      1000
    )
    // 设置相机位置
    this.camera.position.set(0, 0, 120)
    // 把相机添加到场景中
    this.scene.add(this.camera)
  }
  /**
   * 创建渲染器
   */
  setRender() {
    this.renderer = new THREE.WebGLRenderer()
    // 渲染器尺寸
    this.renderer.setSize(window.innerWidth, window.innerHeight)
    //设置背景颜色
    this.renderer.setClearColor(0x000000)
    // 将渲染器追加到dom中
    document.body.appendChild(this.renderer.domElement)

    // CSS2DRenderer 创建的是html的p元素
    // 这里将p设置成绝对定位,盖住canvas画布
    this.css2dRenderer = new CSS2DRenderer();
    this.css2dRenderer.setSize(window.innerWidth, window.innerHeight);
    this.css2dRenderer.domElement.style.position = "absolute";
    this.css2dRenderer.domElement.style.top = "0px";
    this.css2dRenderer.domElement.style.pointerEvents = "none";
    document.body.appendChild(this.css2dRenderer.domElement);
  }
  setRaycaster() {
    this.raycaster = new THREE.Raycaster();
    this.mouse = new THREE.Vector2();
    const onMouse = (event) => {
      // 将鼠标位置归一化为设备坐标。x 和 y 方向的取值范围是 (-1 to +1)
      // threejs的三维坐标系是中间为原点,鼠标事件的获得的坐标是左上角为原点。因此需要在这里转换
      this.mouse.x = (event.clientX / window.innerWidth) * 2 - 1
      this.mouse.y = -(event.clientY / window.innerHeight) * 2 + 1
    };
    window.addEventListener("mousemove", onMouse, false);
  }
  showTip () {
    if (!this.dom) {
      this.dom = document.createElement("p");
      this.tip = new CSS2DObject(this.dom);
    }
    if (this.lastPick) {
      const { x, y, z } = this.lastPick.point;
      const properties = this.lastPick.object.parent.properties;
      // label的样式在直接用css写在样式表中
      this.dom.className = "label";
      this.dom.innerText = properties.name
      this.tip.position.set(x + 10, y + 10, z);
      this.map && this.map.add(this.tip);
    }
  }
  render() {
    this.raycaster.setFromCamera(this.mouse, this.camera)

    const intersects = this.raycaster.intersectObjects(
      this.scene.children,
      true
    )
    
    // 如果this.lastPick存在,将材质颜色还原
    if (this.lastPick) {
      this.lastPick.object.material[0].color.set(MATERIAL_COLOR1);
      this.lastPick.object.material[1].color.set(MATERIAL_COLOR2);
    }
    // 置空
    this.lastPick = null;
    // 查询当前鼠标移动所产生的射线与物体的焦点
    // 有两个material的就是我们要找的对象
    this.lastPick = intersects.find(
      (item) => item.object.material && item.object.material.length === 2
    );
    // 找到后把颜色换成一个鲜艳的绿色
    if (this.lastPick) {
      this.lastPick.object.material[0].color.set("aquamarine");
      this.lastPick.object.material[1].color.set("aquamarine");
    }

    this.showTip()

    this.renderer.render(this.scene, this.camera)
    this.css2dRenderer.render(this.scene, this.camera)
    requestAnimationFrame(this.render.bind(this))
  }
  setControls() {
    this.controls = new OrbitControls(this.camera, this.renderer.domElement)
    // 太灵活了,来个阻尼
    this.controls.enableDamping = true;
    this.controls.dampingFactor = 0.1;
  }
  getGeoJson (adcode = '100000') {
    return fetch(`https://geo.datav.aliyun.com/areas_v3/bound/${adcode}_full.json`)
    .then(res => res.json())
  }
  // 获取中心点和缩放倍数
  getCenter () {
    let maxX, maxY, minX, minY;
    this.geojson.features.forEach((elem) => {
      const coordinates = elem.geometry.coordinates;
      const type = elem.geometry.type;

      function compare (point) {
        maxX === undefined
          ? (maxX = point[0])
          : (maxX = point[0] > maxX ? point[0] : maxX);
        maxY === undefined
          ? (maxY = point[1])
          : (maxY = point[1] > maxY ? point[1] : maxY);
        minX === undefined
          ? (minX = point[0])
          : (minX = point[0] > minX ? minX : point[0]);
        minY === undefined
          ? (minY = point[1])
          : (minY = point[1] > minY ? minY : point[1]);
      }

      if (type === "MultiPolygon") {
        coordinates.forEach((multiPolygon) => {
          multiPolygon.forEach((polygon) => {
            polygon.forEach((point) => {
              compare(point);
            });
          });
        });
      } else {
        coordinates.forEach((polygon) => {
          polygon.forEach((point) => {
            compare(point);
          });
        });
      }
    });
    const xScale = window.innerWidth / (maxX - minX);
    const yScale = window.innerHeight / (maxY - minY);
    return {
      center: [(maxX + minX) / 2, (maxY + minY) / 2],
      scale: Math.min(xScale, yScale),
    };
  }
  async loadData(adcode) {
    // 获取geojson数据
    this.geojson = await this.getGeoJson(adcode)

    const { center, scale } = this.getCenter()
    
    // 创建墨卡托投影
    this.projection = d3
      .geoMercator()
      .center(center)
      .translate([0, 0])
      .scale(scale * 7) // 根据实测,系数7差不多刚好
    
    // Object3D是Three.js中大部分对象的基类,提供了一系列的属性和方法来对三维空间中的物体进行操纵。
    // 初始化一个地图
    this.map = new THREE.Object3D();
    this.geojson.features.forEach(elem => {
      const area = new THREE.Object3D()
      // 坐标系数组(为什么是数组,因为有地区不止一个几何体,比如河北被北京分开了,比如舟山群岛)
      const coordinates = elem.geometry.coordinates
      const type = elem.geometry.type

      // 定义一个画几何体的方法
      const drawPolygon = (polygon) => {
        // Shape(形状)。使用路径以及可选的孔洞来定义一个二维形状平面。 它可以和ExtrudeGeometry、ShapeGeometry一起使用,获取点,或者获取三角面。
        const shape = new THREE.Shape()
        // 存放的点位,最后需要用THREE.Line将点位构成一条线,也就是地图上区域间的边界线
        // 为什么两个数组,因为需要三维地图的两面都画线,且它们的z坐标不同
        let points1 = [];
        let points2 = [];

        for (let i = 0; i < polygon.length; i++) {
          // 将经纬度通过墨卡托投影转换成threejs中的坐标
          const [x, y] = this.projection(polygon[i]);
          // 画二维形状
          if (i === 0) {
            shape.moveTo(x, -y);
          }
          shape.lineTo(x, -y);

          points1.push(new THREE.Vector3(x, -y, 10));
          points2.push(new THREE.Vector3(x, -y, 0));
        }

        /**
         * ExtrudeGeometry (挤压缓冲几何体)
         * 文档链接:https://threejs.org/docs/index.html?q=ExtrudeGeometry#api/zh/geometries/ExtrudeGeometry
         */
        const geometry = new THREE.ExtrudeGeometry(shape, {
          depth: 10,
          bevelEnabled: false,
        });
        /**
         * 基础材质
         */
        // 正反两面的材质
        const material1 = new THREE.MeshBasicMaterial({
          color: MATERIAL_COLOR1,
        });
        // 侧边材质
        const material2 = new THREE.MeshBasicMaterial({
          color: MATERIAL_COLOR2,
        });
        // 生成一个几何物体(如果是中国地图,那么每一个mesh就是一个省份几何体)
        const mesh = new THREE.Mesh(geometry, [material1, material2]);
        area.add(mesh);

        /**
         * 画线
         * link: https://threejs.org/docs/index.html?q=Line#api/zh/objects/Line
         */
        const lineGeometry1 = new THREE.BufferGeometry().setFromPoints(points1);
        const lineGeometry2 = new THREE.BufferGeometry().setFromPoints(points2);
        const lineMaterial = new THREE.LineBasicMaterial({ color: 0xffffff });
        const line1 = new THREE.Line(lineGeometry1, lineMaterial);
        const line2 = new THREE.Line(lineGeometry2, lineMaterial);
        area.add(line1);
        area.add(line2);

        // 把地区属性存到area对象中
        area.properties = elem.properties
      }

      // type可能是MultiPolygon 也可能是Polygon
      if (type === "MultiPolygon") {
        coordinates.forEach((multiPolygon) => {
          multiPolygon.forEach((polygon) => {
            drawPolygon(polygon);
          });
        });
      } else {
        coordinates.forEach((polygon) => {
          drawPolygon(polygon);
        });
      }

      // 把区域添加到地图中
      this.map.add(area);

    })

    // 把地图添加到场景中
    this.scene.add(this.map)
  }
  dispose (o) {
    // 可以遍历该父场景中的所有子物体来执行回调函数
    o.traverse(child => {
      if (child.geometry) {
        child.geometry.dispose()
      }
      if (child.material) {
        if (Array.isArray(child.material)) {
          child.material.forEach(material => {
            material.dispose()
          })
        } else {
          child.material.dispose()
        }
      }
    })
    o.parent.remove(o)
  }

  // 添加监听事件
  on (eventName, callback) {
    const fnName = `${eventName}_fn`;
    if (!this.callbackStack.get(eventName)) {
      this.callbackStack.set(eventName, new Set());
    }
    if (!this.callbackStack.get(eventName).has(callback)) {
      this.callbackStack.get(eventName).add(callback);
    }
    if (!this.callbackStack.get(fnName)) {
      this.callbackStack.set(fnName, (e) => {
        this.callbackStack.get(eventName).forEach((cb) => {
          if (this.lastPick) cb(e, this.lastPick);
        });
      });
    }
    window.addEventListener(eventName, this.callbackStack.get(fnName));
  }

  // 移除监听事件
  off (eventName, callback) {
    const fnName = `${eventName}_fn`;
    if (!this.callbackStack.get(eventName)) return;

    if (this.callbackStack.get(eventName).has(callback)) {
      this.callbackStack.get(eventName).delete(callback);
    }
    if (this.callbackStack.get(eventName).size < 1) {
      window.removeEventListener(eventName, this.callbackStack.get(fnName));
    }
  }
}

const map = new Map3D()

map.on('click', listener)

function listener(e, data) {
  // 区域编码
  const adcode = data.object.parent.properties.adcode

  if(adcode) {
    map.dispose(map.map)
    map.loadData(adcode)
  }
}
展开阅读全文

页面更新:2024-04-19

标签:几何体   地图   数组   坐标   物体   对象   相机   事件   方法   数据

1 2 3 4 5

上滑加载更多 ↓
推荐阅读:
友情链接:
更多:

本站资料均由网友自行发布提供,仅用于学习交流。如有版权问题,请与我联系,QQ:4156828  

© CopyRight 2020-2024 All Rights Reserved. Powered By 71396.com 闽ICP备11008920号-4
闽公网安备35020302034903号

Top