diff options
| author | Ariel Costas Guerrero <ariel@costas.dev> | 2025-06-24 13:29:50 +0200 |
|---|---|---|
| committer | Ariel Costas Guerrero <ariel@costas.dev> | 2025-06-24 13:29:50 +0200 |
| commit | 894e67863dbb89a4819e825fcdf7117021082b2a (patch) | |
| tree | fb544ef7fa99ff86489717e793595f503783bb72 /src/frontend/app/routes/map.tsx | |
| parent | 7dd9ea97a2f34a35e80c28d59d046f839eb6c60b (diff) | |
Replace leaflet for maplibre, use react-router in framework mode
Diffstat (limited to 'src/frontend/app/routes/map.tsx')
| -rw-r--r-- | src/frontend/app/routes/map.tsx | 167 |
1 files changed, 167 insertions, 0 deletions
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> + ); +} |
