From d285093900ff6f8e3d5dba394999bb413f5d00f3 Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Fri, 14 Nov 2025 18:24:43 +0100 Subject: Enhance stop map functionality with new styles and components for better user experience --- src/frontend/app/components/StopMapSheet.css | 17 ++ src/frontend/app/components/StopMapSheet.tsx | 218 +++++++++++++++++++++ .../Stops/ConsolidatedCirculationList.css | 14 +- .../Stops/ConsolidatedCirculationList.tsx | 45 +++-- 4 files changed, 276 insertions(+), 18 deletions(-) create mode 100644 src/frontend/app/components/StopMapSheet.css create mode 100644 src/frontend/app/components/StopMapSheet.tsx (limited to 'src/frontend/app/components') diff --git a/src/frontend/app/components/StopMapSheet.css b/src/frontend/app/components/StopMapSheet.css new file mode 100644 index 0000000..8ad784d --- /dev/null +++ b/src/frontend/app/components/StopMapSheet.css @@ -0,0 +1,17 @@ +/* Stop map container */ +.stop-map-container { + width: 100%; + height: 300px; + border-radius: 8px; + overflow: hidden; + border: 1px solid var(--border-color); + margin-block-start: 0; + margin-block-end: 1rem; + flex-shrink: 0; +} + +@media (max-width: 640px) { + .stop-map-container { + height: 250px; + } +} diff --git a/src/frontend/app/components/StopMapSheet.tsx b/src/frontend/app/components/StopMapSheet.tsx new file mode 100644 index 0000000..a0d30f4 --- /dev/null +++ b/src/frontend/app/components/StopMapSheet.tsx @@ -0,0 +1,218 @@ +import maplibregl from "maplibre-gl"; +import React, { useEffect, useMemo, useRef, useState } from "react"; +import Map, { Marker, NavigationControl, type MapRef } from "react-map-gl/maplibre"; +import { useApp } from "~/AppContext"; +import { getLineColor } from "~/data/LineColors"; +import type { RegionId } from "~/data/RegionConfig"; +import type { Stop } from "~/data/StopDataProvider"; +import { loadStyle } from "~/maps/styleloader"; +import "./StopMapSheet.css"; + +export interface Position { + latitude: number; + longitude: number; + orientationDegrees: number; +} + +export interface ConsolidatedCirculationForMap { + line: string; + route: string; + currentPosition?: Position; +} + +interface StopMapProps { + stop: Stop; + circulations: ConsolidatedCirculationForMap[]; + region: RegionId; +} + +export const StopMap: React.FC = ({ + stop, + circulations, + region, +}) => { + const { theme } = useApp(); + const [styleSpec, setStyleSpec] = useState(null); + const mapRef = useRef(null); + const hasFitBounds = useRef(false); + + useEffect(() => { + let mounted = true; + loadStyle("openfreemap", theme) + .then((style) => { + if (mounted) setStyleSpec(style); + }) + .catch((err) => console.error("Failed to load map style", err)); + return () => { + mounted = false; + }; + }, [theme]); + + const center = useMemo(() => { + if (stop.latitude && stop.longitude) { + return { latitude: stop.latitude, longitude: stop.longitude }; + } + // fallback to first available bus position + const pos = circulations.find((c) => c.currentPosition)?.currentPosition; + return pos + ? { latitude: pos.latitude, longitude: pos.longitude } + : { latitude: 42.2406, longitude: -8.7207 }; // Vigo approx fallback + }, [stop.latitude, stop.longitude, circulations]); + + const busPositions = useMemo( + () => circulations.filter((c) => !!c.currentPosition), + [circulations], + ); + + // Fit bounds to stop + buses, with ~1km padding each side, with a modest animation + // Only fit bounds on the first load, not on subsequent updates + useEffect(() => { + if (!styleSpec || !mapRef.current || hasFitBounds.current) return; + + const points: { lat: number; lon: number }[] = []; + if (stop.latitude && stop.longitude) { + points.push({ lat: stop.latitude, lon: stop.longitude }); + } + for (const c of busPositions) { + if (c.currentPosition) { + points.push({ + lat: c.currentPosition.latitude, + lon: c.currentPosition.longitude, + }); + } + } + if (points.length === 0) return; + + let minLat = points[0].lat, + maxLat = points[0].lat, + minLon = points[0].lon, + maxLon = points[0].lon; + for (const p of points) { + if (p.lat < minLat) minLat = p.lat; + if (p.lat > maxLat) maxLat = p.lat; + if (p.lon < minLon) minLon = p.lon; + if (p.lon > maxLon) maxLon = p.lon; + } + + // ~1km in degrees + const kmToDegLat = 1.0 / 111.32; // ≈0.008983 + const centerLat = (minLat + maxLat) / 2; + const kmToDegLon = kmToDegLat / Math.max(Math.cos((centerLat * Math.PI) / 180), 0.1); + const padLat = kmToDegLat; + const padLon = kmToDegLon; + + const sw = [minLon - padLon, minLat - padLat] as [number, number]; + const ne = [maxLon + padLon, maxLat + padLat] as [number, number]; + const bounds = new maplibregl.LngLatBounds(sw, ne); + + try { + mapRef.current.fitBounds(bounds, { + padding: 32, + duration: 700, + maxZoom: 17, + } as any); + hasFitBounds.current = true; + } catch {} + }, [styleSpec, stop.latitude, stop.longitude, busPositions]); + + return ( +
+ {styleSpec && ( + + + + {/* Stop marker (center) */} + {stop.latitude && stop.longitude && ( + +
+ + )} + + {/* Bus markers with heading */} + {busPositions.map((c, idx) => { + const p = c.currentPosition!; + const lineColor = getLineColor(region, c.line); + return ( + +
+ {/* Line number above */} +
+ {c.line} +
+ {/* Arrow pointing direction */} + + + +
+
+ ); + })} + + )} +
+ ); +}; diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationList.css b/src/frontend/app/components/Stops/ConsolidatedCirculationList.css index 65e897b..3705ec3 100644 --- a/src/frontend/app/components/Stops/ConsolidatedCirculationList.css +++ b/src/frontend/app/components/Stops/ConsolidatedCirculationList.css @@ -104,12 +104,22 @@ color: #09106e; } +/* Scheduled-only: dark blue in light mode, softer blue in dark mode */ .consolidated-circulation-card .arrival-time.time-scheduled { - color: var(--text-color); + color: #0b3d91; /* dark blue */ } .consolidated-circulation-card .arrival-time.time-scheduled svg { - color: var(--subtitle-color); + color: #0b3d91; +} + +@media (prefers-color-scheme: dark) { + .consolidated-circulation-card .arrival-time.time-scheduled { + color: #8fb4ff; /* lighten for dark backgrounds */ + } + .consolidated-circulation-card .arrival-time.time-scheduled svg { + color: #8fb4ff; + } } .consolidated-circulation-card .distance-info { diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx b/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx index 1ba460b..37f6a47 100644 --- a/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx +++ b/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx @@ -1,8 +1,8 @@ -import { useTranslation } from "react-i18next"; import { Clock } from "lucide-react"; -import { type ConsolidatedCirculation } from "~routes/stops-$id"; +import { useTranslation } from "react-i18next"; import LineIcon from "~components/LineIcon"; import { type RegionConfig } from "~data/RegionConfig"; +import { type ConsolidatedCirculation } from "~routes/stops-$id"; import "./ConsolidatedCirculationList.css"; @@ -104,15 +104,11 @@ export const ConsolidatedCirculationList: React.FC = ({ const delay = estimate.realTime.minutes - estimate.schedule.minutes; if (delay >= -1 && delay <= 2) { - return t("estimates.on_time", "on time"); + return "OK" } else if (delay > 2) { - return t("estimates.minutes_late", "{{minutes}} minutes late", { - minutes: delay, - }); + return "R" + delay; } else { - return t("estimates.minutes_early", "{{minutes}} minutes early", { - minutes: Math.abs(delay), - }); + return "A" + Math.abs(delay); } }; @@ -179,15 +175,32 @@ export const ConsolidatedCirculationList: React.FC = ({ ? `${displayMinutes} ${t("estimates.minutes", "min")}` : absoluteArrivalTime(displayMinutes)}
- {estimate.realTime && estimate.realTime.distance >= 0 && ( -
- {formatDistance(estimate.realTime.distance)} -
- )} +
+ {estimate.schedule && ( + <> + {parseServiceId(estimate.schedule.serviceId)} v{getTripIdDisplay(estimate.schedule.tripId)} {" "} + + )} + + {estimate.schedule && + estimate.realTime && + estimate.realTime.distance >= 0 && <> · } + + {estimate.realTime && estimate.realTime.distance >= 0 && ( + <>{formatDistance(estimate.realTime.distance)} + )} + + {estimate.schedule && + estimate.realTime && + estimate.realTime.distance >= 0 && <> · } + + {delayText} + +
-
+ {/*
{delayText && ( <> @@ -213,7 +226,7 @@ export const ConsolidatedCirculationList: React.FC = ({ )} -
+
*/} ); })} -- cgit v1.3