Jelajahi Sumber

Added Levenshtein "Fuzzy" text searching Operator to Filter<> and SQLiteProvider
Added OSM Map Display and Nominatim Geocoder option to GeoFenceEditor
Added Satellite and Road option to GoogleImageryLayer
Removed Geocoding and MapButton <-> Google dependency from AddressEditor
Added support for GNAF_CORE address file (incomplete)

frankvandenbos 2 bulan lalu
induk
melakukan
6ab3eaea5a

+ 10 - 0
InABox.Core/Classes/AddressLookup.cs

@@ -0,0 +1,10 @@
+namespace InABox.Core
+{
+    [UserTracking("Core Functionality")]
+    public class AddressLookup : Entity, IPersistent, IRemotable, ILicense<CoreLicense>
+    {
+        public string Address { get; set; }
+        public double Latitude { get; set; }
+        public double Longitude { get; set; }
+    }
+}

+ 12 - 1
InABox.Core/Query/Filter.cs

@@ -85,6 +85,7 @@ namespace InABox.Core
         None,
         NotInQuery,
         Regex,
+        Resembles,
         Not = byte.MaxValue
     }
 
@@ -385,6 +386,8 @@ namespace InABox.Core
         
         IFilter Regex(string regex);
 
+        IFilter Resembles(string search);
+
         IFilter All();
         IFilter None();
 
@@ -993,6 +996,13 @@ namespace InABox.Core
         public Filter<T> Regex(string regex) => ApplyStringOperator(Operator.Regex, regex);
         IFilter IFilter.Regex(string value) => Regex(value);
         
+        #endregion
+        
+        #region Resembles
+        
+        public Filter<T> Resembles(string search) => ApplyStringOperator(Operator.Resembles, search);
+        IFilter IFilter.Resembles(string value) => Resembles(value);
+        
         #endregion
 
         #region InList
@@ -1676,7 +1686,8 @@ namespace InABox.Core
                 { Operator.NotInList, "{0} not in ({1})" },
                 { Operator.InQuery, "{0} in ({1})" },
                 { Operator.NotInQuery, "{0} not in ({1})" },
-                { Operator.Regex, "{0} regexp {1}" }
+                { Operator.Regex, "{0} regexp {1}" },
+                { Operator.Resembles, "{0} resembles {1}" }
             };
 
             var result = "";

+ 68 - 0
InABox.Database/Stores/AddressLookupStore.cs

@@ -0,0 +1,68 @@
+using InABox.Core;
+using NPOI.HSSF.Record;
+using NPOI.OpenXmlFormats.Spreadsheet;
+
+namespace InABox.Database.Stores;
+
+public class AddressLookupStore : Store<AddressLookup>
+{
+    
+    private static List<AddressLookup> _cache= new();
+    private static IQueryProviderFactory? _queryProviderFactory = null;
+    
+    public override void Init()
+    {
+        base.Init();
+
+        // Load the cache file into memory
+        string cachefile = Path.Combine(CoreUtils.GetPath(), "GNAF_CORE.psv");
+        if (File.Exists(cachefile))
+        {
+            DateTime start = DateTime.Now;
+            Logger.Send(LogType.Information,"",$"Loading Address Lookup File: {cachefile}");
+            using (StreamReader sr = new(cachefile))
+            {
+                var headers = sr.ReadLine().Split('|').ToList();
+                int streetfield = headers.IndexOf("ADDRESS_LABEL");
+                int statefield = headers.IndexOf("STATE");
+                int latfield = headers.IndexOf("LATITUDE");
+                int lngfield = headers.IndexOf("LONGITUDE");
+                while (!sr.EndOfStream)
+                {
+                    var line = sr.ReadLine().Split('|').ToArray();
+                    if (string.Equals(line[statefield], "WA"))
+                    {
+                        _cache.Add(new AddressLookup()
+                        {
+                            Address = line[streetfield],
+                            Latitude = double.Parse(line[latfield]),
+                            Longitude = double.Parse(line[lngfield])
+                        });
+                        
+                    }
+                }
+            }
+            Logger.Send(LogType.Information,"",$"Found {_cache.Count} addresses in {(DateTime.Now - start):g}");            
+        }
+        else
+            Logger.Send(LogType.Information,"",$"Address Lookup File: {cachefile} not found!");
+    }
+
+    protected override CoreTable OnQuery(Filter<AddressLookup>? filter, Columns<AddressLookup>? columns, SortOrder<AddressLookup>? sort, CoreRange? range)
+    {
+        _queryProviderFactory ??= this.GetQueryProviderFactory();
+        var result = new CoreTable();
+        var cols = columns ?? Columns.All<AddressLookup>();
+        result.Columns.AddRange(cols.Select(x => new CoreColumn(x.Property)));
+        foreach (var lookup in _cache)
+        {
+            if (filter == null || filter.Match(lookup, _queryProviderFactory))
+            {
+                    var row = result.NewRow();
+                    result.LoadRow(lookup);
+                    result.Rows.Add(row);
+            }
+        }
+        return result;
+    }
+}

+ 1 - 0
inabox.database.sqlite/InABox.Database.SQLite.csproj

@@ -28,6 +28,7 @@
     </ItemGroup>
 
     <ItemGroup>
+        <PackageReference Include="Quickenshtein" Version="1.5.1" />
         <PackageReference Include="System.Collections.Immutable" Version="9.0.0-preview.4.24266.19" />
         <PackageReference Include="System.Data.SQLite.Core" Version="1.0.118" />
     </ItemGroup>

+ 32 - 1
inabox.database.sqlite/SQLiteProvider.cs

@@ -12,6 +12,7 @@ using System.Text.RegularExpressions;
 using InABox.Core;
 using Microsoft.CodeAnalysis;
 using NPOI.SS.UserModel;
+using Quickenshtein;
 
 namespace InABox.Database.SQLite;
 
@@ -52,11 +53,40 @@ internal abstract class SQLiteAccessor : IDisposable
     }
 }
 
+#region Levenshtein (Fuzzy) Search Function
+
+[SQLiteFunction(Name = "RESEMBLES", Arguments = 2, FuncType = FunctionType.Scalar)]
+public class SQLiteResembles : SQLiteFunction
+{
+
+    public SQLiteResembles() : base()
+    {
+        
+    }
+    
+    public override object Invoke(object[] args)
+    {
+        
+        if (args.Length < 2 || args[0]  is not string source || args[1] is not string target)
+            return 0;
+        return Levenshtein.GetDistance(source, target) <= 2.0;
+    }
+}
+
+
+#endregion
+
 #region Custom Decimal Functions
 
 [SQLiteFunction(Name = "REGEXP", Arguments = 2, FuncType = FunctionType.Scalar)]
 public class SQLiteRegExp : SQLiteFunction
 {
+
+    public SQLiteRegExp() : base()
+    {
+        
+    }
+    
     override public object Invoke(object[] args)
     {
         if (args.Length < 2 || args[0]  is not string pattern || args[1] is not string input)
@@ -1749,7 +1779,8 @@ public class SQLiteProvider : IProvider
         { Operator.NotInList, "{0} NOT IN ({1})" },
         { Operator.InQuery, "{0} IN ({1})" },
         { Operator.NotInQuery, "{0} NOT IN ({1})" },
-        { Operator.Regex, "{0} REGEXP {1}" }
+        { Operator.Regex, "{0} REGEXP {1}" },
+        { Operator.Resembles, "RESEMBLES({0},{1})"}
     };
 
     internal static string EscapeValue(object? value)

+ 49 - 42
inabox.wpf/DynamicGrid/Editors/AddressEditor/AddressEditor.cs

@@ -1,19 +1,13 @@
 using System;
 using InABox.Core;
-using Syncfusion.Windows.Shared;
 using System.Collections.Generic;
-using System.Linq;
-using System.Threading.Tasks;
 using System.Windows;
 using System.Windows.Controls;
 using System.Windows.Media;
-using Geocoding;
-using Geocoding.Google;
 using InABox.Wpf;
 using InABox.WPF;
 using InABox.Wpf.DynamicGrid;
 using Address = InABox.Core.Address;
-using Location = InABox.Core.Location;
 
 namespace InABox.DynamicGrid;
 
@@ -22,6 +16,8 @@ public class AddressEditorControl : DynamicEnclosedEditorControl<Address, Addres
 
     private Grid Grid = null!; // Late-initialized in CreateEditor
     
+    //private TextBox SearchBox = null!; // Late-initialized in CreateEditor
+    //private ListBox Suggestions = null!;
     private TextBox StreetBox = null!; // Late-initialized in CreateEditor
     private TextBox CityBox = null!; // Late-initialized in CreateEditor
     private TextBox StateBox = null!; // Late-initialized in CreateEditor
@@ -55,7 +51,7 @@ public class AddressEditorControl : DynamicEnclosedEditorControl<Address, Addres
     {
     }
 
-    protected override FrameworkElement CreateEditor()
+   protected override FrameworkElement CreateEditor()
     {
         Grid = new Grid
         {
@@ -67,9 +63,44 @@ public class AddressEditorControl : DynamicEnclosedEditorControl<Address, Addres
         Grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Auto) });
         Grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Auto) });
 
+        //Grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) });
         Grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
         Grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) });
 
+        // SearchBox = new TextBox();
+        // SearchBox.SetValue(Grid.RowProperty, 0);
+        // SearchBox.SetValue(Grid.ColumnProperty, 0);
+        // SearchBox.SetValue(Grid.ColumnSpanProperty, 4);
+        // SearchBox.TextChanged += (sender, args) =>
+        // {
+        //     bool visible = SearchBox.Text.Length >= 5;
+        //     if (visible != (Suggestions.Visibility == Visibility.Visible))
+        //         ShowSearch(visible);
+        //     if (visible)
+        //     {
+        //         var addresses = Client.Query<AddressLookup>(
+        //             new Filter<AddressLookup>(x => x.Address).BeginsWith(SearchBox.Text),
+        //             Columns.None<AddressLookup>().Add(x => x.Address));
+        //             Suggestions.ItemsSource =
+        //                 addresses.ExtractValues<AddressLookup, string>(x => x.Address).OrderBy(x => x).ToArray();
+        //             
+        //     }
+        // };
+        // Grid.Children.Add(SearchBox);
+        //
+        // Suggestions = new ListBox();
+        // Suggestions.SetValue(Grid.RowProperty, 1);
+        // Suggestions.SetValue(Grid.RowSpanProperty, 2);
+        // Suggestions.SetValue(Grid.ColumnProperty, 0);
+        // Suggestions.SetValue(Grid.ColumnSpanProperty, 4);
+        // Suggestions.Visibility = Visibility.Collapsed;
+        // Suggestions.SelectionChanged += (sender, args) =>
+        // {
+        //     SearchBox.Text = "";
+        //     //ShowSearch(false);
+        // };
+        // Grid.Children.Add(Suggestions);
+        
         StreetBox = CreateBox(0, 0, 4, 0, 0.0, 0.0, nameof(Address.Street), "Street");
         CityBox = CreateBox(1, 0, 1, 0, 0.0, 5.0, nameof(Address.City),"City");
         StateBox = CreateBox(1, 1, 1, 150, 5.0, 5.0, nameof(Address.State),"State");
@@ -84,7 +115,7 @@ public class AddressEditorControl : DynamicEnclosedEditorControl<Address, Addres
         MapButton.Background = Brushes.Transparent;
         MapButton.Content = new Image() { Source = InABox.Wpf.Resources.map.AsBitmapImage() };
         MapButton.Click += (sender, args) => ShowMap();
-        MapButton.Visibility = !string.IsNullOrWhiteSpace(CoreUtils.GoogleAPIKey) && EditorDefinition.ShowMapButton 
+        MapButton.Visibility = EditorDefinition.ShowMapButton 
             ? Visibility.Visible 
             : Visibility.Collapsed;
         Grid.Children.Add(MapButton);
@@ -92,6 +123,16 @@ public class AddressEditorControl : DynamicEnclosedEditorControl<Address, Addres
         return Grid;
     }
 
+    // private void ShowSearch(bool suggestions)
+    // {
+    //     Suggestions.Visibility = suggestions ? Visibility.Visible : Visibility.Collapsed;
+    //     StreetBox.Visibility = suggestions ? Visibility.Collapsed : Visibility.Visible;
+    //     CityBox.Visibility = suggestions ? Visibility.Collapsed : Visibility.Visible;
+    //     StateBox.Visibility = suggestions ? Visibility.Collapsed : Visibility.Visible;
+    //     PostcodeBox.Visibility = suggestions ? Visibility.Collapsed : Visibility.Visible;
+    //     MapButton.Visibility = suggestions ? Visibility.Collapsed : Visibility.Visible;
+    // }
+
     private void ShowMap()
     {
         var values = new Dictionary<string,object?>(GetChildValues());
@@ -160,46 +201,12 @@ public class AddressEditorControl : DynamicEnclosedEditorControl<Address, Addres
             _dirty = false;
         }
         MapButton.Background = string.IsNullOrWhiteSpace(_geofence) ? Brushes.Red : Brushes.LightGreen;
-
-        // CoreUtils.GoogleAPIKey = "AIzaSyAVReknl_sP2VLz5SUD8R-sZh1KDVCcWis";
-        // IGeocoder geocoder = new GoogleGeocoder(CoreUtils.GoogleAPIKey);
-        // var address = geocoder.GeocodeAsync($"{StreetBox.Text}, {CityBox.Text} {StateBox.Text} {PostcodeBox.Text} Australia").Result.FirstOrDefault();
-        // if (address != null)
-        // {
-        //     var latOffset = new GeoPoint(address.Coordinates.Latitude, address.Coordinates.Longitude).DistanceTo(new GeoPoint(_latitude, address.Coordinates.Longitude));
-        //     if (address.Coordinates.Latitude < _latitude)
-        //         latOffset *= -1.0;
-        //     
-        //     var longOffset = new GeoPoint(address.Coordinates.Latitude, address.Coordinates.Longitude).DistanceTo(new GeoPoint(address.Coordinates.Latitude, _longitude));
-        //     if (address.Coordinates.Longitude < _longitude)
-        //         longOffset *= -1.0;
-        //     
-        //     var fence = Serialization.Deserialize<GeoFence>(_geofence) ?? new GeoFence();
-        //     List<GeoPoint> newPoints = new List<GeoPoint>();
-        //     foreach (var coord in fence.Coordinates)
-        //         newPoints.Add(coord.Move(longOffset * 1000.0, latOffset * 1000.0));
-        //     fence.Coordinates.Clear();
-        //     fence.Coordinates.AddRange(newPoints);
-                
-        //         _latitude = address.Coordinates.Latitude;
-        //         _longitude = address.Coordinates.Longitude;
-        //         _geofence = Serialization.Serialize(fence);
-        //     }
-        //     else
-        //     {
-        //         _latitude = 0.0;
-        //         _longitude = 0.0;
-        //         _geofence = "";
-        //     }
-        //
-        // }
         
     }
 
     private void AddressChanged(object sender, TextChangedEventArgs e)
     {
         _dirty = true;
-        // Set Latitude and Longitude to Zero
     }
 
     public override void SetEnabled(bool enabled)

+ 38 - 27
inabox.wpf/DynamicGrid/Editors/AddressEditor/GeofenceEditor.xaml

@@ -7,7 +7,7 @@
         xmlns:syncfusion="http://schemas.syncfusion.com/wpf"
         xmlns:wpf="clr-namespace:InABox.Wpf"
         mc:Ignorable="d"
-        Title="AddressEditorMap" Height="800" Width="1200">
+        Title="AddressEditorMap" WindowStartupLocation="CenterScreen" Height="800" Width="1200">
     <Grid>
         
         <Grid.ColumnDefinitions>
@@ -29,31 +29,32 @@
             Grid.Column="0"
             MouseUp="Map_OnMouseUp">
             
-            <syncfusion:SfMap.Layers>
-                <local:GoogleImageryLayer
-                    x:Name="ImageryLayer"
-                    Center="-31.95105, 115.85939"
-                    Radius="10"
-                    >
-                    <syncfusion:ImageryLayer.SubShapeFileLayers>
-                        <syncfusion:SubShapeFileLayer x:Name="subLayer">
-                            <syncfusion:SubShapeFileLayer.MapElements>
-
-                                <syncfusion:MapPolygon 
-                                    x:Name="Polygon" 
-                                    Stroke="Firebrick" 
-                                    StrokeThickness="0.75">
-                                    <syncfusion:MapPolygon.Fill>
-                                        <SolidColorBrush Color="Salmon" Opacity="0.5"/>     
-                                    </syncfusion:MapPolygon.Fill>
-                                </syncfusion:MapPolygon>
-                                
-                            </syncfusion:SubShapeFileLayer.MapElements>
-                        </syncfusion:SubShapeFileLayer>
-                    </syncfusion:ImageryLayer.SubShapeFileLayers>
-                    
-                </local:GoogleImageryLayer>
-            </syncfusion:SfMap.Layers>
+            <!-- <syncfusion:SfMap.Layers> -->
+            <!--     <local:GoogleImageryLayer -->
+            <!--         Type="Satellite" -->
+            <!--         x:Name="ImageryLayer" -->
+            <!--         Center="-31.95105, 115.85939" -->
+            <!--         Radius="10" -->
+            <!--         > -->
+            <!--         <syncfusion:ImageryLayer.SubShapeFileLayers> -->
+            <!--             <syncfusion:SubShapeFileLayer x:Name="subLayer"> -->
+            <!--                 <syncfusion:SubShapeFileLayer.MapElements> -->
+            <!-- -->
+            <!--                     <syncfusion:MapPolygon  -->
+            <!--                         x:Name="Polygon"  -->
+            <!--                         Stroke="Firebrick"  -->
+            <!--                         StrokeThickness="0.75"> -->
+            <!--                         <syncfusion:MapPolygon.Fill> -->
+            <!--                             <SolidColorBrush Color="Salmon" Opacity="0.5"/>      -->
+            <!--                         </syncfusion:MapPolygon.Fill> -->
+            <!--                     </syncfusion:MapPolygon> -->
+            <!--                      -->
+            <!--                 </syncfusion:SubShapeFileLayer.MapElements> -->
+            <!--             </syncfusion:SubShapeFileLayer> -->
+            <!--         </syncfusion:ImageryLayer.SubShapeFileLayers> -->
+            <!--          -->
+            <!--     </local:GoogleImageryLayer> -->
+            <!-- </syncfusion:SfMap.Layers> -->
         </syncfusion:SfMap>
         
         <Border
@@ -143,6 +144,16 @@
                 <Image Source="../../../Resources/zoomin.png" />
             </Button>
             
+            <Button 
+                x:Name="SetCoordinates" 
+                Height="50" 
+                Width="50"
+                Padding="5"
+                Margin="0,0,5,0"
+                DockPanel.Dock="Left"
+                Click="SetCoordinates_OnClick">
+                <Image Source="../../../Resources/target.png" />
+            </Button>
             
             <Button 
                 x:Name="SetGeometry" 
@@ -163,7 +174,7 @@
                 Margin="0,0,5,0"
                 DockPanel.Dock="Left"
                 Click="SetRadius_OnClick">
-                <Image Source="../../../Resources/circle.png" />
+                <Image Source="../../../Resources/square.png" />
             </Button>
             
             <syncfusion:SfRangeSlider 

+ 223 - 62
inabox.wpf/DynamicGrid/Editors/AddressEditor/GeofenceEditor.xaml.cs

@@ -1,32 +1,32 @@
 using System;
 using System.Collections.ObjectModel;
 using System.Linq;
+using System.Net.Http;
 using System.Threading.Tasks;
 using System.Windows;
 using System.Windows.Input;
+using System.Windows.Media;
 using Geocoding;
 using Geocoding.Google;
+using InABox.Clients;
 using InABox.Core;
+using Nominatim.API.Geocoders;
+using Nominatim.API.Models;
+using Nominatim.API.Web;
 using Syncfusion.UI.Xaml.Maps;
 using Address = InABox.Core.Address;
 using Point = System.Windows.Point;
 
 namespace InABox.Wpf.DynamicGrid;
 
-public class GoogleImageryLayer : ImageryLayer
-{
-    protected override string GetUri(int X, int Y, int Scale)
-    {
-        var link = $"https://mt1.google.com/vt/lyrs=m@221097413,3&x={X}&y={Y}&z={Scale}";
-        return link;
-    }
-}
-
 public partial class GeofenceEditor : Window
 {
     public Address Address { get; private set; }
     
     GeoFenceDefinition _definition = null;
+
+    private ImageryLayer ImageryLayer;
+    public MapPolygon Polygon;
     
     public GeofenceEditor(Address address, bool canEdit)
     {
@@ -34,6 +34,22 @@ public partial class GeofenceEditor : Window
         _definition = Serialization.Deserialize<GeoFenceDefinition>(address.Geofence) ?? new GeoFenceDefinition();
         
         InitializeComponent();
+
+        ImageryLayer = string.IsNullOrWhiteSpace(CoreUtils.GoogleAPIKey)
+            ? new ImageryLayer()
+            : new GoogleImageryLayer() { Type = GoogleImageryLayerType.Satellite };
+        ImageryLayer.Radius = 10;
+        
+        Map.Layers.Add(ImageryLayer);
+
+        Polygon = new MapPolygon()
+        {
+            Fill = new SolidColorBrush(Colors.LightSalmon) { Opacity = 0.5 },
+            Stroke = new SolidColorBrush(Colors.Firebrick),
+            StrokeThickness = 0.75,
+        };
+        var layer = new SubShapeFileLayer() { MapElements = new ObservableCollection<MapElement>([Polygon]) }; 
+        ImageryLayer.SubShapeFileLayers = new ObservableCollection<SubShapeFileLayer>([layer]);
         
         SetGeometry.Visibility = canEdit ? Visibility.Visible : Visibility.Collapsed;
         SetRadius.Visibility = canEdit ? Visibility.Visible : Visibility.Collapsed;
@@ -44,23 +60,136 @@ public partial class GeofenceEditor : Window
         State.Text = address.State;
         PostCode.Text = Address.PostCode;
 
+        Title = $"Geofence Editor ({(string.IsNullOrWhiteSpace(CoreUtils.GoogleAPIKey) ? "OSM" : "Google")})";
+
         if (canEdit && string.IsNullOrWhiteSpace(Address.Geofence))
             Task.Run(CheckAddress);
         else
             SetupMap();
         
     }
+    
+    private class HttpClientFactory : IHttpClientFactory
+    {
+        private static HttpClient? _client;
+        public HttpClient CreateClient(string name)
+        {
+                
+            _client ??= new HttpClient();
+            return _client;
+        }
+    }
+    
+    private static HttpClientFactory? _httpClientFactory = null;
 
     private async Task CheckAddress()
     {
-        IGeocoder geocoder = new GoogleGeocoder(CoreUtils.GoogleAPIKey);
-        var matches = await geocoder.GeocodeAsync($"{Address.Street}, {Address.City} {Address.State} {Address.PostCode} Australia");
-        var match = matches.FirstOrDefault();
+        if (!string.IsNullOrWhiteSpace(CoreUtils.GoogleAPIKey))
+        {
+        
+            IGeocoder geocoder = new GoogleGeocoder(CoreUtils.GoogleAPIKey);
+            try
+            {
+                var matches =
+                    await geocoder.GeocodeAsync(
+                        $"{Address.Street}, {Address.City} {Address.State} {Address.PostCode} Australia");
+                var match = matches.FirstOrDefault();
+                if (match != null)
+                {
+                    Address.Location.Longitude = match.Coordinates.Longitude;
+                    Address.Location.Latitude = match.Coordinates.Latitude;
+        
+                    if (match is GoogleAddress gAdd)
+                    {
+                        _definition.Coordinates.Clear();
+                        _definition.Coordinates.Add(new GeoPoint(gAdd.Bounds.NorthEast.Latitude,
+                            gAdd.Bounds.NorthEast.Longitude));
+                        _definition.Coordinates.Add(new GeoPoint(gAdd.Bounds.NorthEast.Latitude,
+                            gAdd.Bounds.SouthWest.Longitude));
+                        _definition.Coordinates.Add(new GeoPoint(gAdd.Bounds.SouthWest.Latitude,
+                            gAdd.Bounds.SouthWest.Longitude));
+                        _definition.Coordinates.Add(new GeoPoint(gAdd.Bounds.SouthWest.Latitude,
+                            gAdd.Bounds.NorthEast.Longitude));
+                        _definition.Coordinates.Add(new GeoPoint(gAdd.Bounds.NorthEast.Latitude,
+                            gAdd.Bounds.NorthEast.Longitude));
+                    }
+                    else
+                        SquareFence(20.0);
+        
+                    Address.Geofence = Serialization.Serialize(_definition);
+                }
+                else
+                {
+                    Address.Location.Longitude = 0.0;
+                    Address.Location.Latitude = 0.0;
+                    Address.Geofence = string.Empty;
+                }
+            }
+            catch (Exception e)
+            {
+                Logger.Send(LogType.Error, ClientFactory.UserID, $"{e.Message}\n{e.StackTrace}");
+            }
+        }
+        else
+        {
+            _httpClientFactory ??= new HttpClientFactory();
+            NominatimWebInterface intf = new NominatimWebInterface(_httpClientFactory);
+            
+            var searcher = new ForwardGeocoder(intf);
+
+            var request = new ForwardGeocodeRequest()
+            {
+                StreetAddress = Address.Street,
+                City = Address.City,
+                // County="",
+                State = Address.State,
+                PostalCode = Address.PostCode,
+                Country = "AU",
+                //queryString = $"{Address.Street}, {Address.City}, {Address.State}, {Address.PostCode} Australia",
+                //BreakdownAddressElements = true,
+                ShowExtraTags = true,
+                ShowAlternativeNames = true,
+                ShowGeoJSON = true,
+            };
+
+            try
+            {
+                var matches = await searcher.Geocode(request);
+                var match = matches.FirstOrDefault();
+                if (match != null)
+                {
+                    Address.Location.Longitude = match.Longitude;
+                    Address.Location.Latitude = match.Latitude;
+                    if (match.BoundingBox.HasValue)
+                    {
+                        var bbox = match.BoundingBox.Value;
+                        _definition.Coordinates.Clear();
+                        _definition.Coordinates.Add(new GeoPoint(bbox.minLatitude, bbox.minLongitude));
+                        _definition.Coordinates.Add(new GeoPoint(bbox.minLatitude, bbox.maxLongitude));
+                        _definition.Coordinates.Add(new GeoPoint(bbox.maxLatitude, bbox.maxLongitude));
+                        _definition.Coordinates.Add(new GeoPoint(bbox.maxLatitude, bbox.minLongitude));
+                        _definition.Coordinates.Add(new GeoPoint(bbox.minLatitude, bbox.minLongitude));
+                    }
+                    else
+                        SquareFence(20.0);
+
+                    Address.Geofence = Serialization.Serialize(_definition);
+                }
+                else
+                {
+                    Address.Location.Longitude = 0.0;
+                    Address.Location.Latitude = 0.0;
+                    Address.Geofence = string.Empty;
+                }
+            }
+        
+            catch (Exception e)
+            {
+                Logger.Send(LogType.Error, ClientFactory.UserID, $"{e.Message}\n{e.StackTrace}");
+            }
+            
+        }
 
-        Address.Location.Longitude = match?.Coordinates.Longitude ?? 0.0;
-        Address.Location.Latitude = match?.Coordinates.Latitude ?? 0.0;
-        SquareFence(20.0);
-        Address.Geofence = Serialization.Serialize(_definition);
 
         Dispatcher.BeginInvoke(SetupMap);
 
@@ -79,13 +208,8 @@ public partial class GeofenceEditor : Window
 
     private void SetupMap()
     {
-        ImageryLayer.Center = new Point(Address.Location.Latitude, Address.Location.Longitude);
-        ImageryLayer.Markers = new CoreObservableCollection<MapMarker>([new MapMarker()
-        {
-            Latitude = $"{Address.Location.Latitude:F15}",
-            Longitude = $"{Address.Location.Longitude:F15}"
-        }]);
-        
+        CenterMap();
+
         RadiusSlider.ValueChanged -= RadiusSliderChanged;
         RadiusSlider.Value = 20.0;
         if (_definition.Coordinates.Count < 4)
@@ -93,7 +217,17 @@ public partial class GeofenceEditor : Window
         RadiusSlider.ValueChanged += RadiusSliderChanged;
         UpdateMap();
     }
-    
+
+    private void CenterMap()
+    {
+        ImageryLayer.Center = new Point(Address.Location.Latitude, Address.Location.Longitude);
+        ImageryLayer.Markers = new CoreObservableCollection<MapMarker>([new MapMarker()
+        {
+            Latitude = $"{Address.Location.Latitude:F15}",
+            Longitude = $"{Address.Location.Longitude:F15}"
+        }]);
+    }
+
     private void RadiusSliderChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
     {
         RecalculateSquareFence();
@@ -126,27 +260,46 @@ public partial class GeofenceEditor : Window
         Map.ZoomLevel = Math.Max(5, Map.ZoomLevel - 1);
         UpdateMap();
     }
-    
-    private bool _polygon = false;
-    private bool _square = false;
+
+    private enum GeoFenceAction
+    {
+        None,
+        Polygon,
+        Square,
+        Coordinates
+    }
+
+    private GeoFenceAction _action = GeoFenceAction.None;
+
+    private void UpdateButtons(GeoFenceAction action)
+    {
+        _action = action;
+        SetGeometry.IsEnabled = action == GeoFenceAction.None || action == GeoFenceAction.Polygon;
+        SetGeometry.Background = action == GeoFenceAction.Polygon
+            ? Brushes.Yellow
+            : Brushes.Silver;
+        SetRadius.IsEnabled = action == GeoFenceAction.None || action == GeoFenceAction.Square;
+        SetRadius.Background = action == GeoFenceAction.Square
+            ? Brushes.Yellow
+            : Brushes.Silver;
+        RadiusSlider.Visibility = action == GeoFenceAction.Square 
+            ? Visibility.Visible 
+            : Visibility.Collapsed;
+        SetCoordinates.IsEnabled = action == GeoFenceAction.None || action == GeoFenceAction.Coordinates;
+        SetCoordinates.Background = action == GeoFenceAction.Coordinates
+            ? Brushes.Yellow
+            : Brushes.Silver;
+    }
     
     private void SetGeometry_OnClick(object sender, RoutedEventArgs e)
     {
-        if (!_polygon)
+        if (_action == GeoFenceAction.Polygon)
+            UpdateButtons(GeoFenceAction.None);
+        else
         {
-            _square = false;
-            SetRadius.Background = System.Windows.Media.Brushes.Silver;
-            RadiusSlider.Visibility = Visibility.Collapsed;
-            
-            _polygon = true;
+            UpdateButtons(GeoFenceAction.Polygon);
             _definition.Coordinates.Clear();
             UpdateMap();
-            SetGeometry.Background = System.Windows.Media.Brushes.Yellow;
-        }
-        else
-        {
-            _polygon = false;
-            SetGeometry.Background = System.Windows.Media.Brushes.Silver;
         }
     }
 
@@ -155,42 +308,42 @@ public partial class GeofenceEditor : Window
         var point = Mouse.GetPosition(Map);
         var latlon = ImageryLayer.GetLatLonFromPoint(point);
         var geopoint = new GeoPoint(latlon.Y, latlon.X);
-        
-        if (!_polygon)
-            return;
-        
-        if (!_definition.Coordinates.Any())
-            _definition.Coordinates.Add(geopoint.Copy());
-        _definition.Coordinates.Insert(_definition.Coordinates.Count - 1, geopoint.Copy());
-        Address.Geofence = Serialization.Serialize(_definition);
-        UpdateMap();
-        e.Handled = true;
+
+        if (_action == GeoFenceAction.Polygon)
+        {
+            if (!_definition.Coordinates.Any())
+                _definition.Coordinates.Add(geopoint.Copy());
+            _definition.Coordinates.Insert(_definition.Coordinates.Count - 1, geopoint.Copy());
+            Address.Geofence = Serialization.Serialize(_definition);
+            UpdateMap();
+            e.Handled = true;
+        }
+        else if (_action == GeoFenceAction.Coordinates)
+        {
+            Address.Location.Latitude = geopoint.Latitude;
+            Address.Location.Longitude = geopoint.Longitude;
+            CenterMap();
+            UpdateMap();
+            e.Handled = true;
+        }
+
     }
 
 
     private void SetRadius_OnClick(object sender, RoutedEventArgs e)
     {
-        if (!_square)
+        if (_action == GeoFenceAction.Square)
+            UpdateButtons(GeoFenceAction.None);
+        else
         {
-            _polygon = false;
-            SetGeometry.Background = System.Windows.Media.Brushes.Silver;
-            
-            _square = true;
+            UpdateButtons(GeoFenceAction.Square);
 
             RadiusSlider.ValueChanged -= RadiusSliderChanged;
             RadiusSlider.Value = 20.0;
             SquareFence(20.0);
             RadiusSlider.ValueChanged += RadiusSliderChanged;
-            RadiusSlider.Visibility = Visibility.Visible;
             RecalculateSquareFence();
             UpdateMap();
-            SetRadius.Background = System.Windows.Media.Brushes.Yellow;
-        }
-        else
-        {
-            _square = false;
-            RadiusSlider.Visibility = Visibility.Collapsed;
-            SetRadius.Background = System.Windows.Media.Brushes.Silver;
         }
     }
 
@@ -198,4 +351,12 @@ public partial class GeofenceEditor : Window
     {
         Task.Run(CheckAddress);
     }
+
+    private void SetCoordinates_OnClick(object sender, RoutedEventArgs e)
+    {
+        if (_action == GeoFenceAction.Coordinates)
+            UpdateButtons(GeoFenceAction.None);
+        else
+            UpdateButtons(GeoFenceAction.Coordinates);
+    }
 }

+ 28 - 0
inabox.wpf/DynamicGrid/Editors/AddressEditor/GoogleImageryLayer.cs

@@ -0,0 +1,28 @@
+using Syncfusion.UI.Xaml.Maps;
+
+namespace InABox.Wpf.DynamicGrid;
+
+public enum GoogleImageryLayerType
+{
+    Road,
+    Satellite,
+}
+
+public class GoogleImageryLayer : ImageryLayer
+{
+    private GoogleImageryLayerType _type;
+
+    protected override string GetUri(int X, int Y, int Scale)
+    {
+        var link = _type == GoogleImageryLayerType.Road
+            ? $"https://mt1.google.com/vt/lyrs=m@221097413,3&x={X}&y={Y}&z={Scale}"
+            : $"https://mt0.google.com/vt/lyrs=y&x={X}&y={Y}&z={Scale}";
+        return link;
+    }
+
+    public GoogleImageryLayerType Type
+    {
+        get => _type;
+        set => _type = value; 
+    }
+}

+ 6 - 0
inabox.wpf/InABox.Wpf.csproj

@@ -140,7 +140,9 @@
         <PackageReference Include="HtmlRenderer.WPF" Version="1.5.0.6" />
         <PackageReference Include="Microsoft.CodeAnalysis.Common" Version="4.9.2" />
         <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.9.2" />
+        <PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
         <PackageReference Include="net.sf.mpxj-ikvm" Version="11.1.0" />
+        <PackageReference Include="Nominatim.API" Version="2.1.0" />
         <PackageReference Include="RoslynPad.Editor.Windows" Version="4.8.0" />
         <PackageReference Include="RoslynPad.Roslyn.Windows" Version="4.8.0" />
         <PackageReference Include="SharpVectors.Wpf" Version="1.8.4.2" />
@@ -463,6 +465,10 @@
       <Resource Include="Resources\map.png">
         <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
       </Resource>
+      <None Remove="Resources\target.png" />
+      <Resource Include="Resources\target.png">
+        <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+      </Resource>
     </ItemGroup>
 
     <ItemGroup>

TEMPAT SAMPAH
inabox.wpf/Resources/target.png