• Map
  • About

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.

statesGeo = FileAttachment("data/processed/us_states.geojson").json()
statePower = FileAttachment("data/processed/state_power.csv").csv({typed: true})
datacenters = FileAttachment("data/processed/datacenters.json").json()
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" }
})
viewof compareMode = Inputs.radio(
  ["smaller", "smallest", "closest"],
  {
    value: "smaller",
    format: key => html`<span class="opt">
      <span class="opt-name">${compareModeOptions[key].name}</span>
      <span class="opt-meta">${compareModeOptions[key].sub}</span>
    </span>`
  }
)
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>`;
    }
  }
)
selected = datacenters.filter(d => selectedNames.includes(d.name))
targetGW = d3.sum(selected, d => d.capacity_gw)
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";
}
// Where the selection would slot into the state ranking.
insertion = {
  const bigger = rankedStates.filter(s => s.avg_gw > targetGW);
  return {
    rank: bigger.length + 1,
    above: bigger[bigger.length - 1],
    below: rankedStates[bigger.length]
  };
}
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>`
mapWidth = 975
mapHeight = 610

// Geometry is pre-projected in R (ESRI:102003 with AK/HI shifted), so this is
// a fitted identity transform, not a live projection.
mapProjection = d3.geoIdentity()
  .reflectY(true)
  .fitSize([mapWidth, mapHeight], statesGeo)
// 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;
}