POTUS Economic Scorecard
  • Scorecard
  • About
indicators = FileAttachment("data/indicators.json").json()
presidents = FileAttachment("data/presidents.json").json()

indicatorById = new Map(indicators.map(d => [d.id, d]))
viewof selectedIndex = Inputs.radio(
  new Map(indicators.map(d => [d.name, d.id])),
  {label: "Select Economic Indicator:", value: "sp500"}
)

viewof referenceType = Inputs.radio(
  new Map([["Inauguration Day", "inauguration"], ["Day Before Election", "election"]]),
  {label: "Reference Date:", value: "inauguration"}
)
viewof daysToShow = {
  const min = 10, max = 1460;
  const ticks = [10, 365, 730, 1095, 1460];

  const slider = htl.html`<input type="range" min="${min}" max="${max}" step="10" value="360">`;
  const readout = htl.html`<span class="slider-value">360</span>`;

  const el = htl.html`<div class="field">
    <label>Days to Display: ${readout}</label>
    ${slider}
    <div class="slider-ticks">
      ${ticks.map(t => htl.html`<span style="left:${(t - min) / (max - min) * 100}%">${t.toLocaleString()}</span>`)}
    </div>
  </div>`;

  el.value = +slider.value;
  slider.oninput = () => {
    el.value = +slider.value;
    readout.textContent = el.value.toLocaleString();
    el.dispatchEvent(new CustomEvent("input"));
  };
  return el;
}

viewof partyFilter = Inputs.checkbox(
  ["Democratic", "Republican"],
  {label: "Filter by Party:", value: ["Democratic", "Republican"]}
)
// The president picker is hand-rolled rather than an Inputs.checkbox so that
// Select All / Deselect All can write back into it, and so that presidents the
// current series cannot cover can be dimmed.
viewof selectedPresidents = {
  const names = presidents.map(p => p.president).slice().reverse();
  const initial = ["Trump (2025)", "Biden (2021)", "Trump (2017)", "Obama (2013)"];

  // htl rejects an interpolated bare attribute, so `checked` is set on the
  // element after it is built rather than templated into the tag.
  const boxes = new Map(names.map(name => {
    const box = htl.html`<input type="checkbox">`;
    box.checked = initial.includes(name);
    return [name, box];
  }));

  const selectAll = htl.html`<button class="mini-btn">Select All</button>`;
  const deselectAll = htl.html`<button class="mini-btn">Deselect All</button>`;

  const el = htl.html`<div class="field">
    <label>Select Presidents:</label>
    <div class="select-row">${selectAll}${deselectAll}</div>
    <div class="check-list">
      ${names.map(name => htl.html`<label data-president="${name}">${boxes.get(name)}${name}</label>`)}
    </div>
  </div>`;

  const sync = () => {
    el.value = names.filter(n => boxes.get(n).checked);
    el.dispatchEvent(new CustomEvent("input"));
  };

  for (const box of boxes.values()) box.onchange = sync;
  selectAll.onclick = () => { for (const b of boxes.values()) b.checked = true; sync(); };
  deselectAll.onclick = () => { for (const b of boxes.values()) b.checked = false; sync(); };

  el.value = names.filter(n => boxes.get(n).checked);
  return el;
}
// Dim the presidents the selected series does not reach back far enough to
// cover. This runs as a side effect on the already-rendered picker rather than
// rebuilding it, so the user's selection survives an indicator change.
coverageHint = {
  const covered = new Set(meta.covers);
  for (const label of viewof selectedPresidents.querySelectorAll("[data-president]")) {
    const name = label.dataset.president;
    const ok = covered.has(name);
    label.classList.toggle("option-uncovered", !ok);
    label.title = ok ? "" : `${meta.name} data starts ${meta.first_date}, after this inauguration`;
  }
  return covered;
}
meta = indicatorById.get(selectedIndex)

// Only the selected series is fetched. FileAttachment needs a literal path, so
// this is a plain fetch; the files are declared as project resources.
series = {
  const res = await fetch(`data/${selectedIndex}.json`);
  if (!res.ok) throw new Error(`could not load data/${selectedIndex}.json (${res.status})`);
  return res.json();
}

points = series.dates.map((d, i) => ({date: d, value: series.values[i]}))

dayNumber = {
  const ms = 86400000;
  return (a, b) => Math.round((Date.parse(a + "T00:00:00Z") - Date.parse(b + "T00:00:00Z")) / ms);
}
html`<div class="indicator-note">${meta.description}</div>`
plotData = {
  const isPercent = meta.type === "percent_change";
  const chosen = presidents.filter(p =>
    selectedPresidents.includes(p.president) && partyFilter.includes(p.party)
  );
  const rows = [];

  for (const pres of chosen) {
    const refDate = referenceType === "inauguration" ? pres.inauguration_date : pres.election_date;

    const start = points.findIndex(d => d.date >= refDate && d.value != null && !isNaN(d.value));
    if (start === -1) continue;

    // The reference observation has to actually be near the reference date. The
    // Shiny app takes the first row on or after it with no distance check, so
    // asking for the Home Price Index under Eisenhower silently anchors day 0 to
    // January 1987 and draws thirty years of the wrong presidency. The widest
    // legitimate gap is a quarterly series reporting up to ~92 days later, so
    // 180 days leaves room without admitting a decades-wide jump.
    if (dayNumber(points[start].date, refDate) > 180) continue;

    const refValue = points[start].value;
    const refDay = points[start].date;

    for (let i = start; i < points.length; i++) {
      const day = dayNumber(points[i].date, refDay);
      if (day > daysToShow) break;
      rows.push({
        president: pres.president,
        party: pres.party,
        date: points[i].date,
        day,
        value: points[i].value,
        percent_change: isPercent ? (points[i].value / refValue - 1) * 100 : null,
        plotted: isPercent ? (points[i].value / refValue - 1) * 100 : points[i].value
      });
    }
  }
  return rows;
}

hasData = plotData.length > 0 && plotData.some(d => d.plotted != null && !isNaN(d.plotted))
chartWidth = {
  const card = document.querySelector(".chart-card");
  if (!card) return Math.max(560, Math.min(1200, width));

  const inner = () => {
    const cs = getComputedStyle(card);
    return card.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
  };
  const clamp = w => Math.max(560, Math.min(1200, Math.floor(w)));

  return Generators.observe(notify => {
    const ro = new ResizeObserver(() => notify(clamp(inner())));
    ro.observe(card);
    notify(clamp(inner()));
    return () => ro.disconnect();
  });
}

// A fixed height would go squat on a narrow window and letterboxed on a wide one.
plotHeight = Math.round(Math.max(430, Math.min(620, chartWidth * 0.60)))

marginTop = 78
marginBottom = 92

plotAreaHeight = plotHeight - marginTop - marginBottom

// The y domain is computed here rather than left to Plot's `nice`/`zero`,
// because the declutter below has to know the axis extent exactly. Deriving it
// from the data range instead was wrong wherever the axis is wider than the
// data -- pinning unemployment to zero stretches the axis to 0-10 while the
// values only span 3.4-10, and the gap came out a third too small.
yDomain = {
  const values = plotData.map(d => d.plotted).filter(v => v != null && !isNaN(v));
  if (values.length === 0) return [0, 1];

  const lo = Math.min(...values), hi = Math.max(...values);
  const pad = (hi - lo || Math.abs(hi) || 1) * 0.06;

  // The Shiny app pins unemployment to a zero baseline; a rate charted on a
  // floating axis exaggerates small moves.
  return meta.id === "unemployment" ? [0, hi + pad] : [lo - pad, hi + pad];
}

// With every president selected there are nineteen labels to stack, and at a
// fixed line height they need more room than the plot area has. Shrinking the
// type as the list grows is what keeps them from colliding.
labelCount = new Set(plotData.map(d => d.president)).size

// 1.35 rather than the font size itself: a rendered text box is noticeably
// taller than its nominal size, so spacing labels by font size alone left them
// touching by a pixel or two.
labelFontSize = Math.max(9, Math.min(15,
  Math.floor(plotAreaHeight / Math.max(labelCount, 1) / 1.35)))

labelLineHeight = Math.ceil(labelFontSize * 1.35)

maxDay = plotData.length ? Math.max(...plotData.map(d => d.day)) : daysToShow

partyColor = ({Democratic: "#2166ac", Republican: "#b2182b"})

// Roboto Condensed, with a condensed-first fallback chain so a failed webfont
// load degrades to something of a similar width rather than to a wide default.
fontStack = "'Roboto Condensed', 'Helvetica Neue Condensed', 'Arial Narrow', system-ui, sans-serif"

refLabel = referenceType === "inauguration" ? "Inauguration Day" : "Day Before Election"

// The left margin has to clear the widest tick label plus the rotated axis
// title. A fixed 74px was enough for "-30%" but not for "24,000", so the GDP
// ticks ran straight through the title text. Measure the formatted domain
// endpoints instead -- they bound the tick labels -- and size the margin to fit.
yAxisMetrics = {
  const isPercent = meta.type === "percent_change";
  const fmt = d => isPercent
    ? `${Math.round(d)}%`
    : Math.abs(d) >= 1000
      ? Math.round(d).toLocaleString("en-US")
      : String(Math.round(d * 10) / 10);
  const ctx = document.createElement("canvas").getContext("2d");
  ctx.font = `16px ${fontStack}`;
  const tickWidth = Math.max(...yDomain.map(d => ctx.measureText(fmt(d)).width));
  // 14px of clearance between the tick text and the tick mark, 26px for the
  // rotated title and its own breathing room.
  const marginLeft = Math.max(74, Math.ceil(tickWidth) + 14 + 26);
  return {marginLeft, labelOffset: marginLeft - 13};
}

// The Shiny app formats the end-label value as a percentage for the market
// indices and as a bare number for everything else.
formatValue = meta.type === "percent_change"
  ? (v => `${v.toFixed(1)}%`)
  : (v => v.toFixed(1))
// The last observation for each president, used for the direct end-labels that
// stand in for a colour legend.
endPoints = {
  const last = new Map();
  for (const row of plotData) {
    const prev = last.get(row.president);
    if (!prev || row.day > prev.day) last.set(row.president, row);
  }

  const ends = Array.from(last.values())
    .map(d => ({...d, label: `${d.president} (${formatValue(d.plotted)})`, labelY: d.plotted}))
    .sort((a, b) => a.plotted - b.plotted);

  // A stand-in for ggrepel. An iterative push-apart looked fine at four labels
  // but converged far too slowly at nineteen, where the labels start clustered
  // and need to spread across most of the axis. This is the exact two-pass
  // version instead: sweep up enforcing the minimum gap, and if that overshoots
  // the top of the axis, sweep back down from it.
  //
  // The gap is in data units, derived from the axis extent rather than the range
  // of the labels themselves: when every president lands in a narrow band the
  // label range is tiny while the axis is still wide, and scaling by the label
  // range asks for a gap far smaller than a line of text.
  const axisExtent = yDomain[1] - yDomain[0];
  const minGap = (axisExtent || 1) * (labelLineHeight / plotAreaHeight);

  for (let i = 1; i < ends.length; i++) {
    ends[i].labelY = Math.max(ends[i].labelY, ends[i - 1].labelY + minGap);
  }

  const top = yDomain[1];
  if (ends.length && ends[ends.length - 1].labelY > top) {
    ends[ends.length - 1].labelY = top;
    for (let i = ends.length - 2; i >= 0; i--) {
      ends[i].labelY = Math.min(ends[i].labelY, ends[i + 1].labelY - minGap);
    }
  }

  return ends;
}
chart = {
  if (!hasData) return noDataMessage();

  const isPercent = meta.type === "percent_change";

  const svg = Plot.plot({
    width: chartWidth,
    height: plotHeight,
    marginTop: marginTop,
    marginRight: 205,
    marginLeft: yAxisMetrics.marginLeft,
    marginBottom: marginBottom,
    style: {
      background: "white",
      color: "#2b2b2b",
      fontFamily: fontStack,
      // Drives the tick labels. Roboto Condensed sets narrow, so it needs a
      // couple of points more than a normal-width face to read at the same size.
      fontSize: "16px"
    },
    x: {
      label: `Days Since ${refLabel}`,
      labelAnchor: "center",
      labelArrow: "none",
      labelOffset: 56,
      grid: true,
      // An explicit domain rather than `nice: true`, which rounded the axis out
      // to 400 when the data stopped at 360. The end-labels live in marginRight,
      // so no slack is needed inside the plot area to fit them.
      domain: [0, maxDay]
    },
    y: {
      label: meta.y_label,
      labelAnchor: "center",
      labelArrow: "none",
      labelOffset: yAxisMetrics.labelOffset,
      grid: true,
      domain: yDomain,
      tickFormat: isPercent ? (d => `${d}%`) : undefined
    },
    color: {
      domain: Object.keys(partyColor),
      range: Object.values(partyColor)
    },
    marks: [
      // The zero line is only meaningful when the series is indexed to it.
      isPercent ? Plot.ruleY([0], {stroke: "#8c8c8c", strokeWidth: 1}) : null,
      Plot.line(plotData, {
        x: "day",
        y: "plotted",
        z: "president",
        stroke: "party",
        strokeWidth: 1.8,
        strokeOpacity: 0.9
      }),
      Plot.dot(endPoints, {x: "day", y: "plotted", fill: "party", r: 3.5}),
      // Leader line from the series end out to the label. Labels are pinned to
      // the right edge of the plot area rather than sitting at their series'
      // last x: a presidency still in progress ends partway across the chart,
      // and a label left there lands on top of the other lines.
      Plot.link(endPoints, {
        x1: "day",
        y1: "plotted",
        x2: () => maxDay,
        y2: "labelY",
        stroke: "#bdbdbd",
        strokeWidth: 1
      }),
      Plot.text(endPoints, {
        x: () => maxDay,
        y: "labelY",
        text: "label",
        fill: "party",
        dx: 8,
        textAnchor: "start",
        fontSize: labelFontSize,
        fontWeight: 500
      }),
      Plot.tip(plotData, Plot.pointer({
        x: "day",
        y: "plotted",
        title: d => `${d.president}\n${d.date}\nDay ${d.day}\n${formatValue(d.plotted)}`
      }))
    ].filter(Boolean)
  });

  const verb = isPercent ? "Performance Since" : "Since";
  const subtitle = isPercent
    ? `Showing first ${daysToShow} days (0% = value on reference date)`
    : `Showing first ${daysToShow} days`;

  addSvgText(svg, [
    {text: `${meta.name} ${verb} ${refLabel}`, x: 6, y: 28, size: 23, weight: 700},
    {text: subtitle, x: 6, y: 54, size: 16, weight: 400, fill: "#666"}
  ]);

  // Plot has no separate size for the axis titles; they inherit the tick size.
  // Bump them afterwards so they sit above the ticks in the hierarchy.
  for (const sel of ["x-axis label", "y-axis label"]) {
    const t = svg.querySelector(`g[aria-label="${sel}"] text`);
    if (t) t.setAttribute("font-size", 18);
  }

  addSvgLegend(svg, chartWidth - 205, plotHeight - 14);

  return svg;
}
buttons = {
  const stamp = new Date().toISOString().slice(0, 10);
  const slug = `${selectedIndex}_${referenceType}_${daysToShow}d_${stamp}`;

  const pngButton = htl.html`<button class="download-btn">Download PNG</button>`;
  pngButton.onclick = () => downloadPng(chart, `${slug}.png`);

  const csvButton = htl.html`<button class="download-btn">Download CSV</button>`;
  csvButton.onclick = () => downloadCsv(plotData, `${slug}.csv`);

  // Disabled rather than removed when there is nothing to export. Returning an
  // empty fragment from this cell left the container in a state where the
  // buttons came back present but invisible on the next render, and keeping the
  // row in place also stops the page reflowing every time the chart empties.
  for (const b of [pngButton, csvButton]) {
    b.disabled = !hasData;
    b.style.opacity = hasData ? "" : "0.45";
    b.style.cursor = hasData ? "pointer" : "not-allowed";
  }

  return htl.html`<div class="download-row">${pngButton}${csvButton}</div>`;
}
// Stands in for the chart when nothing can be plotted -- most often because the
// selected series starts after every selected president took office.
function noDataMessage() {
  // Two different reasons land here, and blaming coverage when the real problem
  // is an empty selection sends the reader looking in the wrong place.
  const chosen = presidents.filter(p =>
    selectedPresidents.includes(p.president) && partyFilter.includes(p.party)
  );

  const reason = chosen.length === 0
    ? htl.html`<p>No presidents are selected.</p>`
    : htl.html`<p>${meta.name} data begins ${meta.first_date}, which is after
        ${chosen.length === 1 ? "this presidency" : "every selected presidency"} began.
        It covers ${meta.covers.length} of ${presidents.length} presidencies.</p>`;

  return htl.html`<div class="no-data">
    <h4>Insufficient Data Available</h4>
    ${reason}
    <p><strong>Try one of the following:</strong></p>
    <ul>
      <li>Select a president the series covers</li>
      <li>Change the reference date</li>
      <li>Choose a different time period</li>
      <li>Select a different economic indicator</li>
    </ul>
  </div>`;
}
function addSvgText(svg, items) {
  const NS = "http://www.w3.org/2000/svg";
  for (const item of items) {
    const t = document.createElementNS(NS, "text");
    t.setAttribute("x", item.x);
    t.setAttribute("y", item.y);
    t.setAttribute("font-family", fontStack);
    t.setAttribute("font-size", item.size);
    t.setAttribute("font-weight", item.weight);
    t.setAttribute("fill", item.fill ?? "#2b2b2b");
    t.setAttribute("text-anchor", "start");
    t.textContent = item.text;
    svg.appendChild(t);
  }
  return svg;
}

// The party legend, drawn into the SVG rather than left to Plot's colour legend,
// which renders as an HTML sibling in a <figure> and so would not survive
// serialisation into the PNG. Laid out from a fixed left edge with fixed slot
// widths: the SVG is detached when this runs, so getComputedTextLength() would
// return 0 and there is nothing to measure against.
function addSvgLegend(svg, rightX, y) {
  const NS = "http://www.w3.org/2000/svg";
  const entries = [["Democratic", partyColor.Democratic], ["Republican", partyColor.Republican]];
  const slot = 112, swatch = 28, gap = 8;
  let x = rightX - entries.length * slot;

  for (const [name, color] of entries) {
    const line = document.createElementNS(NS, "line");
    line.setAttribute("x1", x);
    line.setAttribute("x2", x + swatch);
    line.setAttribute("y1", y - 5);
    line.setAttribute("y2", y - 5);
    line.setAttribute("stroke", color);
    line.setAttribute("stroke-width", 3);
    svg.appendChild(line);

    const t = document.createElementNS(NS, "text");
    t.setAttribute("x", x + swatch + gap);
    t.setAttribute("y", y);
    t.setAttribute("font-family", fontStack);
    t.setAttribute("font-size", 15);
    t.setAttribute("fill", "#2b2b2b");
    t.setAttribute("text-anchor", "start");
    t.textContent = name;
    svg.appendChild(t);

    x += slot;
  }
  return svg;
}
fontFaceCss = {
  try {
    const cssUrl = "https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@400;500;700&display=swap";
    const css = await (await fetch(cssUrl)).text();

    // Keep only the latin subset; the other unicode-range blocks would triple
    // the payload for glyphs this chart never draws.
    const blocks = css.split("@font-face").slice(1).filter(b => b.includes("U+0000-00FF"));

    const out = [];
    for (const block of blocks) {
      const url = block.match(/url\((https:[^)]+\.woff2)\)/)?.[1];
      const weight = block.match(/font-weight:\s*(\d+)/)?.[1] ?? "400";
      if (!url) continue;
      const buf = await (await fetch(url)).arrayBuffer();
      let bin = "";
      const bytes = new Uint8Array(buf);
      for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
      out.push(`@font-face{font-family:'Roboto Condensed';font-style:normal;font-weight:${weight};src:url(data:font/woff2;base64,${btoa(bin)}) format('woff2');}`);
    }
    return out.join("");
  } catch (e) {
    return "";
  }
}

// Rasterise the live SVG at 2x. The chart is a bare <svg> by design -- Plot's
// built-in colour legend would have wrapped it in a <figure>, so the direct
// end-labels serve as the legend instead and the whole chart stays serialisable.
function downloadPng(svgEl, filename, scale = 2) {
  const width = +svgEl.getAttribute("width");
  const height = +svgEl.getAttribute("height");
  const captionHeight = 30;

  const clone = svgEl.cloneNode(true);
  clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
  clone.setAttribute("width", width);
  clone.setAttribute("height", height + captionHeight);
  clone.setAttribute("viewBox", `0 0 ${width} ${height + captionHeight}`);

  // SVG is transparent by default; PNG needs a painted background.
  const NS = "http://www.w3.org/2000/svg";
  const bg = document.createElementNS(NS, "rect");
  bg.setAttribute("width", width);
  bg.setAttribute("height", height + captionHeight);
  bg.setAttribute("fill", "white");
  clone.insertBefore(bg, clone.firstChild);

  if (fontFaceCss) {
    const style = document.createElementNS(NS, "style");
    style.textContent = fontFaceCss;
    clone.insertBefore(style, clone.firstChild);
  }

  addSvgText(clone, [{
    text: `Generated on ${new Date().toISOString().slice(0, 10)} from https://jhelvy.github.io/potus-econ-scorecard/`,
    x: 8,
    y: height + 20,
    size: 11,
    weight: 400,
    fill: "#777"
  }]);

  const xml = new XMLSerializer().serializeToString(clone);
  const url = URL.createObjectURL(new Blob([xml], {type: "image/svg+xml;charset=utf-8"}));

  const img = new Image();
  // A malformed clone fails the image load with no exception anywhere, which
  // would look to the user like a button that does nothing.
  img.onerror = () => {
    URL.revokeObjectURL(url);
    console.error("PNG export failed: the chart SVG could not be rasterised");
  };
  img.onload = () => {
    const canvas = document.createElement("canvas");
    canvas.width = width * scale;
    canvas.height = (height + captionHeight) * scale;
    const ctx = canvas.getContext("2d");
    ctx.scale(scale, scale);
    ctx.drawImage(img, 0, 0);
    URL.revokeObjectURL(url);
    canvas.toBlob(blob => saveBlob(blob, filename), "image/png");
  };
  img.src = url;
}

function downloadCsv(rows, filename) {
  const columns = ["president", "party", "date", "day", "value", "percent_change"];
  const escape = v => {
    const s = v == null ? "" : String(v);
    return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
  };
  const csv = [
    columns.join(","),
    ...rows.map(r => columns.map(c => escape(r[c])).join(","))
  ].join("\n");
  saveBlob(new Blob([csv], {type: "text/csv;charset=utf-8"}), filename);
}

function saveBlob(blob, filename) {
  const url = URL.createObjectURL(blob);
  const link = document.createElement("a");
  link.href = url;
  link.download = filename;
  document.body.appendChild(link);
  link.click();
  document.body.removeChild(link);

  // Revoking synchronously races the browser: on a large blob the download is
  // aborted before it starts, silently. Hand the URL back on the next macrotask
  // instead, once the download has been handed off.
  setTimeout(() => URL.revokeObjectURL(url), 60000);
}
 
  • Site made with quarto and Observable Plot
  • Edit this page
  • Report an issue