aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/app/components/StopMapModal.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/frontend/app/components/StopMapModal.tsx')
-rw-r--r--src/frontend/app/components/StopMapModal.tsx659
1 files changed, 0 insertions, 659 deletions
diff --git a/src/frontend/app/components/StopMapModal.tsx b/src/frontend/app/components/StopMapModal.tsx
deleted file mode 100644
index d218af4..0000000
--- a/src/frontend/app/components/StopMapModal.tsx
+++ /dev/null
@@ -1,659 +0,0 @@
-import maplibregl from "maplibre-gl";
-import React, {
- useCallback,
- useEffect,
- useMemo,
- useRef,
- useState,
-} from "react";
-import Map, { Layer, Marker, Source, type MapRef } from "react-map-gl/maplibre";
-import { Sheet } from "react-modal-sheet";
-import { useApp } from "~/AppContext";
-import { APP_CONSTANTS } from "~/config/constants";
-import { getLineColour } from "~/data/LineColors";
-import type { Stop } from "~/data/StopDataProvider";
-import { loadStyle } from "~/maps/styleloader";
-import "./StopMapModal.css";
-
-export interface Position {
- latitude: number;
- longitude: number;
- orientationDegrees: number;
- shapeIndex?: number;
-}
-
-export interface ConsolidatedCirculationForMap {
- id: string;
- line: string;
- route: string;
- currentPosition?: Position;
- stopShapeIndex?: number;
- isPreviousTrip?: boolean;
- previousTripShapeId?: string | null;
- schedule?: {
- shapeId?: string | null;
- };
- shape?: any;
-}
-
-interface StopMapModalProps {
- stop: Stop;
- circulations: ConsolidatedCirculationForMap[];
- isOpen: boolean;
- onClose: () => void;
- selectedCirculationId?: string;
-}
-
-export const StopMapModal: React.FC<StopMapModalProps> = ({
- stop,
- circulations,
- isOpen,
- onClose,
- selectedCirculationId,
-}) => {
- const { theme } = useApp();
- const [styleSpec, setStyleSpec] = useState<any | null>(null);
- const mapRef = useRef<MapRef | null>(null);
- const hasFitBounds = useRef(false);
- const userInteracted = useRef(false);
- const [shapeData, setShapeData] = useState<any | null>(null);
- const [previousShapeData, setPreviousShapeData] = useState<any | null>(null);
-
- // Filter circulations that have GPS coordinates
- const busesWithPosition = useMemo(
- () => circulations.filter((c) => !!c.currentPosition),
- [circulations]
- );
-
- // Use selectedCirculationId if provided, otherwise use first bus with position
- const selectedBus = useMemo(() => {
- if (selectedCirculationId !== undefined) {
- const circulation = circulations.find(
- (c) => c.id === selectedCirculationId
- );
- if (circulation) {
- return circulation;
- }
- }
- // Fallback to first bus with position
- return busesWithPosition.length > 0 ? busesWithPosition[0] : null;
- }, [selectedCirculationId, circulations, busesWithPosition]);
-
- const center = useMemo(() => {
- if (selectedBus?.currentPosition) {
- return {
- latitude: selectedBus.currentPosition.latitude,
- longitude: selectedBus.currentPosition.longitude,
- };
- }
- if (stop.latitude && stop.longitude) {
- return { latitude: stop.latitude, longitude: stop.longitude };
- }
- return { latitude: 42.2406, longitude: -8.7207 }; // Vigo approx fallback
- }, [selectedBus, stop.latitude, stop.longitude]);
-
- const handleCenter = useCallback(() => {
- if (!mapRef.current) return;
- if (userInteracted.current) return;
-
- const points: { lat: number; lon: number }[] = [];
-
- const getStopsFromFeatureCollection = (data: any) => {
- if (!data || data.type !== "FeatureCollection" || !data.features)
- return [];
- return data.features.filter((f: any) => f.properties?.type === "stop");
- };
-
- const findClosestStopIndex = (
- stops: any[],
- pos: { lat: number; lon: number }
- ) => {
- let minDst = Infinity;
- let index = -1;
- stops.forEach((s: any, idx: number) => {
- const [lon, lat] = s.geometry.coordinates;
- const dst = Math.pow(lat - pos.lat, 2) + Math.pow(lon - pos.lon, 2);
- if (dst < minDst) {
- minDst = dst;
- index = idx;
- }
- });
- return index;
- };
-
- const findClosestPointIndex = (
- coords: number[][],
- pos: { lat: number; lon: number }
- ) => {
- let minDst = Infinity;
- let index = -1;
- coords.forEach((c, idx) => {
- const [lon, lat] = c;
- const dst = Math.pow(lat - pos.lat, 2) + Math.pow(lon - pos.lon, 2);
- if (dst < minDst) {
- minDst = dst;
- index = idx;
- }
- });
- return index;
- };
-
- const addShapePoints = (data: any, isPrevious: boolean) => {
- if (!data) return;
-
- if (data.type === "FeatureCollection") {
- const stops = getStopsFromFeatureCollection(data);
- if (stops.length === 0) return;
-
- let startIdx = 0;
- let endIdx = stops.length - 1;
-
- const currentPos = selectedBus?.currentPosition;
- const userStopPos =
- stop.latitude && stop.longitude
- ? { lat: stop.latitude, lon: stop.longitude }
- : null;
-
- if (isPrevious) {
- // Previous trip: Start from Bus, End at last stop
- if (currentPos) {
- const busIdx = findClosestStopIndex(stops, {
- lat: currentPos.latitude,
- lon: currentPos.longitude,
- });
- if (busIdx !== -1) startIdx = busIdx;
- }
- } else {
- // Current trip: Start from Bus (if not previous), End at User Stop
- if (!previousShapeData && currentPos) {
- const busIdx = findClosestStopIndex(stops, {
- lat: currentPos.latitude,
- lon: currentPos.longitude,
- });
- if (busIdx !== -1) startIdx = busIdx;
- }
-
- if (userStopPos) {
- let userIdx = -1;
- // Try name match
- if (stop.name) {
- userIdx = stops.findIndex(
- (s: any) => s.properties?.name === stop.name
- );
- }
- // Fallback to coords
- if (userIdx === -1) {
- userIdx = findClosestStopIndex(stops, userStopPos);
- }
- if (userIdx !== -1) endIdx = userIdx;
- }
- }
-
- // Add stops in range
- if (startIdx <= endIdx) {
- for (let i = startIdx; i <= endIdx; i++) {
- const [lon, lat] = stops[i].geometry.coordinates;
- points.push({ lat, lon });
- }
- }
- return;
- }
-
- const coords = data?.geometry?.coordinates;
- if (!coords) return;
-
- let startIdx = 0;
- let endIdx = coords.length - 1;
- let foundIndices = false;
-
- if (data.properties?.busPoint && data.properties?.stopPoint) {
- startIdx = data.properties.busPoint.index;
- endIdx = data.properties.stopPoint.index;
- foundIndices = true;
- } else {
- // Fallback: find closest points on the line
- if (selectedBus?.currentPosition) {
- const busIdx = findClosestPointIndex(coords, {
- lat: selectedBus.currentPosition.latitude,
- lon: selectedBus.currentPosition.longitude,
- });
- if (busIdx !== -1) startIdx = busIdx;
- }
- if (stop.latitude && stop.longitude) {
- const stopIdx = findClosestPointIndex(coords, {
- lat: stop.latitude,
- lon: stop.longitude,
- });
- if (stopIdx !== -1) endIdx = stopIdx;
- }
- }
-
- const start = Math.min(startIdx, endIdx);
- const end = Math.max(startIdx, endIdx);
-
- for (let i = start; i <= end; i++) {
- points.push({ lat: coords[i][1], lon: coords[i][0] });
- }
- };
-
- addShapePoints(previousShapeData, true);
- addShapePoints(shapeData, false);
-
- if (points.length === 0) {
- if (stop.latitude && stop.longitude) {
- points.push({ lat: stop.latitude, lon: stop.longitude });
- }
-
- if (selectedBus?.currentPosition) {
- points.push({
- lat: selectedBus.currentPosition.latitude,
- lon: selectedBus.currentPosition.longitude,
- });
- }
- } else {
- // Ensure bus and stop are always included if available, to prevent cutting them off
- if (selectedBus?.currentPosition) {
- points.push({
- lat: selectedBus.currentPosition.latitude,
- lon: selectedBus.currentPosition.longitude,
- });
- }
- if (stop.latitude && stop.longitude) {
- points.push({ lat: stop.latitude, lon: stop.longitude });
- }
- }
-
- if (points.length === 0) return;
-
- let minLat = points[0].lat,
- maxLat = points[0].lat,
- minLon = points[0].lon,
- maxLon = points[0].lon;
- for (const p of points) {
- if (p.lat < minLat) minLat = p.lat;
- if (p.lat > maxLat) maxLat = p.lat;
- if (p.lon < minLon) minLon = p.lon;
- if (p.lon > maxLon) maxLon = p.lon;
- }
-
- const sw = [minLon, minLat] as [number, number];
- const ne = [maxLon, maxLat] as [number, number];
- const bounds = new maplibregl.LngLatBounds(sw, ne);
-
- try {
- if (points.length === 1) {
- const only = points[0];
- mapRef.current
- .getMap()
- .easeTo({ center: [only.lon, only.lat], zoom: 16, duration: 450 });
- } else {
- mapRef.current.getMap().fitBounds(bounds, {
- padding: 80,
- duration: 500,
- maxZoom: 17,
- } as any);
- }
- } catch {}
- }, [stop, selectedBus, shapeData, previousShapeData]);
-
- // Load style without traffic layers for the stop map
- useEffect(() => {
- let mounted = true;
- loadStyle("openfreemap", theme, { includeTraffic: false })
- .then((style) => {
- if (mounted) setStyleSpec(style);
- })
- .catch((err) => console.error("Failed to load map style", err));
- return () => {
- mounted = false;
- };
- }, [theme]);
-
- // Resize map and fit bounds when modal opens
- useEffect(() => {
- if (isOpen && mapRef.current) {
- const timer = setTimeout(() => {
- const map = mapRef.current?.getMap();
- if (map) {
- map.resize();
- // Trigger fit bounds logic again
- hasFitBounds.current = false;
- handleCenter();
- }
- }, 300); // Wait for sheet animation
- return () => clearTimeout(timer);
- }
- }, [isOpen, handleCenter]);
-
- // Fit bounds on initial load
- useEffect(() => {
- if (!styleSpec || !mapRef.current || !isOpen)
- return;
-
- const map = mapRef.current.getMap();
-
- // Handle missing sprite images to suppress console warnings
- const handleStyleImageMissing = (e: any) => {
- if (!map || map.hasImage(e.id)) return;
- map.addImage(e.id, {
- width: 1,
- height: 1,
- data: new Uint8Array(4),
- });
- };
-
- map.on("styleimagemissing", handleStyleImageMissing);
-
- handleCenter();
- hasFitBounds.current = true;
-
- return () => {
- if (mapRef.current) {
- const map = mapRef.current.getMap();
- if (map) {
- map.off("styleimagemissing", handleStyleImageMissing);
- }
- }
- };
- }, [styleSpec, stop, selectedBus, isOpen, handleCenter]);
-
- // Reset bounds when modal opens/closes
- useEffect(() => {
- if (!isOpen) {
- hasFitBounds.current = false;
- userInteracted.current = false;
- setShapeData(null);
- setPreviousShapeData(null);
- }
- }, [isOpen]);
-
- // Fetch shape for selected bus
- useEffect(() => {
- if (!isOpen || !selectedBus) {
- setShapeData(null);
- setPreviousShapeData(null);
- return;
- }
-
- if (selectedBus.shape) {
- setShapeData(selectedBus.shape);
- setPreviousShapeData(null);
- handleCenter();
- return;
- }
-
- setShapeData(null);
- setPreviousShapeData(null);
- }, [isOpen, selectedBus]);
-
- if (!selectedBus && busesWithPosition.length === 0) {
- return null; // Don't render if no buses with GPS coordinates and no selected bus
- }
-
- return (
- <Sheet isOpen={isOpen} onClose={onClose} detent="content">
- <Sheet.Container style={{ backgroundColor: "var(--background-color)" }}>
- <Sheet.Header />
- <Sheet.Content disableDrag={true}>
- <div className="stop-map-modal">
- {/* Map Container */}
- <div className="stop-map-modal__map-container">
- {styleSpec && (
- <Map
- mapLib={maplibregl as any}
- initialViewState={{
- latitude: center.latitude,
- longitude: center.longitude,
- zoom: 16,
- }}
- style={{ width: "100%", height: "50vh" }}
- mapStyle={styleSpec}
- attributionControl={{
- compact: false,
- customAttribution:
- "Concello de Vigo & Viguesa de Transportes SL",
- }}
- ref={mapRef}
- interactive={true}
- onMove={(e) => {
- if (e.originalEvent) {
- userInteracted.current = true;
- }
- }}
- onDragStart={() => {
- userInteracted.current = true;
- }}
- onZoomStart={() => {
- userInteracted.current = true;
- }}
- onRotateStart={() => {
- userInteracted.current = true;
- }}
- onPitchStart={() => {
- userInteracted.current = true;
- }}
- onLoad={() => {
- handleCenter();
- }}
- >
- {/* Previous Shape Layer */}
- {previousShapeData && selectedBus && (
- <Source
- id="prev-route-shape"
- type="geojson"
- data={previousShapeData}
- >
- {/* 1. Black border */}
- <Layer
- id="prev-route-shape-border"
- type="line"
- paint={{
- "line-color": "#000000",
- "line-width": 6,
- "line-opacity": 0.8,
- }}
- layout={{
- "line-cap": "round",
- "line-join": "round",
- }}
- />
- {/* 2. White background */}
- <Layer
- id="prev-route-shape-white"
- type="line"
- paint={{
- "line-color": "#FFFFFF",
- "line-width": 4,
- }}
- layout={{
- "line-cap": "round",
- "line-join": "round",
- }}
- />
- {/* 3. Colored dashes */}
- <Layer
- id="prev-route-shape-inner"
- type="line"
- paint={{
- "line-color": getLineColour(selectedBus.line)
- .background,
- "line-width": 4,
- "line-dasharray": [2, 2],
- }}
- layout={{
- "line-cap": "round",
- "line-join": "round",
- }}
- />
- </Source>
- )}
-
- {/* Shape Layer */}
- {shapeData && selectedBus && (
- <Source id="route-shape" type="geojson" data={shapeData}>
- <Layer
- id="route-shape-border"
- type="line"
- paint={{
- "line-color": "#000000",
- "line-width": 5,
- "line-opacity": 0.6,
- }}
- layout={{
- "line-cap": "round",
- "line-join": "round",
- }}
- />
- <Layer
- id="route-shape-inner"
- type="line"
- paint={{
- "line-color": getLineColour(selectedBus.line)
- .background,
- "line-width": 3,
- "line-opacity": 0.7,
- }}
- layout={{
- "line-cap": "round",
- "line-join": "round",
- }}
- />
-
- {/* Stops Layer */}
- <Layer
- id="route-stops"
- type="circle"
- filter={["==", "type", "stop"]}
- paint={{
- "circle-color": "#FFFFFF",
- "circle-radius": 4,
- "circle-stroke-width": 2,
- "circle-stroke-color": getLineColour(selectedBus.line)
- .background,
- }}
- />
- </Source>
- )}
-
- {/* Stop marker */}
- {stop.latitude && stop.longitude && (
- <Marker
- longitude={stop.longitude}
- latitude={stop.latitude}
- anchor="bottom"
- >
- <div title={`Stop ${stop.stopId}`}>
- <svg width="28" height="36" viewBox="0 0 28 36">
- <defs>
- <filter
- id="drop-stop"
- x="-20%"
- y="-20%"
- width="140%"
- height="140%"
- >
- <feDropShadow
- dx="0"
- dy="1"
- stdDeviation="1"
- floodOpacity={0.35}
- />
- </filter>
- </defs>
- <path
- d="M14 0C6.82 0 1 5.82 1 13c0 8.5 11 23 13 23s13-14.5 13-23C27 5.82 21.18 0 14 0z"
- fill="#1976d2"
- stroke="#fff"
- strokeWidth="2"
- filter="url(#drop-stop)"
- />
- <circle cx="14" cy="13" r="5" fill="#fff" />
- <circle cx="14" cy="13" r="3" fill="#1976d2" />
- </svg>
- </div>
- </Marker>
- )}
-
- {/* Selected bus marker */}
- {selectedBus?.currentPosition && (
- <Marker
- longitude={selectedBus.currentPosition.longitude}
- latitude={selectedBus.currentPosition.latitude}
- anchor="center"
- >
- <div
- style={{
- display: "flex",
- flexDirection: "column",
- alignItems: "center",
- gap: 6,
- transform: `rotate(${selectedBus.currentPosition.orientationDegrees}deg)`,
- transformOrigin: "center center",
- }}
- >
- <svg
- width="24"
- height="24"
- viewBox="0 0 24 24"
- style={{
- filter: "drop-shadow(0 2px 4px rgba(0,0,0,0.3))",
- }}
- >
- <path
- d="M12 2 L22 22 L12 17 L2 22 Z"
- fill={getLineColour(selectedBus.line).background}
- stroke="#000"
- strokeWidth="2"
- strokeLinejoin="round"
- />
- </svg>
- </div>
- </Marker>
- )}
- </Map>
- )}
-
- {/* Floating controls */}
- <div className="map-modal-controls">
- <button
- type="button"
- aria-label="Center"
- className="center-btn"
- onClick={() => {
- userInteracted.current = false;
- handleCenter();
- }}
- title="Center view"
- >
- <svg
- width="20"
- height="20"
- viewBox="0 0 24 24"
- aria-hidden="true"
- >
- <circle cx="12" cy="12" r="3" fill="currentColor" />
- <path
- d="M12 2v3M12 19v3M2 12h3M19 12h3"
- stroke="currentColor"
- strokeWidth="2"
- strokeLinecap="round"
- />
- <circle
- cx="12"
- cy="12"
- r="8"
- fill="none"
- stroke="currentColor"
- strokeWidth="1.5"
- />
- </svg>
- </button>
- </div>
- </div>
- </div>
- </Sheet.Content>
- </Sheet.Container>
- <Sheet.Backdrop onClick={onClose} />
- </Sheet>
- );
-};