This tutorial uses a step-by-step approach to explain how you can load and visualize a static vector data set. It covers:

  • Limiting the amount of data loaded based on the map scale

  • Changing the feature visualization based on its state

  • Several options for styling features based on their properties

Scenario

In this tutorial, we visualize OpenStreetMap road data. The features in this dataset represent road segments. Each feature has a type property that describes how the specific road segment is classified, covering the range from motorways to residential streets. When the map is zoomed out, we want to show major roads only, such as motorways. We do not want to show less important roads, because they clutter up the map. Showing minor roads would also force us to load and visualize too much data in the case of large large datasets. As the map user zooms in on the map, it makes much more sense to load and visualize minor roads as well.

To visually distinguish between the road types, we also want to apply a distinct color to each type. Furthermore, we want the width of the roads to indicate their type. Finally, we want those widths to depend on the zoom level as well, so that major roads become even wider when a user zooms in on the map.

The end result we want to achieve is:

roads screenshot
Figure 1. The final result with major roads distinguished from minor roads through color and width

Loading and visualizing feature data

Limiting the data to be loaded based on map scale

To limit the amount of data that is loaded from the model for display in a map, the FeatureLayer builder offers a method addCondition. This method takes a minimum MapScale, a maximum map scale, and an Expression as arguments. When the scale of the map enters the range between the minimum and the maximum map scale, all features accepted by the relevant expression are loaded. If this method is invoked multiple times with overlapping scale ranges, the map layer visualizes all features accepted by any expression that is linked to a scale range containing the current map scale.

Creating expressions

You can create expressions using the ExpressionFactory. In this case, we want an expression that matches features with specific values for the type property.

We start by creating equal expressions. An equal expression evaluates to true if the two expressions used to initialize it evaluate to the same value. To get an equal expression that checks if a feature is of a certain type, we use a valueReference expression for the property type, and a literal expression with the desired value for the type property.

To combine multiple equal expressions, we can use an or expression. An or expression evaluates to true if at least one of its component expressions evaluates to true.

To find road features of specific types, we create an expression that evaluates to true for features that have unclassified or residential as type. It identifies features that should be shown only when the user has zoomed in enough.

Program (C++): Creating expressions
return ExpressionFactory::orOp({
    ExpressionFactory::equal(ExpressionFactory::valueReference(roadTypePropertyName), ExpressionFactory::literal("unclassified")),
    ExpressionFactory::equal(ExpressionFactory::valueReference(roadTypePropertyName), ExpressionFactory::literal("residential")),
});
Program (C#): Creating expressions
return ExpressionFactory.OrOp(new List<Expression>() {
    ExpressionFactory.Equal(ExpressionFactory.ValueReference(roadTypePropertyName), ExpressionFactory.Literal(new ExpressionValue("unclassified"))),
    ExpressionFactory.Equal(ExpressionFactory.ValueReference(roadTypePropertyName), ExpressionFactory.Literal(new ExpressionValue("residential")))
});

Accessing properties in a Feature

We want to base the styling of a road feature on the type of the road. To do so, we must be able to retrieve the value of the type property.

A feature model offers information about the data types of the features it contains. Features have a DataType, which defines the properties of that feature. To retrieve the value of a property, we need to create a DataPropertyPath. We do not want to create such a path for each feature, so we create it once, based on the data types exposed by the feature model.

In this case, we expect a single feature type, which should have a type property. Based on that property, we create the data property path we require.

Program (C++): Getting the type data property path
std::vector<DataType> dataTypes = featureModel->getFeatureModelMetadata().getFeatureTypes();
if (dataTypes.size() != 1) {
  std::cerr << "Expected model containing a single data type." << std::endl;
  return {};
}

DataType& dataType = dataTypes.at(0);
std::optional<DataProperty> typeProperty = dataType.findDataProperty(dataProperty);
if (!typeProperty) {
  std::cerr << "Expected data type containing a property named '" << dataProperty << "'" << std::endl;
  return {};
}

return DataPropertyPath::newBuilder().originType(dataType).property(*typeProperty).build();
Program (C#): Getting the type data property path
IFeatureModel model = GeoPackageModelDecoder.Decode(fileName) as IFeatureModel;
if (model.FeatureModelMetadata.FeatureTypes.Count != 1)
{
    Console.WriteLine("Expected model with a single feature type.");
    return;
}
DataType dataType = model.FeatureModelMetadata.FeatureTypes[0];
DataProperty dataProperty = dataType.FindDataProperty(roadTypePropertyName);
if (dataProperty == null)
{
    Console.WriteLine("Expected model with a single feature type, containing a '" + roadTypePropertyName + "' property.");
    return;
}
DataPropertyPath typePropertyPath = DataPropertyPath.NewBuilder().OriginType(dataType).Property(dataProperty).Build();

Implementing the painter

To associate certain styles with certain features, scale levels, and so on, you need an implementation of a FeaturePainter. In many cases, implementing a feature painter requires these steps:

  1. Defining triggers for a visualization change

  2. Creating the necessary styles

  3. Implementing the paint logic

Defining triggers for a visualization change

You can handle the first step by implementing the configureMetadata method. By updating the submitted FeaturePainterMetadata instance, you can control which changes result in a re-execution of the paint logic.

Program (C++): Feature painter metadata
void RoadsPainter::configureMetadata(FeaturePainterMetadata& metadata) const {
  metadata.painterDependsOnFeatureState(FeatureState::hover(), true);
  metadata.detailLevelScales(std::vector<MapScale>{HighToMedium, MediumToLow});
}
Program (C#): Feature painter metadata
public void ConfigureMetadata(FeaturePainterMetadata metadata)
{
    metadata.PainterDependsOnFeatureState(FeatureState.Hover, true);
    metadata.PainterDependsOnFeature(true);
    metadata.DetailLevelScales(new List<MapScale>() {
        MapScale.FromDenominator(ScaleHighToMediumDenominator),
        MapScale.FromDenominator(ScaleMediumToLowDenominator)
    });
}

In this case, we re-invoke the painter when:

  1. The selected or on-hover state of a feature is updated.

  2. The feature itself is updated. The type of the feature changes, for example.

  3. The map scale crosses one of the two thresholds we define. This results in three detail levels:

    • A detail level that is smaller than the first scale. This is detail level 0.

    • A detail level in between the first and the second scale. This is detail level 1.

    • A detail level that is larger than the second scale. This is detail level 2.

Creating the necessary styles

In this step, we create the styles for the road data. Our main goal is to create the styles only once. Creating a new style instance whenever a feature is painted would result in a lot of unnecessary overhead. It is much more efficient to create the styles statically, or when the painter is constructed, and then storing the styles.

Program (C++): Initializing styles
void RoadsPainter::createStyles() {
  registerStyle(0, "motorway", 4, "#e990a0ff");
  registerStyle(1, "motorway", 6, "#e990a0ff");
  registerStyle(2, "motorway", 7, "#e990a0ff");
}

void RoadsPainter::registerStyle(size_t detailLevel, const std::string& roadType, double width, Color color) {
  _regularStyles.emplace(std::make_pair(detailLevel, roadType), FeatureStyle::newLineStyleBuilder().width(width).color(color).build());
  _selectedStyles.emplace(std::make_pair(detailLevel, roadType), FeatureStyle::newLineStyleBuilder().width(width + 2).color(Color(255, 0, 0)).build());
  _hoverStyles.emplace(std::make_pair(detailLevel, roadType), FeatureStyle::newLineStyleBuilder().width(width + 2).color(color).build());
}
Program (C#): Initializing styles
static RoadsPainter()
{
    RegisterStyles(new Tuple<uint, string>(0, "motorway"), MotorwayColor, 4);
    RegisterStyles(new Tuple<uint, string>(1, "motorway"), MotorwayColor, 6);
    RegisterStyles(new Tuple<uint, string>(2, "motorway"), MotorwayColor, 8);
}

private static void RegisterStyles(Tuple<uint, string> key, Color color, double width)
{
    RegularStylesMap.Add(key, FeatureStyle.NewLineStyleBuilder().Color(color).Width(width).Build());
    HoverStylesMap.Add(key, FeatureStyle.NewLineStyleBuilder().Color(color).Width(width + 2).Build());
    SelectedStylesMap.Add(key, FeatureStyle.NewLineStyleBuilder().Color(SelectionColor).Width(width + 2).Build());
}

In this specific case, we define line colors and widths for several combinations of road type and detail level. We increase the line width for styles assigned to features in the selected and on-hover state. To selected features, we assign a different color as well. The styles are stored in three distinct maps that can be accessed when painting.

Implementing the paint logic

Finally, we implement the paint logic itself, by implementing the paint method.

Program (C++): Feature painter painting
void RoadsPainter::paint(const Feature& feature, const FeaturePainterContext& context, FeatureCanvas& canvas) const {
  const std::optional<std::shared_ptr<Geometry>>& geometry = feature.findGeometry();
  if (!geometry) {
    std::cerr << "Geometry not found for the given feature: " << feature.getId() << std::endl;
    return;
  }

  std::string roadType = feature.getValue<std::string>(_roadTypePropertyPath).value_or("unknown");
  size_t detailLevel = context.getDetailLevel();

  std::map<std::pair<size_t, std::string>, std::shared_ptr<FeatureStyle>> styleMap;
  if (context.isFeatureStateEnabled(FeatureState::selected())) {
    styleMap = _selectedStyles;
  } else if (context.isFeatureStateEnabled(FeatureState::hover())) {
    styleMap = _hoverStyles;
  } else {
    styleMap = _regularStyles;
  }

  std::pair<size_t, std::string> key = std::make_pair(detailLevel, (roadType));
  auto iterator = styleMap.find(key);
  std::shared_ptr<FeatureStyle> style = iterator != styleMap.end() ? iterator->second : _fallbackStyle;

  canvas.drawGeometry().geometry(*geometry).stroke(style).submit();
}
Program (C#): Feature painter painting
public void Paint(Feature feature, FeaturePainterContext context, FeatureCanvas canvas)
{
    Geometry geometry = feature.FindGeometry();
    if (geometry == null)
    {
        Console.WriteLine("Feature [" + feature.Id + "] without geometry!");
        return;
    }
    else
    {
        Dictionary<Tuple<uint, string>, FeatureStyle> styleMap;

        if (context.IsFeatureStateEnabled(FeatureState.Selected))
        {
            styleMap = SelectedStylesMap;
        }
        else if (context.IsFeatureStateEnabled(FeatureState.Hover))
        {
            styleMap = HoverStylesMap;
        }
        else
        {
            styleMap = RegularStylesMap;
        }

        string type = feature.GetValue<string>(TypePropertyPath);
        var key = new Tuple<uint, string>(context.DetailLevel, type);
        if (styleMap.TryGetValue(key, out FeatureStyle style))
        {
            canvas.DrawGeometry().Geometry(geometry).Stroke(style).Draped(true).Submit();
        }
        else
        {
            canvas.DrawGeometry().Geometry(geometry).Stroke(FallbackStyle).Draped(true).Submit();
        }
    }
}

Before we paint, we verify that the feature has a geometry. If that is the case, we retrieve the value of the type property, using the retrieved data property path. Using that type and the detail level in the FeaturePainterContext, we create a key for our style maps. Based on the state of the feature — whether it is selected, or in the on-hover state — we select which map to retrieve the style from.

Once we have a geometry and a style, we can invoke the drawGeometry method on the FeatureCanvas. We use the resulting object to instruct LuciadCPillar to visualize the current feature with the geometry and style of our choice.

Putting it all together

So far, we have created expressions that we want to tie to specific map scales, and that identify which features we want to visualize at those scales. We also created a painter that visualizes features based on their type, their state, and the map scale. Using all of the above, we now create a FeatureLayer to visualize the roads data.

Program (C++): Creating the layer
std::shared_ptr<FeatureLayer> featureLayer = FeatureLayer::newBuilder()
                                                 .model(featureModel)
                                                 .painter(std::make_shared<RoadsPainter>(std::move(*dataPropertyPath)))
                                                 .queryConfiguration(FeatureQueryConfiguration::newBuilder()
                                                                         .addCondition(MapScale::MaxZoomedOut, MapScale::MaxZoomedIn, highPriority)
                                                                         .addCondition(RoadsPainter::HighToMedium, MapScale::MaxZoomedIn, mediumPriority)
                                                                         .addCondition(RoadsPainter::MediumToLow, MapScale::MaxZoomedIn, lowPriority)
                                                                         .loadNothingForNonDefinedScales()
                                                                         .build())
                                                 .build();
Program (C#): Creating the layer
IFeaturePainter roadsPainter = new RoadsPainter(typePropertyPath);
FeatureLayer featureLayer = FeatureLayer.NewBuilder().Model(model)
    .QueryConfiguration(FeatureQueryConfiguration.NewBuilder()
        .AddCondition(MapScale.MaxZoomedOut, MapScale.MaxZoomedIn, high)
        .AddCondition(1 / RoadsPainter.ScaleHighToMediumDenominator, MapScale.MaxZoomedIn, medium)
        .AddCondition(1 / RoadsPainter.ScaleMediumToLowDenominator, MapScale.MaxZoomedIn, low)
        .Build())
    .Painter(roadsPainter)
    .Build();

We define model query conditions for three different scale ranges:

  • Everything

  • When the map is zoomed in past the first threshold

  • When the map is zoomed in past the second threshold

We progressively load more detailed data as users are zooming in on the map.

We also initialize a roads painter with the type data property path, and pass that painter to the layer builder.

For the full source code, used in functional applications, check out these samples:

  • C++: cpillar_sample_feature_styling

  • C#: csharp_sample_feature_styling