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
196 lines
8.6 KiB
Markdown
196 lines
8.6 KiB
Markdown
# 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. |