Files
travelplanning/frontend/src/pages/TourNavigationPage.tsx
T
3dtours 33a5996bee fix: Ngăn bản đồ tự động reset về trung tâm khi người dùng đã tương tác
- TourDetailPage: MapTourBounds chỉ fitBounds 1 lần cho mỗi lần dữ liệu lộ trình thay đổi thực sự (theo locKey). Nếu người dùng đã pan/zoom rồi thì không refit khi data reload.
- TourNavigationPage: FitBounds chỉ chạy khi người dùng CHƯA tương tác (hasInteractedRef). Sau khi người dùng pan/zoom/drag, bản đồ giữ nguyên vị trí.
- Cả hai bản đồ đều tôn trọng vị trí người dùng đã chọn, không reset về mặc định khi có thay đổi dữ liệu phụ.
2026-06-25 12:28:37 +07:00

404 lines
14 KiB
TypeScript

import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { MapContainer, TileLayer, Marker, Polyline, Popup, useMap, useMapEvents } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { ChevronLeft, Compass } from 'lucide-react';
interface NavigationRouteData {
origin: { lat: number; lng: number };
destination: { lat: number; lng: number; name: string };
tourTitle?: string;
}
interface TourNavigationPageProps {
tourId: string;
routeData: NavigationRouteData | null;
onBack: () => void;
}
const FitBounds = ({ coords, destination, hasInteractedRef }: { coords: [number, number][], destination: { lat: number; lng: number; name: string } | null, hasInteractedRef: React.MutableRefObject<boolean> }) => {
const map = useMap();
useEffect(() => {
if (coords.length > 0 && !hasInteractedRef.current && destination) {
map.fitBounds(coords, { padding: [60, 60], maxZoom: 15 });
}
}, [map, coords, destination, hasInteractedRef]);
return null;
};
const MapRotationHandler = ({ rotation }: { rotation: number }) => {
const map = useMap();
const cumulativeRotationRef = useRef(0);
const prevRotationRef = useRef(0);
useEffect(() => {
const container = map.getContainer();
container.style.transformOrigin = 'center center';
container.style.willChange = 'transform';
if (rotation === 0) {
cumulativeRotationRef.current = 0;
prevRotationRef.current = 0;
container.style.transform = '';
return;
}
let delta = rotation - prevRotationRef.current;
if (delta > 180) delta -= 360;
else if (delta < -180) delta += 360;
cumulativeRotationRef.current += delta;
prevRotationRef.current = rotation;
container.style.transform = `rotate(${cumulativeRotationRef.current}deg) scale(2.2) translateZ(0)`;
container.style.transition = 'transform 0.2s cubic-bezier(0.25, 0.1, 0.25, 1)';
}, [rotation, map]);
return null;
};
const CompassInteractionDetector = ({
onUserInteraction,
hasInteractedRef
}: {
onUserInteraction: () => void;
hasInteractedRef: React.MutableRefObject<boolean>;
}) => {
useMapEvents({
movestart: () => { hasInteractedRef.current = true; },
zoomstart: () => { hasInteractedRef.current = true; },
dragstart: () => { hasInteractedRef.current = true; },
});
return null;
};
const MapRefSetter = ({ mapRef }: { mapRef: React.MutableRefObject<L.Map | null> }) => {
const map = useMap();
useEffect(() => {
mapRef.current = map;
}, [map, mapRef]);
return null;
};
const MapInteractionWatcher = ({ hasInteractedRef }: { hasInteractedRef: React.MutableRefObject<boolean> }) => {
const map = useMap();
useEffect(() => {
const onZoomEnd = () => {
map._userZoomLevel = map.getZoom();
hasInteractedRef.current = true;
};
const onMoveEnd = () => {
map._userCenter = map.getCenter();
hasInteractedRef.current = true;
};
map.on('zoomend', onZoomEnd);
map.on('moveend', onMoveEnd);
return () => {
map.off('zoomend', onZoomEnd);
map.off('moveend', onMoveEnd);
};
}, [map, hasInteractedRef]);
return null;
};
const MapSizeHandler = ({ mapRef }: { mapRef: React.MutableRefObject<L.Map | null> }) => {
const map = useMap();
const prevSizeRef = useRef<{ width: number; height: number } | null>(null);
useEffect(() => {
const container = map.getContainer();
if (!container) return;
prevSizeRef.current = { width: container.clientWidth, height: container.clientHeight };
const observer = new ResizeObserver(() => {
const newWidth = container.clientWidth;
const newHeight = container.clientHeight;
const prev = prevSizeRef.current;
if ((prev && (Math.abs(newWidth - prev.width) > 2 || Math.abs(newHeight - prev.height) > 2)) || newWidth === 0 || newHeight === 0) {
prevSizeRef.current = { width: newWidth, height: newHeight };
const userZoom = (map as any)._userZoomLevel as number | undefined;
map.invalidateSize({ animate: false });
if (userZoom !== undefined && Math.abs(map.getZoom() - userZoom) > 0.01) {
map.setZoom(userZoom, { animate: false });
}
}
});
observer.observe(container);
return () => observer.disconnect();
}, [map, mapRef]);
return null;
};
export const TourNavigationPage: React.FC<TourNavigationPageProps> = ({ tourId, routeData, onBack }) => {
const [isCompassActive, setIsCompassActive] = useState(false);
const [isLocatingUser, setIsLocatingUser] = useState(false);
const [currentHeading, setCurrentHeading] = useState(0);
const [routeGeometry, setRouteGeometry] = useState<[number, number][] | null>(null);
const [error, setError] = useState<string | null>(null);
const mapRef = useRef<L.Map | null>(null);
const watchIdRef = useRef<number | null>(null);
const fetchLockRef = useRef(false);
const hasInteractedRef = useRef(false);
const originLat = routeData?.origin?.lat;
const originLng = routeData?.origin?.lng;
const destLat = routeData?.destination?.lat;
const destLng = routeData?.destination?.lng;
const stopLocating = useCallback(() => {
setIsLocatingUser(false);
}, []);
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const onMoveStart = () => {
if (isLocatingUser) {
stopLocating();
}
};
map.on('movestart', onMoveStart);
return () => {
map.off('movestart', onMoveStart);
};
}, [isLocatingUser, stopLocating]);
useEffect(() => {
if (!isLocatingUser || !mapRef.current) return;
if (navigator.geolocation) {
watchIdRef.current = navigator.geolocation.watchPosition(
(position) => {
if (mapRef.current) {
const currentZoom = mapRef.current.getZoom();
mapRef.current.setView([position.coords.latitude, position.coords.longitude], currentZoom, { animate: true });
}
},
(err) => console.error("User location tracking error:", err),
{ enableHighAccuracy: true }
);
}
return () => {
if (watchIdRef.current !== null) {
navigator.geolocation.clearWatch(watchIdRef.current);
watchIdRef.current = null;
}
};
}, [isLocatingUser]);
useEffect(() => {
if (!routeData || !originLat || !originLng || !destLat || !destLng) {
setRouteGeometry(null);
setError(null);
return;
}
if (fetchLockRef.current) return;
const calculateOptimalRoute = async () => {
try {
fetchLockRef.current = true;
const url = `https://router.project-osrm.org/route/v1/driving/${originLng},${originLat};${destLng},${destLat}?overview=full&geometries=geojson`;
const res = await fetch(url);
if (!res.ok) throw new Error(`OSRM error: ${res.status}`);
const data: { code: string; routes: Array<{ geometry: { coordinates: number[][] } }> } = await res.json();
if (data.code !== 'Ok' || !data.routes?.length) throw new Error('Không tìm thấy lộ trình phù hợp');
const coords = data.routes[0].geometry.coordinates.map((c: number[]) => [c[1], c[0]] as [number, number]);
setRouteGeometry(coords);
} catch (err: any) {
setError(err.message);
} finally {
fetchLockRef.current = false;
}
};
calculateOptimalRoute();
return () => {
fetchLockRef.current = false;
};
}, [routeData, originLat, originLng, destLat, destLng]);
const handleUserInteraction = useCallback(() => {
hasInteractedRef.current = true;
if (isCompassActive) {
setIsCompassActive(false);
}
}, [isCompassActive]);
useEffect(() => {
if (!isCompassActive) {
setCurrentHeading(0);
return;
}
if (navigator.geolocation) {
watchIdRef.current = navigator.geolocation.watchPosition(
(position) => {
if (mapRef.current) {
mapRef.current.setView([position.coords.latitude, position.coords.longitude], undefined, { animate: true });
}
if (position.coords.heading !== null) {
setCurrentHeading(position.coords.heading);
}
},
(err) => console.error("Compass tracking acquisition error:", err),
{ enableHighAccuracy: true }
);
}
const handleOrientation = (event: any) => {
let heading: number | null = null;
if (event.webkitCompassHeading !== undefined && event.webkitCompassHeading !== null) {
heading = event.webkitCompassHeading;
} else if (event.alpha !== null && event.alpha !== undefined) {
if (event.absolute === true || event.type === 'deviceorientationabsolute') {
heading = (360 - event.alpha) % 360;
}
}
if (heading !== null) {
setCurrentHeading(heading);
}
};
window.addEventListener('deviceorientation', handleOrientation, true);
window.addEventListener('deviceorientationabsolute', handleOrientation, true);
return () => {
if (watchIdRef.current !== null) {
navigator.geolocation.clearWatch(watchIdRef.current);
}
window.removeEventListener('deviceorientation', handleOrientation, true);
window.removeEventListener('deviceorientationabsolute', handleOrientation, true);
};
}, [isCompassActive]);
const userIcon = useMemo(() => L.divIcon({
className: '!bg-transparent !border-none',
html: `<div class="w-8 h-8 bg-blue-600 rounded-full border-2 border-white shadow-xl flex items-center justify-center"><div class="w-2 h-2 bg-white rounded-full"></div></div>`,
iconSize: [32, 32],
iconAnchor: [16, 16]
}), []);
const destIcon = useMemo(() => L.divIcon({
className: '!bg-transparent !border-none',
html: `<div class="w-10 h-10 bg-red-600 rounded-full border-2 border-white shadow-xl flex items-center justify-center text-white text-xs font-black">Đ</div>`,
iconSize: [40, 40],
iconAnchor: [20, 20]
}), []);
if (!routeData) return null;
const center: [number, number] = routeData.origin && routeData.destination
? [(routeData.origin.lat + routeData.destination.lat) / 2, (routeData.origin.lng + routeData.destination.lng) / 2]
: [0, 0];
const centerOnUser = () => {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(position) => {
if (mapRef.current) {
const currentZoom = mapRef.current.getZoom();
mapRef.current.setView([position.coords.latitude, position.coords.longitude], currentZoom, { animate: true });
canFitRef.current = false;
}
},
(err) => console.error("Cannot get user location:", err),
{ enableHighAccuracy: true }
);
}
};
return (
<div className="fixed inset-0 bg-slate-950 flex flex-col overflow-hidden select-none text-white antialiased">
<div className="w-full bg-[#1e293b]/95 backdrop-blur-md border-b border-slate-800 px-4 py-3.5 flex items-center gap-3 z-50 shrink-0">
<button
onClick={onBack}
className="text-slate-300 hover:text-white p-1 rounded-lg hover:bg-slate-800 transition-colors"
title="Quay lại danh sách lộ trình"
>
<ChevronLeft className="w-6 h-6" />
</button>
<h2 className="text-sm font-bold truncate tracking-wide text-slate-100">
{routeData.tourTitle || "Bản đồ chỉ đường"}
</h2>
</div>
<div className="flex-1 relative min-h-0">
<MapContainer
center={center}
zoom={14}
className="absolute inset-0 h-full w-full"
zoomControl={true}
attributionControl={false}
scrollWheelZoom={true}
doubleClickZoom={true}
touchZoom={true}
dragging={true}
inertia={true}
maxZoom={20}
minZoom={2}
>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution="" />
<MapRefSetter mapRef={mapRef} />
<MapInteractionWatcher hasInteractedRef={hasInteractedRef} />
<FitBounds coords={routeGeometry || []} destination={routeData?.destination || null} hasInteractedRef={hasInteractedRef} />
{routeGeometry && (
<Polyline positions={routeGeometry} color="#2563eb" weight={6} opacity={0.9} smoothFactor={1} />
)}
<MapSizeHandler mapRef={mapRef} />
<MapRotationHandler rotation={currentHeading} />
<CompassInteractionDetector onUserInteraction={handleUserInteraction} hasInteractedRef={hasInteractedRef} />
</MapContainer>
{/* Locate User Button */}
<button
onClick={() => {
setIsLocatingUser(!isLocatingUser);
if (!isLocatingUser) {
centerOnUser();
}
}}
className={`absolute bottom-8 left-6 z-[999] w-14 h-14 rounded-full border-2 flex items-center justify-center shadow-2xl transition-all active:scale-95 ${
isLocatingUser
? 'bg-blue-600 border-blue-400 text-white animate-pulse'
: 'bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-blue-400'
}`}
title="Vị trí của tôi"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M12 2v4M12 18v4M2 12h4M18 12h4" />
</svg>
</button>
<button
onClick={() => setIsCompassActive(!isCompassActive)}
className={`absolute bottom-8 right-6 z-[999] w-14 h-14 rounded-full border-2 flex items-center justify-center shadow-2xl transition-all active:scale-95 ${
isCompassActive
? 'bg-blue-600 border-blue-400 text-white animate-pulse'
: 'bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-blue-400'
}`}
>
<Compass className="w-7 h-7" />
</button>
{error && (
<div className="absolute top-4 left-1/2 -translate-x-1/2 bg-red-900 text-white text-xs px-3 py-1.5 rounded-lg shadow-lg z-[1000]">
{error}
</div>
)}
</div>
</div>
);
};