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,
|
||||
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 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">
|
||||
{routes.map((route, idx) => (
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user