基于ThreeJs的大屏3D地图(二)——气泡图、渐变柱体与热力图
前提
上一篇文章中我们完成了地图区块模型的渲染,在此基础之上本篇来讲解气泡图、3D柱形图以及3D热力图的实现方式。
首先,为了更好的关注点分离及与地图渲染模块的解耦,我们可以把所有类型的可视化元素抽象出一个图层基类BaseLayer:
/**
* 图层基类
*/
abstract class BaseLayer {
map: ThreeMap
uuid: string
cfg: any
setting: LayerSetting
/**
* 初始化时机
*/
abstract init(initCfg): void
/**
* 每帧更新函数
*/
abstract update(timeStamp): void
/**
* 销毁函数
*/
abstract destroy(): void
/**
* 显示图层
*/
abstract show(): void
/**
* 隐藏图层
*/
abstract hide(): void
/*
*
*/
// ......
}
其中ThreeMap类型为上一篇中所实现的地图实例;LayerSetting为图层的配置内容。
图层的更新、销毁等生命周期交由地图/使用方接管。
class Threemap {
constructor() {
/* ... */
const animate = (timeStamp) => {
/* 更新图层 */
this.layers.forEach(layer => {
layer.update(timeStamp)
})
this.requestID = requestAnimationFrame(animate)
}
/* ... */
}
/*
......
*/
public addLayer(layer: BaseLayer, initCfg?) {
this.layers.push(layer)
layer.map = this
layer.init(initCfg)
}
public removeLayer(layer: BaseLayer) {
const idx = this.layers.findIndex(curLayer => curLayer === layer)
if(idx >= 0) {
this.layers.splice(idx, 1)
}
layer.destroy()
}
public clearLayer() {
this.layers.forEach(layer => {
layer.destroy()
})
this.layers = []
}
}
气泡图
气泡包括散点和作扩散动画的波纹两部分组成。其中散点的半径大小通常是由该点所对应值的大小计算而来:
type RenderData = {
name: string
value: [number, number, number]
}
scatterGroup: Group
circleGroup: Group
private createFeatures() {
this.clear()
const { minDiameter, maxDiameter } = this.setting
this.data.forEach((item: RenderData) => {
/* 计算气泡大小 */
const t = item.value[2] / this.maxValue
const diameter = lerp(minDiameter, maxDiameter, t), radius = diameter / 2
/* 散点 */
const scatter = this.createScatter(item, radius)
this.scatterGroup.add(scatter)
/* 波纹 */
const circles = this.createCircle(item, radius)
this.circleGroup.add(circles)
})
}
创建散点
private createScatter(item: RenderData, radius) {
const { color, opacity } = this.setting
const [x, y] = item.value
const material = new MeshBasicMaterial({ color, opacity, transparent: true }), geometry = new CircleGeometry(radius, 50)
const mesh = new Mesh(geometry, material)
mesh.position.set(x, y, this.map.cfg.depth + 1)
mesh.name = item.name
mesh.userData = {/* */}
return mesh
}
创建波纹
通过Curve类曲线来绘制圆圈生成Line需要的几何体。常量Circle_Count用来指定一个散点上的扩散动画中的波纹数量,同组的波纹用一个Group来组织:
const Circle_Count = 3
private createCircle(item: RenderData, radius) {
const { color } = this.setting
const [x, y] = item.value
const arc = new ArcCurve(0, 0, radius)
const points = arc.getPoints(50)
const geometry = new BufferGeometry().setFromPoints(points)
const circlesGroup = new Group()
for(let i = 0; i < Circle_Count; ++i) {
const material = new LineBasicMaterial({ color, opacity: 1, transparent: true })
const circle = new Line(geometry, material)
circle.position.set(x, y, this.map.cfg.depth + 1)
circlesGroup.add(circle)
}
return circlesGroup
}

波纹动画
波纹的扩散动画我们就参照Echarts的地图气泡图来:每一个散点的外围会同时存在Circle_Count个波纹,随着时间推移波纹半径会逐步增大,且透明度也逐步增大,在达到指定的最大半径时透明度也趋近于0最终消失;接着从又立即生成一个同散点大小一致的新波纹。这些波纹互相之间的间距和生成时间都是”等距“的。
在图层的update函数中调用一个animateCircles函数。在该函数中实现根据传入的时间戳更新每个波纹的大小及透明度:
update(timeStamp): void {
/* ... */
this.animateCircles(timeStamp)
}
再定义如下常量:Circle_Max_Scale指定波纹的半径扩散到多大时消失,Circle_Remain_Time指定波纹从出生到消失的持续时间。
const Circle_Count = 3, // 波纹数量
Circle_Max_Scale = 2, // 波纹扩散的最大半径
Circle_Remain_Time = 3000, // 单次动画的生命周期
Circle_Unit_Step = 1 / Circle_Count // 步进单位
虽然知道了波纹扩散的持续时间和变化的半径区间,接下来就需要计算在某一时刻下各个波纹的状态。
以Circle_Remain_Time作为一个周期的话,用当前的时间戳取余它,得到的就是此时刻动画所已经历的时间curLifeTime。由前文知道各个波纹之间的进度是等距的,那么以1为单个周期则每个波纹在进度上的间隔就是Circle_Unit_Step = 1 / Circle_Count。
从下标0开始,对于第i个波纹,它当前所处的进度就是:step = curLifeTime / Circle_Remain_Time + i * Circle_Unit_Step。这个step值是可能大于1的,因此还需要再对1做一次取余,应用取余1后的值相当于重新从散点处开始扩散;这么一来就能不断复用这Circle_Count个波纹无需重新创建。最后再基于得出step值设置波纹此时的大小scale和透明度opacity:
private animateCircles(timeStamp) {
this.circleGroup.children.forEach(circles => {
for(let i = 0; i < Circle_Count; ++i) {
const circle = circles.children[i] as Line
let step: number, scale: number, material = circle.material as Material
const curLifeTime = timeStamp % Circle_Remain_Time
step = (curLifeTime / Circle_Remain_Time + i * Circle_Unit_Step) % 1
scale = lerp(1, Circle_Max_Scale, step)
circle.scale.set(scale, scale, 1)
material.opacity = 1 - lerp(0, 1, step)
}
})
}

3D渐变柱体
创建柱体
3D柱体的形状我们支持四种常见的类型:四方体、圆柱、三角柱和六角柱。圆柱和四方体可以通过three.js内置的CylinderGeometry和BoxGeometry来创建几何体。三角柱和六角柱则可以先使用Shape绘制平面等边三角形/六边形,接着通过ExtrudeGeometry来挤压出立体形状:
private createColumn(shape: string, pos: number[], height: number, userData: any) {
const { width, color } = this.setting
const [x, y] = pos
let geometry: BufferGeometry
switch(shape) {
case 'cylinder': // 圆柱体
geometry = new CylinderGeometry(width / 2, width / 2, height)
geometry.rotateX(Math.PI / 2)
geometry.translate(0, 0, height / 2)
break
case 'triangleColumn': // 三角柱
{
const x = pos[0], y = pos[1], halfWidth = width / 2, h = Math.sqrt(3) / 2 * width
const vertices = [
[x, y - h / 2],
[x - halfWidth, y + h / 2],
[x + halfWidth, y + h / 2]
]
const shape = new Shape()
vertices.forEach((v, index) => {
if(!index) shape.moveTo(v[0], v[1])
else shape.lineTo(v[0], v[1])
})
geometry = new ExtrudeGeometry(shape, {
depth: height,
bevelEnabled: false
})
geometry.translate(0, 0, this.map.cfg.depth)
}
break
case 'hexagonColumn': // 六角柱
{
const x = pos[0], y = pos[1], halfWidth = width / 2, h = Math.sqrt(3) / 2 * halfWidth
const vertices = [
[x - halfWidth, y],
[x - halfWidth / 2, y + h],
[x + halfWidth / 2, y + h],
[x + halfWidth, y],
[x + halfWidth / 2, y - h],
[x - halfWidth / 2, y - h]
]
const shape = new Shape()
vertices.forEach((v, index) => {
if(!index) shape.moveTo(v[0], v[1])
else shape.lineTo(v[0], v[1])
})
geometry = new ExtrudeGeometry(shape, {
depth: height,
bevelEnabled: false
})
geometry.translate(0, 0, this.map.cfg.depth)
}
break
case 'squareColumn': // 四方体
geometry = new BoxGeometry(width, width, height)
geometry.translate(0, 0, height / 2)
break
}
const material = new MeshBasicMaterial({ color })
const mesh = new Mesh(geometry, material)
if(['cylinder', 'squareColumn'].includes(shape)) {
mesh.position.set(x, y, this.map.cfg.depth)
}
mesh.userData = userData
return mesh
}
CylinderGeometry和BoxGeometry的原心都默认位于几何中心,为了方便定位笔者这里还调用geometry的translate和rotateX方法将原心移动到几何体的底面中心。

添加材质与光照
目前我们的柱体使用的都是MeshBasicMaterial材质,因此渲染出来的柱体表面都是纯色的。为了给柱体增添立体感,我们需要为场景中的物体打光并使用能够接受光照计算的材质。
添加一个环境光AmbientLight赋予所有物体一个最低的亮度以及一个平行光DirectionalLight使得某些面得到高亮:
ambientLight: AmbientLight
directLight: DirectionalLight
private createLight() {
this.ambientLight = new AmbientLight(0xFFFFFF, 1.8)
this.map.scene.add(this.ambientLight)
this.directLight = new DirectionalLight(0xFFFFFF, 2)
this.directLight.position.set(1, -3, 2)
this.directLight.target.position.set(0, 0, 0)
this.map.scene.add(this.directLight)
this.map.scene.add(this.directLight.target)
}
材质上选择基于兰伯特光照模型的MeshLambertMaterial:
const material = new MeshLambertMaterial({ color })

渐变色
要实现柱体的渐变色可以有很多种方法,既可以采用在JS层面计算好顶点颜色后传入attributes.color来达到渐变效果,也可以通过覆写材质的shader代码来实现更高效的渲染。
考虑到在不同类型的柱体上其几何体attributes.uv和attributes.normal的差异性,需要分别做额外处理才能实现正确的shader计算,笔者这里采用了更为通用的方法,让材质允许使用顶点颜色数据(vertexColors)渲染来实现渐变效果。
顶点着色
在材质对象上设置vertexColors: true,使其允许使用顶点颜色数据:
const material = new MeshLambertMaterial({ vertexColors: true })
通过geometry.attributes.position中的count和array变量可以访问到该几何体的顶点数量及位置。建立color缓冲区并遍及所有的顶点,依据顶点坐标中的z值与柱体高度height计算得出该处的颜色:
private createColumn(idx: number, shape: string, material: Material, pos: number[], height: number, userData: any) {
const { colorGradient, topColor, bottomColor } = this.setting
/*
......
*/
if(colorGradient) {
const colors = [], posLen = geometry.attributes.position.count
for(let i = 0, pIdx = 2; i < posLen; ++i, pIdx+=3) {
const step = geometry.attributes.position.array[pIdx] / height // 计算步进值
const curColor = interpolate(bottomColor, topColor, step) // 插值计的颜色
const colorVector = getRGBAScore(curColor)
colors.push(colorVector[0], colorVector[1], colorVector[2])
}
geometry.attributes.color = new BufferAttribute(new Float32Array(colors), 3)
}
/*
......
*/
}
色彩空间处理
假设我们在上述代码中使用如下的颜色进行测试查看实际的渲染效果:


渲染出来后会发现柱体的顶端和底端与对应的topColor和bottomColor并不匹配。之所以造成这种问题的原因在于:通过attributes.color传入的顶点颜色,是直接使用css颜色的r、g、b三个分量除以255归一化到[0-1]区间的。但渲染管线是在线性颜色空间下工作的,而前端中日常使用的CSS颜色、纹理贴图的颜色信息都是使用的sRGB颜色空间。我们向color缓冲区注入的颜色数据会被直接用到shader计算中而缺失了颜色空间转换这一步,这才导致了我们看到的渲染颜色不一致。
因此在传入color前可以借助第三方库或者ThreeJS内置的Color.convertSRGBToLinear转换一次颜色空间:
const color = new Color(colorVector[0], colorVector[1], colorVector[2])
color.convertSRGBToLinear()
colors.push(color.r, color.g, color.b)
// or:通过ThreeJS的Color对象完成所有颜色相关的操作,直接new Color(css颜色字符串)构造的颜色对象会自行做处理
// const color = new Color(bottomColor).lerp(new Color(topColor), step)
// colors.push(color.r, color.g, color.b)

3D热力图
在canvas上绘制2D黑白热力图
首先创建一个Canvas画布。根据渲染的地图坐标范围创建对应宽高的画布后,为了使得热力点的坐标系与画布匹配,调用translate(width / 2, height / 2)来将原点移至画布中心:
private generateCanvas() {
const width = this.sideInfo.sizeX, height = this.sideInfo.sizeY
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
ctx.translate(width / 2, height / 2)
}
接着就可以在这个画布上绘制如下黑白渐变的半透明圆形了:

Draw_Circle_Radisu = 150
/**
* 根据value值绘制渐变圆圈
*/
private drawCircle(ctx: CanvasRenderingContext2D, x: number, y: number, alpha: number) {
const grad = ctx.createRadialGradient(x, y, 0, x, y, this.Draw_Circle_Radisu)
grad.addColorStop(0.0, 'rgba(0,0,0,1)')
grad.addColorStop(1.0, 'rgba(0,0,0,0)')
ctx.fillStyle = grad
ctx.beginPath()
ctx.arc(x, y, this.Draw_Circle_Radisu, 0, 2 * Math.PI)
ctx.closePath()
ctx.globalAlpha = alpha
ctx.fill()
}
绘制时使用的全局透明度由热力点的数值计算而来,将所有热力点绘制到canvas画布后就是下面这样:
this.data.forEach((item: RenderData) => {
const [x, y, value] = item.value
const alpha = (value - this.sideInfo.minValue) / this.sideInfo.sizeValue
this.drawCircle(ctx, x, y, alpha)
})

转换至彩色热力图
将canvas画布上的所有像素信息通过getImageData方法提取出来,其返回的数组中每四个下标表示一个像素的r/g/b/alpha值。根据alpha值计算出每一个像素点所对应色阶中的颜色值,最后覆盖回画布上:
type DivisionSetting = {
pStart: number
pEnd: number
color: [r: number, g: number, b: number]
}
/* 根据透明度上色 */
const imageData = ctx.getImageData(0, 0, width, height)
const divisionSetting: DivisionSetting[] = this.setting.divisionSetting
for(let i = 3; i < imageData.data.length; i += 4) {
const alpha = imageData.data[i], step = alpha / 255
let idx
for(let i = 0; i < this.divisionSetting.length; ++i) {
if(this.divisionSetting[i].pStart <= step && step <= this.divisionSetting[i].pEnd) {
idx = i
break
}
}
if(idx === undefined) return
imageData.data[i - 3] = this.divisionSetting[idx].color[0]
imageData.data[i - 2] = this.divisionSetting[idx].color[1]
imageData.data[i - 1] = this.divisionSetting[idx].color[2]
}
ctx.putImageData(imageData, 0, 0)

渲染3D效果
创建一个平面几何PlaneGeometry并将上面的canvas作为纹理贴图赋予给它,就能得到一张2D的彩色热力图了。而要让热力图呈现出立体效果的关键点就在于自定义shader使得平面上的顶点高度依据纹理贴图的透明度来推算得出。
const map = new CanvasTexture(canvas)
const geometry = new PlaneGeometry(this.sideInfo.sizeX, this.sideInfo.sizeY, 500, 500)
const material = new ShaderMaterial({
vertexShader: VertexShader,
fragmentShader: FragmentShader,
transparent: true,
side: DoubleSide,
uniforms: {
map: { value: map },
uHeight: { value: 10 } // 最大高度
}
})
const plane = new Mesh(geometry, material)
plane.position.set(0, 0, this.map.cfg.depth + 0.5)
plane.renderOrder = 1 // 保证渲染顺序不与地图模型上的半透明材质冲突
this.map.scene.add(plane)
const VertexShader = `
${ShaderChunk.logdepthbuf_pars_vertex}
bool isPerspectiveMatrix(mat4) {
return true;
}
uniform sampler2D map;
uniform float uHeight;
varying vec2 v_texcoord;
void main(void)
{
v_texcoord = uv;
float h = texture2D(map, v_texcoord).a * uHeight;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position.x, position.y, h, 1.0 );
${ShaderChunk.logdepthbuf_vertex}
}
`
const FragmentShader = `
${ShaderChunk.logdepthbuf_pars_fragment}
precision mediump float;
uniform float uOpacity;
uniform sampler2D map;
varying vec2 v_texcoord;
void main (void)
{
vec4 color = texture2D(map, v_texcoord);
gl_FragColor.rgb = color.rgb;
gl_FragColor.a = min(color.a * 1.2, 1.0);
${ShaderChunk.logdepthbuf_fragment}
}
`
为材质中的uniforms.uHeight变量添加动画效果使其随时间从0缓慢增长到最大高度,以此观察热力图从平面转换到3D的过程:
uniforms: {
/* ... */
uHeight: { value: 0 }
}
setInterval(() => {
material.uniforms.uHeight.value = material.uniforms.uHeight.value + 0.5
}, 100)

基于ThreeJs的大屏3D地图(二)——气泡图、渐变柱体与热力图的更多相关文章
- vue+echarts可视化大屏,全国地图下钻,页面自适应
之前写过一篇关于数据大屏及地图下钻的文章 https://www.cnblogs.com/weijiutao/p/13977011.html ,但是存在诸多问题,如地图边界线及行政区划老旧,无法自适应 ...
- 基于Docker搭建大数据集群(二)基础组件配置
主要内容 jdk环境搭建 scala环境搭建 zookeeper部署 mysql部署 前提 docker容器之间能免密钥登录 yum源更换为阿里源 安装包 微云分享 | tar包目录下 JDK 1.8 ...
- 【实时数仓】Day06-数据可视化接口:接口介绍、Sugar大屏、成交金额、不同维度交易额(品牌、品类、商品spu)、分省的热力图 、新老顾客流量统计、字符云
一.数据可视化接口介绍 1.设计思路 后把轻度聚合的结果保存到 ClickHouse 中后,提供即时的查询.统计.分析 展现形式:用于数据分析的BI工具[商业智能(Business Intellige ...
- threejs三维地图大屏项目分享
这是最近公司的一个项目.客户的需求是基于总公司和子公司的数据,开发一个数据展示大屏. 大屏两边都是一些图表展示数据,中间部分是一个三维中国地图,点击中国地图的某个省份,可以下钻到省份地图的展示. 地图 ...
- 基于 HTML5 的工业组态高炉炼铁 3D 大屏可视化
前言 在大数据盛行的现在,大屏数据可视化也已经成为了一个热门的话题.大屏可视化可以运用在众多领域中,比如工业互联网.医疗.交通.工业控制等等.将各项重要指标数据以图表.各种图形等形式表现在一个页面上, ...
- 基于 HTML + WebGL 结合 23D 的疫情地图实时大屏 PC 版
前言 2019年12月以来,湖北省武汉市陆续发现了多例肺炎病例,现已证实为一种新型冠状病毒感染引起的急性呼吸道传染病并蔓延全国,肺炎疫情牵动人心,人们每天起来第一件事变成了关注疫情进展,期望这场天灾早 ...
- 基于 HTML + WebGL 结合 23D 的疫情地图实时大屏 PC 版【转载】
前言 2019年12月以来,湖北省武汉市陆续发现了多例肺炎病例,现已证实为一种新型冠状病毒感染引起的急性呼吸道传染病并蔓延全国,肺炎疫情牵动人心,人们每天起来第一件事变成了关注疫情进展,期望这场天灾早 ...
- 使用Three.js实现炫酷的赛博朋克风格3D数字地球大屏 🌐
声明:本文涉及图文和模型素材仅用于个人学习.研究和欣赏,请勿二次修改.非法传播.转载.出版.商用.及进行其他获利行为. 背景 近期工作有涉及到数字大屏的需求,于是利用业余时间,结合 Three.js ...
- Threejs 开发3D地图实践总结【转】
Threejs 开发3D地图实践总结 前段时间连续上了一个月班,加班加点完成了一个3D攻坚项目.也算是由传统web转型到webgl图形学开发中,坑不少,做了一下总结分享. 1.法向量问题 法线是垂 ...
- 使用webgl(three.js)搭建3D智慧园区、3D大屏,3D楼宇,智慧灯杆三维展示,3D灯杆,web版3D,bim管理系统——第六课
前言: 今年是建国70周年,爱国热情异常的高涨,为自己身在如此安全.蓬勃发展的国家深感自豪. 我们公司楼下为庆祝国庆,拉了这样的标语,每个人做好一件事,就组成了我们强大的祖国. 看到这句话,深有感触, ...
随机推荐
- axios 取消请求 (2023-10-10更新)
axios 文档 配置局部取消请求 这种相当于局部的取消请求,作用于单个请求中 import axios from 'axios' const source = axios.cancelToken.s ...
- Slate文档编辑器-TS类型扩展与节点类型检查
Slate文档编辑器-TS类型扩展与节点类型检查 在之前我们基于slate实现的文档编辑器探讨了WrapNode数据结构与操作变换,主要是对于嵌套类型的数据结构类型需要关注的Normalize与Tra ...
- PostgreSQL 的历史
title: PostgreSQL 的历史 date: 2024/12/23 updated: 2024/12/23 author: cmdragon excerpt: PostgreSQL 是一款功 ...
- pip 安装 Caused by SSLError(SSLError(1, '[SSL: WRONG_VERSION_NUMBER] wrong version number (_ssl.c:1123)'))
1.问题 1.1 问题截取 pip install redis -i https://pypi.tuna.tsinghua.edu.cn/simple Looking in indexes: http ...
- 龙哥量化:AI时代到来,百度的文心一言能AI选股吗,让AI写一个选股公式20日涨幅>=30%,AI弱智,垃圾。在通达信测试对比一下
如果您需要代写技术指标公式, 请联系我. 龙哥QQ:591438821 龙哥微信:Long622889 选股要求:20日涨幅>=30% 首先看我写的 HH:=HHV(H,20); LL:=LLV ...
- Qt音视频开发34-不同库版本不同位数的库和头文件的引用
一.前言 做开发过程中难免遇到需要引入第三方库的时候,而且需要在不同库版本.不同系统.不同位数下都需要.第三方的库版本众多,一般在大版本中的小版本都是兼容的,但是大版本不兼容,比如ffmpeg目前就有 ...
- IM通讯协议专题学习(三):由浅入深,从根上理解Protobuf的编解码原理
本文由码农的荒岛求生陆小风分享,为了提升阅读体验,进行了较多修订和排版. 1.引言 搞即时通讯IM方面开发的程序员,在谈到通讯层实现时,必然会提到网络编程.那么计算机网络编程中的一个非常基本的问题:到 ...
- 长连接网关技术专题(八):B站基于微服务的API网关从0到1的演进之路
本文由B站微服务技术团队资深开发工程师周佳辉原创分享. 1.引言 如果你在 2015 年就使用 B 站,那么你一定不会忘记那一年 B 站工作日选择性崩溃,周末必然性崩溃的一段时间. 也是那一年 B 站 ...
- 微服务实战系列(三)-springcloud、springboot及maven之间关系-copy
1 . 问题描述 随着springboot.springcloud的不断迭代升级,开发效率不断提升,越来越多的开发团队加入到spring的大军中,今天用通俗的语言,介绍下什么是springboot,s ...
- 深入理解第三范式(3NF):数据库设计中的重要性与实践
title: 深入理解第三范式(3NF):数据库设计中的重要性与实践 date: 2025/1/17 updated: 2025/1/17 author: cmdragon excerpt: 在数据库 ...