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:
parent
deb1cdc527
commit
564d212148
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
10
src/app/_sections/alerts-section.tsx
Normal file
10
src/app/_sections/alerts-section.tsx
Normal 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} />;
|
||||
}
|
||||
39
src/app/_sections/demand-summary.tsx
Normal file
39
src/app/_sections/demand-summary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
src/app/_sections/gpu-calculator-section.tsx
Normal file
32
src/app/_sections/gpu-calculator-section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
src/app/_sections/hero-metrics.tsx
Normal file
103
src/app/_sections/hero-metrics.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
src/app/_sections/prices-by-region.tsx
Normal file
65
src/app/_sections/prices-by-region.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
src/app/_sections/stress-gauges.tsx
Normal file
77
src/app/_sections/stress-gauges.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
src/app/demand/_sections/demand-chart-section.tsx
Normal file
16
src/app/demand/_sections/demand-chart-section.tsx
Normal 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} />;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
21
src/app/generation/_sections/generation-chart-section.tsx
Normal file
21
src/app/generation/_sections/generation-chart-section.tsx
Normal 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} />
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
92
src/app/map/_sections/map-content.tsx
Normal file
92
src/app/map/_sections/map-content.tsx
Normal 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} />;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
360
src/app/page.tsx
360
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 (
|
||||
<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>
|
||||
);
|
||||
|
||||
16
src/app/trends/_sections/correlation-section.tsx
Normal file
16
src/app/trends/_sections/correlation-section.tsx
Normal 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} />;
|
||||
}
|
||||
64
src/app/trends/_sections/price-chart-section.tsx
Normal file
64
src/app/trends/_sections/price-chart-section.tsx
Normal 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 };
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}`}>
|
||||
|
||||
@ -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 '';
|
||||
}}
|
||||
|
||||
@ -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 ?? '');
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)} />
|
||||
|
||||
62
src/components/map/map-legend.tsx
Normal file
62
src/components/map/map-legend.tsx
Normal 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 >85%</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user