Transport components: - TransportTimeline: Visual timeline of transport events with status badges - JourneyMap: SVG-based map visualization of plant journey locations - CarbonFootprintCard: Carbon metrics display with comparison charts - QRCodeDisplay: QR code generation for traceability verification - TransportEventForm: Form for recording transport events Demand components: - DemandSignalCard: Regional demand signal with supply status indicators - PreferencesForm: Multi-section consumer preference input form - RecommendationList: Planting recommendations with risk assessment - SupplyGapChart: Supply vs demand visualization with gap indicators - SeasonalCalendar: Seasonal produce availability calendar view Analytics components: - EnvironmentalImpact: Comprehensive carbon and food miles analysis - FoodMilesTracker: Food miles tracking with daily charts and targets - SavingsCalculator: Environmental savings vs conventional agriculture All components follow existing patterns, use Tailwind CSS, and are fully typed.
231 lines
8.3 KiB
TypeScript
231 lines
8.3 KiB
TypeScript
import { useState } from 'react';
|
||
import { TransportEvent, TransportLocation } from '../../lib/transport/types';
|
||
|
||
interface JourneyMapProps {
|
||
plantId: string;
|
||
events: TransportEvent[];
|
||
currentLocation?: TransportLocation;
|
||
}
|
||
|
||
interface MapPoint {
|
||
lat: number;
|
||
lng: number;
|
||
label: string;
|
||
eventType: string;
|
||
timestamp: string;
|
||
}
|
||
|
||
export default function JourneyMap({ plantId, events, currentLocation }: JourneyMapProps) {
|
||
const [selectedPoint, setSelectedPoint] = useState<MapPoint | null>(null);
|
||
|
||
// Extract unique locations from events
|
||
const mapPoints: MapPoint[] = [];
|
||
const seenCoords = new Set<string>();
|
||
|
||
events.forEach((event) => {
|
||
const fromKey = `${event.fromLocation.latitude},${event.fromLocation.longitude}`;
|
||
const toKey = `${event.toLocation.latitude},${event.toLocation.longitude}`;
|
||
|
||
if (!seenCoords.has(fromKey)) {
|
||
seenCoords.add(fromKey);
|
||
mapPoints.push({
|
||
lat: event.fromLocation.latitude,
|
||
lng: event.fromLocation.longitude,
|
||
label: event.fromLocation.facilityName || event.fromLocation.city || 'Origin',
|
||
eventType: event.eventType,
|
||
timestamp: event.timestamp,
|
||
});
|
||
}
|
||
|
||
if (!seenCoords.has(toKey)) {
|
||
seenCoords.add(toKey);
|
||
mapPoints.push({
|
||
lat: event.toLocation.latitude,
|
||
lng: event.toLocation.longitude,
|
||
label: event.toLocation.facilityName || event.toLocation.city || 'Destination',
|
||
eventType: event.eventType,
|
||
timestamp: event.timestamp,
|
||
});
|
||
}
|
||
});
|
||
|
||
// Calculate map bounds
|
||
const lats = mapPoints.map((p) => p.lat);
|
||
const lngs = mapPoints.map((p) => p.lng);
|
||
const minLat = Math.min(...lats);
|
||
const maxLat = Math.max(...lats);
|
||
const minLng = Math.min(...lngs);
|
||
const maxLng = Math.max(...lngs);
|
||
|
||
// Calculate center and scale for SVG
|
||
const centerLat = (minLat + maxLat) / 2;
|
||
const centerLng = (minLng + maxLng) / 2;
|
||
const latRange = Math.max(maxLat - minLat, 0.01);
|
||
const lngRange = Math.max(maxLng - minLng, 0.01);
|
||
|
||
// Convert geo coords to SVG coords
|
||
const toSvgCoords = (lat: number, lng: number) => {
|
||
const x = ((lng - minLng) / lngRange) * 280 + 60;
|
||
const y = ((maxLat - lat) / latRange) * 180 + 60;
|
||
return { x, y };
|
||
};
|
||
|
||
// Calculate total distance
|
||
const totalDistance = events.reduce((sum, e) => sum + e.distanceKm, 0);
|
||
|
||
if (events.length === 0) {
|
||
return (
|
||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||
<h3 className="text-xl font-bold text-gray-900 mb-4">Journey Map</h3>
|
||
<div className="bg-gray-100 rounded-lg p-8 text-center">
|
||
<div className="text-4xl mb-2">🗺️</div>
|
||
<p className="text-gray-500">No journey data available yet.</p>
|
||
<p className="text-sm text-gray-400 mt-1">Transport events will appear here as they're recorded.</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h3 className="text-xl font-bold text-gray-900">Journey Map</h3>
|
||
<span className="text-sm text-gray-500">{mapPoints.length} locations</span>
|
||
</div>
|
||
|
||
{/* SVG Map */}
|
||
<div className="relative bg-gradient-to-br from-blue-50 to-green-50 rounded-lg overflow-hidden">
|
||
<svg viewBox="0 0 400 300" className="w-full h-64">
|
||
{/* Grid lines */}
|
||
<defs>
|
||
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
|
||
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="#e5e7eb" strokeWidth="0.5" />
|
||
</pattern>
|
||
</defs>
|
||
<rect width="100%" height="100%" fill="url(#grid)" />
|
||
|
||
{/* Draw paths between consecutive events */}
|
||
{events.map((event, index) => {
|
||
const from = toSvgCoords(event.fromLocation.latitude, event.fromLocation.longitude);
|
||
const to = toSvgCoords(event.toLocation.latitude, event.toLocation.longitude);
|
||
return (
|
||
<g key={`path-${index}`}>
|
||
<line
|
||
x1={from.x}
|
||
y1={from.y}
|
||
x2={to.x}
|
||
y2={to.y}
|
||
stroke="#10b981"
|
||
strokeWidth="2"
|
||
strokeDasharray="5,5"
|
||
className="animate-pulse"
|
||
/>
|
||
{/* Arrow head */}
|
||
<circle cx={to.x} cy={to.y} r="4" fill="#10b981" />
|
||
</g>
|
||
);
|
||
})}
|
||
|
||
{/* Draw location points */}
|
||
{mapPoints.map((point, index) => {
|
||
const { x, y } = toSvgCoords(point.lat, point.lng);
|
||
const isSelected = selectedPoint?.lat === point.lat && selectedPoint?.lng === point.lng;
|
||
return (
|
||
<g
|
||
key={`point-${index}`}
|
||
onClick={() => setSelectedPoint(point)}
|
||
className="cursor-pointer"
|
||
>
|
||
<circle
|
||
cx={x}
|
||
cy={y}
|
||
r={isSelected ? 12 : 8}
|
||
fill={index === 0 ? '#10b981' : index === mapPoints.length - 1 ? '#f59e0b' : '#3b82f6'}
|
||
stroke="white"
|
||
strokeWidth="2"
|
||
className="transition-all hover:r-12"
|
||
/>
|
||
<text
|
||
x={x}
|
||
y={y - 15}
|
||
textAnchor="middle"
|
||
className="text-xs fill-gray-600 font-medium"
|
||
>
|
||
{index + 1}
|
||
</text>
|
||
</g>
|
||
);
|
||
})}
|
||
|
||
{/* Current location marker */}
|
||
{currentLocation && (
|
||
<g>
|
||
{(() => {
|
||
const { x, y } = toSvgCoords(currentLocation.latitude, currentLocation.longitude);
|
||
return (
|
||
<>
|
||
<circle cx={x} cy={y} r="15" fill="#ef4444" fillOpacity="0.3" className="animate-ping" />
|
||
<circle cx={x} cy={y} r="8" fill="#ef4444" stroke="white" strokeWidth="2" />
|
||
</>
|
||
);
|
||
})()}
|
||
</g>
|
||
)}
|
||
</svg>
|
||
|
||
{/* Legend */}
|
||
<div className="absolute bottom-2 left-2 bg-white bg-opacity-90 rounded-lg p-2 text-xs">
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<span className="w-3 h-3 rounded-full bg-green-500"></span>
|
||
<span>Origin</span>
|
||
</div>
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<span className="w-3 h-3 rounded-full bg-blue-500"></span>
|
||
<span>Waypoint</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<span className="w-3 h-3 rounded-full bg-yellow-500"></span>
|
||
<span>Current</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Selected point details */}
|
||
{selectedPoint && (
|
||
<div className="mt-4 p-4 bg-gray-50 rounded-lg border border-gray-200">
|
||
<div className="flex items-center justify-between">
|
||
<h4 className="font-semibold text-gray-900">{selectedPoint.label}</h4>
|
||
<button onClick={() => setSelectedPoint(null)} className="text-gray-400 hover:text-gray-600">
|
||
✕
|
||
</button>
|
||
</div>
|
||
<p className="text-sm text-gray-600 mt-1">
|
||
Event: {selectedPoint.eventType.replace(/_/g, ' ')}
|
||
</p>
|
||
<p className="text-xs text-gray-500 mt-1">
|
||
{new Date(selectedPoint.timestamp).toLocaleString()}
|
||
</p>
|
||
<p className="text-xs text-gray-400 mt-1">
|
||
Coords: {selectedPoint.lat.toFixed(4)}, {selectedPoint.lng.toFixed(4)}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Summary stats */}
|
||
<div className="mt-4 grid grid-cols-3 gap-4 text-center">
|
||
<div className="p-3 bg-blue-50 rounded-lg">
|
||
<p className="text-lg font-bold text-blue-600">{totalDistance.toFixed(1)} km</p>
|
||
<p className="text-xs text-gray-500">Total Distance</p>
|
||
</div>
|
||
<div className="p-3 bg-green-50 rounded-lg">
|
||
<p className="text-lg font-bold text-green-600">{mapPoints.length}</p>
|
||
<p className="text-xs text-gray-500">Locations</p>
|
||
</div>
|
||
<div className="p-3 bg-purple-50 rounded-lg">
|
||
<p className="text-lg font-bold text-purple-600">{events.length}</p>
|
||
<p className="text-xs text-gray-500">Transports</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|