phase 6: post-review enhancements — data, map UX, charts, navigation

Data:
- Expand datacenter inventory from 38 to 108 facilities
- Add 14 North Carolina datacenters (Apple, Google, AWS, Microsoft, etc.)
- Add missing operators (Cloudflare, Switch, Vantage, Apple, Aligned, Iron Mountain)
- Fill geographic gaps (Southeast, Northwest, Midwest)

Map UX:
- Dark mode via ColorScheme.DARK, hide all default UI controls
- Center on Chapel Hill, NC (lat 35.91, lng -79.06) at zoom 6
- New map legend component (price gradient, marker scale, pulse/glow key)
- Floating region price labels via AdvancedMarker at region centroids
- Tune breathing animation: 8s period, only stressed regions, 5 FPS
- Enhanced markers: capacity labels on 200+ MW, status-based styling

Charts:
- Fix generation chart timestamp duplication (use epoch ms dataKey)
- Fix correlation chart black-on-black axis labels
- Context-aware tick formatting (time-only for 24h, date for longer ranges)

GPU Calculator:
- Default to B200 (1,000W), add R200 Rubin (1,800W)

Navigation:
- Granular Suspense boundaries on all 5 pages
- Extract data-fetching into async Server Components per section
- Page shells render instantly, sections stream in independently
This commit is contained in:
Joey Eamigh 2026-02-11 14:44:46 -05:00
parent deb1cdc527
commit 564d212148
No known key found for this signature in database
GPG Key ID: CE8C05DFFC53C9CB
25 changed files with 1708 additions and 463 deletions

View File

@ -480,6 +480,834 @@
"year_opened": 2025,
"region": "ERCOT"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-81.1739, 35.5616] },
"properties": {
"name": "Apple Maiden Data Center",
"operator": "Apple",
"capacity_mw": 100,
"status": "operational",
"year_opened": 2010,
"region": "SERC"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-81.5390, 35.9107] },
"properties": {
"name": "Google Lenoir Data Center",
"operator": "Google",
"capacity_mw": 150,
"status": "operational",
"year_opened": 2007,
"region": "SERC"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-81.3000, 35.7300] },
"properties": {
"name": "Microsoft Catawba County Data Center",
"operator": "Microsoft",
"capacity_mw": 200,
"status": "under_construction",
"year_opened": 2024,
"region": "SERC"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-79.7600, 34.9800] },
"properties": {
"name": "AWS Richmond County Data Center",
"operator": "AWS",
"capacity_mw": 500,
"status": "under_construction",
"year_opened": 2025,
"region": "SERC"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-80.8431, 35.2271] },
"properties": {
"name": "Digital Realty Charlotte Data Center",
"operator": "Digital Realty",
"capacity_mw": 400,
"status": "planned",
"year_opened": 2025,
"region": "SERC"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-78.8986, 35.9940] },
"properties": {
"name": "Compass Datacenters Durham Data Center",
"operator": "Compass",
"capacity_mw": 50,
"status": "operational",
"year_opened": 2013,
"region": "SERC"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-80.8870, 35.7596] },
"properties": {
"name": "Compass Datacenters Statesville Data Center",
"operator": "Compass",
"capacity_mw": 150,
"status": "operational",
"year_opened": 2023,
"region": "SERC"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-81.3412, 35.2440] },
"properties": {
"name": "T5 Data Centers Kings Mountain",
"operator": "T5 Data Centers",
"capacity_mw": 100,
"status": "operational",
"year_opened": 2022,
"region": "SERC"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-82.5301, 35.5088] },
"properties": {
"name": "DartPoints Asheville Data Center",
"operator": "DartPoints",
"capacity_mw": 10,
"status": "operational",
"year_opened": 2019,
"region": "SERC"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-78.6382, 35.7796] },
"properties": {
"name": "American Tower Raleigh Data Center",
"operator": "American Tower",
"capacity_mw": 3,
"status": "operational",
"year_opened": 2025,
"region": "SERC"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-96.0419, 41.1544] },
"properties": {
"name": "Meta Papillion Data Center",
"operator": "Meta",
"capacity_mw": 200,
"status": "operational",
"year_opened": 2019,
"region": "SPP"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-116.5194, 43.4913] },
"properties": {
"name": "Meta Kuna Data Center",
"operator": "Meta",
"capacity_mw": 150,
"status": "operational",
"year_opened": 2025,
"region": "BPA"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-86.5386, 34.6609] },
"properties": {
"name": "Meta Huntsville Data Center",
"operator": "Meta",
"capacity_mw": 300,
"status": "under_construction",
"year_opened": 2026,
"region": "SERC"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-86.2098, 32.3792] },
"properties": {
"name": "Meta Montgomery Data Center",
"operator": "Meta",
"capacity_mw": 200,
"status": "under_construction",
"year_opened": 2026,
"region": "SERC"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-97.3426, 31.0982] },
"properties": {
"name": "Meta Temple Data Center",
"operator": "Meta",
"capacity_mw": 200,
"status": "under_construction",
"year_opened": 2026,
"region": "ERCOT"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-86.4436, 36.9904] },
"properties": {
"name": "Meta Bowling Green Data Center",
"operator": "Meta",
"capacity_mw": 200,
"status": "under_construction",
"year_opened": 2026,
"region": "SERC"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-94.5786, 39.0997] },
"properties": {
"name": "Meta Kansas City Data Center",
"operator": "Meta",
"capacity_mw": 200,
"status": "operational",
"year_opened": 2025,
"region": "SPP"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-86.4467, 36.3831] },
"properties": {
"name": "Meta Gallatin Data Center",
"operator": "Meta",
"capacity_mw": 200,
"status": "under_construction",
"year_opened": 2026,
"region": "SERC"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-93.6271, 43.2617] },
"properties": {
"name": "Meta Forest City Data Center",
"operator": "Meta",
"capacity_mw": 200,
"status": "operational",
"year_opened": 2011,
"region": "MISO"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-80.0107, 33.1913] },
"properties": {
"name": "Google Moncks Corner Data Center",
"operator": "Google",
"capacity_mw": 150,
"status": "operational",
"year_opened": 2007,
"region": "SERC"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-84.7413, 33.7291] },
"properties": {
"name": "Google Douglas County Data Center",
"operator": "Google",
"capacity_mw": 150,
"status": "under_construction",
"year_opened": 2025,
"region": "SERC"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-114.9543, 36.0068] },
"properties": {
"name": "Google Henderson Data Center",
"operator": "Google",
"capacity_mw": 100,
"status": "operational",
"year_opened": 2020,
"region": "SPP"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-96.9938, 32.4820] },
"properties": {
"name": "Google Midlothian Data Center",
"operator": "Google",
"capacity_mw": 200,
"status": "operational",
"year_opened": 2022,
"region": "ERCOT"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-111.8315, 33.4152] },
"properties": {
"name": "Apple Mesa Data Center",
"operator": "Apple",
"capacity_mw": 100,
"status": "operational",
"year_opened": 2018,
"region": "SPP"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-119.7527, 39.5349] },
"properties": {
"name": "Apple Reno Data Center",
"operator": "Apple",
"capacity_mw": 100,
"status": "operational",
"year_opened": 2012,
"region": "SPP"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-122.0294, 37.5175] },
"properties": {
"name": "Apple Newark Data Center",
"operator": "Apple",
"capacity_mw": 50,
"status": "operational",
"year_opened": 2015,
"region": "CAISO"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-115.0935, 36.0821] },
"properties": {
"name": "Switch Las Vegas SuperNAP",
"operator": "Switch",
"capacity_mw": 500,
"status": "operational",
"year_opened": 2000,
"region": "SPP"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-77.4875, 39.0345] },
"properties": {
"name": "Vantage Ashburn Data Center",
"operator": "Vantage",
"capacity_mw": 200,
"status": "operational",
"year_opened": 2019,
"region": "PJM"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-121.9552, 37.3541] },
"properties": {
"name": "Vantage Santa Clara Data Center",
"operator": "Vantage",
"capacity_mw": 100,
"status": "operational",
"year_opened": 2019,
"region": "CAISO"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-121.8863, 37.3382] },
"properties": {
"name": "Cloudflare San Jose Edge",
"operator": "Cloudflare",
"capacity_mw": 8,
"status": "operational",
"year_opened": 2015,
"region": "CAISO"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-87.6298, 41.8781] },
"properties": {
"name": "Cloudflare Chicago Edge",
"operator": "Cloudflare",
"capacity_mw": 6,
"status": "operational",
"year_opened": 2016,
"region": "PJM"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-77.4750, 39.0350] },
"properties": {
"name": "Cloudflare Ashburn Edge",
"operator": "Cloudflare",
"capacity_mw": 10,
"status": "operational",
"year_opened": 2014,
"region": "PJM"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-96.7970, 32.7767] },
"properties": {
"name": "Cloudflare Dallas Edge",
"operator": "Cloudflare",
"capacity_mw": 5,
"status": "operational",
"year_opened": 2016,
"region": "ERCOT"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-81.0348, 34.0007] },
"properties": {
"name": "QTS Columbia Data Center",
"operator": "QTS",
"capacity_mw": 80,
"status": "operational",
"year_opened": 2016,
"region": "SERC"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-84.3880, 33.7490] },
"properties": {
"name": "Equinix AT1 Atlanta Campus",
"operator": "Equinix",
"capacity_mw": 60,
"status": "operational",
"year_opened": 2008,
"region": "SERC"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-86.7816, 36.1627] },
"properties": {
"name": "Digital Realty Nashville Data Center",
"operator": "Digital Realty",
"capacity_mw": 70,
"status": "operational",
"year_opened": 2019,
"region": "SERC"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-85.7585, 38.2527] },
"properties": {
"name": "QTS Louisville Data Center",
"operator": "QTS",
"capacity_mw": 50,
"status": "operational",
"year_opened": 2018,
"region": "SERC"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-82.4572, 27.9506] },
"properties": {
"name": "CyrusOne Tampa Data Center",
"operator": "CyrusOne",
"capacity_mw": 40,
"status": "operational",
"year_opened": 2017,
"region": "SERC"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-80.1918, 25.7617] },
"properties": {
"name": "Equinix MI1 Miami Campus",
"operator": "Equinix",
"capacity_mw": 30,
"status": "operational",
"year_opened": 2003,
"region": "SERC"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-84.5121, 33.9533] },
"properties": {
"name": "Microsoft Atlanta (Cobb) Data Center",
"operator": "Microsoft",
"capacity_mw": 150,
"status": "under_construction",
"year_opened": 2025,
"region": "SERC"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-86.8025, 33.5207] },
"properties": {
"name": "CyrusOne Birmingham Data Center",
"operator": "CyrusOne",
"capacity_mw": 50,
"status": "operational",
"year_opened": 2020,
"region": "SERC"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-122.2332, 47.1854] },
"properties": {
"name": "Microsoft Tacoma Data Center",
"operator": "Microsoft",
"capacity_mw": 100,
"status": "operational",
"year_opened": 2020,
"region": "BPA"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-116.8249, 43.6150] },
"properties": {
"name": "AWS Boise Data Center",
"operator": "AWS",
"capacity_mw": 100,
"status": "under_construction",
"year_opened": 2025,
"region": "BPA"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-112.0352, 46.5884] },
"properties": {
"name": "CyrusOne Helena Data Center",
"operator": "CyrusOne",
"capacity_mw": 30,
"status": "operational",
"year_opened": 2021,
"region": "BPA"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-96.0156, 41.2565] },
"properties": {
"name": "Google Omaha Data Center",
"operator": "Google",
"capacity_mw": 100,
"status": "under_construction",
"year_opened": 2025,
"region": "SPP"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-89.4012, 43.0731] },
"properties": {
"name": "Equinix CH2 Madison Data Center",
"operator": "Equinix",
"capacity_mw": 30,
"status": "operational",
"year_opened": 2018,
"region": "MISO"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-93.2650, 44.9778] },
"properties": {
"name": "Digital Realty Minneapolis Data Center",
"operator": "Digital Realty",
"capacity_mw": 50,
"status": "operational",
"year_opened": 2010,
"region": "MISO"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-90.1994, 38.6270] },
"properties": {
"name": "CyrusOne St. Louis Data Center",
"operator": "CyrusOne",
"capacity_mw": 60,
"status": "operational",
"year_opened": 2016,
"region": "MISO"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-95.3698, 29.7604] },
"properties": {
"name": "CyrusOne Houston Data Center",
"operator": "CyrusOne",
"capacity_mw": 80,
"status": "operational",
"year_opened": 2014,
"region": "ERCOT"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-117.1611, 32.7157] },
"properties": {
"name": "Digital Realty San Diego Data Center",
"operator": "Digital Realty",
"capacity_mw": 40,
"status": "operational",
"year_opened": 2012,
"region": "CAISO"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-118.2437, 34.0522] },
"properties": {
"name": "CoreSite Los Angeles Data Center",
"operator": "CoreSite",
"capacity_mw": 50,
"status": "operational",
"year_opened": 2014,
"region": "CAISO"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-122.3321, 47.6062] },
"properties": {
"name": "Equinix SE2 Seattle Data Center",
"operator": "Equinix",
"capacity_mw": 40,
"status": "operational",
"year_opened": 2011,
"region": "BPA"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-111.8910, 40.7608] },
"properties": {
"name": "Digital Realty Salt Lake City Data Center",
"operator": "Digital Realty",
"capacity_mw": 30,
"status": "operational",
"year_opened": 2015,
"region": "SPP"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-71.0589, 42.3601] },
"properties": {
"name": "Equinix BO1 Boston Data Center",
"operator": "Equinix",
"capacity_mw": 40,
"status": "operational",
"year_opened": 2007,
"region": "ISONE"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-73.9857, 40.7484] },
"properties": {
"name": "Digital Realty NYC Data Center",
"operator": "Digital Realty",
"capacity_mw": 50,
"status": "operational",
"year_opened": 2008,
"region": "NYISO"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-122.4194, 37.7749] },
"properties": {
"name": "Digital Realty San Francisco Data Center",
"operator": "Digital Realty",
"capacity_mw": 60,
"status": "operational",
"year_opened": 2006,
"region": "CAISO"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-121.9288, 37.3688] },
"properties": {
"name": "Equinix SV5 Santa Clara Campus",
"operator": "Equinix",
"capacity_mw": 70,
"status": "operational",
"year_opened": 2012,
"region": "CAISO"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-122.0570, 37.3861] },
"properties": {
"name": "AWS US-West Santa Clara Data Center",
"operator": "AWS",
"capacity_mw": 60,
"status": "operational",
"year_opened": 2013,
"region": "CAISO"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-97.7350, 30.2180] },
"properties": {
"name": "Google Austin Data Center",
"operator": "Google",
"capacity_mw": 80,
"status": "operational",
"year_opened": 2018,
"region": "ERCOT"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-111.8500, 33.3900] },
"properties": {
"name": "Aligned Energy Phoenix Campus",
"operator": "Aligned",
"capacity_mw": 120,
"status": "operational",
"year_opened": 2020,
"region": "SPP"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-96.8100, 32.7900] },
"properties": {
"name": "Aligned Energy Dallas Campus",
"operator": "Aligned",
"capacity_mw": 100,
"status": "operational",
"year_opened": 2019,
"region": "ERCOT"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-77.4820, 39.0410] },
"properties": {
"name": "Aligned Energy Ashburn Campus",
"operator": "Aligned",
"capacity_mw": 80,
"status": "operational",
"year_opened": 2021,
"region": "PJM"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-80.9431, 35.2271] },
"properties": {
"name": "Iron Mountain Charlotte Data Center",
"operator": "Iron Mountain",
"capacity_mw": 30,
"status": "operational",
"year_opened": 2017,
"region": "SERC"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-77.4625, 39.0380] },
"properties": {
"name": "Iron Mountain Ashburn Data Center",
"operator": "Iron Mountain",
"capacity_mw": 50,
"status": "operational",
"year_opened": 2016,
"region": "PJM"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-111.7900, 33.4300] },
"properties": {
"name": "Iron Mountain Phoenix Data Center",
"operator": "Iron Mountain",
"capacity_mw": 40,
"status": "operational",
"year_opened": 2018,
"region": "SPP"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-78.7870, 35.8320] },
"properties": {
"name": "MetroData Centers RTP Campus",
"operator": "MetroData Centers",
"capacity_mw": 20,
"status": "operational",
"year_opened": 2020,
"region": "SERC"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-80.3251, 35.8910] },
"properties": {
"name": "Corning Data Center Catawba County NC",
"operator": "Corning",
"capacity_mw": 15,
"status": "operational",
"year_opened": 2021,
"region": "SERC"
}
},
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-80.7931, 35.3465] },
"properties": {
"name": "Peak 10 Charlotte Data Center",
"operator": "Peak 10",
"capacity_mw": 25,
"status": "operational",
"year_opened": 2014,
"region": "SERC"
}
}
]
}

View File

@ -0,0 +1,10 @@
import { fetchRecentAlerts } from '@/actions/prices.js';
import { AlertsFeed } from '@/components/dashboard/alerts-feed.js';
export async function AlertsSection() {
const alertsResult = await fetchRecentAlerts();
if (!alertsResult.ok) return null;
return <AlertsFeed initialData={alertsResult.data} />;
}

View File

@ -0,0 +1,39 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card.js';
import { Activity } from 'lucide-react';
import { fetchRegionDemandSummary } from '@/actions/demand.js';
import { deserialize } from '@/lib/superjson.js';
function formatNumber(value: number, decimals = 1): string {
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(decimals)}M`;
if (value >= 1_000) return `${(value / 1_000).toFixed(decimals)}K`;
return value.toFixed(decimals);
}
export async function DemandSummary() {
const demandResult = await fetchRegionDemandSummary();
const demandRows = demandResult.ok ? deserialize<Array<{ avg_demand: number | null }>>(demandResult.data) : [];
const avgDemand =
demandRows.length > 0 ? demandRows.reduce((sum, r) => sum + (r.avg_demand ?? 0), 0) / demandRows.length : 0;
if (avgDemand <= 0) return null;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="h-5 w-5 text-chart-3" />
Demand Summary (7-day avg)
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Average regional demand:{' '}
<span className="font-mono font-semibold text-foreground">{formatNumber(avgDemand)} MW</span>
</p>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,32 @@
import { GpuCalculator } from '@/components/dashboard/gpu-calculator.js';
import { fetchLatestPrices } from '@/actions/prices.js';
import { deserialize } from '@/lib/superjson.js';
export async function GpuCalculatorSection() {
const pricesResult = await fetchLatestPrices();
const prices = pricesResult.ok
? deserialize<
Array<{
price_mwh: number;
region_code: string;
region_name: string;
}>
>(pricesResult.data)
: [];
const regionPrices = prices.map(p => ({
regionCode: p.region_code,
regionName: p.region_name,
priceMwh: p.price_mwh,
}));
if (regionPrices.length === 0) return null;
return (
<div className="mt-8">
<GpuCalculator regionPrices={regionPrices} />
</div>
);
}

View File

@ -0,0 +1,103 @@
import { MetricCard } from '@/components/dashboard/metric-card.js';
import { Activity, BarChart3, Droplets, Flame, Server } from 'lucide-react';
import { fetchDatacenters } from '@/actions/datacenters.js';
import { fetchLatestCommodityPrices, fetchLatestPrices, fetchPriceSparklines } from '@/actions/prices.js';
import { deserialize } from '@/lib/superjson.js';
function formatNumber(value: number, decimals = 1): string {
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(decimals)}M`;
if (value >= 1_000) return `${(value / 1_000).toFixed(decimals)}K`;
return value.toFixed(decimals);
}
export async function HeroMetrics() {
const [pricesResult, commoditiesResult, datacentersResult, sparklinesResult] = await Promise.all([
fetchLatestPrices(),
fetchLatestCommodityPrices(),
fetchDatacenters(),
fetchPriceSparklines(),
]);
const prices = pricesResult.ok
? deserialize<
Array<{
price_mwh: number;
demand_mw: number;
region_code: string;
region_name: string;
timestamp: Date;
}>
>(pricesResult.data)
: [];
const commodities = commoditiesResult.ok
? deserialize<Array<{ commodity: string; price: number; unit: string; timestamp: Date }>>(commoditiesResult.data)
: [];
const datacenters = datacentersResult.ok
? deserialize<Array<{ id: string; capacityMw: number }>>(datacentersResult.data)
: [];
const sparklines = sparklinesResult.ok
? deserialize<Array<{ region_code: string; points: { value: number }[] }>>(sparklinesResult.data)
: [];
const avgSparkline: { value: number }[] =
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 ? values.reduce((a, b) => a + b, 0) / values.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 wtiCrude = commodities.find(c => c.commodity === 'wti_crude');
const totalCapacityMw = datacenters.reduce((sum, dc) => sum + dc.capacityMw, 0);
const datacenterCount = datacenters.length;
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
<MetricCard
title="Avg Electricity Price"
value={avgPrice > 0 ? `$${avgPrice.toFixed(2)}` : '--'}
numericValue={avgPrice > 0 ? avgPrice : undefined}
animatedFormat={avgPrice > 0 ? 'dollar' : undefined}
unit="/MWh"
icon={<BarChart3 className="h-4 w-4" />}
sparklineData={avgSparkline}
sparklineColor="hsl(210, 90%, 55%)"
/>
<MetricCard
title="Total DC Capacity"
value={formatNumber(totalCapacityMw)}
numericValue={totalCapacityMw > 0 ? totalCapacityMw : undefined}
animatedFormat={totalCapacityMw > 0 ? 'compact' : undefined}
unit="MW"
icon={<Activity className="h-4 w-4" />}
/>
<MetricCard
title="Datacenters Tracked"
value={datacenterCount.toLocaleString()}
icon={<Server className="h-4 w-4" />}
/>
<MetricCard
title="Natural Gas Spot"
value={natGas ? `$${natGas.price.toFixed(2)}` : '--'}
numericValue={natGas?.price}
animatedFormat={natGas ? 'dollar' : undefined}
unit={natGas?.unit ?? '/MMBtu'}
icon={<Flame className="h-4 w-4" />}
/>
<MetricCard
title="WTI Crude Oil"
value={wtiCrude ? `$${wtiCrude.price.toFixed(2)}` : '--'}
numericValue={wtiCrude?.price}
animatedFormat={wtiCrude ? 'dollar' : undefined}
unit={wtiCrude?.unit ?? '/bbl'}
icon={<Droplets className="h-4 w-4" />}
/>
</div>
);
}

View File

@ -0,0 +1,65 @@
import { Sparkline } from '@/components/charts/sparkline.js';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card.js';
import { BarChart3 } from 'lucide-react';
import { fetchLatestPrices, fetchPriceSparklines } from '@/actions/prices.js';
import { deserialize } from '@/lib/superjson.js';
export async function PricesByRegion() {
const [pricesResult, sparklinesResult] = await Promise.all([fetchLatestPrices(), fetchPriceSparklines()]);
const prices = pricesResult.ok
? deserialize<
Array<{
price_mwh: number;
region_code: string;
region_name: string;
}>
>(pricesResult.data)
: [];
const sparklines = sparklinesResult.ok
? deserialize<Array<{ region_code: string; points: { value: number }[] }>>(sparklinesResult.data)
: [];
const sparklineMap: Record<string, { value: number }[]> = {};
for (const s of sparklines) {
sparklineMap[s.region_code] = s.points;
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-5 w-5 text-chart-2" />
Recent Prices by Region
</CardTitle>
</CardHeader>
<CardContent>
{prices.length > 0 ? (
<div className="space-y-3">
{prices.map(p => {
const regionSparkline = sparklineMap[p.region_code];
return (
<div key={p.region_code} className="flex items-center gap-3 text-sm">
<span className="w-16 shrink-0 font-medium">{p.region_code}</span>
<div className="min-w-0 flex-1">
{regionSparkline && regionSparkline.length >= 2 && (
<Sparkline data={regionSparkline} color="hsl(210, 90%, 55%)" height={24} />
)}
</div>
<div className="flex shrink-0 items-baseline gap-1.5">
<span className="font-mono font-semibold">${p.price_mwh.toFixed(2)}</span>
<span className="text-xs text-muted-foreground">/MWh</span>
</div>
</div>
);
})}
</div>
) : (
<p className="text-sm text-muted-foreground">No price data available yet.</p>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,77 @@
import { GridStressGauge } from '@/components/dashboard/grid-stress-gauge.js';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card.js';
import { Gauge } from 'lucide-react';
import { fetchRegionDemandSummary } from '@/actions/demand.js';
import { deserialize } from '@/lib/superjson.js';
interface RegionDemandEntry {
regionCode: string;
regionName: string;
avgDemand: number;
peakDemand: number;
}
export async function StressGauges() {
const demandResult = await fetchRegionDemandSummary();
const demandRows = demandResult.ok
? deserialize<
Array<{
avg_demand: number | null;
peak_demand: number | null;
region_code: string;
region_name: string;
}>
>(demandResult.data)
: [];
const regionDemandMap: Record<string, RegionDemandEntry> = {};
for (const row of demandRows) {
const existing = regionDemandMap[row.region_code];
const avg = row.avg_demand ?? 0;
const peak = row.peak_demand ?? 0;
if (!existing) {
regionDemandMap[row.region_code] = {
regionCode: row.region_code,
regionName: row.region_name,
avgDemand: avg,
peakDemand: peak,
};
} else {
if (avg > existing.avgDemand) existing.avgDemand = avg;
if (peak > existing.peakDemand) existing.peakDemand = peak;
}
}
const regionDemandList = Object.values(regionDemandMap).filter(
(r): r is RegionDemandEntry => r !== undefined && r.peakDemand > 0,
);
if (regionDemandList.length === 0) return null;
return (
<div className="mt-8">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Gauge className="h-5 w-5 text-chart-4" />
Grid Stress by Region
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-7">
{regionDemandList.map(r => (
<GridStressGauge
key={r.regionCode}
regionCode={r.regionCode}
regionName={r.regionName}
demandMw={r.avgDemand}
capacityMw={r.peakDemand}
/>
))}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,16 @@
import { fetchDemandByRegion, fetchRegionDemandSummary } from '@/actions/demand.js';
import { DemandChart } from '@/components/charts/demand-chart.js';
import type { getDemandByRegion } from '@/generated/prisma/sql.js';
import { deserialize } from '@/lib/superjson.js';
export async function DemandChartSection() {
const [demandResult, summaryResult] = await Promise.all([
fetchDemandByRegion('ALL', '30d'),
fetchRegionDemandSummary(),
]);
const demandData = demandResult.ok ? deserialize<getDemandByRegion.Result[]>(demandResult.data) : [];
const summaryData = summaryResult.ok ? deserialize<getDemandByRegion.Result[]>(summaryResult.data) : [];
return <DemandChart initialData={demandData} summaryData={summaryData} />;
}

View File

@ -1,24 +1,16 @@
import { fetchDemandByRegion, fetchRegionDemandSummary } from '@/actions/demand.js';
import { DemandChart } from '@/components/charts/demand-chart.js';
import type { getDemandByRegion } from '@/generated/prisma/sql.js';
import { deserialize } from '@/lib/superjson.js';
import { Suspense } from 'react';
import { ChartSkeleton } from '@/components/charts/chart-skeleton.js';
import type { Metadata } from 'next';
import { DemandChartSection } from './_sections/demand-chart-section.js';
export const metadata: Metadata = {
title: 'Demand Analysis | Energy & AI Dashboard',
description: 'Regional electricity demand growth, peak tracking, and datacenter load impact',
};
export default async function DemandPage() {
const [demandResult, summaryResult] = await Promise.all([
fetchDemandByRegion('ALL', '30d'),
fetchRegionDemandSummary(),
]);
const demandData = demandResult.ok ? deserialize<getDemandByRegion.Result[]>(demandResult.data) : [];
const summaryData = summaryResult.ok ? deserialize<getDemandByRegion.Result[]>(summaryResult.data) : [];
export default function DemandPage() {
return (
<div className="px-4 py-6 sm:px-6 sm:py-8">
<div className="mb-6 sm:mb-8">
@ -29,7 +21,9 @@ export default async function DemandPage() {
</p>
</div>
<DemandChart initialData={demandData} summaryData={summaryData} />
<Suspense fallback={<ChartSkeleton />}>
<DemandChartSection />
</Suspense>
</div>
);
}

View File

@ -0,0 +1,21 @@
import { fetchGenerationMix } from '@/actions/generation.js';
import { GenerationChart } from '@/components/charts/generation-chart.js';
const DEFAULT_REGION = 'PJM';
const DEFAULT_TIME_RANGE = '30d' as const;
export async function GenerationChartSection() {
const result = await fetchGenerationMix(DEFAULT_REGION, DEFAULT_TIME_RANGE);
if (!result.ok) {
return (
<div className="flex h-96 items-center justify-center rounded-lg border border-dashed border-border">
<p className="text-sm text-destructive">{result.error}</p>
</div>
);
}
return (
<GenerationChart initialData={result.data} initialRegion={DEFAULT_REGION} initialTimeRange={DEFAULT_TIME_RANGE} />
);
}

View File

@ -1,33 +1,16 @@
import { Suspense } from 'react';
import { ChartSkeleton } from '@/components/charts/chart-skeleton.js';
import type { Metadata } from 'next';
import { fetchGenerationMix } from '@/actions/generation.js';
import { GenerationChart } from '@/components/charts/generation-chart.js';
import { GenerationChartSection } from './_sections/generation-chart-section.js';
export const metadata: Metadata = {
title: 'Generation Mix | Energy & AI Dashboard',
description: 'Generation by fuel type per region, renewable vs fossil splits, and carbon intensity',
};
const DEFAULT_REGION = 'PJM';
const DEFAULT_TIME_RANGE = '30d' as const;
export default async function GenerationPage() {
const result = await fetchGenerationMix(DEFAULT_REGION, DEFAULT_TIME_RANGE);
if (!result.ok) {
return (
<div className="px-4 py-6 sm:px-6 sm:py-8">
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">Generation Mix</h1>
<p className="mt-2 text-muted-foreground">
Stacked area charts showing generation by fuel type per region, with renewable vs fossil comparisons.
</p>
<div className="mt-8 flex h-96 items-center justify-center rounded-lg border border-dashed border-border">
<p className="text-sm text-destructive">{result.error}</p>
</div>
</div>
);
}
export default function GenerationPage() {
return (
<div className="px-4 py-6 sm:px-6 sm:py-8">
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">Generation Mix</h1>
@ -37,11 +20,9 @@ export default async function GenerationPage() {
</p>
<div className="mt-8">
<GenerationChart
initialData={result.data}
initialRegion={DEFAULT_REGION}
initialTimeRange={DEFAULT_TIME_RANGE}
/>
<Suspense fallback={<ChartSkeleton />}>
<GenerationChartSection />
</Suspense>
</div>
</div>
);

View File

@ -0,0 +1,92 @@
import { fetchAllDatacentersWithLocation } from '@/actions/datacenters.js';
import { fetchPriceHeatmapData } from '@/actions/prices.js';
import type { DatacenterMarkerData } from '@/components/map/datacenter-marker.js';
import { EnergyMapLoader } from '@/components/map/energy-map-loader.js';
import type { RegionHeatmapData } from '@/components/map/region-overlay.js';
import type { getAllDatacentersWithLocation, getRegionPriceHeatmap } from '@/generated/prisma/sql.js';
import { deserialize } from '@/lib/superjson.js';
interface GeoJsonPoint {
type: string;
coordinates: [number, number];
}
function isGeoJsonPoint(val: unknown): val is GeoJsonPoint {
if (typeof val !== 'object' || val === null || !('coordinates' in val)) return false;
const obj = val as Record<string, unknown>;
const coords = obj['coordinates'];
if (!Array.isArray(coords) || coords.length < 2) return false;
return typeof coords[0] === 'number' && typeof coords[1] === 'number';
}
function parseLocationGeoJson(geojson: string | null): { lat: number; lng: number } | null {
if (!geojson) return null;
try {
const parsed: unknown = JSON.parse(geojson);
if (isGeoJsonPoint(parsed)) {
return { lat: parsed.coordinates[1], lng: parsed.coordinates[0] };
}
} catch {
// Invalid JSON
}
return null;
}
function parseBoundaryGeoJson(geojsonStr: string | null): object | null {
if (!geojsonStr) return null;
try {
const parsed: unknown = JSON.parse(geojsonStr);
if (typeof parsed === 'object' && parsed !== null) {
return parsed;
}
} catch {
// Invalid JSON
}
return null;
}
export async function MapContent() {
const [dcResult, priceResult] = await Promise.all([fetchAllDatacentersWithLocation(), fetchPriceHeatmapData()]);
const datacenters: DatacenterMarkerData[] = [];
if (dcResult.ok) {
const rows = deserialize<getAllDatacentersWithLocation.Result[]>(dcResult.data);
for (const row of rows) {
const loc = parseLocationGeoJson(row.location_geojson);
if (loc) {
datacenters.push({
id: row.id,
name: row.name,
operator: row.operator,
capacity_mw: row.capacity_mw,
status: row.status,
year_opened: row.year_opened,
region_id: row.region_id,
region_code: row.region_code,
region_name: row.region_name,
lat: loc.lat,
lng: loc.lng,
});
}
}
}
const regions: RegionHeatmapData[] = [];
if (priceResult.ok) {
const rows = deserialize<getRegionPriceHeatmap.Result[]>(priceResult.data);
for (const row of rows) {
regions.push({
code: row.code,
name: row.name,
boundaryGeoJson: parseBoundaryGeoJson(row.boundary_geojson),
avgPrice: row.avg_price,
maxPrice: row.max_price,
avgDemand: row.avg_demand,
datacenterCount: row.datacenter_count,
totalDcCapacityMw: row.total_dc_capacity_mw,
});
}
}
return <EnergyMapLoader datacenters={datacenters} regions={regions} />;
}

View File

@ -1,102 +1,25 @@
import { fetchAllDatacentersWithLocation } from '@/actions/datacenters.js';
import { fetchPriceHeatmapData } from '@/actions/prices.js';
import type { DatacenterMarkerData } from '@/components/map/datacenter-marker.js';
import { EnergyMapLoader } from '@/components/map/energy-map-loader.js';
import type { RegionHeatmapData } from '@/components/map/region-overlay.js';
import type { getAllDatacentersWithLocation, getRegionPriceHeatmap } from '@/generated/prisma/sql.js';
import { deserialize } from '@/lib/superjson.js';
import { Suspense } from 'react';
import { Skeleton } from '@/components/ui/skeleton.js';
import type { Metadata } from 'next';
import { MapContent } from './_sections/map-content.js';
export const metadata: Metadata = {
title: 'Interactive Map | Energy & AI Dashboard',
description: 'Datacenter locations, grid region overlays, and real-time electricity price heatmap',
};
interface GeoJsonPoint {
type: string;
coordinates: [number, number];
function MapSkeleton() {
return <Skeleton className="h-full w-full rounded-none" />;
}
function isGeoJsonPoint(val: unknown): val is GeoJsonPoint {
if (typeof val !== 'object' || val === null || !('coordinates' in val)) return false;
const obj = val as Record<string, unknown>;
const coords = obj['coordinates'];
if (!Array.isArray(coords) || coords.length < 2) return false;
return typeof coords[0] === 'number' && typeof coords[1] === 'number';
}
function parseLocationGeoJson(geojson: string | null): { lat: number; lng: number } | null {
if (!geojson) return null;
try {
const parsed: unknown = JSON.parse(geojson);
if (isGeoJsonPoint(parsed)) {
return { lat: parsed.coordinates[1], lng: parsed.coordinates[0] };
}
} catch {
// Invalid JSON
}
return null;
}
function parseBoundaryGeoJson(geojsonStr: string | null): object | null {
if (!geojsonStr) return null;
try {
const parsed: unknown = JSON.parse(geojsonStr);
if (typeof parsed === 'object' && parsed !== null) {
return parsed;
}
} catch {
// Invalid JSON
}
return null;
}
export default async function MapPage() {
const [dcResult, priceResult] = await Promise.all([fetchAllDatacentersWithLocation(), fetchPriceHeatmapData()]);
const datacenters: DatacenterMarkerData[] = [];
if (dcResult.ok) {
const rows = deserialize<getAllDatacentersWithLocation.Result[]>(dcResult.data);
for (const row of rows) {
const loc = parseLocationGeoJson(row.location_geojson);
if (loc) {
datacenters.push({
id: row.id,
name: row.name,
operator: row.operator,
capacity_mw: row.capacity_mw,
status: row.status,
year_opened: row.year_opened,
region_id: row.region_id,
region_code: row.region_code,
region_name: row.region_name,
lat: loc.lat,
lng: loc.lng,
});
}
}
}
const regions: RegionHeatmapData[] = [];
if (priceResult.ok) {
const rows = deserialize<getRegionPriceHeatmap.Result[]>(priceResult.data);
for (const row of rows) {
regions.push({
code: row.code,
name: row.name,
boundaryGeoJson: parseBoundaryGeoJson(row.boundary_geojson),
avgPrice: row.avg_price,
maxPrice: row.max_price,
avgDemand: row.avg_demand,
datacenterCount: row.datacenter_count,
totalDcCapacityMw: row.total_dc_capacity_mw,
});
}
}
export default function MapPage() {
return (
<div className="h-[calc(100vh-3.5rem-3rem)]">
<EnergyMapLoader datacenters={datacenters} regions={regions} />
<Suspense fallback={<MapSkeleton />}>
<MapContent />
</Suspense>
</div>
);
}

View File

@ -1,134 +1,109 @@
import { Sparkline } from '@/components/charts/sparkline.js';
import { AlertsFeed } from '@/components/dashboard/alerts-feed.js';
import { GpuCalculator } from '@/components/dashboard/gpu-calculator.js';
import { GridStressGauge } from '@/components/dashboard/grid-stress-gauge.js';
import { MetricCard } from '@/components/dashboard/metric-card.js';
import { Suspense } from 'react';
import { ChartSkeleton } from '@/components/charts/chart-skeleton.js';
import { MetricCardSkeleton } from '@/components/dashboard/metric-card-skeleton.js';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card.js';
import { deserialize } from '@/lib/superjson.js';
import { Activity, ArrowRight, BarChart3, Droplets, Flame, Gauge, Map as MapIcon, Server } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton.js';
import { Activity, ArrowRight, Map as MapIcon } from 'lucide-react';
import Link from 'next/link';
import { fetchDatacenters } from '@/actions/datacenters.js';
import { fetchRegionDemandSummary } from '@/actions/demand.js';
import {
fetchLatestCommodityPrices,
fetchLatestPrices,
fetchPriceSparklines,
fetchRecentAlerts,
} from '@/actions/prices.js';
import { AlertsSection } from './_sections/alerts-section.js';
import { DemandSummary } from './_sections/demand-summary.js';
import { GpuCalculatorSection } from './_sections/gpu-calculator-section.js';
import { HeroMetrics } from './_sections/hero-metrics.js';
import { PricesByRegion } from './_sections/prices-by-region.js';
import { StressGauges } from './_sections/stress-gauges.js';
function formatNumber(value: number, decimals = 1): string {
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(decimals)}M`;
if (value >= 1_000) return `${(value / 1_000).toFixed(decimals)}K`;
return value.toFixed(decimals);
function MetricCardsSkeleton() {
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
{Array.from({ length: 5 }).map((_, i) => (
<MetricCardSkeleton key={i} />
))}
</div>
);
}
export default async function DashboardHome() {
const [pricesResult, commoditiesResult, datacentersResult, demandResult, sparklinesResult, alertsResult] =
await Promise.all([
fetchLatestPrices(),
fetchLatestCommodityPrices(),
fetchDatacenters(),
fetchRegionDemandSummary(),
fetchPriceSparklines(),
fetchRecentAlerts(),
]);
const prices = pricesResult.ok
? deserialize<
Array<{
price_mwh: number;
demand_mw: number;
region_code: string;
region_name: string;
timestamp: Date;
}>
>(pricesResult.data)
: [];
const commodities = commoditiesResult.ok
? deserialize<Array<{ commodity: string; price: number; unit: string; timestamp: Date }>>(commoditiesResult.data)
: [];
const datacenters = datacentersResult.ok
? deserialize<Array<{ id: string; capacityMw: number }>>(datacentersResult.data)
: [];
const demandRows = demandResult.ok
? deserialize<
Array<{
avg_demand: number | null;
peak_demand: number | null;
region_code: string;
region_name: string;
}>
>(demandResult.data)
: [];
const sparklines = sparklinesResult.ok
? deserialize<Array<{ region_code: string; points: { value: number }[] }>>(sparklinesResult.data)
: [];
const sparklineMap: Record<string, { value: number }[]> = {};
for (const s of sparklines) {
sparklineMap[s.region_code] = s.points;
}
// Build an aggregate sparkline from all regions (average price per time slot)
const avgSparkline: { value: number }[] =
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 ? values.reduce((a, b) => a + b, 0) / values.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 wtiCrude = commodities.find(c => c.commodity === 'wti_crude');
const totalCapacityMw = datacenters.reduce((sum, dc) => sum + dc.capacityMw, 0);
const datacenterCount = datacenters.length;
const avgDemand =
demandRows.length > 0 ? demandRows.reduce((sum, r) => sum + (r.avg_demand ?? 0), 0) / demandRows.length : 0;
const regionPrices = prices.map(p => ({
regionCode: p.region_code,
regionName: p.region_name,
priceMwh: p.price_mwh,
}));
// Aggregate demand by region for stress gauges: track latest avg_demand and peak_demand
interface RegionDemandEntry {
regionCode: string;
regionName: string;
avgDemand: number;
peakDemand: number;
}
const regionDemandMap: Record<string, RegionDemandEntry> = {};
for (const row of demandRows) {
const existing = regionDemandMap[row.region_code];
const avg = row.avg_demand ?? 0;
const peak = row.peak_demand ?? 0;
if (!existing) {
regionDemandMap[row.region_code] = {
regionCode: row.region_code,
regionName: row.region_name,
avgDemand: avg,
peakDemand: peak,
};
} else {
if (avg > existing.avgDemand) existing.avgDemand = avg;
if (peak > existing.peakDemand) existing.peakDemand = peak;
}
}
const regionDemandList = Object.values(regionDemandMap).filter(
(r): r is RegionDemandEntry => r !== undefined && r.peakDemand > 0,
function PricesByRegionSkeleton() {
return (
<Card>
<CardHeader>
<Skeleton className="h-5 w-48" />
</CardHeader>
<CardContent>
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center justify-between">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-20" />
</div>
))}
</div>
</CardContent>
</Card>
);
}
function AlertsSkeleton() {
return (
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent>
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center gap-3">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-32" />
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
}
function GaugesSkeleton() {
return (
<Card>
<CardHeader>
<Skeleton className="h-5 w-44" />
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-7">
{Array.from({ length: 7 }).map((_, i) => (
<div key={i} className="flex flex-col items-center gap-2">
<Skeleton className="h-20 w-20 rounded-full" />
<Skeleton className="h-3 w-12" />
</div>
))}
</div>
</CardContent>
</Card>
);
}
function DemandSummarySkeleton() {
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Activity className="h-5 w-5 text-chart-3" />
<Skeleton className="h-5 w-44" />
</div>
</CardHeader>
<CardContent>
<Skeleton className="h-4 w-64" />
</CardContent>
</Card>
);
}
export default function DashboardHome() {
return (
<div className="px-4 py-6 sm:px-6 sm:py-8">
<div className="mb-6 sm:mb-8">
@ -138,47 +113,9 @@ export default async function DashboardHome() {
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
<MetricCard
title="Avg Electricity Price"
value={avgPrice > 0 ? `$${avgPrice.toFixed(2)}` : '--'}
numericValue={avgPrice > 0 ? avgPrice : undefined}
animatedFormat={avgPrice > 0 ? 'dollar' : undefined}
unit="/MWh"
icon={<BarChart3 className="h-4 w-4" />}
sparklineData={avgSparkline}
sparklineColor="hsl(210, 90%, 55%)"
/>
<MetricCard
title="Total DC Capacity"
value={formatNumber(totalCapacityMw)}
numericValue={totalCapacityMw > 0 ? totalCapacityMw : undefined}
animatedFormat={totalCapacityMw > 0 ? 'compact' : undefined}
unit="MW"
icon={<Activity className="h-4 w-4" />}
/>
<MetricCard
title="Datacenters Tracked"
value={datacenterCount.toLocaleString()}
icon={<Server className="h-4 w-4" />}
/>
<MetricCard
title="Natural Gas Spot"
value={natGas ? `$${natGas.price.toFixed(2)}` : '--'}
numericValue={natGas?.price}
animatedFormat={natGas ? 'dollar' : undefined}
unit={natGas?.unit ?? '/MMBtu'}
icon={<Flame className="h-4 w-4" />}
/>
<MetricCard
title="WTI Crude Oil"
value={wtiCrude ? `$${wtiCrude.price.toFixed(2)}` : '--'}
numericValue={wtiCrude?.price}
animatedFormat={wtiCrude ? 'dollar' : undefined}
unit={wtiCrude?.unit ?? '/bbl'}
icon={<Droplets className="h-4 w-4" />}
/>
</div>
<Suspense fallback={<MetricCardsSkeleton />}>
<HeroMetrics />
</Suspense>
<div className="mt-8 grid gap-4 lg:grid-cols-2">
<Link href="/map">
@ -200,92 +137,27 @@ export default async function DashboardHome() {
</Card>
</Link>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-5 w-5 text-chart-2" />
Recent Prices by Region
</CardTitle>
</CardHeader>
<CardContent>
{prices.length > 0 ? (
<div className="space-y-3">
{prices.map(p => {
const regionSparkline = sparklineMap[p.region_code];
return (
<div key={p.region_code} className="flex items-center gap-3 text-sm">
<span className="w-16 shrink-0 font-medium">{p.region_code}</span>
<div className="min-w-0 flex-1">
{regionSparkline && regionSparkline.length >= 2 && (
<Sparkline data={regionSparkline} color="hsl(210, 90%, 55%)" height={24} />
)}
</div>
<div className="flex shrink-0 items-baseline gap-1.5">
<span className="font-mono font-semibold">${p.price_mwh.toFixed(2)}</span>
<span className="text-xs text-muted-foreground">/MWh</span>
</div>
</div>
);
})}
</div>
) : (
<p className="text-sm text-muted-foreground">No price data available yet.</p>
)}
</CardContent>
</Card>
<Suspense fallback={<PricesByRegionSkeleton />}>
<PricesByRegion />
</Suspense>
</div>
{regionPrices.length > 0 && (
<div className="mt-8">
<GpuCalculator regionPrices={regionPrices} />
</div>
)}
<Suspense fallback={<ChartSkeleton className="mt-8" />}>
<GpuCalculatorSection />
</Suspense>
{regionDemandList.length > 0 && (
<div className="mt-8">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Gauge className="h-5 w-5 text-chart-4" />
Grid Stress by Region
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-7">
{regionDemandList.map(r => (
<GridStressGauge
key={r.regionCode}
regionCode={r.regionCode}
regionName={r.regionName}
demandMw={r.avgDemand}
capacityMw={r.peakDemand}
/>
))}
</div>
</CardContent>
</Card>
</div>
)}
<Suspense fallback={<GaugesSkeleton />}>
<StressGauges />
</Suspense>
<div className="mt-8 grid gap-4 lg:grid-cols-2">
{alertsResult.ok && <AlertsFeed initialData={alertsResult.data} />}
<Suspense fallback={<AlertsSkeleton />}>
<AlertsSection />
</Suspense>
{avgDemand > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="h-5 w-5 text-chart-3" />
Demand Summary (7-day avg)
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Average regional demand:{' '}
<span className="font-mono font-semibold text-foreground">{formatNumber(avgDemand)} MW</span>
</p>
</CardContent>
</Card>
)}
<Suspense fallback={<DemandSummarySkeleton />}>
<DemandSummary />
</Suspense>
</div>
</div>
);

View File

@ -0,0 +1,16 @@
import { fetchRegionCapacityVsPrice } from '@/actions/prices.js';
import { CorrelationChart } from '@/components/charts/correlation-chart.js';
export async function CorrelationSection() {
const correlationResult = await fetchRegionCapacityVsPrice();
if (!correlationResult.ok) {
return (
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-border">
<p className="text-sm text-destructive">{correlationResult.error}</p>
</div>
);
}
return <CorrelationChart data={correlationResult.data} />;
}

View File

@ -0,0 +1,64 @@
import { readFile } from 'fs/promises';
import { join } from 'path';
import { z } from 'zod';
import { fetchAllRegionPriceTrends, fetchCommodityTrends } from '@/actions/prices.js';
import type { AIMilestone } from '@/components/charts/price-chart.js';
import { PriceChart } from '@/components/charts/price-chart.js';
const AIMilestoneSchema = z.object({
date: z.string(),
title: z.string(),
description: z.string(),
category: z.string(),
}) satisfies z.ZodType<AIMilestone>;
async function loadMilestones(): Promise<AIMilestone[]> {
try {
const filePath = join(process.cwd(), 'data', 'ai-milestones.json');
const raw = await readFile(filePath, 'utf-8');
const parsed: unknown = JSON.parse(raw);
return z.array(AIMilestoneSchema).parse(parsed);
} catch {
return [];
}
}
export async function PriceChartSection() {
const defaultRange = '30d' as const;
const [priceResult, commodityResult, milestones] = await Promise.all([
fetchAllRegionPriceTrends(defaultRange),
fetchCommodityTrends(defaultRange),
loadMilestones(),
]);
if (!priceResult.ok || !commodityResult.ok) {
return (
<div className="flex h-96 items-center justify-center rounded-lg border border-dashed border-border">
<p className="text-sm text-destructive">
{!priceResult.ok ? priceResult.error : !commodityResult.ok ? commodityResult.error : ''}
</p>
</div>
);
}
return (
<PriceChart
initialPriceData={priceResult.data}
initialCommodityData={commodityResult.data}
milestones={milestones}
initialTimeRange={defaultRange}
onTimeRangeChange={async range => {
'use server';
const [prices, commodities] = await Promise.all([
fetchAllRegionPriceTrends(range),
fetchCommodityTrends(range),
]);
if (!prices.ok) throw new Error(prices.error);
if (!commodities.ok) throw new Error(commodities.error);
return { prices: prices.data, commodities: commodities.data };
}}
/>
);
}

View File

@ -1,46 +1,17 @@
import { readFile } from 'fs/promises';
import type { Metadata } from 'next';
import { join } from 'path';
import { z } from 'zod';
import { Suspense } from 'react';
import { fetchAllRegionPriceTrends, fetchCommodityTrends, fetchRegionCapacityVsPrice } from '@/actions/prices.js';
import { CorrelationChart } from '@/components/charts/correlation-chart.js';
import type { AIMilestone } from '@/components/charts/price-chart.js';
import { PriceChart } from '@/components/charts/price-chart.js';
import { ChartSkeleton } from '@/components/charts/chart-skeleton.js';
import type { Metadata } from 'next';
import { CorrelationSection } from './_sections/correlation-section.js';
import { PriceChartSection } from './_sections/price-chart-section.js';
export const metadata: Metadata = {
title: 'Price Trends | Energy & AI Dashboard',
description: 'Regional electricity price trends, commodity overlays, and AI milestone annotations',
};
const AIMilestoneSchema = z.object({
date: z.string(),
title: z.string(),
description: z.string(),
category: z.string(),
}) satisfies z.ZodType<AIMilestone>;
async function loadMilestones(): Promise<AIMilestone[]> {
try {
const filePath = join(process.cwd(), 'data', 'ai-milestones.json');
const raw = await readFile(filePath, 'utf-8');
const parsed: unknown = JSON.parse(raw);
return z.array(AIMilestoneSchema).parse(parsed);
} catch {
return [];
}
}
export default async function TrendsPage() {
const defaultRange = '30d' as const;
const [priceResult, commodityResult, correlationResult, milestones] = await Promise.all([
fetchAllRegionPriceTrends(defaultRange),
fetchCommodityTrends(defaultRange),
fetchRegionCapacityVsPrice(),
loadMilestones(),
]);
export default function TrendsPage() {
return (
<div className="space-y-6 px-4 py-6 sm:px-6 sm:py-8">
<div>
@ -50,38 +21,13 @@ export default async function TrendsPage() {
</p>
</div>
{priceResult.ok && commodityResult.ok ? (
<PriceChart
initialPriceData={priceResult.data}
initialCommodityData={commodityResult.data}
milestones={milestones}
initialTimeRange={defaultRange}
onTimeRangeChange={async range => {
'use server';
const [prices, commodities] = await Promise.all([
fetchAllRegionPriceTrends(range),
fetchCommodityTrends(range),
]);
if (!prices.ok) throw new Error(prices.error);
if (!commodities.ok) throw new Error(commodities.error);
return { prices: prices.data, commodities: commodities.data };
}}
/>
) : (
<div className="flex h-96 items-center justify-center rounded-lg border border-dashed border-border">
<p className="text-sm text-destructive">
{!priceResult.ok ? priceResult.error : !commodityResult.ok ? commodityResult.error : ''}
</p>
</div>
)}
<Suspense fallback={<ChartSkeleton />}>
<PriceChartSection />
</Suspense>
{correlationResult.ok ? (
<CorrelationChart data={correlationResult.data} />
) : (
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-border">
<p className="text-sm text-destructive">{correlationResult.error}</p>
</div>
)}
<Suspense fallback={<ChartSkeleton />}>
<CorrelationSection />
</Suspense>
</div>
);
}

View File

@ -129,7 +129,7 @@ export function CorrelationChart({ data }: CorrelationChartProps) {
type="number"
dataKey="total_capacity_mw"
name="DC Capacity"
tick={{ fontSize: 11 }}
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
tickFormatter={(v: number) => `${v} MW`}>
@ -144,7 +144,7 @@ export function CorrelationChart({ data }: CorrelationChartProps) {
type="number"
dataKey="avg_price"
name="Avg Price"
tick={{ fontSize: 11 }}
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
tickFormatter={(v: number) => `$${v}`}>

View File

@ -16,7 +16,7 @@ import {
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select.js';
import type { getGenerationMix } from '@/generated/prisma/sql.js';
import { deserialize } from '@/lib/superjson.js';
import { formatMarketTime } from '@/lib/utils.js';
import { formatMarketDate, formatMarketDateTime, formatMarketTime } from '@/lib/utils.js';
type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y';
@ -277,12 +277,21 @@ export function GenerationChart({ initialData, initialRegion, initialTimeRange }
</defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-border/50" />
<XAxis
dataKey="dateLabel"
dataKey="timestamp"
type="number"
domain={['dataMin', 'dataMax']}
scale="time"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={40}
className="text-xs"
tickFormatter={(ts: number) => {
const d = new Date(ts);
if (timeRange === '24h') return formatMarketTime(d, regionCode);
if (timeRange === '7d') return formatMarketDateTime(d, regionCode);
return formatMarketDate(d, regionCode);
}}
/>
<YAxis
tickLine={false}
@ -302,7 +311,7 @@ export function GenerationChart({ initialData, initialRegion, initialTimeRange }
'timestamp' in firstPayload &&
typeof firstPayload.timestamp === 'number'
) {
return formatMarketTime(new Date(firstPayload.timestamp), regionCode);
return formatMarketDateTime(new Date(firstPayload.timestamp), regionCode);
}
return '';
}}

View File

@ -10,17 +10,18 @@ import { useMemo, useState } from 'react';
import { AnimatedNumber } from './animated-number.js';
const GPU_MODELS = {
B200: { watts: 1000, label: 'NVIDIA B200' },
R200: { watts: 1800, label: 'R200 (Rubin)' },
'H100 SXM': { watts: 700, label: 'NVIDIA H100 SXM' },
'H100 PCIe': { watts: 350, label: 'NVIDIA H100 PCIe' },
H200: { watts: 700, label: 'NVIDIA H200' },
B200: { watts: 1000, label: 'NVIDIA B200' },
'A100 SXM': { watts: 400, label: 'NVIDIA A100 SXM' },
'A100 PCIe': { watts: 300, label: 'NVIDIA A100 PCIe' },
} as const;
type GpuModelKey = keyof typeof GPU_MODELS;
const GPU_MODEL_KEYS: GpuModelKey[] = ['H100 SXM', 'H100 PCIe', 'H200', 'B200', 'A100 SXM', 'A100 PCIe'];
const GPU_MODEL_KEYS: GpuModelKey[] = ['B200', 'R200', 'H100 SXM', 'H100 PCIe', 'H200', 'A100 SXM', 'A100 PCIe'];
function isGpuModelKey(value: string): value is GpuModelKey {
return value in GPU_MODELS;
@ -50,7 +51,7 @@ function formatCurrencyAnimated(n: number): string {
}
export function GpuCalculator({ regionPrices, className }: GpuCalculatorProps) {
const [gpuModel, setGpuModel] = useState<GpuModelKey>('H100 SXM');
const [gpuModel, setGpuModel] = useState<GpuModelKey>('B200');
const [gpuCount, setGpuCount] = useState(1000);
const [selectedRegion, setSelectedRegion] = useState<string>(regionPrices[0]?.regionCode ?? '');

View File

@ -89,14 +89,36 @@ export function DatacenterMarker({ datacenter, onClick, isPulsing = false }: Dat
/>
)}
<div
className="rounded-full border-2 border-white/80 shadow-lg"
className={`rounded-full shadow-lg ${
datacenter.status === 'under_construction'
? 'border-2 border-dashed border-white/80'
: datacenter.status === 'planned'
? 'border-2 border-white/80'
: 'border-2 border-white/80'
}`}
style={{
width: size,
height: size,
backgroundColor: color,
backgroundColor: datacenter.status === 'planned' ? 'transparent' : color,
boxShadow: hovered ? `0 0 12px ${color}80` : `0 2px 4px rgba(0,0,0,0.3)`,
...(datacenter.status === 'planned' ? { borderColor: color } : {}),
}}
/>
{size >= 26 && (
<div
className="pointer-events-none absolute text-white"
style={{
fontSize: '7px',
fontWeight: 700,
lineHeight: 1,
textShadow: '0 1px 3px rgba(0,0,0,0.8)',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
}}>
{datacenter.capacity_mw}
</div>
)}
{hovered && (
<div className="absolute bottom-full left-1/2 z-10 mb-2 -translate-x-1/2 rounded-md bg-zinc-900/95 px-3 py-1.5 text-xs whitespace-nowrap text-zinc-100 shadow-xl">
<div className="font-semibold">{datacenter.name}</div>

View File

@ -1,15 +1,36 @@
'use client';
import { APIProvider, Map } from '@vis.gl/react-google-maps';
import { useCallback, useState } from 'react';
import { AdvancedMarker, APIProvider, ColorScheme, Map } from '@vis.gl/react-google-maps';
import { useCallback, useMemo, useState } from 'react';
import { DatacenterDetailPanel } from './datacenter-detail-panel.js';
import { DatacenterMarker, type DatacenterMarkerData } from './datacenter-marker.js';
import { MapControls } from './map-controls.js';
import { MapLegend } from './map-legend.js';
import { RegionDetailPanel } from './region-detail-panel.js';
import { RegionOverlay, type RegionHeatmapData } from './region-overlay.js';
const US_CENTER = { lat: 39.8, lng: -98.5 };
const DEFAULT_ZOOM = 4;
const US_CENTER = { lat: 35.9132, lng: -79.0558 };
const DEFAULT_ZOOM = 6;
/** Well-known approximate centroids for US ISO/RTO regions. */
const REGION_CENTROIDS: Record<string, { lat: number; lng: number }> = {
PJM: { lat: 39.5, lng: -77.5 },
ERCOT: { lat: 31.5, lng: -98.5 },
CAISO: { lat: 37.0, lng: -119.5 },
MISO: { lat: 41.5, lng: -90.0 },
SPP: { lat: 36.5, lng: -98.0 },
ISONE: { lat: 42.5, lng: -71.8 },
NYISO: { lat: 42.5, lng: -75.5 },
};
function priceToLabelBorderColor(price: number | null): string {
if (price === null || price <= 0) return 'rgba(150, 150, 150, 0.5)';
const clamped = Math.min(price, 100);
const ratio = clamped / 100;
if (ratio < 0.3) return 'rgba(59, 130, 246, 0.7)';
if (ratio < 0.6) return 'rgba(245, 158, 11, 0.7)';
return 'rgba(239, 68, 68, 0.7)';
}
interface EnergyMapProps {
datacenters: DatacenterMarkerData[];
@ -44,6 +65,19 @@ export function EnergyMap({ datacenters, regions }: EnergyMapProps) {
setFilteredDatacenters(filtered);
}, []);
const regionLabels = useMemo(
() =>
regions
.filter((r): r is RegionHeatmapData & { avgPrice: number } => r.avgPrice !== null && r.code in REGION_CENTROIDS)
.map(r => ({
code: r.code,
price: r.avgPrice,
position: REGION_CENTROIDS[r.code],
borderColor: priceToLabelBorderColor(r.avgPrice),
})),
[regions],
);
return (
<APIProvider apiKey={apiKey}>
<div className="relative h-full w-full">
@ -54,10 +88,22 @@ export function EnergyMap({ datacenters, regions }: EnergyMapProps) {
defaultCenter={US_CENTER}
defaultZoom={DEFAULT_ZOOM}
gestureHandling="greedy"
disableDefaultUI={false}
colorScheme={ColorScheme.DARK}
disableDefaultUI={true}
className="h-full w-full">
<RegionOverlay regions={regions} onRegionClick={handleRegionClick} />
{regionLabels.map(label => (
<AdvancedMarker key={`label-${label.code}`} position={label.position} zIndex={0}>
<div
className="pointer-events-none rounded bg-zinc-900/80 px-2 py-1 text-xs backdrop-blur"
style={{ borderLeft: `3px solid ${label.borderColor}` }}>
<div className="font-bold text-zinc-100">{label.code}</div>
<div className="text-zinc-400">${Math.round(label.price)}/MWh</div>
</div>
</AdvancedMarker>
))}
{filteredDatacenters.map(dc => {
const dcRegion = regions.find(r => r.code === dc.region_code);
const isPulsing =
@ -72,6 +118,8 @@ export function EnergyMap({ datacenters, regions }: EnergyMapProps) {
})}
</Map>
<MapLegend />
<DatacenterDetailPanel datacenter={selectedDatacenter} onClose={() => setSelectedDatacenter(null)} />
<RegionDetailPanel region={selectedRegion} datacenters={datacenters} onClose={() => setSelectedRegion(null)} />

View File

@ -0,0 +1,62 @@
'use client';
export function MapLegend() {
return (
<div className="absolute right-4 bottom-4 z-10 rounded-lg border border-zinc-700/60 bg-zinc-900/90 p-3 text-xs backdrop-blur-sm">
{/* Price heatmap gradient */}
<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"
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">
<span>$0</span>
<span>$50</span>
<span>$100+</span>
</div>
<div className="mt-0.5 text-zinc-500">$/MWh</div>
</div>
{/* Marker size scale */}
<div className="mb-2.5">
<div className="mb-1 font-medium text-zinc-300">Datacenter Size</div>
<div className="flex items-end gap-2.5">
<div className="flex flex-col items-center gap-0.5">
<div className="h-3.5 w-3.5 rounded-full border border-white/60 bg-zinc-500" />
<span className="text-zinc-500">50</span>
</div>
<div className="flex flex-col items-center gap-0.5">
<div className="h-5 w-5 rounded-full border border-white/60 bg-zinc-500" />
<span className="text-zinc-500">200</span>
</div>
<div className="flex flex-col items-center gap-0.5">
<div className="h-7 w-7 rounded-full border border-white/60 bg-zinc-500" />
<span className="text-zinc-500">500+</span>
</div>
<span className="pb-0.5 text-zinc-500">MW</span>
</div>
</div>
{/* Pulsing icon */}
<div className="mb-1.5 flex items-center gap-2">
<span className="relative flex h-3 w-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-amber-400 opacity-75" />
<span className="relative inline-flex h-3 w-3 rounded-full bg-amber-500" />
</span>
<span className="text-zinc-400">Price spike</span>
</div>
{/* Grid stress glow icon */}
<div className="flex items-center gap-2">
<span className="relative flex h-3 w-3">
<span className="ambient-glow-slow absolute inline-flex h-full w-full rounded-full bg-red-500/60" />
<span className="relative inline-flex h-3 w-3 rounded-full bg-red-500/80" />
</span>
<span className="text-zinc-400">Grid stress &gt;85%</span>
</div>
</div>
);
}

View File

@ -81,7 +81,7 @@ export function RegionOverlay({ regions, onRegionClick }: RegionOverlayProps) {
const hoveredFeatureRef = useRef<google.maps.Data.Feature | null>(null);
const breathingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
/** Apply breathing opacity to all non-hovered features. */
/** Apply breathing opacity to stressed regions only (price > 100). Calm regions stay static. */
const applyBreathingFrame = useCallback((dataLayer: google.maps.Data, timestamp: number) => {
dataLayer.forEach(feature => {
// Skip the currently hovered feature — it has its own highlight style
@ -92,12 +92,14 @@ export function RegionOverlay({ regions, onRegionClick }: RegionOverlayProps) {
const regionData = priceMapRef.current.get(code);
const price = regionData?.avgPrice ?? null;
// Only animate stressed regions; calm regions stay completely static
const isStressed = price !== null && price > 100;
if (!isStressed) return;
const baseOpacity = priceToBaseOpacity(price);
// Higher-priced regions breathe faster (higher frequency) and with more amplitude
const priceRatio = price !== null && price > 0 ? Math.min(price, 100) / 100 : 0;
const frequency = 0.8 + priceRatio * 1.2; // 0.8 Hz (cheap) to 2.0 Hz (expensive)
const amplitude = 0.05 + priceRatio * 0.1; // +/- 0.05 (cheap) to +/- 0.15 (expensive)
const frequency = 0.125; // 8-second period
const amplitude = 0.03 + 0.04; // stressed always gets the extra 0.04
const oscillation = Math.sin((timestamp / 1000) * frequency * 2 * Math.PI) * amplitude;
const newOpacity = Math.max(0.1, Math.min(0.5, baseOpacity + oscillation));
@ -194,14 +196,14 @@ export function RegionOverlay({ regions, onRegionClick }: RegionOverlayProps) {
});
listenersRef.current.push(clickListener);
// Breathing animation: ~20 FPS interval driving rAF-scheduled style updates
// Breathing animation: 5 FPS interval driving rAF-scheduled style updates (stressed regions only)
breathingTimerRef.current = setInterval(() => {
requestAnimationFrame(timestamp => {
if (dataLayerRef.current) {
applyBreathingFrame(dataLayerRef.current, timestamp);
}
});
}, 50);
}, 200);
return () => {
if (breathingTimerRef.current !== null) {

View File

@ -31,3 +31,25 @@ export function formatMarketTime(utcDate: Date, regionCode: string): string {
timeZoneName: 'short',
});
}
export function formatMarketDateTime(utcDate: Date, regionCode: string): string {
const timezone = REGION_TIMEZONES[regionCode] ?? 'America/New_York';
return utcDate.toLocaleString('en-US', {
timeZone: timezone,
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
timeZoneName: 'short',
});
}
export function formatMarketDate(utcDate: Date, regionCode: string): string {
const timezone = REGION_TIMEZONES[regionCode] ?? 'America/New_York';
return utcDate.toLocaleString('en-US', {
timeZone: timezone,
month: 'short',
day: 'numeric',
});
}