diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/Enmarcha.Backend/Controllers/RoutePlannerController.cs | 66 | ||||
| -rw-r--r-- | src/Enmarcha.Backend/Controllers/TileController.cs | 16 | ||||
| -rw-r--r-- | src/Enmarcha.Backend/Program.cs | 14 | ||||
| -rw-r--r-- | src/Enmarcha.Backend/Types/Planner/PlannerResponse.cs | 2 | ||||
| -rw-r--r-- | src/frontend/app/components/PlannerOverlay.tsx | 6 | ||||
| -rw-r--r-- | src/frontend/app/data/PlannerApi.ts | 14 | ||||
| -rw-r--r-- | src/frontend/app/routes/map.tsx | 125 |
7 files changed, 194 insertions, 49 deletions
diff --git a/src/Enmarcha.Backend/Controllers/RoutePlannerController.cs b/src/Enmarcha.Backend/Controllers/RoutePlannerController.cs index 426170d..a533520 100644 --- a/src/Enmarcha.Backend/Controllers/RoutePlannerController.cs +++ b/src/Enmarcha.Backend/Controllers/RoutePlannerController.cs @@ -46,7 +46,10 @@ public partial class RoutePlannerController : ControllerBase } [HttpGet("autocomplete")] - public async Task<ActionResult<List<PlannerSearchResult>>> Autocomplete([FromQuery] string query) + public async Task<ActionResult<List<PlannerSearchResult>>> Autocomplete( + [FromQuery] string query, + [FromQuery] double? lat = null, + [FromQuery] double? lon = null) { if (string.IsNullOrWhiteSpace(query)) { @@ -83,13 +86,33 @@ public partial class RoutePlannerController : ControllerBase } } + // Sort by distance from the map viewport center when provided + if (lat.HasValue && lon.HasValue) + { + finalResults = [.. finalResults.OrderBy(r => HaversineKm(lat.Value, lon.Value, r.Lat, r.Lon))]; + } + return Ok(finalResults); } + private static double HaversineKm(double lat1, double lon1, double lat2, double lon2) + { + const double R = 6371.0; + var dLat = (lat2 - lat1) * Math.PI / 180.0; + var dLon = (lon2 - lon1) * Math.PI / 180.0; + var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) + + Math.Cos(lat1 * Math.PI / 180.0) * Math.Cos(lat2 * Math.PI / 180.0) + * Math.Sin(dLon / 2) * Math.Sin(dLon / 2); + return R * 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a)); + } + private async Task<List<PlannerSearchResult>> SearchStops(string query) { var stops = await GetCachedStopsAsync(); + // Normalize query for better matching: strip diacritics and punctuation + var normalizedQuery = _feedService.NormalizeRouteNameForMatching(query); + // 1. Exact or prefix matches by stop code var codeMatches = stops .Where(s => s.StopCode != null && s.StopCode.StartsWith(query, StringComparison.OrdinalIgnoreCase)) @@ -97,10 +120,21 @@ public partial class RoutePlannerController : ControllerBase .Take(5) .ToList(); - // 2. Fuzzy search stops by label (Name + Code) - var fuzzyResults = Process.ExtractSorted( - query, - stops.Select(s => s.Label ?? string.Empty), + // 2. Fuzzy search stops by name only (preferential: higher cutoff, name is more meaningful) + var nameOnlyFuzzy = Process.ExtractSorted( + normalizedQuery, + stops.Select(s => _feedService.NormalizeRouteNameForMatching(s.Name ?? string.Empty)), + cutoff: 65 + ) + .OrderByDescending(r => r.Score) + .Take(6) + .Select(r => stops[r.Index]) + .ToList(); + + // 3. Fuzzy search stops by label (Name + Code + Desc) as fallback + var labelFuzzy = Process.ExtractSorted( + normalizedQuery, + stops.Select(s => _feedService.NormalizeRouteNameForMatching(s.Label ?? string.Empty)), cutoff: 60 ) .OrderByDescending(r => r.Score) @@ -108,8 +142,8 @@ public partial class RoutePlannerController : ControllerBase .Select(r => stops[r.Index]) .ToList(); - // Merge stops, prioritizing code matches - var stopResults = codeMatches.Concat(fuzzyResults) + // Merge stops: code matches first, then name matches, then label matches + var stopResults = codeMatches.Concat(nameOnlyFuzzy).Concat(labelFuzzy) .GroupBy(s => s.StopId) .Select(g => g.First()) .Take(6) @@ -182,11 +216,12 @@ public partial class RoutePlannerController : ControllerBase var allStopsRaw = await _otpService.GetStopsByBboxAsync(-9.3, 41.7, -6.7, 43.8); - var stops = allStopsRaw.Select(s => + var mappedStops = 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 (color, textColor) = _feedService.GetFallbackColourForFeed(feedId); return new PlannerSearchResult { @@ -196,8 +231,21 @@ public partial class RoutePlannerController : ControllerBase Lon = s.Lon, Layer = "stop", StopId = s.GtfsId, - StopCode = code + StopCode = code, + Color = color, + TextColor = textColor }; + }); + + // For xunta stops, deduplicate by base code (strip first 2 chars) + // e.g. "1007958" and "2007958" refer to the same physical stop + var xuntaSeenBaseCodes = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + var stops = mappedStops.Where(s => + { + if (s.StopId?.StartsWith("xunta:") != true) return true; + var code = s.StopCode ?? string.Empty; + var baseCode = code.Length > 2 ? code[2..] : code; + return xuntaSeenBaseCodes.Add(baseCode); }).ToList(); _cache.Set(cacheKey, stops, TimeSpan.FromHours(1)); diff --git a/src/Enmarcha.Backend/Controllers/TileController.cs b/src/Enmarcha.Backend/Controllers/TileController.cs index 0d96a14..896fdb9 100644 --- a/src/Enmarcha.Backend/Controllers/TileController.cs +++ b/src/Enmarcha.Backend/Controllers/TileController.cs @@ -98,6 +98,7 @@ public class TileController : ControllerBase var stopsLayer = new Layer { Name = "stops" }; var features = new List<Feature>(); + var xuntaSeenBaseCodes = new HashSet<string>(StringComparer.OrdinalIgnoreCase); responseBody.Data?.StopsByBbox?.ForEach(stop => { @@ -110,6 +111,17 @@ public class TileController : ControllerBase return; } + // For xunta stops, deduplicate by stripping the first 2 chars of the code + // (e.g. "1007958" and "2007958" refer to the same physical stop) + if (feedId == "xunta" && codeWithinFeed.Length > 2) + { + var baseCode = codeWithinFeed[2..]; + if (!xuntaSeenBaseCodes.Add(baseCode)) + { + return; + } + } + // TODO: Duplicate from ArrivalsController var (Color, TextColor) = _feedService.GetFallbackColourForFeed(idParts[0]); var distinctRoutes = GetDistinctRoutes(feedId, stop.Routes ?? []); @@ -127,7 +139,9 @@ public class TileController : ControllerBase { "code", $"{idParts[0]}:{codeWithinFeed}" }, { "name", FeedService.NormalizeStopName(feedId, stop.Name) }, { "icon", GetIconNameForFeed(feedId) }, - { "transitKind", TransitKindClassifier.StringByFeed(feedId) } + { "transitKind", TransitKindClassifier.StringByFeed(feedId) }, + { "color", Color }, + { "textColor", TextColor } } }; diff --git a/src/Enmarcha.Backend/Program.cs b/src/Enmarcha.Backend/Program.cs index 785afe5..8988bc5 100644 --- a/src/Enmarcha.Backend/Program.cs +++ b/src/Enmarcha.Backend/Program.cs @@ -209,6 +209,13 @@ builder.Services.AddSingleton<FareService>(); builder.Services.AddScoped<IPushNotificationService, PushNotificationService>(); builder.Services.AddHostedService<AlertPhaseNotificationHostedService>(); +builder.Services.AddScoped<IArrivalsProcessor, VitrasaRealTimeProcessor>(); +builder.Services.AddScoped<IArrivalsProcessor, CorunaRealTimeProcessor>(); +builder.Services.AddScoped<IArrivalsProcessor, TussaRealTimeProcessor>(); +builder.Services.AddScoped<IArrivalsProcessor, CtagShuttleRealTimeProcessor>(); +builder.Services.AddScoped<IArrivalsProcessor, VitrasaUsageProcessor>(); +builder.Services.AddScoped<IArrivalsProcessor, RenfeRealTimeProcessor>(); + builder.Services.AddScoped<IArrivalsProcessor, FilterAndSortProcessor>(); builder.Services.AddScoped<IArrivalsProcessor, NextStopsProcessor>(); builder.Services.AddScoped<IArrivalsProcessor, ShapeProcessor>(); @@ -217,13 +224,6 @@ builder.Services.AddScoped<IArrivalsProcessor, XuntaNormalizationProcessor>(); builder.Services.AddScoped<IArrivalsProcessor, TranviasNormalizationProcessor>(); builder.Services.AddScoped<IArrivalsProcessor, ColourProcessor>(); -builder.Services.AddScoped<IArrivalsProcessor, VitrasaRealTimeProcessor>(); -builder.Services.AddScoped<IArrivalsProcessor, CorunaRealTimeProcessor>(); -builder.Services.AddScoped<IArrivalsProcessor, TussaRealTimeProcessor>(); -builder.Services.AddScoped<IArrivalsProcessor, CtagShuttleRealTimeProcessor>(); -builder.Services.AddScoped<IArrivalsProcessor, VitrasaUsageProcessor>(); -builder.Services.AddScoped<IArrivalsProcessor, RenfeRealTimeProcessor>(); - builder.Services.AddScoped<ArrivalsPipeline>(); // builder.Services.AddKeyedScoped<IGeocodingService, NominatimGeocodingService>("Nominatim"); diff --git a/src/Enmarcha.Backend/Types/Planner/PlannerResponse.cs b/src/Enmarcha.Backend/Types/Planner/PlannerResponse.cs index 3d48831..af88ccc 100644 --- a/src/Enmarcha.Backend/Types/Planner/PlannerResponse.cs +++ b/src/Enmarcha.Backend/Types/Planner/PlannerResponse.cs @@ -85,4 +85,6 @@ public class PlannerSearchResult public string? Layer { get; set; } public string? StopId { get; set; } public string? StopCode { get; set; } + public string? Color { get; set; } + public string? TextColor { get; set; } } diff --git a/src/frontend/app/components/PlannerOverlay.tsx b/src/frontend/app/components/PlannerOverlay.tsx index d42bd94..39f6848 100644 --- a/src/frontend/app/components/PlannerOverlay.tsx +++ b/src/frontend/app/components/PlannerOverlay.tsx @@ -247,7 +247,11 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({ setRemoteLoading(true); const t = setTimeout(async () => { try { - const results = await searchPlaces(q); + const results = await searchPlaces( + q, + userLocation?.latitude, + userLocation?.longitude + ); if (!cancelled) setRemoteResults(results); } finally { if (!cancelled) setRemoteLoading(false); diff --git a/src/frontend/app/data/PlannerApi.ts b/src/frontend/app/data/PlannerApi.ts index 6f39f50..09f62a6 100644 --- a/src/frontend/app/data/PlannerApi.ts +++ b/src/frontend/app/data/PlannerApi.ts @@ -6,6 +6,8 @@ export interface PlannerSearchResult { layer?: string; stopId?: string; stopCode?: string; + color?: string; + textColor?: string; } export interface RoutePlan { @@ -74,11 +76,15 @@ export interface Step { } export async function searchPlaces( - query: string + query: string, + lat?: number, + lon?: number ): Promise<PlannerSearchResult[]> { - const response = await fetch( - `/api/planner/autocomplete?query=${encodeURIComponent(query)}` - ); + let url = `/api/planner/autocomplete?query=${encodeURIComponent(query)}`; + if (lat !== undefined && lon !== undefined) { + url += `&lat=${lat}&lon=${lon}`; + } + const response = await fetch(url); if (!response.ok) return []; return response.json(); } diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx index 9774a0f..dae92f3 100644 --- a/src/frontend/app/routes/map.tsx +++ b/src/frontend/app/routes/map.tsx @@ -32,6 +32,16 @@ const mapSearchState: { query: string; results: PlannerSearchResult[] } = { results: [], }; +const FEED_LABELS: Record<string, string> = { + vitrasa: "Vitrasa", + tussa: "Tussa", + tranvias: "Tranvías", + ourense: "TUORTE", + lugo: "AUCORSA", + xunta: "Xunta", + renfe: "Renfe", +}; + interface MapSearchBarProps { mapRef: React.RefObject<MapRef | null>; } @@ -82,7 +92,8 @@ function MapSearchBar({ mapRef }: MapSearchBarProps) { debounceRef.current = setTimeout(async () => { setLoading(true); try { - const res = await searchPlaces(q.trim()); + const center = mapRef.current?.getCenter(); + const res = await searchPlaces(q.trim(), center?.lat, center?.lng); setResults(res); mapSearchState.results = res; setShowResults(true); @@ -97,9 +108,11 @@ function MapSearchBar({ mapRef }: MapSearchBarProps) { const handleSelect = (place: PlannerSearchResult) => { const map = mapRef.current; if (map) { - map.flyTo({ center: [place.lon, place.lat], zoom: 15, duration: 800 }); + const zoom = place.layer === "stop" ? 17 : 16; + map.flyTo({ center: [place.lon, place.lat], zoom, duration: 800 }); } - // Keep results visible so user can pick another without retyping + setShowResults(false); + mapSearchState.results = []; }; const handleClear = () => { @@ -129,6 +142,10 @@ function MapSearchBar({ mapRef }: MapSearchBarProps) { onChange={(e) => handleQueryChange(e.target.value)} onFocus={() => { if (results.length > 0) setShowResults(true); + // Re-trigger search if we have a query but results were cleared + if (results.length === 0 && query.trim().length >= 2) { + handleQueryChange(query); + } }} /> {loading ? ( @@ -152,25 +169,50 @@ function MapSearchBar({ mapRef }: MapSearchBarProps) { {showResults && results.length > 0 && ( <div className="bg-white dark:bg-slate-900 rounded-xl shadow-xl border border-slate-200 dark:border-slate-700 overflow-hidden"> <div className="max-h-60 overflow-y-auto divide-y divide-slate-100 dark:divide-slate-800"> - {results.map((place, i) => ( - <button - key={`${place.lat}-${place.lon}-${i}`} - className="w-full flex items-start gap-3 px-4 py-3 text-left hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors text-sm" - onClick={() => handleSelect(place)} - > - <MapPin className="w-4 h-4 text-primary-600 shrink-0 mt-0.5" /> - <div className="min-w-0"> - <div className="font-medium text-slate-900 dark:text-slate-100 truncate"> - {place.name} - </div> - {place.label && place.label !== place.name && ( - <div className="text-xs text-slate-500 dark:text-slate-400 truncate"> - {place.label} - </div> + {results.map((place, i) => { + const isStop = place.layer === "stop"; + const feedId = place.stopId?.split(":")[0]; + const feedLabel = feedId ? (FEED_LABELS[feedId] ?? feedId) : undefined; + const subtitle = isStop && feedLabel && place.stopCode + ? `${feedLabel} · ${place.stopCode}` + : isStop && feedLabel + ? feedLabel + : !isStop && place.label && place.label !== place.name + ? place.label + : null; + return ( + <button + key={`${place.lat}-${place.lon}-${i}`} + className="w-full flex items-start gap-3 px-4 py-3 text-left hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors text-sm" + onClick={() => handleSelect(place)} + > + {isStop && place.color ? ( + <span + className="shrink-0 mt-0.5 rounded-full" + style={{ + width: 16, + height: 16, + backgroundColor: place.color, + display: "inline-block", + flexShrink: 0, + }} + /> + ) : ( + <MapPin className="w-4 h-4 text-primary-600 shrink-0 mt-0.5" /> )} - </div> - </button> - ))} + <div className="min-w-0"> + <div className="font-medium text-slate-900 dark:text-slate-100 truncate"> + {place.name} + </div> + {subtitle && ( + <div className="text-xs text-slate-500 dark:text-slate-400 truncate"> + {subtitle} + </div> + )} + </div> + </button> + ); + })} </div> </div> )} @@ -194,7 +236,7 @@ export default function StopMap() { >(null); const [isSheetOpen, setIsSheetOpen] = useState(false); const [disambiguationStops, setDisambiguationStops] = useState< - Array<StopSheetProps["stop"]> + Array<StopSheetProps["stop"] & { color?: string }> >([]); const mapRef = useRef<MapRef>(null); @@ -323,6 +365,9 @@ export default function StopMap() { // Handle click events on clusters and individual stops const onMapClick = (e: MapLayerMouseEvent) => { + // Clicking anywhere on the map closes the disambiguation panel + setDisambiguationStops([]); + const features = e.features; if (!features || features.length === 0) { console.debug( @@ -349,7 +394,7 @@ export default function StopMap() { // Multiple overlapping stops – deduplicate by stop id and ask the user const seen = new Set<string>(); - const candidates: Array<StopSheetProps["stop"]> = []; + const candidates: Array<StopSheetProps["stop"] & { color?: string }> = []; for (const f of stopFeatures) { const id: string = f.properties!.id; if (!seen.has(id)) { @@ -358,16 +403,29 @@ export default function StopMap() { stopId: id, stopCode: f.properties!.code, name: f.properties!.name || "Unknown Stop", + color: f.properties!.color as string | undefined, }); } } - if (candidates.length === 1) { + // For xunta stops, further deduplicate by base code (strip first 2 chars) + // e.g. "xunta:1007958" and "xunta:2007958" → keep only the first seen + const xuntaBaseSeen = new Set<string>(); + const deduped = candidates.filter((stop) => { + if (!stop.stopId?.startsWith("xunta:")) return true; + const code = stop.stopCode ?? ""; + const base = code.startsWith("xunta:") ? code.slice("xunta:".length + 2) : code.slice(2); + if (xuntaBaseSeen.has(base)) return false; + xuntaBaseSeen.add(base); + return true; + }); + + if (deduped.length === 1) { // After deduplication only one stop remains - setSelectedStop(candidates[0]); + setSelectedStop(deduped[0]); setIsSheetOpen(true); } else { - setDisambiguationStops(candidates); + setDisambiguationStops(deduped); } }; @@ -473,6 +531,7 @@ export default function StopMap() { onMapClick(e); }} onContextMenu={handleContextMenu} + onDragStart={() => setDisambiguationStops([])} attributionControl={{ compact: false }} > <Source @@ -649,7 +708,19 @@ export default function StopMap() { setIsSheetOpen(true); }} > - <MapPin className="w-4 h-4 flex-shrink-0 text-primary-600" /> + {stop.color ? ( + <span + className="rounded-full shrink-0" + style={{ + width: 18, + height: 18, + backgroundColor: stop.color, + display: "inline-block", + }} + /> + ) : ( + <MapPin className="w-4 h-4 shrink-0 text-primary-600" /> + )} <div> <div className="font-medium text-slate-900 dark:text-slate-100 text-sm"> {stop.name} |
