From 3573ecae94aa328591d4b3a6e2d05e4fc9e261fc Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 23:32:50 +0100 Subject: 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> --- .../Controllers/ArrivalsController.cs | 69 ++++++++++++++-------- src/Enmarcha.Backend/Services/FeedService.cs | 53 +++++++++++++++++ .../Queries/StopsInfo.cs | 2 + 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.Create(SortingHelper.SortRouteShortNames)) + routes = _feedService.ConsolidateRoutes(feedId, + s.Routes + .OrderBy(r => r.ShortName, Comparer.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.Create(SortingHelper.SortRouteShortNames)) + lines = _feedService.ConsolidateRoutes(feedId, + (s.Routes ?? []) + .OrderBy(r => r.ShortName, Comparer.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); } + /// + /// 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). + /// + private const int RouteCollapseThreshold = 5; + + /// + /// Deduplicates routes by (always). For the xunta feed only, + /// also collapses groups of 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. + /// + public List ConsolidateRoutes(string feedId, IEnumerable 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(); + 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 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; } -- cgit v1.3