import { useEffect, useMemo, useRef, useState } from "react"; import Marquee from "react-fast-marquee"; import { useTranslation } from "react-i18next"; import LineIcon from "~components/LineIcon"; import { type ConsolidatedCirculation } from "~routes/stops-$id"; import { AlertTriangle, LocateIcon } from "lucide-react"; import "./ConsolidatedCirculationCard.css"; interface ConsolidatedCirculationCardProps { estimate: ConsolidatedCirculation; onMapClick?: () => void; readonly?: boolean; reduced?: boolean; driver?: string; } // Utility function to parse service ID and get the turn number const parseServiceId = (serviceId: string): string => { const parts = serviceId.split("_"); if (parts.length === 0) return ""; const lastPart = parts[parts.length - 1]; if (lastPart.length < 6) return ""; const last6 = lastPart.slice(-6); const lineCode = last6.slice(0, 3); const turnCode = last6.slice(-3); // Remove leading zeros from turn const turnNumber = parseInt(turnCode, 10).toString(); // Parse line number with special cases const lineNumber = parseInt(lineCode, 10); let displayLine: string; switch (lineNumber) { case 1: displayLine = "C1"; break; case 3: displayLine = "C3"; break; case 30: displayLine = "N1"; break; case 33: displayLine = "N4"; break; case 8: displayLine = "A"; break; case 101: displayLine = "H"; break; case 150: displayLine = "REF"; break; case 500: displayLine = "TUR"; break; case 201: displayLine = "U1"; break; case 202: displayLine = "U2"; break; default: displayLine = `L${lineNumber}`; } return `${displayLine}-${turnNumber}`; }; const AutoMarquee = ({ text }: { text: string }) => { const containerRef = useRef(null); const [shouldScroll, setShouldScroll] = useState(false); useEffect(() => { const el = containerRef.current; if (!el) return; const checkScroll = () => { // 9px per char for text-sm font-mono is a safe upper bound estimate // (14px * 0.6 = 8.4px) const charWidth = 9; 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 ConsolidatedCirculationCard: React.FC< ConsolidatedCirculationCardProps > = ({ estimate, onMapClick, readonly, reduced, driver }) => { const { t } = useTranslation(); const formatDistance = (meters: number) => { if (meters > 1024) { return `${(meters / 1000).toFixed(1)} km`; } return `${meters} ${t("estimates.meters", "m")}`; }; const getTripIdDisplay = (tripId: string): string => { const parts = tripId.split("_"); return parts.length > 1 ? parts[1] : tripId; }; const etaMinutes = estimate.realTime?.minutes ?? estimate.schedule?.minutes ?? null; let etaValue: string; let etaUnit: string; if (etaMinutes === null) { etaValue = "--"; etaUnit = t("estimates.minutes", "min"); } else { const isRenfe = driver === "renfe"; const isLongWait = etaMinutes > 60; if (isRenfe || isLongWait) { const now = new Date(); const arrivalTime = new Date(now.getTime() + etaMinutes * 60 * 1000); etaValue = arrivalTime.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false, }); etaUnit = ""; } else { etaValue = Math.max(0, Math.round(etaMinutes)).toString(); etaUnit = t("estimates.minutes", "min"); } } const timeClass = useMemo(() => { if (estimate.realTime && estimate.schedule?.running) { return "time-running"; } if (estimate.realTime && !estimate.schedule) { return "time-running"; } if (estimate.realTime && !estimate.schedule?.running) { return "time-delayed"; } return "time-scheduled"; }, [estimate.realTime, estimate.schedule]); const delayChip = useMemo(() => { if (!estimate.schedule || !estimate.realTime) { return null; } const delta = Math.round( estimate.realTime.minutes - estimate.schedule.minutes ); const absDelta = Math.abs(delta); // On time if (delta === 0) { return { label: reduced ? "OK" : t("estimates.delay_on_time"), tone: "delay-ok", kind: "delay", } as const; } // Delayed if (delta > 0) { const tone = delta <= 2 ? "delay-ok" : delta <= 10 ? "delay-warn" : "delay-critical"; return { label: reduced ? `R${delta}` : t("estimates.delay_positive", { minutes: delta, }), tone, kind: "delay", } as const; } // Early const tone = absDelta <= 2 ? "delay-ok" : "delay-early"; return { label: reduced ? `A${absDelta}` : t("estimates.delay_negative", { minutes: absDelta, }), tone, kind: "delay", } as const; }, [estimate.schedule, estimate.realTime, t, reduced]); const metaChips = useMemo(() => { const chips: Array<{ label: string; tone?: string; kind?: "regular" | "gps" | "delay" | "warning"; }> = []; if (delayChip) { chips.push(delayChip); } if (estimate.schedule && driver !== "renfe") { chips.push({ label: `${parseServiceId(estimate.schedule.serviceId)} · ${getTripIdDisplay( estimate.schedule.tripId )}`, kind: "regular", }); } if (estimate.realTime && estimate.realTime.distance >= 0) { chips.push({ label: formatDistance(estimate.realTime.distance), kind: "regular", }); } if (estimate.currentPosition) { if (estimate.isPreviousTrip) { chips.push({ label: t("estimates.previous_trip"), kind: "gps" }); } else { chips.push({ label: t("estimates.bus_gps_position"), kind: "gps" }); } } if (timeClass === "time-delayed") { chips.push({ label: reduced ? "!" : t("estimates.low_accuracy"), tone: "warning", kind: "warning", }); } if (timeClass === "time-scheduled") { chips.push({ label: reduced ? "⧗" : t("estimates.no_realtime"), tone: "warning", kind: "warning", }); } return chips; }, [delayChip, estimate.schedule, estimate.realTime, timeClass, t, reduced]); // Check if bus has GPS position (live tracking) const hasGpsPosition = !!estimate.currentPosition; const isRenfe = driver === "renfe"; const isClickable = hasGpsPosition; const looksDisabled = !isClickable && !isRenfe; const Tag = readonly ? "div" : "button"; const interactiveProps = readonly ? {} : { onClick: isClickable ? onMapClick : undefined, type: "button" as const, disabled: !isClickable, }; if (reduced) { return (
{driver === "renfe" && estimate.schedule?.tripId && ( {estimate.schedule.tripId} )} {driver === "renfe" ? estimate.route.toUpperCase() : estimate.route} {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-[var(--text-color)]"; } return ( {chip.kind === "gps" && ( )} {chip.kind === "warning" && ( )} {chip.label} ); })}
)}
{etaValue} {etaUnit}
); } return ( <>
{driver === "renfe" && estimate.schedule?.tripId && ( {estimate.schedule.tripId} )} {driver === "renfe" ? estimate.route.toUpperCase() : estimate.route} {estimate.nextStreets && estimate.nextStreets.length > 0 && ( )}
{etaValue} {etaUnit}
{metaChips.length > 0 && (
{metaChips.map((chip, idx) => ( {chip.kind === "gps" && ( )} {chip.kind === "warning" && ( )} {chip.label} ))}
)}
); };