aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2025-11-19 23:54:49 +0100
committerAriel Costas Guerrero <ariel@costas.dev>2025-11-19 23:54:49 +0100
commitf030f1806255c66b86689489d24f8f5ad9b832ce (patch)
treea776e6a6670b50bb43609633cdbd1fe9857b8065
parent3ebb062e99dbd8a63d5642d67ba4be753e61a34d (diff)
feat: Implement StopMapModal component for displaying bus stop locations with live tracking; enhance styles and add interaction features
-rw-r--r--src/Costasdev.Busurbano.Backend/Controllers/VigoController.cs1
-rw-r--r--src/Costasdev.Busurbano.Backend/Types/ConsolidatedCirculation.cs1
-rw-r--r--src/frontend/app/components/StopMapModal.css58
-rw-r--r--src/frontend/app/components/StopMapModal.tsx344
-rw-r--r--src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx23
-rw-r--r--src/frontend/app/components/Stops/ConsolidatedCirculationList.css61
-rw-r--r--src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx3
-rw-r--r--src/frontend/app/maps/styleloader.ts26
-rw-r--r--src/frontend/app/routes/stops-$id.css24
-rw-r--r--src/frontend/app/routes/stops-$id.tsx35
10 files changed, 559 insertions, 17 deletions
diff --git a/src/Costasdev.Busurbano.Backend/Controllers/VigoController.cs b/src/Costasdev.Busurbano.Backend/Controllers/VigoController.cs
index cd75f90..0591230 100644
--- a/src/Costasdev.Busurbano.Backend/Controllers/VigoController.cs
+++ b/src/Costasdev.Busurbano.Backend/Controllers/VigoController.cs
@@ -272,6 +272,7 @@ public class VigoController : ControllerBase
{
Line = estimate.Line,
Route = estimate.Route,
+ NextStreets = [.. closestCirculation.NextStreets],
Schedule = new ScheduleData
{
Running = isRunning,
diff --git a/src/Costasdev.Busurbano.Backend/Types/ConsolidatedCirculation.cs b/src/Costasdev.Busurbano.Backend/Types/ConsolidatedCirculation.cs
index 3806241..a21aa60 100644
--- a/src/Costasdev.Busurbano.Backend/Types/ConsolidatedCirculation.cs
+++ b/src/Costasdev.Busurbano.Backend/Types/ConsolidatedCirculation.cs
@@ -8,6 +8,7 @@ public class ConsolidatedCirculation
public ScheduleData? Schedule { get; set; }
public RealTimeData? RealTime { get; set; }
public Position? CurrentPosition { get; set; }
+ public string[] NextStreets { get; set; } = [];
}
public class RealTimeData
diff --git a/src/frontend/app/components/StopMapModal.css b/src/frontend/app/components/StopMapModal.css
new file mode 100644
index 0000000..f024b38
--- /dev/null
+++ b/src/frontend/app/components/StopMapModal.css
@@ -0,0 +1,58 @@
+/* Stop map modal container */
+.stop-map-modal {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ background: var(--background-color);
+}
+
+.stop-map-modal__map-container {
+ width: 100%;
+ height: 100%;
+ position: relative;
+ flex-shrink: 0;
+}
+
+/* Map floating controls */
+.map-modal-controls {
+ position: absolute;
+ left: 8px;
+ top: 8px;
+ display: flex;
+ gap: 8px;
+ z-index: 2;
+}
+
+.center-btn {
+ appearance: none;
+ border: 1px solid rgba(0, 0, 0, 0.15);
+ background: color-mix(
+ in oklab,
+ var(--background-color, #fff) 85%,
+ transparent
+ );
+ color: var(--text-primary, #111);
+ padding: 6px;
+ border-radius: 6px;
+ font-size: 12px;
+ line-height: 1;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s ease;
+}
+
+.center-btn:hover {
+ background: color-mix(
+ in oklab,
+ var(--background-color, #fff) 75%,
+ transparent
+ );
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
+}
+
+.center-btn:active {
+ transform: scale(0.95);
+}
diff --git a/src/frontend/app/components/StopMapModal.tsx b/src/frontend/app/components/StopMapModal.tsx
new file mode 100644
index 0000000..1799f74
--- /dev/null
+++ b/src/frontend/app/components/StopMapModal.tsx
@@ -0,0 +1,344 @@
+import maplibregl from "maplibre-gl";
+import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import Map, {
+ Marker,
+ type MapRef
+} from "react-map-gl/maplibre";
+import { Sheet } from "react-modal-sheet";
+import { useApp } from "~/AppContext";
+import type { RegionId } from "~/config/RegionConfig";
+import { getLineColor } from "~/data/LineColors";
+import type { Stop } from "~/data/StopDataProvider";
+import { loadStyle } from "~/maps/styleloader";
+import "./StopMapModal.css";
+
+export interface Position {
+ latitude: number;
+ longitude: number;
+ orientationDegrees: number;
+}
+
+export interface ConsolidatedCirculationForMap {
+ line: string;
+ route: string;
+ currentPosition?: Position;
+}
+
+interface StopMapModalProps {
+ stop: Stop;
+ circulations: ConsolidatedCirculationForMap[];
+ region: RegionId;
+ isOpen: boolean;
+ onClose: () => void;
+ selectedCirculationIndex?: number;
+}
+
+export const StopMapModal: React.FC<StopMapModalProps> = ({
+ stop,
+ circulations,
+ region,
+ isOpen,
+ onClose,
+ selectedCirculationIndex,
+}) => {
+ const { theme } = useApp();
+ const [styleSpec, setStyleSpec] = useState<any | null>(null);
+ const mapRef = useRef<MapRef | null>(null);
+ const hasFitBounds = useRef(false);
+
+ // Filter circulations that have GPS coordinates
+ const busesWithPosition = useMemo(
+ () => circulations.filter((c) => !!c.currentPosition),
+ [circulations]
+ );
+
+ // Use selectedCirculationIndex if provided, otherwise use first bus with position
+ const selectedBus = useMemo(() => {
+ if (selectedCirculationIndex !== undefined) {
+ const circulation = circulations[selectedCirculationIndex];
+ if (circulation?.currentPosition) {
+ return circulation;
+ }
+ }
+ // Fallback to first bus with position
+ return busesWithPosition.length > 0 ? busesWithPosition[0] : null;
+ }, [selectedCirculationIndex, circulations, busesWithPosition]);
+
+ const center = useMemo(() => {
+ if (selectedBus?.currentPosition) {
+ return {
+ latitude: selectedBus.currentPosition.latitude,
+ longitude: selectedBus.currentPosition.longitude,
+ };
+ }
+ if (stop.latitude && stop.longitude) {
+ return { latitude: stop.latitude, longitude: stop.longitude };
+ }
+ return { latitude: 42.2406, longitude: -8.7207 }; // Vigo approx fallback
+ }, [selectedBus, stop.latitude, stop.longitude]);
+
+ const handleCenter = useCallback(() => {
+ if (!mapRef.current) return;
+ const points: { lat: number; lon: number }[] = [];
+
+ if (stop.latitude && stop.longitude) {
+ points.push({ lat: stop.latitude, lon: stop.longitude });
+ }
+
+ if (selectedBus?.currentPosition) {
+ points.push({
+ lat: selectedBus.currentPosition.latitude,
+ lon: selectedBus.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;
+ }
+
+ const sw = [minLon, minLat] as [number, number];
+ const ne = [maxLon, maxLat] as [number, number];
+ const bounds = new maplibregl.LngLatBounds(sw, ne);
+
+ try {
+ if (points.length === 1) {
+ const only = points[0];
+ mapRef.current
+ .getMap()
+ .easeTo({ center: [only.lon, only.lat], zoom: 16, duration: 450 });
+ } else {
+ mapRef.current.fitBounds(bounds, {
+ padding: 24,
+ duration: 500,
+ maxZoom: 17,
+ } as any);
+ }
+ } catch {}
+ }, [stop, selectedBus]);
+
+ // Load style without traffic layers for the stop map
+ useEffect(() => {
+ let mounted = true;
+ loadStyle("openfreemap", theme, { includeTraffic: false })
+ .then((style) => {
+ if (mounted) setStyleSpec(style);
+ })
+ .catch((err) => console.error("Failed to load map style", err));
+ return () => {
+ mounted = false;
+ };
+ }, [theme]);
+
+ // Resize map and fit bounds when modal opens
+ useEffect(() => {
+ if (isOpen && mapRef.current) {
+ const timer = setTimeout(() => {
+ const map = mapRef.current?.getMap();
+ if (map) {
+ map.resize();
+ // Trigger fit bounds logic again
+ hasFitBounds.current = false;
+ handleCenter();
+ }
+ }, 300); // Wait for sheet animation
+ return () => clearTimeout(timer);
+ }
+ }, [isOpen, handleCenter]);
+
+ // Fit bounds on initial load
+ useEffect(() => {
+ if (!styleSpec || !mapRef.current || hasFitBounds.current || !isOpen)
+ return;
+
+ const map = mapRef.current.getMap();
+
+ // Handle missing sprite images to suppress console warnings
+ const handleStyleImageMissing = (e: any) => {
+ if (!map || map.hasImage(e.id)) return;
+ map.addImage(e.id, {
+ width: 1,
+ height: 1,
+ data: new Uint8Array(4),
+ });
+ };
+
+ map.on("styleimagemissing", handleStyleImageMissing);
+
+ handleCenter();
+ hasFitBounds.current = true;
+
+ return () => {
+ if (mapRef.current) {
+ const map = mapRef.current.getMap();
+ if (map) {
+ map.off("styleimagemissing", handleStyleImageMissing);
+ }
+ }
+ };
+ }, [styleSpec, stop, selectedBus, isOpen, handleCenter]);
+
+ // Reset bounds when modal opens/closes
+ useEffect(() => {
+ if (!isOpen) {
+ hasFitBounds.current = false;
+ }
+ }, [isOpen]);
+
+ if (busesWithPosition.length === 0) {
+ return null; // Don't render if no buses with GPS coordinates
+ }
+
+ return (
+ <Sheet
+ isOpen={isOpen}
+ onClose={onClose}
+ detent="content"
+ >
+ <Sheet.Container style={{ backgroundColor: "var(--background-color)" }}>
+ <Sheet.Header />
+ <Sheet.Content disableDrag={true}>
+ <div className="stop-map-modal">
+ {/* Map Container */}
+ <div className="stop-map-modal__map-container">
+ {styleSpec && (
+ <Map
+ mapLib={maplibregl as any}
+ initialViewState={{
+ latitude: center.latitude,
+ longitude: center.longitude,
+ zoom: 16,
+ }}
+ style={{ width: "100%", height: "320px" }}
+ mapStyle={styleSpec}
+ attributionControl={false}
+ ref={mapRef}
+ interactive={true}
+ >
+ {/* Stop marker */}
+ {stop.latitude && stop.longitude && (
+ <Marker
+ longitude={stop.longitude}
+ latitude={stop.latitude}
+ anchor="bottom"
+ >
+ <div title={`Stop ${stop.stopId}`}>
+ <svg width="28" height="36" viewBox="0 0 28 36">
+ <defs>
+ <filter
+ id="drop-stop"
+ x="-20%"
+ y="-20%"
+ width="140%"
+ height="140%"
+ >
+ <feDropShadow
+ dx="0"
+ dy="1"
+ stdDeviation="1"
+ floodOpacity={0.35}
+ />
+ </filter>
+ </defs>
+ <path
+ d="M14 0C6.82 0 1 5.82 1 13c0 8.5 11 23 13 23s13-14.5 13-23C27 5.82 21.18 0 14 0z"
+ fill="#1976d2"
+ stroke="#fff"
+ strokeWidth="2"
+ filter="url(#drop-stop)"
+ />
+ <circle cx="14" cy="13" r="5" fill="#fff" />
+ <circle cx="14" cy="13" r="3" fill="#1976d2" />
+ </svg>
+ </div>
+ </Marker>
+ )}
+
+ {/* Selected bus marker */}
+ {selectedBus?.currentPosition && (
+ <Marker
+ longitude={selectedBus.currentPosition.longitude}
+ latitude={selectedBus.currentPosition.latitude}
+ anchor="center"
+ >
+ <div
+ style={{
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ gap: 6,
+ transform: `rotate(${selectedBus.currentPosition.orientationDegrees}deg)`,
+ transformOrigin: "center center",
+ }}
+ >
+ <svg
+ width="24"
+ height="24"
+ viewBox="0 0 24 24"
+ style={{
+ filter: "drop-shadow(0 2px 4px rgba(0,0,0,0.3))",
+ }}
+ >
+ <path
+ d="M12 2 L22 22 L12 17 L2 22 Z"
+ fill={getLineColor(region, selectedBus.line).background}
+ stroke="#fff"
+ strokeWidth="2"
+ strokeLinejoin="round"
+ />
+ </svg>
+ </div>
+ </Marker>
+ )}
+ </Map>
+ )}
+
+ {/* Floating controls */}
+ <div className="map-modal-controls">
+ <button
+ type="button"
+ aria-label="Center"
+ className="center-btn"
+ onClick={handleCenter}
+ title="Center view"
+ >
+ <svg
+ width="20"
+ height="20"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <circle cx="12" cy="12" r="3" fill="currentColor" />
+ <path
+ d="M12 2v3M12 19v3M2 12h3M19 12h3"
+ stroke="currentColor"
+ strokeWidth="2"
+ strokeLinecap="round"
+ />
+ <circle
+ cx="12"
+ cy="12"
+ r="8"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="1.5"
+ />
+ </svg>
+ </button>
+ </div>
+ </div>
+ </div>
+ </Sheet.Content>
+ </Sheet.Container>
+ <Sheet.Backdrop onClick={onClose} />
+ </Sheet>
+ );
+};
diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx b/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx
index f725b8c..47fa56b 100644
--- a/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx
+++ b/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx
@@ -9,6 +9,7 @@ import "./ConsolidatedCirculationList.css";
interface ConsolidatedCirculationCardProps {
estimate: ConsolidatedCirculation;
regionConfig: RegionConfig;
+ onMapClick?: () => void;
}
// Utility function to parse service ID and get the turn number
@@ -70,7 +71,7 @@ const parseServiceId = (serviceId: string): string => {
export const ConsolidatedCirculationCard: React.FC<
ConsolidatedCirculationCardProps
-> = ({ estimate, regionConfig }) => {
+> = ({ estimate, regionConfig, onMapClick }) => {
const { t } = useTranslation();
const absoluteArrivalTime = (minutes: number) => {
@@ -168,8 +169,19 @@ export const ConsolidatedCirculationCard: React.FC<
return chips;
}, [delayChip, estimate.schedule, estimate.realTime]);
+ // Check if bus has GPS position (live tracking)
+ const hasGpsPosition = !!estimate.currentPosition;
+
return (
- <div className="consolidated-circulation-card">
+ <button
+ className={`consolidated-circulation-card ${
+ hasGpsPosition ? "has-gps" : "no-gps"
+ }`}
+ onClick={onMapClick}
+ type="button"
+ disabled={!hasGpsPosition}
+ aria-label={`${hasGpsPosition ? "View" : "No GPS data for"} ${estimate.line} to ${estimate.route}${hasGpsPosition ? " on map" : ""}`}
+ >
<div className="card-row main">
<div className="line-info">
<LineIcon line={estimate.line} region={regionConfig.id} rounded />
@@ -183,6 +195,11 @@ export const ConsolidatedCirculationCard: React.FC<
<span className="eta-unit">{etaUnit}</span>
</div>
</div>
+ {hasGpsPosition && (
+ <div className="gps-indicator" title="Live GPS tracking">
+ <span className="gps-pulse" />
+ </div>
+ )}
</div>
{metaChips.length > 0 && (
<div className="card-row meta">
@@ -196,6 +213,6 @@ export const ConsolidatedCirculationCard: React.FC<
))}
</div>
)}
- </div>
+ </button>
);
};
diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationList.css b/src/frontend/app/components/Stops/ConsolidatedCirculationList.css
index 7e757fb..680a511 100644
--- a/src/frontend/app/components/Stops/ConsolidatedCirculationList.css
+++ b/src/frontend/app/components/Stops/ConsolidatedCirculationList.css
@@ -13,6 +13,7 @@
}
.consolidated-circulation-card {
+ all: unset;
flex: 0 0 auto;
display: flex;
flex-direction: column;
@@ -21,11 +22,34 @@
border-radius: 12px;
border: 1px solid var(--border-color);
padding: 0.65rem 0.85rem;
- transition: box-shadow 0.2s ease;
+ transition: all 0.2s ease;
}
-.consolidated-circulation-card:hover {
+.consolidated-circulation-card.has-gps {
+ cursor: pointer;
+}
+
+.consolidated-circulation-card.no-gps {
+ cursor: not-allowed;
+ opacity: 0.7;
+}
+
+.consolidated-circulation-card.has-gps:hover {
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08);
+ border-color: var(--button-background-color);
+ background-color: color-mix(
+ in oklab,
+ var(--button-background-color) 5%,
+ var(--message-background-color)
+ );
+}
+
+.consolidated-circulation-card.has-gps:active {
+ transform: scale(0.98);
+}
+
+.consolidated-circulation-card:disabled {
+ pointer-events: none;
}
@@ -149,6 +173,39 @@
color: #1d4ed8;
}
+/* GPS Indicator */
+.gps-indicator {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 20px;
+ height: 20px;
+ flex-shrink: 0;
+ position: relative;
+}
+
+.gps-pulse {
+ position: absolute;
+ width: 8px;
+ height: 8px;
+ background: #22c55e;
+ border-radius: 50%;
+ box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2);
+ animation: gpsPulse 2s ease-in-out infinite;
+}
+
+@keyframes gpsPulse {
+ 0% {
+ box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2);
+ }
+ 50% {
+ box-shadow: 0 0 0 6px rgba(34, 197, 94, 0.1);
+ }
+ 100% {
+ box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2);
+ }
+}
+
@media (max-width: 480px) {
.consolidated-circulation-card {
padding: 0.65rem 0.75rem;
diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx b/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx
index 047dfd4..4c2916a 100644
--- a/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx
+++ b/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx
@@ -9,12 +9,14 @@ interface RegularTableProps {
data: ConsolidatedCirculation[];
dataDate: Date | null;
regionConfig: RegionConfig;
+ onCirculationClick?: (estimate: ConsolidatedCirculation, index: number) => void;
}
export const ConsolidatedCirculationList: React.FC<RegularTableProps> = ({
data,
dataDate,
regionConfig,
+ onCirculationClick,
}) => {
const { t } = useTranslation();
@@ -43,6 +45,7 @@ export const ConsolidatedCirculationList: React.FC<RegularTableProps> = ({
key={idx}
estimate={estimate}
regionConfig={regionConfig}
+ onMapClick={() => onCirculationClick?.(estimate, idx)}
/>
))}
</>
diff --git a/src/frontend/app/maps/styleloader.ts b/src/frontend/app/maps/styleloader.ts
index 43118a0..8109e0b 100644
--- a/src/frontend/app/maps/styleloader.ts
+++ b/src/frontend/app/maps/styleloader.ts
@@ -1,10 +1,17 @@
import type { StyleSpecification } from "react-map-gl/maplibre";
import type { Theme } from "~/AppContext";
+export interface StyleLoaderOptions {
+ includeTraffic?: boolean;
+}
+
export async function loadStyle(
styleName: string,
- colorScheme: Theme
+ colorScheme: Theme,
+ options?: StyleLoaderOptions
): Promise<StyleSpecification> {
+ const { includeTraffic = true } = options || {};
+
if (colorScheme == "system") {
const isDarkMode = window.matchMedia(
"(prefers-color-scheme: dark)"
@@ -21,6 +28,15 @@ export async function loadStyle(
}
const style = await resp.json();
+
+ // Remove traffic layers if not requested
+ if (!includeTraffic) {
+ style.layers = (style.layers || []).filter(
+ (layer: any) => !layer.id?.startsWith("vigo_traffic")
+ );
+ delete style.sources?.vigo_traffic;
+ }
+
return style as StyleSpecification;
}
@@ -33,6 +49,14 @@ export async function loadStyle(
const style = await resp.json();
+ // Remove traffic layers if not requested
+ if (!includeTraffic) {
+ style.layers = (style.layers || []).filter(
+ (layer: any) => !layer.id?.startsWith("vigo_traffic")
+ );
+ delete style.sources?.vigo_traffic;
+ }
+
const baseUrl = window.location.origin;
const spritePath = style.sprite;
diff --git a/src/frontend/app/routes/stops-$id.css b/src/frontend/app/routes/stops-$id.css
index 9ecac16..782d9a1 100644
--- a/src/frontend/app/routes/stops-$id.css
+++ b/src/frontend/app/routes/stops-$id.css
@@ -63,6 +63,30 @@
flex-shrink: 0;
}
+.stops-header > div:first-child {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.star-icon,
+.edit-icon {
+ cursor: pointer;
+ transition: all 0.2s ease;
+ color: var(--text-secondary);
+}
+
+.star-icon:hover,
+.edit-icon:hover {
+ color: var(--text-primary);
+ transform: scale(1.1);
+}
+
+.star-icon.active {
+ color: #fbbf24;
+ fill: #fbbf24;
+}
+
.manual-refresh-button {
display: flex;
align-items: center;
diff --git a/src/frontend/app/routes/stops-$id.tsx b/src/frontend/app/routes/stops-$id.tsx
index f340009..a2b2da3 100644
--- a/src/frontend/app/routes/stops-$id.tsx
+++ b/src/frontend/app/routes/stops-$id.tsx
@@ -5,7 +5,7 @@ import { useParams } from "react-router";
import { ErrorDisplay } from "~/components/ErrorDisplay";
import LineIcon from "~/components/LineIcon";
import { StopAlert } from "~/components/StopAlert";
-import { StopMap } from "~/components/StopMapSheet";
+import { StopMapModal } from "~/components/StopMapModal";
import { ConsolidatedCirculationList } from "~/components/Stops/ConsolidatedCirculationList";
import { ConsolidatedCirculationListSkeleton } from "~/components/Stops/ConsolidatedCirculationListSkeleton";
import { type RegionId, getRegionConfig } from "~/config/RegionConfig";
@@ -77,6 +77,10 @@ export default function Estimates() {
const [favourited, setFavourited] = useState(false);
const [isManualRefreshing, setIsManualRefreshing] = useState(false);
+ const [isMapModalOpen, setIsMapModalOpen] = useState(false);
+ const [selectedCirculationIdx, setSelectedCirculationIdx] = useState<
+ number | undefined
+ >(undefined);
const { region } = useApp();
const regionConfig = getRegionConfig(region);
@@ -195,15 +199,16 @@ export default function Estimates() {
<div className="page-container stops-page">
<div className="stops-header">
<div>
- <Star
- className={`star-icon ${favourited ? "active" : ""}`}
- onClick={toggleFavourite}
- width={20}
- />
- <Edit2
- className="edit-icon"
- onClick={handleRename}
- width={20} />
+ <Star
+ className={`star-icon ${favourited ? "active" : ""}`}
+ onClick={toggleFavourite}
+ width={20}
+ />
+ <Edit2
+ className="edit-icon"
+ onClick={handleRename}
+ width={20}
+ />
</div>
<button
@@ -247,12 +252,17 @@ export default function Estimates() {
data={data}
dataDate={dataDate}
regionConfig={regionConfig}
+ onCirculationClick={(estimate, idx) => {
+ setSelectedCirculationIdx(idx);
+ setIsMapModalOpen(true);
+ }}
/>
) : null}
</div>
+ {/* Map Modal - only render if we have stop data */}
{stopData && (
- <StopMap
+ <StopMapModal
stop={stopData}
region={region}
circulations={(data ?? []).map((c) => ({
@@ -260,6 +270,9 @@ export default function Estimates() {
route: c.route,
currentPosition: c.currentPosition,
}))}
+ isOpen={isMapModalOpen}
+ onClose={() => setIsMapModalOpen(false)}
+ selectedCirculationIndex={selectedCirculationIdx}
/>
)}
</div>