热力图能够直观看到大盘涨跌情况及股票和板块间相关性。平面热力图使用颜色来区分,使用echart报表组件容易实现。但使用3d形式比2D更直观,更有交互性可操作性。
HS300Sandbox: 使用Three.Js ,展示沪深300涨跌大盘云图。
先看效果图:
先分析下实现原理:画300个立方体;立方体柱面颜色跟涨跌幅一致,颜色深度表示涨跌程度;用市值大小用高度表示。
Three.js
封装了底层的 WebGL(Web 图形库)API,简化了 3D 渲染的复杂度,让开发者无需深入理解 WebGL 的底层细节,就能快速实现高质量的 3D 效果。
ThreeJs基本要素,包括:
-
场景scence: 所有 3D 物体、光源、相机等容器。
-
相机Camera: 以相机视角,决定了场景中的内容哪些部分会被渲染到屏幕上。
-
渲染器 render: 将场景和相机的内容绘制到网页的canvas元素上。
-
几何体Geomery: 定义物体的形状
-
材质material: 定义物体的外观
-
光源 light: 影响物体的明暗和阴影效果
网格(Mesh):
网格是 3D 计算机图形学中最常见的可见对象,用于显示各种 3D 对象------猫、狗、人类、树木、建筑物、花卉和山脉都可以使用网格来表示。

整个过程如下图:
场景(Scene)是一个大舞台,舞台里有个相机(Camera),相机决定了物体距离感,角度等。场景中的网格就是一个个演员(Mesh)。演员有不同材质(material)的,比如是黑皮肤,白皮肤。脸有长脸,方脸,圆脸等这就几何。把相机的看到的通过放映机(Render),呈现到幕布(Canvas)上. 整个工作流完成。

Ok,我们已经知道了整个工作流程,比葫芦画瓢,我们按照这个流程开始撸代码。
-1. 准备沪深300成分股。
-2. 初始化场景,相机
-3,画300个立方体,并写文字
我们股票数据结构如下:
1 // 封装JSON数据为全局变量
2 export default [
3
4 {
5 "code": "000001",
6 "name": "平安银行",
7 "weight": 0.446
8 },
9 {
10 "code": "000002",
11 "name": "万科A",
12 "weight": 0.19
13 },
14 `````
15 ]
初始化的代码,创建场景,初始化相机,初始化渲染器。
// 初始化场景
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xE0E0E0); // 偏灰色背景色
// 初始化相机,设置合理的远裁剪面以适应所有缩放级别
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 10000);
// 调整相机位置
camera.position.set(12, 20, 12); // 提高相机高度和距离,扩大视野
camera.lookAt(0, 0, 0); // 相机看向原点
// 初始化渲染器
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
原本我准备想画山,实际操作下来太麻烦,根本hold不住,画柱子是最简单。把代码中出现的变量mountains 当成cube立方体即可。初始化完成后,下面主要工作就是循环创建300个立方体。因为代码比较长,只给出了部分功能代码。
代码逻辑是:
-
循环为创建每个立方体,new THREE.BoxBufferGeometry(width, height, depth)
-
循环内,每个立方体添加材质,new THREE.MeshPhongMaterial(), 传入颜色,光泽度等属性
1 function generateMountainsFromJSON(data) {
2 // 使用公共的颜色配置方法
3 const colorConfig = createColorConfig();
4
5 ...
6 // 为每个立方体创建几何体和材质,并设置颜色
7 mountainData.forEach((item, index) => {
8 // 存储原始数据,用于时间序列更新
9 item.originalData = {
10 volume: item.volume,
11 weight: item.weight,
12 name: item.name,
13 code: item.code
14 };
15
16 // 使用BoxBufferGeometry创建立方体
17 const geometry = new THREE.BoxBufferGeometry(width, height, depth);
18
19 // 根据涨跌幅设置颜色
20 let cubeColor = colorConfig.getColor(currentIncrease);
21
22 // 创建材质,增加边框效果提高立方体之间的区分度
23 const material = new THREE.MeshPhongMaterial({
24 color: cubeColor,
25 shininess: 60, // 增加光泽度
26 emissive: cubeColor.multiplyScalar(0.2), // 添加适当的自发光效果
27 wireframe: false,
28 side: THREE.FrontSide,
29 transparent: false
30 });
31
32 const mountain = new THREE.Mesh(geometry, material);
33 // 存储必要的属性,用于后续更新
34 mountain.code = item.code;
35 mountain.originalWeight = item.weight;
36 mountain.name = `${item.name} (${item.code}) - 涨跌幅: ${(currentIncrease * 100).toFixed(2)}%`;
37
38 // 设置立方体位置,使其底部与地面接触
39 mountain.position.y = height / 2;
40
41 });
42
43 ...
44
45 // 设置每个立方体的位置并添加到场景
46 const mountains = [];
47
48 // 为每个立方体设置固定大小和属性
49 mountainData.forEach((item, index) => {
50 const increase = increaseData[item.code] || 0;
51 item.mountain.name = `${item.name} (${item.code}) - 涨跌幅: ${(increase * 100).toFixed(2)}%`; // 设置名称,包含代码和涨跌幅
52
53 // 为立方体添加边框,增强区分度
54 const edges = new THREE.EdgesGeometry(item.mountain.geometry);
55 const edgeMaterial = new THREE.LineBasicMaterial({
56 color: 0x333333, // 深色边框
57 linewidth: 1
58 });
59 const edgeMesh = new THREE.LineSegments(edges, edgeMaterial);
60 item.mountain.add(edgeMesh); // 将边框添加到立方体上
61
62 // 在立方体顶面添加名称和涨跌幅文本
63 const itemIncrease = increaseData[item.code] || 0;
64 addTextToCube(item.mountain, item.name, itemIncrease, height, item.mountain.material.color, fixedCubeSize);
65
66 // 为立方体的四个侧面添加文字
67 const sides = ['front', 'back', 'left', 'right'];
68 sides.forEach(side => {
69 addSideTextToCube(item.mountain, item.name, item.code, height, item.mountain.material.color, side);
70 });
71 });
72
73
74 return mountains;
75 }
调用generateMountainsFromJSON方法 返回的mountains就是我们想要的立方体。只要把立方体加入到场景中即可。
scene.add(mountain);
毛坯房建好了,再加入亿点点小细节:
1,股票的流通市值不一样,我们希望市值大的比较突出。
2.,立方体的颜色,能够反映涨跌幅。
3, 需要给顶部添加文字,标识是哪只票及涨跌数据
4,鼠标移动到立方体上tooltip 提示
5,最好侧面也添加文字
6,可以缩放,转动。
好,我们one by one 处理。
1,股票的流通市值不一样,我们希望市值大的比较突出。
我们可以用上沪深300按照市值算出的个股权重,使用立方体高度来衡量。
// 计算最终高度= 基础高度 + 权重高度 + 涨跌幅高度
const weightHeight = baseHeight + scaledWeight * weightScaleFactor;
const calculatedHeight = weightHeight + increase * increaseScaleFactor;
const height = Math.max(minHeight, calculatedHeight);
// 使用BoxBufferGeometry创建立方体
const geometry = new THREE.BoxBufferGeometry(width, height, depth);
2,给立方体添加颜色,反映涨跌
// 使用BoxBufferGeometry创建立方体
const geometry = new THREE.BoxBufferGeometry(width, height, depth);
// 根据涨跌幅设置颜色
let cubeColor = colorConfig.getColor(currentIncrease);
// 创建材质,增加边框效果提高立方体之间的区分度
const material = new THREE.MeshPhongMaterial({
color: cubeColor,
shininess: 60, // 增加光泽度
emissive: cubeColor.multiplyScalar(0.2), // 添加适当的自发光效果
wireframe: false,
side: THREE.FrontSide,
transparent: false
});
// 将几何体对应材质添加到Mesh网格中
const mountain = new THREE.Mesh(geometry, material);
3,顶部面添加文字
还是老样子,场景中的一切都是mesh。创建平面几何-->添加文字材质-->纹理-->添加到Mesh中.
Mesh可以理解是Dom div节点。Mesh与Mesh组成树状结果。

在three.js中的坐标系是长这个样子的, 补充个重要知识,正旋转 = 逆时旋转

// 为立方体添加顶面文字
function addTextToCube(cube, name, increase, cubeHeight) {
// 创建Canvas作为文字纹理
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
// 设置字体大小
const fontSize = 14;
const increaseFontSize = 12;
context.font = `${fontSize}px Arial`;
// 限制名称长度
const maxNameLength = 10;
let displayName = name;
if (name.length > maxNameLength) {
displayName = name.substring(0, maxNameLength) + '...';
}
// 格式化涨跌幅为百分比,保留两位小数
const increasePercent = (increase * 100).toFixed(2) + '%';
// 测量文本宽度和高度
const nameWidth = context.measureText(displayName).width + 10;
context.font = `${increaseFontSize}px Arial`;
const increaseWidth = context.measureText(increasePercent).width + 10;
const textWidth = Math.max(nameWidth, increaseWidth);
const textHeight = (fontSize + increaseFontSize + 12); // 两行文字高度加上间距
// 设置canvas尺寸
canvas.width = textWidth;
canvas.height = textHeight;
// 重新设置字体
context.font = `${fontSize}px Arial`;
// 设置文本对齐方式
context.textAlign = 'center';
context.textBaseline = 'middle';
// 强制使用白色文字以确保最高可见性
context.fillStyle = 'rgba(255, 255, 255, 1)';
// 添加黑色描边以提高对比度和可读性
context.strokeStyle = 'rgba(0, 0, 0, 1)';
context.lineWidth = 3; // 增加描边宽度以提高文字清晰度
// 添加阴影效果以增强文字的立体感
context.shadowColor = 'rgba(0, 0, 0, 0.8)';
context.shadowBlur = 4;
context.shadowOffsetX = 1;
context.shadowOffsetY = 1;
// 绘制第一行:名称
context.fillText(displayName, textWidth / 2, fontSize + 2);
// 绘制第二行:涨跌幅
context.font = `${increaseFontSize}px Arial`;
context.fillText(increasePercent, textWidth / 2, fontSize + increaseFontSize + 8);
// 创建纹理
const texture = new THREE.CanvasTexture(canvas);
texture.needsUpdate = true;
// 创建文字材质
const textMaterial = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
side: THREE.DoubleSide
});
// 创建立方体顶面大小的平面几何体
const textGeometry = new THREE.PlaneGeometry(FIXED_CUBE_SIZE * 0.8, FIXED_CUBE_SIZE * 0.4); // 文字板的大小
// 创建文字网格
const textMesh = new THREE.Mesh(textGeometry, textMaterial);
// 设置文字位置在立方体顶面中心
textMesh.position.set(0, cubeHeight / 2 + 0.1, 0);
// 使用负角度旋转,确保从顶部观看时文字不被镜像
textMesh.rotation.x = -Math.PI / 2;
// 将文字添加到立方体
cube.add(textMesh);
// 确保文字可见性设置正确
textMesh.visible = true;
textMesh.material.visible = true;
textMesh.material.opacity = 1.0;
}
4,鼠标移动到立方体上tooltip 提示
判断鼠标是否悬停在立方体顶面,这里需要用到 Raycaster 射线器, 它的原理是这样的,可以理解成手电射出一道光,照到某个地方,判断有没有光照到物体(即Mesh网格)。我们可以从相机位置照到鼠标的位置
看有没有重合。就能判断鼠标是不是在某个物体上。
// 创建射线投射器,用于检测鼠标是否悬停在山峰上
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
// 最近悬停的山峰对象
let hoveredMountain = null;
// 更新鼠标位置
function updateMousePosition(event) {
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
}
// 检测鼠标悬停
function detectHoveredMountain(event) {
updateMousePosition(event);
// 更新射线投射器
raycaster.setFromCamera(mouse, camera);
// 计算与所有山峰的交点
const intersects = raycaster.intersectObjects(mountains || []);
// 如果有交点
if (intersects.length > 0) {
const intersect = intersects[0];
const mountain = intersect.object;
// 如果悬停在新的山峰上
if (mountain !== hoveredMountain) {
hoveredMountain = mountain;
// 动态获取当前时间点的涨跌幅数据,而不是使用静态的mountain.name
const currentIncrease = increaseData[mountain.code] || 0;
const increasePercent = (currentIncrease * 100).toFixed(2) + '%';
// 提取mountain.name中的名称部分(不含涨跌幅信息)
let displayName = mountain.name;
if (displayName.includes(' - 涨跌幅:')) {
displayName = displayName.split(' - 涨跌幅:')[0];
}
// 设置tooltip文本为名称和当前时间点的涨跌幅
tooltip.textContent = `${displayName} - 涨跌幅: ${increasePercent}`;
tooltip.style.left = event.clientX + 10 + 'px';
tooltip.style.top = event.clientY - 30 + 'px';
tooltip.style.display = 'block';
} else {
// 更新tooltip位置和内容,确保始终显示最新数据
const currentIncrease = increaseData[mountain.code] || 0;
const increasePercent = (currentIncrease * 100).toFixed(2) + '%';
let displayName = mountain.name;
if (displayName.includes(' - 涨跌幅:')) {
displayName = displayName.split(' - 涨跌幅:')[0];
}
tooltip.textContent = `${displayName} - 涨跌幅: ${increasePercent}`;
tooltip.style.left = event.clientX + 10 + 'px';
tooltip.style.top = event.clientY - 30 + 'px';
}
} else {
// 没有悬停在任何山峰上
if (hoveredMountain !== null) {
hoveredMountain = null;
tooltip.style.display = 'none';
}
}
}
关于第5点,侧面添加文字跟顶面添加文本逻辑类似,不再赘述。
6,可以缩放,转动
缩放,转动,可以直接使用 OrbitControls.js插件来实现。
constructor(scene, camera, renderer, options = {}) {
if (!window.THREE) {
console.error('THREE.js 未正确加载');
return;
}
if (!window.THREE.OrbitControls) {
console.error('OrbitControls 插件未正确加载');
return;
}
this.scene = scene;
this.camera = camera;
this.renderer = renderer;
// 初始化OrbitControls,使用renderer的canvas元素作为事件监听器的目标元素
// 这样可以避免与页面上的UI控件(如下拉菜单)产生事件冲突
this.controls = new THREE.OrbitControls(camera, renderer && renderer.domElement ? renderer.domElement : document.body);
// 设置控制参数
this.controls.enableDamping = true; // 启用阻尼效果,使旋转更平滑
this.controls.dampingFactor = options.dampingFactor || 0.05; // 阻尼系数
this.controls.rotateSpeed = options.rotateSpeed || 0.5; // 旋转速度,设置较低值使控制更精确
this.controls.zoomSpeed = options.zoomSpeed || 0.5; // 缩放速度,设置较低值使控制更精确
this.controls.panSpeed = options.panSpeed || 0.5; // 平移速度
// 设置最小和最大距离
this.controls.minDistance = options.minDistance || 200; // 相机最小距离
this.controls.maxDistance = options.maxDistance || 2000; // 相机最大距离
// 设置最小和最大极角,控制垂直方向的旋转范围
this.controls.minPolarAngle = 0; // 最小极角(俯视角度限制)
this.controls.maxPolarAngle = Math.PI / 2; // 最大极角(仰视角度限制),这里设为π/2,即90度
// 设置目标点,相机将围绕这个点旋转
this.controls.target.set(0, 0, 0); // 围绕原点旋转
// 禁用平移控制,只保留旋转和缩放
this.controls.enablePan = options.enablePan || false;
// 更新控制器,应用初始设置
this.controls.update();
}
主要是初始化 new THREE.OrbitControls(camera, renderer && renderer.domElement ? renderer.domElement : document.body); 指定相机和监控的区域。在配置些相关参数。
// 页面加载完成后执行
window.addEventListener('load', async () => {
// 生成box
loadDataAndGenerateMountains();
// 初始化鼠标交互控制器,传入renderer以避免与UI控件的事件冲突
mouseController =createMouseInteractionController(scene, camera, renderer,
{
rotateSpeed: 0.3,
zoomSpeed: 0.5,
minDistance: 200,
maxDistance: 2000
});
});
至此我们已经完成了功能。但是我们发现,物体有点暗。这里我需要调整下光源,好比把屋子里的灯亮度加大。如何做?
我们来学习下光照。我们的眼睛是使用物体表面阴影的差异来确定深度。如果我们不在场景中添加某种形式的光照,它看起来不是3d的。可以使用直接或环境光,或者将光照作为基于图像的光照存储在纹理中。
如果你在一个黑暗的房间里打开一个灯泡,那个房间里的物体会以两种方式接收到光:
- 直接照明:直接来自灯泡并撞击物体的光线。
- 间接照明:光线在击中物体之前已经从墙壁和房间内的其他物体反弹,每次反弹都会改变颜色并失去强度。
与这些相匹配,three.js 中的灯光类分为两种类型:
- 直接光照,模拟直接光照。
- 环境光 ,这是 一种 廉价且可信的间接照明方式。
-
DirectionalLight=> 阳光 -
PointLight=> 灯泡 -
RectAreaLight=> 条形照明或明亮的窗户 -
SpotLight=> 聚光灯 -
AmbientLight => 环境光
我们在舞台场景中,加些灯光既可。
// 添加更强的光源,提高整体对比度
const ambientLight = new THREE.AmbientLight(0x808080); // 增强环境光
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2); // 增强主光源
directionalLight.position.set(2, 3, 2); // 调整光源位置
scene.add(directionalLight);
// 添加辅助光源,提高立体感
const fillLight = new THREE.DirectionalLight(0xffffff, 0.6);
fillLight.position.set(-3, 2, -1);
scene.add(fillLight);
OK,至此我们大功告成,实现了立体式的大盘涨跌云图。吊不吊。
完整代码,Gitee 自取。HS300Sandbox: 使用Three.Js ,展示沪深300涨跌大盘云图。
代码中还包含了实时涨幅动态展示,但是呈现起来效果不够好,也未找到很好方法。