aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Enmarcha.Backend/Controllers/RoutePlannerController.cs66
-rw-r--r--src/Enmarcha.Backend/Controllers/TileController.cs16
-rw-r--r--src/Enmarcha.Backend/Program.cs14
-rw-r--r--src/Enmarcha.Backend/Types/Planner/PlannerResponse.cs2
-rw-r--r--src/frontend/app/components/PlannerOverlay.tsx6
-rw-r--r--src/frontend/app/data/PlannerApi.ts14
-rw-r--r--src/frontend/app/routes/map.tsx125
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}