Public
Edited
Aug 10, 2023
Paused
3 forks
45 stars
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
// Draws the graphic above
graphic = () => {
const wrapper = d3.create("div");

wrapper.append("style").html(css);

const chart = wrapper.selectAll(".chart")
.data(data.filter(d => d.state !== "Average all states"))
.join("div")
.attr("class", "chart")
chart.call(makeTitles);

chart.each(function(d, i, e) {
const chart = d3.select(this);
chart.call(() => makeIntensityChart(chart, i === 0, i === 0))
});
chart.call(makeFuelsChart);
return wrapper.node()
}
Insert cell
// Generates each chart's titles
makeTitles = chart => {
const title = chart.append("div")
.attr("class", "title");
title.append("div")
.attr("class", "name")
.text(({state}) => state === "Average all states" ? "U.S. total" : state)

const stats = chart.append("div")
.attr("class", "multiple-stats");

const statsCurrent = stats.append("div")
.attr("class", "multiple-stats-section multiple-stats-current");

// statsCurrentSubtitle
statsCurrent.append("div")
.attr("class", "multiple-stats-subtitle")
.text("CO2 intensity in '20")

statsCurrent.append("div")
.attr("class", "multiple-stats-value")
.html(d => `${Math.round(d.end)} <span class="multiple-stats-unit">lbs/MWh</span>`)

const statsChange = stats.append("div")
.attr("class", "multiple-stats-section multiple-stats-change");

// statsChangeSubtitle
statsChange.append("div")
.attr("class", "multiple-stats-subtitle")
.text("Change since '00");

statsChange.append("div")
.attr("class", "multiple-stats-value")
.text(({change}) => `${change < 0 ? "-" : "+"}${Math.abs(Math.round(change))}%`)

return chart;
}
Insert cell
// Generates the chart showing carbon intensity over time
makeIntensityChart = (chart, annotateIntensity, annotateAverage) => {
const svg = chart.append("div").append("svg")
.attr("width", chartwidth + marginH * 2)
.attr("height", heightIntensity + marginIntensity.top + marginIntensity.bottom)
.style("margin-bottom", "-9px")

const g = svg.append("g")
.attr("transform", `translate(${[marginH, marginIntensity.top]})`);

const intensityLabelG = g.selectAll(".intensity-label-g")
.data(({entries}) => entries)
.join("g")
.attr("class", "intensity-label-g")
.attr("transform", d => `translate(${x(d.year)})`);

const usLabelG = g.selectAll(".us-label-g")
.data(average.entries)
.join("g")
.attr("class", "us-label-g")
.attr("transform", d => `translate(${x(d.year)})`);

// intensityLabelLine
intensityLabelG
.filter(d => [2000, 2010, 2020].includes(d.year))
.append("line")
.attr("transform", d => `translate(${d.year === yearExtent[0] ? 1 : 0})`)
.attr("class", "intensity-label-line")
.attr("y1", d => yIntensity(d.intensity))
.attr("y2", heightIntensity);

// usIntensityLine
g.append("path")
.datum(average.entries)
.attr("class", "us-intensity-line")
.attr("d", d => line(d));

// intensityLine
g.append("path")
.datum(d => d.entries)
.attr("class", "intensity-line")
.attr("d", d => line(d));

if (annotateAverage){
// usLabelAnnotation
g.append("text")
.attr("class", "us-label-annotation")
.attr("dy", -7)
.attr("transform", () => {
const anno = average.entries.find(d => d.year === 2019)
return `translate(${[x(anno.year), yIntensity(anno.intensity)]})`
})
.text("U.S. average")
}
if (annotateIntensity){
// intensityLineAnnotation
const anglers = ["Vermont", "Iowa"];
g.append("text")
.attr("class", "intensity-line-annotation")
.attr("dy", -7)
.attr("transform", d => {
const anno = d.entries.find(d => d.year === 2001);
const annoPoint = [x(anno.year), yIntensity(anno.intensity)]
const annoNext = d.entries.find(d => d.year === (d.state === "Iowa" ? 2004 : d.state === "Vermont" ? 2006 : 2020));
const annoNextPoint = [x(annoNext.year), yIntensity(annoNext.intensity)]
const dIntensityAngle = geometric.lineAngle([annoPoint, annoNextPoint])
return `translate(${annoPoint}) rotate(${anglers.includes(d.state) ? dIntensityAngle : 0})`
})
.text("Carbon intensity");
}
return chart;
}
Insert cell
// Generates the chart showing fuel mix over time
makeFuelsChart = (chart) => {
const svg = chart.append("svg")
.attr("width", chartwidth + marginH * 2)
.attr("height", heightFuels + marginFuels.top + marginFuels.bottom);

const g = svg.append("g")
.attr("transform", `translate(${[marginH, marginFuels.top]})`)
g.selectAll(".fuels-area")
.data(d => d.stacked)
.join("path")
.attr("class", "fuels-area")
.attr("d", area)
.attr("fill", d => colors[d[0].fuel]);

g.append("g")
.attr("class", "axis")
.attr("transform", `translate(0, ${heightFuels})`)
.call(
d3.axisBottom(x)
.tickFormat(d => d)
.tickValues(yearExtent)
)
.select(".domain").remove()

g.append("g")
.attr("class", "axis")
.call(
d3.axisTop(x)
.tickFormat(d => d)
.tickValues(yearExtent)
)
.select(".domain").remove()
setTimeout(() => {
g.selectAll(".fuels-label")
.data(d => d.stacked)
.join("text")
.attr("class", d => `fuels-label ${slug(d[0].fuel)}`)
.text(d => d[0].fuel)
.attr("transform", d3.areaLabel(area).padding(0.1))
.each((d, i, e) => {
const sel = d3.select(e[i]);
const scale = +sel.attr("transform").split("scale(")[1].replace(")", "")
sel.attr("opacity", scale < 0.5 ? 0 : 1)
});
}, 1)

return chart;
}
Insert cell
Insert cell
// Each fuel gets its own color
colors = ({
"Coal": "#333333",
"Natural gas": "#ebb347",
"Solar": "#ffec44",
"Wind": "#166dfc",
"Nuclear": "#ff4f83",
"Hydroelectric": "#acc5e8",
"Petroleum": "#f9c296",
"Other": "#E2E2E2"
})
Insert cell
css = `
.chart {
display: inline-block;
margin-bottom: 24px;
margin-right: 50px;
}

.chart svg {
overflow: visible;
}

.title {
font-family: ${franklinBold};
}
.name {
font-weight: bold;
}

.multiple-stats {
overflow: auto;
}

.multiple-stats-section {
display: inline-block;
}

.multiple-stats-subtitle {
color: #2a2a2a;
font-family: ${franklinLight};
font-size: 14px;
margin-bottom: -6px;
}

.multiple-stats-current {
float: left;
}

.multiple-stats-change {
float: right;
}

.multiple-stats-value {
color: #2a2a2a;
font-family: ${franklinBold};
font-size: 18px;
}

.multiple-stats-unit {
color: #999999;
font-family: ${franklinLight};
font-size: 12px;
}

.intensity-line {
fill: none;
stroke: #2a2a2a;
}

.intensity-baseline, .intensity-label-line, .us-label-line {
shape-rendering: crispEdges;
}

.us-label-line {
stroke: #e2e2e2;
}

.us-intensity-line {
fill: none;
stroke-width: 1px;
stroke: #b7b7b7;
}

.us-label-annotation, .intensity-line-annotation {
font-family: ${franklinLight};
font-size: 14px;
paint-order: stroke fill;
stroke: white;
stroke-linejoin: round;
stroke-width: 4px;
}

.us-label-annotation {
fill: #999;
text-anchor: end;
}

.intensity-line-annotation {
fill: #2a2a2a;
text-anchor: start;
}

.intensity-label-line {
stroke: #e2e2e2;
}

.fuels-area {
stroke: #fff;
}

.fuels-label {
font-family: ${postoni};
text-transform: uppercase;
}
.fuels-label.coal {
fill: #e9e9e9;
}
.fuels-label.wind {
fill: #dde5f4;
}

.axis text {
font-family: ${franklinLight};
}
`
Insert cell
Insert cell
line = d3.line()
.x(d => x(d.year))
.y(d => yIntensity(d.intensity))
Insert cell
area = d3.area()
.x(d => x(d.year))
.y1(d => yFuels(d.values[1]))
.y0(d => yFuels(d.values[0]))
.curve(d3.curveMonotoneX);
Insert cell
Insert cell
marginH = 11;
Insert cell
marginIntensity = ({left: marginH, right: marginH, top: 0, bottom: 0})
Insert cell
marginFuels = ({left: marginH, right: marginH, top: 20, bottom: 20})
Insert cell
chartwidth = 250 - marginH * 2
Insert cell
heightIntensity = 60
Insert cell
heightFuels = 100
Insert cell
Insert cell
x = d3.scaleLinear(yearExtent, [0, chartwidth])
Insert cell
yIntensity = d3.scaleLinear([0, efficiencyExtent[1]], [heightIntensity, 0]).nice()
Insert cell
yFuels = d3.scaleLinear()
.domain([0, 1])
.range([heightFuels, 0])
Insert cell
Insert cell
// Fuel mix by state
// Downloaded one state at a time from EIA's State Electricity Profiles at https://www.eia.gov/electricity/state/
// Release date: November 10, 2022
fuels = (await FileAttachment("states-fuels-2021.json").json())
.map(d => (d.value = Math.max(0, d.value), d)) // ignore negligible negative values
.filter(d => d.year >= yearExtent[0] && d.year <= yearExtent[1]); // filter for 2000-2020
Insert cell
// Emissions intensity by state
// Downloaded one state at a time from EIA's State Electricity Profiles at https://www.eia.gov/electricity/state/
// Release date: November 10, 2022
intensity = (await FileAttachment("states-emissions-2021.json").json())
.filter(({gas}) => gas === "Carbon dioxide")
.map(d => {
d.intensity = d.value;
d.state = d.state_name;

// Remove unused columns
delete d.gas;
delete d.state_name;
delete d.state_postal;
delete d.value;
return d;
})
Insert cell
Insert cell
yearExtent = [2000, 2020]
Insert cell
efficiencyExtent = d3.extent(intensity, d => d.intensity)
Insert cell
// The fuels we will highlight
fuelKeys = ["Coal", "Hydroelectric", "Natural gas", "Nuclear", "Petroleum", "Solar", "Wind", "Other"]
Insert cell
// Calculate stacks for each state
states = d3
.groups(fuels, d => d.state_name)
.map(([state_name, entries]) => {
// const state = state_name === "U.S. Total" ? "Average all states" : state_name;
const state = state_name;
const stacked = convertToStackData(entries);
return {
state,
entries,
stacked
}
})
Insert cell
// Add the fuel stacks to the intensity data
data = d3
.groups(intensity.filter(d => d.year >= yearExtent[0] && d.year <= yearExtent[1]), d => d.state)
.map(([state, entries]) => {
const end = entries[0].intensity;
const start = entries[entries.length - 1].intensity;
const peak = d3.max(entries, d => d.intensity);
const change = (end - start) / start * 100;

if(!states.find(d => d.state === state)){
console.log(state)
}

const { stacked } = states.find(d => d.state === state);
return {
state,
change,
end,
start,
peak,
entries,
stacked
}
})
.sort((a, b) => d3.ascending(a[sort], b[sort]))
Insert cell
average = data.find(d => d.state === "U.S. Total")
Insert cell
Insert cell
// Transform a tall set of entries into a wide one, calculating percentages
function convertToStackData(entries){
// All other fuels are Other
const fuels = fuelKeys.filter(d => d !== "Other");

// Group the data by year
const years = d3
.groups(entries, d => d.year)
.sort((a, b) => d3.ascending(a[0], b[0]));

// Sum each year
const yearSums = years.map(([year, E]) => ({year, sum: d3.sum(E, d => d.value)}));

// Convert to wide data, where each year has a key and normalized valued
const data = years
.map(([year, entries]) => {
const o = {};
const { sum } = yearSums.find(d => d.year === year);

// A cumulative sum for the fuels whose names are found in the data
let namedSum = 0;

// Loop through the fuels, adding a property in each year for each for fuel
fuels.forEach(fuel => {
const f = entries.find(d => d.fuel === fuel);
if (f){
const v = f.value / sum;
o[fuel] = v;
namedSum += v;
}
else {
o[fuel] = 0;
}
});
// Calculate the Other category
o["Other"] = 1 - namedSum;
return o;
});

// Calculate the bump areas for the wide data
return bumpArea(data)
.map((area, i0) => {
const fuel = fuelKeys[i0];
return area.map((values, i1) => {
const year = years[i1][0];
return {
fuel,
year,
values
}
})
})
}
Insert cell
// Turn data into a set of top/bottom for a bumped area chart
// via https://observablehq.com/@stroked/auto-label-size
function bumpArea(data) {
let sorted = data.map(d => entries(d).sort((a, b) => a.value - b.value));

return Object.keys(data[0]).map(key => {
return sorted.map((set, i) => {
const index = set.findIndex(d => d.key === key);
const d = set[index];
const sumBefore = d3.sum(set.slice(0, index).map(d => d.value));
const v = [
sumBefore, // bottom
sumBefore + set[index].value, // top
sumBefore + set[index].value / 2 // mid
];

v.data = d;

return v;
});
});
}
Insert cell
// A version of the deprecated d3.entries
// https://github.com/d3/d3-collection#entries
function entries(d){
return Object.keys(d).map(key => ({key, value: d[key]}))
}
Insert cell
// Slug case used for classes
function slug(x) {
return x.toString().toLowerCase()
.replace(/\s+/g, "-") // Replace spaces with -
.replace(/[^\w\-]+/g, "") // Remove all non-word chars
.replace(/\-\-+/g, "-") // Replace multiple - with single -
.replace(/^-+/, "") // Trim - from start of text
.replace(/-+$/, ""); // Trim - from end of text
}
Insert cell
Insert cell
import {
franklinLight,
franklinBold,
postoni
} from "1dec0e3505bd3624"
Insert cell
import { toc } from "@harrystevens/toc"
Insert cell
d3 = require("d3@7", "d3-area-label@1")
Insert cell
geometric = require("geometric@2")
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more