What is IFeatureModel

To provide access to feature data, you must useuseuse a feature modelfeature modelfeature model. IFeatureModelIFeatureModelIFeatureModel acts as an adapter between your application data and the LuciadCPillar Map.

As an adapter to your application data, IFeatureModelIFeatureModelIFeatureModel serves multiple purposes:

  1. Add a uniform representation for any possible application data: FeatureFeatureFeature. IFeatureModelIFeatureModelIFeatureModel exposes application data as FeatureFeatureFeature instances so that they can be used in a uniform way by the LuciadCPillar Map.

  2. Provide the LuciadCPillar Map with a pull mechanism to access application data.

  3. Provide the LuciadCPillar Map with a push mechanism to observe changes to the application data.

  4. Offer a mechanism for LuciadCPillar to make changes to the application data, for example using editingeditingediting or creationcreationcreation.

The next subsections will provide more details on these topics.

Feature

Application data can come in many different forms. Examples are:

  • A list of in-memory business objects

  • A database containing business objects

  • A file (SHP/GeoPackage/…​) containing feature data

  • A network stream with feature updates

  • …​

LuciadCPillar can not handle any kind of business data that could possibly exist. It is also mostly interested in information that is needed for rendering or editing purposes. To accomplish this, IFeatureModelIFeatureModelIFeatureModel exposes FeatureFeatureFeature instances, which have a uniform structure and have a descriptions of the data propertiesdata propertiesdata properties and geometriesgeometriesgeometries they contain. They are a partial copy of the application data that is of interest to the Map.

IFeatureModelIFeatureModelIFeatureModel is responsible for converting business data (which can have any form) to FeatureFeatureFeature instances (which can be used by LuciadCPillar).

Uniquely identifying features

You can use feature IDs to identify features. Feature IDs are defined as unsigned 64-bit integers, and must be unique across the model. It is important that no 2 features have the same ID. When this happens, LuciadCPillar will consider them the same feature.

Adding properties to features

Features that have just an ID are not that interesting. Most likely, you want to associate some business data with each feature that you can then use during painting.

Each feature has an associated DataTypeDataTypeDataType that describes its property structure. During the creation of the feature, you can assignassignassign values to the various properties.

For more information on data types, see Describing a feature structure with a data model.

Features are read-only

Features can not be modified once they have been created. The main advantage of making Feature instances read-only is that they can be used on multiple threads (by LuciadCPillar) in a thread-safe way. If you want to modify a Feature, you can do so by making a copy that has the same ID.

Query (pull) mechanism to access application data

When the Map wants to render feature data, it is interested in only retrieving the features that are visible at a certain camera location or at a certain Map scale. The main mechanism for this is a pull mechanism: the Map’s rendering backend retrieves the features it is interested in using the IFeatureModel::queryIFeatureModel::queryIFeatureModel::query method.

This method allows using filters by configuring them on FeatureQueryFeatureQueryFeatureQuery. These filters are used by the Map to only get the feature data it needs at a certain view point. For example:

How are feature queries used by the Map ?

Imagine a feature data set with a large amount of roads data across the world: highways + smaller roads. The user is interested in visualizing this data in a scalable way.

  • At a zoomed-out level, you only want to see the main features in the dataset. Loading all small roads features for the entire world would not fit into memory or would take a huge amount of processing time before they can be displayed.

  • At a zoomed-in level, you want to see both highways and small roads, but only the ones that are visible in the region of the Map you’re looking at.

The Map accomplishes this by using feature queries with bounds and conditions on it.

  • At a zoomed-out level, a condition is used to limit the amount of features that is returned, even when the bounds on the FeatureQueryFeatureQueryFeatureQuery object covers a large part of the world.

  • At a zoomed-in level, the used condition allows returning more (or all) types of features, but the size of the bounds on the FeatureQueryFeatureQueryFeatureQuery object is small. The end result is again, a relatively small set of features.

See the visualizing feature data article for more information on how to configure the Map to display feature data in a scalable way.

Observer (push) mechanism to observe changes to the application data

Next to the pull mechanism that was explained before, there is also the need for a push mechanism. When models don’t change (like is for example the case for static roads data), there is only a need for the pull mechanism.

This changes when the data backend (that is wrapped by IFeatureModel) is dynamic in nature. Examples are:

  • pure dynamic data: for example TrackFeatureModel that is used in the data formats sample. This model’s data backend is a stream of location updates for tracks.

  • semi-dynamic data: the data is mostly static, but the user can also edit it. See for example the editing sample for such a use case.

In both cases, the Map’s rendering backend will still use the pull mechanism to retrieve the data that it’s interested in. Detecting changes to the backend, is not handled by IFeatureModel::queryIFeatureModel::queryIFeatureModel::query though. This would require the Map to call the query function continuously, which would be highly inefficient.

To resolve this, an observer mechanism was added to IFeatureModel: IFeatureModelObserverIFeatureModelObserverIFeatureModelObserver. This mechanism allows the Map (or anyone else) to get notifications when the application data changes. These changes can be

  • Updates. The feature location or data properties have changed, so the visualization needs to be updated.

  • Additions. A new features was added, so it needs to be added to the Map.

  • Removals: A feature was removed, so it needs to be removed from the Map.

So IFeatureModelObserverIFeatureModelObserverIFeatureModelObserver is the core API that implements the push mechanism. This API is complementary with the queryqueryquery API. Both need to be implemented in a correct and consistent way for a model to be rendered correctly.

One important note is that IFeatureModelObserverIFeatureModelObserverIFeatureModelObserver is decoupled from IFeatureModel::queryIFeatureModel::queryIFeatureModel::query: `IFeatureModelObserver´ should notify of any data changes, not only the ones that are currently visualized by the Map, or that were previously returned by the query method. The Map rendering backend filters out the changes that it is not interested in.

Make changes to the application data

So far, we’ve only discussed feature information going from the application’s data backend to the Map, using both the query (pull) and observer (push) mechanisms. IFeatureModelIFeatureModelIFeatureModel however also allows making changes to the application data: using IFeatureModelUpdaterIFeatureModelUpdaterIFeatureModelUpdater.

This is for example done when editingeditingediting or creatingcreatingcreating features on the Map. Editing or creation generates new or modified FeatureFeatureFeature instances, that are then sent to IFeatureModelUpdaterIFeatureModelUpdaterIFeatureModelUpdater, which then modifies the underlying application data based on these changes.

How does this tie in with the Map

The Map’s rendering backend is not interested in IFeatureModelUpdaterIFeatureModelUpdaterIFeatureModelUpdater itself. It doesn’t use it. It is only interested in calls to IFeatureModelObserverIFeatureModelObserverIFeatureModelObserver. The following happens when editing features on the Map:

Persisting data

IFeatureModelIFeatureModelIFeatureModel gives you control over when to persist the data that is being edited. For more information on saving changes to feature models, see Working with feature models with save support.

Which IFeatureModel should I use

Which IFeatureModelIFeatureModelIFeatureModel you need depends on the data backend you are using, and the nature of the data: is it static data ? Or dynamic data ? This section will go over a few cases and give advice on the type of model to use.

Decode feature data from files

You can decode feature data from file formats like for example GeoPackage, or SHP. Each supported vector format offers a model decoder in the API. That model decoder returns a IFeatureModelIFeatureModelIFeatureModel, based on a path to the data file.

A model based on a list of Feature instances

When you have a list of Feature instances, you can use FeatureModelBuilderFeatureModelBuilderFeatureModelBuilder. This is a convenience model implementation that is easy to use and doesn’t require much customization. It is however not suited for large amounts of features: it doesn’t have a spatial index to efficiently filter on boundsboundsbounds and it doesn’t have other indices to efficiently filter based on conditionsconditionsconditions.

Only use this model when your data set is relatively small.

Connect to a database backend

You will need to implement your own IFeatureModelIFeatureModelIFeatureModel instance that can connect to the database you are using.

Adapt your own application data

You will need to implement your own IFeatureModelIFeatureModelIFeatureModel instance that can query the application data, send notifications for changes and that converts the application data to FeatureFeatureFeature instances.

A (network) stream of data

You will need to implement your own IFeatureModelIFeatureModelIFeatureModel instance that can capture the data stream and convert it to FeatureFeatureFeature instances. A difficulty here is implementing the queryqueryquery method: a stream cannot be queried. You can solve this by building up a local cache of the streamed data in a way that it can be queried efficiently.

How can I implement an IFeatureModel

In some scenario’s, you need your own IFeatureModelIFeatureModelIFeatureModel implementation. This section gives some guidance on how to create your own implementation.

Use FeatureModelBuilder if possible

Implementing an IFeatureModelIFeatureModelIFeatureModel correctly is not a trivial task. Before you continue, double-check if you can use an existing implementation, like FeatureModelBuilderFeatureModelBuilderFeatureModelBuilder. If you can not use it directly, it may still be possible to use an existing model as part of the implementation of your new IFeatureModelIFeatureModelIFeatureModel implementation.

Define the structure of Feature and its DataType

The first step when implementing (or creating) an IFeatureModelIFeatureModelIFeatureModel is to define what a FeatureFeatureFeature contains and how it is structured. This is done by creating a DataModelDataModelDataModel and DataTypeDataTypeDataType. For most use cases, the DataTypeDataTypeDataType must at minimum contain

  • a Feature ID: to uniquely identify the feature. Possible ways to implement feature ID generation are:

    • A counter: For example in-memory, or in the database you connect to

    • Use an existing ID like a UUID or a string, and convert it to a 64-bits integer using hashing. Make sure to avoid hash collisions though.

  • a GeometryGeometryGeometry

An example of this can be found in the data formats sample:

Program: Defining the structure of a feature
DataModel createCustomerTrackDataModel() {
  auto speedValueProperty = DataProperty::newBuilder().name("SpeedValue").valueType(DataType::getDoubleType()).build();
  auto speedUnitProperty = DataProperty::newBuilder().name("SpeedUnit").valueType(DataType::getStringType()).build();
  auto speedType = DataType::newBuilder().name("SpeedType").addProperty(speedValueProperty).addProperty(speedUnitProperty).build();

  auto callSignProperty = DataProperty::newBuilder().name("CallSign").valueType(DataType::getStringType()).build();
  auto locationProperty = DataProperty::newBuilder().name("Location").valueType(DataType::getGeometryType()).build();
  auto azimuthProperty = DataProperty::newBuilder().name("CourseOverGround").valueType(DataType::getDoubleType()).build();
  auto groundSpeedProperty = DataProperty::newBuilder().name("GroundSpeed").valueType(speedType).build();
  auto trackType = DataType::newBuilder()
                       .name("TrackType")
                       .addProperty(callSignProperty)
                       .addProperty(locationProperty)
                       .addProperty(azimuthProperty)
                       .addProperty(groundSpeedProperty)
                       .addAnnotation(IDataAnnotationFactory::create([&](const DataType& dataType) {
                         auto path = DataPropertyPath::newBuilder().originType(dataType).property(locationProperty).build();
                         return std::make_shared<GeometryDataAnnotation>(path);
                       }))
                       .build();

  return DataModel::newBuilder().name("http://www.mydomain.com/datamodel/TrackModel").addDataType(speedType).addDataType(trackType).build();
}

DataModel TrackDataModel = createCustomerTrackDataModel();
DataType TrackType = TrackDataModel.findDataType("TrackType").value();
ModelMetadata TrackModelMetadata = ModelMetadata::newBuilder().title("Tracks").build();
DataPropertyPath CallSignPropertyPath = DataPropertyPath::newBuilder().originType(TrackType).property("CallSign").build();
DataPropertyPath LocationPropertyPath = DataPropertyPath::newBuilder().originType(TrackType).property("Location").build();
DataPropertyPath AzimuthPropertyPath = DataPropertyPath::newBuilder().originType(TrackType).property("CourseOverGround").build();
DataPropertyPath GroundSpeedValuePropertyPath = DataPropertyPath::newBuilder().originType(TrackType).property("GroundSpeed").property("SpeedValue").build();
DataPropertyPath GroundSpeedUnitPropertyPath = DataPropertyPath::newBuilder().originType(TrackType).property("GroundSpeed").property("SpeedUnit").build();

Feature createFeature(CustomerTrack* track, const std::shared_ptr<CoordinateReference>& modelReference) {
  std::shared_ptr<Point> trackLocation = GeometryFactory::createPoint(modelReference, track->getLon(), track->getLat(), track->getHeight());

  return Feature::newBuilder()
      .id(track->getId())
      .dataType(CustomerTrackModel::getTrackType())
      .value<std::string>(CallSignPropertyPath, track->getCallSign())
      .value<std::shared_ptr<Geometry>>(LocationPropertyPath, std::move(trackLocation))
      .value<double>(AzimuthPropertyPath, track->getAzimuth().getDegrees())
      .value<double>(GroundSpeedValuePropertyPath, track->getGroundSpeed())
      .value<std::string>(GroundSpeedUnitPropertyPath, "m/s")
      .build();
}
private static DataModel CreateCustomerTrackDataModel()
{
    var dataModelBuilder = DataModel.NewBuilder().Name("http://www.mydomain.com/datamodel/TrackModel");

    var speedType = DataType.NewBuilder().Name("SpeedType")
        .AddProperty(DataProperty.NewBuilder().Name("SpeedValue")
            .ValueType(DataType.DoubleType)
            .Build())
        .AddProperty(DataProperty.NewBuilder().Name("SpeedUnit")
            .ValueType(DataType.StringType)
            .Build())
        .Build();

    dataModelBuilder.AddDataType(speedType);

    var locationProperty = DataProperty.NewBuilder().Name("Location")
        .ValueType(DataType.GeometryType)
        .Build();
    var azimuthProperty = DataProperty.NewBuilder().Name("CourseOverGround")
        .ValueType(DataType.DoubleType)
        .Build();
    var trackType = DataType.NewBuilder().Name("TrackType")
        .AddProperty(DataProperty.NewBuilder().Name("CallSign")
            .ValueType(DataType.StringType)
            .Build())
        .AddProperty(locationProperty)
        .AddProperty(azimuthProperty)
        .AddProperty(DataProperty.NewBuilder().Name("GroundSpeed")
            .ValueType(speedType)
            .Build())
        .AddAnnotation(new GeometryDataAnnotationFactory(locationProperty))
        .Build();

    dataModelBuilder.AddDataType(trackType);

    return dataModelBuilder.Build();
}
private static Feature CreateFeature(CustomerTrack track, CoordinateReference modelReference)
{
    Point trackLocation = GeometryFactory.CreatePoint(modelReference, track.Lon, track.Lat, track.Height);

    Feature.Builder featureBuilder = Feature.NewBuilder().Id(track.Id).DataType(TrackType);

    featureBuilder.Value<string>(CallSignPropertyPath, track.CallSign);
    featureBuilder.Value<Geometry>(LocationPropertyPath, trackLocation);
    featureBuilder.Value<double>(AzimuthPropertyPath, track.Azimuth.Degrees);
    featureBuilder.Value<double>(GroundSpeedValuePropertyPath, track.GroundSpeed);
    featureBuilder.Value<string>(GroundSpeedUnitPropertyPath, "m/s");

    return featureBuilder.Build();
}
private static DataModel createCustomerTrackDataModel() {
  var dataModelBuilder = DataModel.newBuilder().name("http://www.mydomain.com/datamodel/TrackModel");

  var speedType = DataType.newBuilder().name("SpeedType")
      .addProperty(DataProperty.newBuilder().name("SpeedValue")
          .valueType(DataType.getDoubleType())
          .build())
      .addProperty(DataProperty.newBuilder().name("SpeedUnit")
          .valueType(DataType.getStringType())
          .build())
      .build();

  dataModelBuilder.addDataType(speedType);

  var locationProperty = DataProperty.newBuilder().name("Location")
      .valueType(DataType.getGeometryType())
      .build();
  var azimuthProperty = DataProperty.newBuilder().name("CourseOverGround")
      .valueType(DataType.getDoubleType())
      .build();
  var trackType = DataType.newBuilder().name("TrackType")
      .addProperty(DataProperty.newBuilder().name("CallSign")
          .valueType(DataType.getStringType())
          .build())
      .addProperty(locationProperty)
      .addProperty(azimuthProperty)
      .addProperty(DataProperty.newBuilder().name("GroundSpeed")
          .valueType(speedType)
          .build())
      .addAnnotation(new GeometryDataAnnotationFactory(locationProperty))
      .build();

  dataModelBuilder.addDataType(trackType);

  return dataModelBuilder.build();
}
private static Feature createFeature(CustomerTrack track, CoordinateReference modelReference) {
  Point trackLocation = GeometryFactory.createPoint(modelReference, track.getLon(), track.getLat(), track.getHeight());

  Feature.Builder featureBuilder = Feature.newBuilder().id(track.getId()).dataType(_trackType);

  featureBuilder.value(_callSignPropertyPath, track.getCallSign());
  featureBuilder.<Geometry>value(_locationPropertyPath, trackLocation);
  featureBuilder.value(_azimuthPropertyPath, track.getAzimuth().getDegrees());
  featureBuilder.value(_groundSpeedValuePropertyPath, track.getGroundSpeed());
  featureBuilder.value(_groundSpeedUnitPropertyPath, "m/s");

  return featureBuilder.build();
}

Implement the query method

All IFeatureModelIFeatureModelIFeatureModel implementations must implement the queryqueryquery method. Such an implementation must take a couple of things into account.

General

IFeatureQueryCallback

IFeatureQueryCallback::handleFeature on its turn returns a boolean to indicate that the current query should stop. Implementations of the query method must take this into account. Not doing so may have an impact on feature loading performance.

Program: Calling IFeatureQueryCallback
for (const Feature& feature : featuresToReturn) {
  if (!callback.handleFeature(feature)) {
    break;
  }
}
foreach (var feature in features)
{
    if (!callback.HandleFeature(feature))
    {
        break;
    }
}
for (Feature feature : featuresToReturn) {
  if (!callback.handleFeature(feature)) {
    break;
  }
}

Threading

The implementation of queryqueryquery must be implemented in a synchronous and blocking way. When the query method returns, no more features should be passed to IFeatureQueryCallbackIFeatureQueryCallbackIFeatureQueryCallback.

The query method can be called from any thread, so implementations must be thread-safe.

Condition

All FeatureFeatureFeature instances that you return must be accepted by the query conditionquery conditionquery condition, if available. Ideally, your IFeatureModelIFeatureModelIFeatureModel uses some kind of indexing to make this as efficient as possible. This is for example possible in many databases.

If there is no other way to filter on the given condition using the data backend itself, you can still loop over all features and evaluate the filter.

Program: Filtering features
bool evaluateCondition = query.getCondition() || query.getBounds();
auto conditionEvaluator = FeatureExpressionEvaluatorFactory::createEvaluator(query);
  for (auto& feature : features) {
    if (!evaluateCondition || FeatureExpressionEvaluator::accept(feature, conditionEvaluator)) {
      featuresToReturn.emplace_back(feature);

      if (limit && *limit == featuresToReturn.size()) {
        break;
      }
    }
  }
var conditionEvaluator = FeatureExpressionEvaluatorFactory.CreateEvaluator(query);
        features = features
            .Where(f => FeatureExpressionEvaluator.Accept(f, conditionEvaluator))
            .Take((int)limit.Value)
            .ToList();
var conditionEvaluator = FeatureExpressionEvaluatorFactory.createEvaluator(query);
  List<Feature> featuresToReturn = new ArrayList<>();
  for (Feature feature : features) {
    if (!FeatureExpressionEvaluator.accept(feature, conditionEvaluator)) {
      continue;
    }

    featuresToReturn.add(feature);
    if (limit != null && featuresToReturn.size() >= limit) {
      break;
    }
  }

For more detail on using expressions, see Filtering data with expressions and conditions.

Bounds filter

All FeatureFeatureFeature instances that you return must overlap with the query boundsquery boundsquery bounds, if available. Ideally, your IFeatureModelIFeatureModelIFeatureModel uses a spatial index to check this in an efficient way. You can:

  • use the spatial index of the data backend if it exists, for example in a spatial database.

  • implement a spatial index yourself.

  • don’t use a spatial filter. Beware, for larger models this can have a severe performance impact.

Note that spatial filtering is also implemented when using FeatureExpressionEvaluatorFactoryFeatureExpressionEvaluatorFactoryFeatureExpressionEvaluatorFactory, as in the previous subsection.

Order

Returned features must be ordered according to the query sort operatorquery sort operatorquery sort operator. Ideally, this is also done by the data backend that supports this, for example a database. If this is not available, you can sort the list of features to return as follows:

Program: Sorting features
std::optional<FeatureQuery::SortOperator> order = query.getSortBy();
  if (order) {
    std::sort(std::begin(features), std::end(features), order->getComparer());
  }
var order = query.SortBy;
    if (order != null)
    {
        features.Sort(new FeatureComparer(order));
    }
var order = query.getSortBy();
  if (order != null) {
    features.sort(new FeatureComparator(order));
  }

Feature ID’s

When the query has a feature id filterfeature id filterfeature id filter, you must only return the features with an id that is in this list.

Add support for observing changes to the data

Adding support for observing changes to the data can be done by registering observers and calling them whenever a feature in the data backend changes.

Program: Observer support
void CustomerTrackModel::addObserver(std::shared_ptr<IFeatureModelObserver> modelObserver) {
  std::lock_guard<std::mutex> lockGuard(_observerMutex);
  auto it = std::find_if(_modelObservers.begin(), _modelObservers.end(),
                         [&](const std::shared_ptr<IFeatureModelObserver>& observer) { return observer.get() == modelObserver.get(); });
  if (it != _modelObservers.end()) {
    throw InvalidArgumentException("CustomerTrackModel::addObserver: it is not allowed to add the same observer twice");
  }
  _modelObservers.emplace_back(std::move(modelObserver));
}

void CustomerTrackModel::removeObserver(const std::shared_ptr<IFeatureModelObserver>& modelObserver) {
  std::lock_guard<std::mutex> lockGuard(_observerMutex);
  auto it = std::find_if(_modelObservers.begin(), _modelObservers.end(),
                         [&](const std::shared_ptr<IFeatureModelObserver>& observer) { return observer.get() == modelObserver.get(); });
  if (it != _modelObservers.end()) {
    _modelObservers.erase(it);
  } else {
    throw InvalidArgumentException("CustomerTrackModel::removeObserver: attempting to remove an observer that was never added");
  }
}
void CustomerTrackModel::fireChangeEvent(const FeatureModelEvent& changeEvent) {
  // Fire change events outside the lock to make the code less prone to deadlocks
  std::unique_lock<std::mutex> lockGuard(_observerMutex);
  auto observersCopy = _modelObservers;
  lockGuard.unlock();

  for (const auto& observer : observersCopy) {
    observer->onFeatureModelChanged(changeEvent);
  }
}
private readonly IList<IFeatureModelObserver> _modelObservers = new List<IFeatureModelObserver>();

private void FireChangeEvent(FeatureModelEvent changeEvent)
{
    lock (_modelObservers)
    {
        foreach (IFeatureModelObserver observer in _modelObservers)
        {
            observer.OnFeatureModelChanged(changeEvent);
        }
    }
}
public void AddObserver(IFeatureModelObserver modelObserver)
{
    lock (_modelObservers)
    {
        _modelObservers.Add(modelObserver);
    }
}

public void RemoveObserver(IFeatureModelObserver modelObserver)
{
    lock (_modelObservers)
    {
        if (!_modelObservers.Remove(modelObserver))
        {
            throw new ArgumentException("Attempted to remove model observer that wasn't added by the AddObserver method", "modelObserver");
        }
    }
}
private final List<IFeatureModelObserver> _modelObservers = new ArrayList<>();

private void fireChangeEvent(FeatureModelEvent changeEvent) {
  synchronized (_modelObservers) {
    for (IFeatureModelObserver observer : _modelObservers) {
      observer.onFeatureModelChanged(changeEvent);
    }
  }
}
@Override
public void addObserver(IFeatureModelObserver modelObserver) {
  synchronized (_modelObservers) {
    _modelObservers.add(modelObserver);
  }
}

@Override
public void removeObserver(IFeatureModelObserver modelObserver) {
  synchronized (_modelObservers) {
    if (!_modelObservers.remove(modelObserver)) {
      throw new IllegalArgumentException("Attempted to remove model observer that wasn't added by the AddObserver method");
    }
  }
}

Add support for editing and creation

Adding support for editing and creation can be done by implementing an IFeatureModelUpdaterIFeatureModelUpdaterIFeatureModelUpdater and returning an instance in the IFeatureModel::getUpdaterIFeatureModel::getUpdaterIFeatureModel::getUpdater method.

For more information on feature updates and saving changes to feature models, see Working with feature models with save support.