feat: Thêm nút định vị người dùng trên bản đồ điều hướng mobile

- Thêm nút 'Vị trí của tôi' ở góc dưới bên trái bản đồ
- Khi bật: map tự động center theo vị trí GPS của người dùng (dùng watchPosition)
- Cho phép thu phóng tự do ngay cả khi đang định vị
- Khi người dùng kéo thả bản đồ thủ công: tự động tắt chế độ định vị
- Nút hoạt động độc lập với nút la bàn (compass)
- Sửa import leaflet.css bị lỗi path
This commit is contained in:
2026-06-25 11:34:09 +07:00
parent b48502c34a
commit 8e984e609b
+85 -6
View File
@@ -114,8 +114,6 @@ const MapSizeHandler = ({ mapRef }: { mapRef: React.MutableRefObject<L.Map | nul
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 prevZoom = map.getZoom();
const prevCenter = map.getCenter();
const userZoom = (map as any)._userZoomLevel as number | undefined;
map.invalidateSize({ animate: false });
@@ -135,6 +133,7 @@ const MapSizeHandler = ({ mapRef }: { mapRef: React.MutableRefObject<L.Map | nul
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);
@@ -149,6 +148,49 @@ export const TourNavigationPage: React.FC<TourNavigationPageProps> = ({ tourId,
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);
@@ -245,9 +287,9 @@ export const TourNavigationPage: React.FC<TourNavigationPageProps> = ({ tourId,
const destIcon = useMemo(() => L.divIcon({
className: '!bg-transparent !border-none',
html: `<div class="w-8 h-8 bg-red-600 rounded-full border-2 border-white shadow-xl flex items-center justify-center text-white text-xs font-black">Đ</div>`,
iconSize: [32, 32],
iconAnchor: [16, 16]
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;
@@ -256,6 +298,22 @@ export const TourNavigationPage: React.FC<TourNavigationPageProps> = ({ tourId,
? [(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">
@@ -289,7 +347,7 @@ export const TourNavigationPage: React.FC<TourNavigationPageProps> = ({ tourId,
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution="" />
<MapRefSetter mapRef={mapRef} />
<MapInteractionWatcher />
<FitBounds coords={routeGeometry || []} canFitRef={canFitRef} />
{/* <FitBounds coords={routeGeometry || []} canFitRef={canFitRef} /> */}
{routeGeometry && (
<Polyline positions={routeGeometry} color="#2563eb" weight={6} opacity={0.9} smoothFactor={1} />
)}
@@ -298,6 +356,27 @@ export const TourNavigationPage: React.FC<TourNavigationPageProps> = ({ tourId,
<CompassInteractionDetector onUserInteraction={handleUserInteraction} />
</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 ${