3D热力图封装组件:Vue + Three.js 实现3D图表详解

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实现,支持数据动态渲染,颜色渐变,鼠标悬停提示,自动旋转动画,轨道控制,缩放,鼠标移入提示效果,响应式尺寸,支持网格线和坐标轴辅助显示,提供refreshtoggleAnimation方法供外部开发调用。

实现方案

第一步、数据格式

输入为一个二维数组

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):统一使用该柱体等级的"最高色"(视觉突出)。

侧面:按高度进行多段垂直渐变,体现"温度梯度"。

第六步、设计组件化封装配置

  1. Prop:类型 说明。
  2. datanumber[][] 热力图数据。
  3. width/heightnumber 容器尺寸。
  4. baseSizenumber 柱体底面尺寸。
  5. maxHeightnumber 柱体最大高度。
  6. rotateAnimationboolean 是否开启自动旋转。
  7. rotationSpeednumber 旋转速度。
  8. isStandardColorboolean 是否使用十段渐变色。
  9. rotateAnimationBoolean 是否启用旋转动画
  10. 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同动态数据联动效果

完结~

相关推荐
向宇it3 小时前
【推荐100个unity插件】unity易于使用模块化设计的天空、体积云和天气系统——Enviro 3
游戏·3d·unity·c#·游戏引擎
Moment4 小时前
Next.js 16 新特性:如何启用 MCP 与 AI 助手协作 🤖🤖🤖
前端·javascript·node.js
吃饺子不吃馅4 小时前
Canvas高性能Table架构深度解析
前端·javascript·canvas
一枚前端小能手4 小时前
🔄 重学Vue之生命周期 - 从源码层面解析到实战应用的完整指南
前端·javascript·vue.js
一枚前端小能手4 小时前
「周更第9期」实用JS库推荐:mitt - 极致轻量的事件发射器深度解析
前端·javascript
Moment4 小时前
为什么 Electron 项目推荐使用 Monorepo 架构 🚀🚀🚀
前端·javascript·github
掘金安东尼4 小时前
🧭前端周刊第437期(2025年10月20日–10月26日)
前端·javascript·github
Mintopia4 小时前
🧠 AIGC + 区块链:Web内容确权与溯源的技术融合探索
前端·javascript·全栈
晓得迷路了4 小时前
栗子前端技术周刊第 103 期 - Vitest 4.0、Next.js 16、Vue Router 4.6...
前端·javascript·vue.js