aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/app
diff options
context:
space:
mode:
Diffstat (limited to 'src/frontend/app')
-rw-r--r--src/frontend/app/AppContext.tsx122
-rw-r--r--src/frontend/app/ErrorBoundary.tsx18
-rw-r--r--src/frontend/app/components/GroupedTable.tsx124
-rw-r--r--src/frontend/app/components/LineIcon.css5
-rw-r--r--src/frontend/app/components/LineIcon.tsx4
-rw-r--r--src/frontend/app/components/NavBar.tsx30
-rw-r--r--src/frontend/app/components/RegularTable.tsx129
-rw-r--r--src/frontend/app/components/StopItem.css2
-rw-r--r--src/frontend/app/components/StopItem.tsx15
-rw-r--r--src/frontend/app/components/StopSheet.css146
-rw-r--r--src/frontend/app/components/StopSheet.tsx154
-rw-r--r--src/frontend/app/data/StopDataProvider.ts197
-rw-r--r--src/frontend/app/i18n/index.ts26
-rw-r--r--src/frontend/app/i18n/locales/en-GB.json6
-rw-r--r--src/frontend/app/i18n/locales/es-ES.json6
-rw-r--r--src/frontend/app/i18n/locales/gl-ES.json6
-rw-r--r--src/frontend/app/maps/styleloader.ts83
-rw-r--r--src/frontend/app/root.css8
-rw-r--r--src/frontend/app/root.tsx60
-rw-r--r--src/frontend/app/routes.tsx2
-rw-r--r--src/frontend/app/routes/estimates-$id.css2
-rw-r--r--src/frontend/app/routes/estimates-$id.tsx162
-rw-r--r--src/frontend/app/routes/index.tsx2
-rw-r--r--src/frontend/app/routes/map.css96
-rw-r--r--src/frontend/app/routes/map.tsx284
-rw-r--r--src/frontend/app/routes/settings.tsx192
-rw-r--r--src/frontend/app/routes/stoplist.tsx214
27 files changed, 1271 insertions, 824 deletions
diff --git a/src/frontend/app/AppContext.tsx b/src/frontend/app/AppContext.tsx
index d8db66d..e6d8971 100644
--- a/src/frontend/app/AppContext.tsx
+++ b/src/frontend/app/AppContext.tsx
@@ -1,10 +1,16 @@
/* eslint-disable react-refresh/only-export-components */
-import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
-import { type LngLatLike } from 'maplibre-gl';
+import {
+ createContext,
+ useContext,
+ useEffect,
+ useState,
+ type ReactNode,
+} from "react";
+import { type LngLatLike } from "maplibre-gl";
-type Theme = 'light' | 'dark';
-type TableStyle = 'regular'|'grouped';
-type MapPositionMode = 'gps' | 'last';
+type Theme = "light" | "dark";
+type TableStyle = "regular" | "grouped";
+type MapPositionMode = "gps" | "last";
interface MapState {
center: LngLatLike;
@@ -42,56 +48,62 @@ const AppContext = createContext<AppContextProps | undefined>(undefined);
export const AppProvider = ({ children }: { children: ReactNode }) => {
//#region Theme
const [theme, setTheme] = useState<Theme>(() => {
- const savedTheme = localStorage.getItem('theme');
+ const savedTheme = localStorage.getItem("theme");
if (savedTheme) {
return savedTheme as Theme;
}
- const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
- return prefersDark ? 'dark' : 'light';
+ const prefersDark =
+ window.matchMedia &&
+ window.matchMedia("(prefers-color-scheme: dark)").matches;
+ return prefersDark ? "dark" : "light";
});
const toggleTheme = () => {
- setTheme((prevTheme) => (prevTheme === 'light' ? 'dark' : 'light'));
+ setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light"));
};
useEffect(() => {
- document.documentElement.setAttribute('data-theme', theme);
- localStorage.setItem('theme', theme);
+ document.documentElement.setAttribute("data-theme", theme);
+ localStorage.setItem("theme", theme);
}, [theme]);
//#endregion
//#region Table Style
const [tableStyle, setTableStyle] = useState<TableStyle>(() => {
- const savedTableStyle = localStorage.getItem('tableStyle');
+ const savedTableStyle = localStorage.getItem("tableStyle");
if (savedTableStyle) {
return savedTableStyle as TableStyle;
}
- return 'regular';
+ return "regular";
});
const toggleTableStyle = () => {
- setTableStyle((prevTableStyle) => (prevTableStyle === 'regular' ? 'grouped' : 'regular'));
- }
+ setTableStyle((prevTableStyle) =>
+ prevTableStyle === "regular" ? "grouped" : "regular",
+ );
+ };
useEffect(() => {
- localStorage.setItem('tableStyle', tableStyle);
+ localStorage.setItem("tableStyle", tableStyle);
}, [tableStyle]);
//#endregion
//#region Map Position Mode
- const [mapPositionMode, setMapPositionMode] = useState<MapPositionMode>(() => {
- const saved = localStorage.getItem('mapPositionMode');
- return saved === 'last' ? 'last' : 'gps';
- });
+ const [mapPositionMode, setMapPositionMode] = useState<MapPositionMode>(
+ () => {
+ const saved = localStorage.getItem("mapPositionMode");
+ return saved === "last" ? "last" : "gps";
+ },
+ );
useEffect(() => {
- localStorage.setItem('mapPositionMode', mapPositionMode);
+ localStorage.setItem("mapPositionMode", mapPositionMode);
}, [mapPositionMode]);
//#endregion
//#region Map State
const [mapState, setMapState] = useState<MapState>(() => {
- const savedMapState = localStorage.getItem('mapState');
+ const savedMapState = localStorage.getItem("mapState");
if (savedMapState) {
try {
const parsed = JSON.parse(savedMapState);
@@ -99,56 +111,56 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
center: parsed.center || DEFAULT_CENTER,
zoom: parsed.zoom || DEFAULT_ZOOM,
userLocation: parsed.userLocation || null,
- hasLocationPermission: parsed.hasLocationPermission || false
+ hasLocationPermission: parsed.hasLocationPermission || false,
};
} catch (e) {
- console.error('Error parsing saved map state', e);
+ console.error("Error parsing saved map state", e);
}
}
return {
center: DEFAULT_CENTER,
zoom: DEFAULT_ZOOM,
userLocation: null,
- hasLocationPermission: false
+ hasLocationPermission: false,
};
});
const setMapCenter = (center: LngLatLike) => {
- setMapState(prev => {
+ setMapState((prev) => {
const newState = { ...prev, center };
- localStorage.setItem('mapState', JSON.stringify(newState));
+ localStorage.setItem("mapState", JSON.stringify(newState));
return newState;
});
};
const setMapZoom = (zoom: number) => {
- setMapState(prev => {
+ setMapState((prev) => {
const newState = { ...prev, zoom };
- localStorage.setItem('mapState', JSON.stringify(newState));
+ localStorage.setItem("mapState", JSON.stringify(newState));
return newState;
});
};
const setUserLocation = (userLocation: LngLatLike | null) => {
- setMapState(prev => {
+ setMapState((prev) => {
const newState = { ...prev, userLocation };
- localStorage.setItem('mapState', JSON.stringify(newState));
+ localStorage.setItem("mapState", JSON.stringify(newState));
return newState;
});
};
const setLocationPermission = (hasLocationPermission: boolean) => {
- setMapState(prev => {
+ setMapState((prev) => {
const newState = { ...prev, hasLocationPermission };
- localStorage.setItem('mapState', JSON.stringify(newState));
+ localStorage.setItem("mapState", JSON.stringify(newState));
return newState;
});
};
const updateMapState = (center: LngLatLike, zoom: number) => {
- setMapState(prev => {
+ setMapState((prev) => {
const newState = { ...prev, center, zoom };
- localStorage.setItem('mapState', JSON.stringify(newState));
+ localStorage.setItem("mapState", JSON.stringify(newState));
return newState;
});
};
@@ -164,31 +176,33 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
setUserLocation([latitude, longitude]);
},
(error) => {
- console.error('Error getting location:', error);
+ console.error("Error getting location:", error);
setLocationPermission(false);
- }
+ },
);
}
}
}, [mapState.hasLocationPermission, mapState.userLocation]);
return (
- <AppContext.Provider value={{
- theme,
- setTheme,
- toggleTheme,
- tableStyle,
- setTableStyle,
- toggleTableStyle,
- mapState,
- setMapCenter,
- setMapZoom,
- setUserLocation,
- setLocationPermission,
- updateMapState,
- mapPositionMode,
- setMapPositionMode
- }}>
+ <AppContext.Provider
+ value={{
+ theme,
+ setTheme,
+ toggleTheme,
+ tableStyle,
+ setTableStyle,
+ toggleTableStyle,
+ mapState,
+ setMapCenter,
+ setMapZoom,
+ setUserLocation,
+ setLocationPermission,
+ updateMapState,
+ mapPositionMode,
+ setMapPositionMode,
+ }}
+ >
{children}
</AppContext.Provider>
);
@@ -197,7 +211,7 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
export const useApp = () => {
const context = useContext(AppContext);
if (!context) {
- throw new Error('useApp must be used within a AppProvider');
+ throw new Error("useApp must be used within a AppProvider");
}
return context;
};
diff --git a/src/frontend/app/ErrorBoundary.tsx b/src/frontend/app/ErrorBoundary.tsx
index 5c877b7..c11a82b 100644
--- a/src/frontend/app/ErrorBoundary.tsx
+++ b/src/frontend/app/ErrorBoundary.tsx
@@ -1,4 +1,4 @@
-import React, { Component, type ReactNode } from 'react';
+import React, { Component, type ReactNode } from "react";
interface ErrorBoundaryProps {
children: ReactNode;
@@ -14,14 +14,14 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
super(props);
this.state = {
hasError: false,
- error: null
+ error: null,
};
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return {
hasError: true,
- error
+ error,
};
}
@@ -31,12 +31,12 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
render() {
if (this.state.hasError) {
- return <>
- <h1>Something went wrong.</h1>
- <pre>
- {this.state.error?.stack}
- </pre>
- </>;
+ return (
+ <>
+ <h1>Something went wrong.</h1>
+ <pre>{this.state.error?.stack}</pre>
+ </>
+ );
}
return this.props.children;
diff --git a/src/frontend/app/components/GroupedTable.tsx b/src/frontend/app/components/GroupedTable.tsx
index 3a16d89..47c2d31 100644
--- a/src/frontend/app/components/GroupedTable.tsx
+++ b/src/frontend/app/components/GroupedTable.tsx
@@ -2,73 +2,79 @@ import { type StopDetails } from "../routes/estimates-$id";
import LineIcon from "./LineIcon";
interface GroupedTable {
- data: StopDetails;
- dataDate: Date | null;
+ data: StopDetails;
+ dataDate: Date | null;
}
export const GroupedTable: React.FC<GroupedTable> = ({ data, dataDate }) => {
- const formatDistance = (meters: number) => {
- if (meters > 1024) {
- return `${(meters / 1000).toFixed(1)} km`;
- } else {
- return `${meters} m`;
- }
+ const formatDistance = (meters: number) => {
+ if (meters > 1024) {
+ return `${(meters / 1000).toFixed(1)} km`;
+ } else {
+ return `${meters} m`;
}
+ };
- const groupedEstimates = data.estimates.reduce((acc, estimate) => {
- if (!acc[estimate.line]) {
- acc[estimate.line] = [];
- }
- acc[estimate.line].push(estimate);
- return acc;
- }, {} as Record<string, typeof data.estimates>);
+ const groupedEstimates = data.estimates.reduce(
+ (acc, estimate) => {
+ if (!acc[estimate.line]) {
+ acc[estimate.line] = [];
+ }
+ acc[estimate.line].push(estimate);
+ return acc;
+ },
+ {} as Record<string, typeof data.estimates>,
+ );
- const sortedLines = Object.keys(groupedEstimates).sort((a, b) => {
- const firstArrivalA = groupedEstimates[a][0].minutes;
- const firstArrivalB = groupedEstimates[b][0].minutes;
- return firstArrivalA - firstArrivalB;
- });
+ const sortedLines = Object.keys(groupedEstimates).sort((a, b) => {
+ const firstArrivalA = groupedEstimates[a][0].minutes;
+ const firstArrivalB = groupedEstimates[b][0].minutes;
+ return firstArrivalA - firstArrivalB;
+ });
- return <table className="table">
- <caption>Estimaciones de llegadas a las {dataDate?.toLocaleTimeString()}</caption>
+ return (
+ <table className="table">
+ <caption>
+ Estimaciones de llegadas a las {dataDate?.toLocaleTimeString()}
+ </caption>
- <thead>
- <tr>
- <th>Línea</th>
- <th>Ruta</th>
- <th>Llegada</th>
- <th>Distancia</th>
- </tr>
- </thead>
-
- <tbody>
- {sortedLines.map((line) => (
- groupedEstimates[line].map((estimate, idx) => (
- <tr key={`${line}-${idx}`}>
- {idx === 0 && (
- <td rowSpan={groupedEstimates[line].length}>
- <LineIcon line={line} />
- </td>
- )}
- <td>{estimate.route}</td>
- <td>{`${estimate.minutes} min`}</td>
- <td>
- {estimate.meters > -1
- ? formatDistance(estimate.meters)
- : "No disponible"
- }
- </td>
- </tr>
- ))
- ))}
- </tbody>
+ <thead>
+ <tr>
+ <th>Línea</th>
+ <th>Ruta</th>
+ <th>Llegada</th>
+ <th>Distancia</th>
+ </tr>
+ </thead>
- {data?.estimates.length === 0 && (
- <tfoot>
- <tr>
- <td colSpan={4}>No hay estimaciones disponibles</td>
- </tr>
- </tfoot>
+ <tbody>
+ {sortedLines.map((line) =>
+ groupedEstimates[line].map((estimate, idx) => (
+ <tr key={`${line}-${idx}`}>
+ {idx === 0 && (
+ <td rowSpan={groupedEstimates[line].length}>
+ <LineIcon line={line} />
+ </td>
+ )}
+ <td>{estimate.route}</td>
+ <td>{`${estimate.minutes} min`}</td>
+ <td>
+ {estimate.meters > -1
+ ? formatDistance(estimate.meters)
+ : "No disponible"}
+ </td>
+ </tr>
+ )),
)}
+ </tbody>
+
+ {data?.estimates.length === 0 && (
+ <tfoot>
+ <tr>
+ <td colSpan={4}>No hay estimaciones disponibles</td>
+ </tr>
+ </tfoot>
+ )}
</table>
-}
+ );
+};
diff --git a/src/frontend/app/components/LineIcon.css b/src/frontend/app/components/LineIcon.css
index e7e8949..4b39351 100644
--- a/src/frontend/app/components/LineIcon.css
+++ b/src/frontend/app/components/LineIcon.css
@@ -55,7 +55,8 @@
font-weight: 600;
text-transform: uppercase;
color: inherit;
- /* Prevent color change on hover */
+ background-color: white;
+ border-radius: 0.25rem 0.25rem 0 0;
}
.line-c1 {
@@ -236,4 +237,4 @@
.line-u2 {
border-color: var(--line-u2);
-} \ No newline at end of file
+}
diff --git a/src/frontend/app/components/LineIcon.tsx b/src/frontend/app/components/LineIcon.tsx
index 291b444..3d613e6 100644
--- a/src/frontend/app/components/LineIcon.tsx
+++ b/src/frontend/app/components/LineIcon.tsx
@@ -1,5 +1,5 @@
-import React from 'react';
-import './LineIcon.css';
+import React from "react";
+import "./LineIcon.css";
interface LineIconProps {
line: string;
diff --git a/src/frontend/app/components/NavBar.tsx b/src/frontend/app/components/NavBar.tsx
index eba7196..6a06e63 100644
--- a/src/frontend/app/components/NavBar.tsx
+++ b/src/frontend/app/components/NavBar.tsx
@@ -9,14 +9,14 @@ function isWithinVigo(lngLat: LngLatLike): boolean {
let lng: number, lat: number;
if (Array.isArray(lngLat)) {
[lng, lat] = lngLat;
- } else if ('lng' in lngLat && 'lat' in lngLat) {
+ } else if ("lng" in lngLat && "lat" in lngLat) {
lng = lngLat.lng;
lat = lngLat.lat;
} else {
return false;
}
// Rough bounding box for Vigo
- return lat >= 42.18 && lat <= 42.30 && lng >= -8.78 && lng <= -8.65;
+ return lat >= 42.18 && lat <= 42.3 && lng >= -8.78 && lng <= -8.65;
}
export default function NavBar() {
@@ -25,20 +25,20 @@ export default function NavBar() {
const navItems = [
{
- name: t('navbar.stops', 'Paradas'),
+ name: t("navbar.stops", "Paradas"),
icon: MapPin,
- path: '/stops'
+ path: "/stops",
},
{
- name: t('navbar.map', 'Mapa'),
+ name: t("navbar.map", "Mapa"),
icon: Map,
- path: '/map',
+ path: "/map",
callback: () => {
- if (mapPositionMode !== 'gps') {
+ if (mapPositionMode !== "gps") {
return;
}
- if (!('geolocation' in navigator)) {
+ if (!("geolocation" in navigator)) {
return;
}
@@ -50,20 +50,20 @@ export default function NavBar() {
updateMapState(coords, 16);
}
},
- () => { }
+ () => {},
);
- }
+ },
},
{
- name: t('navbar.settings', 'Ajustes'),
+ name: t("navbar.settings", "Ajustes"),
icon: Settings,
- path: '/settings'
- }
+ path: "/settings",
+ },
];
return (
<nav className="navigation-bar">
- {navItems.map(item => {
+ {navItems.map((item) => {
const Icon = item.icon;
const isActive = location.pathname.startsWith(item.path);
@@ -71,7 +71,7 @@ export default function NavBar() {
<Link
key={item.name}
to={item.path}
- className={`navigation-bar__link ${isActive ? 'active' : ''}`}
+ className={`navigation-bar__link ${isActive ? "active" : ""}`}
onClick={item.callback ? item.callback : undefined}
title={item.name}
aria-label={item.name}
diff --git a/src/frontend/app/components/RegularTable.tsx b/src/frontend/app/components/RegularTable.tsx
index e5b3782..8b01410 100644
--- a/src/frontend/app/components/RegularTable.tsx
+++ b/src/frontend/app/components/RegularTable.tsx
@@ -3,70 +3,85 @@ import { type StopDetails } from "../routes/estimates-$id";
import LineIcon from "./LineIcon";
interface RegularTableProps {
- data: StopDetails;
- dataDate: Date | null;
+ data: StopDetails;
+ dataDate: Date | null;
}
-export const RegularTable: React.FC<RegularTableProps> = ({ data, dataDate }) => {
- const { t } = useTranslation();
+export const RegularTable: React.FC<RegularTableProps> = ({
+ data,
+ dataDate,
+}) => {
+ const { t } = useTranslation();
- const absoluteArrivalTime = (minutes: number) => {
- const now = new Date()
- const arrival = new Date(now.getTime() + minutes * 60000)
- return Intl.DateTimeFormat(navigator.language, {
- hour: '2-digit',
- minute: '2-digit'
- }).format(arrival)
- }
+ 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 formatDistance = (meters: number) => {
+ if (meters > 1024) {
+ return `${(meters / 1000).toFixed(1)} km`;
+ } else {
+ return `${meters} ${t("estimates.meters", "m")}`;
}
+ };
- return <table className="table">
- <caption>{t('estimates.caption', 'Estimaciones de llegadas a las {{time}}', { time: dataDate?.toLocaleTimeString() })}</caption>
+ return (
+ <table className="table">
+ <caption>
+ {t("estimates.caption", "Estimaciones de llegadas a las {{time}}", {
+ time: dataDate?.toLocaleTimeString(),
+ })}
+ </caption>
- <thead>
- <tr>
- <th>{t('estimates.line', 'Línea')}</th>
- <th>{t('estimates.route', 'Ruta')}</th>
- <th>{t('estimates.arrival', 'Llegada')}</th>
- <th>{t('estimates.distance', 'Distancia')}</th>
- </tr>
- </thead>
+ <thead>
+ <tr>
+ <th>{t("estimates.line", "Línea")}</th>
+ <th>{t("estimates.route", "Ruta")}</th>
+ <th>{t("estimates.arrival", "Llegada")}</th>
+ <th>{t("estimates.distance", "Distancia")}</th>
+ </tr>
+ </thead>
- <tbody>
- {data.estimates
- .sort((a, b) => a.minutes - b.minutes)
- .map((estimate, idx) => (
- <tr key={idx}>
- <td><LineIcon line={estimate.line} /></td>
- <td>{estimate.route}</td>
- <td>
- {estimate.minutes > 15
- ? absoluteArrivalTime(estimate.minutes)
- : `${estimate.minutes} ${t('estimates.minutes', 'min')}`}
- </td>
- <td>
- {estimate.meters > -1
- ? formatDistance(estimate.meters)
- : t('estimates.not_available', 'No disponible')
- }
- </td>
- </tr>
- ))}
- </tbody>
+ <tbody>
+ {data.estimates
+ .sort((a, b) => a.minutes - b.minutes)
+ .map((estimate, idx) => (
+ <tr key={idx}>
+ <td>
+ <LineIcon line={estimate.line} />
+ </td>
+ <td>{estimate.route}</td>
+ <td>
+ {estimate.minutes > 15
+ ? absoluteArrivalTime(estimate.minutes)
+ : `${estimate.minutes} ${t("estimates.minutes", "min")}`}
+ </td>
+ <td>
+ {estimate.meters > -1
+ ? formatDistance(estimate.meters)
+ : t("estimates.not_available", "No disponible")}
+ </td>
+ </tr>
+ ))}
+ </tbody>
- {data?.estimates.length === 0 && (
- <tfoot>
- <tr>
- <td colSpan={4}>{t('estimates.none', 'No hay estimaciones disponibles')}</td>
- </tr>
- </tfoot>
- )}
+ {data?.estimates.length === 0 && (
+ <tfoot>
+ <tr>
+ <td colSpan={4}>
+ {t("estimates.none", "No hay estimaciones disponibles")}
+ </td>
+ </tr>
+ </tfoot>
+ )}
</table>
-}
+ );
+};
diff --git a/src/frontend/app/components/StopItem.css b/src/frontend/app/components/StopItem.css
index 9feb2d1..54ab136 100644
--- a/src/frontend/app/components/StopItem.css
+++ b/src/frontend/app/components/StopItem.css
@@ -51,4 +51,4 @@
top: 0;
color: #0078d4;
font-weight: bold;
-} \ No newline at end of file
+}
diff --git a/src/frontend/app/components/StopItem.tsx b/src/frontend/app/components/StopItem.tsx
index 29370b7..b781eb9 100644
--- a/src/frontend/app/components/StopItem.tsx
+++ b/src/frontend/app/components/StopItem.tsx
@@ -1,22 +1,21 @@
-import React from 'react';
-import { Link } from 'react-router';
-import StopDataProvider, { type Stop } from '../data/StopDataProvider';
-import LineIcon from './LineIcon';
+import React from "react";
+import { Link } from "react-router";
+import StopDataProvider, { type Stop } from "../data/StopDataProvider";
+import LineIcon from "./LineIcon";
interface StopItemProps {
stop: Stop;
}
const StopItem: React.FC<StopItemProps> = ({ stop }) => {
-
return (
<li className="list-item">
<Link className="list-item-link" to={`/estimates/${stop.stopId}`}>
- {stop.favourite && <span className="favourite-icon">★</span>} ({stop.stopId}) {StopDataProvider.getDisplayName(stop)}
+ {stop.favourite && <span className="favourite-icon">★</span>} (
+ {stop.stopId}) {StopDataProvider.getDisplayName(stop)}
<div className="line-icons">
- {stop.lines?.map(line => <LineIcon key={line} line={line} />)}
+ {stop.lines?.map((line) => <LineIcon key={line} line={line} />)}
</div>
-
</Link>
</li>
);
diff --git a/src/frontend/app/components/StopSheet.css b/src/frontend/app/components/StopSheet.css
new file mode 100644
index 0000000..3f7621e
--- /dev/null
+++ b/src/frontend/app/components/StopSheet.css
@@ -0,0 +1,146 @@
+/* Stop Sheet Styles */
+.stop-sheet-content {
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.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-loading {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: 32px;
+ color: var(--subtitle-color);
+ font-size: 1rem;
+}
+
+.stop-sheet-estimates {
+ flex: 1;
+ overflow-y: auto;
+ min-height: 0;
+}
+
+.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-time {
+ font-size: 0.85rem;
+ color: var(--subtitle-color);
+}
+
+.stop-sheet-estimate-distance {
+ color: var(--subtitle-color);
+}
+
+.stop-sheet-view-all {
+ display: block;
+ padding: 12px 16px;
+ background-color: var(--button-background-color);
+ color: white;
+ text-decoration: none;
+ text-align: center;
+ border-radius: 8px;
+ font-weight: 500;
+ transition: background-color 0.2s ease;
+
+ margin-block-start: 1rem;
+ margin-inline-start: auto;
+}
+
+.stop-sheet-view-all:hover {
+ background-color: var(--button-hover-background-color);
+ text-decoration: none;
+}
+
+/* Override react-modal-sheet styles for better integration */
+[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);
+}
+
+[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;
+}
diff --git a/src/frontend/app/components/StopSheet.tsx b/src/frontend/app/components/StopSheet.tsx
new file mode 100644
index 0000000..8075e9d
--- /dev/null
+++ b/src/frontend/app/components/StopSheet.tsx
@@ -0,0 +1,154 @@
+import React, { useEffect, useState } from "react";
+import { Sheet } from "react-modal-sheet";
+import { Link } from "react-router";
+import { useTranslation } from "react-i18next";
+import LineIcon from "./LineIcon";
+import { type StopDetails } from "../routes/estimates-$id";
+import "./StopSheet.css";
+
+interface StopSheetProps {
+ isOpen: boolean;
+ onClose: () => void;
+ stopId: number;
+ stopName: string;
+}
+
+const loadStopData = async (stopId: number): Promise<StopDetails> => {
+ const resp = await fetch(`/api/GetStopEstimates?id=${stopId}`, {
+ headers: {
+ Accept: "application/json",
+ },
+ });
+ return await resp.json();
+};
+
+export const StopSheet: React.FC<StopSheetProps> = ({
+ isOpen,
+ onClose,
+ stopId,
+ stopName,
+}) => {
+ const { t } = useTranslation();
+ const [data, setData] = useState<StopDetails | null>(null);
+ const [loading, setLoading] = useState(false);
+
+ useEffect(() => {
+ if (isOpen && stopId) {
+ setLoading(true);
+ setData(null);
+ loadStopData(stopId)
+ .then((stopData) => {
+ setData(stopData);
+ })
+ .catch((error) => {
+ console.error("Failed to load stop data:", error);
+ })
+ .finally(() => {
+ setLoading(false);
+ });
+ }
+ }, [isOpen, stopId]);
+
+ const formatTime = (minutes: number) => {
+ if (minutes > 15) {
+ 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);
+ } else {
+ return `${minutes} ${t("estimates.minutes", "min")}`;
+ }
+ };
+
+ const formatDistance = (meters: number) => {
+ if (meters > 1024) {
+ return `${(meters / 1000).toFixed(1)} km`;
+ } else {
+ return `${meters} ${t("estimates.meters", "m")}`;
+ }
+ };
+
+ // Show only the next 4 arrivals
+ const limitedEstimates =
+ data?.estimates.sort((a, b) => a.minutes - b.minutes).slice(0, 4) || [];
+
+ return (
+ <Sheet
+ isOpen={isOpen}
+ onClose={onClose}
+ detent="content-height"
+ >
+ <Sheet.Container>
+ <Sheet.Header />
+ <Sheet.Content>
+ <div className="stop-sheet-content">
+ <div className="stop-sheet-header">
+ <h2 className="stop-sheet-title">{stopName}</h2>
+ <span className="stop-sheet-id">({stopId})</span>
+ </div>
+
+ {loading && (
+ <div className="stop-sheet-loading">
+ {t("common.loading", "Loading...")}
+ </div>
+ )}
+
+ {data && !loading && (
+ <>
+ <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>
+ ) : (
+ <div className="stop-sheet-estimates-list">
+ {limitedEstimates.map((estimate, idx) => (
+ <div key={idx} className="stop-sheet-estimate-item">
+ <div className="stop-sheet-estimate-line">
+ <LineIcon line={estimate.line} />
+ </div>
+ <div className="stop-sheet-estimate-details">
+ <div className="stop-sheet-estimate-route">
+ {estimate.route}
+ </div>
+ <div className="stop-sheet-estimate-time">
+ {formatTime(estimate.minutes)}
+ {estimate.meters > -1 && (
+ <span className="stop-sheet-estimate-distance">
+ {" • "}
+ {formatDistance(estimate.meters)}
+ </span>
+ )}
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+
+ <Link
+ to={`/estimates/${stopId}`}
+ className="stop-sheet-view-all"
+ onClick={onClose}
+ >
+ {t("map.view_all_estimates", "Ver todas las estimaciones")}
+ </Link>
+ </>
+ )}
+ </div>
+ </Sheet.Content>
+ </Sheet.Container>
+ <Sheet.Backdrop />
+ </Sheet>
+ );
+};
diff --git a/src/frontend/app/data/StopDataProvider.ts b/src/frontend/app/data/StopDataProvider.ts
index 0c1e46e..efb0414 100644
--- a/src/frontend/app/data/StopDataProvider.ts
+++ b/src/frontend/app/data/StopDataProvider.ts
@@ -1,20 +1,20 @@
export interface CachedStopList {
- timestamp: number;
- data: Stop[];
+ timestamp: number;
+ data: Stop[];
}
export type StopName = {
- original: string;
- intersect?: string;
-}
+ original: string;
+ intersect?: string;
+};
export interface Stop {
- stopId: number;
- name: StopName;
- latitude?: number;
- longitude?: number;
- lines: string[];
- favourite?: boolean;
+ stopId: number;
+ name: StopName;
+ latitude?: number;
+ longitude?: number;
+ lines: string[];
+ favourite?: boolean;
}
// In-memory cache and lookup map
@@ -25,136 +25,139 @@ let customNames: Record<number, string> = {};
// Initialize cachedStops and customNames once
async function initStops() {
- if (!cachedStops) {
- const response = await fetch('/stops.json');
- const stops = await response.json() as Stop[];
- // build array and map
- stopsMap = {};
- cachedStops = stops.map(stop => {
- const entry = { ...stop, favourite: false } as Stop;
- stopsMap[stop.stopId] = entry;
- return entry;
- });
- // load custom names
- const rawCustom = localStorage.getItem('customStopNames');
- if (rawCustom) customNames = JSON.parse(rawCustom) as Record<number, string>;
- }
+ if (!cachedStops) {
+ const response = await fetch("/stops.json");
+ const stops = (await response.json()) as Stop[];
+ // build array and map
+ stopsMap = {};
+ cachedStops = stops.map((stop) => {
+ const entry = { ...stop, favourite: false } as Stop;
+ stopsMap[stop.stopId] = entry;
+ return entry;
+ });
+ // load custom names
+ const rawCustom = localStorage.getItem("customStopNames");
+ if (rawCustom)
+ customNames = JSON.parse(rawCustom) as Record<number, string>;
+ }
}
async function getStops(): Promise<Stop[]> {
- await initStops();
- // update favourites
- const rawFav = localStorage.getItem('favouriteStops');
- const favouriteStops = rawFav ? JSON.parse(rawFav) as number[] : [];
- cachedStops!.forEach(stop => stop.favourite = favouriteStops.includes(stop.stopId));
- return cachedStops!;
+ await initStops();
+ // update favourites
+ const rawFav = localStorage.getItem("favouriteStops");
+ const favouriteStops = rawFav ? (JSON.parse(rawFav) as number[]) : [];
+ cachedStops!.forEach(
+ (stop) => (stop.favourite = favouriteStops.includes(stop.stopId)),
+ );
+ return cachedStops!;
}
// New: get single stop by id
async function getStopById(stopId: number): Promise<Stop | undefined> {
- await initStops();
- const stop = stopsMap[stopId];
- if (stop) {
- const rawFav = localStorage.getItem('favouriteStops');
- const favouriteStops = rawFav ? JSON.parse(rawFav) as number[] : [];
- stop.favourite = favouriteStops.includes(stopId);
- }
- return stop;
+ await initStops();
+ const stop = stopsMap[stopId];
+ if (stop) {
+ const rawFav = localStorage.getItem("favouriteStops");
+ const favouriteStops = rawFav ? (JSON.parse(rawFav) as number[]) : [];
+ stop.favourite = favouriteStops.includes(stopId);
+ }
+ return stop;
}
// Updated display name to include custom names
function getDisplayName(stop: Stop): string {
- if (customNames[stop.stopId]) return customNames[stop.stopId];
- const nameObj = stop.name;
- return nameObj.intersect || nameObj.original;
+ if (customNames[stop.stopId]) return customNames[stop.stopId];
+ const nameObj = stop.name;
+ return nameObj.intersect || nameObj.original;
}
// New: set or remove custom names
function setCustomName(stopId: number, label: string) {
- customNames[stopId] = label;
- localStorage.setItem('customStopNames', JSON.stringify(customNames));
+ customNames[stopId] = label;
+ localStorage.setItem("customStopNames", JSON.stringify(customNames));
}
function removeCustomName(stopId: number) {
- delete customNames[stopId];
- localStorage.setItem('customStopNames', JSON.stringify(customNames));
+ delete customNames[stopId];
+ localStorage.setItem("customStopNames", JSON.stringify(customNames));
}
// New: get custom label for a stop
function getCustomName(stopId: number): string | undefined {
- return customNames[stopId];
+ return customNames[stopId];
}
function addFavourite(stopId: number) {
- const rawFavouriteStops = localStorage.getItem('favouriteStops');
- let favouriteStops: number[] = [];
- if (rawFavouriteStops) {
- favouriteStops = JSON.parse(rawFavouriteStops) as number[];
- }
+ const rawFavouriteStops = localStorage.getItem("favouriteStops");
+ let favouriteStops: number[] = [];
+ if (rawFavouriteStops) {
+ favouriteStops = JSON.parse(rawFavouriteStops) as number[];
+ }
- if (!favouriteStops.includes(stopId)) {
- favouriteStops.push(stopId);
- localStorage.setItem('favouriteStops', JSON.stringify(favouriteStops));
- }
+ if (!favouriteStops.includes(stopId)) {
+ favouriteStops.push(stopId);
+ localStorage.setItem("favouriteStops", JSON.stringify(favouriteStops));
+ }
}
function removeFavourite(stopId: number) {
- const rawFavouriteStops = localStorage.getItem('favouriteStops');
- let favouriteStops: number[] = [];
- if (rawFavouriteStops) {
- favouriteStops = JSON.parse(rawFavouriteStops) as number[];
- }
+ const rawFavouriteStops = localStorage.getItem("favouriteStops");
+ let favouriteStops: number[] = [];
+ if (rawFavouriteStops) {
+ favouriteStops = JSON.parse(rawFavouriteStops) as number[];
+ }
- const newFavouriteStops = favouriteStops.filter(id => id !== stopId);
- localStorage.setItem('favouriteStops', JSON.stringify(newFavouriteStops));
+ const newFavouriteStops = favouriteStops.filter((id) => id !== stopId);
+ localStorage.setItem("favouriteStops", JSON.stringify(newFavouriteStops));
}
function isFavourite(stopId: number): boolean {
- const rawFavouriteStops = localStorage.getItem('favouriteStops');
- if (rawFavouriteStops) {
- const favouriteStops = JSON.parse(rawFavouriteStops) as number[];
- return favouriteStops.includes(stopId);
- }
- return false;
+ const rawFavouriteStops = localStorage.getItem("favouriteStops");
+ if (rawFavouriteStops) {
+ const favouriteStops = JSON.parse(rawFavouriteStops) as number[];
+ return favouriteStops.includes(stopId);
+ }
+ return false;
}
const RECENT_STOPS_LIMIT = 10;
function pushRecent(stopId: number) {
- const rawRecentStops = localStorage.getItem('recentStops');
- let recentStops: Set<number> = new Set();
- if (rawRecentStops) {
- recentStops = new Set(JSON.parse(rawRecentStops) as number[]);
- }
+ const rawRecentStops = localStorage.getItem("recentStops");
+ let recentStops: Set<number> = new Set();
+ if (rawRecentStops) {
+ recentStops = new Set(JSON.parse(rawRecentStops) as number[]);
+ }
- recentStops.add(stopId);
- if (recentStops.size > RECENT_STOPS_LIMIT) {
- const iterator = recentStops.values();
- const val = iterator.next().value as number;
- recentStops.delete(val);
- }
+ recentStops.add(stopId);
+ if (recentStops.size > RECENT_STOPS_LIMIT) {
+ const iterator = recentStops.values();
+ const val = iterator.next().value as number;
+ recentStops.delete(val);
+ }
- localStorage.setItem('recentStops', JSON.stringify(Array.from(recentStops)));
+ localStorage.setItem("recentStops", JSON.stringify(Array.from(recentStops)));
}
function getRecent(): number[] {
- const rawRecentStops = localStorage.getItem('recentStops');
- if (rawRecentStops) {
- return JSON.parse(rawRecentStops) as number[];
- }
- return [];
+ const rawRecentStops = localStorage.getItem("recentStops");
+ if (rawRecentStops) {
+ return JSON.parse(rawRecentStops) as number[];
+ }
+ return [];
}
export default {
- getStops,
- getStopById,
- getCustomName,
- getDisplayName,
- setCustomName,
- removeCustomName,
- addFavourite,
- removeFavourite,
- isFavourite,
- pushRecent,
- getRecent
+ getStops,
+ getStopById,
+ getCustomName,
+ getDisplayName,
+ setCustomName,
+ removeCustomName,
+ addFavourite,
+ removeFavourite,
+ isFavourite,
+ pushRecent,
+ getRecent,
};
diff --git a/src/frontend/app/i18n/index.ts b/src/frontend/app/i18n/index.ts
index a7ba6aa..492a9a9 100644
--- a/src/frontend/app/i18n/index.ts
+++ b/src/frontend/app/i18n/index.ts
@@ -1,15 +1,15 @@
-import i18n from 'i18next';
-import { initReactI18next } from 'react-i18next';
-import LanguageDetector from 'i18next-browser-languagedetector';
-import esES from './locales/es-ES.json';
-import glES from './locales/gl-ES.json';
-import enGB from './locales/en-GB.json';
+import i18n from "i18next";
+import { initReactI18next } from "react-i18next";
+import LanguageDetector from "i18next-browser-languagedetector";
+import esES from "./locales/es-ES.json";
+import glES from "./locales/gl-ES.json";
+import enGB from "./locales/en-GB.json";
// Add more languages as needed
const resources = {
- 'es-ES': { translation: esES },
- 'gl-ES': { translation: glES },
- 'en-GB': { translation: enGB },
+ "es-ES": { translation: esES },
+ "gl-ES": { translation: glES },
+ "en-GB": { translation: enGB },
};
i18n
@@ -17,14 +17,14 @@ i18n
.use(initReactI18next)
.init({
resources,
- fallbackLng: 'es-ES',
+ fallbackLng: "es-ES",
interpolation: {
escapeValue: false,
},
- supportedLngs: ['es-ES', 'gl-ES', 'en-GB'],
+ supportedLngs: ["es-ES", "gl-ES", "en-GB"],
detection: {
- order: ['querystring', 'cookie', 'localStorage', 'navigator', 'htmlTag'],
- caches: ['localStorage', 'cookie'],
+ order: ["querystring", "cookie", "localStorage", "navigator", "htmlTag"],
+ caches: ["localStorage", "cookie"],
},
});
diff --git a/src/frontend/app/i18n/locales/en-GB.json b/src/frontend/app/i18n/locales/en-GB.json
index cd0780c..264290b 100644
--- a/src/frontend/app/i18n/locales/en-GB.json
+++ b/src/frontend/app/i18n/locales/en-GB.json
@@ -43,11 +43,13 @@
"arrival": "Arrival",
"distance": "Distance",
"not_available": "Not available",
- "none": "No estimates available"
+ "none": "No estimates available",
+ "next_arrivals": "Next arrivals"
},
"map": {
"popup_title": "Stop",
- "lines": "Lines"
+ "lines": "Lines",
+ "view_all_estimates": "View all estimates"
},
"common": {
"loading": "Loading...",
diff --git a/src/frontend/app/i18n/locales/es-ES.json b/src/frontend/app/i18n/locales/es-ES.json
index 2f2bb86..d7d78ad 100644
--- a/src/frontend/app/i18n/locales/es-ES.json
+++ b/src/frontend/app/i18n/locales/es-ES.json
@@ -43,11 +43,13 @@
"arrival": "Llegada",
"distance": "Distancia",
"not_available": "No disponible",
- "none": "No hay estimaciones disponibles"
+ "none": "No hay estimaciones disponibles",
+ "next_arrivals": "Próximas llegadas"
},
"map": {
"popup_title": "Parada",
- "lines": "Líneas"
+ "lines": "Líneas",
+ "view_all_estimates": "Ver todas las estimaciones"
},
"common": {
"loading": "Cargando...",
diff --git a/src/frontend/app/i18n/locales/gl-ES.json b/src/frontend/app/i18n/locales/gl-ES.json
index d2558e5..3012638 100644
--- a/src/frontend/app/i18n/locales/gl-ES.json
+++ b/src/frontend/app/i18n/locales/gl-ES.json
@@ -43,11 +43,13 @@
"arrival": "Chegada",
"distance": "Distancia",
"not_available": "Non dispoñible",
- "none": "Non hai estimacións dispoñibles"
+ "none": "Non hai estimacións dispoñibles",
+ "next_arrivals": "Próximas chegadas"
},
"map": {
"popup_title": "Parada",
- "lines": "Liñas"
+ "lines": "Liñas",
+ "view_all_estimates": "Ver todas as estimacións"
},
"common": {
"loading": "Cargando...",
diff --git a/src/frontend/app/maps/styleloader.ts b/src/frontend/app/maps/styleloader.ts
index f00aacc..cf285a5 100644
--- a/src/frontend/app/maps/styleloader.ts
+++ b/src/frontend/app/maps/styleloader.ts
@@ -1,49 +1,58 @@
import type { StyleSpecification } from "react-map-gl/maplibre";
-export async function loadStyle(styleName: string, colorScheme: string): Promise<StyleSpecification> {
- const stylePath = `/maps/styles/${styleName}-${colorScheme}.json`;
- const resp = await fetch(stylePath);
+export async function loadStyle(
+ styleName: string,
+ colorScheme: string,
+): Promise<StyleSpecification> {
+ const stylePath = `/maps/styles/${styleName}-${colorScheme}.json`;
+ const resp = await fetch(stylePath);
- if (!resp.ok) {
- throw new Error(`Failed to load style: ${stylePath}`);
- }
+ if (!resp.ok) {
+ throw new Error(`Failed to load style: ${stylePath}`);
+ }
- const style = await resp.json();
+ const style = await resp.json();
- const baseUrl = window.location.origin;
- const spritePath = style.sprite;
+ const baseUrl = window.location.origin;
+ const spritePath = style.sprite;
- // Handle both string and array cases for spritePath
- if (Array.isArray(spritePath)) {
- // For array format, update each sprite object's URL to be absolute
- style.sprite = spritePath.map(spriteObj => {
- const isAbsoluteUrl = spriteObj.url.startsWith("http://") || spriteObj.url.startsWith("https://");
- if (isAbsoluteUrl) {
- return spriteObj;
- }
+ // Handle both string and array cases for spritePath
+ if (Array.isArray(spritePath)) {
+ // For array format, update each sprite object's URL to be absolute
+ style.sprite = spritePath.map((spriteObj) => {
+ const isAbsoluteUrl =
+ spriteObj.url.startsWith("http://") ||
+ spriteObj.url.startsWith("https://");
+ if (isAbsoluteUrl) {
+ return spriteObj;
+ }
- return {
- ...spriteObj,
- url: `${baseUrl}${spriteObj.url}`
- };
- });
- } else if (typeof spritePath === "string") {
- if (!spritePath.startsWith("http://") && !spritePath.startsWith("https://")) {
- style.sprite = `${baseUrl}${spritePath}`;
- }
+ return {
+ ...spriteObj,
+ url: `${baseUrl}${spriteObj.url}`,
+ };
+ });
+ } else if (typeof spritePath === "string") {
+ if (
+ !spritePath.startsWith("http://") &&
+ !spritePath.startsWith("https://")
+ ) {
+ style.sprite = `${baseUrl}${spritePath}`;
}
+ }
- // Detect on each source if it the 'tiles' URLs are relative and convert them to absolute URLs
- for (const sourceKey in style.sources) {
- const source = style.sources[sourceKey];
- for (const tileKey in source.tiles) {
- const tileUrl = source.tiles[tileKey];
- const isAbsoluteUrl = tileUrl.startsWith("http://") || tileUrl.startsWith("https://");
- if (!isAbsoluteUrl) {
- source.tiles[tileKey] = `${baseUrl}${tileUrl}`;
- }
- }
+ // Detect on each source if it the 'tiles' URLs are relative and convert them to absolute URLs
+ for (const sourceKey in style.sources) {
+ const source = style.sources[sourceKey];
+ for (const tileKey in source.tiles) {
+ const tileUrl = source.tiles[tileKey];
+ const isAbsoluteUrl =
+ tileUrl.startsWith("http://") || tileUrl.startsWith("https://");
+ if (!isAbsoluteUrl) {
+ source.tiles[tileKey] = `${baseUrl}${tileUrl}`;
+ }
}
+ }
- return style as StyleSpecification;
+ return style as StyleSpecification;
}
diff --git a/src/frontend/app/root.css b/src/frontend/app/root.css
index a5024df..da3ab67 100644
--- a/src/frontend/app/root.css
+++ b/src/frontend/app/root.css
@@ -10,10 +10,10 @@
--star-color: #ffcc00;
--message-background-color: #f8f9fa;
- font-family: 'Roboto Variable', Roboto, Arial, sans-serif;
+ font-family: "Roboto Variable", Roboto, Arial, sans-serif;
}
-[data-theme='dark'] {
+[data-theme="dark"] {
--colour-scheme: dark;
--background-color: #121212;
--text-color: #ffffff;
@@ -65,8 +65,8 @@ body {
align-items: center;
text-decoration: none;
color: var(--text-color);
- padding: .25rem 0;
- border-radius: .5rem;
+ padding: 0.25rem 0;
+ border-radius: 0.5rem;
}
.navigation-bar__link svg {
diff --git a/src/frontend/app/root.tsx b/src/frontend/app/root.tsx
index 8ffbe86..55c6c16 100644
--- a/src/frontend/app/root.tsx
+++ b/src/frontend/app/root.tsx
@@ -5,7 +5,7 @@ import {
Meta,
Outlet,
Scripts,
- ScrollRestoration
+ ScrollRestoration,
} from "react-router";
import type { Route } from "./+types/root";
@@ -24,13 +24,14 @@ maplibregl.addProtocol("pmtiles", pmtiles.tile);
import "./i18n";
-if ('serviceWorker' in navigator) {
- navigator.serviceWorker.register('/sw.js')
+if ("serviceWorker" in navigator) {
+ navigator.serviceWorker
+ .register("/sw.js")
.then((registration) => {
- console.log('Service Worker registered with scope:', registration.scope);
+ console.log("Service Worker registered with scope:", registration.scope);
})
.catch((error) => {
- console.error('Service Worker registration failed:', error);
+ console.error("Service Worker registration failed:", error);
});
}
@@ -54,15 +55,27 @@ export function Layout({ children }: { children: React.ReactNode }) {
<link rel="canonical" href="https://urbanovigo.costas.dev/" />
- <meta name="description" content="Aplicación web para encontrar paradas y tiempos de llegada de los autobuses urbanos de Vigo, España." />
- <meta name="keywords" content="Vigo, autobús, urbano, parada, tiempo, llegada, transporte, público, España" />
+ <meta
+ name="description"
+ content="Aplicación web para encontrar paradas y tiempos de llegada de los autobuses urbanos de Vigo, España."
+ />
+ <meta
+ name="keywords"
+ content="Vigo, autobús, urbano, parada, tiempo, llegada, transporte, público, España"
+ />
<meta name="author" content="Ariel Costas Guerrero" />
<meta property="og:title" content="UrbanoVigo Web" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://urbanovigo.costas.dev/" />
- <meta property="og:image" content="https://urbanovigo.costas.dev/logo-512.jpg" />
- <meta property="og:description" content="Aplicación web para encontrar paradas y tiempos de llegada de los autobuses urbanos de Vigo, España." />
+ <meta
+ property="og:image"
+ content="https://urbanovigo.costas.dev/logo-512.jpg"
+ />
+ <meta
+ property="og:description"
+ content="Aplicación web para encontrar paradas y tiempos de llegada de los autobuses urbanos de Vigo, España."
+ />
<link rel="manifest" href="/manifest.webmanifest" />
@@ -82,20 +95,20 @@ export function Layout({ children }: { children: React.ReactNode }) {
);
}
- // Helper: check if coordinates are within Vigo bounds
- function isWithinVigo(lngLat: LngLatLike): boolean {
- let lng: number, lat: number;
- if (Array.isArray(lngLat)) {
- [lng, lat] = lngLat;
- } else if ('lng' in lngLat && 'lat' in lngLat) {
- lng = lngLat.lng;
- lat = lngLat.lat;
- } else {
- return false;
- }
- // Rough bounding box for Vigo
- return lat >= 42.18 && lat <= 42.30 && lng >= -8.78 && lng <= -8.65;
+// Helper: check if coordinates are within Vigo bounds
+function isWithinVigo(lngLat: LngLatLike): boolean {
+ let lng: number, lat: number;
+ if (Array.isArray(lngLat)) {
+ [lng, lat] = lngLat;
+ } else if ("lng" in lngLat && "lat" in lngLat) {
+ lng = lngLat.lng;
+ lat = lngLat.lat;
+ } else {
+ return false;
}
+ // Rough bounding box for Vigo
+ return lat >= 42.18 && lat <= 42.3 && lng >= -8.78 && lng <= -8.65;
+}
import NavBar from "./components/NavBar";
@@ -109,9 +122,6 @@ export default function App() {
<NavBar />
</footer>
</AppProvider>
-
-
-
);
}
diff --git a/src/frontend/app/routes.tsx b/src/frontend/app/routes.tsx
index 1bca5e8..9dd8a66 100644
--- a/src/frontend/app/routes.tsx
+++ b/src/frontend/app/routes.tsx
@@ -5,5 +5,5 @@ export default [
route("/stops", "routes/stoplist.tsx"),
route("/map", "routes/map.tsx"),
route("/estimates/:id", "routes/estimates-$id.tsx"),
- route("/settings", "routes/settings.tsx")
+ route("/settings", "routes/settings.tsx"),
] satisfies RouteConfig;
diff --git a/src/frontend/app/routes/estimates-$id.css b/src/frontend/app/routes/estimates-$id.css
index 86ca09b..3905f3e 100644
--- a/src/frontend/app/routes/estimates-$id.css
+++ b/src/frontend/app/routes/estimates-$id.css
@@ -102,4 +102,4 @@
.edit-icon:hover {
color: var(--star-color);
-} \ No newline at end of file
+}
diff --git a/src/frontend/app/routes/estimates-$id.tsx b/src/frontend/app/routes/estimates-$id.tsx
index e0e4fff..f2ef83a 100644
--- a/src/frontend/app/routes/estimates-$id.tsx
+++ b/src/frontend/app/routes/estimates-$id.tsx
@@ -1,7 +1,7 @@
import { type JSX, useEffect, useState } from "react";
import { useParams } from "react-router";
import StopDataProvider from "../data/StopDataProvider";
-import { Star, Edit2 } from 'lucide-react';
+import { Star, Edit2 } from "lucide-react";
import "./estimates-$id.css";
import { RegularTable } from "../components/RegularTable";
import { useApp } from "../AppContext";
@@ -9,97 +9,99 @@ import { GroupedTable } from "../components/GroupedTable";
import { useTranslation } from "react-i18next";
export interface StopDetails {
- stop: {
- id: number;
- name: string;
- latitude: number;
- longitude: number;
- }
- estimates: {
- line: string;
- route: string;
- minutes: number;
- meters: number;
- }[]
+ stop: {
+ id: number;
+ name: string;
+ latitude: number;
+ longitude: number;
+ };
+ estimates: {
+ line: string;
+ route: string;
+ minutes: number;
+ meters: number;
+ }[];
}
const loadData = async (stopId: string) => {
- const resp = await fetch(`/api/GetStopEstimates?id=${stopId}`, {
- headers: {
- 'Accept': 'application/json',
- }
- });
- return await resp.json();
+ const resp = await fetch(`/api/GetStopEstimates?id=${stopId}`, {
+ headers: {
+ Accept: "application/json",
+ },
+ });
+ return await resp.json();
};
export default function Estimates() {
- const { t } = useTranslation();
- const params = useParams();
- const stopIdNum = parseInt(params.id ?? "");
- const [customName, setCustomName] = useState<string | undefined>(undefined);
- const [data, setData] = useState<StopDetails | null>(null);
- const [dataDate, setDataDate] = useState<Date | null>(null);
- const [favourited, setFavourited] = useState(false);
- const { tableStyle } = useApp();
+ const { t } = useTranslation();
+ const params = useParams();
+ const stopIdNum = parseInt(params.id ?? "");
+ const [customName, setCustomName] = useState<string | undefined>(undefined);
+ const [data, setData] = useState<StopDetails | null>(null);
+ const [dataDate, setDataDate] = useState<Date | null>(null);
+ const [favourited, setFavourited] = useState(false);
+ const { tableStyle } = useApp();
- useEffect(() => {
- loadData(params.id!)
- .then((body: StopDetails) => {
- setData(body);
- setDataDate(new Date());
- setCustomName(StopDataProvider.getCustomName(stopIdNum));
- })
+ useEffect(() => {
+ loadData(params.id!).then((body: StopDetails) => {
+ setData(body);
+ setDataDate(new Date());
+ setCustomName(StopDataProvider.getCustomName(stopIdNum));
+ });
+ StopDataProvider.pushRecent(parseInt(params.id ?? ""));
- StopDataProvider.pushRecent(parseInt(params.id ?? ""));
+ setFavourited(StopDataProvider.isFavourite(parseInt(params.id ?? "")));
+ }, [params.id]);
- setFavourited(
- StopDataProvider.isFavourite(parseInt(params.id ?? ""))
- );
- }, [params.id]);
+ const toggleFavourite = () => {
+ if (favourited) {
+ StopDataProvider.removeFavourite(stopIdNum);
+ setFavourited(false);
+ } else {
+ StopDataProvider.addFavourite(stopIdNum);
+ setFavourited(true);
+ }
+ };
+ const handleRename = () => {
+ const current = customName ?? data?.stop.name;
+ const input = window.prompt("Custom name for this stop:", current);
+ if (input === null) return; // cancelled
+ const trimmed = input.trim();
+ if (trimmed === "") {
+ StopDataProvider.removeCustomName(stopIdNum);
+ setCustomName(undefined);
+ } else {
+ StopDataProvider.setCustomName(stopIdNum, trimmed);
+ setCustomName(trimmed);
+ }
+ };
- const toggleFavourite = () => {
- if (favourited) {
- StopDataProvider.removeFavourite(stopIdNum);
- setFavourited(false);
- } else {
- StopDataProvider.addFavourite(stopIdNum);
- setFavourited(true);
- }
- }
+ if (data === null)
+ return <h1 className="page-title">{t("common.loading")}</h1>;
- const handleRename = () => {
- const current = customName ?? data?.stop.name;
- const input = window.prompt('Custom name for this stop:', current);
- if (input === null) return; // cancelled
- const trimmed = input.trim();
- if (trimmed === '') {
- StopDataProvider.removeCustomName(stopIdNum);
- setCustomName(undefined);
- } else {
- StopDataProvider.setCustomName(stopIdNum, trimmed);
- setCustomName(trimmed);
- }
- };
+ return (
+ <div className="page-container">
+ <div className="estimates-header">
+ <h1 className="page-title">
+ <Star
+ className={`star-icon ${favourited ? "active" : ""}`}
+ onClick={toggleFavourite}
+ />
+ <Edit2 className="edit-icon" onClick={handleRename} />
+ {customName ?? data.stop.name}{" "}
+ <span className="estimates-stop-id">({data.stop.id})</span>
+ </h1>
+ </div>
- if (data === null) return <h1 className="page-title">{t('common.loading')}</h1>
-
- return (
- <div className="page-container">
- <div className="estimates-header">
- <h1 className="page-title">
- <Star className={`star-icon ${favourited ? 'active' : ''}`} onClick={toggleFavourite} />
- <Edit2 className="edit-icon" onClick={handleRename} />
- {(customName ?? data.stop.name)} <span className="estimates-stop-id">({data.stop.id})</span>
- </h1>
- </div>
-
- <div className="table-responsive">
- {tableStyle === 'grouped' ?
- <GroupedTable data={data} dataDate={dataDate} /> :
- <RegularTable data={data} dataDate={dataDate} />}
- </div>
- </div>
- )
+ <div className="table-responsive">
+ {tableStyle === "grouped" ? (
+ <GroupedTable data={data} dataDate={dataDate} />
+ ) : (
+ <RegularTable data={data} dataDate={dataDate} />
+ )}
+ </div>
+ </div>
+ );
}
diff --git a/src/frontend/app/routes/index.tsx b/src/frontend/app/routes/index.tsx
index 7c8ab40..252abec 100644
--- a/src/frontend/app/routes/index.tsx
+++ b/src/frontend/app/routes/index.tsx
@@ -1,5 +1,5 @@
import { Navigate, redirect, type LoaderFunction } from "react-router";
export default function Index() {
- return <Navigate to={"/stops"} replace />;
+ return <Navigate to={"/stops"} replace />;
}
diff --git a/src/frontend/app/routes/map.css b/src/frontend/app/routes/map.css
index 115df46..0b3ebe5 100644
--- a/src/frontend/app/routes/map.css
+++ b/src/frontend/app/routes/map.css
@@ -1,86 +1,86 @@
/* Map page specific styles */
.map-container {
- height: calc(100dvh - 140px);
- margin: -16px;
- margin-bottom: 1rem;
- position: relative;
+ height: calc(100dvh - 140px);
+ margin: -16px;
+ margin-bottom: 1rem;
+ position: relative;
}
/* Fullscreen map styles */
.fullscreen-container {
- position: absolute;
- top: 0;
- left: 0;
- width: 100vw;
- height: 100dvh;
- padding: 0;
- margin: 0;
- max-width: none;
- overflow: hidden;
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100vw;
+ height: 100dvh;
+ padding: 0;
+ margin: 0;
+ max-width: none;
+ overflow: hidden;
}
.fullscreen-map {
- width: 100%;
- height: 100%;
+ width: 100%;
+ height: 100%;
}
.fullscreen-loading {
- display: flex;
- justify-content: center;
- align-items: center;
- height: 100dvh;
- width: 100vw;
- font-size: 1.8rem;
- font-weight: 600;
- color: var(--text-color);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 100dvh;
+ width: 100vw;
+ font-size: 1.8rem;
+ font-weight: 600;
+ color: var(--text-color);
}
/* Map marker and popup styles */
.stop-marker {
- box-shadow: 0 0 5px rgba(0, 0, 0, 0.3);
- transition: all 0.2s ease-in-out;
+ box-shadow: 0 0 5px rgba(0, 0, 0, 0.3);
+ transition: all 0.2s ease-in-out;
}
.stop-marker:hover {
- transform: scale(1.2);
+ transform: scale(1.2);
}
.maplibregl-popup {
- max-width: 250px;
+ max-width: 250px;
}
.maplibregl-popup-content {
- padding: 12px;
- border-radius: 8px;
- box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
+ padding: 12px;
+ border-radius: 8px;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.popup-line-icons {
- display: flex;
- flex-wrap: wrap;
- margin: 6px 0;
- gap: 5px;
+ display: flex;
+ flex-wrap: wrap;
+ margin: 6px 0;
+ gap: 5px;
}
.popup-line {
- display: inline-block;
- background-color: var(--button-background-color);
- color: white;
- padding: 2px 6px;
- margin-right: 4px;
- border-radius: 4px;
- font-size: 0.8rem;
- font-weight: 500;
+ display: inline-block;
+ background-color: var(--button-background-color);
+ color: white;
+ padding: 2px 6px;
+ margin-right: 4px;
+ border-radius: 4px;
+ font-size: 0.8rem;
+ font-weight: 500;
}
.popup-link {
- display: block;
- margin-top: 8px;
- color: var(--button-background-color);
- text-decoration: none;
- font-weight: 500;
+ display: block;
+ margin-top: 8px;
+ color: var(--button-background-color);
+ text-decoration: none;
+ font-weight: 500;
}
.popup-link:hover {
- text-decoration: underline;
+ text-decoration: underline;
}
diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx
index ca095e2..5887b9c 100644
--- a/src/frontend/app/routes/map.tsx
+++ b/src/frontend/app/routes/map.tsx
@@ -1,13 +1,21 @@
import StopDataProvider from "../data/StopDataProvider";
-import './map.css';
+import "./map.css";
-import { useEffect, useRef, useState } from 'react';
+import { useEffect, useRef, useState } from "react";
import { useApp } from "../AppContext";
-import Map, { AttributionControl, GeolocateControl, Layer, NavigationControl, Popup, Source, type MapRef, type MapLayerMouseEvent, type StyleSpecification } from "react-map-gl/maplibre";
+import Map, {
+ AttributionControl,
+ GeolocateControl,
+ Layer,
+ NavigationControl,
+ Source,
+ type MapRef,
+ type MapLayerMouseEvent,
+ type StyleSpecification,
+} from "react-map-gl/maplibre";
import { loadStyle } from "app/maps/styleloader";
-import type { Feature as GeoJsonFeature, Point } from 'geojson';
-import LineIcon from "~/components/LineIcon";
-import { Link } from "react-router";
+import type { Feature as GeoJsonFeature, Point } from "geojson";
+import { StopSheet } from "~/components/StopSheet";
import { useTranslation } from "react-i18next";
// Default minimal fallback style before dynamic loading
@@ -16,154 +24,154 @@ const defaultStyle: StyleSpecification = {
glyphs: `${window.location.origin}/maps/fonts/{fontstack}/{range}.pbf`,
sprite: `${window.location.origin}/maps/spritesheet/sprite`,
sources: {},
- layers: []
+ layers: [],
};
// Componente principal del mapa
export default function StopMap() {
- const { t } = useTranslation();
- const [stops, setStops] = useState<GeoJsonFeature<Point, { stopId: number; name: string; lines: string[] }>[]>([]);
- const [popupInfo, setPopupInfo] = useState<any>(null);
- const { mapState, updateMapState, theme } = useApp();
- const mapRef = useRef<MapRef>(null);
- const [mapStyleKey, setMapStyleKey] = useState<string>("light");
+ const { t } = useTranslation();
+ const [stops, setStops] = useState<
+ GeoJsonFeature<Point, { stopId: number; name: string; lines: string[] }>[]
+ >([]);
+ const [selectedStop, setSelectedStop] = useState<{
+ stopId: number;
+ name: string;
+ } | null>(null);
+ const [isSheetOpen, setIsSheetOpen] = useState(false);
+ const { mapState, updateMapState, theme } = useApp();
+ const mapRef = useRef<MapRef>(null);
+ const [mapStyleKey, setMapStyleKey] = useState<string>("light");
- // Style state for Map component
- const [mapStyle, setMapStyle] = useState<StyleSpecification>(defaultStyle);
+ // Style state for Map component
+ const [mapStyle, setMapStyle] = useState<StyleSpecification>(defaultStyle);
- // Handle click events on clusters and individual stops
- const onMapClick = (e: MapLayerMouseEvent) => {
- const features = e.features;
- if (!features || features.length === 0) return;
- const feature = features[0];
- const props: any = feature.properties;
+ // Handle click events on clusters and individual stops
+ const onMapClick = (e: MapLayerMouseEvent) => {
+ const features = e.features;
+ if (!features || features.length === 0) return;
+ const feature = features[0];
+ const props: any = feature.properties;
- handlePointClick(feature);
- };
+ handlePointClick(feature);
+ };
- useEffect(() => {
- StopDataProvider.getStops().then(data => {
- const features: GeoJsonFeature<Point, { stopId: number; name: string; lines: string[] }>[] = data.map(s => ({
- type: "Feature",
- geometry: { type: "Point", coordinates: [s.longitude as number, s.latitude as number] },
- properties: { stopId: s.stopId, name: s.name.original, lines: s.lines }
- }));
- setStops(features);
- });
- }, []);
+ useEffect(() => {
+ StopDataProvider.getStops().then((data) => {
+ const features: GeoJsonFeature<
+ Point,
+ { stopId: number; name: string; lines: string[] }
+ >[] = data.map((s) => ({
+ type: "Feature",
+ geometry: {
+ type: "Point",
+ coordinates: [s.longitude as number, s.latitude as number],
+ },
+ properties: { stopId: s.stopId, name: s.name.original, lines: s.lines },
+ }));
+ setStops(features);
+ });
+ }, []);
- useEffect(() => {
- const styleName = "carto";
- loadStyle(styleName, theme)
- .then(style => setMapStyle(style))
- .catch(error => console.error("Failed to load map style:", error));
- }, [mapStyleKey, theme]);
+ useEffect(() => {
+ const styleName = "carto";
+ loadStyle(styleName, theme)
+ .then((style) => setMapStyle(style))
+ .catch((error) => console.error("Failed to load map style:", error));
+ }, [mapStyleKey, theme]);
- useEffect(() => {
- const handleMapChange = () => {
- if (!mapRef.current) return;
- const map = mapRef.current.getMap();
- if (!map) return;
- const center = map.getCenter();
- const zoom = map.getZoom();
- updateMapState([center.lat, center.lng], zoom);
- };
+ useEffect(() => {
+ const handleMapChange = () => {
+ if (!mapRef.current) return;
+ const map = mapRef.current.getMap();
+ if (!map) return;
+ const center = map.getCenter();
+ const zoom = map.getZoom();
+ updateMapState([center.lat, center.lng], zoom);
+ };
- if (mapRef.current) {
- const map = mapRef.current.getMap();
- if (map) {
- map.on('moveend', handleMapChange);
- }
- }
+ if (mapRef.current) {
+ const map = mapRef.current.getMap();
+ if (map) {
+ map.on("moveend", handleMapChange);
+ }
+ }
- return () => {
- if (mapRef.current) {
- const map = mapRef.current.getMap();
- if (map) {
- map.off('moveend', handleMapChange);
- }
- }
- };
- }, [mapRef.current]);
+ return () => {
+ if (mapRef.current) {
+ const map = mapRef.current.getMap();
+ if (map) {
+ map.off("moveend", handleMapChange);
+ }
+ }
+ };
+ }, [mapRef.current]);
- const getLatitude = (center: any) => Array.isArray(center) ? center[0] : center.lat;
- const getLongitude = (center: any) => Array.isArray(center) ? center[1] : center.lng;
+ const getLatitude = (center: any) =>
+ Array.isArray(center) ? center[0] : center.lat;
+ const getLongitude = (center: any) =>
+ Array.isArray(center) ? center[1] : center.lng;
- const handlePointClick = (feature: any) => {
- const props: any = feature.properties;
- // fetch full stop to get lines array
- StopDataProvider.getStopById(props.stopId).then(stop => {
- if (!stop) return;
- setPopupInfo({
- geometry: feature.geometry,
- properties: {
- stopId: stop.stopId,
- name: stop.name.original,
- lines: stop.lines
- }
- });
- });
- };
+ const handlePointClick = (feature: any) => {
+ const props: any = feature.properties;
+ // fetch full stop to get lines array
+ StopDataProvider.getStopById(props.stopId).then((stop) => {
+ if (!stop) return;
+ setSelectedStop({
+ stopId: stop.stopId,
+ name: stop.name.original,
+ });
+ setIsSheetOpen(true);
+ });
+ };
- return (
- <Map
- mapStyle={mapStyle}
- style={{ width: '100%', height: '100%' }}
- interactiveLayerIds={["stops"]}
- onClick={onMapClick}
- minZoom={11}
- scrollZoom
- pitch={0}
- roll={0}
- ref={mapRef}
- initialViewState={{
- latitude: getLatitude(mapState.center),
- longitude: getLongitude(mapState.center),
- zoom: mapState.zoom,
- }}
- attributionControl={false}
- >
- <NavigationControl position="top-right" />
- <GeolocateControl position="top-right" trackUserLocation={true} />
- <AttributionControl position="bottom-right" compact={false} />
+ return (
+ <Map
+ mapStyle={mapStyle}
+ style={{ width: "100%", height: "100%" }}
+ interactiveLayerIds={["stops"]}
+ onClick={onMapClick}
+ minZoom={11}
+ scrollZoom
+ pitch={0}
+ roll={0}
+ ref={mapRef}
+ initialViewState={{
+ latitude: getLatitude(mapState.center),
+ longitude: getLongitude(mapState.center),
+ zoom: mapState.zoom,
+ }}
+ attributionControl={false}
+ >
+ <NavigationControl position="top-right" />
+ <GeolocateControl position="top-right" trackUserLocation={true} />
+ <AttributionControl position="bottom-right" compact={false} />
- <Source
- id="stops-source"
- type="geojson"
- data={{ type: "FeatureCollection", features: stops }}
- />
+ <Source
+ id="stops-source"
+ type="geojson"
+ data={{ type: "FeatureCollection", features: stops }}
+ />
- <Layer
- id="stops"
- type="symbol"
- source="stops-source"
- layout={{
- "icon-image": "stop",
- "icon-size": ["interpolate", ["linear"], ["zoom"], 11, 0.4, 16, 0.8],
- "icon-allow-overlap": true,
- "icon-ignore-placement": true,
- }}
- />
+ <Layer
+ id="stops"
+ type="symbol"
+ source="stops-source"
+ layout={{
+ "icon-image": "stop",
+ "icon-size": ["interpolate", ["linear"], ["zoom"], 11, 0.4, 16, 0.8],
+ "icon-allow-overlap": true,
+ "icon-ignore-placement": true,
+ }}
+ />
- {popupInfo && (
- <Popup
- latitude={popupInfo.geometry.coordinates[1]}
- longitude={popupInfo.geometry.coordinates[0]}
- onClose={() => setPopupInfo(null)}
- >
- <div>
- <h3>{popupInfo.properties.name}</h3>
- <div>
- {popupInfo.properties.lines.map((line: string) => (
- <LineIcon line={line} key={line} />
- ))}
- </div>
- <Link to={`/estimates/${popupInfo.properties.stopId}`} className="popup-link">
- Ver parada
- </Link>
- </div>
- </Popup>
- )}
- </Map>
- );
+ {selectedStop && (
+ <StopSheet
+ isOpen={isSheetOpen}
+ onClose={() => setIsSheetOpen(false)}
+ stopId={selectedStop.stopId}
+ stopName={selectedStop.name}
+ />
+ )}
+ </Map>
+ );
}
diff --git a/src/frontend/app/routes/settings.tsx b/src/frontend/app/routes/settings.tsx
index e657c03..c08b2c9 100644
--- a/src/frontend/app/routes/settings.tsx
+++ b/src/frontend/app/routes/settings.tsx
@@ -3,74 +3,130 @@ import "./settings.css";
import { useTranslation } from "react-i18next";
export default function Settings() {
- const { t, i18n } = useTranslation();
- const { theme, setTheme, tableStyle, setTableStyle, mapPositionMode, setMapPositionMode } = useApp();
+ const { t, i18n } = useTranslation();
+ const {
+ theme,
+ setTheme,
+ tableStyle,
+ setTableStyle,
+ mapPositionMode,
+ setMapPositionMode,
+ } = useApp();
- return (
- <div className="page-container">
- <h1 className="page-title">{t('about.title')}</h1>
- <p className="about-description">
- {t('about.description')}
- </p>
- <section className="settings-section">
- <h2>{t('about.settings')}</h2>
- <div className="settings-content-inline">
- <label htmlFor="theme" className="form-label-inline">{t('about.theme')}</label>
- <select id="theme" className="form-select-inline" value={theme} onChange={(e) => setTheme(e.target.value as "light" | "dark")}>
- <option value="light">{t('about.theme_light')}</option>
- <option value="dark">{t('about.theme_dark')}</option>
- </select>
- </div>
- <div className="settings-content-inline">
- <label htmlFor="tableStyle" className="form-label-inline">{t('about.table_style')}</label>
- <select id="tableStyle" className="form-select-inline" value={tableStyle} onChange={(e) => setTableStyle(e.target.value as "regular" | "grouped")}>
- <option value="regular">{t('about.table_style_regular')}</option>
- <option value="grouped">{t('about.table_style_grouped')}</option>
- </select>
- </div>
- <div className="settings-content-inline">
- <label htmlFor="mapPositionMode" className="form-label-inline">{t('about.map_position_mode')}</label>
- <select id="mapPositionMode" className="form-select-inline" value={mapPositionMode} onChange={e => setMapPositionMode(e.target.value as 'gps' | 'last')}>
- <option value="gps">{t('about.map_position_gps')}</option>
- <option value="last">{t('about.map_position_last')}</option>
- </select>
- </div>
- <div className="settings-content-inline">
- <label htmlFor="language" className="form-label-inline">Idioma:</label>
- <select
- id="language"
- className="form-select-inline"
- value={i18n.language}
- onChange={e => i18n.changeLanguage(e.target.value)}
- >
- <option value="es-ES">Español</option>
- <option value="gl-ES">Galego</option>
- <option value="en-GB">English</option>
- </select>
- </div>
- <details className="form-details">
- <summary>{t('about.details_summary')}</summary>
- <p>{t('about.details_table')}</p>
- <dl>
- <dt>{t('about.table_style_regular')}</dt>
- <dd>{t('about.details_regular')}</dd>
- <dt>{t('about.table_style_grouped')}</dt>
- <dd>{t('about.details_grouped')}</dd>
- </dl>
- </details>
- </section>
- <h2>{t('about.credits')}</h2>
- <p>
- <a href="https://github.com/arielcostas/urbanovigo-web" className="about-link" rel="nofollow noreferrer noopener">
- {t('about.github')}
- </a> -
- {t('about.developed_by')} <a href="https://www.costas.dev" className="about-link" rel="nofollow noreferrer noopener">
- Ariel Costas
- </a>
- </p>
- <p>
- {t('about.data_source_prefix')} <a href="https://datos.vigo.org" className="about-link" rel="nofollow noreferrer noopener">datos.vigo.org</a> {t('about.data_source_middle')} <a href="https://opendefinition.org/licenses/odc-by/" className="about-link" rel="nofollow noreferrer noopener">Open Data Commons Attribution License</a>
- </p>
+ return (
+ <div className="page-container">
+ <h1 className="page-title">{t("about.title")}</h1>
+ <p className="about-description">{t("about.description")}</p>
+ <section className="settings-section">
+ <h2>{t("about.settings")}</h2>
+ <div className="settings-content-inline">
+ <label htmlFor="theme" className="form-label-inline">
+ {t("about.theme")}
+ </label>
+ <select
+ id="theme"
+ className="form-select-inline"
+ value={theme}
+ onChange={(e) => setTheme(e.target.value as "light" | "dark")}
+ >
+ <option value="light">{t("about.theme_light")}</option>
+ <option value="dark">{t("about.theme_dark")}</option>
+ </select>
</div>
- )
+ <div className="settings-content-inline">
+ <label htmlFor="tableStyle" className="form-label-inline">
+ {t("about.table_style")}
+ </label>
+ <select
+ id="tableStyle"
+ className="form-select-inline"
+ value={tableStyle}
+ onChange={(e) =>
+ setTableStyle(e.target.value as "regular" | "grouped")
+ }
+ >
+ <option value="regular">{t("about.table_style_regular")}</option>
+ <option value="grouped">{t("about.table_style_grouped")}</option>
+ </select>
+ </div>
+ <div className="settings-content-inline">
+ <label htmlFor="mapPositionMode" className="form-label-inline">
+ {t("about.map_position_mode")}
+ </label>
+ <select
+ id="mapPositionMode"
+ className="form-select-inline"
+ value={mapPositionMode}
+ onChange={(e) =>
+ setMapPositionMode(e.target.value as "gps" | "last")
+ }
+ >
+ <option value="gps">{t("about.map_position_gps")}</option>
+ <option value="last">{t("about.map_position_last")}</option>
+ </select>
+ </div>
+ <div className="settings-content-inline">
+ <label htmlFor="language" className="form-label-inline">
+ Idioma:
+ </label>
+ <select
+ id="language"
+ className="form-select-inline"
+ value={i18n.language}
+ onChange={(e) => i18n.changeLanguage(e.target.value)}
+ >
+ <option value="es-ES">Español</option>
+ <option value="gl-ES">Galego</option>
+ <option value="en-GB">English</option>
+ </select>
+ </div>
+ <details className="form-details">
+ <summary>{t("about.details_summary")}</summary>
+ <p>{t("about.details_table")}</p>
+ <dl>
+ <dt>{t("about.table_style_regular")}</dt>
+ <dd>{t("about.details_regular")}</dd>
+ <dt>{t("about.table_style_grouped")}</dt>
+ <dd>{t("about.details_grouped")}</dd>
+ </dl>
+ </details>
+ </section>
+ <h2>{t("about.credits")}</h2>
+ <p>
+ <a
+ href="https://github.com/arielcostas/urbanovigo-web"
+ className="about-link"
+ rel="nofollow noreferrer noopener"
+ >
+ {t("about.github")}
+ </a>{" "}
+ -{t("about.developed_by")}{" "}
+ <a
+ href="https://www.costas.dev"
+ className="about-link"
+ rel="nofollow noreferrer noopener"
+ >
+ Ariel Costas
+ </a>
+ </p>
+ <p>
+ {t("about.data_source_prefix")}{" "}
+ <a
+ href="https://datos.vigo.org"
+ className="about-link"
+ rel="nofollow noreferrer noopener"
+ >
+ datos.vigo.org
+ </a>{" "}
+ {t("about.data_source_middle")}{" "}
+ <a
+ href="https://opendefinition.org/licenses/odc-by/"
+ className="about-link"
+ rel="nofollow noreferrer noopener"
+ >
+ Open Data Commons Attribution License
+ </a>
+ </p>
+ </div>
+ );
}
diff --git a/src/frontend/app/routes/stoplist.tsx b/src/frontend/app/routes/stoplist.tsx
index 9404b39..58cdab4 100644
--- a/src/frontend/app/routes/stoplist.tsx
+++ b/src/frontend/app/routes/stoplist.tsx
@@ -2,125 +2,143 @@ import { useEffect, useMemo, useRef, useState } from "react";
import StopDataProvider, { type Stop } from "../data/StopDataProvider";
import StopItem from "../components/StopItem";
import Fuse from "fuse.js";
-import './stoplist.css';
+import "./stoplist.css";
import { useTranslation } from "react-i18next";
export default function StopList() {
- const { t } = useTranslation();
- const [data, setData] = useState<Stop[] | null>(null)
- const [searchResults, setSearchResults] = useState<Stop[] | null>(null);
- const searchTimeout = useRef<NodeJS.Timeout | null>(null);
+ const { t } = useTranslation();
+ const [data, setData] = useState<Stop[] | null>(null);
+ const [searchResults, setSearchResults] = useState<Stop[] | null>(null);
+ const searchTimeout = useRef<NodeJS.Timeout | null>(null);
- const randomPlaceholder = useMemo(() => t('stoplist.search_placeholder'), [t]);
- const fuse = useMemo(() => new Fuse(data || [], { threshold: 0.3, keys: ['name.original'] }), [data]);
+ const randomPlaceholder = useMemo(
+ () => t("stoplist.search_placeholder"),
+ [t],
+ );
+ const fuse = useMemo(
+ () => new Fuse(data || [], { threshold: 0.3, keys: ["name.original"] }),
+ [data],
+ );
- useEffect(() => {
- StopDataProvider.getStops().then((stops: Stop[]) => setData(stops))
- }, []);
+ useEffect(() => {
+ StopDataProvider.getStops().then((stops: Stop[]) => setData(stops));
+ }, []);
- const handleStopSearch = (event: React.ChangeEvent<HTMLInputElement>) => {
- const stopName = event.target.value || "";
+ const handleStopSearch = (event: React.ChangeEvent<HTMLInputElement>) => {
+ const stopName = event.target.value || "";
- if (searchTimeout.current) {
- clearTimeout(searchTimeout.current);
- }
+ if (searchTimeout.current) {
+ clearTimeout(searchTimeout.current);
+ }
- searchTimeout.current = setTimeout(() => {
- if (stopName.length === 0) {
- setSearchResults(null);
- return;
- }
+ searchTimeout.current = setTimeout(() => {
+ if (stopName.length === 0) {
+ setSearchResults(null);
+ return;
+ }
- if (!data) {
- console.error("No data available for search");
- return;
- }
+ if (!data) {
+ console.error("No data available for search");
+ return;
+ }
- const results = fuse.search(stopName);
- const items = results.map(result => result.item);
- setSearchResults(items);
- }, 300);
- }
+ const results = fuse.search(stopName);
+ const items = results.map((result) => result.item);
+ setSearchResults(items);
+ }, 300);
+ };
- const favouritedStops = useMemo(() => {
- return data?.filter(stop => stop.favourite) ?? []
- }, [data])
+ const favouritedStops = useMemo(() => {
+ return data?.filter((stop) => stop.favourite) ?? [];
+ }, [data]);
- const recentStops = useMemo(() => {
- // no recent items if data not loaded
- if (!data) return null;
- const recentIds = StopDataProvider.getRecent();
- if (recentIds.length === 0) return null;
- // map and filter out missing entries
- const stopsList = recentIds
- .map(id => data.find(stop => stop.stopId === id))
- .filter((s): s is Stop => Boolean(s));
- return stopsList.reverse();
- }, [data]);
+ const recentStops = useMemo(() => {
+ // no recent items if data not loaded
+ if (!data) return null;
+ const recentIds = StopDataProvider.getRecent();
+ if (recentIds.length === 0) return null;
+ // map and filter out missing entries
+ const stopsList = recentIds
+ .map((id) => data.find((stop) => stop.stopId === id))
+ .filter((s): s is Stop => Boolean(s));
+ return stopsList.reverse();
+ }, [data]);
- if (data === null) return <h1 className="page-title">{t('common.loading')}</h1>
+ if (data === null)
+ return <h1 className="page-title">{t("common.loading")}</h1>;
- return (
- <div className="page-container">
- <h1 className="page-title">UrbanoVigo Web</h1>
+ return (
+ <div className="page-container">
+ <h1 className="page-title">UrbanoVigo Web</h1>
- <form className="search-form">
- <div className="form-group">
- <label className="form-label" htmlFor="stopName">
- {t('stoplist.search_label', 'Buscar paradas')}
- </label>
- <input className="form-input" type="text" placeholder={randomPlaceholder} id="stopName" onChange={handleStopSearch} />
- </div>
- </form>
+ <form className="search-form">
+ <div className="form-group">
+ <label className="form-label" htmlFor="stopName">
+ {t("stoplist.search_label", "Buscar paradas")}
+ </label>
+ <input
+ className="form-input"
+ type="text"
+ placeholder={randomPlaceholder}
+ id="stopName"
+ onChange={handleStopSearch}
+ />
+ </div>
+ </form>
- {searchResults && searchResults.length > 0 && (
- <div className="list-container">
- <h2 className="page-subtitle">{t('stoplist.search_results', 'Resultados de la búsqueda')}</h2>
- <ul className="list">
- {searchResults.map((stop: Stop) => (
- <StopItem key={stop.stopId} stop={stop} />
- ))}
- </ul>
- </div>
- )}
+ {searchResults && searchResults.length > 0 && (
+ <div className="list-container">
+ <h2 className="page-subtitle">
+ {t("stoplist.search_results", "Resultados de la búsqueda")}
+ </h2>
+ <ul className="list">
+ {searchResults.map((stop: Stop) => (
+ <StopItem key={stop.stopId} stop={stop} />
+ ))}
+ </ul>
+ </div>
+ )}
- <div className="list-container">
- <h2 className="page-subtitle">{t('stoplist.favourites')}</h2>
+ <div className="list-container">
+ <h2 className="page-subtitle">{t("stoplist.favourites")}</h2>
- {favouritedStops?.length === 0 && (
- <p className="message">
- {t('stoplist.no_favourites', 'Accede a una parada y márcala como favorita para verla aquí.')}
- </p>
- )}
+ {favouritedStops?.length === 0 && (
+ <p className="message">
+ {t(
+ "stoplist.no_favourites",
+ "Accede a una parada y márcala como favorita para verla aquí.",
+ )}
+ </p>
+ )}
- <ul className="list">
- {favouritedStops?.sort((a, b) => a.stopId - b.stopId).map((stop: Stop) => (
- <StopItem key={stop.stopId} stop={stop} />
- ))}
- </ul>
- </div>
+ <ul className="list">
+ {favouritedStops
+ ?.sort((a, b) => a.stopId - b.stopId)
+ .map((stop: Stop) => <StopItem key={stop.stopId} stop={stop} />)}
+ </ul>
+ </div>
- {recentStops && recentStops.length > 0 && (
- <div className="list-container">
- <h2 className="page-subtitle">{t('stoplist.recents')}</h2>
+ {recentStops && recentStops.length > 0 && (
+ <div className="list-container">
+ <h2 className="page-subtitle">{t("stoplist.recents")}</h2>
- <ul className="list">
- {recentStops.map((stop: Stop) => (
- <StopItem key={stop.stopId} stop={stop} />
- ))}
- </ul>
- </div>
- )}
+ <ul className="list">
+ {recentStops.map((stop: Stop) => (
+ <StopItem key={stop.stopId} stop={stop} />
+ ))}
+ </ul>
+ </div>
+ )}
- <div className="list-container">
- <h2 className="page-subtitle">{t('stoplist.all_stops', 'Paradas')}</h2>
+ <div className="list-container">
+ <h2 className="page-subtitle">{t("stoplist.all_stops", "Paradas")}</h2>
- <ul className="list">
- {data?.sort((a, b) => a.stopId - b.stopId).map((stop: Stop) => (
- <StopItem key={stop.stopId} stop={stop} />
- ))}
- </ul>
- </div>
- </div>
- )
+ <ul className="list">
+ {data
+ ?.sort((a, b) => a.stopId - b.stopId)
+ .map((stop: Stop) => <StopItem key={stop.stopId} stop={stop} />)}
+ </ul>
+ </div>
+ </div>
+ );
}