diff --git a/data/datacenters.geojson b/data/datacenters.geojson index 61a0bf4..a54b6b0 100644 --- a/data/datacenters.geojson +++ b/data/datacenters.geojson @@ -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" + } } ] } diff --git a/src/app/_sections/alerts-section.tsx b/src/app/_sections/alerts-section.tsx new file mode 100644 index 0000000..80f3ec8 --- /dev/null +++ b/src/app/_sections/alerts-section.tsx @@ -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 ; +} diff --git a/src/app/_sections/demand-summary.tsx b/src/app/_sections/demand-summary.tsx new file mode 100644 index 0000000..ff0cdf3 --- /dev/null +++ b/src/app/_sections/demand-summary.tsx @@ -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>(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 ( + + + + + Demand Summary (7-day avg) + + + +

+ Average regional demand:{' '} + {formatNumber(avgDemand)} MW +

+
+
+ ); +} diff --git a/src/app/_sections/gpu-calculator-section.tsx b/src/app/_sections/gpu-calculator-section.tsx new file mode 100644 index 0000000..b4fbc67 --- /dev/null +++ b/src/app/_sections/gpu-calculator-section.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/src/app/_sections/hero-metrics.tsx b/src/app/_sections/hero-metrics.tsx new file mode 100644 index 0000000..fc09898 --- /dev/null +++ b/src/app/_sections/hero-metrics.tsx @@ -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>(commoditiesResult.data) + : []; + + const datacenters = datacentersResult.ok + ? deserialize>(datacentersResult.data) + : []; + + const sparklines = sparklinesResult.ok + ? deserialize>(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 ( +
+ 0 ? `$${avgPrice.toFixed(2)}` : '--'} + numericValue={avgPrice > 0 ? avgPrice : undefined} + animatedFormat={avgPrice > 0 ? 'dollar' : undefined} + unit="/MWh" + icon={} + sparklineData={avgSparkline} + sparklineColor="hsl(210, 90%, 55%)" + /> + 0 ? totalCapacityMw : undefined} + animatedFormat={totalCapacityMw > 0 ? 'compact' : undefined} + unit="MW" + icon={} + /> + } + /> + } + /> + } + /> +
+ ); +} diff --git a/src/app/_sections/prices-by-region.tsx b/src/app/_sections/prices-by-region.tsx new file mode 100644 index 0000000..c35dadd --- /dev/null +++ b/src/app/_sections/prices-by-region.tsx @@ -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>(sparklinesResult.data) + : []; + + const sparklineMap: Record = {}; + for (const s of sparklines) { + sparklineMap[s.region_code] = s.points; + } + + return ( + + + + + Recent Prices by Region + + + + {prices.length > 0 ? ( +
+ {prices.map(p => { + const regionSparkline = sparklineMap[p.region_code]; + return ( +
+ {p.region_code} +
+ {regionSparkline && regionSparkline.length >= 2 && ( + + )} +
+
+ ${p.price_mwh.toFixed(2)} + /MWh +
+
+ ); + })} +
+ ) : ( +

No price data available yet.

+ )} +
+
+ ); +} diff --git a/src/app/_sections/stress-gauges.tsx b/src/app/_sections/stress-gauges.tsx new file mode 100644 index 0000000..0876481 --- /dev/null +++ b/src/app/_sections/stress-gauges.tsx @@ -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 = {}; + 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 ( +
+ + + + + Grid Stress by Region + + + +
+ {regionDemandList.map(r => ( + + ))} +
+
+
+
+ ); +} diff --git a/src/app/demand/_sections/demand-chart-section.tsx b/src/app/demand/_sections/demand-chart-section.tsx new file mode 100644 index 0000000..c228a46 --- /dev/null +++ b/src/app/demand/_sections/demand-chart-section.tsx @@ -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(demandResult.data) : []; + const summaryData = summaryResult.ok ? deserialize(summaryResult.data) : []; + + return ; +} diff --git a/src/app/demand/page.tsx b/src/app/demand/page.tsx index 6d7459b..149e583 100644 --- a/src/app/demand/page.tsx +++ b/src/app/demand/page.tsx @@ -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(demandResult.data) : []; - - const summaryData = summaryResult.ok ? deserialize(summaryResult.data) : []; - +export default function DemandPage() { return (
@@ -29,7 +21,9 @@ export default async function DemandPage() {

- + }> + +
); } diff --git a/src/app/generation/_sections/generation-chart-section.tsx b/src/app/generation/_sections/generation-chart-section.tsx new file mode 100644 index 0000000..4bb2b71 --- /dev/null +++ b/src/app/generation/_sections/generation-chart-section.tsx @@ -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 ( +
+

{result.error}

+
+ ); + } + + return ( + + ); +} diff --git a/src/app/generation/page.tsx b/src/app/generation/page.tsx index 2ac12df..58b5916 100644 --- a/src/app/generation/page.tsx +++ b/src/app/generation/page.tsx @@ -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 ( -
-

Generation Mix

-

- Stacked area charts showing generation by fuel type per region, with renewable vs fossil comparisons. -

-
-

{result.error}

-
-
- ); - } - +export default function GenerationPage() { return (

Generation Mix

@@ -37,11 +20,9 @@ export default async function GenerationPage() {

- + }> + +
); diff --git a/src/app/map/_sections/map-content.tsx b/src/app/map/_sections/map-content.tsx new file mode 100644 index 0000000..42764b6 --- /dev/null +++ b/src/app/map/_sections/map-content.tsx @@ -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; + 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(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(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 ; +} diff --git a/src/app/map/page.tsx b/src/app/map/page.tsx index 7bc9755..e67b045 100644 --- a/src/app/map/page.tsx +++ b/src/app/map/page.tsx @@ -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 ; } -function isGeoJsonPoint(val: unknown): val is GeoJsonPoint { - if (typeof val !== 'object' || val === null || !('coordinates' in val)) return false; - const obj = val as Record; - 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(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(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 (
- + }> + +
); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 8721547..14088b2 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ); } -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>(commoditiesResult.data) - : []; - - const datacenters = datacentersResult.ok - ? deserialize>(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>(sparklinesResult.data) - : []; - - const sparklineMap: Record = {}; - 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 = {}; - 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 ( + + + + + +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ + +
+ ))} +
+
+
); +} +function AlertsSkeleton() { + return ( + + + + + +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+
+
+ ); +} + +function GaugesSkeleton() { + return ( + + + + + +
+ {Array.from({ length: 7 }).map((_, i) => ( +
+ + +
+ ))} +
+
+
+ ); +} + +function DemandSummarySkeleton() { + return ( + + +
+ + +
+
+ + + +
+ ); +} + +export default function DashboardHome() { return (
@@ -138,47 +113,9 @@ export default async function DashboardHome() {

-
- 0 ? `$${avgPrice.toFixed(2)}` : '--'} - numericValue={avgPrice > 0 ? avgPrice : undefined} - animatedFormat={avgPrice > 0 ? 'dollar' : undefined} - unit="/MWh" - icon={} - sparklineData={avgSparkline} - sparklineColor="hsl(210, 90%, 55%)" - /> - 0 ? totalCapacityMw : undefined} - animatedFormat={totalCapacityMw > 0 ? 'compact' : undefined} - unit="MW" - icon={} - /> - } - /> - } - /> - } - /> -
+ }> + +
@@ -200,92 +137,27 @@ export default async function DashboardHome() { - - - - - Recent Prices by Region - - - - {prices.length > 0 ? ( -
- {prices.map(p => { - const regionSparkline = sparklineMap[p.region_code]; - return ( -
- {p.region_code} -
- {regionSparkline && regionSparkline.length >= 2 && ( - - )} -
-
- ${p.price_mwh.toFixed(2)} - /MWh -
-
- ); - })} -
- ) : ( -

No price data available yet.

- )} -
-
+ }> + +
- {regionPrices.length > 0 && ( -
- -
- )} + }> + + - {regionDemandList.length > 0 && ( -
- - - - - Grid Stress by Region - - - -
- {regionDemandList.map(r => ( - - ))} -
-
-
-
- )} + }> + +
- {alertsResult.ok && } + }> + + - {avgDemand > 0 && ( - - - - - Demand Summary (7-day avg) - - - -

- Average regional demand:{' '} - {formatNumber(avgDemand)} MW -

-
-
- )} + }> + +
); diff --git a/src/app/trends/_sections/correlation-section.tsx b/src/app/trends/_sections/correlation-section.tsx new file mode 100644 index 0000000..413ba96 --- /dev/null +++ b/src/app/trends/_sections/correlation-section.tsx @@ -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 ( +
+

{correlationResult.error}

+
+ ); + } + + return ; +} diff --git a/src/app/trends/_sections/price-chart-section.tsx b/src/app/trends/_sections/price-chart-section.tsx new file mode 100644 index 0000000..8b3e514 --- /dev/null +++ b/src/app/trends/_sections/price-chart-section.tsx @@ -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; + +async function loadMilestones(): Promise { + 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 ( +
+

+ {!priceResult.ok ? priceResult.error : !commodityResult.ok ? commodityResult.error : ''} +

+
+ ); + } + + return ( + { + '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 }; + }} + /> + ); +} diff --git a/src/app/trends/page.tsx b/src/app/trends/page.tsx index e8fa1d2..d6d231b 100644 --- a/src/app/trends/page.tsx +++ b/src/app/trends/page.tsx @@ -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; - -async function loadMilestones(): Promise { - 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 (
@@ -50,38 +21,13 @@ export default async function TrendsPage() {

- {priceResult.ok && commodityResult.ok ? ( - { - '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 }; - }} - /> - ) : ( -
-

- {!priceResult.ok ? priceResult.error : !commodityResult.ok ? commodityResult.error : ''} -

-
- )} + }> + + - {correlationResult.ok ? ( - - ) : ( -
-

{correlationResult.error}

-
- )} + }> + +
); } diff --git a/src/components/charts/correlation-chart.tsx b/src/components/charts/correlation-chart.tsx index c1d50ce..f894628 100644 --- a/src/components/charts/correlation-chart.tsx +++ b/src/components/charts/correlation-chart.tsx @@ -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}`}> diff --git a/src/components/charts/generation-chart.tsx b/src/components/charts/generation-chart.tsx index 04ff06a..6643bac 100644 --- a/src/components/charts/generation-chart.tsx +++ b/src/components/charts/generation-chart.tsx @@ -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 } { + const d = new Date(ts); + if (timeRange === '24h') return formatMarketTime(d, regionCode); + if (timeRange === '7d') return formatMarketDateTime(d, regionCode); + return formatMarketDate(d, regionCode); + }} /> ('H100 SXM'); + const [gpuModel, setGpuModel] = useState('B200'); const [gpuCount, setGpuCount] = useState(1000); const [selectedRegion, setSelectedRegion] = useState(regionPrices[0]?.regionCode ?? ''); diff --git a/src/components/map/datacenter-marker.tsx b/src/components/map/datacenter-marker.tsx index 0476e30..4e58c62 100644 --- a/src/components/map/datacenter-marker.tsx +++ b/src/components/map/datacenter-marker.tsx @@ -89,14 +89,36 @@ export function DatacenterMarker({ datacenter, onClick, isPulsing = false }: Dat /> )}
+ {size >= 26 && ( +
+ {datacenter.capacity_mw} +
+ )} {hovered && (
{datacenter.name}
diff --git a/src/components/map/energy-map.tsx b/src/components/map/energy-map.tsx index b29204a..6bbf705 100644 --- a/src/components/map/energy-map.tsx +++ b/src/components/map/energy-map.tsx @@ -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 = { + 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 (
@@ -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"> + {regionLabels.map(label => ( + +
+
{label.code}
+
${Math.round(label.price)}/MWh
+
+
+ ))} + {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) { })} + + setSelectedDatacenter(null)} /> setSelectedRegion(null)} /> diff --git a/src/components/map/map-legend.tsx b/src/components/map/map-legend.tsx new file mode 100644 index 0000000..0592b55 --- /dev/null +++ b/src/components/map/map-legend.tsx @@ -0,0 +1,62 @@ +'use client'; + +export function MapLegend() { + return ( +
+ {/* Price heatmap gradient */} +
+
Price Heatmap
+
+
+ $0 + $50 + $100+ +
+
$/MWh
+
+ + {/* Marker size scale */} +
+
Datacenter Size
+
+
+
+ 50 +
+
+
+ 200 +
+
+
+ 500+ +
+ MW +
+
+ + {/* Pulsing icon */} +
+ + + + + Price spike +
+ + {/* Grid stress glow icon */} +
+ + + + + Grid stress >85% +
+
+ ); +} diff --git a/src/components/map/region-overlay.tsx b/src/components/map/region-overlay.tsx index 77fb719..ccff3c2 100644 --- a/src/components/map/region-overlay.tsx +++ b/src/components/map/region-overlay.tsx @@ -81,7 +81,7 @@ export function RegionOverlay({ regions, onRegionClick }: RegionOverlayProps) { const hoveredFeatureRef = useRef(null); const breathingTimerRef = useRef | 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) { diff --git a/src/lib/utils.ts b/src/lib/utils.ts index e883fb2..ed6c8a8 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -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', + }); +}