'use client'; import { useCallback, useMemo, useState, useTransition } from 'react'; import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'; import { fetchGenerationMix } from '@/actions/generation.js'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.js'; import { type ChartConfig, ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent, } from '@/components/ui/chart.js'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select.js'; import type { getGenerationMix } from '@/generated/prisma/sql.js'; import { deserialize } from '@/lib/superjson.js'; import { formatMarketDate, formatMarketDateTime, formatMarketTime } from '@/lib/utils.js'; type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y'; const REGIONS = [ { code: 'PJM', name: 'PJM (Mid-Atlantic)' }, { code: 'ERCOT', name: 'ERCOT (Texas)' }, { code: 'CAISO', name: 'CAISO (California)' }, { code: 'NYISO', name: 'NYISO (New York)' }, { code: 'ISONE', name: 'ISO-NE (New England)' }, { code: 'MISO', name: 'MISO (Midwest)' }, { code: 'SPP', name: 'SPP (South Central)' }, ] as const; const TIME_RANGES: { value: TimeRange; label: string }[] = [ { value: '24h', label: '24H' }, { value: '7d', label: '7D' }, { value: '30d', label: '30D' }, { value: '90d', label: '90D' }, { value: '1y', label: '1Y' }, ]; const FUEL_TYPES = ['gas', 'nuclear', 'wind', 'solar', 'coal', 'hydro', 'other'] as const; type FuelType = (typeof FUEL_TYPES)[number]; const FUEL_COLORS: Record = { gas: 'hsl(25, 95%, 53%)', nuclear: 'hsl(270, 70%, 60%)', wind: 'hsl(190, 90%, 50%)', solar: 'hsl(45, 93%, 58%)', coal: 'hsl(0, 0%, 55%)', hydro: 'hsl(210, 80%, 55%)', other: 'hsl(0, 0%, 40%)', }; const FUEL_LABELS: Record = { gas: 'Natural Gas', nuclear: 'Nuclear', wind: 'Wind', solar: 'Solar', coal: 'Coal', hydro: 'Hydro', other: 'Other', }; const RENEWABLE_FUELS = new Set(['wind', 'solar', 'hydro']); const FOSSIL_FUELS = new Set(['gas', 'coal']); const FUEL_TYPE_SET: Set = new Set(FUEL_TYPES); /** Map EIA fuel type codes to display names */ const EIA_FUEL_MAP: Record = { NG: 'gas', NUC: 'nuclear', WND: 'wind', SUN: 'solar', COL: 'coal', WAT: 'hydro', }; function isFuelType(value: string): value is FuelType { return FUEL_TYPE_SET.has(value); } function resolveFuelType(raw: string): FuelType { return EIA_FUEL_MAP[raw] ?? (isFuelType(raw) ? raw : 'other'); } const TIME_RANGE_SET: Set = new Set(['24h', '7d', '30d', '90d', '1y']); function isTimeRange(value: string): value is TimeRange { return TIME_RANGE_SET.has(value); } const chartConfig: ChartConfig = Object.fromEntries( FUEL_TYPES.map(fuel => [fuel, { label: FUEL_LABELS[fuel], color: FUEL_COLORS[fuel] }]), ); interface PivotedRow { timestamp: number; dateLabel: string; gas: number; nuclear: number; wind: number; solar: number; coal: number; hydro: number; other: number; } function pivotGenerationData(rows: getGenerationMix.Result[], regionCode: string): PivotedRow[] { const byTimestamp = new Map(); for (const row of rows) { const ts = row.timestamp.getTime(); let pivot = byTimestamp.get(ts); if (!pivot) { pivot = { timestamp: ts, dateLabel: formatMarketTime(row.timestamp, regionCode), gas: 0, nuclear: 0, wind: 0, solar: 0, coal: 0, hydro: 0, other: 0, }; byTimestamp.set(ts, pivot); } const fuelKey = resolveFuelType(row.fuel_type); pivot[fuelKey] += row.generation_mw; } return Array.from(byTimestamp.values()).sort((a, b) => a.timestamp - b.timestamp); } interface GenerationSplit { renewable: number; fossil: number; nuclear: number; other: number; total: number; } function computeGenerationSplit(data: PivotedRow[]): GenerationSplit { const totals: GenerationSplit = { renewable: 0, fossil: 0, nuclear: 0, other: 0, total: 0 }; for (const row of data) { for (const fuel of FUEL_TYPES) { const mw = row[fuel]; totals.total += mw; if (RENEWABLE_FUELS.has(fuel)) { totals.renewable += mw; } else if (FOSSIL_FUELS.has(fuel)) { totals.fossil += mw; } else if (fuel === 'nuclear') { totals.nuclear += mw; } else { totals.other += mw; } } } return totals; } interface GenerationChartProps { initialData: ReturnType>; initialRegion: string; initialTimeRange: TimeRange; } export function GenerationChart({ initialData, initialRegion, initialTimeRange }: GenerationChartProps) { const [regionCode, setRegionCode] = useState(initialRegion); const [timeRange, setTimeRange] = useState(initialTimeRange); const [serializedData, setSerializedData] = useState(initialData); const [error, setError] = useState(null); const [isPending, startTransition] = useTransition(); const rows = useMemo(() => deserialize(serializedData), [serializedData]); const chartData = useMemo(() => pivotGenerationData(rows, regionCode), [rows, regionCode]); const split = useMemo(() => computeGenerationSplit(chartData), [chartData]); const refetch = useCallback((newRegion: string, newTimeRange: TimeRange) => { startTransition(async () => { setError(null); const result = await fetchGenerationMix(newRegion, newTimeRange); if (result.ok) { setSerializedData(result.data); } else { setError(result.error); } }); }, []); const handleRegionChange = useCallback( (value: string) => { setRegionCode(value); refetch(value, timeRange); }, [refetch, timeRange], ); const handleTimeRangeChange = useCallback( (value: string) => { if (!isTimeRange(value)) return; setTimeRange(value); refetch(regionCode, value); }, [refetch, regionCode], ); const formatPercent = (value: number, total: number) => { if (total === 0) return '0%'; return `${((value / total) * 100).toFixed(1)}%`; }; return (
Generation by Fuel Type Stacked generation mix over time for {REGIONS.find(r => r.code === regionCode)?.name ?? regionCode}
{error &&
{error}
} {chartData.length === 0 && !isPending ? (

No generation data available for this region and time range.

) : (
{FUEL_TYPES.map(fuel => ( ))} { const d = new Date(ts); if (timeRange === '24h') return formatMarketTime(d, regionCode); if (timeRange === '7d') return formatMarketDateTime(d, regionCode); return formatMarketDate(d, regionCode); }} /> (value >= 1000 ? `${(value / 1000).toFixed(0)}GW` : `${value}MW`)} className="text-xs" /> { const firstPayload: unknown = payload?.[0]?.payload; if ( firstPayload && typeof firstPayload === 'object' && 'timestamp' in firstPayload && typeof firstPayload.timestamp === 'number' ) { return formatMarketDateTime(new Date(firstPayload.timestamp), regionCode); } return ''; }} formatter={(value, name) => { const mw = typeof value === 'number' ? value : Number(value); const nameStr = String(name); const label = isFuelType(nameStr) ? FUEL_LABELS[nameStr] : nameStr; return ( {label}: {mw.toLocaleString(undefined, { maximumFractionDigits: 0 })} MW ); }} /> } /> {FUEL_TYPES.map(fuel => ( ))} } />
)}
{chartData.length > 0 && (
)}
); } function SplitCard({ label, value, total, formatPercent, colorClass, }: { label: string; value: number; total: number; formatPercent: (v: number, t: number) => string; colorClass: string; }) { return (

{label}

{formatPercent(value, total)}

{(value / 1000).toLocaleString(undefined, { maximumFractionDigits: 0 })} GWh total

); }