总体预览
类似echarts的tooltip、 动画效果、下钻功能
功能点
- 基本的地理轮廓
- 交互实现
- 柱状图
- 柱体底部光圈
- icon 精灵标注
- html 3D 标注
- 动画特效
一、基本的地理轮廓
思路与步骤
- 根据 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 数据,格式如下:
以中国 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;
})
我们点击的位置,一般不是三维空间中的实际位置,所以需要将点击位置转化为 -1 至 1 的坐标中,再通过照相机往这个方向发射一条射线,看击中那些物体,再响应相关的事件。
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、创建柱体
//光柱长方体的材质
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];
}
最后的效果
四、柱体底部光圈
这里光圈用到了 2个贴图
贴图1:
贴图2:
第一步: 创建外圈
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 标注,可以用精灵物体。
获取精灵贴图
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);
最后的效果
六、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>
效果展示
同样,可以在柱体旁边添加牌匾,也是由 CSS3DObject 来实现的,可以见上图效果。
七、动画特效
底部光圈
光圈的贴图如下
创建光圈,用平面几何体 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);
})
}