aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCopilot <198982749+Copilot@users.noreply.github.com>2026-02-28 23:32:50 +0100
committerGitHub <noreply@github.com>2026-02-28 23:32:50 +0100
commit3573ecae94aa328591d4b3a6e2d05e4fc9e261fc (patch)
tree1b4484e6d8be40729d5fba473bb2a5309a825434
parenta0639ec35054f8b430a3d5aefff734f2f23c9f21 (diff)
Deduplicate and collapse routes with excessive short-name variants on stop arrivals (#137)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: arielcostas <94913521+arielcostas@users.noreply.github.com>
-rw-r--r--src/Enmarcha.Backend/Controllers/ArrivalsController.cs69
-rw-r--r--src/Enmarcha.Backend/Services/FeedService.cs53
-rw-r--r--src/Enmarcha.Sources.OpenTripPlannerGql/Queries/StopsInfo.cs2
3 files changed, 98 insertions, 26 deletions
diff --git a/src/Enmarcha.Backend/Controllers/ArrivalsController.cs b/src/Enmarcha.Backend/Controllers/ArrivalsController.cs
index a887c89..50d4012 100644
--- a/src/Enmarcha.Backend/Controllers/ArrivalsController.cs
+++ b/src/Enmarcha.Backend/Controllers/ArrivalsController.cs
@@ -158,17 +158,18 @@ public partial class ArrivalsController : ControllerBase
Latitude = stop.Lat,
Longitude = stop.Lon
},
- Routes = [.. stop.Routes
- .OrderBy(r => SortingHelper.GetRouteSortKey(r.ShortName, r.GtfsId))
- .Select(r => new RouteInfo
- {
- GtfsId = r.GtfsId,
- ShortName = _feedService.NormalizeRouteShortName(feedId, r.ShortName ?? ""),
- Colour = r.Color ?? fallbackColor,
- TextColour = r.TextColor is null or "000000" ?
- ContrastHelper.GetBestTextColour(r.Color ?? fallbackColor) :
- r.TextColor
- })],
+ Routes = [.. _feedService.ConsolidateRoutes(feedId,
+ stop.Routes
+ .OrderBy(r => SortingHelper.GetRouteSortKey(r.ShortName, r.GtfsId))
+ .Select(r => new RouteInfo
+ {
+ GtfsId = r.GtfsId,
+ ShortName = _feedService.NormalizeRouteShortName(feedId, r.ShortName ?? ""),
+ Colour = r.Color ?? fallbackColor,
+ TextColour = r.TextColor is null or "000000" ?
+ ContrastHelper.GetBestTextColour(r.Color ?? fallbackColor) :
+ r.TextColor
+ }))],
Arrivals = [.. arrivals.Where(a => a.Estimate.Minutes >= timeThreshold)],
Usage = context.Usage
});
@@ -224,15 +225,23 @@ public partial class ArrivalsController : ControllerBase
id = s.GtfsId,
code = _feedService.NormalizeStopCode(feedId, s.Code ?? ""),
name = s.Name,
- routes = s.Routes
- .OrderBy(r => r.ShortName, Comparer<string?>.Create(SortingHelper.SortRouteShortNames))
+ routes = _feedService.ConsolidateRoutes(feedId,
+ s.Routes
+ .OrderBy(r => r.ShortName, Comparer<string?>.Create(SortingHelper.SortRouteShortNames))
+ .Select(r => new RouteInfo
+ {
+ GtfsId = r.GtfsId,
+ ShortName = _feedService.NormalizeRouteShortName(feedId, r.ShortName ?? ""),
+ Colour = r.Color ?? fallbackColor,
+ TextColour = r.TextColor is null or "000000" ?
+ ContrastHelper.GetBestTextColour(r.Color ?? fallbackColor) :
+ r.TextColor
+ }))
.Select(r => new
{
- shortName = _feedService.NormalizeRouteShortName(feedId, r.ShortName ?? ""),
- colour = r.Color ?? fallbackColor,
- textColour = r.TextColor is null or "000000" ?
- ContrastHelper.GetBestTextColour(r.Color ?? fallbackColor) :
- r.TextColor
+ shortName = r.ShortName,
+ colour = r.Colour,
+ textColour = r.TextColour
})
.ToList()
};
@@ -269,17 +278,25 @@ public partial class ArrivalsController : ControllerBase
name = name,
latitude = s.Lat,
longitude = s.Lon,
- lines = s.Routes?
- .OrderBy(r => r.ShortName, Comparer<string?>.Create(SortingHelper.SortRouteShortNames))
+ lines = _feedService.ConsolidateRoutes(feedId,
+ (s.Routes ?? [])
+ .OrderBy(r => r.ShortName, Comparer<string?>.Create(SortingHelper.SortRouteShortNames))
+ .Select(r => new RouteInfo
+ {
+ GtfsId = r.GtfsId,
+ ShortName = _feedService.NormalizeRouteShortName(feedId, r.ShortName ?? ""),
+ Colour = r.Color ?? fallbackColor,
+ TextColour = r.TextColor is null or "000000" ?
+ ContrastHelper.GetBestTextColour(r.Color ?? fallbackColor) :
+ r.TextColor
+ }))
.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
+ line = r.ShortName,
+ colour = r.Colour,
+ textColour = r.TextColour
})
- .ToList() ?? [],
+ .ToList(),
label = string.IsNullOrWhiteSpace(code) ? name : $"{name} ({code})"
};
}).ToList();
diff --git a/src/Enmarcha.Backend/Services/FeedService.cs b/src/Enmarcha.Backend/Services/FeedService.cs
index 8b0d3e7..b7496ec 100644
--- a/src/Enmarcha.Backend/Services/FeedService.cs
+++ b/src/Enmarcha.Backend/Services/FeedService.cs
@@ -94,6 +94,59 @@ public class FeedService
return NormalizeRouteShortName(feedId, shortName);
}
+ /// <summary>
+ /// When 5 or more distinct routes share the same 3-character short-name prefix,
+ /// they are collapsed into a single entry showing "XG{prefix}" (xunta feed only).
+ /// </summary>
+ private const int RouteCollapseThreshold = 5;
+
+ /// <summary>
+ /// Deduplicates routes by <see cref="RouteInfo.ShortName"/> (always). For the xunta feed only,
+ /// also collapses groups of <see cref="RouteCollapseThreshold"/> or more routes that share the
+ /// same 3-character prefix into a single entry named "XG{prefix}" (e.g. "XG621").
+ /// Other feeds are returned deduplicated but otherwise unchanged.
+ /// </summary>
+ public List<RouteInfo> ConsolidateRoutes(string feedId, IEnumerable<RouteInfo> routes)
+ {
+ // Deduplicate by short name (case-insensitive)
+ var deduplicated = routes
+ .GroupBy(r => r.ShortName, StringComparer.OrdinalIgnoreCase)
+ .Select(g => g.First())
+ .ToList();
+
+ // Prefix collapsing only applies to xunta routes, which can have dozens of
+ // sub-routes per contract that would otherwise flood the badge list.
+ if (feedId != "xunta")
+ {
+ return deduplicated;
+ }
+
+ // Group by the first 3 characters; collapse groups meeting the threshold.
+ // When collapsing, the first entry's colour is used — routes in the same prefix
+ // group (e.g. all xunta "621.*" lines) share the same operator colour.
+ var result = new List<RouteInfo>();
+ foreach (var group in deduplicated.GroupBy(r => r.ShortName.Length >= 3 ? r.ShortName[..3] : r.ShortName))
+ {
+ var items = group.ToList();
+ if (items.Count >= RouteCollapseThreshold)
+ {
+ result.Add(new RouteInfo
+ {
+ GtfsId = items[0].GtfsId,
+ ShortName = $"XG{group.Key}",
+ Colour = items[0].Colour,
+ TextColour = items[0].TextColour
+ });
+ }
+ else
+ {
+ result.AddRange(items);
+ }
+ }
+
+ return result;
+ }
+
public string NormalizeStopName(string feedId, string name)
{
if (feedId == "vitrasa")
diff --git a/src/Enmarcha.Sources.OpenTripPlannerGql/Queries/StopsInfo.cs b/src/Enmarcha.Sources.OpenTripPlannerGql/Queries/StopsInfo.cs
index 01557c0..f95de15 100644
--- a/src/Enmarcha.Sources.OpenTripPlannerGql/Queries/StopsInfo.cs
+++ b/src/Enmarcha.Sources.OpenTripPlannerGql/Queries/StopsInfo.cs
@@ -19,6 +19,7 @@ public class StopsInfoContent : IGraphRequest<StopsInfoContent.Args>
lat
lon
routes {{
+ gtfsId
shortName
color
textColor
@@ -50,6 +51,7 @@ public class StopsInfoResponse : AbstractGraphResponse
public class RouteDetails
{
+ [JsonPropertyName("gtfsId")] public required string GtfsId { get; set; }
[JsonPropertyName("shortName")] public string? ShortName { get; set; }
[JsonPropertyName("color")] public string? Color { get; set; }
[JsonPropertyName("textColor")] public string? TextColor { get; set; }