From b829e4ea261d26edd083a75913e7c5712e841005 Mon Sep 17 00:00:00 2001 From: Zaid Abdulameer Date: Tue, 16 Jun 2026 22:15:09 -0400 Subject: [PATCH] feat: show nearby stops as clickable markers with departure popups on map Add stop markers for nearby stops using StopMarkerIcon, with Leaflet popups that display upcoming departures via StopDeparturesPanel and the useStopDepartures hook (auto-refreshes every 10s). Popups sync with sidebar selection and dismiss on map background click. Closes #94 --- frontend/src/__tests__/App.test.tsx | 2 +- frontend/src/components/MapView.tsx | 91 ++++++++++++++++++++++++++++- 2 files changed, 91 insertions(+), 2 deletions(-) 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} +
+ +
+
+
+ ))} +
);