Add driver trail polyline and toggle
Render a driver's location trail on the map and add controls to toggle it. Changes include: component imports (Polyline, Route), new trail state and interval ref, fetchTrail logic (uses isAuthenticated/getCurrentUser/getDriverTrail) with periodic refresh (every minute), FitTrail component to fit map to the trail, Polyline and start marker rendering, and a Trail toggle button in the UI. Also cleaned up MapBounds (explicit return null), small popup/legend adjustments, and grouped supplier processing remains unchanged. Added getDriverTrail helper in lib/pocketbase.ts to fetch today's driver_locations for a given driver id.
This commit is contained in:
parent
0d3272da46
commit
c66f06b9ab
2 changed files with 123 additions and 25 deletions
|
|
@ -1,10 +1,11 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
|
||||
import { OrderItem, Supplier } from '@/lib/types';
|
||||
import { useEffect, useMemo, useState, useCallback, useRef } from 'react';
|
||||
import { MapContainer, TileLayer, Marker, Popup, Polyline, useMap } from 'react-leaflet';
|
||||
import { OrderItem, Supplier, DriverLocation } from '@/lib/types';
|
||||
import { formatCurrency, calculateDistance, parseCoordinates } from '@/lib/utils';
|
||||
import { Navigation, AlertTriangle } from 'lucide-react';
|
||||
import { getDriverTrail, isAuthenticated, getCurrentUser } from '@/lib/pocketbase';
|
||||
import { Navigation, AlertTriangle, Route } from 'lucide-react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
|
||||
|
|
@ -32,7 +33,6 @@ interface MapViewProps {
|
|||
|
||||
function MapBounds({ items }: { items: OrderItem[] }) {
|
||||
const map = useMap();
|
||||
|
||||
useEffect(() => {
|
||||
const validPoints = items
|
||||
.map((i) => {
|
||||
|
|
@ -40,19 +40,32 @@ function MapBounds({ items }: { items: OrderItem[] }) {
|
|||
return coords ? [coords.latitude, coords.longitude] as [number, number] : null;
|
||||
})
|
||||
.filter((point): point is [number, number] => point !== null);
|
||||
|
||||
if (validPoints.length > 0) {
|
||||
const bounds = L.latLngBounds(validPoints);
|
||||
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 15 });
|
||||
}
|
||||
}, [map, items]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function FitTrail({ trail }: { trail: [number, number][] }) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
if (trail.length > 1) {
|
||||
const bounds = L.latLngBounds(trail);
|
||||
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 16 });
|
||||
}
|
||||
}, [map, trail]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function CollectionMap({ items, onNavigate }: MapViewProps) {
|
||||
const [userLocation, setUserLocation] = useState<[number, number] | null>(null);
|
||||
const [trail, setTrail] = useState<[number, number][]>([]);
|
||||
const [showTrail, setShowTrail] = useState(true);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Get current position
|
||||
useEffect(() => {
|
||||
if ('geolocation' in navigator) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
|
|
@ -63,22 +76,42 @@ export default function CollectionMap({ items, onNavigate }: MapViewProps) {
|
|||
}
|
||||
}, []);
|
||||
|
||||
// Fetch trail data periodically
|
||||
const fetchTrail = useCallback(async () => {
|
||||
if (!isAuthenticated()) return;
|
||||
const user = getCurrentUser();
|
||||
if (!user) return;
|
||||
const data = await getDriverTrail(user.id);
|
||||
const points = data
|
||||
.filter((loc) => loc.latitude && loc.longitude)
|
||||
.map((loc) => [loc.latitude, loc.longitude] as [number, number]);
|
||||
if (points.length > 0) {
|
||||
setTrail(points);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTrail();
|
||||
intervalRef.current = setInterval(fetchTrail, 60000); // refresh every minute
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [fetchTrail]);
|
||||
|
||||
// Group by supplier
|
||||
const suppliers = useMemo(() => {
|
||||
const map = new Map<string, { supplier: Supplier; items: OrderItem[]; coords: { latitude: number; longitude: number } }>();
|
||||
|
||||
items.forEach((item) => {
|
||||
if (!item.expand?.supplier) return;
|
||||
const supplier = item.expand.supplier;
|
||||
const coords = parseCoordinates(supplier.coordinates);
|
||||
if (!coords) return;
|
||||
|
||||
const key = supplier.id;
|
||||
if (!map.has(key)) {
|
||||
map.set(key, { supplier, items: [], coords });
|
||||
}
|
||||
map.get(key)!.items.push(item);
|
||||
});
|
||||
|
||||
return Array.from(map.values());
|
||||
}, [items]);
|
||||
|
||||
|
|
@ -93,7 +126,11 @@ export default function CollectionMap({ items, onNavigate }: MapViewProps) {
|
|||
return (
|
||||
<div className="h-[calc(100vh-4rem)] w-full relative">
|
||||
<MapContainer center={center} zoom={12} className="h-full w-full" zoomControl={false}>
|
||||
<TileLayer attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>' url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
|
||||
<MapBounds items={items} />
|
||||
|
||||
{userLocation && (
|
||||
|
|
@ -102,10 +139,35 @@ export default function CollectionMap({ items, onNavigate }: MapViewProps) {
|
|||
</Marker>
|
||||
)}
|
||||
|
||||
{/* Trail polyline */}
|
||||
{showTrail && trail.length > 1 && (
|
||||
<>
|
||||
<Polyline
|
||||
positions={trail}
|
||||
pathOptions={{
|
||||
color: '#2563eb',
|
||||
weight: 4,
|
||||
opacity: 0.7,
|
||||
dashArray: '10, 8',
|
||||
}}
|
||||
/>
|
||||
{/* Start marker */}
|
||||
<Marker
|
||||
position={trail[0]}
|
||||
icon={L.divIcon({
|
||||
className: '',
|
||||
html: '<div style="width:14px;height:14px;background:#22c55e;border:3px solid white;border-radius:50%;"></div>',
|
||||
iconSize: [14, 14],
|
||||
iconAnchor: [7, 7],
|
||||
})}
|
||||
>
|
||||
<Popup><div className="text-xs font-medium">Trip Start</div></Popup>
|
||||
</Marker>
|
||||
</>
|
||||
)}
|
||||
|
||||
{suppliers.map(({ supplier, items: supplierItems, coords }) => {
|
||||
const distance = getDistance(coords);
|
||||
const hasProblem = supplierItems.some((i) => i.item_status === 'problem' || i.problem_flag);
|
||||
|
||||
return (
|
||||
<Marker key={supplier.id} position={[coords.latitude, coords.longitude]} icon={defaultIcon}>
|
||||
<Popup>
|
||||
|
|
@ -113,7 +175,6 @@ export default function CollectionMap({ items, onNavigate }: MapViewProps) {
|
|||
<h3 className="font-bold text-sm mb-1">{supplier.suppliername}</h3>
|
||||
<p className="text-xs text-gray-500 mb-2">{supplier.supplieraddress}</p>
|
||||
{distance && <p className="text-xs text-orange-600 mb-2 font-medium">{distance.toFixed(1)} km away</p>}
|
||||
|
||||
<div className="space-y-1.5 mb-3">
|
||||
{supplierItems.map((i) => (
|
||||
<div key={i.id} className="text-xs p-1.5 bg-gray-50 rounded">
|
||||
|
|
@ -125,7 +186,6 @@ export default function CollectionMap({ items, onNavigate }: MapViewProps) {
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => onNavigate(coords.latitude, coords.longitude, supplier.suppliername)}
|
||||
className="w-full flex items-center justify-center gap-1.5 py-2 px-3 bg-orange-500 text-white rounded-lg text-sm font-medium active:bg-orange-600 transition-colors"
|
||||
|
|
@ -139,18 +199,41 @@ export default function CollectionMap({ items, onNavigate }: MapViewProps) {
|
|||
})}
|
||||
</MapContainer>
|
||||
|
||||
<div className="absolute top-4 right-4 bg-white/90 backdrop-blur-sm rounded-xl p-3 shadow-lg z-[400]">
|
||||
<p className="text-xs font-medium text-gray-700 mb-2">Legend</p>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-orange-500 border-2 border-white shadow-sm" />
|
||||
<span className="text-[10px] text-gray-600">Your Location</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 relative">
|
||||
<div className="absolute inset-0 bg-orange-500 rounded-sm rotate-45 transform origin-bottom" />
|
||||
{/* Controls */}
|
||||
<div className="absolute top-4 right-4 space-y-2 z-[400]">
|
||||
{/* Trail toggle */}
|
||||
{trail.length > 1 && (
|
||||
<button
|
||||
onClick={() => setShowTrail(!showTrail)}
|
||||
className={`flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-medium shadow-lg transition-colors ${
|
||||
showTrail ? 'bg-blue-500 text-white' : 'bg-white/90 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
<Route size={14} />
|
||||
Trail
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
<div className="bg-white/90 backdrop-blur-sm rounded-xl p-3 shadow-lg">
|
||||
<p className="text-xs font-medium text-gray-700 mb-2">Legend</p>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-orange-500 border-2 border-white shadow-sm" />
|
||||
<span className="text-[10px] text-gray-600">You</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-gray-600">Collection Stop</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 relative">
|
||||
<div className="absolute inset-0 bg-blue-500 rounded-sm rotate-45 transform origin-bottom" />
|
||||
</div>
|
||||
<span className="text-[10px] text-gray-600">Stop</span>
|
||||
</div>
|
||||
{trail.length > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-5 h-0.5 bg-blue-500" style={{ borderTop: '2px dashed #2563eb' }} />
|
||||
<span className="text-[10px] text-gray-600">Route</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -70,6 +70,21 @@ export const saveDriverLocation = async (location: Omit<DriverLocation, 'id'>) =
|
|||
}
|
||||
};
|
||||
|
||||
// Get driver's location trail for today (for map polyline)
|
||||
export const getDriverTrail = async (driverId: string) => {
|
||||
try {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const records = await pb.collection('driver_locations').getFullList({
|
||||
filter: `driver = "${driverId}" && timestamp >= "${today.toISOString()}"`,
|
||||
sort: 'timestamp',
|
||||
});
|
||||
return records as unknown as DriverLocation[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// Settings helpers
|
||||
const SETTINGS_KEY = '1driver_settings';
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue