feat: khi người dùng click vào nút đầu mỗi điểm để tìm đường đi trên bản đồ
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
# To AI Agent: Implement Real-Time Point-to-Point Navigation Map from Timeline Nodes
|
||||
|
||||
## 1. Context & Feature Objective
|
||||
We are introducing a real-time routing feature inside `ItineraryTimeline.tsx` as indicated by pointers [1] and [2] in `image.png`.
|
||||
|
||||
### Target Requirements:
|
||||
1. **Interactive Node Trigger:** Convert the static white circular timeline node (Pointer [1]) next to each location card into an active, clickable interactive button.
|
||||
2. **Real-Time Geolocation Acquisition:** Clicking the button must invoke the HTML5 Geolocation API to fetch the user's exact current device coordinates (`userLat`, `userLng`).
|
||||
3. **Point-to-Point Shortest Route:** Open a dedicated Map Modal (`LocationNavigationModal.tsx`). This map must calculate and draw the shortest transit route connecting **ONLY TWO POINTS**: the user's current location (Origin) and the selected timeline node's coordinates (Destination). No other intermediate tour locations should be displayed on this map layer.
|
||||
|
||||
---
|
||||
|
||||
## 2. Technical Architecture Blueprint
|
||||
|
||||
We will implement this using a localized React component state wrapper combined with the device's native GPS API, loading the route into a clean Map Modal interface:
|
||||
|
||||
[Timeline Circular Button] ➔ [Request Browser GPS] ➔ [Capture Current Coordinates] ➔ [Open Modal with 2-Point Route Engine]
|
||||
|
||||
---
|
||||
|
||||
## 3. Detailed Code Refactoring Specifications
|
||||
|
||||
### Step 1: Upgrade the Timeline Node Checkpoint into a Button
|
||||
Locate the white circular element layout inside the `leg.locations.map` rendering loop. Convert it into a semantic `<button>` equipped with micro-interactions:
|
||||
|
||||
```jsx
|
||||
{/* ❌ OLD STATIC NODE */}
|
||||
<div className="absolute left-[13px] top-[26px] w-6 h-6 bg-white ... " />
|
||||
|
||||
{/* ✅ NEW INTERACTIVE NAVIGATION BUTTON */}
|
||||
<button
|
||||
onClick={() => handleTriggerNavigation(location)}
|
||||
className="absolute left-[13px] top-[26px] w-6 h-6 bg-white rounded-full border-2 border-gray-300 z-20 flex items-center justify-center shadow-sm hover:scale-110 hover:border-blue-500 hover:shadow-md transition-all group"
|
||||
title="Bấm để xem chỉ đường từ vị trí của bạn"
|
||||
>
|
||||
{/* Inner center dot changes color on hover to signify link action */}
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full group-hover:bg-red-500 transition-colors" />
|
||||
</button>
|
||||
|
||||
### Step 2: Implement Geolocation Extraction Handler
|
||||
Inside ItineraryTimeline.tsx, initialize state controllers to manage the modal visibility and add the coordinate-acquisition engine:
|
||||
|
||||
// 1. Initialize State Vectors for the Navigation Pipeline
|
||||
const [navRouteData, setNavRouteData] = useState<{
|
||||
origin: { lat: number; lng: number } | null;
|
||||
destination: { lat: number; lng: number; name: string } | null;
|
||||
}>({ origin: null, destination: null });
|
||||
const [isNavModalOpen, setIsNavModalOpen] = useState(false);
|
||||
|
||||
// 2. Core Handler Engine
|
||||
const handleTriggerNavigation = (location: any) => {
|
||||
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("Trình duyệt hoặc thiết bị của bạn không hỗ trợ định vị toàn cầu GPS.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Request high-accuracy real-time user positioning data
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
setNavRouteData({
|
||||
origin: {
|
||||
lat: position.coords.latitude,
|
||||
lng: position.coords.longitude
|
||||
},
|
||||
destination: {
|
||||
lat: parseFloat(location.latitude),
|
||||
lng: parseFloat(location.longitude),
|
||||
name: location.name || "Điểm đến chọn sẵn"
|
||||
}
|
||||
});
|
||||
setIsNavModalOpen(true);
|
||||
},
|
||||
(error) => {
|
||||
console.error("Error fetching native geolocation metrics:", error);
|
||||
alert("Không thể truy cập vị trí hiện tại của bạn. Vui lòng bật định vị GPS của thiết bị.");
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 8000 }
|
||||
);
|
||||
};
|
||||
|
||||
### Step 3: Create the Dedicated 2-Point Route Modal Component
|
||||
Create a new file components/LocationNavigationModal.tsx. Configure your mapping stack (e.g., Google Maps JavaScript API, Leaflet Routing Machine, or Mapbox Directions) ensuring zero waypoints are injected, displaying strictly the point-to-point path:
|
||||
|
||||
import React from 'react';
|
||||
// Note: Adapt the map layer imports below to match your active mapping stack (e.g., Mapbox, Google, or Leaflet)
|
||||
|
||||
interface NavModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
routeData: {
|
||||
origin: { lat: number; lng: number } | null;
|
||||
destination: { lat: number; lng: number; name: string } | null;
|
||||
};
|
||||
}
|
||||
|
||||
export const LocationNavigationModal: React.FC<NavModalProps> = ({ isOpen, onClose, routeData }) => {
|
||||
if (!isOpen || !routeData.origin || !routeData.destination) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||
<div className="bg-yotripDark-surface border border-yotripDark-border w-full max-w-4xl h-[80vh] rounded-2xl overflow-hidden flex flex-col shadow-2xl animate-fade-in">
|
||||
|
||||
{/* Modal Header */}
|
||||
<div className="p-4 bg-slate-900 border-b border-yotripDark-border flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-md font-bold text-white flex items-center gap-2">
|
||||
📍 Chỉ đường đến: <span className="text-blue-400">{routeData.destination.name}</span>
|
||||
</h3>
|
||||
<p className="text-xs text-gray-400 mt-0.5">Tuyến đường ngắn nhất tối ưu từ vị trí hiện tại của bạn</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-white text-sm font-bold px-3 py-1.5 rounded-lg bg-gray-800">
|
||||
Đóng [X]
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Map Container Viewport */}
|
||||
<div className="flex-1 relative bg-slate-950">
|
||||
{/* AI AGENT IMPLEMENTATION TASK: Load Map View Container here.
|
||||
- Inject Marker A at [routeData.origin.lat, routeData.origin.lng] (Label: "Vị trí của bạn")
|
||||
- Inject Marker B at [routeData.destination.lat, routeData.destination.lng] (Label: Destination Name)
|
||||
- Invoke Directions Service with alternative route options disabled to enforce rendering ONLY the absolute shortest route line connecting these two coordinates.
|
||||
*/}
|
||||
<div className="absolute inset-0 flex items-center justify-center text-xs text-gray-500">
|
||||
[Bản đồ Mapbox/Google/Leaflet định tuyến A ➔ B hiển thị tại đây]
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Mount this modal at the bottom of your main ItineraryTimeline.tsx file return layer:
|
||||
|
||||
<LocationNavigationModal
|
||||
isOpen={isNavModalOpen}
|
||||
onClose={() => setIsNavModalOpen(false)}
|
||||
routeData={navRouteData}
|
||||
/>
|
||||
|
||||
## 4. Verification & Acceptance Criteria for AI Agent
|
||||
[ ] Interaction Verification: Hovering over the circular white node checkpoints must convert the mouse cursor into a pointer hand and slightly scale up the element (scale-110).
|
||||
|
||||
[ ] Security Validation: Triggering the handler must properly request the standard native browser/OS location authorization dialog popup.
|
||||
|
||||
[ ] Clean Routing Strictness: The generated modal map must display precisely 2 custom markers (Current Device Spot & Selected Location Point). Ensure no other intermediate destination waypoints from other stages crawl onto the map workspace canvas.
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+2
File diff suppressed because one or more lines are too long
-213
File diff suppressed because one or more lines are too long
-2
File diff suppressed because one or more lines are too long
+213
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -21,8 +21,8 @@
|
||||
<meta name="twitter:title" content="Travel Planner - Lên kế hoạch chuyến đi của bạn" />
|
||||
<meta name="twitter:description" content="Khám phá những hành trình tuyệt vời và chia sẻ khoảnh khắc đẹp với cộng đồng du lịch." />
|
||||
<meta name="twitter:image" content="https://yotrip.labz.io.vn/background.avif" />
|
||||
<script type="module" crossorigin src="/assets/index-DTmIGIUE.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DpJrHqwq.css">
|
||||
<script type="module" crossorigin src="/assets/index-ULMuD9f4.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BU0L_D9H.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTourStore } from '@/store/useTourStore';
|
||||
import { useConfirm } from '@/hooks/useConfirm';
|
||||
import { useNotification } from '@/hooks/useNotification';
|
||||
import { CommentModal } from '@/components/CommentModal';
|
||||
import { LocationNavigationModal } from '@/components/LocationNavigationModal';
|
||||
|
||||
const TimeVariance = ({ planned, actual }: { planned: string, actual: string | null }) => {
|
||||
if (!actual) return null;
|
||||
@@ -77,6 +78,11 @@ export const ItineraryTimeline = ({
|
||||
|
||||
// State to track single expanded stage (exclusive single-expansion mode)
|
||||
const [expandedStageId, setExpandedStageId] = useState<string | null>(legs.length > 0 ? legs[0]?.id : null);
|
||||
const [navRouteData, setNavRouteData] = useState<{
|
||||
origin: { lat: number; lng: number } | null;
|
||||
destination: { lat: number; lng: number; name: string } | null;
|
||||
}>({ origin: null, destination: null });
|
||||
const [isNavModalOpen, setIsNavModalOpen] = useState(false);
|
||||
|
||||
const toggleStageExpanded = (legId: string) => {
|
||||
// Exclusive mode: if clicking the same stage, close it. Otherwise, open only the clicked one.
|
||||
@@ -127,6 +133,40 @@ export const ItineraryTimeline = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleTriggerNavigation = (location: any) => {
|
||||
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("Trình duyệt hoặc thiết bị của bạn không hỗ trợ định vị toàn cầu GPS.");
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
setNavRouteData({
|
||||
origin: {
|
||||
lat: position.coords.latitude,
|
||||
lng: position.coords.longitude
|
||||
},
|
||||
destination: {
|
||||
lat: parseFloat(location.latitude),
|
||||
lng: parseFloat(location.longitude),
|
||||
name: location.name || "Điểm đến chọn sẵn"
|
||||
}
|
||||
});
|
||||
setIsNavModalOpen(true);
|
||||
},
|
||||
(error) => {
|
||||
console.error("Error fetching native geolocation metrics:", error);
|
||||
alert("Không thể truy cập vị trí hiện tại của bạn. Vui lòng bật định vị GPS của thiết bị.");
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 8000 }
|
||||
);
|
||||
};
|
||||
|
||||
const handleAddLeg = async () => {
|
||||
const note = window.prompt("Nhập ghi chú cho chặng mới:", `Chặng ${legs.length + 1}`);
|
||||
if (note && currentTour) {
|
||||
@@ -503,6 +543,16 @@ export const ItineraryTimeline = ({
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
{location._count?.comments > 0 && `(${location._count.comments})`}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleTriggerNavigation(location);
|
||||
}}
|
||||
className="flex items-center gap-1 px-2 py-1 bg-gray-50 hover:bg-blue-50 text-gray-400 hover:text-blue-600 rounded-lg text-[10px] font-bold transition-all border border-gray-100 hover:border-blue-100"
|
||||
title="Chỉ đường từ vị trí của bạn"
|
||||
>
|
||||
<Navigation className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center text-sm font-black text-blue-600">
|
||||
<Clock className="w-3 h-3 mr-1" />
|
||||
@@ -712,6 +762,11 @@ export const ItineraryTimeline = ({
|
||||
isPublicView={isPublicView}
|
||||
onCommentAdded={() => handleCommentIncrement(commentLocationId)}
|
||||
onCommentDeleted={() => handleCommentDecrement(commentLocationId)}
|
||||
/>
|
||||
<LocationNavigationModal
|
||||
isOpen={isNavModalOpen}
|
||||
onClose={() => setIsNavModalOpen(false)}
|
||||
routeData={navRouteData}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { MapContainer, TileLayer, Marker, Polyline, Popup, useMap } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
|
||||
interface NavModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
routeData: {
|
||||
origin: { lat: number; lng: number } | null;
|
||||
destination: { lat: number; lng: number; name: string } | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface OSRMRoute {
|
||||
geometry: {
|
||||
coordinates: number[][];
|
||||
type: string;
|
||||
};
|
||||
legs: { distance: number; duration: number }[];
|
||||
distance: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
const FitBounds = ({ coords }: { coords: [number, number][] }) => {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
if (coords.length > 0) {
|
||||
map.fitBounds(coords, { padding: [60, 60], maxZoom: 15 });
|
||||
}
|
||||
}, [map, coords]);
|
||||
return null;
|
||||
};
|
||||
|
||||
export const LocationNavigationModal: React.FC<NavModalProps> = ({ isOpen, onClose, routeData }) => {
|
||||
const [routeGeometry, setRouteGeometry] = useState<[number, number][] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [routeInfo, setRouteInfo] = useState<{ distance: string; duration: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !routeData.origin || !routeData.destination) {
|
||||
setRouteGeometry(null);
|
||||
setError(null);
|
||||
setRouteInfo(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const origin = routeData.origin;
|
||||
const destination = routeData.destination;
|
||||
|
||||
const fetchRoute = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const url = `https://router.project-osrm.org/route/v1/driving/${origin.lng},${origin.lat};${destination.lng},${destination.lat}?overview=full&geometries=geojson`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`OSRM error: ${res.status}`);
|
||||
const data: { code: string; routes: OSRMRoute[] } = await res.json();
|
||||
if (data.code !== 'Ok' || !data.routes?.length) throw new Error('Không tìm thấy lộ trình phù hợp');
|
||||
|
||||
const route = data.routes[0];
|
||||
const coords = route.geometry.coordinates.map((c: number[]) => [c[1], c[0]] as [number, number]);
|
||||
setRouteGeometry(coords);
|
||||
|
||||
const hours = Math.floor(route.duration / 3600);
|
||||
const minutes = Math.round((route.duration % 3600) / 60);
|
||||
const durationStr = hours > 0 ? `${hours}h${minutes}p` : `${minutes}p`;
|
||||
setRouteInfo({
|
||||
distance: (route.distance / 1000).toFixed(1),
|
||||
duration: durationStr
|
||||
});
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchRoute();
|
||||
}, [isOpen, routeData.origin, routeData.destination]);
|
||||
|
||||
const userIcon = useMemo(() => L.divIcon({
|
||||
className: '!bg-transparent !border-none',
|
||||
html: `<div class="w-8 h-8 bg-blue-600 rounded-full border-2 border-white shadow-xl flex items-center justify-center"><div class="w-2 h-2 bg-white rounded-full"></div></div>`,
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16]
|
||||
}), []);
|
||||
|
||||
const destIcon = useMemo(() => L.divIcon({
|
||||
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>`,
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16]
|
||||
}), []);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const center: [number, number] = routeData.origin && routeData.destination
|
||||
? [(routeData.origin.lat + routeData.destination.lat) / 2, (routeData.origin.lng + routeData.destination.lng) / 2]
|
||||
: [0, 0];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||
<div className="bg-slate-900 border border-slate-700 w-full max-w-4xl h-[80vh] rounded-2xl overflow-hidden flex flex-col shadow-2xl">
|
||||
{/* Modal Header */}
|
||||
<div className="p-4 bg-slate-800 border-b border-slate-700 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-md font-bold text-white flex items-center gap-2">
|
||||
📍 Chỉ đường đến: <span className="text-blue-400">{routeData.destination?.name}</span>
|
||||
</h3>
|
||||
<p className="text-xs text-gray-400 mt-0.5">Tuyến đường ngắn nhất từ vị trí hiện tại của bạn</p>
|
||||
{routeInfo && (
|
||||
<div className="flex items-center gap-3 mt-1.5">
|
||||
<span className="text-xs font-bold text-blue-300">{routeInfo.distance} km</span>
|
||||
<span className="text-xs text-gray-500">|</span>
|
||||
<span className="text-xs font-bold text-green-300">~{routeInfo.duration}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-white text-sm font-bold px-3 py-1.5 rounded-lg bg-gray-800 hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Đóng [X]
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Map Container */}
|
||||
<div className="flex-1 relative bg-slate-950">
|
||||
<MapContainer
|
||||
center={center}
|
||||
zoom={14}
|
||||
className="h-full w-full"
|
||||
zoomControl={true}
|
||||
attributionControl={false}
|
||||
>
|
||||
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution="" />
|
||||
{routeData.origin && (
|
||||
<Marker position={[routeData.origin.lat, routeData.origin.lng]} icon={userIcon}>
|
||||
<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 && (
|
||||
<Polyline positions={routeGeometry} color="#2563eb" weight={6} opacity={0.9} smoothFactor={1} />
|
||||
)}
|
||||
</MapContainer>
|
||||
|
||||
{loading && (
|
||||
<div className="absolute top-4 left-1/2 -translate-x-1/2 bg-slate-800 text-white text-xs px-3 py-1.5 rounded-lg shadow-lg z-[1000]">
|
||||
Đang tải lộ trình...
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="absolute top-4 left-1/2 -translate-x-1/2 bg-red-900 text-white text-xs px-3 py-1.5 rounded-lg shadow-lg z-[1000]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user