aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Directory.Packages.props1
-rw-r--r--src/Enmarcha.Backend/Controllers/RoutePlannerController.cs93
-rw-r--r--src/Enmarcha.Backend/Controllers/TileController.cs2
-rw-r--r--src/Enmarcha.Backend/Enmarcha.Backend.csproj3
-rw-r--r--src/Enmarcha.Backend/Services/NominatimGeocodingService.cs2
-rw-r--r--src/frontend/app/contexts/PlannerContext.tsx72
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);
}, []);