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
|
||||
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 { AdminDashboard } from './pages/AdminDashboard';
|
||||
import { ShareJourneyPage } from './pages/ShareJourneyPage';
|
||||
import { TourNavigationPage } from './pages/TourNavigationPage';
|
||||
import { ConfirmProvider } from './hooks/useConfirm';
|
||||
import { NotificationProvider } from './hooks/useNotification';
|
||||
|
||||
@@ -21,12 +22,13 @@ function App() {
|
||||
|
||||
const [user, setUser] = useState<any>(null);
|
||||
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'))
|
||||
);
|
||||
const [currentTourId, setCurrentTourId] = useState<string | null>(viewTourId);
|
||||
const [isPublicTourView, setIsPublicTourView] = useState(!!viewTourId);
|
||||
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(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
@@ -122,26 +124,30 @@ function App() {
|
||||
};
|
||||
|
||||
const handleBackFromTourDetail = () => {
|
||||
// Check if this was a public view BEFORE clearing the flag
|
||||
const wasPublicView = isPublicTourView;
|
||||
|
||||
setCurrentTourId(null);
|
||||
setIsPublicTourView(false);
|
||||
|
||||
// If user was viewing a public tour, redirect to index/landing page
|
||||
// Otherwise redirect based on authentication and previous page
|
||||
if (wasPublicView) {
|
||||
// Public tour view - always redirect to index/landing
|
||||
setCurrentPage('landing');
|
||||
} else if (user) {
|
||||
// Authenticated user viewing their own tour - go back to previous page
|
||||
setCurrentPage(previousPage);
|
||||
} else {
|
||||
// Not authenticated and not public view - go to 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 pendingInviteToken = localStorage.getItem('pendingInviteToken');
|
||||
if (pendingInviteToken) {
|
||||
@@ -259,6 +265,7 @@ function App() {
|
||||
onBack={handleBackFromTourDetail}
|
||||
isPublicView={isPublicTourView}
|
||||
onOpenNotes={() => setCurrentPage('notes')}
|
||||
onOpenNavigationPage={handleOpenNavigationPage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -267,6 +274,19 @@ function App() {
|
||||
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') {
|
||||
return (
|
||||
<ExploreMap
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useTourStore } from '@/store/useTourStore';
|
||||
import { useConfirm } from '@/hooks/useConfirm';
|
||||
import { useNotification } from '@/hooks/useNotification';
|
||||
import { CommentModal } from '@/components/CommentModal';
|
||||
import { LocationNavigationModal } from '@/components/LocationNavigationModal';
|
||||
|
||||
const TimeVariance = ({ planned, actual }: { planned: string, actual: string | null }) => {
|
||||
if (!actual) return null;
|
||||
@@ -51,6 +50,7 @@ export const ItineraryTimeline = ({
|
||||
onEditLocation?: (location: any) => void,
|
||||
onQuickNote?: (data: { legId: string; location: any; leg: 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,
|
||||
isPublicView?: boolean
|
||||
}) => {
|
||||
@@ -78,11 +78,6 @@ export const ItineraryTimeline = ({
|
||||
|
||||
// State to track single expanded stage (exclusive single-expansion mode)
|
||||
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) => {
|
||||
// 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(
|
||||
(position) => {
|
||||
setNavRouteData({
|
||||
onOpenNavigationPage?.({
|
||||
origin: {
|
||||
lat: position.coords.latitude,
|
||||
lng: position.coords.longitude
|
||||
@@ -155,9 +150,9 @@ export const ItineraryTimeline = ({
|
||||
lat: parseFloat(location.latitude),
|
||||
lng: parseFloat(location.longitude),
|
||||
name: location.name || "Điểm đến chọn sẵn"
|
||||
}
|
||||
},
|
||||
tourTitle: currentTour?.title || "Chi tiết hành trình"
|
||||
});
|
||||
setIsNavModalOpen(true);
|
||||
},
|
||||
(error) => {
|
||||
console.error("Error fetching native geolocation metrics:", error);
|
||||
@@ -763,11 +758,6 @@ export const ItineraryTimeline = ({
|
||||
onCommentAdded={() => handleCommentIncrement(commentLocationId)}
|
||||
onCommentDeleted={() => handleCommentDecrement(commentLocationId)}
|
||||
/>
|
||||
<LocationNavigationModal
|
||||
isOpen={isNavModalOpen}
|
||||
onClose={() => setIsNavModalOpen(false)}
|
||||
routeData={navRouteData}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -348,12 +348,14 @@ export const TourDetailPage = ({
|
||||
onBack,
|
||||
tourId,
|
||||
isPublicView = false,
|
||||
onOpenNotes
|
||||
onOpenNotes,
|
||||
onOpenNavigationPage
|
||||
}: {
|
||||
onBack: () => void,
|
||||
tourId: string,
|
||||
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
|
||||
const { t } = useTranslation();
|
||||
@@ -2232,19 +2234,17 @@ export const TourDetailPage = ({
|
||||
setEditingLocation(loc);
|
||||
setTargetLegId(loc.legId);
|
||||
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 isEnd = loc.plannedEnd && new Date(loc.plannedEnd).getTime() === 0;
|
||||
setIsStartPointAction(!!isStart);
|
||||
setIsEndPointAction(!!isEnd);
|
||||
|
||||
setIsAddLocationOpen(true);
|
||||
}}
|
||||
onQuickNote={(data) => handleQuickNote(data)}
|
||||
onSuccess={() => fetchTour(tourId)}
|
||||
isPublicView={isPublicView}
|
||||
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">
|
||||
|
||||
@@ -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