'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 = { 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 { return typeof value === 'object' && value !== null; } function getNumberProp(obj: Record, key: string): number { const val = obj[key]; return typeof val === 'number' ? val : 0; } function getScatterPayload(obj: Record): 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 ; const cx = getNumberProp(props, 'cx'); const cy = getNumberProp(props, 'cy'); const payload = getScatterPayload(props); if (!payload) return ; // 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 ( {payload.region_code} ); } export function CorrelationChart({ data }: CorrelationChartProps) { const rows = useMemo(() => deserialize(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 ( DC Capacity vs. Electricity Price No data available for correlation analysis.

No data available. Ingest price data and seed datacenters first.

); } return (
DC Capacity vs. Electricity Price Datacenter capacity (MW) versus average electricity price per region
{regression && (
R² = {regression.r2.toFixed(3)} {regression.r2 >= 0.7 ? 'Strong' : regression.r2 >= 0.4 ? 'Moderate' : 'Weak'} correlation
Trend line
)}
`${v} MW`}> `$${v}`} allowDataOverflow> { 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 && ( )} ); }