aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/app
diff options
context:
space:
mode:
Diffstat (limited to 'src/frontend/app')
-rw-r--r--src/frontend/app/components/StopMapSheet.css149
-rw-r--r--src/frontend/app/components/StopMapSheet.tsx575
-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.tsx168
-rw-r--r--src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx30
-rw-r--r--src/frontend/app/routes/map.tsx2
-rw-r--r--src/frontend/app/routes/stops-$id.css14
-rw-r--r--src/frontend/app/routes/stops-$id.tsx88
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>