Goal

Filtering out some objects based on their properties is one way to manage the number of objects on your map. Another way is clustering the objects: when there are many icons close to each other, you can group them in to clusters. Two or more icons are then painted as one cluster.

In this tutorial, you learn how to implement clustering with LuciadRIA. Implementing clustering on a layer involves two steps.

  • Implementing a transformer

  • Assigning the transformer to the layer

You can also change the visual representation of the cluster. For that, you change the existing layer painter so that it paints clusters in the way you want.

Starting point

We take the code written in the Filtering objects tutorial as our starting point, and expand from there.

Step 1 - Adding clustering to a layer

The key class, responsible for clustering vector data, is ClusteringTransformer. If model elements are close to each other, the transformer replaces them with a single cluster instance. You create a transformer by passing ClusteringParameters to the create() function. You can configure these properties:

  • clusterSize - approximate size of the cluster in pixels

  • minimumPoints - minimum number of points required to create a cluster

  • clusterShapeProvider - determines the shape of the cluster based on the cluster elements. You can define the location of the center of the cluster. If you don’t initialize a clusterShapeProvider, a sensible default is used: a point at the location of the element nearest to the center of mass of the cluster elements.

In the following code snippet, we create a transformer that clusters city objects when there are at least 3 icons within a 100-pixel radius. No clusterShapeProvider is initialized, so the point at the location of the element nearest to the center of mass of the cluster elements is used. If you want to change that behavior, you can by implementing a custom ClusterShapeProvider.

Modification in vectorData.ts file
import {create} from "@luciad/ria/view/feature/transformation/ClusteringTransformer.js";
  // ...

  function createCityTransformer() {
    // Create Clustering transformer
    return create({
      defaultParameters: {
        clusterSize: 100,
        minimumPoints: 3
      }
    });
  }

  export function createCitiesLayer(map: Map) {
    // ...
    const featureLayer = new FeatureLayer(featureModel, {
      label: "US Cities",
      // ...
      // Setting the transformer on the layer
      transformer: createCityTransformer()
    });
    // ...
  }

Step 2 - Clustering with classifications

In general, step 1 is enough to implement clustering. Remember that we’re using two kinds of icons, though — one for big cities and another one for smaller cities. Things get a bit more complicated when you can’t include all features within the same layer in a cluster. For example, we don’t want to group together big cities and smaller ones.

To split those clusters, you can use the classification concept, which groups features with features of the same kind only.

To classify a feature, we use a Classifier. With a Classifier, you can assign features to distinct classes, and cluster them independently. It prevents the clustering of objects with different classifications. In this code snippet, we create a Classifier based on the logic we applied in the CityPainter to style cities with distinct icons:

import {create} from "@luciad/ria/view/feature/transformation/ClusteringTransformer.js";
import {Classifier} from "@luciad/ria/view/feature/transformation/Classifier.js";

// Create city clustering Classifier
  class CityClassifier extends Classifier {
    getClassification(feature: Feature) {
      const isBigCity = feature.properties.TOT_POP > 1000000;
      return isBigCity ? "bigCity" : "city";
    };
  }

  function createCityTransformer() {
    // Create Clustering transformer
    return create({
      // Use the classifier
      classifier: new CityClassifier(),
      defaultParameters: {
        clusterSize: 100,
        minimumPoints: 3
      }
    });
  }

// ...

Step 3 - Adjust the painter to display clusters

Finally, we must change the CityPainter so that it paints clusters with distinct icons and labels.

We take advantage of two utility functions offered by the ClusteringTransformer:

  • isCluster(feature) - a predicate function that returns true for a feature that’s a cluster

  • clusteredFeatures(cluster) - provides the features grouped by the given cluster.

This is a code snippet that selects the correct icon for a cluster and draws labels with cluster information:

CityPainter.ts with modifications to draw icons and labels for clusters.
import {clusteredFeatures, isCluster} from "@luciad/ria/view/feature/transformation/ClusteringTransformer.js";

// ...
  paintBody(geoCanvas: GeoCanvas, feature: Feature, shape: Shape, layer: Layer, map: Map, paintState: PaintState) {

    if (isCluster(feature)) {
      const featuresInCluster = clusteredFeatures(feature);
      const firstFeatureInCluster = featuresInCluster[0];
      const style = this.isBigCity(firstFeatureInCluster) ? this._bigCityStyle : this._cityStyle;
      geoCanvas.drawIcon(shape, style);
    } else {
      const style = this.isBigCity(feature) ? this._bigCityStyle : this._cityStyle;
      style.zOrder = feature.properties.TOT_POP;
      geoCanvas.drawIcon(shape, style);
    }
  };

  paintLabel(labelCanvas: LabelCanvas, feature: Feature, shape: Shape, layer: Layer, map: Map, paintState: PaintState) {
    if (isCluster(feature)) {
      const featuresInCluster = clusteredFeatures(feature);

      let contents;
      if (paintState.selected) {
        contents = featuresInCluster.map((aFeature) => {
          return this.getContentsElements("City", aFeature.properties.CITY);
        }).join("");
      } else {
        const total = featuresInCluster.reduce(
            (sum, aFeature) => sum + aFeature.properties.TOT_POP, 0);
        contents = this.getContentsElements("Total", total);
      }
      const header = `Cluster of ${featuresInCluster.length}`;
      const label = this.getLabelText(header, contents);
      labelCanvas.drawLabel(label, shape, this._labelStyle);

    } else {
      const populationInfo = paintState.selected ?
                             this.getContentsElements("Population", feature.properties.TOT_POP) : "";

      const contents = this.getContentsElements("State", feature.properties.STATE) + populationInfo;
      const label = this.getLabelText(feature.properties.CITY, contents);
      labelCanvas.drawLabel(label, shape, this._labelStyle);
    }
  };

  isBigCity(feature: Feature) {
    return feature.properties.TOT_POP > 1000000;
  }

  getContentsElements(caption: string, value: string | number) {
    return `<div class="type">${caption} : ${value}</div>`;
  }

  getLabelText(header: string, contents: string) {
    return `
            <div class="labelwrapper">
              <div class="blueColorLabel">
                <div class="theader">
                  <div class="leftTick blueColorTick"></div>
                  <div class="rightTick blueColorTick"></div>
                  <div class="name">${header}</div>
                </div>
                ${contents}
              </div>
            </div>`;
  }
}

Although it results in fewer objects rendered at once on the screen, clustering is a rather costly operation. Applying the technique to too many objects might slow down the application.

Final result

Now city icons are clustered on the map. When a user selects a cluster feature, the application displays the relevant information.

clustering

Complete code

CityPainter.ts
import {FeaturePainter, PaintState} from "@luciad/ria/view/feature/FeaturePainter.js";
import {IconStyle} from "@luciad/ria/view/style/IconStyle.js";
import {GeoCanvas} from "@luciad/ria/view/style/GeoCanvas.js";
import {Feature} from "@luciad/ria/model/feature/Feature.js";
import {Shape} from "@luciad/ria/shape/Shape.js";
import {Layer} from "@luciad/ria/view/Layer.js";
import {Map} from "@luciad/ria/view/Map.js";
import {PointLabelStyle} from "@luciad/ria/view/style/PointLabelStyle.js";
import {PointLabelPosition} from "@luciad/ria/view/style/PointLabelPosition.js";
import {LabelCanvas} from "@luciad/ria/view/style/LabelCanvas.js";
import {clusteredFeatures, isCluster} from "@luciad/ria/view/feature/transformation/ClusteringTransformer.js";

export class CityPainter extends FeaturePainter {
  _labelStyle: PointLabelStyle = {
    positions: PointLabelPosition.NORTH,
    offset: 15
  };

  _bigCityStyle: IconStyle = {
    url: "./resources/bigCity.png",
    width: "32px",
    height: "32px"
  };
  _cityStyle: IconStyle = {
    url: "./resources/city.png",
    width: "32px",
    height: "32px"
  };

  paintBody(geoCanvas: GeoCanvas, feature: Feature, shape: Shape, layer: Layer, map: Map, paintState: PaintState) {
    if (isCluster(feature)) {
      const featuresInCluster = clusteredFeatures(feature);
      const numberOfFeaturesInCluster = featuresInCluster.length;
      const firstFeatureInCluster = featuresInCluster[0];
      const style = this.isBigCity(firstFeatureInCluster) ? this._bigCityStyle : this._cityStyle;
      style.zOrder = firstFeatureInCluster.properties.TOT_POP * numberOfFeaturesInCluster;
      geoCanvas.drawIcon(shape, style);
    } else {
      const style = this.isBigCity(feature) ? this._bigCityStyle : this._cityStyle;
      style.zOrder = feature.properties.TOT_POP;
      geoCanvas.drawIcon(shape, style);
    }
  };

  paintLabel(labelCanvas: LabelCanvas, feature: Feature, shape: Shape, layer: Layer, map: Map, paintState: PaintState) {
    if (isCluster(feature)) {
      const featuresInCluster = clusteredFeatures(feature);

      let contents;
      if (paintState.selected) {
        contents = featuresInCluster.map((aFeature) => {
          return this.getContentsElements("City", aFeature.properties.CITY);
        }).join("");
      } else {
        const total = featuresInCluster.reduce(
            (sum, aFeature) => sum + aFeature.properties.TOT_POP, 0);
        contents = this.getContentsElements("Total", total);
      }
      const header = `Cluster of ${featuresInCluster.length}`;
      const label = this.getLabelText(header, contents);
      labelCanvas.drawLabel(label, shape, this._labelStyle);

    } else {
      const populationInfo = paintState.selected ?
                             this.getContentsElements("Population", feature.properties.TOT_POP) : "";

      const contents = this.getContentsElements("State", feature.properties.STATE) + populationInfo;
      const label = this.getLabelText(feature.properties.CITY, contents);
      labelCanvas.drawLabel(label, shape, this._labelStyle);
    }
  };

  getContentsElements(caption: string, value: string | number) {
    return `<div class="type">${caption} : ${value}</div>`;
  }

  getLabelText(header: string, contents: string) {
    return `
            <div class="labelwrapper">
              <div class="blueColorLabel">
                <div class="theader">
                  <div class="leftTick blueColorTick"></div>
                  <div class="rightTick blueColorTick"></div>
                  <div class="name">${header}</div>
                </div>
                ${contents}
              </div>
            </div>`;
  }

  isBigCity(feature: Feature) {
    return !!feature.properties.TOT_POP && feature.properties.TOT_POP > 1000000;
  }
}
vectorData.ts
import {FeatureLayer} from "@luciad/ria/view/feature/FeatureLayer.js";
import {FeatureModel} from "@luciad/ria/model/feature/FeatureModel.js";
import {LayerType} from "@luciad/ria/view/LayerType.js";
import {Map} from "@luciad/ria/view/Map.js";
import {WFSFeatureStore} from "@luciad/ria/model/store/WFSFeatureStore.js";
import {Feature} from "@luciad/ria/model/feature/Feature.js";
import {CityPainter} from "./CityPainter.js";
import {addSelection} from "@luciad/ria/view/feature/FeaturePainterUtil.js";

import {LoadSpatially} from "@luciad/ria/view/feature/loadingstrategy/LoadSpatially.js";
import {QueryProvider} from "@luciad/ria/view/feature/QueryProvider.js";
import {gte, literal, property} from "@luciad/ria/ogc/filter/FilterFactory.js";
import {create} from "@luciad/ria/view/feature/transformation/ClusteringTransformer.js";
import {Classifier} from "@luciad/ria/view/feature/transformation/Classifier.js";

const BIG_CITY_FILTER = gte(property("TOT_POP"), literal(1000000));

class CityQueryProvider extends QueryProvider {
  getQueryLevelScales() {
    return [1 / 50000000];
  }

  getQueryForLevel(level: number) {
    return level === 0 ? {filter: BIG_CITY_FILTER} : null;
  }
}

function createCityLoadingStrategy() {
  return new LoadSpatially({queryProvider: new CityQueryProvider()});
}

class CityClassifier extends Classifier {
  getClassification(feature: Feature) {
    const isBigCity = feature.properties.TOT_POP > 1000000;
    return isBigCity ? "bigCity" : "city";
  };
}

function createCityTransformer() {
  // Create Clustering transformer
  return create({
    classifier: new CityClassifier(),
    defaultParameters: {
      clusterSize: 100,
      minimumPoints: 3
    }
  });
}

export function createCitiesLayer(map: Map) {
  const url = "https://sampleservices.luciad.com/wfs";
  const featureTypeName = "cities";
  // Create a WFS store
  return WFSFeatureStore.createFromURL(url, featureTypeName).then(
      (wfsSore: WFSFeatureStore) => {
        // Create a model based on the created store
        const featureModel = new FeatureModel(wfsSore);
        // Create a feature layer
        const featureLayer = new FeatureLayer(featureModel, {
          label: "US Cities",
          layerType: LayerType.STATIC,
          painter: addSelection(new CityPainter()),
          selectable: true,
          loadingStrategy: createCityLoadingStrategy(),
          transformer: createCityTransformer()
        });

        // Add a layer to the map
        map.layerTree.addChild(featureLayer);
        return featureLayer;
      });
}

To learn more about clustering, see the Clustering guide.