mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-21 08:21:14 +00:00
[AZ-312] [AZ-313] [AZ-314] Split Services into per-component csprojs
Phase B of architecture coupling refactor (epic AZ-309). Replaces the monolithic SatelliteProvider.Services with three per-component csprojs to add a compiler-enforced module boundary (resolves F4): - SatelliteProvider.Services.TileDownloader - SatelliteProvider.Services.RegionProcessing - SatelliteProvider.Services.RouteManagement DI registrations relocated into per-component AddTileDownloader / AddRegionProcessing / AddRouteManagement extension methods called from Program.cs. RateLimitException moved to Common/Exceptions/ to keep the three new csprojs as siblings (no Region->TileDownloader ProjectReference). Dockerfiles and consumer csprojs (Api, Tests) rewired to the new project paths. No DI lifetime or hosted-service order changes. Build: 0 warn, 0 err. Unit tests: 40/40. Smoke integration: green. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+15
@@ -0,0 +1,15 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using SatelliteProvider.Common.Interfaces;
|
||||
|
||||
namespace SatelliteProvider.Services.RouteManagement;
|
||||
|
||||
public static class RouteManagementServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddRouteManagement(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IRouteService, RouteService>();
|
||||
services.AddHostedService<RouteProcessingService>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,757 @@
|
||||
using System.IO.Compression;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SatelliteProvider.Common.Configs;
|
||||
using SatelliteProvider.Common.Utils;
|
||||
using SatelliteProvider.DataAccess.Repositories;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
|
||||
namespace SatelliteProvider.Services.RouteManagement;
|
||||
|
||||
public class RouteProcessingService : BackgroundService
|
||||
{
|
||||
private readonly IRouteRepository _routeRepository;
|
||||
private readonly IRegionRepository _regionRepository;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly StorageConfig _storageConfig;
|
||||
private readonly ILogger<RouteProcessingService> _logger;
|
||||
private readonly TimeSpan _checkInterval = TimeSpan.FromSeconds(5);
|
||||
|
||||
public RouteProcessingService(
|
||||
IRouteRepository routeRepository,
|
||||
IRegionRepository regionRepository,
|
||||
IServiceProvider serviceProvider,
|
||||
IOptions<StorageConfig> storageConfig,
|
||||
ILogger<RouteProcessingService> logger)
|
||||
{
|
||||
_routeRepository = routeRepository;
|
||||
_regionRepository = regionRepository;
|
||||
_serviceProvider = serviceProvider;
|
||||
_storageConfig = storageConfig.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("Route Processing Service started");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ProcessPendingRoutesAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in route processing service");
|
||||
}
|
||||
|
||||
await Task.Delay(_checkInterval, stoppingToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Route Processing Service stopped");
|
||||
}
|
||||
|
||||
private async Task ProcessPendingRoutesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var pendingRoutes = await GetRoutesWithPendingMapsAsync();
|
||||
|
||||
foreach (var route in pendingRoutes)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
try
|
||||
{
|
||||
await ProcessRouteSequentiallyAsync(route.Id, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error processing route {RouteId}", route.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<(Guid Id, bool RequestMaps)>> GetRoutesWithPendingMapsAsync()
|
||||
{
|
||||
var routes = await _routeRepository.GetRoutesWithPendingMapsAsync();
|
||||
return routes.Select(r => (r.Id, r.RequestMaps)).ToList();
|
||||
}
|
||||
|
||||
private async Task ProcessRouteSequentiallyAsync(Guid routeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var route = await _routeRepository.GetByIdAsync(routeId);
|
||||
if (route == null)
|
||||
{
|
||||
_logger.LogWarning("Route {RouteId}: Route not found, skipping processing", routeId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!route.RequestMaps || route.MapsReady)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var routePointsList = (await _routeRepository.GetRoutePointsAsync(routeId)).ToList();
|
||||
var regionIdsList = (await _routeRepository.GetRegionIdsByRouteAsync(routeId)).ToList();
|
||||
var geofenceRegionIdsList = (await _routeRepository.GetGeofenceRegionIdsByRouteAsync(routeId)).ToList();
|
||||
|
||||
var allRegionIds = regionIdsList.Union(geofenceRegionIdsList).ToList();
|
||||
|
||||
if (regionIdsList.Count == 0 && routePointsList.Count > 0)
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var regionService = scope.ServiceProvider.GetRequiredService<Common.Interfaces.IRegionService>();
|
||||
|
||||
foreach (var point in routePointsList)
|
||||
{
|
||||
var regionId = Guid.NewGuid();
|
||||
|
||||
await regionService.RequestRegionAsync(
|
||||
regionId,
|
||||
point.Latitude,
|
||||
point.Longitude,
|
||||
route.RegionSizeMeters,
|
||||
route.ZoomLevel,
|
||||
stitchTiles: false);
|
||||
|
||||
await _routeRepository.LinkRouteToRegionAsync(routeId, regionId);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var regions = new List<DataAccess.Models.RegionEntity>();
|
||||
foreach (var regionId in allRegionIds)
|
||||
{
|
||||
var region = await _regionRepository.GetByIdAsync(regionId);
|
||||
if (region != null)
|
||||
{
|
||||
regions.Add(region);
|
||||
}
|
||||
}
|
||||
|
||||
var completedRegions = regions.Where(r => r.Status == "completed").ToList();
|
||||
var failedRegions = regions.Where(r => r.Status == "failed").ToList();
|
||||
var processingRegions = regions.Where(r => r.Status == "queued" || r.Status == "processing").ToList();
|
||||
|
||||
var completedRoutePointRegions = completedRegions.Where(r => !geofenceRegionIdsList.Contains(r.Id)).ToList();
|
||||
var completedGeofenceRegions = completedRegions.Where(r => geofenceRegionIdsList.Contains(r.Id)).ToList();
|
||||
|
||||
var hasRoutePointRegions = regionIdsList.Count > 0;
|
||||
var hasEnoughRoutePointRegions = !hasRoutePointRegions || completedRoutePointRegions.Count >= routePointsList.Count;
|
||||
var hasAllGeofenceRegions = geofenceRegionIdsList.Count == 0 || completedGeofenceRegions.Count >= geofenceRegionIdsList.Count;
|
||||
var hasEnoughCompleted = hasEnoughRoutePointRegions && hasAllGeofenceRegions;
|
||||
|
||||
var activeRegions = completedRegions.Count + processingRegions.Count;
|
||||
var shouldRetryFailed = failedRegions.Count > 0 && !hasEnoughCompleted && activeRegions < allRegionIds.Count;
|
||||
|
||||
if (hasEnoughCompleted)
|
||||
{
|
||||
var orderedRouteRegions = MatchRegionsToRoutePoints(routePointsList, completedRoutePointRegions, routeId);
|
||||
var routeRegionIds = orderedRouteRegions.Select(r => r.Id).ToList();
|
||||
var allRegionIdsForStitching = routeRegionIds.Concat(completedGeofenceRegions.Select(r => r.Id)).Distinct();
|
||||
var geofenceRegionIdsForBorders = completedGeofenceRegions.Select(r => r.Id).ToList();
|
||||
|
||||
await GenerateRouteMapsAsync(routeId, route, allRegionIdsForStitching, geofenceRegionIdsForBorders, routePointsList, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldRetryFailed)
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var regionService = scope.ServiceProvider.GetRequiredService<Common.Interfaces.IRegionService>();
|
||||
|
||||
foreach (var failedRegion in failedRegions)
|
||||
{
|
||||
var newRegionId = Guid.NewGuid();
|
||||
|
||||
await regionService.RequestRegionAsync(
|
||||
newRegionId,
|
||||
failedRegion.Latitude,
|
||||
failedRegion.Longitude,
|
||||
failedRegion.SizeMeters,
|
||||
failedRegion.ZoomLevel,
|
||||
stitchTiles: false);
|
||||
|
||||
await _routeRepository.LinkRouteToRegionAsync(routeId, newRegionId);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var anyProcessing = processingRegions.Count > 0;
|
||||
if (anyProcessing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogWarning("Route {RouteId}: No processing regions, but not enough completed. This is an unexpected state - hasEnoughCompleted={HasEnough}, shouldRetryFailed={ShouldRetry}, anyProcessing={AnyProcessing}",
|
||||
routeId, hasEnoughCompleted, shouldRetryFailed, anyProcessing);
|
||||
}
|
||||
|
||||
private async Task GenerateRouteMapsAsync(
|
||||
Guid routeId,
|
||||
DataAccess.Models.RouteEntity route,
|
||||
IEnumerable<Guid> regionIds,
|
||||
List<Guid> geofenceRegionIds,
|
||||
List<DataAccess.Models.RoutePointEntity> routePoints,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var readyDir = _storageConfig.ReadyDirectory;
|
||||
Directory.CreateDirectory(readyDir);
|
||||
|
||||
var allTiles = new Dictionary<string, TileInfo>();
|
||||
int totalTilesFromRegions = 0;
|
||||
int duplicateTiles = 0;
|
||||
|
||||
foreach (var regionId in regionIds)
|
||||
{
|
||||
var region = await _regionRepository.GetByIdAsync(regionId);
|
||||
if (region == null || string.IsNullOrEmpty(region.CsvFilePath) || !File.Exists(region.CsvFilePath))
|
||||
{
|
||||
_logger.LogWarning("Route {RouteId}: Region {RegionId} CSV not found", routeId, regionId);
|
||||
continue;
|
||||
}
|
||||
|
||||
var csvLines = await File.ReadAllLinesAsync(region.CsvFilePath, cancellationToken);
|
||||
|
||||
var lineNumber = 0;
|
||||
foreach (var line in csvLines.Skip(1))
|
||||
{
|
||||
lineNumber++;
|
||||
var parts = line.Split(',');
|
||||
if (parts.Length < 3) continue;
|
||||
|
||||
if (!double.TryParse(parts[0], out var lat))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!double.TryParse(parts[1], out var lon))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var filePath = parts[2];
|
||||
|
||||
totalTilesFromRegions++;
|
||||
var key = $"{lat:F6}_{lon:F6}";
|
||||
|
||||
if (!allTiles.ContainsKey(key))
|
||||
{
|
||||
allTiles[key] = new TileInfo
|
||||
{
|
||||
Latitude = lat,
|
||||
Longitude = lon,
|
||||
FilePath = filePath
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
duplicateTiles++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var csvPath = Path.Combine(readyDir, $"route_{routeId}_ready.csv");
|
||||
await GenerateRouteCsvAsync(csvPath, allTiles.Values, cancellationToken);
|
||||
|
||||
string? stitchedImagePath = null;
|
||||
if (route.RequestMaps)
|
||||
{
|
||||
var geofencePolygonBounds = new List<(int MinX, int MinY, int MaxX, int MaxY)>();
|
||||
|
||||
var geofencesByPolygon = await _routeRepository.GetGeofenceRegionsByPolygonAsync(routeId);
|
||||
|
||||
foreach (var (polygonIndex, polygonRegionIds) in geofencesByPolygon.OrderBy(kvp => kvp.Key))
|
||||
{
|
||||
int? minX = null, minY = null, maxX = null, maxY = null;
|
||||
|
||||
foreach (var geofenceId in polygonRegionIds)
|
||||
{
|
||||
var region = await _regionRepository.GetByIdAsync(geofenceId);
|
||||
if (region != null && !string.IsNullOrEmpty(region.CsvFilePath) && File.Exists(region.CsvFilePath))
|
||||
{
|
||||
var csvLines = await File.ReadAllLinesAsync(region.CsvFilePath, cancellationToken);
|
||||
|
||||
foreach (var line in csvLines.Skip(1))
|
||||
{
|
||||
var parts = line.Split(',');
|
||||
if (parts.Length >= 3)
|
||||
{
|
||||
if (double.TryParse(parts[0], out var lat) && double.TryParse(parts[1], out var lon))
|
||||
{
|
||||
var tile = GeoUtils.WorldToTilePos(new Common.DTO.GeoPoint { Lat = lat, Lon = lon }, route.ZoomLevel);
|
||||
minX = minX == null ? tile.x : Math.Min(minX.Value, tile.x);
|
||||
minY = minY == null ? tile.y : Math.Min(minY.Value, tile.y);
|
||||
maxX = maxX == null ? tile.x : Math.Max(maxX.Value, tile.x);
|
||||
maxY = maxY == null ? tile.y : Math.Max(maxY.Value, tile.y);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (minX.HasValue && minY.HasValue && maxX.HasValue && maxY.HasValue)
|
||||
{
|
||||
geofencePolygonBounds.Add((minX.Value, minY.Value, maxX.Value, maxY.Value));
|
||||
}
|
||||
}
|
||||
|
||||
stitchedImagePath = Path.Combine(readyDir, $"route_{routeId}_stitched.jpg");
|
||||
|
||||
await StitchRouteTilesAsync(allTiles.Values.ToList(), stitchedImagePath, route.ZoomLevel, geofencePolygonBounds, routePoints, cancellationToken);
|
||||
}
|
||||
|
||||
string? tilesZipPath = null;
|
||||
if (route.CreateTilesZip)
|
||||
{
|
||||
tilesZipPath = Path.Combine(readyDir, $"route_{routeId}_tiles.zip");
|
||||
await CreateTilesZipAsync(tilesZipPath, allTiles.Values, cancellationToken);
|
||||
}
|
||||
|
||||
var summaryPath = Path.Combine(readyDir, $"route_{routeId}_summary.txt");
|
||||
await GenerateRouteSummaryAsync(summaryPath, route, allTiles.Count, totalTilesFromRegions, duplicateTiles, tilesZipPath, cancellationToken);
|
||||
|
||||
route.MapsReady = true;
|
||||
route.CsvFilePath = csvPath;
|
||||
route.SummaryFilePath = summaryPath;
|
||||
route.StitchedImagePath = stitchedImagePath;
|
||||
route.TilesZipPath = tilesZipPath;
|
||||
route.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _routeRepository.UpdateRouteAsync(route);
|
||||
|
||||
await CleanupRegionFilesAsync(regionIds, cancellationToken);
|
||||
|
||||
_logger.LogInformation("Route {RouteId} maps processing completed successfully", routeId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error generating maps for route {RouteId}", routeId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CleanupRegionFilesAsync(IEnumerable<Guid> regionIds, CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var regionId in regionIds)
|
||||
{
|
||||
var region = await _regionRepository.GetByIdAsync(regionId);
|
||||
if (region == null) continue;
|
||||
|
||||
if (!string.IsNullOrEmpty(region.CsvFilePath) && File.Exists(region.CsvFilePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(region.CsvFilePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to delete region CSV file: {FilePath}", region.CsvFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(region.SummaryFilePath) && File.Exists(region.SummaryFilePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(region.SummaryFilePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to delete region summary file: {FilePath}", region.SummaryFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
var readyDir = _storageConfig.ReadyDirectory;
|
||||
var stitchedImagePath = Path.Combine(readyDir, $"region_{regionId}_stitched.jpg");
|
||||
if (File.Exists(stitchedImagePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(stitchedImagePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to delete region stitched image: {FilePath}", stitchedImagePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task GenerateRouteCsvAsync(
|
||||
string filePath,
|
||||
IEnumerable<TileInfo> tiles,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var orderedTiles = tiles.OrderByDescending(t => t.Latitude).ThenBy(t => t.Longitude).ToList();
|
||||
|
||||
using var writer = new StreamWriter(filePath);
|
||||
await writer.WriteLineAsync("latitude,longitude,file_path");
|
||||
|
||||
foreach (var tile in orderedTiles)
|
||||
{
|
||||
await writer.WriteLineAsync($"{tile.Latitude:F6},{tile.Longitude:F6},{tile.FilePath}");
|
||||
}
|
||||
|
||||
_logger.LogInformation("Route CSV generated: {FilePath} with {Count} tiles", filePath, orderedTiles.Count);
|
||||
}
|
||||
|
||||
private async Task GenerateRouteSummaryAsync(
|
||||
string filePath,
|
||||
DataAccess.Models.RouteEntity route,
|
||||
int uniqueTiles,
|
||||
int totalTilesFromRegions,
|
||||
int duplicateTiles,
|
||||
string? tilesZipPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var summary = new System.Text.StringBuilder();
|
||||
summary.AppendLine("Route Maps Summary");
|
||||
summary.AppendLine("==================");
|
||||
summary.AppendLine($"Route ID: {route.Id}");
|
||||
summary.AppendLine($"Route Name: {route.Name}");
|
||||
if (!string.IsNullOrEmpty(route.Description))
|
||||
{
|
||||
summary.AppendLine($"Description: {route.Description}");
|
||||
}
|
||||
summary.AppendLine($"Total Points: {route.TotalPoints}");
|
||||
summary.AppendLine($"Total Distance: {route.TotalDistanceMeters:F2} meters");
|
||||
summary.AppendLine($"Region Size: {route.RegionSizeMeters:F0} meters");
|
||||
summary.AppendLine($"Zoom Level: {route.ZoomLevel}");
|
||||
summary.AppendLine();
|
||||
summary.AppendLine("Tile Statistics:");
|
||||
summary.AppendLine($"- Unique Tiles: {uniqueTiles}");
|
||||
summary.AppendLine($"- Total Tiles from Regions: {totalTilesFromRegions}");
|
||||
summary.AppendLine($"- Duplicate Tiles (overlap): {duplicateTiles}");
|
||||
summary.AppendLine();
|
||||
summary.AppendLine("Files Created:");
|
||||
summary.AppendLine($"- CSV: route_{route.Id}_ready.csv");
|
||||
summary.AppendLine($"- Summary: route_{route.Id}_summary.txt");
|
||||
if (route.RequestMaps)
|
||||
{
|
||||
summary.AppendLine($"- Stitched Map: route_{route.Id}_stitched.jpg");
|
||||
}
|
||||
if (tilesZipPath != null)
|
||||
{
|
||||
summary.AppendLine($"- Tiles ZIP: route_{route.Id}_tiles.zip");
|
||||
}
|
||||
summary.AppendLine();
|
||||
summary.AppendLine($"Completed: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC");
|
||||
|
||||
await File.WriteAllTextAsync(filePath, summary.ToString(), cancellationToken);
|
||||
|
||||
_logger.LogInformation("Route summary generated: {FilePath}", filePath);
|
||||
}
|
||||
|
||||
private async Task StitchRouteTilesAsync(
|
||||
List<TileInfo> tiles,
|
||||
string outputPath,
|
||||
int zoomLevel,
|
||||
List<(int MinX, int MinY, int MaxX, int MaxY)> geofencePolygonBounds,
|
||||
List<DataAccess.Models.RoutePointEntity> routePoints,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (tiles.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("No tiles to stitch for route map");
|
||||
return;
|
||||
}
|
||||
|
||||
const int tileSizePixels = 256;
|
||||
|
||||
var tileCoords = tiles.Select(t =>
|
||||
{
|
||||
var (tileX, tileY) = ExtractTileCoordinatesFromFilename(t.FilePath);
|
||||
return new
|
||||
{
|
||||
t.Latitude,
|
||||
t.Longitude,
|
||||
t.FilePath,
|
||||
TileX = tileX,
|
||||
TileY = tileY
|
||||
};
|
||||
}).Where(t => t.TileX >= 0 && t.TileY >= 0).ToList();
|
||||
|
||||
var minX = tileCoords.Min(t => t.TileX);
|
||||
var maxX = tileCoords.Max(t => t.TileX);
|
||||
var minY = tileCoords.Min(t => t.TileY);
|
||||
var maxY = tileCoords.Max(t => t.TileY);
|
||||
|
||||
var gridWidth = maxX - minX + 1;
|
||||
var gridHeight = maxY - minY + 1;
|
||||
var imageWidth = gridWidth * tileSizePixels;
|
||||
var imageHeight = gridHeight * tileSizePixels;
|
||||
|
||||
using var stitchedImage = new Image<Rgb24>(imageWidth, imageHeight);
|
||||
stitchedImage.Mutate(ctx => ctx.BackgroundColor(Color.Black));
|
||||
|
||||
var uniqueTileCoords = tileCoords
|
||||
.GroupBy(t => $"{t.TileX}_{t.TileY}")
|
||||
.Select(g => g.First())
|
||||
.OrderBy(t => t.TileY)
|
||||
.ThenBy(t => t.TileX)
|
||||
.ToList();
|
||||
|
||||
int placedTiles = 0;
|
||||
int missingTiles = 0;
|
||||
|
||||
foreach (var tile in uniqueTileCoords)
|
||||
{
|
||||
var destX = (tile.TileX - minX) * tileSizePixels;
|
||||
var destY = (tile.TileY - minY) * tileSizePixels;
|
||||
|
||||
if (File.Exists(tile.FilePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var tileImage = await Image.LoadAsync<Rgb24>(tile.FilePath, cancellationToken);
|
||||
|
||||
stitchedImage.Mutate(ctx => ctx.DrawImage(tileImage, new Point(destX, destY), 1f));
|
||||
placedTiles++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load tile at {FilePath}, leaving black", tile.FilePath);
|
||||
missingTiles++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Tile file not found: {FilePath}, leaving black", tile.FilePath);
|
||||
missingTiles++;
|
||||
}
|
||||
}
|
||||
|
||||
if (geofencePolygonBounds.Count > 0)
|
||||
{
|
||||
for (int i = 0; i < geofencePolygonBounds.Count; i++)
|
||||
{
|
||||
var (geoMinX, geoMinY, geoMaxX, geoMaxY) = geofencePolygonBounds[i];
|
||||
|
||||
var x1 = (geoMinX - minX) * tileSizePixels;
|
||||
var y1 = (geoMinY - minY + 1) * tileSizePixels;
|
||||
var x2 = (geoMaxX - minX + 2) * tileSizePixels - 1;
|
||||
var y2 = (geoMaxY - minY + 1) * tileSizePixels - 1;
|
||||
|
||||
x1 = Math.Max(0, Math.Min(x1, imageWidth - 1));
|
||||
y1 = Math.Max(0, Math.Min(y1, imageHeight - 1));
|
||||
x2 = Math.Max(0, Math.Min(x2, imageWidth - 1));
|
||||
y2 = Math.Max(0, Math.Min(y2, imageHeight - 1));
|
||||
|
||||
if (x1 >= 0 && y1 >= 0 && x2 < imageWidth && y2 < imageHeight && x2 > x1 && y2 > y1)
|
||||
{
|
||||
DrawRectangleBorder(stitchedImage, x1, y1, x2, y2, new Rgb24(255, 255, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var point in routePoints)
|
||||
{
|
||||
var geoPoint = new Common.DTO.GeoPoint { Lat = point.Latitude, Lon = point.Longitude };
|
||||
var (tileX, tileY) = GeoUtils.WorldToTilePos(geoPoint, zoomLevel);
|
||||
|
||||
var pixelX = (tileX - minX) * tileSizePixels + tileSizePixels / 2;
|
||||
var pixelY = (tileY - minY) * tileSizePixels + tileSizePixels / 2;
|
||||
|
||||
if (pixelX >= 0 && pixelX < imageWidth && pixelY >= 0 && pixelY < imageHeight)
|
||||
{
|
||||
DrawCross(stitchedImage, pixelX, pixelY, new Rgb24(255, 0, 0), 50);
|
||||
}
|
||||
}
|
||||
|
||||
await stitchedImage.SaveAsJpegAsync(outputPath, cancellationToken);
|
||||
}
|
||||
|
||||
private List<DataAccess.Models.RegionEntity> MatchRegionsToRoutePoints(
|
||||
List<DataAccess.Models.RoutePointEntity> routePoints,
|
||||
List<DataAccess.Models.RegionEntity> regions,
|
||||
Guid routeId)
|
||||
{
|
||||
var orderedRegions = new List<DataAccess.Models.RegionEntity>();
|
||||
var availableRegions = new List<DataAccess.Models.RegionEntity>(regions);
|
||||
|
||||
foreach (var point in routePoints)
|
||||
{
|
||||
var matchedRegion = availableRegions
|
||||
.OrderBy(r => CalculateDistance(point.Latitude, point.Longitude, r.Latitude, r.Longitude))
|
||||
.FirstOrDefault();
|
||||
|
||||
if (matchedRegion != null)
|
||||
{
|
||||
orderedRegions.Add(matchedRegion);
|
||||
availableRegions.Remove(matchedRegion);
|
||||
}
|
||||
}
|
||||
|
||||
return orderedRegions;
|
||||
}
|
||||
|
||||
private static double CalculateDistance(double lat1, double lon1, double lat2, double lon2)
|
||||
{
|
||||
const double earthRadiusMeters = 6371000;
|
||||
var dLat = (lat2 - lat1) * Math.PI / 180.0;
|
||||
var dLon = (lon2 - lon1) * Math.PI / 180.0;
|
||||
|
||||
var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) +
|
||||
Math.Cos(lat1 * Math.PI / 180.0) * Math.Cos(lat2 * Math.PI / 180.0) *
|
||||
Math.Sin(dLon / 2) * Math.Sin(dLon / 2);
|
||||
var c = 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));
|
||||
|
||||
return earthRadiusMeters * c;
|
||||
}
|
||||
|
||||
private static (int TileX, int TileY) ExtractTileCoordinatesFromFilename(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var filename = Path.GetFileNameWithoutExtension(filePath);
|
||||
var parts = filename.Split('_');
|
||||
|
||||
if (parts.Length >= 4 && parts[0] == "tile")
|
||||
{
|
||||
if (int.TryParse(parts[2], out var tileX) && int.TryParse(parts[3], out var tileY))
|
||||
{
|
||||
return (tileX, tileY);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
return (-1, -1);
|
||||
}
|
||||
|
||||
private static void DrawRectangleBorder(Image<Rgb24> image, int x1, int y1, int x2, int y2, Rgb24 color)
|
||||
{
|
||||
const int thickness = 5;
|
||||
|
||||
for (int t = 0; t < thickness; t++)
|
||||
{
|
||||
for (int x = x1; x <= x2; x++)
|
||||
{
|
||||
int topY = y1 + t;
|
||||
int bottomY = y2 - t;
|
||||
if (x >= 0 && x < image.Width && topY >= 0 && topY < image.Height)
|
||||
image[x, topY] = color;
|
||||
if (x >= 0 && x < image.Width && bottomY >= 0 && bottomY < image.Height)
|
||||
image[x, bottomY] = color;
|
||||
}
|
||||
|
||||
for (int y = y1; y <= y2; y++)
|
||||
{
|
||||
int leftX = x1 + t;
|
||||
int rightX = x2 - t;
|
||||
if (leftX >= 0 && leftX < image.Width && y >= 0 && y < image.Height)
|
||||
image[leftX, y] = color;
|
||||
if (rightX >= 0 && rightX < image.Width && y >= 0 && y < image.Height)
|
||||
image[rightX, y] = color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void DrawCross(Image<Rgb24> image, int centerX, int centerY, Rgb24 color, int armLength)
|
||||
{
|
||||
const int thickness = 10;
|
||||
int halfThickness = thickness / 2;
|
||||
|
||||
for (int dx = -armLength; dx <= armLength; dx++)
|
||||
{
|
||||
for (int t = -halfThickness; t <= halfThickness; t++)
|
||||
{
|
||||
int x = centerX + dx;
|
||||
int y = centerY + t;
|
||||
if (x >= 0 && x < image.Width && y >= 0 && y < image.Height)
|
||||
image[x, y] = color;
|
||||
}
|
||||
}
|
||||
|
||||
for (int dy = -armLength; dy <= armLength; dy++)
|
||||
{
|
||||
for (int t = -halfThickness; t <= halfThickness; t++)
|
||||
{
|
||||
int x = centerX + t;
|
||||
int y = centerY + dy;
|
||||
if (x >= 0 && x < image.Width && y >= 0 && y < image.Height)
|
||||
image[x, y] = color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Task CreateTilesZipAsync(
|
||||
string zipFilePath,
|
||||
IEnumerable<TileInfo> tiles,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.Run(() =>
|
||||
{
|
||||
if (File.Exists(zipFilePath))
|
||||
{
|
||||
File.Delete(zipFilePath);
|
||||
}
|
||||
|
||||
using var zipArchive = ZipFile.Open(zipFilePath, ZipArchiveMode.Create);
|
||||
int addedFiles = 0;
|
||||
int missingFiles = 0;
|
||||
|
||||
var tilesBasePath = _storageConfig.TilesDirectory;
|
||||
var normalizedBasePath = Path.GetFullPath(tilesBasePath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
|
||||
foreach (var tile in tiles)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
if (File.Exists(tile.FilePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var fullPath = Path.GetFullPath(tile.FilePath);
|
||||
string entryName;
|
||||
|
||||
if (fullPath.StartsWith(normalizedBasePath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var relativePath = fullPath.Substring(normalizedBasePath.Length + 1);
|
||||
relativePath = relativePath.Replace(Path.DirectorySeparatorChar, '/');
|
||||
entryName = "tiles/" + relativePath;
|
||||
}
|
||||
else
|
||||
{
|
||||
entryName = "tiles/" + Path.GetFileName(tile.FilePath);
|
||||
}
|
||||
|
||||
zipArchive.CreateEntryFromFile(tile.FilePath, entryName, CompressionLevel.Optimal);
|
||||
addedFiles++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to add tile to zip: {FilePath}", tile.FilePath);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Tile file not found for zip: {FilePath}", tile.FilePath);
|
||||
missingFiles++;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Tiles zip created: {ZipPath} with {AddedFiles} tiles ({MissingFiles} missing)",
|
||||
zipFilePath, addedFiles, missingFiles);
|
||||
}, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public class TileInfo
|
||||
{
|
||||
public double Latitude { get; set; }
|
||||
public double Longitude { get; set; }
|
||||
public string FilePath { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SatelliteProvider.Common.DTO;
|
||||
using SatelliteProvider.Common.Interfaces;
|
||||
using SatelliteProvider.Common.Utils;
|
||||
using SatelliteProvider.DataAccess.Models;
|
||||
using SatelliteProvider.DataAccess.Repositories;
|
||||
|
||||
namespace SatelliteProvider.Services.RouteManagement;
|
||||
|
||||
public class RouteService : IRouteService
|
||||
{
|
||||
private readonly IRouteRepository _routeRepository;
|
||||
private readonly IRegionService _regionService;
|
||||
private readonly ILogger<RouteService> _logger;
|
||||
private const double MAX_POINT_SPACING_METERS = 200.0;
|
||||
|
||||
public RouteService(
|
||||
IRouteRepository routeRepository,
|
||||
IRegionService regionService,
|
||||
ILogger<RouteService> logger)
|
||||
{
|
||||
_routeRepository = routeRepository;
|
||||
_regionService = regionService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<RouteResponse> CreateRouteAsync(CreateRouteRequest request)
|
||||
{
|
||||
if (request.Points.Count < 2)
|
||||
{
|
||||
throw new ArgumentException("Route must have at least 2 points");
|
||||
}
|
||||
|
||||
if (request.RegionSizeMeters < 100 || request.RegionSizeMeters > 10000)
|
||||
{
|
||||
throw new ArgumentException("Region size must be between 100 and 10000 meters");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
throw new ArgumentException("Route name is required");
|
||||
}
|
||||
|
||||
|
||||
var allPoints = new List<RoutePointDto>();
|
||||
var totalDistance = 0.0;
|
||||
var sequenceNumber = 0;
|
||||
|
||||
for (int segmentIndex = 0; segmentIndex < request.Points.Count; segmentIndex++)
|
||||
{
|
||||
var currentPoint = request.Points[segmentIndex];
|
||||
var isStart = segmentIndex == 0;
|
||||
var isEnd = segmentIndex == request.Points.Count - 1;
|
||||
|
||||
var geoPoint = new GeoPoint(currentPoint.Latitude, currentPoint.Longitude);
|
||||
|
||||
double? distanceFromPrevious = null;
|
||||
if (allPoints.Count > 0)
|
||||
{
|
||||
var lastAddedPoint = allPoints[^1];
|
||||
var prevGeoPoint = new GeoPoint(lastAddedPoint.Latitude, lastAddedPoint.Longitude);
|
||||
distanceFromPrevious = GeoUtils.CalculateDistance(prevGeoPoint, geoPoint);
|
||||
totalDistance += distanceFromPrevious.Value;
|
||||
}
|
||||
|
||||
var pointType = isStart ? "start" : (isEnd ? "end" : "action");
|
||||
|
||||
var routePointDto = new RoutePointDto
|
||||
{
|
||||
Latitude = currentPoint.Latitude,
|
||||
Longitude = currentPoint.Longitude,
|
||||
PointType = pointType,
|
||||
SequenceNumber = sequenceNumber++,
|
||||
SegmentIndex = segmentIndex,
|
||||
DistanceFromPrevious = distanceFromPrevious
|
||||
};
|
||||
|
||||
allPoints.Add(routePointDto);
|
||||
|
||||
if (!isEnd)
|
||||
{
|
||||
var nextPoint = request.Points[segmentIndex + 1];
|
||||
var startGeo = new GeoPoint(currentPoint.Latitude, currentPoint.Longitude);
|
||||
var endGeo = new GeoPoint(nextPoint.Latitude, nextPoint.Longitude);
|
||||
|
||||
var intermediatePoints = GeoUtils.CalculateIntermediatePoints(startGeo, endGeo, MAX_POINT_SPACING_METERS);
|
||||
|
||||
foreach (var intermediateGeo in intermediatePoints)
|
||||
{
|
||||
var lastAddedPoint = allPoints[^1];
|
||||
var prevGeo = new GeoPoint(lastAddedPoint.Latitude, lastAddedPoint.Longitude);
|
||||
|
||||
var distFromPrev = GeoUtils.CalculateDistance(prevGeo, intermediateGeo);
|
||||
totalDistance += distFromPrev;
|
||||
|
||||
allPoints.Add(new RoutePointDto
|
||||
{
|
||||
Latitude = intermediateGeo.Lat,
|
||||
Longitude = intermediateGeo.Lon,
|
||||
PointType = "intermediate",
|
||||
SequenceNumber = sequenceNumber++,
|
||||
SegmentIndex = segmentIndex,
|
||||
DistanceFromPrevious = distFromPrev
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var routeEntity = new RouteEntity
|
||||
{
|
||||
Id = request.Id,
|
||||
Name = request.Name,
|
||||
Description = request.Description,
|
||||
RegionSizeMeters = request.RegionSizeMeters,
|
||||
ZoomLevel = request.ZoomLevel,
|
||||
TotalDistanceMeters = totalDistance,
|
||||
TotalPoints = allPoints.Count,
|
||||
RequestMaps = request.RequestMaps,
|
||||
CreateTilesZip = request.CreateTilesZip,
|
||||
MapsReady = false,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
await _routeRepository.InsertRouteAsync(routeEntity);
|
||||
|
||||
var pointEntities = allPoints.Select(p => new RoutePointEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
RouteId = request.Id,
|
||||
SequenceNumber = p.SequenceNumber,
|
||||
Latitude = p.Latitude,
|
||||
Longitude = p.Longitude,
|
||||
PointType = p.PointType,
|
||||
SegmentIndex = p.SegmentIndex,
|
||||
DistanceFromPrevious = p.DistanceFromPrevious,
|
||||
CreatedAt = now
|
||||
}).ToList();
|
||||
|
||||
await _routeRepository.InsertRoutePointsAsync(pointEntities);
|
||||
|
||||
if (request.Geofences?.Polygons != null && request.Geofences.Polygons.Count > 0)
|
||||
{
|
||||
for (int polygonIndex = 0; polygonIndex < request.Geofences.Polygons.Count; polygonIndex++)
|
||||
{
|
||||
var polygon = request.Geofences.Polygons[polygonIndex];
|
||||
|
||||
if (polygon.NorthWest is null || polygon.SouthEast is null)
|
||||
{
|
||||
throw new ArgumentException("Geofence polygon coordinates are required");
|
||||
}
|
||||
|
||||
if ((Math.Abs(polygon.NorthWest.Lat) < 0.0001 && Math.Abs(polygon.NorthWest.Lon) < 0.0001) ||
|
||||
(Math.Abs(polygon.SouthEast.Lat) < 0.0001 && Math.Abs(polygon.SouthEast.Lon) < 0.0001))
|
||||
{
|
||||
throw new ArgumentException("Geofence polygon coordinates cannot be (0,0)");
|
||||
}
|
||||
|
||||
if (polygon.NorthWest.Lat < -90 || polygon.NorthWest.Lat > 90 ||
|
||||
polygon.SouthEast.Lat < -90 || polygon.SouthEast.Lat > 90 ||
|
||||
polygon.NorthWest.Lon < -180 || polygon.NorthWest.Lon > 180 ||
|
||||
polygon.SouthEast.Lon < -180 || polygon.SouthEast.Lon > 180)
|
||||
{
|
||||
throw new ArgumentException("Geofence polygon coordinates must be valid (lat: -90 to 90, lon: -180 to 180)");
|
||||
}
|
||||
|
||||
if (polygon.NorthWest.Lat <= polygon.SouthEast.Lat)
|
||||
{
|
||||
throw new ArgumentException("Geofence northWest latitude must be greater than southEast latitude");
|
||||
}
|
||||
|
||||
var geofenceRegions = CreateGeofenceRegionGrid(polygon.NorthWest, polygon.SouthEast, request.RegionSizeMeters);
|
||||
|
||||
foreach (var geofencePoint in geofenceRegions)
|
||||
{
|
||||
var geofenceRegionId = Guid.NewGuid();
|
||||
|
||||
await _regionService.RequestRegionAsync(
|
||||
geofenceRegionId,
|
||||
geofencePoint.Lat,
|
||||
geofencePoint.Lon,
|
||||
request.RegionSizeMeters,
|
||||
request.ZoomLevel,
|
||||
stitchTiles: false);
|
||||
|
||||
await _routeRepository.LinkRouteToRegionAsync(request.Id, geofenceRegionId, isGeofence: true, geofencePolygonIndex: polygonIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new RouteResponse
|
||||
{
|
||||
Id = routeEntity.Id,
|
||||
Name = routeEntity.Name,
|
||||
Description = routeEntity.Description,
|
||||
RegionSizeMeters = routeEntity.RegionSizeMeters,
|
||||
ZoomLevel = routeEntity.ZoomLevel,
|
||||
TotalDistanceMeters = routeEntity.TotalDistanceMeters,
|
||||
TotalPoints = routeEntity.TotalPoints,
|
||||
Points = allPoints,
|
||||
RequestMaps = routeEntity.RequestMaps,
|
||||
MapsReady = routeEntity.MapsReady,
|
||||
CsvFilePath = routeEntity.CsvFilePath,
|
||||
SummaryFilePath = routeEntity.SummaryFilePath,
|
||||
StitchedImagePath = routeEntity.StitchedImagePath,
|
||||
TilesZipPath = routeEntity.TilesZipPath,
|
||||
CreatedAt = routeEntity.CreatedAt,
|
||||
UpdatedAt = routeEntity.UpdatedAt
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<RouteResponse?> GetRouteAsync(Guid id)
|
||||
{
|
||||
var route = await _routeRepository.GetByIdAsync(id);
|
||||
if (route == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var points = await _routeRepository.GetRoutePointsAsync(id);
|
||||
|
||||
return new RouteResponse
|
||||
{
|
||||
Id = route.Id,
|
||||
Name = route.Name,
|
||||
Description = route.Description,
|
||||
RegionSizeMeters = route.RegionSizeMeters,
|
||||
ZoomLevel = route.ZoomLevel,
|
||||
TotalDistanceMeters = route.TotalDistanceMeters,
|
||||
TotalPoints = route.TotalPoints,
|
||||
Points = points.Select(p => new RoutePointDto
|
||||
{
|
||||
Latitude = p.Latitude,
|
||||
Longitude = p.Longitude,
|
||||
PointType = p.PointType,
|
||||
SequenceNumber = p.SequenceNumber,
|
||||
SegmentIndex = p.SegmentIndex,
|
||||
DistanceFromPrevious = p.DistanceFromPrevious
|
||||
}).ToList(),
|
||||
RequestMaps = route.RequestMaps,
|
||||
MapsReady = route.MapsReady,
|
||||
CsvFilePath = route.CsvFilePath,
|
||||
SummaryFilePath = route.SummaryFilePath,
|
||||
StitchedImagePath = route.StitchedImagePath,
|
||||
TilesZipPath = route.TilesZipPath,
|
||||
CreatedAt = route.CreatedAt,
|
||||
UpdatedAt = route.UpdatedAt
|
||||
};
|
||||
}
|
||||
|
||||
private List<GeoPoint> CreateGeofenceRegionGrid(GeoPoint northWest, GeoPoint southEast, double regionSizeMeters)
|
||||
{
|
||||
var regions = new List<GeoPoint>();
|
||||
|
||||
var northPoint = new GeoPoint(northWest.Lat, (northWest.Lon + southEast.Lon) / 2);
|
||||
var southPoint = new GeoPoint(southEast.Lat, (northWest.Lon + southEast.Lon) / 2);
|
||||
var heightMeters = GeoUtils.CalculateDistance(northPoint, southPoint);
|
||||
|
||||
var westPoint = new GeoPoint((northWest.Lat + southEast.Lat) / 2, northWest.Lon);
|
||||
var eastPoint = new GeoPoint((northWest.Lat + southEast.Lat) / 2, southEast.Lon);
|
||||
var widthMeters = GeoUtils.CalculateDistance(westPoint, eastPoint);
|
||||
|
||||
var numLatSteps = Math.Max(1, (int)Math.Ceiling(heightMeters / regionSizeMeters));
|
||||
var numLonSteps = Math.Max(1, (int)Math.Ceiling(widthMeters / regionSizeMeters));
|
||||
|
||||
var latStep = (northWest.Lat - southEast.Lat) / numLatSteps;
|
||||
var lonStep = (southEast.Lon - northWest.Lon) / numLonSteps;
|
||||
|
||||
for (int latIdx = 0; latIdx < numLatSteps; latIdx++)
|
||||
{
|
||||
for (int lonIdx = 0; lonIdx < numLonSteps; lonIdx++)
|
||||
{
|
||||
var lat = northWest.Lat - (latIdx + 0.5) * latStep;
|
||||
var lon = northWest.Lon + (lonIdx + 0.5) * lonStep;
|
||||
regions.Add(new GeoPoint(lat, lon));
|
||||
}
|
||||
}
|
||||
|
||||
return regions;
|
||||
}
|
||||
}
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.10" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\SatelliteProvider.Common\SatelliteProvider.Common.csproj" />
|
||||
<ProjectReference Include="..\SatelliteProvider.DataAccess\SatelliteProvider.DataAccess.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user