diff --git a/.gitignore b/.gitignore index 6ed48a9..b89e93d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ .env node_modules +server/dist +dist diff --git a/INSTALL_MAP.md b/INSTALL_MAP.md new file mode 100644 index 0000000..ac1d764 --- /dev/null +++ b/INSTALL_MAP.md @@ -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 +} /> + +### 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(null); // Anchor pointer to hold your initialized Map instance + const watchIdRef = useRef(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 ( +
+ + {/* PART 1: TOP-BAR HEADER ZONE (Isolated from Sub-Tabs layout skins) */} +
+ +

+ {routeData.tourTitle} +

+
+ + {/* PART 2: FULLSCREEN MAP EDGE-TO-EDGE CONTAINER */} +
+ {/* Map injection Canvas - Absolutely no rounded boundaries or wrapper cushions */} +
+ + {/* PART 3: FLOATING INTERACTIVE COMPASS ACTION BUTTON */} + +
+ +
+ ); +}; + +## 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. \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 611ae69..325e458 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(null); const [shareJourneyToken, setShareJourneyToken] = useState(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(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 setCurrentPage('tourDetail')} />; } + if (currentPage === 'tourNavigation' && navigationPayload) { + return ( + + ); + } + if (currentPage === 'explore') { return ( { 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(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)} /> - setIsNavModalOpen(false)} - routeData={navRouteData} - />
); }; \ No newline at end of file diff --git a/frontend/src/pages/TourDetailPage.tsx b/frontend/src/pages/TourDetailPage.tsx index f4cf5e9..fca2b74 100644 --- a/frontend/src/pages/TourDetailPage.tsx +++ b/frontend/src/pages/TourDetailPage.tsx @@ -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} /> ) : (
diff --git a/frontend/src/pages/TourNavigationPage.tsx b/frontend/src/pages/TourNavigationPage.tsx new file mode 100644 index 0000000..9cda0dd --- /dev/null +++ b/frontend/src/pages/TourNavigationPage.tsx @@ -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 }) => { + const map = useMap(); + useEffect(() => { + mapRef.current = map; + }, [map, mapRef]); + return null; +}; + +export const TourNavigationPage: React.FC = ({ 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(null); + + const mapRef = useRef(null); + const watchIdRef = useRef(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: `
`, + iconSize: [32, 32], + iconAnchor: [16, 16] + }), []); + + const destIcon = useMemo(() => L.divIcon({ + className: '!bg-transparent !border-none', + html: `
Đ
`, + 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 ( +
+
+ +

+ {routeData.tourTitle || "Bản đồ chỉ đường"} +

+
+ +
+ + + + {routeData.origin && ( + + +
Bạn đang ở đây
+
+
+ )} + {routeData.destination && ( + + +
{routeData.destination.name}
+
+
+ )} + {routeGeometry && } + {routeGeometry && ( + + )} + + +
+ + + + {error && ( +
+ {error} +
+ )} +
+
+ ); +};