aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/app/components
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2025-11-14 18:24:43 +0100
committerAriel Costas Guerrero <ariel@costas.dev>2025-11-14 18:24:53 +0100
commitd285093900ff6f8e3d5dba394999bb413f5d00f3 (patch)
tree88b9127d031177b8e3787d4e263ae7cb7d9a461f /src/frontend/app/components
parent80f6263516e307bcc6d887f6f91757bc73ae63f2 (diff)
Enhance stop map functionality with new styles and components for better user experience
Diffstat (limited to 'src/frontend/app/components')
-rw-r--r--src/frontend/app/components/StopMapSheet.css17
-rw-r--r--src/frontend/app/components/StopMapSheet.tsx218
-rw-r--r--src/frontend/app/components/Stops/ConsolidatedCirculationList.css14
-rw-r--r--src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx45
4 files changed, 276 insertions, 18 deletions
diff --git a/src/frontend/app/components/StopMapSheet.css b/src/frontend/app/components/StopMapSheet.css
new file mode 100644
index 0000000..8ad784d
--- /dev/null
+++ b/src/frontend/app/components/StopMapSheet.css
@@ -0,0 +1,17 @@
+/* Stop map container */
+.stop-map-container {
+ width: 100%;
+ height: 300px;
+ border-radius: 8px;
+ overflow: hidden;
+ border: 1px solid var(--border-color);
+ margin-block-start: 0;
+ margin-block-end: 1rem;
+ flex-shrink: 0;
+}
+
+@media (max-width: 640px) {
+ .stop-map-container {
+ height: 250px;
+ }
+}
diff --git a/src/frontend/app/components/StopMapSheet.tsx b/src/frontend/app/components/StopMapSheet.tsx
new file mode 100644
index 0000000..a0d30f4
--- /dev/null
+++ b/src/frontend/app/components/StopMapSheet.tsx
@@ -0,0 +1,218 @@
+import maplibregl from "maplibre-gl";
+import React, { useEffect, useMemo, useRef, useState } from "react";
+import Map, { Marker, NavigationControl, type MapRef } from "react-map-gl/maplibre";
+import { useApp } from "~/AppContext";
+import { getLineColor } from "~/data/LineColors";
+import type { RegionId } from "~/data/RegionConfig";
+import type { Stop } from "~/data/StopDataProvider";
+import { loadStyle } from "~/maps/styleloader";
+import "./StopMapSheet.css";
+
+export interface Position {
+ latitude: number;
+ longitude: number;
+ orientationDegrees: number;
+}
+
+export interface ConsolidatedCirculationForMap {
+ line: string;
+ route: string;
+ currentPosition?: Position;
+}
+
+interface StopMapProps {
+ stop: Stop;
+ circulations: ConsolidatedCirculationForMap[];
+ region: RegionId;
+}
+
+export const StopMap: React.FC<StopMapProps> = ({
+ stop,
+ circulations,
+ region,
+}) => {
+ const { theme } = useApp();
+ const [styleSpec, setStyleSpec] = useState<any | null>(null);
+ const mapRef = useRef<MapRef | null>(null);
+ const hasFitBounds = useRef(false);
+
+ useEffect(() => {
+ let mounted = true;
+ loadStyle("openfreemap", theme)
+ .then((style) => {
+ if (mounted) setStyleSpec(style);
+ })
+ .catch((err) => console.error("Failed to load map style", err));
+ return () => {
+ mounted = false;
+ };
+ }, [theme]);
+
+ const center = useMemo(() => {
+ if (stop.latitude && stop.longitude) {
+ return { latitude: stop.latitude, longitude: stop.longitude };
+ }
+ // fallback to first available bus position
+ const pos = circulations.find((c) => c.currentPosition)?.currentPosition;
+ return pos
+ ? { latitude: pos.latitude, longitude: pos.longitude }
+ : { latitude: 42.2406, longitude: -8.7207 }; // Vigo approx fallback
+ }, [stop.latitude, stop.longitude, circulations]);
+
+ const busPositions = useMemo(
+ () => circulations.filter((c) => !!c.currentPosition),
+ [circulations],
+ );
+
+ // Fit bounds to stop + buses, with ~1km padding each side, with a modest animation
+ // Only fit bounds on the first load, not on subsequent updates
+ useEffect(() => {
+ if (!styleSpec || !mapRef.current || hasFitBounds.current) return;
+
+ const points: { lat: number; lon: number }[] = [];
+ if (stop.latitude && stop.longitude) {
+ points.push({ lat: stop.latitude, lon: stop.longitude });
+ }
+ for (const c of busPositions) {
+ if (c.currentPosition) {
+ points.push({
+ lat: c.currentPosition.latitude,
+ lon: c.currentPosition.longitude,
+ });
+ }
+ }
+ if (points.length === 0) return;
+
+ let minLat = points[0].lat,
+ maxLat = points[0].lat,
+ minLon = points[0].lon,
+ maxLon = points[0].lon;
+ for (const p of points) {
+ if (p.lat < minLat) minLat = p.lat;
+ if (p.lat > maxLat) maxLat = p.lat;
+ if (p.lon < minLon) minLon = p.lon;
+ if (p.lon > maxLon) maxLon = p.lon;
+ }
+
+ // ~1km in degrees
+ const kmToDegLat = 1.0 / 111.32; // ≈0.008983
+ const centerLat = (minLat + maxLat) / 2;
+ const kmToDegLon = kmToDegLat / Math.max(Math.cos((centerLat * Math.PI) / 180), 0.1);
+ const padLat = kmToDegLat;
+ const padLon = kmToDegLon;
+
+ const sw = [minLon - padLon, minLat - padLat] as [number, number];
+ const ne = [maxLon + padLon, maxLat + padLat] as [number, number];
+ const bounds = new maplibregl.LngLatBounds(sw, ne);
+
+ try {
+ mapRef.current.fitBounds(bounds, {
+ padding: 32,
+ duration: 700,
+ maxZoom: 17,
+ } as any);
+ hasFitBounds.current = true;
+ } catch {}
+ }, [styleSpec, stop.latitude, stop.longitude, busPositions]);
+
+ return (
+ <div className="stop-map-container">
+ {styleSpec && (
+ <Map
+ mapLib={maplibregl as any}
+ initialViewState={{
+ latitude: center.latitude,
+ longitude: center.longitude,
+ zoom: 16,
+ }}
+ style={{ width: "100%", height: "100%" }}
+ mapStyle={styleSpec}
+ attributionControl={false}
+ ref={mapRef}
+ >
+ <NavigationControl position="top-left" />
+
+ {/* Stop marker (center) */}
+ {stop.latitude && stop.longitude && (
+ <Marker
+ longitude={stop.longitude}
+ latitude={stop.latitude}
+ anchor="bottom"
+ >
+ <div
+ style={{
+ width: 14,
+ height: 14,
+ background: "#1976d2",
+ border: "2px solid white",
+ borderRadius: "50%",
+ boxShadow: "0 0 0 2px rgba(0,0,0,0.2)",
+ }}
+ title={`Stop ${stop.stopId}`}
+ />
+ </Marker>
+ )}
+
+ {/* Bus markers with heading */}
+ {busPositions.map((c, idx) => {
+ const p = c.currentPosition!;
+ const lineColor = getLineColor(region, c.line);
+ return (
+ <Marker
+ key={idx}
+ longitude={p.longitude}
+ latitude={p.latitude}
+ anchor="center"
+ >
+ <div
+ title={`${c.line} → ${c.route}`}
+ style={{
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ gap: 2,
+ transform: `rotate(${p.orientationDegrees}deg)`,
+ transformOrigin: "center center",
+ }}
+ >
+ {/* Line number above */}
+ <div
+ style={{
+ background: lineColor.background,
+ color: lineColor.text,
+ padding: "2px 4px",
+ borderRadius: 4,
+ fontSize: 10,
+ fontWeight: 700,
+ lineHeight: 1,
+ border: "1px solid #fff",
+ boxShadow: "0 1px 2px rgba(0,0,0,0.3)",
+ }}
+ >
+ {c.line}
+ </div>
+ {/* Arrow pointing direction */}
+ <svg
+ width="20"
+ height="20"
+ viewBox="0 0 24 24"
+ style={{
+ filter: "drop-shadow(0 1px 2px rgba(0,0,0,0.3))",
+ }}
+ >
+ <path
+ d="M12 2 L20 22 L12 18 L4 22 Z"
+ fill={lineColor.background}
+ stroke="#fff"
+ strokeWidth="1.5"
+ />
+ </svg>
+ </div>
+ </Marker>
+ );
+ })}
+ </Map>
+ )}
+ </div>
+ );
+};
diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationList.css b/src/frontend/app/components/Stops/ConsolidatedCirculationList.css
index 65e897b..3705ec3 100644
--- a/src/frontend/app/components/Stops/ConsolidatedCirculationList.css
+++ b/src/frontend/app/components/Stops/ConsolidatedCirculationList.css
@@ -104,12 +104,22 @@
color: #09106e;
}
+/* Scheduled-only: dark blue in light mode, softer blue in dark mode */
.consolidated-circulation-card .arrival-time.time-scheduled {
- color: var(--text-color);
+ color: #0b3d91; /* dark blue */
}
.consolidated-circulation-card .arrival-time.time-scheduled svg {
- color: var(--subtitle-color);
+ color: #0b3d91;
+}
+
+@media (prefers-color-scheme: dark) {
+ .consolidated-circulation-card .arrival-time.time-scheduled {
+ color: #8fb4ff; /* lighten for dark backgrounds */
+ }
+ .consolidated-circulation-card .arrival-time.time-scheduled svg {
+ color: #8fb4ff;
+ }
}
.consolidated-circulation-card .distance-info {
diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx b/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx
index 1ba460b..37f6a47 100644
--- a/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx
+++ b/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx
@@ -1,8 +1,8 @@
-import { useTranslation } from "react-i18next";
import { Clock } from "lucide-react";
-import { type ConsolidatedCirculation } from "~routes/stops-$id";
+import { useTranslation } from "react-i18next";
import LineIcon from "~components/LineIcon";
import { type RegionConfig } from "~data/RegionConfig";
+import { type ConsolidatedCirculation } from "~routes/stops-$id";
import "./ConsolidatedCirculationList.css";
@@ -104,15 +104,11 @@ export const ConsolidatedCirculationList: React.FC<RegularTableProps> = ({
const delay = estimate.realTime.minutes - estimate.schedule.minutes;
if (delay >= -1 && delay <= 2) {
- return t("estimates.on_time", "on time");
+ return "OK"
} else if (delay > 2) {
- return t("estimates.minutes_late", "{{minutes}} minutes late", {
- minutes: delay,
- });
+ return "R" + delay;
} else {
- return t("estimates.minutes_early", "{{minutes}} minutes early", {
- minutes: Math.abs(delay),
- });
+ return "A" + Math.abs(delay);
}
};
@@ -179,15 +175,32 @@ export const ConsolidatedCirculationList: React.FC<RegularTableProps> = ({
? `${displayMinutes} ${t("estimates.minutes", "min")}`
: absoluteArrivalTime(displayMinutes)}
</div>
- {estimate.realTime && estimate.realTime.distance >= 0 && (
- <div className="distance-info">
- {formatDistance(estimate.realTime.distance)}
- </div>
- )}
+ <div className="distance-info">
+ {estimate.schedule && (
+ <>
+ {parseServiceId(estimate.schedule.serviceId)} v{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>
</div>
</div>
- <div className="card-footer">
+ {/*<div className="card-footer">
<span className="status-text">
{delayText && (
<>
@@ -213,7 +226,7 @@ export const ConsolidatedCirculationList: React.FC<RegularTableProps> = ({
</>
)}
</span>
- </div>
+ </div>*/}
</div>
);
})}