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