From 894e67863dbb89a4819e825fcdf7117021082b2a Mon Sep 17 00:00:00 2001
From: Ariel Costas Guerrero <ariel@costas.dev>
Date: Tue, 24 Jun 2025 13:29:50 +0200
Subject: Replace leaflet for maplibre, use react-router in framework mode

---
 src/frontend/app/routes/map.tsx | 167 ++++++++++++++++++++++++++++++++++++++++
 1 file changed, 167 insertions(+)
 create mode 100644 src/frontend/app/routes/map.tsx

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

diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx
new file mode 100644
index 0000000..a938148
--- /dev/null
+++ b/src/frontend/app/routes/map.tsx
@@ -0,0 +1,167 @@
+import StopDataProvider from "../data/StopDataProvider";
+import './map.css';
+
+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 { loadStyle } from "app/maps/styleloader";
+import type { Feature as GeoJsonFeature, Point } from 'geojson';
+import LineIcon from "~/components/LineIcon";
+import { Link } from "react-router";
+
+// Default minimal fallback style before dynamic loading
+const defaultStyle: StyleSpecification = {
+  version: 8,
+  glyphs: `${window.location.origin}/maps/fonts/{fontstack}/{range}.pbf`,
+  sprite: `${window.location.origin}/maps/spritesheet/sprite`,
+  sources: {},
+  layers: []
+};
+
+// Componente principal del mapa
+export default function StopMap() {
+   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);
+      });
+	}, []);
+
+	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>
+	);
+}
-- 
cgit v1.3

