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.
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.
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.
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
};
}
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.
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
- 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
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);
}
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);
}
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);
});
}
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
}
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.
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.
Article Discussion: