aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Enmarcha.Backend/Controllers/ArrivalsController.cs74
-rw-r--r--src/Enmarcha.Backend/Controllers/RoutePlannerController.cs84
-rw-r--r--src/Enmarcha.Backend/Services/OtpService.cs37
-rw-r--r--src/Enmarcha.Backend/Types/Planner/PlannerResponse.cs2
-rw-r--r--src/frontend/app/data/PlannerApi.ts2
-rw-r--r--src/frontend/app/routes/home.tsx16
6 files changed, 164 insertions, 51 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;
}
}
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<List<StopTileResponse.Stop>> GetStopsByBboxAsync(double minLon, double minLat, double maxLon, double maxLat)
+ {
+ const string cacheKey = "otp_all_stops_detailed";
+ if (_cache.TryGetValue(cacheKey, out List<StopTileResponse.Stop>? 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<GraphClientResponse<StopTileResponse>>();
+
+ if (responseBody is not { IsSuccess: true } || responseBody.Data?.StopsByBbox == null)
+ {
+ _logger.LogError("Error fetching stops from OTP for caching");
+ return new List<StopTileResponse.Stop>();
+ }
+
+ 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<StopTileResponse.Stop>();
+ }
+ }
+
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);
};