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:
@@ -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) {
|
if ((prev && (Math.abs(newWidth - prev.width) > 2 || Math.abs(newHeight - prev.height) > 2)) || newWidth === 0 || newHeight === 0) {
|
||||||
prevSizeRef.current = { width: newWidth, height: newHeight };
|
prevSizeRef.current = { width: newWidth, height: newHeight };
|
||||||
const prevZoom = map.getZoom();
|
|
||||||
const prevCenter = map.getCenter();
|
|
||||||
const userZoom = (map as any)._userZoomLevel as number | undefined;
|
const userZoom = (map as any)._userZoomLevel as number | undefined;
|
||||||
|
|
||||||
map.invalidateSize({ animate: false });
|
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 }) => {
|
export const TourNavigationPage: React.FC<TourNavigationPageProps> = ({ tourId, routeData, onBack }) => {
|
||||||
const [isCompassActive, setIsCompassActive] = useState(false);
|
const [isCompassActive, setIsCompassActive] = useState(false);
|
||||||
|
const [isLocatingUser, setIsLocatingUser] = useState(false);
|
||||||
const [currentHeading, setCurrentHeading] = useState(0);
|
const [currentHeading, setCurrentHeading] = useState(0);
|
||||||
const [routeGeometry, setRouteGeometry] = useState<[number, number][] | null>(null);
|
const [routeGeometry, setRouteGeometry] = useState<[number, number][] | null>(null);
|
||||||
const [error, setError] = useState<string | 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 destLat = routeData?.destination?.lat;
|
||||||
const destLng = routeData?.destination?.lng;
|
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(() => {
|
useEffect(() => {
|
||||||
if (!routeData || !originLat || !originLng || !destLat || !destLng) {
|
if (!routeData || !originLat || !originLng || !destLat || !destLng) {
|
||||||
setRouteGeometry(null);
|
setRouteGeometry(null);
|
||||||
@@ -245,9 +287,9 @@ export const TourNavigationPage: React.FC<TourNavigationPageProps> = ({ tourId,
|
|||||||
|
|
||||||
const destIcon = useMemo(() => L.divIcon({
|
const destIcon = useMemo(() => L.divIcon({
|
||||||
className: '!bg-transparent !border-none',
|
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>`,
|
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: [32, 32],
|
iconSize: [40, 40],
|
||||||
iconAnchor: [16, 16]
|
iconAnchor: [20, 20]
|
||||||
}), []);
|
}), []);
|
||||||
|
|
||||||
if (!routeData) return null;
|
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]
|
? [(routeData.origin.lat + routeData.destination.lat) / 2, (routeData.origin.lng + routeData.destination.lng) / 2]
|
||||||
: [0, 0];
|
: [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 (
|
return (
|
||||||
<div className="fixed inset-0 bg-slate-950 flex flex-col overflow-hidden select-none text-white antialiased">
|
<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">
|
<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="" />
|
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution="" />
|
||||||
<MapRefSetter mapRef={mapRef} />
|
<MapRefSetter mapRef={mapRef} />
|
||||||
<MapInteractionWatcher />
|
<MapInteractionWatcher />
|
||||||
<FitBounds coords={routeGeometry || []} canFitRef={canFitRef} />
|
{/* <FitBounds coords={routeGeometry || []} canFitRef={canFitRef} /> */}
|
||||||
{routeGeometry && (
|
{routeGeometry && (
|
||||||
<Polyline positions={routeGeometry} color="#2563eb" weight={6} opacity={0.9} smoothFactor={1} />
|
<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} />
|
<CompassInteractionDetector onUserInteraction={handleUserInteraction} />
|
||||||
</MapContainer>
|
</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
|
<button
|
||||||
onClick={() => setIsCompassActive(!isCompassActive)}
|
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 ${
|
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 ${
|
||||||
|
|||||||
Reference in New Issue
Block a user