From 52256fd634300b39b915bf1db6020d9d2871a0b4 Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Fri, 7 Nov 2025 14:58:32 +0100 Subject: Implement experimental consolidated circulation view --- src/frontend/app/components/LineIcon.css | 7 + src/frontend/app/components/SchedulesTable.css | 46 ++--- src/frontend/app/components/SchedulesTable.tsx | 6 + src/frontend/app/components/StopSheet.tsx | 2 +- .../Stops/ConsolidatedCirculationList.css | 158 +++++++++++++++ .../Stops/ConsolidatedCirculationList.tsx | 217 +++++++++++++++++++++ 6 files changed, 412 insertions(+), 24 deletions(-) create mode 100644 src/frontend/app/components/Stops/ConsolidatedCirculationList.css create mode 100644 src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx (limited to 'src/frontend/app/components') diff --git a/src/frontend/app/components/LineIcon.css b/src/frontend/app/components/LineIcon.css index fe4a87f..0b93023 100644 --- a/src/frontend/app/components/LineIcon.css +++ b/src/frontend/app/components/LineIcon.css @@ -38,15 +38,20 @@ --line-vigo-l24: rgb(191, 191, 191); --line-vigo-l25: rgb(172, 100, 4); --line-vigo-l27: rgb(112, 74, 42); + --line-vigo-l27-text: #ffffff; --line-vigo-l28: rgb(176, 189, 254); --line-vigo-l29: rgb(248, 184, 90); --line-vigo-l31: rgb(255, 255, 0); --line-vigo-a: rgb(119, 41, 143); --line-vigo-a-text: #ffffff; --line-vigo-h: rgb(0, 96, 168); + --line-vigo-h-text: #ffffff; --line-vigo-h1: rgb(0, 96, 168); + --line-vigo-h1-text: #ffffff; --line-vigo-h2: rgb(0, 96, 168); + --line-vigo-h2-text: #ffffff; --line-vigo-h3: rgb(0, 96, 168); + --line-vigo-h3-text: #ffffff; --line-vigo-lzd: rgb(61, 78, 167); --line-vigo-n1: rgb(191, 191, 191); --line-vigo-n4: rgb(102, 51, 102); @@ -56,7 +61,9 @@ --line-vigo-ptl: rgb(150, 220, 153); --line-vigo-turistico: rgb(102, 51, 102); --line-vigo-u1: rgb(172, 100, 4); + --line-vigo-u1-text: #ffffff; --line-vigo-u2: rgb(172, 100, 4); + --line-vigo-u2-text: #ffffff; --line-santiago-l1: #f32621; --line-santiago-l4: #ffcc33; diff --git a/src/frontend/app/components/SchedulesTable.css b/src/frontend/app/components/SchedulesTable.css index 74d7569..c0c5cb7 100644 --- a/src/frontend/app/components/SchedulesTable.css +++ b/src/frontend/app/components/SchedulesTable.css @@ -52,25 +52,25 @@ border: 1px solid var(--card-border); } -.card-header { +.timetable-card .card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; } -.line-info { +.timetable-card .line-info { flex-shrink: 0; } -.destination-info { +.timetable-card .destination-info { flex: 1; text-align: left; margin: 0 1rem; color: var(--text-primary); } -.destination-info strong { +.timetable-card .destination-info strong { font-size: 0.95rem; } @@ -78,14 +78,14 @@ color: var(--text-secondary); } -.time-info { +.timetable-card .time-info { display: flex; flex-direction: column; align-items: flex-end; flex-shrink: 0; } -.departure-time { +.timetable-card .timetable-card .departure-time { font-weight: bold; font-family: monospace; font-size: 1.1rem; @@ -96,18 +96,18 @@ color: var(--text-secondary); } -.card-body { +.timetable-card .card-body { line-height: 1.4; } -.route-streets { +.timetable-card .route-streets { font-size: 0.85rem; color: var(--text-secondary); line-height: 1.8; word-break: break-word; } -.service-id { +.timetable-card .timetable-card .service-id { font-family: monospace; font-size: 0.8rem; color: var(--text-secondary); @@ -124,7 +124,7 @@ background: var(--service-background-past); } -.no-data { +.timetable-container .no-data { text-align: center; color: var(--text-secondary); font-style: italic; @@ -142,66 +142,66 @@ .timetable-card { padding: 0.75rem; } - .card-header { + .timetable-card .card-header { margin-bottom: 0.5rem; } - .destination-info { + .timetable-card .destination-info { margin: 0 0.5rem; } - .destination-info strong { + .timetable-card .destination-info strong { font-size: 0.9rem; } - .departure-time { + .timetable-card .departure-time { font-size: 1rem; } - .service-id { + .timetable-card .service-id { font-size: 0.8rem; padding: 0.2rem 0.4rem; } } @media (max-width: 480px) { - .card-header { + .timetable-card .card-header { flex-direction: column; align-items: stretch; gap: 0.5rem; } - .destination-info { + .timetable-card .destination-info { text-align: left; margin: 0; order: 2; } - .time-info { + .timetable-card .time-info { align-items: flex-start; order: 1; align-self: flex-end; } - .line-info { + .timetable-card .line-info { order: 0; align-self: flex-start; } /* Create a flex container for line and time on mobile */ - .card-header { + .timetable-card .card-header { position: relative; } - .line-info { + .timetable-card .line-info { position: absolute; left: 0; top: 0; } - .time-info { + .timetable-card .time-info { position: absolute; right: 0; top: 0; } - .destination-info { + .timetable-card .destination-info { margin-top: 2rem; text-align: left; } diff --git a/src/frontend/app/components/SchedulesTable.tsx b/src/frontend/app/components/SchedulesTable.tsx index 9f3f062..07d3136 100644 --- a/src/frontend/app/components/SchedulesTable.tsx +++ b/src/frontend/app/components/SchedulesTable.tsx @@ -73,6 +73,12 @@ const parseServiceId = (serviceId: string): string => { case 150: displayLine = "REF"; break; + case 201: + displayLine = "U1"; + break; + case 202: + displayLine = "U2"; + break; case 500: displayLine = "TUR"; break; diff --git a/src/frontend/app/components/StopSheet.tsx b/src/frontend/app/components/StopSheet.tsx index 0c19cb6..2f37519 100644 --- a/src/frontend/app/components/StopSheet.tsx +++ b/src/frontend/app/components/StopSheet.tsx @@ -140,7 +140,7 @@ export const StopSheet: React.FC = ({
= 6 ? "scrollable" : ""}`} + className={`stop-sheet-lines-container ${stop.lines.length >= 10 ? "scrollable" : ""}`} > {stop.lines.map((line) => (
diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationList.css b/src/frontend/app/components/Stops/ConsolidatedCirculationList.css new file mode 100644 index 0000000..65e897b --- /dev/null +++ b/src/frontend/app/components/Stops/ConsolidatedCirculationList.css @@ -0,0 +1,158 @@ +/* Consolidated Circulation List Styles */ +.consolidated-circulation-container { + width: 100%; + margin-block-start: 1.5rem; +} + +.consolidated-circulation-caption { + font-size: 0.9rem; + color: var(--subtitle-color); + text-align: center; + margin-bottom: 1rem; + padding: 0.5rem; +} + +.consolidated-circulation-no-data { + text-align: center; + padding: 2rem 1rem; + color: var(--subtitle-color); + font-size: 0.95rem; +} + +.consolidated-circulation-list { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding-block: 0 1rem; +} + +.consolidated-circulation-card { + display: flex; + flex-direction: column; + background-color: var(--message-background-color); + border-radius: 8px; + border: 1px solid var(--border-color); + overflow: hidden; + transition: box-shadow 0.2s ease; +} + +.consolidated-circulation-card:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.consolidated-circulation-card .card-header { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.875rem 1rem; +} + +.consolidated-circulation-card .line-info { + flex-shrink: 0; +} + +.consolidated-circulation-card .route-info { + flex: 1; + min-width: 0; +} + +.consolidated-circulation-card .route-info strong { + font-size: 0.95rem; + color: var(--text-color); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: block; +} + +.consolidated-circulation-card .time-info { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.25rem; + flex-shrink: 0; +} + +.consolidated-circulation-card .arrival-time { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 1.05rem; + font-weight: 600; +} + +.consolidated-circulation-card .arrival-time svg { + width: 18px; + height: 18px; + flex-shrink: 0; +} + +/* Time color states */ +.consolidated-circulation-card .arrival-time.time-running { + color: #22c55e; +} + +.consolidated-circulation-card .arrival-time.time-running svg { + color: #22c55e; +} + +.consolidated-circulation-card .arrival-time.time-delayed { + color: #09106e; +} + +.consolidated-circulation-card .arrival-time.time-delayed svg { + color: #09106e; +} + +.consolidated-circulation-card .arrival-time.time-scheduled { + color: var(--text-color); +} + +.consolidated-circulation-card .arrival-time.time-scheduled svg { + color: var(--subtitle-color); +} + +.consolidated-circulation-card .distance-info { + font-size: 0.75rem; + color: var(--subtitle-color); + text-align: right; +} + +.consolidated-circulation-card .card-footer { + padding: 0.5rem 1rem 0.75rem 1rem; + border-top: 1px solid var(--border-color); + background-color: rgba(0, 0, 0, 0.02); +} + +@media (prefers-color-scheme: dark) { + .consolidated-circulation-card .card-footer { + background-color: rgba(255, 255, 255, 0.03); + } +} + +.consolidated-circulation-card .status-text { + font-size: 0.8rem; + color: var(--subtitle-color); + line-height: 1.4; + display: block; +} + +/* Responsive adjustments */ +@media (max-width: 480px) { + .consolidated-circulation-card .card-header { + gap: 0.5rem; + padding: 0.75rem 0.875rem; + } + + .consolidated-circulation-card .arrival-time { + font-size: 1rem; + } + + .consolidated-circulation-card .card-footer { + padding: 0.5rem 0.875rem 0.625rem 0.875rem; + } + + .consolidated-circulation-card .status-text { + font-size: 0.9rem; + } +} diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx b/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx new file mode 100644 index 0000000..a1b50f2 --- /dev/null +++ b/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx @@ -0,0 +1,217 @@ +import { useTranslation } from "react-i18next"; +import { Clock } from "lucide-react"; +import { type ConsolidatedCirculation } from "~routes/stops-$id"; +import LineIcon from "~components/LineIcon"; +import { type RegionConfig } from "~data/RegionConfig"; + +import './ConsolidatedCirculationList.css'; + +interface RegularTableProps { + data: ConsolidatedCirculation[]; + dataDate: Date | null; + regionConfig: RegionConfig; +} + +// Utility function to parse service ID and get the turn number +const parseServiceId = (serviceId: string): string => { + const parts = serviceId.split("_"); + if (parts.length === 0) return ""; + + const lastPart = parts[parts.length - 1]; + if (lastPart.length < 6) return ""; + + const last6 = lastPart.slice(-6); + const lineCode = last6.slice(0, 3); + const turnCode = last6.slice(-3); + + // Remove leading zeros from turn + const turnNumber = parseInt(turnCode, 10).toString(); + + // Parse line number with special cases + const lineNumber = parseInt(lineCode, 10); + let displayLine: string; + + switch (lineNumber) { + case 1: + displayLine = "C1"; + break; + case 3: + displayLine = "C3"; + break; + case 30: + displayLine = "N1"; + break; + case 33: + displayLine = "N4"; + break; + case 8: + displayLine = "A"; + break; + case 101: + displayLine = "H"; + break; + case 150: + displayLine = "REF"; + break; + case 500: + displayLine = "TUR"; + break; + case 201: + displayLine = "U1"; + break; + case 202: + displayLine = "U2"; + break; + default: + displayLine = `L${lineNumber}`; + } + + return `${displayLine}-${turnNumber}`; +}; + +export const ConsolidatedCirculationList: React.FC = ({ + data, + dataDate, + regionConfig, +}) => { + const { t } = useTranslation(); + + const absoluteArrivalTime = (minutes: number) => { + const now = new Date(); + const arrival = new Date(now.getTime() + minutes * 60000); + return Intl.DateTimeFormat( + typeof navigator !== "undefined" ? navigator.language : "en", + { + hour: "2-digit", + minute: "2-digit", + }, + ).format(arrival); + }; + + const formatDistance = (meters: number) => { + if (meters > 1024) { + return `${(meters / 1000).toFixed(1)} km`; + } else { + return `${meters} ${t("estimates.meters", "m")}`; + } + }; + + const getDelayText = (estimate: ConsolidatedCirculation): string | null => { + if (!estimate.schedule || !estimate.realTime) { + return null; + } + + const delay = estimate.realTime.minutes - estimate.schedule.minutes; + + if (delay >= -1 && delay <= 2) { + return t("estimates.on_time", "on time"); + } else if (delay > 2) { + return t("estimates.minutes_late", "{{minutes}} minutes late", { minutes: delay }); + } else { + return t("estimates.minutes_early", "{{minutes}} minutes early", { minutes: Math.abs(delay) }); + } + }; + + const getTripIdDisplay = (tripId: string): string => { + const parts = tripId.split("_"); + return parts.length > 1 ? parts[1] : tripId; + }; + + const getTimeClass = (estimate: ConsolidatedCirculation): string => { + if (estimate.realTime && estimate.schedule?.running) { + return "time-running"; + } + + if (estimate.realTime && !estimate.schedule) { + return "time-running"; + } + + else if (estimate.realTime && !estimate.schedule?.running) { + return "time-delayed"; + } + + return "time-scheduled"; + }; + + const sortedData = [...data].sort( + (a, b) => (a.realTime?.minutes ?? a.schedule?.minutes ?? 999) - + (b.realTime?.minutes ?? b.schedule?.minutes ?? 999) + ); + + return ( +
+
+ {t("estimates.caption", "Estimaciones de llegadas a las {{time}}", { + time: dataDate?.toLocaleTimeString(), + })} +
+ + {sortedData.length === 0 ? ( +
+ {t("estimates.none", "No hay estimaciones disponibles")} +
+ ) : ( +
+ {sortedData.map((estimate, idx) => { + const displayMinutes = estimate.realTime?.minutes ?? estimate.schedule?.minutes ?? 0; + const timeClass = getTimeClass(estimate); + const delayText = getDelayText(estimate); + + return ( +
+
+
+ +
+ +
+ {estimate.route} +
+ +
+
+ + {estimate.realTime + ? `${displayMinutes} ${t("estimates.minutes", "min")}` + : absoluteArrivalTime(displayMinutes)} +
+ {estimate.realTime && estimate.realTime.distance >= 0 && ( +
+ {formatDistance(estimate.realTime.distance)} +
+ )} +
+
+ +
+ + {delayText && ( + <> + {t("estimates.bus_is", "Bus is")} {delayText}.{" "} + + )} + + + {estimate.schedule ? ( + <> + {t("estimates.service", "Service")}{" "} + {parseServiceId(estimate.schedule.serviceId)} + {", "} + {t("estimates.trip", "trip")}{" "} + {getTripIdDisplay(estimate.schedule.tripId)} + + ) : ( + <> + {t("estimates.unknown_service", "Unknown service. It may be a reinforcement or the service has a different name than planned.")} + + )} + +
+
+ ); + })} +
+ )} +
+ ); +}; -- cgit v1.3