587 lines
20 KiB
C#
587 lines
20 KiB
C#
namespace GeoSus.Server;
|
|
|
|
using System.Collections.Concurrent;
|
|
using System.Net.Http.Json;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
/// <summary>
|
|
/// Service for fetching and processing OpenStreetMap data via Overpass API
|
|
/// </summary>
|
|
public class OverpassService
|
|
{
|
|
private readonly ServerConfig _config;
|
|
private readonly ILogger _logger;
|
|
private readonly HttpClient _httpClient;
|
|
private readonly ConcurrentDictionary<string, MapData> _cache = new();
|
|
|
|
public OverpassService(ServerConfig config, ILogger logger)
|
|
{
|
|
_config = config;
|
|
_logger = logger;
|
|
_httpClient = new HttpClient
|
|
{
|
|
Timeout = TimeSpan.FromSeconds(config.OverpassTimeoutSec)
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fetch map data for a circular area, with caching
|
|
/// </summary>
|
|
public async Task<MapData?> GetMapDataAsync(Position center, double radiusMeters)
|
|
{
|
|
var cacheKey = $"{center.Lat:F5}_{center.Lon:F5}_{radiusMeters:F0}";
|
|
|
|
if (_cache.TryGetValue(cacheKey, out var cached))
|
|
{
|
|
_logger.LogDebug("Map data cache hit for {CacheKey}", cacheKey);
|
|
return cached;
|
|
}
|
|
|
|
try
|
|
{
|
|
var mapData = await FetchMapDataAsync(center, radiusMeters);
|
|
if (mapData != null)
|
|
{
|
|
_cache[cacheKey] = mapData;
|
|
|
|
// Clean old cache entries if too many
|
|
if (_cache.Count > _config.OverpassCacheMaxEntries)
|
|
{
|
|
var oldest = _cache.Keys.First();
|
|
_cache.TryRemove(oldest, out _);
|
|
}
|
|
}
|
|
return mapData;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to fetch Overpass data for {Center}, radius {Radius}m", center, radiusMeters);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public async Task<MapData?> FetchMapDataAsync(Position center, double radiusMeters)
|
|
{
|
|
// Build Overpass QL query
|
|
var query = BuildOverpassQuery(center, radiusMeters);
|
|
|
|
_logger.LogInformation("Fetching Overpass data for center {Center}, radius {Radius}m", center, radiusMeters);
|
|
|
|
var response = await _httpClient.PostAsync(
|
|
_config.OverpassApiUrl,
|
|
new FormUrlEncodedContent(new[] { new KeyValuePair<string, string>("data", query) })
|
|
);
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
_logger.LogError("Overpass API returned {StatusCode}: {Content}",
|
|
response.StatusCode, await response.Content.ReadAsStringAsync());
|
|
return null;
|
|
}
|
|
|
|
var json = await response.Content.ReadAsStringAsync();
|
|
var overpassResult = JsonSerializer.Deserialize<OverpassResult>(json, new JsonSerializerOptions
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
|
});
|
|
|
|
if (overpassResult == null)
|
|
{
|
|
_logger.LogError("Failed to parse Overpass response");
|
|
return null;
|
|
}
|
|
|
|
return ProcessOverpassResult(overpassResult, center, radiusMeters);
|
|
}
|
|
|
|
private string BuildOverpassQuery(Position center, double radiusMeters)
|
|
{
|
|
// Query for: roads, paths, buildings, amenities
|
|
// We use "around" to get data within radius of center point
|
|
return $@"
|
|
[out:json][timeout:{_config.OverpassTimeoutSec}];
|
|
(
|
|
// Walkable ways (roads, paths, sidewalks)
|
|
way[""highway""~""footway|path|pedestrian|steps|track|residential|tertiary|secondary|primary|service|living_street|cycleway|bridleway""]
|
|
(around:{radiusMeters},{center.Lat},{center.Lon});
|
|
|
|
// Buildings
|
|
way[""building""]
|
|
(around:{radiusMeters},{center.Lat},{center.Lon});
|
|
relation[""building""]
|
|
(around:{radiusMeters},{center.Lat},{center.Lon});
|
|
|
|
// Interesting POIs for task placement
|
|
node[""amenity""]
|
|
(around:{radiusMeters},{center.Lat},{center.Lon});
|
|
way[""amenity""]
|
|
(around:{radiusMeters},{center.Lat},{center.Lon});
|
|
|
|
// Parks and green areas
|
|
way[""leisure""~""park|playground|garden""]
|
|
(around:{radiusMeters},{center.Lat},{center.Lon});
|
|
relation[""leisure""~""park|playground|garden""]
|
|
(around:{radiusMeters},{center.Lat},{center.Lon});
|
|
);
|
|
out body;
|
|
>;
|
|
out skel qt;
|
|
";
|
|
}
|
|
|
|
private MapData ProcessOverpassResult(OverpassResult result, Position center, double radiusMeters)
|
|
{
|
|
var mapData = new MapData
|
|
{
|
|
Center = center,
|
|
RadiusMeters = radiusMeters,
|
|
FetchedAt = DateTime.UtcNow
|
|
};
|
|
|
|
// Index nodes by ID for quick lookup
|
|
var nodesById = new Dictionary<long, OverpassNode>();
|
|
foreach (var element in result.Elements)
|
|
{
|
|
if (element.Type == "node" && element.Lat.HasValue && element.Lon.HasValue)
|
|
{
|
|
nodesById[element.Id] = new OverpassNode
|
|
{
|
|
Id = element.Id,
|
|
Lat = element.Lat.Value,
|
|
Lon = element.Lon.Value,
|
|
Tags = element.Tags
|
|
};
|
|
}
|
|
}
|
|
|
|
// Process ways
|
|
foreach (var element in result.Elements.Where(e => e.Type == "way"))
|
|
{
|
|
if (element.Nodes == null || element.Nodes.Count < 2) continue;
|
|
|
|
var wayNodes = new List<Position>();
|
|
foreach (var nodeId in element.Nodes)
|
|
{
|
|
if (nodesById.TryGetValue(nodeId, out var node))
|
|
{
|
|
wayNodes.Add(new Position(node.Lat, node.Lon));
|
|
}
|
|
}
|
|
|
|
if (wayNodes.Count < 2) continue;
|
|
|
|
var tags = element.Tags ?? new Dictionary<string, string>();
|
|
|
|
// Classify way type
|
|
if (tags.ContainsKey("building"))
|
|
{
|
|
mapData.Buildings.Add(new MapBuilding
|
|
{
|
|
Id = $"b_{element.Id}",
|
|
Outline = wayNodes,
|
|
BuildingType = tags.GetValueOrDefault("building", "yes"),
|
|
Name = tags.GetValueOrDefault("name"),
|
|
Levels = int.TryParse(tags.GetValueOrDefault("building:levels", "1"), out var l) ? l : 1
|
|
});
|
|
}
|
|
else if (tags.ContainsKey("highway"))
|
|
{
|
|
var highwayType = tags["highway"];
|
|
mapData.Pathways.Add(new MapPathway
|
|
{
|
|
Id = $"p_{element.Id}",
|
|
Points = wayNodes,
|
|
PathType = ClassifyPathType(highwayType),
|
|
Name = tags.GetValueOrDefault("name"),
|
|
IsWalkable = IsWalkableHighway(highwayType),
|
|
Width = EstimatePathWidth(highwayType)
|
|
});
|
|
}
|
|
else if (tags.ContainsKey("leisure"))
|
|
{
|
|
mapData.Areas.Add(new MapArea
|
|
{
|
|
Id = $"a_{element.Id}",
|
|
Outline = wayNodes,
|
|
AreaType = MapAreaType.Park,
|
|
Name = tags.GetValueOrDefault("name")
|
|
});
|
|
}
|
|
else if (tags.ContainsKey("amenity"))
|
|
{
|
|
// Calculate centroid for POI
|
|
var centroid = CalculateCentroid(wayNodes);
|
|
mapData.PointsOfInterest.Add(new MapPOI
|
|
{
|
|
Id = $"poi_{element.Id}",
|
|
Position = centroid,
|
|
PoiType = ClassifyAmenity(tags["amenity"]),
|
|
Name = tags.GetValueOrDefault("name")
|
|
});
|
|
}
|
|
}
|
|
|
|
// Process standalone POI nodes
|
|
foreach (var element in result.Elements.Where(e => e.Type == "node" && e.Tags != null))
|
|
{
|
|
var tags = element.Tags!;
|
|
if (tags.ContainsKey("amenity") && element.Lat.HasValue && element.Lon.HasValue)
|
|
{
|
|
mapData.PointsOfInterest.Add(new MapPOI
|
|
{
|
|
Id = $"poi_{element.Id}",
|
|
Position = new Position(element.Lat.Value, element.Lon.Value),
|
|
PoiType = ClassifyAmenity(tags["amenity"]),
|
|
Name = tags.GetValueOrDefault("name")
|
|
});
|
|
}
|
|
}
|
|
|
|
// Build reachability graph and compute reachable positions
|
|
BuildReachabilityData(mapData, center, radiusMeters);
|
|
|
|
_logger.LogInformation(
|
|
"Processed map data: {Buildings} buildings, {Pathways} pathways, {POIs} POIs, {ReachablePoints} reachable points",
|
|
mapData.Buildings.Count, mapData.Pathways.Count, mapData.PointsOfInterest.Count, mapData.ReachablePositions.Count);
|
|
|
|
return mapData;
|
|
}
|
|
|
|
private void BuildReachabilityData(MapData mapData, Position center, double radiusMeters)
|
|
{
|
|
// Build a graph of walkable paths and find connected components from center
|
|
var graph = new PathGraph();
|
|
|
|
// Add all walkable pathway segments to graph
|
|
foreach (var pathway in mapData.Pathways.Where(p => p.IsWalkable))
|
|
{
|
|
for (int i = 0; i < pathway.Points.Count - 1; i++)
|
|
{
|
|
graph.AddEdge(pathway.Points[i], pathway.Points[i + 1]);
|
|
}
|
|
}
|
|
|
|
// Find all positions reachable from center (or nearest walkable point to center)
|
|
var startNode = graph.FindNearestNode(center);
|
|
if (startNode == null)
|
|
{
|
|
_logger.LogWarning("No walkable paths found near center, using all pathway points as reachable");
|
|
// Fallback: all pathway points are considered reachable
|
|
foreach (var pathway in mapData.Pathways.Where(p => p.IsWalkable))
|
|
{
|
|
mapData.ReachablePositions.AddRange(pathway.Points);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// BFS from start node to find all reachable nodes
|
|
var reachable = graph.GetReachableNodes(startNode.Value, radiusMeters);
|
|
|
|
// Filter to only include points within the play area
|
|
foreach (var pos in reachable)
|
|
{
|
|
if (pos.DistanceTo(center) <= radiusMeters)
|
|
{
|
|
mapData.ReachablePositions.Add(pos);
|
|
}
|
|
}
|
|
|
|
// Also mark pathways as reachable or not
|
|
foreach (var pathway in mapData.Pathways)
|
|
{
|
|
var reachablePoints = pathway.Points.Count(p =>
|
|
mapData.ReachablePositions.Any(rp => rp.DistanceTo(p) < 5));
|
|
pathway.IsFullyReachable = reachablePoints == pathway.Points.Count;
|
|
pathway.IsPartiallyReachable = reachablePoints > 0;
|
|
}
|
|
|
|
_logger.LogDebug("Reachability analysis: {Reachable} of {Total} positions are reachable from center",
|
|
mapData.ReachablePositions.Count,
|
|
mapData.Pathways.Sum(p => p.Points.Count));
|
|
}
|
|
|
|
private PathType ClassifyPathType(string highway)
|
|
{
|
|
return highway switch
|
|
{
|
|
"footway" or "pedestrian" or "path" => PathType.Footway,
|
|
"steps" => PathType.Steps,
|
|
"cycleway" => PathType.Cycleway,
|
|
"residential" or "living_street" => PathType.Residential,
|
|
"service" => PathType.Service,
|
|
"tertiary" or "secondary" or "primary" => PathType.Road,
|
|
"track" or "bridleway" => PathType.Track,
|
|
_ => PathType.Other
|
|
};
|
|
}
|
|
|
|
private bool IsWalkableHighway(string highway)
|
|
{
|
|
// Everything except motorways is considered walkable
|
|
return highway switch
|
|
{
|
|
"motorway" or "motorway_link" or "trunk" or "trunk_link" => false,
|
|
_ => true
|
|
};
|
|
}
|
|
|
|
private double EstimatePathWidth(string highway)
|
|
{
|
|
return highway switch
|
|
{
|
|
"footway" or "path" => 1.5,
|
|
"pedestrian" => 5.0,
|
|
"steps" => 2.0,
|
|
"cycleway" => 2.0,
|
|
"residential" => 6.0,
|
|
"living_street" => 5.0,
|
|
"service" => 4.0,
|
|
"tertiary" => 7.0,
|
|
"secondary" => 9.0,
|
|
"primary" => 11.0,
|
|
_ => 3.0
|
|
};
|
|
}
|
|
|
|
private MapPOIType ClassifyAmenity(string amenity)
|
|
{
|
|
return amenity switch
|
|
{
|
|
"cafe" or "restaurant" or "fast_food" or "bar" or "pub" => MapPOIType.FoodDrink,
|
|
"bank" or "atm" => MapPOIType.Finance,
|
|
"pharmacy" or "hospital" or "clinic" or "doctors" => MapPOIType.Health,
|
|
"school" or "university" or "library" => MapPOIType.Education,
|
|
"fuel" or "parking" => MapPOIType.Transport,
|
|
"shop" or "supermarket" or "convenience" => MapPOIType.Shop,
|
|
"toilets" or "bench" or "drinking_water" => MapPOIType.Amenity,
|
|
_ => MapPOIType.Other
|
|
};
|
|
}
|
|
|
|
private Position CalculateCentroid(List<Position> points)
|
|
{
|
|
if (points.Count == 0) return new Position(0, 0);
|
|
var lat = points.Average(p => p.Lat);
|
|
var lon = points.Average(p => p.Lon);
|
|
return new Position(lat, lon);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get a random reachable position suitable for placing a task or repair station
|
|
/// </summary>
|
|
public Position? GetRandomReachablePosition(MapData mapData, Random random, double minDistFromCenter = 0)
|
|
{
|
|
var candidates = mapData.ReachablePositions
|
|
.Where(p => p.DistanceTo(mapData.Center) >= minDistFromCenter)
|
|
.ToList();
|
|
|
|
if (candidates.Count == 0)
|
|
{
|
|
// Fallback to any pathway point
|
|
candidates = mapData.Pathways
|
|
.Where(p => p.IsWalkable)
|
|
.SelectMany(p => p.Points)
|
|
.Where(p => p.DistanceTo(mapData.Center) <= mapData.RadiusMeters)
|
|
.ToList();
|
|
}
|
|
|
|
if (candidates.Count == 0)
|
|
return null;
|
|
|
|
return candidates[random.Next(candidates.Count)];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get multiple well-distributed reachable positions (e.g., for placing multiple tasks)
|
|
/// </summary>
|
|
public List<Position> GetDistributedReachablePositions(MapData mapData, int count, Random random, double minSpacing = 20)
|
|
{
|
|
var result = new List<Position>();
|
|
var available = mapData.ReachablePositions.ToList();
|
|
|
|
// Shuffle available positions
|
|
for (int i = available.Count - 1; i > 0; i--)
|
|
{
|
|
int j = random.Next(i + 1);
|
|
(available[i], available[j]) = (available[j], available[i]);
|
|
}
|
|
|
|
foreach (var pos in available)
|
|
{
|
|
if (result.Count >= count) break;
|
|
|
|
// Check minimum spacing from already selected positions
|
|
bool tooClose = result.Any(r => r.DistanceTo(pos) < minSpacing);
|
|
if (!tooClose)
|
|
{
|
|
result.Add(pos);
|
|
}
|
|
}
|
|
|
|
// If we couldn't find enough spaced positions, fill with any available
|
|
if (result.Count < count)
|
|
{
|
|
foreach (var pos in available)
|
|
{
|
|
if (result.Count >= count) break;
|
|
if (!result.Contains(pos))
|
|
{
|
|
result.Add(pos);
|
|
}
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get positions near POIs (better for task placement as they have semantic meaning)
|
|
/// </summary>
|
|
public List<Position> GetPOIBasedPositions(MapData mapData, int count, Random random)
|
|
{
|
|
var result = new List<Position>();
|
|
|
|
// Prefer POIs that are reachable
|
|
var reachablePOIs = mapData.PointsOfInterest
|
|
.Where(poi => mapData.ReachablePositions.Any(rp => rp.DistanceTo(poi.Position) < 10))
|
|
.ToList();
|
|
|
|
// Shuffle
|
|
for (int i = reachablePOIs.Count - 1; i > 0; i--)
|
|
{
|
|
int j = random.Next(i + 1);
|
|
(reachablePOIs[i], reachablePOIs[j]) = (reachablePOIs[j], reachablePOIs[i]);
|
|
}
|
|
|
|
foreach (var poi in reachablePOIs.Take(count))
|
|
{
|
|
result.Add(poi.Position);
|
|
}
|
|
|
|
// Fill remaining with distributed positions
|
|
if (result.Count < count)
|
|
{
|
|
var additional = GetDistributedReachablePositions(mapData, count - result.Count, random);
|
|
result.AddRange(additional);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
}
|
|
|
|
#region Overpass API Response Types
|
|
|
|
internal class OverpassResult
|
|
{
|
|
[JsonPropertyName("elements")]
|
|
public List<OverpassElement> Elements { get; set; } = new();
|
|
}
|
|
|
|
internal class OverpassElement
|
|
{
|
|
[JsonPropertyName("type")]
|
|
public string Type { get; set; } = "";
|
|
|
|
[JsonPropertyName("id")]
|
|
public long Id { get; set; }
|
|
|
|
[JsonPropertyName("lat")]
|
|
public double? Lat { get; set; }
|
|
|
|
[JsonPropertyName("lon")]
|
|
public double? Lon { get; set; }
|
|
|
|
[JsonPropertyName("nodes")]
|
|
public List<long>? Nodes { get; set; }
|
|
|
|
[JsonPropertyName("tags")]
|
|
public Dictionary<string, string>? Tags { get; set; }
|
|
}
|
|
|
|
internal class OverpassNode
|
|
{
|
|
public long Id { get; set; }
|
|
public double Lat { get; set; }
|
|
public double Lon { get; set; }
|
|
public Dictionary<string, string>? Tags { get; set; }
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Path Graph for Reachability
|
|
|
|
internal class PathGraph
|
|
{
|
|
private readonly Dictionary<Position, HashSet<Position>> _adjacency = new();
|
|
private readonly List<Position> _allNodes = new();
|
|
|
|
public void AddEdge(Position a, Position b)
|
|
{
|
|
if (!_adjacency.ContainsKey(a))
|
|
{
|
|
_adjacency[a] = new HashSet<Position>();
|
|
_allNodes.Add(a);
|
|
}
|
|
if (!_adjacency.ContainsKey(b))
|
|
{
|
|
_adjacency[b] = new HashSet<Position>();
|
|
_allNodes.Add(b);
|
|
}
|
|
|
|
_adjacency[a].Add(b);
|
|
_adjacency[b].Add(a);
|
|
}
|
|
|
|
public Position? FindNearestNode(Position target)
|
|
{
|
|
if (_allNodes.Count == 0) return null;
|
|
|
|
Position? nearest = null;
|
|
double minDist = double.MaxValue;
|
|
|
|
foreach (var node in _allNodes)
|
|
{
|
|
var dist = node.DistanceTo(target);
|
|
if (dist < minDist)
|
|
{
|
|
minDist = dist;
|
|
nearest = node;
|
|
}
|
|
}
|
|
|
|
return nearest;
|
|
}
|
|
|
|
public HashSet<Position> GetReachableNodes(Position start, double maxDistance)
|
|
{
|
|
var visited = new HashSet<Position>();
|
|
var queue = new Queue<Position>();
|
|
|
|
queue.Enqueue(start);
|
|
visited.Add(start);
|
|
|
|
while (queue.Count > 0)
|
|
{
|
|
var current = queue.Dequeue();
|
|
|
|
if (!_adjacency.TryGetValue(current, out var neighbors))
|
|
continue;
|
|
|
|
foreach (var neighbor in neighbors)
|
|
{
|
|
if (!visited.Contains(neighbor) && neighbor.DistanceTo(start) <= maxDistance)
|
|
{
|
|
visited.Add(neighbor);
|
|
queue.Enqueue(neighbor);
|
|
}
|
|
}
|
|
}
|
|
|
|
return visited;
|
|
}
|
|
}
|
|
|
|
#endregion
|