This commit is contained in:
Joey Eamigh 2026-02-11 22:03:19 -05:00
parent d8478ace96
commit 79850a61be
No known key found for this signature in database
GPG Key ID: CE8C05DFFC53C9CB
16 changed files with 41 additions and 22 deletions

19
SUMMARY.md Normal file
View File

@ -0,0 +1,19 @@
# Energy & AI Dashboard — One-Page Summary
## Purpose & Functionality
The Energy & AI Dashboard is a real-time interactive tool that visualizes how the US datacenter buildout is reshaping regional electricity markets. It ingests live data from the EIA (Energy Information Administration) and FRED (Federal Reserve Economic Data) APIs, overlays 100+ datacenter locations and 5,000+ power plants onto an interactive Google Maps interface, and presents electricity prices, demand, and generation mix across 10 US grid regions. Users can explore price trends annotated with AI milestones (GPT-3, ChatGPT, GPT-4), monitor grid stress gauges that flag regions approaching capacity limits, and use a GPU Cost Calculator to compare the real-time cost of running GPU clusters across regions. A scrolling price ticker, animated metrics, pulsing map markers on price spikes, and auto-refreshing data (60-second intervals) keep the dashboard feeling live and urgent. Five core pages — Dashboard, Map, Trends, Demand, and Generation — provide layered analysis from high-level national overview down to region-specific breakdowns with correlation charts linking datacenter concentration to electricity price increases.
## Target Audience
**Primary users:** Energy investors evaluating utility stocks in AI-heavy regions, datacenter site selection teams comparing regional electricity costs for new facilities, and utility analysts planning generation and transmission capacity investments.
**How they use it:** An energy investor opens the dashboard to see that PJM (Virginia — the densest datacenter corridor in the US) electricity prices are spiking relative to the 30-day average. The map's pulsing markers confirm elevated activity. They switch to the Trends page and see the price trajectory alongside natural gas spot prices. The correlation chart confirms: regions with more datacenter capacity consistently show higher electricity prices. The GPU Cost Calculator tells them it currently costs 40% more to run 1,000 B200 GPUs in NYISO than in ERCOT. This is actionable intelligence for portfolio positioning, infrastructure planning, or site selection — synthesized in seconds instead of hours of spreadsheet work.
**Why over alternatives:** No existing tool connects datacenter infrastructure data with live energy market data in a single geospatial view. The EIA publishes raw data tables. ISOs publish regional dashboards that cover only their own territory. Datacenter trackers (Baxtel, DatacenterHawk) don't show energy prices. This dashboard is the first to unify all three — infrastructure, energy prices, and grid capacity — into one real-time interface with the AI narrative baked in.
## Sales Pitch
**Value generation:** The dashboard generates value for three groups. (1) **Energy investors and traders** gain an information edge by seeing how datacenter demand growth correlates with regional price movements before it shows up in quarterly reports. (2) **Datacenter operators and hyperscalers** (AWS, Google, Meta, Microsoft) can optimize site selection by comparing real-time electricity costs, grid stress, and generation mix across regions — a decision that determines hundreds of millions in operating costs over a facility's lifetime. (3) **Utilities and grid operators** gain visibility into where datacenter load is concentrating and which regions are approaching capacity constraints, informing capital expenditure planning for new generation and transmission infrastructure.
**Monetization:** A tiered SaaS model fits naturally. A free tier provides the public dashboard with 24-hour data and basic map views — a lead generation tool and industry reference. A professional tier ($200500/month) unlocks full historical data (2+ years), custom alerts on price spikes and grid stress events, exportable reports, and API access for integration into existing energy trading or site selection workflows. An enterprise tier provides custom region analysis, private datacenter portfolio overlays, and dedicated support for utility planning teams and hyperscaler real estate divisions. The GPU Cost Calculator alone — answering "where is it cheapest to run AI infrastructure right now?" — is a feature datacenter operators would pay for daily.

View File

@ -37,7 +37,7 @@ export async function PricesByRegion() {
</CardHeader>
<CardContent>
{prices.length > 0 ? (
<div className="max-h-[400px] space-y-3 overflow-y-auto pr-1">
<div className="max-h-100 space-y-3 overflow-y-auto pr-1">
{prices.map(p => {
const regionSparkline = sparklineMap[p.region_code];
return (

View File

@ -6,7 +6,7 @@ export default function DemandLoading() {
<div className="px-6 py-8">
<div className="mb-8">
<Skeleton className="h-9 w-52" />
<Skeleton className="mt-2 h-5 w-[32rem] max-w-full" />
<Skeleton className="mt-2 h-5 w-lg max-w-full" />
</div>
<ChartSkeleton />

View File

@ -5,7 +5,7 @@ export default function GenerationLoading() {
return (
<div className="px-6 py-8">
<Skeleton className="h-9 w-48" />
<Skeleton className="mt-2 h-5 w-[30rem] max-w-full" />
<Skeleton className="mt-2 h-5 w-120 max-w-full" />
<div className="mt-8">
<ChartSkeleton />

View File

@ -26,7 +26,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<body className="flex min-h-dvh flex-col overflow-x-hidden font-sans antialiased">
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false} disableTransitionOnChange>
<Nav />
<main className="mx-auto w-full max-w-[1400px] flex-1">{children}</main>
<main className="mx-auto w-full max-w-350 flex-1">{children}</main>
<Footer />
<Toaster theme="dark" richColors position="bottom-right" />
</ThemeProvider>

View File

@ -16,7 +16,7 @@ function MapSkeleton() {
export default function MapPage() {
return (
<div className="[margin-right:calc(50%-50vw)] [margin-left:calc(50%-50vw)] h-[calc(100dvh-var(--header-h)-var(--footer-h))] [width:100vw] !max-w-none overflow-hidden">
<div className="mr-[calc(50%-50vw)] ml-[calc(50%-50vw)] h-[calc(100dvh-var(--header-h)-var(--footer-h))] w-screen max-w-none! overflow-hidden">
<Suspense fallback={<MapSkeleton />}>
<MapContent />
</Suspense>

View File

@ -238,7 +238,7 @@ export function CorrelationChart({ data }: CorrelationChartProps) {
</div>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-[40vh] max-h-[500px] min-h-[300px] w-full">
<ChartContainer config={chartConfig} className="h-[40vh] max-h-125 min-h-75 w-full">
<ComposedChart data={combinedData} margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border/30" />
<XAxis

View File

@ -287,7 +287,7 @@ export function DemandChart({ initialData, summaryData }: DemandChartProps) {
</CardHeader>
<CardContent>
{hasData ? (
<ChartContainer config={trendChartConfig} className="h-[50vh] max-h-[600px] min-h-[300px] w-full">
<ChartContainer config={trendChartConfig} className="h-[50vh] max-h-150 min-h-75 w-full">
<ComposedChart data={trendChartData}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis
@ -388,7 +388,7 @@ export function DemandChart({ initialData, summaryData }: DemandChartProps) {
<CardContent>
{hasDcImpact ? (
<>
<ChartContainer config={dcImpactConfig} className="h-[280px] w-full">
<ChartContainer config={dcImpactConfig} className="h-70 w-full">
<BarChart data={dcImpactData} layout="vertical">
<CartesianGrid horizontal={false} strokeDasharray="3 3" />
<XAxis

View File

@ -230,7 +230,7 @@ export function GenerationChart({ initialData, initialRegion, initialTimeRange }
</div>
<div className="flex gap-2">
<Select value={regionCode} onValueChange={handleRegionChange}>
<SelectTrigger className="w-[200px]">
<SelectTrigger className="w-50">
<SelectValue placeholder="Select region" />
</SelectTrigger>
<SelectContent>
@ -242,7 +242,7 @@ export function GenerationChart({ initialData, initialRegion, initialTimeRange }
</SelectContent>
</Select>
<Select value={timeRange} onValueChange={handleTimeRangeChange}>
<SelectTrigger className="w-[90px]">
<SelectTrigger className="w-22.5">
<SelectValue />
</SelectTrigger>
<SelectContent>
@ -267,7 +267,7 @@ export function GenerationChart({ initialData, initialRegion, initialTimeRange }
</div>
) : (
<div className={isPending ? 'opacity-50 transition-opacity' : ''}>
<ChartContainer config={chartConfig} className="h-[50vh] max-h-[600px] min-h-[320px] w-full">
<ChartContainer config={chartConfig} className="h-[50vh] max-h-150 min-h-80 w-full">
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<defs>
{FUEL_TYPES.map(fuel => (

View File

@ -415,7 +415,7 @@ export function PriceChart({
</div>
<div className={cn('relative', loading && 'opacity-50 transition-opacity')}>
<ChartContainer config={chartConfig} className="h-[50vh] max-h-[600px] min-h-[300px] w-full">
<ChartContainer config={chartConfig} className="h-[50vh] max-h-150 min-h-75 w-full">
<LineChart data={pivoted} margin={{ top: 20, right: 60, left: 10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border/30" />
<XAxis

View File

@ -79,7 +79,7 @@ export function AlertsFeed({ initialData, className }: AlertsFeedProps) {
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="max-h-[400px] overflow-y-auto px-6 pb-6">
<div className="max-h-100 overflow-y-auto px-6 pb-6">
<div className="space-y-1">
{alerts.map(alert => {
const styles = SEVERITY_STYLES[alert.severity];

View File

@ -201,7 +201,7 @@ export function GpuCalculator({ regionPrices, className }: GpuCalculatorProps) {
return (
<Card className={cn('relative gap-0 overflow-hidden py-0', className)}>
{/* Accent top border */}
<div className="h-0.5 bg-gradient-to-r from-chart-1 via-chart-4 to-chart-2" />
<div className="h-0.5 bg-linear-to-r from-chart-1 via-chart-4 to-chart-2" />
<CardHeader className="pt-5 pb-2">
<CardTitle className="flex items-center gap-2.5 text-base font-semibold">

View File

@ -23,7 +23,7 @@ export function Nav() {
const [mobileOpen, setMobileOpen] = useState(false);
return (
<header className="sticky top-0 z-50 border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<header className="sticky top-0 z-50 border-b border-border/40 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60">
<TickerTape />
<div className="flex h-14 items-center px-4 sm:px-6">

View File

@ -25,12 +25,12 @@ export function MapLegend({ showPowerPlants = false }: MapLegendProps) {
<div className="mb-2.5">
<div className="mb-1 font-medium text-zinc-300">Price Heatmap</div>
<div
className="h-3 w-[120px] rounded-sm"
className="h-3 w-30 rounded-sm"
style={{
background: 'linear-gradient(to right, rgb(30,80,220), rgb(60,220,200), rgb(255,160,30), rgb(220,40,110))',
}}
/>
<div className="mt-0.5 flex w-[120px] justify-between text-zinc-500">
<div className="mt-0.5 flex w-30 justify-between text-zinc-500">
<span>$0</span>
<span>$50</span>
<span>$100+</span>

View File

@ -31,7 +31,7 @@ function SelectTrigger({
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className,
)}
{...props}>
@ -55,7 +55,7 @@ function SelectContent({
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
'relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
'relative z-50 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
@ -68,7 +68,7 @@ function SelectContent({
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1',
'h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width) scroll-my-1',
)}>
{children}
</SelectPrimitive.Viewport>
@ -93,7 +93,7 @@ function SelectItem({ className, children, ...props }: React.ComponentProps<type
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}>

View File

@ -26,7 +26,7 @@ function Slider({
min={min}
max={max}
className={cn(
'relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col',
'relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col',
className,
)}
{...props}>