Data Centers vs. States
A single AI data center can draw as much electric power as an entire state - pick one or more below to see which states it outweighs and where it would rank if it were one.
powerSourceLabels = ({
grid: "Grid",
behind_the_meter_gas: "Behind-the-meter gas",
mixed: "Mixed (grid + on-site)",
other: "Other (e.g. nuclear)"
})
powerSourceColor = d3.scaleOrdinal()
.domain(["grid", "behind_the_meter_gas", "mixed", "other"])
.range(["#4C78A8", "#E45756", "#F58518", "#72B7B2"])
stateAvgGwByAbbr = new Map(statePower.map(s => [s.state, s.avg_gw]))
// States ordered biggest-first and smallest-first; both matching strategies
// and the rank readout work off these.
rankedStates = [...statePower].sort((a, b) => d3.descending(a.avg_gw, b.avg_gw))
smallestStates = [...statePower].sort((a, b) => d3.ascending(a.avg_gw, b.avg_gw))
// 1 = biggest consumer. Shown in the map's hover tooltip.
stateRankByAbbr = new Map(rankedStates.map((s, i) => [s.state, i + 1]))
usTotalGW = d3.sum(statePower, s => s.avg_gw)Comparison mode
// Name on top, qualifier beneath, matching the data-center rows below.
compareModeOptions = ({
smaller: { name: "States smaller than the selection", sub: "one state at a time" },
smallest: { name: "Equivalent states", sub: "most whole states" },
closest: { name: "Equivalent states", sub: "closest total" }
})strategyNote = html`<p class="strategy-note">${
compareMode === "smaller"
? "Highlights every state whose average electricity load is smaller than the selected data-center capacity. The remaining states each draw more power than the selection on their own."
: compareMode === "smallest"
? "Adds up whole states starting from the smallest electricity consumer until their combined average load reaches the selected capacity."
: "Searches every combination of states for the subset whose combined average load lands closest to the selected capacity."
}</p>`Data centers
// Drive the checkbox view directly, then fire the event `viewof` listens for.
selectionButtons = {
const setTo = names => {
viewof selectedNames.value = names;
viewof selectedNames.dispatchEvent(new Event("input"));
};
const all = html`<button class="dc-btn" type="button">Select all</button>`;
const none = html`<button class="dc-btn" type="button">Clear</button>`;
all.onclick = () => setTo(datacenters.map(d => d.name));
none.onclick = () => setTo([]);
return html`<div class="dc-btn-row">${all}${none}</div>`;
}viewof selectedNames = Inputs.checkbox(
datacenters.map(d => d.name),
{
value: [datacenters[0].name],
format: name => {
const d = datacenters.find(dc => dc.name === name);
return html`<span class="opt">
<span class="opt-name">${d.name}</span>
<span class="opt-meta">${d.capacity_gw.toFixed(2)} GW · ${d.state} ·
${powerSourceLabels[d.power_source]} · ${d.status.replace(/_/g, " ")}</span>
</span>`;
}
}
)function smallestFirstMatch(states, target) {
const chosen = [];
let cum = 0;
for (const s of states) {
if (cum >= target) break;
chosen.push(s);
cum += s.avg_gw;
}
return { states: chosen, total: cum };
}
// 0/1 subset-sum over average state power (GW), rounded to 0.1 GW so the
// search space stays small, minimizing |sum - target|.
function closestMatch(states, target) {
const scale = 10;
const values = states.map(s => Math.round(s.avg_gw * scale));
const targetScaled = Math.round(target * scale);
const maxSum = d3.sum(values);
const reachable = new Uint8Array(maxSum + 1);
const parent = new Int16Array(maxSum + 1).fill(-1);
reachable[0] = 1;
for (let i = 0; i < values.length; i++) {
const v = values[i];
if (v <= 0) continue;
for (let s = maxSum; s >= v; s--) {
if (reachable[s - v] && !reachable[s]) {
reachable[s] = 1;
parent[s] = i;
}
}
}
let bestSum = 0, bestDiff = Infinity;
for (let s = 0; s <= maxSum; s++) {
if (reachable[s] && Math.abs(s - targetScaled) < bestDiff) {
bestDiff = Math.abs(s - targetScaled);
bestSum = s;
}
}
const chosenIdx = [];
let cur = bestSum;
while (cur > 0) {
const i = parent[cur];
chosenIdx.push(i);
cur -= values[i];
}
return { states: chosenIdx.map(i => states[i]), total: bestSum / scale };
}
// Floating hover tooltip shared by the map and the treemap. The native SVG
// <title> element is delayed ~1s, unstyleable, and can't show a rank line.
function makeTooltip(container) {
const tip = document.createElement("div");
tip.className = "viz-tip";
tip.hidden = true;
container.appendChild(tip);
return {
show(event, name, sub) {
tip.textContent = "";
const n = document.createElement("span");
n.className = "viz-tip-name";
n.textContent = name;
const s = document.createElement("span");
s.className = "viz-tip-sub";
s.textContent = sub;
tip.append(n, s);
tip.hidden = false;
this.move(event);
},
move(event) {
const r = container.getBoundingClientRect();
tip.style.left = `${event.clientX - r.left}px`;
tip.style.top = `${event.clientY - r.top}px`;
},
hide() {
tip.hidden = true;
}
};
}
match = (selected.length === 0 || compareMode === "smaller")
? { states: [], total: 0 }
: compareMode === "smallest"
? smallestFirstMatch(smallestStates, targetGW)
: closestMatch(rankedStates, targetGW)
matchedStateSet = new Set(match.states.map(s => s.state))
// Threshold view: every state that draws less power than the selection.
smallerStates = targetGW > 0
? rankedStates.filter(s => s.avg_gw < targetGW)
: []
// Single source of truth for how a state is tinted, in every mode.
// Returns: "below" | "above" | "matched" | "unmatched"
stateTone = {
if (compareMode === "smaller") {
const below = new Set(smallerStates.map(s => s.state));
return abbr => targetGW === 0 ? "unmatched" : (below.has(abbr) ? "below" : "above");
}
return abbr => matchedStateSet.has(abbr) ? "matched" : "unmatched";
}readout = {
if (selected.length === 0) {
return html`<div class="kpi-empty">Select at least one data center to see the comparison.</div>`;
}
const overUnder = match.total >= targetGW ? "over" : "under";
const diff = Math.abs(match.total - targetGW).toFixed(2);
const plural = n => n === 1 ? "" : "s";
return html`<div>
<div class="kpi-row">
<div class="kpi kpi-primary">
<div class="kpi-value">${targetGW.toFixed(2)} <span class="kpi-unit">GW</span></div>
<div class="kpi-label">Selected capacity</div>
<div class="kpi-sub">${selected.length} data center${plural(selected.length)}</div>
</div>
${compareMode === "smaller"
? html`<div class="kpi">
<div class="kpi-value">${smallerStates.length}</div>
<div class="kpi-label">State${plural(smallerStates.length)} smaller than selection</div>
<div class="kpi-sub">of ${statePower.length} states</div>
</div>`
: html`<div class="kpi">
<div class="kpi-value">${match.states.length}</div>
<div class="kpi-label">Equivalent state${plural(match.states.length)}</div>
<div class="kpi-sub">${match.total.toFixed(2)} GW · ${diff} GW ${overUnder}</div>
</div>`}
<div class="kpi">
<div class="kpi-value">#${insertion.rank}</div>
<div class="kpi-label">Rank if it were a state</div>
<div class="kpi-sub">${insertion.above === undefined
? "above every state"
: `below ${insertion.above.state_name}${insertion.below
? `, above ${insertion.below.state_name}` : ""}`}</div>
</div>
<div class="kpi">
<div class="kpi-value">${(targetGW / usTotalGW * 100).toFixed(1)}<span class="kpi-unit">%</span></div>
<div class="kpi-label">Of U.S. total</div>
<div class="kpi-sub">${usTotalGW.toFixed(0)} GW average load</div>
</div>
</div>
${compareMode === "smaller"
? html`<p class="matched-list"><span class="matched-list-label">Where it lands:</span>
${insertion.above === undefined
? `Draws more power than every state, including ${rankedStates[0].state_name}.`
: insertion.below === undefined
? `Draws less power than every state, including ${rankedStates[rankedStates.length - 1].state_name}.`
: `Bigger than ${smallerStates.length} state${plural(smallerStates.length)} — sits just above ${insertion.below.state_name} and just below ${insertion.above.state_name}.`}</p>`
: html`<p class="matched-list"><span class="matched-list-label">Matched states:</span>
${match.states.map(s => s.state_name).join(", ")}</p>`}
</div>`;
}States by average electricity load
// Only the green fill needs explaining, and the mode label already does that,
// so the state tones carry no key -- just the data-center dot colors.
legend = html`<div class="legend">
<div class="legend-group">
<div class="legend-title">Data center power source</div>
${Object.entries(powerSourceLabels).map(([key, label]) => html`<span class="legend-item"><span class="swatch dot" style="background:${powerSourceColor(key)}"></span> ${label}</span>`)}
</div>
</div>`// Built exactly once: it depends only on the geometry, never on the selection.
// Keeping the same <path> elements across updates is what lets the CSS `fill`
// transition animate when the tone changes, instead of the map snapping.
chart = {
const container = html`<div class="viz-wrap"></div>`;
const tip = makeTooltip(container);
const svg = d3.create("svg")
.attr("viewBox", [0, 0, mapWidth, mapHeight])
.attr("width", "100%")
.attr("height", "auto")
.attr("style", "max-width: 100%; height: auto;");
svg.append("g")
.attr("class", "state-layer")
.selectAll("path")
.data(statesGeo.features)
.join("path")
.attr("d", d3.geoPath(mapProjection))
.attr("class", "state-path state-unmatched")
.on("pointerenter", function (event, d) {
// Raise so the hover outline isn't clipped by neighbouring states.
d3.select(this).raise().classed("is-hovered", true);
const abbr = d.properties.state;
tip.show(
event,
d.properties.state_name,
`${(stateAvgGwByAbbr.get(abbr) ?? 0).toFixed(2)} GW average load · #${stateRankByAbbr.get(abbr)} of ${statePower.length}`
);
})
.on("pointermove", event => tip.move(event))
.on("pointerleave", function () {
d3.select(this).classed("is-hovered", false);
tip.hide();
});
svg.append("g").attr("class", "dc-layer");
container.appendChild(svg.node());
return container;
}// Re-tints the existing states and redraws the pins whenever the selection or
// the mode changes. Returns nothing visible; `chart` above holds the output.
chartUpdate = {
const svg = d3.select(chart).select("svg");
svg.selectAll(".state-path")
.attr("class", d => "state-path state-" + stateTone(d.properties.state));
// Lay out the pin labels: flip to the left near the right edge, then push
// overlapping ones down so several selected centers stay readable.
const pins = selected
.map(d => {
const [x, y] = mapProjection([d.x, d.y]);
return { d, x, y, text: `${d.name} — ${d.capacity_gw.toFixed(2)} GW` };
})
.sort((a, b) => d3.ascending(a.y, b.y));
const taken = [];
for (const p of pins) {
p.flip = p.x + p.text.length * 6.2 + 14 > mapWidth;
let ly = p.y;
while (taken.some(t => Math.abs(t - ly) < 13)) ly += 13;
p.ly = ly;
taken.push(ly);
}
const pin = svg.select(".dc-layer")
.selectAll("g.dc-pin")
.data(pins, p => p.d.name)
.join(enter => {
const g = enter.append("g").attr("class", "dc-pin");
g.append("line").attr("class", "dc-leader");
g.append("circle").attr("class", "dc-dot").attr("r", 5);
g.append("text").attr("class", "dc-label");
return g;
});
pin.select("circle")
.attr("cx", p => p.x)
.attr("cy", p => p.y)
.attr("fill", p => powerSourceColor(p.d.power_source));
pin.select("line")
.attr("x1", p => p.x)
.attr("y1", p => p.y)
.attr("x2", p => p.x + (p.flip ? -8 : 8))
.attr("y2", p => p.ly)
.attr("display", p => Math.abs(p.ly - p.y) > 3 ? null : "none");
pin.select("text")
.attr("x", p => p.x + (p.flip ? -8 : 8))
.attr("y", p => p.ly + 4)
.attr("text-anchor", p => p.flip ? "end" : "start")
.text(p => p.text);
return html`<span hidden></span>`;
}Hover any state for its average load and rank. State load is 2024 retail electricity sales ÷ 8,760 h (EIA Form EIA-861); data center figures are announced capacity, which is not the same as built capacity. Full sources on the About page.
Every state, sized by average power draw
treemapLeaves = [
...statePower.map(s => ({
kind: "state",
key: s.state,
label: s.state,
full: s.state_name,
value: s.avg_gw,
tone: stateTone(s.state)
})),
...selected.map(d => ({
kind: "dc",
key: `dc:${d.name}`,
label: d.name,
full: d.name,
value: d.capacity_gw,
power_source: d.power_source
}))
]
treemap = {
const width = 975;
const height = 380;
const root = d3.treemap()
.tile(d3.treemapSquarify)
.size([width, height])
.paddingInner(1.5)
.round(true)
(d3.hierarchy({ children: treemapLeaves })
.sum(d => d.value)
.sort((a, b) => d3.descending(a.value, b.value)));
const container = html`<div class="viz-wrap"></div>`;
const tip = makeTooltip(container);
const svg = d3.create("svg")
.attr("viewBox", [0, 0, width, height])
.attr("width", "100%")
.attr("height", "auto")
.attr("style", "max-width: 100%; height: auto;");
const cell = svg.selectAll("g")
.data(root.leaves())
.join("g")
.attr("transform", d => `translate(${d.x0},${d.y0})`)
.on("pointerenter", function (event, d) {
d3.select(this).select("rect").classed("is-hovered", true);
tip.show(event, d.data.full, `${d.data.value.toFixed(2)} GW `
+ (d.data.kind === "dc" ? "announced capacity" : "average draw"));
})
.on("pointermove", event => tip.move(event))
.on("pointerleave", function () {
d3.select(this).select("rect").classed("is-hovered", false);
tip.hide();
});
cell.append("rect")
.attr("width", d => d.x1 - d.x0)
.attr("height", d => d.y1 - d.y0)
.attr("class", d => d.data.kind === "dc"
? "tm-rect tm-dc"
: "tm-rect tm-" + d.data.tone)
.attr("fill", d => d.data.kind === "dc" ? powerSourceColor(d.data.power_source) : null);
// Only label rectangles with room for the text.
const fits = (d, w, h) => (d.x1 - d.x0) > w && (d.y1 - d.y0) > h;
cell.filter(d => fits(d, 30, 14))
.append("text")
.attr("class", d => "tm-label" + (d.data.kind === "dc"
? " tm-label-dc"
: (d.data.tone === "below" || d.data.tone === "matched") ? " tm-on-fill" : ""))
.attr("x", 4)
.attr("y", 13)
.text(d => d.data.kind === "dc"
? (fits(d, 90, 14) ? d.data.label : "●")
: d.data.label);
cell.filter(d => fits(d, 40, 30))
.append("text")
.attr("class", d => "tm-value" + (d.data.kind !== "dc"
&& (d.data.tone === "below" || d.data.tone === "matched") ? " tm-on-fill" : ""))
.attr("x", 4)
.attr("y", 26)
.text(d => `${d.data.value.toFixed(1)} GW`);
container.appendChild(svg.node());
return container;
}