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,我们实现了热力图的三维化展示。这种可视化方式不仅保留了传统热力图的密度展示特性,还增加了高度维度的信息表达,使数据展示更加丰富和直观。希望本文对大家实现类似功能有所帮助。如有任何问题,欢迎在评论区讨论交流。

相关推荐
小二·10 分钟前
layui树形组件点击树节点后高亮的解决方案
前端·javascript·layui
Minions_Fatman11 分钟前
【layui】table的switch、edit修改
前端·javascript·layui
小孙姐27 分钟前
4——单页面应用程序,vue-cli脚手架
前端·javascript·vue.js
生椰拿铁You29 分钟前
15 —— Webpack中的优化——前端项目使用CDN技术
前端·webpack
生椰拿铁You29 分钟前
13 —— 开发环境调错-source map
前端
知野小兔41 分钟前
【Angular】async详解
前端·javascript·angular.js
来啦来啦~1 小时前
vue项目实现动效交互---lottie动画库
前端·vue.js·交互
没了对象省了流量ii1 小时前
11.9K Star!强大的 Web 爬虫工具 FireCrawl:为 AI 训练与数据提取提供全面支持
前端·人工智能·爬虫
我的div丢了肿么办1 小时前
vue项目中如何加载markdown作为组件
前端·javascript·vue.js
南山十一少1 小时前
Spring Boot 实战:基于 Validation 注解实现分层数据校验与校验异常拦截器统一返回处理
java·前端·spring boot·后端