Adding feature editing tools to the WPF client application

entry

Adding feature editing tools to the WPF client application


Published on: 2024-03-05

In this second part of the article series covering the topic of building a WPF client with Carmenta Engine, we will add application logic to support editing geographical features in the map.

If you didn’t follow the previous article, please do that first since we will keep extending the application we built during that article.

This archive contains the solution outlined below.

Application features in Carmenta Engine

Geographical features can be read from many different sources in Carmenta Engine, for example, different file-based sources such as shapefiles, map packages or remote sources such as web services or databases. The most convenient way of handling features created by the application itself is to store them in a MemoryDataSet. If the MemoryDataSet is attached properly to the View one will also have great options for visualizing the features as well.

Adding the MemoryDataSet to the configuration

Let’s modify our map configuration to add a new OrdinaryLayer, a series of operators and a MemoryDataSet so that we have a foundation for adding the application logic for editing features. Open world_map.px in the project root directory in Carmenta Studio. Create a new OrdinaryLayer below the existing LongLatGrid OrdinaryLayer and name it FeatureLayer. Then add a VisualizationOperator to the newly created OrdinaryLayer and a ReadOperator to the VisualizationOperator. Then add the MemoryDataSet to the ReadOperator, name it FeatureMemoryDataSet. Finally, add some simple visualization to the VisualizationOperator, a LineVisualizer, a SymbolVisualizer and a PolygonVisualizer with default settings will give us a good start. The modified map configuration will look like this:

To make it possible to interact with features after they have been created, select the OrdinaryLayer and change the Selectable property value to True.

Creating the tools menu

Let’s start by setting up the tools menu, this will be opened from the drawer menu from the previous article. Create a new user control for the view in Views\ToolsMenuView.xaml and add the following code:

Views\ToolsMenuView.xaml

<UserControl x:Class="CarmentaWpfClient.Views.ToolsMenuView"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             mc:Ignorable="d" 
             d:DesignHeight="450" d:DesignWidth="800">
    <Grid>
        <StackPanel HorizontalAlignment="Center">
            <Menu Background="{StaticResource SystemControlBackgroundBaseLowBrush}" Margin="5" >
                <MenuItem Header="Select Tool..." >
                    <MenuItem Header="Standard" Tag="standard" Command="{Binding ItemSelectedCommand}" CommandParameter="{Binding RelativeSource={RelativeSource Self}, Path=Tag}"/>
                    <Separator />
                    <MenuItem Header="Point" Tag="point" Command="{Binding ItemSelectedCommand}" CommandParameter="{Binding RelativeSource={RelativeSource Self}, Path=Tag}"/>
                    <MenuItem Header="Line" Tag="line" Command="{Binding ItemSelectedCommand}" CommandParameter="{Binding RelativeSource={RelativeSource Self}, Path=Tag}"/>
                    <MenuItem Header="Polygon" Tag="polygon" Command="{Binding ItemSelectedCommand}" CommandParameter="{Binding RelativeSource={RelativeSource Self}, Path=Tag}"/>
                </MenuItem>
            </Menu>
        </StackPanel>
    </Grid>
</UserControl>

It adds a Menu control with menu items corresponding to different tools. The Command binding expression binds to a command on the ViewModel called ItemsSelectedCommand and passes the Tag property value as a method parameter. Let’s create the ViewModel by adding a class called ToolsMenuViewModel:

ViewModels\ToolsMenuViewModel.cs

using Carmenta.Engine;

using CarmentaWpfClient.Messages;

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;

namespace CarmentaWpfClient.ViewModels
{
    public partial class ToolsMenuViewModel : ObservableObject
    {
        [RelayCommand]
        public void ItemSelected(string? tag)
        {
            if (tag is null) return;

            ChangeToolMessage message = tag switch
            {
                "point" => new ChangeToolMessage(ToolType.Create, ToolCreateMode.Point),
                "line" => new ChangeToolMessage(ToolType.Create, ToolCreateMode.Line),
                "polygon" => new ChangeToolMessage(ToolType.Create, ToolCreateMode.Polygon),
                _ => new ChangeToolMessage(ToolType.Standard),
            };
            WeakReferenceMessenger.Default.Send(message);
        }
    }
}

The ItemsSelectedCommand wraps the ItemsSelected method that will receive a tag in the form of a string. It uses a switch expression to create a ChangeToolMessage (coming soon) to indicate the selected tool type and mode on the tool. The message is then dispatched using the WeakReferenceMessenger.

Messaging

Let’s create ChangeToolMessage itself next.

Message\ChangeToolMessage.cs

using Carmenta.Engine;

namespace CarmentaWpfClient.Messages;

public enum ToolType
{
    Standard,
    Create
}

public record ChangeToolMessage(ToolType ToolType, ToolCreateMode? ToolCreateMode = null);

The message is a simple record with two enum properties indicating the tool type (the standard tool for map interaction or a create tool for creating features) and the tool create mode indicating the type of feature geometry to create.

Message received

Now let’s edit the MapViewModel to receive the ChangeToolMessage and set the tool accordingly. The MapViewModel will have to keep the different tools in separate private fields and set the current tool according to the value of the properties in the ChangeToolMessage. This way, ToolsMenuViewModel doesn’t need to know anything about the Tool implementations that MapViewModel uses.

Let’s edit MapViewModel.cs to accommodate this functionality:

ViewModels\MapViewModel.cs

using Carmenta.Engine;

using CarmentaWpfClient.Messages;
using CarmentaWpfClient.Models;
using CarmentaWpfClient.Utils;

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;

using System.Linq;

namespace CarmentaDemoWPF.ViewModels
{
    public partial class MapViewModel : ObservableObject,
        IRecipient, IRecipient
    {
        private CreateTool? _createTool;
        private StandardTool _standardTool = new();

        [ObservableProperty]
        private ITool? _currentTool;

        private readonly MapProvider _mapProvider;

        [ObservableProperty]
        private MapModel? _currentMap;

        public MapViewModel(MapProvider mapProvider)
        {
            WeakReferenceMessenger.Default.RegisterAll(this);
            _mapProvider = mapProvider;

            CurrentMap = _mapProvider.Maps.First();
            CurrentTool = _standardTool;

            var featureMemoryDataSet = CurrentMap.View
                .GetTypedObject("FeatureMemoryDataSet");

            if (featureMemoryDataSet is not null)
                _createTool = new CreateTool(featureMemoryDataSet);
        }

        public void Receive(UpdateViewMessage message) => CurrentMap?.Update();

        public void Receive(ChangeToolMessage message)
        {
            if (message is { ToolType: ToolType.Create, ToolCreateMode: not null })
            {
                if (_createTool == null)
                {
                    return;
                }

                _createTool.CreateMode = message.ToolCreateMode.Value;
                CurrentTool = _createTool;
            }
            else
                CurrentTool = _standardTool;
        }
    }
}

We made MapViewModel inherit from ObservableObject, giving us access to the SetProperty method which will signal to any listeners that the property value was changed. This will be used by the view to update the tool. We also implemented the IRecipient interface and changed the registration method call in the constructor to RegisterAll to register all Receive methods in the WeakReferenceMessenger. The new Receive(ChangeToolMessage) overload handles setting the respective tool on the CurrentTool property.

Notice that the CreateTool requires a MemoryDataSet when it’s being initialized, this MemoryDataSet is retrieved from the View by using an extension method that we will create in Utils\EngineExtensions.cs:

Utils\EngineExtensions.cs

using Carmenta.Engine;

using System.Linq;

namespace CarmentaWpfClient.Utils;

public static class EngineExtensions
{
    public static T? GetTypedObject(this View view, string name = "")
        where T : ResourceObject
    {
        var objectsOfType = view.GetChildObjects().OfType();

        return string.IsNullOrEmpty(name) ? objectsOfType.FirstOrDefault() : objectsOfType.FirstOrDefault(x => x.Name == name);
    }
}

This method enables us to search for child objects of a certain type in the View hierarchy, optionally searching by name of the object. This gives us a handy way to find objects without having to clutter our ViewModels with overly complex LINQ queries. In the MapViewModel constructor, we use this extension method to find the MemoryDataSet we added to the map configuration earlier, then we use that when instantiating the CreateTool.

At this point, our view will not be aware when we change the value of the CurrentTool property. To fix that we will have to subscribe to the PropertyChanged event on the MapViewModel (provided by the ObservableObject base class). Do this by adding the following to MapView.xaml.cs:

Views\MapView.xaml.cs

...

namespace CarmentaWpfClient.Views
{
    /// 
    /// Interaction logic for MapView.xaml
    /// 
    public partial class MapView : UserControl
    {
        public MapView()
        {
            InitializeComponent();
        }

        private void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
        {
            if (e.NewValue is MapViewModel mapViewModel)
            {
                mapViewModel.PropertyChanged += OnPropertyChanged;
                MapControl.View = mapViewModel.CurrentMap.View;
                MapControl.Tool = mapViewModel.CurrentTool;
            }
        }

        private void OnPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
        {
            if (sender is not MapViewModel mapViewModel)
                return;

            if (e.PropertyName == nameof(MapViewModel.CurrentTool))
                MapControl.Tool = mapViewModel.CurrentTool;
        }
    }
}

If the sender is a MapViewModel and if the property that was updated was MapViewModel.CurrentTool, we update the MapControl.Tool property to match.

Put it in the drawer

The last step is to make sure that the DrawerMenu will display the ToolsMenu when we select the Tools option in the drawer. Edit the DrawerMenuViewModel.SelectionChanged event handler to add a new case in the switch expression for “tools”. We also have to inject the ToolsMenuViewModel and store that in a private field:

ViewModels\DrawerMenuViewModel.cs

...

namespace CarmentaDemoWPF.ViewModels
{
    public partial class DrawerMenuViewModel(
        LayerControlViewModel _layerControlViewModel,
        ToolsMenuViewModel _toolsMenuViewModel) : ObservableObject
    {
        [ObservableProperty]
        private object? _currentViewModel;

        private bool _isOpen;

        public bool IsOpen
        {
            get => _isOpen;
            set => SetProperty(ref _isOpen, value);
        }

        [RelayCommand]
        public void ButtonClicked() => IsOpen = !IsOpen;

        [RelayCommand]
        private void SelectionChanged(SelectionChangedEventArgs? e)
        {
            if (e is null) return;
            if (e.AddedItems.Count == 0)
            {
                CurrentViewModel = null;
                return;
            }

            if (e.AddedItems[0] is ListBoxItem listBoxItem)
            {
                var tag = listBoxItem.Tag.ToString();

                CurrentViewModel = tag switch
                {
                    "layers" => _layerControlViewModel,
                    "tools" => _toolsMenuViewModel,
                    _ => null
                };
            }
        }
    }
}

Now let’s add the ToolsMenuView to the contextual part of DrawerMenuView.xaml by adding it to the Resources section:

Views\DrawerMenuView.xaml

<UserControl ...>
    <UserControl.Resources>
        ...
        <DataTemplate DataType="{x:Type vm:ToolsMenuViewModel}">
            <local:ToolsMenuView />
        </DataTemplate>
		...
    </UserControl.Resources>
	<Grid Background="{StaticResource SystemAccentColorDark1Brush}">
       ...
    </Grid>
</UserControl>

Hooking it all up through the service provider

Finally, before we can test our changes, we need to add the ToolsMenuViewModel to the list of dependencies by modifying the ConfigureServices method of App.xaml.cs:

App.xaml.cs

...
private static IServiceProvider ConfigureServices()
{
    ...
    services.AddTransient();
    return services.BuildServiceProvider();
}
...

All done!

Start the application and select the Tools option in the drawer menu. This will expand the tools menu. Press the button and select an option in the menu, this will set the tool according to the selected option. Try it in the map and you should see the cursor change appearance when a create tool is selected:

While editing geometries consisting of several points, such as lines or polygons, simply double-click somewhere to make that the last point and exit editing mode.

Conclusion

Today, we have seen how we can add interaction to the map in the form of feature creation. We’ve added new ViewModels and set up zero-coupling communication lines between them using the WeakReferenceMessenger facility.

In the next article we’ll look at drawing more complex features, namely tactical graphics.