前提

上一篇文章中我们完成了地图区块模型的渲染,在此基础之上本篇来讲解气泡图、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内置的CylinderGeometryBoxGeometry来创建几何体。三角柱和六角柱则可以先使用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
}

CylinderGeometryBoxGeometry的原心都默认位于几何中心,为了方便定位笔者这里还调用geometrytranslaterotateX方法将原心移动到几何体的底面中心。

添加材质与光照

目前我们的柱体使用的都是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.uvattributes.normal的差异性,需要分别做额外处理才能实现正确的shader计算,笔者这里采用了更为通用的方法,让材质允许使用顶点颜色数据(vertexColors)渲染来实现渐变效果。

顶点着色

在材质对象上设置vertexColors: true,使其允许使用顶点颜色数据:

const material = new MeshLambertMaterial({ vertexColors: true })

通过geometry.attributes.position中的countarray变量可以访问到该几何体的顶点数量及位置。建立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)
} /*
......
*/
}

色彩空间处理

假设我们在上述代码中使用如下的颜色进行测试查看实际的渲染效果:

渲染出来后会发现柱体的顶端和底端与对应的topColorbottomColor并不匹配。之所以造成这种问题的原因在于:通过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地图(二)——气泡图、渐变柱体与热力图的更多相关文章

  1. vue+echarts可视化大屏,全国地图下钻,页面自适应

    之前写过一篇关于数据大屏及地图下钻的文章 https://www.cnblogs.com/weijiutao/p/13977011.html ,但是存在诸多问题,如地图边界线及行政区划老旧,无法自适应 ...

  2. 基于Docker搭建大数据集群(二)基础组件配置

    主要内容 jdk环境搭建 scala环境搭建 zookeeper部署 mysql部署 前提 docker容器之间能免密钥登录 yum源更换为阿里源 安装包 微云分享 | tar包目录下 JDK 1.8 ...

  3. 【实时数仓】Day06-数据可视化接口:接口介绍、Sugar大屏、成交金额、不同维度交易额(品牌、品类、商品spu)、分省的热力图 、新老顾客流量统计、字符云

    一.数据可视化接口介绍 1.设计思路 后把轻度聚合的结果保存到 ClickHouse 中后,提供即时的查询.统计.分析 展现形式:用于数据分析的BI工具[商业智能(Business Intellige ...

  4. threejs三维地图大屏项目分享

    这是最近公司的一个项目.客户的需求是基于总公司和子公司的数据,开发一个数据展示大屏. 大屏两边都是一些图表展示数据,中间部分是一个三维中国地图,点击中国地图的某个省份,可以下钻到省份地图的展示. 地图 ...

  5. 基于 HTML5 的工业组态高炉炼铁 3D 大屏可视化

    前言 在大数据盛行的现在,大屏数据可视化也已经成为了一个热门的话题.大屏可视化可以运用在众多领域中,比如工业互联网.医疗.交通.工业控制等等.将各项重要指标数据以图表.各种图形等形式表现在一个页面上, ...

  6. 基于 HTML + WebGL 结合 23D 的疫情地图实时大屏 PC 版

    前言 2019年12月以来,湖北省武汉市陆续发现了多例肺炎病例,现已证实为一种新型冠状病毒感染引起的急性呼吸道传染病并蔓延全国,肺炎疫情牵动人心,人们每天起来第一件事变成了关注疫情进展,期望这场天灾早 ...

  7. 基于 HTML + WebGL 结合 23D 的疫情地图实时大屏 PC 版【转载】

    前言 2019年12月以来,湖北省武汉市陆续发现了多例肺炎病例,现已证实为一种新型冠状病毒感染引起的急性呼吸道传染病并蔓延全国,肺炎疫情牵动人心,人们每天起来第一件事变成了关注疫情进展,期望这场天灾早 ...

  8. 使用Three.js实现炫酷的赛博朋克风格3D数字地球大屏 🌐

    声明:本文涉及图文和模型素材仅用于个人学习.研究和欣赏,请勿二次修改.非法传播.转载.出版.商用.及进行其他获利行为. 背景 近期工作有涉及到数字大屏的需求,于是利用业余时间,结合 Three.js ...

  9. Threejs 开发3D地图实践总结【转】

    Threejs 开发3D地图实践总结   前段时间连续上了一个月班,加班加点完成了一个3D攻坚项目.也算是由传统web转型到webgl图形学开发中,坑不少,做了一下总结分享. 1.法向量问题 法线是垂 ...

  10. 使用webgl(three.js)搭建3D智慧园区、3D大屏,3D楼宇,智慧灯杆三维展示,3D灯杆,web版3D,bim管理系统——第六课

    前言: 今年是建国70周年,爱国热情异常的高涨,为自己身在如此安全.蓬勃发展的国家深感自豪. 我们公司楼下为庆祝国庆,拉了这样的标语,每个人做好一件事,就组成了我们强大的祖国. 看到这句话,深有感触, ...

随机推荐

  1. 《Django 5 By Example》读后感

    一. 为什么选择这本书? 本人的工作方向为Python Web方向,想了解下今年该方向有哪些新书出版,遂上packt出版社网站上看了看,发现这本书出版时间比较新(2024年9月),那就它了. 从202 ...

  2. 使用 Web Compiler 2022+

    使用 Web Compiler 2022+ Web Compiler 2022+ for Visual Studio 2022 Web Compiler for Visual Studio 2019 ...

  3. 2024年1月Java项目开发指南4:IDEA里配置MYSQL

    提前声明:文章首发博客园(cnblogs.com/mllt) 自动"搬家"(同步)到CSDN,如果博客园中文章发生修改是不会同步过去的,所以建议大家到我的博客园中查看 前提条件: ...

  4. 【MyBatis】学习笔记10:添加功能获取自增的主键

    [Mybatis]学习笔记01:连接数据库,实现增删改 [Mybatis]学习笔记02:实现简单的查 [MyBatis]学习笔记03:配置文件进一步解读(非常重要) [MyBatis]学习笔记04:配 ...

  5. linux tc命令进行网络限速、丢包、延迟设置(简单使用)

    linux自带tc命令版本不是很低的linux系统都自带tc如果你的系统不带这个命令,建议使用类似括号中的命令进行安装 (yum -y install iproute) TC 中使用下列的缩写表示相应 ...

  6. mysql5.7配置文件详解

    8核心32G独立mysql服务器的配置文件如下: [client] port = 3306 socket = /data/mysql/mysql.sock [mysql] prompt = " ...

  7. Qt音视频开发38-ffmpeg视频暂停录制的设计

    一.前言 基本上各种播放器提供的录制视频接口,都是只有开始录制和结束录制两个,当然一般用的最多的也是这两个接口,但是实际使用过程中,还有一种可能需要中途暂停录制,暂停以后再次继续录制,将中间部分视频不 ...

  8. Qt编写地图综合应用44-悬浮工具条

    一.前言 百度地图内置了悬浮工具条,可以自行开启,包括离线地图也可以开启,用到了DrawingManager这个库,鼠标绘制工具条库,提供鼠标绘制点.线.面.多边形(矩形.圆)的编辑工具条的开源代码库 ...

  9. 在Quartz .NET的工作类中使用依赖注入

    Quartz .NET默认的Execute方法是不支持非空的构造函数的,所以.net core常用的构造函数依赖注入也搞不来,网上搜索一番搞定了这个问题. 解决方案简单来说就是自定义一个任务工厂,替换 ...

  10. PaperAssistant:使用Microsoft.Extensions.AI实现

    前言 上篇文章介绍了使用Semantic Kernel Chat Completion Agent实现的版本. 使用C#构建一个论文总结AI Agent 今天来介绍一下使用Microsoft.Exte ...