ArcGIS Blog

Mapping

ArcGIS Maps SDK for JavaScript

Summarize and explore point clusters in web apps

By Kristian Ekenes

The ArcGIS API for JavaScript (JS API) version 4.18 added support for querying point clusters. This allows you to summarize and explore the features of a cluster in more detail, including the following scenarios:

  • Display the extent or convex hull of clustered features
  • Display all features belonging to a cluster
  • Display summary statistics for a cluster in a popup
  • Allow users to browse clustered features in a popup or view

The Point clustering – query clusters sample app demonstrates each of these scenarios using popup actions.

Using popup actions, you can allow users to query clusters in a variety of ways.
Using popup actions, you can allow users to query clusters in a variety of ways.

Before diving into some examples, let’s take a quick tour of what clustering is and the clustering capabilities the JS API provides you out of the box.

If you are already familiar with clustering and how it works in the ArcGIS JS API, then feel free to skip to the Query cluster features section below.

Clustering overview

Clustering is a method of merging nearby and overlapping features into a single symbol to declutter the view. The size of the cluster icon indicates the number of features in each cluster relative to other clusters. Clustering is configured with the FeatureReductionCluster class, which is set on the featureReduction property of the layer.

layer.featureReduction = {
  type: "cluster"
}

For example, check out this map of earthquakes that occurred along the Aleutian Islands in June 2020.

Earthquakes clustered.
Earthquakes that occurred along the Aleutian Islands in June 2020. The points are clustered so you can easily see where more earthquakes occur.

Clustering helps you immediately see the relative density of features. When clustering is not enabled, it’s difficult to discern areas with a lot of overlapping points.

Notice how the cluster of 1,000+ earthquakes in the previous image is not noticeable when the same layer is not clustered.

Earthquakes along the Aleutian Islands from the last 30 days. Not clustered.
Earthquakes that occurred along the Aleutian Islands in June 2020. In this visualization, each earthquake is represented with one point. When points aren't clustered, overlapping points make it difficult to see areas with a high number of earthquakes.

You can define cluster labels and popups to provide users with additional information about the cluster.

Click to view code snippet
layer.featureReduction = {
  type: "cluster",
  clusterRadius: "100px",
  // {cluster_count} is an aggregate field containing
  // the number of features comprised by the cluster
  popupTemplate: {
    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"
    }
  ]
};

Alternatively, you can use smart mapping methods to generate suggested popup templates and labels to apply to the layer.

Click to view code snippet
async function generateClusterConfig(layer) {
  // generates default popupTemplate
  const popupTemplate = await clusterPopupCreator
    .getTemplates({
      layer
    })
    .then(
      (popupTemplateResponse) =>
        popupTemplateResponse.primaryTemplate.value
    );

  popupTemplate.title = "Cluster summary";

  // generates default labelingInfo
  const { labelingInfo, clusterMinSize } = await clusterLabelCreator
    .getLabelSchemes({
      layer,
      view
    })
    .then((labelSchemes) => labelSchemes.primaryScheme);

  layer.featureReduction = {
    type: "cluster",
    popupTemplate,
    labelingInfo,
    clusterMinSize
  };
}
Clustered places of worship in India. The color of each graphic indicates the predominant religion of the cluster.
Clustered places of worship in India. The color of each graphic indicates the predominant religion of the cluster.

Clusters are styled so they match the layer’s renderer. For example, in the image above, the layer is styled with a UniqueValueRenderer, which colors each place of worship based on its religion. Each cluster’s color (and shape) is determined by the predominant religion contained within the cluster.

If the renderer styles the layer with a color, size, rotation, or opacity visual variable, the average value of the attribute among features in the cluster is used to render the cluster.

This layer visualizes the mW capacity of power plants using color. When clustering is enabled, the average capacity of the power plants in the cluster determines the color of the cluster graphic.
This layer visualizes the MW capacity of power plants using color. When clustering is enabled, the average capacity of the power plants in the cluster determines the color of the cluster graphic.

Query cluster features

Version 4.18 of the ArcGIS JS API adds support for querying cluster features. This is done with the aggregateIds property of the Query object. Simply pass the ObjectID of the cluster graphic to this property, and use it to query the clustered layer’s layer view.

const layerView = await view.whenLayerView(layer);
const query = layerView.createQuery();
query.aggregateIds = [ clusterGraphic.getObjectId() ];
// specify any other query parameters and execute the query
const { features } = await layerView.queryFeatures(query);
// do something with these features…

Prior to executing the query, however, you should ensure the ObjectID of the selected graphic belongs to a cluster graphic and not an individual feature. I check the isAggregate property on the graphic prior to each query using the function below.

function processParams(graphic, layerView) {
  if (!graphic || !layerView) {
    throw new Error("Graphic or layerView not provided.");
  }

  if (!graphic.isAggregate) {
    throw new Error("Graphic must represent a cluster.");
  }
}

I will follow this pattern of querying clusters within popup actions to demonstrate each of the following scenarios in this app:

Display the extent or convex hull of clustered features

When clustering is enabled, the spatial dispersion of clustered features isn’t immediately obvious. Displaying the extent of a cluster’s features helps the user understand the area claimed by the cluster.

To display the extent, simply call the queryExtent method on the layer view after specifying the cluster’s ObjectID in the query.

Click to view code snippet
async function displayClusterExtent(graphic) {
  processParams(graphic, layerView);

  const query = layerView.createQuery();
  query.aggregateIds = [ graphic.getObjectId() ];

  const { extent } = await layerView.queryExtent(query);
  const extentGraphic = {
    geometry: extent,
    symbol: {
      type: "simple-fill",
      outline: {
        width: 1.5,
        color: [75, 75, 75, 1]
      },
      style: "none",
      color: [0, 0, 0, 0.1]
    }
  };
  view.graphics.add(extentGraphic);
}
Clustered power plants with a square graphic indicating the cluster's extent.

While viewing the extent is instructive, the convex hull provides a more accurate representation of the spatial footprint of the clustered features. You can calculate the convex hull using the geometryEngine.

Click to view code snippet
async function displayConvexHull(graphic) {
  processParams(graphic, layerView);

  const query = layerView.createQuery();
  query.aggregateIds = [graphic.getObjectId()];

  const { features } = await layerView.queryFeatures(query);
  const geometries = features.map((feature) => feature.geometry);
  const [ convexHull ] = geometryEngine.convexHull(geometries, true);

  const convexHullGraphic = {
    geometry: convexHull,
    symbol: {
      type: "simple-fill",
      outline: {
        width: 1.5,
        color: [75, 75, 75, 1]
      },
      style: "none",
      color: [0, 0, 0, 0.1]
    }
  };
  view.graphics.add(convexHullGraphic);
}
Clustered power plants with a graphic indicating the cluster's convex hull.

Display all features in the view

There may be occasions when you want to display all features, or just a few of the clustered features directly in the view. For example, if you have a layer of clustered traffic incidents, perhaps you want to give the user the ability to display only features that represent fatalities.

To accomplish this, simply add the features returned from the query directly to the view. Remember to set a symbol to the returned graphics. I use the getDisplayedSymbol method to ensure the symbol matches the renderer of the layer.

Click to view code snippet
// displays all features from a given cluster in the view
async function displayFeatures(graphic) {
  processParams(graphic, layerView);

  const query = layerView.createQuery();
  query.aggregateIds = [graphic.getObjectId()];

  const { features } = await layerView.queryFeatures(query);

  features.forEach(async (feature) => {
    const symbol = await symbolUtils.getDisplayedSymbol(feature);
    feature.symbol = symbol;
    view.graphics.add(feature);
  });
}
Clustered power plants. One cluster shows all features in the cluster.

Display summary statistics

The popup displays useful information, such as cluster count, predominant value of string fields, and the average value of numeric fields. However, sometimes the suggested cluster popup doesn’t display enough information to the user.

In the case of a layer rendered with a UniqueValueRenderer, you may want to display the count of each category in the cluster rather than display only the total count with the predominant value.

You can get this information with the outStatistics and groupByFieldsForStatistics query parameters.

Click to view code snippet
async function calculateStatistics(graphic) {
  processParams(graphic, layerView);

  const query = layerView.createQuery();
  query.aggregateIds = [graphic.getObjectId()];
  query.groupByFieldsForStatistics = ["fuel1"];
  query.outFields = ["capacity_mw", "fuel1"];
  query.orderByFields = ["num_features desc"];
  query.outStatistics = [
    {
      onStatisticField: "capacity_mw",
      outStatisticFieldName: "capacity_total",
      statisticType: "sum"
    },
    {
      onStatisticField: "1",
      outStatisticFieldName: "num_features",
      statisticType: "count"
    },
    {
      onStatisticField: "capacity_mw",
      outStatisticFieldName: "capacity_max",
      statisticType: "max"
    }
  ];

  const { features } = await layerView.queryFeatures(query);
  const stats = features.map((feature) => feature.attributes);

  // display stats in the popup or some other element
}
Clustered power plants with a popup showing statistics for the selected cluster.

Once the statistics are returned, you can display the results in the popup, or in another UI element, such as a table or chart. You can make the statistical summaries as simple or as complex as you want. The example below displays multiple charts summarizing the selected cluster’s data.

This app displays 10 years of homicide data and summarizes it based on whether the crime was solved and the age, gender, and race of the victims. Each time the user clicks a cluster, the cluster is summarized by the charts to the right.
This app displays 10 years of homicide data and summarizes it based on whether the crime was solved and the age, gender, and race of the victims. Each time the user clicks a cluster, the cluster is summarized by the charts to the right.

Browse features

While summaries and charts can be extremely useful, there are cases when the user would rather drill into the cluster and browse the popups of individual features. You can do this by querying for all features in the cluster and pushing them to the view’s popup.

// push all features in cluster to the popup features
// to allow users to browse their popup templates
const { features } = await layerView.queryFeatures(query);
view.popup.features = features;

When a feature is selected in the popup, you can also display its location in the map. Again, don’t forget to set a symbol on the graphic.

Click to view code snippet
async function browseFeatures(graphic) {
  processParams(graphic, layerView);

  displayConvexHull(graphic);

  const query = layerView.createQuery();
  query.aggregateIds = [graphic.getObjectId()];

  // push all features in cluster to the popup features
  // to allow users to browse their popup templates
  const { features } = await layerView.queryFeatures(query);
  view.popup.features = [graphic].concat(features);

  // when user selects a feature in the cluster, display its location in the view
  selectedFeatureHandle = view.popup.watch(
    "selectedFeature",
    async (feature) => {
      if (!feature || feature?.isAggregate) {
        return;
      }
      const symbol = await symbolUtils.getDisplayedSymbol(feature);
      symbol.outline = {
        color: [50, 50, 50, 0.75],
        width: 0.5
      };
      feature.symbol = symbol;

      if (selectedFeature && view.graphics.includes(selectedFeature)) {
        view.graphics.remove(selectedFeature);
        selectedFeature = null;
      }
      view.graphics.add(feature);
      selectedFeature = feature;
    }
  );
}

Conclusion

While you can display lots of detailed information for clusters, it is important to remember you shouldn’t use summary statistics from clusters as if it were the result of a scientific study. Clusters do not represent statistically significant hot spots. Instead, clustering is the result of aggregating points according to geohashes (spatial grids) to declutter the view.

Also, once the view’s extent changes either by zooming in or out, a cluster’s information in the previous view is no longer relevant and should be discarded.

Thanks for reading! Please reach out in the comments or email me directly if you have any questions or suggestions for improving to our clustering implementation.

Share this article

Subscribe
Notify of
1 Comment
Oldest
Newest
Inline Feedbacks
View all comments