f74587376e
- 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
8.6 KiB
8.6 KiB
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:
- 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. - 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 (
100vwby100vh minus header height). - 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:
// 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.