diff options
| -rw-r--r-- | Directory.Packages.props | 1 | ||||
| -rw-r--r-- | src/Enmarcha.Backend/Controllers/RoutePlannerController.cs | 93 | ||||
| -rw-r--r-- | src/Enmarcha.Backend/Controllers/TileController.cs | 2 | ||||
| -rw-r--r-- | src/Enmarcha.Backend/Enmarcha.Backend.csproj | 3 | ||||
| -rw-r--r-- | src/Enmarcha.Backend/Services/NominatimGeocodingService.cs | 2 | ||||
| -rw-r--r-- | src/frontend/app/contexts/PlannerContext.tsx | 72 |
6 files changed, 146 insertions, 27 deletions
diff --git a/Directory.Packages.props b/Directory.Packages.props index 8fad92f..6889fc5 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -19,5 +19,6 @@ <PackageVersion Include="NetTopologySuite.IO.VectorTiles.Mapbox" Version="1.1.0" /> <PackageVersion Include="CsvHelper" Version="33.1.0" /> + <PackageVersion Include="FuzzySharp" Version="2.0.2" /> </ItemGroup> </Project> diff --git a/src/Enmarcha.Backend/Controllers/RoutePlannerController.cs b/src/Enmarcha.Backend/Controllers/RoutePlannerController.cs index c7201b0..7a03a24 100644 --- a/src/Enmarcha.Backend/Controllers/RoutePlannerController.cs +++ b/src/Enmarcha.Backend/Controllers/RoutePlannerController.cs @@ -4,7 +4,9 @@ using Enmarcha.Sources.OpenTripPlannerGql.Queries; using Enmarcha.Backend.Configuration; using Enmarcha.Backend.Services; using Enmarcha.Backend.Types.Planner; +using FuzzySharp; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; namespace Enmarcha.Backend.Controllers; @@ -18,13 +20,19 @@ public partial class RoutePlannerController : ControllerBase private readonly IGeocodingService _geocodingService; private readonly AppConfiguration _config; private readonly HttpClient _httpClient; + private readonly IMemoryCache _cache; + private readonly FeedService _feedService; + + private const string GaliciaBounds = "-9.3,43.8,-6.7,41.7"; public RoutePlannerController( ILogger<RoutePlannerController> logger, OtpService otpService, IGeocodingService geocodingService, IOptions<AppConfiguration> config, - HttpClient httpClient + HttpClient httpClient, + IMemoryCache cache, + FeedService feedService ) { _logger = logger; @@ -32,6 +40,8 @@ public partial class RoutePlannerController : ControllerBase _geocodingService = geocodingService; _config = config.Value; _httpClient = httpClient; + _cache = cache; + _feedService = feedService; } [HttpGet("autocomplete")] @@ -42,8 +52,33 @@ public partial class RoutePlannerController : ControllerBase return BadRequest("Query cannot be empty"); } - var results = await _geocodingService.GetAutocompleteAsync(query); - return Ok(results); + var nominatimTask = _geocodingService.GetAutocompleteAsync(query); + var stopsTask = GetCachedStopsAsync(); + + await Task.WhenAll(nominatimTask, stopsTask); + + var nominatimResults = await nominatimTask; + var allStops = await stopsTask; + + // Fuzzy search stops + var fuzzyResults = Process.ExtractSorted( + query, + allStops.Select(s => s.Name ?? string.Empty), + cutoff: 60 + ).Take(5).Select(r => allStops[r.Index]).ToList(); + + // Merge results: stops first, then nominatim, deduplicating by coordinates (approx) + var finalResults = new List<PlannerSearchResult>(fuzzyResults); + + foreach (var res in nominatimResults) + { + if (!finalResults.Any(f => Math.Abs(f.Lat - res.Lat) < 0.0001 && Math.Abs(f.Lon - res.Lon) < 0.0001)) + { + finalResults.Add(res); + } + } + + return Ok(finalResults); } [HttpGet("reverse")] @@ -99,4 +134,56 @@ public partial class RoutePlannerController : ControllerBase [LoggerMessage(LogLevel.Error, "Error fetching route planning, received {statusCode} {responseBody}")] partial void LogErrorFetchingRoutes(HttpStatusCode? statusCode, string responseBody); + + private async Task<List<PlannerSearchResult>> GetCachedStopsAsync() + { + const string cacheKey = "otp_all_stops"; + if (_cache.TryGetValue(cacheKey, out List<PlannerSearchResult>? cachedStops) && cachedStops != null) + { + 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>>(); + + 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 = responseBody.Data.StopsByBbox.Select(s => + { + 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<PlannerSearchResult>(); + } + } } diff --git a/src/Enmarcha.Backend/Controllers/TileController.cs b/src/Enmarcha.Backend/Controllers/TileController.cs index 3459997..4065ecd 100644 --- a/src/Enmarcha.Backend/Controllers/TileController.cs +++ b/src/Enmarcha.Backend/Controllers/TileController.cs @@ -186,7 +186,7 @@ public class TileController : ControllerBase "vitrasa" or "tussa" or "tranvias" => "bus", "xunta" => "coach", "renfe" or "feve" => "train", - _ => "unknown", + _ => throw new ArgumentException("Feed ID not a known type", feedId) }; } diff --git a/src/Enmarcha.Backend/Enmarcha.Backend.csproj b/src/Enmarcha.Backend/Enmarcha.Backend.csproj index 463d985..941286b 100644 --- a/src/Enmarcha.Backend/Enmarcha.Backend.csproj +++ b/src/Enmarcha.Backend/Enmarcha.Backend.csproj @@ -18,8 +18,9 @@ <PackageReference Include="NetTopologySuite" /> <PackageReference Include="NetTopologySuite.IO.GeoJSON" /> <PackageReference Include="NetTopologySuite.IO.VectorTiles.Mapbox" /> - + <PackageReference Include="CsvHelper" /> + <PackageReference Include="FuzzySharp" /> </ItemGroup> <ItemGroup> diff --git a/src/Enmarcha.Backend/Services/NominatimGeocodingService.cs b/src/Enmarcha.Backend/Services/NominatimGeocodingService.cs index 8c4b8a5..e8eccb5 100644 --- a/src/Enmarcha.Backend/Services/NominatimGeocodingService.cs +++ b/src/Enmarcha.Backend/Services/NominatimGeocodingService.cs @@ -26,7 +26,7 @@ public class NominatimGeocodingService : IGeocodingService // Nominatim requires a User-Agent if (!_httpClient.DefaultRequestHeaders.Contains("User-Agent")) { - _httpClient.DefaultRequestHeaders.Add("User-Agent", "Enmarcha/0.1 testing only, will replace soon. Written 2025-12-28 (https://enmarcha.app)"); + _httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Compatible; Enmarcha/0.1; https://enmarcha.app)"); } } diff --git a/src/frontend/app/contexts/PlannerContext.tsx b/src/frontend/app/contexts/PlannerContext.tsx index 8b64a2e..5fd0229 100644 --- a/src/frontend/app/contexts/PlannerContext.tsx +++ b/src/frontend/app/contexts/PlannerContext.tsx @@ -63,13 +63,14 @@ const PlannerContext = createContext<PlannerContextType | undefined>(undefined); export const PlannerProvider: React.FC<{ children: React.ReactNode }> = ({ children, }) => { - const [origin, setOrigin] = useState<PlannerSearchResult | null>(null); - const [destination, setDestination] = useState<PlannerSearchResult | null>( + const [origin, setOriginInternal] = useState<PlannerSearchResult | null>( null ); + const [destination, setDestinationInternal] = + useState<PlannerSearchResult | null>(null); const [plan, setPlan] = useState<RoutePlan | null>(null); - const [searchTime, setSearchTime] = useState<Date | null>(null); - const [arriveBy, setArriveBy] = useState(false); + const [searchTime, setSearchTimeInternal] = useState<Date | null>(null); + const [arriveBy, setArriveByInternal] = useState(false); const [selectedItineraryIndex, setSelectedItineraryIndex] = useState< number | null >(null); @@ -77,6 +78,27 @@ export const PlannerProvider: React.FC<{ children: React.ReactNode }> = ({ const [recentPlaces, setRecentPlaces] = useState<PlannerSearchResult[]>([]); const [pickingMode, setPickingMode] = useState<PickingMode>(null); const [isExpanded, setIsExpanded] = useState(false); + const [searchTriggered, setSearchTriggered] = useState(false); + + const setOrigin = useCallback((p: PlannerSearchResult | null) => { + setOriginInternal(p); + setSearchTriggered(false); + }, []); + + const setDestination = useCallback((p: PlannerSearchResult | null) => { + setDestinationInternal(p); + setSearchTriggered(false); + }, []); + + const setSearchTime = useCallback((t: Date | null) => { + setSearchTimeInternal(t); + setSearchTriggered(false); + }, []); + + const setArriveBy = useCallback((a: boolean) => { + setArriveByInternal(a); + setSearchTriggered(false); + }, []); const queryClient = useQueryClient(); @@ -135,7 +157,7 @@ export const PlannerProvider: React.FC<{ children: React.ReactNode }> = ({ destination?.lon, searchTime ?? undefined, arriveBy, - !!(origin && destination && searchTime) + searchTriggered && !!(origin && destination && searchTime) ); // Sync query result to local state and storage @@ -207,11 +229,14 @@ export const PlannerProvider: React.FC<{ children: React.ReactNode }> = ({ ); setPlan(last.plan); } - setOrigin(last.origin); - setDestination(last.destination); - setSearchTime(last.searchTime ? new Date(last.searchTime) : null); - setArriveBy(last.arriveBy ?? false); + setOriginInternal(last.origin); + setDestinationInternal(last.destination); + setSearchTimeInternal( + last.searchTime ? new Date(last.searchTime) : null + ); + setArriveByInternal(last.arriveBy ?? false); setSelectedItineraryIndex(last.selectedItineraryIndex ?? null); + setSearchTriggered(true); } } catch (e) { localStorage.removeItem(STORAGE_KEY); @@ -225,12 +250,13 @@ export const PlannerProvider: React.FC<{ children: React.ReactNode }> = ({ time?: Date, arriveByParam: boolean = false ) => { - setOrigin(from); - setDestination(to); + setOriginInternal(from); + setDestinationInternal(to); const finalTime = time ?? new Date(); - setSearchTime(finalTime); - setArriveBy(arriveByParam); + setSearchTimeInternal(finalTime); + setArriveByInternal(arriveByParam); setSelectedItineraryIndex(null); + setSearchTriggered(true); const toStore: StoredRoute = { timestamp: Date.now(), @@ -275,11 +301,14 @@ export const PlannerProvider: React.FC<{ children: React.ReactNode }> = ({ ); setPlan(route.plan); } - setOrigin(route.origin); - setDestination(route.destination); - setSearchTime(route.searchTime ? new Date(route.searchTime) : null); - setArriveBy(route.arriveBy ?? false); + setOriginInternal(route.origin); + setDestinationInternal(route.destination); + setSearchTimeInternal( + route.searchTime ? new Date(route.searchTime) : null + ); + setArriveByInternal(route.arriveBy ?? false); setSelectedItineraryIndex(route.selectedItineraryIndex ?? null); + setSearchTriggered(true); setHistory((prev) => { const filtered = prev.filter( @@ -304,11 +333,12 @@ export const PlannerProvider: React.FC<{ children: React.ReactNode }> = ({ const clearRoute = useCallback(() => { setPlan(null); - setOrigin(null); - setDestination(null); - setSearchTime(null); - setArriveBy(false); + setOriginInternal(null); + setDestinationInternal(null); + setSearchTimeInternal(null); + setArriveByInternal(false); setSelectedItineraryIndex(null); + setSearchTriggered(false); setHistory([]); localStorage.removeItem(STORAGE_KEY); }, []); |
