从零开始-使用three.Js实现沪深300股票热力图
热力图能够直观看到大盘涨跌情况及股票和板块间相关性。平面热力图使用颜色来区分,使用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就是我们想要的立方体。只要把立方体加入到场景中即可。
// 计算最终高度= 基础高度 + 权重高度 + 涨跌幅高度 const weightHeight = baseHeight + scaledWeight * weightScaleFactor; const calculatedHeight = weightHeight + increase * increaseScaleFactor; const height = Math.max(minHeight, calculatedHeight);
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,顶部面添加文字

在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涨跌大盘云图。
代码中还包含了实时涨幅动态展示,但是呈现起来效果不够好,也未找到很好方法。
本文来自博客园,作者:至道中和,转载请注明原文链接:https://www.cnblogs.com/voidobject/p/19193198

浙公网安备 33010602011771号