aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/app/components/map
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2025-12-19 13:06:27 +0100
committerAriel Costas Guerrero <ariel@costas.dev>2025-12-19 13:06:27 +0100
commit2a9aca302485bc08f5b2dd2a54987de6f80fc338 (patch)
tree38171abad21b2952eca6ff9e8534545b4c28ed12 /src/frontend/app/components/map
parent37cdb0c418a7f2b47e40ae9db7ad86e1fddc86fe (diff)
Implement loading stops as tiles from OTP
Diffstat (limited to 'src/frontend/app/components/map')
-rw-r--r--src/frontend/app/components/map/StopSummarySheet.css309
-rw-r--r--src/frontend/app/components/map/StopSummarySheet.tsx220
-rw-r--r--src/frontend/app/components/map/StopSummarySheetSkeleton.tsx79
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>
+ );
+};