13 Commits

Author SHA1 Message Date
3dtours 03ee337e28 xóa các tập tin ảnh up public 2026-06-25 20:42:31 +07:00
3dtours 48ffd7e49b Thêm README.md cho dự án 2026-06-25 20:07:52 +07:00
3dtours 8df94bca26 fix: sửa lỗi hiển thị trên màn hình android 2026-06-25 17:11:19 +07:00
3dtours d2aced396f fix: bản đồ xoay mượt trên Iphone 2026-06-25 16:48:26 +07:00
3dtours 4e93722ba5 fix: bản đồ không zoom hay thu nhỏ được, không hiển thị nút vị trí của tôi trên bản đồ 2026-06-25 15:57:25 +07:00
3dtours 33a5996bee fix: Ngăn bản đồ tự động reset về trung tâm khi người dùng đã tương tác
- TourDetailPage: MapTourBounds chỉ fitBounds 1 lần cho mỗi lần dữ liệu lộ trình thay đổi thực sự (theo locKey). Nếu người dùng đã pan/zoom rồi thì không refit khi data reload.
- TourNavigationPage: FitBounds chỉ chạy khi người dùng CHƯA tương tác (hasInteractedRef). Sau khi người dùng pan/zoom/drag, bản đồ giữ nguyên vị trí.
- Cả hai bản đồ đều tôn trọng vị trí người dùng đã chọn, không reset về mặc định khi có thay đổi dữ liệu phụ.
2026-06-25 12:28:37 +07:00
3dtours 90475d4130 fix: Ngăn bản đồ ExploreMap tự động nhảy về vị trí người dùng khi thay đổi zoom trên mobile
- Tách userPos (dùng cho marker) và mapCenter (dùng cho center bản đồ) thành 2 state riêng biệt
- MapContainer dùng center={mapCenter} cố định thay vì center={userPos} bị GPS thay đổi liên tục
- Xóa component RecenterMap tự động re-center khi userPos thay đổi
- Thêm nút 'Vị trí của tôi' để người dùng chủ động nhấn mới nhảy bản đồ đến vị trí của mình
- requestGpsPosition cập nhật cả mapCenter lẫn store để map thực sự nhảy khi người dùng chủ động yêu cầu
- Các thao tác khác như click gợi ý tìm kiếm, click ảnh trên bản đồ cũng cập nhật mapCenter để map nhảy đúng vị trí
- Áp dụng cho ExploreMap.tsx - bản đồ chính khi người dùng mở app
2026-06-25 11:58:32 +07:00
3dtours 8e984e609b feat: Thêm nút định vị người dùng trên bản đồ điều hướng mobile
- Thêm nút 'Vị trí của tôi' ở góc dưới bên trái bản đồ
- Khi bật: map tự động center theo vị trí GPS của người dùng (dùng watchPosition)
- Cho phép thu phóng tự do ngay cả khi đang định vị
- Khi người dùng kéo thả bản đồ thủ công: tự động tắt chế độ định vị
- Nút hoạt động độc lập với nút la bàn (compass)
- Sửa import leaflet.css bị lỗi path
2026-06-25 11:34:09 +07:00
3dtours b48502c34a fix: Bản đồ không reset khi người dùng thay đổi zoom trên mobile
- Thêm MapInteractionWatcher lưu zoom/center khi người dùng tương tác
- MapSizeHandler dùng ResizeObserver thay vì setTimeout: chỉ gọi invalidateSize khi container thực sự thay đổi (>2px)
- Sau invalidateSize, tự động restore zoom mà người dùng đã chọn
- canFitRef đảm bảo fitBounds chỉ chạy 1 lần duy nhất khi route vừa load
- Áp dụng cho cả chế độ la bàn bật (xoay theo hướng) và tắt (hướng Bắc)
2026-06-25 11:22:29 +07:00
3dtours 978992057a fix: Luôn giữ kích thước bản đồ người dùng chọn khi tìm đường ngắn nhất
- Thay userInteractedRef bằng canFitRef - chỉ fitBounds đúng 1 lần khi route vừa load
- Sau lần fit đầu, canFitRef = false mãi mãi, không bao giờ reset khi route đổi
- FitBounds chỉ chạy khi canFitRef còn true VÀ compass đang tắt
- MapSizeHandler chỉ gọi invalidateSize() mà không setView để không kích hoạt zoomend/moveend
- Loại bỏ useEffect chết đăng ký zoomend/moveend ở page level (đã có trong MapRefSetter)
- Áp dụng cho cả 2 chế độ: compass bật (xoay theo hướng) và tắt (hướng Bắc)
2026-06-25 11:15:09 +07:00
3dtours ed0fb8dd64 fix: Giữ nguyên zoom/center của người dùng khi bản đồ resize trên mobile
- Thêm userInteractedRef để theo dõi người dùng đã zoom/drag chưa
- FitBounds chỉ chạy khi người dùng chưa tương tác
- MapSizeHandler lưu center/zoom trước khi invalidateSize, khôi phục sau nếu user đã tương tác
- MapRotationHandler xóa transform thay vì set rotate(0) khi compass tắt
- Ngăn map tự động reset về mức zoom ban đầu sau khi người dùng pinch-zoom
2026-06-25 11:08:13 +07:00
3dtours 828bc89890 fix: Sửa lỗi bản đồ mobile chỉ hiển thị kích thước mặc định và không zoom
- Xóa state viewportHeight phụ thuộc documentElement.clientHeight (không ổn định trên mobile)
- Xóa style dvh/-webkit-fill-available gây lỗi trên iOS Safari
- Đổi layout sang flex-1 thuần túy, không tính chiều cao bằng JS
- Xóa CSS transform khi rotation = 0 để tránh lỗi render trên mobile
- Tăng delay invalidateSize lên 100ms để Leaflet đo đúng kích thước container sau layout
2026-06-25 11:03:45 +07:00
3dtours b8bf4f88cc fix: Sửa lỗi bản đồ không full màn hình và không zoom được trên iOS
- Dùng viewportHeight từ JS thay vì CSS dvh để tránh vấn đề iOS Safari
- Thêm MapSizeHandler gọi invalidateSize() để Leaflet tính lại kích thước
- Bật scrollWheelZoom, touchZoom, doubleClickZoom, dragging, inertia
- Đặt maxZoom=20, minZoom=2 rõ ràng
- Loại bỏ max-width/overflow-x trong index.css để không ảnh hưởng trang khác
2026-06-25 10:33:01 +07:00
9 changed files with 706 additions and 310 deletions
-196
View File
@@ -1,196 +0,0 @@
# 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.
+133
View File
@@ -0,0 +1,133 @@
# Travel Planning - Multi-User Travel Itinerary Management System
A comprehensive travel planning application that enables collaborative itinerary planning, expense tracking, and photo sharing for travel groups.
## Features
- **Tour Management**: Create and manage travel tours with multiple legs (stages) and locations
- **Multi-user Collaboration**: Invite friends to join tours with role-based access control
- **Interactive Maps**: Visual tour planning with Leaflet.js integration
- **Expense Tracking**: Automatic cost splitting with configurable adult/child discounts
- **Photo Sharing**: Secure photo album with privacy controls (PUBLIC, TOUR_ONLY, PRIVATE)
- **Real-time Navigation**: Live GPS tracking and route optimization using OSRM API
## Tech Stack
| Layer | Technology | Purpose |
|-------|-----------|---------|
| **Frontend** | React + Vite | Single Page Application with fast HMR |
| | TailwindCSS | Utility-first CSS framework |
| | Zustand | Lightweight state management |
| | Leaflet.js | Interactive map rendering |
| **Backend** | NestJS | Scalable Node.js framework |
| | JWT | Authentication & authorization |
| | Prisma ORM | Type-safe database access |
| | PostgreSQL + PostGIS | Spatial database for geographic data |
## Project Structure
```
travelplanning/
├── backend/ # Backend API server
│ ├── src/
│ │ ├── auth/ # Authentication modules
│ │ ├── main.ts # NestJS entry point
│ │ └── v1/ # API v1 endpoints
│ └── prisma/
│ └── schema.prisma # Database schema
├── frontend/ # React frontend
│ └── src/
│ ├── pages/ # Main pages
│ ├── components/ # Reusable UI components
│ ├── hooks/ # Custom React hooks
│ └── store/ # Zustand stores
├── docs/ # Documentation
│ ├── ARCHITECTURE.md # System architecture
│ └── UITourDesign.md # UI design specifications
└── .env # Environment variables
```
## Database Schema
The application uses PostgreSQL with Prisma ORM. Key models include:
- **User**: Registered users with admin capability
- **Tour**: Travel itineraries with date ranges and participant management
- **Leg**: Stages within a tour (ordered sequence)
- **Location**: Geographic points with timing and status tracking
- **Expense**: Cost tracking linked to legs/locations
- **Photo**: Media storage with privacy controls
- **TourParticipant**: Many-to-many relationship with role-based permissions
### User Roles
| Role | Permissions |
|------|-------------|
| OWNER | Full access to all features |
| MANAGER | Can edit tour content and manage members |
| MEMBER | View tour and participate, access financial data |
| MEMBER_NO_FINANCE | View tour only, no financial access |
| VIEWER_ONLY | Read-only access to itinerary and photos |
## API Endpoints
### Tours
- `GET /api/v1/tours` - Get all public tours
- `POST /api/v1/tours` - Create new tour
- `GET /api/v1/tours/:id` - Get tour details
- `PUT /api/v1/tours/:id` - Update tour
### Authentication
- `POST /api/v1/auth/login` - User login
- `POST /api/v1/auth/register` - User registration
- `POST /api/v1/auth/promote-admin` - Admin role promotion (with secret key)
### Photos
- `GET /api/v1/public-photos` - Get public photos
- `POST /api/v1/tours/:id/photos` - Upload tour photos
## Quick Start
### Prerequisites
- Node.js 18+
- PostgreSQL with PostGIS extension
### Installation
```bash
# Install dependencies
npm install
cd frontend && npm install
cd ../backend && npm install
# Set up database
npx prisma migrate dev
npx prisma generate
# Start development servers
npm run dev # Frontend (Vite)
npm run start:backend # Backend (NestJS)
```
### Environment Variables
Create `.env` in the root directory:
```env
DATABASE_URL="postgresql://user:password@localhost:5432/traveldb"
JWT_SECRET="your-secret-key"
```
## Mobile Optimization
The application is built mobile-first with support for:
- Safe area insets for notch displays (iOS/Android)
- Touch gestures for map interactions
- Responsive layouts for all screen sizes
- Device orientation and compass integration
## Documentation
See the `docs/` directory for detailed documentation:
- [ARCHITECTURE.md](docs/ARCHITECTURE.md) - System architecture and data models
- [UITourDesign.md](docs/UITourDesign.md) - UI design specifications
+134
View File
@@ -0,0 +1,134 @@
# To AI Agent: Audit Location Lock Bug and Refactor GPS Tracking into a Toggle Stateful Button
## 1. Context & Problem Statement
Currently, in our travel planner application map engine (on pages like `TourNavigationPage.tsx`, `LocationNavigationModal.tsx`, or map utilities), the viewport is continuously forced to lock onto the user's live GPS position. This architecture severely damages mobile UX because:
1. It prevents users from manually dragging, panning, or scouting other areas of the map terrain.
2. There is no control interface to temporarily mute or disable live GPS tracking.
**Objective:** - Run a global audit across the entire codebase to locate functions driving this forced-center trap (e.g., custom hooks, `requestGpsPosition`, native geolocation callbacks, or reactive map state updates).
- Refactor the logic so that live tracking is bound strictly to an independent toggle state button. The map must **ONLY** lock/re-center on the user's coordinates when this tracking toggle button is actively switched **ON**.
---
## 2. Phase 1: Codebase Audit Plan (Where to Search)
Scan the entire project repository (specifically `/frontend/src`) using code search patterns to intercept the lock mechanism. Target the following files and keywords:
### Key Target Files to Inspect:
- `frontend/src/pages/TourNavigationPage.tsx`
- `frontend/src/components/LocationNavigationModal.tsx`
- Any custom hooks or contexts handling geography, such as `useGeolocation.ts`, `useMap.ts`, or generic map setup wrappers.
### Regex & Keyword Global Search Queries:
- Search for native background watchers: `navigator.geolocation.watchPosition` or `navigator.geolocation.getCurrentPosition`
- Search for custom map-centering loops: `requestGpsPosition`, `followUser`, `centerToUser`
- Search for viewport mutation commands specific to our active map engine stack:
- **Leaflet:** `.setView(`, `.panTo(`, `center={`
- **Mapbox GL JS:** `.flyTo(`, `.easeTo(`, `.jumpTo(`
---
## 3. Phase 2: Technical Refactoring Blueprint
Once the tracking logic code blocks are isolated from Phase 1, implement the structural state safety rails below:
### Step 1: Initialize the Tracking State Guard
Introduce a state hook controller (`isTrackingLocation`) to manage whether the view should actively mirror device coordinates:
```typescript
// Add inside the map controller/page container component
const [isTrackingLocation, setIsTrackingLocation] = useState(false);
const watchIdRef = useRef<number | null>(null);
### Step 2: Encapsulate the Geolocation Watcher Handler
Wrap your positioning tracking engine loop inside a conditional check governed directly by the state guard. Ensure that if the tracking state is disabled, the background watcher cleanly unmounts:
useEffect(() => {
if (isTrackingLocation) {
if (navigator.geolocation) {
console.log("GPS Location Tracking engaged. Syncing viewport to center...");
watchIdRef.current = navigator.geolocation.watchPosition(
(position) => {
const { latitude, longitude, heading } = position.coords;
if (mapRef.current) {
// ✅ CORRECTION: Viewport ONLY repositions center when tracking button is active
mapRef.current.easeTo({
center: [longitude, latitude],
zoom: 16, // Lock to comfortable navigation zoom level
duration: 600
});
}
},
(error) => console.error("GPS stream tracking lost:", error),
{ enableHighAccuracy: true }
);
}
} else {
// Clean up tracking process instantly when toggled OFF
if (watchIdRef.current !== null) {
navigator.geolocation.clearWatch(watchIdRef.current);
watchIdRef.current = null;
console.log("GPS Location Tracking disabled. Map control released to user.");
}
}
return () => {
if (watchIdRef.current !== null) navigator.geolocation.clearWatch(watchIdRef.current);
};
}, [isTrackingLocation]);
### Step 3: Implement Gesture Detection (UX Safety Rail)
If the user manually drags the screen while tracking is active, the tracking state must automatically toggle OFF so the viewport doesn't fight against the user's finger movements:
useEffect(() => {
if (!mapRef.current) return;
const map = mapRef.current;
const breakTrackingOnGesture = () => {
if (isTrackingLocation) {
console.log("User touch map interaction detected. Disengaging auto-center lock.");
setIsTrackingLocation(false); // Automatically drop tracking flag on map pan/zoom
}
};
map.on('dragstart', breakTrackingOnGesture);
map.on('zoomstart', breakTrackingOnGesture);
map.on('movestart', breakTrackingOnGesture);
return () => {
map.off('dragstart', breakTrackingOnGesture);
map.off('zoomstart', breakTrackingOnGesture);
map.off('movestart', breakTrackingOnGesture);
};
}, [isTrackingLocation]);
### Step 4: Render the UI Toggle Button UI Component
Deploy a new independent floating button on top of the map canvas workspace (placed at bottom-24 right-6, just right above your custom Compass button layout):
{/* FLOATING LIVE GPS TRACKING CENTER LOCK BUTTON */}
<button
onClick={() => setIsTrackingLocation(!isTrackingLocation)}
className={`absolute bottom-24 right-6 z-40 w-14 h-14 rounded-full border-2 flex items-center justify-center shadow-2xl transition-all active:scale-95 ${
isTrackingLocation
? 'bg-green-600 border-green-400 text-white animate-pulse'
: 'bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-green-500'
}`}
title={isTrackingLocation ? "Tắt tự động định tâm vị trí" : "Bật tự động định tâm theo vị trí của bạn"}
>
{/* Replace Crosshair icon element with your active layout icon package asset */}
<svg xmlns="[http://www.w3.org/2000/svg](http://www.w3.org/2000/svg)" className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
## 4. Verification & Quality Acceptance Criteria
[ ] Code Erasure Verification: Confirm that old continuous loops or uncontrolled recursive .setView/.easeTo methods triggered instantly on map load are fully removed or properly contained inside the state block.
[ ] Default State Freedom: Upon opening the map page path, tracking must default to OFF. Users must be able to drag the map anywhere in the world without the screen snapped or yanked back to their physical house position.
[ ] Toggle Activation Centering: Pressing the new GPS tracking button must instantly engage the animation, center the map view directly on top of the user blue dot icon, and follow them smoothly if they move.
[ ] Manual Override Interception: Turn tracking ON. Drag the map manually with a finger gesture. Verify that the tracking button instantly changes style states back to deactivated and tracking shuts down cleanly.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 844 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 408 KiB

+50 -21
View File
@@ -39,15 +39,6 @@ const PHOTO_TAG_LABELS: { [key: string]: string } = {
'thu-cung': '🐕 Thú cưng' 'thu-cung': '🐕 Thú cưng'
}; };
// Component Helper để cập nhật tâm bản đồ khi vị trí người dùng thay đổi
function RecenterMap({ position }: { position: [number, number] }) {
const map = useMap();
useEffect(() => {
map.setView(position, map.getZoom());
}, [position, map]);
return null;
}
// Component Helper để đóng menu khi tương tác với bản đồ // Component Helper để đóng menu khi tương tác với bản đồ
function MapEvents({ onMapAction }: { onMapAction: () => void }) { function MapEvents({ onMapAction }: { onMapAction: () => void }) {
useMapEvents({ useMapEvents({
@@ -202,7 +193,11 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
return null; return null;
}); });
const [userPos, setUserPos] = useState<[number, number]>(initialViewState?.center || [10.7769, 106.7009]); // Map dùng center cố định để không bị GPS tự nhảy vị trí người dùng
const defaultCenter = initialViewState?.center || [10.7769, 106.7009];
const [userPos, setUserPos] = useState<[number, number]>(defaultCenter);
const [mapCenter, setLocalMapCenter] = useState<[number, number]>(initialViewState?.center || defaultCenter);
const [mapZoom] = useState(initialViewState?.zoom || 13); const [mapZoom] = useState(initialViewState?.zoom || 13);
const [publicPhotos, setPublicPhotos] = useState<any[]>([]); const [publicPhotos, setPublicPhotos] = useState<any[]>([]);
const [selectedPhoto, setSelectedPhoto] = useState<any | null>(null); const [selectedPhoto, setSelectedPhoto] = useState<any | null>(null);
@@ -262,7 +257,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
const [blacklist, setBlacklist] = useState<any[]>([]); const [blacklist, setBlacklist] = useState<any[]>([]);
const [isReportModalOpen, setIsReportModalOpen] = useState(false); const [isReportModalOpen, setIsReportModalOpen] = useState(false);
const [isBlacklistOpen, setIsBlacklistOpen] = useState(false); const [isBlacklistOpen, setIsBlacklistOpen] = useState(false);
const mapCenter = useTourStore(state => state.mapCenter); const storeMapCenter = useTourStore(state => state.mapCenter);
// Recommendations and GPS States // Recommendations and GPS States
const [recommendedLocations, setRecommendedLocations] = useState<any[]>([]); const [recommendedLocations, setRecommendedLocations] = useState<any[]>([]);
@@ -342,6 +337,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
const posArray: [number, number] = [pos.coords.latitude, pos.coords.longitude]; const posArray: [number, number] = [pos.coords.latitude, pos.coords.longitude];
setUserGpsPos(posArray); setUserGpsPos(posArray);
setUserPos(posArray); setUserPos(posArray);
setLocalMapCenter(posArray);
setMapCenter(posArray); setMapCenter(posArray);
}, },
() => { () => {
@@ -435,7 +431,28 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
fetchTrustedUsers(); fetchTrustedUsers();
fetchBlacklist(); fetchBlacklist();
fetchRecommendations(); fetchRecommendations();
// Luôn tải danh sách ảnh công khai để hiển thị trên bản đồ cho tất cả mọi người
fetchPublicPhotos();
// Chỉ tải danh sách tour khi người dùng đã đăng nhập và có token
if (user || localStorage.getItem('token')) {
fetchPublicTours();
}
}, []); }, []);
// Chỉ lấy vị trí GPS ban đầu để hiển thị marker, KHÔNG tự động nhảy bản đồ đến vị trí đó
// Người dùng phải chủ động nhấn nút định vị mới nhảy bản đồ đến vị trí của mình
useEffect(() => {
if (!initialViewState && navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(pos) => {
const posArray: [number, number] = [pos.coords.latitude, pos.coords.longitude];
setUserPos(posArray);
},
() => console.log("Không thể lấy vị trí người dùng")
);
}
}, [initialViewState]);
const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false); // State để điều khiển hiển thị dropdown lọc const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false); // State để điều khiển hiển thị dropdown lọc
const [searchQuery, setSearchQuery] = useState(''); // State cho giá trị tìm kiếm const [searchQuery, setSearchQuery] = useState(''); // State cho giá trị tìm kiếm
const [suggestions, setSuggestions] = useState<{ type: 'tour' | 'location', id: string, name: string, lat?: number, lon?: number }[]>([]); const [suggestions, setSuggestions] = useState<{ type: 'tour' | 'location', id: string, name: string, lat?: number, lon?: number }[]>([]);
@@ -576,6 +593,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
} else if (s.lat && s.lon) { } else if (s.lat && s.lon) {
const pos: [number, number] = [s.lat, s.lon]; const pos: [number, number] = [s.lat, s.lon];
setUserPos(pos); setUserPos(pos);
setLocalMapCenter(pos);
setMapCenter(pos); setMapCenter(pos);
notify({ notify({
title: 'Tìm thấy địa điểm', title: 'Tìm thấy địa điểm',
@@ -783,6 +801,18 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
{/* Nhóm bên phải: Các thao tác người dùng */} {/* Nhóm bên phải: Các thao tác người dùng */}
<div className="flex items-center gap-2 pointer-events-auto"> <div className="flex items-center gap-2 pointer-events-auto">
{/* Nút định vị người dùng */}
<button
onClick={requestGpsPosition}
className="w-11 h-11 bg-[var(--surface)] rounded-full shadow-xl hover:bg-blue-100 text-blue-600 transition-all flex items-center justify-center border border-[var(--border)] shrink-0"
title="Vị trí của tôi"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M12 2v4M12 18v4M2 12h4M18 12h4" />
</svg>
</button>
{/* Nút Ảnh của tôi */} {/* Nút Ảnh của tôi */}
{isLoggedInOrGuest && ( {isLoggedInOrGuest && (
<button <button
@@ -890,13 +920,13 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
</div> </div>
</div> </div>
<MapContainer <MapContainer
center={userPos} center={mapCenter}
zoom={mapZoom} zoom={mapZoom}
className="h-full w-full" className="h-full w-full"
preferCanvas={true} preferCanvas={true}
attributionControl={false} attributionControl={false}
> >
<TileLayer <TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/> />
@@ -906,9 +936,6 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
{/* Đóng menu và dropdown khi tương tác bản đồ */} {/* Đóng menu và dropdown khi tương tác bản đồ */}
<MapEvents onMapAction={() => { setShareMenu(null); setSuggestions([]); setIsFilterDropdownOpen(false); }} /> <MapEvents onMapAction={() => { setShareMenu(null); setSuggestions([]); setIsFilterDropdownOpen(false); }} />
{/* Tự động di chuyển bản đồ đến vị trí người dùng khi tìm thấy tọa độ */}
<RecenterMap position={userPos} />
<MarkerClusterGroup key={`cluster-${filteredTours.length}`} chunkedLoading> <MarkerClusterGroup key={`cluster-${filteredTours.length}`} chunkedLoading>
{filteredTours.map((tour) => { {filteredTours.map((tour) => {
let startLoc = null; let startLoc = null;
@@ -1403,6 +1430,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
<button <button
onClick={() => { onClick={() => {
setUserPos([item.latitude, item.longitude]); setUserPos([item.latitude, item.longitude]);
setLocalMapCenter([item.latitude, item.longitude]);
setMapCenter([item.latitude, item.longitude]); setMapCenter([item.latitude, item.longitude]);
}} }}
className="text-[9px] font-bold text-blue-600 dark:text-blue-400 text-left hover:underline flex items-center gap-1 mt-0.5 cursor-pointer bg-transparent border-none p-0" className="text-[9px] font-bold text-blue-600 dark:text-blue-400 text-left hover:underline flex items-center gap-1 mt-0.5 cursor-pointer bg-transparent border-none p-0"
@@ -1481,6 +1509,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
<button <button
onClick={() => { onClick={() => {
setUserPos([item.latitude, item.longitude]); setUserPos([item.latitude, item.longitude]);
setLocalMapCenter([item.latitude, item.longitude]);
setMapCenter([item.latitude, item.longitude]); setMapCenter([item.latitude, item.longitude]);
}} }}
className="text-[9px] font-bold text-blue-600 dark:text-blue-400 text-left hover:underline flex items-center gap-1 mt-0.5 cursor-pointer bg-transparent border-none p-0" className="text-[9px] font-bold text-blue-600 dark:text-blue-400 text-left hover:underline flex items-center gap-1 mt-0.5 cursor-pointer bg-transparent border-none p-0"
+157 -55
View File
@@ -148,28 +148,40 @@ const combineSegmentRoutes = (segmentRoutes: OSRMRoute[][], selectedIndices: num
// Component Helper để tự động điều chỉnh khung nhìn bản đồ bao phủ toàn bộ lộ trình // Component Helper để tự động điều chỉnh khung nhìn bản đồ bao phủ toàn bộ lộ trình
const MapTourBounds = ({ locations }: { locations: any[] }) => { const MapTourBounds = ({ locations }: { locations: any[] }) => {
const map = useMap(); const map = useMap();
// Tạo một key dựa trên giá trị tọa độ để tránh chạy lại khi chỉ thay đổi tham chiếu mảng const shouldFitRef = useRef(true);
const locKey = useMemo(() => JSON.stringify(locations.map(l => [l.latitude, l.longitude])), [locations]); const prevLocKeyRef = useRef<string | null>(null);
const locKey = useMemo(() => JSON.stringify(locations.map((l: any) => [l.latitude, l.longitude])), [locations]);
useEffect(() => { useEffect(() => {
if (locations.length > 0) { if (locations.length === 0) return;
const bounds = L.latLngBounds(locations.map(l => [l.latitude, l.longitude]));
if (locations.length === 1) { if (prevLocKeyRef.current !== null && prevLocKeyRef.current !== locKey) {
// Chỉ thực hiện nếu bản đồ chưa ở đúng vị trí (tránh trigger moveend liên tục) shouldFitRef.current = true;
map.setView([locations[0].latitude, locations[0].longitude], 15, { animate: true });
} else {
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 15 });
}
} }
}, [locKey, map]); prevLocKeyRef.current = locKey;
if (!shouldFitRef.current) return;
const bounds = L.latLngBounds(locations.map((l: any) => [l.latitude, l.longitude]));
if (locations.length === 1) {
map.setView([locations[0].latitude, locations[0].longitude], 15, { animate: true });
} else {
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 15 });
}
shouldFitRef.current = false;
}, [locKey, map, locations.length]);
return null; return null;
}; };
// Component Helper để di chuyển tâm bản đồ về vị trí người dùng khi nhấn nút // Component Helper để di chuyển tâm bản đồ về vị trí người dùng khi nhấn nút
const RecenterUser = ({ position, trigger }: { position: [number, number] | null, trigger: number }) => { const RecenterUser = ({ position, trigger }: { position: [number, number] | null, trigger: number }) => {
const map = useMap(); const map = useMap();
const lastTriggerRef = useRef(0);
useEffect(() => { useEffect(() => {
if (position && trigger > 0) { if (position && trigger > lastTriggerRef.current) {
lastTriggerRef.current = trigger;
map.setView(position, 16, { animate: true }); map.setView(position, 16, { animate: true });
} }
}, [trigger, position, map]); }, [trigger, position, map]);
@@ -210,7 +222,7 @@ const MapRotationHandler = ({ rotation }: { rotation: number }) => {
// 1. Dùng translateZ(0) để kích hoạt GPU trên iOS. // 1. Dùng translateZ(0) để kích hoạt GPU trên iOS.
// 2. Giảm scale xuống ~1.6 (vừa đủ che góc) để giảm tải cho bộ nhớ đệm đồ họa. // 2. Giảm scale xuống ~1.6 (vừa đủ che góc) để giảm tải cho bộ nhớ đệm đồ họa.
container.style.transform = `rotate(${cumulativeRotationRef.current}deg) scale(2.2) translateZ(0)`; 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)'; container.style.transition = 'transform 0.15s ease-out';
}, [rotation, map]); }, [rotation, map]);
return null; return null;
}; };
@@ -855,6 +867,8 @@ export const TourDetailPage = ({
// Theo dõi hướng thiết bị (la bàn) // Theo dõi hướng thiết bị (la bàn)
useEffect(() => { useEffect(() => {
let lastHeading: number | null = null;
const handleOrientation = (event: any) => { const handleOrientation = (event: any) => {
let heading: number | null = null; let heading: number | null = null;
@@ -864,17 +878,17 @@ export const TourDetailPage = ({
} }
// 2. Đối với Android (Chrome): Cần kiểm tra tính tuyệt đối của dữ liệu // 2. Đối với Android (Chrome): Cần kiểm tra tính tuyệt đối của dữ liệu
else if (event.alpha !== null && event.alpha !== undefined) { else if (event.alpha !== null && event.alpha !== undefined) {
// Chrome trên Android chỉ cung cấp hướng la bàn chuẩn khi event.absolute là true
// hoặc khi nhận từ sự kiện 'deviceorientationabsolute'
if (event.absolute === true || event.type === 'deviceorientationabsolute') { if (event.absolute === true || event.type === 'deviceorientationabsolute') {
// Alpha trên Android tăng theo chiều ngược kim đồng hồ (0=North, 90=West)
// Cần chuyển đổi sang chiều kim đồng hồ để khớp với logic quay bản đồ
heading = (360 - event.alpha) % 360; heading = (360 - event.alpha) % 360;
} }
} }
if (heading !== null) { if (heading !== null) {
setDeviceOrientationHeading(heading); // Chỉ cập nhật nếu hướng thay đổi lớn hơn 2 độ để giảm tải vẽ lại
if (lastHeading === null || Math.abs(heading - lastHeading) > 2) {
lastHeading = heading;
setDeviceOrientationHeading(heading);
}
} }
}; };
@@ -1230,6 +1244,31 @@ export const TourDetailPage = ({
const rejectJoinRequest = useTourStore(state => state.rejectJoinRequest); const rejectJoinRequest = useTourStore(state => state.rejectJoinRequest);
const deleteTour = useTourStore(state => state.deleteTour); const deleteTour = useTourStore(state => state.deleteTour);
const toggleCompassMode = async () => {
if (!isHeadingMode) {
if (
typeof DeviceOrientationEvent !== 'undefined' &&
typeof (DeviceOrientationEvent as any).requestPermission === 'function'
) {
try {
const permissionState = await (DeviceOrientationEvent as any).requestPermission();
if (permissionState === 'granted') {
setIsHeadingMode(true);
} else {
alert("Để xoay bản đồ theo hướng di chuyển, vui lòng cấp quyền truy cập cảm biến hướng (La bàn).");
}
} catch (error) {
console.error("Error requesting compass permission:", error);
setIsHeadingMode(true);
}
} else {
setIsHeadingMode(true);
}
} else {
setIsHeadingMode(false);
setMapRotation(0);
}
};
// Khôi phục vị trí và mức zoom từ localStorage // Khôi phục vị trí và mức zoom từ localStorage
const [initialViewState] = useState(() => { const [initialViewState] = useState(() => {
@@ -1893,7 +1932,9 @@ export const TourDetailPage = ({
)} )}
</div> </div>
</div> </div>
{/* Tour Header Info */} {!(activeTab === 'plan' && viewMode === 'map') && (
<>
{/* Tour Header Info */}
<div className="relative min-h-[480px] w-full bg-blue-900 flex flex-col justify-end"> <div className="relative min-h-[480px] w-full bg-blue-900 flex flex-col justify-end">
<img <img
src={tourInfo.coverImage} src={tourInfo.coverImage}
@@ -2156,11 +2197,14 @@ export const TourDetailPage = ({
</div> </div>
)} )}
</div> </div>
</>
)}
{/* Main Content Area - Điều chỉnh max-width khi ở chế độ bản đồ */} {/* Main Content Area - Điều chỉnh max-width khi ở chế độ bản đồ */}
<div className={`${activeTab === 'plan' && viewMode === 'map' ? 'w-full' : 'max-w-2xl mx-auto'} px-4 pb-24`}> <div className={`${activeTab === 'plan' && viewMode === 'map' ? 'w-full !px-0 !pb-0' : 'max-w-2xl mx-auto px-4 pb-24'}`}>
{/* Tab Switcher */} {/* Tab Switcher */}
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-1 flex mb-6 sticky top-[60px] z-40"> {!(activeTab === 'plan' && viewMode === 'map') && (
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-1 flex mb-6 sticky top-[60px] z-40">
{tabs.map((tab) => ( {tabs.map((tab) => (
<button <button
key={tab.id} key={tab.id}
@@ -2184,44 +2228,49 @@ export const TourDetailPage = ({
</button> </button>
))} ))}
</div> </div>
)}
{/* Tab Panels */} {/* Tab Panels */}
<div className="transition-opacity duration-300"> <div className="transition-opacity duration-300">
{activeTab === 'plan' && ( {activeTab === 'plan' && (
<div className="animate-in fade-in slide-in-from-bottom-2"> <div className="animate-in fade-in slide-in-from-bottom-2">
{/* View Mode Toggle */} {/* View Mode Toggle */}
<div className="flex justify-center items-center mb-3"> {viewMode !== 'map' && (
<div className="bg-gray-100 dark:bg-slate-800 p-1 rounded-2xl flex gap-1"> <div className="flex justify-center items-center mb-3">
<button <div className="bg-gray-100 dark:bg-slate-800 p-1 rounded-2xl flex gap-1">
onClick={() => setViewMode('timeline')} <button
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all ${viewMode === 'timeline' ? 'bg-white dark:bg-slate-900 shadow-sm text-blue-600 dark:text-blue-400' : 'text-gray-500'}`} onClick={() => setViewMode('timeline')}
> className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all ${viewMode === 'timeline' ? 'bg-white dark:bg-slate-900 shadow-sm text-blue-600 dark:text-blue-400' : 'text-gray-500'}`}
<List className="w-3.5 h-3.5" /> Danh sách >
</button> <List className="w-3.5 h-3.5" /> Danh sách
<button </button>
onClick={() => setViewMode('map')} <button
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all ${viewMode === 'map' ? 'bg-white dark:bg-slate-900 shadow-sm text-blue-600 dark:text-blue-400' : 'text-gray-500'}`} onClick={() => setViewMode('map')}
> className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all ${viewMode === 'map' ? 'bg-white dark:bg-slate-900 shadow-sm text-blue-600 dark:text-blue-400' : 'text-gray-500'}`}
<MapIconLucide className="w-3.5 h-3.5" /> Bản đ >
</button> <MapIconLucide className="w-3.5 h-3.5" /> Bản đ
</button>
</div>
</div> </div>
</div> )}
{/* Export Buttons */} {/* Export Buttons */}
<div className="flex justify-center gap-2 mb-6"> {viewMode !== 'map' && (
<button <div className="flex justify-center gap-2 mb-6">
onClick={handleExportCSV} <button
className="flex items-center gap-1 px-3 py-1.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-[11px] font-bold shadow-md transition-all active:scale-95 whitespace-nowrap" onClick={handleExportCSV}
> className="flex items-center gap-1 px-3 py-1.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-[11px] font-bold shadow-md transition-all active:scale-95 whitespace-nowrap"
📊 Google Sheets >
</button> 📊 Google Sheets
<button </button>
onClick={handleExportPDF} <button
className="flex items-center gap-1 px-3 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-[11px] font-bold shadow-md transition-all active:scale-95 whitespace-nowrap" onClick={handleExportPDF}
> className="flex items-center gap-1 px-3 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-[11px] font-bold shadow-md transition-all active:scale-95 whitespace-nowrap"
📥 {t('exportPDF') || 'Xuất PDF'} >
</button> 📥 {t('exportPDF') || 'Xuất PDF'}
</div> </button>
</div>
)}
{viewMode === 'timeline' ? ( {viewMode === 'timeline' ? (
<ItineraryTimeline onAddLocation={(legId, isStart, isEnd) => { <ItineraryTimeline onAddLocation={(legId, isStart, isEnd) => {
@@ -2247,7 +2296,7 @@ export const TourDetailPage = ({
onOpenNavigationPage={onOpenNavigationPage} 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"> <div className="h-[calc(100vh-53px)] w-full md:rounded-3xl overflow-hidden md:shadow-xl md:border-4 md:border-white relative animate-in fade-in duration-500">
{/* Search Bar Overlay - Thanh tìm kiếm địa điểm trên bản đồ lộ trình */} {/* Search Bar Overlay - Thanh tìm kiếm địa điểm trên bản đồ lộ trình */}
{!isPublicView && ( {!isPublicView && (
<div className="absolute top-3 right-3 z-[1001] w-48 md:w-64"> <div className="absolute top-3 right-3 z-[1001] w-48 md:w-64">
@@ -2473,6 +2522,38 @@ export const TourDetailPage = ({
</MarkerClusterGroup> </MarkerClusterGroup>
</MapContainer> </MapContainer>
{/* Floating Compass Button */}
<button
onClick={toggleCompassMode}
className={`absolute bottom-16 right-4 z-[1001] w-10 h-10 rounded-xl border flex items-center justify-center shadow-xl transition-all active:scale-95 ${
isHeadingMode
? 'bg-blue-600 border-blue-400 text-white shadow-md'
: 'bg-white/90 backdrop-blur-md border-white text-gray-500 hover:bg-gray-100'
}`}
title={isHeadingMode ? "Khóa hướng Bắc" : "Xoay theo hướng nhìn"}
>
<Compass className="w-5 h-5 transition-transform duration-300" style={{ transform: `rotate(${mapRotation}deg)` }} />
</button>
{/* Floating Locate User Button */}
<button
onClick={() => setLocateTrigger(prev => prev + 1)}
disabled={!userLocation}
className={`absolute bottom-4 right-4 z-[1001] w-10 h-10 bg-white/90 backdrop-blur-md rounded-xl border border-white shadow-xl text-blue-600 hover:bg-blue-50 transition-all active:scale-95 flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed`}
title="Vị trí của tôi"
>
<LocateFixed className="w-5 h-5" />
</button>
{/* Floating Back to Timeline Button */}
<button
onClick={() => setViewMode('timeline')}
className="absolute top-3 left-14 z-[1001] h-9 px-3 bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-white text-gray-700 hover:bg-gray-50 hover:text-blue-600 transition-all active:scale-95 flex items-center gap-1.5 text-xs font-bold"
title="Quay lại danh sách chặng"
>
<List className="w-4 h-4" /> Danh sách
</button>
{/* Menu ngữ cảnh khi click chuột phải vào con đường */} {/* Menu ngữ cảnh khi click chuột phải vào con đường */}
{routeMenu && ( {routeMenu && (
<div <div
@@ -3172,7 +3253,7 @@ export const TourDetailPage = ({
</div> </div>
{/* Floating Action Button (Mobile) */} {/* Floating Action Button (Mobile) */}
{((activeTab === 'plan' && canEdit) || (activeTab === 'photo' && canUploadPhoto)) && !isPublicView && ( {((activeTab === 'plan' && viewMode === 'timeline' && canEdit) || (activeTab === 'photo' && canUploadPhoto)) && !isPublicView && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40"> <div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40">
<button <button
onClick={() => { onClick={() => {
@@ -3259,6 +3340,29 @@ export const TourDetailPage = ({
})} })}
</MapContainer> </MapContainer>
{/* Floating Compass Button (Fullscreen) */}
<button
onClick={toggleCompassMode}
className={`absolute bottom-22 right-6 z-[1001] w-12 h-12 rounded-2xl border flex items-center justify-center shadow-xl transition-all active:scale-95 ${
isHeadingMode
? 'bg-blue-600 border-blue-400 text-white shadow-md'
: 'bg-white/90 backdrop-blur-md border-white text-gray-500 hover:bg-gray-100'
}`}
title={isHeadingMode ? "Khóa hướng Bắc" : "Xoay theo hướng nhìn"}
>
<Compass className="w-6 h-6 transition-transform duration-300" style={{ transform: `rotate(${mapRotation}deg)` }} />
</button>
{/* Floating Locate User Button (Fullscreen) */}
<button
onClick={() => setLocateTrigger(prev => prev + 1)}
disabled={!userLocation}
className={`absolute bottom-8 right-6 z-[1001] w-12 h-12 bg-white/90 backdrop-blur-md rounded-2xl border border-white shadow-xl text-blue-600 hover:bg-blue-50 transition-all active:scale-95 flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed`}
title="Vị trí của tôi"
>
<LocateFixed className="w-6 h-6" />
</button>
{/* Overlay điều khiển trên bản đồ toàn màn hình */} {/* Overlay điều khiển trên bản đồ toàn màn hình */}
<div className="absolute top-[calc(1rem+env(safe-area-inset-top,0px))] right-4 z-[1001] flex flex-col gap-2"> <div className="absolute top-[calc(1rem+env(safe-area-inset-top,0px))] right-4 z-[1001] flex flex-col gap-2">
<button <button
@@ -3275,9 +3379,7 @@ export const TourDetailPage = ({
<button onClick={() => { setTravelMode('foot'); setIsMapControlsOpen(false); }} className={`w-9 h-9 flex items-center justify-center rounded-xl ${travelMode === 'foot' ? 'bg-blue-600 text-white' : 'text-gray-500'}`}><Footprints className="w-4 h-4" /></button> <button onClick={() => { setTravelMode('foot'); setIsMapControlsOpen(false); }} className={`w-9 h-9 flex items-center justify-center rounded-xl ${travelMode === 'foot' ? 'bg-blue-600 text-white' : 'text-gray-500'}`}><Footprints className="w-4 h-4" /></button>
<button <button
onClick={() => { onClick={() => {
const newMode = !isHeadingMode; toggleCompassMode();
setIsHeadingMode(newMode);
if (!newMode) setMapRotation(0);
setIsMapControlsOpen(false); setIsMapControlsOpen(false);
}} }}
className={`w-9 h-9 flex items-center justify-center rounded-xl border-t border-gray-100 transition-all ${isHeadingMode ? 'bg-blue-600 text-white' : 'text-gray-500'}`} className={`w-9 h-9 flex items-center justify-center rounded-xl border-t border-gray-100 transition-all ${isHeadingMode ? 'bg-blue-600 text-white' : 'text-gray-500'}`}
+232 -38
View File
@@ -16,13 +16,13 @@ interface TourNavigationPageProps {
onBack: () => void; onBack: () => void;
} }
const FitBounds = ({ coords }: { coords: [number, number][] }) => { const FitBounds = ({ coords, destination, hasInteractedRef }: { coords: [number, number][], destination: { lat: number; lng: number; name: string } | null, hasInteractedRef: React.MutableRefObject<boolean> }) => {
const map = useMap(); const map = useMap();
useEffect(() => { useEffect(() => {
if (coords.length > 0) { if (coords.length > 0 && !hasInteractedRef.current && destination) {
map.fitBounds(coords, { padding: [60, 60], maxZoom: 15 }); map.fitBounds(coords, { padding: [60, 60], maxZoom: 15 });
} }
}, [map, coords]); }, [map, coords, destination, hasInteractedRef]);
return null; return null;
}; };
@@ -39,7 +39,7 @@ const MapRotationHandler = ({ rotation }: { rotation: number }) => {
if (rotation === 0) { if (rotation === 0) {
cumulativeRotationRef.current = 0; cumulativeRotationRef.current = 0;
prevRotationRef.current = 0; prevRotationRef.current = 0;
container.style.transform = 'rotate(0deg) scale(1)'; container.style.transform = '';
return; return;
} }
@@ -57,14 +57,16 @@ const MapRotationHandler = ({ rotation }: { rotation: number }) => {
}; };
const CompassInteractionDetector = ({ const CompassInteractionDetector = ({
onUserInteraction onUserInteraction,
hasInteractedRef
}: { }: {
onUserInteraction: () => void; onUserInteraction: () => void;
hasInteractedRef: React.MutableRefObject<boolean>;
}) => { }) => {
useMapEvents({ useMapEvents({
movestart: onUserInteraction, movestart: () => { hasInteractedRef.current = true; },
zoomstart: onUserInteraction, zoomstart: () => { hasInteractedRef.current = true; },
dragstart: onUserInteraction, dragstart: () => { hasInteractedRef.current = true; },
}); });
return null; return null;
}; };
@@ -77,21 +79,83 @@ const MapRefSetter = ({ mapRef }: { mapRef: React.MutableRefObject<L.Map | null>
return null; return null;
}; };
const MapInteractionWatcher = ({ hasInteractedRef }: { hasInteractedRef: React.MutableRefObject<boolean> }) => {
const map = useMap();
useEffect(() => {
const onZoomEnd = () => {
map._userZoomLevel = map.getZoom();
hasInteractedRef.current = true;
};
const onMoveEnd = () => {
map._userCenter = map.getCenter();
hasInteractedRef.current = true;
};
map.on('zoomend', onZoomEnd);
map.on('moveend', onMoveEnd);
return () => {
map.off('zoomend', onZoomEnd);
map.off('moveend', onMoveEnd);
};
}, [map, hasInteractedRef]);
return null;
};
const MapSizeHandler = ({ mapRef }: { mapRef: React.MutableRefObject<L.Map | null> }) => {
const map = useMap();
const prevSizeRef = useRef<{ width: number; height: number } | null>(null);
useEffect(() => {
const container = map.getContainer();
if (!container) return;
prevSizeRef.current = { width: container.clientWidth, height: container.clientHeight };
const observer = new ResizeObserver(() => {
const newWidth = container.clientWidth;
const newHeight = container.clientHeight;
const prev = prevSizeRef.current;
if ((prev && (Math.abs(newWidth - prev.width) > 2 || Math.abs(newHeight - prev.height) > 2)) || newWidth === 0 || newHeight === 0) {
prevSizeRef.current = { width: newWidth, height: newHeight };
const userZoom = (map as any)._userZoomLevel as number | undefined;
map.invalidateSize({ animate: false });
if (userZoom !== undefined && Math.abs(map.getZoom() - userZoom) > 0.01) {
map.setZoom(userZoom, { animate: false });
}
}
});
observer.observe(container);
return () => observer.disconnect();
}, [map, mapRef]);
return null;
};
export const TourNavigationPage: React.FC<TourNavigationPageProps> = ({ tourId, routeData, onBack }) => { export const TourNavigationPage: React.FC<TourNavigationPageProps> = ({ tourId, routeData, onBack }) => {
const [isCompassActive, setIsCompassActive] = useState(false); const [isCompassActive, setIsCompassActive] = useState(false);
const [isLocatingUser, setIsLocatingUser] = useState(false);
const [isTrackingLocation, setIsTrackingLocation] = useState(false);
const [currentHeading, setCurrentHeading] = useState(0); const [currentHeading, setCurrentHeading] = useState(0);
const [routeGeometry, setRouteGeometry] = useState<[number, number][] | null>(null); const [routeGeometry, setRouteGeometry] = useState<[number, number][] | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const mapRef = useRef<L.Map | null>(null); const mapRef = useRef<L.Map | null>(null);
const watchIdRef = useRef<number | null>(null); const trackingWatchIdRef = useRef<number | null>(null);
const compassWatchIdRef = useRef<number | null>(null);
const fetchLockRef = useRef(false); const fetchLockRef = useRef(false);
const hasInteractedRef = useRef(false);
const isProgrammaticMoveRef = useRef(false);
const originLat = routeData?.origin?.lat; const originLat = routeData?.origin?.lat;
const originLng = routeData?.origin?.lng; const originLng = routeData?.origin?.lng;
const destLat = routeData?.destination?.lat; const destLat = routeData?.destination?.lat;
const destLng = routeData?.destination?.lng; const destLng = routeData?.destination?.lng;
useEffect(() => { useEffect(() => {
if (!routeData || !originLat || !originLng || !destLat || !destLng) { if (!routeData || !originLat || !originLng || !destLat || !destLng) {
setRouteGeometry(null); setRouteGeometry(null);
@@ -126,7 +190,76 @@ export const TourNavigationPage: React.FC<TourNavigationPageProps> = ({ tourId,
}; };
}, [routeData, originLat, originLng, destLat, destLng]); }, [routeData, originLat, originLng, destLat, destLng]);
// Effect 1: Live tracking using watchPosition
useEffect(() => {
if (isTrackingLocation) {
if (navigator.geolocation) {
console.log("GPS Location Tracking engaged. Syncing viewport to center...");
trackingWatchIdRef.current = navigator.geolocation.watchPosition(
(position) => {
const { latitude, longitude } = position.coords;
if (mapRef.current) {
const currentZoom = mapRef.current.getZoom();
isProgrammaticMoveRef.current = true;
mapRef.current.setView([latitude, longitude], Math.max(currentZoom, 16), { animate: true });
setTimeout(() => {
isProgrammaticMoveRef.current = false;
}, 100);
}
},
(error) => console.error("GPS stream tracking lost:", error),
{ enableHighAccuracy: true }
);
}
} else {
if (trackingWatchIdRef.current !== null) {
navigator.geolocation.clearWatch(trackingWatchIdRef.current);
trackingWatchIdRef.current = null;
console.log("GPS Location Tracking disabled. Map control released to user.");
}
}
return () => {
if (trackingWatchIdRef.current !== null) {
navigator.geolocation.clearWatch(trackingWatchIdRef.current);
trackingWatchIdRef.current = null;
}
};
}, [isTrackingLocation]);
// Effect 2: Gesture detection to auto-toggle tracking OFF when user interacts
useEffect(() => {
if (!mapRef.current) return;
const map = mapRef.current;
const breakTrackingOnGesture = () => {
if (isProgrammaticMoveRef.current) {
return;
}
if (isTrackingLocation) {
console.log("User touch map interaction detected. Disengaging auto-center lock.");
setIsTrackingLocation(false);
}
if (isCompassActive) {
console.log("User touch map interaction detected. Disengaging compass lock.");
setIsCompassActive(false);
}
};
map.on('dragstart', breakTrackingOnGesture);
map.on('zoomstart', breakTrackingOnGesture);
map.on('movestart', breakTrackingOnGesture);
return () => {
map.off('dragstart', breakTrackingOnGesture);
map.off('zoomstart', breakTrackingOnGesture);
map.off('movestart', breakTrackingOnGesture);
};
}, [isTrackingLocation, isCompassActive]);
const handleUserInteraction = useCallback(() => { const handleUserInteraction = useCallback(() => {
hasInteractedRef.current = true;
if (isCompassActive) { if (isCompassActive) {
setIsCompassActive(false); setIsCompassActive(false);
} }
@@ -139,10 +272,14 @@ export const TourNavigationPage: React.FC<TourNavigationPageProps> = ({ tourId,
} }
if (navigator.geolocation) { if (navigator.geolocation) {
watchIdRef.current = navigator.geolocation.watchPosition( compassWatchIdRef.current = navigator.geolocation.watchPosition(
(position) => { (position) => {
if (mapRef.current) { if (mapRef.current) {
isProgrammaticMoveRef.current = true;
mapRef.current.setView([position.coords.latitude, position.coords.longitude], undefined, { animate: true }); mapRef.current.setView([position.coords.latitude, position.coords.longitude], undefined, { animate: true });
setTimeout(() => {
isProgrammaticMoveRef.current = false;
}, 100);
} }
if (position.coords.heading !== null) { if (position.coords.heading !== null) {
setCurrentHeading(position.coords.heading); setCurrentHeading(position.coords.heading);
@@ -171,8 +308,9 @@ export const TourNavigationPage: React.FC<TourNavigationPageProps> = ({ tourId,
window.addEventListener('deviceorientationabsolute', handleOrientation, true); window.addEventListener('deviceorientationabsolute', handleOrientation, true);
return () => { return () => {
if (watchIdRef.current !== null) { if (compassWatchIdRef.current !== null) {
navigator.geolocation.clearWatch(watchIdRef.current); navigator.geolocation.clearWatch(compassWatchIdRef.current);
compassWatchIdRef.current = null;
} }
window.removeEventListener('deviceorientation', handleOrientation, true); window.removeEventListener('deviceorientation', handleOrientation, true);
window.removeEventListener('deviceorientationabsolute', handleOrientation, true); window.removeEventListener('deviceorientationabsolute', handleOrientation, true);
@@ -188,9 +326,9 @@ export const TourNavigationPage: React.FC<TourNavigationPageProps> = ({ tourId,
const destIcon = useMemo(() => L.divIcon({ const destIcon = useMemo(() => L.divIcon({
className: '!bg-transparent !border-none', 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>`, html: `<div class="w-10 h-10 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], iconSize: [40, 40],
iconAnchor: [16, 16] iconAnchor: [20, 20]
}), []); }), []);
if (!routeData) return null; if (!routeData) return null;
@@ -199,14 +337,40 @@ export const TourNavigationPage: React.FC<TourNavigationPageProps> = ({ tourId,
? [(routeData.origin.lat + routeData.destination.lat) / 2, (routeData.origin.lng + routeData.destination.lng) / 2] ? [(routeData.origin.lat + routeData.destination.lat) / 2, (routeData.origin.lng + routeData.destination.lng) / 2]
: [0, 0]; : [0, 0];
const centerOnUser = () => {
if (navigator.geolocation) {
setIsLocatingUser(true);
navigator.geolocation.getCurrentPosition(
(position) => {
setIsLocatingUser(false);
if (mapRef.current) {
const currentZoom = mapRef.current.getZoom();
isProgrammaticMoveRef.current = true;
mapRef.current.setView([position.coords.latitude, position.coords.longitude], Math.max(currentZoom, 16), { animate: true });
setTimeout(() => {
isProgrammaticMoveRef.current = false;
}, 100);
hasInteractedRef.current = true;
}
},
(err) => {
setIsLocatingUser(false);
console.error("Cannot get user location:", err);
},
{ enableHighAccuracy: true }
);
}
};
return ( return (
<div className="fixed inset-0 bg-slate-950 flex flex-col overflow-hidden select-none text-white antialiased"> <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"> <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 shrink-0"
<button style={{ paddingTop: 'calc(0.75rem + env(safe-area-inset-top, 0px))' }}>
onClick={onBack} <button
className="text-slate-300 hover:text-white p-1 rounded-lg hover:bg-slate-800 transition-colors" onClick={onBack}
title="Quay lại danh sách lộ trình" 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" /> <ChevronLeft className="w-6 h-6" />
</button> </button>
<h2 className="text-sm font-bold truncate tracking-wide text-slate-100"> <h2 className="text-sm font-bold truncate tracking-wide text-slate-100">
@@ -214,38 +378,67 @@ export const TourNavigationPage: React.FC<TourNavigationPageProps> = ({ tourId,
</h2> </h2>
</div> </div>
<div className="flex-1 relative h-[calc(100vh-56px)]"> <div className="flex-1 relative min-h-0" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}>
<MapContainer <MapContainer
center={center} center={center}
zoom={14} zoom={14}
className="absolute inset-0 h-full w-full" className="absolute inset-0 h-full w-full"
zoomControl={true} zoomControl={true}
attributionControl={false} attributionControl={false}
scrollWheelZoom={true}
doubleClickZoom={true}
touchZoom={true}
dragging={true}
inertia={true}
maxZoom={20}
minZoom={2}
> >
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution="" /> <TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution="" />
<MapRefSetter mapRef={mapRef} /> <MapRefSetter mapRef={mapRef} />
{routeData.origin && ( <MapInteractionWatcher hasInteractedRef={hasInteractedRef} />
<Marker position={[routeData.origin.lat, routeData.origin.lng]} icon={userIcon}> <FitBounds coords={routeGeometry || []} destination={routeData?.destination || null} hasInteractedRef={hasInteractedRef} />
<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 && ( {routeGeometry && (
<Polyline positions={routeGeometry} color="#2563eb" weight={6} opacity={0.9} smoothFactor={1} /> <Polyline positions={routeGeometry} color="#2563eb" weight={6} opacity={0.9} smoothFactor={1} />
)} )}
<MapSizeHandler mapRef={mapRef} />
<MapRotationHandler rotation={currentHeading} /> <MapRotationHandler rotation={currentHeading} />
<CompassInteractionDetector onUserInteraction={handleUserInteraction} /> <CompassInteractionDetector onUserInteraction={handleUserInteraction} hasInteractedRef={hasInteractedRef} />
</MapContainer> </MapContainer>
{/* Locate User Button */}
<button
onClick={centerOnUser}
className={`absolute bottom-8 left-6 z-[999] w-14 h-14 rounded-full border-2 flex items-center justify-center shadow-2xl transition-all active:scale-95 ${
isLocatingUser
? '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'
}`}
style={{ bottom: 'calc(2rem + env(safe-area-inset-bottom, 0px))' }}
title="Định vị vị trí hiện tại của bạn"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M12 2v4M12 18v4M2 12h4M18 12h4" />
</svg>
</button>
{/* FLOATING LIVE GPS TRACKING CENTER LOCK BUTTON */}
<button
onClick={() => setIsTrackingLocation(!isTrackingLocation)}
className={`absolute bottom-24 right-6 z-[999] w-14 h-14 rounded-full border-2 flex items-center justify-center shadow-2xl transition-all active:scale-95 ${
isTrackingLocation
? 'bg-green-600 border-green-400 text-white animate-pulse'
: 'bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-green-500'
}`}
style={{ bottom: 'calc(6rem + env(safe-area-inset-bottom, 0px))' }}
title={isTrackingLocation ? "Tắt tự động định tâm vị trí" : "Bật tự động định tâm theo vị trí của bạn"}
>
<svg xmlns="http://www.w3.org/2000/svg" className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M12 2v4M12 18v4M2 12h4M18 12h4" />
</svg>
</button>
<button <button
onClick={() => setIsCompassActive(!isCompassActive)} 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 ${ 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 ${
@@ -253,6 +446,7 @@ export const TourNavigationPage: React.FC<TourNavigationPageProps> = ({ tourId,
? 'bg-blue-600 border-blue-400 text-white animate-pulse' ? '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' : 'bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-blue-400'
}`} }`}
style={{ bottom: 'calc(2rem + env(safe-area-inset-bottom, 0px))' }}
> >
<Compass className="w-7 h-7" /> <Compass className="w-7 h-7" />
</button> </button>