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);
}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);
}