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:
parent
9e83cfead3
commit
ad1a6792f5
@ -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]
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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
49
src/actions/freshness.ts
Normal 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)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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} />
|
||||||
|
|||||||
@ -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 => (
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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} />
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
82
src/components/layout/data-freshness.tsx
Normal file
82
src/components/layout/data-freshness.tsx
Normal 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 & 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 & FRED</span>
|
||||||
|
<span className="text-muted-foreground/40">|</span>
|
||||||
|
<span className={TEXT_CLASS[display.staleness]}>Updated {display.relativeTime}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@ -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
72
src/instrumentation.ts
Normal 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);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user