diff options
| author | Ariel Costas Guerrero <ariel@costas.dev> | 2026-01-25 21:35:36 +0100 |
|---|---|---|
| committer | Ariel Costas Guerrero <ariel@costas.dev> | 2026-01-25 21:35:36 +0100 |
| commit | 6192730d1b7b0d08095d7da88caba73fd07fe99e (patch) | |
| tree | 4a62610d8b8ee42c79380f2e4d3eb1480caccbf5 /src/Enmarcha.Backend/Controllers | |
| parent | f9b7af64550be1320acc84d60184e8c8ce873b94 (diff) | |
Bring back basic stop search
Diffstat (limited to 'src/Enmarcha.Backend/Controllers')
| -rw-r--r-- | src/Enmarcha.Backend/Controllers/ArrivalsController.cs | 74 | ||||
| -rw-r--r-- | src/Enmarcha.Backend/Controllers/RoutePlannerController.cs | 84 |
2 files changed, 109 insertions, 49 deletions
diff --git a/src/Enmarcha.Backend/Controllers/ArrivalsController.cs b/src/Enmarcha.Backend/Controllers/ArrivalsController.cs index eb147fc..b93b3c9 100644 --- a/src/Enmarcha.Backend/Controllers/ArrivalsController.cs +++ b/src/Enmarcha.Backend/Controllers/ArrivalsController.cs @@ -6,6 +6,7 @@ using Enmarcha.Backend.Helpers; using Enmarcha.Backend.Services; using Enmarcha.Backend.Types; using Enmarcha.Backend.Types.Arrivals; +using FuzzySharp; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; @@ -22,6 +23,7 @@ public partial class ArrivalsController : ControllerBase private readonly ArrivalsPipeline _pipeline; private readonly FeedService _feedService; private readonly AppConfiguration _config; + private readonly OtpService _otpService; public ArrivalsController( ILogger<ArrivalsController> logger, @@ -29,7 +31,8 @@ public partial class ArrivalsController : ControllerBase HttpClient httpClient, ArrivalsPipeline pipeline, FeedService feedService, - IOptions<AppConfiguration> configOptions + IOptions<AppConfiguration> configOptions, + OtpService otpService ) { _logger = logger; @@ -38,6 +41,7 @@ public partial class ArrivalsController : ControllerBase _pipeline = pipeline; _feedService = feedService; _config = configOptions.Value; + _otpService = otpService; } [HttpGet("arrivals")] @@ -239,10 +243,72 @@ public partial class ArrivalsController : ControllerBase } [HttpGet("search")] - public IActionResult SearchStops([FromQuery] string q) + public async Task<IActionResult> SearchStops([FromQuery] string q) { - // Placeholder for future implementation with Postgres and fuzzy searching - return Ok(new List<object>()); + if (string.IsNullOrWhiteSpace(q)) + { + return Ok(new List<object>()); + } + + const string cacheKey = "arrivals_search_mapped_stops"; + if (!_cache.TryGetValue(cacheKey, out List<dynamic>? allStops) || allStops == null) + { + var allStopsRaw = await _otpService.GetStopsByBboxAsync(-9.3, 41.7, -6.7, 43.8); + + allStops = allStopsRaw.Select(s => + { + var feedId = s.GtfsId.Split(':', 2)[0]; + var (fallbackColor, _) = _feedService.GetFallbackColourForFeed(feedId); + var code = _feedService.NormalizeStopCode(feedId, s.Code ?? ""); + var name = _feedService.NormalizeStopName(feedId, s.Name); + + return (dynamic)new + { + stopId = s.GtfsId, + stopCode = code, + name = name, + latitude = s.Lat, + longitude = s.Lon, + lines = s.Routes? + .OrderBy(r => r.ShortName, Comparer<string?>.Create(SortingHelper.SortRouteShortNames)) + .Select(r => new + { + line = _feedService.NormalizeRouteShortName(feedId, r.ShortName ?? ""), + colour = r.Color ?? fallbackColor, + textColour = r.TextColor is null or "000000" ? + ContrastHelper.GetBestTextColour(r.Color ?? fallbackColor) : + r.TextColor + }) + .ToList() ?? [], + label = string.IsNullOrWhiteSpace(code) ? name : $"{name} ({code})" + }; + }).ToList(); + + _cache.Set(cacheKey, allStops, TimeSpan.FromHours(1)); + } + + // 1. Exact or prefix matches by stop code + var codeMatches = allStops + .Where(s => s.stopCode != null && ((string)s.stopCode).StartsWith(q, StringComparison.OrdinalIgnoreCase)) + .OrderBy(s => ((string)s.stopCode).Length) + .Take(10) + .ToList(); + + // 2. Fuzzy search stops by label + var fuzzyResults = Process.ExtractSorted( + q, + allStops.Select(s => (string)s.label), + cutoff: 60 + ).Take(15).Select(r => allStops[r.Index]).ToList(); + + // Combine and deduplicate + var results = codeMatches.Concat(fuzzyResults) + .GroupBy(s => s.stopId) + .Select(g => g.First()) + .Take(20) + .ToList(); + + return Ok(results); } [LoggerMessage(LogLevel.Error, "Error fetching stop data, received {statusCode} {responseBody}")] diff --git a/src/Enmarcha.Backend/Controllers/RoutePlannerController.cs b/src/Enmarcha.Backend/Controllers/RoutePlannerController.cs index 89f6c59..3ffb02f 100644 --- a/src/Enmarcha.Backend/Controllers/RoutePlannerController.cs +++ b/src/Enmarcha.Backend/Controllers/RoutePlannerController.cs @@ -61,17 +61,31 @@ public partial class RoutePlannerController : ControllerBase var geocodingResults = await nominatimTask; var allStops = await stopsTask; - // Fuzzy search stops + // 1. Exact or prefix matches by stop code + var codeMatches = allStops + .Where(s => s.StopCode != null && s.StopCode.StartsWith(query, StringComparison.OrdinalIgnoreCase)) + .OrderBy(s => s.StopCode?.Length) // Shorter codes (more exact matches) first + .Take(5) + .ToList(); + + // 2. Fuzzy search stops by label (Name + Code) var fuzzyResults = Process.ExtractSorted( query, - allStops.Select(s => s.Name ?? string.Empty), + allStops.Select(s => s.Label ?? string.Empty), cutoff: 60 - ).Take(4).Select(r => allStops[r.Index]).ToList(); + ).Take(6).Select(r => allStops[r.Index]).ToList(); + + // Merge stops, prioritizing code matches + var stopResults = codeMatches.Concat(fuzzyResults) + .GroupBy(s => s.StopId) + .Select(g => g.First()) + .Take(6) + .ToList(); // Merge results: geocoding first, then stops, deduplicating by coordinates (approx) var finalResults = new List<PlannerSearchResult>(geocodingResults); - foreach (var res in fuzzyResults) + foreach (var res in stopResults) { if (!finalResults.Any(f => Math.Abs(f.Lat - res.Lat) < 0.00001 && Math.Abs(f.Lon - res.Lon) < 0.00001)) { @@ -138,53 +152,33 @@ public partial class RoutePlannerController : ControllerBase private async Task<List<PlannerSearchResult>> GetCachedStopsAsync() { - const string cacheKey = "otp_all_stops"; - if (_cache.TryGetValue(cacheKey, out List<PlannerSearchResult>? cachedStops) && cachedStops != null) + const string cacheKey = "planner_mapped_stops"; + if (_cache.TryGetValue(cacheKey, out List<PlannerSearchResult>? cachedStops)) { - return cachedStops; + return cachedStops!; } - try - { - // Galicia bounds: minLon, minLat, maxLon, maxLat - var bbox = new StopTileRequestContent.Bbox(-9.3, 41.7, -6.7, 43.8); - var query = StopTileRequestContent.Query(bbox); - - var request = new HttpRequestMessage(HttpMethod.Post, $"{_config.OpenTripPlannerBaseUrl}/gtfs/v1"); - request.Content = JsonContent.Create(new GraphClientRequest { Query = query }); - - var response = await _httpClient.SendAsync(request); - var responseBody = await response.Content.ReadFromJsonAsync<GraphClientResponse<StopTileResponse>>(); + var allStopsRaw = await _otpService.GetStopsByBboxAsync(-9.3, 41.7, -6.7, 43.8); - if (responseBody is not { IsSuccess: true } || responseBody.Data?.StopsByBbox == null) - { - _logger.LogError("Error fetching stops from OTP for caching"); - return new List<PlannerSearchResult>(); - } + var stops = allStopsRaw.Select(s => + { + var feedId = s.GtfsId.Split(':')[0]; + var name = _feedService.NormalizeStopName(feedId, s.Name); + var code = _feedService.NormalizeStopCode(feedId, s.Code ?? string.Empty); - var stops = responseBody.Data.StopsByBbox.Select(s => + return new PlannerSearchResult { - var feedId = s.GtfsId.Split(':')[0]; - var name = _feedService.NormalizeStopName(feedId, s.Name); - var code = _feedService.NormalizeStopCode(feedId, s.Code ?? string.Empty); - - return new PlannerSearchResult - { - Name = name, - Label = string.IsNullOrWhiteSpace(code) ? name : $"{name} ({code})", - Lat = s.Lat, - Lon = s.Lon, - Layer = "stop" - }; - }).ToList(); + Name = name, + Label = string.IsNullOrWhiteSpace(code) ? name : $"{name} ({code})", + Lat = s.Lat, + Lon = s.Lon, + Layer = "stop", + StopId = s.GtfsId, + StopCode = code + }; + }).ToList(); - _cache.Set(cacheKey, stops, TimeSpan.FromHours(18)); - return stops; - } - catch (Exception ex) - { - _logger.LogError(ex, "Exception fetching stops from OTP for caching"); - return new List<PlannerSearchResult>(); - } + _cache.Set(cacheKey, stops, TimeSpan.FromHours(1)); + return stops; } } |
