497 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|