فهرست منبع

Added GeoFence Master List
Added GeoFence and Zoom functions to MapForm
Added PostCode Master List

frankvandenbos 8 ماه پیش
والد
کامیت
834b35e486

+ 146 - 0
prs.classes/Entities/GeoFence/GeoFence.cs

@@ -0,0 +1,146 @@
+using System;
+using System.Collections.Generic;
+using System.Drawing;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using InABox.Core;
+
+namespace Comal.Classes
+{
+    
+
+    public class GeoJSONCrsProperties
+    {
+        public string Name { get; set; }
+    }
+
+    public class GeoJSONCrs
+    {
+        public string Type { get; set; }
+        public GeoJSONCrsProperties Properties { get; set; }
+    }
+
+    public class GeoJSONFeatureGeometry
+    {
+        public string Type { get; set; }
+        public List<List<List<List<float>>>> Coordinates { get; set; }
+
+        public List<PointF> Points()
+        {
+            var result = new List<PointF>();
+            var list = Coordinates?.FirstOrDefault()?.FirstOrDefault() ?? new List<List<float>>();
+            foreach (var item in list)
+            {
+                if (item.Count == 2)
+                    result.Add(new PointF(item[0], item[1]));
+            }
+            return result;
+        }
+        
+        public Tuple<PointF,PointF> Bounds()
+        {
+            float minX = float.MaxValue, minY = float.MaxValue, maxX = float.MinValue, maxY = float.MinValue;
+            var points = Points();
+            foreach (var point in points)
+            {
+                if (point.X < minX) minX = point.X;
+                if (point.X > maxX) maxX = point.X;
+                if (point.Y < minY) minY = point.Y;
+                if (point.Y > maxY) maxY = point.Y;
+            }
+            return new Tuple<PointF,PointF>(new PointF(minX, minY), new PointF(maxX, maxY));
+        }
+    }
+
+    public class GeoJSONFeatureProperties
+    {
+        public int? Land_ID { get; set; }
+        public string? Road_Number_type { get; set; }
+        public string? Road_Number_1 { get; set; }
+        public string? Road_Number_2 { get; set; }
+        public string? Lot_Number { get; set; }
+        public string? Road_Name { get; set; }
+        public string? Road_Type { get; set; }
+        public string? Road_Suffix { get; set; }
+        public string? Locality { get; set; }
+        public string? View_Scale { get; set; }
+        public double? ST_Area_Shape_ { get; set; }
+        public double? ST_Perimeter_Shape_ { get; set; }
+
+        public string ID() => Land_ID?.ToString() ?? "";
+        
+        public string FullAddress() => $"{Street()} {Suburb()} {State()} {Country()}".Trim().ToUpper();
+        
+        public String Street()
+        {
+            List<String> result = new List<string>();
+            if (string.IsNullOrWhiteSpace(Lot_Number))
+            {
+                if (!string.IsNullOrWhiteSpace(Road_Number_2))
+                    result.Add($"{Road_Number_1 ?? ""}/{Road_Number_2 ?? ""}");
+                else
+                    result.Add(Road_Number_1 ?? "");
+            }
+            else
+                result.Add($"LOT {Lot_Number ?? ""}");
+            result.Add(Road_Name ?? "");
+            result.Add(Road_Type ?? "");
+            
+            return CultureInfo.CurrentCulture.TextInfo.ToTitleCase(string.Join(" ", result).ToLower());
+        }
+
+        public String Suburb() => CultureInfo.CurrentCulture.TextInfo.ToTitleCase(Locality?.ToLower() ?? "");
+        
+        public String State() => "";
+        
+        public String Postcode() => "";
+
+        public String Country() => "";
+
+    }
+
+    public class GeoJSONFeature
+    {
+        public string? Type { get; set; }
+
+        public GeoJSONFeatureProperties? Properties { get; set; }
+        
+        public GeoJSONFeatureGeometry? Geometry { get; set; }
+        
+    }
+
+    public class GeoJSONFile
+    {
+        public string? Type { get; set; }
+        public string? Name { get; set; }
+        public GeoJSONCrs? Crs { get; set; }
+        public List<GeoJSONFeature>? Features { get; set; }
+
+        public static GeoJSONFile? Load(string filename)
+        {
+            var fs = new FileStream(filename, FileMode.Open);
+            var geojson = Serialization.Deserialize<GeoJSONFile>(fs);
+            return geojson;
+        }
+    }
+    
+    public class GeoFence : Entity, IRemotable, IPersistent
+    {
+        public String Street { get; set; }
+        public String City { get; set; }
+        public String State { get; set; }
+        public String Country { get; set; }
+        public String PostCode { get; set; }
+
+        public string FullAddress { get; set; }
+        
+        public double MinX { get; set; }
+        public double MinY { get; set; }
+        public double MaxX { get; set; }
+        public double MaxY { get; set; }
+        
+        public string Geometry { get; set; }
+    }
+    
+}

+ 10 - 1
prs.desktop/Forms/MapForm.xaml

@@ -5,10 +5,19 @@
         xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
 		xmlns:wpf="clr-namespace:InABox.Wpf;assembly=InABox.Wpf"
         mc:Ignorable="d"
-        Title="MapForm" Height="640" Width="640" WindowStyle="None" WindowStartupLocation="CenterScreen">
+        Title="MapForm" WindowStyle="None" WindowStartupLocation="CenterScreen" SizeToContent="WidthAndHeight" MaxHeight="1000" MaxWidth="1000">
     <Grid>
         <Label x:Name="TimeStamp" Panel.ZIndex="1000" VerticalAlignment="Top" HorizontalAlignment="Center"
                FontSize="24" />
         <Image x:Name="staticmap" Margin="0,0,0,0" MouseUp="staticmap_MouseUp" />
+        <StackPanel VerticalAlignment="Bottom" HorizontalAlignment="Right" Margin="0,0,10,10" Orientation="Horizontal" >
+            <Button x:Name="ZoomIn" Height="40" Width="40" Click="ZoomIn_OnClick">
+                <Image Source="../Resources/zoomin.png" />
+            </Button>
+            <Button x:Name="ZoomOut" Height="40" Width="40" Margin="5,0,0,0" Click="ZoomOut_OnClick">
+                <Image Source="../Resources/zoomout.png" />
+            </Button>
+        </StackPanel>
+        
     </Grid>
 </wpf:ThemableWindow>

+ 54 - 7
prs.desktop/Forms/MapForm.xaml.cs

@@ -1,13 +1,21 @@
 using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Drawing;
+using System.Linq;
 using System.Windows;
 using System.Windows.Input;
 using System.Windows.Media.Imaging;
 using Google.Maps;
 using Google.Maps.StaticMaps;
+using InABox.Core;
 using InABox.Wpf;
+using Syncfusion.Linq;
+using Location = Google.Maps.Location;
 
 namespace PRSDesktop.Forms
 {
+	
 	/// <summary>
 	/// Interaction logic for MapForm.xaml
 	/// </summary>
@@ -17,6 +25,8 @@ namespace PRSDesktop.Forms
 		StaticMapRequest request = new StaticMapRequest();
 		StaticMapService service = new StaticMapService();
 
+		private double _zoom = 18.0;
+		
 		public BitmapImage ToImage(byte[] array)
 		{
 			using (var ms = new System.IO.MemoryStream(array))
@@ -30,7 +40,7 @@ namespace PRSDesktop.Forms
 			}
 		}
 
-		public MapForm(double latitude, double longitude, DateTime timestamp)
+		public MapForm(double latitude, double longitude, DateTime timestamp, string? geometry = null)
 		{
 			InitializeComponent();
 
@@ -42,25 +52,62 @@ namespace PRSDesktop.Forms
 			request.Center = location;
 			request.Markers.Add(location);
 			request.Scale = 2;
-			request.Size = new MapSize(640, 640); // Convert.ToInt32(ActualWidth)-20, Convert.ToInt32(ActualHeight)-20);
-			request.Zoom = 15;
+			request.Size = new MapSize(640,640); // Convert.ToInt32(ActualWidth)-20, Convert.ToInt32(ActualHeight)-20);
+			
+			request.Format = GMapsImageFormats.JPG;
+			if (!string.IsNullOrWhiteSpace(geometry))
+			{
+				var perims = Serialization.Deserialize<Dictionary<string, List<PointF>>>(geometry) ?? new Dictionary<string, List<PointF>>();
+				var paths = new List<Path>();
+				foreach (var perim in perims)
+				{
+					var path = new Path()
+					{
+						Points = new Collection<Location>(perim.Value.Select(x => new Location($"{x.Y},{x.X}")).ToList()),
+						Color = MapColor.FromArgb(255, 255, 0, 0),
+						FillColor = MapColor.FromArgb(64, 255, 0, 0),
+						Weight = 1
+
+					};
+					paths.Add(path);
+				}
+				request.Paths = paths;
+			}
 
 			service = new StaticMapService();
 
+			GetImage();
+
+			TimeStamp.Content = String.Format("Last Updated {0:dd MMM yyy hh:mm:ss tt}", timestamp);
+
+		}
+
+		private void GetImage()
+		{
+			request.Zoom = (int)Math.Round(_zoom, 0);
 			var imageSource = new BitmapImage();
 			imageSource.BeginInit();
 			imageSource.StreamSource = service.GetStream(request);
 			imageSource.CacheOption = BitmapCacheOption.OnLoad;
 			imageSource.EndInit();
 			staticmap.Source = imageSource;
-
-            TimeStamp.Content = String.Format("Last Updated {0:dd MMM yyy hh:mm:ss tt}", timestamp);
-
 		}
 
 		private void staticmap_MouseUp(object sender, MouseButtonEventArgs e)
 		{
 			Close();
 		}
-	}
+
+		private void ZoomOut_OnClick(object sender, RoutedEventArgs e)
+		{
+			_zoom -= 1.0;
+			GetImage();
+		}
+
+		private void ZoomIn_OnClick(object sender, RoutedEventArgs e)
+		{
+			_zoom += 1.0;
+			GetImage();
+		}
+    }
 }

+ 133 - 0
prs.desktop/Grids/GeoFenceGrid.cs

@@ -0,0 +1,133 @@
+using System;
+using System.Collections.Generic;
+using System.Drawing;
+using System.Linq;
+using System.Windows.Controls;
+using System.Windows.Media.Imaging;
+using Comal.Classes;
+using Google.Maps;
+using InABox.Clients;
+using InABox.Core;
+using InABox.DynamicGrid;
+using InABox.WPF;
+using Microsoft.Win32;
+using PRSDesktop.Forms;
+using Path = System.IO.Path;
+
+namespace PRSDesktop;
+
+public class GeoFenceGrid : DynamicDataGrid<GeoFence>
+{
+    private static BitmapImage MAP = PRSDesktop.Resources.map.AsBitmapImage();
+    
+    protected override void Init()
+    {
+        base.Init();
+        HiddenColumns.Add(x=>x.LastUpdate);
+        HiddenColumns.Add(x=>x.MinX);
+        HiddenColumns.Add(x=>x.MaxX);
+        HiddenColumns.Add(x=>x.MinY);
+        HiddenColumns.Add(x=>x.MaxY);
+        HiddenColumns.Add(x=>x.Geometry);
+        AddButton("Import", PRSDesktop.Resources.mapmarker.AsBitmapImage(), ImportGeoJSON);
+        ActionColumns.Add(new DynamicImageColumn((r) => MAP, MapClick));
+        
+    }
+
+    protected override void DoReconfigure(DynamicGridOptions options)
+    {
+        base.DoReconfigure(options);
+        options.PageSize = 1000;
+        options.RecordCount = true;
+    }
+
+    private bool MapClick(CoreRow? row)
+    {
+        if (row == null) 
+            return false;
+        var longitude = (row.Get<GeoFence,double>(x=>x.MinX) + row.Get<GeoFence,double>(x=>x.MaxX)) / 2.0;
+        var latitude = (row.Get<GeoFence,double>(x=>x.MinY) + row.Get<GeoFence,double>(x=>x.MaxY)) / 2.0;
+        var timestamp = row.Get<GeoFence, DateTime>(x=>x.LastUpdate);
+        var geometry = row.Get<GeoFence, string>(x=>x.Geometry);
+        var form = new MapForm(latitude, longitude, timestamp, geometry);
+        form.ShowDialog();
+        return false;
+    }
+
+
+    private bool ImportGeoJSON(Button button, CoreRow[] rows)
+    {
+        OpenFileDialog ofd = new();
+        ofd.Filter = "GeoJSON Files (*.geojson)|*.geojson";
+        if (ofd.ShowDialog() == true)
+        {
+            Progress.ShowModal("Loading File", progress =>
+            {
+                var geojson =  GeoJSONFile.Load(ofd.FileName);
+                if (geojson != null)
+                {
+                    var queue = geojson.Features?.ToQueue() ?? new Queue<GeoJSONFeature>();
+                    int fTotal = queue.Count;
+                    while (queue.Count > 0)
+                    {
+                        List<GeoFence> updates = new();
+                        var chunk = queue.Dequeue(1000).ToArray();
+                        progress.Report($"Processing {((fTotal - queue.Count)*100.0F/fTotal):F2}% complete)");
+                        
+                        var fulladdresses = chunk.Select(x=>x.Properties?.FullAddress() ?? string.Empty).Distinct().ToArray();
+                        var existing = Client.Query(new Filter<GeoFence>(x => x.FullAddress).InList(fulladdresses))
+                            .ToObjects<GeoFence>().ToList();
+                        
+                        foreach (var record in chunk)
+                        {
+                            if (record.Properties == null || record.Geometry == null)
+                                continue;
+                            
+                            string id = record.Properties.ID();
+                            string fulladdress = record.Properties.FullAddress();
+                            
+                            
+                            if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(fulladdress)) 
+                                continue;
+                            
+                            var update = existing.FirstOrDefault(x => string.Equals(x.FullAddress, fulladdress));
+                            if (update == null)
+                            {
+                                update = new GeoFence();
+                                update.FullAddress = fulladdress;
+                                existing.Add(update);
+                            }
+                            
+                            update.Street = record.Properties.Street();
+                            update.City = record.Properties.Suburb();
+                            update.State = record.Properties.State();
+                            update.Country = record.Properties.Country();
+                            update.PostCode = record.Properties.Postcode();
+                            
+                            var bounds = record.Geometry.Bounds();
+                            update.MinX = bounds.Item1.X;
+                            update.MinY = bounds.Item1.Y;
+                            update.MaxX = bounds.Item2.X;
+                            update.MaxY = bounds.Item2.Y;
+                            
+                            var geometry =
+                                Serialization.Deserialize<Dictionary<string, List<PointF>>>(update.Geometry) ?? new Dictionary<string, List<PointF>>();
+                            geometry[id] = record.Geometry.Points();
+                            update.Geometry = Serialization.Serialize(geometry);
+                            
+                            if (!updates.Contains(update) && (update.ID == Guid.Empty || update.IsChanged()))
+                                updates.Add(update);
+                        }
+                        if (updates.Any())
+                            Client.Save(updates, $"Loaded from {Path.GetFileName(ofd.FileName)}");
+                    }
+                    
+                }
+            });
+            
+        }
+
+        return true;
+    }
+    
+}

+ 6 - 0
prs.desktop/Panels/Equipment/EquipmentPanel.xaml.cs

@@ -125,6 +125,12 @@ namespace PRSDesktop
             host.CreateSetupSeparator();
 
             host.CreateSetupAction(new PanelAction() { Caption = "Equipment Settings", Image = PRSDesktop.Resources.specifications, OnExecute = EquipmentSettingsClick });
+            host.CreateSetupAction(new PanelAction() { Caption = "EditGeofences", Image = PRSDesktop.Resources.map, OnExecute = EditGeofencesClick});
+        }
+
+        private void EditGeofencesClick(PanelAction obj)
+        {
+            new MasterList(typeof(GeoFence)).ShowDialog();
         }
 
         private void EquipmentSettingsClick(PanelAction obj)

+ 13 - 0
prs.desktop/Panels/PostCodes/CountryGrid.cs

@@ -0,0 +1,13 @@
+using InABox.Core;
+using InABox.DynamicGrid;
+
+namespace PRSDesktop;
+
+public class CountryGrid : DynamicDataGrid<Country>
+{
+    protected override void DoReconfigure(DynamicGridOptions options)
+    {
+        base.DoReconfigure(options);
+        options.MultiSelect = true;
+    }
+}

+ 31 - 0
prs.desktop/Panels/PostCodes/LocalityGrid.cs

@@ -0,0 +1,31 @@
+using System;
+using System.Threading;
+using InABox.Core;
+using InABox.DynamicGrid;
+using NPOI.SS.Formula;
+
+namespace PRSDesktop;
+
+public class LocalityGrid : DynamicDataGrid<Locality>
+{
+    public Guid StateId { get; set; }
+
+    protected override void DoReconfigure(DynamicGridOptions options)
+    {
+        base.DoReconfigure(options);
+        options.MultiSelect = true;
+    }
+
+    protected override void Reload(Filters<Locality> criteria, Columns<Locality> columns, ref SortOrder<Locality>? sort, CancellationToken token, Action<CoreTable?, Exception?> action)
+    {
+        criteria.Add(new Filter<Locality>(x => x.State.ID).IsEqualTo(StateId));
+        base.Reload(criteria, columns, ref sort, token, action);
+    }
+    
+    public override Locality CreateItem()
+    {
+        var result = base.CreateItem();
+        result.State.ID = StateId;
+        return result;
+    }
+}

+ 179 - 0
prs.desktop/Panels/PostCodes/LocalityTree.cs

@@ -0,0 +1,179 @@
+using System;
+using System.Linq;
+using System.Threading;
+using System.Windows;
+using System.Windows.Controls;
+using InABox.Clients;
+using InABox.Core;
+using InABox.DynamicGrid;
+using InABox.Wpf;
+using InABox.WPF;
+using Microsoft.Win32;
+using Microsoft.Xaml.Behaviors.Core;
+using ContextMenu = Fluent.ContextMenu;
+
+namespace PRSDesktop;
+
+public class LocalityTree : DynamicDataGrid<LocalitySummary>
+{
+    protected override void Init()
+    {
+        base.Init();
+        HiddenColumns.Add(x=>x.Type);
+        ContextMenu = new ContextMenu();
+        ContextMenu.Items.Add(new Separator());
+        AddButton("Import", PRSDesktop.Resources.download.AsBitmapImage(), ImportFile);
+    }
+
+    
+    protected override void DoReconfigure(DynamicGridOptions options)
+    {
+        base.DoReconfigure(options);
+        options.AddRows = true;
+        options.EditRows = true;
+        options.DeleteRows = true;
+        options.FilterRows = true;
+    }
+    
+    protected override IDynamicGridUIComponent<LocalitySummary> CreateUIComponent()
+    {
+        return new DynamicGridTreeUIComponent<LocalitySummary, Guid>(x => x.ID, x => x.ParentID, Guid.Empty) { Parent = this};
+    }
+    
+    #region Editing
+    
+    private void CreateCountry()
+    {
+        var country = new Country();
+        Edit(country);
+    }
+    
+    private void CreateState(Guid country)
+    {
+        var state = new State();
+        state.Country.ID = country;
+        Edit(state);
+    }
+
+    private void CreateLocality(Guid state)
+    {
+        var locality = new Locality();
+        locality.State.ID = state;
+        Edit(locality);
+    }
+    
+    private void Edit<T>(T item) where T : Entity, IRemotable, IPersistent, new()
+    {
+        var result = new DynamicDataGrid<T>().EditItems([item]);
+        if (result)
+            Refresh(false,true);
+    }
+    
+    private void Delete<T>(Guid id) where T : Entity, IRemotable, IPersistent, new()
+    {
+        if (MessageWindow.ShowYesNo($"Are you sure you wish to delete this {typeof(T).EntityName().Split('.').Last()}?", "Confirm Delete"))
+        {
+            Progress.ShowModal("Deleteing Items", progress =>
+            {
+                var item = new T() { ID = id };
+                Client.Delete([item], "Deleted from Post Codes Setup Screen");
+            });
+            Refresh(false, true);
+        }
+    }
+    
+    protected override void DoAdd(bool openEditorOnDirectEdit = false)
+    {
+        var row = SelectedRows.FirstOrDefault();
+        if (row == null)
+            CreateCountry();
+        else if (row.Get<LocalitySummary, LocalityType>(x => x.Type) == LocalityType.Country)
+        {
+            var menu = new ContextMenu();
+            menu.Items.Add(new MenuItem()
+            {
+                Header = "Create State",
+                Command = new ActionCommand(() => CreateState(row.Get<LocalitySummary, Guid>(x => x.ID)))
+            });
+            menu.Items.Add(new Separator());
+            menu.Items.Add(new MenuItem()
+            {
+                Header = "Create Country",
+                Command = new ActionCommand(CreateCountry)
+            });
+            menu.IsOpen = true;
+            
+        }
+        else if (row.Get<LocalitySummary, LocalityType>(x => x.Type) == LocalityType.State)
+        {
+            var menu = new ContextMenu();
+            menu.Items.Add(new MenuItem()
+            {
+                Header = "Create Locality",
+                Command = new ActionCommand(() => CreateLocality(row.Get<LocalitySummary, Guid>(x => x.ID)))
+            });
+            menu.Items.Add(new Separator());
+            menu.Items.Add(new MenuItem()
+            {
+                Header = "Create State",
+                Command = new ActionCommand(() => CreateState(row.Get<LocalitySummary, Guid>(x => x.ParentID)))
+            });
+            menu.Items.Add(new MenuItem()
+            {
+                Header = "Create Country",
+                Command = new ActionCommand(CreateCountry)
+            });
+            menu.IsOpen = true;
+        }
+        else
+            CreateLocality(row.Get<LocalitySummary, Guid>(x => x.ParentID));
+    }
+
+   
+
+    protected override void DoEdit()
+    {
+        var row = SelectedRows.FirstOrDefault();
+        if (row == null)
+            return;
+        if (row.Get<LocalitySummary, LocalityType>(x => x.Type) == LocalityType.Country)
+            Edit(row.ToObject<Country>());
+        else if (row.Get<LocalitySummary, LocalityType>(x => x.Type) == LocalityType.State)
+        {
+            var state = row.ToObject<State>();
+            state.Country.ID = row.Get<LocalitySummary, Guid>(x => x.ParentID);
+            state.CommitChanges();
+            Edit(state);
+        }
+        else
+        {
+            var locality = row.ToObject<Locality>();
+            locality.State.ID = row.Get<LocalitySummary, Guid>(x => x.ParentID);
+            locality.CommitChanges();
+            Edit(locality);
+        }
+    }
+
+    protected override void DoDelete()
+    {
+        var row = SelectedRows.FirstOrDefault();
+        if (row == null)
+            return;
+        if (row.Get<LocalitySummary, LocalityType>(x => x.Type) == LocalityType.Country)
+            Delete<Country>(row.Get<LocalitySummary, Guid>(x => x.ID));
+        else if (row.Get<LocalitySummary, LocalityType>(x => x.Type) == LocalityType.State)
+            Delete<State>(row.Get<LocalitySummary, Guid>(x => x.ID));
+        else
+            Delete<Locality>(row.Get<LocalitySummary, Guid>(x => x.ID));
+    }
+    
+    private bool ImportFile(Button button, CoreRow[] rows)
+    {
+        var grid = new DynamicDataGrid<Locality>();
+        grid.Import();
+        return true;
+    }
+
+    #endregion
+    
+}

+ 58 - 0
prs.desktop/Panels/PostCodes/PostCodePanel.xaml

@@ -0,0 +1,58 @@
+<UserControl x:Class="PRSDesktop.PostCodePanel"
+             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:local="clr-namespace:PRSDesktop"
+             xmlns:dynamicGrid="clr-namespace:InABox.DynamicGrid;assembly=InABox.Wpf"
+             mc:Ignorable="d"
+             d:DesignHeight="300" d:DesignWidth="1000">
+    <Grid>
+        <Grid.ColumnDefinitions>
+            <ColumnDefinition Width="400"/>
+            <ColumnDefinition Width="*"/>
+        </Grid.ColumnDefinitions>
+        
+        <local:LocalityTree
+            x:Name="LocalityTree"/>
+        
+        <dynamicGrid:DynamicSplitPanel
+            Grid.Column="1"
+            Margin="5,0,0,0"
+            AllowableViews="Combined"
+            View="Combined"
+            Anchor="Master"
+            AnchorWidth="400">
+            
+            <dynamicGrid:DynamicSplitPanel.Master>
+                <local:CountryGrid
+                    x:Name="CountryGrid"
+                    OnSelectItem="CountryGrid_OnOnSelectItem"/>    
+            </dynamicGrid:DynamicSplitPanel.Master>
+            
+            <dynamicGrid:DynamicSplitPanel.Detail>
+                
+                <dynamicGrid:DynamicSplitPanel
+                    AllowableViews="Combined"
+                    View="Combined"
+                    Anchor="Master"
+                    AnchorWidth="400">
+                    
+                    <dynamicGrid:DynamicSplitPanel.Master>
+                        <local:StateGrid 
+                            x:Name="StateGrid"
+                            OnSelectItem="StateGrid_OnOnSelectItem"/>
+                    </dynamicGrid:DynamicSplitPanel.Master>
+                    
+                    <dynamicGrid:DynamicSplitPanel.Detail>
+                        <local:LocalityGrid
+                            x:Name="LocalityGrid"/>
+                    </dynamicGrid:DynamicSplitPanel.Detail>
+                    
+                </dynamicGrid:DynamicSplitPanel>
+                
+            </dynamicGrid:DynamicSplitPanel.Detail>
+            
+        </dynamicGrid:DynamicSplitPanel>
+    </Grid>
+</UserControl>

+ 82 - 0
prs.desktop/Panels/PostCodes/PostCodePanel.xaml.cs

@@ -0,0 +1,82 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Linq;
+using System.Windows.Controls;
+using InABox.Core;
+using InABox.DynamicGrid;
+using InABox.Wpf;
+
+namespace PRSDesktop;
+
+public partial class PostCodePanel : UserControl, IPanel<Country>
+{
+    public PostCodePanel()
+    {
+        InitializeComponent();
+    }
+
+    private void CountryGrid_OnOnSelectItem(object sender, DynamicGridSelectionEventArgs e)
+    {
+        StateGrid.CountryId = CountryGrid.SelectedRows.FirstOrDefault()?.Get<Country, Guid>(x => x.ID) ?? Guid.Empty;
+        StateGrid.Refresh(false,true);
+        
+        LocalityGrid.StateId = Guid.Empty;
+        LocalityGrid.Refresh(false,true);
+    }
+    
+    private void StateGrid_OnOnSelectItem(object sender, DynamicGridSelectionEventArgs e)
+    {
+       LocalityGrid.StateId = StateGrid.SelectedRows.FirstOrDefault()?.Get<State, Guid>(x => x.ID) ?? Guid.Empty;
+       LocalityGrid.Refresh(false,true);
+    }
+
+
+    public void Setup()
+    {
+        LocalityTree.Refresh(true,false);
+        CountryGrid.Refresh(true,false);
+        StateGrid.Refresh(true,false);
+        LocalityGrid.Refresh(true,false);
+    }
+
+    public void Shutdown(CancelEventArgs? cancel)
+    {
+        
+    }
+
+    public void Refresh()
+    {
+        LocalityTree.Refresh(false,true);
+        CountryGrid.Refresh(false,true);
+        StateGrid.Refresh(false,true);
+        LocalityGrid.Refresh(false,true);
+    }
+
+    public string SectionName { get; }
+    
+    public DataModel DataModel(Selection selection)
+    {
+        return new AutoDataModel<Locality>(null);
+    }
+
+    public event DataModelUpdateEvent? OnUpdateDataModel;
+    
+    public bool IsReady { get; set; }
+    
+
+    public void CreateToolbarButtons(IPanelHost host)
+    {
+        
+    }
+
+    public Dictionary<string, object[]> Selected()
+    {
+        return new Dictionary<string, object[]>();
+    }
+
+    public void Heartbeat(TimeSpan time)
+    {
+        
+    }
+}

+ 18 - 0
prs.desktop/Panels/PostCodes/PostCodeWindow.xaml

@@ -0,0 +1,18 @@
+<Window x:Class="PRSDesktop.PostCodeWindow"
+        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:local="clr-namespace:PRSDesktop"
+        mc:Ignorable="d"
+        Title="Post Codes" Height="600" Width="400" WindowStartupLocation="CenterScreen">
+    
+    <!-- <local:PostCodePanel -->
+    <!--     x:Name="PostCodePanel"  -->
+    <!--     Margin="5" /> -->
+    
+    <local:LocalityTree
+        x:Name="LocalityTree" 
+        Margin="5"/>
+    
+</Window>

+ 28 - 0
prs.desktop/Panels/PostCodes/PostCodeWindow.xaml.cs

@@ -0,0 +1,28 @@
+using System;
+using System.Windows;
+using InABox.WPF;
+
+namespace PRSDesktop;
+
+public partial class PostCodeWindow : Window
+{
+    public PostCodeWindow()
+    {
+        InitializeComponent();
+        LocalityTree.Refresh(true,true);
+        //PostCodePanel.Setup();
+        //PostCodePanel.Refresh();
+    }
+    
+    // private bool _first = true;
+    //
+    // protected override void OnActivated(EventArgs e)
+    // {
+    //     if (_first)
+    //     {
+    //         _first = false;
+    //         this.MoveToCenter();
+    //     }
+    //     base.OnActivated(e);
+    // }
+}

+ 30 - 0
prs.desktop/Panels/PostCodes/StateGrid.cs

@@ -0,0 +1,30 @@
+using System;
+using System.Threading;
+using InABox.Core;
+using InABox.DynamicGrid;
+
+namespace PRSDesktop;
+
+public class StateGrid : DynamicDataGrid<State>
+{
+    public Guid CountryId { get; set; }
+
+    protected override void DoReconfigure(DynamicGridOptions options)
+    {
+        base.DoReconfigure(options);
+        options.MultiSelect = true;
+    }
+
+    protected override void Reload(Filters<State> criteria, Columns<State> columns, ref SortOrder<State>? sort, CancellationToken token, Action<CoreTable?, Exception?> action)
+    {
+        criteria.Add(new Filter<State>(x => x.Country.ID).IsEqualTo(CountryId));
+        base.Reload(criteria, columns, ref sort, token, action);
+    }
+
+    public override State CreateItem()
+    {
+        var result = base.CreateItem();
+        result.Country.ID = CountryId;
+        return result;
+    }
+}

+ 7 - 0
prs.desktop/Setups/AccountsSetupActions.cs

@@ -6,6 +6,7 @@ using System.Collections.Generic;
 using System.Linq;
 using System.Text;
 using System.Threading.Tasks;
+using InABox.Core;
 
 namespace PRSDesktop;
 
@@ -53,6 +54,12 @@ public static class AccountsSetupActions
             list.ShowDialog();
         });
         
+        host.CreateSetupActionIfCanView<Locality>("Post Codes", PRSDesktop.Resources.map, (action) =>
+        {
+            var form = new PostCodeWindow();
+            form.ShowDialog();
+        });
+        
         host.CreateSetupActionIfCanView<ForeignCurrency>("Foreign Currencies", PRSDesktop.Resources.payment, (action) =>
         {
             var list = new MasterList(typeof(ForeignCurrency));

+ 120 - 0
prs.server/Engines/GPS/GeoJSONFile.cs

@@ -0,0 +1,120 @@
+using System;
+using System.Collections.Generic;
+using System.Drawing;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using InABox.Core;
+
+namespace PRSServer.Engines.GPS;
+
+public class GeoJSONCrsProperties
+{
+    public string Name { get; set; }
+}
+
+public class GeoJSONCrs
+{
+    public string Type { get; set; }
+    public GeoJSONCrsProperties Properties { get; set; }
+}
+
+public class GeoJSONFeatureGeometry
+{
+    public string Type { get; set; }
+    public List<List<List<List<float>>>> Coordinates { get; set; }
+
+    public List<PointF> Points()
+    {
+        var result = new List<PointF>();
+        var list = Coordinates?.FirstOrDefault()?.FirstOrDefault() ?? new List<List<float>>();
+        foreach (var item in list)
+        {
+            if (item.Count == 2)
+                result.Add(new PointF(item[0], item[1]));
+        }
+        return result;
+    }
+    
+    public Tuple<PointF,PointF> BoundingBox()
+    {
+        float minX = float.MaxValue, minY = float.MaxValue, maxX = float.MinValue, maxY = float.MinValue;
+        var points = Points();
+        foreach (var point in points)
+        {
+            if (point.X < minX) minX = point.X;
+            if (point.X > maxX) maxX = point.X;
+            if (point.Y < minY) minY = point.Y;
+            if (point.Y > maxY) maxY = point.Y;
+        }
+        return new Tuple<PointF,PointF>(new PointF(minX, minY), new PointF(maxX, maxY));
+    }
+}
+
+public class GeoJSONFeatureProperties
+{
+    public int? Land_ID { get; set; }
+    public string? Road_Number_type { get; set; }
+    public string? Road_Number_1 { get; set; }
+    public string? Road_Number_2 { get; set; }
+    public string? Lot_Number { get; set; }
+    public string? Road_Name { get; set; }
+    public string? Road_Type { get; set; }
+    public string? Road_Suffix { get; set; }
+    public string? Locality { get; set; }
+    public string? View_Scale { get; set; }
+    public double? ST_Area_Shape_ { get; set; }
+    public double? ST_Perimeter_Shape_ { get; set; }
+
+    public String Street()
+    {
+        List<String> result = new List<string>();
+        if (string.IsNullOrWhiteSpace(Lot_Number))
+        {
+            if (!string.IsNullOrWhiteSpace(Road_Number_2))
+                result.Add($"{Road_Number_1}/{Road_Number_2}");
+            else
+                result.Add(Road_Number_1);
+        }
+        else
+            result.Add($"LOT {Lot_Number}");
+        result.Add(Road_Name);
+        result.Add(Road_Type);
+        
+        return CultureInfo.CurrentCulture.TextInfo.ToTitleCase(string.Join(" ", result).ToLower());
+    }
+
+    public String Suburb() => CultureInfo.CurrentCulture.TextInfo.ToTitleCase(Locality?.ToLower() ?? "");
+    
+    public String State() => "";
+
+    public String Country => "";
+
+}
+
+public class GeoJSONFeature
+{
+    public string? Type { get; set; }
+
+    public GeoJSONFeatureProperties? Properties { get; set; }
+    
+    public GeoJSONFeatureGeometry? Geometry { get; set; }
+    
+}
+
+public class GeoJSONFile
+{
+    public string? Type { get; set; }
+    public string? Name { get; set; }
+    public GeoJSONCrs? Crs { get; set; }
+    public List<GeoJSONFeature>? Features { get; set; }
+
+    public static GeoJSONFile? Load(string filename)
+    {
+        var serializer = new Newtonsoft.Json.JsonSerializer();
+        using var sr = new StreamReader(filename);
+        using var jtr = new Newtonsoft.Json.JsonTextReader(sr);
+        var geojson = serializer.Deserialize<GeoJSONFile>(jtr);
+        return geojson;
+    }
+}