How I built it — US electric generation map

Step 1: Download, export to CSV

For generation data:

Step 2: CSV → GeoJSON

I wrote a small node script to join the data from these data sources into a single GeoJSON file.

  • csv-parse module to parse CSV
  • geojson module to output GeoJSON
  • Simplify the “prime mover fuel codes” into nine major categories
  • Join the location data and generation data based on “plant ID”, collect aggregate statistics

Step 3: Building the Map

I started with the Mapbox “Dark” style, which is designed with data visualization in mind. I added my GeoJSON as a data source:

map.addSource('plant-generation', {
"type": "geojson",
"data": "plant_generation.geojson"
map.addSource('satellite', {
"type": "raster",
"url": "mapbox://mapbox.satellite",
"tileSize": 256
"id": "plant-generation",
"type": "heatmap",
"source": "plant-generation",
map.setPaintProperty('generation-heatmap', 'heatmap-weight',
["/", ["+",
["*", ["to-number",
["get",`netgen_${base.year}_${base.quarter}`]], baseMix],
["*", ["to-number",
["get", `netgen_${next.year}_${next.quarter}`]], nextMix]], 2000000]);
var intensityRatio = totalGeneration /
map.setPaintProperty('plant-generation', 'heatmap-intensity',
[ "exponential", 2 ], // Exponential intensity curve matches
[ "zoom" ], // exponential zoom curve
0, // At zoom 0:
intensityRatio, // Start with the base intensity
10, // By zoom 10:
10 * intensityRatio // Reach maximum intensity
  • Fade in the satellite layer
  • Fade out the heatmap while scaling up individual circles to represent plants.
  • Color encodes fuel type, size encodes monthly generation, and label encodes name, type, and total generation over the 17 years of data.

Step 4: Telling a Story

At this point, I had a lot of fun exploring the map and finding interesting patterns, but I wanted to add a narrative element so I drafted up a list of stories I felt the map could tell:

  • The decline of Coal — The biggest story in CO2-reduction.
  • The rise of Gas — The twin of the decline of coal, and a staggering infrastructure build-out. Digging into individual plant data, I was surprised by how many coal-to-gas plant conversions showed up.
  • The rise of Solar — Sunny and future-focused California is no surprise here, but did you know North Carolina had such a big solar industry?
  • The rise of Wind — Dominated by “red” states, and a hopeful reminder that if we get the technology and economics right, energy transition doesn’t have to be partisan issue.
  • The seasonality of Hydro — and its emerging potential as a “battery” for intermittent sources (although with monthly data, pumped hydro storage just looks like a small and boring negative value)
  • The stagnation of Nuclear — The rise of solar and wind is inspiring, but discussions of clean energy too often miss a sense of scale. Nuclear power still dominates our existing clean energy production, and it’s the central technology used by the only countries that have already transitioned to clean electricity, such as Sweden and France.

Step 5: Performance (GeoJSON → MBTiles)

Up to this point I was working with a 27 MB GeoJSON file, which was fine for testing, but way too big for a finished product. So I took a little detour in order to turn the GeoJSON into a tileset I could host with Mapbox. To make low zoom tiles fit within the 500KB per-tile limit, I had to cluster nearby plants.

tippecanoe -zg -o us_electricity_generation.mbtiles -r1 — cluster-distance=4 — accumulate-attribute=netgen_[year]_[month]:sum plant_generation.geojson
  • Tippecanoe implements clustering by finding adjacent points on a Hilbert Curve. This is really efficient on large datasets, but it gave me less control over how clusters get created. Specifically, I wanted clusters to tend to center on the largest plant in the cluster, so that the collective weight of the heatmap would look similar before and after clustering.
  • If I clustered plants with different fuel types, I wanted to calculate separate “aggregate” generation properties per-fuel-type, but Tippecanoe’s “sum” aggregation operator didn’t give me a way to do that.
map.addSource('plant-generation', {
"type": "geojson",
"data": "plant_generation.geojson",
"cluster": true,
"clusterRadius": 2,
"clusterProperties": {
"netgen_2001_0": ["+", ["to-number", ["get", "netgen_2001_0"]]],



Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store