From 3ebb062e99dbd8a63d5642d67ba4be753e61a34d Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Wed, 19 Nov 2025 22:58:40 +0100 Subject: feat: Enhance map attribution feature; improve styles and add toggle functionality in StopMapSheet component --- src/frontend/app/components/LineIcon.tsx | 2 +- src/frontend/app/components/StopMapSheet.css | 62 +++++++-- src/frontend/app/components/StopMapSheet.tsx | 31 ++++- .../Stops/ConsolidatedCirculationCard.tsx | 141 +++++++++++-------- .../Stops/ConsolidatedCirculationList.css | 153 ++++++++++++--------- 5 files changed, 246 insertions(+), 143 deletions(-) (limited to 'src/frontend/app/components') diff --git a/src/frontend/app/components/LineIcon.tsx b/src/frontend/app/components/LineIcon.tsx index 3ad9293..8c3dbeb 100644 --- a/src/frontend/app/components/LineIcon.tsx +++ b/src/frontend/app/components/LineIcon.tsx @@ -25,7 +25,7 @@ const LineIcon: React.FC = ({ style={ { "--line-colour": `var(${cssVarName})`, - "--line-text-colour": `var(${cssTextVarName}, unset)`, + "--line-text-colour": `var(${cssTextVarName}, #000000)`, } as React.CSSProperties } > diff --git a/src/frontend/app/components/StopMapSheet.css b/src/frontend/app/components/StopMapSheet.css index 2b28a13..6b2e8ed 100644 --- a/src/frontend/app/components/StopMapSheet.css +++ b/src/frontend/app/components/StopMapSheet.css @@ -76,15 +76,61 @@ animation: userPulse 1.8s ease-out infinite; } -.stop-map-container small { +/* Map attribution */ +.map-attribution { position: absolute; - bottom: 4px; - left: 4px; - font-size: 12px; - color: var(--text-secondary); - background-color: rgba(255, 255, 255, 0.7); - padding: 2px 4px; - border-radius: 4px; + 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 { diff --git a/src/frontend/app/components/StopMapSheet.tsx b/src/frontend/app/components/StopMapSheet.tsx index e1f0bf7..71a1095 100644 --- a/src/frontend/app/components/StopMapSheet.tsx +++ b/src/frontend/app/components/StopMapSheet.tsx @@ -1,10 +1,6 @@ import maplibregl from "maplibre-gl"; import React, { useEffect, useMemo, useRef, useState } from "react"; -import Map, { - AttributionControl, - Marker, - type MapRef, -} from "react-map-gl/maplibre"; +import Map, { Marker, type MapRef } from "react-map-gl/maplibre"; import { useApp } from "~/AppContext"; import type { RegionId } from "~/config/RegionConfig"; import { getLineColor } from "~/data/LineColors"; @@ -47,6 +43,7 @@ export const StopMap: React.FC = ({ const geoWatchId = useRef(null); const [zoom, setZoom] = useState(16); const [moveTick, setMoveTick] = useState(0); + const [showAttribution, setShowAttribution] = useState(false); type Pt = { lat: number; lon: number }; const haversineKm = (a: Pt, b: Pt) => { @@ -311,8 +308,6 @@ export const StopMap: React.FC = ({ setMoveTick((t) => (t + 1) % 1000000); }} > - {/* Compact attribution (closed by default) */} - {/* Stop marker (center) */} {stop.latitude && stop.longitude && ( @@ -490,6 +485,28 @@ export const StopMap: React.FC = ({ + +
+ +
+ OpenFreeMap © OpenMapTiles data from + + OpenStreetMap + +
+
); }; diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx b/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx index 9733d89..f725b8c 100644 --- a/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx +++ b/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx @@ -1,4 +1,4 @@ -import { Clock } from "lucide-react"; +import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { type RegionConfig } from "~/config/RegionConfig"; import LineIcon from "~components/LineIcon"; @@ -88,25 +88,8 @@ export const ConsolidatedCirculationCard: React.FC< const formatDistance = (meters: number) => { if (meters > 1024) { return `${(meters / 1000).toFixed(1)} km`; - } else { - return `${meters} ${t("estimates.meters", "m")}`; - } - }; - - const getDelayText = (estimate: ConsolidatedCirculation): string | null => { - if (!estimate.schedule || !estimate.realTime) { - return null; - } - - const delay = estimate.realTime.minutes - estimate.schedule.minutes; - - if (delay >= -1 && delay <= 2) { - return "OK"; - } else if (delay > 2) { - return "R" + delay; - } else { - return "A" + Math.abs(delay); } + return `${meters} ${t("estimates.meters", "m")}`; }; const getTripIdDisplay = (tripId: string): string => { @@ -114,67 +97,105 @@ export const ConsolidatedCirculationCard: React.FC< return parts.length > 1 ? parts[1] : tripId; }; - const getTimeClass = (estimate: ConsolidatedCirculation): string => { + const etaMinutes = + estimate.realTime?.minutes ?? estimate.schedule?.minutes ?? null; + const etaValue = + etaMinutes === null ? "--" : Math.max(0, Math.round(etaMinutes)).toString(); + const 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"; - } else if (estimate.realTime && !estimate.schedule?.running) { + } + if (estimate.realTime && !estimate.schedule?.running) { return "time-delayed"; } - return "time-scheduled"; - }; + }, [estimate.realTime, estimate.schedule]); - const displayMinutes = - estimate.realTime?.minutes ?? estimate.schedule?.minutes ?? 0; - const timeClass = getTimeClass(estimate); - const delayText = getDelayText(estimate); + 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); + + if (delta === 0) { + return { + label: t("estimates.delay_on_time", "En hora (0 min)"), + tone: "delay-ok", + } as const; + } + + if (delta > 0) { + const tone = delta <= 2 ? "delay-ok" : delta <= 10 ? "delay-warn" : "delay-critical"; + return { + label: t("estimates.delay_positive", "Retraso de {{minutes}} min", { + minutes: delta, + }), + tone, + } as const; + } + + const tone = absDelta <= 2 ? "delay-ok" : "delay-early"; + return { + label: t("estimates.delay_negative", "Adelanto de {{minutes}} min", { + minutes: absDelta, + }), + tone, + } as const; + }, [estimate.schedule, estimate.realTime, t]); + + const metaChips = useMemo(() => { + const chips: Array<{ label: string; tone?: string }> = []; + if (delayChip) { + chips.push(delayChip); + } + if (estimate.schedule) { + chips.push({ + label: `${parseServiceId(estimate.schedule.serviceId)} · ${getTripIdDisplay( + estimate.schedule.tripId + )}`, + }); + } + if (estimate.realTime && estimate.realTime.distance >= 0) { + chips.push({ label: formatDistance(estimate.realTime.distance) }); + } + return chips; + }, [delayChip, estimate.schedule, estimate.realTime]); return (
-
+
- +
-
{estimate.route}
- -
-
- - {estimate.realTime - ? `${displayMinutes} ${t("estimates.minutes", "min")}` - : absoluteArrivalTime(displayMinutes)} -
-
- {estimate.schedule && ( - <> - {parseServiceId(estimate.schedule.serviceId)} ( - {getTripIdDisplay(estimate.schedule.tripId)}) - - )} - - {estimate.schedule && - estimate.realTime && - estimate.realTime.distance >= 0 && <> · } - - {estimate.realTime && estimate.realTime.distance >= 0 && ( - <>{formatDistance(estimate.realTime.distance)} - )} - - {estimate.schedule && - estimate.realTime && - estimate.realTime.distance >= 0 && <> · } - - {delayText} +
+
+ {etaValue} + {etaUnit}
+ {metaChips.length > 0 && ( +
+ {metaChips.map((chip, idx) => ( + + {chip.label} + + ))} +
+ )}
); }; diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationList.css b/src/frontend/app/components/Stops/ConsolidatedCirculationList.css index b79fc73..7e757fb 100644 --- a/src/frontend/app/components/Stops/ConsolidatedCirculationList.css +++ b/src/frontend/app/components/Stops/ConsolidatedCirculationList.css @@ -14,25 +14,29 @@ .consolidated-circulation-card { flex: 0 0 auto; - display: flex; flex-direction: column; + gap: 0.5rem; background-color: var(--message-background-color); - border-radius: 8px; + border-radius: 12px; border: 1px solid var(--border-color); - overflow: hidden; + padding: 0.65rem 0.85rem; transition: box-shadow 0.2s ease; } .consolidated-circulation-card:hover { - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08); } -.consolidated-circulation-card .card-header { + +.consolidated-circulation-card .card-row { display: flex; align-items: center; - gap: 0.75rem; - padding: 0.875rem 1rem; + gap: 0.65rem; +} + +.consolidated-circulation-card .card-row.main { + min-height: 48px; } .consolidated-circulation-card .line-info { @@ -45,101 +49,116 @@ } .consolidated-circulation-card .route-info strong { - font-size: 0.95rem; + font-size: 1rem; color: var(--text-color); overflow: hidden; text-overflow: ellipsis; - white-space: nowrap; - display: block; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + line-height: 1.25; } -.consolidated-circulation-card .time-info { - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 0.25rem; - flex-shrink: 0; +.consolidated-circulation-card .eta-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.3rem 0.45rem; + border-radius: 12px; } -.consolidated-circulation-card .arrival-time { +.consolidated-circulation-card .eta-text { display: flex; + flex-direction: column; align-items: center; - gap: 0.4rem; - font-size: 1.05rem; - font-weight: 600; + line-height: 1; } -.consolidated-circulation-card .arrival-time svg { - width: 18px; - height: 18px; - flex-shrink: 0; +.consolidated-circulation-card .eta-value { + font-size: 1.15rem; + font-weight: 700; } -/* Time color states */ -.consolidated-circulation-card .arrival-time.time-running, -.consolidated-circulation-card .arrival-time.time-running svg { - color: #22c55e; +.consolidated-circulation-card .eta-unit { + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.08em; } -.consolidated-circulation-card .arrival-time.time-delayed, -.consolidated-circulation-card .arrival-time.time-delayed svg { - color: #ff6a00; +.consolidated-circulation-card .eta-badge.time-running { + background: rgba(34, 197, 94, 0.12); + color: #1a9e56; } -.consolidated-circulation-card .arrival-time.time-scheduled, -.consolidated-circulation-card .arrival-time.time-scheduled svg { - color: #0b3d91; /* dark blue */ +.consolidated-circulation-card .eta-badge.time-delayed { + background: rgba(255, 106, 0, 0.12); + color: #d06100; } -[data-theme="dark"] .consolidated-circulation-card .arrival-time.time-scheduled, -[data-theme="dark"] - .consolidated-circulation-card - .arrival-time.time-scheduled - svg { - color: #8fb4ff; /* lighten for dark backgrounds */ +.consolidated-circulation-card .eta-badge.time-scheduled { + background: rgba(11, 61, 145, 0.12); + color: #0b3d91; } -.consolidated-circulation-card .distance-info { - font-size: 0.75rem; - color: var(--subtitle-color); - text-align: right; +[data-theme="dark"] .consolidated-circulation-card .eta-badge.time-scheduled { + color: #8fb4ff; +} + +.consolidated-circulation-card .card-row.meta { + flex-wrap: wrap; + justify-content: flex-start; + gap: 0.4rem; } -.consolidated-circulation-card .card-footer { - padding: 0.5rem 1rem 0.75rem 1rem; - border-top: 1px solid var(--border-color); - background-color: rgba(0, 0, 0, 0.02); +.meta-chip { + font-size: 0.75rem; + padding: 0.2rem 0.55rem; + border-radius: 999px; + background: rgba(0, 0, 0, 0.03); + color: var(--subtitle-color); + border: 1px solid var(--border-color); } @media (prefers-color-scheme: dark) { - .consolidated-circulation-card .card-footer { - background-color: rgba(255, 255, 255, 0.03); + .meta-chip { + background: rgba(255, 255, 255, 0.05); } } -.consolidated-circulation-card .status-text { - font-size: 0.8rem; - color: var(--subtitle-color); - line-height: 1.4; - display: block; +.meta-chip.delay-ok { + background: rgba(34, 197, 94, 0.15); + border-color: rgba(34, 197, 94, 0.3); + color: #1a9e56; } -/* Responsive adjustments */ -@media (max-width: 480px) { - .consolidated-circulation-card .card-header { - gap: 0.5rem; - padding: 0.5rem 0.65rem; - } +.meta-chip.delay-warn { + background: rgba(251, 191, 36, 0.25); + border-color: rgba(251, 191, 36, 0.5); + color: #fbbf24; +} + +.meta-chip.delay-critical { + background: rgba(239, 68, 68, 0.2); + border-color: rgba(239, 68, 68, 0.35); + color: #b91c1c; +} - .consolidated-circulation-card .arrival-time { - font-size: 1rem; +.meta-chip.delay-early { + background: rgba(59, 130, 246, 0.17); + border-color: rgba(59, 130, 246, 0.3); + color: #1d4ed8; +} + +@media (max-width: 480px) { + .consolidated-circulation-card { + padding: 0.65rem 0.75rem; } - .consolidated-circulation-card .card-footer { - padding: 0.5rem 0.875rem 0.625rem 0.875rem; + .consolidated-circulation-card .card-row { + gap: 0.5rem; } - .consolidated-circulation-card .status-text { - font-size: 0.9rem; + .consolidated-circulation-card .eta-badge { + padding: 0.25rem 0.4rem; } } -- cgit v1.3