From 4a866f5352a51916ddb9849b2d68213856196c9c Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Tue, 23 Dec 2025 21:33:17 +0100 Subject: Full real-time page, coruña real time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/frontend/app/components/StopMapModal.tsx | 290 +++++++++++++-------- .../app/components/arrivals/ArrivalCard.tsx | 231 ++++++++++------ .../app/components/arrivals/ArrivalList.tsx | 29 ++- .../app/components/arrivals/ReducedArrivalCard.tsx | 198 ++++++++++++++ 4 files changed, 556 insertions(+), 192 deletions(-) create mode 100644 src/frontend/app/components/arrivals/ReducedArrivalCard.tsx (limited to 'src/frontend/app/components') diff --git a/src/frontend/app/components/StopMapModal.tsx b/src/frontend/app/components/StopMapModal.tsx index bb6a3fa..d218af4 100644 --- a/src/frontend/app/components/StopMapModal.tsx +++ b/src/frontend/app/components/StopMapModal.tsx @@ -29,10 +29,11 @@ export interface ConsolidatedCirculationForMap { currentPosition?: Position; stopShapeIndex?: number; isPreviousTrip?: boolean; - previousTripShapeId?: string; + previousTripShapeId?: string | null; schedule?: { - shapeId?: string; + shapeId?: string | null; }; + shape?: any; } interface StopMapModalProps { @@ -70,7 +71,7 @@ export const StopMapModal: React.FC = ({ const circulation = circulations.find( (c) => c.id === selectedCirculationId ); - if (circulation?.currentPosition) { + if (circulation) { return circulation; } } @@ -97,27 +98,146 @@ export const StopMapModal: React.FC = ({ const points: { lat: number; lon: number }[] = []; - const addShapePoints = (data: any) => { - if ( - data?.properties?.busPoint && - data?.properties?.stopPoint && - data?.geometry?.coordinates - ) { - const busIdx = data.properties.busPoint.index; - const stopIdx = data.properties.stopPoint.index; - const coords = data.geometry.coordinates; - - const start = Math.min(busIdx, stopIdx); - const end = Math.max(busIdx, stopIdx); - - for (let i = start; i <= end; i++) { - points.push({ lat: coords[i][1], lon: coords[i][0] }); + const getStopsFromFeatureCollection = (data: any) => { + if (!data || data.type !== "FeatureCollection" || !data.features) + return []; + return data.features.filter((f: any) => f.properties?.type === "stop"); + }; + + const findClosestStopIndex = ( + stops: any[], + pos: { lat: number; lon: number } + ) => { + let minDst = Infinity; + let index = -1; + stops.forEach((s: any, idx: number) => { + const [lon, lat] = s.geometry.coordinates; + const dst = Math.pow(lat - pos.lat, 2) + Math.pow(lon - pos.lon, 2); + if (dst < minDst) { + minDst = dst; + index = idx; + } + }); + return index; + }; + + const findClosestPointIndex = ( + coords: number[][], + pos: { lat: number; lon: number } + ) => { + let minDst = Infinity; + let index = -1; + coords.forEach((c, idx) => { + const [lon, lat] = c; + const dst = Math.pow(lat - pos.lat, 2) + Math.pow(lon - pos.lon, 2); + if (dst < minDst) { + minDst = dst; + index = idx; + } + }); + return index; + }; + + const addShapePoints = (data: any, isPrevious: boolean) => { + if (!data) return; + + if (data.type === "FeatureCollection") { + const stops = getStopsFromFeatureCollection(data); + if (stops.length === 0) return; + + let startIdx = 0; + let endIdx = stops.length - 1; + + const currentPos = selectedBus?.currentPosition; + const userStopPos = + stop.latitude && stop.longitude + ? { lat: stop.latitude, lon: stop.longitude } + : null; + + if (isPrevious) { + // Previous trip: Start from Bus, End at last stop + if (currentPos) { + const busIdx = findClosestStopIndex(stops, { + lat: currentPos.latitude, + lon: currentPos.longitude, + }); + if (busIdx !== -1) startIdx = busIdx; + } + } else { + // Current trip: Start from Bus (if not previous), End at User Stop + if (!previousShapeData && currentPos) { + const busIdx = findClosestStopIndex(stops, { + lat: currentPos.latitude, + lon: currentPos.longitude, + }); + if (busIdx !== -1) startIdx = busIdx; + } + + if (userStopPos) { + let userIdx = -1; + // Try name match + if (stop.name) { + userIdx = stops.findIndex( + (s: any) => s.properties?.name === stop.name + ); + } + // Fallback to coords + if (userIdx === -1) { + userIdx = findClosestStopIndex(stops, userStopPos); + } + if (userIdx !== -1) endIdx = userIdx; + } + } + + // Add stops in range + if (startIdx <= endIdx) { + for (let i = startIdx; i <= endIdx; i++) { + const [lon, lat] = stops[i].geometry.coordinates; + points.push({ lat, lon }); + } } + return; + } + + const coords = data?.geometry?.coordinates; + if (!coords) return; + + let startIdx = 0; + let endIdx = coords.length - 1; + let foundIndices = false; + + if (data.properties?.busPoint && data.properties?.stopPoint) { + startIdx = data.properties.busPoint.index; + endIdx = data.properties.stopPoint.index; + foundIndices = true; + } else { + // Fallback: find closest points on the line + if (selectedBus?.currentPosition) { + const busIdx = findClosestPointIndex(coords, { + lat: selectedBus.currentPosition.latitude, + lon: selectedBus.currentPosition.longitude, + }); + if (busIdx !== -1) startIdx = busIdx; + } + if (stop.latitude && stop.longitude) { + const stopIdx = findClosestPointIndex(coords, { + lat: stop.latitude, + lon: stop.longitude, + }); + if (stopIdx !== -1) endIdx = stopIdx; + } + } + + const start = Math.min(startIdx, endIdx); + const end = Math.max(startIdx, endIdx); + + for (let i = start; i <= end; i++) { + points.push({ lat: coords[i][1], lon: coords[i][0] }); } }; - addShapePoints(shapeData); - addShapePoints(previousShapeData); + addShapePoints(previousShapeData, true); + addShapePoints(shapeData, false); if (points.length === 0) { if (stop.latitude && stop.longitude) { @@ -130,6 +250,17 @@ export const StopMapModal: React.FC = ({ lon: selectedBus.currentPosition.longitude, }); } + } else { + // Ensure bus and stop are always included if available, to prevent cutting them off + if (selectedBus?.currentPosition) { + points.push({ + lat: selectedBus.currentPosition.latitude, + lon: selectedBus.currentPosition.longitude, + }); + } + if (stop.latitude && stop.longitude) { + points.push({ lat: stop.latitude, lon: stop.longitude }); + } } if (points.length === 0) return; @@ -156,7 +287,7 @@ export const StopMapModal: React.FC = ({ .getMap() .easeTo({ center: [only.lon, only.lat], zoom: 16, duration: 450 }); } else { - mapRef.current.fitBounds(bounds, { + mapRef.current.getMap().fitBounds(bounds, { padding: 80, duration: 500, maxZoom: 17, @@ -196,7 +327,7 @@ export const StopMapModal: React.FC = ({ // Fit bounds on initial load useEffect(() => { - if (!styleSpec || !mapRef.current || hasFitBounds.current || !isOpen) + if (!styleSpec || !mapRef.current || !isOpen) return; const map = mapRef.current.getMap(); @@ -238,103 +369,25 @@ export const StopMapModal: React.FC = ({ // Fetch shape for selected bus useEffect(() => { - if ( - !isOpen || - !selectedBus || - !selectedBus.schedule?.shapeId || - selectedBus.currentPosition?.shapeIndex === undefined || - !APP_CONSTANTS.shapeEndpoint - ) { + if (!isOpen || !selectedBus) { setShapeData(null); setPreviousShapeData(null); return; } - const shapeId = selectedBus.schedule.shapeId; - const shapeIndex = selectedBus.currentPosition.shapeIndex; - const stopShapeIndex = selectedBus.stopShapeIndex; - const stopLat = stop.latitude; - const stopLon = stop.longitude; - - const fetchShape = async ( - sId: string, - bIndex?: number, - sIndex?: number, - sLat?: number, - sLon?: number - ) => { - let url = `${APP_CONSTANTS.shapeEndpoint}?shapeId=${sId}`; - if (bIndex !== undefined) url += `&busShapeIndex=${bIndex}`; - if (sIndex !== undefined) url += `&stopShapeIndex=${sIndex}`; - else if (sLat && sLon) url += `&stopLat=${sLat}&stopLon=${sLon}`; - - const res = await fetch(url); - if (res.ok) return res.json(); - return null; - }; - - const loadShapes = async () => { - if (selectedBus.isPreviousTrip && selectedBus.previousTripShapeId) { - // Bus is on previous trip - // 1. Load previous shape (where bus is) - const prevData = await fetchShape( - selectedBus.previousTripShapeId, - shapeIndex, - stopShapeIndex - ); - - // 2. Load current scheduled shape (where bus is going) - // Bus is not on this shape yet, so no bus index - const currData = await fetchShape( - shapeId, - undefined, - undefined, - stopLat, - stopLon - ); - - if ( - prevData && - prevData.geometry && - prevData.geometry.coordinates && - prevData.properties?.busPoint?.index !== undefined - ) { - const busIdx = prevData.properties.busPoint.index; - const coords = prevData.geometry.coordinates; - // Slice from busIdx - 5 (clamped to 0) to end - const startIdx = Math.max(0, busIdx - 5); - const slicedCoords = coords.slice(startIdx); - - // Join with the first point of the next shape to close the gap - if (currData?.geometry?.coordinates?.length > 0) { - slicedCoords.push(currData.geometry.coordinates[0]); - } - - prevData.geometry.coordinates = slicedCoords; - } - - setPreviousShapeData(prevData); - setShapeData(currData); - } else { - // Normal case - const data = await fetchShape( - shapeId, - shapeIndex, - stopShapeIndex, - stopLat, - stopLon - ); - setShapeData(data); - setPreviousShapeData(null); - } + if (selectedBus.shape) { + setShapeData(selectedBus.shape); + setPreviousShapeData(null); handleCenter(); - }; + return; + } - loadShapes().catch((err) => console.error("Failed to load shape", err)); + setShapeData(null); + setPreviousShapeData(null); }, [isOpen, selectedBus]); - if (busesWithPosition.length === 0) { - return null; // Don't render if no buses with GPS coordinates + if (!selectedBus && busesWithPosition.length === 0) { + return null; // Don't render if no buses with GPS coordinates and no selected bus } return ( @@ -379,6 +432,9 @@ export const StopMapModal: React.FC = ({ onPitchStart={() => { userInteracted.current = true; }} + onLoad={() => { + handleCenter(); + }} > {/* Previous Shape Layer */} {previousShapeData && selectedBus && ( @@ -462,6 +518,20 @@ export const StopMapModal: React.FC = ({ "line-join": "round", }} /> + + {/* Stops Layer */} + )} diff --git a/src/frontend/app/components/arrivals/ArrivalCard.tsx b/src/frontend/app/components/arrivals/ArrivalCard.tsx index 5cfbaa3..6952f8f 100644 --- a/src/frontend/app/components/arrivals/ArrivalCard.tsx +++ b/src/frontend/app/components/arrivals/ArrivalCard.tsx @@ -1,5 +1,6 @@ import { AlertTriangle, LocateIcon } from "lucide-react"; -import React, { useMemo } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; +import Marquee from "react-fast-marquee"; import { useTranslation } from "react-i18next"; import LineIcon from "~/components/LineIcon"; import { type Arrival } from "../../api/schema"; @@ -7,9 +8,56 @@ import "./ArrivalCard.css"; interface ArrivalCardProps { arrival: Arrival; + onClick?: () => void; } -export const ReducedArrivalCard: React.FC = ({ arrival }) => { +const AutoMarquee = ({ text }: { text: string }) => { + const containerRef = useRef(null); + const [shouldScroll, setShouldScroll] = useState(false); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + + const checkScroll = () => { + const charWidth = 8; + const availableWidth = el.offsetWidth; + const textWidth = text.length * charWidth; + setShouldScroll(textWidth > availableWidth); + }; + + checkScroll(); + const observer = new ResizeObserver(checkScroll); + observer.observe(el); + return () => observer.disconnect(); + }, [text]); + + if (shouldScroll) { + return ( +
+ +
+ {text} +
+
+
+ ); + } + + return ( +
+ {text} +
+ ); +}; + +export const ArrivalCard: React.FC = ({ + arrival, + onClick, +}) => { const { t } = useTranslation(); const { route, headsign, estimate, delay, shift } = arrival; @@ -36,6 +84,14 @@ export const ReducedArrivalCard: React.FC = ({ arrival }) => { kind?: "regular" | "gps" | "delay" | "warning"; }> = []; + // Badge/Shift info as a chip + if (headsign.badge) { + chips.push({ + label: headsign.badge, + kind: "regular", + }); + } + // Delay chip if (delay) { const delta = Math.round(delay.minutes); @@ -43,7 +99,7 @@ export const ReducedArrivalCard: React.FC = ({ arrival }) => { if (delta === 0) { chips.push({ - label: "OK", + label: t("estimates.delay_on_time"), tone: "delay-ok", kind: "delay", }); @@ -55,14 +111,14 @@ export const ReducedArrivalCard: React.FC = ({ arrival }) => { ? "delay-warn" : "delay-critical"; chips.push({ - label: `R${delta}`, + label: t("estimates.delay_positive", { minutes: delta }), tone, kind: "delay", }); } else { const tone = absDelta <= 2 ? "delay-ok" : "delay-early"; chips.push({ - label: `A${absDelta}`, + label: t("estimates.delay_negative", { minutes: absDelta }), tone, kind: "delay", }); @@ -80,23 +136,42 @@ export const ReducedArrivalCard: React.FC = ({ arrival }) => { // Precision chips if (estimate.precision === "unsure") { chips.push({ - label: "!", + label: t("estimates.low_accuracy"), tone: "warning", kind: "warning", }); } else if (estimate.precision === "confident") { chips.push({ - label: "", // Just the icon for reduced + label: t("estimates.bus_gps_position"), kind: "gps", }); } + if (estimate.precision === "scheduled") { + chips.push({ + label: t("estimates.no_realtime"), + tone: "warning", + kind: "warning", + }); + } + return chips; - }, [delay, shift, estimate.precision]); + }, [delay, shift, estimate.precision, t, headsign.badge]); + + const isClickable = !!onClick && estimate.precision !== "past"; + const Tag = isClickable ? "button" : "div"; return ( -
-
+ +
= ({ arrival }) => { />
- - {headsign.destination} - - {metaChips.length > 0 && ( -
- {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; - case "warning": - chipColourClasses = - "bg-orange-400/20 dark:bg-orange-600/30 text-orange-700 dark:text-orange-300"; - break; - default: - chipColourClasses = - "bg-black/[0.06] dark:bg-white/[0.12] text-slate-600 dark:text-slate-400"; - } - - return ( - - {chip.kind === "gps" && ( - - )} - {chip.kind === "warning" && ( - - )} - {chip.label} - - ); - })} +
+
+ + {headsign.destination} + + {headsign.marquee && ( +
+ +
+ )} +
+
+
+ {etaValue} + + {etaUnit} + +
- )} -
-
-
- {etaValue} - - {etaUnit} - +
+ +
+ {metaChips.map((chip, idx) => { + let chipColourClasses = ""; + switch (chip.tone) { + case "delay-ok": + chipColourClasses = + "bg-green-600/10 dark:bg-green-600/20 text-green-700 dark:text-green-300"; + break; + case "delay-warn": + chipColourClasses = + "bg-amber-600/10 dark:bg-yellow-600/20 text-amber-700 dark:text-yellow-300"; + break; + case "delay-critical": + chipColourClasses = + "bg-red-400/10 dark:bg-red-600/20 text-red-600 dark:text-red-300"; + break; + case "delay-early": + chipColourClasses = + "bg-blue-400/10 dark:bg-blue-600/20 text-blue-700 dark:text-blue-300"; + break; + case "warning": + chipColourClasses = + "bg-orange-400/10 dark:bg-orange-600/20 text-orange-700 dark:text-orange-300"; + break; + default: + chipColourClasses = + "bg-black/[0.04] dark:bg-white/[0.08] text-slate-500 dark:text-slate-400"; + } + + return ( + + {chip.kind === "gps" && ( + + )} + {chip.kind === "warning" && ( + + )} + {chip.label} + + ); + })}
-
+ ); }; diff --git a/src/frontend/app/components/arrivals/ArrivalList.tsx b/src/frontend/app/components/arrivals/ArrivalList.tsx index b2394fb..0186682 100644 --- a/src/frontend/app/components/arrivals/ArrivalList.tsx +++ b/src/frontend/app/components/arrivals/ArrivalList.tsx @@ -1,25 +1,38 @@ import React from "react"; import { type Arrival } from "../../api/schema"; -import { ReducedArrivalCard } from "./ArrivalCard"; +import { ArrivalCard } from "./ArrivalCard"; +import { ReducedArrivalCard } from "./ReducedArrivalCard"; interface ArrivalListProps { arrivals: Arrival[]; reduced?: boolean; + onArrivalClick?: (arrival: Arrival) => void; } export const ArrivalList: React.FC = ({ arrivals, reduced, + onArrivalClick, }) => { + const clickable = Boolean(onArrivalClick); + return (
- {arrivals.map((arrival, index) => ( - - ))} + {arrivals.map((arrival, index) => + reduced ? ( + onArrivalClick?.(arrival) : undefined} + /> + ) : ( + onArrivalClick?.(arrival) : undefined} + /> + ) + )}
); }; diff --git a/src/frontend/app/components/arrivals/ReducedArrivalCard.tsx b/src/frontend/app/components/arrivals/ReducedArrivalCard.tsx new file mode 100644 index 0000000..2c1ea20 --- /dev/null +++ b/src/frontend/app/components/arrivals/ReducedArrivalCard.tsx @@ -0,0 +1,198 @@ +import { AlertTriangle, LocateIcon } from "lucide-react"; +import React, { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import LineIcon from "~/components/LineIcon"; +import { type Arrival } from "../../api/schema"; +import "./ArrivalCard.css"; + +interface ArrivalCardProps { + arrival: Arrival; + onClick?: () => void; +} + +export const ReducedArrivalCard: React.FC = ({ + arrival, + onClick, +}) => { + const { t } = useTranslation(); + const { route, headsign, estimate, delay, shift } = arrival; + + const etaValue = estimate.minutes.toString(); + const etaUnit = t("estimates.minutes", "min"); + + const timeClass = useMemo(() => { + switch (estimate.precision) { + case "confident": + return "time-running"; + case "unsure": + return "time-delayed"; + case "past": + return "time-past"; + default: + return "time-scheduled"; + } + }, [estimate.precision]); + + const metaChips = useMemo(() => { + const chips: Array<{ + label: string; + tone?: string; + kind?: "regular" | "gps" | "delay" | "warning"; + }> = []; + + // Badge/Shift info as a chip + if (headsign.badge) { + chips.push({ + label: headsign.badge, + kind: "regular", + }); + } + + // Delay chip + if (delay) { + const delta = Math.round(delay.minutes); + const absDelta = Math.abs(delta); + + if (delta === 0) { + chips.push({ + label: "OK", + tone: "delay-ok", + kind: "delay", + }); + } else if (delta > 0) { + const tone = + delta <= 2 + ? "delay-ok" + : delta <= 10 + ? "delay-warn" + : "delay-critical"; + chips.push({ + label: `R${delta}`, + tone, + kind: "delay", + }); + } else { + const tone = absDelta <= 2 ? "delay-ok" : "delay-early"; + chips.push({ + label: `A${absDelta}`, + tone, + kind: "delay", + }); + } + } + + // Shift chip + if (shift) { + chips.push({ + label: `${shift.shiftName} · ${shift.shiftTrip}`, + kind: "regular", + }); + } + + // Precision chips + if (estimate.precision === "unsure") { + chips.push({ + label: "!", + tone: "warning", + kind: "warning", + }); + } else if (estimate.precision === "confident") { + chips.push({ + label: "", // Just the icon for reduced + kind: "gps", + }); + } + + return chips; + }, [delay, shift, estimate.precision, headsign.badge]); + + const isClickable = !!onClick && estimate.precision !== "past"; + const Tag = isClickable ? "button" : "div"; + + return ( + +
+ +
+
+ + {headsign.destination} + + {metaChips.length > 0 && ( +
+ {metaChips.map((chip, idx) => { + let chipColourClasses = ""; + switch (chip.tone) { + case "delay-ok": + chipColourClasses = + "bg-green-600/10 dark:bg-green-600/20 text-green-700 dark:text-green-300"; + break; + case "delay-warn": + chipColourClasses = + "bg-amber-600/10 dark:bg-yellow-600/20 text-amber-700 dark:text-yellow-300"; + break; + case "delay-critical": + chipColourClasses = + "bg-red-400/10 dark:bg-red-600/20 text-red-600 dark:text-red-300"; + break; + case "delay-early": + chipColourClasses = + "bg-blue-400/10 dark:bg-blue-600/20 text-blue-700 dark:text-blue-300"; + break; + case "warning": + chipColourClasses = + "bg-orange-400/10 dark:bg-orange-600/20 text-orange-700 dark:text-orange-300"; + break; + default: + chipColourClasses = + "bg-black/[0.04] dark:bg-white/[0.08] text-slate-500 dark:text-slate-400"; + } + + return ( + + {chip.kind === "gps" && ( + + )} + {chip.kind === "warning" && ( + + )} + {chip.label} + + ); + })} +
+ )} +
+
+
+ {etaValue} + + {etaUnit} + +
+
+
+ ); +}; -- cgit v1.3