309 lines
10 KiB
TypeScript
309 lines
10 KiB
TypeScript
'use client';
|
|
|
|
import { useMemo } from 'react';
|
|
import { CartesianGrid, ComposedChart, Label, Line, Scatter, XAxis, YAxis, ZAxis } from 'recharts';
|
|
import type { SuperJSONResult } from 'superjson';
|
|
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.js';
|
|
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart.js';
|
|
import { deserialize } from '@/lib/superjson.js';
|
|
|
|
interface RegionCapacityPrice {
|
|
region_code: string;
|
|
avg_price: number;
|
|
total_capacity_mw: number;
|
|
}
|
|
|
|
interface CorrelationChartProps {
|
|
data: SuperJSONResult;
|
|
}
|
|
|
|
const REGION_COLORS: Record<string, string> = {
|
|
PJM: 'hsl(210, 90%, 55%)',
|
|
ERCOT: 'hsl(25, 90%, 55%)',
|
|
CAISO: 'hsl(140, 70%, 45%)',
|
|
NYISO: 'hsl(280, 70%, 55%)',
|
|
ISONE: 'hsl(340, 70%, 55%)',
|
|
MISO: 'hsl(55, 80%, 50%)',
|
|
SPP: 'hsl(180, 60%, 45%)',
|
|
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%)',
|
|
};
|
|
|
|
const chartConfig: ChartConfig = {
|
|
correlation: {
|
|
label: 'DC Capacity vs. Avg Price',
|
|
color: 'hsl(210, 90%, 55%)',
|
|
},
|
|
};
|
|
|
|
interface ScatterPoint {
|
|
region_code: string;
|
|
total_capacity_mw: number;
|
|
avg_price: number;
|
|
fill: string;
|
|
/** Dot radius scales with capacity — used as ZAxis value */
|
|
z: number;
|
|
}
|
|
|
|
interface TrendLinePoint {
|
|
total_capacity_mw: number;
|
|
trendPrice: number;
|
|
}
|
|
|
|
interface RegressionResult {
|
|
slope: number;
|
|
intercept: number;
|
|
r2: number;
|
|
line: TrendLinePoint[];
|
|
}
|
|
|
|
/** Simple OLS linear regression: y = slope * x + intercept */
|
|
function linearRegression(points: ScatterPoint[]): RegressionResult | null {
|
|
const n = points.length;
|
|
if (n < 2) return null;
|
|
|
|
let sumX = 0;
|
|
let sumY = 0;
|
|
let sumXY = 0;
|
|
let sumX2 = 0;
|
|
|
|
for (const p of points) {
|
|
sumX += p.total_capacity_mw;
|
|
sumY += p.avg_price;
|
|
sumXY += p.total_capacity_mw * p.avg_price;
|
|
sumX2 += p.total_capacity_mw * p.total_capacity_mw;
|
|
}
|
|
|
|
const denom = n * sumX2 - sumX * sumX;
|
|
if (denom === 0) return null;
|
|
|
|
const slope = (n * sumXY - sumX * sumY) / denom;
|
|
const intercept = (sumY - slope * sumX) / n;
|
|
|
|
// Pearson R squared
|
|
const ssRes = points.reduce((s, p) => {
|
|
const predicted = slope * p.total_capacity_mw + intercept;
|
|
return s + (p.avg_price - predicted) ** 2;
|
|
}, 0);
|
|
const meanY = sumY / n;
|
|
const ssTot = points.reduce((s, p) => s + (p.avg_price - meanY) ** 2, 0);
|
|
const r2 = ssTot === 0 ? 0 : 1 - ssRes / ssTot;
|
|
|
|
// Build line points from min to max X
|
|
const xs = points.map(p => p.total_capacity_mw);
|
|
const minX = Math.min(...xs);
|
|
const maxX = Math.max(...xs);
|
|
const line: TrendLinePoint[] = [
|
|
{ total_capacity_mw: minX, trendPrice: slope * minX + intercept },
|
|
{ total_capacity_mw: maxX, trendPrice: slope * maxX + intercept },
|
|
];
|
|
|
|
return { slope, intercept, r2, line };
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null;
|
|
}
|
|
|
|
function getNumberProp(obj: Record<string, unknown>, key: string): number {
|
|
const val = obj[key];
|
|
return typeof val === 'number' ? val : 0;
|
|
}
|
|
|
|
function getScatterPayload(obj: Record<string, unknown>): ScatterPoint | null {
|
|
const payload = obj['payload'];
|
|
if (!isRecord(payload)) return null;
|
|
const regionCode = typeof payload['region_code'] === 'string' ? payload['region_code'] : '';
|
|
const fill = typeof payload['fill'] === 'string' ? payload['fill'] : 'hsl(0,0%,50%)';
|
|
return {
|
|
region_code: regionCode,
|
|
total_capacity_mw: getNumberProp(payload, 'total_capacity_mw'),
|
|
avg_price: getNumberProp(payload, 'avg_price'),
|
|
fill,
|
|
z: getNumberProp(payload, 'z'),
|
|
};
|
|
}
|
|
|
|
function CustomDot(props: unknown): React.JSX.Element {
|
|
if (!isRecord(props)) return <g />;
|
|
const cx = getNumberProp(props, 'cx');
|
|
const cy = getNumberProp(props, 'cy');
|
|
const payload = getScatterPayload(props);
|
|
if (!payload) return <g />;
|
|
|
|
// Radius derived from ZAxis mapping — use z value to compute a proportional radius
|
|
const radius = Math.max(6, Math.min(20, payload.z / 80));
|
|
|
|
return (
|
|
<g>
|
|
<circle cx={cx} cy={cy} r={radius} fill={payload.fill} fillOpacity={0.6} stroke={payload.fill} strokeWidth={2} />
|
|
<text
|
|
x={cx}
|
|
y={cy - radius - 6}
|
|
textAnchor="middle"
|
|
fill="var(--color-foreground)"
|
|
fontSize={11}
|
|
fontWeight={600}>
|
|
{payload.region_code}
|
|
</text>
|
|
</g>
|
|
);
|
|
}
|
|
|
|
export function CorrelationChart({ data }: CorrelationChartProps) {
|
|
const rows = useMemo(() => deserialize<RegionCapacityPrice[]>(data), [data]);
|
|
|
|
const scatterData: ScatterPoint[] = useMemo(
|
|
() =>
|
|
rows
|
|
.filter(r => r.total_capacity_mw > 0 || r.avg_price > 0)
|
|
.map(r => ({
|
|
region_code: r.region_code,
|
|
total_capacity_mw: Math.round(r.total_capacity_mw),
|
|
avg_price: Number(r.avg_price.toFixed(2)),
|
|
fill: REGION_COLORS[r.region_code] ?? 'hsl(0, 0%, 50%)',
|
|
z: Math.round(r.total_capacity_mw),
|
|
})),
|
|
[rows],
|
|
);
|
|
|
|
const regression = useMemo(() => linearRegression(scatterData), [scatterData]);
|
|
|
|
// Merge scatter + trend line data for ComposedChart
|
|
const combinedData = useMemo(() => {
|
|
const scattered = scatterData.map(p => ({
|
|
...p,
|
|
trendPrice: undefined as number | undefined,
|
|
}));
|
|
if (!regression) return scattered;
|
|
// Add two trend-line-only points
|
|
const trendPoints = regression.line.map(t => ({
|
|
region_code: '',
|
|
avg_price: undefined as number | undefined,
|
|
total_capacity_mw: t.total_capacity_mw,
|
|
fill: '',
|
|
z: 0,
|
|
trendPrice: Number(t.trendPrice.toFixed(2)),
|
|
}));
|
|
return [...scattered, ...trendPoints].sort((a, b) => a.total_capacity_mw - b.total_capacity_mw);
|
|
}, [scatterData, regression]);
|
|
|
|
if (scatterData.length === 0) {
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>DC Capacity vs. Electricity Price</CardTitle>
|
|
<CardDescription>No data available for correlation analysis.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-border">
|
|
<p className="text-sm text-muted-foreground">
|
|
No data available. Ingest price data and seed datacenters first.
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
|
<div>
|
|
<CardTitle>DC Capacity vs. Electricity Price</CardTitle>
|
|
<CardDescription>Datacenter capacity (MW) versus average electricity price per region</CardDescription>
|
|
</div>
|
|
{regression && (
|
|
<div className="flex items-center gap-3 rounded-lg border border-border bg-muted/50 px-3 py-2">
|
|
<div className="text-xs text-muted-foreground">
|
|
<span className="font-medium text-foreground">R² = {regression.r2.toFixed(3)}</span>
|
|
<span className="ml-2">
|
|
{regression.r2 >= 0.7 ? 'Strong' : regression.r2 >= 0.4 ? 'Moderate' : 'Weak'} correlation
|
|
</span>
|
|
</div>
|
|
<div className="h-3 w-px bg-border" />
|
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
<span className="inline-block h-0.5 w-4 bg-foreground/40" />
|
|
Trend line
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<ChartContainer config={chartConfig} className="h-[40vh] max-h-125 min-h-75 w-full">
|
|
<ComposedChart data={combinedData} margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
|
|
<CartesianGrid strokeDasharray="3 3" className="stroke-border/30" />
|
|
<XAxis
|
|
type="number"
|
|
dataKey="total_capacity_mw"
|
|
name="DC Capacity"
|
|
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
tickFormatter={(v: number) => `${v} MW`}>
|
|
<Label
|
|
value="Total DC Capacity (MW)"
|
|
offset={-10}
|
|
position="insideBottom"
|
|
style={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
|
|
/>
|
|
</XAxis>
|
|
<YAxis
|
|
type="number"
|
|
dataKey="avg_price"
|
|
name="Avg Price"
|
|
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
tickFormatter={(v: number) => `$${v}`}
|
|
allowDataOverflow>
|
|
<Label
|
|
value="Avg Price ($/MWh)"
|
|
angle={-90}
|
|
position="insideLeft"
|
|
style={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
|
|
/>
|
|
</YAxis>
|
|
<ZAxis dataKey="z" range={[100, 1600]} />
|
|
<ChartTooltip
|
|
content={
|
|
<ChartTooltipContent
|
|
formatter={(value, name) => {
|
|
const nameStr = String(name);
|
|
const valStr = String(value);
|
|
if (nameStr === 'DC Capacity') return [`${valStr} MW`, undefined];
|
|
if (nameStr === 'Avg Price') return [`$${valStr}/MWh`, undefined];
|
|
return [valStr, undefined];
|
|
}}
|
|
/>
|
|
}
|
|
/>
|
|
{/* Trend line */}
|
|
{regression && (
|
|
<Line
|
|
type="linear"
|
|
dataKey="trendPrice"
|
|
stroke="color-mix(in oklch, var(--color-foreground) 30%, transparent)"
|
|
strokeWidth={2}
|
|
strokeDasharray="8 4"
|
|
dot={false}
|
|
connectNulls
|
|
isAnimationActive={false}
|
|
/>
|
|
)}
|
|
<Scatter name="Regions" dataKey="avg_price" data={scatterData} shape={CustomDot} />
|
|
</ComposedChart>
|
|
</ChartContainer>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|