diff options
Diffstat (limited to 'src/frontend')
| -rw-r--r-- | src/frontend/app/AppContext.tsx | 13 | ||||
| -rw-r--r-- | src/frontend/app/components/LineIcon.css | 10 | ||||
| -rw-r--r-- | src/frontend/app/components/Stops/ConsolidatedCirculationCard.css | 1 | ||||
| -rw-r--r-- | src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx | 96 | ||||
| -rw-r--r-- | src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx | 3 | ||||
| -rw-r--r-- | src/frontend/app/config/RegionConfig.ts | 25 | ||||
| -rw-r--r-- | src/frontend/app/routes/home.tsx | 10 | ||||
| -rw-r--r-- | src/frontend/app/routes/stops-$id.tsx | 5 |
8 files changed, 127 insertions, 36 deletions
diff --git a/src/frontend/app/AppContext.tsx b/src/frontend/app/AppContext.tsx index 59f2724..12a54da 100644 --- a/src/frontend/app/AppContext.tsx +++ b/src/frontend/app/AppContext.tsx @@ -3,11 +3,11 @@ import { type ReactNode } from "react"; import { type RegionId } from "./config/RegionConfig"; import { MapProvider, useMap } from "./contexts/MapContext"; import { - SettingsProvider, - useSettings, - type MapPositionMode, - type TableStyle, - type Theme, + SettingsProvider, + useSettings, + type MapPositionMode, + type TableStyle, + type Theme, } from "./contexts/SettingsContext"; // Re-export types for compatibility @@ -21,6 +21,9 @@ export const useApp = () => { return { ...settings, ...map, + // Mock region support for now since we only have one region + region: "vigo" as RegionId, + setRegion: (region: RegionId) => { console.log("Set region", region); }, }; }; diff --git a/src/frontend/app/components/LineIcon.css b/src/frontend/app/components/LineIcon.css index 6363c85..6492d39 100644 --- a/src/frontend/app/components/LineIcon.css +++ b/src/frontend/app/components/LineIcon.css @@ -87,6 +87,16 @@ --line-gol: hsl(208, 68%, 66%); --line-gol-text: hsl(0, 0%, 100%); + --line-md: hsl(316, 99%, 27%); + --line-md-text: hsl(0, 0%, 100%); + --line-ave: hsl(316, 99%, 27%); + --line-ave-text: hsl(0, 0%, 100%); + --line-alvia: hsl(316, 99%, 27%); + --line-alvia-text: hsl(0, 0%, 100%); + --line-trencelta: hsl(135, 58%, 25%); + --line-trencelta-text: hsl(0, 0%, 100%); + --line-regional: hsl(316, 99%, 27%); + --line-regional-text: hsl(0, 0%, 100%); } .line-icon-default { diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationCard.css b/src/frontend/app/components/Stops/ConsolidatedCirculationCard.css index 57d30c8..9922b03 100644 --- a/src/frontend/app/components/Stops/ConsolidatedCirculationCard.css +++ b/src/frontend/app/components/Stops/ConsolidatedCirculationCard.css @@ -61,7 +61,6 @@ } .consolidated-circulation-card .route-info strong { - font-size: 1rem; color: var(--text-color); overflow: hidden; text-overflow: ellipsis; diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx b/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx index 635c0ce..70a9355 100644 --- a/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx +++ b/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import Marquee from 'react-fast-marquee'; import { useTranslation } from "react-i18next"; import LineIcon from "~components/LineIcon"; @@ -12,6 +12,7 @@ interface ConsolidatedCirculationCardProps { onMapClick?: () => void; readonly?: boolean; reduced?: boolean; + driver?: string; } // Utility function to parse service ID and get the turn number @@ -71,9 +72,52 @@ const parseServiceId = (serviceId: string): string => { return `${displayLine}-${turnNumber}`; }; +const AutoMarquee = ({ text }: { text: string }) => { + const containerRef = useRef<HTMLDivElement>(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 ( + <div ref={containerRef} className="w-full overflow-hidden"> + <Marquee speed={60} gradient={false}> + <div className="mr-64 text-sm font-mono">{text}</div> + </Marquee> + </div> + ); + } + + return ( + <div ref={containerRef} className="w-full overflow-hidden text-sm font-mono truncate"> + {text} + </div> + ); +}; + export const ConsolidatedCirculationCard: React.FC< ConsolidatedCirculationCardProps -> = ({ estimate, onMapClick, readonly, reduced }) => { +> = ({ estimate, onMapClick, readonly, reduced, driver }) => { const { t } = useTranslation(); const formatDistance = (meters: number) => { @@ -157,7 +201,7 @@ export const ConsolidatedCirculationCard: React.FC< chips.push(delayChip); } - if (estimate.schedule) { + if (estimate.schedule && driver !== 'renfe') { chips.push({ label: `${parseServiceId(estimate.schedule.serviceId)} · ${getTripIdDisplay( estimate.schedule.tripId @@ -199,14 +243,17 @@ export const ConsolidatedCirculationCard: React.FC< // 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: onMapClick, + onClick: isClickable ? onMapClick : undefined, type: "button" as const, - disabled: !hasGpsPosition, + disabled: !isClickable, }; if (reduced) { @@ -217,12 +264,14 @@ export const ConsolidatedCirculationCard: React.FC< bg-(--message-background-color) border border-(--border-color) rounded-xl px-3 py-2.5 transition-all ${readonly - ? !hasGpsPosition + ? looksDisabled ? "opacity-70 cursor-not-allowed" : "" - : hasGpsPosition + : isClickable ? "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" + : looksDisabled + ? "opacity-70 cursor-not-allowed" + : "" } `.trim()} {...interactiveProps} @@ -232,6 +281,9 @@ export const ConsolidatedCirculationCard: React.FC< </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"> + {driver === 'renfe' && estimate.schedule?.tripId && ( + <span className="font-mono text-slate-500 mr-1.5 text-sm">{estimate.schedule.tripId}</span> + )} {estimate.route} </strong> {metaChips.length > 0 && ( @@ -295,12 +347,14 @@ export const ConsolidatedCirculationCard: React.FC< return ( <Tag className={`consolidated-circulation-card ${readonly - ? !hasGpsPosition + ? looksDisabled ? "no-gps" : "" - : hasGpsPosition + : isClickable ? "has-gps" - : "no-gps" + : looksDisabled + ? "no-gps" + : "" }`} {...interactiveProps} > @@ -310,17 +364,15 @@ export const ConsolidatedCirculationCard: React.FC< <LineIcon line={estimate.line} mode="pill" /> </div> <div className="route-info"> - <strong>{estimate.route}</strong> - {estimate.nextStreets && estimate.nextStreets.length > 0 && (() => { - const text = estimate.nextStreets.join(" — "); - return ( - <Marquee speed={85} play={text.length > 30}> - <div className="mr-32 font-mono"> - {text} - </div> - </Marquee> - ); - })()} + <strong className="uppercase"> + {driver === 'renfe' && estimate.schedule?.tripId && ( + <span className="font-mono text-slate-500 mr-2 text-[0.9em]">{estimate.schedule.tripId}</span> + )} + {estimate.route} + </strong> + {estimate.nextStreets && estimate.nextStreets.length > 0 && ( + <AutoMarquee text={estimate.nextStreets.join(" — ")} /> + )} </div> <div className={`eta-badge ${timeClass}`}> <div className="eta-text"> diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx b/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx index 088f978..ec79f1c 100644 --- a/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx +++ b/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx @@ -9,12 +9,14 @@ interface ConsolidatedCirculationListProps { data: ConsolidatedCirculation[]; onCirculationClick?: (estimate: ConsolidatedCirculation, index: number) => void; reduced?: boolean; + driver?: string; } export const ConsolidatedCirculationList: React.FC<ConsolidatedCirculationListProps> = ({ data, onCirculationClick, reduced, + driver, }) => { const { t } = useTranslation(); @@ -43,6 +45,7 @@ export const ConsolidatedCirculationList: React.FC<ConsolidatedCirculationListPr {sortedData.map((estimate, idx) => ( <ConsolidatedCirculationCard reduced={reduced} + driver={driver} key={generateKey(estimate)} estimate={estimate} onMapClick={() => onCirculationClick?.(estimate, idx)} diff --git a/src/frontend/app/config/RegionConfig.ts b/src/frontend/app/config/RegionConfig.ts index 4677509..43fe70a 100644 --- a/src/frontend/app/config/RegionConfig.ts +++ b/src/frontend/app/config/RegionConfig.ts @@ -1,6 +1,26 @@ import type { LngLatLike } from "maplibre-gl"; -export const REGION_DATA = { +export type RegionId = "vigo"; + +export interface RegionData { + id: RegionId; + name: string; + stopsEndpoint: string; + estimatesEndpoint: string; + consolidatedCirculationsEndpoint: string; + timetableEndpoint: string; + shapeEndpoint: string; + defaultCenter: LngLatLike; + bounds: { + sw: LngLatLike; + ne: LngLatLike; + }; + textColour: string; + defaultZoom: number; + showMeters: boolean; +} + +export const REGION_DATA: RegionData = { id: "vigo", name: "Vigo", stopsEndpoint: "/stops/vigo.json", @@ -20,3 +40,6 @@ export const REGION_DATA = { defaultZoom: 14, showMeters: true, }; + +export const getAvailableRegions = (): RegionData[] => [REGION_DATA]; + diff --git a/src/frontend/app/routes/home.tsx b/src/frontend/app/routes/home.tsx index 5d56b48..f97fdf7 100644 --- a/src/frontend/app/routes/home.tsx +++ b/src/frontend/app/routes/home.tsx @@ -104,7 +104,7 @@ export default function StopList() { } if (!userLocation) { - return [...data].sort((a, b) => a.stopId - b.stopId); + return [...data].sort((a, b) => a.stopId.localeCompare(b.stopId)); } const toRadians = (value: number) => (value * Math.PI) / 180; @@ -147,7 +147,7 @@ export default function StopList() { }) .sort((a, b) => { if (a.distance === b.distance) { - return a.stop.stopId - b.stop.stopId; + return a.stop.stopId.localeCompare(b.stop.stopId); } return a.distance - b.distance; }) @@ -216,8 +216,8 @@ export default function StopList() { let items: Stop[]; if (isNumericSearch) { // Direct match for stop codes - const stopId = parseInt(searchQuery.trim(), 10); - const exactMatch = data.filter((stop) => stop.stopId === stopId); + const stopId = searchQuery.trim(); + const exactMatch = data.filter((stop) => stop.stopId === stopId || stop.stopId.endsWith(`:${stopId}`)); if (exactMatch.length > 0) { items = exactMatch; } else { @@ -281,7 +281,7 @@ export default function StopList() { {/* Favourites Gallery */} {!loading && ( <StopGallery - stops={favouriteStops.sort((a, b) => a.stopId - b.stopId)} + stops={favouriteStops.sort((a, b) => a.stopId.localeCompare(b.stopId))} title={t("stoplist.favourites")} emptyMessage={t("stoplist.no_favourites")} /> diff --git a/src/frontend/app/routes/stops-$id.tsx b/src/frontend/app/routes/stops-$id.tsx index 5260c32..553b8e7 100644 --- a/src/frontend/app/routes/stops-$id.tsx +++ b/src/frontend/app/routes/stops-$id.tsx @@ -247,8 +247,8 @@ export default function Estimates() { <div className="flex items-center gap-8"> <Star className={`cursor-pointer transition-colors ${favourited - ? "fill-[var(--star-color)] text-[var(--star-color)]" - : "text-slate-500" + ? "fill-[var(--star-color)] text-[var(--star-color)]" + : "text-slate-500" }`} onClick={toggleFavourite} /> @@ -273,6 +273,7 @@ export default function Estimates() { <ConsolidatedCirculationList data={data} reduced={isReducedView} + driver={stopData?.stopId.split(':')[0]} onCirculationClick={(estimate, idx) => { setSelectedCirculationId(getCirculationId(estimate)); setIsMapModalOpen(true); |
