Files
travelplanning/INSTALL_MAP.md
T
3dtours f74587376e 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
2026-06-25 10:17:23 +07:00

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:

  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:

// 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.