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..538828f70ed2 100644 --- a/packages/engine/Source/DataSources/EntityCluster.js +++ b/packages/engine/Source/DataSources/EntityCluster.js @@ -67,6 +67,11 @@ function EntityCluster(options) { this._clusterEvent = new Event(); + this._declusteredEvent = new Event(); + this._allProcessedEntities = []; + this._lastClusteredEntities = []; + this._lastDeclusteredEntities = []; + /** * Determines if entities in this collection will be shown. * @@ -127,6 +132,10 @@ function getBoundingBox(item, coord, pixelRange, entityCluster, result) { function addNonClusteredItem(item, entityCluster) { item.clusterShow = true; + if (defined(item.id)) { + entityCluster._lastDeclusteredEntities.push(item.id); + } + if ( !defined(item._labelCollection) && defined(item.id) && @@ -157,7 +166,16 @@ function addCluster(position, numPoints, ids, entityCluster) { cluster.point.position = position; + entityCluster._lastClusteredEntities = + entityCluster._lastClusteredEntities.concat(ids); + entityCluster._clusterEvent.raiseEvent(ids, cluster); + + entityCluster._declusteredEvent.raiseEvent({ + clustered: ids, + declustered: entityCluster._lastDeclusteredEntities.slice(), + cluster: cluster, + }); } function hasLabelIndex(entityCluster, entityId) { @@ -207,6 +225,10 @@ function getScreenSpacePositions( continue; } + if (defined(item.id)) { + entityCluster._allProcessedEntities.push(item.id); + } + points.push({ index: i, collection: collection, @@ -216,7 +238,7 @@ function getScreenSpacePositions( } } -const pointBoundinRectangleScratch = new BoundingRectangle(); +const pointBoundingRectangleScratch = new BoundingRectangle(); const totalBoundingRectangleScratch = new BoundingRectangle(); const neighborBoundingRectangleScratch = new BoundingRectangle(); @@ -226,6 +248,10 @@ function createDeclutterCallback(entityCluster) { return; } + entityCluster._allProcessedEntities = []; + entityCluster._lastClusteredEntities = []; + entityCluster._lastDeclusteredEntities = []; + const scene = entityCluster._scene; const labelCollection = entityCluster._labelCollection; @@ -240,6 +266,11 @@ function createDeclutterCallback(entityCluster) { !entityCluster._clusterLabels && !entityCluster._clusterPoints) ) { + entityCluster._declusteredEvent.raiseEvent({ + clustered: [], + declustered: [], + cluster: null, + }); return; } @@ -414,7 +445,7 @@ function createDeclutterCallback(entityCluster) { point.coord, pixelRange, entityCluster, - pointBoundinRectangleScratch, + pointBoundingRectangleScratch, ); const totalBBox = BoundingRectangle.clone( bbox, @@ -485,6 +516,18 @@ function createDeclutterCallback(entityCluster) { } } + if ( + entityCluster._lastClusteredEntities.length > 0 || + entityCluster._lastDeclusteredEntities.length > 0 + ) { + entityCluster._declusteredEvent.raiseEvent({ + clustered: entityCluster._lastClusteredEntities.slice(), + declustered: entityCluster._lastDeclusteredEntities.slice(), + cluster: null, + allProcessed: entityCluster._allProcessedEntities.slice(), + }); + } + if (clusteredLabelCollection.length === 0) { clusteredLabelCollection.destroy(); entityCluster._clusterLabelCollection = undefined; @@ -567,6 +610,16 @@ Object.defineProperties(EntityCluster.prototype, { return this._clusterEvent; }, }, + /** + * Gets the event that will be raised when clustering is processed, including both clustered and declustered entities. + * @memberof EntityCluster.prototype + * @type {Event} + */ + declusteredEvent: { + get: function () { + return this._declusteredEvent; + }, + }, /** * Gets or sets whether clustering billboard entities is enabled. * @memberof EntityCluster.prototype @@ -847,6 +900,58 @@ function disableCollectionClustering(collection) { } } +function getVisibleEntitiesFromCollection(collection) { + if (!defined(collection)) { + return []; + } + + const visibleEntities = []; + for (let i = 0; i < collection.length; i++) { + const item = collection.get(i); + if (defined(item.id) && item.show) { + visibleEntities.push(item.id); + } + } + return visibleEntities; +} + +function handleDeclusteredEvent(entityCluster) { + if (entityCluster._declusteredEvent.numberOfListeners === 0) { + return; + } + const allVisibleEntities = [ + ...getVisibleEntitiesFromCollection(entityCluster._labelCollection), + ...getVisibleEntitiesFromCollection(entityCluster._billboardCollection), + ...getVisibleEntitiesFromCollection(entityCluster._pointCollection), + ]; + + if (allVisibleEntities.length > 0) { + const uniqueEntities = Array.from(new Set(allVisibleEntities)); + + entityCluster._declusteredEvent.raiseEvent({ + clustered: [], + declustered: uniqueEntities, + cluster: null, + allProcessed: uniqueEntities, + }); + + entityCluster._lastClusteredEntities = []; + entityCluster._lastDeclusteredEntities = uniqueEntities.slice(); + entityCluster._allProcessedEntities = uniqueEntities.slice(); + } else { + entityCluster._declusteredEvent.raiseEvent({ + clustered: [], + declustered: [], + cluster: null, + allProcessed: [], + }); + + entityCluster._lastClusteredEntities = []; + entityCluster._lastDeclusteredEntities = []; + entityCluster._allProcessedEntities = []; + } +} + function updateEnable(entityCluster) { if (entityCluster.enabled) { return; @@ -869,6 +974,8 @@ function updateEnable(entityCluster) { disableCollectionClustering(entityCluster._labelCollection); disableCollectionClustering(entityCluster._billboardCollection); disableCollectionClustering(entityCluster._pointCollection); + + handleDeclusteredEvent(entityCluster); } /** @@ -998,9 +1105,37 @@ EntityCluster.prototype.destroy = function () { this._pixelRangeDirty = false; this._minimumClusterSizeDirty = false; + this._allProcessedEntities = []; + this._lastClusteredEntities = []; + this._lastDeclusteredEntities = []; + return undefined; }; +/** + * Returns the last set of clustered entities from the most recent clustering operation. + * @returns {Entity[]} Array of entities that were clustered + */ +EntityCluster.prototype.getLastClusteredEntities = function () { + return this._lastClusteredEntities.slice(); +}; + +/** + * Returns the last set of declustered entities from the most recent clustering operation. + * @returns {Entity[]} Array of entities that were not clustered + */ +EntityCluster.prototype.getLastDeclusteredEntities = function () { + return this._lastDeclusteredEntities.slice(); +}; + +/** + * Returns all entities that were processed in the most recent clustering operation. + * @returns {Entity[]} Array of all processed entities + */ +EntityCluster.prototype.getAllProcessedEntities = function () { + return this._allProcessedEntities.slice(); +}; + /** * A event listener function used to style clusters. * @callback EntityCluster.newClusterCallback @@ -1019,4 +1154,26 @@ EntityCluster.prototype.destroy = function () { * cluster.label.text = entities.length.toLocaleString(); * }); */ + +/** + * A event listener function for enhanced clustering information. + * @callback EntityCluster.declusteredCallback + * + * @param {object} clusteringData An object containing clustering information. + * @param {Entity[]} clusteringData.clustered An array of entities that were clustered. + * @param {Entity[]} clusteringData.declustered An array of entities that were not clustered. + * @param {object|null} clusteringData.cluster The cluster object (if this event is for a specific cluster) or null for summary events. + * @param {Entity[]} [clusteringData.allProcessed] An array of all entities processed (only in summary events). + * + * @example + * // Using the enhanced declusteredEvent to access both clustered and declustered entities + * dataSource.clustering.declusteredEvent.addEventListener(function(data) { + * console.log('Clustered entities:', data.clustered.length); + * console.log('Declustered entities:', data.declustered.length); + * if (data.allProcessed) { + * console.log('Total processed entities:', data.allProcessed.length); + * } + * }); + */ + 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", );