diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx index d5169c9..8421d26 100644 --- a/frontend/src/__tests__/App.test.tsx +++ b/frontend/src/__tests__/App.test.tsx @@ -13,7 +13,7 @@ vi.mock("react-leaflet", () => ({ ), TileLayer: () =>
, Polyline: () =>
, - useMap: () => ({}), + useMap: () => ({ on: vi.fn(), off: vi.fn() }), })); // Also mock the sub-components that depend on Leaflet diff --git a/frontend/src/components/MapView.tsx b/frontend/src/components/MapView.tsx index 6b7c3a3..ff1813b 100644 --- a/frontend/src/components/MapView.tsx +++ b/frontend/src/components/MapView.tsx @@ -1,7 +1,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { MapContainer, Polyline, TileLayer } from "react-leaflet"; +import { MapContainer, Marker, Polyline, Popup, TileLayer, useMap } from "react-leaflet"; import L from "leaflet"; import type { Route, Stop, VehicleArrivalStop, VehiclePosition } from "../api/types"; +import { buildStopMarkerHtml } from "./StopMarker"; +import { useStopDepartures } from "../hooks/useStopDepartures"; +import StopDeparturesPanel from "./StopDeparturesPanel"; import MapBindings from "./MapBindings"; import SelectedVehicleMarker from "./SelectedVehicleMarker"; import VehicleMarker from "./VehicleMarker"; @@ -33,6 +36,49 @@ interface MapViewProps { const defaultCenter: [number, number] = [43.4516, -80.4925]; const defaultZoom = 12; +/** Renders upcoming departures inside a Leaflet popup. */ +function DeparturePopupContent({ stopId }: { stopId: string }) { + const { data: departures, isLoading } = useStopDepartures(stopId); + return ( +
+ +
+ ); +} + +/** + * Listens for clicks on the map background (not on markers) and dismisses + * the currently open stop popup by toggling off the selection. + */ +function MapClickHandler({ + selectedStopId, + onSelectStop, +}: { + selectedStopId: string | null; + onSelectStop: (stopId: string) => void; +}) { + const map = useMap(); + const selectedStopIdRef = useRef(selectedStopId); + selectedStopIdRef.current = selectedStopId; + const onSelectStopRef = useRef(onSelectStop); + onSelectStopRef.current = onSelectStop; + + useEffect(() => { + const handleClick = () => { + const current = selectedStopIdRef.current; + if (current) { + onSelectStopRef.current(current); + } + }; + map.on("click", handleClick); + return () => { + map.off("click", handleClick); + }; + }, [map]); + + return null; +} + const buildRouteIndex = (routes: Route[]) => { const map = new Map(); routes.forEach((route) => map.set(route.id, route)); @@ -180,6 +226,17 @@ export default function MapView({ focusedStopIndexRef.current = _focusedStopIndex; const nearbyStopsCurrent = nearbyStops; + const markerRefs = useRef>(new Map()); + + // When a stop is selected (from sidebar or keyboard), open its popup + useEffect(() => { + if (selectedStopId) { + const marker = markerRefs.current.get(selectedStopId); + if (marker) { + marker.openPopup(); + } + } + }, [selectedStopId]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -307,6 +364,38 @@ export default function MapView({ onZoomToLocation={handleZoomToLocation} userLocationEnabled={!!userLocation} /> + {nearbyStops.map((stop) => ( + { + if (marker) { + markerRefs.current.set(stop.stopId, marker); + } else { + markerRefs.current.delete(stop.stopId); + } + }} + eventHandlers={{ + click: () => onSelectStop(stop.stopId), + }} + > + +
+
+ {stop.stopName ?? stop.stopId} +
+ +
+
+
+ ))} +
);