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