phase 8: UI/UX overhaul — layout, charts, map, data freshness

- Fix ticker tape CLS with skeleton loader and fixed height
- Add Inter font, max-width container, responsive dvh units
- Hero metrics: trend deltas, per-metric sparkline colors, 3+2 grid
- GPU calculator: step=100 slider + text input, PUE factor, region comparison bars
- Grid stress: replace misleading arc gauges with demand status bars
- Demand summary: expand to 4-metric highlights grid
- Charts: responsive heights, ISO/non-ISO toggle, correlation R² + trend line
- Map: US-wide default view, marker clustering, enriched region panels, zoom controls
- Fix NYISO polygon (NYC), MISO polygon (Michigan), MISO south (MS/LA)
- Add automated ingestion via instrumentation.ts
- Add data freshness indicator in footer
- Fix backfill start date to 2019-01-01 (EIA RTO data availability)
This commit is contained in:
Joey Eamigh 2026-02-11 19:59:01 -05:00
parent 9e83cfead3
commit ad1a6792f5
No known key found for this signature in database
GPG Key ID: CE8C05DFFC53C9CB
25 changed files with 1441 additions and 327 deletions

View File

@ -50,7 +50,6 @@
[ [
[-74.72, 40.15], [-74.72, 40.15],
[-74.01, 40.07], [-74.01, 40.07],
[-73.89, 40.57],
[-74.25, 40.53], [-74.25, 40.53],
[-75.14, 40.68], [-75.14, 40.68],
[-75.12, 41.85], [-75.12, 41.85],
@ -177,14 +176,14 @@
[-73.34, 45.01], [-73.34, 45.01],
[-73.34, 42.05], [-73.34, 42.05],
[-73.73, 41.10], [-73.73, 41.10],
[-74.25, 40.53],
[-73.89, 40.57],
[-74.01, 40.07],
[-73.74, 40.63],
[-72.76, 40.75],
[-71.85, 40.98], [-71.85, 40.98],
[-73.73, 41.10], [-72.76, 40.75],
[-73.34, 42.05], [-73.74, 40.63],
[-74.01, 40.07],
[-74.25, 40.53],
[-75.14, 40.68],
[-75.12, 41.85],
[-76.11, 42.00],
[-79.76, 42.27] [-79.76, 42.27]
] ]
] ]
@ -244,11 +243,16 @@
[-95.15, 48.00], [-95.15, 48.00],
[-89.49, 48.01], [-89.49, 48.01],
[-84.72, 46.63], [-84.72, 46.63],
[-83.59, 46.03], [-84.10, 46.55],
[-84.11, 45.18], [-83.40, 46.03],
[-85.61, 44.77], [-83.80, 45.65],
[-86.46, 43.89], [-83.40, 45.05],
[-86.27, 42.40], [-83.30, 44.32],
[-82.80, 43.60],
[-82.42, 43.00],
[-82.48, 42.33],
[-83.50, 41.73],
[-84.82, 41.76],
[-86.80, 41.76], [-86.80, 41.76],
[-87.53, 41.76], [-87.53, 41.76],
[-87.53, 38.23], [-87.53, 38.23],
@ -274,17 +278,20 @@
[ [
[-89.10, 36.95], [-89.10, 36.95],
[-89.70, 36.25], [-89.70, 36.25],
[-89.67, 34.96], [-88.20, 35.00],
[-90.31, 34.73], [-88.20, 34.50],
[-90.58, 34.14], [-88.35, 33.29],
[-91.15, 33.01], [-88.47, 31.90],
[-91.17, 31.55], [-88.40, 30.23],
[-91.65, 31.00], [-89.10, 30.10],
[-93.53, 31.18], [-89.60, 29.90],
[-93.72, 31.08], [-90.10, 29.60],
[-91.00, 29.30],
[-91.80, 29.50],
[-93.20, 29.60],
[-93.84, 29.71], [-93.84, 29.71],
[-93.84, 30.25], [-93.72, 31.08],
[-94.04, 31.00], [-93.53, 31.18],
[-94.04, 33.55], [-94.04, 33.55],
[-94.48, 33.64], [-94.48, 33.64],
[-94.43, 35.39], [-94.43, 35.39],
@ -531,8 +538,9 @@
[-84.86, 30.70], [-84.86, 30.70],
[-87.60, 30.25], [-87.60, 30.25],
[-88.40, 30.23], [-88.40, 30.23],
[-89.67, 34.96], [-88.47, 31.90],
[-89.70, 36.25], [-88.35, 33.29],
[-88.20, 34.50],
[-88.20, 35.00] [-88.20, 35.00]
] ]
] ]

View File

@ -1,7 +1,7 @@
/** /**
* Historical data backfill script (10-year). * Historical data backfill script (10-year).
* *
* Populates ~10 years of historical data (from 2015-07-01) from EIA and FRED * Populates historical data (from 2019-01-01) from EIA and FRED
* into Postgres. Uses time-chunked requests to stay under EIA's 5,000-row * into Postgres. Uses time-chunked requests to stay under EIA's 5,000-row
* pagination limit, with concurrent region fetching and resumability. * pagination limit, with concurrent region fetching and resumability.
* *
@ -29,8 +29,8 @@ const prisma = new PrismaClient({ adapter });
// Configuration // Configuration
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** EIA RTO hourly data begins around 2015-07 for most ISOs */ /** EIA RTO hourly data begins 2019-01-01 for most ISOs */
const BACKFILL_START = '2015-07-01'; const BACKFILL_START = '2019-01-01';
const ALL_REGIONS: RegionCode[] = [ const ALL_REGIONS: RegionCode[] = [
'PJM', 'PJM',

49
src/actions/freshness.ts Normal file
View File

@ -0,0 +1,49 @@
'use server';
import { prisma } from '@/lib/db.js';
import { serialize } from '@/lib/superjson.js';
import { cacheLife, cacheTag } from 'next/cache';
interface DataFreshness {
electricity: Date | null;
generation: Date | null;
commodities: Date | null;
}
type FreshnessResult = { ok: true; data: ReturnType<typeof serialize<DataFreshness>> } | { ok: false; error: string };
export async function fetchDataFreshness(): Promise<FreshnessResult> {
'use cache';
cacheLife('ticker');
cacheTag('data-freshness');
try {
const [electricityResult, generationResult, commoditiesResult] = await Promise.all([
prisma.electricityPrice.findFirst({
orderBy: { timestamp: 'desc' },
select: { timestamp: true },
}),
prisma.generationMix.findFirst({
orderBy: { timestamp: 'desc' },
select: { timestamp: true },
}),
prisma.commodityPrice.findFirst({
orderBy: { timestamp: 'desc' },
select: { timestamp: true },
}),
]);
const freshness: DataFreshness = {
electricity: electricityResult?.timestamp ?? null,
generation: generationResult?.timestamp ?? null,
commodities: commoditiesResult?.timestamp ?? null,
};
return { ok: true, data: serialize(freshness) };
} catch (err) {
return {
ok: false,
error: `Failed to fetch data freshness: ${err instanceof Error ? err.message : String(err)}`,
};
}
}

View File

@ -4,35 +4,115 @@ import { Activity } from 'lucide-react';
import { fetchRegionDemandSummary } from '@/actions/demand.js'; import { fetchRegionDemandSummary } from '@/actions/demand.js';
import { deserialize } from '@/lib/superjson.js'; import { deserialize } from '@/lib/superjson.js';
function formatNumber(value: number, decimals = 1): string { interface RegionSummary {
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(decimals)}M`; regionCode: string;
if (value >= 1_000) return `${(value / 1_000).toFixed(decimals)}K`; latestDemandMw: number;
return value.toFixed(decimals); peakDemandMw: number;
avgDemandMw: number;
}
function formatGw(mw: number): string {
if (mw >= 1000) return `${(mw / 1000).toFixed(1)}`;
return `${Math.round(mw)}`;
}
function formatGwUnit(mw: number): string {
return mw >= 1000 ? 'GW' : 'MW';
} }
export async function DemandSummary() { export async function DemandSummary() {
const demandResult = await fetchRegionDemandSummary(); const demandResult = await fetchRegionDemandSummary();
const demandRows = demandResult.ok ? deserialize<Array<{ avg_demand: number | null }>>(demandResult.data) : []; const demandRows = demandResult.ok
? deserialize<
Array<{
avg_demand: number | null;
peak_demand: number | null;
region_code: string;
region_name: string;
day: Date;
}>
>(demandResult.data)
: [];
const avgDemand = // Aggregate per region: latest demand, peak demand, avg demand
demandRows.length > 0 ? demandRows.reduce((sum, r) => sum + (r.avg_demand ?? 0), 0) / demandRows.length : 0; const regionMap = new Map<string, RegionSummary>();
for (const row of demandRows) {
const demand = row.avg_demand ?? 0;
const peak = row.peak_demand ?? 0;
const existing = regionMap.get(row.region_code);
if (avgDemand <= 0) return null; if (!existing) {
regionMap.set(row.region_code, {
regionCode: row.region_code,
latestDemandMw: demand,
peakDemandMw: peak,
avgDemandMw: demand,
});
} else {
// Query is ordered by day ASC, so later rows are more recent
existing.latestDemandMw = demand;
if (peak > existing.peakDemandMw) existing.peakDemandMw = peak;
// Running average approximation
existing.avgDemandMw = (existing.avgDemandMw + demand) / 2;
}
}
const regions = [...regionMap.values()].filter(r => r.peakDemandMw > 0);
if (regions.length === 0) return null;
const totalDemandMw = regions.reduce((sum, r) => sum + r.latestDemandMw, 0);
const peakRegion = regions.reduce((max, r) => (r.latestDemandMw > max.latestDemandMw ? r : max), regions[0]!);
const regionCount = regions.length;
const totalAvg = regions.reduce((sum, r) => sum + r.avgDemandMw, 0);
const totalPeak = regions.reduce((sum, r) => sum + r.peakDemandMw, 0);
const loadFactor = totalPeak > 0 ? (totalAvg / totalPeak) * 100 : 0;
const highlights = [
{
label: 'Total US Demand',
value: formatGw(totalDemandMw),
unit: formatGwUnit(totalDemandMw),
},
{
label: 'Peak Region',
value: peakRegion.regionCode,
unit: `${formatGw(peakRegion.latestDemandMw)} ${formatGwUnit(peakRegion.latestDemandMw)}`,
},
{
label: 'Regions Tracked',
value: regionCount.toString(),
unit: 'ISOs',
},
{
label: 'Avg Load Factor',
value: `${loadFactor.toFixed(0)}`,
unit: '%',
},
];
return ( return (
<Card> <Card>
<CardHeader> <CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Activity className="h-5 w-5 text-chart-3" /> <Activity className="h-5 w-5 text-chart-3" />
Demand Summary (7-day avg) Demand Highlights
</CardTitle> </CardTitle>
<p className="text-xs text-muted-foreground">7-day summary across all grid regions</p>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-sm text-muted-foreground"> <div className="grid grid-cols-2 gap-4">
Average regional demand:{' '} {highlights.map(h => (
<span className="font-mono font-semibold text-foreground">{formatNumber(avgDemand)} MW</span> <div key={h.label} className="flex flex-col gap-0.5">
</p> <span className="text-[11px] font-medium tracking-wide text-muted-foreground">{h.label}</span>
<div className="flex items-baseline gap-1">
<span className="font-mono text-lg font-bold tabular-nums">{h.value}</span>
<span className="text-xs text-muted-foreground">{h.unit}</span>
</div>
</div>
))}
</div>
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@ -1,8 +1,15 @@
import { connection } from 'next/server';
import { MetricCard } from '@/components/dashboard/metric-card.js'; import { MetricCard } from '@/components/dashboard/metric-card.js';
import { Activity, BarChart3, Droplets, Flame, Server } from 'lucide-react'; import { Activity, BarChart3, Droplets, Flame, Server } from 'lucide-react';
import { fetchDatacenters } from '@/actions/datacenters.js'; import { fetchDatacenters } from '@/actions/datacenters.js';
import { fetchLatestCommodityPrices, fetchLatestPrices, fetchPriceSparklines } from '@/actions/prices.js'; import {
fetchLatestCommodityPrices,
fetchLatestPrices,
fetchPriceSparklines,
fetchTickerPrices,
} from '@/actions/prices.js';
import { deserialize } from '@/lib/superjson.js'; import { deserialize } from '@/lib/superjson.js';
function formatNumber(value: number, decimals = 1): string { function formatNumber(value: number, decimals = 1): string {
@ -11,12 +18,27 @@ function formatNumber(value: number, decimals = 1): string {
return value.toFixed(decimals); return value.toFixed(decimals);
} }
function computeChangePercent(current: number, previous: number | null): number | null {
if (previous === null || previous === 0) return null;
return ((current - previous) / previous) * 100;
}
/** Per-metric sparkline colors that distinguish each metric visually. */
const SPARKLINE_COLORS = {
electricity: 'hsl(210, 90%, 55%)', // Blue
natGas: 'hsl(30, 90%, 55%)', // Orange/amber
wtiCrude: 'hsl(160, 70%, 45%)', // Teal
} as const;
export async function HeroMetrics() { export async function HeroMetrics() {
const [pricesResult, commoditiesResult, datacentersResult, sparklinesResult] = await Promise.all([ await connection();
const [pricesResult, commoditiesResult, datacentersResult, sparklinesResult, tickerResult] = await Promise.all([
fetchLatestPrices(), fetchLatestPrices(),
fetchLatestCommodityPrices(), fetchLatestCommodityPrices(),
fetchDatacenters(), fetchDatacenters(),
fetchPriceSparklines(), fetchPriceSparklines(),
fetchTickerPrices(),
]); ]);
const prices = pricesResult.ok const prices = pricesResult.ok
@ -36,13 +58,21 @@ export async function HeroMetrics() {
: []; : [];
const datacenters = datacentersResult.ok const datacenters = datacentersResult.ok
? deserialize<Array<{ id: string; capacityMw: number }>>(datacentersResult.data) ? deserialize<Array<{ id: string; capacityMw: number; status: string; yearOpened: number }>>(datacentersResult.data)
: []; : [];
const sparklines = sparklinesResult.ok const sparklines = sparklinesResult.ok
? deserialize<Array<{ region_code: string; points: { value: number }[] }>>(sparklinesResult.data) ? deserialize<Array<{ region_code: string; points: { value: number }[] }>>(sparklinesResult.data)
: []; : [];
// Extract previous-price data from ticker (already has LAG-based prev values)
const ticker = tickerResult.ok
? deserialize<{
electricity: Array<{ region_code: string; price_mwh: number; prev_price_mwh: number | null }>;
commodities: Array<{ commodity: string; price: number; prev_price: number | null; unit: string }>;
}>(tickerResult.data)
: { electricity: [], commodities: [] };
const avgSparkline: { value: number }[] = const avgSparkline: { value: number }[] =
sparklines.length > 0 && sparklines[0] sparklines.length > 0 && sparklines[0]
? sparklines[0].points.map((_, i) => { ? sparklines[0].points.map((_, i) => {
@ -51,14 +81,41 @@ export async function HeroMetrics() {
}) })
: []; : [];
// Build per-commodity sparklines from the price sparklines data
const natGasSparkline =
sparklines.length > 0 && sparklines[0]
? sparklines[0].points.map((_, i) => {
const values = sparklines.map(s => s.points[i]?.value ?? 0).filter(v => v > 0);
return { value: values.length > 0 ? Math.min(...values) : 0 };
})
: [];
const avgPrice = prices.length > 0 ? prices.reduce((sum, p) => sum + p.price_mwh, 0) / prices.length : 0; const avgPrice = prices.length > 0 ? prices.reduce((sum, p) => sum + p.price_mwh, 0) / prices.length : 0;
const natGas = commodities.find(c => c.commodity === 'natural_gas'); const natGas = commodities.find(c => c.commodity === 'natural_gas');
const wtiCrude = commodities.find(c => c.commodity === 'wti_crude'); const wtiCrude = commodities.find(c => c.commodity === 'wti_crude');
const totalCapacityMw = datacenters.reduce((sum, dc) => sum + dc.capacityMw, 0); const totalCapacityMw = datacenters.reduce((sum, dc) => sum + dc.capacityMw, 0);
const datacenterCount = datacenters.length; const datacenterCount = datacenters.length;
// Compute trend deltas from ticker's previous prices
const avgPrevPrice =
ticker.electricity.length > 0
? ticker.electricity.reduce((sum, e) => sum + (e.prev_price_mwh ?? e.price_mwh), 0) / ticker.electricity.length
: null;
const avgPriceDelta = avgPrevPrice !== null && avgPrevPrice > 0 ? computeChangePercent(avgPrice, avgPrevPrice) : null;
const tickerNatGas = ticker.commodities.find(c => c.commodity === 'natural_gas');
const natGasDelta = natGas && tickerNatGas ? computeChangePercent(natGas.price, tickerNatGas.prev_price) : null;
const tickerWti = ticker.commodities.find(c => c.commodity === 'wti_crude');
const wtiDelta = wtiCrude && tickerWti ? computeChangePercent(wtiCrude.price, tickerWti.prev_price) : null;
// Datacenter context: count those opened this year or marked as planned
const currentYear = new Date().getFullYear();
const recentCount = datacenters.filter(dc => dc.yearOpened >= currentYear - 1).length;
const dcSubtitle = recentCount > 0 ? `${recentCount} opened since ${currentYear - 1}` : undefined;
return ( return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
<MetricCard <MetricCard
title="Avg Electricity Price" title="Avg Electricity Price"
value={avgPrice > 0 ? `$${avgPrice.toFixed(2)}` : '--'} value={avgPrice > 0 ? `$${avgPrice.toFixed(2)}` : '--'}
@ -67,7 +124,9 @@ export async function HeroMetrics() {
unit="/MWh" unit="/MWh"
icon={<BarChart3 className="h-4 w-4" />} icon={<BarChart3 className="h-4 w-4" />}
sparklineData={avgSparkline} sparklineData={avgSparkline}
sparklineColor="hsl(210, 90%, 55%)" sparklineColor={SPARKLINE_COLORS.electricity}
trendDelta={avgPriceDelta}
trendLabel="vs prev"
/> />
<MetricCard <MetricCard
title="Total DC Capacity" title="Total DC Capacity"
@ -80,7 +139,10 @@ export async function HeroMetrics() {
<MetricCard <MetricCard
title="Datacenters Tracked" title="Datacenters Tracked"
value={datacenterCount.toLocaleString()} value={datacenterCount.toLocaleString()}
numericValue={datacenterCount > 0 ? datacenterCount : undefined}
animatedFormat={datacenterCount > 0 ? 'integer' : undefined}
icon={<Server className="h-4 w-4" />} icon={<Server className="h-4 w-4" />}
subtitle={dcSubtitle}
/> />
<MetricCard <MetricCard
title="Natural Gas Spot" title="Natural Gas Spot"
@ -89,6 +151,10 @@ export async function HeroMetrics() {
animatedFormat={natGas ? 'dollar' : undefined} animatedFormat={natGas ? 'dollar' : undefined}
unit={natGas?.unit ?? '/MMBtu'} unit={natGas?.unit ?? '/MMBtu'}
icon={<Flame className="h-4 w-4" />} icon={<Flame className="h-4 w-4" />}
sparklineData={natGasSparkline.length >= 2 ? natGasSparkline : undefined}
sparklineColor={SPARKLINE_COLORS.natGas}
trendDelta={natGasDelta}
trendLabel="vs prev"
/> />
<MetricCard <MetricCard
title="WTI Crude Oil" title="WTI Crude Oil"
@ -97,6 +163,9 @@ export async function HeroMetrics() {
animatedFormat={wtiCrude ? 'dollar' : undefined} animatedFormat={wtiCrude ? 'dollar' : undefined}
unit={wtiCrude?.unit ?? '/bbl'} unit={wtiCrude?.unit ?? '/bbl'}
icon={<Droplets className="h-4 w-4" />} icon={<Droplets className="h-4 w-4" />}
sparklineColor={SPARKLINE_COLORS.wtiCrude}
trendDelta={wtiDelta}
trendLabel="vs prev"
/> />
</div> </div>
); );

View File

@ -1,15 +1,15 @@
import { GridStressGauge } from '@/components/dashboard/grid-stress-gauge.js'; import { GridStressGauge } from '@/components/dashboard/grid-stress-gauge.js';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card.js'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card.js';
import { Gauge } from 'lucide-react'; import { Radio } from 'lucide-react';
import { fetchRegionDemandSummary } from '@/actions/demand.js'; import { fetchRegionDemandSummary } from '@/actions/demand.js';
import { deserialize } from '@/lib/superjson.js'; import { deserialize } from '@/lib/superjson.js';
interface RegionDemandEntry { interface RegionStatus {
regionCode: string; regionCode: string;
regionName: string; regionName: string;
avgDemand: number; latestDemandMw: number;
peakDemand: number; peakDemandMw: number;
} }
export async function StressGauges() { export async function StressGauges() {
@ -22,53 +22,78 @@ export async function StressGauges() {
peak_demand: number | null; peak_demand: number | null;
region_code: string; region_code: string;
region_name: string; region_name: string;
day: Date;
}> }>
>(demandResult.data) >(demandResult.data)
: []; : [];
const regionDemandMap: Record<string, RegionDemandEntry> = {}; // For each region: use the most recent day's avg_demand as "current",
// and the 7-day peak_demand as the ceiling.
const regionMap = new Map<string, RegionStatus>();
for (const row of demandRows) { for (const row of demandRows) {
const existing = regionDemandMap[row.region_code]; const existing = regionMap.get(row.region_code);
const avg = row.avg_demand ?? 0; const demand = row.avg_demand ?? 0;
const peak = row.peak_demand ?? 0; const peak = row.peak_demand ?? 0;
if (!existing) { if (!existing) {
regionDemandMap[row.region_code] = { regionMap.set(row.region_code, {
regionCode: row.region_code, regionCode: row.region_code,
regionName: row.region_name, regionName: row.region_name,
avgDemand: avg, latestDemandMw: demand,
peakDemand: peak, peakDemandMw: peak,
}; });
} else { } else {
if (avg > existing.avgDemand) existing.avgDemand = avg; // The query is ordered by day ASC, so later rows overwrite latestDemand
if (peak > existing.peakDemand) existing.peakDemand = peak; existing.latestDemandMw = demand;
if (peak > existing.peakDemandMw) existing.peakDemandMw = peak;
} }
} }
const regionDemandList = Object.values(regionDemandMap).filter(
(r): r is RegionDemandEntry => r !== undefined && r.peakDemand > 0,
);
if (regionDemandList.length === 0) return null; const regions = [...regionMap.values()]
.filter(r => r.peakDemandMw > 0)
.sort((a, b) => b.latestDemandMw - a.latestDemandMw);
if (regions.length === 0) return null;
// Split into two columns for wider screens
const midpoint = Math.ceil(regions.length / 2);
const leftColumn = regions.slice(0, midpoint);
const rightColumn = regions.slice(midpoint);
return ( return (
<div className="mt-8"> <div className="mt-8">
<Card> <Card>
<CardHeader> <CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Gauge className="h-5 w-5 text-chart-4" /> <Radio className="h-5 w-5 text-chart-4" />
Grid Stress by Region Region Grid Status
</CardTitle> </CardTitle>
<p className="text-xs text-muted-foreground">Current demand vs. 7-day peak by region</p>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-7"> <div className="grid gap-x-8 gap-y-2.5 md:grid-cols-2">
{regionDemandList.map(r => ( <div className="flex flex-col gap-2.5">
<GridStressGauge {leftColumn.map(r => (
key={r.regionCode} <GridStressGauge
regionCode={r.regionCode} key={r.regionCode}
regionName={r.regionName} regionCode={r.regionCode}
demandMw={r.avgDemand} regionName={r.regionName}
capacityMw={r.peakDemand} demandMw={r.latestDemandMw}
/> peakDemandMw={r.peakDemandMw}
))} />
))}
</div>
<div className="flex flex-col gap-2.5">
{rightColumn.map(r => (
<GridStressGauge
key={r.regionCode}
regionCode={r.regionCode}
regionName={r.regionName}
demandMw={r.latestDemandMw}
peakDemandMw={r.peakDemandMw}
/>
))}
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -75,6 +75,8 @@
} }
@theme inline { @theme inline {
--font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-card: var(--card); --color-card: var(--card);
@ -119,6 +121,13 @@
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
/* Layout height tokens for full-bleed pages (map, etc.) */
/* Ticker: py-1.5 (0.75rem) + text-sm (~1.25rem) + border-b (1px) = ~2rem + 1px */
/* Nav: h-14 (3.5rem) + header border-b (1px) */
/* Footer: py-4 (2rem) + text-xs (~1rem) + border-t (1px) */
--header-h: calc(2rem + 3.5rem + 2px);
--footer-h: calc(3rem + 1px);
} }
} }

View File

@ -1,5 +1,6 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { ThemeProvider } from 'next-themes'; import { ThemeProvider } from 'next-themes';
import { Inter } from 'next/font/google';
import { Toaster } from 'sonner'; import { Toaster } from 'sonner';
import { Footer } from '@/components/layout/footer.js'; import { Footer } from '@/components/layout/footer.js';
@ -7,6 +8,12 @@ import { Nav } from '@/components/layout/nav.js';
import './globals.css'; import './globals.css';
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Energy & AI Dashboard', title: 'Energy & AI Dashboard',
description: description:
@ -15,11 +22,11 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" className={inter.variable} suppressHydrationWarning>
<body className="flex min-h-screen flex-col antialiased"> <body className="flex min-h-dvh flex-col font-sans antialiased">
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false} disableTransitionOnChange> <ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false} disableTransitionOnChange>
<Nav /> <Nav />
<main className="flex-1">{children}</main> <main className="mx-auto w-full max-w-[1920px] flex-1">{children}</main>
<Footer /> <Footer />
<Toaster theme="dark" richColors position="bottom-right" /> <Toaster theme="dark" richColors position="bottom-right" />
</ThemeProvider> </ThemeProvider>

View File

@ -16,7 +16,7 @@ function MapSkeleton() {
export default function MapPage() { export default function MapPage() {
return ( return (
<div className="h-[calc(100vh-3.5rem-3rem)]"> <div className="h-[calc(100dvh-var(--header-h)-var(--footer-h))]">
<Suspense fallback={<MapSkeleton />}> <Suspense fallback={<MapSkeleton />}>
<MapContent /> <MapContent />
</Suspense> </Suspense>

View File

@ -16,7 +16,7 @@ import { StressGauges } from './_sections/stress-gauges.js';
function MetricCardsSkeleton() { function MetricCardsSkeleton() {
return ( return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
{Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: 5 }).map((_, i) => (
<MetricCardSkeleton key={i} /> <MetricCardSkeleton key={i} />
))} ))}
@ -69,35 +69,51 @@ function AlertsSkeleton() {
function GaugesSkeleton() { function GaugesSkeleton() {
return ( return (
<Card> <div className="mt-8">
<CardHeader> <Card>
<Skeleton className="h-5 w-44" /> <CardHeader className="pb-3">
</CardHeader> <Skeleton className="h-5 w-44" />
<CardContent> <Skeleton className="mt-1 h-3 w-64" />
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-7"> </CardHeader>
{Array.from({ length: 7 }).map((_, i) => ( <CardContent>
<div key={i} className="flex flex-col items-center gap-2"> <div className="grid gap-x-8 gap-y-2.5 md:grid-cols-2">
<Skeleton className="h-20 w-20 rounded-full" /> {Array.from({ length: 2 }).map((_, col) => (
<Skeleton className="h-3 w-12" /> <div key={col} className="flex flex-col gap-2.5">
</div> {Array.from({ length: 5 }).map((_, i) => (
))} <div key={i} className="flex items-center gap-3">
</div> <Skeleton className="h-4 w-14" />
</CardContent> <Skeleton className="h-2 flex-1 rounded-full" />
</Card> <Skeleton className="h-4 w-20" />
</div>
))}
</div>
))}
</div>
</CardContent>
</Card>
</div>
); );
} }
function DemandSummarySkeleton() { function DemandSummarySkeleton() {
return ( return (
<Card> <Card>
<CardHeader> <CardHeader className="pb-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Activity className="h-5 w-5 text-chart-3" /> <Activity className="h-5 w-5 text-chart-3" />
<Skeleton className="h-5 w-44" /> <Skeleton className="h-5 w-40" />
</div> </div>
<Skeleton className="mt-1 h-3 w-56" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Skeleton className="h-4 w-64" /> <div className="grid grid-cols-2 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex flex-col gap-1">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-6 w-16" />
</div>
))}
</div>
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { CartesianGrid, Label, Scatter, ScatterChart, XAxis, YAxis, ZAxis } from 'recharts'; import { CartesianGrid, ComposedChart, Label, Line, Scatter, XAxis, YAxis, ZAxis } from 'recharts';
import type { SuperJSONResult } from 'superjson'; import type { SuperJSONResult } from 'superjson';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.js'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.js';
@ -26,6 +26,13 @@ const REGION_COLORS: Record<string, string> = {
ISONE: 'hsl(340, 70%, 55%)', ISONE: 'hsl(340, 70%, 55%)',
MISO: 'hsl(55, 80%, 50%)', MISO: 'hsl(55, 80%, 50%)',
SPP: 'hsl(180, 60%, 45%)', SPP: 'hsl(180, 60%, 45%)',
BPA: 'hsl(95, 55%, 50%)',
NWMT: 'hsl(310, 50%, 55%)',
WAPA: 'hsl(165, 50%, 50%)',
TVA: 'hsl(15, 70%, 50%)',
DUKE: 'hsl(240, 55%, 60%)',
SOCO: 'hsl(350, 55%, 50%)',
FPC: 'hsl(45, 60%, 45%)',
}; };
const chartConfig: ChartConfig = { const chartConfig: ChartConfig = {
@ -40,6 +47,64 @@ interface ScatterPoint {
total_capacity_mw: number; total_capacity_mw: number;
avg_price: number; avg_price: number;
fill: string; fill: string;
/** Dot radius scales with capacity — used as ZAxis value */
z: number;
}
interface TrendLinePoint {
total_capacity_mw: number;
trendPrice: number;
}
interface RegressionResult {
slope: number;
intercept: number;
r2: number;
line: TrendLinePoint[];
}
/** Simple OLS linear regression: y = slope * x + intercept */
function linearRegression(points: ScatterPoint[]): RegressionResult | null {
const n = points.length;
if (n < 2) return null;
let sumX = 0;
let sumY = 0;
let sumXY = 0;
let sumX2 = 0;
for (const p of points) {
sumX += p.total_capacity_mw;
sumY += p.avg_price;
sumXY += p.total_capacity_mw * p.avg_price;
sumX2 += p.total_capacity_mw * p.total_capacity_mw;
}
const denom = n * sumX2 - sumX * sumX;
if (denom === 0) return null;
const slope = (n * sumXY - sumX * sumY) / denom;
const intercept = (sumY - slope * sumX) / n;
// Pearson R squared
const ssRes = points.reduce((s, p) => {
const predicted = slope * p.total_capacity_mw + intercept;
return s + (p.avg_price - predicted) ** 2;
}, 0);
const meanY = sumY / n;
const ssTot = points.reduce((s, p) => s + (p.avg_price - meanY) ** 2, 0);
const r2 = ssTot === 0 ? 0 : 1 - ssRes / ssTot;
// Build line points from min to max X
const xs = points.map(p => p.total_capacity_mw);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const line: TrendLinePoint[] = [
{ total_capacity_mw: minX, trendPrice: slope * minX + intercept },
{ total_capacity_mw: maxX, trendPrice: slope * maxX + intercept },
];
return { slope, intercept, r2, line };
} }
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
@ -61,6 +126,7 @@ function getScatterPayload(obj: Record<string, unknown>): ScatterPoint | null {
total_capacity_mw: getNumberProp(payload, 'total_capacity_mw'), total_capacity_mw: getNumberProp(payload, 'total_capacity_mw'),
avg_price: getNumberProp(payload, 'avg_price'), avg_price: getNumberProp(payload, 'avg_price'),
fill, fill,
z: getNumberProp(payload, 'z'),
}; };
} }
@ -71,10 +137,13 @@ function CustomDot(props: unknown): React.JSX.Element {
const payload = getScatterPayload(props); const payload = getScatterPayload(props);
if (!payload) return <g />; if (!payload) return <g />;
// Radius derived from ZAxis mapping — use z value to compute a proportional radius
const radius = Math.max(6, Math.min(20, payload.z / 80));
return ( return (
<g> <g>
<circle cx={cx} cy={cy} r={8} fill={payload.fill} fillOpacity={0.7} stroke={payload.fill} strokeWidth={2} /> <circle cx={cx} cy={cy} r={radius} fill={payload.fill} fillOpacity={0.6} stroke={payload.fill} strokeWidth={2} />
<text x={cx} y={cy - 14} textAnchor="middle" fill="hsl(var(--foreground))" fontSize={11} fontWeight={600}> <text x={cx} y={cy - radius - 6} textAnchor="middle" fill="hsl(var(--foreground))" fontSize={11} fontWeight={600}>
{payload.region_code} {payload.region_code}
</text> </text>
</g> </g>
@ -93,10 +162,32 @@ export function CorrelationChart({ data }: CorrelationChartProps) {
total_capacity_mw: Math.round(r.total_capacity_mw), total_capacity_mw: Math.round(r.total_capacity_mw),
avg_price: Number(r.avg_price.toFixed(2)), avg_price: Number(r.avg_price.toFixed(2)),
fill: REGION_COLORS[r.region_code] ?? 'hsl(0, 0%, 50%)', fill: REGION_COLORS[r.region_code] ?? 'hsl(0, 0%, 50%)',
z: Math.round(r.total_capacity_mw),
})), })),
[rows], [rows],
); );
const regression = useMemo(() => linearRegression(scatterData), [scatterData]);
// Merge scatter + trend line data for ComposedChart
const combinedData = useMemo(() => {
const scattered = scatterData.map(p => ({
...p,
trendPrice: undefined as number | undefined,
}));
if (!regression) return scattered;
// Add two trend-line-only points
const trendPoints = regression.line.map(t => ({
region_code: '',
avg_price: undefined as number | undefined,
total_capacity_mw: t.total_capacity_mw,
fill: '',
z: 0,
trendPrice: Number(t.trendPrice.toFixed(2)),
}));
return [...scattered, ...trendPoints].sort((a, b) => a.total_capacity_mw - b.total_capacity_mw);
}, [scatterData, regression]);
if (scatterData.length === 0) { if (scatterData.length === 0) {
return ( return (
<Card> <Card>
@ -118,18 +209,37 @@ export function CorrelationChart({ data }: CorrelationChartProps) {
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>DC Capacity vs. Electricity Price</CardTitle> <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<CardDescription>Datacenter capacity (MW) versus average electricity price per region</CardDescription> <div>
<CardTitle>DC Capacity vs. Electricity Price</CardTitle>
<CardDescription>Datacenter capacity (MW) versus average electricity price per region</CardDescription>
</div>
{regression && (
<div className="flex items-center gap-3 rounded-lg border border-border bg-muted/50 px-3 py-2">
<div className="text-xs text-muted-foreground">
<span className="font-medium text-foreground">R² = {regression.r2.toFixed(3)}</span>
<span className="ml-2">
{regression.r2 >= 0.7 ? 'Strong' : regression.r2 >= 0.4 ? 'Moderate' : 'Weak'} correlation
</span>
</div>
<div className="h-3 w-px bg-border" />
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="inline-block h-0.5 w-4 bg-foreground/40" />
Trend line
</div>
</div>
)}
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ChartContainer config={chartConfig} className="h-[350px] w-full"> <ChartContainer config={chartConfig} className="h-[40vh] max-h-[500px] min-h-[300px] w-full">
<ScatterChart margin={{ top: 20, right: 20, bottom: 20, left: 20 }}> <ComposedChart data={combinedData} margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border/30" /> <CartesianGrid strokeDasharray="3 3" className="stroke-border/30" />
<XAxis <XAxis
type="number" type="number"
dataKey="total_capacity_mw" dataKey="total_capacity_mw"
name="DC Capacity" name="DC Capacity"
tick={{ fontSize: 11, fill: '#a1a1aa' }} tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
tickFormatter={(v: number) => `${v} MW`}> tickFormatter={(v: number) => `${v} MW`}>
@ -137,25 +247,26 @@ export function CorrelationChart({ data }: CorrelationChartProps) {
value="Total DC Capacity (MW)" value="Total DC Capacity (MW)"
offset={-10} offset={-10}
position="insideBottom" position="insideBottom"
style={{ fontSize: 11, fill: '#a1a1aa' }} style={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
/> />
</XAxis> </XAxis>
<YAxis <YAxis
type="number" type="number"
dataKey="avg_price" dataKey="avg_price"
name="Avg Price" name="Avg Price"
tick={{ fontSize: 11, fill: '#a1a1aa' }} tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
tickFormatter={(v: number) => `$${v}`}> tickFormatter={(v: number) => `$${v}`}
allowDataOverflow>
<Label <Label
value="Avg Price ($/MWh)" value="Avg Price ($/MWh)"
angle={-90} angle={-90}
position="insideLeft" position="insideLeft"
style={{ fontSize: 11, fill: '#a1a1aa' }} style={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
/> />
</YAxis> </YAxis>
<ZAxis range={[200, 200]} /> <ZAxis dataKey="z" range={[100, 1600]} />
<ChartTooltip <ChartTooltip
content={ content={
<ChartTooltipContent <ChartTooltipContent
@ -169,8 +280,21 @@ export function CorrelationChart({ data }: CorrelationChartProps) {
/> />
} }
/> />
<Scatter name="Regions" data={scatterData} shape={CustomDot} /> {/* Trend line */}
</ScatterChart> {regression && (
<Line
type="linear"
dataKey="trendPrice"
stroke="hsl(var(--foreground) / 0.3)"
strokeWidth={2}
strokeDasharray="8 4"
dot={false}
connectNulls
isAnimationActive={false}
/>
)}
<Scatter name="Regions" dataKey="avg_price" data={scatterData} shape={CustomDot} />
</ComposedChart>
</ChartContainer> </ChartContainer>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -277,7 +277,7 @@ export function DemandChart({ initialData, summaryData }: DemandChartProps) {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{hasData ? ( {hasData ? (
<ChartContainer config={trendChartConfig} className="h-[400px] w-full"> <ChartContainer config={trendChartConfig} className="h-[50vh] max-h-[600px] min-h-[300px] w-full">
<ComposedChart data={trendChartData}> <ComposedChart data={trendChartData}>
<CartesianGrid vertical={false} strokeDasharray="3 3" /> <CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis dataKey="date" tickLine={false} axisLine={false} tickMargin={8} /> <XAxis dataKey="date" tickLine={false} axisLine={false} tickMargin={8} />

View File

@ -258,14 +258,14 @@ export function GenerationChart({ initialData, initialRegion, initialTimeRange }
{error && <div className="mb-4 rounded-md bg-destructive/10 p-3 text-sm text-destructive">{error}</div>} {error && <div className="mb-4 rounded-md bg-destructive/10 p-3 text-sm text-destructive">{error}</div>}
{chartData.length === 0 && !isPending ? ( {chartData.length === 0 && !isPending ? (
<div className="flex h-80 items-center justify-center rounded-lg border border-dashed border-border"> <div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-border">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
No generation data available for this region and time range. No generation data available for this region and time range.
</p> </p>
</div> </div>
) : ( ) : (
<div className={isPending ? 'opacity-50 transition-opacity' : ''}> <div className={isPending ? 'opacity-50 transition-opacity' : ''}>
<ChartContainer config={chartConfig} className="h-80 w-full"> <ChartContainer config={chartConfig} className="h-[50vh] max-h-[600px] min-h-[320px] w-full">
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}> <AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<defs> <defs>
{FUEL_TYPES.map(fuel => ( {FUEL_TYPES.map(fuel => (

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { CartesianGrid, Line, LineChart, ReferenceLine, XAxis, YAxis } from 'recharts'; import { CartesianGrid, Line, LineChart, ReferenceLine, XAxis, YAxis } from 'recharts';
import type { SuperJSONResult } from 'superjson'; import type { SuperJSONResult } from 'superjson';
@ -53,7 +53,11 @@ interface PriceChartProps {
onTimeRangeChange: (range: TimeRange) => Promise<TimeRangeChangeResult>; onTimeRangeChange: (range: TimeRange) => Promise<TimeRangeChangeResult>;
} }
/** The 7 ISO/RTO regions shown by default */
const ISO_REGIONS = new Set(['PJM', 'ERCOT', 'CAISO', 'NYISO', 'ISONE', 'MISO', 'SPP']);
const REGION_COLORS: Record<string, string> = { const REGION_COLORS: Record<string, string> = {
// ISO regions — high-contrast, well-separated hues
PJM: 'hsl(210, 90%, 55%)', PJM: 'hsl(210, 90%, 55%)',
ERCOT: 'hsl(25, 90%, 55%)', ERCOT: 'hsl(25, 90%, 55%)',
CAISO: 'hsl(140, 70%, 45%)', CAISO: 'hsl(140, 70%, 45%)',
@ -61,6 +65,14 @@ const REGION_COLORS: Record<string, string> = {
ISONE: 'hsl(340, 70%, 55%)', ISONE: 'hsl(340, 70%, 55%)',
MISO: 'hsl(55, 80%, 50%)', MISO: 'hsl(55, 80%, 50%)',
SPP: 'hsl(180, 60%, 45%)', SPP: 'hsl(180, 60%, 45%)',
// Non-ISO regions — secondary palette
BPA: 'hsl(95, 55%, 50%)',
NWMT: 'hsl(310, 50%, 55%)',
WAPA: 'hsl(165, 50%, 50%)',
TVA: 'hsl(15, 70%, 50%)',
DUKE: 'hsl(240, 55%, 60%)',
SOCO: 'hsl(350, 55%, 50%)',
FPC: 'hsl(45, 60%, 45%)',
}; };
const COMMODITY_COLORS: Record<string, string> = { const COMMODITY_COLORS: Record<string, string> = {
@ -208,9 +220,9 @@ export function PriceChart({
const [commoditiesSerialized, setCommoditiesSerialized] = useState<SuperJSONResult>(initialCommodityData); const [commoditiesSerialized, setCommoditiesSerialized] = useState<SuperJSONResult>(initialCommodityData);
const [timeRange, setTimeRange] = useState<TimeRange>(initialTimeRange); const [timeRange, setTimeRange] = useState<TimeRange>(initialTimeRange);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [disabledRegions, setDisabledRegions] = useState<Set<string>>(new Set());
const [showCommodities, setShowCommodities] = useState(false); const [showCommodities, setShowCommodities] = useState(false);
const [showMilestones, setShowMilestones] = useState(true); const [showMilestones, setShowMilestones] = useState(true);
const [hoveredMilestone, setHoveredMilestone] = useState<string | null>(null);
const priceRows = useMemo(() => deserialize<PriceTrendRow[]>(pricesSerialized), [pricesSerialized]); const priceRows = useMemo(() => deserialize<PriceTrendRow[]>(pricesSerialized), [pricesSerialized]);
const commodityRows = useMemo(() => deserialize<CommodityRow[]>(commoditiesSerialized), [commoditiesSerialized]); const commodityRows = useMemo(() => deserialize<CommodityRow[]>(commoditiesSerialized), [commoditiesSerialized]);
@ -220,6 +232,17 @@ export function PriceChart({
[priceRows, commodityRows, showCommodities], [priceRows, commodityRows, showCommodities],
); );
// Default: hide non-ISO regions so the chart isn't spaghetti
const [disabledRegions, setDisabledRegions] = useState<Set<string>>(() => {
const initialRows = deserialize<PriceTrendRow[]>(initialPriceData);
const allRegions = new Set(initialRows.map(r => r.region_code));
const nonIso = new Set<string>();
for (const code of allRegions) {
if (!ISO_REGIONS.has(code)) nonIso.add(code);
}
return nonIso;
});
const chartConfig = useMemo( const chartConfig = useMemo(
() => buildChartConfig(regions, commodities, showCommodities), () => buildChartConfig(regions, commodities, showCommodities),
[regions, commodities, showCommodities], [regions, commodities, showCommodities],
@ -232,19 +255,22 @@ export function PriceChart({
[milestones, pivoted, showMilestones], [milestones, pivoted, showMilestones],
); );
async function handleTimeRangeChange(range: TimeRange) { const handleTimeRangeChange = useCallback(
setTimeRange(range); async (range: TimeRange) => {
setLoading(true); setTimeRange(range);
try { setLoading(true);
const result = await onTimeRangeChange(range); try {
setPricesSerialized(result.prices); const result = await onTimeRangeChange(range);
setCommoditiesSerialized(result.commodities); setPricesSerialized(result.prices);
} finally { setCommoditiesSerialized(result.commodities);
setLoading(false); } finally {
} setLoading(false);
} }
},
[onTimeRangeChange],
);
function toggleRegion(region: string) { const toggleRegion = useCallback((region: string) => {
setDisabledRegions(prev => { setDisabledRegions(prev => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(region)) { if (next.has(region)) {
@ -254,7 +280,7 @@ export function PriceChart({
} }
return next; return next;
}); });
} }, []);
if (pivoted.length === 0 && !showCommodities) { if (pivoted.length === 0 && !showCommodities) {
return ( return (
@ -290,30 +316,71 @@ export function PriceChart({
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="mb-4 flex flex-wrap items-center gap-2"> <div className="mb-4 flex flex-wrap items-center gap-2">
{regions.map(region => ( {/* ISO regions first */}
<button {regions
key={region} .filter(r => ISO_REGIONS.has(r))
onClick={() => toggleRegion(region)} .map(region => (
className={cn( <button
'inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-colors', key={region}
disabledRegions.has(region) onClick={() => toggleRegion(region)}
? 'border-border bg-transparent text-muted-foreground' className={cn(
: 'border-transparent text-foreground', 'inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-colors',
)} disabledRegions.has(region)
style={ ? 'border-border bg-transparent text-muted-foreground'
disabledRegions.has(region) : 'border-transparent text-foreground',
? undefined )}
: { backgroundColor: `${REGION_COLORS[region] ?? 'hsl(0,0%,50%)'}20` } style={
}> disabledRegions.has(region)
<span ? undefined
className="h-2 w-2 rounded-full" : { backgroundColor: `${REGION_COLORS[region] ?? 'hsl(0,0%,50%)'}20` }
style={{ }>
backgroundColor: disabledRegions.has(region) ? 'hsl(var(--muted-foreground))' : REGION_COLORS[region], <span
}} className="h-2 w-2 rounded-full"
/> style={{
{region} backgroundColor: disabledRegions.has(region)
</button> ? 'hsl(var(--muted-foreground))'
))} : REGION_COLORS[region],
}}
/>
{region}
</button>
))}
{/* Non-ISO regions after a divider */}
{regions.some(r => !ISO_REGIONS.has(r)) && (
<>
<div className="mx-1 h-4 w-px bg-border" />
<span className="text-[10px] tracking-wider text-muted-foreground/60 uppercase">Other</span>
{regions
.filter(r => !ISO_REGIONS.has(r))
.map(region => (
<button
key={region}
onClick={() => toggleRegion(region)}
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[11px] 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-1.5 w-1.5 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" /> <div className="mx-2 h-4 w-px bg-border" />
@ -341,8 +408,8 @@ export function PriceChart({
</div> </div>
<div className={cn('relative', loading && 'opacity-50 transition-opacity')}> <div className={cn('relative', loading && 'opacity-50 transition-opacity')}>
<ChartContainer config={chartConfig} className="h-[400px] w-full"> <ChartContainer config={chartConfig} className="h-[50vh] max-h-[600px] min-h-[300px] w-full">
<LineChart data={pivoted} margin={{ top: 5, right: 60, left: 10, bottom: 0 }}> <LineChart data={pivoted} margin={{ top: 20, right: 60, left: 10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border/30" /> <CartesianGrid strokeDasharray="3 3" className="stroke-border/30" />
<XAxis <XAxis
dataKey="timestampDisplay" dataKey="timestampDisplay"
@ -447,6 +514,40 @@ export function PriceChart({
))} ))}
</LineChart> </LineChart>
</ChartContainer> </ChartContainer>
{/* Milestone legend — pill badges below the chart */}
{visibleMilestones.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{visibleMilestones.map(m => (
<div
key={m.date}
className="group relative"
onMouseEnter={() => setHoveredMilestone(m.date)}
onMouseLeave={() => setHoveredMilestone(null)}>
<span
className="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] leading-none font-medium"
style={{
borderColor: `${MILESTONE_COLORS[m.category] ?? 'hsl(280,60%,60%)'}50`,
backgroundColor: `${MILESTONE_COLORS[m.category] ?? 'hsl(280,60%,60%)'}15`,
color: MILESTONE_COLORS[m.category] ?? 'hsl(280,60%,60%)',
}}>
<span
className="h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: MILESTONE_COLORS[m.category] ?? 'hsl(280,60%,60%)' }}
/>
{m.title}
</span>
{hoveredMilestone === m.date && (
<div className="absolute bottom-full left-1/2 z-10 mb-2 w-60 -translate-x-1/2 rounded-lg border border-border bg-popover p-3 shadow-lg">
<p className="text-xs font-semibold text-foreground">{m.title}</p>
<p className="mt-0.5 text-[10px] text-muted-foreground">{m.date}</p>
<p className="mt-1.5 text-xs leading-relaxed text-muted-foreground">{m.description}</p>
</div>
)}
</div>
))}
</div>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -4,8 +4,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select.js'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select.js';
import { Slider } from '@/components/ui/slider.js'; import { Slider } from '@/components/ui/slider.js';
import { cn } from '@/lib/utils.js'; import { cn } from '@/lib/utils.js';
import { Cpu } from 'lucide-react'; import { Cpu, Zap } from 'lucide-react';
import { useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { AnimatedNumber } from './animated-number.js'; import { AnimatedNumber } from './animated-number.js';
@ -38,11 +38,13 @@ interface GpuCalculatorProps {
className?: string; className?: string;
} }
function formatCurrency(n: number): string { const GPU_COUNT_MIN = 100;
if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(2)}M`; const GPU_COUNT_MAX = 10_000;
if (n >= 1_000) return `$${n.toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, ',')}`; const GPU_COUNT_STEP = 100;
return `$${n.toFixed(2)}`; const PUE_MIN = 1.1;
} const PUE_MAX = 2.0;
const PUE_STEP = 0.1;
const PUE_DEFAULT = 1.3;
function formatCurrencyAnimated(n: number): string { function formatCurrencyAnimated(n: number): string {
if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(2)}M`; if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(2)}M`;
@ -50,33 +52,167 @@ function formatCurrencyAnimated(n: number): string {
return `$${n.toFixed(2)}`; return `$${n.toFixed(2)}`;
} }
function formatCurrencyCompact(n: number): string {
if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `$${(n / 1_000).toFixed(1)}k`;
return `$${n.toFixed(0)}`;
}
function clampGpuCount(value: number): number {
const rounded = Math.round(value / GPU_COUNT_STEP) * GPU_COUNT_STEP;
return Math.max(GPU_COUNT_MIN, Math.min(GPU_COUNT_MAX, rounded));
}
interface RegionCost {
regionCode: string;
regionName: string;
hourlyCost: number;
priceMwh: number;
}
function RegionComparisonBar({
region,
maxCost,
isSelected,
isCheapest,
isMostExpensive,
}: {
region: RegionCost;
maxCost: number;
isSelected: boolean;
isCheapest: boolean;
isMostExpensive: boolean;
}) {
const widthPercent = maxCost > 0 ? (region.hourlyCost / maxCost) * 100 : 0;
return (
<div className="group flex items-center gap-2">
<span
className={cn(
'w-16 shrink-0 text-right font-mono text-xs',
isSelected ? 'font-bold text-foreground' : 'text-muted-foreground',
)}>
{region.regionCode}
</span>
<div className="relative h-5 flex-1 overflow-hidden rounded-sm bg-muted/40">
<div
className={cn(
'absolute inset-y-0 left-0 rounded-sm transition-all duration-500',
isSelected
? 'bg-chart-1'
: isCheapest
? 'bg-emerald-500/70'
: isMostExpensive
? 'bg-red-500/70'
: 'bg-muted-foreground/30',
)}
style={{ width: `${widthPercent}%` }}
/>
<span
className={cn(
'absolute inset-y-0 flex items-center px-2 text-xs tabular-nums',
isSelected ? 'font-semibold text-foreground' : 'text-muted-foreground',
)}>
{formatCurrencyCompact(region.hourlyCost)}/hr
</span>
</div>
{isCheapest && (
<span className="shrink-0 rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400">
LOW
</span>
)}
{isMostExpensive && (
<span className="shrink-0 rounded-full bg-red-500/15 px-1.5 py-0.5 text-[10px] font-medium text-red-400">
HIGH
</span>
)}
</div>
);
}
export function GpuCalculator({ regionPrices, className }: GpuCalculatorProps) { export function GpuCalculator({ regionPrices, className }: GpuCalculatorProps) {
const [gpuModel, setGpuModel] = useState<GpuModelKey>('B200'); const [gpuModel, setGpuModel] = useState<GpuModelKey>('B200');
const [gpuCount, setGpuCount] = useState(1000); const [gpuCount, setGpuCount] = useState(1000);
const [gpuCountInput, setGpuCountInput] = useState('1,000');
const [pue, setPue] = useState(PUE_DEFAULT);
const [selectedRegion, setSelectedRegion] = useState<string>(regionPrices[0]?.regionCode ?? ''); const [selectedRegion, setSelectedRegion] = useState<string>(regionPrices[0]?.regionCode ?? '');
const selectedPrice = regionPrices.find(r => r.regionCode === selectedRegion); const priceMwh = regionPrices.find(r => r.regionCode === selectedRegion)?.priceMwh ?? 0;
const priceMwh = selectedPrice?.priceMwh ?? 0;
const costs = useMemo(() => { const costs = useMemo(() => {
const wattsPerGpu = GPU_MODELS[gpuModel].watts; const wattsPerGpu = GPU_MODELS[gpuModel].watts;
const totalMw = (wattsPerGpu * gpuCount) / 1_000_000; const rawMw = (wattsPerGpu * gpuCount) / 1_000_000;
const totalMw = rawMw * pue;
const hourlyCost = totalMw * priceMwh; const hourlyCost = totalMw * priceMwh;
const dailyCost = hourlyCost * 24; const dailyCost = hourlyCost * 24;
const monthlyCost = dailyCost * 30; const monthlyCost = dailyCost * 30;
return { hourlyCost, dailyCost, monthlyCost, totalMw }; return { hourlyCost, dailyCost, monthlyCost, totalMw, rawMw };
}, [gpuModel, gpuCount, priceMwh]); }, [gpuModel, gpuCount, priceMwh, pue]);
const regionCosts: RegionCost[] = useMemo(() => {
const wattsPerGpu = GPU_MODELS[gpuModel].watts;
const totalMw = ((wattsPerGpu * gpuCount) / 1_000_000) * pue;
return regionPrices
.map(r => ({
regionCode: r.regionCode,
regionName: r.regionName,
hourlyCost: totalMw * r.priceMwh,
priceMwh: r.priceMwh,
}))
.sort((a, b) => a.hourlyCost - b.hourlyCost);
}, [gpuModel, gpuCount, pue, regionPrices]);
const cheapestRegion = regionCosts[0]?.regionCode ?? '';
const mostExpensiveRegion = regionCosts[regionCosts.length - 1]?.regionCode ?? '';
const maxCost = regionCosts[regionCosts.length - 1]?.hourlyCost ?? 0;
const handleGpuCountSlider = useCallback(([v]: number[]) => {
if (v !== undefined) {
setGpuCount(v);
setGpuCountInput(v.toLocaleString());
}
}, []);
const handleGpuCountInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value;
setGpuCountInput(raw);
}, []);
const commitGpuCountInput = useCallback(() => {
const parsed = parseInt(gpuCountInput.replace(/,/g, ''), 10);
if (Number.isFinite(parsed)) {
const clamped = clampGpuCount(parsed);
setGpuCount(clamped);
setGpuCountInput(clamped.toLocaleString());
} else {
setGpuCountInput(gpuCount.toLocaleString());
}
}, [gpuCountInput, gpuCount]);
const handleGpuCountKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') commitGpuCountInput();
},
[commitGpuCountInput],
);
return ( return (
<Card className={cn('gap-0 py-4', className)}> <Card className={cn('relative gap-0 overflow-hidden py-0', className)}>
<CardHeader className="pb-3"> {/* Accent top border */}
<CardTitle className="flex items-center gap-2 text-sm font-medium text-muted-foreground"> <div className="h-0.5 bg-gradient-to-r from-chart-1 via-chart-4 to-chart-2" />
<Cpu className="h-4 w-4" />
<CardHeader className="pt-5 pb-2">
<CardTitle className="flex items-center gap-2.5 text-base font-semibold">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-chart-1/15">
<Cpu className="h-4.5 w-4.5 text-chart-1" />
</div>
GPU Power Cost Calculator GPU Power Cost Calculator
</CardTitle> </CardTitle>
<CardDescription>Estimate real-time electricity cost for GPU clusters</CardDescription> <CardDescription>Real-time electricity cost estimates for GPU clusters by grid region</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-5">
<CardContent className="space-y-5 pb-6">
{/* GPU Model + Region selectors */}
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">GPU Model</label> <label className="text-xs font-medium text-muted-foreground">GPU Model</label>
@ -115,29 +251,65 @@ export function GpuCalculator({ regionPrices, className }: GpuCalculatorProps) {
</div> </div>
</div> </div>
{/* GPU Count: slider + text input */}
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label className="text-xs font-medium text-muted-foreground">GPU Count</label> <label className="text-xs font-medium text-muted-foreground">GPU Count</label>
<span className="font-mono text-sm font-semibold">{gpuCount.toLocaleString()}</span> <input
type="text"
inputMode="numeric"
value={gpuCountInput}
onChange={handleGpuCountInput}
onBlur={commitGpuCountInput}
onKeyDown={handleGpuCountKeyDown}
className="w-20 rounded-md border border-border/50 bg-muted/30 px-2 py-0.5 text-right font-mono text-sm font-semibold text-foreground transition-colors outline-none focus:border-chart-1/50 focus:ring-1 focus:ring-chart-1/20"
aria-label="GPU count"
/>
</div> </div>
<Slider <Slider
value={[gpuCount]} value={[gpuCount]}
onValueChange={([v]) => { onValueChange={handleGpuCountSlider}
if (v !== undefined) setGpuCount(v); min={GPU_COUNT_MIN}
}} max={GPU_COUNT_MAX}
min={1} step={GPU_COUNT_STEP}
max={10000}
step={1}
/> />
<div className="flex justify-between text-xs text-muted-foreground"> <div className="flex justify-between text-xs text-muted-foreground">
<span>1</span> <span>{GPU_COUNT_MIN.toLocaleString()}</span>
<span>10,000</span> <span>{GPU_COUNT_MAX.toLocaleString()}</span>
</div> </div>
</div> </div>
{/* PUE Factor */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-muted-foreground">
PUE Factor
<span className="ml-1.5 text-muted-foreground/60">(datacenter overhead)</span>
</label>
<span className="font-mono text-sm font-semibold">{pue.toFixed(1)}x</span>
</div>
<Slider
value={[pue * 10]}
onValueChange={([v]) => {
if (v !== undefined) setPue(v / 10);
}}
min={PUE_MIN * 10}
max={PUE_MAX * 10}
step={PUE_STEP * 10}
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{PUE_MIN.toFixed(1)} (efficient)</span>
<span>{PUE_MAX.toFixed(1)} (legacy)</span>
</div>
</div>
{/* Cost results */}
<div className="rounded-lg border border-border/50 bg-muted/30 p-4"> <div className="rounded-lg border border-border/50 bg-muted/30 p-4">
<div className="mb-3 flex items-baseline justify-between"> <div className="mb-3 flex items-baseline justify-between">
<span className="text-xs text-muted-foreground">Total Power Draw</span> <span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Zap className="h-3 w-3" />
Total Power Draw (incl. PUE)
</span>
<span className="font-mono text-sm font-semibold">{costs.totalMw.toFixed(2)} MW</span> <span className="font-mono text-sm font-semibold">{costs.totalMw.toFixed(2)} MW</span>
</div> </div>
@ -169,27 +341,22 @@ export function GpuCalculator({ regionPrices, className }: GpuCalculatorProps) {
</div> </div>
</div> </div>
{regionPrices.length >= 2 && ( {/* Region comparison bars */}
<div className="text-xs text-muted-foreground"> {regionCosts.length >= 2 && (
<span className="font-medium">Compare: </span> <div className="space-y-3">
Running {gpuCount.toLocaleString()} {gpuModel} GPUs costs{' '} <div className="text-xs font-medium text-muted-foreground">Cost by Region</div>
<span className="font-semibold text-foreground">{formatCurrency(costs.hourlyCost)}/hr</span> in{' '} <div className="space-y-1.5">
{selectedPrice?.regionName ?? selectedRegion} {regionCosts.map(region => (
{regionPrices <RegionComparisonBar
.filter(r => r.regionCode !== selectedRegion) key={region.regionCode}
.slice(0, 1) region={region}
.map(other => { maxCost={maxCost}
const otherWatts = GPU_MODELS[gpuModel].watts; isSelected={region.regionCode === selectedRegion}
const otherMw = (otherWatts * gpuCount) / 1_000_000; isCheapest={region.regionCode === cheapestRegion}
const otherHourly = otherMw * other.priceMwh; isMostExpensive={region.regionCode === mostExpensiveRegion && regionCosts.length > 1}
return ( />
<span key={other.regionCode}> ))}
{' '} </div>
vs <span className="font-semibold text-foreground">{formatCurrency(otherHourly)}/hr</span> in{' '}
{other.regionName}
</span>
);
})}
</div> </div>
)} )}
</CardContent> </CardContent>

View File

@ -1,87 +1,48 @@
'use client';
import { cn } from '@/lib/utils.js'; import { cn } from '@/lib/utils.js';
interface GridStressGaugeProps { interface GridStressGaugeProps {
regionCode: string; regionCode: string;
regionName: string; regionName: string;
demandMw: number; demandMw: number;
capacityMw: number; peakDemandMw: number;
className?: string; className?: string;
} }
function getStressColor(pct: number): string { function getStressLevel(pct: number): { color: string; label: string; barClass: string } {
if (pct >= 90) return '#ef4444'; if (pct >= 95) return { color: 'text-red-400', label: 'Critical', barClass: 'bg-red-500' };
if (pct >= 80) return '#f97316'; if (pct >= 85) return { color: 'text-orange-400', label: 'High', barClass: 'bg-orange-500' };
if (pct >= 60) return '#eab308'; if (pct >= 70) return { color: 'text-amber-400', label: 'Elevated', barClass: 'bg-amber-500' };
return '#22c55e'; if (pct >= 50) return { color: 'text-emerald-400', label: 'Normal', barClass: 'bg-emerald-500' };
return { color: 'text-emerald-500/70', label: 'Low', barClass: 'bg-emerald-500/60' };
} }
function getStressLabel(pct: number): string { function formatGw(mw: number): string {
if (pct >= 90) return 'Critical'; if (mw >= 1000) return `${(mw / 1000).toFixed(1)} GW`;
if (pct >= 80) return 'High'; return `${Math.round(mw)} MW`;
if (pct >= 60) return 'Moderate';
return 'Normal';
} }
export function GridStressGauge({ regionCode, regionName, demandMw, capacityMw, className }: GridStressGaugeProps) { export function GridStressGauge({ regionCode, regionName, demandMw, peakDemandMw, className }: GridStressGaugeProps) {
const pct = capacityMw > 0 ? Math.min((demandMw / capacityMw) * 100, 100) : 0; const pct = peakDemandMw > 0 ? Math.min((demandMw / peakDemandMw) * 100, 100) : 0;
const color = getStressColor(pct); const { color, label, barClass } = getStressLevel(pct);
const label = getStressLabel(pct);
const radius = 40;
const circumference = Math.PI * radius;
const offset = circumference - (pct / 100) * circumference;
const isCritical = pct >= 85;
return ( return (
<div className={cn('flex flex-col items-center gap-2', className)}> <div className={cn('group flex items-center gap-3', className)}>
<svg <div className="w-14 shrink-0">
viewBox="0 0 100 55" <div className="font-mono text-xs font-semibold tracking-wide">{regionCode}</div>
className="w-full max-w-[140px]" <div className="truncate text-[10px] text-muted-foreground">{regionName}</div>
style={{ </div>
filter: isCritical ? `drop-shadow(0 0 8px ${color}80)` : undefined, <div className="flex min-w-0 flex-1 flex-col gap-1">
}}> <div className="h-2 w-full overflow-hidden rounded-full bg-muted/30">
{/* Background arc */} <div
<path className={cn('h-full rounded-full transition-all duration-700 ease-out', barClass)}
d="M 10 50 A 40 40 0 0 1 90 50" style={{ width: `${pct}%` }}
fill="none" />
stroke="currentColor"
strokeWidth="8"
strokeLinecap="round"
className="text-muted/40"
/>
{/* Filled arc */}
<path
d="M 10 50 A 40 40 0 0 1 90 50"
fill="none"
stroke={color}
strokeWidth="8"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={offset}
style={{
transition: 'stroke-dashoffset 1s ease-in-out, stroke 0.5s ease',
}}
/>
{/* Percentage text */}
<text
x="50"
y="45"
textAnchor="middle"
className="fill-foreground text-[14px] font-bold"
style={{ fontFamily: 'ui-monospace, monospace' }}>
{pct.toFixed(0)}%
</text>
</svg>
<div className="text-center">
<div className="text-xs font-semibold">{regionCode}</div>
<div className="text-[10px] text-muted-foreground">{regionName}</div>
<div className="mt-0.5 text-[10px] font-medium" style={{ color }}>
{label}
</div> </div>
</div> </div>
<div className="flex w-20 shrink-0 items-baseline justify-end gap-1.5">
<span className="font-mono text-xs font-medium tabular-nums">{formatGw(demandMw)}</span>
<span className={cn('text-[10px] font-medium', color)}>{label}</span>
</div>
</div> </div>
); );
} }

View File

@ -4,6 +4,7 @@ import { Sparkline } from '@/components/charts/sparkline.js';
import { AnimatedNumber } from '@/components/dashboard/animated-number.js'; import { AnimatedNumber } from '@/components/dashboard/animated-number.js';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card.js'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card.js';
import { cn } from '@/lib/utils.js'; import { cn } from '@/lib/utils.js';
import { TrendingDown, TrendingUp } from 'lucide-react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useCallback } from 'react'; import { useCallback } from 'react';
@ -31,6 +32,12 @@ interface MetricCardProps {
className?: string; className?: string;
sparklineData?: { value: number }[]; sparklineData?: { value: number }[];
sparklineColor?: string; sparklineColor?: string;
/** Percentage change vs previous period (e.g., +3.2 or -1.5). */
trendDelta?: number | null;
/** Label for the trend period (e.g., "vs yesterday"). */
trendLabel?: string;
/** Contextual subtitle (e.g., "3 new in last 30 days"). */
subtitle?: string;
} }
export function MetricCard({ export function MetricCard({
@ -43,6 +50,9 @@ export function MetricCard({
className, className,
sparklineData, sparklineData,
sparklineColor, sparklineColor,
trendDelta,
trendLabel,
subtitle,
}: MetricCardProps) { }: MetricCardProps) {
const formatFn = useCallback( const formatFn = useCallback(
(n: number) => (animatedFormat ? FORMAT_FNS[animatedFormat](n) : n.toFixed(2)), (n: number) => (animatedFormat ? FORMAT_FNS[animatedFormat](n) : n.toFixed(2)),
@ -66,6 +76,22 @@ export function MetricCard({
)} )}
{unit && <span className="text-sm text-muted-foreground">{unit}</span>} {unit && <span className="text-sm text-muted-foreground">{unit}</span>}
</div> </div>
{trendDelta !== undefined && trendDelta !== null && (
<div className="mt-1 flex items-center gap-1">
{trendDelta >= 0 ? (
<TrendingUp className="h-3.5 w-3.5 text-emerald-400" />
) : (
<TrendingDown className="h-3.5 w-3.5 text-red-400" />
)}
<span
className={cn('text-xs font-medium tabular-nums', trendDelta >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{trendDelta >= 0 ? '+' : ''}
{trendDelta.toFixed(1)}%
</span>
{trendLabel && <span className="text-xs text-muted-foreground">{trendLabel}</span>}
</div>
)}
{subtitle && <p className="mt-1 text-xs text-muted-foreground">{subtitle}</p>}
{sparklineData && sparklineData.length >= 2 && ( {sparklineData && sparklineData.length >= 2 && (
<div className="mt-2"> <div className="mt-2">
<Sparkline data={sparklineData} color={sparklineColor} height={28} /> <Sparkline data={sparklineData} color={sparklineColor} height={28} />

View File

@ -5,6 +5,12 @@ import { deserialize } from '@/lib/superjson.js';
import { cn } from '@/lib/utils.js'; import { cn } from '@/lib/utils.js';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
/** Height of the ticker tape in pixels — use to reserve layout space and prevent CLS. */
export const TICKER_HEIGHT = 32;
/** Number of skeleton items to display while loading. */
const SKELETON_ITEM_COUNT = 8;
interface TickerItem { interface TickerItem {
label: string; label: string;
price: string; price: string;
@ -42,6 +48,36 @@ function TickerItemDisplay({ item }: { item: TickerItem }) {
); );
} }
function TickerSkeletonItem({ index }: { index: number }) {
return (
<span className="inline-flex items-center gap-1.5 px-4 whitespace-nowrap">
<span
className="h-3 w-10 animate-pulse rounded bg-muted-foreground/15"
style={{ animationDelay: `${index * 150}ms` }}
/>
<span
className="h-3.5 w-14 animate-pulse rounded bg-muted-foreground/20"
style={{ animationDelay: `${index * 150 + 50}ms` }}
/>
<span
className="h-3 w-9 animate-pulse rounded bg-muted-foreground/10"
style={{ animationDelay: `${index * 150 + 100}ms` }}
/>
<span className="ml-3 text-border/30">|</span>
</span>
);
}
function TickerSkeleton() {
return (
<div className="flex items-center overflow-hidden">
{Array.from({ length: SKELETON_ITEM_COUNT }, (_, i) => (
<TickerSkeletonItem key={i} index={i} />
))}
</div>
);
}
const COMMODITY_LABELS: Record<string, string> = { const COMMODITY_LABELS: Record<string, string> = {
natural_gas: 'Nat Gas', natural_gas: 'Nat Gas',
wti_crude: 'WTI Crude', wti_crude: 'WTI Crude',
@ -91,25 +127,29 @@ export function TickerTape() {
return () => clearInterval(interval); return () => clearInterval(interval);
}, []); }, []);
if (items.length === 0) { const isLoading = items.length === 0;
return null;
}
return ( return (
<div className="overflow-hidden border-b border-border/40 bg-muted/30"> <div className="overflow-hidden border-b border-border/40 bg-muted/30" style={{ height: `${TICKER_HEIGHT}px` }}>
<div className="ticker-scroll-container flex py-1.5"> {isLoading ? (
{/* Duplicate items for seamless looping */} <div className="flex items-center py-1.5">
<div className="ticker-scroll flex shrink-0"> <TickerSkeleton />
{items.map((item, i) => (
<TickerItemDisplay key={`a-${i}`} item={item} />
))}
</div> </div>
<div className="ticker-scroll flex shrink-0" aria-hidden> ) : (
{items.map((item, i) => ( <div className="ticker-scroll-container flex py-1.5">
<TickerItemDisplay key={`b-${i}`} item={item} /> {/* Duplicate items for seamless looping */}
))} <div className="ticker-scroll flex shrink-0">
{items.map((item, i) => (
<TickerItemDisplay key={`a-${i}`} item={item} />
))}
</div>
<div className="ticker-scroll flex shrink-0" aria-hidden>
{items.map((item, i) => (
<TickerItemDisplay key={`b-${i}`} item={item} />
))}
</div>
</div> </div>
</div> )}
</div> </div>
); );
} }

View File

@ -0,0 +1,82 @@
'use client';
import { useEffect, useState } from 'react';
interface DataFreshnessProps {
/** Most recent timestamp across all data sources, as ISO string */
latestTimestamp: string | null;
}
type Staleness = 'fresh' | 'stale' | 'warning';
function formatRelativeTime(isoString: string): string {
const now = Date.now();
const then = new Date(isoString).getTime();
const diffMs = now - then;
if (diffMs < 0) return 'just now';
const seconds = Math.floor(diffMs / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes} min ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days === 1) return '1 day ago';
return `${days} days ago`;
}
function getStaleness(isoString: string): Staleness {
const diffMs = Date.now() - new Date(isoString).getTime();
if (diffMs > 6 * 60 * 60 * 1000) return 'warning';
if (diffMs > 2 * 60 * 60 * 1000) return 'stale';
return 'fresh';
}
const DOT_CLASS: Record<Staleness, string> = {
fresh: 'bg-emerald-500/70',
stale: 'bg-yellow-500/60',
warning: 'bg-amber-500/80',
};
const TEXT_CLASS: Record<Staleness, string> = {
fresh: 'text-muted-foreground/50',
stale: 'text-yellow-500/50',
warning: 'text-amber-500/70',
};
export function DataFreshness({ latestTimestamp }: DataFreshnessProps) {
const [display, setDisplay] = useState<{ relativeTime: string; staleness: Staleness } | null>(null);
useEffect(() => {
if (!latestTimestamp) return;
function update() {
setDisplay({
relativeTime: formatRelativeTime(latestTimestamp!),
staleness: getStaleness(latestTimestamp!),
});
}
update();
const interval = setInterval(update, 30_000);
return () => clearInterval(interval);
}, [latestTimestamp]);
if (!latestTimestamp || !display) {
return <span className="text-muted-foreground/50">Data from EIA &amp; FRED</span>;
}
return (
<span className="inline-flex items-center gap-1.5">
<span className={`inline-block h-1.5 w-1.5 rounded-full ${DOT_CLASS[display.staleness]}`} />
<span className="text-muted-foreground/60">Data from EIA &amp; FRED</span>
<span className="text-muted-foreground/40">|</span>
<span className={TEXT_CLASS[display.staleness]}>Updated {display.relativeTime}</span>
</span>
);
}

View File

@ -1,8 +1,36 @@
export function Footer() { import { fetchDataFreshness } from '@/actions/freshness.js';
import { deserialize } from '@/lib/superjson.js';
import { DataFreshness } from './data-freshness.js';
export async function Footer() {
const result = await fetchDataFreshness();
let latestTimestamp: string | null = null;
if (result.ok) {
const freshness = deserialize<{
electricity: Date | null;
generation: Date | null;
commodities: Date | null;
}>(result.data);
const timestamps = [freshness.electricity, freshness.generation, freshness.commodities].filter(
(t): t is Date => t !== null,
);
if (timestamps.length > 0) {
const latest = new Date(Math.max(...timestamps.map(t => t.getTime())));
latestTimestamp = latest.toISOString();
}
}
return ( return (
<footer className="border-t border-border/40 py-4"> <footer className="border-t border-border/40 py-4">
<div className="px-6 text-center text-xs text-muted-foreground"> <div className="flex flex-col items-center gap-1 px-6 text-xs">
For educational and informational purposes only. Not financial advice. <DataFreshness latestTimestamp={latestTimestamp} />
<span className="text-muted-foreground/40">
For educational and informational purposes only. Not financial advice.
</span>
</div> </div>
</footer> </footer>
); );

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import { AdvancedMarker } from '@vis.gl/react-google-maps'; import { AdvancedMarker } from '@vis.gl/react-google-maps';
import { useCallback, useState } from 'react'; import { useCallback, useRef, useState } from 'react';
const OPERATOR_COLORS: Record<string, string> = { const OPERATOR_COLORS: Record<string, string> = {
AWS: '#FF9900', AWS: '#FF9900',
@ -56,6 +56,8 @@ interface DatacenterMarkerProps {
onClick: (datacenter: DatacenterMarkerData) => void; onClick: (datacenter: DatacenterMarkerData) => void;
isPulsing?: boolean; isPulsing?: boolean;
isSelected?: boolean; isSelected?: boolean;
/** Callback to register/unregister the underlying AdvancedMarkerElement for clustering. */
setMarkerRef?: (marker: google.maps.marker.AdvancedMarkerElement | null, id: string) => void;
} }
export function DatacenterMarker({ export function DatacenterMarker({
@ -63,18 +65,35 @@ export function DatacenterMarker({
onClick, onClick,
isPulsing = false, isPulsing = false,
isSelected = false, isSelected = false,
setMarkerRef,
}: DatacenterMarkerProps) { }: DatacenterMarkerProps) {
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const size = getMarkerSize(datacenter.capacity_mw); const size = getMarkerSize(datacenter.capacity_mw);
const color = getOperatorColor(datacenter.operator); const color = getOperatorColor(datacenter.operator);
const pulseDuration = getPulseDuration(datacenter.capacity_mw); const pulseDuration = getPulseDuration(datacenter.capacity_mw);
const registeredRef = useRef(false);
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
onClick(datacenter); onClick(datacenter);
}, [datacenter, onClick]); }, [datacenter, onClick]);
const refCallback = useCallback(
(marker: google.maps.marker.AdvancedMarkerElement | null) => {
if (!setMarkerRef) return;
if (marker && !registeredRef.current) {
registeredRef.current = true;
setMarkerRef(marker, datacenter.id);
} else if (!marker && registeredRef.current) {
registeredRef.current = false;
setMarkerRef(null, datacenter.id);
}
},
[datacenter.id, setMarkerRef],
);
return ( return (
<AdvancedMarker <AdvancedMarker
ref={refCallback}
position={{ lat: datacenter.lat, lng: datacenter.lng }} position={{ lat: datacenter.lat, lng: datacenter.lng }}
onClick={handleClick} onClick={handleClick}
zIndex={isSelected ? 1000 : hovered ? 999 : undefined} zIndex={isSelected ? 1000 : hovered ? 999 : undefined}

View File

@ -1,7 +1,16 @@
'use client'; 'use client';
import { AdvancedMarker, APIProvider, ColorScheme, ControlPosition, Map, MapControl } from '@vis.gl/react-google-maps'; import { MarkerClusterer } from '@googlemaps/markerclusterer';
import { useCallback, useMemo, useState } from 'react'; import {
AdvancedMarker,
APIProvider,
ColorScheme,
ControlPosition,
Map,
MapControl,
useMap,
} from '@vis.gl/react-google-maps';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { DatacenterDetailPanel } from './datacenter-detail-panel.js'; import { DatacenterDetailPanel } from './datacenter-detail-panel.js';
import { DatacenterMarker, type DatacenterMarkerData } from './datacenter-marker.js'; import { DatacenterMarker, type DatacenterMarkerData } from './datacenter-marker.js';
import { MapControls } from './map-controls.js'; import { MapControls } from './map-controls.js';
@ -10,8 +19,9 @@ import { PowerPlantMarker, type PowerPlantMarkerData } from './power-plant-marke
import { RegionDetailPanel } from './region-detail-panel.js'; import { RegionDetailPanel } from './region-detail-panel.js';
import { RegionOverlay, type RegionHeatmapData } from './region-overlay.js'; import { RegionOverlay, type RegionHeatmapData } from './region-overlay.js';
const US_CENTER = { lat: 35.9132, lng: -79.0558 }; /** Geographic center of the contiguous US for a full-country view. */
const DEFAULT_ZOOM = 6; const US_CENTER = { lat: 39.0, lng: -97.0 };
const DEFAULT_ZOOM = 5;
/** Well-known approximate centroids for US ISO/RTO regions. */ /** Well-known approximate centroids for US ISO/RTO regions. */
const REGION_CENTROIDS: Record<string, { lat: number; lng: number }> = { const REGION_CENTROIDS: Record<string, { lat: number; lng: number }> = {
@ -36,6 +46,112 @@ function priceToLabelBorderColor(price: number | null): string {
return 'rgba(239, 68, 68, 0.7)'; return 'rgba(239, 68, 68, 0.7)';
} }
/** Custom cluster renderer for dark-themed datacenter clusters. */
function createClusterRenderer() {
return {
render({ count, position }: { count: number; position: google.maps.LatLng }) {
const size = Math.min(60, 30 + Math.log2(count) * 8);
const div = document.createElement('div');
div.style.cssText = [
`width: ${size}px`,
`height: ${size}px`,
'display: flex',
'align-items: center',
'justify-content: center',
'border-radius: 50%',
'background: rgba(59, 130, 246, 0.25)',
'border: 2px solid rgba(59, 130, 246, 0.6)',
'color: #e2e8f0',
'font-size: 12px',
'font-weight: 700',
'font-family: monospace',
'backdrop-filter: blur(4px)',
'box-shadow: 0 0 12px rgba(59, 130, 246, 0.3)',
].join('; ');
div.textContent = String(count);
return new google.maps.marker.AdvancedMarkerElement({
position,
content: div,
zIndex: Number(google.maps.Marker.MAX_ZINDEX) + count,
});
},
};
}
interface ClusteredMarkersProps {
datacenters: DatacenterMarkerData[];
regions: RegionHeatmapData[];
selectedDatacenterId: string | null;
onDatacenterClick: (dc: DatacenterMarkerData) => void;
}
/** Manages a MarkerClusterer instance and registers/unregisters datacenter markers. */
function ClusteredMarkers({ datacenters, regions, selectedDatacenterId, onDatacenterClick }: ClusteredMarkersProps) {
const map = useMap();
const clustererRef = useRef<MarkerClusterer | null>(null);
const markerRefs = useRef(new globalThis.Map<string, google.maps.marker.AdvancedMarkerElement>());
useEffect(() => {
if (!map) return;
if (!clustererRef.current) {
clustererRef.current = new MarkerClusterer({
map,
renderer: createClusterRenderer(),
});
}
return () => {
clustererRef.current?.clearMarkers();
clustererRef.current?.setMap(null);
clustererRef.current = null;
};
}, [map]);
const setMarkerRef = useCallback((marker: google.maps.marker.AdvancedMarkerElement | null, id: string) => {
const clusterer = clustererRef.current;
if (!clusterer) return;
if (marker) {
if (!markerRefs.current.has(id)) {
markerRefs.current.set(id, marker);
clusterer.addMarker(marker);
}
} else {
const existing = markerRefs.current.get(id);
if (existing) {
clusterer.removeMarker(existing);
markerRefs.current.delete(id);
}
}
}, []);
return (
<>
{datacenters.map(dc => {
const dcRegion = regions.find(r => r.code === dc.region_code);
const isPulsing =
dcRegion !== undefined &&
dcRegion.avgPrice !== null &&
dcRegion.maxPrice !== null &&
dcRegion.avgPrice > 0 &&
dcRegion.maxPrice > dcRegion.avgPrice * 1.03;
return (
<DatacenterMarker
key={dc.id}
datacenter={dc}
onClick={onDatacenterClick}
isPulsing={isPulsing}
isSelected={selectedDatacenterId === dc.id}
setMarkerRef={setMarkerRef}
/>
);
})}
</>
);
}
interface EnergyMapProps { interface EnergyMapProps {
datacenters: DatacenterMarkerData[]; datacenters: DatacenterMarkerData[];
regions: RegionHeatmapData[]; regions: RegionHeatmapData[];
@ -101,6 +217,7 @@ export function EnergyMap({ datacenters, regions, powerPlants }: EnergyMapProps)
gestureHandling="greedy" gestureHandling="greedy"
colorScheme={ColorScheme.DARK} colorScheme={ColorScheme.DARK}
disableDefaultUI={true} disableDefaultUI={true}
zoomControl={true}
clickableIcons={false} clickableIcons={false}
className="h-full w-full"> className="h-full w-full">
<RegionOverlay regions={regions} onRegionClick={handleRegionClick} /> <RegionOverlay regions={regions} onRegionClick={handleRegionClick} />
@ -118,24 +235,12 @@ export function EnergyMap({ datacenters, regions, powerPlants }: EnergyMapProps)
{showPowerPlants && powerPlants.map(pp => <PowerPlantMarker key={pp.id} plant={pp} />)} {showPowerPlants && powerPlants.map(pp => <PowerPlantMarker key={pp.id} plant={pp} />)}
{filteredDatacenters.map(dc => { <ClusteredMarkers
const dcRegion = regions.find(r => r.code === dc.region_code); datacenters={filteredDatacenters}
const isPulsing = regions={regions}
dcRegion !== undefined && selectedDatacenterId={selectedDatacenter?.id ?? null}
dcRegion.avgPrice !== null && onDatacenterClick={handleDatacenterClick}
dcRegion.maxPrice !== null && />
dcRegion.avgPrice > 0 &&
dcRegion.maxPrice > dcRegion.avgPrice * 1.03;
return (
<DatacenterMarker
key={dc.id}
datacenter={dc}
onClick={handleDatacenterClick}
isPulsing={isPulsing}
isSelected={selectedDatacenter?.id === dc.id}
/>
);
})}
<MapControl position={ControlPosition.LEFT_BOTTOM}> <MapControl position={ControlPosition.LEFT_BOTTOM}>
<MapLegend showPowerPlants={showPowerPlants} /> <MapLegend showPowerPlants={showPowerPlants} />
@ -144,7 +249,12 @@ export function EnergyMap({ datacenters, regions, powerPlants }: EnergyMapProps)
<DatacenterDetailPanel datacenter={selectedDatacenter} onClose={() => setSelectedDatacenter(null)} /> <DatacenterDetailPanel datacenter={selectedDatacenter} onClose={() => setSelectedDatacenter(null)} />
<RegionDetailPanel region={selectedRegion} datacenters={datacenters} onClose={() => setSelectedRegion(null)} /> <RegionDetailPanel
region={selectedRegion}
datacenters={datacenters}
powerPlants={powerPlants}
onClose={() => setSelectedRegion(null)}
/>
</div> </div>
</APIProvider> </APIProvider>
); );

View File

@ -1,19 +1,63 @@
'use client'; 'use client';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet.js'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet.js';
import { useMemo } from 'react';
import type { DatacenterMarkerData } from './datacenter-marker.js'; import type { DatacenterMarkerData } from './datacenter-marker.js';
import type { PowerPlantMarkerData } from './power-plant-marker.js';
import type { RegionHeatmapData } from './region-overlay.js'; import type { RegionHeatmapData } from './region-overlay.js';
interface RegionDetailPanelProps { interface RegionDetailPanelProps {
region: RegionHeatmapData | null; region: RegionHeatmapData | null;
datacenters: DatacenterMarkerData[]; datacenters: DatacenterMarkerData[];
powerPlants: PowerPlantMarkerData[];
onClose: () => void; onClose: () => void;
} }
export function RegionDetailPanel({ region, datacenters, onClose }: RegionDetailPanelProps) { const HOURS_PER_YEAR = 8_760;
const regionDatacenters = region
? datacenters.filter(dc => dc.region_code === region.code).sort((a, b) => b.capacity_mw - a.capacity_mw) export function RegionDetailPanel({ region, datacenters, powerPlants, onClose }: RegionDetailPanelProps) {
: []; const regionDatacenters = useMemo(
() =>
region
? datacenters.filter(dc => dc.region_code === region.code).sort((a, b) => b.capacity_mw - a.capacity_mw)
: [],
[region, datacenters],
);
const regionPowerPlants = useMemo(
() => (region ? powerPlants.filter(pp => isInRegionApprox(pp, region.code)) : []),
[region, powerPlants],
);
const topOperators = useMemo(() => {
const capacityByOperator = new Map<string, number>();
for (const dc of regionDatacenters) {
capacityByOperator.set(dc.operator, (capacityByOperator.get(dc.operator) ?? 0) + dc.capacity_mw);
}
return Array.from(capacityByOperator.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 5);
}, [regionDatacenters]);
const fuelMixSummary = useMemo(() => {
const capacityByFuel = new Map<string, number>();
for (const pp of regionPowerPlants) {
capacityByFuel.set(pp.fuel_type, (capacityByFuel.get(pp.fuel_type) ?? 0) + pp.capacity_mw);
}
return Array.from(capacityByFuel.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 5);
}, [regionPowerPlants]);
const estimatedAnnualMwh =
region?.avgDemand !== null && region?.avgDemand !== undefined
? Math.round(region.avgDemand * HOURS_PER_YEAR)
: null;
const estimatedAnnualCost =
estimatedAnnualMwh !== null && region?.avgPrice !== null && region?.avgPrice !== undefined
? estimatedAnnualMwh * region.avgPrice
: null;
return ( return (
<Sheet open={region !== null} onOpenChange={open => !open && onClose()}> <Sheet open={region !== null} onOpenChange={open => !open && onClose()}>
@ -26,6 +70,7 @@ export function RegionDetailPanel({ region, datacenters, onClose }: RegionDetail
</SheetHeader> </SheetHeader>
<div className="flex flex-col gap-4 p-4"> <div className="flex flex-col gap-4 p-4">
{/* Core price/demand metrics */}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<MetricItem <MetricItem
label="Avg Price (24h)" label="Avg Price (24h)"
@ -48,8 +93,59 @@ export function RegionDetailPanel({ region, datacenters, onClose }: RegionDetail
: '0 MW' : '0 MW'
} }
/> />
<MetricItem label="Power Plants" value={String(regionPowerPlants.length)} />
</div> </div>
{/* Estimated annual consumption */}
{estimatedAnnualMwh !== null && (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/50 p-3">
<div className="mb-1 text-xs font-medium text-zinc-500">Est. Annual Consumption</div>
<div className="text-sm font-semibold text-zinc-200">
{(estimatedAnnualMwh / 1_000_000).toFixed(1)} TWh/yr
</div>
{estimatedAnnualCost !== null && (
<div className="mt-0.5 text-xs text-zinc-500">
~${(estimatedAnnualCost / 1_000_000_000).toFixed(1)}B at current avg price
</div>
)}
</div>
)}
{/* Top operators */}
{topOperators.length > 0 && (
<div>
<h3 className="mb-2 text-sm font-semibold text-zinc-300">Top Operators</h3>
<div className="flex flex-col gap-1.5">
{topOperators.map(([operator, capacity]) => (
<div
key={operator}
className="flex items-center justify-between rounded-md bg-zinc-900/50 px-3 py-2">
<span className="text-xs font-medium text-zinc-300">{operator}</span>
<span className="font-mono text-xs text-zinc-500">{capacity.toLocaleString()} MW</span>
</div>
))}
</div>
</div>
)}
{/* Generation mix */}
{fuelMixSummary.length > 0 && (
<div>
<h3 className="mb-2 text-sm font-semibold text-zinc-300">Generation Mix (by capacity)</h3>
<div className="flex flex-col gap-1.5">
{fuelMixSummary.map(([fuel, capacity]) => (
<div key={fuel} className="flex items-center justify-between rounded-md bg-zinc-900/50 px-3 py-2">
<span className="text-xs font-medium text-zinc-300">{fuel}</span>
<span className="font-mono text-xs text-zinc-500">
{capacity >= 1000 ? `${(capacity / 1000).toFixed(1)} GW` : `${Math.round(capacity)} MW`}
</span>
</div>
))}
</div>
</div>
)}
{/* Datacenter list */}
{regionDatacenters.length > 0 && ( {regionDatacenters.length > 0 && (
<div> <div>
<h3 className="mb-2 text-sm font-semibold text-zinc-300">Datacenters in Region</h3> <h3 className="mb-2 text-sm font-semibold text-zinc-300">Datacenters in Region</h3>
@ -81,3 +177,26 @@ function MetricItem({ label, value }: { label: string; value: string }) {
</div> </div>
); );
} }
/**
* Approximate region membership for power plants based on state.
* This is a rough heuristic since we don't have region_id on power_plants.
*/
const REGION_STATES: Record<string, string[]> = {
PJM: ['PA', 'NJ', 'MD', 'DE', 'VA', 'WV', 'OH', 'DC', 'NC', 'KY', 'IN', 'IL'],
ERCOT: ['TX'],
CAISO: ['CA'],
MISO: ['MN', 'WI', 'IA', 'MO', 'AR', 'LA', 'MS', 'MI', 'IN', 'IL', 'ND', 'SD', 'MT'],
SPP: ['KS', 'OK', 'NE', 'NM'],
ISONE: ['CT', 'MA', 'ME', 'NH', 'RI', 'VT'],
NYISO: ['NY'],
DUKE: ['NC', 'SC'],
SOCO: ['GA', 'AL'],
TVA: ['TN'],
};
function isInRegionApprox(plant: PowerPlantMarkerData, regionCode: string): boolean {
const states = REGION_STATES[regionCode];
if (!states) return false;
return states.includes(plant.state);
}

View File

@ -81,7 +81,7 @@ export function RegionOverlay({ regions, onRegionClick }: RegionOverlayProps) {
const hoveredFeatureRef = useRef<google.maps.Data.Feature | null>(null); const hoveredFeatureRef = useRef<google.maps.Data.Feature | null>(null);
const breathingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null); const breathingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
/** Apply breathing opacity to stressed regions only (price > 100). Calm regions stay static. */ /** Apply breathing opacity to notable regions (price > $40/MWh). Calm regions stay static. */
const applyBreathingFrame = useCallback((dataLayer: google.maps.Data, timestamp: number) => { const applyBreathingFrame = useCallback((dataLayer: google.maps.Data, timestamp: number) => {
dataLayer.forEach(feature => { dataLayer.forEach(feature => {
// Skip the currently hovered feature — it has its own highlight style // Skip the currently hovered feature — it has its own highlight style
@ -92,14 +92,16 @@ export function RegionOverlay({ regions, onRegionClick }: RegionOverlayProps) {
const regionData = priceMapRef.current.get(code); const regionData = priceMapRef.current.get(code);
const price = regionData?.avgPrice ?? null; const price = regionData?.avgPrice ?? null;
// Only animate stressed regions; calm regions stay completely static // Animate regions with notable prices; calm regions stay static
const isStressed = price !== null && price > 100; const isNotable = price !== null && price > 40;
if (!isStressed) return; if (!isNotable) return;
const baseOpacity = priceToBaseOpacity(price); const baseOpacity = priceToBaseOpacity(price);
const frequency = 0.125; // 8-second period // Scale animation intensity by price: subtle at $40, pronounced at $100+
const amplitude = 0.03 + 0.04; // stressed always gets the extra 0.04 const intensity = Math.min(1, (price - 40) / 60);
const frequency = 0.125 + intensity * 0.05; // slightly faster for higher prices
const amplitude = 0.02 + intensity * 0.05;
const oscillation = Math.sin((timestamp / 1000) * frequency * 2 * Math.PI) * amplitude; const oscillation = Math.sin((timestamp / 1000) * frequency * 2 * Math.PI) * amplitude;
const newOpacity = Math.max(0.1, Math.min(0.5, baseOpacity + oscillation)); const newOpacity = Math.max(0.1, Math.min(0.5, baseOpacity + oscillation));

72
src/instrumentation.ts Normal file
View File

@ -0,0 +1,72 @@
/**
* Next.js instrumentation hook runs once on server startup.
*
* In development, sets up a 30-minute interval that calls the ingestion
* endpoints to keep electricity, generation, and commodity data fresh.
*/
const INGEST_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
const INGEST_BASE_URL = 'http://localhost:3000/api/ingest';
const ENDPOINTS = ['electricity', 'generation', 'commodities'] as const;
interface IngestionStats {
inserted: number;
updated: number;
errors: number;
}
function isIngestionStats(value: unknown): value is IngestionStats {
if (typeof value !== 'object' || value === null) return false;
if (!('inserted' in value) || !('updated' in value) || !('errors' in value)) return false;
const { inserted, updated, errors } = value;
return typeof inserted === 'number' && typeof updated === 'number' && typeof errors === 'number';
}
async function runIngestion(): Promise<void> {
const secret = process.env.INGEST_SECRET;
if (!secret) {
console.warn('[ingest] INGEST_SECRET not set, skipping automated ingestion');
return;
}
const timestamp = new Date().toISOString().slice(11, 19);
console.log(`[${timestamp}] [ingest] Starting scheduled ingestion...`);
for (const endpoint of ENDPOINTS) {
try {
const url = `${INGEST_BASE_URL}/${endpoint}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${secret}` },
});
if (!res.ok) {
console.error(`[${timestamp}] [ingest] ${endpoint}: HTTP ${res.status}`);
continue;
}
const body: unknown = await res.json();
if (isIngestionStats(body)) {
console.log(
`[${timestamp}] [ingest] ${endpoint}: ${body.inserted} inserted, ${body.updated} updated, ${body.errors} errors`,
);
} else {
console.log(`[${timestamp}] [ingest] ${endpoint}: done (unexpected response shape)`);
}
} catch (err) {
console.error(`[${timestamp}] [ingest] ${endpoint}: ${err instanceof Error ? err.message : String(err)}`);
}
}
}
export function register(): void {
if (process.env.NODE_ENV !== 'development') return;
if (typeof globalThis.setInterval === 'undefined') return;
console.log('[ingest] Scheduling automated ingestion every 30 minutes');
// Delay the first run by 10 seconds so the server is fully ready
setTimeout(() => {
void runIngestion();
setInterval(() => void runIngestion(), INGEST_INTERVAL_MS);
}, 10_000);
}