feat: thêm tính năng hiển thị vị trí người dùng trên bản đồ

This commit is contained in:
2026-06-17 18:27:52 +07:00
parent e664a3797e
commit 3a3296c340
+86 -12
View File
@@ -30,6 +30,7 @@ import {
Loader2,
Car,
Bike,
Navigation,
Footprints,
Flag,
Clock,
@@ -182,6 +183,7 @@ export const TourDetailPage = ({
const publicTours = useTourStore(state => state.publicTours);
const userRole = useTourStore(state => state.userRole);
const mapCenter = useTourStore(state => state.mapCenter);
const [userLocation, setUserLocation] = useState<[number, number] | null>(null);
// Đị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(() => ({
@@ -202,9 +204,31 @@ export const TourDetailPage = ({
html: `<div class="w-5 h-5 bg-indigo-500 rounded-full border-2 border-white shadow-lg hover:scale-125 transition-transform flex items-center justify-center"><div class="w-1.5 h-1.5 bg-white rounded-full opacity-50"></div></div>`,
iconSize: [20, 20],
iconAnchor: [10, 10]
}),
user: L.divIcon({
className: '!bg-transparent !border-none',
html: `
<div class="relative">
<div class="w-4 h-4 bg-blue-500 rounded-full border-2 border-white shadow-lg z-10"></div>
<div class="absolute -inset-2 bg-blue-400 rounded-full opacity-40 animate-ping"></div>
</div>
`,
iconSize: [16, 16],
iconAnchor: [8, 8]
})
}), []);
useEffect(() => {
if (!isPublicView && navigator.geolocation) {
const watchId = navigator.geolocation.watchPosition(
(pos) => setUserLocation([pos.coords.latitude, pos.coords.longitude]),
(err) => console.warn("Lỗi định vị người dùng:", err),
{ enableHighAccuracy: true }
);
return () => navigator.geolocation.clearWatch(watchId);
}
}, [isPublicView]);
const [activeTab, setActiveTab] = useState<'plan' | 'expense' | 'photo' | 'settings'>('plan');
const [viewMode, setViewMode] = useState<'map' | 'timeline'>('timeline');
const [isAddLocationOpen, setIsAddLocationOpen] = useState(false);
@@ -451,8 +475,8 @@ export const TourDetailPage = ({
setIsRoutingLoading(true);
try {
// Thêm alternatives=3 để yêu cầu tối đa 3 phương án lộ trình khác từ OSRM
const response = await fetch(`https://router.project-osrm.org/route/v1/${travelMode}/${coordsString}?overview=full&geometries=geojson&alternatives=3`);
// Thêm alternatives=true để yêu cầu các phương án lộ trình khác từ OSRM (Lưu ý: OSRM thường chỉ trả về lộ trình thay thế cho 2 điểm tọa độ)
const response = await fetch(`https://router.project-osrm.org/route/v1/${travelMode}/${coordsString}?overview=full&geometries=geojson&alternatives=true`);
const data = await response.json();
if (data.code === 'Ok' && data.routes.length > 0) {
setRoutes(data.routes);
@@ -736,6 +760,19 @@ export const TourDetailPage = ({
}
};
// Ngăn chặn sự kiện click trên menu lộ trình làm ảnh hưởng bản đồ
const routeMenuRef = React.useRef<HTMLDivElement>(null);
useEffect(() => {
if (routeMenu && routeMenuRef.current) {
L.DomEvent.disableClickPropagation(routeMenuRef.current);
}
}, [routeMenu]);
// Đóng menu lộ trình khi chuyển tab hoặc thay đổi chế độ xem
useEffect(() => {
setRouteMenu(null);
}, [activeTab, viewMode]);
// Xác định Điểm xuất phát và Điểm kết thúc hiển thị dưới widget tài chính
const startPoint = useMemo(() =>
legs.flatMap(l => l.locations).find(loc => loc.plannedStart && new Date(loc.plannedStart).getTime() === 0),
@@ -1155,11 +1192,25 @@ export const TourDetailPage = ({
preferCanvas={true}
>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
{canEdit && !isPublicView && <MapContextMenu onAction={handleMapAction} onMapInteraction={() => setRouteMenu(null)} />} {/* Hide map context menu in public view */}
{canEdit && !isPublicView && (
<MapContextMenu
onAction={handleMapAction}
onOpen={() => setRouteMenu(null)}
/>
)}
{/* Tự động đóng khung Start và End khi dữ liệu thay đổi */}
<MapTourBounds locations={allLocations} />
{/* Hiển thị vị trí hiện tại của người dùng */}
{userLocation && (
<Marker position={userLocation} icon={mapIcons.user} zIndexOffset={1000}>
<Popup>
<div className="text-xs font-bold text-blue-600">Bạn đang đây</div>
</Popup>
</Marker>
)}
{/* Vẽ tất cả lộ trình: Vẽ các đường phụ trước, đường chính sau để hiển thị đè lên trên */}
{routes.length > 0 ? (
[...routes]
@@ -1171,23 +1222,40 @@ export const TourDetailPage = ({
})
.map(({ data, index }) => (
<Polyline
key={`route-${index}-${routeKey}`}
key={`route-${index}-${index === selectedRouteIndex ? 'active' : 'alt'}-${routeKey}-${routes.length}`}
positions={data.geometry.coordinates.map((c: any) => [c[1], c[0]])}
color={index === selectedRouteIndex ? "#2563eb" : "#94a3b8"}
weight={index === selectedRouteIndex ? 6 : 4}
opacity={index === selectedRouteIndex ? 0.9 : 0.4}
dashArray={index === selectedRouteIndex ? undefined : "10, 10"}
weight={index === selectedRouteIndex ? 6 : 14}
opacity={index === selectedRouteIndex ? 1 : 0.4}
dashArray={index === selectedRouteIndex ? undefined : "15, 15"}
smoothFactor={1}
eventHandlers={{
click: (e) => {
L.DomEvent.stopPropagation(e as any);
const originalEvent = (e as any).originalEvent;
if (originalEvent) L.DomEvent.stopPropagation(originalEvent);
setRouteMenu(null);
setSelectedRouteIndex(index);
},
contextmenu: (e) => {
L.DomEvent.stopPropagation(e as any);
L.DomEvent.preventDefault(e as any);
const originalEvent = (e as any).originalEvent;
if (originalEvent) {
L.DomEvent.stopPropagation(originalEvent);
L.DomEvent.preventDefault(originalEvent);
// Đánh dấu để MapContextMenu biết đã có Layer xử lý
(originalEvent as any)._routeTriggered = true;
}
setRouteMenu({ x: (e as any).containerPoint.x, y: (e as any).containerPoint.y, index });
},
mouseover: (e) => {
if (index !== selectedRouteIndex) {
(e.target as L.Polyline).setStyle({ opacity: 0.8, weight: 16, color: '#64748b' });
}
},
mouseout: (e) => {
if (index !== selectedRouteIndex) {
(e.target as L.Polyline).setStyle({ opacity: 0.4, weight: 14, color: '#94a3b8' });
}
}
}}
/>
@@ -1272,6 +1340,7 @@ export const TourDetailPage = ({
{/* Menu ngữ cảnh khi click chuột phải vào con đường */}
{routeMenu && (
<div
ref={routeMenuRef}
className="absolute z-[2001] bg-white rounded-2xl shadow-2xl border border-gray-100 py-1 w-44 animate-in zoom-in-95 duration-200"
style={{ top: routeMenu.y, left: routeMenu.x }}
onClick={(e) => e.stopPropagation()}
@@ -1328,9 +1397,14 @@ export const TourDetailPage = ({
)}
{/* Danh sách lựa chọn lộ trình đề xuất */}
{routes.length > 1 && (
<div className="bg-white/90 backdrop-blur-md p-2 rounded-2xl shadow-xl border border-white flex flex-col gap-1.5 animate-in slide-in-from-left-2 duration-300 min-w-[140px] max-w-[160px]">
{routes.length > 0 && (
<div className="bg-white/90 backdrop-blur-md p-2 rounded-2xl shadow-xl border border-white flex flex-col gap-1.5 animate-in slide-in-from-left-2 duration-300 min-w-[150px] max-w-[180px]">
<div className="text-[10px] font-black text-gray-400 uppercase tracking-tighter px-1 mb-1">Lộ trình đ xuất</div>
{routes.length === 1 && allLocations.length > 2 && (
<div className="px-2 py-1 text-[9px] text-amber-600 bg-amber-50 rounded-lg font-medium leading-tight mb-1 border border-amber-100">
Lưu ý: Dịch vụ bản đ chỉ gợi ý đưng đi khác khi lộ trình đúng 2 điểm (Điểm đu & Điểm cuối).
</div>
)}
<div className="flex flex-col gap-1.5 max-h-[160px] overflow-y-auto pr-1 custom-scrollbar">
{routes.map((route, idx) => (
<button