你有没有遇到过这种需求:手里有一张“维度A × 维度B”的交叉表,比如一周七天乘以一天24小时的客流量,或者多个实验组在多个时间点的指标变化,数据一多,堆成表格根本看不下去,做成折线图就是一团乱麻。我第一次接到这种需求时,第一反应是把每个维度拆成多条折线,结果图上线条叠了十几层,连图例都要放大镜找。后来才意识到,这种场景天生适合热力图:两个维度当坐标轴,第三个维度的数值映射成颜色,一眼看出哪里是高峰、哪里是洼地。
Echarts 里做热力图不算冷门,但网上大部分教程止步于“能渲染出来”,对坐标轴类型怎么选、visualMap 区间怎么定、大数据量怎么不卡、地图热力图和网格热力图有什么区别这些真正影响使用体验的问题讲得很少。这篇文章我把做热力图从入门到能上生产环境的完整思路拆一遍,包括配置原理、排查过的坑、以及从模型输出到图表呈现这类特殊热力图的落地经验,适合用过 Echarts 但没系统研究过热力图,或者做过一版但总觉得不对劲的人。
1. 热力图定位:本质是“坐标点 + 数值着色”,不是一种特殊图形
1.1 什么时候该用热力图,什么时候千万别用
先把适用场景说清楚。热力图最适合的输入是三元组数据:两个自变量决定位置,一个因变量决定颜色。最典型的就是时间×地点矩阵、基因×样本表达量、算法混淆矩阵、相关系数矩阵。这类数据的共同特征是:横纵轴都是离散维度,中间每个格子的含义都独立,我们关心的不是某个格子的精确数值,而是整体的分布模式和异常区块。
反过来,两类场景我不建议用热力图。第一类是数据本身存在明确的趋势连续性,比如股价走势、月度销售趋势,折线图能直接读出增减速,热力图只会把信息砸扁。第二类是格子数太少,比如只有三行四列,颜色区分度极低,这种规模直接上表格或普通柱状图就够了,强行热力图属于为了炫技而炫技。
这里还有个反常识的点:热力图并不擅长精确取值。人眼对颜色的感知精度远低于对长度和位置的感知精度,所以当业务方要求“每个格子数值要绝对准确”时,热力图的正确做法是配合 tooltip 精确展示数值,而不是指望靠肉眼从色块里读出来。
1.2 Echarts 中 heatmap 系列的真实运作方式
Echarts 里 heatmap 系列本质上是在直角坐标系里画一个个等宽的矩形格子,然后把每个矩形填充成由 visualMap 控制的颜色。所以它不是独立于坐标系的图形,更像是“散点矩阵的格子化表达”:散点图每个点用大小表达数值,热力图每个格用颜色表达数值。
这意味着三件事。
第一,heatmap 必须依赖一个坐标系,最常用的是直角坐标系,也就是 xAxis、yAxis、grid 三者配合;但如果做地图热力图,可以换成 geo 坐标系。
第二,它的数据格式是三维数组的列表,每一项是[x值, y值, 数值],其中 x 和 y 可以是类目名称,也可以是数值坐标,这取决于坐标轴的类型,后面详说。
第三,heatmap 本身不负责颜色,只负责把矩形画出来,颜色完全由 visualMap 组件接管。如果你只配了 series 没配 visualMap,出来的图是一片默认纯色,没有任何信息量。很多新手在这步卡住,以为代码错了,其实只是少了 visualMap。
这三个认知定下来,后面调什么都有方向。
2. 最小可运行配置:三维数组、双类目轴与第一版热力图
2.1 数据格式的排布逻辑
先看数据。假设我要展示一周七天、每天 8 个时段(0~7点)的事件发生次数,正确数据长这样:
const data = [ // [周几, 时段, 数值] ['周一', 0, 12], ['周一', 1, 34], ['周一', 2, 23], // ...依次把所有交叉格填完 ['周日', 7, 8] ];这里有一个经常踩的坑:数据必须覆盖所有交叉组合。热力图是“按格填色”,如果某个交叉格缺失,这个位置就是空白,看起来像数据丢了,但其实是数据源里压根没这一项。解决办法有两个:要么在数据源端把空值补 0,要么在 series.data 里补项并用值'-'表示缺失,配合 visualMap 把缺失值映射成特殊颜色(比如浅灰)。
另外注意,x 和 y 用类目名时,Echarts 会自动创建空坐标点,这也是很多人以为“简单填个数据就行”却得到错位图的原因:两个轴的类目顺序是否和数据源的顺序一一对应,直接决定格子落位是否正确。稳妥做法是把 xAxis.data 和 yAxis.data 显式传进去,哪怕顺序和数据源里的第一行重复,也要保持一致。
2.2 第一版完整配置
下面是能直接复制跑的配置,注意看每个字段的用途:
option = { tooltip: { position: 'top' }, grid: { left: 80, bottom: 60, right: 30, top: 30 }, xAxis: { type: 'category', data: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'], splitArea: { show: true } // 显示分隔区块 }, yAxis: { type: 'category', data: [0, 1, 2, 3, 4, 5, 6, 7], inverse: true, // 让 0 在顶部 splitArea: { show: true } }, visualMap: { min: 0, max: 100, calculable: true, orient: 'horizontal', left: 'center', bottom: 0, inRange: { color: ['#50a3ba', '#eac736', '#d94e5d'] } }, series: [{ type: 'heatmap', data: data, label: { show: false } }] };这段配置里两个细节值得展开。
一个是yAxis.inverse: true。默认类目轴从下往上排,也就是第一个类目在底部。但热力图贴近日常阅读习惯应该是“第一行在最上面”,所以通常要把 y 轴翻转。这是我做热力图被人问得最多的“为什么我的图上下反了”的根因。
另一个是splitArea.show: true。它在每个类目格子间画浅浅的分割线,视觉上让每个数据格独立出来。数据显示量小、格子少的时候,分割线能大幅提升可读性;但到了几百乘几百的矩阵,分割线反而制造视觉噪声,后面性能部分会讲。
跑通这一版后,你已经拿到一张能看出基本分布的热力图了。但离能用还差得远,因为 visualMap 的区间策略还没认真设计。
3. visualMap 调优是热力图的灵魂:区间、渐变与阈值
3.1 连续型 visualMap:min/max 定不好,图就是废的
visualMap 是整个热力图的信息编码核心。默认写法min: 0, max: 100其实很危险,因为如果实际数据范围是 0~1000,所有颜色都会挤在最深的几档,图上看起来全是深红色,分布细节完全丢失;反过来,如果数据范围是 0~5,浅色区域又会占据绝大多数画面,高值区对比不足。
正确做法是先扫一遍数据的真实范围,再做对齐。实践中我通常写一个小函数:
function getDataRange(data) { let min = Infinity; let max = -Infinity; for (let i = 0; i < data.length; i++) { const v = data[i][2]; if (v < min) min = v; if (v > max) max = v; } return { min, max }; }然后visualMap.min = range.min,visualMap.max = range.max。
但这里有个隐蔽问题:极端值。如果数据里有 99.9% 的值在 0~10,蹦出一个 100 的极端值,直接用真实最大最小值,会把整个色阶区间拉到 0~100,正常值全部挤在色阶底部,图变成一块均色板。我的习惯是取分位数而不是极值,比如 P95 作为 max,超过 P95 的值统一显示最深色,这样能保住有效信息的对比度。如果业务上需要完整真实范围,那就接受局部无对比的现实,决定权在业务方。
颜色梯度上,Echarts 接受数组形式的离散色阶,比如['#313695', '#74add1', '#e0f3f8', '#fdae61', '#f46d43', '#a50026'],它会在这些颜色之间插值。建议至少给 3 个锚点色,只用两端颜色的话中间过渡太单调,浅值区和深值区的边界会不明显。
3.2 piecewise 分段与相关系数类数据的阈值设定
连续型 visualMap 适合一般业务指标,但有一类数据必须用分段型,就是相关系数矩阵,也就是热搜里经常出现的“相关热力图阈值”场景。
相关系数范围是 -1 到 1,正负以 0 为界,业务上通常把|r| >= 0.6视为强相关,0.3 ~ 0.6中等,< 0.3视为弱相关。如果直接用连续型渐变,分界线会糊成一片,很难精确归类。这时候切成 piecewise 最稳妥:
visualMap: { type: 'piecewise', pieces: [ { min: 0.7, max: 1, label: '强正相关', color: '#b71c1c' }, { min: 0.3, max: 0.7, label: '中正相关', color: '#ff8a80' }, { min: -0.3, max: 0.3, label: '弱相关', color: '#eceff1' }, { min: -0.7, max: -0.3, label: '中负相关', color: '#80d8ff' }, { min: -1, max: -0.7, label: '强负相关', color: '#0d47a1' } ] }顺便说一个做相关系数热力图时的通病:忘了把中间值映射到浅色、两端映射到深色。连续型如果用默认['蓝', '红']做渐变,零值会被渲染成紫色,不是视觉上“中性的浅色”,读者会下意识把紫色当成一种特殊状态。正确做法是在颜色数组中间放浅色作为零值锚点,比如['#2166ac', '#f7f7f7', '#b2182b'],让 0 落在白色上。
阈值设定的逻辑同样适用于信号类热力图,比如 RT-DETR 这类目标检测模型输出的置信度矩阵。阈值不是视觉参数,应该在数据层处理:小于阈值的值置为'-'或置 0,再交给 visualMap,而不是靠颜色区间硬分,否则图例和真实结果对不上。
4. 让热力图真正可读:tooltip 换行、label 与坐标轴细节
4.1 tooltip 自动换行的正确姿势
Echarts 的 tooltip 默认内容在一行里拼命挤,尤其是热力图这种数据项里同时含有横轴名、纵轴名、数值三个信息,长名称一旦超宽就会溢出屏幕被截断。网上很多方案是把 formatter 写死成带<br/>的模板,比如:
tooltip: { formatter: function(params) { const [x, y, value] = params.value; return `横轴:${x}<br/>纵轴:${y}<br/>数值:${value}`; } }这是能用的,但如果想完全按容器宽度自适应换行,formatter 返回 HTML 字符串然后用 CSS 控制是最灵活的。配合tooltip.extraCssText可以调整提示框内部样式:
tooltip: { extraCssText: 'max-width:240px;word-break:break-all;white-space:normal;' }有个细节:Echarts 5 里 tooltip 的confine: true也很重要,它会让提示框强制限制在图容器内部,而不是撑出容器被裁掉,大屏左上角的数据项尤其容易触发。
4.2 label 显示策略与坐标轴标签旋转
热力图是否显示格内数值,这个决策直接影响可读性,取决于格子密度。
当格子总数少于 200 时,label.show = true基本没问题,用户可以直接读数值。格子数 200~1000 之间,建议只在关键高值区显示 label,这个 Echarts 原生没法按条件控制,我的方案是在 formatter 里判断数值大小,超过某一阈值才返回数值,否则返回空字符串。当格子数超过 1000,建议彻底关掉 label,颜色已经是最好的信息载体,硬显示只会变成“马赛克上的苍蝇”。
坐标轴标签是另一个容易被忽略的点。类目轴名称一旦过长,比如时间戳2024-01-01 08:00:00,横向排列会互相覆盖。处理方式有两个:一是xAxis.axisLabel.interval按步长跳着显示;二是rotate: 45旋转,旋转角度在 45 度时兼顾紧凑和可读性,低于 30 度容易造成歧义,高于 60 度阅读太费力。
还有一类情况:当格子尺寸小于 10px 时,即便旋转也会重叠,这时候与其调 label,不如调整 grid 的宽度和高度,让轴标签有足够空间。我一般这样估算:每个标签约 60px 宽,x 轴类目数乘 60 加上左右留白,就是 grid 至少需要的宽度。没算清楚之前,先画出来看,标签挤了就加高 grid.bottom,别让 canvas 默认的 60px 高度背锅。
5. 从网格走向地图与信号类扩展:热力图的另外两种形态
5.1 行政区域地图热力图:地图注册与 visualMap 联动
网格热力图做熟了,很容易遇到地图上做热力的需求,典型的场景是“各区域指标分布”,也是热搜里搜得最多的方向之一。
地图热力图和网格热力图有本质区别:坐标系不再是直角坐标系,而是地理坐标系。在 Echarts 里实现分两步。
第一步是准备地图数据。官方地图包已经不在默认 bundle 里,需要单独引入 GeoJSON 或从第三方渠道拿地图数据,然后注册:
import chartData from './area.geo.json'; echarts.registerMap('area', chartData);第二步是用 geo 组件或 map 系列。两者的选择标准我总结一下:
| 需求类型 | 推荐方案 | 原因 |
|---|---|---|
| 只想给区域填充颜色展示分布 | map 系列 + visualMap | 配置简单,color 直接映射到区域填充 |
| 区域上还要叠加散点、气泡等 | geo 组件 + 散点系列 + visualMap | geo 只负责底图,数据系列可以独立控制 |
| 想同时展示多个指标 | 多个 map 系列切换 | 每次切换一个 series,视觉无残留 |
以最常见的纯区域着色为例:
option = { tooltip: {}, visualMap: { min: 0, max: 1000, left: 'right', inRange: { color: ['#d9edf7', '#f0ad4e', '#d9534f'] } }, series: [{ type: 'map', map: 'area', roam: true, label: { show: false }, data: [ { name: '区域A', value: 321 }, { name: '区域B', value: 654 } ] }] };地图热力图上最容易翻车的点:地图数据里name字段和业务数据里的名字必须完全一致,多一个空格、名称不规范都会导致该区域渲染成空白。我在项目里统一用“先校验再渲染”的策略:业务数据拿到后,先和地图数据的features.name列表做一次交集比对,把对不上的名字打印出来,能省下大量排查时间。
5.2 信号/检测热力图:从 RT-DETR 等模型输出到前端呈现
另一个高频搜索词是“信号热力图”和“rtdetr 热力图”。这类需求的本质是:你有一个模型或传感器,输出的是某个区域每个位置的置信度或信号强度,要在前端把这张“热度图”画出来。
信号热力图在数据形态上和前面的网格热力图一致,也是三维数组[x坐标, y坐标, 强度值],区别在于坐标轴通常是数值轴,而不是类目轴。把 xAxis 和 yAxis 的 type 改成'value',坐标系会自动按数值定位格子,而不是按名称索引:
xAxis: { type: 'value', min: 0, max: 640 }, yAxis: { type: 'value', min: 0, max: 480, scale: true }实际做 RT-DETR 热力图时,我通常不在前端直接渲染原始逐像素置信度,而是先把模型输出的特征图下采样成 32×32 或 64×64 网格,再把网格值按坐标映射成数据点。这样有几个好处:数据体积小、渲染快、视觉上自动做了平滑。如果前端拿到的已经是单通道特征图,可以用 canvas 读取像素值,以 4 个像素为步长抽取,生成数据数组:
function featureToHeatmapData(imageData, step = 4) { const data = []; for (let y = 0; y < imageData.height; y += step) { for (let x = 0; x < imageData.width; x += step) { const idx = (y * imageData.width + x) * 4; const val = imageData.data[idx]; // 单通道灰度值 data.push([x, y, val]); } } return data; }阈值控制同理,可以放在特征提取阶段,也可以放在 Echarts 的 data 构造阶段。我的建议是放在前者,因为模型侧阈值可以同时影响显示数量和算法判定逻辑,保持一致,避免前后端两套阈值互相冲突。
6. 大屏项目里最容易翻车的三个细节
6.1 数据量变大后的性能与渲染器选型
热力图性能瓶颈有两个阶段。
第一阶段是网格数中等(几百到几千格子)时,瓶颈通常在 DOM 渲染和 tooltip 监听。Echarts 默认使用 canvas 渲染器,canvas 对大量矩形填充的效率极高,这个阶段基本不用优化。
第二阶段是网格数上万,比如 200×200 = 40000 个格子,如果每帧都在 hover 事件里频繁触发 tooltip,卡顿感会非常明显。这里有个关键优化:把 series 的progressive参数调低,让 Echarts 分块绘制,而不是一次性全部画完。默认 400 个数据点就开始渐进渲染,对热力图来说可以设成 1000~2000。
另一个容易忽略的是renderer选择。SVG 渲染器在格子数少、需要高清导出时效果好,但数据量上来后性能会显著下降。我的判断标准很简单:超过 5000 个格子直接用 canvas,需要导出高清图时单独用 SVG 渲一版,而不是在同一张图上纠结。
大屏场景还有个独有坑:网格分割线。默认splitArea.show: true在 50×50 网格下会画 2500 条分隔线,canvas 压力反而在填充之外增加大量描边。上大屏前我会统一关掉分割线,需要看格子边界时用itemStyle.borderWidth控制粗细分隔,视觉效果更干净。
6.2 容器尺寸变化引发的重绘问题
大屏项目几乎都会涉及自适应。Echarts 默认不会监听容器尺寸变化,窗口一缩放,图表就糊在旧尺寸里。常规做法是:
window.addEventListener('resize', () => chart.resize());但真实大屏项目更推荐用 ResizeObserver 监听图表容器本身,因为大屏布局里经常有侧栏展开收起、tab 切换这类不改变窗口宽度、只改变局部容器尺寸的操作:
const observer = new ResizeObserver(() => chart.resize()); observer.observe(containerEl);还要注意一点:chart.resize()后热力图的格子尺寸不会自动重新计算,视觉上会出现格子间距不均。解决办法是在 resize 后重新设置 grid,或者直接把 option 里没有变化的部分再 setOption 一遍,触发内部布局刷新。
6.3 pxToRem 对 Echarts 不生效的真相
热搜里专门有一条“pxtorem 对 echarts 没起到效果”,这个坑我在移动端项目里也踩过。
原因其实很简单:Echarts 的渲染发生在 canvas 内部,而 pxToRem 这类 postcss 插件只处理 CSS 样式,根本管不到 canvas 里的绘制尺寸。Echarts 内部所有字体、宽度、边框都是直接读配置里的数值,以像素为单位绘制的。
所以在适配移动端或大屏缩放时,不能用 CSS 方案一劳永逸。我的做法是在初始化前根据容器宽度计算一个缩放系数,动态设置基础字号和格子尺寸:
const scale = Math.min(containerWidth / 1920, containerHeight / 1080); const baseFontSize = 12 * scale;然后把所有和字号、padding 相关的配置统一用 baseFontSize 计算。这套方案在常规大屏上跑下来,效果比 root font-size 方案稳定得多,也省去了一堆媒体查询。
如果项目已经用了 rem 布局且不想全局改造,还可以在初始化时强制指定:
const chart = echarts.init(container, null, { renderer: 'canvas', width: 'auto', height: 'auto' });然后依赖外层的 transform scale 做整体缩放,但这种方式缩放后 tooltip 的定位会偏移,需要额外处理提示框位置,复杂度不低,不如一开始就用动态系数计算。
最后说一个我自己的习惯:热力图调完色阶,我会随手把 visualMap 关闭再打开一次,确认数据重映射没有违和感。这一步虽然简单,但能在交付前及时发现 min/max 设错、颜色翻转这些问题,比我见过的大多数 debug 手段都有效。做数据可视化,做到能看只是起点,真正做到让业务方指着图上某块颜色说出“这里有问题”的那一刻,这图才算合格。