From 51c5b376f9b6ad3ec05da6f8933c5b6a46c29d60 Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Wed, 11 Feb 2026 16:50:34 +0100 Subject: Implement better route sorting for Vitrasa Closes #134 --- routes.json | 442 +++++++++++++++++++++ .../Controllers/ArrivalsController.cs | 5 +- .../Controllers/TransitController.cs | 2 +- src/Enmarcha.Backend/Helpers/SortingHelper.cs | 88 ++++ 4 files changed, 532 insertions(+), 5 deletions(-) create mode 100644 routes.json diff --git a/routes.json b/routes.json new file mode 100644 index 0000000..7b4cc00 --- /dev/null +++ b/routes.json @@ -0,0 +1,442 @@ +[ + { + "id": "vitrasa:8", + "shortName": "A", + "longName": "ARENAL – PORTO / UNIVERSIDADE", + "color": "#77298F", + "textColor": "#FFFFFF", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 66 + }, + { + "id": "vitrasa:104", + "shortName": "H", + "longName": "NAVIA - BOUZAS - HOSPITAL ALVARO CUNQUEIRO", + "color": "#0060A8", + "textColor": "#FFFFFF", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 1 + }, + { + "id": "vitrasa:751", + "shortName": "LZD", + "longName": "STELLANTIS - ALV. CUNQUEIRO", + "color": "#3D4EA7", + "textColor": "#FFFFFF", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 2 + }, + { + "id": "vitrasa:304", + "shortName": "PTL", + "longName": "PARQUE TECNOLÓXICO", + "color": "#96DC99", + "textColor": "#000000", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 2 + }, + { + "id": "vitrasa:1", + "shortName": "C1", + "longName": "CIRCULAR CENTRO", + "color": "#ED4713", + "textColor": "#FFFFFF", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 91 + }, + { + "id": "vitrasa:101", + "shortName": "H1", + "longName": "POLICARPO SANZ – HOSPITAL ÁLVARO CUNQUEIRO", + "color": "#0060A8", + "textColor": "#FFFFFF", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 30 + }, + { + "id": "vitrasa:30", + "shortName": "N1", + "longName": "SAMIL – BUENOS AIRES", + "color": "#C44848", + "textColor": "#FFFFFF", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 8 + }, + { + "id": "vitrasa:301", + "shortName": "PSA1", + "longName": "STELLANTIS - G.BARBON", + "color": "#009900", + "textColor": "#FFFFFF", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 4 + }, + { + "id": "vitrasa:201", + "shortName": "U1", + "longName": "LANZADEIRA PZA. AMÉRICA – UNIVERSIDADE", + "color": "#AC6404", + "textColor": "#FFFFFF", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 71 + }, + { + "id": "vitrasa:102", + "shortName": "H2", + "longName": "GREGORIO ESPINO – HOSPITAL ÁLVARO CUNQU", + "color": "#0060A8", + "textColor": "#FFFFFF", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 47 + }, + { + "id": "vitrasa:202", + "shortName": "U2", + "longName": "LANZADEIRA PZA. DE ESPAÑA – UNIVERSIDADE", + "color": "#AC6404", + "textColor": "#FFFFFF", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 66 + }, + { + "id": "vitrasa:3001", + "shortName": "C3d", + "longName": "BOUZAS/COIA – ENCARNACIÓN Por G. BARBÓN", + "color": "#FFCC00", + "textColor": "#000000", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 120 + }, + { + "id": "vitrasa:3002", + "shortName": "C3i", + "longName": "BOUZAS/COIA – ENCARNACIÓN por TRV. VIGO", + "color": "#FFCC00", + "textColor": "#000000", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 122 + }, + { + "id": "vitrasa:105", + "shortName": "H3", + "longName": "GARCÍA BARBÓN – HOSPITAL ÁLVARO CUNQUEIRO", + "color": "#0060A8", + "textColor": "#FFFFFF", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 4 + }, + { + "id": "vitrasa:4001", + "shortName": "4A", + "longName": "ARAGÓN - COIA", + "color": "#009900", + "textColor": "#FFFFFF", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 61 + }, + { + "id": "vitrasa:4003", + "shortName": "4C", + "longName": "G. ESPINO - COIA", + "color": "#009900", + "textColor": "#FFFFFF", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 53 + }, + { + "id": "vitrasa:3305", + "shortName": "N4", + "longName": "NAVIA - G. ESPINO", + "color": "#C44848", + "textColor": "#FFFFFF", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 0 + }, + { + "id": "vitrasa:4004", + "shortName": "PSA4", + "longName": "STELLANTIS - G. BARBON", + "color": "#009900", + "textColor": "#FFFFFF", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 3 + }, + { + "id": "vitrasa:5001", + "shortName": "5A", + "longName": "NAVIA-TRV. DE VIGO", + "color": "#CCAFAF", + "textColor": "#000000", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 61 + }, + { + "id": "vitrasa:5004", + "shortName": "5B", + "longName": "NAVIA-S. BADÍA", + "color": "#DFD5D5", + "textColor": "#000000", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 61 + }, + { + "id": "vitrasa:6", + "shortName": "6", + "longName": "HOSP. ALVARO CUNQUEIRO - BEADE – PZA. ESPAÑA", + "color": "#CC3399", + "textColor": "#FFFFFF", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 33 + }, + { + "id": "vitrasa:7", + "shortName": "7", + "longName": "PZA. ESPAÑA – GARRIDA / ZAMÁNS / SOBREIRA", + "color": "#96DC99", + "textColor": "#000000", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 67 + }, + { + "id": "vitrasa:9002", + "shortName": "9B", + "longName": "P. SANZ - RABADEIRA", + "color": "#F4CA8C", + "textColor": "#000000", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 34 + }, + { + "id": "vitrasa:10", + "shortName": "10", + "longName": "TEIS – CANIDO – SAIÁNS", + "color": "#993300", + "textColor": "#FFFFFF", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 36 + }, + { + "id": "vitrasa:11", + "shortName": "11", + "longName": "SAN MIGUEL - CABRAL", + "color": "#D9556D", + "textColor": "#FFFFFF", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 65 + }, + { + "id": "vitrasa:1201", + "shortName": "12A", + "longName": "SAIÁNS – MUÍÑOS – HOSP. MEIXOEIRO", + "color": "#6A96BE", + "textColor": "#FFFFFF", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 34 + }, + { + "id": "vitrasa:1202", + "shortName": "12B", + "longName": "HOSP. ALVARO CUNQUEIRO – HOSP. MEIXOEIRO", + "color": "#6A96BE", + "textColor": "#FFFFFF", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 33 + }, + { + "id": "vitrasa:13", + "shortName": "13", + "longName": "TEIXUGUEIRAS – HOSP. MEIXOEIRO", + "color": "#00B0F0", + "textColor": "#000000", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 47 + }, + { + "id": "vitrasa:14", + "shortName": "14", + "longName": "CHANS – GRAN VÍA", + "color": "#818E7E", + "textColor": "#FFFFFF", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 35 + }, + { + "id": "vitrasa:1501", + "shortName": "15A", + "longName": "CABRAL - SAMIL", + "color": "#D8A8CE", + "textColor": "#000000", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 68 + }, + { + "id": "vitrasa:1506", + "shortName": "15B", + "longName": "HOSP. MEIXOEIRO - SAMIL / NAVIA", + "color": "#D8A8CE", + "textColor": "#000000", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 33 + }, + { + "id": "vitrasa:1507", + "shortName": "15C", + "longName": "UNIVERSIDADE – SAMIL / NAVIA", + "color": "#D8A8CE", + "textColor": "#000000", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 35 + }, + { + "id": "vitrasa:16", + "shortName": "16", + "longName": "COIA – ESTACIÓN FF.CC. (GUIXAR)", + "color": "#818E7E", + "textColor": "#FFFFFF", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 32 + }, + { + "id": "vitrasa:17", + "shortName": "17", + "longName": "MATAMÁ (BALSA) – A GUÍA / RÍOS", + "color": "#D6F51F", + "textColor": "#000000", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 65 + }, + { + "id": "vitrasa:18", + "shortName": "18A", + "longName": "AREAL/COLÓN - SÁRDOMA/POULEIRA", + "color": "#D450A8", + "textColor": "#FFFFFF", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 28 + }, + { + "id": "vitrasa:1801", + "shortName": "18B", + "longName": "URZAIZ / P.ESPAÑA - POULEIRA", + "color": "#D450A8", + "textColor": "#FFFFFF", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 9 + }, + { + "id": "vitrasa:1802", + "shortName": "18H", + "longName": "URZAIZ / P. ESPAÑA - H. ALV. CUNQUEIRO", + "color": "#D450A8", + "textColor": "#FFFFFF", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 6 + }, + { + "id": "vitrasa:23", + "shortName": "23", + "longName": "M. ECHEGARAY – G. ESPINO", + "color": "#B9C8E4", + "textColor": "#000000", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 48 + }, + { + "id": "vitrasa:24", + "shortName": "24", + "longName": "POULO – ESTACIÓN FF.CC. (GUIXAR)", + "color": "#BFBFBF", + "textColor": "#000000", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 30 + }, + { + "id": "vitrasa:25", + "shortName": "25", + "longName": "PZA. ESPAÑA – SABAXÁNS / CAEIRO", + "color": "#AC6404", + "textColor": "#FFFFFF", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 31 + }, + { + "id": "vitrasa:27", + "shortName": "27", + "longName": "BEADE (C. CULTURAL) – RABADEIRA", + "color": "#704A2A", + "textColor": "#FFFFFF", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 34 + }, + { + "id": "vitrasa:28", + "shortName": "28", + "longName": "VIGOZOO - SAN PAIO - BOUZAS", + "color": "#B0BDFE", + "textColor": "#000000", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 14 + }, + { + "id": "vitrasa:29", + "shortName": "29", + "longName": "FRAGOSELO / S. ANDRÉS – PZA. ESPAÑA", + "color": "#F8B85A", + "textColor": "#000000", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 56 + }, + { + "id": "vitrasa:31", + "shortName": "31", + "longName": "SAN LOURENZO – HOSP. MEIXOEIRO", + "color": "#F57F00", + "textColor": "#FFFFFF", + "sortOrder": null, + "agencyName": "Viguesa de Transportes S.L.", + "tripCount": 60 + } +] diff --git a/src/Enmarcha.Backend/Controllers/ArrivalsController.cs b/src/Enmarcha.Backend/Controllers/ArrivalsController.cs index b93b3c9..5c64efa 100644 --- a/src/Enmarcha.Backend/Controllers/ArrivalsController.cs +++ b/src/Enmarcha.Backend/Controllers/ArrivalsController.cs @@ -157,10 +157,7 @@ public partial class ArrivalsController : ControllerBase Longitude = stop.Lon }, Routes = [.. stop.Routes - .OrderBy( - r => r.ShortName, - Comparer.Create(SortingHelper.SortRouteShortNames) - ) + .OrderBy(r => SortingHelper.GetRouteSortKey(r.ShortName, r.GtfsId)) .Select(r => new RouteInfo { GtfsId = r.GtfsId, diff --git a/src/Enmarcha.Backend/Controllers/TransitController.cs b/src/Enmarcha.Backend/Controllers/TransitController.cs index 93129f9..a70f46e 100644 --- a/src/Enmarcha.Backend/Controllers/TransitController.cs +++ b/src/Enmarcha.Backend/Controllers/TransitController.cs @@ -68,7 +68,7 @@ public class TransitController : ControllerBase var routes = response.Data.Routes .Select(_otpService.MapRoute) - .OrderBy(r => r.ShortName, Comparer.Create(SortingHelper.SortRouteShortNames)) + .OrderBy(r => SortingHelper.GetRouteSortKey(r.ShortName, r.Id)) .ToList(); _cache.Set(cacheKey, routes, TimeSpan.FromHours(1)); diff --git a/src/Enmarcha.Backend/Helpers/SortingHelper.cs b/src/Enmarcha.Backend/Helpers/SortingHelper.cs index c70dab2..e2267af 100644 --- a/src/Enmarcha.Backend/Helpers/SortingHelper.cs +++ b/src/Enmarcha.Backend/Helpers/SortingHelper.cs @@ -2,6 +2,10 @@ namespace Enmarcha.Backend.Helpers; public class SortingHelper { + /// + /// Generic route short name comparison. Non-numeric names sort first, then numeric by value, + /// then alphabetical tiebreak. Used for per-stop sorting where route IDs may not be available. + /// public static int SortRouteShortNames(string? a, string? b) { if (a == null && b == null) return 0; @@ -32,4 +36,88 @@ public class SortingHelper return string.Compare(a, b, StringComparison.OrdinalIgnoreCase); } + /// + /// Feed-aware route sort key. For Vitrasa, applies custom group ordering: + /// Circular (C*) → Regular numbered → Hospital (H*) → Others (N*, PSA*, U*, specials). + /// For other feeds, uses generic numeric-then-alphabetic ordering. + /// + public static (int Group, string Prefix, int Number, string Name) GetRouteSortKey( + string? shortName, string? routeId) + { + if (string.IsNullOrEmpty(shortName)) + return (99, "", int.MaxValue, shortName ?? ""); + + var feed = routeId?.Split(':')[0]; + + if (feed == "vitrasa") + { + int group = GetVitrasaRouteGroup(shortName); + // For "Others" group, sub-sort by alphabetic prefix to keep N*, PSA*, U* etc. grouped + string prefix = group == 3 ? ExtractAlphaPrefix(shortName) : ""; + int number = ExtractNumber(shortName); + + // For routes with no number in short name (like "A"), use the GTFS route ID number + if (number == int.MaxValue && routeId != null) + { + var idPart = routeId.Split(':').Last(); + if (int.TryParse(idPart, out int idNumber)) + number = idNumber; + } + + return (group, prefix, number, shortName); + } + + // Generic: non-numeric names first, then by number, then alphabetical + int genericNumber = ExtractNumber(shortName); + bool hasDigits = genericNumber != int.MaxValue; + return (hasDigits ? 1 : 0, "", genericNumber, shortName); + } + + /// + /// Vitrasa route groups: + /// 0 = Circular (C1, C3d, C3i) + /// 1 = Regular numbered routes (4A, 6, 10, A, etc.) + /// 2 = Hospital (H, H1, H2, H3) + /// 3 = Others (N*, PSA*, U*, LZD, PTL) + /// + private static int GetVitrasaRouteGroup(string shortName) + { + // Circular: "C" followed by a digit + if (shortName.Length > 1 && shortName[0] == 'C' && char.IsDigit(shortName[1])) + return 0; + + // Hospital: starts with "H" + if (shortName[0] == 'H') + return 2; + + // Night: "N" followed by a digit + if (shortName[0] == 'N' && shortName.Length > 1 && char.IsDigit(shortName[1])) + return 3; + + // PSA shuttle lines + if (shortName.StartsWith("PSA", StringComparison.OrdinalIgnoreCase)) + return 3; + + // University: "U" followed by a digit + if (shortName[0] == 'U' && shortName.Length > 1 && char.IsDigit(shortName[1])) + return 3; + + // Multi-letter codes with no digits (LZD, PTL) + if (shortName.Length >= 2 && shortName.All(char.IsLetter)) + return 3; + + // Everything else is regular (numbered routes like 4A, 6, 10, single letters like A) + return 1; + } + + private static int ExtractNumber(string name) + { + var digits = new string(name.Where(char.IsDigit).ToArray()); + return int.TryParse(digits, out int number) ? number : int.MaxValue; + } + + private static string ExtractAlphaPrefix(string name) + { + return new string(name.TakeWhile(char.IsLetter).ToArray()); + } } -- cgit v1.3