busi488energy/src/components/charts/demand-chart.tsx
Joey Eamigh 79850a61be
cleanup
2026-02-11 22:03:19 -05:00

497 lines
18 KiB
TypeScript

'use client';
import { fetchDemandByRegion } from '@/actions/demand.js';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.js';
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@/components/ui/chart.js';
import { deserialize } from '@/lib/superjson.js';
import { Activity, Server, TrendingUp, Zap } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { Bar, BarChart, CartesianGrid, ComposedChart, Line, XAxis, YAxis } from 'recharts';
type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y' | '5y' | 'all';
interface DemandRow {
region_code: string;
region_name: string;
day: Date | null;
avg_demand: number | null;
peak_demand: number | null;
datacenter_count: number | null;
total_dc_capacity_mw: number | null;
}
interface DemandChartProps {
initialData: DemandRow[];
summaryData: DemandRow[];
}
const TIME_RANGES: { value: TimeRange; label: string }[] = [
{ value: '7d', label: '7D' },
{ value: '30d', label: '30D' },
{ value: '90d', label: '90D' },
{ value: '1y', label: '1Y' },
{ value: '5y', label: '5Y' },
{ value: 'all', label: 'ALL' },
];
const REGION_COLORS: Record<string, string> = {
PJM: 'hsl(210, 80%, 60%)',
ERCOT: 'hsl(30, 80%, 55%)',
CAISO: 'hsl(145, 65%, 50%)',
NYISO: 'hsl(280, 70%, 60%)',
ISONE: 'hsl(350, 70%, 55%)',
MISO: 'hsl(60, 70%, 50%)',
SPP: 'hsl(180, 60%, 50%)',
BPA: 'hsl(95, 55%, 50%)',
NWMT: 'hsl(310, 50%, 55%)',
WAPA: 'hsl(165, 50%, 50%)',
TVA: 'hsl(15, 70%, 50%)',
DUKE: 'hsl(240, 55%, 60%)',
SOCO: 'hsl(350, 55%, 50%)',
FPC: 'hsl(45, 60%, 45%)',
};
function formatDemandValue(value: number): string {
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`;
return value.toFixed(0);
}
function formatDateLabel(date: Date): string {
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
export function DemandChart({ initialData, summaryData }: DemandChartProps) {
const [timeRange, setTimeRange] = useState<TimeRange>('all');
const [selectedRegion, setSelectedRegion] = useState<string>('ALL');
const [chartData, setChartData] = useState<DemandRow[]>(initialData);
const [loading, setLoading] = useState(false);
const regions = useMemo(() => {
const regionSet = new Map<string, string>();
for (const row of summaryData) {
if (!regionSet.has(row.region_code)) {
regionSet.set(row.region_code, row.region_name);
}
}
return Array.from(regionSet.entries()).map(([code, name]) => ({
code,
name,
}));
}, [summaryData]);
const handleTimeRangeChange = useCallback(
async (range: TimeRange) => {
setTimeRange(range);
setLoading(true);
try {
const result = await fetchDemandByRegion(selectedRegion, range);
if (result.ok) {
setChartData(deserialize<DemandRow[]>(result.data));
}
} finally {
setLoading(false);
}
},
[selectedRegion],
);
const handleRegionChange = useCallback(
async (region: string) => {
setSelectedRegion(region);
setLoading(true);
try {
const result = await fetchDemandByRegion(region, timeRange);
if (result.ok) {
setChartData(deserialize<DemandRow[]>(result.data));
}
} finally {
setLoading(false);
}
},
[timeRange],
);
const trendChartData = useMemo(() => {
const filtered = selectedRegion === 'ALL' ? chartData : chartData.filter(r => r.region_code === selectedRegion);
const byDay = new Map<string, { date: string; dateObj: Date; [key: string]: number | string | Date }>();
for (const row of filtered) {
if (!row.day || row.avg_demand === null) continue;
const dateKey = row.day.toISOString().split('T')[0] ?? '';
if (!byDay.has(dateKey)) {
byDay.set(dateKey, { date: formatDateLabel(row.day), dateObj: row.day });
}
const entry = byDay.get(dateKey);
if (entry) {
entry[row.region_code] = Math.round(row.avg_demand);
}
}
return Array.from(byDay.values()).sort((a, b) => a.dateObj.getTime() - b.dateObj.getTime());
}, [chartData, selectedRegion]);
const trendChartConfig = useMemo(() => {
const config: ChartConfig = {};
const activeRegions = selectedRegion === 'ALL' ? regions : regions.filter(r => r.code === selectedRegion);
for (const region of activeRegions) {
config[region.code] = {
label: region.code,
color: REGION_COLORS[region.code] ?? 'hsl(0, 0%, 60%)',
};
}
return config;
}, [regions, selectedRegion]);
const peakDemandByRegion = useMemo(() => {
const peaks = new Map<string, { regionCode: string; regionName: string; peakDemand: number; day: Date }>();
for (const row of chartData) {
if (!row.day || row.peak_demand === null) continue;
const existing = peaks.get(row.region_code);
if (!existing || row.peak_demand > existing.peakDemand) {
peaks.set(row.region_code, {
regionCode: row.region_code,
regionName: row.region_name,
peakDemand: row.peak_demand,
day: row.day,
});
}
}
return Array.from(peaks.values()).sort((a, b) => b.peakDemand - a.peakDemand);
}, [chartData]);
const dcImpactData = useMemo(() => {
const regionAgg = new Map<
string,
{
regionCode: string;
regionName: string;
totalDemand: number;
count: number;
dcCapacityMw: number;
}
>();
for (const row of summaryData) {
if (row.avg_demand === null) continue;
const existing = regionAgg.get(row.region_code);
if (existing) {
existing.totalDemand += row.avg_demand;
existing.count += 1;
existing.dcCapacityMw = row.total_dc_capacity_mw ?? existing.dcCapacityMw;
} else {
regionAgg.set(row.region_code, {
regionCode: row.region_code,
regionName: row.region_name,
totalDemand: row.avg_demand,
count: 1,
dcCapacityMw: row.total_dc_capacity_mw ?? 0,
});
}
}
return Array.from(regionAgg.values())
.map(r => {
const avgDemand = r.totalDemand / r.count;
const dcPercent = avgDemand > 0 ? (r.dcCapacityMw / avgDemand) * 100 : 0;
return {
region: r.regionCode,
regionName: r.regionName,
avgDemand: Math.round(avgDemand),
dcCapacityMw: Math.round(r.dcCapacityMw),
dcPercent: Math.round(dcPercent * 10) / 10,
};
})
.sort((a, b) => b.dcPercent - a.dcPercent);
}, [summaryData]);
const dcImpactConfig = {
avgDemand: {
label: 'Avg Demand (MW)',
color: 'hsl(210, 80%, 60%)',
},
dcCapacityMw: {
label: 'DC Capacity (MW)',
color: 'hsl(340, 75%, 55%)',
},
} satisfies ChartConfig;
const hasData = trendChartData.length > 0;
const hasDcImpact = dcImpactData.length > 0;
return (
<div className="space-y-6">
{/* Controls */}
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2 rounded-lg border border-border bg-card p-1">
{TIME_RANGES.map(range => (
<button
key={range.value}
onClick={() => handleTimeRangeChange(range.value)}
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
timeRange === range.value
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}>
{range.label}
</button>
))}
</div>
<div className="flex items-center gap-2 overflow-x-auto rounded-lg border border-border bg-card p-1">
<button
onClick={() => handleRegionChange('ALL')}
className={`shrink-0 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
selectedRegion === 'ALL'
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}>
All Regions
</button>
{regions.map(region => (
<button
key={region.code}
onClick={() => handleRegionChange(region.code)}
className={`shrink-0 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
selectedRegion === region.code
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}>
{region.code}
</button>
))}
</div>
{loading && <span className="text-sm text-muted-foreground">Loading...</span>}
</div>
{/* Demand Trend Chart */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="h-5 w-5 text-chart-1" />
Regional Demand Trends
</CardTitle>
<CardDescription>Average daily electricity demand by ISO region (MW)</CardDescription>
</CardHeader>
<CardContent>
{hasData ? (
<ChartContainer config={trendChartConfig} className="h-[50vh] max-h-150 min-h-75 w-full">
<ComposedChart data={trendChartData}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value: number) => formatDemandValue(value)}
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
/>
<ChartTooltip
content={
<ChartTooltipContent
formatter={value => {
const numVal = typeof value === 'number' ? value : Number(value);
return `${formatDemandValue(numVal)} MW`;
}}
/>
}
/>
<ChartLegend content={<ChartLegendContent />} />
{Object.keys(trendChartConfig).map(regionCode => (
<Line
key={regionCode}
type="monotone"
dataKey={regionCode}
stroke={`var(--color-${regionCode})`}
strokeWidth={2}
dot={false}
connectNulls
isAnimationActive={trendChartData.length <= 200}
/>
))}
</ComposedChart>
</ChartContainer>
) : (
<EmptyState message="No demand trend data available for this time range." />
)}
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-2">
{/* Peak Demand Records */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-5 w-5 text-chart-2" />
Peak Demand Records
</CardTitle>
<CardDescription>Highest recorded demand in selected period</CardDescription>
</CardHeader>
<CardContent>
{peakDemandByRegion.length > 0 ? (
<div className="space-y-4">
{peakDemandByRegion.map((peak, idx) => (
<div key={peak.regionCode} className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span
className="flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold"
style={{
backgroundColor: `${REGION_COLORS[peak.regionCode] ?? 'hsl(0,0%,40%)'}`,
color: 'white',
}}>
{idx + 1}
</span>
<div>
<p className="font-mono text-sm font-medium">{peak.regionCode}</p>
<p className="text-xs text-muted-foreground">{formatDateLabel(peak.day)}</p>
</div>
</div>
<div className="text-right">
<p className="font-mono text-sm font-semibold">{formatDemandValue(peak.peakDemand)} MW</p>
</div>
</div>
))}
</div>
) : (
<EmptyState message="No peak demand records for this period." />
)}
</CardContent>
</Card>
{/* DC Impact Chart */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Server className="h-5 w-5 text-chart-4" />
Datacenter Load Impact
</CardTitle>
<CardDescription>Estimated datacenter capacity as % of regional demand</CardDescription>
</CardHeader>
<CardContent>
{hasDcImpact ? (
<>
<ChartContainer config={dcImpactConfig} className="h-70 w-full">
<BarChart data={dcImpactData} layout="vertical">
<CartesianGrid horizontal={false} strokeDasharray="3 3" />
<XAxis
type="number"
tickLine={false}
axisLine={false}
tickFormatter={(value: number) => formatDemandValue(value)}
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
/>
<YAxis
type="category"
dataKey="region"
tickLine={false}
axisLine={false}
width={55}
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
/>
<ChartTooltip
content={
<ChartTooltipContent
formatter={value => {
const numVal = typeof value === 'number' ? value : Number(value);
return `${formatDemandValue(numVal)} MW`;
}}
/>
}
/>
<ChartLegend content={<ChartLegendContent />} />
<Bar dataKey="avgDemand" fill="var(--color-avgDemand)" radius={[0, 4, 4, 0]} />
<Bar dataKey="dcCapacityMw" fill="var(--color-dcCapacityMw)" radius={[0, 4, 4, 0]} />
</BarChart>
</ChartContainer>
{/* DC % Table */}
<div className="mt-4 space-y-2">
{dcImpactData.map(row => (
<div key={row.region} className="flex items-center justify-between text-sm">
<span className="font-mono text-xs font-medium text-muted-foreground">{row.region}</span>
<div className="flex items-center gap-3">
<div className="h-2 w-24 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-chart-4 transition-all"
style={{
width: `${Math.min(row.dcPercent, 100)}%`,
}}
/>
</div>
<span className="w-14 text-right font-mono text-xs font-semibold">{row.dcPercent}%</span>
</div>
</div>
))}
</div>
</>
) : (
<EmptyState message="No datacenter impact data available." />
)}
</CardContent>
</Card>
</div>
{/* Summary Stats Row */}
{hasDcImpact && (
<div className="grid gap-4 sm:grid-cols-3">
<SummaryCard
icon={<Zap className="h-5 w-5 text-chart-1" />}
label="Total Avg Demand"
value={`${formatDemandValue(dcImpactData.reduce((sum, r) => sum + r.avgDemand, 0))} MW`}
/>
<SummaryCard
icon={<Server className="h-5 w-5 text-chart-4" />}
label="Total DC Capacity"
value={`${formatDemandValue(dcImpactData.reduce((sum, r) => sum + r.dcCapacityMw, 0))} MW`}
/>
<SummaryCard
icon={<TrendingUp className="h-5 w-5 text-chart-2" />}
label="Regions Tracked"
value={`${dcImpactData.length}`}
/>
</div>
)}
</div>
);
}
function EmptyState({ message }: { message: string }) {
return (
<div className="flex h-48 items-center justify-center rounded-lg border border-dashed border-border">
<p className="text-sm text-muted-foreground">{message}</p>
</div>
);
}
function SummaryCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return (
<Card>
<CardContent className="flex items-center gap-4 pt-6">
{icon}
<div>
<p className="text-xs text-muted-foreground">{label}</p>
<p className="font-mono text-lg font-semibold">{value}</p>
</div>
</CardContent>
</Card>
);
}