388 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|