vue3和ThreeJS实现3D热力图含组件封装
哎~,最近一直没更新CSDN的博客,主要是因为频繁出差,加上手头项目压力比较大,实在抽不出太多时间来做总结。今天总算忙里偷闲,挤出一点时间,把近期积累的一些实战经验和学习心得整理分享出来,虽然很忙,但是我有外甥了,我也要做舅舅了!记录一下【 2025年10月23日9:28分,3840g(7.68斤)男孩,母女平安,大名叫张思彦,小名叫做丰收】。
前言
进入正题!,在工业互联网中,尤其是对于质量要求很高的领域,比如玻璃加工行业,实时监控分析玻璃质量很重要,比如应力斑,划伤,平整度等等,刚巧这个需求被我碰到了,需要开发一个3D热力图,可以直观地展示某特定区域出现瑕疵的概率,帮助加工厂或者加工师傅快速定位并解决问题。当当当! 开发就开始啦!
查看效果地址:【https://wuyonggithub.github.io/charts/3DHeatChart-examples.html】>>>
常规版本热力图效果:

因为发现数据刷新会卡顿,进行了优化版本,实时性更高

需求理解
需求当然是产品+UI直接啪一下给你一个概念图,让你去实现啦,先看概念图!

拿到这张图脑子嗡嗡的,经过一番浮想联翩,想一想数据结构,想一想技术方案,搜索一下3D案例,从中找到了突破点,完全可以通过二维数据data去动态生成横向和纵向的每一个柱子,然后给每一个柱子进行vertexColors 颜色渐变,给集合体的每个顶点设置一个颜色,然后启用vertexColors:true材质,webgl会在片段着色器中对这些顶点颜色进行插值,从而实现平滑的渐变效果【归一化高度,区分顶面与侧片进行上色,根据多段渐变插值逻辑进行分段上色】。
基于threeJS3D实现,支持数据动态渲染,颜色渐变,鼠标悬停提示,自动旋转动画,轨道控制,缩放,鼠标移入提示效果,响应式尺寸,支持网格线和坐标轴辅助显示,提供refresh和toggleAnimation方法供外部开发调用。
实现方案
第一步、数据格式
输入为一个二维数组
powershell
const data = [
[1.2, 3.5, 7.8, 5.1],
[2.3, 4.6, 8.9, 6.3],
[3.1, 5.2, 9.5, 7.0]
];
每个元素代表对应位置的热度值,数组的行数row和列数cols决定热力图的大小和尺寸。
第二步、数据处理
动态计算
powershell
rows = data.length
cols = data[0]?.length || 0
计算最大热值和最小热值
powershell
min = Math.min(...allValues)
max = Math.max(...allValues)
归一化用于计算柱体高度
powershell
height = 0.1 + ((value - min) / (max - min)) * maxHeight
第三步、初始化ThreeJS环境
powershell
创建 Scene、PerspectiveCamera、WebGLRenderer
设置背景色、抗锯齿、阴影等
添加光源(环境光 + 方向光)保证基本光照效果
第四步、构建热力图主体
使用
THREE.Group作为容器,便于整体变换(如旋转)。遍历
data数组,为每个数据点创建一个柱体(THREE.Mesh)。柱体使用
BoxGeometry,尺寸为(baseSize, height, baseSize)。位置居中对齐:
x = (i - (rows-1)/2) * baseSize, z = (j - (cols-1)/2) * baseSize。
第五步、渐变颜色实现
1. 核心:顶点着色
为集合体的每一个顶点分配颜色,three会自动插值渲染出渐变效果。
powershell
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
const material = new THREE.MeshBasicMaterial({ vertexColors: true });
2. 几何体分段
创建BoxGeometry时增加y轴的分段数,8段确保侧面有足够的顶点来表现颜色变化
powershell
new THREE.BoxGeometry(width, height, depth, 1, 8, 1)
3. 多段渐变策略【十阶段渐变】
定义
10种颜色构成的渐变序列(深蓝 → 浅蓝 → 淡紫 → 橙红 → 深红)。根据数据值的归一化比例
t = (value - min) / (max - min),决定使用前 N 段颜色。在侧面顶点上,根据其高度比例
t_vertex ∈ [0,1],在线性插值(lerpColors)对应颜色区间。
4. 区分顶面与侧面
顶面(法线
Y > 0.5):统一使用该柱体等级的"最高色"(视觉突出)。侧面:按高度进行多段垂直渐变,体现"温度梯度"。
第六步、设计组件化封装配置
Prop:类型 说明。data:number[][]热力图数据。width/height:number容器尺寸。baseSize:number柱体底面尺寸。maxHeight:number柱体最大高度。rotateAnimation:boolean是否开启自动旋转。rotationSpeed:number旋转速度。isStandardColor:boolean是否使用十段渐变色。rotateAnimation:Boolean是否启用旋转动画cameraPosition:控制three3D中的相机视角。
第七步、设计暴露方法
refresh():重新生成热力图(数据更新后调用)
toggleAnimation():切换旋转动画状态
组件整体代码
javascript
<!--
* @Author: wyk
* @Date: 2025-10-20
* @Description: 3D 热力图封装组件
-->
<template>
<div class="heatmap-wrapper" :style="{ width: width + 'px', height: height + 'px' }">
<div v-if="tooltip.show" class="tooltip" :style="{ left: tooltip.x + 'px', top: tooltip.y + 'px' }" v-html="tooltip.content" />
<div ref="containerRef" class="three-container"></div>
</div>
</template>
<script setup lang="ts">
import * as THREE from "three";
defineOptions({ name: "JBHeatMap3D" });
// 在其他 ref 定义之后添加
// const rotationSpeed = ref(0.01); // 控制旋转速度
const currentAngle = ref(0); // 当前的角度
// ========== Props ==========
const props = defineProps({
data: {
type: Array as () => number[][],
default: () => [],
},
width: { type: Number, default: 600 },
height: { type: Number, default: 500 },
baseSize: { type: Number, default: 0.1 },
maxHeight: { type: Number, default: 3 },
enableOrbit: { type: Boolean, default: true },
backgroundColor: { type: String, default: "#0d1b2a" },
showGridHelper: { type: Boolean, default: true },
showAxesHelper: { type: Boolean, default: true },
isStandardColor: { type: Boolean, default: true },
rotateAnimation: { type: Boolean, default: false }, // 是否启用旋转动画
rotationSpeed: { type: Number, default: 0.01 }, // 旋转速度,可由外部传入
autoAnimate: { type: Boolean, default: false },
cameraPosition: {
type: Object as () => { x: number; y: number; z: number },
default: () => ({ x: 5, y: 5, z: 5 }),
},
});
// 👉 全局变量声明
let instancedMesh: THREE.InstancedMesh | null = null;
const dummy = new THREE.Object3D(); // 用于临时变换计算
// 材质和几何体缓存(可选)
let heatmapGeometry: THREE.BoxGeometry | null = null;
let heatmapMaterial: THREE.MeshBasicMaterial | THREE.MeshStandardMaterial | null = null;
let gridHelper: THREE.GridHelper | null = null;
const containerRef = ref<HTMLDivElement | null>(null);
const tooltip = ref({ show: false, x: 0, y: 0, content: "" });
const isAnimating = ref(props.autoAnimate);
let scene: THREE.Scene;
let camera: THREE.PerspectiveCamera;
let renderer: THREE.WebGLRenderer;
let controls: any = null;
let heatmapGroup: THREE.Group;
let raycaster: THREE.Raycaster;
let mouse: THREE.Vector2;
let animationId: number | null = null;
let dataGrid: number[][] = [];
let rows = 0; // 动态行数
let cols = 0; // 动态列数
// ========== 初始化 ==========
onMounted(async () => {
const { OrbitControls } = await import("three/examples/jsm/controls/OrbitControls.js");
// 在 initThree() 之前创建 controls,因为它依赖 renderer.domElement
initThree();
if (props.enableOrbit) {
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
}
animate();
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("resize", onResize);
// 在 onMounted 中添加
if (containerRef.value) {
containerRef.value.addEventListener("mouseleave", () => {
tooltip.value.show = false;
});
}
});
onUnmounted(() => {
if (animationId) cancelAnimationFrame(animationId);
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("resize", onResize);
renderer.dispose();
});
// ========== Three.js 初始化 ==========
function initThree() {
const { backgroundColor, showGridHelper, showAxesHelper, cameraPosition } = props;
scene = new THREE.Scene();
scene.background = new THREE.Color(backgroundColor);
camera = new THREE.PerspectiveCamera(75, props.width / props.height, 0.1, 1000);
camera.position.set(cameraPosition.x, cameraPosition.y, cameraPosition.z);
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(props.width, props.height);
renderer.shadowMap.enabled = true;
if (containerRef.value) containerRef.value.appendChild(renderer.domElement);
// 轨道控制器
if (props.enableOrbit && controls) {
controls.enableDamping = true;
controls.dampingFactor = 0.05;
}
// 光源
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
const light = new THREE.DirectionalLight(0xffffff, 0.8);
light.position.set(10, 20, 5);
scene.add(light);
// 热力图组
heatmapGroup = new THREE.Group();
scene.add(heatmapGroup);
// 使用动态尺寸创建网格辅助线
if (showGridHelper) {
// 估算一个合理的网格尺寸
const gridSize = Math.max(rows, cols) * props.baseSize * 1.2;
const gridHelper = new THREE.GridHelper(gridSize, Math.max(rows, cols), 0x444444, 0x222222);
scene.add(gridHelper);
}
if (showAxesHelper) {
const axesHelper = new THREE.AxesHelper(Math.max(rows, cols) * props.baseSize * 0.6);
scene.add(axesHelper);
}
raycaster = new THREE.Raycaster();
mouse = new THREE.Vector2();
createHeatmap();
}
// ========== 生成数据 & 创建立方体 ==========
function generateData(rowCount: number = 10, colCount: number = 10) {
const grid = Array.from({ length: rowCount }, () => Array.from({ length: colCount }, () => Math.random() * 15 + 1));
return grid;
}
const allValues: number[] = [];
// 使用传统渐变色
function createCube(x: number, z: number, value: number, min: number, max: number, allValues: number[]) {
const height = 0.1 + ((value - min) / (max - min)) * props.maxHeight;
// 增加高度方向的分段数,以便有更多的顶点来表现渐变
const geometry = new THREE.BoxGeometry(props.baseSize, height, props.baseSize, 1, 8, 1);
const positions = geometry.attributes.position as THREE.BufferAttribute;
const normals = geometry.attributes.normal as THREE.BufferAttribute;
const colors: number[] = [];
// 计算值在[min, max]范围内的归一化比例 (0到1)
const normalizedValue = (value - min) / (max - min);
// 定义完整的颜色渐变序列
const fullGradient = [
new THREE.Color(0x00008b), // 深蓝
new THREE.Color(0x00ffff), // 青
new THREE.Color(0xffff00), // 黄
new THREE.Color(0xff0000), // 红
];
// 根据实际值的大小决定使用多少段渐变
let usedColors: THREE.Color[] = [];
if (normalizedValue >= 0.75) {
// 最高值:使用完整的4段渐变
usedColors = [...fullGradient];
} else if (normalizedValue >= 0.5) {
// 较高值:使用前3段渐变
usedColors = fullGradient.slice(0, 3);
} else if (normalizedValue >= 0.25) {
// 中等值:使用前2段渐变
usedColors = fullGradient.slice(0, 2);
} else {
// 低值:只使用第1段颜色
usedColors = fullGradient.slice(0, 1);
}
// 为每个顶点设置颜色
for (let i = 0; i < positions.count; i++) {
const y = positions.getY(i);
const ny = normals.getY(i);
let color = new THREE.Color();
// 计算顶点在柱体高度上的位置 (0到1)
const t = (y + height / 2) / height;
if (ny > 0.5) {
// 顶面:使用对应等级的最高颜色
color.copy(usedColors[usedColors.length - 1]);
} else {
// 侧面:根据使用的颜色数量进行渐变
if (usedColors.length === 4) {
// 四段渐变:深蓝 → 青 → 黄 → 红
if (t < 0.25) {
color.lerpColors(usedColors[0], usedColors[1], t / 0.25);
} else if (t < 0.5) {
color.lerpColors(usedColors[1], usedColors[2], (t - 0.25) / 0.25);
} else if (t < 0.75) {
color.lerpColors(usedColors[2], usedColors[3], (t - 0.5) / 0.25);
} else {
color.copy(usedColors[3]);
}
} else if (usedColors.length === 3) {
// 三段渐变:深蓝 → 青 → 黄
if (t < 0.33) {
color.lerpColors(usedColors[0], usedColors[1], t / 0.33);
} else if (t < 0.66) {
color.lerpColors(usedColors[1], usedColors[2], (t - 0.33) / 0.33);
} else {
color.copy(usedColors[2]);
}
} else if (usedColors.length === 2) {
// 两段渐变:深蓝 → 青
if (t < 0.5) {
color.lerpColors(usedColors[0], usedColors[1], t / 0.5);
} else {
color.copy(usedColors[1]);
}
} else {
// 单色:深蓝
color.copy(usedColors[0]);
}
}
colors.push(color.r, color.g, color.b);
}
geometry.setAttribute("color", new THREE.BufferAttribute(new Float32Array(colors), 3));
const material = new THREE.MeshBasicMaterial({ vertexColors: true });
const cube = new THREE.Mesh(geometry, material);
cube.position.set((x - (rows - 1) / 2) * props.baseSize, height / 2, (z - (cols - 1) / 2) * props.baseSize);
cube.userData = { value, x, z };
return cube;
}
// 使用十阶段渐变色
function createCube2(x: number, z: number, value: number, min: number, max: number, allValues: number[]) {
const height = 0.1 + ((value - min) / (max - min)) * props.maxHeight;
const geometry = new THREE.BoxGeometry(props.baseSize, height, props.baseSize, 1, 8, 1);
const positions = geometry.attributes.position as THREE.BufferAttribute;
const normals = geometry.attributes.normal as THREE.BufferAttribute;
const colors: number[] = [];
// 计算值在[min, max]范围内的归一化比例 (0到1)
const normalizedValue = (value - min) / (max - min);
// 定义完整的十段颜色渐变序列
const fullGradient = [
new THREE.Color(0x08315f), // 深蓝色
new THREE.Color(0x4588bb), // 蓝色
new THREE.Color(0x85bee3), // 浅蓝色
new THREE.Color(0xbedcf4), // 更浅的蓝色
new THREE.Color(0xe4d7dc), // 淡紫色
new THREE.Color(0xfcdfda), // 淡粉色
new THREE.Color(0xf9b6a2), // 浅橙色
new THREE.Color(0xf97860), // 橙色
new THREE.Color(0xc81626), // 红色
new THREE.Color(0xaf000f), // 深红色
];
// 根据归一化值决定使用多少段渐变
let usedColors: THREE.Color[] = [];
let segmentCount = 0;
if (normalizedValue >= 0.9) {
segmentCount = 10;
usedColors = fullGradient.slice(0, 10);
} else if (normalizedValue >= 0.8) {
segmentCount = 9;
usedColors = fullGradient.slice(0, 9);
} else if (normalizedValue >= 0.7) {
segmentCount = 8;
usedColors = fullGradient.slice(0, 8);
} else if (normalizedValue >= 0.6) {
segmentCount = 7;
usedColors = fullGradient.slice(0, 7);
} else if (normalizedValue >= 0.5) {
segmentCount = 6;
usedColors = fullGradient.slice(0, 6);
} else if (normalizedValue >= 0.4) {
segmentCount = 5;
usedColors = fullGradient.slice(0, 5);
} else if (normalizedValue >= 0.3) {
segmentCount = 4;
usedColors = fullGradient.slice(0, 4);
} else if (normalizedValue >= 0.2) {
segmentCount = 3;
usedColors = fullGradient.slice(0, 3);
} else if (normalizedValue >= 0.1) {
segmentCount = 2;
usedColors = fullGradient.slice(0, 2);
} else {
segmentCount = 1;
usedColors = fullGradient.slice(0, 1);
}
// 为每个顶点设置颜色
for (let i = 0; i < positions.count; i++) {
const y = positions.getY(i);
const ny = normals.getY(i);
let color = new THREE.Color();
// 计算顶点在柱体高度上的位置 (0到1)
const t = Math.max(0, Math.min(1, (y + height / 2) / height)); // 确保t在0-1范围内
if (ny > 0.5) {
// 顶面:使用对应等级的最高颜色
color.copy(usedColors[usedColors.length - 1]);
} else {
// 侧面:根据使用的颜色数量进行渐变
if (segmentCount === 1) {
// 单色:深蓝色
color.copy(usedColors[0]);
} else {
// 多段渐变 - 修复索引计算
const segmentLength = 1.0 / (segmentCount - 1);
let segmentIndex = Math.floor(t / segmentLength);
// 确保索引不越界
segmentIndex = Math.min(segmentIndex, segmentCount - 2);
segmentIndex = Math.max(0, segmentIndex); // 确保不小于0
const localT = (t - segmentIndex * segmentLength) / segmentLength;
// 确保颜色索引有效
if (segmentIndex >= 0 && segmentIndex + 1 < usedColors.length) {
color.lerpColors(usedColors[segmentIndex], usedColors[segmentIndex + 1], localT);
} else {
// 如果索引无效,使用最后一个颜色
color.copy(usedColors[usedColors.length - 1]);
}
}
}
colors.push(color.r, color.g, color.b);
}
geometry.setAttribute("color", new THREE.BufferAttribute(new Float32Array(colors), 3));
const material = new THREE.MeshBasicMaterial({ vertexColors: true });
const cube = new THREE.Mesh(geometry, material);
cube.position.set((x - (rows - 1) / 2) * props.baseSize, height / 2, (z - (cols - 1) / 2) * props.baseSize);
cube.userData = { value, x, z };
return cube;
}
function disposeHeatmap() {
if (instancedMesh) {
instancedMesh.geometry.dispose();
(instancedMesh.material as THREE.Material).dispose();
heatmapGroup.remove(instancedMesh);
instancedMesh = null;
} else {
// fallback:清理普通 mesh
const { children } = heatmapGroup;
children.forEach((child) => {
if (child instanceof THREE.Mesh) {
child.geometry.dispose();
const materials = Array.isArray(child.material) ? child.material : [child.material];
materials.forEach((mat) => mat.dispose());
}
});
heatmapGroup.clear();
}
}
function createHeatmap() {
// 先释放旧的几何体和材质资源
disposeHeatmap();
// 判断 props.data 是否为二维数组且维度合法
const hasValidExternalData = Array.isArray(props.data) && props.data.length > 0 && props.data.every((row) => Array.isArray(row));
if (hasValidExternalData) {
// dataGrid = props.data as number[][];
dataGrid = props.data.map((row) => [...row]); // 深拷贝
} else {
console.log("数据不合法,使用默认数据");
dataGrid = generateData(); // 使用默认 10x10
}
// 清除旧的子对象
heatmapGroup.clear();
// === 动态计算行数和列数 ===
rows = dataGrid.length;
cols = dataGrid[0]?.length || 0;
if (rows === 0 || cols === 0) {
console.warn("数据为空,无法创建热力图");
return;
}
// 计算 min / max 并收集所有值
let min = Infinity;
let max = -Infinity;
const allValues: number[] = [];
for (let i = 0; i < rows; i++) {
const row = dataGrid[i];
if (!Array.isArray(row)) continue;
for (let j = 0; j < cols; j++) {
const v = row[j];
if (typeof v !== "number") continue;
allValues.push(v);
if (v < min) min = v;
if (v > max) max = v;
}
}
if (min === Infinity) min = 0;
if (max === -Infinity) max = 0;
// 对所有值排序(从高到低)
allValues.sort((a, b) => b - a);
// 创建柱体
for (let i = 0; i < rows; i++) {
const row = dataGrid[i];
if (!Array.isArray(row)) continue;
for (let j = 0; j < cols; j++) {
const v = row[j];
const val = typeof v === "number" ? v : 0;
let cube = null;
if (props.isStandardColor) {
cube = createCube2(i, j, val, min, max, allValues);
} else {
cube = createCube2(i, j, val, min, max, allValues);
}
heatmapGroup.add(cube);
}
}
}
function animate() {
animationId = requestAnimationFrame(animate);
// 根据 rotateAnimation 的值决定是否旋转 heatmapGroup
if (props.rotateAnimation) {
currentAngle.value += props.rotationSpeed; // 使用 props 控制速度
heatmapGroup.rotation.y = currentAngle.value;
}
if (controls) controls.update();
renderer.render(scene, camera);
}
function animateData() {
for (let i = 0; i < rows; i++) {
for (let j = 0; j < cols; j++) {
if (dataGrid[i] && typeof dataGrid[i][j] === "number") {
dataGrid[i][j] += (Math.random() - 0.5) * 0.3;
dataGrid[i][j] = Math.min(20, Math.max(1, dataGrid[i][j]));
}
}
}
}
let raycasterTimeout: number | null = null;
// ========== 鼠标交互 ==========
function onMouseMove(event: MouseEvent) {
if (!containerRef.value) return;
const rect = containerRef.value.getBoundingClientRect();
if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) {
tooltip.value.show = false;
return;
}
// 防抖处理
if (raycasterTimeout) {
cancelAnimationFrame(raycasterTimeout);
}
raycasterTimeout = requestAnimationFrame(() => {
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(heatmapGroup.children);
if (intersects.length) {
const cube = intersects[0].object;
const { value, x, z } = cube.userData;
tooltip.value = {
show: true,
x: event.clientX - rect.left + 10,
y: event.clientY - rect.top - 30,
content: `数值: ${value.toFixed(2)}<br>位置: (${x}, ${z})`,
};
} else {
tooltip.value.show = false;
}
});
}
function onResize() {
camera.aspect = props.width / props.height;
camera.updateProjectionMatrix();
renderer.setSize(props.width, props.height);
}
// ========== 暴露方法 ==========
defineExpose({
refresh: createHeatmap,
toggleAnimation: () => (isAnimating.value = !isAnimating.value),
});
</script>
<style scoped lang="scss">
.heatmap-wrapper {
position: relative;
}
.three-container {
width: 100%;
height: 100%;
}
.tooltip {
position: absolute;
z-index: 10;
padding: 8px 12px;
font-size: 14px;
border-radius: 6px;
color: #fff;
background: rgba(0, 0, 0, 80%);
pointer-events: none;
}
</style>
组件调用示例
javascript
<!--
* @Author: wyk
* @Date: 2024-10-20 14:44:35
* @LastEditTime: 2024-10-22 11:10:37
* @Description:
* 使用方式就是把JBHeatMap改为JBHeatMap2
-->
<template>
<div>
<HeatMap
:isStandardColor="false"
ref="heatmap3D"
:cameraPosition="{
x: 18,
y: 18,
z: 0,
}"
:data="heatmapData"
width="675"
height="475"
base-size="0.5"
max-height="5"
:rotationSpeed="0.006"
:rotateAnimation="true"
:auto-animate="true" />
<div style="margin-top: 10px">
<Button @click="refreshHeatmap">刷新热力图数据</Button>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: "JBHeatMap" });
const heatmap3D = ref(null);
function generateData(rows: number, cols: number): number[][] {
const totalElements = rows * cols;
// 计算各区间元素数量
const count_40_to_50 = Math.round(totalElements * 0.02); // 2%
const count_20_to_30 = Math.round(totalElements * 0.2); // 20%
const count_10_to_20 = Math.round(totalElements * 0.3); // 30%
const count_1_to_5 = totalElements - count_40_to_50 - count_20_to_30 - count_10_to_20; // 剩余 48%
console.log({
total: totalElements,
"40-50": count_40_to_50,
"20-30": count_20_to_30,
"10-20": count_10_to_20,
"1-5": count_1_to_5,
});
// 初始化一个扁平数组用于存储所有值
const values: number[] = [];
// 生成指定数量的随机值,加入数组
// 1. 40--50 区间(含)
for (let i = 0; i < count_40_to_50; i++) {
values.push(Math.floor(Math.random() * 11) + 40); // 40~50
}
// 2. 20--30 区间(含)
for (let i = 0; i < count_20_to_30; i++) {
values.push(Math.floor(Math.random() * 11) + 20); // 20~30
}
// 3. 10--20 区间(含)
for (let i = 0; i < count_10_to_20; i++) {
values.push(Math.floor(Math.random() * 11) + 10); // 10~20
}
// 4. 1--5 区间(含)
for (let i = 0; i < count_1_to_5; i++) {
values.push(Math.floor(Math.random() * 5) + 1); // 1~5
}
// 洗牌算法
for (let i = values.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[values[i], values[j]] = [values[j], values[i]];
}
// 转为二维数组
const dataGrid: number[][] = [];
let index = 0;
for (let i = 0; i < rows; i++) {
const row: number[] = [];
for (let j = 0; j < cols; j++) {
row.push(values[index++]);
}
dataGrid.push(row);
}
return dataGrid;
}
// 使用示例
const heatmapData = ref(generateData(17, 23)); // 初始化50*100的热力图数据,其中2%的值为50,其他为5
function toggleAnimation() {
heatmap3D.value.toggleAnimation();
}
function refreshHeatmap() {
heatmapData.value = generateData(17, 23); // 刷新为新的30*70热力图数据
heatmap3D.value.refresh(); // 调用组件暴露的方法,刷新热力图数据
console.log("热力图已刷新", heatmapData.value);
}
function onHeatmapRefresh() {
console.log("热力图已刷新");
}
</script>
<style lang="scss" scoped></style>
效果预览

优化版本代码
javascript
"
<template>
<div class="heatmap-wrapper" :style="{ width: width + 'px', height: height + 'px' }">
<div v-if="tooltip.show" class="tooltip" :style="{ left: tooltip.x + 'px', top: tooltip.y + 'px' }" v-html="tooltip.content" />
<div ref="containerRef" class="three-container"></div>
</div>
</template>
<script setup lang="ts">
import * as THREE from "three";
// import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
defineOptions({ name: "JBHeatMap3DUV" });
const props = defineProps({
data: {
type: Array as () => number[][],
default: () => [],
},
width: { type: Number, default: 600 },
height: { type: Number, default: 500 },
baseSize: { type: Number, default: 0.1 },
maxHeight: { type: Number, default: 3 },
enableOrbit: { type: Boolean, default: true },
backgroundColor: { type: String, default: "#0d1b2a" },
showGridHelper: { type: Boolean, default: true },
showAxesHelper: { type: Boolean, default: true },
gradientColors: {
type: Array as () => string[],
default: () => ["#00008b", "#00ffff", "#ffff00", "#ff0000"],
},
autoAnimate: { type: Boolean, default: false },
cameraPosition: {
type: Object as () => { x: number; y: number; z: number },
default: () => ({ x: 8, y: 8, z: 8 }),
},
});
const containerRef = ref<HTMLDivElement | null>(null);
const tooltip = ref({ show: false, x: 0, y: 0, content: "" });
const isAnimating = ref(props.autoAnimate);
let scene: THREE.Scene;
let camera: THREE.PerspectiveCamera;
let renderer: THREE.WebGLRenderer;
let controls: any = null;
let heatmapGroup: THREE.Group;
let raycaster: THREE.Raycaster;
let mouse: THREE.Vector2;
let animationId: number | null = null;
let instancedMesh: THREE.InstancedMesh | null = null;
const instanceData: { value: number; x: number; z: number; normalizedValue: number }[] = [];
let rows = 0;
let cols = 0;
let dataMin = 0;
let dataMax = 1;
// 着色器
let shaderMaterial: THREE.ShaderMaterial;
// ========== 初始化 ==========
onMounted(async () => {
initThree();
const { OrbitControls } = await import("three/examples/jsm/controls/OrbitControls.js");
if (props.enableOrbit) {
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
}
animate();
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("resize", onResize);
if (containerRef.value) {
containerRef.value.addEventListener("mouseleave", () => {
tooltip.value.show = false;
});
}
});
onUnmounted(() => {
if (animationId) cancelAnimationFrame(animationId);
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("resize", onResize);
// 清理资源
if (instancedMesh) {
instancedMesh.geometry.dispose();
(instancedMesh.material as THREE.Material).dispose();
}
if (shaderMaterial) shaderMaterial.dispose();
renderer?.dispose();
});
// ========== 创建着色器材质 - 实现十段渐变 ==========
function createShaderMaterial() {
console.log("创建十段渐变着色器材质...");
// 定义十段渐变的颜色序列
const colors = [
new THREE.Vector3(0.0314, 0.1922, 0.3725), // #08315f 深蓝
new THREE.Vector3(0.2706, 0.5333, 0.7333), // #4588bb 蓝色
new THREE.Vector3(0.5216, 0.7451, 0.8902), // #85bee3 浅蓝色
new THREE.Vector3(0.7451, 0.8627, 0.9569), // #bedcf4 更浅的蓝色
new THREE.Vector3(0.8941, 0.8431, 0.8627), // #e4d7dc 淡紫色
new THREE.Vector3(0.9882, 0.8745, 0.8549), // #fcdfda 淡粉色
new THREE.Vector3(0.9765, 0.7137, 0.6353), // #f9b6a2 浅橙色
new THREE.Vector3(0.9765, 0.4706, 0.3765), // #f97860 橙色
new THREE.Vector3(0.7843, 0.0863, 0.149), // #c81626 红色
new THREE.Vector3(0.6863, 0.0, 0.0588), // #af000f 深红色
];
const material = new THREE.ShaderMaterial({
uniforms: {
color0: { value: colors[0] },
color1: { value: colors[1] },
color2: { value: colors[2] },
color3: { value: colors[3] },
color4: { value: colors[4] },
color5: { value: colors[5] },
color6: { value: colors[6] },
color7: { value: colors[7] },
color8: { value: colors[8] },
color9: { value: colors[9] },
baseSize: { value: props.baseSize },
maxHeight: { value: props.maxHeight },
},
vertexShader: `
attribute float instanceValue;
attribute vec3 instancePosition;
attribute vec3 instanceScale;
varying float vValue;
varying float vHeight;
varying vec3 vPosition;
void main() {
vValue = instanceValue;
vPosition = position;
// 计算顶点在柱体中的相对高度 (0到1)
vHeight = (position.y + 0.5); // 立方体原本y范围是-0.5到0.5
// 应用实例的缩放和位置
vec3 transformed = position * instanceScale;
transformed += instancePosition;
gl_Position = projectionMatrix * modelViewMatrix * vec4(transformed, 1.0);
}
`,
fragmentShader: `
uniform vec3 color0;
uniform vec3 color1;
uniform vec3 color2;
uniform vec3 color3;
uniform vec3 color4;
uniform vec3 color5;
uniform vec3 color6;
uniform vec3 color7;
uniform vec3 color8;
uniform vec3 color9;
varying float vValue;
varying float vHeight;
varying vec3 vPosition;
vec3 getGradientColor(float value, float height) {
// 根据数值大小决定使用多少段渐变
if (value < 0.1) {
// 第一段:纯颜色
return color0;
} else if (value < 0.2) {
// 第二段:2色渐变
return mix(color0, color1, height);
} else if (value < 0.3) {
// 第三段:3色渐变
if (height < 0.5) {
return mix(color0, color1, height / 0.5);
} else {
return mix(color1, color2, (height - 0.5) / 0.5);
}
} else if (value < 0.4) {
// 第四段:4色渐变
if (height < 0.33) {
return mix(color0, color1, height / 0.33);
} else if (height < 0.66) {
return mix(color1, color2, (height - 0.33) / 0.33);
} else {
return mix(color2, color3, (height - 0.66) / 0.34);
}
} else if (value < 0.5) {
// 第五段:5色渐变
if (height < 0.25) {
return mix(color0, color1, height / 0.25);
} else if (height < 0.5) {
return mix(color1, color2, (height - 0.25) / 0.25);
} else if (height < 0.75) {
return mix(color2, color3, (height - 0.5) / 0.25);
} else {
return mix(color3, color4, (height - 0.75) / 0.25);
}
} else if (value < 0.6) {
// 第六段:6色渐变
if (height < 0.2) {
return mix(color0, color1, height / 0.2);
} else if (height < 0.4) {
return mix(color1, color2, (height - 0.2) / 0.2);
} else if (height < 0.6) {
return mix(color2, color3, (height - 0.4) / 0.2);
} else if (height < 0.8) {
return mix(color3, color4, (height - 0.6) / 0.2);
} else {
return mix(color4, color5, (height - 0.8) / 0.2);
}
} else if (value < 0.7) {
// 第七段:7色渐变
if (height < 0.1667) {
return mix(color0, color1, height / 0.1667);
} else if (height < 0.3333) {
return mix(color1, color2, (height - 0.1667) / 0.1667);
} else if (height < 0.5) {
return mix(color2, color3, (height - 0.3333) / 0.1667);
} else if (height < 0.6667) {
return mix(color3, color4, (height - 0.5) / 0.1667);
} else if (height < 0.8333) {
return mix(color4, color5, (height - 0.6667) / 0.1667);
} else {
return mix(color5, color6, (height - 0.8333) / 0.1667);
}
} else if (value < 0.8) {
// 第八段:8色渐变
if (height < 0.1429) {
return mix(color0, color1, height / 0.1429);
} else if (height < 0.2857) {
return mix(color1, color2, (height - 0.1429) / 0.1429);
} else if (height < 0.4286) {
return mix(color2, color3, (height - 0.2857) / 0.1429);
} else if (height < 0.5714) {
return mix(color3, color4, (height - 0.4286) / 0.1429);
} else if (height < 0.7143) {
return mix(color4, color5, (height - 0.5714) / 0.1429);
} else if (height < 0.8571) {
return mix(color5, color6, (height - 0.7143) / 0.1429);
} else {
return mix(color6, color7, (height - 0.8571) / 0.1429);
}
} else if (value < 0.9) {
// 第九段:9色渐变
if (height < 0.125) {
return mix(color0, color1, height / 0.125);
} else if (height < 0.25) {
return mix(color1, color2, (height - 0.125) / 0.125);
} else if (height < 0.375) {
return mix(color2, color3, (height - 0.25) / 0.125);
} else if (height < 0.5) {
return mix(color3, color4, (height - 0.375) / 0.125);
} else if (height < 0.625) {
return mix(color4, color5, (height - 0.5) / 0.125);
} else if (height < 0.75) {
return mix(color5, color6, (height - 0.625) / 0.125);
} else if (height < 0.875) {
return mix(color6, color7, (height - 0.75) / 0.125);
} else {
return mix(color7, color8, (height - 0.875) / 0.125);
}
} else {
// 第十段:10色渐变
if (height < 0.1111) {
return mix(color0, color1, height / 0.1111);
} else if (height < 0.2222) {
return mix(color1, color2, (height - 0.1111) / 0.1111);
} else if (height < 0.3333) {
return mix(color2, color3, (height - 0.2222) / 0.1111);
} else if (height < 0.4444) {
return mix(color3, color4, (height - 0.3333) / 0.1111);
} else if (height < 0.5556) {
return mix(color4, color5, (height - 0.4444) / 0.1111);
} else if (height < 0.6667) {
return mix(color5, color6, (height - 0.5556) / 0.1111);
} else if (height < 0.7778) {
return mix(color6, color7, (height - 0.6667) / 0.1111);
} else if (height < 0.8889) {
return mix(color7, color8, (height - 0.7778) / 0.1111);
} else {
return mix(color8, color9, (height - 0.8889) / 0.1111);
}
}
}
void main() {
// 简单的光照效果
vec3 lightDir = normalize(vec3(1.0, 2.0, 0.5));
vec3 normal;
// 计算法向量
if (abs(vPosition.y) > 0.49) {
normal = vec3(0.0, sign(vPosition.y), 0.0);
} else if (abs(vPosition.x) > 0.49) {
normal = vec3(sign(vPosition.x), 0.0, 0.0);
} else {
normal = vec3(0.0, 0.0, sign(vPosition.z));
}
float diffuse = max(dot(normal, lightDir), 0.3);
// 顶部更亮
float topBrightness = vPosition.y > 0.4 ? 1.2 : 1.0;
// 获取渐变颜色
vec3 gradientColor = getGradientColor(vValue, vHeight);
gl_FragColor = vec4(gradientColor * diffuse * topBrightness, 1.0);
}
`,
transparent: false,
});
console.log("十段渐变着色器材质创建完成");
return material;
}
// ========== Three.js 初始化 ==========
function initThree() {
console.log("初始化 Three.js...");
const { backgroundColor, showGridHelper, showAxesHelper, cameraPosition, enableOrbit } = props;
// 创建场景
scene = new THREE.Scene();
scene.background = new THREE.Color(backgroundColor);
console.log("场景创建完成,背景色:", backgroundColor);
// 创建相机
camera = new THREE.PerspectiveCamera(75, props.width / props.height, 0.1, 1000);
camera.position.set(cameraPosition.x, cameraPosition.y, cameraPosition.z);
console.log("相机创建完成,位置:", cameraPosition);
// 创建渲染器
renderer = new THREE.WebGLRenderer({
antialias: true,
powerPreference: "high-performance",
});
renderer.setSize(props.width, props.height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
console.log("渲染器创建完成,尺寸:", props.width, "x", props.height);
if (containerRef.value) {
containerRef.value.innerHTML = "";
containerRef.value.appendChild(renderer.domElement);
console.log("渲染器DOM添加到容器");
}
// 光源
const ambientLight = new THREE.AmbientLight(0xffffff, 0.8);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(5, 10, 5);
scene.add(directionalLight);
console.log("光源设置完成");
// 热力图组
heatmapGroup = new THREE.Group();
scene.add(heatmapGroup);
console.log("热力图组创建完成");
raycaster = new THREE.Raycaster();
mouse = new THREE.Vector2();
// 立即创建热力图
createHeatmap();
console.log("Three.js 初始化完成");
}
// ========== 创建立方体几何体 ==========
function createCubeGeometry() {
// 增加高度方向的分段数,以便有更多的顶点来表现渐变
return new THREE.BoxGeometry(1, 1, 1, 1, 8, 1);
}
// ========== 创建热力图 ==========
function createHeatmap() {
console.log("开始创建热力图...");
// 清理旧的实例
if (instancedMesh) {
heatmapGroup.remove(instancedMesh);
instancedMesh.geometry.dispose();
(instancedMesh.material as THREE.Material).dispose();
instancedMesh = null;
console.log("清理旧的热力图实例");
}
instanceData.length = 0;
// 处理数据 - 使用测试数据
let dataGrid = props.data;
const hasValidData = Array.isArray(dataGrid) && dataGrid.length > 0 && dataGrid.every((row) => Array.isArray(row) && row.length > 0);
if (!hasValidData) {
console.log("使用默认测试数据");
dataGrid = [
[1, 3, 5, 7, 9],
[2, 4, 6, 8, 10],
[3, 5, 7, 9, 11],
[4, 6, 8, 10, 12],
[5, 7, 9, 11, 13],
];
}
rows = dataGrid.length;
cols = dataGrid[0]?.length || 0;
console.log(`网格尺寸: ${rows} x ${cols}`, "数据:", dataGrid);
// 计算最小最大值
let min = Infinity;
let max = -Infinity;
for (let i = 0; i < rows; i++) {
const row = dataGrid[i];
if (!Array.isArray(row)) continue;
for (let j = 0; j < cols; j++) {
const v = row[j];
if (typeof v !== "number") continue;
if (v < min) min = v;
if (v > max) max = v;
}
}
if (min === Infinity) min = 0;
if (max === -Infinity) max = 1;
// 避免除零
if (min === max) max = min + 1;
dataMin = min;
dataMax = max;
console.log(`数据范围: ${min} - ${max}`);
// 创建着色器材质
shaderMaterial = createShaderMaterial();
// 创建立方体几何体
const cubeGeometry = createCubeGeometry();
// 创建实例化属性
const instanceCount = rows * cols;
const instanceValues = new Float32Array(instanceCount);
const instancePositions = new Float32Array(instanceCount * 3);
const instanceScales = new Float32Array(instanceCount * 3);
let instanceIndex = 0;
for (let i = 0; i < rows; i++) {
const row = dataGrid[i];
if (!Array.isArray(row)) continue;
for (let j = 0; j < cols; j++) {
const value = typeof row[j] === "number" ? row[j] : 0;
const normalizedValue = (value - min) / (max - min);
const height = 0.1 + normalizedValue * props.maxHeight;
// 设置实例值 (归一化值)
instanceValues[instanceIndex] = normalizedValue;
// 设置实例位置
const x = (i - (rows - 1) / 2) * props.baseSize;
const z = (j - (cols - 1) / 2) * props.baseSize;
const y = height / 2; // 柱体中心在高度的一半
instancePositions[instanceIndex * 3] = x;
instancePositions[instanceIndex * 3 + 1] = y;
instancePositions[instanceIndex * 3 + 2] = z;
// 设置实例缩放
instanceScales[instanceIndex * 3] = props.baseSize; // X 缩放
instanceScales[instanceIndex * 3 + 1] = height; // Y 缩放 (高度)
instanceScales[instanceIndex * 3 + 2] = props.baseSize; // Z 缩放
// 存储实例数据用于交互
instanceData[instanceIndex] = {
value,
x: i,
z: j,
normalizedValue,
};
instanceIndex++;
}
}
console.log(`创建 ${instanceCount} 个实例`);
// 添加实例属性
cubeGeometry.setAttribute("instanceValue", new THREE.InstancedBufferAttribute(instanceValues, 1));
cubeGeometry.setAttribute("instancePosition", new THREE.InstancedBufferAttribute(instancePositions, 3));
cubeGeometry.setAttribute("instanceScale", new THREE.InstancedBufferAttribute(instanceScales, 3));
// 创建 InstancedMesh
instancedMesh = new THREE.InstancedMesh(cubeGeometry, shaderMaterial, instanceCount);
heatmapGroup.add(instancedMesh);
// 更新网格辅助线
updateGridHelper();
console.log("热力图创建完成");
}
// ========== 更新网格辅助线 ==========
let gridHelper: THREE.GridHelper | null = null;
let axesHelper: THREE.AxesHelper | null = null;
function updateGridHelper() {
// 清理旧的辅助线
if (gridHelper) {
scene.remove(gridHelper);
gridHelper = null;
}
if (axesHelper) {
scene.remove(axesHelper);
axesHelper = null;
}
const { showGridHelper, showAxesHelper } = props;
if (showGridHelper) {
const gridSize = Math.max(rows, cols) * props.baseSize * 1.5;
const divisions = Math.max(rows, cols);
gridHelper = new THREE.GridHelper(gridSize, divisions, 0xffffff, 0x888888);
gridHelper.position.y = -0.01;
scene.add(gridHelper);
}
if (showAxesHelper) {
const axesSize = Math.max(rows, cols) * props.baseSize * 0.8;
axesHelper = new THREE.AxesHelper(axesSize);
scene.add(axesHelper);
}
}
// ========== 动画循环 ==========
function animate() {
animationId = requestAnimationFrame(animate);
if (controls) controls.update();
renderer.render(scene, camera);
}
// ========== 数据动画 ==========
function animateData() {
if (!instancedMesh) return;
const geometry = instancedMesh.geometry;
const instanceValueAttribute = geometry.getAttribute("instanceValue") as THREE.InstancedBufferAttribute;
const instanceScaleAttribute = geometry.getAttribute("instanceScale") as THREE.InstancedBufferAttribute;
const instanceValues = instanceValueAttribute.array as Float32Array;
const instanceScales = instanceScaleAttribute.array as Float32Array;
for (let i = 0; i < instanceData.length; i++) {
// 更新数据值
const currentValue = instanceData[i].value;
const newValue = Math.min(20, Math.max(1, currentValue + (Math.random() - 0.5) * 0.3));
// 计算归一化值和高度
const normalizedValue = (newValue - dataMin) / (dataMax - dataMin);
const height = 0.1 + normalizedValue * props.maxHeight;
// 更新实例数据
instanceData[i].value = newValue;
instanceData[i].normalizedValue = normalizedValue;
// 更新实例属性
instanceValues[i] = normalizedValue;
instanceScales[i * 3 + 1] = height; // 更新高度缩放
}
// 标记属性需要更新
instanceValueAttribute.needsUpdate = true;
instanceScaleAttribute.needsUpdate = true;
}
// ========== 鼠标交互 ==========
let raycasterTimeout: number | null = null;
function onMouseMove(event: MouseEvent) {
if (!containerRef.value || !instancedMesh) return;
const rect = containerRef.value.getBoundingClientRect();
if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY < rect.bottom) {
tooltip.value.show = false;
return;
}
// 防抖处理
if (raycasterTimeout) {
cancelAnimationFrame(raycasterTimeout);
}
raycasterTimeout = requestAnimationFrame(() => {
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObject(instancedMesh);
if (intersects.length > 0) {
const instanceId = intersects[0].instanceId;
if (instanceId !== undefined && instanceData[instanceId]) {
const data = instanceData[instanceId];
tooltip.value = {
show: true,
x: event.clientX - rect.left + 10,
y: event.clientY - rect.top - 30,
content: `数值: ${data.value.toFixed(2)}<br>位置: (${data.x}, ${data.z})<br>高度: ${(data.normalizedValue * 100).toFixed(1)}%`,
};
}
} else {
tooltip.value.show = false;
}
});
}
// ========== 窗口大小调整 ==========
function onResize() {
if (!camera || !renderer) return;
camera.aspect = props.width / props.height;
camera.updateProjectionMatrix();
renderer.setSize(props.width, props.height);
}
// ========== 暴露方法 ==========
defineExpose({
refresh: createHeatmap,
toggleAnimation: () => (isAnimating.value = !isAnimating.value),
updateData: (newData: number[][]) => {
createHeatmap();
},
});
</script>
<style scoped lang="scss">
.heatmap-wrapper {
position: relative;
border: 1px solid #ccc;
background: #f8f8f8;
}
.three-container {
display: block;
width: 100%;
height: 100%;
}
.tooltip {
position: absolute;
z-index: 10;
padding: 8px 12px;
font-size: 14px;
border: 1px solid rgba(255, 255, 255, 30%);
border-radius: 6px;
white-space: nowrap;
color: #fff;
background: rgba(0, 0, 0, 90%);
pointer-events: none;
}
</style>
优化版本调用示例
javascript
<template>
<div>
<HeatMap2
ref="heatmap3D"
:cameraPosition="{
x: 18,
y: 18,
z: 0,
}"
:data="heatmapData"
width="675"
height="475"
base-size="0.5"
max-height="5"
:auto-animate="true"
@refresh="onHeatmapRefresh" />
<div style="margin-top: 10px">
<Button @click="refreshHeatmap">刷新热力图</Button>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: "JBHeatMap2" });
const heatmap3D = ref(null);
function generateData(rows: number, cols: number): number[][] {
const totalElements = rows * cols;
// 计算各区间元素数量(四舍五入)
const count_40_to_50 = Math.round(totalElements * 0.02); // 2%
const count_20_to_30 = Math.round(totalElements * 0.2); // 20%
const count_10_to_20 = Math.round(totalElements * 0.3); // 30%
const count_1_to_5 = totalElements - count_40_to_50 - count_20_to_30 - count_10_to_20; // 剩余 48%
console.log({
total: totalElements,
"40-50": count_40_to_50,
"20-30": count_20_to_30,
"10-20": count_10_to_20,
"1-5": count_1_to_5,
});
// 初始化一个扁平数组用于存储所有值
const values: number[] = [];
// 生成指定数量的随机值,加入数组
// 1. 40--50 区间(含)
for (let i = 0; i < count_40_to_50; i++) {
values.push(Math.floor(Math.random() * 11) + 40); // 40~50
}
// 2. 20--30 区间(含)
for (let i = 0; i < count_20_to_30; i++) {
values.push(Math.floor(Math.random() * 11) + 20); // 20~30
}
// 3. 10--20 区间(含)
for (let i = 0; i < count_10_to_20; i++) {
values.push(Math.floor(Math.random() * 11) + 10); // 10~20
}
// 4. 1--5 区间(含)
for (let i = 0; i < count_1_to_5; i++) {
values.push(Math.floor(Math.random() * 5) + 1); // 1~5
}
// 打乱数组顺序,避免区域聚集
// Fisher-Yates 洗牌算法
for (let i = values.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[values[i], values[j]] = [values[j], values[i]];
}
// 转为二维数组
const dataGrid: number[][] = [];
let index = 0;
for (let i = 0; i < rows; i++) {
const row: number[] = [];
for (let j = 0; j < cols; j++) {
row.push(values[index++]);
}
dataGrid.push(row);
}
return dataGrid;
}
// 使用示例
const heatmapData = ref(generateData(100, 200)); // 初始化50*100的热力图数据,其中2%的值为50,其他为5
function toggleAnimation() {
heatmap3D.value.toggleAnimation();
}
function refreshHeatmap() {
heatmapData.value = generateData(100, 200); // 刷新为新的30*70热力图数据
heatmap3D.value.refresh(); // 调用组件暴露的方法,刷新热力图
}
function onHeatmapRefresh() {
console.log("热力图已刷新");
}
</script>
<style lang="scss" scoped></style>
优化版本效果

后续更新热力图2D/3D同动态数据联动效果
