diff options
| author | Ariel Costas Guerrero <ariel@costas.dev> | 2025-12-19 13:06:27 +0100 |
|---|---|---|
| committer | Ariel Costas Guerrero <ariel@costas.dev> | 2025-12-19 13:06:27 +0100 |
| commit | 2a9aca302485bc08f5b2dd2a54987de6f80fc338 (patch) | |
| tree | 38171abad21b2952eca6ff9e8534545b4c28ed12 /src/frontend/app/components/map | |
| parent | 37cdb0c418a7f2b47e40ae9db7ad86e1fddc86fe (diff) | |
Implement loading stops as tiles from OTP
Diffstat (limited to 'src/frontend/app/components/map')
3 files changed, 608 insertions, 0 deletions
diff --git a/src/frontend/app/components/map/StopSummarySheet.css b/src/frontend/app/components/map/StopSummarySheet.css new file mode 100644 index 0000000..5869d41 --- /dev/null +++ b/src/frontend/app/components/map/StopSummarySheet.css @@ -0,0 +1,309 @@ +/* Stop Sheet Styles */ +.react-modal-sheet-container { + background-color: var(--background-color) !important; + touch-action: none; +} + +/*.react-modal-sheet-content > * > *:not(.stop-sheet-actions){ + interactivity: inert; +}*/ + +.react-modal-sheet-content-scroller { + overscroll-behavior-y: unset !important; + overflow-y: unset !important; +} + +.stop-sheet-content { + padding: 16px; + display: flex; + flex-direction: column; + /* overflow: hidden; */ + touch-action: pan-y; +} + +.stop-sheet-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; +} + +.stop-sheet-title { + font-size: 1.5rem; + font-weight: 600; + color: var(--text-color); + margin: 0; +} + +.stop-sheet-id { + font-size: 1rem; + color: var(--subtitle-color); +} + +.stop-sheet-lines-container { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.stop-sheet-lines-container.scrollable { + display: grid; + grid-template-rows: repeat(2, 1fr); + grid-auto-flow: column; + /* align-content: flex-start; */ + scrollbar-width: thin; + gap: 0.5rem 1rem; + overflow-x: scroll; +} + +.stop-sheet-lines-container.scrollable::-webkit-scrollbar { + height: 6px; +} + +.stop-sheet-lines-container.scrollable::-webkit-scrollbar-thumb { + background-color: var(--border-color); + border-radius: 3px; +} + +.stop-sheet-line-icon { + flex-shrink: 0; +} + +.stop-sheet-loading { + display: flex; + justify-content: center; + align-items: center; + padding: 32px; + color: var(--subtitle-color); + font-size: 1rem; +} + +.stop-sheet-estimates { + flex: 1; + min-height: 0; + margin-block-start: 1.25rem; +} + +.stop-sheet-subtitle { + font-size: 1.1rem; + font-weight: 500; + color: var(--text-color); + margin: 0 0 12px 0; +} + +.stop-sheet-no-estimates { + text-align: center; + padding: 32px 16px; + color: var(--subtitle-color); + font-size: 0.95rem; +} + +.stop-sheet-estimates-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.stop-sheet-estimate-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + background-color: var(--message-background-color); + border-radius: 8px; + border: 1px solid var(--border-color); +} + +.stop-sheet-estimate-line { + flex-shrink: 0; +} + +.stop-sheet-estimate-details { + flex: 1; + min-width: 0; +} + +.stop-sheet-estimate-route { + font-weight: 500; + color: var(--text-color); + font-size: 0.95rem; + margin-bottom: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.stop-sheet-estimate-arrival { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; + flex-shrink: 0; +} + +.stop-sheet-estimate-time { + display: flex; + align-items: center; + gap: 6px; + font-size: 1.05rem; + font-weight: 600; + color: var(--text-color); +} + +.stop-sheet-estimate-time.is-minutes { + color: #22c55e; +} + +.stop-sheet-estimate-time svg { + width: 18px; + height: 18px; + color: var(--subtitle-color); + flex-shrink: 0; +} + +.stop-sheet-estimate-time.is-minutes svg { + color: #22c55e; +} + +.stop-sheet-estimate-distance { + font-size: 0.75rem; + color: var(--subtitle-color); + text-align: right; +} + +.stop-sheet-footer { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin: 0; + padding: 0.75rem 16px 1rem 16px; + border-top: 1px solid var(--border-color); + background-color: var(--background-color); + z-index: 10; +} + +.stop-sheet-timestamp { + font-size: 0.8rem; + color: var(--subtitle-color); + text-align: center; +} + +.stop-sheet-actions { + display: flex; + gap: 0.75rem; +} + +.stop-sheet-reload { + display: inline-flex; + align-items: center; + gap: 0.4rem; + background: transparent; + border: 1px solid var(--border-color); + color: var(--text-color); + padding: 0.5rem 0.75rem; + border-radius: 6px; + font-size: 0.85rem; + cursor: pointer; + transition: all 0.2s ease; + flex: 1; + justify-content: center; +} + +.stop-sheet-reload:hover:not(:disabled) { + background: var(--message-background-color); + border-color: var(--button-background-color); +} + +.stop-sheet-reload:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.reload-icon { + width: 14px; + height: 14px; + transition: transform 0.5s ease; +} + +.reload-icon.spinning { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.stop-sheet-view-all { + display: block; + padding: 0.5rem 0.75rem; + background-color: var(--button-background-color); + color: white; + text-decoration: none; + text-align: center; + border-radius: 6px; + font-weight: 500; + font-size: 0.85rem; + transition: background-color 0.2s ease; + flex: 2; +} + +.stop-sheet-view-all:hover { + background-color: var(--button-hover-background-color); + text-decoration: none; +} + +/* Error display adjustments for sheet */ +.stop-sheet-content .error-display { + margin: 1rem 0; +} + +.stop-sheet-content .error-display.compact { + min-height: 100px; + padding: 1rem; +} + +.stop-sheet-content .error-display.compact .error-icon { + width: 28px; + height: 28px; +} + +.stop-sheet-content .error-display.compact .error-title { + font-size: 1.1rem; +} + +.stop-sheet-content .error-display.compact .error-message { + font-size: 0.85rem; +} + +[data-rsbs-overlay] { + background-color: rgba(0, 0, 0, 0.3); +} + +[data-rsbs-header] { + background-color: var(--background-color); + border-bottom: 1px solid var(--border-color); + touch-action: none; +} + +[data-rsbs-header]:before { + background-color: var(--subtitle-color); +} + +[data-rsbs-root] [data-rsbs-overlay] { + border-top-left-radius: 16px; + border-top-right-radius: 16px; +} + +[data-rsbs-root] [data-rsbs-content] { + background-color: var(--background-color); + border-top-left-radius: 16px; + border-top-right-radius: 16px; + max-height: 95vh; + overflow: hidden; + touch-action: none; +} diff --git a/src/frontend/app/components/map/StopSummarySheet.tsx b/src/frontend/app/components/map/StopSummarySheet.tsx new file mode 100644 index 0000000..b24e71c --- /dev/null +++ b/src/frontend/app/components/map/StopSummarySheet.tsx @@ -0,0 +1,220 @@ +import { RefreshCw } from "lucide-react"; +import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Sheet } from "react-modal-sheet"; +import { Link } from "react-router"; +import { ConsolidatedCirculationList } from "~/components/Stops/ConsolidatedCirculationList"; +import { APP_CONSTANTS } from "~/config/constants"; +import { type ConsolidatedCirculation } from "../../routes/stops-$id"; +import { ErrorDisplay } from "../ErrorDisplay"; +import LineIcon from "../LineIcon"; +import "./StopSummarySheet.css"; +import { StopSummarySheetSkeleton } from "./StopSummarySheetSkeleton"; + +export interface StopSheetProps { + isOpen: boolean; + onClose: () => void; + stop: { + stopId: string; + stopCode?: string; + stopFeed?: string; + name: string; + lines: { + line: string; + colour?: string; + textColour?: string; + }[]; + }; +} + +interface ErrorInfo { + type: "network" | "server" | "unknown"; + status?: number; + message?: string; +} + +const loadConsolidatedData = async ( + stopId: string +): Promise<ConsolidatedCirculation[]> => { + const resp = await fetch( + `${APP_CONSTANTS.consolidatedCirculationsEndpoint}?stopId=${stopId}`, + { + headers: { + Accept: "application/json", + }, + } + ); + + if (!resp.ok) { + throw new Error(`HTTP ${resp.status}: ${resp.statusText}`); + } + + return await resp.json(); +}; + +export const StopSheet: React.FC<StopSheetProps> = ({ + isOpen, + onClose, + stop, +}) => { + const { t } = useTranslation(); + const [data, setData] = useState<ConsolidatedCirculation[] | null>(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState<ErrorInfo | null>(null); + const [lastUpdated, setLastUpdated] = useState<Date | null>(null); + + const parseError = (error: any): ErrorInfo => { + if (!navigator.onLine) { + return { type: "network", message: "No internet connection" }; + } + + if ( + error.message?.includes("Failed to fetch") || + error.message?.includes("NetworkError") + ) { + return { type: "network" }; + } + + if (error.message?.includes("HTTP")) { + const statusMatch = error.message.match(/HTTP (\d+):/); + const status = statusMatch ? parseInt(statusMatch[1]) : undefined; + return { type: "server", status }; + } + + return { type: "unknown", message: error.message }; + }; + + const loadData = async () => { + try { + setLoading(true); + setError(null); + setData(null); + + const stopData = await loadConsolidatedData(stop.stopId); + setData(stopData); + setLastUpdated(new Date()); + } catch (err) { + console.error("Failed to load stop data:", err); + setError(parseError(err)); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (isOpen && stop.stopId) { + loadData(); + } + }, [isOpen, stop.stopId]); + + // Show only the next 4 arrivals + const sortedData = data + ? [...data].sort( + (a, b) => + (a.realTime?.minutes ?? a.schedule?.minutes ?? 999) - + (b.realTime?.minutes ?? b.schedule?.minutes ?? 999) + ) + : []; + const limitedEstimates = sortedData.slice(0, 4); + + return ( + <Sheet isOpen={isOpen} onClose={onClose} detent="content"> + <Sheet.Container> + <Sheet.Header /> + <Sheet.Content drag="y"> + <div className="stop-sheet-content"> + <div className="stop-sheet-header"> + <h2 className="stop-sheet-title">{stop.name}</h2> + <span className="stop-sheet-id">({stop.stopCode})</span> + </div> + + <div className={`d-flex flex-wrap flex-row gap-2`}> + {stop.lines.map((lineObj) => ( + <LineIcon + key={lineObj.line} + line={lineObj.line} + mode="pill" + colour={lineObj.colour} + textColour={lineObj.textColour} + /> + ))} + </div> + + {/* TODO: Enable stop alerts when available */} + {/*<StopAlert stop={stop} compact />*/} + + {loading ? ( + <StopSummarySheetSkeleton /> + ) : error ? ( + <ErrorDisplay + error={error} + onRetry={loadData} + title={t( + "errors.estimates_title", + "Error al cargar estimaciones" + )} + className="compact" + /> + ) : data ? ( + <> + <div className="stop-sheet-estimates"> + <h3 className="stop-sheet-subtitle"> + {t("estimates.next_arrivals", "Next arrivals")} + </h3> + + {limitedEstimates.length === 0 ? ( + <div className="stop-sheet-no-estimates"> + {t("estimates.none", "No hay estimaciones disponibles")} + </div> + ) : ( + <ConsolidatedCirculationList + data={data.slice(0, 4)} + driver={stop.stopFeed} + reduced + /> + )} + </div> + </> + ) : null} + </div> + </Sheet.Content> + + <div className="stop-sheet-footer"> + {lastUpdated && ( + <div className="stop-sheet-timestamp"> + {t("estimates.last_updated", "Actualizado a las")}{" "} + {lastUpdated.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + })} + </div> + )} + + <div className="stop-sheet-actions"> + <button + className="stop-sheet-reload" + onClick={loadData} + disabled={loading} + title={t("estimates.reload", "Recargar estimaciones")} + > + <RefreshCw + className={`reload-icon ${loading ? "spinning" : ""}`} + /> + {t("estimates.reload", "Recargar")} + </button> + + <Link + to={`/stops/${stop.stopId}`} + className="stop-sheet-view-all" + onClick={onClose} + > + {t("map.view_all_estimates", "Ver todas las estimaciones")} + </Link> + </div> + </div> + </Sheet.Container> + <Sheet.Backdrop onTap={onClose} /> + </Sheet> + ); +}; diff --git a/src/frontend/app/components/map/StopSummarySheetSkeleton.tsx b/src/frontend/app/components/map/StopSummarySheetSkeleton.tsx new file mode 100644 index 0000000..7697efc --- /dev/null +++ b/src/frontend/app/components/map/StopSummarySheetSkeleton.tsx @@ -0,0 +1,79 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import Skeleton, { SkeletonTheme } from "react-loading-skeleton"; +import "react-loading-skeleton/dist/skeleton.css"; + +interface StopSheetSkeletonProps { + rows?: number; +} + +export const StopSummarySheetSkeleton: React.FC<StopSheetSkeletonProps> = ({ + rows = 4, +}) => { + const { t } = useTranslation(); + + return ( + <SkeletonTheme + baseColor="var(--skeleton-base)" + highlightColor="var(--skeleton-highlight)" + > + <div className="stop-sheet-estimates"> + <h3 className="stop-sheet-subtitle"> + {t("estimates.next_arrivals", "Next arrivals")} + </h3> + + <div className="stop-sheet-estimates-list"> + {Array.from({ length: rows }, (_, index) => ( + <div key={`skeleton-${index}`} className="stop-sheet-estimate-item"> + <div className="stop-sheet-estimate-line"> + <Skeleton + width="40px" + height="24px" + style={{ borderRadius: "4px" }} + /> + </div> + + <div className="stop-sheet-estimate-details"> + <div className="stop-sheet-estimate-route"> + <Skeleton width="120px" height="0.95rem" /> + </div> + <div className="stop-sheet-estimate-time"> + <Skeleton width="80px" height="0.85rem" /> + </div> + </div> + </div> + ))} + </div> + </div> + + <div className="stop-sheet-footer"> + <div className="stop-sheet-timestamp"> + <Skeleton width="140px" height="0.8rem" /> + </div> + + <div className="stop-sheet-actions"> + <div + className="stop-sheet-reload" + style={{ + opacity: 0.6, + pointerEvents: "none", + }} + > + <Skeleton width="70px" height="0.85rem" /> + </div> + + <div + className="stop-sheet-view-all" + style={{ + background: "var(--service-background)", + cursor: "not-allowed", + pointerEvents: "none", + }} + > + <Skeleton width="180px" height="0.85rem" /> + </div> + </div> + </div> + </SkeletonTheme> + ); +}; |
