diff --git a/CHANGES.md b/CHANGES.md index e8a7a93e1692..2bdc12b7d31b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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 diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 8be2fa306470..45bb04290c5f 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -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) diff --git a/packages/engine/Source/DataSources/EntityCluster.js b/packages/engine/Source/DataSources/EntityCluster.js index ff572aaa975e..94055780a2bd 100644 --- a/packages/engine/Source/DataSources/EntityCluster.js +++ b/packages/engine/Source/DataSources/EntityCluster.js @@ -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. @@ -216,7 +218,7 @@ function getScreenSpacePositions( } } -const pointBoundinRectangleScratch = new BoundingRectangle(); +const pointBoundingRectangleScratch = new BoundingRectangle(); const totalBoundingRectangleScratch = new BoundingRectangle(); const neighborBoundingRectangleScratch = new BoundingRectangle(); @@ -414,7 +416,7 @@ function createDeclutterCallback(entityCluster) { point.coord, pixelRange, entityCluster, - pointBoundinRectangleScratch, + pointBoundingRectangleScratch, ); const totalBBox = BoundingRectangle.clone( bbox, @@ -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; }; } @@ -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} + */ + declusterEvent: { + get: function () { + return this._declusterEvent; + }, + }, /** * Gets or sets whether clustering billboard entities is enabled. * @memberof EntityCluster.prototype @@ -993,6 +1025,7 @@ EntityCluster.prototype.destroy = function () { this._previousClusters = []; this._previousHeight = undefined; + this._previouslyClusteredEntities = []; this._enabledDirty = false; this._pixelRangeDirty = false; @@ -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; diff --git a/packages/engine/Specs/DataSources/EntityClusterSpec.js b/packages/engine/Specs/DataSources/EntityClusterSpec.js index ca13f98058b3..14c0f6b0d92f 100644 --- a/packages/engine/Specs/DataSources/EntityClusterSpec.js +++ b/packages/engine/Specs/DataSources/EntityClusterSpec.js @@ -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", );