第51篇:数据分析-热力图——笔刷画的是圆,落到地上却成了椭圆

屏幕上一张热力图,蓝底上一团红,看着非常"科学"。

但你有没有想过一个问题:那团红色到底有多大?

不是"屏幕上多大"------是地面上多少公里。你把"画布宽度"从 1000 拖到 2000,画面几乎没变(还是那团红),可它的物理尺寸缩到了原来的一半。你把地图往北拖一点,同一个圆斑,南北向被拉长了 30%。这些都不是渲染 bug,是那三行算画布尺寸的代码决定的。

前面几篇我们一直在三维特效里量账,这次换个方向,到数据分析这条线里,量一笔更朴素、也更容易被忽略的账:一个热力半径,究竟等于几公里。

我是 Cesium酱,一只在 WebGIS 领域摸爬滚打多年的前端猿。这个系列写到第 51 篇,规矩一直没变:只要面板上有数字,就得拿进几何里对一遍账。而这篇文章的账,恰恰是所有"数据可视化"类面板的通病------滑块上写的是"热力半径 70",没人告诉你这个 70 的单位是像素。

本文目标:把一个热力图从「一行数据」到「屏幕上一团颜色」之间的每一笔账拆开算清------哪个点会被悄悄丢掉、圈出来的到底是圆还是椭圆、为什么密度永远到不了满、以及那个会自己往上爬的滑块。💪

💡 本文涉及的全部代码均已开源,完整的仓库地址见文末,可自行取用、随意魔改。


一、痛点与场景:为什么「一团颜色」也需要对账?

🎯 背景现状:热力图是最常见的空间数据表达方式之一------它不画精确的边界,只传达"这里比较热"。正因为"差不多就行",它身上的参数最容易被当成感觉量来调:拖到"看着舒服"就收工,没人去管它对应的物理尺度。

  • 🎯 点位告警密度:某个区域短时间内的告警点位扎堆,用热力图看聚簇
  • 🎯 客流/车流热度:从采样点反推人流密度,做运营决策
  • 🎯 设备负载分布:基站、充电桩、传感器的负载值铺成一张面
  • 🎯 POI 热度:搜索热度、点评数量,做选址参考

🤦 传统痛点:热力图的实现路线很多,但每条都有一个不太好说出口的硬伤。

方案 问题
直接画彩色圆点 只表达"点",不表达"面";点一多就糊成一坨,谁热谁冷看不出来
每个点画一层带透明度的圆,叠起来 叠得越多越深,但叠到一定程度就分不出 10 个点和 100 个点
引入成熟的 heatmap 库 依赖一套渲染管线,和 Cesium 的坐标系之间还得多套一层转换
Cesium 自带的图元 没有热力图;得自己把数据变成一张图,再想办法贴上去

💡 本文方案 :不引第三方库,就在离屏 canvas 上手工把密度"算"出来,再当纹理贴到 Rectangle 上。三件事:

1️⃣ 生成数据:用可复现的伪随机,把点撒成四种典型分布(聚簇 / 带状 / 环形 / 核聚),保证同一 seed 永远同一张图

2️⃣ 渲染密度 :每个点画一个带渐变的圆盘,用 globalAlpha 记归一化值,靠 canvas 的 source-over 自然累加成密度场

3️⃣ 上色贴合 :把灰度密度按 alpha 查一条 256 色的色带,转成 dataURL,交给 ImageMaterialProperty 贴到矩形上

✨ 一句话总结:把「密度」当成一张灰度图,用 canvas 的合成能力当累加器,再把灰度写成颜色"翻译"成一张纹理------整条链路里没有一个参数带单位,这就是所有账的起点。


二、核心技术原理:一笔热力账的三层换算

2.1 数据结构:四个场景,一个种子

先看数据是怎么来的。它不读文件、不请求接口,四个场景全是现场生成的------但生成的方式是确定性的。

ts 复制代码
// 源码级剧透:heatmap-data.ts
export type HeatmapScene = {
  id: string
  label: string
  bounds: Bounds          // west / south / east / north(经纬度)
  defaultRadius: number   // 面板上的「热力半径」默认值
  pointCount: number
  description: string
}

export type SceneData = {
  points: HeatPoint[]     // { x: 经度, y: 纬度, value: 0~1000 }
  valueMin: number
  valueMax: number
}

四个场景的差别只有一行公式(mode)我列一下,后面量账会反复用到:

  • 🔵 beijing-clusters(北京多中心) :三个随机核心,各撒高斯噪声;value = 500 + |g| × 250 + rand × 250
  • 🟢 shanghai-band(上海沿江带状) :lat 沿中心线高斯抖动,value 随经度成正弦波动(0~900)
  • 🟡 guangzhou-ring(广州环形) :极坐标撒在环带半径 0.28~0.52 之间,value 带三次谐波起伏
  • 🔴 chengdu-core(成都核心聚集) :单一中心高斯聚簇,value = 600 + |g| × 200 + rand × 200
ts 复制代码
function seededRandom(seed: number): () => number {
  let state = seed >>> 0
  return () => {
    state |= 0
    state = (state + 0x6d2b79f5) | 0
    let t = Math.imul(state ^ (state >>> 15), 1 | state)
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296   // 🌰 mulberry32:同一个 seed → 同一个 [0, 1)
  }
}

function gaussian(random: () => number): number {
  let u = 0
  let v = 0
  while (u === 0) u = random()
  while (v === 0) v = random()
  return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v)   // 🌰 Box--Muller 变换
}

🔑 关键点说明:

  • seededRandom 是 mulberry32,位数运算 + 位异或,几十毫秒就能吐出十万个数
  • 注意 while (u === 0) 这两行:Box--Muller 里 Math.log(0) 是 -Infinity,while 反复抽直到抽到非零------这是这套代码里唯一一处"不确定循环多少次"的地方,理论上可以死循环(概率为 0)
  • 面板上那个「随机生成」按钮,干的事就是 randomSeed.value = Math.floor(Math.random() * 1e9)------换种子,不换公式
  • 页面初始的 seed 是 20260827。本文后面所有数字都是在这个 seed 下量出来的,你自己跑一遍应该能对上

2.2 完整技术路线

我们设计的完整技术路线:

核心亮点:第 ③ 步 createPointTemplate 和第 ⑤ 步「逐像素查色带」是本文的两把刀 🔪 ------前者决定了笔刷长什么样,后者决定了这张图最终"好看不好看"。而这条链路上最反直觉的两处,一处藏在第 ③ 步的 blurFactor,一处藏在第 ② 步的 continue。

2.3 为什么用 Canvas 而不是着色器

方案 原理 性能 复杂度
GLSL 后处理 把每个点当片元,GPU 里逐像素叠加 ⚡ 十万点也不喘 🔴 高:得自己搭 FBO、传 uniform 数组
Canvas 手动叠加 每个点画一个圆盘,靠 source-over 累加 🐢 千点级还行(本机 1000 px 约 10.6 ms) 🟢 低:几行 drawImage
WebGL 粒子 每个点一个 quad,加法混合 ⚡ 快 🔴 高:还得处理颜色映射回读
服务端出图 后端渲染好 PNG,前端只贴图 🐢 网络往返 🟡 中:但交互(拖半径)就废了

简单说:热力图的点数通常只有几百到几千,Canvas 的合成能力绰绰有余;而"拖一下滑块立刻重画"这个交互,恰恰是 GLSL 那条路最不划算的地方。 用 Canvas,是为了让参数调整保持在"重跑一个几十毫秒的函数"这个量级。


三、代码实战:关键实现拆解

⚠️ 温馨提示 :本文只拆 heatmap-canvas 这一个案例(Canvas 版)。仓库里还有热力图的三维版,那是另一套坐标系和另一篇文章的事。另外,本文所有耗时数字都是**本机无头浏览器(软件渲染、--disable-gpu)**跑出来的,绝对值会比你的真机慢,但相对关系是准的。

3.1 归一化:为什么最小值那个点一笔都不画

拿到点之后,第一件事是把它压到 [0, 1]。

ts 复制代码
const values = points.map((point) => point.value)
const dataMin = options.min ?? (values.length ? Math.min(...values) : 0)
const dataMax = options.max ?? (values.length ? Math.max(...values) : 1)
const min = Math.min(dataMin, dataMax)
const max = Math.max(dataMin, dataMax)
...
for (const point of points) {
  const value = Math.min(point.value, max)                        // ⚠️ 只夹了上界
  const normalized = max > min ? (value - min) / (max - min) : 0
  if (normalized <= 0) continue                                   // 🔑 值等于 min 的点,直接跳过
  shadowContext.globalAlpha = normalized
  shadowContext.drawImage(template, x - radius, y - radius)
}

🔑 关键点说明:

  • < 和 <= 的分界点,正好落在数据里最大的那个"最小值"上。 自动范围时 min 就等于数据的最小值,于是必然有至少一个点的 normalized 恰好是 0,被 continue 跳过。四个场景实测各少一个点:
场景 点数 实际落笔 被跳过
北京多中心 120 119 1
上海沿江带状 100 99 1
广州环形 80 79 1
成都核心聚集 60 59 1

北京场景实测 valueMin = 511.28、valueMax = 1000.00------被跳过的正是那个 511.28 的点。

  • 如果数据里有并列最小值,跳过的就不止一个。 本猿另造了一组 10 个点、其中 5 个值都是 100 的数据:实测落笔 5 个、跳过 5 个。把固定下界手动设成 90,落笔立刻回到 10 个。
  • Math.min(point.value, max) 只有上界、没有下界 ------配合那个 continue,就意味着固定范围的下界一拉高,低值点不是变淡,而是整点消失。实测(同一个 seed):
场景 引擎 min/max 落笔 消失 消失占比
北京多中心 自动范围 119 1 0.8%
北京多中心 600 ~ 1000 111 9 7.5%
北京多中心 800 ~ 1000 57 63 52.5%
广州环形 600 ~ 1000 62 18 22.5%
广州环形 800 ~ 1000 23 57 71.3%

这个行为在业务上通常是反的:你拉高下界,本意是"让低值不要那么显眼",结果它把低值整个抹掉了。地图上直接少掉七成的点,而面板上「数据点数」那栏还理直气壮地写着 80。

  • 还有一处只夹上限 带来的副作用:clampValue(value, 0, 1000) 把 value 压到 1000 封顶,而自动范围的 max 又恰好取到这同一个 1000。北京 120 个点里,有 19 个被压到 1000(15.8%) ,它们的 normalized 全是 1.000------也就是说,这 19 个点在画面上完全等价,一个比一个深都没有。数据分布的那点层次,在封顶的一瞬间就没了。

3.2 画布尺寸:按「度」算,不按「米」

这一步只有三行,但它是整篇文章最值钱的三行:

ts 复制代码
const width = Math.max(8, Math.round(options.canvasWidth ?? 1000))
const height = Math.max(8, Math.round(
  (width / Math.max(bounds.east - bounds.west, 1e-6)) * Math.max(bounds.north - bounds.south, 1e-6)
))
// 🔑 宽高比 = 经度跨度 : 纬度跨度,两边都是「度」

🔑 关键点说明:

  • 这个式子保证的是**"每像素代表多少个度"在两个方向上都一样**。它保证不了"每像素代表多少米"一样------因为 1 度纬度 ≈ 111.32 km,1 度经度 ≈ 111.32 × cos(纬度) km。
  • 所以画布上的正圆笔刷,落到地面上必然是个椭圆 :南北向被拉长 1 / cos(纬度) 倍。写对这条式子应该长这样:height = width × (latRange × 111.32) / (lonRange × 111.32 × cos(lat)),约掉 111.32 之后,分母上还差一个 cos(lat)。
  • 实测四个场景的每像素米数:
场景 中心纬度 每像素东西向 每像素南北向 x / y cos(纬度) 偏差
北京多中心 39.90° 265.09 m 344.11 m 0.77037 0.76717 0.32 个百分点
上海沿江带状 31.25° 142.88 m 166.25 m 0.85945 0.85491 0.45 个百分点
广州环形 23.05° 122.98 m 132.85 m 0.92575 0.92016 0.56 个百分点
成都核心聚集 30.70° 95.80 m 110.86 m 0.86413 0.85985 0.43 个百分点

比值和 cos(纬度) 对得很好(余下那零点几个百分点来自 WGS84 子午圈与卯酉圈半径本来就不同)。

  • 于是「热力半径 = 70」的真实含义是:在北京,东西向半轴 18.557 km、南北向半轴 24.088 km (拉长 1.298 倍,而 1 / cos(39.9°) = 1.303,两者只差 0.4%------这个 0.4% 就是椭球两半径不同带来的)。它不是 70 米,不是 70 公里,是 70 个像素。
  • 更麻烦的是:这个像素的含义还会随"画布宽度"变。
canvasWidth 每像素东西向 半径 70 的东西半轴 相对 1000 px
400 662.73 m 46.391 km 2.50 ×
1000(默认) 265.09 m 18.557 km 1.00 ×
2000 132.55 m 9.278 km 0.50 ×

也就是说,你把"画布宽度"从 400 拖到 2000,同一张图、同一个半径 70,笔刷的物理尺寸缩到了原来的五分之一。 画面上看起来几乎没变(都是圆圆的色斑),变的是"一个色斑代表多少公里"------而这恰恰是热力图上最该被说清楚的那个数。

  • 顺带说一句反向的坑:既然画布高度是按经纬度跨度等比算的,那么场景的 bounds 如果南北跨度大、东西跨度小,画布就会变得又高又窄(成都 1000×800,广州 1000×917,北京 1000×968)。这不是 bug,但如果你把这块画布当成"方形纹理"来用,就要留意它其实不是方的。

3.3 笔刷模板:「模糊度」拉到 1.00 反而变成硬边实心圆

笔刷的生成只有几行,但这里埋了本篇最漂亮的一个反转:

ts 复制代码
function createPointTemplate(radius: number, blurFactor: number): HTMLCanvasElement {
  const template = document.createElement('canvas')
  template.width = template.height = radius * 2
  const context = template.getContext('2d')
  if (blurFactor >= 1) {                                     // ⚠️ blur === 1.00 会掉进这里
    context.beginPath()
    context.arc(radius, radius, radius, 0, 2 * Math.PI, false)
    context.fillStyle = 'rgba(0,0,0,1)'
    context.fill()                                           // 实心圆:一点渐变都没有
  } else {
    const gradient = context.createRadialGradient(
      radius, radius, radius * blurFactor,                   // 内圈半径 = r × blurFactor(alpha 1)
      radius, radius, radius                                 // 外圈半径 = r(alpha 0)
    )
    gradient.addColorStop(0, 'rgba(0,0,0,1)')
    gradient.addColorStop(1, 'rgba(0,0,0,0)')
    context.fillStyle = gradient
    context.fillRect(0, 0, 2 * radius, 2 * radius)
  }
  return template
}

// 调用处:
const blurFactor = blur >= 1 ? 1 : 1 - blur

🔑 关键点说明:

  • blurFactor 是内圈实心核的半径占比 :它越小,实心核越小、渐变段越长,笔刷越软。
  • blurFactor = 1 - blur,所以面板上的「模糊度」越大,blurFactor 越小 → 越软。这个方向是对的。
  • 但滑块最大值恰好是 1。 blur = 1 时 blurFactor = 1 - 1 = 0......如果代码真这么写,createRadialGradient(r, r, 0, r, r, r) 会得到一个"从圆心 0 开始就是 0"的退化渐变(内圈半径 0 意味着内圈退化成一点,实际上会画出最软的 一个圆)。可代码不是这么写的------它写的是 blur >= 1 ? 1 : 1 - blur,在最后那一格直接短路到"实心圆"分支。
  • 实测(单点、r = 70、maxOpacity = 0.9、minOpacity = 0.1),笔刷中心的实心核半径:
模糊度 blurFactor 实心核 可见半径 平台宽度
0.20 0.80 55 px 70 px 1 px
0.40 0.60 41 px 70 px 3 px
0.60 0.40 27 px 70 px 4 px
0.85(默认) 0.15 10 px 70 px 6 px
1.00 1.00 68 px 70 px 71 px

看得清清楚楚:从 0.85 到 1.00,实心核从 10 px 跳到 68 px 。如果滑到 0.95,blurFactor 是 0.05,那是全场最软 的一档;再往右拖一格到 1.00,它变成了全场最硬的一档。滑块最右边那 5% 的行程里,藏着一次完全反向的跳变。

顺便说一句:这也就是本猿在写这篇之前的那点私心------面板上凡是"越大越 X"的滑块,都值得把它拖到最右看一眼。

  • 另外注意模板是按 radius 缓存在 Map 里的 (templates.get(radius)),而 blurFactor 不在 key 里。这里之所以没出问题,纯粹是因为 createHeatmapCanvas 每次都是重新调用的、Map 每轮都是新的。如果哪天有人把 templates 提到函数外面去做跨帧缓存,改模糊度就会失效------这个坑先记在这。

3.4 落笔叠加:为什么密度永远到不了满

ts 复制代码
shadowContext.globalAlpha = normalized
shadowContext.drawImage(template, x - radius, y - radius)

🔑 关键点说明:

  • canvas 的默认合成模式是 source-over:新画的像素以 globalAlpha 为权重盖在旧像素上。这不是加法,是"剩下的部分再盖一层"。
  • 所以 k 个归一化值都是 n 的点叠在一起,中心的 alpha 是 1 - (1 - n)^k,而不是 k × n。实测 n = 0.5:
叠了几层 实测 alpha 理论 1 − (1 − 0.5)^k
1 128 127.5
2 191 191.25
3 223 223.125
5 247 247.03
  • 它永远到不了 255。 100 个点叠在一起也只是 0.99999...... 这是个纯粹的数学结论:(1 - n)^k 只有在 k 无穷大时才趋近 0。
  • 这件事的工程含义是:热力图的"密度"天生是对数式饱和的 。想区分"100 个点"和"1000 个点",光靠叠是区分不出来的------你必须在某个地方把它线性化(比如按点数量除以一个基数),或者干脆换个叠加方式。这不是这段代码的缺陷,是所有基于 alpha 叠加的热力图共同的天花板。

3.5 染色:alpha 当索引,以及色带上那 25% 的死区

密度场画完了,它还是一张灰度图。上色这一步是关键:

ts 复制代码
const offset = alpha * 4                     // 🌰 直接拿 alpha(0~255)当色带下标
let finalAlpha: number
if (alpha > maxOpacity) {
  finalAlpha = maxOpacity
} else if (alpha < minOpacity) {
  finalAlpha = minOpacity                  // 🔑 平台就是这么来的
} else {
  finalAlpha = alpha
}
imageData[i - 3] = palette[offset]         // 色带只有 256 项,乘 4 正好对上 Uint8 的四通道
imageData[i - 2] = palette[offset + 1]
imageData[i - 1] = palette[offset + 2]
imageData[i] = finalAlpha

🔑 关键点说明:

  • palette 是一张 256 × 1 的渐变,alpha 直接就是色带位置。这意味着色带的分辨率只有 256 级------对热力图来说完全够,因为它本来就不是拿来看细节的。
  • 第一个坑:色带会被 canvas 自动"延伸填补"。 默认色带是:
ts 复制代码
const gradient = options.gradient ?? { 0.25: 'rgb(0,0,255)', 0.55: 'rgb(0,255,0)', 0.85: 'yellow', 1: 'rgb(255,0,0)' }

注意第一个色标在 0.25,不是 0 。canvas 的 createLinearGradient 会把 0 ~ 0.25 这一段全部填成第一个颜色 ------于是色带最下面 25% 是一片死蓝 。实测逐点取样:alpha = 0 ~ 63(归一化密度 0 ~ 0.247)全部是 rgb(0,0,255),alpha = 64 才开始有第二个分量。

这个死区有多大?北京场景 1000 px 画布,非透明像素 377538 个,其中落在死蓝段的有 80931 个,占 21.4% (固定范围 0~1000 时是 15.7%)。也就是说,你图上超过五分之一的颜色,其实是同一个颜色。

另外三个色带都聪明得多:thermal / hot / viridis 的第一个色标都写在 0,死区只占色带 0.4%。这个差别在面板上就是个"换个色带看着更舒服"的感觉------但数值上的差别是 21.4% vs 0.4%。

  • 第二个坑:minOpacity 会把笔刷压成一块平板。 alpha < minOpacity 的像素被统一抬到 minOpacity,而这些像素本来是从中心到边缘连续衰减的。于是笔刷边缘原本那个柔和的淡出,被一刀切成"整块都是一个亮度,然后突然归零"。

    实测(单点、r = 70、blur = 0.85、maxOpacity = 0.9,可见像素总数恒为 15332):

minOpacity 平台值 被拍平的像素 占可见像素
0.00 0 64 0.4%
0.10(默认) 26 2640 17.2%
0.25 64 5936 38.7%
0.50 128 10348 67.5%

默认值 0.10 就把 17.2% 的像素压成了一个恒定的 26/255;拖到 0.50,三分之二的笔刷是同一个颜色,你看到的就是一个边缘锐利的圆盘,而不是热力晕染。

  • 第三个坑:平台只在"落过笔"的地方生效。 循环开头有 if (alpha <= 0) continue,所以笔刷外面 的像素不会被抬到 minOpacity。这个处理是对的(不然整张画布都会铺满一层底),但它也意味着平台的内外两侧是一个硬边 :从 minOpacity 直接掉到 0。想要"最小透明度"这个滑块名副其实,其实应该把笔刷的 globalAlpha 下限也一起抬起来,而不是只在最终结果上夹一刀。

3.6 出图与贴合:dataURL 和它的 569 KB

ts 复制代码
const dataUrl = heatmap.canvas.toDataURL()
const material = new Cesium.ImageMaterialProperty({
  image: dataUrl,
  transparent: true
})
if (!heatmapEntity) {
  heatmapEntity = viewer.entities.add({
    rectangle: {
      coordinates: Cesium.Rectangle.fromDegrees(scene.bounds.west, scene.bounds.south, scene.bounds.east, scene.bounds.north),
      material
    }
  })
} else {
  heatmapEntity.rectangle!.material = material     // ⚠️ 每次都换一个新对象
}

🔑 关键点说明:

  • toDataURL() 出来的是未压缩的 base64 PNG 。实测 1000 px 宽的画布 → 569 KB;2000 px → 1408 KB。这个字符串每次更新都要重新生成一遍。
  • 纹理坐标这边可以放心:RectangleGeometryLibrary.computePosition 里是 st.x = (lon − west) × lonScalar、st.y = (lat − south) × latScalar,经纬度线性 ,和画布生成的映射方式完全一致。而 Cesium 的 Texture 构造函数里 flipY 的默认值就是 true(Cesium.js 第 42302 行),所以画布第一行(北)正好落在 v = 1------方向是对的,不需要手动翻图。这一处是"看着该翻、其实不用翻"。
  • Rectangle 走的是 GroundGeometryUpdater 那条路(RectangleGeometryUpdater 继承它),它的 observedPropertyNames 只有 ["availability", "rectangle"]------换材质不会触发几何重建,只走材质/外观更新那条线。这一点比 #49 那个"每次都重建"要健康得多。
  • 但注意那句 heatmapEntity.rectangle!.material = material:每次 updateHeatmap 都 new 一个 ImageMaterialProperty,哪怕图像内容一模一样。它触发的是一次纹理上传,而不是几何重建------不过在"拖滑块"这种高频场景里,这也是一笔白白多花的钱。

四、功能亮点与性能优势

4.1 五个滑块的档位与形状

滑块 范围 / 步长 档位 它真正改的是什么
热力半径 15 ~ 160 / 5 30 笔刷的像素半径(物理尺寸随画布宽度与纬度漂)
最大透明度 0.2 ~ 1 / 0.05 17 最终 alpha 的上限(乘 255 后夹取)
最小透明度 0 ~ 0.5 / 0.05 11 低密度区的平台值,制造硬边
模糊度 0.2 ~ 1 / 0.05 17 内圈实心核占比的倒数,1.00 处反向跳变
画布宽度 400 ~ 2000 / 100 17 整张图的分辨率,同时改变笔刷的物理尺寸

4.2 性能:分段耗时与两份白拷贝

先自嘲一下测试条件:本猿用的是无头浏览器 + 软件渲染(--disable-gpu),绝对值比真机慢不少,但各段之间的比例是可以信的。每组跑 9 次、去掉前 2 次热身取中位数。

北京场景 120 个点,radius = 70:

画布宽 画布尺寸 模板 落笔 getImageData 染色循环 putImageData getImageData² 小计 toDataURL 合计 dataURL
400 400×387 0.0 0.2 5.9 0.4 0.2 0.1 6.8 1.7 8.5 ms 130 KB
1000 1000×968 0.0 0.7 6.8 1.5 0.7 0.8 10.6 7.3 17.8 ms 569 KB
2000 2000×1935 0.1 1.8 9.2 6.7 2.8 2.5 22.9 19.7 43.0 ms 1408 KB

🔑 几个能从表里读出来的结论:

  • 最大单项不是画点,是把像素读回来。 getImageData 在 1000 px 时占 6.8 ms,比"落笔"(0.7 ms)贵近十倍。它要读回 968000 个像素 × 4 通道。这就是 canvas 路线的本质成本:GPU 画得快,但数据得过一次 PCIe 到主内存。
  • toDataURL 从 1.7 ms 涨到 19.7 ms,超线性。 PNG 是压缩算法,像素越多压得越久,2000 px 时它单独就吃掉 19.7 ms。
  • 首次(冷启动)远比稳定态慢。 同样是 1000 px,第一次跑 createHeatmapCanvas 要 121 ms 、toDataURL 要 100 ms(JIT 还没热、大画布还没分配)------稳定后降到 10.6 + 7.3 ms。这也是为什么用户"第一次打开页面"感觉卡一下,之后拖滑块就顺了。
  • 有两份拷贝是纯白做的 :函数里 const shadowData = new Uint8ClampedArray(imageData) 复制了一份全画布像素,末尾 const colorData = context.getImageData(0, 0, width, height).data 又读了一份。Canvas 版演示一个都不用 (它们只对"需要拿到像素去做空间查询"的调用方有意义)。1000×968 时是 3.87 MB × 2 = 7.74 MB ;2000×1935 时是 15.48 MB × 2 = 30.96 MB------每次拖滑块,白分配三十兆。
  • 每一次改动都是全量重画。 五个滑块加起来 92 档,没有任何一档走的是"只改外观"的捷径。改颜色?重画。改最大透明度?重画。改模糊度?重画(而且这次真的必须重画,因为笔刷模板变了)。

4.3 相机自适应半径:一个会自己往上爬的滑块

面板上有个开关叫「半径随视角」,本意是"相机拉远时把笔刷调大一点,保持视觉密度"。

ts 复制代码
const maxRadius = 120
const minHeight = 6375000        // 约 4 km 高度
const maxHeight = 10000000      // 约 3629 km 高度
cameraMoveEndListener = () => {
  const height = viewer.camera.getMagnitude()               // 🔑 到地心的距离
  if (Math.abs(height - lastCameraHeight) < 1500) return    // 1.5 km 死区
  lastCameraHeight = height
  const base = initRadiusValue
  const nextRadius = Math.round(base + ((maxRadius - base) * (height - minHeight)) / (maxHeight - minHeight))
  if (nextRadius >= 20 && nextRadius <= 200 && nextRadius !== radius.value) {
    radius.value = nextRadius
    updateHeatmap()
  }
}

🔑 这里有个很隐蔽的自反馈:

  • maxRadius 是个常数 120 ,所以这条式子做的事其实是:把当前半径往 120 插值 。相机在最低处(height = minHeight)不动它,在最高处(height = maxHeight)直接拉到 120。
  • 每次触发都是 radius.value = nextRadius → updateHeatmap(),而 updateHeatmap() 的最后一行 是 initRadiusValue = radius.value。
  • 也就是说,下一轮的 base 已经被改成了上一轮的输出 。这不是自适应,这是棘轮。

实测:北京场景在同一个相机高度(16:9 初始视角,|pos| = 7087.0 km,对应高度约 717.6 km),反复触发 moveEnd:

复制代码
70 → 80 → 88 → 94 → 99 → 103 → 106 → 109 → 111 → 113 → 114 → 115 → 116 → 117 → 118 → 118

十六次之后钉在 118 ,一路逼近 120 的常数上限。你什么都没动,只是把地图来回平移了几次,"热力半径"就从 70 爬到了 118。

  • 各场景 16:9 初始视角的第一次跳变:北京 70 → 80 、上海 → 76、广州 → 75、成都 → 75。注意这意味着页面刚打开、还没碰过任何东西,面板上的 70 就已经不是真的在用的值了------实际生效的是 80。
  • 视口比例也会影响它:北京 16:9 → 80、4:3 → 77、1:1 → 75(因为 setView 给的是一个矩形,不同比例下算出来的相机高度不一样)。
  • 更麻烦的是它改不回去 。棘轮只会往上(当 base < 120 时),想回到用户最初设的 70,只能手动关掉开关再拖滑块。而这时 initRadiusValue 已经是 118 了------开关再打开,"自适应"的基准就成了 118。

4.4 核心优势

  • 🚀 零依赖纯前端:整张热力图在浏览器里凭空生成,没有接口、没有瓦片、没有服务端成本
  • 🎨 可复现:同一份 seed 永远出同一张图,调试的时候不用"凭感觉重现现场"
  • 🧩 贴图路线轻 :走 Rectangle + ImageMaterialProperty,不碰几何重建,改图不改网格
  • ⚙️ 参数即时反馈:稳定态下一次全量重画只要 8.5 ~ 43 ms,交互体感是"跟手"的

五、小贴士:避坑指南

🎯 关于归一化与「最小值」

常识是:热力图的强弱靠相对值,最小值自然最淡。但这份实现里,最小值不是"最淡",是"不存在" ------normalized <= 0 直接 continue。

⚠️ 重要提示 :这个 <= 0 的影响面比看上去大得多,因为它同时是两个问题的入口:

  1. 自动范围时必然触发 :min 取的就是数据最小值,所以永远有一个点(或一批并列最小的点)被跳过
  2. 固定范围时被放大 :下界一拉高,normalized 为负的点全部消失。实测广州场景下界拉到 800,71.3% 的点直接没了

建议的正确做法(按代价从低到高):

  1. 把 continue 换成 normalized = Math.max(normalized, 0.0001)------让它画出最淡的一笔,而不是不画
  2. 归一化时把 min 稍微下探一点(min - (max - min) * 0.02),给最小值留一条"可见"的缝
  3. 面板上加一栏"实际落笔点数",让"消失"这件事在 UI 上可见------现在是面板写 80、图上有 23

🎨 关于色带

⚠️ 重要提示 :createLinearGradient 会把首尾色标之外的范围自动延伸填补,而不是留空或报错。

  • 写色带时务必把第一个色标放在 0、最后一个放在 1。默认色带因为首标在 0.25,白白浪费了 25% 的色域,实测有 21.4% 的非透明像素挤在那一段死蓝里
  • 想保留"低密度就是纯蓝"这种设计意图,就显式写 { 0: 'rgb(0,0,255)', 0.25: 'rgb(0,0,255)', ... }------让意图写在代码里,而不是藏在 canvas 的默认行为里
  • 四个色带里 thermal / hot / viridis 都是规范写法,可以直接拿来对照

⚡ 关于性能

  • getImageData 是这条路线的天花板。 它是唯一一个"必须把 GPU 画的东西读回主内存"的步骤。如果你的画布要开到 2000 px 以上,先量一下这一步
  • 删掉那两份白拷贝。 shadowData 和 colorData 在 Canvas 版演示里一个都没用到,每次却要分配 30 MB(2000 px 时)
  • toDataURL 是超线性的,2000 px 时 19.7 ms 里有很大一块是 PNG 压缩。数据不变时可以缓存 dataURL,只在参数真的变了才重编
  • 别在高频事件里调它。 camera.moveEnd 是安全的(一帧一次),但如果你哪天想挂到 camera.changed 上做实时跟随,17.8 ms 一帧会把交互拖到 50 fps 以下

🧭 关于「像素」和「米」

  • 面板上的「热力半径」没有单位,它的单位是像素。 想让它有意义,就得在面板上把它换算出来显示:半径 × 每像素米数。这个换算需要知道 bounds 和中心纬度,两样数据都在手边
  • 半径随视角那个棘轮,本质是把 initRadiusValue 当成了"上一次的输出"。 正确做法是单独存一个 userRadius,自适应只改一个"显示半径",永远不改用户设的基准值
  • 如果业务上真的需要"固定 20 公里的热力斑",那正确做法是反过来算:先定物理半径,再按当前画布宽度和纬度反推像素半径

📦 关于当前局限

目前这个案例最明显的几处局限,本猿也一并列出来:

  • 密度是对数式饱和的 (1 - (1 - n)^k),点数一多就分不出高下;真要表达"绝对数量",得引入一个基准归一化
  • 每次参数变化都是全量重画,包括"最大透明度"这种只影响最终夹取的参数------它完全可以做成后处理
  • 面板的「数据范围」和引擎实际用的范围可能不是一回事 :metricRange 显示的是 data.valueMin ~ data.valueMax(数据自身的值域),而引擎在开了固定范围后用的是 fixedMin ~ fixedMax。固定 800~1000 时面板会写"388 ~ 1000",但图上画的是 800~1000
  • 画布宽高比按"度"算 ,所以笔刷落地永远是椭圆;要正圆就得在高度里补一个 cos(中心纬度)

这些优化点,都可以在实际应用中按需调整------本文的目标是把账算清,不是把它做完美。


六、写在最后

这是第 51 篇,也是数据分析这条线的第一站。回头看,这一篇量的东西其实和前面几篇不太一样:前几篇量的是"某个特效算错了什么",这一篇量的是**"一个参数根本没有单位"**。

70 这个数字,在面板上叫"热力半径",在代码里叫 radius,在画布上叫"70 个像素",在地面上叫"18.557 公里的椭圆"。四个名字,没有一个写在界面上。

而这一篇最让本猿意外的不是那个 blur >= 1 的短路分支,也不是那个 25% 的死蓝,而是------

那个会自己爬的滑块。

它是全场唯一一个"你什么都没做、它自己变了"的地方。没有动画、没有定时器,只有一行 initRadiusValue = radius.value 和一行 radius.value = nextRadius,安安静静地互相喂了一轮又一轮。你把地图平移十六次,它就爬到 118。

热力图是最"不讲理"的一种表达:它不画边界,不给数值,只给你一团颜色。

可越是这样的图,越需要有人去问一句------

那团颜色,到底是几个像素?

那几个像素,又是几公里?

数据分析线第一篇。这条线往下还有热力图的三维版、聚合、轨迹、流向等等------它们大概率会再遇到同一批问题:归一化的边界、色带的死区、像素与米之间的那层换算。参数名换一换,账还是那笔账。

🎉 热力图量完了------模糊度滑到最右反而最硬,半径 70 落地是个 18.557 km 的椭圆,那个滑块还会自己往上爬。

如果你有更好的想法,或者在实际使用中遇到问题,欢迎来 GitHub 提 Issue!这个系列还会继续往下挖,下一站大概是热力图的三维版------换一套坐标系,看同样的账会不会又变个算法。


开源地址:https://github.com/jianlei-wang/CESIUM-TREASURE-BOX

🌟 项目已开源,欢迎 Star!(你的 Star ⭐ 就是对我最大的支持!)

仓库公开,直接到 GitHub 取即可。本篇涉及的目录:

  • src/cases/heatmap-canvas/ ------ 演示面板与场景装配(HeatmapCanvasDemo.vue,298 行)+ index.ts(id: heatmap-canvas / category: data / tag: 数据可视化 / updatedAt: 2026-08-27)
  • src/cases/heatmap-lib/heatmap-engine.ts ------ buildPalette + createPointTemplate + createHeatmapCanvas(共 172 行)
  • src/cases/heatmap-lib/heatmap-data.ts ------ seededRandom + gaussian + 四个场景 + generateSceneData(共 141 行)

Cesium酱の百宝箱 · 第 51 篇

莫数色斑几许宽,且将像素问归米

2026 年深秋,愿我们拖动每一个滑块之前,都先问清楚「它改的是像素还是米」

相关推荐
计算机编程指导师2 小时前
【计算机毕设】基于Hadoop+Spark的美国互联网银行贷款数据可视化分析系统源码 毕业设计 选题推荐 毕设选题 数据分析 机器学习
大数据·hadoop·python·数据分析·spark·毕业设计·贷款
Martina_03212 小时前
AI生成的海岸城市一移动镜头,水面反射就跳?用6步统一反射探针、SSR与平面反射
图像处理·人工智能·平面·3d·ai·自然语言处理·游戏策划
Alphapeople2 小时前
2D转3D方案
3d
data analyse 4563 小时前
服务端访问日志聚合 PV 与 UV 怎么实现?日志采集与口径设计
大数据·前端·数据分析
风花一世月4 小时前
[特殊字符] 我把一套分布式消防方案做成了能跑的 3D 沙盘(三架无人机共用一根水带,在线直接玩)
分布式·3d·ai·无人机
SelectDB4 小时前
Doris VARIANT 速查:类型映射、建表写法、索引配置与常见注意点
大数据·数据库·数据分析
大雷神4 小时前
用鸿蒙开发助手推进《3D种植》鸿蒙版与 Flame 库适配
3d·华为·harmonyos
SelectDB5 小时前
冷热分层速查手册:参数、命令、生效判据与常见失效原因一页看完
大数据·数据库·数据分析