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

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>
);
}