ProGuide Sketch Tool With Halo - Esri/arcgis-pro-sdk GitHub Wiki

Language:      C#
Subject:       Editing
Contributor:   ArcGIS Pro SDK Team <[email protected]>
Organization:  Esri, http://www.esri.com
Date:          09/06/2024
ArcGIS Pro:    3.4
Visual Studio: 2022

This guide demonstrates how to create a sketch tool with halos centered around the cursor. The halos are implemented using an embeddable control assigned to the SketchTipID of the tool. See ProConcepts Map Exploration, MapTool for more information on the MapTool pattern. The code used in this ProGuide can be found at Sketch Tool Halo Sample.

Prerequisite

  • Download and install the sample data required for this guide as instructed in ArcGIS Pro SDK Community Samples Releases.
  • Create a new ArcGIS Pro Module Add-in, and name the project SketchToolWithHalos. If you are not familiar with the ArcGIS Pro SDK, you can follow the steps in the ProGuide Build your first add-in to get started.
  • Add a new ArcGIS Pro Add-ins | ArcGIS Pro Map Tool item to the add-in project, and name the item SketchToolHalo.cs.
  • Add a new ArcGIS Pro Add-ins | ArcGIS Pro Embeddable Control item to the add-in project, and name the item HaloEmbeddableControl.

Step 1

Modify the Config.daml file tool item as follows:

Change the caption to "SketchTool with Halos". Change the tool heading to "SketchTool with Halos" and the ToolTip text to "Tool with a set of halos centered around the cursor."

<tool id="SketchToolWithHalos_SketchToolHalo" 
        caption="SketchTool with Halos" className="SketchToolHalo" 
        loadOnClick="true" 
        smallImage="GenericButtonRed16" largeImage="GenericButtonRed32" 
        condition="esri_mapping_mapPane">
    <tooltip heading="SketchTool with Halos">Tool with a set of halos centered around the cursor.
         <disabledText />
    </tooltip>
</tool>

Step 2

Modify the SketchToolHalo.cs file as follows:

In the constructor, change the SketchType of the map tool to a line geometry type. This is the geometry of the map tool feedback used to symbolize the user interaction. For this example, you're using a single solid line that adds a linear segment on each mouse click. Set the SketchTipID variable to the ID of the embeddable control added. If you followed the naming conventions in the Prerequisite section then the ID should be "SketchToolWithHalos_HaloEmbeddableControl".

The default properties of a sketch tip on a MapTool are to be located to the lower right of the cursor and to have a grey background. Two properties were added to the MapTool class at the 3.4 release to allow you to customize the sketch tip location and transparency. The properties are SketchTipControlPosition and IsSketchTipControlTransparent.

The halos will be centered around the cursor so set the SketchTipControlPosition to SketchTipControlPosition.Center. We also want the embeddable control to be transparent so set IsSketchTipControlTransparent to true.

  public SketchToolHalo()
  {
    IsSketchTool = true;
    SketchType = SketchGeometryType.Line;
    SketchOutputMode = SketchOutputMode.Map;

    SketchTipID = "SketchToolWithHalos_HaloEmbeddableControl";
    SketchTipControlPosition = SketchTipControlPosition.Center;
    IsSketchTipControlTransparent = true;
  }

Build the sample and verify that there are no compile errors.

Step 3

In this step we will define a custom class called a HaloItem. The embeddable control will display a collection of HaloItems in the following step. Add a new class file and call it HaloItem.cs. Copy the following code into the file.

internal class HaloItem : PropertyChangedBase
{
  public HaloItem(double radius, CIMColor color, double strokeThickness)
  {
    RadiusM = radius;
    OutlineColor = color;
    StrokeThickness = strokeThickness;
  }

  public double Height => ScreenDiameter;

  internal CIMColor OutlineColor { get; set; }
  public Brush WPFOutlineBrush { get; internal set; }
  public double StrokeThickness { get; internal set; }

  private double _x;
  public double X { get => _x; set => SetProperty(ref _x, value); }
  private double _y;
  public double Y { get => _y; set => SetProperty(ref _y, value); }

  internal double RadiusM { get; set; }      // in m
  internal double ScreenRadius { get; set; }
  internal double ScreenDiameter => ScreenRadius * 2;
}

Each HaloItem is initialized with a radius (in meters), a CIMColor for the outline and a stroke thickness. The radius and outline color will be converted into a ScreenRadius and a WPFOutlineBrush by utility methods to be defined in a later step. The ScreenRadius property is used to determine the ScreenDiameter; which is also the Height (and Width) of the halo circle. The HaloItem also has X, Y properties that are used to locate the top left position of the halo. These properties are calculated each time the map extent changes and will also be defined in a later step.

Step 4

Now that we have a basic HaloItem defined, we need to create and display them on the embeddable control. Open the HaloEmbeddedControl.xaml file to start with the view.

Replace the entire <Grid> definition in the xaml with the following to display a collection of HaloItems The HaloItems are displayed on a WPF Canvas; each HaloItem is displayed as a WPF ellipse control with the same Width and Height (i.e. it is a circle) and a specific stroke color (WPFOutlineBrush), stroke thickness (StrokeThickness) and location on the canvas (X, Y).

  <Grid Background="Transparent">
    <ItemsControl ItemsSource="{Binding HaloItems}">
      <ItemsControl.ItemsPanel>
        <ItemsPanelTemplate>
          <Canvas Background="Transparent" Width="{Binding CanvasHeight}" Height="{Binding CanvasHeight}"/>
        </ItemsPanelTemplate>
      </ItemsControl.ItemsPanel>
      <ItemsControl.ItemContainerStyle>
        <Style TargetType="ContentPresenter">
          <Setter Property="Canvas.Left" Value="{Binding X}"/>
          <Setter Property="Canvas.Top" Value="{Binding Y}"/>
        </Style>
      </ItemsControl.ItemContainerStyle>
      <ItemsControl.ItemTemplate>
        <DataTemplate>
          <Grid Background="Transparent">
            <Grid.RowDefinitions>
              <RowDefinition Height="Auto"/>
            </Grid.RowDefinitions>
            <Ellipse  Grid.Row="0"  HorizontalAlignment="Left" VerticalAlignment="Top"
                    Width="{Binding Height}" 
                    Height="{Binding Height}"
                    Stroke= "{Binding WPFOutlineBrush}"
                    StrokeThickness="{Binding StrokeThickness}"
                    Fill="Transparent" />
          </Grid>
        </DataTemplate>
      </ItemsControl.ItemTemplate>
    </ItemsControl>
  </Grid>

Next we will build the collection of HaloItems within the ViewModel. Open the HaloEmbeddableControlViewModel.cs file.

Add the following to the class which defines the collection of HaloItems.

    private readonly object _lockCollection = new object();

    private readonly ObservableCollection<HaloItem> _halos = new ObservableCollection<HaloItem>();
    private readonly ReadOnlyObservableCollection<HaloItem> _readOnlyHalos;

    public ReadOnlyObservableCollection<HaloItem> HaloItems => _readOnlyHalos;

Update the constructor to the following.

    public HaloEmbeddableControlViewModel(XElement options, bool canChangeOptions) : base(options, canChangeOptions)
    {
      _readOnlyHalos = new ReadOnlyObservableCollection<HaloItem>(_halos);
      BindingOperations.EnableCollectionSynchronization(_readOnlyHalos, _lockCollection);
    }

When the embeddable control is opened (i.e. when the sketch tool is activated) we will assign a set of halos if they do not already exist. Override the OpenAsync method and add the following code.

    public override async Task OpenAsync()
    {
      if (_halos.Count == 0)
        await AssignHalos();
    }

    internal async Task AssignHalos()
    {
      
    }

We also need to ensure that the display of the halos is updated whenever the map scale is changed; as we zoom in we want the halo circles to be larger on the screen; as we zoom out the halo circles should be smaller. To accomplish this, subscribe to the MapViewCameraChangedEvent in the coonstructor and define the delegate function.

    public HaloEmbeddableControlViewModel(XElement options, bool canChangeOptions) : base(options, canChangeOptions)
    {
      _readOnlyHalos = new ReadOnlyObservableCollection<HaloItem>(_halos);
      BindingOperations.EnableCollectionSynchronization(_readOnlyHalos, _lockCollection);

      // subscribe to MapViewCameraChanged
      ArcGIS.Desktop.Mapping.Events.MapViewCameraChangedEvent.Subscribe(OnMapViewCameraChanged);
    }

    // when the camera / scale changes; recalculate the halo screen sizes
    internal async void OnMapViewCameraChanged(ArcGIS.Desktop.Mapping.Events.MapViewCameraChangedEventArgs args)
    {
      if (args == null)
        return;

      await UpdateHalos();
    }

    internal async Task UpdateHalos()
    {
      
    }

Code will be added to the AssignHalos and UpdateHalos methods in the next steps.

Finally, add an additional property to the ViewModel with defines the height of the Canvas.

    private double _canvasHeight = 0;
    public double CanvasHeight
    {
      get => _canvasHeight;
      set => SetProperty(ref _canvasHeight, value);
    }

Your entire Viewmodel code should be like the following.

  internal class HaloEmbeddableControlViewModel : EmbeddableControl
  {
    private readonly object _lockCollection = new object();

    private readonly ObservableCollection<HaloItem> _halos = new ObservableCollection<HaloItem>();
    private readonly ReadOnlyObservableCollection<HaloItem> _readOnlyHalos;

    public ReadOnlyObservableCollection<HaloItem> HaloItems => _readOnlyHalos;

    private double _canvasHeight = 0;
    public double CanvasHeight
    {
      get => _canvasHeight;
      set => SetProperty(ref _canvasHeight, value);
    }

    public HaloEmbeddableControlViewModel(XElement options, bool canChangeOptions) : base(options, canChangeOptions)
    {
      _readOnlyHalos = new ReadOnlyObservableCollection<HaloItem>(_halos);
      BindingOperations.EnableCollectionSynchronization(_readOnlyHalos, _lockCollection);

      // subscribe to MapViewCameraChanged
      ArcGIS.Desktop.Mapping.Events.MapViewCameraChangedEvent.Subscribe(OnMapViewCameraChanged);
    }

    public override async Task OpenAsync()
    {
      if (_halos.Count == 0)
        await AssignHalos();
    }

    // when the camera / scale changes; recalculate the halo screen sizes
    internal async void OnMapViewCameraChanged(ArcGIS.Desktop.Mapping.Events.MapViewCameraChangedEventArgs args)
    {
      if (args == null)
        return;

      await UpdateHalos();
    }

    internal async Task AssignHalos()
    {
      
    }

    internal async Task UpdateHalos()
    {
      
    }
  }

Build the sample and verify that there are no compile errors.

In the next steps we will add the conversion utility methods to the HaloItem class along with code for the AssignHalos and updateHalos in the ViewModel.

Step 5

Return to the HaloItem class (in the HaloItem.cs file). Add the following code to the file. These methods convert the halo radius (in meters) to a screen radius, calculate the X,Y position of the halo on the canvas as per the canvas height and screen radius and converts a CIM color to a WPF brush.

    internal async Task DoConversions()
    {
      // convert radius to screen coords
      await DoRadiusConversion();

      // convert the CIMColor to a UI color brush
      var brushColor = await ConvertCIMColorToUIColorBrush(OutlineColor);
      WPFOutlineBrush = new SolidColorBrush(brushColor);
    }

    internal async Task DoRadiusConversion()
    {
      if (MapView.Active == null)
        return;

      ScreenRadius = await ConvertRadiusToScreenUnits(RadiusM);
      // force a notification on the public Height property
      NotifyPropertyChanged(nameof(Height));
    }

    internal void CalculatePosition(double canvasHeight)
    {
      var halfCanvasHeight = canvasHeight / 2;
      // determine left, top offset to apply to be canvas WPF margin
      double offset = halfCanvasHeight - ScreenRadius;

      X = offset;
      Y = offset;
    }

    private Task<double> ConvertRadiusToScreenUnits(double radiusInMeters)
    {
      // convert the radius (in m) to screen units by using
      // GeometryEngine.ConstructGeodeticLineFromDistance to construct a line
      // of "radiusInMeters" distance from an arbitrary point (the center of the extent)


      // get the center of the extent
      var centerPt = MapView.Active.Extent.Center;

      return QueuedTask.Run(() =>
      {
        // construct the geodetic line of radiusInMeters distance from the centerPt
        var polyline = GeometryEngine.Instance.ConstructGeodeticLineFromDistance(GeodeticCurveType.Geodesic, centerPt, radiusInMeters, 0, null, CurveDensifyMethod.ByLength, 3000);
        var ptCount = polyline.PointCount;
        var endPoint = polyline.Points[ptCount - 1];

        // convert the real points
        var startPtClient = MapView.Active.MapToClient(centerPt);
        var startPtScreen = MapView.Active.MapToScreen(centerPt);
        var endPtClient = MapView.Active.MapToClient(endPoint);
        var endPtScreen = MapView.Active.MapToScreen(endPoint);

        // now get the distance
        var xDiff = endPtScreen.X - startPtScreen.X;
        var yDiff = endPtScreen.Y - startPtScreen.Y;

        var dist = Math.Sqrt((xDiff * xDiff) + (yDiff * yDiff));

        return dist;
      });
    }

    private Task<System.Windows.Media.Color> ConvertCIMColorToUIColorBrush(CIMColor color)
    {
      return QueuedTask.Run(() =>
      {
        var rgbColor = ColorFactory.Instance.ConvertToRGB(color);
        var uiColor = ArcGIS.Desktop.Internal.Mapping.Symbology.ColorHelper.UIColor(rgbColor);
        return uiColor;
      });
    }

Step 6

Return to the HaloEmbeddableControlViewModel.cs file. Replace the empty AssignHalos method with the following.

    internal async Task AssignHalos()
    {
      // assign a list of radii in m
      List<double> radii = new List<double>() { 200, 60, 500, 2500 };   // in m

      var map = MapView.Active?.Map;
      if (map == null)
        return;

      double maxDiameter = 0;

      var count = radii.Count();
      // for each radius
      for (int idx = 0; idx < count; idx++)
      {
        // randomize color and stroke thickness
        var val = idx % 3;
        CIMColor color = null;
        double thickness = val + 1;
        switch (val)
        {
          case 0:
            color = CIMRGBColor.CreateRGBColor(255, 0, 0);
            break;
          case 1:
            color = CIMRGBColor.CreateRGBColor(255, 0, 255);
            break;
          case 2:
            color = CIMRGBColor.CreateRGBColor(0, 255, 255);
            break;
        }

        // create the halo item
        var halo = new HaloItem(radii[idx], color, thickness);
        // do the calculation / conversions
        await halo.DoConversions();
        // add to the collection
        _halos.Add(halo);

        // keep track of the max screen diameter
        var diameter = halo.ScreenDiameter;
        if (diameter > maxDiameter)
          maxDiameter = diameter;
      }

      // update the canvas dimensions to the maximum screen diameter
      CanvasHeight = maxDiameter;

      // calculate the positions of all the halos on the canvas
      foreach (var halo in _halos)
        halo.CalculatePosition(CanvasHeight);

      // notify the UI that the collection has changed
      NotifyPropertyChanged(nameof(HaloItems));
    }

This method starts by setting up a number of radii to use for the halos. A random color and stroke thickness are also used. For the purposes of this ProGuide and this tool, these values have been hardcoded. Additional UI can be developed to allow the radius, color and stroke thickness to be user specified to create a more complete tool.

For each of the radii, define a HaloItem, perform the conversions to determine the screen radius then add the halo item to the collection, keeping track of the largest halo's screen diameter. This diameter will the dimensions of the Canvas. Once the canvas dimensions are determined, calculate the positions of each of the halos with respect to that canvas height.

The UpdateHalos method is very similar in that it recalculates the screen radius and position of each halo each time that the map scale has changed. Here is the UpdateHalos code.

    internal async Task UpdateHalos()
    {
      var map = MapView.Active?.Map;
      if (map == null)
        return;

      double maxDiameter = 0;

      foreach (var halo in _halos)
      {
        // do the radius conversion based on screen scale
        await halo.DoRadiusConversion();

        // keep track of the max screen diameter
        var diameter = halo.ScreenDiameter;
        if (diameter > maxDiameter)
          maxDiameter = diameter;
      }

      // update the canvas dimensions to the maximum screen diameter
      CanvasHeight = maxDiameter;

      // calculate the positions of all the halos on the canvas
      foreach (var halo in _halos)
        halo.CalculatePosition(CanvasHeight);

      // notify the UI that the collection has changed
      NotifyPropertyChanged(nameof(HaloItems));
    }

Build the sample and verify that there are no compile errors.

Run ArcGIS Pro. Open the C:\Data\Interacting with Maps\Interacting with Maps.aprx project that contains 2D and 3D maps with feature data. Open the 2D map. Activate the Sketch Tool with Halos on the Add-in tab.

SketchToolHalos.png

Move your mouse around and notice how the halos follow the cursor. Zoom in and out and notice how the halos adjust to the different map scale. The more you zoom out the smaller the halos become. Eventually you reach a scale where the halo display is not particularly useful as they become too small to be displayed. In the final step, a minimum scale property will be added. This value will be compared against the screen radius of each halo to determine whether it should be displayed or not.

Step 7

First, open the HaloItem.cs file and add some additional properties to the HaloItem class to determine whether it is visible or not.

    public bool IsVisible => ShowAtAllScales || IsValidScale;
    internal bool IsValidScale { get; set; }
    internal bool ShowAtAllScales { get; set; }

Then add the following method to the class.

    internal void CalculateVisibility(double minSize, bool showHalosAtAllScales)
    {
      IsValidScale = ScreenRadius >= minSize;
      ShowAtAllScales = showHalosAtAllScales;

      NotifyPropertyChanged(nameof(IsVisible));
    }

Next, open the HaloEmbeddableControlViewModel.cs file and add the following properties to the ViewModel.

    // Use _showAllHalos to indicate if the halos should be visible at all scales. 
    // As the halo size (in pixels) becomes smaller it becomes less useful on the screen. 
    // It may be useful to filter these smaller halos out. 
    private bool _showAllHalos = false;
    // indicates a custom cut-off scale (in pixels) as to when a halo will no longer be visible
    private double _haloMinimumVisibleSize = 10;

The viewModel needs to call the CalculateVisibility on each halo when it is created and when the map scale changes so that it can determine whether it should be visible or not. Add the CalculateVisibility method as follows

in AssignHalos; after the haloItem has been created and the conversions have occurred.

    // create the halo item
    var halo = new HaloItem(radii[idx], color, thickness);
    // do the calculation / conversions
    await halo.DoConversions();
    // update visibility
    **halo.CalculateVisibility(_haloMinimumVisibleSize, _showAllHalos);**

    // add to the collection
    _halos.Add(halo);

And in UpdateHalos;

  foreach (var halo in _halos)
  {
    // do the radius conversion based on screen scale
    await halo.DoRadiusConversion();

    // update visibility
    **halo.CalculateVisibility(_haloMinimumVisibleSize, _showAllHalos);**

Finally update the HaloEmbeddableControl.xaml file to ensure that each of the halos use the IsVisible property.

Start by adding the local namespace to the file by including the following at the top of the file following the extensions namespace declaration

  xmlns:local="clr-namespace:SketchToolWithHalos"

Then add a reference to a converter within the ResourceDictionary tag.

        <ResourceDictionary>
            <ResourceDictionary.MergedDictionaries>
                <extensions:DesignOnlyResourceDictionary Source="pack://application:,,,/ArcGIS.Desktop.Framework;component\Themes\Default.xaml"/>
            </ResourceDictionary.MergedDictionaries>
          
            <local:BoolToVisibleConverter x:Key="boolToVisibilityConverter"/>
        </ResourceDictionary>

Set the visibility of the grid element within the dataTemplate; binding to the IsVisible property and using the converter.

  <ItemsControl.ItemTemplate>
    <DataTemplate>
      <Grid Background="Transparent" Visibility="{Binding IsVisible, Converter={StaticResource boolToVisibilityConverter}}">

The entire file should look as follows.

<UserControl x:Class="SketchToolWithHalos.HaloEmbeddableControlView"
             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" 
             xmlns:ui="clr-namespace:SketchToolWithHalos"
             xmlns:extensions="clr-namespace:ArcGIS.Desktop.Extensions;assembly=ArcGIS.Desktop.Extensions"
               xmlns:local="clr-namespace:SketchToolWithHalos"
             mc:Ignorable="d" 
             d:DesignHeight="300" d:DesignWidth="300"
             d:DataContext="{Binding Path=ui.HaloEmbeddableControlViewModel}">
            <UserControl.Resources>
        <ResourceDictionary>
            <ResourceDictionary.MergedDictionaries>
                <extensions:DesignOnlyResourceDictionary Source="pack://application:,,,/ArcGIS.Desktop.Framework;component\Themes\Default.xaml"/>
            </ResourceDictionary.MergedDictionaries>
          
            <local:BoolToVisibleConverter x:Key="boolToVisibilityConverter"/>
        </ResourceDictionary>
    </UserControl.Resources>


  <Grid Background="Transparent">
    <ItemsControl ItemsSource="{Binding HaloItems}">
      <ItemsControl.ItemsPanel>
        <ItemsPanelTemplate>
          <Canvas Background="Transparent" Width="{Binding CanvasHeight}" Height="{Binding CanvasHeight}"/>
        </ItemsPanelTemplate>
      </ItemsControl.ItemsPanel>
      <ItemsControl.ItemContainerStyle>
        <Style TargetType="ContentPresenter">
          <Setter Property="Canvas.Left" Value="{Binding X}"/>
          <Setter Property="Canvas.Top" Value="{Binding Y}"/>
        </Style>
      </ItemsControl.ItemContainerStyle>
      <ItemsControl.ItemTemplate>
        <DataTemplate>
          <Grid Background="Transparent" Visibility="{Binding IsVisible, Converter={StaticResource boolToVisibilityConverter}}">
            <Grid.RowDefinitions>
              <RowDefinition Height="Auto"/>
            </Grid.RowDefinitions>
            <Ellipse  Grid.Row="0"  HorizontalAlignment="Left" VerticalAlignment="Top"
                    Width="{Binding Height}" 
                    Height="{Binding Height}"
                    Stroke= "{Binding WPFOutlineBrush}"
                    StrokeThickness="{Binding StrokeThickness}"
                    Fill="Transparent" />
          </Grid>
        </DataTemplate>
      </ItemsControl.ItemTemplate>
    </ItemsControl>
  </Grid>
</UserControl>

Finally we'll add the converter which translates a boolean value into a System.Windows.Visibility value. Add another class file to the project and call it Converters.cs. Replace the class definition with the following.

  internal sealed class BoolToVisibleConverter : IValueConverter
  {
    public BoolToVisibleConverter() { }

    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
    {
      if (targetType != typeof(Visibility))
        throw new InvalidOperationException("The target must be of type " + nameof(Visibility));

      var invert = (parameter is bool?)
                       ? parameter as bool?
                       : parameter != null && System.Convert.ToBoolean(parameter);

      var boolValue = System.Convert.ToBoolean(value);
      if (invert == true)
        boolValue = !boolValue;
      return boolValue ? Visibility.Visible : Visibility.Collapsed;
    }

    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
    {
      if (targetType != typeof(bool))
        throw new InvalidOperationException("The target must be of type bool");

      if (value == null)
        return true;

      Visibility visibility = (Visibility)System.Convert.ToInt32(value);

      return visibility == Visibility.Visible ? true : false;
    }
  }

Build the sample and verify that there are no compile errors.

Run ArcGIS Pro. Open the C:\Data\Interacting with Maps\Interacting with Maps.aprx project Activate the Sketch Tool with Halos on the Add-in tab and explore how the halos disappear and reappear when you zoom in and out.

⚠️ **GitHub.com Fallback** ⚠️