phase 4: charts & analysis — price trends, demand analysis, generation mix, commodity overlay, correlation view
This commit is contained in:
parent
c33257092d
commit
2dddbe78cb
@ -71,6 +71,91 @@ export async function fetchPriceHeatmapData(): Promise<ActionResult<getRegionPri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchAllRegionPriceTrends(
|
||||||
|
timeRange: TimeRange = '30d',
|
||||||
|
): Promise<ActionResult<getPriceTrends.Result[]>> {
|
||||||
|
try {
|
||||||
|
const startDate = timeRangeToStartDate(timeRange);
|
||||||
|
const endDate = new Date();
|
||||||
|
const regions = await prisma.gridRegion.findMany({ select: { code: true } });
|
||||||
|
const results = await Promise.all(
|
||||||
|
regions.map(r => prisma.$queryRawTyped(getPriceTrends(r.code, startDate, endDate))),
|
||||||
|
);
|
||||||
|
return { ok: true, data: serialize(results.flat()) };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `Failed to fetch all region price trends: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCommodityTrends(timeRange: TimeRange = '30d'): Promise<
|
||||||
|
ActionResult<
|
||||||
|
Array<{
|
||||||
|
commodity: string;
|
||||||
|
price: number;
|
||||||
|
unit: string;
|
||||||
|
timestamp: Date;
|
||||||
|
source: string;
|
||||||
|
}>
|
||||||
|
>
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
const startDate = timeRangeToStartDate(timeRange);
|
||||||
|
const rows = await prisma.commodityPrice.findMany({
|
||||||
|
where: { timestamp: { gte: startDate } },
|
||||||
|
orderBy: { timestamp: 'asc' },
|
||||||
|
});
|
||||||
|
return { ok: true, data: serialize(rows) };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `Failed to fetch commodity trends: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRegionCapacityVsPrice(): Promise<
|
||||||
|
ActionResult<
|
||||||
|
Array<{
|
||||||
|
region_code: string;
|
||||||
|
avg_price: number;
|
||||||
|
total_capacity_mw: number;
|
||||||
|
}>
|
||||||
|
>
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
const regions = await prisma.gridRegion.findMany({
|
||||||
|
select: {
|
||||||
|
code: true,
|
||||||
|
datacenters: { select: { capacityMw: true } },
|
||||||
|
electricityPrices: {
|
||||||
|
select: { priceMwh: true },
|
||||||
|
orderBy: { timestamp: 'desc' },
|
||||||
|
take: 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = regions.map(r => ({
|
||||||
|
region_code: r.code,
|
||||||
|
total_capacity_mw: r.datacenters.reduce((sum, d) => sum + d.capacityMw, 0),
|
||||||
|
avg_price:
|
||||||
|
r.electricityPrices.length > 0
|
||||||
|
? r.electricityPrices.reduce((sum, p) => sum + p.priceMwh, 0) / r.electricityPrices.length
|
||||||
|
: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { ok: true, data: serialize(result) };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `Failed to fetch region capacity vs price: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchLatestCommodityPrices(): Promise<
|
export async function fetchLatestCommodityPrices(): Promise<
|
||||||
ActionResult<
|
ActionResult<
|
||||||
Array<{
|
Array<{
|
||||||
|
|||||||
@ -1,3 +1,7 @@
|
|||||||
|
import { fetchDemandByRegion, fetchRegionDemandSummary } from '@/actions/demand.js';
|
||||||
|
import { DemandChart } from '@/components/charts/demand-chart.js';
|
||||||
|
import type { getDemandByRegion } from '@/generated/prisma/sql.js';
|
||||||
|
import { deserialize } from '@/lib/superjson.js';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@ -5,18 +9,27 @@ export const metadata: Metadata = {
|
|||||||
description: 'Regional electricity demand growth, peak tracking, and datacenter load impact',
|
description: 'Regional electricity demand growth, peak tracking, and datacenter load impact',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function DemandPage() {
|
export default async function DemandPage() {
|
||||||
|
const [demandResult, summaryResult] = await Promise.all([
|
||||||
|
fetchDemandByRegion('ALL', '30d'),
|
||||||
|
fetchRegionDemandSummary(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const demandData = demandResult.ok ? deserialize<getDemandByRegion.Result[]>(demandResult.data) : [];
|
||||||
|
|
||||||
|
const summaryData = summaryResult.ok ? deserialize<getDemandByRegion.Result[]>(summaryResult.data) : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-6 py-8">
|
<div className="px-6 py-8">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Demand Analysis</h1>
|
<div className="mb-8">
|
||||||
<p className="mt-2 text-muted-foreground">
|
<h1 className="text-3xl font-bold tracking-tight">Demand Analysis</h1>
|
||||||
Regional demand growth trends, peak demand tracking, and estimated datacenter load as a percentage of regional
|
<p className="mt-2 text-muted-foreground">
|
||||||
demand.
|
Regional demand growth trends, peak demand tracking, and estimated datacenter load as a percentage of regional
|
||||||
</p>
|
demand.
|
||||||
|
</p>
|
||||||
<div className="mt-8 flex h-96 items-center justify-center rounded-lg border border-dashed border-border">
|
|
||||||
<p className="text-sm text-muted-foreground">Charts coming in Phase 4</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<DemandChart initialData={demandData} summaryData={summaryData} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,33 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
import { fetchGenerationMix } from '@/actions/generation.js';
|
||||||
|
import { GenerationChart } from '@/components/charts/generation-chart.js';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Generation Mix | Energy & AI Dashboard',
|
title: 'Generation Mix | Energy & AI Dashboard',
|
||||||
description: 'Generation by fuel type per region, renewable vs fossil splits, and carbon intensity',
|
description: 'Generation by fuel type per region, renewable vs fossil splits, and carbon intensity',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function GenerationPage() {
|
const DEFAULT_REGION = 'PJM';
|
||||||
|
const DEFAULT_TIME_RANGE = '30d' as const;
|
||||||
|
|
||||||
|
export default async function GenerationPage() {
|
||||||
|
const result = await fetchGenerationMix(DEFAULT_REGION, DEFAULT_TIME_RANGE);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
return (
|
||||||
|
<div className="px-6 py-8">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Generation Mix</h1>
|
||||||
|
<p className="mt-2 text-muted-foreground">
|
||||||
|
Stacked area charts showing generation by fuel type per region, with renewable vs fossil comparisons.
|
||||||
|
</p>
|
||||||
|
<div className="mt-8 flex h-96 items-center justify-center rounded-lg border border-dashed border-border">
|
||||||
|
<p className="text-sm text-destructive">{result.error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-6 py-8">
|
<div className="px-6 py-8">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Generation Mix</h1>
|
<h1 className="text-3xl font-bold tracking-tight">Generation Mix</h1>
|
||||||
@ -14,8 +36,12 @@ export default function GenerationPage() {
|
|||||||
intensity indicators.
|
intensity indicators.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-8 flex h-96 items-center justify-center rounded-lg border border-dashed border-border">
|
<div className="mt-8">
|
||||||
<p className="text-sm text-muted-foreground">Charts coming in Phase 4</p>
|
<GenerationChart
|
||||||
|
initialData={result.data}
|
||||||
|
initialRegion={DEFAULT_REGION}
|
||||||
|
initialTimeRange={DEFAULT_TIME_RANGE}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,21 +1,87 @@
|
|||||||
|
import { readFile } from 'fs/promises';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { fetchAllRegionPriceTrends, fetchCommodityTrends, fetchRegionCapacityVsPrice } from '@/actions/prices.js';
|
||||||
|
import { CorrelationChart } from '@/components/charts/correlation-chart.js';
|
||||||
|
import type { AIMilestone } from '@/components/charts/price-chart.js';
|
||||||
|
import { PriceChart } from '@/components/charts/price-chart.js';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Price Trends | Energy & AI Dashboard',
|
title: 'Price Trends | Energy & AI Dashboard',
|
||||||
description: 'Regional electricity price trends, commodity overlays, and AI milestone annotations',
|
description: 'Regional electricity price trends, commodity overlays, and AI milestone annotations',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function TrendsPage() {
|
const AIMilestoneSchema = z.object({
|
||||||
return (
|
date: z.string(),
|
||||||
<div className="px-6 py-8">
|
title: z.string(),
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Price Trends</h1>
|
description: z.string(),
|
||||||
<p className="mt-2 text-muted-foreground">
|
category: z.string(),
|
||||||
Multi-line regional electricity price charts with commodity overlays and AI milestone annotations.
|
}) satisfies z.ZodType<AIMilestone>;
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mt-8 flex h-96 items-center justify-center rounded-lg border border-dashed border-border">
|
async function loadMilestones(): Promise<AIMilestone[]> {
|
||||||
<p className="text-sm text-muted-foreground">Charts coming in Phase 4</p>
|
try {
|
||||||
|
const filePath = join(process.cwd(), 'data', 'ai-milestones.json');
|
||||||
|
const raw = await readFile(filePath, 'utf-8');
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
return z.array(AIMilestoneSchema).parse(parsed);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function TrendsPage() {
|
||||||
|
const defaultRange = '30d' as const;
|
||||||
|
|
||||||
|
const [priceResult, commodityResult, correlationResult, milestones] = await Promise.all([
|
||||||
|
fetchAllRegionPriceTrends(defaultRange),
|
||||||
|
fetchCommodityTrends(defaultRange),
|
||||||
|
fetchRegionCapacityVsPrice(),
|
||||||
|
loadMilestones(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 px-6 py-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Price Trends</h1>
|
||||||
|
<p className="mt-2 text-muted-foreground">
|
||||||
|
Regional electricity price charts with commodity overlays and AI milestone annotations.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{priceResult.ok && commodityResult.ok ? (
|
||||||
|
<PriceChart
|
||||||
|
initialPriceData={priceResult.data}
|
||||||
|
initialCommodityData={commodityResult.data}
|
||||||
|
milestones={milestones}
|
||||||
|
initialTimeRange={defaultRange}
|
||||||
|
onTimeRangeChange={async range => {
|
||||||
|
'use server';
|
||||||
|
const [prices, commodities] = await Promise.all([
|
||||||
|
fetchAllRegionPriceTrends(range),
|
||||||
|
fetchCommodityTrends(range),
|
||||||
|
]);
|
||||||
|
if (!prices.ok) throw new Error(prices.error);
|
||||||
|
if (!commodities.ok) throw new Error(commodities.error);
|
||||||
|
return { prices: prices.data, commodities: commodities.data };
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-96 items-center justify-center rounded-lg border border-dashed border-border">
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{!priceResult.ok ? priceResult.error : !commodityResult.ok ? commodityResult.error : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{correlationResult.ok ? (
|
||||||
|
<CorrelationChart data={correlationResult.data} />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-border">
|
||||||
|
<p className="text-sm text-destructive">{correlationResult.error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
178
src/components/charts/correlation-chart.tsx
Normal file
178
src/components/charts/correlation-chart.tsx
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
'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 }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickFormatter={(v: number) => `${v} MW`}>
|
||||||
|
<Label
|
||||||
|
value="Total DC Capacity (MW)"
|
||||||
|
offset={-10}
|
||||||
|
position="insideBottom"
|
||||||
|
style={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
|
/>
|
||||||
|
</XAxis>
|
||||||
|
<YAxis
|
||||||
|
type="number"
|
||||||
|
dataKey="avg_price"
|
||||||
|
name="Avg Price"
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickFormatter={(v: number) => `$${v}`}>
|
||||||
|
<Label
|
||||||
|
value="Avg Price ($/MWh)"
|
||||||
|
angle={-90}
|
||||||
|
position="insideLeft"
|
||||||
|
style={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
470
src/components/charts/demand-chart.tsx
Normal file
470
src/components/charts/demand-chart.tsx
Normal file
@ -0,0 +1,470 @@
|
|||||||
|
'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';
|
||||||
|
|
||||||
|
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' },
|
||||||
|
];
|
||||||
|
|
||||||
|
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%)',
|
||||||
|
};
|
||||||
|
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DemandChart({ initialData, summaryData }: DemandChartProps) {
|
||||||
|
const [timeRange, setTimeRange] = useState<TimeRange>('30d');
|
||||||
|
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.name,
|
||||||
|
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 rounded-lg border border-border bg-card p-1">
|
||||||
|
<button
|
||||||
|
onClick={() => handleRegionChange('ALL')}
|
||||||
|
className={`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={`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-[400px] w-full">
|
||||||
|
<ComposedChart data={trendChartData}>
|
||||||
|
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="date" tickLine={false} axisLine={false} tickMargin={8} />
|
||||||
|
<YAxis
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={8}
|
||||||
|
tickFormatter={(value: number) => formatDemandValue(value)}
|
||||||
|
/>
|
||||||
|
<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
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</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="text-sm font-medium">{peak.regionName}</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-[280px] 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)}
|
||||||
|
/>
|
||||||
|
<YAxis type="category" dataKey="region" tickLine={false} axisLine={false} width={55} />
|
||||||
|
<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="text-muted-foreground">{row.regionName}</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
387
src/components/charts/generation-chart.tsx
Normal file
387
src/components/charts/generation-chart.tsx
Normal file
@ -0,0 +1,387 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useState, useTransition } from 'react';
|
||||||
|
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts';
|
||||||
|
|
||||||
|
import { fetchGenerationMix } from '@/actions/generation.js';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.js';
|
||||||
|
import {
|
||||||
|
type ChartConfig,
|
||||||
|
ChartContainer,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
} from '@/components/ui/chart.js';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select.js';
|
||||||
|
import type { getGenerationMix } from '@/generated/prisma/sql.js';
|
||||||
|
import { deserialize } from '@/lib/superjson.js';
|
||||||
|
import { formatMarketTime } from '@/lib/utils.js';
|
||||||
|
|
||||||
|
type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y';
|
||||||
|
|
||||||
|
const REGIONS = [
|
||||||
|
{ code: 'PJM', name: 'PJM (Mid-Atlantic)' },
|
||||||
|
{ code: 'ERCOT', name: 'ERCOT (Texas)' },
|
||||||
|
{ code: 'CAISO', name: 'CAISO (California)' },
|
||||||
|
{ code: 'NYISO', name: 'NYISO (New York)' },
|
||||||
|
{ code: 'ISONE', name: 'ISO-NE (New England)' },
|
||||||
|
{ code: 'MISO', name: 'MISO (Midwest)' },
|
||||||
|
{ code: 'SPP', name: 'SPP (South Central)' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const TIME_RANGES: { value: TimeRange; label: string }[] = [
|
||||||
|
{ value: '24h', label: '24H' },
|
||||||
|
{ value: '7d', label: '7D' },
|
||||||
|
{ value: '30d', label: '30D' },
|
||||||
|
{ value: '90d', label: '90D' },
|
||||||
|
{ value: '1y', label: '1Y' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const FUEL_TYPES = ['gas', 'nuclear', 'wind', 'solar', 'coal', 'hydro', 'other'] as const;
|
||||||
|
type FuelType = (typeof FUEL_TYPES)[number];
|
||||||
|
|
||||||
|
const FUEL_COLORS: Record<FuelType, string> = {
|
||||||
|
gas: 'hsl(25, 95%, 53%)',
|
||||||
|
nuclear: 'hsl(270, 70%, 60%)',
|
||||||
|
wind: 'hsl(190, 90%, 50%)',
|
||||||
|
solar: 'hsl(45, 93%, 58%)',
|
||||||
|
coal: 'hsl(0, 0%, 55%)',
|
||||||
|
hydro: 'hsl(210, 80%, 55%)',
|
||||||
|
other: 'hsl(0, 0%, 40%)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const FUEL_LABELS: Record<FuelType, string> = {
|
||||||
|
gas: 'Natural Gas',
|
||||||
|
nuclear: 'Nuclear',
|
||||||
|
wind: 'Wind',
|
||||||
|
solar: 'Solar',
|
||||||
|
coal: 'Coal',
|
||||||
|
hydro: 'Hydro',
|
||||||
|
other: 'Other',
|
||||||
|
};
|
||||||
|
|
||||||
|
const RENEWABLE_FUELS = new Set<FuelType>(['wind', 'solar', 'hydro']);
|
||||||
|
const FOSSIL_FUELS = new Set<FuelType>(['gas', 'coal']);
|
||||||
|
|
||||||
|
const FUEL_TYPE_SET: Set<string> = new Set(FUEL_TYPES);
|
||||||
|
|
||||||
|
function isFuelType(value: string): value is FuelType {
|
||||||
|
return FUEL_TYPE_SET.has(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIME_RANGE_SET: Set<string> = new Set(['24h', '7d', '30d', '90d', '1y']);
|
||||||
|
|
||||||
|
function isTimeRange(value: string): value is TimeRange {
|
||||||
|
return TIME_RANGE_SET.has(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartConfig: ChartConfig = Object.fromEntries(
|
||||||
|
FUEL_TYPES.map(fuel => [fuel, { label: FUEL_LABELS[fuel], color: FUEL_COLORS[fuel] }]),
|
||||||
|
);
|
||||||
|
|
||||||
|
interface PivotedRow {
|
||||||
|
timestamp: number;
|
||||||
|
dateLabel: string;
|
||||||
|
gas: number;
|
||||||
|
nuclear: number;
|
||||||
|
wind: number;
|
||||||
|
solar: number;
|
||||||
|
coal: number;
|
||||||
|
hydro: number;
|
||||||
|
other: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pivotGenerationData(rows: getGenerationMix.Result[], regionCode: string): PivotedRow[] {
|
||||||
|
const byTimestamp = new Map<number, PivotedRow>();
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const ts = row.timestamp.getTime();
|
||||||
|
let pivot = byTimestamp.get(ts);
|
||||||
|
if (!pivot) {
|
||||||
|
pivot = {
|
||||||
|
timestamp: ts,
|
||||||
|
dateLabel: formatMarketTime(row.timestamp, regionCode),
|
||||||
|
gas: 0,
|
||||||
|
nuclear: 0,
|
||||||
|
wind: 0,
|
||||||
|
solar: 0,
|
||||||
|
coal: 0,
|
||||||
|
hydro: 0,
|
||||||
|
other: 0,
|
||||||
|
};
|
||||||
|
byTimestamp.set(ts, pivot);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fuelKey = isFuelType(row.fuel_type) ? row.fuel_type : 'other';
|
||||||
|
pivot[fuelKey] += row.generation_mw;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(byTimestamp.values()).sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GenerationSplit {
|
||||||
|
renewable: number;
|
||||||
|
fossil: number;
|
||||||
|
nuclear: number;
|
||||||
|
other: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeGenerationSplit(data: PivotedRow[]): GenerationSplit {
|
||||||
|
const totals: GenerationSplit = { renewable: 0, fossil: 0, nuclear: 0, other: 0, total: 0 };
|
||||||
|
|
||||||
|
for (const row of data) {
|
||||||
|
for (const fuel of FUEL_TYPES) {
|
||||||
|
const mw = row[fuel];
|
||||||
|
totals.total += mw;
|
||||||
|
if (RENEWABLE_FUELS.has(fuel)) {
|
||||||
|
totals.renewable += mw;
|
||||||
|
} else if (FOSSIL_FUELS.has(fuel)) {
|
||||||
|
totals.fossil += mw;
|
||||||
|
} else if (fuel === 'nuclear') {
|
||||||
|
totals.nuclear += mw;
|
||||||
|
} else {
|
||||||
|
totals.other += mw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return totals;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GenerationChartProps {
|
||||||
|
initialData: ReturnType<typeof import('@/lib/superjson.js').serialize<getGenerationMix.Result[]>>;
|
||||||
|
initialRegion: string;
|
||||||
|
initialTimeRange: TimeRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GenerationChart({ initialData, initialRegion, initialTimeRange }: GenerationChartProps) {
|
||||||
|
const [regionCode, setRegionCode] = useState(initialRegion);
|
||||||
|
const [timeRange, setTimeRange] = useState<TimeRange>(initialTimeRange);
|
||||||
|
const [serializedData, setSerializedData] = useState(initialData);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const rows = useMemo(() => deserialize<getGenerationMix.Result[]>(serializedData), [serializedData]);
|
||||||
|
const chartData = useMemo(() => pivotGenerationData(rows, regionCode), [rows, regionCode]);
|
||||||
|
const split = useMemo(() => computeGenerationSplit(chartData), [chartData]);
|
||||||
|
|
||||||
|
const refetch = useCallback((newRegion: string, newTimeRange: TimeRange) => {
|
||||||
|
startTransition(async () => {
|
||||||
|
setError(null);
|
||||||
|
const result = await fetchGenerationMix(newRegion, newTimeRange);
|
||||||
|
if (result.ok) {
|
||||||
|
setSerializedData(result.data);
|
||||||
|
} else {
|
||||||
|
setError(result.error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRegionChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setRegionCode(value);
|
||||||
|
refetch(value, timeRange);
|
||||||
|
},
|
||||||
|
[refetch, timeRange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTimeRangeChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
if (!isTimeRange(value)) return;
|
||||||
|
setTimeRange(value);
|
||||||
|
refetch(regionCode, value);
|
||||||
|
},
|
||||||
|
[refetch, regionCode],
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatPercent = (value: number, total: number) => {
|
||||||
|
if (total === 0) return '0%';
|
||||||
|
return `${((value / total) * 100).toFixed(1)}%`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Generation by Fuel Type</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Stacked generation mix over time for {REGIONS.find(r => r.code === regionCode)?.name ?? regionCode}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select value={regionCode} onValueChange={handleRegionChange}>
|
||||||
|
<SelectTrigger className="w-[200px]">
|
||||||
|
<SelectValue placeholder="Select region" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{REGIONS.map(r => (
|
||||||
|
<SelectItem key={r.code} value={r.code}>
|
||||||
|
{r.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={timeRange} onValueChange={handleTimeRangeChange}>
|
||||||
|
<SelectTrigger className="w-[90px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{TIME_RANGES.map(t => (
|
||||||
|
<SelectItem key={t.value} value={t.value}>
|
||||||
|
{t.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{error && <div className="mb-4 rounded-md bg-destructive/10 p-3 text-sm text-destructive">{error}</div>}
|
||||||
|
|
||||||
|
{chartData.length === 0 && !isPending ? (
|
||||||
|
<div className="flex h-80 items-center justify-center rounded-lg border border-dashed border-border">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No generation data available for this region and time range.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={isPending ? 'opacity-50 transition-opacity' : ''}>
|
||||||
|
<ChartContainer config={chartConfig} className="h-80 w-full">
|
||||||
|
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||||
|
<defs>
|
||||||
|
{FUEL_TYPES.map(fuel => (
|
||||||
|
<linearGradient key={fuel} id={`fill-${fuel}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor={`var(--color-${fuel})`} stopOpacity={0.8} />
|
||||||
|
<stop offset="95%" stopColor={`var(--color-${fuel})`} stopOpacity={0.1} />
|
||||||
|
</linearGradient>
|
||||||
|
))}
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="stroke-border/50" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="dateLabel"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={8}
|
||||||
|
minTickGap={40}
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={8}
|
||||||
|
tickFormatter={(value: number) => (value >= 1000 ? `${(value / 1000).toFixed(0)}GW` : `${value}MW`)}
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
labelFormatter={(_, payload) => {
|
||||||
|
const firstPayload: unknown = payload?.[0]?.payload;
|
||||||
|
if (
|
||||||
|
firstPayload &&
|
||||||
|
typeof firstPayload === 'object' &&
|
||||||
|
'timestamp' in firstPayload &&
|
||||||
|
typeof firstPayload.timestamp === 'number'
|
||||||
|
) {
|
||||||
|
return formatMarketTime(new Date(firstPayload.timestamp), regionCode);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}}
|
||||||
|
formatter={(value, name) => {
|
||||||
|
const mw = typeof value === 'number' ? value : Number(value);
|
||||||
|
const nameStr = String(name);
|
||||||
|
const label = isFuelType(nameStr) ? FUEL_LABELS[nameStr] : nameStr;
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
{label}: {mw.toLocaleString(undefined, { maximumFractionDigits: 0 })} MW
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{FUEL_TYPES.map(fuel => (
|
||||||
|
<Area
|
||||||
|
key={fuel}
|
||||||
|
stackId="generation"
|
||||||
|
type="monotone"
|
||||||
|
dataKey={fuel}
|
||||||
|
fill={`url(#fill-${fuel})`}
|
||||||
|
stroke={`var(--color-${fuel})`}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<ChartLegend content={<ChartLegendContent />} />
|
||||||
|
</AreaChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{chartData.length > 0 && (
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
|
<SplitCard
|
||||||
|
label="Renewable"
|
||||||
|
value={split.renewable}
|
||||||
|
total={split.total}
|
||||||
|
formatPercent={formatPercent}
|
||||||
|
colorClass="text-cyan-400"
|
||||||
|
/>
|
||||||
|
<SplitCard
|
||||||
|
label="Fossil"
|
||||||
|
value={split.fossil}
|
||||||
|
total={split.total}
|
||||||
|
formatPercent={formatPercent}
|
||||||
|
colorClass="text-orange-400"
|
||||||
|
/>
|
||||||
|
<SplitCard
|
||||||
|
label="Nuclear"
|
||||||
|
value={split.nuclear}
|
||||||
|
total={split.total}
|
||||||
|
formatPercent={formatPercent}
|
||||||
|
colorClass="text-purple-400"
|
||||||
|
/>
|
||||||
|
<SplitCard
|
||||||
|
label="Other"
|
||||||
|
value={split.other}
|
||||||
|
total={split.total}
|
||||||
|
formatPercent={formatPercent}
|
||||||
|
colorClass="text-gray-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SplitCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
total,
|
||||||
|
formatPercent,
|
||||||
|
colorClass,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
total: number;
|
||||||
|
formatPercent: (v: number, t: number) => string;
|
||||||
|
colorClass: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<p className="text-sm text-muted-foreground">{label}</p>
|
||||||
|
<p className={`mt-1 text-2xl font-bold tabular-nums ${colorClass}`}>{formatPercent(value, total)}</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{(value / 1000).toLocaleString(undefined, { maximumFractionDigits: 0 })} GWh total
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
436
src/components/charts/price-chart.tsx
Normal file
436
src/components/charts/price-chart.tsx
Normal file
@ -0,0 +1,436 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { CartesianGrid, Line, LineChart, ReferenceLine, XAxis, YAxis } from 'recharts';
|
||||||
|
import type { SuperJSONResult } from 'superjson';
|
||||||
|
|
||||||
|
import { TimeRangeSelector, type TimeRange } from '@/components/charts/time-range-selector.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 { cn } from '@/lib/utils.js';
|
||||||
|
|
||||||
|
interface PriceTrendRow {
|
||||||
|
timestamp: Date;
|
||||||
|
price_mwh: number;
|
||||||
|
demand_mw: number;
|
||||||
|
region_code: string;
|
||||||
|
region_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommodityRow {
|
||||||
|
commodity: string;
|
||||||
|
price: number;
|
||||||
|
unit: string;
|
||||||
|
timestamp: Date;
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIMilestone {
|
||||||
|
date: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
category: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimeRangeChangeResult {
|
||||||
|
prices: SuperJSONResult;
|
||||||
|
commodities: SuperJSONResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PriceChartProps {
|
||||||
|
initialPriceData: SuperJSONResult;
|
||||||
|
initialCommodityData: SuperJSONResult;
|
||||||
|
milestones: AIMilestone[];
|
||||||
|
initialTimeRange: TimeRange;
|
||||||
|
onTimeRangeChange: (range: TimeRange) => Promise<TimeRangeChangeResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 COMMODITY_COLORS: Record<string, string> = {
|
||||||
|
natural_gas: 'hsl(30, 100%, 70%)',
|
||||||
|
wti_crude: 'hsl(0, 70%, 60%)',
|
||||||
|
coal: 'hsl(0, 0%, 60%)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const COMMODITY_LABELS: Record<string, string> = {
|
||||||
|
natural_gas: 'Natural Gas ($/MMBtu)',
|
||||||
|
wti_crude: 'WTI Crude ($/bbl)',
|
||||||
|
coal: 'Coal ($/short ton)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const MILESTONE_COLORS: Record<string, string> = {
|
||||||
|
model_launch: 'hsl(280, 60%, 60%)',
|
||||||
|
infrastructure: 'hsl(200, 80%, 60%)',
|
||||||
|
market: 'hsl(140, 60%, 50%)',
|
||||||
|
policy: 'hsl(40, 80%, 55%)',
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildChartConfig(regions: string[], commodities: string[], showCommodities: boolean): ChartConfig {
|
||||||
|
const config: ChartConfig = {};
|
||||||
|
for (const region of regions) {
|
||||||
|
config[region] = {
|
||||||
|
label: region,
|
||||||
|
color: REGION_COLORS[region] ?? 'hsl(0, 0%, 50%)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (showCommodities) {
|
||||||
|
for (const commodity of commodities) {
|
||||||
|
config[commodity] = {
|
||||||
|
label: COMMODITY_LABELS[commodity] ?? commodity,
|
||||||
|
color: COMMODITY_COLORS[commodity] ?? 'hsl(0, 0%, 60%)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PivotedRow {
|
||||||
|
timestamp: number;
|
||||||
|
timestampDisplay: string;
|
||||||
|
[key: string]: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pivotData(
|
||||||
|
priceRows: PriceTrendRow[],
|
||||||
|
commodityRows: CommodityRow[],
|
||||||
|
showCommodities: boolean,
|
||||||
|
): { pivoted: PivotedRow[]; regions: string[]; commodities: string[] } {
|
||||||
|
const regionSet = new Set<string>();
|
||||||
|
const commoditySet = new Set<string>();
|
||||||
|
const byTimestamp = new Map<number, PivotedRow>();
|
||||||
|
|
||||||
|
for (const row of priceRows) {
|
||||||
|
regionSet.add(row.region_code);
|
||||||
|
const ts = row.timestamp.getTime();
|
||||||
|
|
||||||
|
if (!byTimestamp.has(ts)) {
|
||||||
|
byTimestamp.set(ts, {
|
||||||
|
timestamp: ts,
|
||||||
|
timestampDisplay: row.timestamp.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const pivotRow = byTimestamp.get(ts)!;
|
||||||
|
pivotRow[row.region_code] = row.price_mwh;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showCommodities) {
|
||||||
|
for (const row of commodityRows) {
|
||||||
|
commoditySet.add(row.commodity);
|
||||||
|
const ts = row.timestamp.getTime();
|
||||||
|
|
||||||
|
if (!byTimestamp.has(ts)) {
|
||||||
|
byTimestamp.set(ts, {
|
||||||
|
timestamp: ts,
|
||||||
|
timestampDisplay: row.timestamp.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const pivotRow = byTimestamp.get(ts)!;
|
||||||
|
pivotRow[row.commodity] = row.price;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const regions = Array.from(regionSet).sort();
|
||||||
|
const commodities = Array.from(commoditySet).sort();
|
||||||
|
const pivoted = Array.from(byTimestamp.values()).sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
|
||||||
|
return { pivoted, regions, commodities };
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterMilestonesByRange(milestones: AIMilestone[], pivoted: PivotedRow[]): AIMilestone[] {
|
||||||
|
if (pivoted.length === 0) return [];
|
||||||
|
const minTs = pivoted[0]!.timestamp;
|
||||||
|
const maxTs = pivoted[pivoted.length - 1]!.timestamp;
|
||||||
|
|
||||||
|
return milestones.filter(m => {
|
||||||
|
const mTs = new Date(m.date).getTime();
|
||||||
|
return mTs >= minTs && mTs <= maxTs;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PriceChart({
|
||||||
|
initialPriceData,
|
||||||
|
initialCommodityData,
|
||||||
|
milestones,
|
||||||
|
initialTimeRange,
|
||||||
|
onTimeRangeChange,
|
||||||
|
}: PriceChartProps) {
|
||||||
|
const [pricesSerialized, setPricesSerialized] = useState<SuperJSONResult>(initialPriceData);
|
||||||
|
const [commoditiesSerialized, setCommoditiesSerialized] = useState<SuperJSONResult>(initialCommodityData);
|
||||||
|
const [timeRange, setTimeRange] = useState<TimeRange>(initialTimeRange);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [disabledRegions, setDisabledRegions] = useState<Set<string>>(new Set());
|
||||||
|
const [showCommodities, setShowCommodities] = useState(false);
|
||||||
|
const [showMilestones, setShowMilestones] = useState(true);
|
||||||
|
|
||||||
|
const priceRows = useMemo(() => deserialize<PriceTrendRow[]>(pricesSerialized), [pricesSerialized]);
|
||||||
|
const commodityRows = useMemo(() => deserialize<CommodityRow[]>(commoditiesSerialized), [commoditiesSerialized]);
|
||||||
|
|
||||||
|
const { pivoted, regions, commodities } = useMemo(
|
||||||
|
() => pivotData(priceRows, commodityRows, showCommodities),
|
||||||
|
[priceRows, commodityRows, showCommodities],
|
||||||
|
);
|
||||||
|
|
||||||
|
const chartConfig = useMemo(
|
||||||
|
() => buildChartConfig(regions, commodities, showCommodities),
|
||||||
|
[regions, commodities, showCommodities],
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeRegions = useMemo(() => regions.filter(r => !disabledRegions.has(r)), [regions, disabledRegions]);
|
||||||
|
|
||||||
|
const visibleMilestones = useMemo(
|
||||||
|
() => (showMilestones ? filterMilestonesByRange(milestones, pivoted) : []),
|
||||||
|
[milestones, pivoted, showMilestones],
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleTimeRangeChange(range: TimeRange) {
|
||||||
|
setTimeRange(range);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await onTimeRangeChange(range);
|
||||||
|
setPricesSerialized(result.prices);
|
||||||
|
setCommoditiesSerialized(result.commodities);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRegion(region: string) {
|
||||||
|
setDisabledRegions(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(region)) {
|
||||||
|
next.delete(region);
|
||||||
|
} else {
|
||||||
|
next.add(region);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pivoted.length === 0 && !showCommodities) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Regional Electricity Prices</CardTitle>
|
||||||
|
<CardDescription>No price data available for the selected time range.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<TimeRangeSelector value={timeRange} onChange={handleTimeRangeChange} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-8 flex h-64 items-center justify-center rounded-lg border border-dashed border-border">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No data available. Try ingesting electricity price data first.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Regional Electricity Prices</CardTitle>
|
||||||
|
<CardDescription>Price per MWh across ISO/RTO regions over time</CardDescription>
|
||||||
|
</div>
|
||||||
|
<TimeRangeSelector value={timeRange} onChange={handleTimeRangeChange} />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||||
|
{regions.map(region => (
|
||||||
|
<button
|
||||||
|
key={region}
|
||||||
|
onClick={() => toggleRegion(region)}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-colors',
|
||||||
|
disabledRegions.has(region)
|
||||||
|
? 'border-border bg-transparent text-muted-foreground'
|
||||||
|
: 'border-transparent text-foreground',
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
disabledRegions.has(region)
|
||||||
|
? undefined
|
||||||
|
: { backgroundColor: `${REGION_COLORS[region] ?? 'hsl(0,0%,50%)'}20` }
|
||||||
|
}>
|
||||||
|
<span
|
||||||
|
className="h-2 w-2 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: disabledRegions.has(region) ? 'hsl(var(--muted-foreground))' : REGION_COLORS[region],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{region}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="mx-2 h-4 w-px bg-border" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCommodities(prev => !prev)}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-colors',
|
||||||
|
showCommodities
|
||||||
|
? 'border-amber-500/30 bg-amber-500/10 text-amber-300'
|
||||||
|
: 'border-border text-muted-foreground',
|
||||||
|
)}>
|
||||||
|
Commodities
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowMilestones(prev => !prev)}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-colors',
|
||||||
|
showMilestones
|
||||||
|
? 'border-purple-500/30 bg-purple-500/10 text-purple-300'
|
||||||
|
: 'border-border text-muted-foreground',
|
||||||
|
)}>
|
||||||
|
AI Milestones
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cn('relative', loading && 'opacity-50 transition-opacity')}>
|
||||||
|
<ChartContainer config={chartConfig} className="h-[400px] w-full">
|
||||||
|
<LineChart data={pivoted} margin={{ top: 5, right: 60, left: 10, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="stroke-border/30" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="timestampDisplay"
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
interval="preserveStartEnd"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
yAxisId="left"
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickFormatter={(value: number) => `$${value}`}
|
||||||
|
label={{
|
||||||
|
value: '$/MWh',
|
||||||
|
angle: -90,
|
||||||
|
position: 'insideLeft',
|
||||||
|
style: { fontSize: 11, fill: 'hsl(var(--muted-foreground))' },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{showCommodities && (
|
||||||
|
<YAxis
|
||||||
|
yAxisId="right"
|
||||||
|
orientation="right"
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickFormatter={(value: number) => `$${value}`}
|
||||||
|
label={{
|
||||||
|
value: 'Commodity Price',
|
||||||
|
angle: 90,
|
||||||
|
position: 'insideRight',
|
||||||
|
style: { fontSize: 11, fill: 'hsl(var(--muted-foreground))' },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ChartTooltip
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
labelFormatter={label => (typeof label === 'string' || typeof label === 'number' ? label : '')}
|
||||||
|
formatter={(value, name) => {
|
||||||
|
const numVal = Number(value);
|
||||||
|
const nameStr = String(name);
|
||||||
|
if (nameStr in COMMODITY_LABELS) {
|
||||||
|
return [`$${numVal.toFixed(2)}`, undefined];
|
||||||
|
}
|
||||||
|
return [`$${numVal.toFixed(2)}/MWh`, undefined];
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ChartLegend content={<ChartLegendContent />} />
|
||||||
|
|
||||||
|
{activeRegions.map(region => (
|
||||||
|
<Line
|
||||||
|
key={region}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={region}
|
||||||
|
yAxisId="left"
|
||||||
|
stroke={`var(--color-${region})`}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
connectNulls
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{showCommodities &&
|
||||||
|
commodities.map(commodity => (
|
||||||
|
<Line
|
||||||
|
key={commodity}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={commodity}
|
||||||
|
yAxisId="right"
|
||||||
|
stroke={`var(--color-${commodity})`}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeDasharray="6 3"
|
||||||
|
dot={false}
|
||||||
|
connectNulls
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{visibleMilestones.map(milestone => (
|
||||||
|
<ReferenceLine
|
||||||
|
key={milestone.date}
|
||||||
|
x={new Date(milestone.date).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
yAxisId="left"
|
||||||
|
stroke={MILESTONE_COLORS[milestone.category] ?? 'hsl(280, 60%, 60%)'}
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
strokeWidth={1}
|
||||||
|
label={{
|
||||||
|
value: milestone.title,
|
||||||
|
position: 'top',
|
||||||
|
style: {
|
||||||
|
fontSize: 9,
|
||||||
|
fill: MILESTONE_COLORS[milestone.category] ?? 'hsl(280, 60%, 60%)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
src/components/charts/time-range-selector.tsx
Normal file
38
src/components/charts/time-range-selector.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils.js';
|
||||||
|
|
||||||
|
export type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y';
|
||||||
|
|
||||||
|
const TIME_RANGES: Array<{ value: TimeRange; label: string }> = [
|
||||||
|
{ value: '24h', label: '24H' },
|
||||||
|
{ value: '7d', label: '1W' },
|
||||||
|
{ value: '30d', label: '1M' },
|
||||||
|
{ value: '90d', label: '3M' },
|
||||||
|
{ value: '1y', label: '1Y' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface TimeRangeSelectorProps {
|
||||||
|
value: TimeRange;
|
||||||
|
onChange: (range: TimeRange) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TimeRangeSelector({ value, onChange }: TimeRangeSelectorProps) {
|
||||||
|
return (
|
||||||
|
<div className="inline-flex items-center gap-1 rounded-lg bg-muted p-1">
|
||||||
|
{TIME_RANGES.map(range => (
|
||||||
|
<button
|
||||||
|
key={range.value}
|
||||||
|
onClick={() => onChange(range.value)}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
|
||||||
|
value === range.value
|
||||||
|
? 'bg-background text-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}>
|
||||||
|
{range.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
344
src/components/ui/chart.tsx
Normal file
344
src/components/ui/chart.tsx
Normal file
@ -0,0 +1,344 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import type { LegendPayload, TooltipPayloadEntry } from 'recharts';
|
||||||
|
import * as RechartsPrimitive from 'recharts';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils.js';
|
||||||
|
|
||||||
|
type VerticalAlignmentType = 'top' | 'bottom' | 'middle';
|
||||||
|
type ValueType = number | string | ReadonlyArray<number | string>;
|
||||||
|
type NameType = number | string;
|
||||||
|
|
||||||
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
|
const THEMES = { light: '', dark: '.dark' } as const;
|
||||||
|
|
||||||
|
export type ChartConfig = {
|
||||||
|
[k in string]: {
|
||||||
|
label?: React.ReactNode;
|
||||||
|
icon?: React.ComponentType;
|
||||||
|
} & ({ color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> });
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChartContextProps = {
|
||||||
|
config: ChartConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||||
|
|
||||||
|
function useChart() {
|
||||||
|
const context = React.useContext(ChartContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useChart must be used within a <ChartContainer />');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartContainer({
|
||||||
|
id,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
config,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & {
|
||||||
|
config: ChartConfig;
|
||||||
|
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>['children'];
|
||||||
|
}) {
|
||||||
|
const uniqueId = React.useId();
|
||||||
|
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContext.Provider value={{ config }}>
|
||||||
|
<div
|
||||||
|
data-slot="chart"
|
||||||
|
data-chart={chartId}
|
||||||
|
className={cn(
|
||||||
|
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}>
|
||||||
|
<ChartStyle id={chartId} config={config} />
|
||||||
|
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</ChartContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||||
|
const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color);
|
||||||
|
|
||||||
|
if (!colorConfig.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<style
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: Object.entries(THEMES)
|
||||||
|
.map(
|
||||||
|
([theme, prefix]) => `
|
||||||
|
${prefix} [data-chart=${id}] {
|
||||||
|
${colorConfig
|
||||||
|
.map(([key, itemConfig]) => {
|
||||||
|
const themeKey = theme === 'light' || theme === 'dark' ? theme : undefined;
|
||||||
|
const color = (itemConfig.theme && themeKey ? itemConfig.theme[themeKey] : undefined) || itemConfig.color;
|
||||||
|
return color ? ` --color-${key}: ${color};` : null;
|
||||||
|
})
|
||||||
|
.join('\n')}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join('\n'),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||||
|
|
||||||
|
function cssVarsToStyle(vars: Record<string, string | undefined>): React.CSSProperties {
|
||||||
|
const style: Record<string, string | undefined> = {};
|
||||||
|
for (const [key, val] of Object.entries(vars)) {
|
||||||
|
style[key] = val;
|
||||||
|
}
|
||||||
|
return style;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dataKeyToString(dataKey: unknown): string {
|
||||||
|
if (typeof dataKey === 'string' || typeof dataKey === 'number') {
|
||||||
|
return String(dataKey);
|
||||||
|
}
|
||||||
|
return 'value';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPayloadFill(item: TooltipPayloadEntry<ValueType, NameType>): string | undefined {
|
||||||
|
const p: unknown = item.payload;
|
||||||
|
if (typeof p === 'object' && p !== null && 'fill' in p) {
|
||||||
|
const fill = (p as { fill: unknown }).fill;
|
||||||
|
return typeof fill === 'string' ? fill : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartTooltipContent({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
className,
|
||||||
|
indicator = 'dot',
|
||||||
|
hideLabel = false,
|
||||||
|
hideIndicator = false,
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
labelClassName,
|
||||||
|
formatter,
|
||||||
|
color,
|
||||||
|
nameKey,
|
||||||
|
labelKey,
|
||||||
|
}: {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: ReadonlyArray<TooltipPayloadEntry<ValueType, NameType>>;
|
||||||
|
label?: string | number;
|
||||||
|
labelFormatter?: (
|
||||||
|
label: React.ReactNode,
|
||||||
|
payload: ReadonlyArray<TooltipPayloadEntry<ValueType, NameType>>,
|
||||||
|
) => React.ReactNode;
|
||||||
|
formatter?: (
|
||||||
|
value: ValueType,
|
||||||
|
name: NameType,
|
||||||
|
item: TooltipPayloadEntry<ValueType, NameType>,
|
||||||
|
index: number,
|
||||||
|
payload: ReadonlyArray<TooltipPayloadEntry<ValueType, NameType>>,
|
||||||
|
) => React.ReactNode;
|
||||||
|
} & React.ComponentProps<'div'> & {
|
||||||
|
hideLabel?: boolean;
|
||||||
|
hideIndicator?: boolean;
|
||||||
|
indicator?: 'line' | 'dot' | 'dashed';
|
||||||
|
nameKey?: string;
|
||||||
|
labelKey?: string;
|
||||||
|
labelClassName?: string;
|
||||||
|
color?: string;
|
||||||
|
}) {
|
||||||
|
const { config } = useChart();
|
||||||
|
|
||||||
|
const tooltipLabel = React.useMemo(() => {
|
||||||
|
if (hideLabel || !payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [item] = payload;
|
||||||
|
const key = labelKey || dataKeyToString(item?.dataKey) || String(item?.name) || 'value';
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
const value = !labelKey && typeof label === 'string' ? config[label]?.label || label : itemConfig?.label;
|
||||||
|
|
||||||
|
if (labelFormatter) {
|
||||||
|
return <div className={cn('font-medium', labelClassName)}>{labelFormatter(value, payload)}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={cn('font-medium', labelClassName)}>{value}</div>;
|
||||||
|
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
|
||||||
|
|
||||||
|
if (!active || !payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestLabel = payload.length === 1 && indicator !== 'dot';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl',
|
||||||
|
className,
|
||||||
|
)}>
|
||||||
|
{!nestLabel ? tooltipLabel : null}
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{payload
|
||||||
|
.filter(item => item.type !== 'none')
|
||||||
|
.map((item, index) => {
|
||||||
|
const key = nameKey || String(item.name) || dataKeyToString(item.dataKey) || 'value';
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
const indicatorColor = color || getPayloadFill(item) || item.color;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={dataKeyToString(item.dataKey)}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground',
|
||||||
|
indicator === 'dot' && 'items-center',
|
||||||
|
)}>
|
||||||
|
{formatter && item?.value !== undefined && item.name ? (
|
||||||
|
formatter(item.value, item.name, item, index, payload)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{itemConfig?.icon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
!hideIndicator && (
|
||||||
|
<div
|
||||||
|
className={cn('shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)', {
|
||||||
|
'h-2.5 w-2.5': indicator === 'dot',
|
||||||
|
'w-1': indicator === 'line',
|
||||||
|
'w-0 border-[1.5px] border-dashed bg-transparent': indicator === 'dashed',
|
||||||
|
'my-0.5': nestLabel && indicator === 'dashed',
|
||||||
|
})}
|
||||||
|
style={cssVarsToStyle({
|
||||||
|
'--color-bg': indicatorColor,
|
||||||
|
'--color-border': indicatorColor,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-1 justify-between leading-none',
|
||||||
|
nestLabel ? 'items-end' : 'items-center',
|
||||||
|
)}>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{nestLabel ? tooltipLabel : null}
|
||||||
|
<span className="text-muted-foreground">{itemConfig?.label || item.name}</span>
|
||||||
|
</div>
|
||||||
|
{item.value && (
|
||||||
|
<span className="font-mono font-medium text-foreground tabular-nums">
|
||||||
|
{item.value.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartLegend = RechartsPrimitive.Legend;
|
||||||
|
|
||||||
|
function ChartLegendContent({
|
||||||
|
className,
|
||||||
|
hideIcon = false,
|
||||||
|
payload,
|
||||||
|
verticalAlign = 'bottom',
|
||||||
|
nameKey,
|
||||||
|
}: React.ComponentProps<'div'> & {
|
||||||
|
hideIcon?: boolean;
|
||||||
|
nameKey?: string;
|
||||||
|
payload?: ReadonlyArray<LegendPayload>;
|
||||||
|
verticalAlign?: VerticalAlignmentType;
|
||||||
|
}) {
|
||||||
|
const { config } = useChart();
|
||||||
|
|
||||||
|
if (!payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center justify-center gap-4', verticalAlign === 'top' ? 'pb-3' : 'pt-3', className)}>
|
||||||
|
{payload
|
||||||
|
.filter(item => item.type !== 'none')
|
||||||
|
.map(item => {
|
||||||
|
const key = nameKey || dataKeyToString(item.dataKey) || 'value';
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.value}
|
||||||
|
className={cn('flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground')}>
|
||||||
|
{itemConfig?.icon && !hideIcon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: item.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{itemConfig?.label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecordValue(obj: unknown, key: string): unknown {
|
||||||
|
if (typeof obj === 'object' && obj !== null && key in obj) {
|
||||||
|
return Object.getOwnPropertyDescriptor(obj, key)?.value;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to extract item config from a payload.
|
||||||
|
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
|
||||||
|
if (typeof payload !== 'object' || payload === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadPayload =
|
||||||
|
'payload' in payload && typeof payload.payload === 'object' && payload.payload !== null
|
||||||
|
? payload.payload
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
let configLabelKey: string = key;
|
||||||
|
|
||||||
|
const payloadValue = getRecordValue(payload, key);
|
||||||
|
if (typeof payloadValue === 'string') {
|
||||||
|
configLabelKey = payloadValue;
|
||||||
|
} else {
|
||||||
|
const nestedValue = getRecordValue(payloadPayload, key);
|
||||||
|
if (typeof nestedValue === 'string') {
|
||||||
|
configLabelKey = nestedValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return configLabelKey in config ? config[configLabelKey] : config[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ChartContainer, ChartLegend, ChartLegendContent, ChartStyle, ChartTooltip, ChartTooltipContent };
|
||||||
156
src/components/ui/select.tsx
Normal file
156
src/components/ui/select.tsx
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
|
||||||
|
import { Select as SelectPrimitive } from 'radix-ui';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils.js';
|
||||||
|
|
||||||
|
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = 'default',
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
|
size?: 'sm' | 'default';
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = 'item-aligned',
|
||||||
|
align = 'center',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
className={cn(
|
||||||
|
'relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||||
|
position === 'popper' &&
|
||||||
|
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
align={align}
|
||||||
|
{...props}>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
'p-1',
|
||||||
|
position === 'popper' &&
|
||||||
|
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1',
|
||||||
|
)}>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn('px-2 py-1.5 text-xs text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}>
|
||||||
|
<span data-slot="select-item-indicator" className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn('pointer-events-none -mx-1 my-1 h-px bg-border', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||||
|
{...props}>
|
||||||
|
<ChevronUpIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||||
|
{...props}>
|
||||||
|
<ChevronDownIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user