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.
187 lines
7 KiB
TypeScript
187 lines
7 KiB
TypeScript
import { DemandItem, DemandSignal } from '../../lib/demand/types';
|
|
|
|
interface SupplyGapChartProps {
|
|
demandSignal: DemandSignal;
|
|
showTopN?: number;
|
|
}
|
|
|
|
function formatCategory(category: string): string {
|
|
return category.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase());
|
|
}
|
|
|
|
export default function SupplyGapChart({ demandSignal, showTopN = 10 }: SupplyGapChartProps) {
|
|
// Sort items by gap (largest gaps first)
|
|
const sortedItems = [...demandSignal.demandItems]
|
|
.filter((item) => item.gapKg > 0)
|
|
.sort((a, b) => b.gapKg - a.gapKg)
|
|
.slice(0, showTopN);
|
|
|
|
// Find the max for scaling
|
|
const maxDemand = Math.max(...demandSignal.demandItems.map((item) => item.weeklyDemandKg));
|
|
|
|
// Calculate overall stats
|
|
const totalDemand = demandSignal.totalWeeklyDemandKg;
|
|
const totalSupply = demandSignal.currentSupplyKg;
|
|
const totalGap = demandSignal.supplyGapKg;
|
|
const coveragePercentage = totalDemand > 0 ? ((totalSupply / totalDemand) * 100) : 100;
|
|
|
|
if (demandSignal.demandItems.length === 0) {
|
|
return (
|
|
<div className="bg-white rounded-lg shadow-lg p-6">
|
|
<h3 className="text-xl font-bold text-gray-900 mb-4">Supply vs Demand</h3>
|
|
<div className="text-center py-8">
|
|
<div className="text-4xl mb-3">📊</div>
|
|
<p className="text-gray-500">No demand data available.</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="bg-white rounded-lg shadow-lg p-6">
|
|
<h3 className="text-xl font-bold text-gray-900 mb-2">Supply vs Demand</h3>
|
|
<p className="text-sm text-gray-500 mb-6">{demandSignal.region.name} - {demandSignal.seasonalPeriod}</p>
|
|
|
|
{/* Overall summary */}
|
|
<div className="mb-6 p-4 bg-gradient-to-r from-blue-50 to-green-50 rounded-lg">
|
|
<div className="grid grid-cols-4 gap-4 text-center">
|
|
<div>
|
|
<p className="text-2xl font-bold text-blue-600">{totalDemand.toFixed(0)}</p>
|
|
<p className="text-xs text-gray-500">Weekly Demand (kg)</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-2xl font-bold text-green-600">{totalSupply.toFixed(0)}</p>
|
|
<p className="text-xs text-gray-500">Current Supply (kg)</p>
|
|
</div>
|
|
<div>
|
|
<p className={`text-2xl font-bold ${totalGap > 0 ? 'text-red-600' : 'text-green-600'}`}>
|
|
{totalGap.toFixed(0)}
|
|
</p>
|
|
<p className="text-xs text-gray-500">Gap (kg)</p>
|
|
</div>
|
|
<div>
|
|
<p className={`text-2xl font-bold ${
|
|
coveragePercentage >= 100 ? 'text-green-600' :
|
|
coveragePercentage >= 80 ? 'text-yellow-600' : 'text-red-600'
|
|
}`}>
|
|
{coveragePercentage.toFixed(0)}%
|
|
</p>
|
|
<p className="text-xs text-gray-500">Coverage</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Coverage bar */}
|
|
<div className="mt-4">
|
|
<div className="w-full bg-gray-200 rounded-full h-4 overflow-hidden">
|
|
<div
|
|
className={`h-4 transition-all ${
|
|
coveragePercentage >= 100 ? 'bg-green-500' :
|
|
coveragePercentage >= 80 ? 'bg-yellow-500' : 'bg-red-500'
|
|
}`}
|
|
style={{ width: `${Math.min(coveragePercentage, 100)}%` }}
|
|
></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Gap items chart */}
|
|
{sortedItems.length > 0 && (
|
|
<div className="space-y-3">
|
|
<h4 className="text-sm font-semibold text-gray-900">Largest Supply Gaps</h4>
|
|
{sortedItems.map((item, index) => (
|
|
<SupplyGapBar key={index} item={item} maxDemand={maxDemand} />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Items with surplus */}
|
|
{demandSignal.demandItems.filter(item => item.gapKg <= 0).length > 0 && (
|
|
<div className="mt-6 pt-4 border-t border-gray-200">
|
|
<h4 className="text-sm font-semibold text-gray-900 mb-3">Well Supplied Items</h4>
|
|
<div className="flex flex-wrap gap-2">
|
|
{demandSignal.demandItems
|
|
.filter(item => item.gapKg <= 0)
|
|
.slice(0, 8)
|
|
.map((item, index) => (
|
|
<span
|
|
key={index}
|
|
className="px-3 py-1 bg-green-100 text-green-800 text-sm rounded-full"
|
|
>
|
|
{item.produceType}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Legend */}
|
|
<div className="mt-6 pt-4 border-t border-gray-200 flex items-center justify-center gap-6 text-sm">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-4 h-4 bg-blue-500 rounded"></div>
|
|
<span className="text-gray-600">Demand</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-4 h-4 bg-green-500 rounded"></div>
|
|
<span className="text-gray-600">Supply</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-4 h-4 bg-red-400 rounded"></div>
|
|
<span className="text-gray-600">Gap</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SupplyGapBar({ item, maxDemand }: { item: DemandItem; maxDemand: number }) {
|
|
const demandWidth = (item.weeklyDemandKg / maxDemand) * 100;
|
|
const supplyWidth = (item.matchedSupply / maxDemand) * 100;
|
|
const gapPercentage = item.weeklyDemandKg > 0
|
|
? ((item.gapKg / item.weeklyDemandKg) * 100)
|
|
: 0;
|
|
|
|
return (
|
|
<div className="p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div>
|
|
<span className="font-medium text-gray-900">{item.produceType}</span>
|
|
<span className="text-xs text-gray-500 ml-2">{formatCategory(item.category)}</span>
|
|
</div>
|
|
<div className="text-right">
|
|
<span className={`text-sm font-bold ${gapPercentage > 50 ? 'text-red-600' : 'text-yellow-600'}`}>
|
|
-{item.gapKg.toFixed(1)} kg
|
|
</span>
|
|
<span className="text-xs text-gray-500 ml-1">({gapPercentage.toFixed(0)}% gap)</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stacked bar */}
|
|
<div className="relative h-6 bg-gray-200 rounded-full overflow-hidden">
|
|
{/* Supply (filled portion) */}
|
|
<div
|
|
className="absolute top-0 left-0 h-full bg-green-500 transition-all"
|
|
style={{ width: `${supplyWidth}%` }}
|
|
></div>
|
|
{/* Gap (unfilled portion shown as demand outline) */}
|
|
<div
|
|
className="absolute top-0 left-0 h-full border-2 border-blue-500 rounded-full pointer-events-none"
|
|
style={{ width: `${demandWidth}%` }}
|
|
></div>
|
|
{/* Gap indicator */}
|
|
<div
|
|
className="absolute top-0 h-full bg-red-300 opacity-50"
|
|
style={{
|
|
left: `${supplyWidth}%`,
|
|
width: `${demandWidth - supplyWidth}%`
|
|
}}
|
|
></div>
|
|
</div>
|
|
|
|
{/* Values */}
|
|
<div className="flex justify-between mt-1 text-xs text-gray-500">
|
|
<span>Supply: {item.matchedSupply.toFixed(0)} kg</span>
|
|
<span>Demand: {item.weeklyDemandKg.toFixed(0)} kg</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|