feat: Tạo trang bản đồ chỉ đường độc lập TourNavigationPage
- Thêm trang TourNavigationPage.tsx hiển thị full màn hình với top-bar, bản đồ fullscreen và nút la bàn nổi ở góc dưới bên phải - Cập nhật ItineraryTimeline.tsx: thay thế LocationNavigationModal bằng callback onOpenNavigationPage để chuyển đến trang mới - Đăng ký trang tourNavigation trong App.tsx với navigationPayload - Cập nhật TourDetailPage.tsx truyền onOpenNavigationPage xuống - Thêm INSTALL_MAP.md hướng dẫn thực hiện tính năng
This commit is contained in:
@@ -1,2 +1,4 @@
|
|||||||
.env
|
.env
|
||||||
node_modules
|
node_modules
|
||||||
|
server/dist
|
||||||
|
dist
|
||||||
|
|||||||
+196
@@ -0,0 +1,196 @@
|
|||||||
|
# To AI Agent: Create Standalone Navigation Route and Page for Mobile Map Viewport
|
||||||
|
|
||||||
|
## 1. Context & Architectural Goal
|
||||||
|
We are replacing the modal-based map system. On mobile viewports, when a user clicks the circular timeline node next to a location card, the application must transition entirely to a **new, dedicated standalone page** rather than opening a popup.
|
||||||
|
|
||||||
|
This architectural shift prevents the map engine from being trapped inside parent stacking contexts (accordions/scroll wrappers) or restricted app layout frames, allowing the map to take up 100% of the mobile viewport safely.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Structural Requirements for the New Page Layout
|
||||||
|
The new page (`TourNavigationPage.tsx`) must strictly render only two visual zones:
|
||||||
|
1. **Top-bar (Zone 1):** A sticky/fixed header containing a back button (`<`) to return to the previous tour timeline, and the text title of the active Tour.
|
||||||
|
2. **Fullscreen Map (Zone 2):** Extending from the absolute bottom edge of the Top-bar all the way to the bottom edge of the browser viewport (`100vw` by `100vh minus header height`).
|
||||||
|
3. **Compass Floating Action Button:** A separate, high-priority circular button placed explicitly at the **bottom-right corner** (`bottom-6 right-6`) floating directly on top of the map grid tiles.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Step-by-Step Implementation Refactoring Blueprint
|
||||||
|
|
||||||
|
### Step 1: Register the Standalone Route
|
||||||
|
Locate your central application routing file (e.g., `frontend/src/App.tsx`, `backend/src/main.ts`, or `routes.tsx`) and register the isolated navigation path:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Insert this route path inside your React Router / routing array configuration
|
||||||
|
<Route path="/tour/:id/navigation" element={<TourNavigationPage />} />
|
||||||
|
|
||||||
|
### Step 2: Update Trigger Behavior in ItineraryTimeline.tsx
|
||||||
|
Locate the white circular node button component. Convert the click handler from opening a modal state to a standard React Router redirection hook, passing all geospatial metadata safely within the history state container:
|
||||||
|
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { id: tourId } = useParams();
|
||||||
|
|
||||||
|
const handleTriggerNavigation = (location: any, tourTitle: string) => {
|
||||||
|
if (!location.latitude || !location.longitude) {
|
||||||
|
alert("Địa điểm này chưa được cấu hình tọa độ GPS chính xác.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
alert("Thiết bị không hỗ trợ định vị GPS toàn cầu.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request device coordinates before firing routing transitions
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(position) => {
|
||||||
|
// Transition out of the timeline and push spatial coordinates via state pack
|
||||||
|
navigate(`/tour/${tourId}/navigation`, {
|
||||||
|
state: {
|
||||||
|
origin: {
|
||||||
|
lat: position.coords.latitude,
|
||||||
|
lng: position.coords.longitude
|
||||||
|
},
|
||||||
|
destination: {
|
||||||
|
lat: parseFloat(location.latitude),
|
||||||
|
lng: parseFloat(location.longitude),
|
||||||
|
name: location.name || "Điểm đến"
|
||||||
|
},
|
||||||
|
tourTitle: tourTitle || "Chi tiết hành trình"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
alert("Vui lòng bật quyền truy cập vị trí (GPS) trên trình duyệt để tìm đường.");
|
||||||
|
},
|
||||||
|
{ enableHighAccuracy: true, timeout: 7000 }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
### Step 3: Create the New Page File (TourNavigationPage.tsx)
|
||||||
|
Create a brand new separate file at pages/TourNavigationPage.tsx. Enforce a flat layout architecture free from layout decorators or panel decorators:
|
||||||
|
|
||||||
|
import React, { useEffect, useState, useRef } from 'react';
|
||||||
|
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { ChevronLeft, Compass } from 'lucide-react';
|
||||||
|
|
||||||
|
export const TourNavigationPage: React.FC = () => {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { id: tourId } = useParams();
|
||||||
|
|
||||||
|
const [isCompassActive, setIsCompassActive] = useState(false);
|
||||||
|
const mapRef = useRef<any>(null); // Anchor pointer to hold your initialized Map instance
|
||||||
|
const watchIdRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const routeData = location.state?.origin && location.state?.destination ? location.state : null;
|
||||||
|
|
||||||
|
// Security Rail Guard: Bounce user back to timeline overview if accessed via raw URL parameters
|
||||||
|
useEffect(() => {
|
||||||
|
if (!routeData) {
|
||||||
|
console.warn("Direct route access missing state payload variables. Backtracking.");
|
||||||
|
navigate(`/tour/${tourId}`);
|
||||||
|
}
|
||||||
|
}, [routeData, navigate, tourId]);
|
||||||
|
|
||||||
|
// Compass Toggle Control Loops (Auto-rotation and Gesture cancellation)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapRef.current || !routeData) return;
|
||||||
|
const map = mapRef.current;
|
||||||
|
|
||||||
|
const disableCompassOnGesture = () => {
|
||||||
|
if (isCompassActive) {
|
||||||
|
console.log("User manual gesture detected. Detaching compass auto-centering.");
|
||||||
|
setIsCompassActive(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen to manual map manipulations to automatically drop tracking state
|
||||||
|
map.on('movestart', disableCompassOnGesture);
|
||||||
|
map.on('zoomstart', disableCompassOnGesture);
|
||||||
|
map.on('dragstart', disableCompassOnGesture);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.off('movestart', disableCompassOnGesture);
|
||||||
|
map.off('zoomstart', disableCompassOnGesture);
|
||||||
|
map.off('dragstart', disableCompassOnGesture);
|
||||||
|
};
|
||||||
|
}, [isCompassActive, routeData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isCompassActive && navigator.geolocation) {
|
||||||
|
watchIdRef.current = navigator.geolocation.watchPosition(
|
||||||
|
(pos) => {
|
||||||
|
if (mapRef.current) {
|
||||||
|
// Dynamically center and turn map bearing heading to follow direction of travel
|
||||||
|
mapRef.current.easeTo({
|
||||||
|
center: [pos.coords.longitude, pos.coords.latitude],
|
||||||
|
bearing: pos.coords.heading || 0,
|
||||||
|
duration: 800
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(err) => console.error(err),
|
||||||
|
{ enableHighAccuracy: true }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (watchIdRef.current !== null) {
|
||||||
|
navigator.geolocation.clearWatch(watchIdRef.current);
|
||||||
|
watchIdRef.current = null;
|
||||||
|
}
|
||||||
|
if (mapRef.current) mapRef.current.easeTo({ bearing: 0, duration: 400 });
|
||||||
|
}
|
||||||
|
return () => { if (watchIdRef.current !== null) navigator.geolocation.clearWatch(watchIdRef.current); };
|
||||||
|
}, [isCompassActive]);
|
||||||
|
|
||||||
|
if (!routeData) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-screen h-screen min-h-screen bg-slate-950 flex flex-col overflow-hidden select-none text-white antialiased">
|
||||||
|
|
||||||
|
{/* PART 1: TOP-BAR HEADER ZONE (Isolated from Sub-Tabs layout skins) */}
|
||||||
|
<div className="w-full bg-[#1e293b]/95 backdrop-blur-md border-b border-slate-800 px-4 py-3.5 flex items-center gap-3 z-50">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/tour/${tourId}`)}
|
||||||
|
className="text-slate-300 hover:text-white p-1 rounded-lg hover:bg-slate-800 transition-colors"
|
||||||
|
title="Quay lại danh sách lộ trình"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
<h2 className="text-sm font-bold truncate tracking-wide text-slate-100">
|
||||||
|
{routeData.tourTitle}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PART 2: FULLSCREEN MAP EDGE-TO-EDGE CONTAINER */}
|
||||||
|
<div className="w-full h-full relative flex-1">
|
||||||
|
{/* Map injection Canvas - Absolutely no rounded boundaries or wrapper cushions */}
|
||||||
|
<div id="dedicated-page-map-canvas" className="w-full h-full absolute inset-0 rounded-none border-none" />
|
||||||
|
|
||||||
|
{/* PART 3: FLOATING INTERACTIVE COMPASS ACTION BUTTON */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsCompassActive(!isCompassActive)}
|
||||||
|
className={`absolute bottom-8 right-6 z-40 w-14 h-14 rounded-full border-2 flex items-center justify-center shadow-2xl transition-all active:scale-95 ${
|
||||||
|
isCompassActive
|
||||||
|
? 'bg-blue-600 border-blue-400 text-white animate-pulse'
|
||||||
|
: 'bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-blue-400'
|
||||||
|
}`}
|
||||||
|
title="Chuyển đổi chế độ xoay bản đồ tự động theo hướng di chuyển"
|
||||||
|
>
|
||||||
|
<Compass className="w-7 h-7" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
## 4. Verification & Quality Acceptance Criteria for AI Agent
|
||||||
|
|
||||||
|
[ ] Eradication of Inset Borders: The map canvas must draw edge-to-edge on mobile browser views without exposing structural margins or inner framing borders.
|
||||||
|
|
||||||
|
[ ] Tab Bar Exclusion: The secondary menu header rows ("Lộ trình, Chi phí, Ảnh...") must be 100% hidden on this path layout.
|
||||||
|
|
||||||
|
[ ] Lifecycle Integrity Test: Verify that performing a quick finger-drag pan gesture on the map surface immediately turns off the pulse mode state of the bottom-right Compass button.
|
||||||
+27
-7
@@ -9,6 +9,7 @@ import { JoinTourPage } from './pages/JoinTourPage';
|
|||||||
import { MemberDashboard } from './pages/MemberDashboard';
|
import { MemberDashboard } from './pages/MemberDashboard';
|
||||||
import { AdminDashboard } from './pages/AdminDashboard';
|
import { AdminDashboard } from './pages/AdminDashboard';
|
||||||
import { ShareJourneyPage } from './pages/ShareJourneyPage';
|
import { ShareJourneyPage } from './pages/ShareJourneyPage';
|
||||||
|
import { TourNavigationPage } from './pages/TourNavigationPage';
|
||||||
import { ConfirmProvider } from './hooks/useConfirm';
|
import { ConfirmProvider } from './hooks/useConfirm';
|
||||||
import { NotificationProvider } from './hooks/useNotification';
|
import { NotificationProvider } from './hooks/useNotification';
|
||||||
|
|
||||||
@@ -21,12 +22,13 @@ function App() {
|
|||||||
|
|
||||||
const [user, setUser] = useState<any>(null);
|
const [user, setUser] = useState<any>(null);
|
||||||
const [shareJourneyToken, setShareJourneyToken] = useState<string | null>(journeyTokenVal);
|
const [shareJourneyToken, setShareJourneyToken] = useState<string | null>(journeyTokenVal);
|
||||||
const [currentPage, setCurrentPage] = useState<'landing' | 'dashboard' | 'admin' | 'explore' | 'tourDetail' | 'signup' | 'myPhotos' | 'notes' | 'joinTour' | 'shareJourney'>(
|
const [currentPage, setCurrentPage] = useState<'landing' | 'dashboard' | 'admin' | 'explore' | 'tourDetail' | 'signup' | 'myPhotos' | 'notes' | 'joinTour' | 'shareJourney' | 'tourNavigation'>(
|
||||||
journeyTokenVal ? 'shareJourney' : (viewTourId ? 'tourDetail' : (window.location.pathname === '/join-tour' || params.has('token') ? 'joinTour' : 'landing'))
|
journeyTokenVal ? 'shareJourney' : (viewTourId ? 'tourDetail' : (window.location.pathname === '/join-tour' || params.has('token') ? 'joinTour' : 'landing'))
|
||||||
);
|
);
|
||||||
const [currentTourId, setCurrentTourId] = useState<string | null>(viewTourId);
|
const [currentTourId, setCurrentTourId] = useState<string | null>(viewTourId);
|
||||||
const [isPublicTourView, setIsPublicTourView] = useState(!!viewTourId);
|
const [isPublicTourView, setIsPublicTourView] = useState(!!viewTourId);
|
||||||
const [previousPage, setPreviousPage] = useState<'explore' | 'dashboard' | 'landing'>('explore');
|
const [previousPage, setPreviousPage] = useState<'explore' | 'dashboard' | 'landing'>('explore');
|
||||||
|
const [navigationPayload, setNavigationPayload] = useState<{ tourId: string; origin: { lat: number; lng: number }; destination: { lat: number; lng: number; name: string }; tourTitle: string } | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
@@ -122,26 +124,30 @@ function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleBackFromTourDetail = () => {
|
const handleBackFromTourDetail = () => {
|
||||||
// Check if this was a public view BEFORE clearing the flag
|
|
||||||
const wasPublicView = isPublicTourView;
|
const wasPublicView = isPublicTourView;
|
||||||
|
|
||||||
setCurrentTourId(null);
|
setCurrentTourId(null);
|
||||||
setIsPublicTourView(false);
|
setIsPublicTourView(false);
|
||||||
|
|
||||||
// If user was viewing a public tour, redirect to index/landing page
|
|
||||||
// Otherwise redirect based on authentication and previous page
|
|
||||||
if (wasPublicView) {
|
if (wasPublicView) {
|
||||||
// Public tour view - always redirect to index/landing
|
|
||||||
setCurrentPage('landing');
|
setCurrentPage('landing');
|
||||||
} else if (user) {
|
} else if (user) {
|
||||||
// Authenticated user viewing their own tour - go back to previous page
|
|
||||||
setCurrentPage(previousPage);
|
setCurrentPage(previousPage);
|
||||||
} else {
|
} else {
|
||||||
// Not authenticated and not public view - go to landing
|
|
||||||
setCurrentPage('landing');
|
setCurrentPage('landing');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOpenNavigationPage = (payload: { tourId: string; origin: { lat: number; lng: number }; destination: { lat: number; lng: number; name: string }; tourTitle: string }) => {
|
||||||
|
setNavigationPayload(payload);
|
||||||
|
setCurrentPage('tourNavigation');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBackFromNavigation = () => {
|
||||||
|
setNavigationPayload(null);
|
||||||
|
setCurrentPage('tourDetail');
|
||||||
|
};
|
||||||
|
|
||||||
const handleBackFromSignup = () => {
|
const handleBackFromSignup = () => {
|
||||||
const pendingInviteToken = localStorage.getItem('pendingInviteToken');
|
const pendingInviteToken = localStorage.getItem('pendingInviteToken');
|
||||||
if (pendingInviteToken) {
|
if (pendingInviteToken) {
|
||||||
@@ -259,6 +265,7 @@ function App() {
|
|||||||
onBack={handleBackFromTourDetail}
|
onBack={handleBackFromTourDetail}
|
||||||
isPublicView={isPublicTourView}
|
isPublicView={isPublicTourView}
|
||||||
onOpenNotes={() => setCurrentPage('notes')}
|
onOpenNotes={() => setCurrentPage('notes')}
|
||||||
|
onOpenNavigationPage={handleOpenNavigationPage}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -267,6 +274,19 @@ function App() {
|
|||||||
return <MyNotePage tourId={currentTourId!} onBack={() => setCurrentPage('tourDetail')} />;
|
return <MyNotePage tourId={currentTourId!} onBack={() => setCurrentPage('tourDetail')} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (currentPage === 'tourNavigation' && navigationPayload) {
|
||||||
|
return (
|
||||||
|
<TourNavigationPage
|
||||||
|
tourId={navigationPayload.tourId}
|
||||||
|
routeData={{
|
||||||
|
origin: navigationPayload.origin,
|
||||||
|
destination: navigationPayload.destination
|
||||||
|
}}
|
||||||
|
onBack={handleBackFromNavigation}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (currentPage === 'explore') {
|
if (currentPage === 'explore') {
|
||||||
return (
|
return (
|
||||||
<ExploreMap
|
<ExploreMap
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useTourStore } from '@/store/useTourStore';
|
|||||||
import { useConfirm } from '@/hooks/useConfirm';
|
import { useConfirm } from '@/hooks/useConfirm';
|
||||||
import { useNotification } from '@/hooks/useNotification';
|
import { useNotification } from '@/hooks/useNotification';
|
||||||
import { CommentModal } from '@/components/CommentModal';
|
import { CommentModal } from '@/components/CommentModal';
|
||||||
import { LocationNavigationModal } from '@/components/LocationNavigationModal';
|
|
||||||
|
|
||||||
const TimeVariance = ({ planned, actual }: { planned: string, actual: string | null }) => {
|
const TimeVariance = ({ planned, actual }: { planned: string, actual: string | null }) => {
|
||||||
if (!actual) return null;
|
if (!actual) return null;
|
||||||
@@ -51,6 +50,7 @@ export const ItineraryTimeline = ({
|
|||||||
onEditLocation?: (location: any) => void,
|
onEditLocation?: (location: any) => void,
|
||||||
onQuickNote?: (data: { legId: string; location: any; leg: any }) => void,
|
onQuickNote?: (data: { legId: string; location: any; leg: any }) => void,
|
||||||
onNavigate?: (location: any) => void,
|
onNavigate?: (location: any) => void,
|
||||||
|
onOpenNavigationPage?: (routeData: { origin: { lat: number; lng: number }; destination: { lat: number; lng: number; name: string }; tourTitle: string }) => void,
|
||||||
onSuccess?: () => void,
|
onSuccess?: () => void,
|
||||||
isPublicView?: boolean
|
isPublicView?: boolean
|
||||||
}) => {
|
}) => {
|
||||||
@@ -78,11 +78,6 @@ export const ItineraryTimeline = ({
|
|||||||
|
|
||||||
// State to track single expanded stage (exclusive single-expansion mode)
|
// State to track single expanded stage (exclusive single-expansion mode)
|
||||||
const [expandedStageId, setExpandedStageId] = useState<string | null>(legs.length > 0 ? legs[0]?.id : null);
|
const [expandedStageId, setExpandedStageId] = useState<string | null>(legs.length > 0 ? legs[0]?.id : null);
|
||||||
const [navRouteData, setNavRouteData] = useState<{
|
|
||||||
origin: { lat: number; lng: number } | null;
|
|
||||||
destination: { lat: number; lng: number; name: string } | null;
|
|
||||||
}>({ origin: null, destination: null });
|
|
||||||
const [isNavModalOpen, setIsNavModalOpen] = useState(false);
|
|
||||||
|
|
||||||
const toggleStageExpanded = (legId: string) => {
|
const toggleStageExpanded = (legId: string) => {
|
||||||
// Exclusive mode: if clicking the same stage, close it. Otherwise, open only the clicked one.
|
// Exclusive mode: if clicking the same stage, close it. Otherwise, open only the clicked one.
|
||||||
@@ -146,7 +141,7 @@ export const ItineraryTimeline = ({
|
|||||||
|
|
||||||
navigator.geolocation.getCurrentPosition(
|
navigator.geolocation.getCurrentPosition(
|
||||||
(position) => {
|
(position) => {
|
||||||
setNavRouteData({
|
onOpenNavigationPage?.({
|
||||||
origin: {
|
origin: {
|
||||||
lat: position.coords.latitude,
|
lat: position.coords.latitude,
|
||||||
lng: position.coords.longitude
|
lng: position.coords.longitude
|
||||||
@@ -155,9 +150,9 @@ export const ItineraryTimeline = ({
|
|||||||
lat: parseFloat(location.latitude),
|
lat: parseFloat(location.latitude),
|
||||||
lng: parseFloat(location.longitude),
|
lng: parseFloat(location.longitude),
|
||||||
name: location.name || "Điểm đến chọn sẵn"
|
name: location.name || "Điểm đến chọn sẵn"
|
||||||
}
|
},
|
||||||
|
tourTitle: currentTour?.title || "Chi tiết hành trình"
|
||||||
});
|
});
|
||||||
setIsNavModalOpen(true);
|
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
console.error("Error fetching native geolocation metrics:", error);
|
console.error("Error fetching native geolocation metrics:", error);
|
||||||
@@ -763,11 +758,6 @@ export const ItineraryTimeline = ({
|
|||||||
onCommentAdded={() => handleCommentIncrement(commentLocationId)}
|
onCommentAdded={() => handleCommentIncrement(commentLocationId)}
|
||||||
onCommentDeleted={() => handleCommentDecrement(commentLocationId)}
|
onCommentDeleted={() => handleCommentDecrement(commentLocationId)}
|
||||||
/>
|
/>
|
||||||
<LocationNavigationModal
|
|
||||||
isOpen={isNavModalOpen}
|
|
||||||
onClose={() => setIsNavModalOpen(false)}
|
|
||||||
routeData={navRouteData}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -348,12 +348,14 @@ export const TourDetailPage = ({
|
|||||||
onBack,
|
onBack,
|
||||||
tourId,
|
tourId,
|
||||||
isPublicView = false,
|
isPublicView = false,
|
||||||
onOpenNotes
|
onOpenNotes,
|
||||||
|
onOpenNavigationPage
|
||||||
}: {
|
}: {
|
||||||
onBack: () => void,
|
onBack: () => void,
|
||||||
tourId: string,
|
tourId: string,
|
||||||
isPublicView?: boolean,
|
isPublicView?: boolean,
|
||||||
onOpenNotes?: () => void
|
onOpenNotes?: () => void,
|
||||||
|
onOpenNavigationPage?: (routeData: { origin: { lat: number; lng: number }; destination: { lat: number; lng: number; name: string }; tourTitle: string }) => void
|
||||||
}) => {
|
}) => {
|
||||||
// Tách biệt các state và actions để tối ưu performance - Chuyển lên đầu để tránh lỗi initialization
|
// Tách biệt các state và actions để tối ưu performance - Chuyển lên đầu để tránh lỗi initialization
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -2232,19 +2234,17 @@ export const TourDetailPage = ({
|
|||||||
setEditingLocation(loc);
|
setEditingLocation(loc);
|
||||||
setTargetLegId(loc.legId);
|
setTargetLegId(loc.legId);
|
||||||
setMapCenter([loc.latitude, loc.longitude]);
|
setMapCenter([loc.latitude, loc.longitude]);
|
||||||
|
|
||||||
// Kiểm tra xem địa điểm đang sửa có phải là điểm mốc đặc biệt không (dựa trên timestamp 1970)
|
|
||||||
const isStart = loc.plannedStart && new Date(loc.plannedStart).getTime() === 0;
|
const isStart = loc.plannedStart && new Date(loc.plannedStart).getTime() === 0;
|
||||||
const isEnd = loc.plannedEnd && new Date(loc.plannedEnd).getTime() === 0;
|
const isEnd = loc.plannedEnd && new Date(loc.plannedEnd).getTime() === 0;
|
||||||
setIsStartPointAction(!!isStart);
|
setIsStartPointAction(!!isStart);
|
||||||
setIsEndPointAction(!!isEnd);
|
setIsEndPointAction(!!isEnd);
|
||||||
|
|
||||||
setIsAddLocationOpen(true);
|
setIsAddLocationOpen(true);
|
||||||
}}
|
}}
|
||||||
onQuickNote={(data) => handleQuickNote(data)}
|
onQuickNote={(data) => handleQuickNote(data)}
|
||||||
onSuccess={() => fetchTour(tourId)}
|
onSuccess={() => fetchTour(tourId)}
|
||||||
isPublicView={isPublicView}
|
isPublicView={isPublicView}
|
||||||
onNavigate={handleNavigateToLocation}
|
onNavigate={handleNavigateToLocation}
|
||||||
|
onOpenNavigationPage={onOpenNavigationPage}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-[60vh] w-full rounded-3xl overflow-hidden shadow-xl border-4 border-white relative animate-in fade-in duration-500">
|
<div className="h-[60vh] w-full rounded-3xl overflow-hidden shadow-xl border-4 border-white relative animate-in fade-in duration-500">
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||||
|
import { MapContainer, TileLayer, Marker, Polyline, Popup, useMap, useMapEvents } from 'react-leaflet';
|
||||||
|
import L from 'leaflet';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
import { ChevronLeft, Compass } from 'lucide-react';
|
||||||
|
|
||||||
|
interface NavigationRouteData {
|
||||||
|
origin: { lat: number; lng: number };
|
||||||
|
destination: { lat: number; lng: number; name: string };
|
||||||
|
tourTitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TourNavigationPageProps {
|
||||||
|
tourId: string;
|
||||||
|
routeData: NavigationRouteData | null;
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FitBounds = ({ coords }: { coords: [number, number][] }) => {
|
||||||
|
const map = useMap();
|
||||||
|
useEffect(() => {
|
||||||
|
if (coords.length > 0) {
|
||||||
|
map.fitBounds(coords, { padding: [60, 60], maxZoom: 15 });
|
||||||
|
}
|
||||||
|
}, [map, coords]);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MapRotationHandler = ({ rotation }: { rotation: number }) => {
|
||||||
|
const map = useMap();
|
||||||
|
const cumulativeRotationRef = useRef(0);
|
||||||
|
const prevRotationRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = map.getContainer();
|
||||||
|
container.style.transformOrigin = 'center center';
|
||||||
|
container.style.willChange = 'transform';
|
||||||
|
|
||||||
|
if (rotation === 0) {
|
||||||
|
cumulativeRotationRef.current = 0;
|
||||||
|
prevRotationRef.current = 0;
|
||||||
|
container.style.transform = 'rotate(0deg) scale(1)';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let delta = rotation - prevRotationRef.current;
|
||||||
|
if (delta > 180) delta -= 360;
|
||||||
|
else if (delta < -180) delta += 360;
|
||||||
|
|
||||||
|
cumulativeRotationRef.current += delta;
|
||||||
|
prevRotationRef.current = rotation;
|
||||||
|
|
||||||
|
container.style.transform = `rotate(${cumulativeRotationRef.current}deg) scale(2.2) translateZ(0)`;
|
||||||
|
container.style.transition = 'transform 0.2s cubic-bezier(0.25, 0.1, 0.25, 1)';
|
||||||
|
}, [rotation, map]);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CompassInteractionDetector = ({
|
||||||
|
onUserInteraction
|
||||||
|
}: {
|
||||||
|
onUserInteraction: () => void;
|
||||||
|
}) => {
|
||||||
|
useMapEvents({
|
||||||
|
movestart: onUserInteraction,
|
||||||
|
zoomstart: onUserInteraction,
|
||||||
|
dragstart: onUserInteraction,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MapRefSetter = ({ mapRef }: { mapRef: React.MutableRefObject<L.Map | null> }) => {
|
||||||
|
const map = useMap();
|
||||||
|
useEffect(() => {
|
||||||
|
mapRef.current = map;
|
||||||
|
}, [map, mapRef]);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TourNavigationPage: React.FC<TourNavigationPageProps> = ({ tourId, routeData, onBack }) => {
|
||||||
|
const [isCompassActive, setIsCompassActive] = useState(false);
|
||||||
|
const [currentHeading, setCurrentHeading] = useState(0);
|
||||||
|
const [routeGeometry, setRouteGeometry] = useState<[number, number][] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const mapRef = useRef<L.Map | null>(null);
|
||||||
|
const watchIdRef = useRef<number | null>(null);
|
||||||
|
const fetchLockRef = useRef(false);
|
||||||
|
|
||||||
|
const originLat = routeData?.origin?.lat;
|
||||||
|
const originLng = routeData?.origin?.lng;
|
||||||
|
const destLat = routeData?.destination?.lat;
|
||||||
|
const destLng = routeData?.destination?.lng;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!routeData || !originLat || !originLng || !destLat || !destLng) {
|
||||||
|
setRouteGeometry(null);
|
||||||
|
setError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fetchLockRef.current) return;
|
||||||
|
|
||||||
|
const calculateOptimalRoute = async () => {
|
||||||
|
try {
|
||||||
|
fetchLockRef.current = true;
|
||||||
|
const url = `https://router.project-osrm.org/route/v1/driving/${originLng},${originLat};${destLng},${destLat}?overview=full&geometries=geojson`;
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`OSRM error: ${res.status}`);
|
||||||
|
const data: { code: string; routes: Array<{ geometry: { coordinates: number[][] } }> } = await res.json();
|
||||||
|
if (data.code !== 'Ok' || !data.routes?.length) throw new Error('Không tìm thấy lộ trình phù hợp');
|
||||||
|
|
||||||
|
const coords = data.routes[0].geometry.coordinates.map((c: number[]) => [c[1], c[0]] as [number, number]);
|
||||||
|
setRouteGeometry(coords);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
fetchLockRef.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
calculateOptimalRoute();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
fetchLockRef.current = false;
|
||||||
|
};
|
||||||
|
}, [routeData, originLat, originLng, destLat, destLng]);
|
||||||
|
|
||||||
|
const handleUserInteraction = useCallback(() => {
|
||||||
|
if (isCompassActive) {
|
||||||
|
setIsCompassActive(false);
|
||||||
|
}
|
||||||
|
}, [isCompassActive]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isCompassActive) {
|
||||||
|
setCurrentHeading(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (navigator.geolocation) {
|
||||||
|
watchIdRef.current = navigator.geolocation.watchPosition(
|
||||||
|
(position) => {
|
||||||
|
if (mapRef.current) {
|
||||||
|
mapRef.current.setView([position.coords.latitude, position.coords.longitude], undefined, { animate: true });
|
||||||
|
}
|
||||||
|
if (position.coords.heading !== null) {
|
||||||
|
setCurrentHeading(position.coords.heading);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(err) => console.error("Compass tracking acquisition error:", err),
|
||||||
|
{ enableHighAccuracy: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOrientation = (event: any) => {
|
||||||
|
let heading: number | null = null;
|
||||||
|
if (event.webkitCompassHeading !== undefined && event.webkitCompassHeading !== null) {
|
||||||
|
heading = event.webkitCompassHeading;
|
||||||
|
} else if (event.alpha !== null && event.alpha !== undefined) {
|
||||||
|
if (event.absolute === true || event.type === 'deviceorientationabsolute') {
|
||||||
|
heading = (360 - event.alpha) % 360;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (heading !== null) {
|
||||||
|
setCurrentHeading(heading);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('deviceorientation', handleOrientation, true);
|
||||||
|
window.addEventListener('deviceorientationabsolute', handleOrientation, true);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (watchIdRef.current !== null) {
|
||||||
|
navigator.geolocation.clearWatch(watchIdRef.current);
|
||||||
|
}
|
||||||
|
window.removeEventListener('deviceorientation', handleOrientation, true);
|
||||||
|
window.removeEventListener('deviceorientationabsolute', handleOrientation, true);
|
||||||
|
};
|
||||||
|
}, [isCompassActive]);
|
||||||
|
|
||||||
|
const userIcon = useMemo(() => L.divIcon({
|
||||||
|
className: '!bg-transparent !border-none',
|
||||||
|
html: `<div class="w-8 h-8 bg-blue-600 rounded-full border-2 border-white shadow-xl flex items-center justify-center"><div class="w-2 h-2 bg-white rounded-full"></div></div>`,
|
||||||
|
iconSize: [32, 32],
|
||||||
|
iconAnchor: [16, 16]
|
||||||
|
}), []);
|
||||||
|
|
||||||
|
const destIcon = useMemo(() => L.divIcon({
|
||||||
|
className: '!bg-transparent !border-none',
|
||||||
|
html: `<div class="w-8 h-8 bg-red-600 rounded-full border-2 border-white shadow-xl flex items-center justify-center text-white text-xs font-black">Đ</div>`,
|
||||||
|
iconSize: [32, 32],
|
||||||
|
iconAnchor: [16, 16]
|
||||||
|
}), []);
|
||||||
|
|
||||||
|
if (!routeData) return null;
|
||||||
|
|
||||||
|
const center: [number, number] = routeData.origin && routeData.destination
|
||||||
|
? [(routeData.origin.lat + routeData.destination.lat) / 2, (routeData.origin.lng + routeData.destination.lng) / 2]
|
||||||
|
: [0, 0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-slate-950 flex flex-col overflow-hidden select-none text-white antialiased">
|
||||||
|
<div className="w-full bg-[#1e293b]/95 backdrop-blur-md border-b border-slate-800 px-4 py-3.5 flex items-center gap-3 z-50">
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="text-slate-300 hover:text-white p-1 rounded-lg hover:bg-slate-800 transition-colors"
|
||||||
|
title="Quay lại danh sách lộ trình"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
<h2 className="text-sm font-bold truncate tracking-wide text-slate-100">
|
||||||
|
{routeData.tourTitle || "Bản đồ chỉ đường"}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 relative h-[calc(100vh-56px)]">
|
||||||
|
<MapContainer
|
||||||
|
center={center}
|
||||||
|
zoom={14}
|
||||||
|
className="absolute inset-0 h-full w-full"
|
||||||
|
zoomControl={true}
|
||||||
|
attributionControl={false}
|
||||||
|
>
|
||||||
|
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution="" />
|
||||||
|
<MapRefSetter mapRef={mapRef} />
|
||||||
|
{routeData.origin && (
|
||||||
|
<Marker position={[routeData.origin.lat, routeData.origin.lng]} icon={userIcon}>
|
||||||
|
<Popup>
|
||||||
|
<div className="text-xs font-bold text-blue-600">Bạn đang ở đây</div>
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
)}
|
||||||
|
{routeData.destination && (
|
||||||
|
<Marker position={[routeData.destination.lat, routeData.destination.lng]} icon={destIcon}>
|
||||||
|
<Popup>
|
||||||
|
<div className="text-xs font-bold text-red-600">{routeData.destination.name}</div>
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
)}
|
||||||
|
{routeGeometry && <FitBounds coords={routeGeometry} />}
|
||||||
|
{routeGeometry && (
|
||||||
|
<Polyline positions={routeGeometry} color="#2563eb" weight={6} opacity={0.9} smoothFactor={1} />
|
||||||
|
)}
|
||||||
|
<MapRotationHandler rotation={currentHeading} />
|
||||||
|
<CompassInteractionDetector onUserInteraction={handleUserInteraction} />
|
||||||
|
</MapContainer>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setIsCompassActive(!isCompassActive)}
|
||||||
|
className={`absolute bottom-8 right-6 z-[999] w-14 h-14 rounded-full border-2 flex items-center justify-center shadow-2xl transition-all active:scale-95 ${
|
||||||
|
isCompassActive
|
||||||
|
? 'bg-blue-600 border-blue-400 text-white animate-pulse'
|
||||||
|
: 'bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-blue-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Compass className="w-7 h-7" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="absolute top-4 left-1/2 -translate-x-1/2 bg-red-900 text-white text-xs px-3 py-1.5 rounded-lg shadow-lg z-[1000]">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user