diff options
| -rw-r--r-- | src/frontend/app/components/StopMapSheet.css | 17 | ||||
| -rw-r--r-- | src/frontend/app/components/StopMapSheet.tsx | 218 | ||||
| -rw-r--r-- | src/frontend/app/components/Stops/ConsolidatedCirculationList.css | 14 | ||||
| -rw-r--r-- | src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx | 45 | ||||
| -rw-r--r-- | src/frontend/app/data/LineColors.ts | 100 | ||||
| -rw-r--r-- | src/frontend/app/routes/estimates-$id.css | 53 | ||||
| -rw-r--r-- | src/frontend/app/routes/stops-$id.tsx | 79 | ||||
| -rw-r--r-- | src/stop_downloader/vigo/overrides/navidad-2025.yaml | 19 |
8 files changed, 479 insertions, 66 deletions
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<StopMapProps> = ({ + stop, + circulations, + region, +}) => { + const { theme } = useApp(); + const [styleSpec, setStyleSpec] = useState<any | null>(null); + const mapRef = useRef<MapRef | null>(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 ( + <div className="stop-map-container"> + {styleSpec && ( + <Map + mapLib={maplibregl as any} + initialViewState={{ + latitude: center.latitude, + longitude: center.longitude, + zoom: 16, + }} + style={{ width: "100%", height: "100%" }} + mapStyle={styleSpec} + attributionControl={false} + ref={mapRef} + > + <NavigationControl position="top-left" /> + + {/* Stop marker (center) */} + {stop.latitude && stop.longitude && ( + <Marker + longitude={stop.longitude} + latitude={stop.latitude} + anchor="bottom" + > + <div + style={{ + width: 14, + height: 14, + background: "#1976d2", + border: "2px solid white", + borderRadius: "50%", + boxShadow: "0 0 0 2px rgba(0,0,0,0.2)", + }} + title={`Stop ${stop.stopId}`} + /> + </Marker> + )} + + {/* Bus markers with heading */} + {busPositions.map((c, idx) => { + const p = c.currentPosition!; + const lineColor = getLineColor(region, c.line); + return ( + <Marker + key={idx} + longitude={p.longitude} + latitude={p.latitude} + anchor="center" + > + <div + title={`${c.line} → ${c.route}`} + style={{ + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: 2, + transform: `rotate(${p.orientationDegrees}deg)`, + transformOrigin: "center center", + }} + > + {/* Line number above */} + <div + style={{ + background: lineColor.background, + color: lineColor.text, + padding: "2px 4px", + borderRadius: 4, + fontSize: 10, + fontWeight: 700, + lineHeight: 1, + border: "1px solid #fff", + boxShadow: "0 1px 2px rgba(0,0,0,0.3)", + }} + > + {c.line} + </div> + {/* Arrow pointing direction */} + <svg + width="20" + height="20" + viewBox="0 0 24 24" + style={{ + filter: "drop-shadow(0 1px 2px rgba(0,0,0,0.3))", + }} + > + <path + d="M12 2 L20 22 L12 18 L4 22 Z" + fill={lineColor.background} + stroke="#fff" + strokeWidth="1.5" + /> + </svg> + </div> + </Marker> + ); + })} + </Map> + )} + </div> + ); +}; 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<RegularTableProps> = ({ 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<RegularTableProps> = ({ ? `${displayMinutes} ${t("estimates.minutes", "min")}` : absoluteArrivalTime(displayMinutes)} </div> - {estimate.realTime && estimate.realTime.distance >= 0 && ( - <div className="distance-info"> - {formatDistance(estimate.realTime.distance)} - </div> - )} + <div className="distance-info"> + {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} + + </div> </div> </div> - <div className="card-footer"> + {/*<div className="card-footer"> <span className="status-text"> {delayText && ( <> @@ -213,7 +226,7 @@ export const ConsolidatedCirculationList: React.FC<RegularTableProps> = ({ </> )} </span> - </div> + </div>*/} </div> ); })} diff --git a/src/frontend/app/data/LineColors.ts b/src/frontend/app/data/LineColors.ts new file mode 100644 index 0000000..878fd8f --- /dev/null +++ b/src/frontend/app/data/LineColors.ts @@ -0,0 +1,100 @@ +import type { RegionId } from "./RegionConfig"; + +interface LineColorInfo { + background: string; + text: string; +} + +const vigoLineColors: Record<string, LineColorInfo> = { + c1: { background: "rgb(237, 71, 19)", text: "#ffffff" }, + c3d: { background: "rgb(255, 204, 0)", text: "#000000" }, + c3i: { background: "rgb(255, 204, 0)", text: "#000000" }, + l4a: { background: "rgb(0, 153, 0)", text: "#ffffff" }, + l4c: { background: "rgb(0, 153, 0)", text: "#ffffff" }, + l5a: { background: "rgb(0, 176, 240)", text: "#000000" }, + l5b: { background: "rgb(0, 176, 240)", text: "#000000" }, + l6: { background: "rgb(204, 51, 153)", text: "#ffffff" }, + l7: { background: "rgb(150, 220, 153)", text: "#000000" }, + l9b: { background: "rgb(244, 202, 140)", text: "#000000" }, + l10: { background: "rgb(153, 51, 0)", text: "#ffffff" }, + l11: { background: "rgb(226, 0, 38)", text: "#ffffff" }, + l12a: { background: "rgb(106, 150, 190)", text: "#000000" }, + l12b: { background: "rgb(106, 150, 190)", text: "#000000" }, + l13: { background: "rgb(0, 176, 240)", text: "#000000" }, + l14: { background: "rgb(129, 142, 126)", text: "#ffffff" }, + l15a: { background: "rgb(216, 168, 206)", text: "#000000" }, + l15b: { background: "rgb(216, 168, 206)", text: "#000000" }, + l15c: { background: "rgb(216, 168, 168)", text: "#000000" }, + l16: { background: "rgb(129, 142, 126)", text: "#ffffff" }, + l17: { background: "rgb(214, 245, 31)", text: "#000000" }, + l18a: { background: "rgb(212, 80, 168)", text: "#ffffff" }, + l18b: { background: "rgb(212, 80, 168)", text: "#ffffff" }, + l18h: { background: "rgb(212, 80, 168)", text: "#ffffff" }, + l23: { background: "rgb(0, 70, 210)", text: "#ffffff" }, + l24: { background: "rgb(191, 191, 191)", text: "#000000" }, + l25: { background: "rgb(172, 100, 4)", text: "#ffffff" }, + l27: { background: "rgb(112, 74, 42)", text: "#ffffff" }, + l28: { background: "rgb(176, 189, 254)", text: "#000000" }, + l29: { background: "rgb(248, 184, 90)", text: "#000000" }, + l31: { background: "rgb(255, 255, 0)", text: "#000000" }, + a: { background: "rgb(119, 41, 143)", text: "#ffffff" }, + h: { background: "rgb(0, 96, 168)", text: "#ffffff" }, + h1: { background: "rgb(0, 96, 168)", text: "#ffffff" }, + h2: { background: "rgb(0, 96, 168)", text: "#ffffff" }, + h3: { background: "rgb(0, 96, 168)", text: "#ffffff" }, + lzd: { background: "rgb(61, 78, 167)", text: "#ffffff" }, + n1: { background: "rgb(191, 191, 191)", text: "#000000" }, + n4: { background: "rgb(102, 51, 102)", text: "#ffffff" }, + psa1: { background: "rgb(0, 153, 0)", text: "#ffffff" }, + psa4: { background: "rgb(0, 153, 0)", text: "#ffffff" }, + ptl: { background: "rgb(150, 220, 153)", text: "#000000" }, + turistico: { background: "rgb(102, 51, 102)", text: "#ffffff" }, + u1: { background: "rgb(172, 100, 4)", text: "#ffffff" }, + u2: { background: "rgb(172, 100, 4)", text: "#ffffff" }, +}; + +const santiagoLineColors: Record<string, LineColorInfo> = { + l1: { background: "#f32621", text: "#ffffff" }, + l4: { background: "#ffcc33", text: "#000000" }, + l5: { background: "#fa8405", text: "#ffffff" }, + l6: { background: "#d73983", text: "#ffffff" }, + l6a: { background: "#d73983", text: "#ffffff" }, + l7: { background: "#488bc1", text: "#ffffff" }, + l8: { background: "#6aaf48", text: "#ffffff" }, + l9: { background: "#46b8bb", text: "#ffffff" }, + c11: { background: "#aec741", text: "#000000" }, + l12: { background: "#842e14", text: "#ffffff" }, + l13: { background: "#336600", text: "#ffffff" }, + l15: { background: "#7a4b2a", text: "#ffffff" }, + c2: { background: "#283a87", text: "#ffffff" }, + c4: { background: "#283a87", text: "#ffffff" }, + c5: { background: "#999999", text: "#000000" }, + c6: { background: "#006666", text: "#ffffff" }, + p1: { background: "#537eb3", text: "#ffffff" }, + p2: { background: "#d23354", text: "#ffffff" }, + p3: { background: "#75bd96", text: "#000000" }, + p4: { background: "#f1c54f", text: "#000000" }, + p6: { background: "#999999", text: "#000000" }, + p7: { background: "#d2438c", text: "#ffffff" }, + p8: { background: "#e28c3a", text: "#ffffff" }, +}; + +const defaultLineColor: LineColorInfo = { + background: "#d32f2f", + text: "#ffffff", +}; + +export function getLineColor( + region: RegionId, + line: string, +): LineColorInfo { + const normalizedLine = line.toLowerCase().trim(); + + if (region === "vigo") { + return vigoLineColors[normalizedLine] ?? defaultLineColor; + } else if (region === "santiago") { + return santiagoLineColors[normalizedLine] ?? defaultLineColor; + } + + return defaultLineColor; +} diff --git a/src/frontend/app/routes/estimates-$id.css b/src/frontend/app/routes/estimates-$id.css index 81fba1d..ff09f82 100644 --- a/src/frontend/app/routes/estimates-$id.css +++ b/src/frontend/app/routes/estimates-$id.css @@ -1,6 +1,20 @@ +.estimates-content-wrapper { + display: flex; + flex-direction: column; + gap: 1rem; + min-height: 0; + flex: 1; +} + +.estimates-list-container { + overflow-y: auto; + flex: 1; + min-height: 0; + border-radius: 0.5rem; +} + .table-responsive { overflow-x: auto; - margin-bottom: 1.5rem; } .table { @@ -29,12 +43,20 @@ } /* Estimates page specific styles */ +.estimates-page { + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; +} + .estimates-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; gap: 1rem; + flex-shrink: 0; } .manual-refresh-button { @@ -63,6 +85,32 @@ transform: none; } +.toggle-map-button { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + background: var(--primary-color); + border: none; + border-radius: 0.375rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + min-width: max-content; +} + +.toggle-map-button:hover:not(:disabled) { + background: var(--primary-color-hover); + transform: translateY(-1px); +} + +.toggle-map-button:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} + .refresh-icon { width: 1.5rem; height: 1.5rem; @@ -215,6 +263,7 @@ gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem; + flex-shrink: 0; } .estimates-lines-container.scrollable { @@ -245,6 +294,7 @@ padding: 1rem; margin-bottom: 1rem; color: #856404; + flex-shrink: 0; } .experimental-notice strong { @@ -282,6 +332,7 @@ color: var(--primary-color); font-size: 0.9rem; font-weight: 500; + flex-shrink: 0; } .refresh-status .refresh-icon { diff --git a/src/frontend/app/routes/stops-$id.tsx b/src/frontend/app/routes/stops-$id.tsx index 86e74d9..efe2867 100644 --- a/src/frontend/app/routes/stops-$id.tsx +++ b/src/frontend/app/routes/stops-$id.tsx @@ -1,18 +1,19 @@ -import { useEffect, useState, useCallback } from "react"; -import { useParams, Link } from "react-router"; -import StopDataProvider, { type Stop } from "../data/StopDataProvider"; -import { Star, Edit2, ExternalLink, RefreshCw } from "lucide-react"; -import "./estimates-$id.css"; -import { useApp } from "../AppContext"; +import { Edit2, RefreshCw, Star } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import { useParams } from "react-router"; +import { ErrorDisplay } from "~/components/ErrorDisplay"; +import LineIcon from "~/components/LineIcon"; import { PullToRefresh } from "~/components/PullToRefresh"; -import { useAutoRefresh } from "~/hooks/useAutoRefresh"; -import { type RegionId, getRegionConfig } from "~/data/RegionConfig"; import { StopAlert } from "~/components/StopAlert"; -import LineIcon from "~/components/LineIcon"; +import { StopMap } from "~/components/StopMapSheet"; import { ConsolidatedCirculationList } from "~/components/Stops/ConsolidatedCirculationList"; import { ConsolidatedCirculationListSkeleton } from "~/components/Stops/ConsolidatedCirculationListSkeleton"; -import { ErrorDisplay } from "~/components/ErrorDisplay"; +import { type RegionId, getRegionConfig } from "~/data/RegionConfig"; +import { useAutoRefresh } from "~/hooks/useAutoRefresh"; +import { useApp } from "../AppContext"; +import StopDataProvider, { type Stop } from "../data/StopDataProvider"; +import "./estimates-$id.css"; export interface ConsolidatedCirculation { line: string; @@ -27,6 +28,11 @@ export interface ConsolidatedCirculation { minutes: number; distance: number; }; + currentPosition?: { + latitude: number; + longitude: number; + orientationDegrees: number; + }; } interface ErrorInfo { @@ -266,25 +272,42 @@ export default function Estimates() { {stopData && <StopAlert stop={stopData} />} - <div className="table-responsive"> - {dataLoading ? ( - <ConsolidatedCirculationListSkeleton /> - ) : dataError ? ( - <ErrorDisplay - error={dataError} - onRetry={loadData} - title={t( - "errors.estimates_title", - "Error al cargar estimaciones", - )} - /> - ) : data ? ( - <ConsolidatedCirculationList - data={data} - dataDate={dataDate} - regionConfig={regionConfig} + <div className="estimates-content-wrapper"> + <div className="estimates-list-container"> + <div className="table-responsive"> + {dataLoading ? ( + <ConsolidatedCirculationListSkeleton /> + ) : dataError ? ( + <ErrorDisplay + error={dataError} + onRetry={loadData} + title={t( + "errors.estimates_title", + "Error al cargar estimaciones", + )} + /> + ) : data ? ( + <ConsolidatedCirculationList + data={data} + dataDate={dataDate} + regionConfig={regionConfig} + /> + ) : null} + </div> + </div> + + {/* Map showing stop and bus positions */} + {stopData && ( + <StopMap + stop={stopData} + region={region} + circulations={(data ?? []).map((c) => ({ + line: c.line, + route: c.route, + currentPosition: c.currentPosition, + }))} /> - ) : null} + )} </div> </div> </PullToRefresh> diff --git a/src/stop_downloader/vigo/overrides/navidad-2025.yaml b/src/stop_downloader/vigo/overrides/navidad-2025.yaml index cb20222..cb7f1d7 100644 --- a/src/stop_downloader/vigo/overrides/navidad-2025.yaml +++ b/src/stop_downloader/vigo/overrides/navidad-2025.yaml @@ -1,22 +1,3 @@ -20208: - new: true - name: "Colón 12" - title: "Parada provisional sin datos" - message: "Parada provisional donde paran las líneas de Policarpo Sanz 40 *exceptuando 9B*" - location: - latitude: 42.23805815883466 - longitude: -8.72057889828808 - lines: - - "C1" - - "A" - - "5A" - - "9B" - - "15B" - - "15C" - - "24" - - "28" - - "N4" - 20194: # Cánovas del Castillo 28 cancelled: true alert: "error" |
