feat: thêm tính năng đường dành cho xe máy và đi bộ
This commit is contained in:
@@ -28,6 +28,9 @@ import {
|
|||||||
MapPin,
|
MapPin,
|
||||||
Search,
|
Search,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Car,
|
||||||
|
Bike,
|
||||||
|
Footprints,
|
||||||
Flag,
|
Flag,
|
||||||
Clock,
|
Clock,
|
||||||
Check,
|
Check,
|
||||||
@@ -270,6 +273,11 @@ export const TourDetailPage = ({
|
|||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [searchResults, setSearchResults] = useState<any[]>([]);
|
const [searchResults, setSearchResults] = useState<any[]>([]);
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [isRoutingLoading, setIsRoutingLoading] = useState(false);
|
||||||
|
const [travelMode, setTravelMode] = useState<'driving' | 'bike' | 'foot'>('driving');
|
||||||
|
const [routes, setRoutes] = useState<any[]>([]);
|
||||||
|
const [selectedRouteIndex, setSelectedRouteIndex] = useState(0);
|
||||||
|
const [routeMenu, setRouteMenu] = useState<{ x: number, y: number, index: number } | null>(null);
|
||||||
const [drivingRoute, setDrivingRoute] = useState<[number, number][]>([]);
|
const [drivingRoute, setDrivingRoute] = useState<[number, number][]>([]);
|
||||||
const [segmentDistances, setSegmentDistances] = useState<number[]>([]);
|
const [segmentDistances, setSegmentDistances] = useState<number[]>([]);
|
||||||
|
|
||||||
@@ -423,10 +431,15 @@ export const TourDetailPage = ({
|
|||||||
// Memoize danh sách tọa độ để MapTourBounds không bị trigger thừa
|
// Memoize danh sách tọa độ để MapTourBounds không bị trigger thừa
|
||||||
const allLocations = useMemo(() => legs.flatMap(l => l.locations), [legs]);
|
const allLocations = useMemo(() => legs.flatMap(l => l.locations), [legs]);
|
||||||
|
|
||||||
// Tự động tìm quãng đường di chuyển bằng ô tô thực tế và vẽ lên bản đồ
|
// Tạo key định danh cho lộ trình để buộc bản đồ vẽ lại khi dữ liệu thay đổi
|
||||||
|
const routeKey = useMemo(() => allLocations.map(l => `${l.id}-${l.latitude}-${l.longitude}`).join('|'), [allLocations]);
|
||||||
|
|
||||||
|
// Tự động tìm các quãng đường di chuyển thực tế theo phương tiện và vẽ lên bản đồ
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchDrivingRoute = async () => {
|
const fetchRoutes = async () => {
|
||||||
if (allLocations.length < 2) {
|
if (allLocations.length < 2) {
|
||||||
|
setRoutes([]);
|
||||||
|
setSelectedRouteIndex(0);
|
||||||
setDrivingRoute([]);
|
setDrivingRoute([]);
|
||||||
setSegmentDistances([]);
|
setSegmentDistances([]);
|
||||||
return;
|
return;
|
||||||
@@ -436,23 +449,37 @@ export const TourDetailPage = ({
|
|||||||
.map(loc => `${loc.longitude},${loc.latitude}`)
|
.map(loc => `${loc.longitude},${loc.latitude}`)
|
||||||
.join(';');
|
.join(';');
|
||||||
|
|
||||||
|
setIsRoutingLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`https://router.project-osrm.org/route/v1/driving/${coordsString}?overview=full&geometries=geojson`);
|
// 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`);
|
||||||
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) {
|
||||||
// OSRM trả về [lng, lat], cần đổi sang [lat, lng] cho Leaflet
|
setRoutes(data.routes);
|
||||||
const mappedCoords: [number, number][] = data.routes[0].geometry.coordinates.map((c: any) => [c[1], c[0]]);
|
// Reset về lộ trình đầu tiên khi danh sách điểm đến thay đổi hoàn toàn
|
||||||
setDrivingRoute(mappedCoords);
|
setSelectedRouteIndex(0);
|
||||||
// Lưu quãng đường từng chặng (đơn vị km)
|
|
||||||
setSegmentDistances(data.routes[0].legs.map((leg: any) => leg.distance / 1000));
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Lỗi lấy lộ trình di chuyển ô tô:", error);
|
console.error(`Lỗi lấy lộ trình ${travelMode}:`, error);
|
||||||
|
} finally {
|
||||||
|
setIsRoutingLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchDrivingRoute();
|
fetchRoutes();
|
||||||
}, [allLocations]);
|
}, [allLocations, travelMode]);
|
||||||
|
|
||||||
|
// Cập nhật dữ liệu lộ trình hiển thị khi người dùng chọn phương án khác
|
||||||
|
useEffect(() => {
|
||||||
|
if (routes.length > 0 && routes[selectedRouteIndex]) {
|
||||||
|
const route = routes[selectedRouteIndex];
|
||||||
|
// OSRM trả về [lng, lat], cần đổi sang [lat, lng] cho Leaflet
|
||||||
|
const mappedCoords: [number, number][] = route.geometry.coordinates.map((c: any) => [c[1], c[0]]);
|
||||||
|
setDrivingRoute(mappedCoords);
|
||||||
|
// Lưu quãng đường từng chặng của lộ trình được chọn
|
||||||
|
setSegmentDistances(route.legs.map((leg: any) => leg.distance / 1000));
|
||||||
|
}
|
||||||
|
}, [selectedRouteIndex, routes]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialViewState) {
|
if (initialViewState) {
|
||||||
@@ -1128,21 +1155,45 @@ 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} />} {/* Hide map context menu in public view */}
|
{canEdit && !isPublicView && <MapContextMenu onAction={handleMapAction} onMapInteraction={() => setRouteMenu(null)} />} {/* Hide map context menu in public view */}
|
||||||
|
|
||||||
{/* 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} />
|
||||||
|
|
||||||
{/* Vẽ đường đi ô tô thực tế (Driving route) nếu lấy được dữ liệu, nếu không dùng đường thẳng nét đứt */}
|
{/* 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 */}
|
||||||
{drivingRoute.length > 0 ? (
|
{routes.length > 0 ? (
|
||||||
|
[...routes]
|
||||||
|
.map((r, i) => ({ data: r, index: i }))
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.index === selectedRouteIndex) return 1;
|
||||||
|
if (b.index === selectedRouteIndex) return -1;
|
||||||
|
return 0;
|
||||||
|
})
|
||||||
|
.map(({ data, index }) => (
|
||||||
<Polyline
|
<Polyline
|
||||||
positions={drivingRoute}
|
key={`route-${index}-${routeKey}`}
|
||||||
color="#2563eb"
|
positions={data.geometry.coordinates.map((c: any) => [c[1], c[0]])}
|
||||||
weight={5}
|
color={index === selectedRouteIndex ? "#2563eb" : "#94a3b8"}
|
||||||
opacity={0.8}
|
weight={index === selectedRouteIndex ? 6 : 4}
|
||||||
|
opacity={index === selectedRouteIndex ? 0.9 : 0.4}
|
||||||
|
dashArray={index === selectedRouteIndex ? undefined : "10, 10"}
|
||||||
smoothFactor={1}
|
smoothFactor={1}
|
||||||
|
eventHandlers={{
|
||||||
|
click: (e) => {
|
||||||
|
L.DomEvent.stopPropagation(e as any);
|
||||||
|
setRouteMenu(null);
|
||||||
|
setSelectedRouteIndex(index);
|
||||||
|
},
|
||||||
|
contextmenu: (e) => {
|
||||||
|
L.DomEvent.stopPropagation(e as any);
|
||||||
|
L.DomEvent.preventDefault(e as any);
|
||||||
|
setRouteMenu({ x: (e as any).containerPoint.x, y: (e as any).containerPoint.y, index });
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
))
|
||||||
) : allLocations.length > 1 && (
|
) : allLocations.length > 1 && (
|
||||||
|
/* Vẽ đường thẳng nét đứt nếu không lấy được dữ liệu lộ trình thực tế */
|
||||||
<Polyline
|
<Polyline
|
||||||
positions={allLocations.map(l => [l.latitude, l.longitude]) as any}
|
positions={allLocations.map(l => [l.latitude, l.longitude]) as any}
|
||||||
color="#3b82f6"
|
color="#3b82f6"
|
||||||
@@ -1201,7 +1252,11 @@ export const TourDetailPage = ({
|
|||||||
<Navigation className="w-3 h-3 text-blue-600 rotate-45" />
|
<Navigation className="w-3 h-3 text-blue-600 rotate-45" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-[9px] font-black text-blue-400 uppercase leading-none tracking-tighter mb-0.5">{drivingDist ? 'Đường bộ từ điểm trước' : 'Khoảng cách chim bay'}</span>
|
<span className="text-[9px] font-black text-blue-400 uppercase leading-none tracking-tighter mb-0.5">
|
||||||
|
{drivingDist
|
||||||
|
? `${travelMode === 'driving' ? 'Đường ô tô' : travelMode === 'bike' ? 'Đường xe máy' : 'Đường đi bộ'} từ điểm trước`
|
||||||
|
: 'Khoảng cách chim bay'}
|
||||||
|
</span>
|
||||||
<span className="text-xs font-black text-blue-700">{drivingDist ? drivingDist.toFixed(1) : distanceToPrev} km</span>
|
<span className="text-xs font-black text-blue-700">{drivingDist ? drivingDist.toFixed(1) : distanceToPrev} km</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1213,8 +1268,93 @@ export const TourDetailPage = ({
|
|||||||
})}
|
})}
|
||||||
</MarkerClusterGroup>
|
</MarkerClusterGroup>
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
<div className="absolute top-4 left-4 z-[1000] bg-white/90 backdrop-blur-md p-3 rounded-2xl text-[10px] font-bold text-gray-500 shadow-lg border border-white">
|
|
||||||
{isPublicView ? 'Xem chi tiết lộ trình' : 'Mẹo: Nhấn giữ (Mobile) hoặc Chuột phải để ghim địa điểm'}
|
{/* Menu ngữ cảnh khi click chuột phải vào con đường */}
|
||||||
|
{routeMenu && (
|
||||||
|
<div
|
||||||
|
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()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedRouteIndex(routeMenu.index);
|
||||||
|
setRouteMenu(null);
|
||||||
|
notify({
|
||||||
|
title: 'Đã chọn đường đi',
|
||||||
|
message: `Hệ thống đã chuyển sang Lựa chọn ${routeMenu.index + 1}`,
|
||||||
|
type: 'success'
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-4 py-3 hover:bg-blue-50 text-sm font-bold text-blue-600 flex items-center gap-2 transition-colors rounded-xl"
|
||||||
|
>
|
||||||
|
<Navigation className="w-4 h-4 rotate-45" /> Đi đường này
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Overlay điều khiển trên bản đồ */}
|
||||||
|
<div className="absolute top-4 left-4 z-[1001] flex flex-col gap-2">
|
||||||
|
<div className="bg-white/90 backdrop-blur-md p-1.5 rounded-2xl shadow-xl border border-white flex flex-col gap-1.5 animate-in slide-in-from-left-2 duration-300">
|
||||||
|
<button
|
||||||
|
onClick={() => setTravelMode('driving')}
|
||||||
|
className={`p-2.5 rounded-xl transition-all ${travelMode === 'driving' ? 'bg-blue-600 text-white shadow-md' : 'text-gray-500 hover:bg-gray-100'}`}
|
||||||
|
title="Ô tô"
|
||||||
|
>
|
||||||
|
<Car className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setTravelMode('bike')}
|
||||||
|
className={`p-2.5 rounded-xl transition-all ${travelMode === 'bike' ? 'bg-blue-600 text-white shadow-md' : 'text-gray-500 hover:bg-gray-100'}`}
|
||||||
|
title="Xe máy / Xe đạp"
|
||||||
|
>
|
||||||
|
<Bike className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setTravelMode('foot')}
|
||||||
|
className={`p-2.5 rounded-xl transition-all ${travelMode === 'foot' ? 'bg-blue-600 text-white shadow-md' : 'text-gray-500 hover:bg-gray-100'}`}
|
||||||
|
title="Đi bộ"
|
||||||
|
>
|
||||||
|
<Footprints className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chỉ báo đang tìm đường */}
|
||||||
|
{isRoutingLoading && (
|
||||||
|
<div className="bg-white/90 backdrop-blur-md px-3 py-2 rounded-xl shadow-lg border border-white flex items-center gap-2 animate-pulse animate-in slide-in-from-left-2">
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin text-blue-600" />
|
||||||
|
<span className="text-[10px] font-black text-gray-500 uppercase tracking-tighter">Đang tìm đường tối ưu...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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]">
|
||||||
|
<div className="text-[10px] font-black text-gray-400 uppercase tracking-tighter px-1 mb-1">Lộ trình đề xuất</div>
|
||||||
|
<div className="flex flex-col gap-1.5 max-h-[160px] overflow-y-auto pr-1 custom-scrollbar">
|
||||||
|
{routes.map((route, idx) => (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
onClick={() => setSelectedRouteIndex(idx)}
|
||||||
|
className={`p-2 rounded-xl text-left transition-all border ${
|
||||||
|
selectedRouteIndex === idx
|
||||||
|
? 'bg-blue-600 text-white border-blue-600 shadow-md'
|
||||||
|
: 'bg-gray-50 text-gray-600 border-gray-100 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="text-[10px] font-bold">Lựa chọn {idx + 1}</div>
|
||||||
|
<div className={`text-[9px] font-medium leading-tight ${selectedRouteIndex === idx ? 'text-blue-100' : 'text-gray-400'}`}>
|
||||||
|
{(route.distance / 1000).toFixed(1)} km • {Math.round(route.duration / 60)}p
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-white/90 backdrop-blur-md p-3 rounded-2xl text-[10px] font-bold text-gray-500 shadow-lg border border-white max-w-[120px]">
|
||||||
|
{isPublicView ? 'Xem chi tiết lộ trình' : 'Mẹo: Nhấn giữ hoặc Chuột phải để ghim'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user