33a5996bee
- 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ụ.
404 lines
14 KiB
TypeScript
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>
|
|
);
|
|
};
|