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:
@@ -30,6 +30,7 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Car,
|
Car,
|
||||||
Bike,
|
Bike,
|
||||||
|
Navigation,
|
||||||
Footprints,
|
Footprints,
|
||||||
Flag,
|
Flag,
|
||||||
Clock,
|
Clock,
|
||||||
@@ -182,6 +183,7 @@ 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);
|
||||||
|
|
||||||
// Đị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(() => ({
|
||||||
@@ -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>`,
|
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],
|
iconSize: [20, 20],
|
||||||
iconAnchor: [10, 10]
|
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 [activeTab, setActiveTab] = useState<'plan' | 'expense' | 'photo' | 'settings'>('plan');
|
||||||
const [viewMode, setViewMode] = useState<'map' | 'timeline'>('timeline');
|
const [viewMode, setViewMode] = useState<'map' | 'timeline'>('timeline');
|
||||||
const [isAddLocationOpen, setIsAddLocationOpen] = useState(false);
|
const [isAddLocationOpen, setIsAddLocationOpen] = useState(false);
|
||||||
@@ -451,8 +475,8 @@ export const TourDetailPage = ({
|
|||||||
|
|
||||||
setIsRoutingLoading(true);
|
setIsRoutingLoading(true);
|
||||||
try {
|
try {
|
||||||
// Thêm alternatives=3 để yêu cầu tối đa 3 phương án lộ trình khác từ OSRM
|
// 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=3`);
|
const response = await fetch(`https://router.project-osrm.org/route/v1/${travelMode}/${coordsString}?overview=full&geometries=geojson&alternatives=true`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.code === 'Ok' && data.routes.length > 0) {
|
if (data.code === 'Ok' && data.routes.length > 0) {
|
||||||
setRoutes(data.routes);
|
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
|
// 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(() =>
|
const startPoint = useMemo(() =>
|
||||||
legs.flatMap(l => l.locations).find(loc => loc.plannedStart && new Date(loc.plannedStart).getTime() === 0),
|
legs.flatMap(l => l.locations).find(loc => loc.plannedStart && new Date(loc.plannedStart).getTime() === 0),
|
||||||
@@ -1155,11 +1192,25 @@ export const TourDetailPage = ({
|
|||||||
preferCanvas={true}
|
preferCanvas={true}
|
||||||
>
|
>
|
||||||
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
<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 */}
|
{/* Tự động đóng khung Start và End khi dữ liệu thay đổi */}
|
||||||
<MapTourBounds locations={allLocations} />
|
<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 */}
|
{/* 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.length > 0 ? (
|
||||||
[...routes]
|
[...routes]
|
||||||
@@ -1171,23 +1222,40 @@ export const TourDetailPage = ({
|
|||||||
})
|
})
|
||||||
.map(({ data, index }) => (
|
.map(({ data, index }) => (
|
||||||
<Polyline
|
<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]])}
|
positions={data.geometry.coordinates.map((c: any) => [c[1], c[0]])}
|
||||||
color={index === selectedRouteIndex ? "#2563eb" : "#94a3b8"}
|
color={index === selectedRouteIndex ? "#2563eb" : "#94a3b8"}
|
||||||
weight={index === selectedRouteIndex ? 6 : 4}
|
weight={index === selectedRouteIndex ? 6 : 14}
|
||||||
opacity={index === selectedRouteIndex ? 0.9 : 0.4}
|
opacity={index === selectedRouteIndex ? 1 : 0.4}
|
||||||
dashArray={index === selectedRouteIndex ? undefined : "10, 10"}
|
dashArray={index === selectedRouteIndex ? undefined : "15, 15"}
|
||||||
smoothFactor={1}
|
smoothFactor={1}
|
||||||
eventHandlers={{
|
eventHandlers={{
|
||||||
click: (e) => {
|
click: (e) => {
|
||||||
L.DomEvent.stopPropagation(e as any);
|
const originalEvent = (e as any).originalEvent;
|
||||||
|
if (originalEvent) L.DomEvent.stopPropagation(originalEvent);
|
||||||
setRouteMenu(null);
|
setRouteMenu(null);
|
||||||
setSelectedRouteIndex(index);
|
setSelectedRouteIndex(index);
|
||||||
},
|
},
|
||||||
contextmenu: (e) => {
|
contextmenu: (e) => {
|
||||||
L.DomEvent.stopPropagation(e as any);
|
const originalEvent = (e as any).originalEvent;
|
||||||
L.DomEvent.preventDefault(e as any);
|
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 });
|
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 */}
|
{/* Menu ngữ cảnh khi click chuột phải vào con đường */}
|
||||||
{routeMenu && (
|
{routeMenu && (
|
||||||
<div
|
<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"
|
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 }}
|
style={{ top: routeMenu.y, left: routeMenu.x }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
@@ -1328,9 +1397,14 @@ export const TourDetailPage = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Danh sách lựa chọn lộ trình đề xuất */}
|
{/* Danh sách lựa chọn lộ trình đề xuất */}
|
||||||
{routes.length > 1 && (
|
{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-[140px] max-w-[160px]">
|
<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>
|
<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 có đú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">
|
<div className="flex flex-col gap-1.5 max-h-[160px] overflow-y-auto pr-1 custom-scrollbar">
|
||||||
{routes.map((route, idx) => (
|
{routes.map((route, idx) => (
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user