From 894e67863dbb89a4819e825fcdf7117021082b2a Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Tue, 24 Jun 2025 13:29:50 +0200 Subject: Replace leaflet for maplibre, use react-router in framework mode --- src/frontend/app/routes/estimates-$id.css | 105 ++++++++++ src/frontend/app/routes/estimates-$id.tsx | 103 ++++++++++ src/frontend/app/routes/index.tsx | 5 + src/frontend/app/routes/map.css | 86 +++++++++ src/frontend/app/routes/map.tsx | 167 ++++++++++++++++ src/frontend/app/routes/settings.css | 94 +++++++++ src/frontend/app/routes/settings.tsx | 65 +++++++ src/frontend/app/routes/stoplist.css | 310 ++++++++++++++++++++++++++++++ src/frontend/app/routes/stoplist.tsx | 136 +++++++++++++ 9 files changed, 1071 insertions(+) create mode 100644 src/frontend/app/routes/estimates-$id.css create mode 100644 src/frontend/app/routes/estimates-$id.tsx create mode 100644 src/frontend/app/routes/index.tsx create mode 100644 src/frontend/app/routes/map.css create mode 100644 src/frontend/app/routes/map.tsx create mode 100644 src/frontend/app/routes/settings.css create mode 100644 src/frontend/app/routes/settings.tsx create mode 100644 src/frontend/app/routes/stoplist.css create mode 100644 src/frontend/app/routes/stoplist.tsx (limited to 'src/frontend/app/routes') diff --git a/src/frontend/app/routes/estimates-$id.css b/src/frontend/app/routes/estimates-$id.css new file mode 100644 index 0000000..86ca09b --- /dev/null +++ b/src/frontend/app/routes/estimates-$id.css @@ -0,0 +1,105 @@ +.table-responsive { + overflow-x: auto; + margin-bottom: 1.5rem; +} + +.table { + width: 100%; + border-collapse: collapse; +} + +.table caption { + margin-bottom: 0.5rem; + font-weight: 500; +} + +.table th, +.table td { + padding: 0.75rem; + text-align: left; + border-bottom: 1px solid #eee; +} + +.table th { + border-bottom: 2px solid #ddd; +} + +.table tfoot td { + text-align: center; +} + +/* Estimates page specific styles */ +.estimates-header { + display: flex; + align-items: center; + margin-bottom: 1rem; +} + +.estimates-stop-id { + font-size: 1rem; + color: var(--subtitle-color); + margin-left: 0.5rem; +} + +.estimates-arrival { + color: #28a745; + font-weight: 500; +} + +.estimates-delayed { + color: #dc3545; +} + +.button-group { + display: flex; + gap: 1rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; +} + +.button { + padding: 0.75rem 1rem; + background-color: var(--button-background-color); + color: white; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + text-align: center; + text-decoration: none; + display: inline-block; +} + +.button:hover { + background-color: var(--button-hover-background-color); +} + +.button:disabled { + background-color: var(--button-disabled-background-color); + cursor: not-allowed; +} + +.star-icon { + margin-right: 0.5rem; + color: #ccc; + fill: none; +} + +.star-icon.active { + color: var(--star-color); + /* Yellow color for active star */ + fill: var(--star-color); +} + +/* Pencil (edit) icon next to header */ +.edit-icon { + margin-right: 0.5rem; + color: #ccc; + cursor: pointer; + stroke-width: 2px; +} + +.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 new file mode 100644 index 0000000..761a8d4 --- /dev/null +++ b/src/frontend/app/routes/estimates-$id.tsx @@ -0,0 +1,103 @@ +import { type JSX, useEffect, useState } from "react"; +import { useParams } from "react-router"; +import StopDataProvider from "../data/StopDataProvider"; +import { Star, Edit2 } from 'lucide-react'; +import "./estimates-$id.css"; +import { RegularTable } from "../components/RegularTable"; +import { useApp } from "../AppContext"; +import { GroupedTable } from "../components/GroupedTable"; + +export interface StopDetails { + 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(); +}; + +export default function Estimates() { + const params = useParams(); + const stopIdNum = parseInt(params.id ?? ""); + const [customName, setCustomName] = useState(undefined); + const [data, setData] = useState(null); + const [dataDate, setDataDate] = useState(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)); + }) + + + StopDataProvider.pushRecent(parseInt(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); + } + }; + + if (data === null) return

Cargando datos en tiempo real...

+ + return ( +
+
+

+ + + {(customName ?? data.stop.name)} ({data.stop.id}) +

+
+ +
+ {tableStyle === 'grouped' ? + : + } +
+
+ ) +} diff --git a/src/frontend/app/routes/index.tsx b/src/frontend/app/routes/index.tsx new file mode 100644 index 0000000..7c8ab40 --- /dev/null +++ b/src/frontend/app/routes/index.tsx @@ -0,0 +1,5 @@ +import { Navigate, redirect, type LoaderFunction } from "react-router"; + +export default function Index() { + return ; +} diff --git a/src/frontend/app/routes/map.css b/src/frontend/app/routes/map.css new file mode 100644 index 0000000..3af112a --- /dev/null +++ b/src/frontend/app/routes/map.css @@ -0,0 +1,86 @@ +/* Map page specific styles */ +.map-container { + height: calc(100vh - 140px); + margin: -16px; + margin-bottom: 1rem; + position: relative; +} + +/* Fullscreen map styles */ +.fullscreen-container { + position: absolute; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + padding: 0; + margin: 0; + max-width: none; + overflow: hidden; +} + +.fullscreen-map { + width: 100%; + height: 100%; +} + +.fullscreen-loading { + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + 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; +} + +.stop-marker:hover { + transform: scale(1.2); +} + +.maplibregl-popup { + max-width: 250px; +} + +.maplibregl-popup-content { + 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; +} + +.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; +} + +.popup-link { + display: block; + margin-top: 8px; + color: var(--button-background-color); + text-decoration: none; + font-weight: 500; +} + +.popup-link:hover { + text-decoration: underline; +} \ No newline at end of file diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx new file mode 100644 index 0000000..a938148 --- /dev/null +++ b/src/frontend/app/routes/map.tsx @@ -0,0 +1,167 @@ +import StopDataProvider from "../data/StopDataProvider"; +import './map.css'; + +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 { loadStyle } from "app/maps/styleloader"; +import type { Feature as GeoJsonFeature, Point } from 'geojson'; +import LineIcon from "~/components/LineIcon"; +import { Link } from "react-router"; + +// Default minimal fallback style before dynamic loading +const defaultStyle: StyleSpecification = { + version: 8, + glyphs: `${window.location.origin}/maps/fonts/{fontstack}/{range}.pbf`, + sprite: `${window.location.origin}/maps/spritesheet/sprite`, + sources: {}, + layers: [] +}; + +// Componente principal del mapa +export default function StopMap() { + const [stops, setStops] = useState[]>([]); + const [popupInfo, setPopupInfo] = useState(null); + const { mapState, updateMapState, theme } = useApp(); + const mapRef = useRef(null); + const [mapStyleKey, setMapStyleKey] = useState("light"); + + // Style state for Map component + const [mapStyle, setMapStyle] = useState(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; + + handlePointClick(feature); + }; + + useEffect(() => { + StopDataProvider.getStops().then(data => { + const features: GeoJsonFeature[] = 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 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); + } + } + + 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 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 + } + }); + }); + }; + + return ( + + + + + + + + + + {popupInfo && ( + setPopupInfo(null)} + > +
+

{popupInfo.properties.name}

+
+ {popupInfo.properties.lines.map((line: string) => ( + + ))} +
+ + Ver parada + +
+
+ )} +
+ ); +} diff --git a/src/frontend/app/routes/settings.css b/src/frontend/app/routes/settings.css new file mode 100644 index 0000000..8c612d3 --- /dev/null +++ b/src/frontend/app/routes/settings.css @@ -0,0 +1,94 @@ +/* About page specific styles */ +.about-page { + text-align: center; + padding: 1rem; +} + +.about-version { + color: var(--subtitle-color); + font-size: 0.9rem; + margin-top: 2rem; +} + +.about-description { + margin-top: 1rem; + line-height: 1.6; +} + +.settings-section { + margin-bottom: 2em; + padding: 1rem; + border: 1px solid var(--border-color); + border-radius: 8px; + background-color: var(--message-background-color); + text-align: left; +} + +.settings-section h2 { + margin-bottom: 1em; +} + +.settings-content { + display: flex; + flex-direction: column; + align-items: flex-start; + margin-bottom: 1em; +} + +.settings-content-inline { + display: flex; + align-items: center; + margin-bottom: 1em; +} + +.settings-section .form-button { + margin-bottom: 1em; + padding: 0.75rem 1.5rem; + font-size: 1.1rem; +} + +.settings-section .form-select-inline { + margin-left: 0.5em; + padding: 0.5rem; + font-size: 1rem; + border: 1px solid var(--border-color); + border-radius: 8px; +} + +.settings-section .form-label-inline { + font-weight: 500; +} + +.settings-section .form-label { + display: block; + margin-bottom: 0.5em; + font-weight: 500; +} + +.settings-section .form-description { + margin-top: 0.5em; + font-size: 0.9rem; + color: var(--subtitle-color); +} + +.settings-section .form-details { + margin-top: 0.5em; + font-size: 0.9rem; + color: var(--subtitle-color); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 0.5rem; +} + +.settings-section .form-details summary { + cursor: pointer; + font-weight: 500; +} + +.settings-section .form-details p { + margin-top: 0.5em; +} + +.settings-section p { + margin-top: 0.5em; +} diff --git a/src/frontend/app/routes/settings.tsx b/src/frontend/app/routes/settings.tsx new file mode 100644 index 0000000..b5e91f1 --- /dev/null +++ b/src/frontend/app/routes/settings.tsx @@ -0,0 +1,65 @@ +import { useApp } from "../AppContext"; +import "./settings.css"; + +export default function Settings() { + const { theme, setTheme, tableStyle, setTableStyle, mapPositionMode, setMapPositionMode } = useApp(); + + return ( +
+

Sobre UrbanoVigo Web

+

+ Aplicación web para encontrar paradas y tiempos de llegada de los autobuses + urbanos de Vigo, España. +

+
+

Ajustes

+
+ + +
+
+ + +
+
+ + +
+
+ ¿Qué significa esto? +

+ La tabla de horarios puede mostrarse de dos formas: +

+
+
Mostrar por orden
+
Las paradas se muestran en el orden en que se visitan. Aplicaciones como Infobus (Vitrasa) usan este estilo.
+
Agrupar por línea
+
Las paradas se agrupan por la línea de autobús. Aplicaciones como iTranvias (A Coruña) o Moovit (más o menos) usan este estilo.
+
+
+
+

Créditos

+

+ + Código en GitHub + - + Desarrollado por + Ariel Costas + +

+

+ Datos obtenidos de datos.vigo.org bajo + licencia Open Data Commons Attribution License +

+
+ ) +} diff --git a/src/frontend/app/routes/stoplist.css b/src/frontend/app/routes/stoplist.css new file mode 100644 index 0000000..d65e048 --- /dev/null +++ b/src/frontend/app/routes/stoplist.css @@ -0,0 +1,310 @@ +/* Common page styles */ +.page-title { + font-size: 1.8rem; + margin-bottom: 1rem; + font-weight: 600; + color: var(--text-color); +} + +.page-subtitle { + font-size: 1.4rem; + margin-top: 1.5rem; + margin-bottom: 0.75rem; + font-weight: 500; + color: var(--subtitle-color); +} + +/* Form styles */ +.search-form { + margin-bottom: 1.5rem; +} + +.form-group { + margin-bottom: 1rem; + display: flex; + flex-direction: column; +} + +.form-label { + font-size: 0.9rem; + margin-bottom: 0.25rem; + font-weight: 500; +} + +.form-input { + padding: 0.75rem; + font-size: 1rem; + border: 1px solid var(--border-color); + border-radius: 8px; +} + +.form-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 1rem; + + padding: 0.75rem 1rem; + background-color: var(--button-background-color); + color: white; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + width: 100%; + margin-top: 0.5rem; +} + +.form-button:hover { + background-color: var(--button-hover-background-color); +} + +/* List styles */ +.list-container { + margin-bottom: 1.5rem; +} + +.list { + list-style: none; + padding: 0; + margin: 0; +} + +.list-item { + padding: 1rem; + border-bottom: 1px solid var(--border-color); +} + +.list-item-link { + display: block; + color: var(--text-color); + text-decoration: none; + font-size: 1.1rem; /* Increased font size for stop name */ +} + +.list-item-link:hover { + color: var(--button-background-color); +} + +.list-item-link:hover .line-icon { + color: var(--text-color); +} + +.distance-info { + font-size: 0.9rem; + color: var(--subtitle-color); +} + +/* Message styles */ +.message { + padding: 1rem; + background-color: var(--message-background-color); + border-radius: 8px; + margin-bottom: 1rem; +} + +/* About page specific styles */ +.about-page { + text-align: center; + padding: 1rem; +} + +.about-version { + color: var(--subtitle-color); + font-size: 0.9rem; + margin-top: 2rem; +} + +.about-description { + margin-top: 1rem; + line-height: 1.6; +} + +/* Map page specific styles */ +.map-container { + height: calc(100vh - 140px); + margin: -16px; + margin-bottom: 1rem; + position: relative; +} + +/* Fullscreen map styles */ +.fullscreen-container { + position: absolute; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + padding: 0; + margin: 0; + max-width: none; + overflow: hidden; +} + +.fullscreen-map { + width: 100%; + height: 100%; +} + +.fullscreen-loading { + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + 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; +} + +.stop-marker:hover { + transform: scale(1.2); +} + +.maplibregl-popup { + max-width: 250px; +} + +.maplibregl-popup-content { + 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; +} + +.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; +} + +.popup-link { + display: block; + margin-top: 8px; + color: var(--button-background-color); + text-decoration: none; + font-weight: 500; +} + +.popup-link:hover { + text-decoration: underline; +} + +/* Estimates page specific styles */ +.estimates-header { + display: flex; + align-items: center; + margin-bottom: 1rem; +} + +.estimates-stop-id { + font-size: 1rem; + color: var(--subtitle-color); + margin-left: 0.5rem; +} + +.estimates-arrival { + color: #28a745; + font-weight: 500; +} + +.estimates-delayed { + color: #dc3545; +} + +.button-group { + display: flex; + gap: 1rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; +} + +.button { + padding: 0.75rem 1rem; + background-color: var(--button-background-color); + color: white; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + text-align: center; + text-decoration: none; + display: inline-block; +} + +.button:hover { + background-color: var(--button-hover-background-color); +} + +.button:disabled { + background-color: var(--button-disabled-background-color); + cursor: not-allowed; +} + +.star-icon { + margin-right: 0.5rem; + color: #ccc; + fill: none; +} + +.star-icon.active { + color: var(--star-color); /* Yellow color for active star */ + fill: var(--star-color); +} + +/* Tablet and larger breakpoint */ +@media (min-width: 768px) { + .search-form { + display: flex; + align-items: flex-end; + gap: 1rem; + } + + .form-group { + flex: 1; + margin-bottom: 0; + } + + .form-button { + width: auto; + margin-top: 0; + } + + .list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1rem; + } + + .list-item { + border: 1px solid var(--border-color); + border-radius: 8px; + margin-bottom: 0; + } +} + +/* Desktop breakpoint */ +@media (min-width: 1024px) { + .list { + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + } +} diff --git a/src/frontend/app/routes/stoplist.tsx b/src/frontend/app/routes/stoplist.tsx new file mode 100644 index 0000000..ff1da71 --- /dev/null +++ b/src/frontend/app/routes/stoplist.tsx @@ -0,0 +1,136 @@ +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'; + +const placeholders = [ + "Urzaiz", + "Gran Vía", + "Castelao", + "García Barbón", + "Valladares", + "Florida", + "Pizarro", + "Estrada Madrid", + "Sanjurjo Badía" +]; + +export default function StopList() { + const [data, setData] = useState(null) + const [searchResults, setSearchResults] = useState(null); + const searchTimeout = useRef(null); + + const randomPlaceholder = useMemo(() => placeholders[Math.floor(Math.random() * placeholders.length)], []); + const fuse = useMemo(() => new Fuse(data || [], { threshold: 0.3, keys: ['name.original'] }), [data]); + + useEffect(() => { + StopDataProvider.getStops().then((stops: Stop[]) => setData(stops)) + }, []); + + const handleStopSearch = (event: React.ChangeEvent) => { + const stopName = event.target.value || ""; + + if (searchTimeout.current) { + clearTimeout(searchTimeout.current); + } + + searchTimeout.current = setTimeout(() => { + if (stopName.length === 0) { + setSearchResults(null); + 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 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]); + + if (data === null) return

Loading...

+ + return ( +
+

UrbanoVigo Web

+ +
+
+ + +
+
+ + {searchResults && searchResults.length > 0 && ( +
+

Resultados de la búsqueda

+
    + {searchResults.map((stop: Stop) => ( + + ))} +
+
+ )} + +
+

Paradas favoritas

+ + {favouritedStops?.length === 0 && ( +

+ Accede a una parada y márcala como favorita para verla aquí. +

+ )} + +
    + {favouritedStops?.sort((a, b) => a.stopId - b.stopId).map((stop: Stop) => ( + + ))} +
+
+ + {recentStops && recentStops.length > 0 && ( +
+

Recientes

+ +
    + {recentStops.map((stop: Stop) => ( + + ))} +
+
+ )} + +
+

Paradas

+ +
    + {data?.sort((a, b) => a.stopId - b.stopId).map((stop: Stop) => ( + + ))} +
+
+
+ ) +} -- cgit v1.3