Cesium实现3D热力图

简介

热力图(Heatmap)是一种常用的数据可视化方式,通过颜色的深浅来展示数据的分布密度。本文将介绍如何在Cesium中实现三维热力图效果,让热力图不再局限于二维平面。

实现原理

整体实现思路如下:

  • 使用heatmap.js生成热力图纹理
  • 根据热力值生成三维网格
  • 将热力图纹理应用到三维网格上
  • 通过顶点着色器实现高度效果

核心代码实现

1. 初始化热力图

首先需要创建热力图实例并配置相关参数:

javascript 复制代码
const heatmapConfig = {
  container: document.getElementById(`heatmap-${instanceId}`),
  radius: options.radius || 20,
  maxOpacity: 0.7,
  minOpacity: 0,
  blur: 0.75,
  gradient: {
    ".3": "blue",
    ".5": "green",
    ".7": "yellow",
    ".95": "red"
  }
};

const heatmapInstance = h337.create(heatmapConfig);

2. 生成三维网格

根据热力图范围生成网格数据:

ini 复制代码
function generateMeshData(heatmapState) {
  const gridWidth = heatmapState.canvasWidth;
  const gridHeight = heatmapState.canvasWidth;
  const positions = [];
  const textureCoords = [];
  const indices = [];

  // 遍历网格点生成顶点数据
  for (let i = 0; i < gridWidth; i++) {
    for (let j = 0; j < gridHeight; j++) {
      // 获取热力值
      const heatValue = heatmapInstance.getValueAt({ x: i, y: j });

      // 生成顶点坐标
      const cartesian3 = Cesium.Cartesian3.fromDegrees(
        currentLongitude,
        currentLatitude,
        baseElevation + heatValue
      );
      positions.push(cartesian3.x, cartesian3.y, cartesian3.z);

      // 生成纹理坐标
      textureCoords.push(i / gridWidth, j / gridHeight);

      // 生成三角形索引
      if (j !== gridHeight - 1 && i !== gridWidth - 1) {
        indices.push(
          i * gridHeight + j,
          i * gridHeight + j + 1,
          (i + 1) * gridHeight + j
        );
        indices.push(
          (i + 1) * gridHeight + j,
          (i + 1) * gridHeight + j + 1,
          i * gridHeight + j + 1
        );
      }
    }
  }

  return { positions, textureCoords, indices };
}

3. 创建Primitive

将生成的网格数据创建为Cesium的Primitive:

php 复制代码
const primitive = viewer.scene.primitives.add(
  new Cesium.Primitive({
    geometryInstances: new Cesium.GeometryInstance({
      geometry: createHeatmapGeometry(heatmapState)
    }),
    appearance: new Cesium.MaterialAppearance({
      material: new Cesium.Material({
        fabric: {
          type: "Image",
          uniforms: {
            image: heatmapInstance.getDataURL()
          }
        }
      }),
      vertexShaderSource: vertexShader,
      translucent: true,
      flat: true
    })
  })
);

4. 顶点着色器

通过顶点着色器实现热力值的高度效果:

ini 复制代码
void main() {
  vec4 p = czm_computePosition();
  v_normalEC = czm_normal * normal;
  v_positionEC = (czm_modelViewRelativeToEye * p).xyz;
  vec4 positionWC = czm_inverseModelView * vec4(v_positionEC,1.0);
  v_st = st;
  vec4 color = texture(image_0, v_st);

  // 根据热力值调整高度
  vec3 upDir = normalize(positionWC.xyz);
  p += vec4(color.r * upDir * 1000.0, 0.0);

  gl_Position = czm_modelViewProjectionRelativeToEye * p;
}

使用示例

javascript 复制代码
// 创建热力图数据
const points = new Array(50).fill("").map(() => ({
  lnglat: [
    116.46 + Math.random() * 0.1 * (Math.random() > 0.5 ? 1 : -1),
    39.92 + Math.random() * 0.1 * (Math.random() > 0.5 ? 1 : -1)
  ],
  value: 1000 * Math.random()
}));

// 创建热力图
create3DHeatmap(viewer, {
  dataPoints: points,
  radius: 15,
  baseElevation: 0,
  primitiveType: "TRIANGLES",
  colorGradient: {
    ".3": "blue",
    ".5": "green",
    ".7": "yellow",
    ".95": "red"
  }
});

全部代码

ini 复制代码
/**
 * 创建三维热力图
 * @param {Cesium.Viewer} viewer 地图viewer对象
 * @param {Object} options 基础参数
 * @param {Array} options.dataPoints 热力值数组
 * @param {Array} options.radius 热力点半径
 * @param {Array} options.baseElevation 最低高度
 * @param {Array} options.colorGradient 颜色配置
 */
function create3DHeatmap(viewer, options = {}) {
  const heatmapState = {
    viewer,
    options,
    dataPoints: options.dataPoints || [],
    containerElement: undefined,
    instanceId: Number(
      `${new Date().getTime()}${Number(Math.random() * 1000).toFixed(0)}`
    ),
    canvasWidth: 200,
    boundingBox: undefined, // 四角坐标
    boundingRect: {}, // 经纬度范围
    xAxis: undefined, // x 轴
    yAxis: undefined, // y 轴
    xAxisLength: 0, // x轴长度
    yAxisLength: 0, // y轴长度
    baseElevation: options.baseElevation || 0,
    heatmapPrimitive: undefined,
    positionHierarchy: [],
    heatmapInstance: null,
  };

  if (!heatmapState.dataPoints || heatmapState.dataPoints.length < 2) {
    console.log("热力图点位不得少于3个!");
    return;
  }

  createHeatmapContainer(heatmapState);

  const heatmapConfig = {
    container: document.getElementById(`heatmap-${heatmapState.instanceId}`),
    radius: options.radius || 20,
    maxOpacity: 0.7,
    minOpacity: 0,
    blur: 0.75,
    gradient: options.colorGradient || {
      ".1": "blue",
      ".5": "yellow",
      ".7": "red",
      ".99": "white",
    },
  };

  heatmapState.primitiveType = options.primitiveType || "TRIANGLES";
  heatmapState.heatmapInstance = h337.create(heatmapConfig);

  initializeHeatmap(heatmapState);

  return {
    destroy: () => destroyHeatmap(heatmapState),
    heatmapState,
  };
}

function initializeHeatmap(heatmapState) {
  for (const [index, dataPoint] of heatmapState.dataPoints.entries()) {
    const cartesianPosition = Cesium.Cartesian3.fromDegrees(
      dataPoint.lnglat[0],
      dataPoint.lnglat[1],
      0
    );
    heatmapState.positionHierarchy.push(cartesianPosition);
  }

  computeBoundingBox(heatmapState.positionHierarchy, heatmapState);

  const heatmapPoints = heatmapState.positionHierarchy.map(
    (position, index) => {
      const normalizedCoords = computeNormalizedCoordinates(
        position,
        heatmapState
      );
      return {
        x: normalizedCoords.x,
        y: normalizedCoords.y,
        value: heatmapState.dataPoints[index].value,
      };
    }
  );

  heatmapState.heatmapInstance.addData(heatmapPoints);

  const geometryInstance = new Cesium.GeometryInstance({
    geometry: createHeatmapGeometry(heatmapState),
  });

  heatmapState.heatmapPrimitive = heatmapState.viewer.scene.primitives.add(
    new Cesium.Primitive({
      geometryInstances: geometryInstance,
      appearance: new Cesium.MaterialAppearance({
        material: new Cesium.Material({
          fabric: {
            type: "Image",
            uniforms: {
              image: heatmapState.heatmapInstance.getDataURL(),
            },
          },
        }),
        vertexShaderSource: `
        in vec3 position3DHigh;
        in vec3 position3DLow;
        in vec2 st;
        in float batchId;
        uniform sampler2D image_0; 
        out vec3 v_positionEC;
        in vec3 normal;
        out vec3 v_normalEC;
        out vec2 v_st; 
        void main(){
            vec4 p = czm_computePosition();
           
            v_normalEC = czm_normal * normal;   
            v_positionEC = (czm_modelViewRelativeToEye * p).xyz;
            vec4 positionWC=czm_inverseModelView* vec4(v_positionEC,1.0);
            v_st = st; 
            vec4 color = texture(image_0, v_st); 
            vec3 upDir = normalize(positionWC.xyz); 
            p += vec4(color.r *upDir * 1000., 0.0); 
            gl_Position = czm_modelViewProjectionRelativeToEye * p; 
        }`,
        translucent: true,
        flat: true,
      }),
      asynchronous: false,
    })
  );
  heatmapState.heatmapPrimitive.id = "heatmap3d";
}

function destroyHeatmap(heatmapState) {
  const containerElement = document.getElementById(
    `heatmap-${heatmapState.instanceId}`
  );
  if (containerElement) containerElement.remove();
  if (heatmapState.heatmapPrimitive) {
    heatmapState.viewer.scene.primitives.remove(heatmapState.heatmapPrimitive);
    heatmapState.heatmapPrimitive = undefined;
  }
}

function computeNormalizedCoordinates(position, heatmapState) {
  if (!position) return;
  const cartographic = Cesium.Cartographic.fromCartesian(position.clone());
  cartographic.height = 0;
  position = Cesium.Cartographic.toCartesian(cartographic.clone());

  const originVector = Cesium.Cartesian3.subtract(
    position.clone(),
    heatmapState.boundingBox.leftTop,
    new Cesium.Cartesian3()
  );
  const xOffset = Cesium.Cartesian3.dot(originVector, heatmapState.xAxis);
  const yOffset = Cesium.Cartesian3.dot(originVector, heatmapState.yAxis);
  return {
    x: Number(
      (xOffset / heatmapState.xAxisLength) * heatmapState.canvasWidth
    ).toFixed(0),
    y: Number(
      (yOffset / heatmapState.yAxisLength) * heatmapState.canvasWidth
    ).toFixed(0),
  };
}

function cartesiansToLnglats(cartesians, viewer) {
  if (!cartesians || cartesians.length < 1) return;
  viewer = viewer || window.viewer;
  if (!viewer) {
    console.log("请传入viewer对象");
    return;
  }
  var coordinates = [];
  for (var i = 0; i < cartesians.length; i++) {
    coordinates.push(cartesianToLnglat(cartesians[i], viewer));
  }
  return coordinates;
}

function cartesianToLnglat(cartesian, viewer) {
  if (!cartesian) return [];
  viewer = viewer || window.viewer;
  var cartographic = Cesium.Cartographic.fromCartesian(cartesian);
  var latitude = Cesium.Math.toDegrees(cartographic.latitude);
  var longitude = Cesium.Math.toDegrees(cartographic.longitude);
  var height = cartographic.height;
  return [longitude, latitude, height];
}

function computeBoundingBox(positions, heatmapState) {
  if (!positions) return;
  const boundingSphere = Cesium.BoundingSphere.fromPoints(
    positions,
    new Cesium.BoundingSphere()
  );
  const centerPoint = boundingSphere.center;
  const sphereRadius = boundingSphere.radius;

  const modelMatrix = Cesium.Transforms.eastNorthUpToFixedFrame(
    centerPoint.clone()
  );
  const modelMatrixInverse = Cesium.Matrix4.inverse(
    modelMatrix.clone(),
    new Cesium.Matrix4()
  );
  const yAxisVector = new Cesium.Cartesian3(0, 1, 0);

  const boundingVertices = [];
  for (let angle = 45; angle <= 360; angle += 90) {
    const rotationMatrix = Cesium.Matrix3.fromRotationZ(
      Cesium.Math.toRadians(angle),
      new Cesium.Matrix3()
    );
    let rotatedYAxis = Cesium.Matrix3.multiplyByVector(
      rotationMatrix,
      yAxisVector,
      new Cesium.Cartesian3()
    );
    rotatedYAxis = Cesium.Cartesian3.normalize(
      rotatedYAxis,
      new Cesium.Cartesian3()
    );
    const scaledVector = Cesium.Cartesian3.multiplyByScalar(
      rotatedYAxis,
      sphereRadius,
      new Cesium.Cartesian3()
    );
    const vertex = Cesium.Matrix4.multiplyByPoint(
      modelMatrix,
      scaledVector.clone(),
      new Cesium.Cartesian3()
    );

    boundingVertices.push(vertex);
  }

  const coordinates = cartesiansToLnglats(
    boundingVertices,
    heatmapState.viewer
  );
  let minLatitude = Number.MAX_VALUE,
    maxLatitude = Number.MIN_VALUE,
    minLongitude = Number.MAX_VALUE,
    maxLongitude = Number.MIN_VALUE;
  const vertexCount = boundingVertices.length;

  coordinates.forEach((coordinate) => {
    if (coordinate[0] < minLongitude) minLongitude = coordinate[0];
    if (coordinate[0] > maxLongitude) maxLongitude = coordinate[0];
    if (coordinate[1] < minLatitude) minLatitude = coordinate[1];
    if (coordinate[1] > maxLatitude) maxLatitude = coordinate[1];
  });

  const latitudeRange = maxLatitude - minLatitude;
  const longitudeRange = maxLongitude - minLongitude;

  heatmapState.boundingRect = {
    minLatitude: minLatitude - latitudeRange / vertexCount,
    maxLatitude: maxLatitude + latitudeRange / vertexCount,
    minLongitude: minLongitude - longitudeRange / vertexCount,
    maxLongitude: maxLongitude + longitudeRange / vertexCount,
  };

  heatmapState.boundingBox = {
    leftTop: Cesium.Cartesian3.fromDegrees(
      heatmapState.boundingRect.minLongitude,
      heatmapState.boundingRect.maxLatitude
    ),
    leftBottom: Cesium.Cartesian3.fromDegrees(
      heatmapState.boundingRect.minLongitude,
      heatmapState.boundingRect.minLatitude
    ),
    rightTop: Cesium.Cartesian3.fromDegrees(
      heatmapState.boundingRect.maxLongitude,
      heatmapState.boundingRect.maxLatitude
    ),
    rightBottom: Cesium.Cartesian3.fromDegrees(
      heatmapState.boundingRect.maxLongitude,
      heatmapState.boundingRect.minLatitude
    ),
  };

  heatmapState.xAxis = Cesium.Cartesian3.subtract(
    heatmapState.boundingBox.rightTop,
    heatmapState.boundingBox.leftTop,
    new Cesium.Cartesian3()
  );
  heatmapState.xAxis = Cesium.Cartesian3.normalize(
    heatmapState.xAxis,
    new Cesium.Cartesian3()
  );
  heatmapState.yAxis = Cesium.Cartesian3.subtract(
    heatmapState.boundingBox.leftBottom,
    heatmapState.boundingBox.leftTop,
    new Cesium.Cartesian3()
  );
  heatmapState.yAxis = Cesium.Cartesian3.normalize(
    heatmapState.yAxis,
    new Cesium.Cartesian3()
  );
  heatmapState.xAxisLength = Cesium.Cartesian3.distance(
    heatmapState.boundingBox.rightTop,
    heatmapState.boundingBox.leftTop
  );
  heatmapState.yAxisLength = Cesium.Cartesian3.distance(
    heatmapState.boundingBox.leftBottom,
    heatmapState.boundingBox.leftTop
  );
}

function createHeatmapGeometry(heatmapState) {
  const meshData = generateMeshData(heatmapState);
  const geometry = new Cesium.Geometry({
    attributes: new Cesium.GeometryAttributes({
      position: new Cesium.GeometryAttribute({
        componentDatatype: Cesium.ComponentDatatype.DOUBLE,
        componentsPerAttribute: 3,
        values: meshData.positions,
      }),
      st: new Cesium.GeometryAttribute({
        componentDatatype: Cesium.ComponentDatatype.FLOAT,
        componentsPerAttribute: 2,
        values: new Float32Array(meshData.textureCoords),
      }),
    }),
    indices: new Uint16Array(meshData.indices),
    primitiveType: Cesium.PrimitiveType[heatmapState.primitiveType],
    boundingSphere: Cesium.BoundingSphere.fromVertices(meshData.positions),
  });
  return geometry;
}

function generateMeshData(heatmapState) {
  const gridWidth = heatmapState.canvasWidth || 200;
  const gridHeight = heatmapState.canvasWidth || 200;
  const { maxLongitude, maxLatitude, minLongitude, minLatitude } =
    heatmapState.boundingRect;

  const longitudeStep = (maxLongitude - minLongitude) / gridWidth;
  const latitudeStep = (maxLatitude - minLatitude) / gridHeight;
  const positions = [];
  const textureCoords = [];
  const indices = [];

  for (let i = 0; i < gridWidth; i++) {
    const currentLongitude = minLongitude + longitudeStep * i;

    for (let j = 0; j < gridHeight; j++) {
      const currentLatitude = minLatitude + latitudeStep * j;
      const heatValue = heatmapState.heatmapInstance.getValueAt({
        x: i,
        y: j,
      });
      const cartesian3 = Cesium.Cartesian3.fromDegrees(
        currentLongitude,
        currentLatitude,
        heatmapState.baseElevation + heatValue
      );
      positions.push(cartesian3.x, cartesian3.y, cartesian3.z);
      textureCoords.push(i / gridWidth, j / gridHeight);
      if (j !== gridHeight - 1 && i !== gridWidth - 1) {
        indices.push(
          i * gridHeight + j,
          i * gridHeight + j + 1,
          (i + 1) * gridHeight + j
        );
        indices.push(
          (i + 1) * gridHeight + j,
          (i + 1) * gridHeight + j + 1,
          i * gridHeight + j + 1
        );
      }
    }
  }

  return {
    positions,
    textureCoords,
    indices,
  };
}

function createHeatmapContainer(heatmapState) {
  heatmapState.containerElement = window.document.createElement("div");
  heatmapState.containerElement.id = `heatmap-${heatmapState.instanceId}`;
  heatmapState.containerElement.className = `heatmap`;
  heatmapState.containerElement.style.width = `${heatmapState.canvasWidth}px`;
  heatmapState.containerElement.style.height = `${heatmapState.canvasWidth}px`;
  heatmapState.containerElement.style.position = "absolute";
  heatmapState.containerElement.style.display = "none";
  const mapContainer = window.document.getElementById(
    heatmapState.viewer.container.id
  );
  mapContainer.appendChild(heatmapState.containerElement);
}

const DOM = document.getElementById("box");

const viewer = new Cesium.Viewer(DOM, {
  animation: false, //是否创建动画小器件,左下角仪表

  baseLayerPicker: false, //是否显示图层选择器,右上角图层选择按钮

  baseLayer: Cesium.ImageryLayer.fromProviderAsync(
    Cesium.ArcGisMapServerImageryProvider.fromUrl(
      "https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer"
    )
  ),

  fullscreenButton: false, //是否显示全屏按钮,右下角全屏选择按钮

  timeline: false, //是否显示时间轴

  infoBox: false, //是否显示信息框
});

viewer._cesiumWidget._creditContainer.style.display = "none";

// 模拟数值
const points = new Array(50).fill("").map(() => {
  return {
    lnglat: [
      116.46 + Math.random() * 0.1 * (Math.random() > 0.5 ? 1 : -1),
      39.92 + Math.random() * 0.1 * (Math.random() > 0.5 ? 1 : -1),
    ],

    value: 1000 * Math.random(),
  };
});
// 创建热力图
create3DHeatmap(viewer, {
  dataPoints: points,
  radius: 15,
  baseElevation: 0,
  primitiveType: "TRIANGLES",
  colorGradient: {
    ".3": "blue",
    ".5": "green",
    ".7": "yellow",
    ".95": "red",
  },
});
viewer.camera.flyTo({
  destination: Cesium.Cartesian3.fromDegrees(116.46, 39.92, 100000),
  orientation: {},
  duration: 3,
});

效果展示

通过以上实现,我们可以得到一个随热力值高度变化的三维热力图效果。热力值越大的区域,高度越高,同时颜色也越深。这种可视化方式能够更直观地展示数据的空间分布特征。

总结

本文介绍了在Cesium中实现三维热力图的方法。通过结合heatmap.js和Cesium的Primitive,我们实现了热力图的三维化展示。这种可视化方式不仅保留了传统热力图的密度展示特性,还增加了高度维度的信息表达,使数据展示更加丰富和直观。希望本文对大家实现类似功能有所帮助。如有任何问题,欢迎在评论区讨论交流。

相关推荐
崔庆才丨静觅4 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60614 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了4 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅4 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅5 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅5 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment5 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅6 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊6 小时前
jwt介绍
前端
爱敲代码的小鱼6 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax