From 7b8594debceb93a1fa400d48fe1dcff943bd5af6 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Thu, 26 Jun 2025 23:44:25 +0200
Subject: Implement stop sheet modal for map stop interactions (#27)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: arielcostas <94913521+arielcostas@users.noreply.github.com>
Co-authored-by: Ariel Costas Guerrero <ariel@costas.dev>
---
 src/frontend/app/routes/map.tsx | 310 +++++++++++++++++++++-------------------
 1 file changed, 159 insertions(+), 151 deletions(-)

(limited to 'src/frontend/app/routes/map.tsx')

diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx
index ca095e2..5887b9c 100644
--- a/src/frontend/app/routes/map.tsx
+++ b/src/frontend/app/routes/map.tsx
@@ -1,13 +1,21 @@
 import StopDataProvider from "../data/StopDataProvider";
-import './map.css';
+import "./map.css";
 
-import { useEffect, useRef, useState } from 'react';
+import { useEffect, useRef, useState } from "react";
 import { useApp } from "../AppContext";
-import Map, { AttributionControl, GeolocateControl, Layer, NavigationControl, Popup, Source, type MapRef, type MapLayerMouseEvent, type StyleSpecification } from "react-map-gl/maplibre";
+import Map, {
+  AttributionControl,
+  GeolocateControl,
+  Layer,
+  NavigationControl,
+  Source,
+  type MapRef,
+  type MapLayerMouseEvent,
+  type StyleSpecification,
+} from "react-map-gl/maplibre";
 import { loadStyle } from "app/maps/styleloader";
-import type { Feature as GeoJsonFeature, Point } from 'geojson';
-import LineIcon from "~/components/LineIcon";
-import { Link } from "react-router";
+import type { Feature as GeoJsonFeature, Point } from "geojson";
+import { StopSheet } from "~/components/StopSheet";
 import { useTranslation } from "react-i18next";
 
 // Default minimal fallback style before dynamic loading
@@ -16,154 +24,154 @@ const defaultStyle: StyleSpecification = {
   glyphs: `${window.location.origin}/maps/fonts/{fontstack}/{range}.pbf`,
   sprite: `${window.location.origin}/maps/spritesheet/sprite`,
   sources: {},
-  layers: []
+  layers: [],
 };
 
 // Componente principal del mapa
 export default function StopMap() {
-   const { t } = useTranslation();
-   const [stops, setStops] = useState<GeoJsonFeature<Point, { stopId: number; name: string; lines: string[] }>[]>([]);
-    const [popupInfo, setPopupInfo] = useState<any>(null);
-    const { mapState, updateMapState, theme } = useApp();
-    const mapRef = useRef<MapRef>(null);
-    const [mapStyleKey, setMapStyleKey] = useState<string>("light");
-
-	// Style state for Map component
-	const [mapStyle, setMapStyle] = useState<StyleSpecification>(defaultStyle);
-
-	// Handle click events on clusters and individual stops
-	const onMapClick = (e: MapLayerMouseEvent) => {
-		const features = e.features;
-		if (!features || features.length === 0) return;
-		const feature = features[0];
-		const props: any = feature.properties;
-
-		handlePointClick(feature);
-	};
-
-	useEffect(() => {
-		StopDataProvider.getStops().then(data => {
-         const features: GeoJsonFeature<Point, { stopId: number; name: string; lines: string[] }>[] = data.map(s => ({
-            type: "Feature",
-            geometry: { type: "Point", coordinates: [s.longitude as number, s.latitude as number] },
-            properties: { stopId: s.stopId, name: s.name.original, lines: s.lines }
-         }));
-         setStops(features);
+  const { t } = useTranslation();
+  const [stops, setStops] = useState<
+    GeoJsonFeature<Point, { stopId: number; name: string; lines: string[] }>[]
+  >([]);
+  const [selectedStop, setSelectedStop] = useState<{
+    stopId: number;
+    name: string;
+  } | null>(null);
+  const [isSheetOpen, setIsSheetOpen] = useState(false);
+  const { mapState, updateMapState, theme } = useApp();
+  const mapRef = useRef<MapRef>(null);
+  const [mapStyleKey, setMapStyleKey] = useState<string>("light");
+
+  // Style state for Map component
+  const [mapStyle, setMapStyle] = useState<StyleSpecification>(defaultStyle);
+
+  // Handle click events on clusters and individual stops
+  const onMapClick = (e: MapLayerMouseEvent) => {
+    const features = e.features;
+    if (!features || features.length === 0) return;
+    const feature = features[0];
+    const props: any = feature.properties;
+
+    handlePointClick(feature);
+  };
+
+  useEffect(() => {
+    StopDataProvider.getStops().then((data) => {
+      const features: GeoJsonFeature<
+        Point,
+        { stopId: number; name: string; lines: string[] }
+      >[] = data.map((s) => ({
+        type: "Feature",
+        geometry: {
+          type: "Point",
+          coordinates: [s.longitude as number, s.latitude as number],
+        },
+        properties: { stopId: s.stopId, name: s.name.original, lines: s.lines },
+      }));
+      setStops(features);
+    });
+  }, []);
+
+  useEffect(() => {
+    const styleName = "carto";
+    loadStyle(styleName, theme)
+      .then((style) => setMapStyle(style))
+      .catch((error) => console.error("Failed to load map style:", error));
+  }, [mapStyleKey, theme]);
+
+  useEffect(() => {
+    const handleMapChange = () => {
+      if (!mapRef.current) return;
+      const map = mapRef.current.getMap();
+      if (!map) return;
+      const center = map.getCenter();
+      const zoom = map.getZoom();
+      updateMapState([center.lat, center.lng], zoom);
+    };
+
+    if (mapRef.current) {
+      const map = mapRef.current.getMap();
+      if (map) {
+        map.on("moveend", handleMapChange);
+      }
+    }
+
+    return () => {
+      if (mapRef.current) {
+        const map = mapRef.current.getMap();
+        if (map) {
+          map.off("moveend", handleMapChange);
+        }
+      }
+    };
+  }, [mapRef.current]);
+
+  const getLatitude = (center: any) =>
+    Array.isArray(center) ? center[0] : center.lat;
+  const getLongitude = (center: any) =>
+    Array.isArray(center) ? center[1] : center.lng;
+
+  const handlePointClick = (feature: any) => {
+    const props: any = feature.properties;
+    // fetch full stop to get lines array
+    StopDataProvider.getStopById(props.stopId).then((stop) => {
+      if (!stop) return;
+      setSelectedStop({
+        stopId: stop.stopId,
+        name: stop.name.original,
       });
-	}, []);
-
-	useEffect(() => {
-		const styleName = "carto";
-		loadStyle(styleName, theme)
-			.then(style => setMapStyle(style))
-			.catch(error => console.error("Failed to load map style:", error));
-	}, [mapStyleKey, theme]);
-
-	useEffect(() => {
-		const handleMapChange = () => {
-			if (!mapRef.current) return;
-			const map = mapRef.current.getMap();
-			if (!map) return;
-			const center = map.getCenter();
-			const zoom = map.getZoom();
-			updateMapState([center.lat, center.lng], zoom);
-		};
-
-		if (mapRef.current) {
-			const map = mapRef.current.getMap();
-			if (map) {
-				map.on('moveend', handleMapChange);
-			}
-		}
-
-		return () => {
-			if (mapRef.current) {
-				const map = mapRef.current.getMap();
-				if (map) {
-					map.off('moveend', handleMapChange);
-				}
-			}
-		};
-	}, [mapRef.current]);
-
-	const getLatitude = (center: any) => Array.isArray(center) ? center[0] : center.lat;
-	const getLongitude = (center: any) => Array.isArray(center) ? center[1] : center.lng;
-
-	const handlePointClick = (feature: any) => {
-		const props: any = feature.properties;
-		// fetch full stop to get lines array
-		StopDataProvider.getStopById(props.stopId).then(stop => {
-			if (!stop) return;
-			setPopupInfo({
-				geometry: feature.geometry,
-				properties: {
-					stopId: stop.stopId,
-					name: stop.name.original,
-					lines: stop.lines
-				}
-			});
-		});
-	};
-
-	return (
-		<Map
-			mapStyle={mapStyle}
-			style={{ width: '100%', height: '100%' }}
-			interactiveLayerIds={["stops"]}
-			onClick={onMapClick}
-			minZoom={11}
-			scrollZoom
-			pitch={0}
-			roll={0}
-			ref={mapRef}
-			initialViewState={{
-				latitude: getLatitude(mapState.center),
-				longitude: getLongitude(mapState.center),
-				zoom: mapState.zoom,
-			}}
-			attributionControl={false}
-		>
-			<NavigationControl position="top-right" />
-			<GeolocateControl position="top-right" trackUserLocation={true} />
-			<AttributionControl position="bottom-right" compact={false} />
-
-			<Source
-				id="stops-source"
-				type="geojson"
-				data={{ type: "FeatureCollection", features: stops }}
-			/>
-
-			<Layer
-				id="stops"
-				type="symbol"
-				source="stops-source"
-				layout={{
-					"icon-image": "stop",
-					"icon-size": ["interpolate", ["linear"], ["zoom"], 11, 0.4, 16, 0.8],
-					"icon-allow-overlap": true,
-					"icon-ignore-placement": true,
-				}}
-			/>
-
-			{popupInfo && (
-				<Popup
-					latitude={popupInfo.geometry.coordinates[1]}
-					longitude={popupInfo.geometry.coordinates[0]}
-					onClose={() => setPopupInfo(null)}
-				>
-					<div>
-						<h3>{popupInfo.properties.name}</h3>
-						<div>
-							{popupInfo.properties.lines.map((line: string) => (
-								<LineIcon line={line} key={line} />
-							))}
-						</div>
-						<Link to={`/estimates/${popupInfo.properties.stopId}`} className="popup-link">
-							Ver parada
-						</Link>
-					</div>
-				</Popup>
-			)}
-		</Map>
-	);
+      setIsSheetOpen(true);
+    });
+  };
+
+  return (
+    <Map
+      mapStyle={mapStyle}
+      style={{ width: "100%", height: "100%" }}
+      interactiveLayerIds={["stops"]}
+      onClick={onMapClick}
+      minZoom={11}
+      scrollZoom
+      pitch={0}
+      roll={0}
+      ref={mapRef}
+      initialViewState={{
+        latitude: getLatitude(mapState.center),
+        longitude: getLongitude(mapState.center),
+        zoom: mapState.zoom,
+      }}
+      attributionControl={false}
+    >
+      <NavigationControl position="top-right" />
+      <GeolocateControl position="top-right" trackUserLocation={true} />
+      <AttributionControl position="bottom-right" compact={false} />
+
+      <Source
+        id="stops-source"
+        type="geojson"
+        data={{ type: "FeatureCollection", features: stops }}
+      />
+
+      <Layer
+        id="stops"
+        type="symbol"
+        source="stops-source"
+        layout={{
+          "icon-image": "stop",
+          "icon-size": ["interpolate", ["linear"], ["zoom"], 11, 0.4, 16, 0.8],
+          "icon-allow-overlap": true,
+          "icon-ignore-placement": true,
+        }}
+      />
+
+      {selectedStop && (
+        <StopSheet
+          isOpen={isSheetOpen}
+          onClose={() => setIsSheetOpen(false)}
+          stopId={selectedStop.stopId}
+          stopName={selectedStop.name}
+        />
+      )}
+    </Map>
+  );
 }
-- 
cgit v1.3

