Select to view content in your preferred language

Determine whether a feature inside a FeatureLayer is clustered or not with a query

254
3
08-09-2024 04:51 AM
Slyke
by
Emerging Contributor

Hello,

How can I determine whether a feature has been clustered or not?

I have tried:

 

 

      const queryRendered = circleFeatureLayer.createQuery();
      queryRendered.returnGeometry = true;
      queryRendered.outFields = ['someId'];
      query.where = '1=1';
      const renderedFeatures = await circleFeatureLayer.queryFeatures(queryRendered);

      renderedFeatures.features.forEach((feature) => {
        const isClustered = feature.getAttribute('cluster_count');
        console.log(feature, {
          isClustered, // undefined
          visible: feature?.visible, // True
          isAggregate: feature?.isAggregate // False
        });
      });

 

 

But for all the features on the map, both clustered and unclustered items, isClustered is undefined, visible is true and isAggregate is false. I can't seem to find any property that lets me know if a feature is being rendered or not.

0 Kudos
3 Replies
JoelBennett
MVP Regular Contributor

There doesn't appear to be a simple way to do this, but the following function would do the job in theory. Note, due to the nature of hitTest, it can't just return a Boolean, but instead has to return a Promise.

function checkCluster(view, graphic) {
	return new Promise(function(resolve, reject) {
		view.hitTest(view.toScreen(graphic.geometry), {include:[graphic.layer]}).then(function(hitTestResult) {
			var result = ((hitTestResult.results.length === 0) || (hitTestResult.results[0].isAggregate === true));

			resolve({graphic:graphic, isCluster:result});
		}, reject);
	});
}

 

However, it doesn't work as expected in practice.  The reason is because in cases where a graphic that is part of a cluster doesn't intersect the cluster graphic, hitTest will still return the graphic from the layer, even though it's not visible in the view.

I don't think hitTest is supposed to work this way. The documentation is somewhat ambiguous, but my expectation is that it's supposed to only return objects that are visible in the view.  After all, why would a hit test return something that's not rendered in the view?  It doesn't seem like you should be able to"hit" something that's not there.

@UndralBatsukh could you please weigh in here?

Here is the adjusted code for testing that you can paste into the Sandbox for the "Intro to clustering" sample. If you click somewhere on the map, the dev tools console will output results, indicating if the features whose coordinates (but not symbology!) intersecting the view are part of a cluster or not. I highly recommend panning to a region where there's only a few, for clarity of results.

<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta
    name="viewport"
    content="initial-scale=1,maximum-scale=1,user-scalable=no"
  />

    <title>Intro to clustering | Sample | ArcGIS Maps SDK for JavaScript 4.30</title>

    <style>
      html,
      body,
      #viewDiv {
        padding: 0;
        margin: 0;
        height: 100%;
        width: 100%;
        background: rgba(50,50,50);
      }
      #infoDiv {
        padding: 10px;
      }
    </style>

    <link rel="stylesheet" href="https://js.arcgis.com/4.30/esri/themes/dark/main.css" />
    <script src="https://js.arcgis.com/4.30/"></script>

    <script>
      require([
        "esri/Map",
        "esri/layers/FeatureLayer",
        "esri/layers/GeoJSONLayer",
        "esri/views/MapView",
        "esri/widgets/Legend",
        "esri/widgets/Expand",
        "esri/widgets/Home"
      ], (Map, FeatureLayer, GeoJSONLayer, MapView, Legend, Expand, Home) => {

        // Configures clustering on the layer. A cluster radius
        // of 100px indicates an area comprising screen space 100px
        // in length from the center of the cluster

        const clusterConfig = {
          type: "cluster",
          clusterRadius: "100px",
          // {cluster_count} is an aggregate field containing
          // the number of features comprised by the cluster
          popupTemplate: {
            title: "Cluster summary",
            content: "This cluster represents {cluster_count} earthquakes.",
            fieldInfos: [{
              fieldName: "cluster_count",
              format: {
                places: 0,
                digitSeparator: true
              }
            }]
          },
          clusterMinSize: "24px",
          clusterMaxSize: "60px",
          labelingInfo: [{
            deconflictionStrategy: "none",
            labelExpressionInfo: {
              expression: "Text($feature.cluster_count, '#,###')"
            },
            symbol: {
              type: "text",
              color: "#004a5d",
              font: {
                weight: "bold",
                family: "Noto Sans",
                size: "12px"
              }
            },
            labelPlacement: "center-center",
          }]
        };


        const layer = new GeoJSONLayer({
          title: "Earthquakes from the last month",
          url: "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_month.geojson",
          copyright: "USGS Earthquakes",

          featureReduction: clusterConfig,

          // popupTemplates can still be viewed on
          // individual features
          popupTemplate: {
            title: "Magnitude {mag} {type}",
            content: "Magnitude {mag} {type} hit {place} on {time}",
            fieldInfos: [
              {
                fieldName: "time",
                format: {
                  dateFormat: "short-date-short-time"
                }
              }
            ]
          },
          renderer: {
            type: "simple",
            field: "mag",
            symbol: {
              type: "simple-marker",
              size: 4,
              color: "#69dcff",
              outline: {
                color: "rgba(0, 139, 174, 0.5)",
                width: 5
              }
            }
          }
        });

        // background layer for geographic context
        // projected to Alaska Polar Stereographic
        const baseLayer = new FeatureLayer({
          portalItem: {
            id: "2b93b06dc0dc4e809d3c8db5cb96ba69"
          },
          legendEnabled: false,
          popupEnabled: false,
          renderer: {
            type: "simple",
            symbol: {
              type: "simple-fill",
              color: [65, 65, 65, 1],
              outline: {
                color: [50, 50, 50, 0.75],
                width: 0.5
              }
            }
          },
          spatialReference: {
            wkid: 5936
          }
        });

        const map = new Map({
          layers: [baseLayer, layer]
        });

        const view = new MapView({
          container: "viewDiv",
          extent: {
            spatialReference: {
              wkid: 5936
            },
            xmin: 1270382,
            ymin: -1729511,
            xmax: 2461436,
            ymax: -953893
          },
          spatialReference: {
            // WGS_1984_EPSG_Alaska_Polar_Stereographic
            wkid: 5936
          },
          constraints: {
            minScale: 15469455
          },
          map: map
        });

        view.ui.add(new Home({
          view: view
        }), "top-left");

        const legend = new Legend({
          view: view,
          container: "legendDiv"
        });

        const infoDiv = document.getElementById("infoDiv");
        view.ui.add(new Expand({
          view: view,
          content: infoDiv,
          expandIcon: "list-bullet",
          expanded: false
        }), "top-left");

        const toggleButton = document.getElementById("cluster");

        // To turn off clustering on a layer, set the
        // featureReduction property to null
        toggleButton.addEventListener("click", () => {
          let fr = layer.featureReduction;
          layer.featureReduction = fr && fr.type === "cluster" ? null : clusterConfig;
          toggleButton.innerText = toggleButton.innerText === "Enable Clustering" ? "Disable Clustering" : "Enable Clustering";
        });

        function checkCluster(view, graphic) {
          return new Promise(function(resolve, reject) {
            view.hitTest(view.toScreen(graphic.geometry), {include:[graphic.layer]}).then(function(hitTestResult) {
              var result = ((hitTestResult.results.length === 0) || (hitTestResult.results[0].isAggregate === true));

              resolve({graphic:graphic, isCluster:result});
            }, reject);
          });
        }

        view.on("click", function(evt) {
          view.whenLayerView(layer).then(function(layerView) {
            var query = layerView.createQuery();
            query.geometry = view.extent;

            layerView.queryFeatures(query).then(function(featureSet) {
              featureSet.features.forEach(function(feature, index) {
                checkCluster(view, feature).then(function(result) {
                  console.info(result.graphic.getObjectId().toString() + ": " + result.isCluster.toString());
                });
              });
            });
          });
        });
      });
    </script>
  </head>

  <body>
    <div id="viewDiv"></div>
    <div id="infoDiv" class="esri-widget">
      <button id="cluster" class="esri-button">Disable Clustering</button>
      <div id="legendDiv"></div>
    </div>
  </body>
</html>

 

In this example below, we see there's a cluster of 10 features, and a standalone feature:

image1.png

 

Here's what it looks like with clustering disabled:

image2.png

 

Before running the test, I would expect one unclustered feature, and as many as 10 features that have been clustered. (I say "as many as" because some could be off-screen.)  However, running the test indicates 5 unclustered features, and 5 clustered features. Four of those features said to be unclustered are such because their shapes don't intersect the cluster graphic, and therefore are invisible, but returned by hitTest nonetheless.

This kind of workflow is the only way I can think of to determine if a feature is clustered or not.  But with hitTest implemented the way it is, the results here are unreliable.

KristianEkenes
Esri Regular Contributor

A couple of things on this:

1. You can tell if a feature represents a cluster if the isAggregate property of the feature is `true`.  Checking for `cluster_count` is pretty reliable for clusters, but unreliable if you want to check if a feature represents an aggregate from binning or clustering.

2. You cannot tell if a feature is an aggregate from a layer query (e.g. `layer.queryFeatures()`). This will always result in returning features from the underlying service, not from the client. At this point, clusters (and bins) are always generated client-side. Therefore, you cannot query them from a service. However, you may query them on the layerView.

3. You can query aggregates (i.e. clusters or bins) from the layer view using the queryAggregates() method. This will only query aggregates in the view, and not return any individual features.

4. If you query an aggregate that you want to drill into further (e.g. you query for a cluster with 5,000 features) you can use the resulting aggregate graphic of that query to query the features represented by (or contained within) that cluster. You can do this by calling the queryFeatures() method on the layer view and by setting the aggregateIds property to include the id of the aggregate feature you had just queried.

5. How can I determine whether a feature has been clustered or not? This depends on the origin of the feature. You can easily find out through hitTest if you are getting the feature from a click event. Simply check the isAggregate property. If you are getting the feature from a service query, then it's a bit more complicated. You would need to query all aggregates, iterate through each, then query all features included in each aggregate and check for a match. This is a pretty fast operation since these are all client-side queries. Here's an example of how to do this: https://codepen.io/kekenes/pen/MWMOrVP?editors=1000 In that sample I query a feature from the service, then highlight the cluster to which it belongs (even though it is not rendered in the view).

const highlightCluster = async (layerView, id ) => {
  if (highlightHandle) {
    highlightHandle.remove();
    highlightHandle = null;
  }
  
  const { features } = await layerView.queryAggregates();
  
  features.forEach( async (f) => {
    const aggId = f.getObjectId();
    const q = layerView.createQuery();
    q.aggregateIds = [ aggId ];
    
    const { features } = await layerView.queryFeatures(q);
    const result = features.find(f => f.getObjectId() === id);
    if(result){
      highlightHandle = layerView.highlight([aggId]);
    }
  });
};

Hopefully this helps.

JoelBennett
MVP Regular Contributor

Thank you @KristianEkenes, I didn't know about queryAggregates, but it's a game-changer for this problem.  However, there are a couple subtleties about this approach that have to be taken into account, one of which I think should be classified as a bug.

I've revised my "checkCluster" function below, which produces accurate results now:

 

function checkCluster(view, graphic) {
	return new Promise(function(resolve, reject) {
		view.whenLayerView(graphic.layer).then(function(layerView) {
			layerView.queryAggregates().then(function(featureSet) {
				var aggregateIDs = [];

				featureSet.features.forEach(function(feature, index) {
//					if (feature.attributes.cluster_count === 1)
//						console.warn("Aggregate feature with OID " + feature.getObjectId() + " has cluster count of 1; this shouldn't be possible...");

					if ((feature.attributes.cluster_count > 1) && (view.extent.intersects(feature.geometry)))
						aggregateIDs.push(feature.getObjectId());
				});

				if (aggregateIDs.length === 0)
					resolve({graphic:graphic, isCluster:false});
				else {
					var query = layerView.createQuery();
					query.aggregateIds = aggregateIDs;

					layerView.queryFeatures(query).then(function(featureSet2) {
						for (var x = 0; x < featureSet2.features.length; x++) {
							if (featureSet2.features[x].getObjectId() == graphic.getObjectId()) {
								resolve({graphic:graphic, isCluster:true});
								return;
							}
						}

						resolve({graphic:graphic, isCluster:false});
					}, reject);
				}
			}, reject);
		}, reject);
	});
}

 

 

The two subtleties are addressed on line 11.  One is that queryAggregates, as documented, doesn't support spatial queries.  This is fine in and of itself...just something to be aware of.  The second condition in line 11 is used to filter only results that intersect the View, but that may not be necessary depending on your needs.

The other issue is addressed by the first condition, and also further highlighted by lines 8 and 9, which are commented out.  The queryAggregates function can (and does) return features with a cluster_count of 1 (one).  I don't think this should be possible.  Here is a quote from the FeatureReductionCluster documentation (all emphasis is found in the original):

"This documentation refers to two kinds of features: aggregate features and individual features. Aggregate features represent clusters. Clusters always represent two or more features. There's no such thing as clusters with a count of one. If a feature does not belong to a cluster, it is considered an individual feature."

I don't think the documentation there could really be any clearer.  If "there's no such thing as clusters with a count of one", then I don't think queryAggregates should be returning features with a cluster_count of one.  It definitely threw me for a loop as I was revising my function.

0 Kudos