你有没有遇到过这种需求手里有一张“维度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 连续型 visualMapmin/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.minvisualMap.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 组件 散点系列 visualMapgeo 只负责底图数据系列可以独立控制想同时展示多个指标多个 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 手段都有效。做数据可视化做到能看只是起点真正做到让业务方指着图上某块颜色说出“这里有问题”的那一刻这图才算合格。