localgreenchain/components/transport/JourneyMap.tsx
Claude 0cce5e2345
Add UI components for transport tracking, demand visualization, and analytics
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.
2025-11-22 18:34:51 +00:00

231 lines
8.3 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}