CSS variable hsl(var(--muted-foreground)) doesn't resolve in SVG context. Use #a1a1aa (zinc-400) directly for reliable visibility on dark background.
179 lines
5.8 KiB
TypeScript
179 lines
5.8 KiB
TypeScript
'use client';
|
|
|
|
import { useMemo } from 'react';
|
|
import { CartesianGrid, Label, Scatter, ScatterChart, 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%)',
|
|
};
|
|
|
|
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;
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
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 />;
|
|
|
|
return (
|
|
<g>
|
|
<circle cx={cx} cy={cy} r={8} fill={payload.fill} fillOpacity={0.7} stroke={payload.fill} strokeWidth={2} />
|
|
<text x={cx} y={cy - 14} textAnchor="middle" fill="hsl(var(--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%)',
|
|
})),
|
|
[rows],
|
|
);
|
|
|
|
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>
|
|
<CardTitle>DC Capacity vs. Electricity Price</CardTitle>
|
|
<CardDescription>Datacenter capacity (MW) versus average electricity price per region</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<ChartContainer config={chartConfig} className="h-[350px] w-full">
|
|
<ScatterChart 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: '#a1a1aa' }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
tickFormatter={(v: number) => `${v} MW`}>
|
|
<Label
|
|
value="Total DC Capacity (MW)"
|
|
offset={-10}
|
|
position="insideBottom"
|
|
style={{ fontSize: 11, fill: '#a1a1aa' }}
|
|
/>
|
|
</XAxis>
|
|
<YAxis
|
|
type="number"
|
|
dataKey="avg_price"
|
|
name="Avg Price"
|
|
tick={{ fontSize: 11, fill: '#a1a1aa' }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
tickFormatter={(v: number) => `$${v}`}>
|
|
<Label
|
|
value="Avg Price ($/MWh)"
|
|
angle={-90}
|
|
position="insideLeft"
|
|
style={{ fontSize: 11, fill: '#a1a1aa' }}
|
|
/>
|
|
</YAxis>
|
|
<ZAxis range={[200, 200]} />
|
|
<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];
|
|
}}
|
|
/>
|
|
}
|
|
/>
|
|
<Scatter name="Regions" data={scatterData} shape={CustomDot} />
|
|
</ScatterChart>
|
|
</ChartContainer>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|