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:
2026-06-25 10:17:23 +07:00
parent a135196cb5
commit f74587376e
6 changed files with 502 additions and 26 deletions
+2
View File
@@ -1,2 +1,4 @@
.env .env
node_modules node_modules
server/dist
dist
+196
View File
@@ -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
View File
@@ -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
+4 -14
View File
@@ -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>
); );
}; };
+5 -5
View File
@@ -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">
+268
View File
@@ -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>
);
};