diff options
| author | Ariel Costas Guerrero <ariel@costas.dev> | 2025-12-01 22:25:56 +0100 |
|---|---|---|
| committer | Ariel Costas Guerrero <ariel@costas.dev> | 2025-12-01 22:25:56 +0100 |
| commit | 3227c7bc6bd233c92b1cf54bec689f0582dca547 (patch) | |
| tree | 66405a8f51a4abe826f268009f2ed461e434df83 /src/frontend/app | |
| parent | 6d15a6113d1c467d1a6113eea052882f4037dcf2 (diff) | |
refactor: replace StopSheet with StopSummarySheet and update related components
- Deleted StopSheet and StopSheetSkeleton components.
- Introduced StopSummarySheet and StopSummarySheetSkeleton components.
- Updated ConsolidatedCirculationCard to support a reduced view.
- Modified ConsolidatedCirculationList to accept a reduced prop.
- Adjusted map route to use StopSummarySheet.
- Cleaned up CSS styles related to the stop sheet components.
- Enhanced error handling and loading states in the new summary sheet.
- Updated stop report logic to filter out empty next streets.
Diffstat (limited to 'src/frontend/app')
| -rw-r--r-- | src/frontend/app/components/StopMapSheet.css | 149 | ||||
| -rw-r--r-- | src/frontend/app/components/StopMapSheet.tsx | 575 | ||||
| -rw-r--r-- | src/frontend/app/components/StopSummarySheet.css (renamed from src/frontend/app/components/StopSheet.css) | 0 | ||||
| -rw-r--r-- | src/frontend/app/components/StopSummarySheet.tsx (renamed from src/frontend/app/components/StopSheet.tsx) | 21 | ||||
| -rw-r--r-- | src/frontend/app/components/StopSummarySheetSkeleton.tsx (renamed from src/frontend/app/components/StopSheetSkeleton.tsx) | 4 | ||||
| -rw-r--r-- | src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx | 168 | ||||
| -rw-r--r-- | src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx | 30 | ||||
| -rw-r--r-- | src/frontend/app/routes/map.tsx | 2 | ||||
| -rw-r--r-- | src/frontend/app/routes/stops-$id.css | 14 | ||||
| -rw-r--r-- | src/frontend/app/routes/stops-$id.tsx | 88 |
10 files changed, 191 insertions, 860 deletions
diff --git a/src/frontend/app/components/StopMapSheet.css b/src/frontend/app/components/StopMapSheet.css deleted file mode 100644 index 7c96c2b..0000000 --- a/src/frontend/app/components/StopMapSheet.css +++ /dev/null @@ -1,149 +0,0 @@ -/* Stop map container */ -.stop-map-container { - width: 100%; - height: 50vh; - overflow: hidden; - border: 1px solid var(--border-color); - margin-block-start: 0; - flex-shrink: 0; - position: relative; -} - -@media (max-width: 640px) { - .stop-map-container { - height: 30vh; - } -} - -/* Floating controls */ -.map-floating-controls { - position: absolute; - left: 8px; - top: 8px; - display: flex; - gap: 8px; - z-index: 2; -} - -.center-btn { - appearance: none; - border: 1px solid rgba(0, 0, 0, 0.15); - background: color-mix( - in oklab, - var(--background-color, #fff) 85%, - transparent - ); - color: var(--text-primary, #111); - padding: 6px; - border-radius: 6px; - font-size: 12px; - line-height: 1; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); - cursor: pointer; -} - -/* User location dot */ -.user-dot { - position: relative; - width: 22px; - height: 22px; -} - -.user-dot__core { - position: absolute; - left: 50%; - top: 50%; - width: 10px; - height: 10px; - margin-left: -5px; - margin-top: -5px; - background: #2a6df4; - border: 2px solid #fff; - border-radius: 50%; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); -} - -.user-dot__pulse { - position: absolute; - left: 50%; - top: 50%; - width: 22px; - height: 22px; - margin-left: -11px; - margin-top: -11px; - border-radius: 50%; - background: rgba(42, 109, 244, 0.25); - animation: userPulse 1.8s ease-out infinite; -} - -/* Map attribution */ -.map-attribution { - position: absolute; - left: 8px; - bottom: 8px; - display: flex; - align-items: flex-end; - gap: 0.4rem; - z-index: 2; -} - -.map-attribution__toggle { - width: 28px; - height: 28px; - border-radius: 50%; - border: 1px solid rgba(0, 0, 0, 0.2); - background: rgba(255, 255, 255, 0.9); - color: #111; - font-weight: 700; - font-size: 0.85rem; - cursor: pointer; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); -} - -[data-theme="dark"] .map-attribution__toggle { - background: rgba(17, 24, 39, 0.9); - color: #f8fafc; - border-color: rgba(255, 255, 255, 0.2); -} - -.map-attribution__panel { - font-size: 0.7rem; - background: rgba(0, 0, 0, 0.75); - color: #fff; - padding: 0.35rem 0.65rem; - border-radius: 999px; - max-width: 220px; - opacity: 0; - transform: translateX(-6px) scale(0.98); - transform-origin: left bottom; - transition: opacity 0.2s ease, transform 0.2s ease; - pointer-events: none; - line-height: 1.2; -} - -.map-attribution__panel a { - color: inherit; - text-decoration: underline; - font-weight: 600; -} - -.map-attribution.open .map-attribution__panel { - opacity: 1; - transform: translateX(0) scale(1); - pointer-events: auto; -} - -@keyframes userPulse { - 0% { - transform: scale(0.6); - opacity: 0.8; - } - 70% { - transform: scale(1.2); - opacity: 0.15; - } - 100% { - transform: scale(1.4); - opacity: 0; - } -} diff --git a/src/frontend/app/components/StopMapSheet.tsx b/src/frontend/app/components/StopMapSheet.tsx deleted file mode 100644 index b00ca1c..0000000 --- a/src/frontend/app/components/StopMapSheet.tsx +++ /dev/null @@ -1,575 +0,0 @@ -import maplibregl from "maplibre-gl"; -import React, { useEffect, useMemo, useRef, useState } from "react"; -import Map, { Layer, Marker, Source, type MapRef } from "react-map-gl/maplibre"; -import { useApp } from "~/AppContext"; -import { REGION_DATA } from "~/config/RegionConfig"; -import { getLineColour } from "~/data/LineColors"; -import type { Stop } from "~/data/StopDataProvider"; -import { loadStyle } from "~/maps/styleloader"; -import "./StopMapSheet.css"; - -export interface Position { - latitude: number; - longitude: number; - orientationDegrees: number; - shapeIndex?: number; -} - -export interface ConsolidatedCirculationForMap { - line: string; - route: string; - currentPosition?: Position; - stopShapeIndex?: number; - schedule?: { - shapeId?: string; - }; -} - -interface StopMapProps { - stop: Stop; - circulations: ConsolidatedCirculationForMap[]; -} - -export const StopMap: React.FC<StopMapProps> = ({ - stop, - circulations -}) => { - const { theme } = useApp(); - const [styleSpec, setStyleSpec] = useState<any | null>(null); - const mapRef = useRef<MapRef | null>(null); - const [userPosition, setUserPosition] = useState<{ - latitude: number; - longitude: number; - accuracy?: number; - } | null>(null); - const geoWatchId = useRef<number | null>(null); - const [viewState, setViewState] = useState(() => { - let latitude = 42.2406; - let longitude = -8.7207; - if (stop.latitude && stop.longitude) { - latitude = stop.latitude; - longitude = stop.longitude; - } else { - const pos = circulations.find((c) => c.currentPosition)?.currentPosition; - if (pos) { - latitude = pos.latitude; - longitude = pos.longitude; - } - } - return { - latitude, - longitude, - zoom: 16, - }; - }); - const { zoom } = viewState; - const hasFitted = useRef(false); - const [moveTick, setMoveTick] = useState<number>(0); - const [showAttribution, setShowAttribution] = useState(false); - const [shapes, setShapes] = useState<Record<string, any>>({}); - - useEffect(() => { - circulations.forEach((c) => { - if ( - c.schedule?.shapeId && - c.currentPosition?.shapeIndex !== undefined && - REGION_DATA.shapeEndpoint - ) { - const key = `${c.schedule.shapeId}_${c.currentPosition.shapeIndex}`; - if (!shapes[key]) { - let url = `${REGION_DATA.shapeEndpoint}?shapeId=${c.schedule.shapeId}&busShapeIndex=${c.currentPosition.shapeIndex}`; - if (c.stopShapeIndex !== undefined) { - url += `&stopShapeIndex=${c.stopShapeIndex}`; - } else { - url += `&stopLat=${stop.latitude}&stopLon=${stop.longitude}`; - } - - fetch(url) - .then((res) => { - if (res.ok) return res.json(); - return null; - }) - .then((data) => { - if (data) { - setShapes((prev) => ({ ...prev, [key]: data })); - } - }) - .catch((err) => console.error("Failed to load shape", err)); - } - } - }); - }, [circulations, shapes]); - - type Pt = { lat: number; lon: number }; - const haversineKm = (a: Pt, b: Pt) => { - const R = 6371; - const dLat = ((b.lat - a.lat) * Math.PI) / 180; - const dLon = ((b.lon - a.lon) * Math.PI) / 180; - const lat1 = (a.lat * Math.PI) / 180; - const lat2 = (b.lat * Math.PI) / 180; - const sinDLat = Math.sin(dLat / 2); - const sinDLon = Math.sin(dLon / 2); - const h = - sinDLat * sinDLat + Math.cos(lat1) * Math.cos(lat2) * sinDLon * sinDLon; - return 2 * R * Math.asin(Math.min(1, Math.sqrt(h))); - }; - const computeFocusPoints = (): Pt[] => { - const buses: Pt[] = []; - for (const c of busPositions) { - if (c.currentPosition) - buses.push({ - lat: c.currentPosition.latitude, - lon: c.currentPosition.longitude, - }); - } - const stopPt = - stop.latitude && stop.longitude - ? { lat: stop.latitude, lon: stop.longitude } - : null; - const userPt = userPosition - ? { lat: userPosition.latitude, lon: userPosition.longitude } - : null; - - if (buses.length === 0 && !stopPt && !userPt) return []; - - // Choose anchor for proximity: stop > user > average of buses - let anchor: Pt | null = stopPt || userPt || null; - if (!anchor && buses.length) { - let lat = 0, - lon = 0; - for (const b of buses) { - lat += b.lat; - lon += b.lon; - } - anchor = { lat: lat / buses.length, lon: lon / buses.length }; - } - - const nearBuses = buses - .map((p) => ({ p, d: anchor ? haversineKm(anchor, p) : 0 })) - .sort((a, b) => a.d - b.d) - .slice(0, 8) // take closest N - .filter((x) => x.d <= 8) // within 8km - .map((x) => x.p); - - const pts: Pt[] = []; - if (stopPt) pts.push(stopPt); - pts.push(...nearBuses); - if (userPt) { - // include user if not too far from anchor - const includeUser = anchor ? haversineKm(anchor, userPt) <= 10 : true; - if (includeUser) pts.push(userPt); - } - // Fallback: if no buses survived, at least return stop or user - if (pts.length === 0) { - if (stopPt) return [stopPt]; - if (userPt) return [userPt]; - } - return pts; - }; - - 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]); - - // Geolocation: request immediately without blocking UI; update when available. - useEffect(() => { - if (!("geolocation" in navigator)) return; - try { - navigator.geolocation.getCurrentPosition( - (pos) => { - setUserPosition({ - latitude: pos.coords.latitude, - longitude: pos.coords.longitude, - accuracy: pos.coords.accuracy, - }); - }, - () => { }, - { enableHighAccuracy: true, maximumAge: 15000, timeout: 5000 } - ); - geoWatchId.current = navigator.geolocation.watchPosition( - (pos) => { - setUserPosition({ - latitude: pos.coords.latitude, - longitude: pos.coords.longitude, - accuracy: pos.coords.accuracy, - }); - }, - () => { }, - { enableHighAccuracy: true, maximumAge: 30000, timeout: 10000 } - ); - } catch { } - return () => { - if (geoWatchId.current != null && "geolocation" in navigator) { - try { - navigator.geolocation.clearWatch(geoWatchId.current); - } catch { } - } - }; - }, []); - - const busPositions = useMemo( - () => circulations.filter((c) => !!c.currentPosition), - [circulations] - ); - - const handleMapLoad = (e: any) => { - if (hasFitted.current) return; - hasFitted.current = true; - - const map = e.target; - - // Handle missing sprite images to suppress console warnings - const handleStyleImageMissing = (e: any) => { - if (!map || map.hasImage(e.id)) return; - // Add a transparent 1x1 placeholder - map.addImage(e.id, { - width: 1, - height: 1, - data: new Uint8Array(4), - }); - }; - - map.on("styleimagemissing", handleStyleImageMissing); - - const points = computeFocusPoints(); - 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; - } - - const sw = [minLon, minLat] as [number, number]; - const ne = [maxLon, maxLat] as [number, number]; - const bounds = new maplibregl.LngLatBounds(sw, ne); - - // Determine predominant bus quadrant relative to stop to bias padding. - const padding: - | number - | { top: number; right: number; bottom: number; left: number } = 24; - - // If the diagonal is huge (likely outliers sneaked in), clamp via zoom fallback - try { - if (points.length === 1) { - const only = points[0]; - map.jumpTo({ center: [only.lon, only.lat], zoom: 16 }); - } else { - map.fitBounds(bounds, { - padding: padding as any, - duration: 700, - maxZoom: 17, - } as any); - } - } catch { } - }; - - const handleCenter = () => { - if (!mapRef.current) return; - const pts = computeFocusPoints(); - if (pts.length === 0) return; - - let minLat = pts[0].lat, - maxLat = pts[0].lat, - minLon = pts[0].lon, - maxLon = pts[0].lon; - for (const p of pts) { - 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; - } - - const sw = [minLon, minLat] as [number, number]; - const ne = [maxLon, maxLat] as [number, number]; - const bounds = new maplibregl.LngLatBounds(sw, ne); - - const padding: - | number - | { top: number; right: number; bottom: number; left: number } = 24; - - try { - if (pts.length === 1) { - const only = pts[0]; - mapRef.current - .getMap() - .easeTo({ center: [only.lon, only.lat], zoom: 16, duration: 450 }); - } else { - mapRef.current.fitBounds(bounds, { - padding: padding as any, - duration: 500, - maxZoom: 17, - } as any); - } - } catch { } - }; - - return ( - <div className="stop-map-container"> - {styleSpec && ( - <Map - mapLib={maplibregl as any} - {...viewState} - style={{ width: "100%", height: "100%" }} - mapStyle={styleSpec} - attributionControl={false} - ref={mapRef} - onLoad={handleMapLoad} - onMove={(e) => { - setViewState(e.viewState); - setMoveTick((t) => (t + 1) % 1000000); - }} - > - {/* Shapes */} - {circulations.map((c, idx) => { - if ( - !c.schedule?.shapeId || - c.currentPosition?.shapeIndex === undefined - ) - return null; - const key = `${c.schedule.shapeId}_${c.currentPosition.shapeIndex}`; - const shapeData = shapes[key]; - if (!shapeData) return null; - const lineColor = getLineColour(c.line); - - return ( - <Source - key={idx} - id={`shape-${idx}`} - type="geojson" - data={shapeData} - > - <Layer - id={`layer-border-${idx}`} - type="line" - paint={{ - "line-color": "#000000", - "line-width": 6, - }} - /> - <Layer - id={`layer-inner-${idx}`} - type="line" - paint={{ - "line-color": lineColor.background, - "line-width": 4, - }} - /> - </Source> - ); - })} - - {/* Stop marker (center) */} - {stop.latitude && stop.longitude && ( - <Marker - longitude={stop.longitude} - latitude={stop.latitude} - anchor="bottom" - > - <div title={`Stop ${stop.stopId}`}> - <svg width="28" height="36" viewBox="0 0 28 36"> - <defs> - <filter - id="drop" - x="-20%" - y="-20%" - width="140%" - height="140%" - > - <feDropShadow - dx="0" - dy="1" - stdDeviation="1" - floodOpacity={0.35} - /> - </filter> - </defs> - <path - d="M14 0C6.82 0 1 5.82 1 13c0 8.5 11 23 13 23s13-14.5 13-23C27 5.82 21.18 0 14 0z" - fill="#1976d2" - stroke="#fff" - strokeWidth="2" - filter="url(#drop)" - /> - <circle cx="14" cy="13" r="5" fill="#fff" /> - <circle cx="14" cy="13" r="3" fill="#1976d2" /> - </svg> - </div> - </Marker> - )} - - {/* User position marker (if available) */} - {userPosition && ( - <Marker - longitude={userPosition.longitude} - latitude={userPosition.latitude} - anchor="center" - > - <div className="user-dot" title="Your location"> - <div className="user-dot__pulse" /> - <div className="user-dot__core" /> - </div> - </Marker> - )} - - {/* Bus markers with heading and dynamic label spacing */} - {(() => { - const map = mapRef.current?.getMap(); - const baseGap = 6; - const thresholdPx = 22; - const gaps: number[] = new Array(busPositions.length).fill(baseGap); - if (map && zoom >= 14.5 && busPositions.length > 1) { - const pts = busPositions.map((c) => - c.currentPosition - ? map.project([ - c.currentPosition.longitude, - c.currentPosition.latitude, - ]) - : null - ); - for (let i = 0; i < pts.length; i++) { - const pi = pts[i]; - if (!pi) continue; - let close = 0; - for (let j = 0; j < pts.length; j++) { - if (i === j) continue; - const pj = pts[j]; - if (!pj) continue; - const dx = pi.x - pj.x; - const dy = pi.y - pj.y; - if (dx * dx + dy * dy <= thresholdPx * thresholdPx) close++; - } - gaps[i] = baseGap + Math.min(3, close) * 10; - } - } - - return busPositions.map((c, idx) => { - const p = c.currentPosition!; - const lineColor = getLineColour(c.line); - const showLabel = zoom >= 13; - const labelGap = gaps[idx] ?? baseGap; - return ( - <Marker - key={idx} - longitude={p.longitude} - latitude={p.latitude} - anchor="center" - > - <div - style={{ - display: "flex", - flexDirection: "column", - alignItems: "center", - gap: labelGap, - transform: `rotate(${p.orientationDegrees}deg)`, - transformOrigin: "center center", - }} - > - <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> - {showLabel && ( - <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)", - transform: `rotate(${-p.orientationDegrees}deg)`, - pointerEvents: "none", - zIndex: 0, - }} - > - {c.line} - </div> - )} - </div> - </Marker> - ); - }); - })()} - </Map> - )} - - <div className="map-floating-controls"> - <button - type="button" - aria-label="Center" - className="center-btn" - onClick={handleCenter} - title="Center view" - > - <svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true"> - <circle cx="12" cy="12" r="3" fill="currentColor" /> - <path - d="M12 2v3M12 19v3M2 12h3M19 12h3" - stroke="currentColor" - strokeWidth="2" - strokeLinecap="round" - /> - <circle - cx="12" - cy="12" - r="8" - fill="none" - stroke="currentColor" - strokeWidth="1.5" - /> - </svg> - </button> - </div> - - <div className={`map-attribution ${showAttribution ? "open" : ""}`}> - <button - type="button" - aria-label="Mostrar atribución del mapa" - aria-expanded={showAttribution} - onClick={() => setShowAttribution((open) => !open)} - className="map-attribution__toggle" - > - i - </button> - <div className="map-attribution__panel"> - <span>OpenFreeMap © OpenMapTiles data from </span> - <a - href="https://www.openstreetmap.org/copyright" - target="_blank" - rel="noreferrer" - > - OpenStreetMap - </a> - </div> - </div> - </div> - ); -}; diff --git a/src/frontend/app/components/StopSheet.css b/src/frontend/app/components/StopSummarySheet.css index 5869d41..5869d41 100644 --- a/src/frontend/app/components/StopSheet.css +++ b/src/frontend/app/components/StopSummarySheet.css diff --git a/src/frontend/app/components/StopSheet.tsx b/src/frontend/app/components/StopSummarySheet.tsx index 77bb5f1..17c0afd 100644 --- a/src/frontend/app/components/StopSheet.tsx +++ b/src/frontend/app/components/StopSummarySheet.tsx @@ -3,15 +3,15 @@ import React, { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Sheet } from "react-modal-sheet"; import { Link } from "react-router"; +import { ConsolidatedCirculationList } from "~/components/Stops/ConsolidatedCirculationList"; import { REGION_DATA } from "~/config/RegionConfig"; import type { Stop } from "~/data/StopDataProvider"; import { type ConsolidatedCirculation } from "../routes/stops-$id"; import { ErrorDisplay } from "./ErrorDisplay"; import LineIcon from "./LineIcon"; import { StopAlert } from "./StopAlert"; -import { ConsolidatedCirculationCard } from "./Stops/ConsolidatedCirculationCard"; -import "./StopSheet.css"; -import { StopSheetSkeleton } from "./StopSheetSkeleton"; +import "./StopSummarySheet.css"; +import { StopSummarySheetSkeleton } from "./StopSummarySheetSkeleton"; interface StopSheetProps { isOpen: boolean; @@ -133,7 +133,7 @@ export const StopSheet: React.FC<StopSheetProps> = ({ <StopAlert stop={stop} compact /> {loading ? ( - <StopSheetSkeleton /> + <StopSummarySheetSkeleton /> ) : error ? ( <ErrorDisplay error={error} @@ -156,15 +156,10 @@ export const StopSheet: React.FC<StopSheetProps> = ({ {t("estimates.none", "No hay estimaciones disponibles")} </div> ) : ( - <div className="stop-sheet-estimates-list"> - {limitedEstimates.map((estimate, idx) => ( - <ConsolidatedCirculationCard - key={idx} - estimate={estimate} - readonly - /> - ))} - </div> + <ConsolidatedCirculationList + data={data.slice(0, 4)} + reduced + /> )} </div> </> diff --git a/src/frontend/app/components/StopSheetSkeleton.tsx b/src/frontend/app/components/StopSummarySheetSkeleton.tsx index 3874038..7697efc 100644 --- a/src/frontend/app/components/StopSheetSkeleton.tsx +++ b/src/frontend/app/components/StopSummarySheetSkeleton.tsx @@ -1,13 +1,13 @@ import React from "react"; +import { useTranslation } from "react-i18next"; import Skeleton, { SkeletonTheme } from "react-loading-skeleton"; import "react-loading-skeleton/dist/skeleton.css"; -import { useTranslation } from "react-i18next"; interface StopSheetSkeletonProps { rows?: number; } -export const StopSheetSkeleton: React.FC<StopSheetSkeletonProps> = ({ +export const StopSummarySheetSkeleton: React.FC<StopSheetSkeletonProps> = ({ rows = 4, }) => { const { t } = useTranslation(); diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx b/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx index 7198c7b..8f43939 100644 --- a/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx +++ b/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx @@ -10,6 +10,7 @@ interface ConsolidatedCirculationCardProps { estimate: ConsolidatedCirculation; onMapClick?: () => void; readonly?: boolean; + reduced?: boolean; } // Utility function to parse service ID and get the turn number @@ -71,7 +72,7 @@ const parseServiceId = (serviceId: string): string => { export const ConsolidatedCirculationCard: React.FC< ConsolidatedCirculationCardProps -> = ({ estimate, onMapClick, readonly }) => { +> = ({ estimate, onMapClick, readonly, reduced }) => { const { t } = useTranslation(); const formatDistance = (meters: number) => { @@ -118,7 +119,7 @@ export const ConsolidatedCirculationCard: React.FC< // On time if (delta === 0) { return { - label: t("estimates.delay_on_time", "En hora (0 min)"), + label: reduced ? "OK" : t("estimates.delay_on_time", "En hora (0 min)"), tone: "delay-ok", } as const; } @@ -128,7 +129,7 @@ export const ConsolidatedCirculationCard: React.FC< const tone = delta <= 2 ? "delay-ok" : delta <= 10 ? "delay-warn" : "delay-critical"; return { - label: t("estimates.delay_positive", "Retraso de {{minutes}} min", { + label: reduced ? `R${delta}` : t("estimates.delay_positive", "Retraso de {{minutes}} min", { minutes: delta, }), tone, @@ -138,12 +139,12 @@ export const ConsolidatedCirculationCard: React.FC< // Early const tone = absDelta <= 2 ? "delay-ok" : "delay-early"; return { - label: t("estimates.delay_negative", "Adelanto de {{minutes}} min", { + label: reduced ? `A${absDelta}` : t("estimates.delay_negative", "Adelanto de {{minutes}} min", { minutes: absDelta, }), tone, } as const; - }, [estimate.schedule, estimate.realTime, t]); + }, [estimate.schedule, estimate.realTime, t, reduced]); const metaChips = useMemo(() => { const chips: Array<{ label: string; tone?: string }> = []; @@ -175,6 +176,84 @@ export const ConsolidatedCirculationCard: React.FC< disabled: !hasGpsPosition, }; + if (reduced) { + return ( + <Tag + className={` + flex-none flex items-center gap-2.5 min-h-12 + bg-(--message-background-color) border border-(--border-color) + rounded-xl px-3 py-2.5 transition-all + ${readonly + ? !hasGpsPosition + ? "opacity-70 cursor-not-allowed" + : "" + : hasGpsPosition + ? "cursor-pointer hover:shadow-[0_4px_14px_rgba(0,0,0,0.08)] hover:border-(--button-background-color) hover:bg-[color-mix(in_oklab,var(--button-background-color)_5%,var(--message-background-color))] active:scale-[0.98]" + : "opacity-70 cursor-not-allowed" + } + `.trim()} + {...interactiveProps} + > + <div className="shrink-0"> + <LineIcon line={estimate.line} mode="pill" /> + </div> + <div className="flex-1 min-w-0 flex flex-col gap-1"> + <strong className="text-base text-(--text-color) overflow-hidden text-ellipsis line-clamp-2 leading-tight"> + {estimate.route} + </strong> + {metaChips.length > 0 && ( + <div className="flex items-center gap-1.5 flex-wrap"> + {metaChips.map((chip, idx) => { + let chipColourClasses = ""; + switch (chip.tone) { + case "delay-ok": + chipColourClasses = "bg-green-600/20 dark:bg-green-600/30 text-green-700 dark:text-green-300"; + break; + case "delay-warn": + chipColourClasses = "bg-amber-600/20 dark:bg-yellow-600/30 text-amber-700 dark:text-yellow-300"; + break; + case "delay-critical": + chipColourClasses = "bg-red-400/20 dark:bg-red-600/30 text-red-600 dark:text-red-300"; + break; + case "delay-early": + chipColourClasses = "bg-blue-400/20 dark:bg-blue-600/30 text-blue-700 dark:text-blue-300"; + break; + default: + chipColourClasses = "bg-black/[0.06] dark:bg-white/[0.12] text-[var(--text-color)]"; + } + + return ( + <span + key={`${chip.label}-${idx}`} + className={`text-xs px-2 py-0.5 rounded-full flex items-center justify-center gap-1 shrink-0 ${chipColourClasses}`} + > + {chip.label} + </span> + ); + })} + </div> + )} + </div> + <div + className={` + inline-flex items-center justify-center px-2 py-1.5 rounded-xl shrink-0 + ${timeClass === "time-running" + ? "bg-green-600/20 dark:bg-green-600/25 text-[#1a9e56] dark:text-[#22c55e]" + : timeClass === "time-delayed" + ? "bg-orange-600/20 dark:bg-orange-600/25 text-[#d06100] dark:text-[#fb923c]" + : "bg-blue-900/20 dark:bg-blue-600/25 text-[#0b3d91] dark:text-[#93c5fd]" + } + `.trim()} + > + <div className="flex flex-col items-center leading-none"> + <span className="text-lg font-bold leading-none">{etaValue}</span> + <span className="text-[0.65rem] uppercase tracking-wider mt-0.5 opacity-90">{etaUnit}</span> + </div> + </div> + </Tag> + ); + } + return ( <Tag className={`consolidated-circulation-card ${readonly @@ -186,50 +265,51 @@ export const ConsolidatedCirculationCard: React.FC< : "no-gps" }`} {...interactiveProps} - aria-label={`${hasGpsPosition ? "View" : "No GPS data for"} ${estimate.line - } to ${estimate.route}${hasGpsPosition ? " on map" : ""}`} > - <div className="card-row main"> - <div className="line-info"> - <LineIcon line={estimate.line} mode="pill" /> - </div> - <div className="route-info"> - <strong>{estimate.route}</strong> - </div> - {hasGpsPosition && ( - <div className="gps-indicator" title="Live GPS tracking"> - <span - className={`gps-pulse ${estimate.isPreviousTrip ? "previous-trip" : "" - }`} - /> + <> + <div className="card-row main"> + <div className="line-info"> + <LineIcon line={estimate.line} mode="pill" /> </div> - )} - <div className={`eta-badge ${timeClass}`}> - <div className="eta-text"> - <span className="eta-value">{etaValue}</span> - <span className="eta-unit">{etaUnit}</span> + <div className="route-info"> + <strong>{estimate.route}</strong> + {estimate.nextStreets && estimate.nextStreets.length > 0 && ( + <Marquee speed={85}> + <div className="mr-32 font-mono"> + {estimate.nextStreets.join(" — ")} + </div> + </Marquee> + )} </div> - </div> - </div> - {metaChips.length > 0 && ( - <div className="card-row meta"> - {metaChips.map((chip, idx) => ( - <span - key={`${chip.label}-${idx}`} - className={`meta-chip ${chip.tone ?? ""}`.trim()} - > - {chip.label} - </span> - ))} - - {estimate.nextStreets && estimate.nextStreets.length > 0 && ( - <Marquee speed={85}> - <div className="mr-64"></div> - {estimate.nextStreets.join(" — ")} - </Marquee> + {hasGpsPosition && ( + <div className="gps-indicator" title="Live GPS tracking"> + <span + className={`gps-pulse ${estimate.isPreviousTrip ? "previous-trip" : "" + }`} + /> + </div> )} + <div className={`eta-badge ${timeClass}`}> + <div className="eta-text"> + <span className="eta-value">{etaValue}</span> + <span className="eta-unit">{etaUnit}</span> + </div> + </div> </div> - )} + + {metaChips.length > 0 && ( + <div className="card-row meta"> + {metaChips.map((chip, idx) => ( + <span + key={`${chip.label}-${idx}`} + className={`meta-chip ${chip.tone ?? ""}`.trim()} + > + {chip.label} + </span> + ))} + </div> + )} + </> </Tag> ); }; diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx b/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx index 547fdf7..088f978 100644 --- a/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx +++ b/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx @@ -2,18 +2,19 @@ import { useTranslation } from "react-i18next"; import { type ConsolidatedCirculation } from "~routes/stops-$id"; import { ConsolidatedCirculationCard } from "./ConsolidatedCirculationCard"; +import { useCallback } from "react"; import "./ConsolidatedCirculationList.css"; -interface RegularTableProps { +interface ConsolidatedCirculationListProps { data: ConsolidatedCirculation[]; - dataDate: Date | null; onCirculationClick?: (estimate: ConsolidatedCirculation, index: number) => void; + reduced?: boolean; } -export const ConsolidatedCirculationList: React.FC<RegularTableProps> = ({ +export const ConsolidatedCirculationList: React.FC<ConsolidatedCirculationListProps> = ({ data, - dataDate, onCirculationClick, + reduced, }) => { const { t } = useTranslation(); @@ -23,28 +24,31 @@ export const ConsolidatedCirculationList: React.FC<RegularTableProps> = ({ (b.realTime?.minutes ?? b.schedule?.minutes ?? 999) ); + const generateKey = useCallback((estimate: ConsolidatedCirculation) => { + if (estimate.realTime && estimate.schedule) { + return `rt-${estimate.schedule.tripId}`; + } + + return `sch-${estimate.schedule ? estimate.schedule.tripId : estimate.line + "-" + estimate.route}`; + }, []); + return ( <> - <div className="consolidated-circulation-caption"> - {t("estimates.caption", "Estimaciones de llegadas a las {{time}}", { - time: dataDate?.toLocaleTimeString(), - })} - </div> - {sortedData.length === 0 ? ( <div className="consolidated-circulation-no-data"> {t("estimates.none", "No hay estimaciones disponibles")} </div> ) : ( - <> + <div className="flex flex-col gap-3"> {sortedData.map((estimate, idx) => ( <ConsolidatedCirculationCard - key={idx} + reduced={reduced} + key={generateKey(estimate)} estimate={estimate} onMapClick={() => onCirculationClick?.(estimate, idx)} /> ))} - </> + </div> )} </> ); diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx index 343cf91..461e891 100644 --- a/src/frontend/app/routes/map.tsx +++ b/src/frontend/app/routes/map.tsx @@ -14,7 +14,7 @@ import Map, { type MapRef, type StyleSpecification } from "react-map-gl/maplibre"; -import { StopSheet } from "~/components/StopSheet"; +import { StopSheet } from "~/components/StopSummarySheet"; import { REGION_DATA } from "~/config/RegionConfig"; import { usePageTitle } from "~/contexts/PageTitleContext"; import { useApp } from "../AppContext"; diff --git a/src/frontend/app/routes/stops-$id.css b/src/frontend/app/routes/stops-$id.css index 1144584..fa29833 100644 --- a/src/frontend/app/routes/stops-$id.css +++ b/src/frontend/app/routes/stops-$id.css @@ -53,20 +53,6 @@ gap: 1rem; } -.stops-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - flex-shrink: 0; -} - -.stops-header > div:first-child { - display: flex; - align-items: center; - gap: 0.5rem; -} - .star-icon, .edit-icon { cursor: pointer; diff --git a/src/frontend/app/routes/stops-$id.tsx b/src/frontend/app/routes/stops-$id.tsx index 25aa3e7..32152f9 100644 --- a/src/frontend/app/routes/stops-$id.tsx +++ b/src/frontend/app/routes/stops-$id.tsx @@ -1,4 +1,4 @@ -import { Edit2, RefreshCw, Star } from "lucide-react"; +import { Eye, EyeClosed, RefreshCw, Star } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useParams } from "react-router"; @@ -88,6 +88,7 @@ export default function Estimates() { const [favourited, setFavourited] = useState(false); const [isManualRefreshing, setIsManualRefreshing] = useState(false); const [isMapModalOpen, setIsMapModalOpen] = useState(false); + const [isReducedView, setIsReducedView] = useState(false); const [selectedCirculationId, setSelectedCirculationId] = useState< string | undefined >(undefined); @@ -185,49 +186,9 @@ export default function Estimates() { } }; - const handleRename = () => { - const current = getStopDisplayName(); - 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 ( <PullToRefresh onRefresh={handleManualRefresh}> <div className="page-container stops-page"> - <div className="stops-header"> - <div> - <Star - className={`star-icon ${favourited ? "active" : ""}`} - onClick={toggleFavourite} - width={20} - /> - <Edit2 - className="edit-icon" - onClick={handleRename} - width={20} - /> - </div> - - <button - className="manual-refresh-button" - onClick={handleManualRefresh} - disabled={isManualRefreshing || dataLoading} - title={t("estimates.reload", "Recargar estimaciones")} - > - <RefreshCw - className={`refresh-icon ${isManualRefreshing ? "spinning" : ""}`} - /> - </button> - </div> - {stopData && stopData.lines && stopData.lines.length > 0 && ( <div className={`estimates-lines-container scrollable`}> {stopData.lines.map((line) => ( @@ -253,14 +214,43 @@ export default function Estimates() { )} /> ) : data ? ( - <ConsolidatedCirculationList - data={data} - dataDate={dataDate} - onCirculationClick={(estimate, idx) => { - setSelectedCirculationId(getCirculationId(estimate)); - setIsMapModalOpen(true); - }} - /> + <> + <div className="flex items-center justify-between py-2"> + <div className="flex items-center gap-4"> + <Star + className={`text-slate-500 ${favourited ? "active" : ""}`} + onClick={toggleFavourite} + /> + + <RefreshCw + className={`text-slate-500 ${isManualRefreshing ? "spinning" : ""}`} + onClick={handleManualRefresh} + /> + </div> + + <div className="consolidated-circulation-caption"> + {t("estimates.caption", "Estimaciones de llegadas a las {{time}}", { + time: dataDate?.toLocaleTimeString(), + })} + </div> + + <div> + {isReducedView ? ( + <EyeClosed className="text-slate-500" onClick={() => setIsReducedView(false)} /> + ) : ( + <Eye className="text-slate-500" onClick={() => setIsReducedView(true)} /> + )} + </div> + </div> + <ConsolidatedCirculationList + data={data} + reduced={isReducedView} + onCirculationClick={(estimate, idx) => { + setSelectedCirculationId(getCirculationId(estimate)); + setIsMapModalOpen(true); + }} + /> + </> ) : null} </div> |
