feat: xoay màn hình theo hướng người nhìn trong màn hình bản đồ vẫn còn lag
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
import { io } from 'socket.io-client';
|
import { io } from 'socket.io-client';
|
||||||
import { ItineraryTimeline } from '../components/ItineraryTimeline';
|
import { ItineraryTimeline } from '../components/ItineraryTimeline';
|
||||||
import { ExpenseManager } from '../components/ExpenseManager';
|
import { ExpenseManager } from '../components/ExpenseManager';
|
||||||
@@ -166,14 +166,15 @@ const RecenterUser = ({ position, trigger }: { position: [number, number] | null
|
|||||||
}, [trigger, position, map]);
|
}, [trigger, position, map]);
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
// Component Helper để xử lý xoay bản đồ theo hướng di chuyển hoặc hướng Bắc
|
|
||||||
|
// Component Helper để xử lý xoay bản đồ theo hướng di chuyển
|
||||||
const MapRotationHandler = ({ rotation }: { rotation: number }) => {
|
const MapRotationHandler = ({ rotation }: { rotation: number }) => {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = map.getContainer();
|
const container = map.getContainer();
|
||||||
// Xoay container bản đồ và scale nhẹ để tránh lộ khoảng trắng ở các góc khi xoay
|
// Tăng scale lên 1.8 để đảm bảo bao phủ toàn bộ màn hình điện thoại (vốn có tỉ lệ dài) khi xoay ở mọi góc độ
|
||||||
container.style.transform = `rotate(${rotation}deg) scale(${rotation === 0 ? 1 : 1.2})`;
|
container.style.transform = `rotate(${rotation}deg) scale(${rotation === 0 ? 1 : 1.8})`;
|
||||||
container.style.transition = 'transform 0.5s cubic-bezier(0.4, 0, 0.2, 1)';
|
container.style.transition = 'transform 0.3s ease-out';
|
||||||
}, [rotation, map]);
|
}, [rotation, map]);
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
@@ -314,7 +315,20 @@ export const TourDetailPage = ({
|
|||||||
const publicTours = useTourStore(state => state.publicTours);
|
const publicTours = useTourStore(state => state.publicTours);
|
||||||
const userRole = useTourStore(state => state.userRole);
|
const userRole = useTourStore(state => state.userRole);
|
||||||
const mapCenter = useTourStore(state => state.mapCenter);
|
const mapCenter = useTourStore(state => state.mapCenter);
|
||||||
const [userLocation, setUserLocation] = useState<[number, number] | null>(null);
|
const [userLocation, setUserLocation] = useState<[number, number] | null>(null); // Vị trí hiện tại của người dùng
|
||||||
|
const [gpsHeading, setGpsHeading] = useState<number | null>(null); // Hướng di chuyển từ GPS
|
||||||
|
const [userSpeed, setUserSpeed] = useState<number | null>(null); // Tốc độ di chuyển từ GPS
|
||||||
|
|
||||||
|
// Di chuyển khai báo state lên trên useEffect để tránh lỗi "before initialization"
|
||||||
|
const [activeTab, setActiveTab] = useState<'plan' | 'expense' | 'photo' | 'settings'>('plan');
|
||||||
|
const [viewMode, setViewMode] = useState<'map' | 'timeline'>('timeline');
|
||||||
|
const [isHeadingMode, setIsHeadingMode] = useState(false);
|
||||||
|
const [mapRotation, setMapRotation] = useState(0);
|
||||||
|
const [isAddLocationOpen, setIsAddLocationOpen] = useState(false);
|
||||||
|
const [isMapFullscreen, setIsMapFullscreen] = useState(false);
|
||||||
|
const [isAddMemberOpen, setIsAddMemberOpen] = useState(false);
|
||||||
|
const [isAddPhotoOpen, setIsAddPhotoOpen] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
// Định nghĩa Icons bên trong Component bằng useMemo để đảm bảo tính ổn định và tránh lỗi render
|
// Định nghĩa Icons bên trong Component bằng useMemo để đảm bảo tính ổn định và tránh lỗi render
|
||||||
const mapIcons = useMemo(() => ({
|
const mapIcons = useMemo(() => ({
|
||||||
@@ -350,22 +364,82 @@ export const TourDetailPage = ({
|
|||||||
}), []);
|
}), []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Theo dõi vị trí GPS của người dùng (bao gồm hướng và tốc độ khi di chuyển)
|
||||||
if (!isPublicView && navigator.geolocation) {
|
if (!isPublicView && navigator.geolocation) {
|
||||||
const watchId = navigator.geolocation.watchPosition(
|
const watchId = navigator.geolocation.watchPosition(
|
||||||
(pos) => setUserLocation([pos.coords.latitude, pos.coords.longitude]),
|
(pos) => {
|
||||||
|
setUserLocation([pos.coords.latitude, pos.coords.longitude]);
|
||||||
|
setGpsHeading(pos.coords.heading);
|
||||||
|
setUserSpeed(pos.coords.speed);
|
||||||
|
},
|
||||||
(err) => console.warn("Lỗi định vị người dùng:", err),
|
(err) => console.warn("Lỗi định vị người dùng:", err),
|
||||||
{ enableHighAccuracy: true }
|
{ enableHighAccuracy: true }
|
||||||
);
|
);
|
||||||
return () => navigator.geolocation.clearWatch(watchId);
|
return () => navigator.geolocation.clearWatch(watchId);
|
||||||
|
} else {
|
||||||
|
setGpsHeading(null);
|
||||||
|
setUserSpeed(null);
|
||||||
}
|
}
|
||||||
}, [isPublicView]);
|
}, [isPublicView]); // Chỉ phụ thuộc vào isPublicView
|
||||||
|
|
||||||
|
// State mới cho hướng thiết bị (la bàn)
|
||||||
|
const [deviceOrientationHeading, setDeviceOrientationHeading] = useState<number | null>(null);
|
||||||
|
const rotationUpdateTimer = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
// Theo dõi hướng thiết bị (la bàn)
|
||||||
|
useEffect(() => {
|
||||||
|
if (window.DeviceOrientationEvent) {
|
||||||
|
const handleDeviceOrientation = (event: DeviceOrientationEvent) => {
|
||||||
|
// Sử dụng webkitCompassHeading cho iOS nếu có, nếu không thì dùng alpha
|
||||||
|
const heading = (event as any).webkitCompassHeading || event.alpha;
|
||||||
|
if (heading !== null && heading !== undefined) {
|
||||||
|
setDeviceOrientationHeading(heading);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('deviceorientation', handleDeviceOrientation);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('deviceorientation', handleDeviceOrientation);
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
setDeviceOrientationHeading(null);
|
||||||
|
}
|
||||||
|
}, []); // Chạy một lần khi component mount
|
||||||
|
|
||||||
|
// Logic kết hợp để xác định hướng xoay bản đồ
|
||||||
|
useEffect(() => {
|
||||||
|
if (rotationUpdateTimer.current) {
|
||||||
|
clearTimeout(rotationUpdateTimer.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isHeadingMode) {
|
||||||
|
setMapRotation(0); // Đặt lại hướng Bắc nếu chế độ xoay tắt
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let newRotation: number | null = null;
|
||||||
|
// Ưu tiên hướng GPS nếu có và người dùng đang di chuyển (tốc độ > 0.5 m/s)
|
||||||
|
if (gpsHeading !== null && userSpeed !== null && userSpeed > 0.5) {
|
||||||
|
newRotation = -gpsHeading;
|
||||||
|
} else if (deviceOrientationHeading !== null) {
|
||||||
|
// Nếu không di chuyển hoặc không có hướng GPS, dùng hướng thiết bị
|
||||||
|
newRotation = -deviceOrientationHeading;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newRotation !== null) {
|
||||||
|
// Debounce cập nhật xoay để làm mượt chuyển động
|
||||||
|
rotationUpdateTimer.current = setTimeout(() => {
|
||||||
|
setMapRotation(newRotation!);
|
||||||
|
}, 100); // Trì hoãn 100ms
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (rotationUpdateTimer.current) {
|
||||||
|
clearTimeout(rotationUpdateTimer.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isHeadingMode, gpsHeading, userSpeed, deviceOrientationHeading]);
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<'plan' | 'expense' | 'photo' | 'settings'>('plan');
|
|
||||||
const [viewMode, setViewMode] = useState<'map' | 'timeline'>('timeline');
|
|
||||||
const [isAddLocationOpen, setIsAddLocationOpen] = useState(false);
|
|
||||||
const [isMapFullscreen, setIsMapFullscreen] = useState(false);
|
|
||||||
const [isAddMemberOpen, setIsAddMemberOpen] = useState(false);
|
|
||||||
const [isAddPhotoOpen, setIsAddPhotoOpen] = useState(false);
|
|
||||||
const [isStartPointAction, setIsStartPointAction] = useState(false);
|
const [isStartPointAction, setIsStartPointAction] = useState(false);
|
||||||
const [isEndPointAction, setIsEndPointAction] = useState(false);
|
const [isEndPointAction, setIsEndPointAction] = useState(false);
|
||||||
const [targetLegId, setTargetLegId] = useState<string | null>(null);
|
const [targetLegId, setTargetLegId] = useState<string | null>(null);
|
||||||
@@ -431,8 +505,6 @@ export const TourDetailPage = ({
|
|||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
const [locateTrigger, setLocateTrigger] = useState(0);
|
const [locateTrigger, setLocateTrigger] = useState(0);
|
||||||
const [isMapControlsOpen, setIsMapControlsOpen] = useState(false);
|
const [isMapControlsOpen, setIsMapControlsOpen] = useState(false);
|
||||||
const [isHeadingMode, setIsHeadingMode] = useState(false);
|
|
||||||
const [mapRotation, setMapRotation] = useState(0);
|
|
||||||
const [isRoutingLoading, setIsRoutingLoading] = useState(false);
|
const [isRoutingLoading, setIsRoutingLoading] = useState(false);
|
||||||
const [travelMode, setTravelMode] = useState<'driving' | 'bike' | 'foot'>('driving');
|
const [travelMode, setTravelMode] = useState<'driving' | 'bike' | 'foot'>('driving');
|
||||||
const [routes, setRoutes] = useState<any[]>([]);
|
const [routes, setRoutes] = useState<any[]>([]);
|
||||||
@@ -1475,12 +1547,12 @@ export const TourDetailPage = ({
|
|||||||
{/* Tự động đóng khung Start và End khi dữ liệu thay đổi */}
|
{/* Tự động đóng khung Start và End khi dữ liệu thay đổi */}
|
||||||
<MapTourBounds locations={allLocations} />
|
<MapTourBounds locations={allLocations} />
|
||||||
|
|
||||||
|
{/* Xử lý xoay bản đồ theo Heading */}
|
||||||
|
<MapRotationHandler rotation={mapRotation} />
|
||||||
|
|
||||||
{/* Xử lý di chuyển tâm bản đồ về phía người dùng */}
|
{/* Xử lý di chuyển tâm bản đồ về phía người dùng */}
|
||||||
<RecenterUser position={userLocation} trigger={locateTrigger} />
|
<RecenterUser position={userLocation} trigger={locateTrigger} />
|
||||||
|
|
||||||
{/* Xử lý xoay bản đồ */}
|
|
||||||
<MapRotationHandler rotation={mapRotation} />
|
|
||||||
|
|
||||||
{/* Hiển thị vị trí hiện tại của người dùng */}
|
{/* Hiển thị vị trí hiện tại của người dùng */}
|
||||||
{userLocation && (
|
{userLocation && (
|
||||||
<Marker position={userLocation} icon={mapIcons.user} zIndexOffset={1000}>
|
<Marker position={userLocation} icon={mapIcons.user} zIndexOffset={1000}>
|
||||||
@@ -1683,17 +1755,15 @@ export const TourDetailPage = ({
|
|||||||
{/* Nút La bàn / Xoay bản đồ */}
|
{/* Nút La bàn / Xoay bản đồ */}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (mapRotation !== 0) {
|
const newMode = !isHeadingMode;
|
||||||
setMapRotation(0);
|
setIsHeadingMode(newMode);
|
||||||
setIsHeadingMode(false);
|
if (!newMode) setMapRotation(0);
|
||||||
} else {
|
setIsMapControlsOpen(false);
|
||||||
setIsHeadingMode(!isHeadingMode);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
className={`w-8 h-8 flex items-center justify-center rounded-lg transition-all border-t border-gray-100 mt-0.5 ${isHeadingMode ? 'bg-blue-600 text-white shadow-md' : 'text-gray-500 hover:bg-gray-100'}`}
|
className={`w-8 h-8 flex items-center justify-center rounded-lg transition-all border-t border-gray-100 mt-0.5 ${isHeadingMode ? 'bg-blue-600 text-white shadow-md' : 'text-gray-500 hover:bg-gray-100'}`}
|
||||||
title={isHeadingMode ? "Dừng xoay (Khóa hướng Bắc)" : "Tự động xoay theo hướng di chuyển"}
|
title={isHeadingMode ? "Khóa hướng Bắc" : "Xoay theo hướng nhìn"}
|
||||||
>
|
>
|
||||||
<Compass className="w-3.5 h-3.5 transition-transform duration-500" style={{ transform: `rotate(${mapRotation}deg)` }} />
|
<Compass className="w-3.5 h-3.5 transition-transform duration-300" style={{ transform: `rotate(${mapRotation}deg)` }} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Nút Tìm tôi */}
|
{/* Nút Tìm tôi */}
|
||||||
@@ -2094,7 +2164,7 @@ export const TourDetailPage = ({
|
|||||||
|
|
||||||
{/* Fullscreen Map Overlay - Hiển thị khi click vào nút trạng thái/chỉ đường */}
|
{/* Fullscreen Map Overlay - Hiển thị khi click vào nút trạng thái/chỉ đường */}
|
||||||
{isMapFullscreen && (
|
{isMapFullscreen && (
|
||||||
<div className="fixed inset-0 z-[5000] bg-white animate-in fade-in slide-in-from-bottom-16 duration-500">
|
<div className="fixed inset-0 z-[5000] bg-white animate-in fade-in slide-in-from-bottom-16 duration-500 overflow-hidden">
|
||||||
{/* Nút X để quay lại */}
|
{/* Nút X để quay lại */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsMapFullscreen(false)}
|
onClick={() => setIsMapFullscreen(false)}
|
||||||
@@ -2127,8 +2197,8 @@ export const TourDetailPage = ({
|
|||||||
>
|
>
|
||||||
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
||||||
<MapTourBounds locations={allLocations} />
|
<MapTourBounds locations={allLocations} />
|
||||||
<RecenterUser position={userLocation} trigger={locateTrigger} />
|
|
||||||
<MapRotationHandler rotation={mapRotation} />
|
<MapRotationHandler rotation={mapRotation} />
|
||||||
|
<RecenterUser position={userLocation} trigger={locateTrigger} />
|
||||||
|
|
||||||
{userLocation && (
|
{userLocation && (
|
||||||
<Marker position={userLocation} icon={mapIcons.user} />
|
<Marker position={userLocation} icon={mapIcons.user} />
|
||||||
@@ -2175,6 +2245,17 @@ export const TourDetailPage = ({
|
|||||||
<button onClick={() => { setTravelMode('driving'); setIsMapControlsOpen(false); }} className={`w-9 h-9 flex items-center justify-center rounded-xl ${travelMode === 'driving' ? 'bg-blue-600 text-white' : 'text-gray-500'}`}><Car className="w-4 h-4" /></button>
|
<button onClick={() => { setTravelMode('driving'); setIsMapControlsOpen(false); }} className={`w-9 h-9 flex items-center justify-center rounded-xl ${travelMode === 'driving' ? 'bg-blue-600 text-white' : 'text-gray-500'}`}><Car className="w-4 h-4" /></button>
|
||||||
<button onClick={() => { setTravelMode('bike'); setIsMapControlsOpen(false); }} className={`w-9 h-9 flex items-center justify-center rounded-xl ${travelMode === 'bike' ? 'bg-blue-600 text-white' : 'text-gray-500'}`}><Bike className="w-4 h-4" /></button>
|
<button onClick={() => { setTravelMode('bike'); setIsMapControlsOpen(false); }} className={`w-9 h-9 flex items-center justify-center rounded-xl ${travelMode === 'bike' ? 'bg-blue-600 text-white' : 'text-gray-500'}`}><Bike className="w-4 h-4" /></button>
|
||||||
<button onClick={() => { setTravelMode('foot'); setIsMapControlsOpen(false); }} className={`w-9 h-9 flex items-center justify-center rounded-xl ${travelMode === 'foot' ? 'bg-blue-600 text-white' : 'text-gray-500'}`}><Footprints className="w-4 h-4" /></button>
|
<button onClick={() => { setTravelMode('foot'); setIsMapControlsOpen(false); }} className={`w-9 h-9 flex items-center justify-center rounded-xl ${travelMode === 'foot' ? 'bg-blue-600 text-white' : 'text-gray-500'}`}><Footprints className="w-4 h-4" /></button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const newMode = !isHeadingMode;
|
||||||
|
setIsHeadingMode(newMode);
|
||||||
|
if (!newMode) setMapRotation(0);
|
||||||
|
setIsMapControlsOpen(false);
|
||||||
|
}}
|
||||||
|
className={`w-9 h-9 flex items-center justify-center rounded-xl border-t border-gray-100 transition-all ${isHeadingMode ? 'bg-blue-600 text-white' : 'text-gray-500'}`}
|
||||||
|
>
|
||||||
|
<Compass className="w-4 h-4" style={{ transform: `rotate(${mapRotation}deg)` }} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user