|
|
|
@@ -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 { ItineraryTimeline } from '../components/ItineraryTimeline';
|
|
|
|
|
import { ExpenseManager } from '../components/ExpenseManager';
|
|
|
|
@@ -49,6 +49,26 @@ import L from 'leaflet';
|
|
|
|
|
// Định nghĩa kiểu dữ liệu cho Địa điểm để khớp với Schema Prisma
|
|
|
|
|
type LocationType = 'MOVE' | 'VISIT' | 'REST' | 'EAT';
|
|
|
|
|
|
|
|
|
|
// Định nghĩa kiểu dữ liệu cho OSRM Route
|
|
|
|
|
interface OSRMRoute {
|
|
|
|
|
geometry: {
|
|
|
|
|
coordinates: [number, number][]; // [lng, lat]
|
|
|
|
|
};
|
|
|
|
|
distance: number; // meters
|
|
|
|
|
duration: number; // seconds
|
|
|
|
|
legs: { distance: number; duration: number; }[]; // OSRM's internal legs for a route
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Định nghĩa kiểu dữ liệu cho điểm (bao gồm cả userLocation khi được chuyển đổi)
|
|
|
|
|
interface LocationPoint {
|
|
|
|
|
latitude: number;
|
|
|
|
|
longitude: number;
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
type: string;
|
|
|
|
|
legId: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fix lỗi icon mặc định của Leaflet cho môi trường Vite
|
|
|
|
|
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
|
|
|
|
L.Icon.Default.mergeOptions({
|
|
|
|
@@ -69,6 +89,53 @@ function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: numbe
|
|
|
|
|
return 12742 * Math.asin(Math.sqrt(a)); // 2 * R; R = 6371 km
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Helper function to combine segment routes into a single overall route
|
|
|
|
|
const combineSegmentRoutes = (segmentRoutes: OSRMRoute[][], selectedIndices: number[]): OSRMRoute | null => {
|
|
|
|
|
if (segmentRoutes.length === 0) return null;
|
|
|
|
|
|
|
|
|
|
let combinedGeometry: [number, number][] = [];
|
|
|
|
|
let combinedDistance = 0;
|
|
|
|
|
let combinedDuration = 0;
|
|
|
|
|
const combinedLegs: { distance: number; duration: number; }[] = [];
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < segmentRoutes.length; i++) {
|
|
|
|
|
const segmentIndex = selectedIndices[i] !== undefined ? selectedIndices[i] : 0; // Default to first alternative
|
|
|
|
|
const chosenRoute = segmentRoutes[i][segmentIndex];
|
|
|
|
|
|
|
|
|
|
if (!chosenRoute) {
|
|
|
|
|
// If a segment has no chosen route (e.g., no alternatives or API failed),
|
|
|
|
|
// we cannot form a complete route.
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Concatenate geometry, avoiding duplicate points at segment junctions
|
|
|
|
|
if (i > 0 && combinedGeometry.length > 0 && chosenRoute.geometry.coordinates.length > 0) {
|
|
|
|
|
const lastPointOfPrev = combinedGeometry[combinedGeometry.length - 1];
|
|
|
|
|
const firstPointOfCurrent = chosenRoute.geometry.coordinates[0];
|
|
|
|
|
// OSRM coordinates are [lng, lat]
|
|
|
|
|
if (Math.abs(lastPointOfPrev[0] - firstPointOfCurrent[0]) < 1e-6 &&
|
|
|
|
|
Math.abs(lastPointOfPrev[1] - firstPointOfCurrent[1]) < 1e-6) {
|
|
|
|
|
combinedGeometry = combinedGeometry.concat(chosenRoute.geometry.coordinates.slice(1));
|
|
|
|
|
} else {
|
|
|
|
|
combinedGeometry = combinedGeometry.concat(chosenRoute.geometry.coordinates);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
combinedGeometry = combinedGeometry.concat(chosenRoute.geometry.coordinates);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
combinedDistance += chosenRoute.distance;
|
|
|
|
|
combinedDuration += chosenRoute.duration;
|
|
|
|
|
combinedLegs.push(...chosenRoute.legs); // OSRM legs are sub-segments within a route
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
geometry: { coordinates: combinedGeometry },
|
|
|
|
|
distance: combinedDistance,
|
|
|
|
|
duration: combinedDuration,
|
|
|
|
|
legs: combinedLegs,
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Component Helper để tự động điều chỉnh khung nhìn bản đồ bao phủ toàn bộ lộ trình
|
|
|
|
|
const MapTourBounds = ({ locations }: { locations: any[] }) => {
|
|
|
|
|
const map = useMap();
|
|
|
|
@@ -99,14 +166,36 @@ const RecenterUser = ({ position, trigger }: { position: [number, number] | null
|
|
|
|
|
}, [trigger, position, map]);
|
|
|
|
|
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 map = useMap();
|
|
|
|
|
// Sử dụng Ref để lưu trữ giá trị xoay cộng dồn, giúp bản đồ luôn xoay theo hướng ngắn nhất
|
|
|
|
|
// Thay vì nhảy giá trị từ -359 về 0 (làm CSS xoay ngược 1 vòng), ta tính toán delta.
|
|
|
|
|
const cumulativeRotationRef = useRef(0);
|
|
|
|
|
const prevRotationRef = useRef(0);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const container = map.getContainer();
|
|
|
|
|
// Xoay container bản đồ và scale nhẹ để tránh lộ khoảng trắng ở các góc khi xoay
|
|
|
|
|
container.style.transform = `rotate(${rotation}deg) scale(${rotation === 0 ? 1 : 1.2})`;
|
|
|
|
|
container.style.transition = 'transform 0.5s cubic-bezier(0.4, 0, 0.2, 1)';
|
|
|
|
|
|
|
|
|
|
if (rotation === 0) {
|
|
|
|
|
cumulativeRotationRef.current = 0;
|
|
|
|
|
prevRotationRef.current = 0;
|
|
|
|
|
container.style.transform = `rotate(0deg) scale(1)`;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let delta = rotation - prevRotationRef.current;
|
|
|
|
|
// Chuẩn hóa delta trong khoảng [-180, 180] để tìm hướng xoay gần nhất
|
|
|
|
|
if (delta > 180) delta -= 360;
|
|
|
|
|
else if (delta < -180) delta += 360;
|
|
|
|
|
|
|
|
|
|
cumulativeRotationRef.current += delta;
|
|
|
|
|
prevRotationRef.current = rotation;
|
|
|
|
|
|
|
|
|
|
// Áp dụng transform với giá trị cộng dồn liên tục
|
|
|
|
|
container.style.transform = `rotate(${cumulativeRotationRef.current}deg) scale(2.5)`;
|
|
|
|
|
container.style.transition = 'transform 0.1s linear';
|
|
|
|
|
}, [rotation, map]);
|
|
|
|
|
return null;
|
|
|
|
|
};
|
|
|
|
@@ -247,7 +336,20 @@ 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);
|
|
|
|
|
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
|
|
|
|
|
const mapIcons = useMemo(() => ({
|
|
|
|
@@ -283,21 +385,84 @@ export const TourDetailPage = ({
|
|
|
|
|
}), []);
|
|
|
|
|
|
|
|
|
|
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) {
|
|
|
|
|
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),
|
|
|
|
|
{ enableHighAccuracy: true }
|
|
|
|
|
);
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
// Theo dõi hướng thiết bị (la bàn)
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const handleOrientation = (event: any) => {
|
|
|
|
|
let heading: number | null = null;
|
|
|
|
|
|
|
|
|
|
// 1. Đối với iOS: Sử dụng webkitCompassHeading (đã chuẩn hóa hướng Bắc thực)
|
|
|
|
|
if (event.webkitCompassHeading !== undefined && event.webkitCompassHeading !== null) {
|
|
|
|
|
heading = event.webkitCompassHeading;
|
|
|
|
|
}
|
|
|
|
|
// 2. Đối với Android (Chrome): Cần kiểm tra tính tuyệt đối của dữ liệu
|
|
|
|
|
else if (event.alpha !== null && event.alpha !== undefined) {
|
|
|
|
|
// Chrome trên Android chỉ cung cấp hướng la bàn chuẩn khi event.absolute là true
|
|
|
|
|
// hoặc khi nhận từ sự kiện 'deviceorientationabsolute'
|
|
|
|
|
if (event.absolute === true || event.type === 'deviceorientationabsolute') {
|
|
|
|
|
// Alpha trên Android tăng theo chiều ngược kim đồng hồ (0=North, 90=West)
|
|
|
|
|
// Cần chuyển đổi sang chiều kim đồng hồ để khớp với logic quay bản đồ
|
|
|
|
|
heading = (360 - event.alpha) % 360;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (heading !== null) {
|
|
|
|
|
setDeviceOrientationHeading(heading);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Đăng ký cả hai loại sự kiện để hỗ trợ tối đa các dòng điện thoại
|
|
|
|
|
window.addEventListener('deviceorientation', handleOrientation, true);
|
|
|
|
|
window.addEventListener('deviceorientationabsolute', handleOrientation, true);
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
window.removeEventListener('deviceorientation', handleOrientation, true);
|
|
|
|
|
window.removeEventListener('deviceorientationabsolute', handleOrientation, true);
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// Logic kết hợp để xác định hướng xoay bản đồ
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
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) {
|
|
|
|
|
// Cập nhật ngay lập tức để tăng độ nhạy, CSS transition sẽ lo phần mượt mà
|
|
|
|
|
setMapRotation(newRotation);
|
|
|
|
|
}
|
|
|
|
|
}, [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 [isAddMemberOpen, setIsAddMemberOpen] = useState(false);
|
|
|
|
|
const [isAddPhotoOpen, setIsAddPhotoOpen] = useState(false);
|
|
|
|
|
const [isStartPointAction, setIsStartPointAction] = useState(false);
|
|
|
|
|
const [isEndPointAction, setIsEndPointAction] = useState(false);
|
|
|
|
|
const [targetLegId, setTargetLegId] = useState<string | null>(null);
|
|
|
|
@@ -363,8 +528,6 @@ export const TourDetailPage = ({
|
|
|
|
|
const [isSearching, setIsSearching] = useState(false);
|
|
|
|
|
const [locateTrigger, setLocateTrigger] = useState(0);
|
|
|
|
|
const [isMapControlsOpen, setIsMapControlsOpen] = useState(false);
|
|
|
|
|
const [isHeadingMode, setIsHeadingMode] = useState(false);
|
|
|
|
|
const [mapRotation, setMapRotation] = useState(0);
|
|
|
|
|
const [isRoutingLoading, setIsRoutingLoading] = useState(false);
|
|
|
|
|
const [travelMode, setTravelMode] = useState<'driving' | 'bike' | 'foot'>('driving');
|
|
|
|
|
const [routes, setRoutes] = useState<any[]>([]);
|
|
|
|
@@ -524,12 +687,30 @@ export const TourDetailPage = ({
|
|
|
|
|
const allLocations = useMemo(() => legs.flatMap(l => l.locations), [legs]);
|
|
|
|
|
|
|
|
|
|
// 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]);
|
|
|
|
|
const routeKey = useMemo(() => JSON.stringify({
|
|
|
|
|
locations: allLocations.map(l => ({ id: l.id, lat: l.latitude, lon: l.longitude })),
|
|
|
|
|
user: userLocation ? { lat: userLocation[0].toFixed(5), lon: userLocation[1].toFixed(5) } : null,
|
|
|
|
|
travelMode: travelMode,
|
|
|
|
|
selectedRouteIndex: selectedRouteIndex // Include selectedRouteIndex to force re-render when alternative is chosen
|
|
|
|
|
}), [allLocations, userLocation, travelMode, selectedRouteIndex]);
|
|
|
|
|
|
|
|
|
|
// 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(() => {
|
|
|
|
|
const fetchRoutes = async () => {
|
|
|
|
|
if (allLocations.length < 2) {
|
|
|
|
|
// Prepare points: userLocation + allLocations
|
|
|
|
|
const currentPoints: LocationPoint[] = [...allLocations];
|
|
|
|
|
if (userLocation) {
|
|
|
|
|
currentPoints.unshift({
|
|
|
|
|
latitude: userLocation[0],
|
|
|
|
|
longitude: userLocation[1],
|
|
|
|
|
id: 'user-location', // Dummy ID
|
|
|
|
|
name: 'Vị trí hiện tại', // Dummy name
|
|
|
|
|
type: 'MOVE', // Dummy type
|
|
|
|
|
legId: '', // Dummy legId
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (currentPoints.length < 2) {
|
|
|
|
|
setRoutes([]);
|
|
|
|
|
setSelectedRouteIndex(0);
|
|
|
|
|
setDrivingRoute([]);
|
|
|
|
@@ -537,29 +718,92 @@ export const TourDetailPage = ({
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const coordsString = allLocations
|
|
|
|
|
.map(loc => `${loc.longitude},${loc.latitude}`)
|
|
|
|
|
.join(';');
|
|
|
|
|
// Chèn vị trí người dùng vào đầu danh sách tọa độ nếu có
|
|
|
|
|
const coordsArray = allLocations.map(loc => `${loc.longitude},${loc.latitude}`);
|
|
|
|
|
|
|
|
|
|
if (userLocation) {
|
|
|
|
|
coordsArray.unshift(`${userLocation[1]},${userLocation[0]}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const coordsString = coordsArray.join(';');
|
|
|
|
|
|
|
|
|
|
setIsRoutingLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
// 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();
|
|
|
|
|
const segmentPromises: Promise<OSRMRoute[] | null>[] = [];
|
|
|
|
|
for (let i = 0; i < currentPoints.length - 1; i++) {
|
|
|
|
|
const p1 = currentPoints[i];
|
|
|
|
|
const p2 = currentPoints[i + 1];
|
|
|
|
|
const coordsString = `${p1.longitude},${p1.latitude};${p2.longitude},${p2.latitude}`;
|
|
|
|
|
|
|
|
|
|
segmentPromises.push(
|
|
|
|
|
fetch(`https://router.project-osrm.org/route/v1/${travelMode}/${coordsString}?overview=full&geometries=geojson&alternatives=3`) // Yêu cầu tối đa 5 phương án thay thế cho mỗi phân đoạn
|
|
|
|
|
.then(res => {
|
|
|
|
|
if (!res.ok) throw new Error(`OSRM API error: ${res.status}`);
|
|
|
|
|
return res.json();
|
|
|
|
|
})
|
|
|
|
|
.then(data => {
|
|
|
|
|
if (data.code === 'Ok' && data.routes.length > 0) {
|
|
|
|
|
setRoutes(data.routes);
|
|
|
|
|
// Reset về lộ trình đầu tiên khi danh sách điểm đến thay đổi hoàn toàn
|
|
|
|
|
setSelectedRouteIndex(0);
|
|
|
|
|
return data.routes; // Array of OSRMRoute for this segment
|
|
|
|
|
}
|
|
|
|
|
return null; // No routes for this segment
|
|
|
|
|
})
|
|
|
|
|
.catch(error => {
|
|
|
|
|
console.error(`Lỗi lấy lộ trình cho phân đoạn ${i}-${i+1}:`, error);
|
|
|
|
|
return null;
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const allSegmentAlternativesRaw = await Promise.all(segmentPromises);
|
|
|
|
|
const allSegmentAlternatives: OSRMRoute[][] = allSegmentAlternativesRaw.filter((seg): seg is OSRMRoute[] => seg !== null);
|
|
|
|
|
|
|
|
|
|
if (allSegmentAlternatives.length === 0) {
|
|
|
|
|
setRoutes([]);
|
|
|
|
|
setSelectedRouteIndex(0);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Now, construct overall alternative routes from segment alternatives
|
|
|
|
|
// Heuristic: Take the first alternative of each segment to form the primary route.
|
|
|
|
|
// Then, for subsequent overall alternatives, try different alternatives for the first segment,
|
|
|
|
|
// keeping other segments at their primary alternative.
|
|
|
|
|
|
|
|
|
|
const overallAlternativeRoutes: OSRMRoute[] = [];
|
|
|
|
|
// Limit overall alternatives based on the number of alternatives for the first segment, up to 3
|
|
|
|
|
const maxOverallAlternativesToGenerate = 3; // Số lượng lộ trình tổng thể muốn tạo
|
|
|
|
|
|
|
|
|
|
// Heuristic để tạo các lộ trình tổng thể đa dạng hơn:
|
|
|
|
|
// 1. Lộ trình chính (fastest/default cho tất cả các phân đoạn)
|
|
|
|
|
const primarySegmentIndices = allSegmentAlternatives.map(() => 0);
|
|
|
|
|
const primaryCombinedRoute = combineSegmentRoutes(allSegmentAlternatives, primarySegmentIndices);
|
|
|
|
|
if (primaryCombinedRoute) {
|
|
|
|
|
overallAlternativeRoutes.push(primaryCombinedRoute);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. Các lộ trình thay thế: Thử kết hợp các phương án thay thế từ các phân đoạn
|
|
|
|
|
// Ví dụ: Lấy phương án thứ N của mỗi phân đoạn (nếu có), hoặc phương án 0 nếu không có
|
|
|
|
|
for (let altChoice = 1; altChoice < maxOverallAlternativesToGenerate; altChoice++) {
|
|
|
|
|
const selectedSegmentIndices: number[] = allSegmentAlternatives.map(segmentAlts =>
|
|
|
|
|
Math.min(altChoice, segmentAlts.length - 1) // Chọn phương án thứ 'altChoice', hoặc phương án cuối cùng nếu không đủ
|
|
|
|
|
);
|
|
|
|
|
const combinedRoute = combineSegmentRoutes(allSegmentAlternatives, selectedSegmentIndices);
|
|
|
|
|
if (combinedRoute && !overallAlternativeRoutes.some(r => JSON.stringify(r.geometry.coordinates) === JSON.stringify(combinedRoute.geometry.coordinates))) {
|
|
|
|
|
overallAlternativeRoutes.push(combinedRoute);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
setRoutes(overallAlternativeRoutes);
|
|
|
|
|
setSelectedRouteIndex(0); // Always select the first overall alternative by default
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error(`Lỗi lấy lộ trình ${travelMode}:`, error);
|
|
|
|
|
setRoutes([]);
|
|
|
|
|
} finally {
|
|
|
|
|
setIsRoutingLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
fetchRoutes();
|
|
|
|
|
}, [allLocations, travelMode]);
|
|
|
|
|
}, [allLocations, travelMode, userLocation]);
|
|
|
|
|
|
|
|
|
|
// 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(() => {
|
|
|
|
@@ -573,6 +817,45 @@ export const TourDetailPage = ({
|
|
|
|
|
}
|
|
|
|
|
}, [selectedRouteIndex, routes]);
|
|
|
|
|
|
|
|
|
|
// Tính toán thông tin hiển thị cho lộ trình đang chọn để đề xuất cho người dùng
|
|
|
|
|
const selectedRouteInfo = useMemo(() => {
|
|
|
|
|
if (!routes || routes.length === 0 || !routes[selectedRouteIndex]) return null;
|
|
|
|
|
const r = routes[selectedRouteIndex];
|
|
|
|
|
|
|
|
|
|
// Định dạng thời gian di chuyển
|
|
|
|
|
const duration = r.duration;
|
|
|
|
|
const hours = Math.floor(duration / 3600);
|
|
|
|
|
const minutes = Math.round((duration % 3600) / 60);
|
|
|
|
|
const durationStr = hours > 0 ? `${hours}h${minutes}p` : `${minutes}p`;
|
|
|
|
|
|
|
|
|
|
// Xác định nhãn đề xuất: Index 0 thường là lộ trình tối ưu nhất của OSRM (thông dụng nhất)
|
|
|
|
|
// Kiểm tra thêm nếu đây là lộ trình ngắn nhất trong các phương án
|
|
|
|
|
const isShortest = routes.length > 1 && r.distance === Math.min(...routes.map(rt => rt.distance));
|
|
|
|
|
|
|
|
|
|
let label = "Lộ trình";
|
|
|
|
|
if (selectedRouteIndex === 0) label = "Đề xuất";
|
|
|
|
|
else if (isShortest) label = "Ngắn nhất";
|
|
|
|
|
else label = `Lựa chọn ${selectedRouteIndex + 1}`;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
distance: (r.distance / 1000).toFixed(1),
|
|
|
|
|
duration: durationStr,
|
|
|
|
|
label
|
|
|
|
|
};
|
|
|
|
|
}, [routes, selectedRouteIndex]);
|
|
|
|
|
|
|
|
|
|
const handleNavigateToLocation = (location: any) => {
|
|
|
|
|
setMapCenter([location.latitude, location.longitude]);
|
|
|
|
|
setLocateTrigger(prev => prev + 1);
|
|
|
|
|
setIsMapFullscreen(true);
|
|
|
|
|
|
|
|
|
|
notify({
|
|
|
|
|
title: 'Bắt đầu chỉ đường',
|
|
|
|
|
message: `Đang hiển thị lộ trình từ vị trí của bạn đến ${location.name}`,
|
|
|
|
|
type: 'info'
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (initialViewState) {
|
|
|
|
|
setMapCenter(initialViewState.center);
|
|
|
|
@@ -1203,23 +1486,24 @@ export const TourDetailPage = ({
|
|
|
|
|
onQuickNote={(locName: string) => handleQuickNote(locName)}
|
|
|
|
|
onSuccess={() => fetchTour(tourId)}
|
|
|
|
|
isPublicView={isPublicView}
|
|
|
|
|
onNavigate={handleNavigateToLocation}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="h-[60vh] w-full rounded-3xl overflow-hidden shadow-xl border-4 border-white relative">
|
|
|
|
|
<div className="h-[60vh] w-full rounded-3xl overflow-hidden shadow-xl border-4 border-white relative animate-in fade-in duration-500">
|
|
|
|
|
{/* Search Bar Overlay - Thanh tìm kiếm địa điểm trên bản đồ lộ trình */}
|
|
|
|
|
{!isPublicView && (
|
|
|
|
|
<div className="absolute top-4 right-4 z-[1001] w-64 md:w-80">
|
|
|
|
|
<div className="absolute top-3 right-3 z-[1001] w-48 md:w-64">
|
|
|
|
|
<div className="relative group">
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
placeholder="Tìm địa điểm để ghim..."
|
|
|
|
|
className="w-full pl-10 pr-10 py-3 bg-white/95 backdrop-blur-md border border-white/20 rounded-2xl shadow-xl outline-none focus:ring-2 focus:ring-blue-500 transition-all text-sm font-bold"
|
|
|
|
|
placeholder="Tìm địa điểm..."
|
|
|
|
|
className="w-full pl-8 pr-8 py-2 bg-white/95 backdrop-blur-md border border-white/20 rounded-xl shadow-xl outline-none focus:ring-2 focus:ring-blue-500 transition-all text-xs font-bold"
|
|
|
|
|
value={searchQuery}
|
|
|
|
|
onChange={e => handleSearchLocation(e.target.value)}
|
|
|
|
|
/>
|
|
|
|
|
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-blue-500" />
|
|
|
|
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-blue-500" />
|
|
|
|
|
{isSearching ? (
|
|
|
|
|
<Loader2 className="absolute right-3.5 top-1/2 -translate-y-1/2 w-4 h-4 animate-spin text-blue-500" />
|
|
|
|
|
<Loader2 className="absolute right-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 animate-spin text-blue-500" />
|
|
|
|
|
) : searchQuery && (
|
|
|
|
|
<button onClick={() => { setSearchQuery(''); setSearchResults([]); }} className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-red-500 transition-colors">
|
|
|
|
|
<X className="w-4 h-4" />
|
|
|
|
@@ -1253,6 +1537,22 @@ export const TourDetailPage = ({
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Overlay thông tin lộ trình đề xuất (Lộ trình thông dụng nhất) */}
|
|
|
|
|
{selectedRouteInfo && drivingRoute.length > 0 && (
|
|
|
|
|
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-[1001] bg-white/80 backdrop-blur-md px-4 py-2 rounded-2xl shadow-xl border border-white flex items-center gap-3 animate-in fade-in slide-in-from-top-2 duration-500">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Navigation className="w-3.5 h-3.5 text-blue-600 rotate-45" />
|
|
|
|
|
<span className="text-[10px] font-black text-gray-400 uppercase tracking-tighter">{selectedRouteInfo.label}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-2 text-xs font-bold text-blue-700 whitespace-nowrap">
|
|
|
|
|
<span>{selectedRouteInfo.distance} km</span>
|
|
|
|
|
<span className="text-gray-300">•</span>
|
|
|
|
|
<span>{selectedRouteInfo.duration}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<MapContainer
|
|
|
|
|
center={initialViewState?.center || mapCenter}
|
|
|
|
|
zoom={mapZoom}
|
|
|
|
@@ -1270,12 +1570,12 @@ export const TourDetailPage = ({
|
|
|
|
|
{/* Tự động đóng khung Start và End khi dữ liệu thay đổi */}
|
|
|
|
|
<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 */}
|
|
|
|
|
<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 */}
|
|
|
|
|
{userLocation && (
|
|
|
|
|
<Marker position={userLocation} icon={mapIcons.user} zIndexOffset={1000}>
|
|
|
|
@@ -1296,11 +1596,11 @@ export const TourDetailPage = ({
|
|
|
|
|
})
|
|
|
|
|
.map(({ data, index }) => (
|
|
|
|
|
<Polyline
|
|
|
|
|
key={`route-${index}-${index === selectedRouteIndex ? 'active' : 'alt'}-${routeKey}-${routes.length}`}
|
|
|
|
|
key={`polyline-${index}-${index === selectedRouteIndex ? 'active' : 'alt'}-${routeKey}`}
|
|
|
|
|
positions={data.geometry.coordinates.map((c: any) => [c[1], c[0]])}
|
|
|
|
|
color={index === selectedRouteIndex ? "#2563eb" : "#94a3b8"}
|
|
|
|
|
weight={index === selectedRouteIndex ? 6 : 14}
|
|
|
|
|
opacity={index === selectedRouteIndex ? 1 : 0.4}
|
|
|
|
|
weight={index === selectedRouteIndex ? 6 : 12}
|
|
|
|
|
opacity={index === selectedRouteIndex ? 1 : 0.35}
|
|
|
|
|
dashArray={index === selectedRouteIndex ? undefined : "15, 15"}
|
|
|
|
|
smoothFactor={1}
|
|
|
|
|
eventHandlers={{
|
|
|
|
@@ -1323,12 +1623,12 @@ export const TourDetailPage = ({
|
|
|
|
|
},
|
|
|
|
|
mouseover: (e) => {
|
|
|
|
|
if (index !== selectedRouteIndex) {
|
|
|
|
|
(e.target as L.Polyline).setStyle({ opacity: 0.8, weight: 16, color: '#64748b' });
|
|
|
|
|
(e.target as L.Polyline).setStyle({ opacity: 0.7, weight: 14, color: '#64748b' });
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
mouseout: (e) => {
|
|
|
|
|
if (index !== selectedRouteIndex) {
|
|
|
|
|
(e.target as L.Polyline).setStyle({ opacity: 0.4, weight: 14, color: '#94a3b8' });
|
|
|
|
|
(e.target as L.Polyline).setStyle({ opacity: 0.35, weight: 12, color: '#94a3b8' });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
@@ -1437,86 +1737,84 @@ export const TourDetailPage = ({
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 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="absolute top-3 left-3 z-[1001] flex flex-col gap-1.5">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setIsMapControlsOpen(!isMapControlsOpen)}
|
|
|
|
|
className="bg-white/90 backdrop-blur-md p-3 rounded-2xl shadow-xl border border-white text-blue-600 hover:bg-blue-50 transition-all active:scale-95 flex items-center justify-center"
|
|
|
|
|
className="w-9 h-9 bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-white text-blue-600 hover:bg-blue-50 transition-all active:scale-95 flex items-center justify-center shrink-0"
|
|
|
|
|
>
|
|
|
|
|
{isMapControlsOpen ? (
|
|
|
|
|
<X className="w-5 h-5 text-gray-400" />
|
|
|
|
|
<X className="w-4 h-4 text-gray-400" />
|
|
|
|
|
) : (
|
|
|
|
|
travelMode === 'driving' ? <Car className="w-5 h-5" /> :
|
|
|
|
|
travelMode === 'bike' ? <Bike className="w-5 h-5" /> :
|
|
|
|
|
<Footprints className="w-5 h-5" />
|
|
|
|
|
travelMode === 'driving' ? <Car className="w-4 h-4" /> :
|
|
|
|
|
travelMode === 'bike' ? <Bike className="w-4 h-4" /> :
|
|
|
|
|
<Footprints className="w-4 h-4" />
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{isMapControlsOpen && (
|
|
|
|
|
<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-top-2 duration-300">
|
|
|
|
|
<div className="bg-white/90 backdrop-blur-md p-1 rounded-xl shadow-xl border border-white flex flex-col gap-1 animate-in slide-in-from-top-2 duration-300 items-center">
|
|
|
|
|
<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'}`}
|
|
|
|
|
onClick={() => { setTravelMode('driving'); setIsMapControlsOpen(false); }}
|
|
|
|
|
className={`w-8 h-8 flex items-center justify-center rounded-lg 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" />
|
|
|
|
|
<Car className="w-3.5 h-3.5" />
|
|
|
|
|
</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'}`}
|
|
|
|
|
onClick={() => { setTravelMode('bike'); setIsMapControlsOpen(false); }}
|
|
|
|
|
className={`w-8 h-8 flex items-center justify-center rounded-lg 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" />
|
|
|
|
|
<Bike className="w-3.5 h-3.5" />
|
|
|
|
|
</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'}`}
|
|
|
|
|
onClick={() => { setTravelMode('foot'); setIsMapControlsOpen(false); }}
|
|
|
|
|
className={`w-8 h-8 flex items-center justify-center rounded-lg 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" />
|
|
|
|
|
<Footprints className="w-3.5 h-3.5" />
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{/* Nút La bàn / Xoay bản đồ */}
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => {
|
|
|
|
|
if (mapRotation !== 0) {
|
|
|
|
|
setMapRotation(0);
|
|
|
|
|
setIsHeadingMode(false);
|
|
|
|
|
} else {
|
|
|
|
|
setIsHeadingMode(!isHeadingMode);
|
|
|
|
|
}
|
|
|
|
|
const newMode = !isHeadingMode;
|
|
|
|
|
setIsHeadingMode(newMode);
|
|
|
|
|
if (!newMode) setMapRotation(0);
|
|
|
|
|
setIsMapControlsOpen(false);
|
|
|
|
|
}}
|
|
|
|
|
className={`p-2.5 rounded-xl transition-all border-t border-gray-100 mt-1 ${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"}
|
|
|
|
|
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 ? "Khóa hướng Bắc" : "Xoay theo hướng nhìn"}
|
|
|
|
|
>
|
|
|
|
|
<Compass className="w-4 h-4 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>
|
|
|
|
|
|
|
|
|
|
{/* Nút Tìm tôi */}
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setLocateTrigger(prev => prev + 1)}
|
|
|
|
|
onClick={() => { setLocateTrigger(prev => prev + 1); setIsMapControlsOpen(false); }}
|
|
|
|
|
disabled={!userLocation}
|
|
|
|
|
className={`p-2.5 rounded-xl transition-all border-t border-gray-100 mt-1 ${!userLocation ? 'opacity-30 cursor-not-allowed' : 'text-blue-600 hover:bg-blue-50'}`}
|
|
|
|
|
className={`w-8 h-8 flex items-center justify-center rounded-lg transition-all border-t border-gray-100 mt-0.5 ${!userLocation ? 'opacity-30 cursor-not-allowed' : 'text-blue-600 hover:bg-blue-50'}`}
|
|
|
|
|
title="Vị trí của tôi"
|
|
|
|
|
>
|
|
|
|
|
<LocateFixed className="w-4 h-4" />
|
|
|
|
|
<LocateFixed className="w-3.5 h-3.5" />
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{/* Danh sách lộ trình rút gọn */}
|
|
|
|
|
{routes.length > 0 && (
|
|
|
|
|
<div className="pt-2 border-t border-gray-100 flex flex-col gap-1 min-w-[120px]">
|
|
|
|
|
<div className="text-[9px] font-black text-gray-400 uppercase tracking-tighter px-1 mb-1">Lộ trình</div>
|
|
|
|
|
<div className="pt-1 border-t border-gray-100 flex flex-col gap-1">
|
|
|
|
|
<div className="flex flex-col gap-1 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 ${
|
|
|
|
|
onClick={() => { setSelectedRouteIndex(idx); setIsMapControlsOpen(false); }}
|
|
|
|
|
title={`${(route.distance / 1000).toFixed(1)} km - ${Math.round(route.duration / 60)}p`}
|
|
|
|
|
className={`w-8 h-8 flex items-center justify-center rounded-lg transition-all border ${
|
|
|
|
|
selectedRouteIndex === idx
|
|
|
|
|
? 'bg-blue-600 text-white border-blue-600 shadow-sm'
|
|
|
|
|
: 'bg-gray-50 text-gray-600 border-gray-100'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
<div className="text-[10px] font-bold">#{idx + 1} - {(route.distance / 1000).toFixed(1)} km</div>
|
|
|
|
|
<span className="text-[10px] font-bold">{idx + 1}</span>
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
@@ -1881,12 +2179,112 @@ export const TourDetailPage = ({
|
|
|
|
|
if (activeTab === 'plan') setIsAddLocationOpen(true);
|
|
|
|
|
if (activeTab === 'photo' && canUploadPhoto) setIsAddPhotoOpen(true);
|
|
|
|
|
}}
|
|
|
|
|
className="bg-blue-600 text-white px-6 py-3 rounded-full shadow-lg hover:bg-blue-700 transition-all flex items-center font-bold">
|
|
|
|
|
className="bg-blue-600 text-white px-4 py-2.5 rounded-full shadow-lg hover:bg-blue-700 transition-all flex items-center font-bold text-sm">
|
|
|
|
|
{activeTab === 'plan' ? 'Thêm địa điểm' : activeTab === 'expense' ? 'Thêm chi phí' : 'Đăng ảnh'}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Fullscreen Map Overlay - Hiển thị khi click vào nút trạng thái/chỉ đường */}
|
|
|
|
|
{isMapFullscreen && (
|
|
|
|
|
<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 */}
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setIsMapFullscreen(false)}
|
|
|
|
|
className="absolute top-4 left-4 z-[1002] w-11 h-11 bg-white/90 backdrop-blur-md rounded-full shadow-2xl flex items-center justify-center border border-white/20 hover:bg-white transition-all active:scale-95 group"
|
|
|
|
|
title="Đóng bản đồ"
|
|
|
|
|
>
|
|
|
|
|
<X className="w-6 h-6 text-gray-800 group-hover:rotate-90 transition-transform duration-300" />
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{/* Transparent Top Bar Label - Glassmorphism style */}
|
|
|
|
|
{selectedRouteInfo && drivingRoute.length > 0 && (
|
|
|
|
|
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-[1001] bg-white/20 backdrop-blur-lg px-6 py-2.5 rounded-full border border-white/30 flex items-center gap-4 animate-in fade-in slide-in-from-top-2 duration-500 shadow-xl">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Navigation className="w-4 h-4 text-blue-600 rotate-45 fill-blue-600" />
|
|
|
|
|
<span className="text-[11px] font-black text-gray-700 uppercase tracking-widest">{selectedRouteInfo.label}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-3 text-sm font-black text-blue-700 whitespace-nowrap">
|
|
|
|
|
<span>{selectedRouteInfo.distance} km</span>
|
|
|
|
|
<span className="text-gray-400 opacity-40">•</span>
|
|
|
|
|
<span>{selectedRouteInfo.duration}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<MapContainer
|
|
|
|
|
center={mapCenter}
|
|
|
|
|
zoom={mapZoom}
|
|
|
|
|
className="h-full w-full"
|
|
|
|
|
preferCanvas={true}
|
|
|
|
|
>
|
|
|
|
|
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
|
|
|
|
<MapTourBounds locations={allLocations} />
|
|
|
|
|
<MapRotationHandler rotation={mapRotation} />
|
|
|
|
|
<RecenterUser position={userLocation} trigger={locateTrigger} />
|
|
|
|
|
|
|
|
|
|
{userLocation && (
|
|
|
|
|
<Marker position={userLocation} icon={mapIcons.user} />
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{routes.length > 0 ? (
|
|
|
|
|
routes.map((r, i) => (
|
|
|
|
|
<Polyline
|
|
|
|
|
key={`fs-poly-${i}`}
|
|
|
|
|
positions={r.geometry.coordinates.map((c: any) => [c[1], c[0]])}
|
|
|
|
|
color={i === selectedRouteIndex ? "#2563eb" : "#94a3b8"}
|
|
|
|
|
weight={i === selectedRouteIndex ? 6 : 4}
|
|
|
|
|
opacity={i === selectedRouteIndex ? 1 : 0.4}
|
|
|
|
|
/>
|
|
|
|
|
))
|
|
|
|
|
) : allLocations.length > 1 && (
|
|
|
|
|
<Polyline positions={allLocations.map(l => [l.latitude, l.longitude]) as any} color="#3b82f6" weight={3} dashArray="5, 10" />
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{allLocations.map((loc: any, index: number) => {
|
|
|
|
|
const isStart = startPoint && startPoint.id === loc.id;
|
|
|
|
|
const isEnd = endPoint && endPoint.id === loc.id;
|
|
|
|
|
return (
|
|
|
|
|
<Marker key={`fs-marker-${loc.id}`} position={[loc.latitude, loc.longitude]} icon={isStart ? mapIcons.start : isEnd ? mapIcons.end : mapIcons.visit}>
|
|
|
|
|
<Popup>
|
|
|
|
|
<div className="font-bold">{loc.name}</div>
|
|
|
|
|
</Popup>
|
|
|
|
|
</Marker>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</MapContainer>
|
|
|
|
|
|
|
|
|
|
{/* Overlay điều khiển trên bản đồ toàn màn hình */}
|
|
|
|
|
<div className="absolute top-4 right-4 z-[1001] flex flex-col gap-2">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setIsMapControlsOpen(!isMapControlsOpen)}
|
|
|
|
|
className="w-11 h-11 bg-white/90 backdrop-blur-md rounded-2xl shadow-xl border border-white text-blue-600 flex items-center justify-center transition-all active:scale-95"
|
|
|
|
|
>
|
|
|
|
|
{travelMode === 'driving' ? <Car className="w-5 h-5" /> : travelMode === 'bike' ? <Bike className="w-5 h-5" /> : <Footprints className="w-5 h-5" />}
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{isMapControlsOpen && (
|
|
|
|
|
<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-top-2">
|
|
|
|
|
<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('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>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Add Member Modal */}
|
|
|
|
|
{currentTour && (
|
|
|
|
|
<AddMemberModal
|
|
|
|
|