html`<span class="cite-label">Cite as:</span> Kinane, Christina M. ${today.getFullYear()}. <em>(Un)Confirmed Appointments Database (UCAP): Nominations Tracker</em> [online dashboard]. https://www.christinakinane.com/kapi-lab/nominations.html (accessed ${today.toLocaleDateString("en-US", {year: "numeric", month: "long", day: "numeric"})}).`(Un)Confirmed Appointments Database:
Nominations Tracker
This dashboard draws from the (Un)Confirmed Appointments Database (UCAP) and tracks the progress of all individual nominations, from submission through final action, for Presidential Appointments requiring Senate Confirmation (PAS positions) in the Executive Branch, from the 97th to 119th Congresses (Reagan through Trump II).
actingLinks = FileAttachment("../data/actings_nomination_links.json").json()
actingByNom = actingLinks.by_nomination
actingsLive = actingLinks.actings_live
nomByKey = new Map(data.map(d => [`${d.congress}:${d.citation}`, d]))
actingOverlapsFor = (d) => actingByNom[`${d.congress}:${d.citation}`] || []positionCategory = (d) => {
const pos = d.position || "", dept = d.department || "";
if (d.is_amb_usatty_marsh) {
if (/ambassador/i.test(pos)) return "Ambassadors";
if (/attorney/i.test(pos) && !/attorney general/i.test(pos)) return "U.S. Attorneys";
if (/marshal/i.test(pos)) return "U.S. Marshals";
return dept === "State" ? "Ambassadors" : "U.S. Attorneys";
}
if (dept === "Executive Office of the President") return "Executive Office of the President";
if (d.is_cabinet_dept) return "Cabinet departments";
if (d.is_independent_agency) return "Independent agencies & commissions";
return "Other";
}
positionCategoryTable = {
const info = {
"Cabinet departments": "Secretaries, deputy / under / assistant secretaries, general counsels, CFOs, and inspectors general of the 15 executive departments",
"Independent agencies & commissions": "Members, chairs, commissioners, administrators, and directors of independent regulatory agencies and boards (EPA, Federal Reserve, SEC, NLRB, FCC, NRC, …)",
"Executive Office of the President": "OMB, USTR, OSTP, CEA, and other EOP leadership",
"Ambassadors": "Chiefs of mission and diplomatic representatives",
"U.S. Attorneys": "District U.S. attorneys",
"U.S. Marshals": "District U.S. marshals"
};
return Object.keys(info).map(cat => ({
Category: cat,
"Offices included": info[cat]
}));
}admins = ["All", ...new Set(data.map(d => d.administration))]
statuses = ["All", ...new Set(data.map(d => d.status))]
congresses = ["All", ...Array.from(new Set(data.map(d => d.congress))).sort((a, b) => a - b)]
years = ["All", ...Array.from(new Set(data.map(d => (d.date_submitted || "").slice(0, 4)).filter(y => y))).sort((a, b) => b.localeCompare(a))]
departments = ["All", ...Array.from(new Set(data.map(d => d.department).filter(x => x))).sort((a, b) => a.localeCompare(b))]
categories = ["All", "Cabinet departments", "Independent agencies & commissions", "Executive Office of the President", "Ambassadors", "U.S. Attorneys", "U.S. Marshals"]// Compound filter laid out in a 2-column grid; `filters` is an object of the
// current selections that the rest of the page reacts to.
viewof filters = {
// Departments shown in the second dropdown depend on the chosen category.
const deptsForCategory = (cat) => {
if (cat === "All") return departments;
const set = new Set(data.filter(d => positionCategory(d) === cat).map(d => d.department).filter(x => x));
return ["All", ...Array.from(set).sort((a, b) => a.localeCompare(b))];
};
const admin = Inputs.select(admins, {label: "Administration", value: "All"});
const category = Inputs.select(categories, {label: "Category", value: "All"});
const congress = Inputs.select(congresses, {label: "Congress", value: "All"});
const year = Inputs.select(years, {label: "Year", value: "All"});
const status = Inputs.select(statuses, {label: "Status", value: "All"});
const search = Inputs.text({placeholder: "Search by nominee name...", label: "Search"});
const deptWrap = html`<div style="width:100%"></div>`;
let dept = Inputs.select(deptsForCategory("All"), {label: "Department / agency", value: "All"});
deptWrap.appendChild(dept);
const grid = html`<div class="filter-grid"></div>`;
for (const el of [admin, category, deptWrap, congress, year, status, search]) grid.appendChild(el);
const read = () => ({
admin: admin.value, category: category.value, dept: dept.value,
congress: congress.value, year: year.value, status: status.value, search: search.value
});
grid.value = read();
grid.addEventListener("input", () => { grid.value = read(); });
// Rebuild the department dropdown when the category changes.
category.addEventListener("input", () => {
const next = Inputs.select(deptsForCategory(category.value), {label: "Department / agency", value: "All"});
deptWrap.replaceChild(next, dept);
dept = next;
grid.value = read();
});
return grid;
}html`<div class="stat-grid" style="margin-top:1.25rem;">
<div class="stat-box" style="border-top:4px solid ${selColor}">
<div class="stat-number">${totalNoms.toLocaleString()}</div>
<div class="stat-label">Total Nominations</div>
</div>
<div class="stat-box stat-sage" style="border-top:4px solid ${selColor}">
<div class="stat-number">${confirmedCount.toLocaleString()}</div>
<div class="stat-label">Confirmed</div>
</div>
<div class="stat-box stat-rose" style="border-top:4px solid ${selColor}">
<div class="stat-number">${withdrawnCount.toLocaleString()}</div>
<div class="stat-label">Withdrawn</div>
</div>
<div class="stat-box" style="border-top:4px solid ${selColor}">
<div class="stat-number">${avgDaysConfirm}</div>
<div class="stat-label">Avg. Days to Confirm</div>
</div>
</div>`filtered = data.filter(d =>
(filters.admin === "All" || d.administration === filters.admin) &&
(filters.category === "All" || positionCategory(d) === filters.category) &&
(filters.dept === "All" || d.department === filters.dept) &&
(filters.congress === "All" || d.congress === filters.congress) &&
(filters.year === "All" || (d.date_submitted || "").slice(0, 4) === filters.year) &&
(filters.status === "All" || d.status === filters.status) &&
d.nominee_name.toLowerCase().includes(filters.search.toLowerCase())
)totalNoms = filtered.length
confirmedCount = filtered.filter(d => d.status === "Confirmed").length
withdrawnCount = filtered.filter(d => d.status === "Withdrawn").length
confirmedOnly = filtered.filter(d => d.status === "Confirmed" && d.days_to_resolution != null)
avgDaysConfirm = confirmedOnly.length > 0
? Math.round(confirmedOnly.reduce((sum, d) => sum + d.days_to_resolution, 0) / confirmedOnly.length)
: 0
// When a single administration is selected, accent the tiles with its color.
selColor = filters.admin === "All" ? "transparent" : adminColors[filters.admin]// Average days to confirmation by administration (real data).
avgByAdmin = {
const result = [];
for (const admin of adminOrder) {
const confirmed = filtered.filter(d => d.administration === admin && d.status === "Confirmed" && d.days_to_resolution != null);
if (confirmed.length > 0) {
const avg = Math.round(confirmed.reduce((s, d) => s + d.days_to_resolution, 0) / confirmed.length);
result.push({ administration: admin, avg_days: avg });
}
}
return result;
}pipelineData = {
const total = filtered.length;
const confirmed = filtered.filter(d => d.status === "Confirmed").length;
const withdrawn = filtered.filter(d => d.status === "Withdrawn").length;
const returned = filtered.filter(d => d.status === "Returned").length;
const pendingCommittee = filtered.filter(d => d.status === "Pending - Committee").length;
const pendingFloor = filtered.filter(d => d.status === "Pending - Floor").length;
const submitted = total;
const reachedCommittee = total - withdrawn;
const reachedFloor = reachedCommittee - returned - pendingCommittee;
const finalConfirmed = confirmed;
return {
stages: [
{ label: "Submitted", count: submitted },
{ label: "Committee", count: Math.max(reachedCommittee, 0) },
{ label: "Floor Vote", count: Math.max(reachedFloor, 0) },
{ label: "Confirmed", count: Math.max(finalConfirmed, 0) }
],
dropouts: [
{ label: `${withdrawn} withdrawn`, between: "0-1" },
{ label: `${returned + pendingCommittee} returned/pending`, between: "1-2" },
{ label: `${pendingFloor} pending floor`, between: "2-3" }
],
total: submitted
};
}Nomination Outcomes, Pipeline & Confirmation Time
{
// A single editorial figure: composition (doughnut) on the left, and the flow
// (pipeline) over timing (avg days by administration) on the right, split by a
// hairline. Reuses the existing data semantics and hues; nothing is recolored.
const statusColor = d3.scaleOrdinal()
.domain(["Confirmed", "Withdrawn", "Returned", "Rejected", "Pending"])
.range(["#6B8F6B", "#B85C5C", "#7A8290", "#8A2846", "#5B7FA5"]);
const pcolors = ["#5B7FA5", "#C06840", "#B8923E", "#6B8F6B"];
// ---- Left column: composition (doughnut + legend) ----
const left = d3.create("div").attr("class", "merged-fig__left");
left.append("div").attr("class", "merged-fig__sublabel").text("Composition");
const dw = 200, dh = 200, r = 100, ir = r * 0.58;
const donut = d3.create("svg")
.attr("viewBox", [-dw / 2, -dh / 2, dw, dh])
.attr("width", dw).attr("height", dh)
.style("max-width", "100%").style("height", "auto").style("display", "block")
.style("font-family", "Nunito Sans, sans-serif");
const pie = d3.pie().value(d => d.count).sort(null).padAngle(0.02);
const arc = d3.arc().innerRadius(ir).outerRadius(r - 2);
donut.selectAll("path").data(pie(outcomeData)).join("path")
.attr("fill", d => statusColor(d.data.status)).attr("d", arc)
.attr("stroke", "#FEFCF8").attr("stroke-width", 2)
.append("title").text(d => `${d.data.status}: ${d.data.count.toLocaleString()}`);
donut.append("text").attr("text-anchor", "middle").attr("dy", "-0.08em")
.attr("font-size", "22px").attr("font-weight", "700").attr("fill", "#1E2D4F")
.text(totalNoms.toLocaleString());
donut.append("text").attr("text-anchor", "middle").attr("dy", "1.6em")
.attr("font-size", "9px").attr("letter-spacing", "0.12em").attr("fill", "#7A8290")
.text("TOTAL");
left.append(() => donut.node());
const legend = left.append("div").attr("class", "merged-fig__legend");
outcomeData.slice().sort((a, b) => b.count - a.count).forEach(d => {
const row = legend.append("div").attr("class", "merged-fig__legend-row");
row.append("span").attr("class", "merged-fig__swatch").style("background", statusColor(d.status));
row.append("span").html(`<strong>${d.status}</strong> · ${d.count.toLocaleString()} (${Math.round(d.count / (totalNoms || 1) * 100)}%)`);
});
// ---- Right column: pipeline funnel over timing bars ----
const right = d3.create("div").attr("class", "merged-fig__right");
const pipe = right.append("div").attr("class", "merged-fig__block");
pipe.append("div").attr("class", "merged-fig__sublabel").text("Pipeline");
const total = pipelineData.total || 1;
const stages = pipelineData.stages;
const funnel = pipe.append("div").attr("class", "merged-fig__funnel");
stages.forEach((s, i) => {
const seg = funnel.append("div").attr("class", "merged-fig__funnel-seg")
.style("width", `${(s.count / total) * 100}%`)
.style("background", pcolors[i % pcolors.length])
.attr("title", `${s.label}: ${s.count.toLocaleString()}`);
seg.append("span").attr("class", "merged-fig__seg-name").text(s.label);
seg.append("span").attr("class", "merged-fig__seg-val").text(s.count.toLocaleString());
});
const drops = pipe.append("div").attr("class", "merged-fig__drops");
pipelineData.dropouts.forEach((d, i) => {
drops.append("div").attr("class", "merged-fig__drop")
.style("width", `${(stages[i].count / total) * 100}%`)
.text(`↓ ${d.label}`);
});
const timing = right.append("div").attr("class", "merged-fig__block");
timing.append("div").attr("class", "merged-fig__sublabel").text("Days to Confirmation");
// Box-and-whisker per administration (confirmed nominees, min n = 5): the box is
// the middle 50% (IQR), the navy tick the median, the whisker the range; delay
// is right-skewed, so this shows what a single average hides.
const boxByAdmin = [];
for (const a of adminOrder) {
const vals = filtered.filter(d => d.administration === a && d.status === "Confirmed" && d.days_to_resolution != null)
.map(d => d.days_to_resolution).sort(d3.ascending);
if (vals.length >= 5) {
const q1 = d3.quantile(vals, 0.25), med = d3.quantile(vals, 0.5), q3 = d3.quantile(vals, 0.75), iqr = q3 - q1;
boxByAdmin.push({ a, q1, med, q3, n: vals.length, lo: Math.max(vals[0], q1 - 1.5 * iqr), hi: Math.min(vals[vals.length - 1], q3 + 1.5 * iqr) });
}
}
const maxHi = d3.max(boxByAdmin, d => d.hi) || 1;
const bars = timing.append("div").attr("class", "merged-fig__bars");
boxByAdmin.forEach(b => {
const row = bars.append("div").attr("class", "merged-fig__row");
row.append("span").attr("class", "merged-fig__row-label").attr("title", b.a).text(b.a);
const track = row.append("div").attr("class", "merged-fig__box-track")
.attr("title", `${b.a}: median ${Math.round(b.med)}d · middle 50% ${Math.round(b.q1)}–${Math.round(b.q3)}d · range ${Math.round(b.lo)}–${Math.round(b.hi)}d (n=${b.n.toLocaleString()})`);
track.append("div").attr("class", "merged-fig__whisker")
.style("left", `${b.lo / maxHi * 100}%`).style("width", `${(b.hi - b.lo) / maxHi * 100}%`);
track.append("div").attr("class", "merged-fig__iqr")
.style("left", `${b.q1 / maxHi * 100}%`).style("width", `${(b.q3 - b.q1) / maxHi * 100}%`)
.style("background", adminColors[b.a]);
track.append("div").attr("class", "merged-fig__median").style("left", `${b.med / maxHi * 100}%`);
row.append("span").attr("class", "merged-fig__row-val").text(`${Math.round(b.med)}d`);
});
// Shared 0 -> max scale reference.
const axisRow = bars.append("div").attr("class", "merged-fig__row merged-fig__axis-row");
axisRow.append("span");
const scale = axisRow.append("div").attr("class", "merged-fig__scale");
scale.append("span").text("0");
scale.append("span").text(`${Math.round(maxHi)}d`);
axisRow.append("span").attr("class", "merged-fig__row-val");
timing.append("div").attr("class", "merged-fig__boxkey")
.html(`<span class="merged-fig__boxkey-mark"></span>box = middle 50% · line = median · whisker = range`);
// ---- Assemble ----
const fig = d3.create("div").attr("class", "merged-fig");
fig.append(() => left.node());
fig.append(() => right.node());
return fig.node();
}usStates = FileAttachment("../data/us-states.geojson").json()
stateAbbrevToName = ({
AL:"Alabama", AK:"Alaska", AZ:"Arizona", AR:"Arkansas", CA:"California",
CO:"Colorado", CT:"Connecticut", DE:"Delaware", FL:"Florida", GA:"Georgia",
HI:"Hawaii", ID:"Idaho", IL:"Illinois", IN:"Indiana", IA:"Iowa", KS:"Kansas",
KY:"Kentucky", LA:"Louisiana", ME:"Maine", MD:"Maryland", MA:"Massachusetts",
MI:"Michigan", MN:"Minnesota", MS:"Mississippi", MO:"Missouri", MT:"Montana",
NE:"Nebraska", NV:"Nevada", NH:"New Hampshire", NJ:"New Jersey", NM:"New Mexico",
NY:"New York", NC:"North Carolina", ND:"North Dakota", OH:"Ohio", OK:"Oklahoma",
OR:"Oregon", PA:"Pennsylvania", RI:"Rhode Island", SC:"South Carolina",
SD:"South Dakota", TN:"Tennessee", TX:"Texas", UT:"Utah", VT:"Vermont",
VA:"Virginia", WA:"Washington", WV:"West Virginia", WI:"Wisconsin", WY:"Wyoming",
DC:"District of Columbia", PR:"Puerto Rico", GU:"Guam", VI:"Virgin Islands",
AS:"American Samoa", MP:"Northern Mariana Islands"
})
stateNameToAbbrev = Object.fromEntries(Object.entries(stateAbbrevToName).map(([a, n]) => [n, a]))Where Nominees Come From
Nominees’ home states as recorded in each nomination. The map responds to every filter above: administration, category, department/agency, Congress, and year.
usStatesRewound = {
const ringArea = ring => d3.geoArea({ type: "Polygon", coordinates: [ring] });
const fixRing = ring => ringArea(ring) > 2 * Math.PI ? ring.slice().reverse() : ring;
const fixPoly = poly => poly.map(fixRing);
const fix = f => {
const g = f.geometry;
const coordinates = g.type === "Polygon" ? fixPoly(g.coordinates) : g.coordinates.map(fixPoly);
return { ...f, geometry: { ...g, coordinates } };
};
return { ...usStates, features: usStates.features.map(fix) };
}
// Static base geometry: project each state ONCE (raw d3, not Plot) so the
// outlines are fixed; only the fill color updates when the filters change.
usGeoPaths = {
const width = 900, height = 520;
const projection = d3.geoAlbersUsa().fitSize([width, height], usStatesRewound);
const path = d3.geoPath(projection);
return usStatesRewound.features
.map(f => ({ name: f.properties.name, abbrev: stateNameToAbbrev[f.properties.name], d: path(f) }))
.filter(s => s.d);
}{
const width = 900, height = 520, legendH = 42;
const maxN = d3.max(usGeoPaths, s => homeStateCounts[s.abbrev] || 0) || 1;
const color = d3.scaleSequentialSqrt(d3.interpolateYlOrBr).domain([0, maxN]);
const svg = d3.create("svg")
.attr("viewBox", [0, 0, width, height + legendH])
.style("width", "100%").style("max-width", "900px").style("height", "auto")
.style("font-family", "Nunito Sans, sans-serif");
// States: fixed outlines from the static geometry, gradient fill on top.
svg.append("g")
.selectAll("path")
.data(usGeoPaths)
.join("path")
.attr("d", s => s.d)
.attr("fill", s => { const n = homeStateCounts[s.abbrev] || 0; return n ? color(n) : "#EFE7DA"; })
.attr("stroke", "#8A8172")
.attr("stroke-width", 0.5)
.append("title")
.text(s => `${s.name}: ${(homeStateCounts[s.abbrev] || 0).toLocaleString()} nominees`);
// Color legend.
const grad = svg.append("defs").append("linearGradient").attr("id", "map-legend-grad");
for (let i = 0; i <= 10; i++) {
grad.append("stop").attr("offset", `${i * 10}%`).attr("stop-color", color(maxN * Math.pow(i / 10, 2)));
}
const lg = svg.append("g").attr("transform", `translate(20, ${height + 6})`);
lg.append("text").attr("y", -2).attr("font-size", "10px").attr("font-weight", "700").attr("fill", "#4F5B6E").text("Nominees");
lg.append("rect").attr("y", 4).attr("width", 220).attr("height", 10).attr("rx", 2).attr("fill", "url(#map-legend-grad)");
const lx = d3.scaleSqrt().domain([0, maxN]).range([0, 220]);
[0, Math.round(maxN * 0.25), Math.round(maxN * 0.5), maxN].forEach(v => {
lg.append("text").attr("x", lx(v)).attr("y", 26).attr("font-size", "9px").attr("fill", "#7A8290").attr("text-anchor", "middle").text(v.toLocaleString());
});
return svg.node();
}{
const counts = homeStateCounts;
const top = Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 6)
.map(([s, n]) => `${s} (${n.toLocaleString()})`).join(" · ");
const blank = filtered.filter(d => !d.nominee_state).length;
return html`<p class="data-disclaimer" style="margin-top:0.75rem">Top home states: ${top || "none in current view"}. ${blank.toLocaleString()} of ${filtered.length.toLocaleString()} shown nominations have no recorded home state (a small number of military and board appointees identified by rank or office rather than by state) and are not mapped.</p>`;
}Confirmation Rate Over Time, by Position Category
Share of decided nominations that were confirmed, confirmedconfirmed + returned + withdrawn + rejected, by Congress. Pending nominations are excluded, so an incomplete current Congress is not counted as failure. The dashed grey line in each panel is the overall rate across all categories. Responds to every filter above.
catMeta = [
{ key: "Cabinet departments", short: "Cabinet depts." },
{ key: "Independent agencies & commissions", short: "Independent agencies" },
{ key: "Executive Office of the President", short: "Exec. Office of the Pres." },
{ key: "Ambassadors", short: "Ambassadors" },
{ key: "U.S. Attorneys", short: "U.S. Attorneys" },
{ key: "U.S. Marshals", short: "U.S. Marshals" }
]// Confirmation rate among DECIDED nominations (confirmed / confirmed+returned+
// withdrawn); pending excluded so an incomplete current Congress is not counted
// as failure. Grouped by Congress and position category (min n = 3 per point).
confRateByCongressCat = {
const decided = filtered.filter(d => d.status === "Confirmed" || d.status === "Returned" || d.status === "Withdrawn" || d.status === "Rejected");
const rows = [];
for (const [congress, recs] of d3.group(decided, d => d.congress)) {
const byCat = d3.group(recs, positionCategory);
for (const { key } of catMeta) {
const cr = byCat.get(key) || [];
if (cr.length >= 3) {
rows.push({ congress: +congress, category: key, rate: cr.filter(d => d.status === "Confirmed").length / cr.length, n: cr.length });
}
}
}
return rows;
}{
if (!confRateByCongressCat.length) return html`<em style="color:#7A8290">No decided nominations in the current view.</em>`;
const congresses = Array.from(new Set(confRateByCongressCat.map(d => d.congress)));
const xDomain = [d3.min(congresses), d3.max(congresses)];
const overall = Array.from(
d3.rollup(confRateByCongressCat, v => d3.sum(v, d => d.rate * d.n) / d3.sum(v, d => d.n), d => d.congress),
([congress, rate]) => ({ congress, rate })
).sort((a, b) => a.congress - b.congress);
const panel = (cat) => {
const data = confRateByCongressCat.filter(d => d.category === cat.key).sort((a, b) => a.congress - b.congress);
return Plot.plot({
width: 250, height: 148,
marginLeft: 34, marginRight: 8, marginTop: 24, marginBottom: 22,
style: { fontFamily: "Nunito Sans, sans-serif", background: "transparent" },
x: { domain: xDomain, label: null, tickFormat: "d", ticks: xDomain },
y: { domain: [0, 1], label: null, tickFormat: "%", ticks: [0, 0.5, 1], grid: true },
marks: [
Plot.line(overall, { x: "congress", y: "rate", stroke: "#CBBEA9", strokeWidth: 1, strokeDasharray: "3,2" }),
Plot.line(data, { x: "congress", y: "rate", stroke: "#C06840", strokeWidth: 2, curve: "catmull-rom" }),
Plot.dot(data, { x: "congress", y: "rate", fill: "#C06840", r: 2.5, tip: true, title: d => `${cat.short} · ${d.congress}th Congress\n${d3.format(".0%")(d.rate)} confirmed (n=${d.n})` }),
Plot.text([cat.short], { frameAnchor: "top", dy: -14, text: d => d, fontWeight: 700, fontSize: 11, fill: "#1E2D4F" })
]
});
};
const grid = d3.create("div").style("display", "flex").style("flex-wrap", "wrap")
.style("gap", "0.5rem 1.25rem").style("justify-content", "center");
for (const cat of catMeta) grid.append(() => panel(cat));
return grid.node();
}Where Confirmation Is Slowest: Median Days by Category and Administration
Median days from submission to confirmation for confirmed nominees, by position category (rows) and administration (columns). Darker = slower. Cells with fewer than three confirmations are left blank. Responds to every filter above.
heatData = {
const rows = [];
for (const { key } of catMeta) {
for (const admin of adminOrder) {
const vals = filtered.filter(d => positionCategory(d) === key && d.administration === admin
&& d.status === "Confirmed" && d.days_to_resolution != null).map(d => d.days_to_resolution);
if (vals.length >= 3) rows.push({ category: key, administration: admin, median: d3.median(vals), n: vals.length });
}
}
return rows;
}{
if (!heatData.length) return html`<em style="color:#7A8290">No confirmed nominations in the current view.</em>`;
const shortOf = Object.fromEntries(catMeta.map(c => [c.key, c.short]));
const yDomain = catMeta.map(c => c.key).filter(k => heatData.some(d => d.category === k));
const xDomain = adminOrder.filter(a => heatData.some(d => d.administration === a));
const maxMed = d3.max(heatData, d => d.median);
return Plot.plot({
marginLeft: 160, marginTop: 10, marginBottom: 34, height: 40 + yDomain.length * 34,
style: { fontFamily: "Nunito Sans, sans-serif" },
x: { domain: xDomain, label: null, tickSize: 0 },
y: { domain: yDomain, label: null, tickSize: 0, tickFormat: k => shortOf[k] },
color: { type: "linear", domain: [0, maxMed], range: ["#FBEFE1", "#A8431C"], label: "Median days", legend: true },
marks: [
Plot.cell(heatData, { x: "administration", y: "category", fill: "median", inset: 1.5, rx: 3 }),
Plot.text(heatData, { x: "administration", y: "category", text: d => Math.round(d.median), fill: d => d.median > maxMed * 0.5 ? "#FEFCF8" : "#4F5B6E", fontSize: 11, fontWeight: 600 })
]
});
}Individual Nomination Records
Every nomination matching the filters above. Click any column header to sort, for example by Congress, State, or days to final action.
// Days from nomination to final Senate action. Nominations still pending are
// counted through today; resolved ones use their final-action date.
daysToFinalAction = (d) => {
if (!d.date_submitted) return null;
const start = new Date(d.date_submitted + "T00:00:00");
const end = d.date_resolved ? new Date(d.date_resolved + "T00:00:00") : today;
return Math.max(0, Math.round((end - start) / 86400000));
}Inputs.table(tableRows, {
columns: ["congress", "department", "position", "nominee_name", "date_submitted", "date_resolved", "status", "days_to_final"],
header: {
congress: "Congress",
department: "Department",
position: "Position",
nominee_name: "Nominee",
date_submitted: "Date Submitted",
date_resolved: "Final Action Date",
status: "Status",
days_to_final: "Days to Final Action"
},
sort: "date_submitted",
reverse: true,
rows: 20
})⚠️ THE FOLLOWING DATA ARE UNDER CONSTRUCTION ⚠️
The sections below use simulated placeholder data (the real acting-appointments dataset is not yet released). All figures are illustrative only and will change once the real data lands.
// Metric (confirmed): average days from when an office became VACANT (acting
// service began) until the nomination was submitted, by administration. This is
// exactly days_before_submission from the linkage; correct definition, but it
// needs the REAL acting/vacancy dataset; the mock data makes the values here
// illustrative only until then.
avgToNomination = {
const byAdmin = {};
for (const [key, overlaps] of Object.entries(actingByNom)) {
const nom = nomByKey.get(key);
if (!nom || !overlaps.length) continue;
(byAdmin[nom.administration] = byAdmin[nom.administration] || []).push(overlaps[0].days_before_submission || 0);
}
return adminOrder.filter(a => byAdmin[a]).map(a => ({
administration: a,
avg_days: Math.round(byAdmin[a].reduce((s, v) => s + v, 0) / byAdmin[a].length)
}));
}Average Days to Nomination by Administration
Under construction. This will show the average days from when an office became vacant (acting service began) until the President submitted a nomination, by administration. It requires the real acting/vacancy dataset. The bars below are drawn from simulated placeholder data and are illustrative only.
Plot.plot({
marginLeft: 90,
height: 300,
x: { label: "Days (illustrative)" },
y: { label: null, domain: adminOrder.filter(a => avgToNomination.some(d => d.administration === a)) },
color: { domain: adminOrder, range: adminOrder.map(a => adminColors[a]) },
marks: [
Plot.barX(avgToNomination, { y: "administration", x: "avg_days", fill: "administration", fillOpacity: 0.75 }),
Plot.text(avgToNomination, { y: "administration", x: "avg_days", text: d => `${d.avg_days}d`, dx: 6, fill: "#4F5B6E", fontSize: 11, textAnchor: "start" }),
Plot.ruleX([0])
]
})Nominee ↔︎ Acting Appointee Linkage
⚠️ UNDER CONSTRUCTION: ILLUSTRATIVE ONLY. Linked to placeholder acting data. The overlapping acting spells and the four metrics below are simulated and not yet real findings. The acting official is not the nominee; the two are matched by office and overlapping dates.
viewof selectedNomKey = Inputs.select(
Object.keys(actingByNom).sort((a, b) => {
const na = nomByKey.get(a), nb = nomByKey.get(b);
return (na && na.nominee_name || "").localeCompare(nb && nb.nominee_name || "");
}),
{
label: "Nominee",
format: k => {
const n = nomByKey.get(k);
return n ? `${n.nominee_name}, ${n.position} (${n.administration})` : k;
}
}
){
const key = selectedNomKey;
const nom = key ? nomByKey.get(key) : null;
const overlaps = key ? (actingByNom[key] || []) : [];
if (!nom) return html`<em style="color:#7A8290">No linked nominee selected.</em>`;
const parse = s => s ? new Date(s + "T00:00:00") : null;
const today = new Date();
const nomStart = parse(nom.date_submitted);
const nomEnd = parse(nom.date_resolved) || today;
const spells = overlaps.slice(0, 5).map(o => ({
name: o.acting_name || "Acting official",
start: parse(o.acting_start),
end: parse(o.acting_end) || today,
m: o
}));
let minD = nomStart, maxD = nomEnd;
for (const s of spells) {
if (s.start && s.start < minD) minD = s.start;
if (s.end && s.end > maxD) maxD = s.end;
}
const pad = Math.max(1, (maxD - minD)) * 0.06;
const width = 720, rowH = 30, topPad = 18;
const rows = 1 + spells.length;
const height = topPad + rows * rowH + 34;
const margin = { left: 150, right: 20 };
const x = d3.scaleTime()
.domain([new Date(+minD - pad), new Date(+maxD + pad)])
.range([margin.left, width - margin.right]);
const svg = d3.create("svg")
.attr("viewBox", [0, 0, width, height])
.attr("width", width)
.style("max-width", "100%").style("height", "auto")
.style("font-family", "Nunito Sans, sans-serif");
svg.append("g")
.attr("transform", `translate(0,${topPad + rows * rowH + 6})`)
.call(d3.axisBottom(x).ticks(6).tickFormat(d3.timeFormat("%b %Y")))
.call(g => g.selectAll("text").attr("font-size", "10px").attr("fill", "#7A8290"))
.call(g => g.selectAll("line,path").attr("stroke", "#E6DDD0"));
svg.append("rect")
.attr("x", x(nomStart)).attr("y", topPad)
.attr("width", Math.max(2, x(nomEnd) - x(nomStart))).attr("height", 18)
.attr("rx", 3).attr("fill", "#1E2D4F").attr("opacity", 0.85)
.append("title")
.text(`Nomination pending: ${nom.date_submitted} → ${nom.date_resolved || "ongoing"} (${nom.status})`);
svg.append("text")
.attr("x", margin.left - 8).attr("y", topPad + 13).attr("text-anchor", "end")
.attr("font-size", "11px").attr("font-weight", "700").attr("fill", "#1E2D4F")
.text("Nomination");
spells.forEach((s, i) => {
const y = topPad + (i + 1) * rowH;
svg.append("rect")
.attr("x", x(s.start)).attr("y", y)
.attr("width", Math.max(2, x(s.end) - x(s.start))).attr("height", 18)
.attr("rx", 3).attr("fill", "#C06840").attr("opacity", 0.85)
.append("title")
.text(`Acting: ${s.name} · ${s.m.acting_start} → ${s.m.acting_end || "ongoing"} · overlap ${s.m.overlap_days}d`);
svg.append("text")
.attr("x", margin.left - 8).attr("y", y + 13).attr("text-anchor", "end")
.attr("font-size", "10px").attr("fill", "#C06840")
.text(s.name.length > 20 ? s.name.slice(0, 19) + "…" : s.name);
});
return svg.node();
}{
const key = selectedNomKey;
const overlaps = key ? (actingByNom[key] || []) : [];
if (!overlaps.length) return html``;
const o = overlaps[0];
return html`<div class="stat-grid" style="grid-template-columns:repeat(4,1fr);margin-top:1rem">
<div class="stat-box stat-terracotta"><div class="stat-number">${o.overlap_days}</div><div class="stat-label">Days nominee waited while acting served</div></div>
<div class="stat-box"><div class="stat-number" style="font-size:1.15rem">${o.status_during}</div><div class="stat-label">Nomination status during acting</div></div>
<div class="stat-box"><div class="stat-number">${o.days_before_submission}</div><div class="stat-label">Acting days before submission</div></div>
<div class="stat-box stat-rose"><div class="stat-number">${o.days_after_resolution}</div><div class="stat-label">Acting days after return/withdrawal</div></div>
</div>`;
}