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:
SJCoetz 2026-05-25 01:33:54 +02:00
parent 0d3272da46
commit c66f06b9ab
2 changed files with 123 additions and 25 deletions

View file

@ -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='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>' url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
<TileLayer
attribution='&copy; <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>

View file

@ -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';