Blog
Apr 23, 2026 - 10 MIN READ
Cesium GeoJSON 热力图 SDK 实现说明

Cesium GeoJSON 热力图 SDK 实现说明

记录通用 GeoJSON 热力图 SDK 的实现逻辑,包括 Cesium runtime 单例、GeoJSON 点数据解析、canvas 热力纹理生成、GroundPrimitive 分类渲染、世界地形接入和 OSM Buildings 测试场景管理。

Kevin

Kevin

这篇笔记解决什么问题

这个热力图实现的目标不是做一个只能服务某个 Demo 的页面效果,而是沉淀成一个通用 SDK 模块:

  • 业务侧只负责提供 FeatureCollection<Point> 格式的 GeoJSON 数据
  • SDK 内部负责从 GeoJSON 中解析经纬度和权重
  • SDK 内部负责把点数据绘制成二维 canvas 热力纹理
  • SDK 内部负责把热力纹理绑定到 Cesium 的 GroundPrimitive
  • 半径、模糊度、透明度、显示目标、数据更新和销毁都通过统一 API 处理

所以这里最重要的设计边界是:Demo 页面只是使用者,真正可复用的能力在 demos/cesium/heatmap/* 和 Cesium runtime 模块里。

对外 API 形态

业务接入时先用统一 runtime 加载 Cesium,再创建 Viewer,最后把 GeoJSON 传给热力图图层工厂。

import { loadCesium } from './cesiumRuntime.js'
import { createGeoJsonHeatmapLayer } from './heatmap/cesiumHeatmapLayer.js'

const Cesium = await loadCesium()
const viewer = new Cesium.Viewer(container)

const heatmapLayer = createGeoJsonHeatmapLayer(viewer, {
  geoJson,
  bounds: {
    west: 113.7,
    south: 22.4,
    east: 114.7,
    north: 22.9
  },
  valueProperty: 'value',
  radius: 42,
  blur: 32,
  minOpacity: 0.12,
  maxOpacity: 0.86,
  visible: true,
  classificationTarget: 'both'
})

这个 API 有三个关键点:

  • 业务代码不需要把 Cesium 参数继续传进热力图模块。
  • 覆盖范围通过 bounds 表达,SDK 只认 west/south/east/north
  • 显示目标通过 'terrain' | 'tiles' | 'both' 控制,而不是额外绑一个 tileset 实例。

图层对象返回后,后续更新都走同一个对象:

heatmapLayer.updateGeoJson(nextGeoJson)

heatmapLayer.setOptions({
  radius: 56,
  blur: 40,
  minOpacity: 0.08,
  maxOpacity: 0.9,
  classificationTarget: 'tiles'
})

heatmapLayer.setVisible(false)
heatmapLayer.setClassificationTarget('both')
heatmapLayer.destroy()

整体链路

当前实现可以按下面这条链路理解:

  1. loadCesium() 统一加载 Cesium,并缓存 Cesium module。
  2. Demo 或业务代码创建 Cesium Viewer
  3. createGeoJsonHeatmapLayer(viewer, options) 创建热力图图层。
  4. parseGeoJsonPoints() 校验 GeoJSON,并提取经纬度、权重和统计信息。
  5. renderHeatmapCanvas() 把点位投影到 canvas,并生成热力纹理。
  6. GroundPrimitive 读取热力 canvas,并按 classificationTarget 分类到地形、3D Tiles 或两者。
  7. 参数或数据变化时,SDK 只重绘热力图,必要时重建 primitive。
  8. 页面销毁时,SDK 移除 primitive,并释放内部状态。

这条链路把“数据解析”“纹理绘制”“Cesium 绑定”“生命周期”拆开了。后续换真实接口数据、换业务范围、换权重字段时,不需要重写热力图渲染逻辑。

Cesium runtime 为什么要单例化

之前每个 Cesium Demo 都自己设置 CESIUM_BASE_URL、注入 Widgets CSS、动态导入 /cesium/index.js。这些逻辑散在多个文件里,会带来两个问题:

  • 每个 Demo 都有重复初始化代码。
  • 热力图 SDK 如果也要求传入 Cesium,业务调用会变得啰嗦。

现在统一收敛到 demos/cesium/cesiumRuntime.js

let cesiumPromise = null
let cesiumModule = null

export async function loadCesium() {
  ensureCesiumBaseUrl()
  ensureCesiumWidgetsStyles()

  if (!cesiumPromise) {
    cesiumPromise = import(/* @vite-ignore */ CESIUM_RUNTIME_URL)
      .then((module) => {
        cesiumModule = module
        return module
      })
      .catch((error) => {
        cesiumPromise = null
        throw error
      })
  }

  return cesiumPromise
}

热力图模块内部通过 getCesium() 读取已经加载好的 Cesium。这样业务入口只需要保证先 await loadCesium(),后面的通用模块就能直接工作。

为了让 Demo 能直接加载 Cesium World Terrain 和 OSM Buildings,runtime 现在还会在模块初始化时同步注入 Ion token:

function resolveCesiumIonToken() {
  if (typeof window === 'undefined') {
    return ''
  }

  return window.__NUXT__?.config?.public?.cesiumIonToken || ''
}
cesiumPromise = import(/* @vite-ignore */ CESIUM_RUNTIME_URL)
  .then((module) => {
    const ionToken = resolveCesiumIonToken()

    if (ionToken) {
      module.Ion.defaultAccessToken = ionToken
    }

    cesiumModule = module
    return module
  })

这样 Demo 页面里的 terrain / 3D Tiles 加载,就不需要再到业务层单独处理 token 传递。

GeoJSON 数据约定

热力图输入固定使用 FeatureCollection<Point>。坐标使用 WGS84,经纬度顺序为 [longitude, latitude]。权重字段默认读取 properties.value

{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "properties": {
        "id": "heat-001",
        "name": "sample point",
        "category": "event",
        "value": 92
      },
      "geometry": {
        "type": "Point",
        "coordinates": [113.941, 22.541]
      }
    }
  ]
}

SDK 只依赖两部分数据:

  • geometry.coordinates:点位经纬度
  • properties[valueProperty]:点位权重,默认是 properties.value

GeoJSON 怎么被解析

解析逻辑在 demos/cesium/heatmap/geojson.js。它不会因为某一个坏点中断整张热力图,而是跳过无效点,并返回统计信息。

关键判断如下:

const isPointFeature = feature?.type === 'Feature' &&
  geometry?.type === 'Point' &&
  Array.isArray(coordinates) &&
  coordinates.length >= 2

if (!isPointFeature || !Number.isFinite(coordinates[0]) || !Number.isFinite(coordinates[1]) || !Number.isFinite(value) || value < 0) {
  skipped += 1
  continue
}

然后再用 bounds 判断点是否落在当前热力图覆盖范围内。这样业务接入时只需要明确覆盖范围,解析器就能稳定产出当前图层应该消费的点集。

Canvas 热力纹理怎么生成

真正生成热力图的是 demos/cesium/heatmap/canvasHeatmap.js。它把热力图拆成两步:

  1. 先绘制灰度密度图
  2. 再把灰度 alpha 映射成热力颜色和透明度

生成单个点的影响范围时,使用径向渐变:

const innerRadius = clamp(radius - blur, 0, radius)
const gradient = context.createRadialGradient(center, center, innerRadius, center, center, radius)
gradient.addColorStop(0, 'rgba(0, 0, 0, 1)')
gradient.addColorStop(1, 'rgba(0, 0, 0, 0)')

每个 GeoJSON 点绘制到灰度密度图时,会先按最大权重归一化:

const projected = projectLngLatToHeatmap(point.longitude, point.latitude, bounds, size.width, size.height)
const normalizedValue = point.value / maxValue

context.globalAlpha = clamp(normalizedValue, 0.02, 1)
context.drawImage(
  sprite,
  projected.x - heatmapOptions.radius,
  projected.y - heatmapOptions.radius
)

最后再把 alpha 转成彩色热力图:

const intensity = pixels[index + 3] / 255
const colorIndex = clampInteger(intensity * 255, 0, 255) * 4
const opacity = minOpacity + intensity * (maxOpacity - minOpacity)

pixels[index] = colorRamp[colorIndex]
pixels[index + 1] = colorRamp[colorIndex + 1]
pixels[index + 2] = colorRamp[colorIndex + 2]
pixels[index + 3] = clampInteger(opacity * 255, 0, 255)

所以 minOpacitymaxOpacity 不是简单设置整个图层透明度,而是在每个像素颜色映射阶段参与计算。

GroundPrimitive 怎么绑定热力图

这次改造的核心,就是把原来的 SingleTileImageryProvider 改成 GroundPrimitive

首先会做能力检查:

function ensureGroundPrimitiveSupport(Cesium, viewer) {
  if (!Cesium.GroundPrimitive.isSupported(viewer.scene)) {
    throw new Error('当前环境不支持 GroundPrimitive,无法渲染热力图。')
  }

  if (!Cesium.GroundPrimitive.supportsMaterials(viewer.scene)) {
    throw new Error('当前环境不支持 GroundPrimitive 材质,无法渲染热力图。')
  }
}

然后把 canvas 纹理包装成 Image material:

function createGroundMaterial(Cesium, canvas) {
  return Cesium.Material.fromType('Image', {
    image: canvas,
    transparent: true,
    repeat: new Cesium.Cartesian2(1, 1),
    color: Cesium.Color.WHITE
  })
}

真正创建 primitive 的逻辑如下:

function createGroundPrimitive(Cesium, state) {
  const material = createGroundMaterial(Cesium, state.renderResult.canvas)
  const appearance = new Cesium.EllipsoidSurfaceAppearance({
    material,
    translucent: true,
    aboveGround: false
  })
  const primitive = new Cesium.GroundPrimitive({
    geometryInstances: new Cesium.GeometryInstance({
      geometry: new Cesium.RectangleGeometry({
        rectangle: createCesiumRectangle(Cesium, state.bounds),
        vertexFormat: Cesium.EllipsoidSurfaceAppearance.VERTEX_FORMAT
      }),
      id: 'geojson-heatmap-ground-primitive'
    }),
    appearance,
    classificationType: toClassificationType(Cesium, state.classificationTarget)
  })

  primitive.show = state.visible

  return {
    primitive,
    material,
    appearance
  }
}

这里的职责边界很清楚:

  • RectangleGeometry 定义热力图的经纬度覆盖范围
  • Image material 负责把 canvas 结果变成可采样纹理
  • GroundPrimitive 负责把这张热力图分类到 Cesium 场景表面

Demo 怎么补地形和 3D Tiles 测试场景

为了验证热力图是否真的同时贴到地形和模型,Demo runtime 会额外加载两层场景资源:

  1. Cesium World Terrain
  2. Cesium OSM Buildings

加载逻辑集中在 loadTerrainAndTiles()

async function loadTerrainAndTiles(Cesium, viewer) {
  const results = await Promise.allSettled([
    Cesium.createWorldTerrainAsync({
      requestWaterMask: true,
      requestVertexNormals: true
    }),
    Cesium.createOsmBuildingsAsync({
      defaultColor: Cesium.Color.fromCssColorString('#d9e0ea').withAlpha(0.92),
      enableShowOutline: false,
      showOutline: false
    })
  ])

地形成功后,直接挂到 viewer.terrainProvider

if (terrainResult?.status === 'fulfilled') {
  viewer.terrainProvider = terrainResult.value
  sceneState.terrainReady = true
}

OSM Buildings 成功后,直接加到 viewer.scene.primitives

if (tilesResult?.status === 'fulfilled') {
  sceneState.tileset = viewer.scene.primitives.add(tilesResult.value)
  sceneState.tilesReady = true
}

这里不用把热力图和 tileset 再做额外绑定,因为当前热力图本身就是 GroundPrimitive classification。只要 tileset 已经进入场景,classificationTarget = 'tiles' | 'both' 就会直接作用到模型表面。

为什么要选一个热力图测试点

只把地形和 3D Tiles 加进场景还不够,观察上也需要一个固定锚点。Demo 现在会从当前 GeoJSON 中选一个离参考城市点最近的真实热力点,作为“热力图测试点”:

const HEATMAP_TEST_POINT_REFERENCE = Object.freeze({
  longitude: 114.059,
  latitude: 22.543,
  name: '热力图测试点'
})

然后从当前热力图点集中挑选最适合观察的点:

function findHeatmapTestPoint(featureCollection) {
  const features = Array.isArray(featureCollection?.features) ? featureCollection.features : []
  let bestPoint = null
  let bestScore = Number.POSITIVE_INFINITY

  for (const feature of features) {
    const point = getFeaturePoint(feature)

    if (!point) {
      continue
    }

    const longitudeDelta = point.longitude - HEATMAP_TEST_POINT_REFERENCE.longitude
    const latitudeDelta = point.latitude - HEATMAP_TEST_POINT_REFERENCE.latitude
    const distanceScore = longitudeDelta * longitudeDelta + latitudeDelta * latitudeDelta
    const valueBonus = point.value / 100000
    const score = distanceScore - valueBonus

这个策略的目的不是找“全局最大值”,而是找“离城市高密区域更近、同时又确实属于当前热力图数据”的一个真实点。这样相机切过去时,更容易直接看到 OSM Buildings,也更容易观察热力图是否贴到了建筑表面。

测试点怎么可视化和定位

选出测试点后,Demo 会往场景里加一个贴地 marker:

return viewer.entities.add({
  position: Cesium.Cartesian3.fromDegrees(testPoint.longitude, testPoint.latitude, 0),
  point: {
    pixelSize: 12,
    color: Cesium.Color.fromCssColorString('#fff173'),
    outlineColor: Cesium.Color.fromCssColorString('#08111f'),
    outlineWidth: 2,
    heightReference: Cesium.HeightReference.CLAMP_TO_GROUND
  },
  label: {
    text: labelText,
    showBackground: true,
    heightReference: Cesium.HeightReference.CLAMP_TO_GROUND
  }
})

同时提供一个专门的相机动作:

function focusTestPoint(Cesium, viewer, testPoint, duration = 1.2) {
  viewer.camera.flyTo({
    destination: Cesium.Cartesian3.fromDegrees(testPoint.longitude, testPoint.latitude, 1800),
    orientation: {
      heading: Cesium.Math.toRadians(-18),
      pitch: Cesium.Math.toRadians(-38),
      roll: 0
    },
    duration
  })
}

这样一来:

  • 俯视时可以看热力图贴地范围
  • 近看时可以看热力图是否爬到了 OSM Buildings 表面

classificationTarget 在测试场景里的作用

当测试环境已经把地形和 OSM Buildings 都加载进来以后,classificationTarget 的行为会更直观:

  • 'terrain':只看热力图贴地
  • 'tiles':只看热力图贴模型
  • 'both':同时看贴地和贴模型

这也是为什么这次测试场景要同时把 terrain 和 tiles 都拉起来。否则即使 SDK 层支持 'tiles',页面里也没有可观察的模型目标。

classificationTarget 怎么控制地形和 3D Tiles

旧方案需要额外把热力图纹理再绑定到 tileset,现在不需要了。显示目标统一用 classificationTarget 控制:

function toClassificationType(Cesium, target) {
  const normalizedTarget = normalizeClassificationTarget(target)

  if (normalizedTarget === 'terrain') {
    return Cesium.ClassificationType.TERRAIN
  }

  if (normalizedTarget === 'tiles') {
    return Cesium.ClassificationType.CESIUM_3D_TILE
  }

  return Cesium.ClassificationType.BOTH
}

这意味着:

  • 'terrain' 只作用到地形
  • 'tiles' 只作用到 3D Tiles
  • 'both' 同时作用到两者

SDK 对外不暴露 Cesium 原生枚举,业务只需要记住三个字符串即可。

参数更新为什么能实时生效

半径、模糊度、透明度变化时,不需要重建 Viewer,也不需要重新创建 primitive。SDK 只需要重绘热力纹理,然后把新 canvas 填回材质:

function syncGroundMaterial() {
  if (!state.groundMaterial) {
    rebuildGroundPrimitive()
    return
  }

  state.groundMaterial.uniforms.image = state.renderResult.canvas
}

只有两个场景需要重建 primitive:

  • bounds 变了
  • classificationTarget 变了

对应的重建入口集中在 rebuildHeatmap()

function rebuildHeatmap({ rebuildPrimitive = false } = {}) {
  if (state.destroyed) {
    return
  }

  state.renderResult = renderHeatmapCanvas(state.parsed.points, state.bounds, state.heatmapOptions)

  if (rebuildPrimitive || !state.groundPrimitive) {
    rebuildGroundPrimitive()
  }
  else {
    syncGroundMaterial()
  }

  requestSceneRender()
}

这就是这次改造后的关键性能边界:普通调参不重建几何,只有几何范围和分类目标变化时才重新创建 primitive。

GeoJSON 更新怎么处理

业务数据更新时,统一调用 updateGeoJson()

function updateGeoJson(featureCollection) {
  state.geoJson = featureCollection || createEmptyFeatureCollection()
  parseCurrentGeoJson()
  rebuildHeatmap()

  return getState()
}

它会完整执行:

  • 替换 GeoJSON
  • 重新校验点位
  • 重新统计 accepted/skipped/minValue/maxValue
  • 重新绘制 canvas
  • 同步更新 ground material

这样业务侧只表达“数据变了”,热力图图层内部负责决定需要刷新哪些资源。

Demo 在 regenerate() 里除了更新热力图本体,还会同步刷新测试点 marker,这样重新生成随机点位之后,观察锚点也会继续跟着当前热力图数据走。

生命周期怎么清理

Cesium 页面最容易遗留的是 primitive 和材质状态。SDK 里把这些清理集中到 destroy()

function destroyGroundPrimitive() {
  if (!state.groundPrimitive) {
    return
  }

  if (viewer.scene.primitives.contains(state.groundPrimitive)) {
    viewer.scene.primitives.remove(state.groundPrimitive)
  }

  state.groundPrimitive = null
  state.groundAppearance = null
  state.groundMaterial = null
}

function destroy() {
  state.destroyed = true
  destroyGroundPrimitive()
  requestSceneRender()
}

这样路由切换或页面销毁时,业务只需要先销毁热力图图层,再销毁 Cesium Viewer。

对于 Demo 额外加进去的资源,也会在销毁时一并清理:

if (sceneState.tileset && viewer.scene.primitives.contains(sceneState.tileset)) {
  viewer.scene.primitives.remove(sceneState.tileset)
}

if (sceneState.testMarker) {
  viewer.entities.remove(sceneState.testMarker)
}

当前实现边界

当前实现是二维热力纹理,不是三维体渲染。它适合表达某个地理范围内的密度、强度或风险分布。

这次用 GroundPrimitive 统一了地形和 3D Tiles 的显示目标控制,但 Cesium 官方也明确提示:textured GroundPrimitive 更适合“模式化覆盖”,如果业务未来要追求特别严格的地表纹理精度,仍然要结合具体场景重新评估影像方案。

当前 Demo 的 3D Tiles 测试资源使用的是 OSM Buildings,它依赖在线资源和 Ion token。如果网络或 token 不可用,热力图本体仍然能继续工作,但“贴模型”这部分就没法在页面里直接观察到。

当前 bounds 建议由业务侧显式传入。更进一步可以在 GeoJSON 解析阶段自动计算 bbox,再根据业务需要加 padding,这样热力图覆盖范围就能完全跟随数据本身。

凯文的技术博客 • © 2026