如何用 Three.js 做3D地图并超越 Echarts

5,322 阅读12分钟

总体预览

1.png

类似echarts的tooltip、 动画效果、下钻功能

newmkv_1.gif

功能点

  1. 基本的地理轮廓
  2. 交互实现
  3. 柱状图
  4. 柱体底部光圈
  5. icon 精灵标注
  6. html 3D 标注
  7. 动画特效

一、基本的地理轮廓

思路与步骤

  • 根据 three.js 的框架,搭建 3D 场景
  • 获取 geojson 数据,处理 geojson 数据
  • 创建合适的物体,加入到 3D 场景中

1、搭建 3d 场景

创建 three.js 四大天王

  • 场景 scene —— 创建 3D 世界
  • 相机 camera —— 模仿人眼看世界的位置、角度
  • 渲染器 renderer —— 将相机看到的世界渲染在 web 平面画布上
  • 控制器 orbitControls —— 控制相机位置,实现页面放大缩小、拖拽等功能

典型的代码如下

initScenes() {
    //第1步,Scene,初始化场景
    this.scene = new THREE.Scene();
    
    //第2步,Camera,初始化照相机,并摆好照相机的位置,之所以z轴变成-250,就是最先看到中国
    this.camera = new THREE.PerspectiveCamera(30, window.innerWidth / window.innerHeight, 1, 10000);
    this.camera.position.set(-450, 180, -600);
    
    //第3步,设置好渲染器
    this.renderer = new THREE.WebGLRenderer({
      //透明,设置整个canvas是否透明,true的话,会显示大背景颜色,false的话,会覆盖大背景颜色
      alpha: true,
      //抗锯齿,true的话,放大缩小后,线条更加圆润
      antialias: true,
    });
    //设置屏幕像素比
    this.renderer.setPixelRatio(window.devicePixelRatio);
    this.renderer.setSize(window.innerWidth, window.innerHeight);
    this.dom.appendChild(this.renderer.domElement);

    //第4步,设置css3d渲染器
    this.css3DRenderer = new CSS3DRenderer();
    this.css3DRenderer.setSize(window.innerWidth, window.innerHeight);
    this.css3DRenderer.domElement.style.position = 'absolute';
    this.css3DRenderer.domElement.style.top = '0';
    this.css3DRenderer.domElement.style.zIndex = '100';
    document.body.appendChild(this.css3DRenderer.domElement);
  }

注:上面渲染器用到了2种,一种是比较常规的 WebGLRenderer ,另外一种是 css3D 渲染器 CSS3DRenderer,CSS3DRenderer 主要是将自定义样式的 div 转化为 3D 世界中的平面,有别于 Three 精灵 的概念, 可以正反两面都可以看见,更加像 3D 世界里的物品,后面会介绍到。

2、获取 geojson 数据

做地理可视化项目,免不了要 geojson 数据,这些数据,有的可以直接在网上找,例如中国地图的 geojson 数据,格式如下:

1.png

以中国 geojson 为例,features 其实就是各个省或者直辖市的地理信息,每个省或者直辖市的边界线数据存放在 coordinates 这个列表里面,而 coordinates 又包括了各个边界线围起来的圈圈,每个圈圈都是由一系列的经纬度的点构成,每个点就是一个数组,这个数组包含 2个元素,一个代表经度,一个代表维度,所以获取到 geojson 数据之后,就要将这些 边界线围起来的圈圈处理一下。

3、根据 geojson 数据,往 3D 世界添加物品

思路

  • 地理边界线通过 Line 这种形式加入到 3D 世界中
  • 地理板块通过 ExtrudeGeometry 这种形式,将边界线围成的平面撑开一定的厚度,成为 3D 的物品,并加入到 3D 世界中

准备工作

  • 需要了解如何将经纬度数据,转为3维空间的坐标数据
  • 这里会涉及到一个概念 —— 墨卡托投影转
  • 投影的相关理论,可以参考知乎这篇文章

 https://zhuanlan.zhihu.com/p/326955505 

实际操作中,不需要自己去写一个算法来实现这个坐标转换,只需要借助 d3 这个库实现就可以,典型代码如下:

const fun = d3.geoMercator().center([104.0, 37.5]).translate([0, 0]);
const [x, y] = this.projection(points[i]);

上面这2行代码,就可以将经纬度坐标 points[i] (格式如 [112.11, 30.45])转化成空间坐标 (x, -y, z),z 这个分量,可以自己随便给个固定的值,一般代表地图的 “厚度” 。

利用 shape 、ExtrudeGeometry 创建省份轮廓、省份板块。

首先省份的轮廓是一条曲线,曲线是由点构成的,点是一个数组 [m, n] ,其中 m 和 n 代表经纬度。根据上面的分析,利用投影算法,就可以将经纬度点 (m, n) 转化为三维空间的点 (x, y, z) 。 three.Shape 可以将众多的点连起来,形成一个平面(这些点的 z 都是一样的,所以都是在同一个平面上),还可以将这些点保存在一个数组 vertices 中。

(1)shape 将轮廓点连起来
for (let i = 0; i < oneCircle.length; i++) {
  const length = oneCircle[i].length;
  const [x, y] = this.projection(oneCircle[i]);
  if (i === 0) {
    shape.moveTo(x, -y);
  }
  shape.lineTo(x, -y);
  vertices.push(x, -y, this.mapStyle.deep);
}
(2)用 Line 对象生成省份的轮廓线条
const lineGeometry = new BufferGeometry();
lineGeometry.setAttribute("position", new BufferAttribute(new Float32Array(vertices), 3));
const lineMaterial = new LineBasicMaterial({
    color: new Color(this.mapStyle.lineColor),
});
const line = new Line(lineGeometry, lineMaterial);
(3)生成省份板块

综上所述,three.Shape 可以将众多的点连起来,形成一个平面,但是这个平面没有厚度,可以给它挤压一定的厚度,形成 3D 的省份板块,这个板块的正面和侧面的颜色可以不同,方法就是用不同颜色的材质来区分。

const extrudeSettings = {
  depth: 10,
  bevelEnabled: false,
};
const geometry = new ExtrudeGeometry(
  shape,
  extrudeSettings
);
const material = new MeshBasicMaterial({
  color: new Color(this.mapStyle.planeColor),
  transparent: true,
  opacity: 0.8,
});
material.needsUpdate = true;
const sideMaterial = new MeshBasicMaterial({
  color: new Color(this.mapStyle.sideColor),
  transparent: true,
  opacity: 0.5,
});
sideMaterial.needsUpdate = true;
const mesh = new Mesh(geometry, [material, sideMaterial]);

二、交互实现

需求

  • 用户将光标移动到某个省上面,希望这个省颜色高亮一下
  • 某个省 hover 状态下,希望出现类似 echarts 那样的 tooltip
  • 点击某个省的时候,做出一些响应事件

这时候,就需要用到 射线追踪、射线拾取。three.js 这个框架中,实现点击事件,hover 事件,只能通过 射线(Ray) 与 射线拾取(Raycaster) 。

1、创建射线

//创建映射用,用于保存映射结果的顶点
let raycaster = new THREE.Raycaster();  
let mouse = new THREE.Vector2();

2、监听页面的点击事件

//创建射线对象
let raycaster = new THREE.Raycaster();
//创建映射用,用于保存映射结果的顶点
let mouse = new THREE.Vector2();
renderer.domElement.addEventListener('click',e =>{
  //获取鼠标点击的位置
  let x = e.clientX;
  let y = e.clientY;
  //我们最终点击的位置,要用映射的方式传给射线,射线根据计算的比例,计算出实际发射射线的方向
  mouse.x = ( e.clientX / window.innerWidth ) * 2 - 1;
  mouse.y = - ( e.clientY / window.innerHeight ) * 2 + 1;
})

11.png

我们点击的位置,一般不是三维空间中的实际位置,所以需要将点击位置转化为 -1 至 1 的坐标中,再通过照相机往这个方向发射一条射线,看击中那些物体,再响应相关的事件。

2.png

3、创建射线,并拾取物体

//创建射线对象
let raycaster = new THREE.Raycaster();
//创建映射用,用于保存映射结果的顶点
let mouse = new THREE.Vector2();
renderer.domElement.addEventListener('click', e => {
  //获取鼠标点击的位置
  let x = e.clientX;
  let y = e.clientY;
  mouse.x = (x / window.innerWidth ) \* 2 - 1;
  mouse.y = - ( y / window.innerHeight ) \* 2 + 1;
  //使用当前相机和映射点修改当前射线属性
  raycaster.setFromCamera(mouse,camera);
  //计算物体和射线的交点
  let intersects = raycaster.intersectObjects( scene.children );
  console.log(intersects);
})

以上代码中,射线拾取到的物体会放在集合 intersects 中,实际上射线击中物体后,可能还是再击中其它物体,如果想要减少遍历的个数,可以在 intersectObjects 这个方法中,传递待需要响应点击事件的物体。

//创建一个待点击的物品集合
this.clickMesh = [];
//将需要响应点击事件的物体,加入以上集合中
this.clickMesh.push(mesh1);
//遍历拾取的物体,做响应
const intersects = this.raycaster.intersectObjects(this.clickMesh);
if(intersects && intersects.length > 0){
  const firstObj = intersects[0];
  const message = firstObj.object.userData;
  this.option.callback(message);
} 

如果物体很多,不好区分,那么可以在物体中添加一些自定义的属性,如 id、name 等,如下:

//将这个精灵存放起来
sprite.userData['event_type'] = 'sprite';
sprite.userData['event_name'] = e.name;
this.clickMesh.push(sprite);

4、hover 提示框

hover 交互与上面的 click 交互类似,但是 hover 需要监听页面的 mousemove 事件,并记录下这个位置, 然后再 requestAnimationFrame 方法中,发射一条射线,看 hover 的位置击中了哪些物体, 典型的交互包括:

(1) hover 改变省份的颜色

这时候只需要将材质的颜色改变一下就可以了,见代码:

this.currentHoverMesh.object.material[0].color.set(this.mapStyle.planeColor);

(2) hover 加上 tooltip

首先在 html 页面上加上 tooltip 的 div,并隐藏起来

<div id="tooltip"></div>
#tooltip {
  position: absolute;
  z-index: 1000;
  background: rgba(50, 50, 50, 0.8);
  color: #ffffff;
  padding: 8px 10px;
  border-radius: 2px;
  visibility: hidden;
  cursor: pointer;
}

监听页面的 mousemove 事件,及时改变 tooltip 的位置

this.tooltip = document.getElementById('tooltip');
//鼠标移动记录位置,注意这个如果换成renderer,反而无法触发相关的事件,想一下这里面的原因
this.css3DRenderer.domElement.addEventListener('mousemove', e => {
  const x = e.clientX / window.innerWidth * 2 - 1;
  const y = -1 * (e.clientY / window.innerHeight) * 2 + 1;
  this.mouse.x = x;
  this.mouse.y = y;
  //更改div位置
  this.tooltip.style.left = e.clientX + 20 + 'px'
  this.tooltip.style.top = e.clientY + 5 + 'px'
})

最后,在请求帧动画 requestanimationframe 中进行射线拾取,如果 hover 的位置命中某个可以出现提示框的物体,就将这个 tooltip 显示出来,tooltip 里面展示的信息,来自 userData 这个属性,userData 允许 3D 物体创建时,给这个物体添加一些自定义的属性,方便后续响应相关的事件,代码如下:

const meshInfo = this.currentHoverMesh.object.userData['properties'];
this.tooltip.innerHTML = `
    <div>${meshInfo.name}<div>
    <div>${meshInfo.value} 万元<div>
`;
this.tooltip.style.visibility = 'visible';

注意:以上的 click 事件还是存在一定的问题,因为页面支持拖拽、放大缩小,这时候,拖拽的事件,也很可能被当成 click 事件来处理,实际上应该通过 mousedown、mouseup、mousemove 加上时间间隔,来区分拖拽事件、点击事件。

三、柱状图

3D 地图最大的特色,就是可以标注柱体,并通过柱体高度,直观做对比。

这里柱体用长方体模型就可以了,然后在材质里面,设置颜色,为了让柱体有渐变的光泽,可以在柱体内部加几个发光的平面,发光的平面,其实也就是用贴图来实现。

贴图如下:

1.png

1、创建柱体

//光柱长方体的材质
const material = new MeshBasicMaterial({
  color: 0x77fbf5,
  transparent: true,
  opacity: 0.7,
  depthTest: false,
  fog: false,
});
//创建光柱立方体(这时候,光柱被XOY平面平分成2部分)
const box = new BoxGeometry(1, 1, barHeight);
//让光柱的底部贴近XOY平面(往Z轴位移半截柱体的距离)
box.translate(0, 0, barHeight / 2);
//创建3D物体,并添加自定义属性properties,方便在hover的时候用到
const areaBar = new Mesh(box, material);

2、柱体加上自定义属性,并设置位置

areaBar.name = 'province_bar';
areaBar.userData['properties'] = {
  name: item.province,
  value: item.count
};
areaBar.position.set(x, y, z);

3、柱体内部加上几个发光平面

//柱体内部加上光平面
const lights = this.createBarLights(barHeight, 0xfffef4);
areaBar.add(...lights);
this.group.add(areaBar);
this.hoverMeshs.push(areaBar);

//柱体内部,加上几个平面
createBarLights(height: number, color: number) {
    const geometry = new PlaneGeometry(4, height);
    geometry.translate(0, height / 2, 0);
    const material = new MeshBasicMaterial({
      color: color,
      map: this.mapStyle.huiguangTexture,
      transparent: true,
      opacity: 0.4,
      depthWrite: false,
      side: DoubleSide,
      blending: AdditiveBlending,
    });
    const mesh = new Mesh(geometry, material);
    mesh.renderOrder = 10;
    mesh.rotateX(Math.PI / 2);
    const mesh2 = mesh.clone();
    const mesh3 = mesh.clone();
    mesh2.rotateY((Math.PI / 180) * 60);
    mesh3.rotateY((Math.PI / 180) * 120);
    return [mesh, mesh2, mesh3];
}

最后的效果

1.png

四、柱体底部光圈

这里光圈用到了 2个贴图

贴图1:

guangquan02.png

贴图2:

guangquan01.png

1.png

第一步: 创建外圈

const position = new Vector3(x, y, z);
const guangquan1 = this.mapStyle.guangquan01;
const geometry = new PlaneGeometry(5, 5);
const material1 = new MeshBasicMaterial({
  color: 0xffffff,
  map: guangquan1,
  alphaMap: guangquan1,
  opacity: 1,
  transparent: true,
  depthTest: false,
  fog: false,
  blending: AdditiveBlending,
  side: DoubleSide
});
const mesh1 = new Mesh(geometry, material1);
mesh1.position.copy(position);

注意: 由于贴图是黑白配色,所以用到法向量贴图 alphaMap, 注意 map 和

alphaMap 这两个属性在设置材质上的差别。

第二步:创建内圈

const position = new Vector3(x, y, z);
const guangquan2 = this.mapStyle.guangquan02;
const geometry = new PlaneGeometry(5, 5);
const material2 = new MeshBasicMaterial({
  color: 0xffffff,
  //map: guangquan2,
  alphaMap: guangquan2,
  opacity: 1,
  transparent: true,
  depthTest: false,
  fog: false,
  blending: AdditiveBlending,
  side: DoubleSide
});
const mesh2 = new Mesh(geometry, material2);
mesh2.position.copy(position);

第三步:将这两个圈组合起来,加入3D世界中

const quanGroup = new Group();
quanGroup.add(mesh1, mesh2);
this.group.add(quanGroup);

第四步:将需要转动的圈,保存起来

private barCircles: Object3D[];
this.barCircles = [];
this.barCircles.push(mesh1);

后续会加上动画特效。

五、icon 精灵标注

精灵

精灵是 three.js 中一个比较重要的概念,就是在 3D 世界中,无论用户怎么拖拽、翻转,精灵物体始终是面朝着用户,用来做简单的文本标注、icon标注是最合适的,同样,如果需要在地图上做 icon 标注,可以用精灵物体。

获取精灵贴图

1.png

const texture = this.mapStyle.pointTexture

创建精灵

const material = new SpriteMaterial({
    map: texture,
    color: colors[index % colors.length],
    fog: false,
    transparent: true,
    depthTest: false,
})
const sprite = new Sprite(material);

设置精灵位置

const size = 8;
const [x, y] = this.projection(p.center);
sprite.scale.set(size, size, size);
sprite.position.set(x, -y, this.mapStyle.deep + size / 3);

加入动画集合(非必选)

private animatedPoints: Object3D[];
this.animatedPoints = [];
this.animatedPoints.push(sprite);

最后的效果

1.png

六、html 3D 标注

场景:在3D世界中,普通的文本标注、icon标注可能满足不了复杂的需求,如果能将平面 div+css 渲染的效果也加入到 3D 世界中,那么3D世界将更加丰富,当然这是可以实现的,这里需要用到 CSS3DObject 。

创建 CSS3DObject 并加入 3D 世界

createWall(){
  const content = `
      <div class="country-cn">中国</div>
      <div class="country-en">CHINA</div>
    `;
  const tag = document.createElement("div");
  tag.innerHTML = content;
  tag.className = 'country-label';
  tag.style.position = "absolute";
  const label = new CSS3DObject(tag);
  label.scale.set(0.1, 0.1, 0.1);
  label.rotation.x = Math.PI / 2;
  label.position.set(15, -68, 2);
  this.group.add(label);
}

CSS 设置 CSS3DObject 的样式

<style>
  .country-label {
    border-radius: 30px 30px 30px 0px;
  }
  .country-label .country-cn {
    color: #fff;
    font-size: 60px;
    font-weight: 500;
    text-align: center;
  }
  .country-en{
    color: #a6d4ee;
    font-size: 35px;
    text-align: center;
    position: relative;
    top: -10px
  }
</style>

效果展示

1.png

同样,可以在柱体旁边添加牌匾,也是由 CSS3DObject 来实现的,可以见上图效果。

七、动画特效

底部光圈

光圈的贴图如下

1.png

2.png

创建光圈,用平面几何体 PlaneGeometry 加贴图材质即可,代码如下

createCirclePlane(){
    //创建第一个大圆环
    const radius1 = 130;
    const plane1 = new PlaneGeometry(radius1, radius1);
    const material1 = new MeshBasicMaterial({
      map: this.mapStyle.rotationBorder1,
      color: 0x2aa8ac,
      transparent: true,
      opacity: 0.2,
      side: DoubleSide,
      depthWrite: false,
      blending: AdditiveBlending,
    })

    const mesh1 = new Mesh(plane1, material1);
    mesh1.translateZ(-1);
    mesh1.translateX(10);
    this.group.add(mesh1);
    this.bigCirclePlane = mesh1;

    //创建第二个大圆环
    const radiu2 = 110;
    const plane2 = new PlaneGeometry(radiu2, radiu2);
    const material2 = new MeshBasicMaterial({
      map: this.mapStyle.rotationBorder2,
      color: 0x2aa8ac,
      transparent: true,
      opacity: 0.2,
      side: DoubleSide,
      depthWrite: false,
      blending: AdditiveBlending,
    })

    const mesh2 = new Mesh(plane2, material2);
    mesh2.translateZ(-1);
    mesh2.translateX(10);
    this.group.add(mesh2);
    this.smallCirclePlane = mesh2;
}

让光圈转动起来,也很简单,就是在 requestanimationframe 中,往 z 轴旋转的角度不断地递增

if(this.bigCirclePlane){
  this.bigCirclePlane.rotation.z += 0.01;
}
if(this.smallCirclePlane){
  this.smallCirclePlane.rotation.z -= 0.008;
}

光柱底部旋转

同样,在 requestanimationframe 中,往 Z轴旋转

if(this.barCircles && this.barCircles.length > 0){
  this.barCircles.forEach(circle => {
    circle.rotation.z += 0.08;
  })
}

icon标注上下跳动

上下来回跳动,符合周期性特征,这里用高中数学三角函数来处理就可以了,简单方便

if(this.animatedPoints && this.animatedPoints.length > 0){
  this.animatedPoints.forEach(mesh => {
    if(!mesh.userData['height']){
      mesh.userData['height'] = 0;
    }
    const height = mesh.userData['height'] + Math.sin(this.tickNumber * 0.1);
    mesh.userData['height'] = height;
    mesh.position.setZ(height * 0.2 + this.mapStyle.deep);
  })
}

big_gif.gif