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; /// /// Service for fetching and processing OpenStreetMap data via Overpass API /// public class OverpassService { private readonly ServerConfig _config; private readonly ILogger _logger; private readonly HttpClient _httpClient; private readonly ConcurrentDictionary _cache = new(); public OverpassService(ServerConfig config, ILogger logger) { _config = config; _logger = logger; _httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(config.OverpassTimeoutSec) }; } /// /// Fetch map data for a circular area, with caching /// public async Task 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 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("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(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(); 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(); 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(); // 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 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); } /// /// Get a random reachable position suitable for placing a task or repair station /// 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)]; } /// /// Get multiple well-distributed reachable positions (e.g., for placing multiple tasks) /// public List GetDistributedReachablePositions(MapData mapData, int count, Random random, double minSpacing = 20) { var result = new List(); 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; } /// /// Get positions near POIs (better for task placement as they have semantic meaning) /// public List GetPOIBasedPositions(MapData mapData, int count, Random random) { var result = new List(); // 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 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? Nodes { get; set; } [JsonPropertyName("tags")] public Dictionary? Tags { get; set; } } internal class OverpassNode { public long Id { get; set; } public double Lat { get; set; } public double Lon { get; set; } public Dictionary? Tags { get; set; } } #endregion #region Path Graph for Reachability internal class PathGraph { private readonly Dictionary> _adjacency = new(); private readonly List _allNodes = new(); public void AddEdge(Position a, Position b) { if (!_adjacency.ContainsKey(a)) { _adjacency[a] = new HashSet(); _allNodes.Add(a); } if (!_adjacency.ContainsKey(b)) { _adjacency[b] = new HashSet(); _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 GetReachableNodes(Position start, double maxDistance) { var visited = new HashSet(); var queue = new Queue(); 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