项目中有用到CesiumJS,
大概需求是在地球上建立几千个Entity. 并且随着地球的放大缩小,可以做到按区域聚合并显示这一区域的目标数量
如果只是用Cesium中的Entity,则非常简单,api中有基于dataSource的clustering方法
官方demo: https://sandcastle.cesium.com/?src=Clustering.html
API: https://cesium.com/learn/cesiumjs/ref-doc/DataSource.html?classFilter=datas
效果如下:
但是重点来了
我们的项目中有几千个Entity, 甚至上万个,官方Demo中只有几百个。
Web页面中加载上万个Entity 性能消耗太大,导致帧数降为0,巨卡无比。
因为Entity封装了太多复杂的功能,而我们需要建立的Target只是一个个的静态目标,
简单来说就只需要一个Billboard和一个Label
所以用Primitive的方式就能满足。
然而Primitive并没有自带的Clustering功能,没办法,只有看看Entity的源码了 T_T
经过看源码+求大佬经验,最后实现效果如下:
(GIF有点小,上万个目标聚合帧数60帧)
思路简介:
一个main.js,实现添加目标功能
一个库函数cluster.js,实现对目标的聚合功能
==== main.js ===
- 定义两个Collection,将一个目标分为一个Label和一个Billboard添加进Collection中
targetDataSource = {
labels: new Cesium.LabelCollection(),
billboards: new Cesium.BillboardCollection()
}
- 开启Cluster功能:
Cluster.init(viewer, {
billboards: targetDataSource.billboards,
labels: targetDataSource.labels,
})
- 更新Cluster
Cluster.update(targetDataSource, true)
=== cluster.js 核心函数 ===
- init()函数,
export const init = (viewer, option) => {
if (!viewer) return
scene = viewer.scene
entityCluster = {
_enabled: false,
_billboardCollection: option.billboards,
_labelCollection: option.labels,
_clusterBillboardCollection: undefined, //所有显示在视口内的billboard 包括target和聚合簇的billboard
_clusterLabelCollection: undefined, //所有显示在视口内的label
_pixelRange: option.pixelRange || 100,
_minimumClusterSize: option.minimumClusterSize || 10,
_previousClusters: null,
_previousHeight: undefined,
_labelShow: true,
}
setEnabled(true)
}
- setEnable()函数,添加相机改变的监听
/**
* 设置是否开启聚合
* @param {Boolean} value
*/
export const setEnabled = (value) => {
if (entityCluster._enabled !== value) {
entityCluster._enabled = value
if (entityCluster._enabled && !eventListener) {
eventListener = scene.camera.changed.addEventListener(
createDeclutterCallback
)
createDeclutterCallback()
} else {
scene.camera.changed.removeEventListener(createDeclutterCallback)
eventListener = null
}
}
}
- 相机监听的事件回调,大概思路就是,
(1)遍历所有的点,将他们从三维坐标转换成屏幕坐标
(2)根据pixelRange的范围画出一个区域getBoundingBox
(3)然后根据kdbush的聚类算法,找到当前点的所有邻居点的数量,进行聚合
(4)已经聚合的标识clustered = true 未聚合的标识clustered = false
这里有一个很重要的kdbush的聚类算法 ,具体原理如果想知道可以搜一下
function createDeclutterCallback(amount) {
if (defined(amount) && amount < 0.05) {
return
}
let pixelRange = entityCluster._pixelRange
let minimumClusterSize = entityCluster._minimumClusterSize
// 清空
if (defined(entityCluster._clusterLabelCollection)) {
entityCluster._clusterLabelCollection.removeAll()
} else {
entityCluster._clusterLabelCollection = scene.primitives.add(
new LabelCollection({ scene: scene })
)
}
if (defined(entityCluster._clusterBillboardCollection)) {
entityCluster._clusterBillboardCollection.removeAll()
} else {
entityCluster._clusterBillboardCollection = scene.primitives.add(
new BillboardCollection({ scene: scene })
)
}
let ellipsoid = scene.mapProjection.ellipsoid
let cameraPosition = scene.camera.positionWC
let occluder = new EllipsoidalOccluder(ellipsoid, cameraPosition) //椭球坐标投影到平面坐标相关的函数
let points = [] //points当前视口内显示的所有点以及他们在屏幕上的坐标 (label 和 billboard)
// 被过滤了
getScreenSpacePositions(
entityCluster._labelCollection,
points,
scene,
occluder
)
getScreenSpacePositions(
entityCluster._billboardCollection,
points,
scene,
occluder
)
let i
let j
let length
let bbox
let neighbors
let neighborLength
let neighborIndex
let neighborPoint
let ids
let numPoints
let collection
let collectionIndex
let index = kdbush(points, getX, getY, 64, Int32Array)
for (i = 0; i < points.length; ++i) {
let point = points[i]
if (point.clustered) {
continue
}
point.clustered = true
collection = point.collection
collectionIndex = point.index
let item = collection.get(collectionIndex)
bbox = getBoundingBox(
item,
point,
pixelRange,
entityCluster,
pointBoundinRectangleScratch
)
// 时间很短
neighbors = index.range(
bbox.x,
bbox.y,
bbox.x + bbox.width,
bbox.y + bbox.height
)
neighborLength = neighbors.length
let clusterPosition = Cartesian3.clone(item.position)
numPoints = 1
ids = [item.id]
numPoints = neighborLength
if (numPoints >= minimumClusterSize) {
for (j = 0; j < neighborLength; ++j) {
neighborIndex = neighbors[j]
neighborPoint = points[neighborIndex]
if (!neighborPoint.clustered) {
neighborPoint.clustered = true
let neighborItem = neighborPoint.collection.get(neighborPoint.index)
Cartesian3.add(
neighborItem.position,
clusterPosition,
clusterPosition
)
ids.push(neighborItem.id)
}
}
let position = Cartesian3.multiplyByScalar(
clusterPosition,
1.0 / numPoints,
clusterPosition
)
addCluster(position, numPoints, ids, entityCluster)
} else {
addNonClusteredItem(item, point, entityCluster)
}
}
}
///
getScreenSpacePositions() 这个函数的就是找出目前屏幕上显示的所有target,然后转换成屏幕坐标,保存在points数组里。
/**
* 找出所有视口区域内的点,放进points数组里
* @param {*} collection
* @param {*} points
* @param {*} scene
* @param {*} occluder
* @returns
*/
function getScreenSpacePositions(collection, points, scene, occluder) {
if (!defined(collection)) {
return
}
let length = collection.length //所有点
for (let i = 0; i < length; ++i) {
let item = collection.get(i)
if (
!item.show ||
(scene.mode === SceneMode.SCENE3D &&
!occluder.isPointVisible(item.position))
) {
continue
}
if (scene.mode === SceneMode.SCENE2D) {
//2D模式下,根据经纬度判断目标是否在屏幕内
//3D坐标转2D坐标
let itemPosition = SceneTransforms.wgs84ToWindowCoordinates(
scene,
item.position
)
let canvas = viewer.scene.canvas
let upperLeft = new Cartesian2(0, 0) //canvas左上角坐标转2d坐标
let lowerRight = new Cartesian2(canvas.clientWidth, canvas.clientHeight) //canvas右下角坐标转2d坐标
if (
!(
itemPosition.x > upperLeft.x &&
itemPosition.x < lowerRight.x &&
itemPosition.y > upperLeft.y &&
itemPosition.y < lowerRight.y
)
) {
// console.log("不在屏幕内", item.id)
// console.log(item)
continue
}
}
//过滤掉label的 只留下billboard的
if (defined(item._text)) {
continue
}
let coord = item.computeScreenSpacePosition(scene)
if (!defined(coord)) {
continue
}
points.push({
index: i,
collection: collection,
clustered: false,
coord: coord,
})
}
}