aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/app
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2025-12-07 23:33:10 +0100
committerAriel Costas Guerrero <ariel@costas.dev>2025-12-07 23:37:38 +0100
commita1d589c1a0d5a5010e5fe4e8a1ec403ffafb289f (patch)
tree870366d9ce178530b836086e432331f78ec4a07e /src/frontend/app
parent5fa8d1ffeb4a3a0c5c6846de3986ec779a4fe564 (diff)
Implement Renfe data source
Diffstat (limited to 'src/frontend/app')
-rw-r--r--src/frontend/app/components/StopItemSkeleton.tsx2
-rw-r--r--src/frontend/app/components/StopSummarySheet.tsx2
-rw-r--r--src/frontend/app/data/StopDataProvider.ts127
-rw-r--r--src/frontend/app/routes/map.tsx28
-rw-r--r--src/frontend/app/routes/stops-$id.tsx49
5 files changed, 136 insertions, 72 deletions
diff --git a/src/frontend/app/components/StopItemSkeleton.tsx b/src/frontend/app/components/StopItemSkeleton.tsx
index 68172fd..778b5e1 100644
--- a/src/frontend/app/components/StopItemSkeleton.tsx
+++ b/src/frontend/app/components/StopItemSkeleton.tsx
@@ -4,7 +4,7 @@ import "react-loading-skeleton/dist/skeleton.css";
interface StopItemSkeletonProps {
showId?: boolean;
- stopId?: number;
+ stopId?: string;
}
const StopItemSkeleton: React.FC<StopItemSkeletonProps> = ({
diff --git a/src/frontend/app/components/StopSummarySheet.tsx b/src/frontend/app/components/StopSummarySheet.tsx
index 17c0afd..e85dda3 100644
--- a/src/frontend/app/components/StopSummarySheet.tsx
+++ b/src/frontend/app/components/StopSummarySheet.tsx
@@ -26,7 +26,7 @@ interface ErrorInfo {
}
const loadConsolidatedData = async (
- stopId: number
+ stopId: string
): Promise<ConsolidatedCirculation[]> => {
const resp = await fetch(
`${REGION_DATA.consolidatedCirculationsEndpoint}?stopId=${stopId}`,
diff --git a/src/frontend/app/data/StopDataProvider.ts b/src/frontend/app/data/StopDataProvider.ts
index abe7123..920c7e1 100644
--- a/src/frontend/app/data/StopDataProvider.ts
+++ b/src/frontend/app/data/StopDataProvider.ts
@@ -11,7 +11,8 @@ export type StopName = {
};
export interface Stop {
- stopId: number;
+ stopId: string;
+ type?: 'bus' | 'train';
name: StopName;
latitude?: number;
longitude?: number;
@@ -27,29 +28,46 @@ export interface Stop {
// In-memory cache and lookup map per region
const cachedStopsByRegion: Record<string, Stop[] | null> = {};
-const stopsMapByRegion: Record<string, Record<number, Stop>> = {};
+const stopsMapByRegion: Record<string, Record<string, Stop>> = {};
// Custom names loaded from localStorage per region
-const customNamesByRegion: Record<string, Record<number, string>> = {};
+const customNamesByRegion: Record<string, Record<string, string>> = {};
+
+// Helper to normalize ID
+function normalizeId(id: number | string): string {
+ const s = String(id);
+ if (s.includes(':')) return s;
+ return `vitrasa:${s}`;
+}
// Initialize cachedStops and customNames once per region
async function initStops() {
if (!cachedStopsByRegion[REGION_DATA.id]) {
const response = await fetch(REGION_DATA.stopsEndpoint);
- const stops = (await response.json()) as Stop[];
+ const rawStops = (await response.json()) as any[];
+
// build array and map
stopsMapByRegion[REGION_DATA.id] = {};
- cachedStopsByRegion[REGION_DATA.id] = stops.map((stop) => {
- const entry = { ...stop, favourite: false } as Stop;
- stopsMapByRegion[REGION_DATA.id][stop.stopId] = entry;
+ cachedStopsByRegion[REGION_DATA.id] = rawStops.map((raw) => {
+ const id = normalizeId(raw.stopId);
+ const entry = {
+ ...raw,
+ stopId: id,
+ type: raw.type || (id.startsWith('renfe:') ? 'train' : 'bus'),
+ favourite: false
+ } as Stop;
+ stopsMapByRegion[REGION_DATA.id][id] = entry;
return entry;
});
+
// load custom names
const rawCustom = localStorage.getItem(`customStopNames_${REGION_DATA.id}`);
if (rawCustom) {
- customNamesByRegion[REGION_DATA.id] = JSON.parse(rawCustom) as Record<
- number,
- string
- >;
+ const parsed = JSON.parse(rawCustom);
+ const normalized: Record<string, string> = {};
+ for (const [key, value] of Object.entries(parsed)) {
+ normalized[normalizeId(key)] = value as string;
+ }
+ customNamesByRegion[REGION_DATA.id] = normalized;
} else {
customNamesByRegion[REGION_DATA.id] = {};
}
@@ -60,7 +78,8 @@ async function getStops(): Promise<Stop[]> {
await initStops();
// update favourites
const rawFav = localStorage.getItem("favouriteStops_vigo");
- const favouriteStops = rawFav ? (JSON.parse(rawFav) as number[]) : [];
+ const favouriteStops = rawFav ? (JSON.parse(rawFav) as (number|string)[]).map(normalizeId) : [];
+
cachedStopsByRegion["vigo"]!.forEach(
(stop) => (stop.favourite = favouriteStops.includes(stop.stopId))
);
@@ -69,14 +88,15 @@ async function getStops(): Promise<Stop[]> {
// New: get single stop by id
async function getStopById(
- stopId: number
+ stopId: string | number
): Promise<Stop | undefined> {
await initStops();
- const stop = stopsMapByRegion[REGION_DATA.id]?.[stopId];
+ const id = normalizeId(stopId);
+ const stop = stopsMapByRegion[REGION_DATA.id]?.[id];
if (stop) {
const rawFav = localStorage.getItem(`favouriteStops_${REGION_DATA.id}`);
- const favouriteStops = rawFav ? (JSON.parse(rawFav) as number[]) : [];
- stop.favourite = favouriteStops.includes(stopId);
+ const favouriteStops = rawFav ? (JSON.parse(rawFav) as (number|string)[]).map(normalizeId) : [];
+ stop.favourite = favouriteStops.includes(id);
}
return stop;
}
@@ -90,20 +110,22 @@ function getDisplayName(stop: Stop): string {
}
// New: set or remove custom names
-function setCustomName(stopId: number, label: string) {
+function setCustomName(stopId: string | number, label: string) {
+ const id = normalizeId(stopId);
if (!customNamesByRegion[REGION_DATA.id]) {
customNamesByRegion[REGION_DATA.id] = {};
}
- customNamesByRegion[REGION_DATA.id][stopId] = label;
+ customNamesByRegion[REGION_DATA.id][id] = label;
localStorage.setItem(
`customStopNames_${REGION_DATA.id}`,
JSON.stringify(customNamesByRegion[REGION_DATA.id])
);
}
-function removeCustomName(stopId: number) {
- if (customNamesByRegion[REGION_DATA.id]?.[stopId]) {
- delete customNamesByRegion[REGION_DATA.id][stopId];
+function removeCustomName(stopId: string | number) {
+ const id = normalizeId(stopId);
+ if (customNamesByRegion[REGION_DATA.id]?.[id]) {
+ delete customNamesByRegion[REGION_DATA.id][id];
localStorage.setItem(
`customStopNames_${REGION_DATA.id}`,
JSON.stringify(customNamesByRegion[REGION_DATA.id])
@@ -112,19 +134,21 @@ function removeCustomName(stopId: number) {
}
// New: get custom label for a stop
-function getCustomName(stopId: number): string | undefined {
- return customNamesByRegion[REGION_DATA.id]?.[stopId];
+function getCustomName(stopId: string | number): string | undefined {
+ const id = normalizeId(stopId);
+ return customNamesByRegion[REGION_DATA.id]?.[id];
}
-function addFavourite(stopId: number) {
+function addFavourite(stopId: string | number) {
+ const id = normalizeId(stopId);
const rawFavouriteStops = localStorage.getItem(`favouriteStops_vigo`);
- let favouriteStops: number[] = [];
+ let favouriteStops: string[] = [];
if (rawFavouriteStops) {
- favouriteStops = JSON.parse(rawFavouriteStops) as number[];
+ favouriteStops = (JSON.parse(rawFavouriteStops) as (number|string)[]).map(normalizeId);
}
- if (!favouriteStops.includes(stopId)) {
- favouriteStops.push(stopId);
+ if (!favouriteStops.includes(id)) {
+ favouriteStops.push(id);
localStorage.setItem(
`favouriteStops_vigo`,
JSON.stringify(favouriteStops)
@@ -132,42 +156,45 @@ function addFavourite(stopId: number) {
}
}
-function removeFavourite(stopId: number) {
+function removeFavourite(stopId: string | number) {
+ const id = normalizeId(stopId);
const rawFavouriteStops = localStorage.getItem(`favouriteStops_vigo`);
- let favouriteStops: number[] = [];
+ let favouriteStops: string[] = [];
if (rawFavouriteStops) {
- favouriteStops = JSON.parse(rawFavouriteStops) as number[];
+ favouriteStops = (JSON.parse(rawFavouriteStops) as (number|string)[]).map(normalizeId);
}
- const newFavouriteStops = favouriteStops.filter((id) => id !== stopId);
+ const newFavouriteStops = favouriteStops.filter((sid) => sid !== id);
localStorage.setItem(
`favouriteStops_vigo`,
JSON.stringify(newFavouriteStops)
);
}
-function isFavourite(stopId: number): boolean {
+function isFavourite(stopId: string | number): boolean {
+ const id = normalizeId(stopId);
const rawFavouriteStops = localStorage.getItem(`favouriteStops_vigo`);
if (rawFavouriteStops) {
- const favouriteStops = JSON.parse(rawFavouriteStops) as number[];
- return favouriteStops.includes(stopId);
+ const favouriteStops = (JSON.parse(rawFavouriteStops) as (number|string)[]).map(normalizeId);
+ return favouriteStops.includes(id);
}
return false;
}
const RECENT_STOPS_LIMIT = 10;
-function pushRecent(stopId: number) {
+function pushRecent(stopId: string | number) {
+ const id = normalizeId(stopId);
const rawRecentStops = localStorage.getItem(`recentStops_vigo`);
- let recentStops: Set<number> = new Set();
+ let recentStops: Set<string> = new Set();
if (rawRecentStops) {
- recentStops = new Set(JSON.parse(rawRecentStops) as number[]);
+ recentStops = new Set((JSON.parse(rawRecentStops) as (number|string)[]).map(normalizeId));
}
- recentStops.add(stopId);
+ recentStops.add(id);
if (recentStops.size > RECENT_STOPS_LIMIT) {
const iterator = recentStops.values();
- const val = iterator.next().value as number;
+ const val = iterator.next().value as string;
recentStops.delete(val);
}
@@ -177,18 +204,18 @@ function pushRecent(stopId: number) {
);
}
-function getRecent(): number[] {
+function getRecent(): string[] {
const rawRecentStops = localStorage.getItem(`recentStops_vigo`);
if (rawRecentStops) {
- return JSON.parse(rawRecentStops) as number[];
+ return (JSON.parse(rawRecentStops) as (number|string)[]).map(normalizeId);
}
return [];
}
-function getFavouriteIds(): number[] {
+function getFavouriteIds(): string[] {
const rawFavouriteStops = localStorage.getItem(`favouriteStops_vigo`);
if (rawFavouriteStops) {
- return JSON.parse(rawFavouriteStops) as number[];
+ return (JSON.parse(rawFavouriteStops) as (number|string)[]).map(normalizeId);
}
return [];
}
@@ -196,8 +223,16 @@ function getFavouriteIds(): number[] {
// New function to load stops from network
async function loadStopsFromNetwork(): Promise<Stop[]> {
const response = await fetch(REGION_DATA.stopsEndpoint);
- const stops = (await response.json()) as Stop[];
- return stops.map((stop) => ({ ...stop, favourite: false }) as Stop);
+ const rawStops = (await response.json()) as any[];
+ return rawStops.map((raw) => {
+ const id = normalizeId(raw.stopId);
+ return {
+ ...raw,
+ stopId: id,
+ type: raw.type || (id.startsWith('renfe:') ? 'train' : 'bus'),
+ favourite: false
+ } as Stop;
+ });
}
export default {
diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx
index 461e891..402bf60 100644
--- a/src/frontend/app/routes/map.tsx
+++ b/src/frontend/app/routes/map.tsx
@@ -35,7 +35,7 @@ export default function StopMap() {
const [stops, setStops] = useState<
GeoJsonFeature<
Point,
- { stopId: number; name: string; lines: string[]; cancelled?: boolean }
+ { stopId: string; name: string; lines: string[]; cancelled?: boolean, prefix: string }
>[]
>([]);
const [selectedStop, setSelectedStop] = useState<Stop | null>(null);
@@ -65,7 +65,7 @@ export default function StopMap() {
StopDataProvider.getStops().then((data) => {
const features: GeoJsonFeature<
Point,
- { stopId: number; name: string; lines: string[]; cancelled?: boolean }
+ { stopId: string; name: string; lines: string[]; cancelled?: boolean, prefix: string }
>[] = data.map((s) => ({
type: "Feature",
geometry: {
@@ -77,6 +77,7 @@ export default function StopMap() {
name: s.name.original,
lines: s.lines,
cancelled: s.cancelled ?? false,
+ prefix: s.stopId.startsWith("renfe:") ? "stop-renfe" : (s.cancelled ? "stop-vitrasa-cancelled" : "stop-vitrasa"),
},
}));
setStops(features);
@@ -152,7 +153,7 @@ export default function StopMap() {
return;
}
- const stopId = parseInt(props.stopId, 10);
+ const stopId = props.stopId;
// fetch full stop to get lines array
StopDataProvider.getStopById(stopId)
@@ -200,25 +201,23 @@ export default function StopMap() {
<Layer
id="stops"
type="symbol"
- minzoom={13}
+ minzoom={11}
source="stops-source"
layout={{
"icon-image": [
- "case",
- ["coalesce", ["get", "cancelled"], false],
- `stop-vigo-cancelled`,
- `stop-vigo`,
+ "get",
+ "prefix"
],
"icon-size": [
"interpolate",
["linear"],
["zoom"],
13,
- 0.4,
- 14,
0.7,
+ 16,
+ 0.8,
18,
- 1.0,
+ 1.2,
],
"icon-allow-overlap": true,
"icon-ignore-placement": true,
@@ -239,7 +238,12 @@ export default function StopMap() {
"text-size": ["interpolate", ["linear"], ["zoom"], 11, 8, 22, 16],
}}
paint={{
- "text-color": `${REGION_DATA.textColour}`,
+ "text-color": [
+ "case",
+ ["==", ["get", "prefix"], "stop-renfe"],
+ "#870164",
+ "#e72b37"
+ ],
"text-halo-color": "#FFF",
"text-halo-width": 1,
}}
diff --git a/src/frontend/app/routes/stops-$id.tsx b/src/frontend/app/routes/stops-$id.tsx
index 31cc75f..d836c12 100644
--- a/src/frontend/app/routes/stops-$id.tsx
+++ b/src/frontend/app/routes/stops-$id.tsx
@@ -72,10 +72,35 @@ const loadConsolidatedData = async (
return await resp.json();
};
+export interface ConsolidatedCirculation {
+ line: string;
+ route: string;
+ schedule?: {
+ running: boolean;
+ minutes: number;
+ serviceId: string;
+ tripId: string;
+ shapeId?: string;
+ };
+ realTime?: {
+ minutes: number;
+ distance: number;
+ };
+ currentPosition?: {
+ latitude: number;
+ longitude: number;
+ orientationDegrees: number;
+ shapeIndex?: number;
+ };
+ isPreviousTrip?: boolean;
+ previousTripShapeId?: string;
+ nextStreets?: string[];
+}
+
export default function Estimates() {
const { t } = useTranslation();
const params = useParams();
- const stopIdNum = parseInt(params.id ?? "");
+ const stopId = params.id ?? "";
const [customName, setCustomName] = useState<string | undefined>(undefined);
const [stopData, setStopData] = useState<Stop | undefined>(undefined);
@@ -98,8 +123,8 @@ export default function Estimates() {
if (customName) return customName;
if (stopData?.name.intersect) return stopData.name.intersect;
if (stopData?.name.original) return stopData.name.original;
- return `Parada ${stopIdNum}`;
- }, [customName, stopData, stopIdNum]);
+ return `Parada ${stopId}`;
+ }, [customName, stopData, stopId]);
usePageTitle(getStopDisplayName());
@@ -128,21 +153,21 @@ export default function Estimates() {
try {
setDataError(null);
- const body = await loadConsolidatedData(params.id!);
+ const body = await loadConsolidatedData(stopId);
setData(body);
setDataDate(new Date());
// Load stop data from StopDataProvider
- const stop = await StopDataProvider.getStopById(stopIdNum);
+ const stop = await StopDataProvider.getStopById(stopId);
setStopData(stop);
- setCustomName(StopDataProvider.getCustomName(stopIdNum));
+ setCustomName(StopDataProvider.getCustomName(stopId));
} catch (error) {
console.error("Error loading consolidated data:", error);
setDataError(parseError(error));
setData(null);
setDataDate(null);
}
- }, [params.id, stopIdNum]);
+ }, [stopId]);
const refreshData = useCallback(async () => {
await Promise.all([loadData()]);
@@ -170,19 +195,19 @@ export default function Estimates() {
setDataLoading(true);
loadData();
- StopDataProvider.pushRecent(parseInt(params.id ?? ""));
+ StopDataProvider.pushRecent(stopId);
setFavourited(
- StopDataProvider.isFavourite(parseInt(params.id ?? ""))
+ StopDataProvider.isFavourite(stopId)
);
setDataLoading(false);
- }, [params.id, loadData]);
+ }, [stopId, loadData]);
const toggleFavourite = () => {
if (favourited) {
- StopDataProvider.removeFavourite(stopIdNum);
+ StopDataProvider.removeFavourite(stopId);
setFavourited(false);
} else {
- StopDataProvider.addFavourite(stopIdNum);
+ StopDataProvider.addFavourite(stopId);
setFavourited(true);
}
};