Sfoglia il codice sorgente

Added live maps screen; still need to implement the filter window

Kenric Nugteren 4 mesi fa
parent
commit
db9958bb28

+ 3 - 0
PRS.Avalonia/Directory.Packages.props

@@ -17,6 +17,7 @@
     <PackageVersion Include="AvaloniaDialogs" Version="3.6.1" />
     <PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.0" />
     <PackageVersion Include="DialogHost.Avalonia" Version="0.9.2" />
+    <PackageVersion Include="Mapsui.Avalonia" Version="4.1.8" />
     <PackageVersion Include="Material.Avalonia" Version="3.9.2" />
     <PackageVersion Include="Material.Avalonia.DataGrid" Version="3.9.2" />
     <PackageVersion Include="Material.Avalonia.Dialogs" Version="3.9.2" />
@@ -25,7 +26,9 @@
     <PackageVersion Include="Serilog" Version="4.2.0" />
     <PackageVersion Include="Serilog.Sinks.File" Version="6.0.0" />
     <PackageVersion Include="SkiaSharp" Version="3.116.1" />
+    <PackageVersion Include="SkiaSharp.NativeAssets.WebAssembly" Version="3.116.1" />
     <PackageVersion Include="SkiaSharp.Views" Version="3.116.1" />
+    <PackageVersion Include="Svg.Skia" Version="2.0.0.4" />
     <PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.0.1.14" />
   </ItemGroup>
 </Project>

+ 1 - 1
PRS.Avalonia/PRS.Avalonia/App.axaml.cs

@@ -34,7 +34,7 @@ public class App : Application
             {
                 DataContext = new MainViewModel(),
                 Width = 400,
-                Height = 800,
+                Height = 700,
                 WindowStartupLocation = WindowStartupLocation.CenterScreen
             };
         }

+ 15 - 1
PRS.Avalonia/PRS.Avalonia/Modules/EquipmentModule/EquipmentMaps/EquipmentMapsView.axaml

@@ -4,6 +4,7 @@
              xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
              xmlns:modules="clr-namespace:PRS.Avalonia.Modules"
              xmlns:components="clr-namespace:InABox.Avalonia.Components;assembly=InABox.Avalonia"
+			 xmlns:mapsui="using:Mapsui.UI.Avalonia"
              mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
              x:Class="PRS.Avalonia.Modules.EquipmentMapsView"
              x:DataType="modules:EquipmentMapsViewModel">
@@ -11,9 +12,22 @@
 		<Grid.RowDefinitions>
 			<RowDefinition Height="Auto"/>
 			<RowDefinition Height="*"/>
+			<RowDefinition Height="Auto"/>
 		</Grid.RowDefinitions>
+		<Grid.ColumnDefinitions>
+			<ColumnDefinition Width="*"/>
+			<ColumnDefinition Width="Auto"/>
+		</Grid.ColumnDefinitions>
 		
 		<components:SearchBar Command="{Binding SearchCommand}"
-							  Text="{Binding SearchText}"/>
+							  Text="{Binding SearchText}"
+							  Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="2"/>
+		<Border Grid.Row="1" Grid.RowSpan="2" Grid.Column="0" Grid.ColumnSpan="2">
+			<mapsui:MapControl x:Name="Map"/>
+		</Border>
+		<Button Grid.Row="2" Grid.Column="1"
+				Command="{Binding ResetCommand}">
+			<Image Classes="Medium" Source="{SvgImage /Images/refresh.svg}"/>
+		</Button>
 	</Grid>
 </UserControl>

+ 176 - 1
PRS.Avalonia/PRS.Avalonia/Modules/EquipmentModule/EquipmentMaps/EquipmentMapsView.axaml.cs

@@ -1,11 +1,186 @@
-using Avalonia.Controls;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Data;
+using Avalonia.Media;
+using Mapsui;
+using Mapsui.Layers;
+using Mapsui.Projections;
+using Mapsui.Styles;
+using ReactiveUI;
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Collections.Specialized;
+using System.Linq;
+using Color = Mapsui.Styles.Color;
 
 namespace PRS.Avalonia.Modules;
 
 public partial class EquipmentMapsView : UserControl
 {
+    public static readonly StyledProperty<ObservableCollection<Marker>> JobMarkersProperty
+        = AvaloniaProperty.Register<EquipmentMapsView, ObservableCollection<Marker>>(nameof(JobMarkers), new());
+
+    public static readonly StyledProperty<ObservableCollection<Marker>> EquipmentMarkersProperty
+        = AvaloniaProperty.Register<EquipmentMapsView, ObservableCollection<Marker>>(nameof(EquipmentMarkers), new());
+
+    public static readonly StyledProperty<Point> CoordinatesProperty
+        = AvaloniaProperty.Register<EquipmentMapsView, Point>(nameof(JobMarkers), new());
+
+    public static readonly StyledProperty<int> ZoomLevelProperty
+        = AvaloniaProperty.Register<EquipmentMapsView, int>(nameof(EquipmentMarkers), new());
+
+    private readonly MemoryLayer _jobLayer;
+    private readonly MemoryLayer _equipmentLayer;
+
+    public ObservableCollection<Marker> JobMarkers
+    {
+        get => GetValue(JobMarkersProperty);
+    }
+
+    public ObservableCollection<Marker> EquipmentMarkers
+    {
+        get => GetValue(EquipmentMarkersProperty);
+    }
+
+    public Point Coordinates
+    {
+        get => GetValue(CoordinatesProperty);
+    }
+
+    public int ZoomLevel
+    {
+        get => GetValue(ZoomLevelProperty);
+    }
+
+    static EquipmentMapsView()
+    {
+        JobMarkersProperty.Changed.AddClassHandler<EquipmentMapsView>(JobMarkersChanged);
+        EquipmentMarkersProperty.Changed.AddClassHandler<EquipmentMapsView>(EquipmentMarkersChanged);
+        CoordinatesProperty.Changed.AddClassHandler<EquipmentMapsView>(CoordinatesChanged);
+        ZoomLevelProperty.Changed.AddClassHandler<EquipmentMapsView>(ZoomLevelChanged);
+    }
+
+    private static void ZoomLevelChanged(EquipmentMapsView view, AvaloniaPropertyChangedEventArgs args)
+    {
+        view.Map.Map.Navigator.ZoomToLevel(view.ZoomLevel);
+    }
+
+    private static void CoordinatesChanged(EquipmentMapsView view, AvaloniaPropertyChangedEventArgs args)
+    {
+        var point = SphericalMercator.FromLonLat(view.Coordinates.Y, view.Coordinates.X);
+        view.Map.Map.Navigator.CenterOn(point.x, point.y);
+    }
+
+    private static void JobMarkersChanged(EquipmentMapsView view, AvaloniaPropertyChangedEventArgs args)
+    {
+        if(args.OldValue is INotifyCollectionChanged changed)
+        {
+            changed.CollectionChanged -= view.JobMarkers_CollectionChanged;
+        }
+        view.JobMarkers.CollectionChanged += view.JobMarkers_CollectionChanged;
+        view._jobLayer.Features = view.JobMarkers.Select(x => x.ToFeature(true));
+    }
+
+    private static void EquipmentMarkersChanged(EquipmentMapsView view, AvaloniaPropertyChangedEventArgs args)
+    {
+        if(args.OldValue is INotifyCollectionChanged changed)
+        {
+            changed.CollectionChanged -= view.EquipmentMarkers_CollectionChanged;
+        }
+        view.EquipmentMarkers.CollectionChanged += view.EquipmentMarkers_CollectionChanged;
+        view._equipmentLayer.Features = view.EquipmentMarkers.Select(x => x.ToFeature(false));
+    }
+
     public EquipmentMapsView()
     {
         InitializeComponent();
+
+        Bind(JobMarkersProperty, new Binding(nameof(EquipmentMapsViewModel.JobMarkers)));
+        Bind(EquipmentMarkersProperty, new Binding(nameof(EquipmentMapsViewModel.EquipmentMarkers)));
+        Bind(CoordinatesProperty, new Binding(nameof(EquipmentMapsViewModel.Coordinates)));
+        Bind(ZoomLevelProperty, new Binding(nameof(EquipmentMapsViewModel.ZoomLevel)));
+
+        _jobLayer = CreateJobLayer();
+        _equipmentLayer = CreateEquipmentLayer();
+
+        Map.Map.Layers.Add(Mapsui.Tiling.OpenStreetMap.CreateTileLayer());
+        Map.Map.Layers.Add(_jobLayer);
+        Map.Map.Layers.Add(_equipmentLayer);
+    }
+
+    private void EquipmentMarkers_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
+    {
+        _equipmentLayer.Features = EquipmentMarkers.Select(x => x.ToFeature(false));
+    }
+
+    private void JobMarkers_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
+    {
+        _jobLayer.Features = JobMarkers.Select(x => x.ToFeature(true));
+    }
+
+    private MemoryLayer CreateJobLayer()
+    {
+        var layer = new MemoryLayer
+        {
+            Name = "Job Layer",
+            Features = JobMarkers.Select(x => x.ToFeature(true)),
+            Style = new SymbolStyle
+            {
+                SymbolScale = 0.2,
+                SymbolOffset = new(),
+                SymbolRotation = 45,
+                SymbolType = SymbolType.Rectangle,
+                Fill = new Mapsui.Styles.Brush(Color.Red),
+            }
+        };
+
+        return layer;
+    }
+
+    private MemoryLayer CreateEquipmentLayer()
+    {
+        var layer = new MemoryLayer
+        {
+            Name = "Equipment Layer",
+            Features = EquipmentMarkers.Select(x => x.ToFeature(false)),
+            Style = new SymbolStyle
+            {
+                SymbolScale = 0.25,
+                SymbolOffset = new(),
+                SymbolType = SymbolType.Ellipse,
+                Fill = new Mapsui.Styles.Brush(Color.Blue),
+            }
+        };
+
+        return layer;
+    }
+
+    public class Marker(string code, double latitude, double longitude)
+    {
+        public string Code { get; set; } = code;
+
+        public Point Coordinates { get; set; } = new(longitude, latitude);
+
+        public IFeature ToFeature(bool isJob)
+        {
+            var point = SphericalMercator.FromLonLat(Coordinates.X, Coordinates.Y);
+            var feature = new PointFeature(point.x, point.y)
+            {
+                ["Label"] = Code,
+                Styles =
+                {
+                    new LabelStyle
+                    {
+                        Text = Code,
+                        Offset = new(8, 0),
+                        HorizontalAlignment = LabelStyle.HorizontalAlignmentEnum.Left,
+                        ForeColor = isJob ? Color.Red : Color.Blue,
+                        BackColor = new(Color.Transparent)
+                    }
+                }
+            };
+            return feature;
+        }
     }
 }

+ 176 - 1
PRS.Avalonia/PRS.Avalonia/Modules/EquipmentModule/EquipmentMaps/EquipmentMapsViewModel.cs

@@ -1,12 +1,27 @@
+using Avalonia;
+using Comal.Classes;
 using CommunityToolkit.Mvvm.ComponentModel;
 using CommunityToolkit.Mvvm.Input;
 using InABox.Avalonia;
 using InABox.Avalonia.Components;
+using InABox.Configuration;
+using InABox.Core;
 using PRS.Avalonia.Dialogs;
+using ReactiveUI;
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
 using System.Threading.Tasks;
+using static PRS.Avalonia.Modules.EquipmentMapsView;
 
 namespace PRS.Avalonia.Modules;
 
+public class EquipmentMapsSettings : ILocalConfigurationSettings
+{
+    public Guid[] SelectedCategories { get; set; } = [];
+}
+
 public partial class EquipmentMapsViewModel : ModuleViewModel
 {
     public override string Title => "Live Maps";
@@ -14,19 +29,179 @@ public partial class EquipmentMapsViewModel : ModuleViewModel
     [ObservableProperty]
     private string _searchText = "";
 
+    private readonly EquipmentMapsSettings _settings;
+
+    [ObservableProperty]
+    private ObservableCollection<Marker> _jobMarkers = new();
+
+    [ObservableProperty]
+    private ObservableCollection<Marker> _equipmentMarkers = new();
+    
+    [ObservableProperty]
+    private JobModel _jobs;
+    
+    [ObservableProperty]
+    private EquipmentModel _equipment;
+    
+    [ObservableProperty]
+    private EquipmentGroupModel _equipmentGroups;
+
+    [ObservableProperty]
+    private Point _coordinates;
+
+    [ObservableProperty]
+    private int _zoomLevel;
+
     public EquipmentMapsViewModel()
     {
+        _settings = new LocalConfiguration<EquipmentMapsSettings>().Load();
+
+        Jobs = new JobModel(
+            DataAccess,
+            () => Repositories.JobFilter(),
+            () => DefaultCacheFileName<JobShell>());
+
+        Equipment = new EquipmentModel(
+            DataAccess, 
+            () => new Filter<Equipment>().All(),
+            () => DefaultCacheFileName<EquipmentShell>());
+
+        EquipmentGroups = new EquipmentGroupModel(
+            DataAccess,
+            () => LookupFactory.DefineFilter<EquipmentGroup>());
+
         PrimaryMenu.Add(new AvaloniaMenuItem(Images.menu, SelectFilter));
     }
 
+    protected override async Task<TimeSpan> OnRefresh()
+    {
+        await Task.WhenAll(
+            Jobs.RefreshAsync(false),
+            Equipment.RefreshAsync(false),
+            EquipmentGroups.RefreshAsync(false));
+
+        Refresh();
+
+        return TimeSpan.Zero;
+    }
+
+    private void Refresh()
+    {
+        var jobs = Jobs.Items.Where(FilterJob)
+            .Select(x => new EquipmentMapsView.Marker(x.JobNumber, x.Location.Latitude, x.Location.Longitude))
+            .ToArray();
+        var equipment = Equipment.Items.Where(FilterEquipment)
+            .Select(x => new EquipmentMapsView.Marker(x.Code, x.Latitude, x.Longitude))
+            .ToArray();
+
+        CenterAndZoom(jobs.Concat(equipment).Select(x => x.Coordinates).ToArray());
+
+        JobMarkers = new ObservableCollection<Marker>(jobs);
+        EquipmentMarkers = new ObservableCollection<Marker>(equipment);
+    }
+
+    private void CenterAndZoom(Point[] points)
+    {
+        points = points.Where(x => x != default).ToArray();
+
+        Point coords;
+        int zoom = 15;
+
+        if (points.Length == 0)
+        {
+            if(App.GPS is not null)
+            {
+                coords = new Point(App.GPS.Latitude, App.GPS.Longitude);
+            }
+            else
+            {
+                return;
+            }
+        }
+        else if (points.Length == 1)
+        {
+            coords = new Point(points[0].Y, points[0].X);
+        }
+        else
+        {
+            var latitudes = points.Select(x => x.Y).OrderBy(x => x).ToList();
+            var longitudes = points.Select(x => x.X).OrderBy(x => x).ToList();
+
+            var firstLat = latitudes.First();
+            var lastLat = latitudes.Last();
+            var firstLong = longitudes.First();
+            var lastLong = longitudes.Last();
+            var resultLat = (firstLat + lastLat) / 2;
+            var resultLong = (firstLong + lastLong) / 2;
+
+            var firstLocation = new Location()
+                { Latitude = firstLat, Longitude = firstLong };
+            var lastLocation = new Location()
+                { Latitude = lastLat, Longitude = lastLong };
+            var distance = firstLocation.DistanceTo(lastLocation, UnitOfLength.Kilometers);
+            coords = new Point(resultLat, resultLong);
+            zoom = CalculateZoom(distance);
+        }
+        
+        Coordinates = coords;
+        ZoomLevel = zoom;
+    }
+
+    private static int CalculateZoom(double distance)
+    {
+        Dictionary<double, int> thresholds = new Dictionary<double, int>()
+        {
+            { 1, 17 },
+            { 5, 16 },
+            { 10, 15 },
+            { 20, 11 },
+            { 50, 10 },
+            { 100, 9 },
+            { 200, 8 },
+            { 400, 7 },
+        };
+        foreach (var key in thresholds.Keys.OrderBy(x => x))
+        {
+            if (distance < key)
+                return thresholds[key];
+        }
+
+        return 6;
+    }
+
+    private bool FilterJob(JobShell shell)
+    {
+        return _settings.SelectedCategories.Contains(CoreUtils.FullGuid)
+            && (shell.Location.Latitude != 0.0F) && (shell.Location.Longitude != 0.0F)
+            && (
+                SearchText.IsNullOrWhiteSpace()
+                || shell.JobNumber.Contains(SearchText, StringComparison.InvariantCultureIgnoreCase)
+                || shell.Name.Contains(SearchText, StringComparison.InvariantCultureIgnoreCase));
+    }
+    private bool FilterEquipment(EquipmentShell shell)
+    {
+        return _settings.SelectedCategories.Contains(shell.GroupID)
+            && (shell.Latitude != 0.0F) && (shell.Longitude != 0.0F)
+            && (
+              SearchText.IsNullOrWhiteSpace()
+              || shell.Code.Contains(SearchText, StringComparison.InvariantCultureIgnoreCase)
+              || shell.Description.Contains(SearchText, StringComparison.InvariantCultureIgnoreCase));
+    }
+
     private async Task<bool> SelectFilter()
     {
-        return true;
+        return await Task.FromResult(true);
     }
 
     [RelayCommand]
     private void Search()
     {
+        Refresh();
+    }
 
+    [RelayCommand]
+    private void Reset()
+    {
+        CenterAndZoom(JobMarkers.Concat(EquipmentMarkers).Select(x => x.Coordinates).ToArray());
     }
 }

+ 3 - 0
PRS.Avalonia/PRS.Avalonia/PRS.Avalonia.csproj

@@ -309,11 +309,14 @@
 
         <PackageReference Include="CommunityToolkit.Mvvm" />
         <PackageReference Include="DialogHost.Avalonia" />
+        <PackageReference Include="Mapsui.Avalonia" />
         <PackageReference Include="Material.Avalonia" />
         <PackageReference Include="Material.Avalonia.DataGrid" />
         <PackageReference Include="Material.Avalonia.Dialogs" />
         <PackageReference Include="SkiaSharp" />
+        <PackageReference Include="SkiaSharp.NativeAssets.WebAssembly" />
         <PackageReference Include="SkiaSharp.Views" />
+        <PackageReference Include="Svg.Skia" />
     </ItemGroup>
 
     <ItemGroup>