busi488energy/src/components/charts/generation-chart.tsx

388 lines
13 KiB
TypeScript

'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 { 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<FuelType, string> = {
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<FuelType, string> = {
gas: 'Natural Gas',
nuclear: 'Nuclear',
wind: 'Wind',
solar: 'Solar',
coal: 'Coal',
hydro: 'Hydro',
other: 'Other',
};
const RENEWABLE_FUELS = new Set<FuelType>(['wind', 'solar', 'hydro']);
const FOSSIL_FUELS = new Set<FuelType>(['gas', 'coal']);
const FUEL_TYPE_SET: Set<string> = new Set(FUEL_TYPES);
function isFuelType(value: string): value is FuelType {
return FUEL_TYPE_SET.has(value);
}
const TIME_RANGE_SET: Set<string> = 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<number, PivotedRow>();
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 = isFuelType(row.fuel_type) ? row.fuel_type : 'other';
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<typeof import('@/lib/superjson.js').serialize<getGenerationMix.Result[]>>;
initialRegion: string;
initialTimeRange: TimeRange;
}
export function GenerationChart({ initialData, initialRegion, initialTimeRange }: GenerationChartProps) {
const [regionCode, setRegionCode] = useState(initialRegion);
const [timeRange, setTimeRange] = useState<TimeRange>(initialTimeRange);
const [serializedData, setSerializedData] = useState(initialData);
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const rows = useMemo(() => deserialize<getGenerationMix.Result[]>(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 (
<div className="space-y-6">
<Card>
<CardHeader>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>Generation by Fuel Type</CardTitle>
<CardDescription>
Stacked generation mix over time for {REGIONS.find(r => r.code === regionCode)?.name ?? regionCode}
</CardDescription>
</div>
<div className="flex gap-2">
<Select value={regionCode} onValueChange={handleRegionChange}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Select region" />
</SelectTrigger>
<SelectContent>
{REGIONS.map(r => (
<SelectItem key={r.code} value={r.code}>
{r.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={timeRange} onValueChange={handleTimeRangeChange}>
<SelectTrigger className="w-[90px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIME_RANGES.map(t => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardHeader>
<CardContent>
{error && <div className="mb-4 rounded-md bg-destructive/10 p-3 text-sm text-destructive">{error}</div>}
{chartData.length === 0 && !isPending ? (
<div className="flex h-80 items-center justify-center rounded-lg border border-dashed border-border">
<p className="text-sm text-muted-foreground">
No generation data available for this region and time range.
</p>
</div>
) : (
<div className={isPending ? 'opacity-50 transition-opacity' : ''}>
<ChartContainer config={chartConfig} className="h-80 w-full">
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<defs>
{FUEL_TYPES.map(fuel => (
<linearGradient key={fuel} id={`fill-${fuel}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={`var(--color-${fuel})`} stopOpacity={0.8} />
<stop offset="95%" stopColor={`var(--color-${fuel})`} stopOpacity={0.1} />
</linearGradient>
))}
</defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-border/50" />
<XAxis
dataKey="dateLabel"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={40}
className="text-xs"
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value: number) => (value >= 1000 ? `${(value / 1000).toFixed(0)}GW` : `${value}MW`)}
className="text-xs"
/>
<ChartTooltip
content={
<ChartTooltipContent
labelFormatter={(_, payload) => {
const firstPayload: unknown = payload?.[0]?.payload;
if (
firstPayload &&
typeof firstPayload === 'object' &&
'timestamp' in firstPayload &&
typeof firstPayload.timestamp === 'number'
) {
return formatMarketTime(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 (
<span>
{label}: {mw.toLocaleString(undefined, { maximumFractionDigits: 0 })} MW
</span>
);
}}
/>
}
/>
{FUEL_TYPES.map(fuel => (
<Area
key={fuel}
stackId="generation"
type="monotone"
dataKey={fuel}
fill={`url(#fill-${fuel})`}
stroke={`var(--color-${fuel})`}
strokeWidth={1.5}
/>
))}
<ChartLegend content={<ChartLegendContent />} />
</AreaChart>
</ChartContainer>
</div>
)}
</CardContent>
</Card>
{chartData.length > 0 && (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<SplitCard
label="Renewable"
value={split.renewable}
total={split.total}
formatPercent={formatPercent}
colorClass="text-cyan-400"
/>
<SplitCard
label="Fossil"
value={split.fossil}
total={split.total}
formatPercent={formatPercent}
colorClass="text-orange-400"
/>
<SplitCard
label="Nuclear"
value={split.nuclear}
total={split.total}
formatPercent={formatPercent}
colorClass="text-purple-400"
/>
<SplitCard
label="Other"
value={split.other}
total={split.total}
formatPercent={formatPercent}
colorClass="text-gray-400"
/>
</div>
)}
</div>
);
}
function SplitCard({
label,
value,
total,
formatPercent,
colorClass,
}: {
label: string;
value: number;
total: number;
formatPercent: (v: number, t: number) => string;
colorClass: string;
}) {
return (
<Card>
<CardContent className="pt-6">
<p className="text-sm text-muted-foreground">{label}</p>
<p className={`mt-1 text-2xl font-bold tabular-nums ${colorClass}`}>{formatPercent(value, total)}</p>
<p className="mt-1 text-xs text-muted-foreground">
{(value / 1000).toLocaleString(undefined, { maximumFractionDigits: 0 })} GWh total
</p>
</CardContent>
</Card>
);
}