基于ThreeJs的大屏3D地图(二)——气泡图、渐变柱体与热力图

news/2025/3/4 4:21:47/文章来源:https://www.cnblogs.com/geek1116/p/18745931

前提

上一篇文章中我们完成了地图区块模型的渲染,在此基础之上本篇来讲解气泡图、3D柱形图以及3D热力图的实现方式。

首先,为了更好的关注点分离及与地图渲染模块的解耦,我们可以把所有类型的可视化元素抽象出一个图层基类BaseLayer

/*** 图层基类*/
abstract class BaseLayer {map: ThreeMapuuid: stringcfg: anysetting: 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 = thislayer.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: stringvalue: [number, number, number]
}
scatterGroup: Group
circleGroup: Groupprivate createFeatures() {this.clear()const { minDiameter, maxDiameter } = this.settingthis.data.forEach((item: RenderData) => {/* 计算气泡大小 */const t = item.value[2] / this.maxValueconst 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.settingconst [x, y] = item.valueconst 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.namemesh.userData = {/* */}return mesh
}

创建波纹

通过Curve类曲线来绘制圆圈生成Line需要的几何体。常量Circle_Count用来指定一个散点上的扩散动画中的波纹数量,同组的波纹用一个Group来组织:

const Circle_Count = 3private createCircle(item: RenderData, radius) {const { color } = this.settingconst [x, y] = item.valueconst 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 Linelet step: number, scale: number, material = circle.material as Materialconst curLifeTime = timeStamp % Circle_Remain_Timestep = (curLifeTime / Circle_Remain_Time + i * Circle_Unit_Step) % 1scale = 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.settingconst [x, y] = poslet geometry: BufferGeometryswitch(shape) {case 'cylinder':        // 圆柱体geometry = new CylinderGeometry(width / 2, width / 2, height)geometry.rotateX(Math.PI / 2)geometry.translate(0, 0, height / 2)breakcase 'triangleColumn':  // 三角柱{const x = pos[0], y = pos[1], halfWidth = width / 2, h = Math.sqrt(3) / 2 * widthconst 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)}breakcase 'hexagonColumn':   // 六角柱{const x = pos[0], y = pos[1], halfWidth = width / 2, h = Math.sqrt(3) / 2 * halfWidthconst 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)}breakcase '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 = userDatareturn mesh
}

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

添加材质与光照

目前我们的柱体使用的都是MeshBasicMaterial材质,因此渲染出来的柱体表面都是纯色的。为了给柱体增添立体感,我们需要为场景中的物体打光并使用能够接受光照计算的材质。

添加一个环境光AmbientLight赋予所有物体一个最低的亮度以及一个平行光DirectionalLight使得某些面得到高亮:

ambientLight: AmbientLight
directLight: DirectionalLightprivate 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.countfor(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.sizeYconst canvas = document.createElement('canvas')canvas.width = widthcanvas.height = heightconst 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 = gradctx.beginPath()ctx.arc(x, y, this.Draw_Circle_Radisu, 0, 2 * Math.PI)ctx.closePath()ctx.globalAlpha = alphactx.fill()
}

绘制时使用的全局透明度由热力点的数值计算而来,将所有热力点绘制到canvas画布后就是下面这样:

this.data.forEach((item: RenderData) => {const [x, y, value] = item.valueconst alpha = (value - this.sideInfo.minValue) / this.sideInfo.sizeValuethis.drawCircle(ctx, x, y, alpha)
})

转换至彩色热力图

将canvas画布上的所有像素信息通过getImageData方法提取出来,其返回的数组中每四个下标表示一个像素的r/g/b/alpha值。根据alpha值计算出每一个像素点所对应色阶中的颜色值,最后覆盖回画布上:

type DivisionSetting = {pStart: numberpEnd: numbercolor: [r: number, g: number, b: number]
}/* 根据透明度上色 */
const imageData = ctx.getImageData(0, 0, width, height)
const divisionSetting: DivisionSetting[] = this.setting.divisionSettingfor(let i = 3; i < imageData.data.length; i += 4) {const alpha = imageData.data[i], step = alpha / 255let idxfor(let i = 0; i < this.divisionSetting.length; ++i) {if(this.divisionSetting[i].pStart <= step && step <= this.divisionSetting[i].pEnd) {idx = ibreak}}if(idx === undefined) returnimageData.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)

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/892173.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

揭露GPT幻觉只需一个提示

把像GPT这样的超大语言模型投入真实世界应用时,最大挑战之一就是经常说的幻觉。这就是说这些模型会开始编造一些根本不对的事实。最麻烦的地方是你可能根本不会发现,因为这些文字放在上下文里听起来很自然。 这对那些需要事实核查,或者某种形式的事后验证才能信任LLM回答的关…

如何使用ChatGPT画流程图

如何使用ChatGPT画流程图 MermaidMermaid 是一款基于 JavaScript 的图表绘制工具,使用 Markdown 风格的文本定义和渲染器来创建和修改复杂图表。Mermaid 的主要目的是帮助文档跟上开发的步伐。使用示例将ChatGPT回复的代码粘贴到下面的网站上 https://mermaid.live/

成都控制板定制:常见的MAX485芯片型号和后缀的含义

我处承接提供优质的单片机系统开发、电路板PCB设计、控制器研发控制箱定制、电子产品、硬件开发、工控测控传感自动化PLC系统设计、仪器定制仪表订做、信号采集器研发、物联网、软件EXE编程、安卓APP等开发定制加工优质服务(www点yonko-tech点com),在项目时会经常用到485通信…

震撼揭秘:LLM幻觉如何颠覆你的认知!

LLM幻觉 把幻觉理解为训练流水线中的一种涌现认知效应 Prashal RuchirangaRobina Weermeijer 在 Unsplash 上的照片介绍 在一个名为《深入剖析像ChatGPT这样的LLM》的YouTube视频里,特斯拉前AI资深总监Andrej Karpathy探讨了大型语言模型(LLM)的心理现象,把它看作是训练流水…

Windows 10 Hyper-V 安装不了 统信UOS Server 解决方案

如果一直停留在上面的页面,删除虚拟机,记得创建虚拟机,不要选择2代CPU。

Windows下DeepSeek R1简单搭建

目录安装 Ollama简介安装运行模型选择嵌入模型(Embedding)安装和使用Cherry Studio配置Cherry Studio配置使用本地模型知识库配置 安装 Ollama 简介 Ollama 是一个开源的大型语言模型(LLM)平台,旨在让用户能够轻松地在本地运行、管理和与大型语言模型进行交互。 提供了一个简…

学习进度记录贴

本文主要记录作者的各个学习记录🐫学习进度记录帖本贴开立初衷是为了督促作者好好学习,用记录的方式收获一点正反馈。作者目前大三下半学期,由于对考研上不了岸z的担忧,所以想边实习边考研。虽然这是很多人都不建议走的一条路,但是只有这样才能够缓解我的焦虑,让我不必在…

SSL/TLS握手阶段解析

众所周知SSL/TLS是HTTPS的基石,我觉得对经常都在使用的网络需要有进一步的了解。 HTTPS协议全称(Hypertext Transfer Protocol Secure),它与HTTP协议最大的不同就在于更安全。 HTTP是明文协议,所有内容默认都没有经过加密,当然也可以由开发人员将客户端和服务端要发送的内…

CICD+K8s项目实战讲解

CICD 流水先实战,使用git+Jenkins(git+mvn+docker)+ harbor + k8s 1.环境说明

我的公众号接入了DeepSeek-R1模型,成为了一个会深度思考的强大.NET AI智能体!

前言 前不久腾讯元器宣布接入满血版 Deepseek R1 模型,模型免费使用且不限量,为智能体开发提供更多样化的模型选择,带来更丰富的智能体功能和玩法。 今天咱们一起来把我公众号的.NET AI智能体模型从腾讯混元大模型-turbo(32k)切换为DeepSeek-R1(32k),使其拥有深度思考功能变…

图周围添加阴影更逼真 filter:drop-shadow(0 2px 14.7px rgba(0, 0, 0, .08));

filter:drop-shadow(0 2px 14.7px rgba(0, 0, 0, .08));在CSS中,filter 属性可以用来应用图形效果,如模糊、阴影、颜色变换等。drop-shadow 则是 filter 属性中的一种效果,用于给元素添加阴影效果。 语法filter: drop-shadow(offset-x offset-y blur-radius color);offset-x…

趋势还是噪声?ADF与KPSS检验结果矛盾时的高级时间序列处理方法

在时间序列分析领域,评估数据的平稳性是构建准确模型的基础。ADF(Augmented Dickey-Fuller,增广迪基-富勒检验)和KPSS(Kwiatkowski-Phillips-Schmidt-Shin)检验是用于评估时间序列数据平稳性的两种关键统计假设检验方法。当我们遇到ADF检验失败而KPSS检验通过的情况时,这…