纯前端实现热力图:分箱统计、颜色映射与高分屏绘制
热力图看起来像「把点堆在一起涂个色」,真写起来却有几个绕不开的问题:网格该多细、颜色怎么过渡、高分屏为什么发虚、点多了怎么不卡。这篇文章用一个纯前端的实现,把这几件事讲清楚。
一、先分箱,再上色
输入的是一组二维坐标,输出是一个密度矩阵。最直接的做法是把画布切成 cols × rows 的网格,每个点落在哪个格子,就给哪个格子的计数加一:
const grid = new Float32Array(cols * rows);
for (const [x, y] of points) {
const cx = Math.min(cols - 1, Math.floor(x / cellW));
const cy = Math.min(rows - 1, Math.floor(y / cellH));
grid[cy * cols + cx] += 1;
}
两个细节容易出错:一是右边界和下边界的点会算出等于 cols 的索引,必须夹到最后一格;二是格子太小会让大部分格子为 0,图看起来像散点,格子太大又会糊成一团。经验值是让格子边长在 8~16 像素之间。
二、平滑:让格子之间连起来
直接用格子画矩形,会得到一张马赛克。想要平滑,可以在采样颜色时做双线性插值:对每个像素找到它落在哪四个格子之间,按距离加权。
function sample(grid, cols, rows, fx, fy) {
const x0 = Math.floor(fx), y0 = Math.floor(fy);
const x1 = Math.min(x0 + 1, cols - 1);
const y1 = Math.min(y0 + 1, rows - 1);
const tx = fx - x0, ty = fy - y0;
const a = grid[y0 * cols + x0], b = grid[y0 * cols + x1];
const c = grid[y1 * cols + x0], d = grid[y1 * cols + x1];
return (a * (1 - tx) + b * tx) * (1 - ty) + (c * (1 - tx) + d * tx) * ty;
}
如果想让每个点的影响范围更自然,可以先用高斯核把点「撒」到周围格子,再做插值。代价是计算量上升,适合点数量在几万以内的场景。
三、颜色映射:分段线性插值
密度值要先归一化到 0~1,再映射到颜色。相比 hsl 直接旋转色相,用一条固定的色带更可控,也更容易保证低值区域足够透明:
const stops = [
[0.0, [0, 0, 255, 0]],
[0.2, [0, 128, 255, 90]],
[0.5, [0, 255, 180, 160]],
[0.8, [255, 230, 0, 210]],
[1.0, [255, 60, 0, 240]],
];
function colorAt(t) {
for (let i = 1; i < stops.length; i++) {
if (t <= stops[i][0]) {
const [p0, c0] = stops[i - 1], [p1, c1] = stops[i];
const k = (t - p0) / (p1 - p0);
return c0.map((v, j) => Math.round(v + (c1[j] - v) * k));
}
}
return stops[stops.length - 1][1];
}
低值用全透明是关键:否则整张图会被底色铺满,看起来像一块色板而不是热区。
四、高分屏为什么发虚
Canvas 的尺寸分两层:CSS 尺寸和位图尺寸。在 devicePixelRatio 为 2 的屏幕上,如果只设置 CSS 尺寸,浏览器会把 1 个位图像素拉伸成 2 个物理像素,边缘就会糊。
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(cssW * dpr);
canvas.height = Math.round(cssH * dpr);
canvas.style.width = cssW + 'px';
canvas.style.height = cssH + 'px';
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
设置 setTransform 之后,后续绘制仍然可以按 CSS 像素思考,不用到处乘 dpr。
五、性能:别在每帧新建数组
热力图通常不是每帧都变,但拖拽、缩放时会频繁重算。三个习惯能省不少时间:
第一,密度矩阵用 Float32Array,复用同一块内存,重算前用 fill(0) 清空;第二,颜色查表预先算好,比如把 0~1 切成 256 档,采样时直接查数组;第三,把绘制放进 requestAnimationFrame,并做一层节流,避免一次交互触发几十次重绘。
const lut = new Uint8ClampedArray(256 * 4);
for (let i = 0; i < 256; i++) {
lut.set(colorAt(i / 255), i * 4);
}
六、小结
热力图的核心不是画,而是采样:分箱决定分辨率,插值决定平滑度,颜色映射决定可读性,高分屏适配决定清晰度。把这四步拆开,每一层都可以单独调参和测试,改起来比一坨绘制代码轻松得多。
- 点赞
- 收藏
- 关注作者
评论(0)