From 6192730d1b7b0d08095d7da88caba73fd07fe99e Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Sun, 25 Jan 2026 21:35:36 +0100 Subject: Bring back basic stop search --- .../Controllers/ArrivalsController.cs | 74 ++++++++++++++++++- .../Controllers/RoutePlannerController.cs | 86 ++++++++++------------ src/Enmarcha.Backend/Services/OtpService.cs | 37 ++++++++++ .../Types/Planner/PlannerResponse.cs | 2 + src/frontend/app/data/PlannerApi.ts | 2 + src/frontend/app/routes/home.tsx | 16 +++- 6 files changed, 165 insertions(+), 52 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 logger, @@ -29,7 +31,8 @@ public partial class ArrivalsController : ControllerBase HttpClient httpClient, ArrivalsPipeline pipeline, FeedService feedService, - IOptions configOptions + IOptions 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 SearchStops([FromQuery] string q) { - // Placeholder for future implementation with Postgres and fuzzy searching - return Ok(new List()); + if (string.IsNullOrWhiteSpace(q)) + { + return Ok(new List()); + } + + const string cacheKey = "arrivals_search_mapped_stops"; + if (!_cache.TryGetValue(cacheKey, out List? 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.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(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> GetCachedStopsAsync() { - const string cacheKey = "otp_all_stops"; - if (_cache.TryGetValue(cacheKey, out List? cachedStops) && cachedStops != null) + const string cacheKey = "planner_mapped_stops"; + if (_cache.TryGetValue(cacheKey, out List? 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>(); + 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(); - } + 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(); - - _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(); - } + 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(1)); + return stops; } } diff --git a/src/Enmarcha.Backend/Services/OtpService.cs b/src/Enmarcha.Backend/Services/OtpService.cs index e4b4846..1484583 100644 --- a/src/Enmarcha.Backend/Services/OtpService.cs +++ b/src/Enmarcha.Backend/Services/OtpService.cs @@ -5,6 +5,7 @@ using Enmarcha.Backend.Helpers; using Enmarcha.Backend.Types.Otp; using Enmarcha.Backend.Types.Planner; using Enmarcha.Backend.Types.Transit; +using Enmarcha.Sources.OpenTripPlannerGql; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; @@ -89,6 +90,42 @@ public class OtpService }; } + public async Task> GetStopsByBboxAsync(double minLon, double minLat, double maxLon, double maxLat) + { + const string cacheKey = "otp_all_stops_detailed"; + if (_cache.TryGetValue(cacheKey, out List? cachedStops) && cachedStops != null) + { + return cachedStops; + } + + try + { + var bbox = new StopTileRequestContent.Bbox(minLon, minLat, maxLon, maxLat); + 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>(); + + if (responseBody is not { IsSuccess: true } || responseBody.Data?.StopsByBbox == null) + { + _logger.LogError("Error fetching stops from OTP for caching"); + return new List(); + } + + var stops = responseBody.Data.StopsByBbox; + _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(); + } + } + private Leg MapLeg(OtpLeg otpLeg) { return new Leg diff --git a/src/Enmarcha.Backend/Types/Planner/PlannerResponse.cs b/src/Enmarcha.Backend/Types/Planner/PlannerResponse.cs index 7713029..3d48831 100644 --- a/src/Enmarcha.Backend/Types/Planner/PlannerResponse.cs +++ b/src/Enmarcha.Backend/Types/Planner/PlannerResponse.cs @@ -83,4 +83,6 @@ public class PlannerSearchResult public double Lat { get; set; } public double Lon { get; set; } public string? Layer { get; set; } + public string? StopId { get; set; } + public string? StopCode { get; set; } } diff --git a/src/frontend/app/data/PlannerApi.ts b/src/frontend/app/data/PlannerApi.ts index 4c78004..8d51ceb 100644 --- a/src/frontend/app/data/PlannerApi.ts +++ b/src/frontend/app/data/PlannerApi.ts @@ -4,6 +4,8 @@ export interface PlannerSearchResult { lat: number; lon: number; layer?: string; + stopId?: string; + stopCode?: string; } export interface RoutePlan { diff --git a/src/frontend/app/routes/home.tsx b/src/frontend/app/routes/home.tsx index 3e7f12d..ff415fd 100644 --- a/src/frontend/app/routes/home.tsx +++ b/src/frontend/app/routes/home.tsx @@ -143,8 +143,20 @@ export default function StopList() { return; } - // Placeholder for future backend search - setSearchResults([]); + try { + const response = await fetch( + `/api/stops/search?q=${encodeURIComponent(searchQuery)}` + ); + if (response.ok) { + const results = await response.json(); + setSearchResults(results); + } else { + setSearchResults([]); + } + } catch (error) { + console.error("Search failed:", error); + setSearchResults([]); + } }, 300); }; -- cgit v1.3