<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<title>Highlight features with FeatureFilter | Sample | ArcGIS Maps SDK for JavaScript</title>
<!-- Load the ArcGIS Maps SDK for JavaScript from CDN -->
<script type="module" src="https://js.arcgis.com/5.2/"></script>
grid-template-columns: 1fr 1fr;
<arcgis-map item-id="ddce6ef1f5e54839821860bd06adccf9" popup-disabled>
<arcgis-zoom slot="top-left"></arcgis-zoom>
expand-tooltip="Show help"
collapse-tooltip="Hide help"
<calcite-notice id="app-help" icon="information" scale="s" open>
<span slot="title">Explore neighborhood trees</span>
Move the pointer over the map to highlight nearby trees and update the tree statistics. Change the
radius to adjust the buffer.
<p>Click the map to pin the buffer; click again to follow the pointer.</p>
Hover over a tree health category or species to preview matching trees. Click an entry to keep those
<arcgis-expand slot="bottom-left" expand-icon="legend" expand-tooltip="Tree health legend">
<arcgis-legend></arcgis-legend>
<calcite-shell-panel slot="panel-end">
<calcite-panel heading="Neighborhood Trees" description="Street-tree census | Manhattan">
<calcite-block heading="Buffer" expanded>
<calcite-chip id="area-state" slot="content-end" scale="s">Following pointer</calcite-chip>
<calcite-label layout="inline"
<calcite-select id="radius" scale="s" label="Buffer radius">
<calcite-option value="150">150 m</calcite-option>
<calcite-option value="250" selected>250 m</calcite-option>
<calcite-option value="500">500 m</calcite-option>
<section class="summary">
<span id="record-count" slot="heading">--</span>
<span slot="description">Tree count</span>
<span id="mean-diameter" slot="heading">--</span>
<span slot="description">Avg. diameter (in)</span>
<calcite-block heading="Tree health" expanded>
<calcite-list id="condition-list" label="Tree health" selection-mode="single"></calcite-list>
<calcite-block heading="Top 5 species" expanded>
<calcite-list id="species-list" label="Trees by species" selection-mode="single"></calcite-list>
const [GraphicsLayer, Graphic, Circle, FeatureFilter, reactiveUtils, promiseUtils] = await $arcgis.import([
"@arcgis/core/layers/GraphicsLayer.js",
"@arcgis/core/Graphic.js",
"@arcgis/core/geometry/Circle.js",
"@arcgis/core/layers/support/FeatureFilter.js",
"@arcgis/core/core/reactiveUtils.js",
"@arcgis/core/core/promiseUtils.js",
const viewElement = document.querySelector("arcgis-map");
await viewElement.viewOnReady();
viewElement.constraints.minScale = 150000;
let highlightPinned = false;
let selectedWhere = "1=1";
let bufferGeometry; // geometry of the buffer area
let areaHighlightHandle; // highlight handle for the buffer area
let criteriaHighlightHandle; // highlight handle for selected tree criteria
let statisticsController;
// Change the highlight colors in the view's highlight collection
viewElement.highlights.getItemAt(0).color = "magenta";
viewElement.highlights.getItemAt(1).color = "#00E5FF";
// Get the ParquetLayer from the web map
const layer = viewElement.map.layers.find((layer) => layer.type === "parquet");
layer.outFields = ["tree_id", "tree_dbh", "health", "spc_common"];
const bufferLayer = new GraphicsLayer({
title: "Neighborhood buffer",
const bufferGraphic = new Graphic({
color: [0, 213, 255, 0.035],
bufferLayer.add(bufferGraphic);
viewElement.map.add(bufferLayer);
// Wait for the layer view to finish its initial update
const layerView = await viewElement.whenLayerView(layer);
await reactiveUtils.whenOnce(() => !layerView.updating && !layerView.dataUpdating);
// Get references to the health and species lists and the radius selector
const conditionList = document.querySelector("#condition-list");
const speciesList = document.querySelector("#species-list");
const radiusSelect = document.querySelector("#radius");
// Define the available tree health categories
{ label: "Good", where: "health = 'Good'", color: "#00875A" },
{ label: "Fair", where: "health = 'Fair'", color: "#E69500" },
{ label: "Poor", where: "health = 'Poor'", color: "#D12D6B" },
// Create the list items once and update their properties when statistics change
const conditionItems = conditions.map(({ label, where, color }) =>
createItem(conditionList, label, where, color),
const speciesItems = Array.from({ length: 5 }, () => createItem(speciesList));
const emptyItems = [conditionList, speciesList].map((list) => {
const item = createItem(list, "No trees in this area");
// Update the buffer circle, highlight intersecting trees and run
// tree statistics when the pointer moves, the map is clicked, or the radius changes
function moveBuffer(center) {
bufferGeometry = new Circle({
radius: Number(radiusSelect.value),
bufferGraphic.geometry = bufferGeometry;
areaHighlightHandle?.remove();
// Highlight trees that intersect the buffer circle
areaHighlightHandle = layerView.highlight(
geometry: bufferGeometry,
spatialRelationship: "intersects",
// Refresh the criteria highlight to match the current buffer geometry
// For example, increasing radius to 500m must also highlight trees matching the criteria
highlightMatchingTrees();
// Update the tree statistics for the current buffer area
return updateStatistics(bufferGeometry);
// Refresh the health or species highlight for list previews, selections, and buffer changes
function highlightMatchingTrees(where = selectedWhere) {
// Do not let a hover preview replace the selected criteria
if (selectedWhere !== "1=1" && where !== selectedWhere) {
criteriaHighlightHandle?.remove();
criteriaHighlightHandle = null;
// Highlight a specific health category or species, whether previewed or selected
criteriaHighlightHandle = layerView.highlight(
geometry: bufferGeometry,
spatialRelationship: "intersects",
// Keep one active selection across both lists and update its highlight when the selection changes
function selectCriteria(where) {
const previousWhere = selectedWhere;
for (const list of [conditionList, speciesList]) {
for (const item of list.querySelectorAll("calcite-list-item")) {
item.selected = item.value === selectedWhere;
if (selectedWhere !== previousWhere) {
highlightMatchingTrees();
// Apply the selected entry's filter, or clear it when the entry is deselected
for (const list of [conditionList, speciesList]) {
list.addEventListener("calciteListChange", () => {
const item = list.selectedItems[0];
selectCriteria(item?.value ?? "1=1");
// Toggle pinning and center the buffer on the clicked map location
viewElement.addEventListener("arcgisViewClick", (event) => {
setPinned(!highlightPinned);
moveBuffer(viewElement.toMap(event.detail));
// Debounce pointer-driven buffer updates and skip them while the buffer is pinned
const moveBufferFromPointer = promiseUtils.debounce(async (center) => {
return moveBuffer(center);
// Follow the pointer and clear list selections only while the buffer is unpinned
viewElement.addEventListener("arcgisViewPointerMove", (event) => {
// Clear the tree criteria selection when the buffer is moved by the pointer
if (selectedWhere !== "1=1") {
const center = viewElement.toMap(event.detail);
const bufferUpdate = moveBufferFromPointer(center);
promiseUtils.ignoreAbortErrors(bufferUpdate).catch(console.error);
// Resize the buffer without changing its center
radiusSelect.addEventListener("calciteSelectChange", () => moveBuffer(bufferGeometry.center));
// Keep the pinning state and the status chip in sync
function setPinned(value) {
document.getElementById("area-state").textContent = value ? "Pinned area" : "Following pointer";
// Add a reusable list entry with preview interactions and an optional health icon
function createItem(list, label = "", where = "", color) {
const item = document.createElement("calcite-list-item");
const symbol = document.createElement("calcite-icon");
symbol.slot = "content-start";
symbol.className = "condition-symbol";
symbol.icon = "circle-f";
symbol.style.color = color;
// Support the same temporary preview for pointer hover and keyboard focus
item.addEventListener("pointerenter", () => highlightMatchingTrees(item.value));
item.addEventListener("pointerleave", () => highlightMatchingTrees());
item.addEventListener("focusin", () => highlightMatchingTrees(item.value));
item.addEventListener("focusout", () => highlightMatchingTrees());
// Query grouped statistics from the ParquetLayerView and update the summary and existing list items
async function updateStatistics(geometry) {
statisticsController?.abort();
statisticsController = new AbortController();
const { signal } = statisticsController;
const query = layerView.createQuery();
query.geometry = geometry;
query.groupByFieldsForStatistics = ["spc_common"];
query.orderByFields = ["speciesCount DESC"];
// Include only positive trunk measurements when calculating the mean diameter
onStatisticField: "tree_id",
outStatisticFieldName: "speciesCount",
onStatisticField: "CASE WHEN tree_dbh > 0 THEN tree_dbh ELSE 0 END",
outStatisticFieldName: "diameter_sum",
onStatisticField: "CASE WHEN tree_dbh > 0 THEN 1 ELSE 0 END",
outStatisticFieldName: "diameter_count",
// Include each health category's count in the species statistics
for (const condition of conditions) {
query.outStatistics.push({
onStatisticField: `CASE WHEN ${condition.where} THEN 1 ELSE 0 END`,
outStatisticFieldName: condition.label,
const result = await layerView.queryFeatures(query, { signal });
const healthCounts = { Good: 0, Fair: 0, Poor: 0 };
// Combine group sums and counts instead of averaging the averages of differently sized groups
for (const feature of result.features) {
const { speciesCount, diameter_sum, diameter_count } = feature.attributes;
recordCount += speciesCount;
diameterSum += diameter_sum ?? 0;
diameterCount += diameter_count ?? 0;
for (const condition of conditions) {
healthCounts[condition.label] += feature.attributes[condition.label];
// Update the record count and mean diameter in the UI
document.getElementById("record-count").textContent = recordCount.toLocaleString();
document.getElementById("mean-diameter").textContent = diameterCount
? (diameterSum / diameterCount).toFixed(1)
for (const item of emptyItems) {
item.hidden = recordCount !== 0;
// Update the existing health entries without removing them from the list
for (const [index, condition] of conditions.entries()) {
const item = conditionItems[index];
item.hidden = recordCount === 0;
item.description = `${healthCounts[condition.label].toLocaleString()} records`;
item.selected = recordCount !== 0 && item.value === selectedWhere;
// Reuse the five species entries as their rankings and counts change
for (const [index, item] of speciesItems.entries()) {
const feature = recordCount === 0 ? undefined : result.features[index];
const { spc_common: speciesName, speciesCount } = feature.attributes;
let where = "spc_common IS NULL";
if (speciesName != null) {
// Escape apostrophes before including the species name in a SQL expression
const escapedSpeciesName = speciesName.replaceAll("'", "''");
where = `spc_common = '${escapedSpeciesName}'`;
item.label = speciesName || "Species not recorded";
item.description = `${speciesCount.toLocaleString()} records`;
item.selected = where === selectedWhere;
if (!promiseUtils.isAbortError(error)) {
moveBuffer(viewElement.center);