Skip to content
1 change: 1 addition & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@

- Adds Google2DImageryProvider to load imagery from [Google Maps](https://developers.google.com/maps/documentation/tile/2d-tiles-overview) [#12913](https://github.com/CesiumGS/cesium/pull/12913)
- Adds an async factory method for the Material class that allows callers to wait on resource loading. [#10566](https://github.com/CesiumGS/cesium/issues/10566)
- Adds new declusteredEvent: Fires with complete clustering information including both clustered and declustered entities [#5760](https://github.com/CesiumGS/cesium/issues/5760)

## 1.133.1 - 2025-09-08

Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -432,3 +432,4 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to contribute to Cesiu
- [Pamela Augustine](https://github.com/pamelaAugustine)
- [宋时旺](https://github.com/BlockCnFuture)
- [Marco Zhan](https://github.com/marcoYxz)
- [Alexander Remer](https://github.com/Oko-Tester)
54 changes: 50 additions & 4 deletions packages/engine/Source/DataSources/EntityCluster.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ function EntityCluster(options) {
this._removeEventListener = undefined;

this._clusterEvent = new Event();
this._declusterEvent = new Event();
this._previouslyClusteredEntities = [];

/**
* Determines if entities in this collection will be shown.
Expand Down Expand Up @@ -216,7 +218,7 @@ function getScreenSpacePositions(
}
}

const pointBoundinRectangleScratch = new BoundingRectangle();
const pointBoundingRectangleScratch = new BoundingRectangle();
const totalBoundingRectangleScratch = new BoundingRectangle();
const neighborBoundingRectangleScratch = new BoundingRectangle();

Expand Down Expand Up @@ -414,7 +416,7 @@ function createDeclutterCallback(entityCluster) {
point.coord,
pixelRange,
entityCluster,
pointBoundinRectangleScratch,
pointBoundingRectangleScratch,
);
const totalBBox = BoundingRectangle.clone(
bbox,
Expand Down Expand Up @@ -500,8 +502,28 @@ function createDeclutterCallback(entityCluster) {
entityCluster._clusterPointCollection = undefined;
}

entityCluster._previousClusters = newClusters;
entityCluster._previousHeight = currentHeight;
const currentlyClusteredIds = [];

if (newClusters.length > 0 && defined(clusteredLabelCollection)) {
for (let c = 0; c < clusteredLabelCollection.length; ++c) {
const clusterLabel = clusteredLabelCollection.get(c);
if (defined(clusterLabel) && defined(clusterLabel.id)) {
currentlyClusteredIds.push(...clusterLabel.id);
}
}
}

const hasActiveClusters = currentlyClusteredIds.length > 0;
const hadPreviouslyClusters =
entityCluster._previouslyClusteredEntities.length > 0;

if (!hasActiveClusters && hadPreviouslyClusters) {
entityCluster._declusterEvent.raiseEvent(
entityCluster._previouslyClusteredEntities,
);
}

entityCluster._previouslyClusteredEntities = currentlyClusteredIds;
};
}

Expand Down Expand Up @@ -567,6 +589,16 @@ Object.defineProperties(EntityCluster.prototype, {
return this._clusterEvent;
},
},
/**
* Gets the event that will be raised when all entities have been declustered.
* @memberof EntityCluster.prototype
* @type {Event<EntityCluster.declusterCallback>}
*/
declusterEvent: {
get: function () {
return this._declusterEvent;
},
},
/**
* Gets or sets whether clustering billboard entities is enabled.
* @memberof EntityCluster.prototype
Expand Down Expand Up @@ -993,6 +1025,7 @@ EntityCluster.prototype.destroy = function () {

this._previousClusters = [];
this._previousHeight = undefined;
this._previouslyClusteredEntities = [];

this._enabledDirty = false;
this._pixelRangeDirty = false;
Expand All @@ -1019,4 +1052,17 @@ EntityCluster.prototype.destroy = function () {
* cluster.label.text = entities.length.toLocaleString();
* });
*/
/**
* A event listener function used when all entities have been declustered.
* @callback EntityCluster.declusterCallback
*
* @param {Entity[]} declusteredEntities An array of the entities that were in the last
* remaining clusters and are now displayed individually.
*
* @example
* // Listen for decluster events
* dataSource.clustering.declusterEvent.addEventListener(function(entities) {
* console.log('All clusters removed. Last entities declustered:', entities);
* });
*/
export default EntityCluster;
181 changes: 181 additions & 0 deletions packages/engine/Specs/DataSources/EntityClusterSpec.js
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not an expert on unit testing philosphy, so maybe this is a bad suggestion, but could we maybe have unit test(s) that check that clustering generally works for all three of: points, billboards, and labels (collections)?

I'm just a little wary given the discussion about using the item.show property as if these classes all have a shared abstract interface (which they don't). I don't expect that to be an issue, but if someone were to ever change that property name on one of the classes, at least having a failing unit test here would prevent the issue.

Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,187 @@ describe(
expect(cluster._billboardCollection).toBeDefined();
expect(cluster._billboardCollection.length).toEqual(2);
});

it("has declusteredEvent property", function () {
cluster = new EntityCluster();
expect(cluster.declusteredEvent).toBeDefined();
expect(typeof cluster.declusteredEvent.addEventListener).toEqual(
"function",
);
});

it("provides access to clustering data via new API methods", function () {
cluster = new EntityCluster();
cluster._initialize(scene);

expect(typeof cluster.getLastClusteredEntities).toEqual("function");
expect(typeof cluster.getLastDeclusteredEntities).toEqual("function");
expect(typeof cluster.getAllProcessedEntities).toEqual("function");

expect(cluster.getLastClusteredEntities()).toEqual([]);
expect(cluster.getLastDeclusteredEntities()).toEqual([]);
expect(cluster.getAllProcessedEntities()).toEqual([]);
});

it("fires declusteredEvent with both clustered and declustered entities", function () {
cluster = new EntityCluster();
cluster._initialize(scene);

let receivedData = null;
cluster.declusteredEvent.addEventListener(function (data) {
receivedData = data;
});

const entity1 = new Entity();
const point1 = cluster.getPoint(entity1);
point1.id = entity1;
point1.pixelSize = 1;
point1.position = SceneTransforms.drawingBufferToWorldCoordinates(
scene,
new Cartesian2(0.0, 0.0),
depth,
);

const entity2 = new Entity();
const point2 = cluster.getPoint(entity2);
point2.id = entity2;
point2.pixelSize = 1;
point2.position = SceneTransforms.drawingBufferToWorldCoordinates(
scene,
new Cartesian2(1.0, 1.0),
depth,
);

const entity3 = new Entity();
const point3 = cluster.getPoint(entity3);
point3.id = entity3;
point3.pixelSize = 1;
point3.position = SceneTransforms.drawingBufferToWorldCoordinates(
scene,
new Cartesian2(scene.canvas.clientWidth, scene.canvas.clientHeight),
farDepth,
);

cluster.enabled = true;
return updateUntilDone(cluster).then(function () {
expect(receivedData).toBeDefined();
expect(receivedData.clustered).toBeDefined();
expect(receivedData.declustered).toBeDefined();
expect(Array.isArray(receivedData.clustered)).toEqual(true);
expect(Array.isArray(receivedData.declustered)).toEqual(true);
});
});

it("maintains backward compatibility - original clusterEvent still works", function () {
cluster = new EntityCluster();
cluster._initialize(scene);

let originalEventFired = false;
let newEventFired = false;

cluster.clusterEvent.addEventListener(function (entities, clusterObj) {
originalEventFired = true;
expect(Array.isArray(entities)).toEqual(true);
expect(clusterObj).toBeDefined();
});

cluster.declusteredEvent.addEventListener(function (data) {
newEventFired = true;
expect(data.clustered).toBeDefined();
expect(data.declustered).toBeDefined();
});

const entity1 = new Entity();
const point1 = cluster.getPoint(entity1);
point1.id = entity1;
point1.pixelSize = 1;
point1.position = SceneTransforms.drawingBufferToWorldCoordinates(
scene,
new Cartesian2(0.0, 0.0),
depth,
);

const entity2 = new Entity();
const point2 = cluster.getPoint(entity2);
point2.id = entity2;
point2.pixelSize = 1;
point2.position = SceneTransforms.drawingBufferToWorldCoordinates(
scene,
new Cartesian2(1.0, 1.0),
depth,
);

cluster.enabled = true;
return updateUntilDone(cluster).then(function () {
expect(originalEventFired).toEqual(true);
expect(newEventFired).toEqual(true);
});
});

it("tracks processed entities correctly", function () {
cluster = new EntityCluster();
cluster._initialize(scene);

const entity1 = new Entity();
const point1 = cluster.getPoint(entity1);
point1.id = entity1;
point1.pixelSize = 1;
point1.position = SceneTransforms.drawingBufferToWorldCoordinates(
scene,
new Cartesian2(0.0, 0.0),
depth,
);

const entity2 = new Entity();
const point2 = cluster.getPoint(entity2);
point2.id = entity2;
point2.pixelSize = 1;
point2.position = SceneTransforms.drawingBufferToWorldCoordinates(
scene,
new Cartesian2(scene.canvas.clientWidth, scene.canvas.clientHeight),
farDepth,
);

cluster.enabled = true;
return updateUntilDone(cluster).then(function () {
const clusteredEntities = cluster.getLastClusteredEntities();
const declusteredEntities = cluster.getLastDeclusteredEntities();
const allProcessed = cluster.getAllProcessedEntities();

expect(allProcessed.length).toBeGreaterThan(0);
expect(
clusteredEntities.length + declusteredEntities.length,
).toBeLessThanOrEqual(allProcessed.length);
});
});

it("cleans up tracking arrays on destroy", function () {
cluster = new EntityCluster();
cluster._initialize(scene);

const entity = new Entity();
const point = cluster.getPoint(entity);
point.id = entity;
point.pixelSize = 1;
point.position = SceneTransforms.drawingBufferToWorldCoordinates(
scene,
new Cartesian2(0.0, 0.0),
depth,
);

cluster.enabled = true;
return updateUntilDone(cluster).then(function () {
expect(cluster._allProcessedEntities).toBeDefined();
expect(cluster._lastClusteredEntities).toBeDefined();
expect(cluster._lastDeclusteredEntities).toBeDefined();

cluster.destroy();

expect(cluster._allProcessedEntities).toEqual([]);
expect(cluster._lastClusteredEntities).toEqual([]);
expect(cluster._lastDeclusteredEntities).toEqual([]);
});
});
},
"WebGL",
);