aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/app
diff options
context:
space:
mode:
Diffstat (limited to 'src/frontend/app')
-rw-r--r--src/frontend/app/components/LineIcon.tsx2
-rw-r--r--src/frontend/app/components/StopMapSheet.css62
-rw-r--r--src/frontend/app/components/StopMapSheet.tsx31
-rw-r--r--src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx141
-rw-r--r--src/frontend/app/components/Stops/ConsolidatedCirculationList.css153
5 files changed, 246 insertions, 143 deletions
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<LineIconProps> = ({
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<StopMapProps> = ({
const geoWatchId = useRef<number | null>(null);
const [zoom, setZoom] = useState<number>(16);
const [moveTick, setMoveTick] = useState<number>(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<StopMapProps> = ({
setMoveTick((t) => (t + 1) % 1000000);
}}
>
- {/* Compact attribution (closed by default) */}
- <AttributionControl position="bottom-left" compact />
{/* Stop marker (center) */}
{stop.latitude && stop.longitude && (
@@ -490,6 +485,28 @@ export const StopMap: React.FC<StopMapProps> = ({
</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/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 delayChip = useMemo(() => {
+ if (!estimate.schedule || !estimate.realTime) {
+ return null;
+ }
- const displayMinutes =
- estimate.realTime?.minutes ?? estimate.schedule?.minutes ?? 0;
- const timeClass = getTimeClass(estimate);
- const delayText = getDelayText(estimate);
+ 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 (
<div className="consolidated-circulation-card">
- <div className="card-header">
+ <div className="card-row main">
<div className="line-info">
- <LineIcon line={estimate.line} region={regionConfig.id} />
+ <LineIcon line={estimate.line} region={regionConfig.id} rounded />
</div>
-
<div className="route-info">
<strong>{estimate.route}</strong>
</div>
-
- <div className="time-info">
- <div className={`arrival-time ${timeClass}`}>
- <Clock />
- {estimate.realTime
- ? `${displayMinutes} ${t("estimates.minutes", "min")}`
- : absoluteArrivalTime(displayMinutes)}
- </div>
- <div className="distance-info">
- {estimate.schedule && (
- <>
- {parseServiceId(estimate.schedule.serviceId)} (
- {getTripIdDisplay(estimate.schedule.tripId)})
- </>
- )}
-
- {estimate.schedule &&
- estimate.realTime &&
- estimate.realTime.distance >= 0 && <> &middot; </>}
-
- {estimate.realTime && estimate.realTime.distance >= 0 && (
- <>{formatDistance(estimate.realTime.distance)}</>
- )}
-
- {estimate.schedule &&
- estimate.realTime &&
- estimate.realTime.distance >= 0 && <> &middot; </>}
-
- {delayText}
+ <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>
+ )}
</div>
);
};
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;
}
}