纯前端实现热力图:分箱统计、颜色映射与高分屏绘制

举报
yd_295628683 发表于 2026/10/23 07:40:48 2026/10/23
【摘要】 从一堆 (x, y) 坐标到一张平滑的热力图,中间要做三件事:把点分箱成网格、把密度映射成颜色、把画布画清楚。这篇用原生 Canvas 把三步都拆开讲。

热力图看起来像「把点堆在一起涂个色」,真写起来却有几个绕不开的问题:网格该多细、颜色怎么过渡、高分屏为什么发虚、点多了怎么不卡。这篇文章用一个纯前端的实现,把这几件事讲清楚。

一、先分箱,再上色

输入的是一组二维坐标,输出是一个密度矩阵。最直接的做法是把画布切成 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);
}

六、小结

热力图的核心不是画,而是采样:分箱决定分辨率,插值决定平滑度,颜色映射决定可读性,高分屏适配决定清晰度。把这四步拆开,每一层都可以单独调参和测试,改起来比一坨绘制代码轻松得多。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。