1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
|
using Enmarcha.Backend.Configuration;
using Enmarcha.Sources.OpenTripPlannerGql;
using Enmarcha.Sources.OpenTripPlannerGql.Queries;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
namespace Enmarcha.Backend.Services;
public class BackofficeSelectorService(
HttpClient httpClient,
IOptions<AppConfiguration> config,
IMemoryCache cache,
ILogger<BackofficeSelectorService> logger)
{
public async Task<SelectorTransitData> GetTransitDataAsync()
{
const string cacheKey = "backoffice_transit";
if (cache.TryGetValue(cacheKey, out SelectorTransitData? cached) && cached is not null)
return cached;
var feeds = config.Value.OtpFeeds;
var today = DateTime.Today.ToString("yyyy-MM-dd");
var query = RoutesListContent.Query(new RoutesListContent.Args(feeds, today));
List<RoutesListResponse.RouteItem> routes = [];
try
{
var req = new HttpRequestMessage(HttpMethod.Post, $"{config.Value.OpenTripPlannerBaseUrl}/gtfs/v1");
req.Content = JsonContent.Create(new GraphClientRequest { Query = query });
var resp = await httpClient.SendAsync(req);
resp.EnsureSuccessStatusCode();
var body = await resp.Content.ReadFromJsonAsync<GraphClientResponse<RoutesListResponse>>();
routes = body?.Data?.Routes ?? [];
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to fetch routes from OTP");
}
var routeDtos = routes
.Select(r =>
{
var (feedId, routeId) = SplitGtfsId(r.GtfsId);
var color = NormalizeColor(r.Color);
return new SelectorRouteItem(feedId, r.GtfsId, $"route#{feedId}:{routeId}", r.ShortName, r.LongName, r.Agency?.Name, color);
})
.OrderBy(r => r.ShortName)
.ToList();
var agencyDtos = routeDtos
.Where(r => r.AgencyName is not null)
.GroupBy(r => r.FeedId)
.Select(g => new SelectorAgencyItem(g.Key, $"agency#{g.Key}", g.First().AgencyName!))
.ToList();
var result = new SelectorTransitData(agencyDtos, routeDtos);
cache.Set(cacheKey, result, TimeSpan.FromHours(1));
return result;
}
public async Task<List<SelectorStopItem>> GetStopsByBboxAsync(
double minLon, double minLat, double maxLon, double maxLat)
{
// Cache per coarse grid (~0.1° cells, roughly 8 km) to reuse across small pans
var cacheKey = $"stops_{minLon:F1}_{minLat:F1}_{maxLon:F1}_{maxLat:F1}";
if (cache.TryGetValue(cacheKey, out List<SelectorStopItem>? cached) && cached is not null)
return cached;
var query = StopTileRequestContent.Query(
new StopTileRequestContent.TileRequestParams(minLon, minLat, maxLon, maxLat));
try
{
var req = new HttpRequestMessage(HttpMethod.Post, $"{config.Value.OpenTripPlannerBaseUrl}/gtfs/v1");
req.Content = JsonContent.Create(new GraphClientRequest { Query = query });
var resp = await httpClient.SendAsync(req);
resp.EnsureSuccessStatusCode();
var body = await resp.Content.ReadFromJsonAsync<GraphClientResponse<StopTileResponse>>();
var stops = (body?.Data?.StopsByBbox ?? [])
.Select(s =>
{
var (feedId, stopId) = SplitGtfsId(s.GtfsId);
var routeItems = (s.Routes ?? []).Select(r =>
{
var (rf, ri) = SplitGtfsId(r.GtfsId);
return new SelectorRouteItem(rf, r.GtfsId, $"route#{rf}:{ri}", r.ShortName, null, null, NormalizeColor(r.Color));
}).ToList();
return new SelectorStopItem(s.GtfsId, $"stop#{feedId}:{stopId}", s.Name, s.Code, s.Lat, s.Lon, routeItems);
})
.ToList();
cache.Set(cacheKey, stops, TimeSpan.FromMinutes(30));
return stops;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to fetch stops from OTP for bbox {MinLon},{MinLat} to {MaxLon},{MaxLat}",
minLon, minLat, maxLon, maxLat);
return [];
}
}
private static (string FeedId, string EntityId) SplitGtfsId(string gtfsId)
{
var parts = gtfsId.Split(':', 2);
return (parts[0], parts.Length > 1 ? parts[1] : gtfsId);
}
private static string? NormalizeColor(string? color)
{
if (string.IsNullOrWhiteSpace(color)) return null;
return color.StartsWith('#') ? color : '#' + color;
}
}
public record SelectorTransitData(List<SelectorAgencyItem> Agencies, List<SelectorRouteItem> Routes);
public record SelectorAgencyItem(string FeedId, string Selector, string Name);
public record SelectorRouteItem(string FeedId, string GtfsId, string Selector, string? ShortName, string? LongName, string? AgencyName, string? Color);
public record SelectorStopItem(string GtfsId, string Selector, string Name, string? Code, double Lat, double Lon, List<SelectorRouteItem> Routes);
|