Household Disaggregation and Auto Ownership
viewof globalCalibRun = {
const options = calib_runs_ojs;
if (options.length === 0) {
return Inputs.select([], {label: "Calibration Run:", disabled: true});
}
// Default to the run with the highest numeric ID (e.g. "C51" over "C50"),
// not just the last entry in `options` -- that list comes from a plain
// alphabetical sort (see list_available_runs in _validation_scripts.py),
// which only happens to match numeric order today because both IDs have
// the same digit count ("C9" would sort after "C50" alphabetically despite
// being numerically earlier).
const runNumber = id => {
const m = /(\d+)/.exec(id);
return m ? parseInt(m[1], 10) : -Infinity;
};
const latestRun = options.reduce((best, id) => runNumber(id) > runNumber(best) ? id : best, options[0]);
const stored = window.localStorage.getItem("tdmcalib:selectedRun");
const initial = options.includes(stored) ? stored : latestRun;
const input = Inputs.select(options, {label: "Calibration Run:", value: initial});
const selectEl = input.querySelector("select");
input.classList.add("calib-run-bar");
// Sidebar links for pages that don't have data for every run (currently
// just report/cvm-update.qmd, which is only ever generated for the model
// version's reference run -- not planned for every run the way the other
// report pages are) get greyed out -- rather than the run dropdown itself
// shrinking to that page's subset -- so the dropdown's options stay
// identical everywhere on the site. The link stays clickable. If the
// current page happens to be cvm-update.qmd itself, this also swaps its
// own #cvm-content/#cvm-empty-message visibility -- both elements are
// absent on every other page, so this is a no-op there.
function updateSidebarAvailability(run) {
const available = cvm_update_available_runs_ojs.includes(run);
const link = document.querySelector('#quarto-sidebar a[href$="cvm-update.html"]');
if (link) {
const item = link.closest(".sidebar-item") ?? link;
item.classList.toggle("sidebar-item-no-run-data", !available);
link.title = available ? "" : `No commercial-vehicle factoring data for ${run}`;
}
const content = document.getElementById("cvm-content");
const emptyMsg = document.getElementById("cvm-empty-message");
if (content) content.style.display = available ? "" : "none";
if (emptyMsg) emptyMsg.style.display = available ? "none" : "";
}
updateSidebarAvailability(initial);
input.addEventListener("input", () => {
window.localStorage.setItem("tdmcalib:selectedRun", input.value);
updateSidebarAvailability(input.value);
});
// Quarto's own OJS runtime places this cell's returned node into its
// normal in-page output slot right after this function returns -- moving
// it into the header/sidebar synchronously, before that happens, would
// just get undone a moment later. Deferring to the next tick runs this
// after that placement, so the move actually sticks.
setTimeout(() => {
const navbarBrand = document.querySelector("#quarto-header .navbar-brand-container");
if (navbarBrand) {
input.classList.remove("calib-run-bar");
input.classList.add("calib-run-navbar");
navbarBrand.appendChild(input);
return;
}
const sidebar = document.querySelector("#quarto-sidebar");
const menu = sidebar?.querySelector(".sidebar-menu-container");
if (!sidebar || !menu) return; // no navbar brand container or docked sidebar -- stays inline as a .calib-run-bar
// Inputs.select renders the label text and <select> on the same line by
// default -- force them onto separate lines to match the sidebar's
// stacked item style. Insert relative to the select's actual parent
// (not assumed to be the <label>, which varies by Inputs version) so
// this doesn't throw if they aren't nested.
selectEl?.parentNode.insertBefore(document.createElement("br"), selectEl);
input.classList.remove("calib-run-bar");
input.classList.add("calib-run-sidebar");
sidebar.insertBefore(input, menu);
}, 0);
return input;
}themeModel = "#ea7b00" // WFRC core orange
themeTarget = "#036fa8" // WFRC core blue
themeTargetAlt = "#007426" // WFRC core green
themeTargetLight = "#8ebed8" // Lighter tint of Target blue (#036fa8)
themeAccent = "#9d4879" // WFRC RTP fuschia
themeNeutral = "#7f7a76" // WFRC brand gray
themeDanger = "#d32f00" // WFRC core red -- status color, pair with a label/icon, never color alone
themeLinkGrey = "#d8d7d6"Household Disaggregation
Modeled vs. Target
hh_data = transpose(hhdis_data);
// Sorted unique values for facet dimensions (HHSIZE = cols, WRKS = rows)
hh_hhsize_vals = Array.from(new Set(hh_data.map(d => d.HHSIZE))).sort((a, b) => a - b)
hh_wrks_vals = Array.from(new Set(hh_data.map(d => d.WRKS))).sort((a, b) => a - b)
inc_list = ["Total", "<$50k", "$50k–$100k", "$100k–$150k", ">$150k"]viewof vXAxis = Inputs.select(["Modeled", "PUMS", "HTS"],
{ label: "X Axis:", value: "PUMS" })
viewof vYAxis = Inputs.select(
["Modeled", "PUMS", "HTS"].filter(d => d !== vXAxis),
{
label: "Y Axis:",
value: ["Modeled", "PUMS", "HTS"].find(d => d !== vXAxis)
}
)
viewof vDisplay = Inputs.radio(["Households", "Percentages"],
{ label: "Display:", value: "Households" })hh_filtered_base = hh_data.filter(d =>
d.calib_run === globalCalibRun && (vINC === "Total" || d.INC === vINC)
)
// Aggregate by geo × HHSIZE × WRKS
hh_agg_facet = {
const grouped = d3.groups(hh_filtered_base, d => d[vGeoLevel], d => d.HHSIZE, d => d.WRKS);
const rows = [];
for (const [geo_id, bySize] of grouped) {
for (const [hhsize, byWrks] of bySize) {
for (const [wrks, records] of byWrks) {
const mod = d3.sum(records, d => d.Modeled_Count);
const pums = d3.sum(records, d => d.Observed_Count);
const hts = d3.sum(records, d => d.HTS_Count);
rows.push({
geo_id, HHSIZE: hhsize, WRKS: wrks,
Modeled_Count: mod, Observed_Count: pums, HTS_Count: hts,
Mod_vs_PUMS: pums > 0 ? (mod - pums) / pums : 0,
Mod_vs_HTS: hts > 0 ? (mod - hts) / hts : 0,
PUMS_vs_Mod: mod > 0 ? (pums - mod) / mod : 0,
HTS_vs_Mod: mod > 0 ? (hts - mod) / mod : 0,
PUMS_vs_HTS: hts > 0 ? (pums - hts) / hts : 0,
HTS_vs_PUMS: pums > 0 ? (hts - pums) / pums : 0,
});
}
}
}
// Normalise each source by its own grand total across all geo × size × workers
const totMod = d3.sum(rows, d => d.Modeled_Count);
const totPums = d3.sum(rows, d => d.Observed_Count);
const totHts = d3.sum(rows, d => d.HTS_Count);
return rows.map(r => ({
...r,
Modeled_Pct: totMod > 0 ? r.Modeled_Count / totMod : 0,
Observed_Pct: totPums > 0 ? r.Observed_Count / totPums : 0,
HTS_Pct: totHts > 0 ? r.HTS_Count / totHts : 0,
}));
}
hh_xy_cfg = {
const fm = {
"PUMS": { field: "Observed_Count", pctField: "Observed_Pct", label: "Target (PUMS)", color: themeTarget },
"HTS": { field: "HTS_Count", pctField: "HTS_Pct", label: "Target (HTS)", color: themeTargetAlt },
"Modeled": { field: "Modeled_Count", pctField: "Modeled_Pct", label: "Modeled", color: themeModel }
};
const errMap = {
"PUMS|Modeled": "Mod_vs_PUMS", "PUMS|HTS": "HTS_vs_PUMS",
"HTS|Modeled": "Mod_vs_HTS", "HTS|PUMS": "PUMS_vs_HTS",
"Modeled|PUMS": "PUMS_vs_Mod", "Modeled|HTS": "HTS_vs_Mod"
};
return { xCfg: fm[vXAxis], yCfg: fm[vYAxis], errField: errMap[`${vXAxis}|${vYAxis}`] };
}
hh_vf = vDisplay === "Percentages"
? { xf: hh_xy_cfg.xCfg.pctField, yf: hh_xy_cfg.yCfg.pctField,
fmt: d3.format(".1%"), tickFmt: d3.format(".0%"), axSuffix: " (%)" }
: { xf: hh_xy_cfg.xCfg.field, yf: hh_xy_cfg.yCfg.field,
fmt: d3.format(",.0f"), tickFmt: undefined, axSuffix: "" }
hh_chart_max = d3.max(hh_agg_facet, d => Math.max(d[hh_vf.xf], d[hh_vf.yf])) * 1.05// 2-D faceted scatter: columns = HH Size, rows = Workers
Plot.plot({
grid: true,
width: 900,
height: 140 * hh_wrks_vals.length + 40,
marginRight: 60,
marginLeft: 50,
fx: { label: "HH Size", tickFormat: d => `Size ${d}`, padding: 0.1 },
fy: { label: "Workers", tickFormat: d => `${d} Worker${d !== 1 ? "s" : ""}`, padding: 0.1 },
x: {
label: `${hh_xy_cfg.xCfg.label}${hh_vf.axSuffix} →`,
domain: [0, hh_chart_max],
tickFormat: hh_vf.tickFmt
},
y: {
label: `↑ ${hh_xy_cfg.yCfg.label}${hh_vf.axSuffix}`,
domain: [0, hh_chart_max],
tickFormat: hh_vf.tickFmt
},
caption: html`<b>${hh_xy_cfg.yCfg.label} vs. ${hh_xy_cfg.xCfg.label}</b> — columns: HH Size | rows: Workers`,
marks: [
// 45-degree equal reference line in every facet panel
Plot.link(
hh_hhsize_vals.flatMap(s => hh_wrks_vals.map(w => ({ s, w }))),
{
x1: 0, y1: 0,
x2: hh_chart_max, y2: hh_chart_max,
fx: "s", fy: "w",
stroke: "gray", strokeOpacity: 0.7, strokeWidth: 1.5
}
),
Plot.dot(hh_agg_facet, {
x: hh_vf.xf, y: hh_vf.yf,
fx: "HHSIZE", fy: "WRKS",
r: 3.5, fill: hh_xy_cfg.yCfg.color, fillOpacity: 0.6,
tip: true,
title: d =>
`${vGeoLevel}: ${d.geo_id}\nHH Size: ${d.HHSIZE} Workers: ${d.WRKS}\n` +
`${hh_xy_cfg.xCfg.label}: ${hh_vf.fmt(d[hh_vf.xf])}\n` +
`${hh_xy_cfg.yCfg.label}: ${hh_vf.fmt(d[hh_vf.yf])}`
}),
Plot.linearRegressionY(hh_agg_facet, {
x: hh_vf.xf, y: hh_vf.yf,
fx: "HHSIZE", fy: "WRKS",
stroke: hh_xy_cfg.yCfg.color, strokeDasharray: "4 4"
})
]
})function geojsonBoundsHh(fc) {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
const visit = (coords) => {
if (typeof coords[0] === "number") {
const [x, y] = coords;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
} else {
coords.forEach(visit);
}
};
for (const f of fc.features || []) {
if (f.geometry) visit(f.geometry.coordinates);
}
return minX === Infinity ? null : [[minX, minY], [maxX, maxY]];
}
// A legend/info box rendered as a MapLibre IControl, mirroring the
// L.control({position}) + onAdd-returns-a-div pattern these maps used to
// build their legends under Leaflet.
class HtmlLegendControlHh {
constructor(html) { this._html = html; }
onAdd(map) {
this._map = map;
this._container = document.createElement("div");
this._container.className = "maplibregl-ctrl";
this._container.innerHTML = this._html;
return this._container;
}
onRemove() {
this._container.parentNode?.removeChild(this._container);
this._map = undefined;
}
}
// Basemap style. UGRC's vector tile services (bundled into one merged
// style, report/data/basemap/ugrc-wfrc-lite.json -- a WFRC-tweaked copy
// of UGRC's "lite" style) don't need an API key, unlike the commercial
// basemap these maps used before -- see _quarto.yml's `resources:` entry
// for why that file ships with the site. Path is relative to each
// rendered report page (report/data/basemap/... under docs/), not to
// this .qmd source file.
UGRC_BASEMAP_STYLE_HH = "data/basemap/ugrc-wfrc-lite.json"
UGRC_ATTRIBUTION_HH = "© <a href=\"https://gis.utah.gov/\">Utah AGRC</a>"{
const maplibregl = await require("maplibre-gl@5.24.0/dist/maplibre-gl.js");
if (!document.getElementById("maplibre-css")) {
const link = document.createElement("link");
link.id = "maplibre-css";
link.rel = "stylesheet";
link.href = "https://unpkg.com/maplibre-gl@5.24.0/dist/maplibre-gl.css";
document.head.appendChild(link);
}
const container = html`<div style="height:560px; width:100%; border-radius:6px; overflow:hidden;"></div>`;
yield container;
const map = new maplibregl.Map({
container,
style: UGRC_BASEMAP_STYLE_HH,
center: [-111.8910, 40.7608],
zoom: 9,
attributionControl: { customAttribution: UGRC_ATTRIBUTION_HH }
});
map.addControl(new maplibregl.NavigationControl(), "top-left");
// Aggregate map data across all HHSIZE × WRKS combinations for selected income filter
const mapAgg = d3.rollup(
hh_filtered_base,
v => {
const mod = d3.sum(v, d => d.Modeled_Count);
const pums = d3.sum(v, d => d.Observed_Count);
const hts = d3.sum(v, d => d.HTS_Count);
return {
Modeled_Count: mod, Observed_Count: pums, HTS_Count: hts,
Mod_vs_PUMS: pums > 0 ? (mod - pums) / pums : null,
Mod_vs_HTS: hts > 0 ? (mod - hts) / hts : null,
PUMS_vs_Mod: mod > 0 ? (pums - mod) / mod : null,
HTS_vs_Mod: mod > 0 ? (hts - mod) / mod : null,
PUMS_vs_HTS: hts > 0 ? (pums - hts) / hts : null,
HTS_vs_PUMS: pums > 0 ? (hts - pums) / pums : null,
};
},
d => d[vGeoLevel]
);
const geo_lookup = {
"CO_FIPS": { geo: geo_county_hh, prop: "CO_FIPS" },
"PUMA": { geo: geo_puma_hh, prop: "PUMA" }
};
const { geo: base_geo, prop: name_prop } = geo_lookup[vGeoLevel];
const colorScale = d3.scaleDiverging(d3.interpolateRdBu).domain([0.5, 0, -0.5]);
function getColor(pct) {
if (pct === null || pct === undefined) return "#cccccc";
return colorScale(Math.max(-0.5, Math.min(0.5, pct)));
}
const features = base_geo.features.map((f, i) => {
const key = f.properties[name_prop];
const row = mapAgg.get(+key) || mapAgg.get(String(key))
|| { Modeled_Count: 0, Observed_Count: 0, HTS_Count: 0,
Mod_vs_PUMS: null, Mod_vs_HTS: null,
PUMS_vs_Mod: null, HTS_vs_Mod: null,
PUMS_vs_HTS: null, HTS_vs_PUMS: null };
const err = row[hh_xy_cfg.errField];
const noData = err === null || err === undefined;
const popup =
`<b>${vGeoLevel} ${key}</b><br>` +
`<i style="font-size:10px;color:#666">${hh_xy_cfg.yCfg.label} vs. ${hh_xy_cfg.xCfg.label}</i><br>` +
`Pct Error: ${noData ? "N/A" : (err * 100).toFixed(1) + "%"}<br>` +
`Modeled: ${d3.format(",.0f")(row.Modeled_Count)}<br>` +
`Target (PUMS): ${d3.format(",.0f")(row.Observed_Count)}<br>` +
`Target (HTS): ${d3.format(",.0f")(row.HTS_Count)}`;
return { ...f, id: i, properties: { ...f.properties, ...row, _color: getColor(err), _popup: popup } };
});
const map_features_hh = { type: "FeatureCollection", features };
let loaded = false;
let hoveredId = null;
map.on("load", () => {
loaded = true;
map.addSource("hh-choropleth", { type: "geojson", data: map_features_hh });
map.addLayer({
id: "hh-choropleth-fill",
type: "fill",
source: "hh-choropleth",
paint: {
"fill-color": ["get", "_color"],
"fill-opacity": ["case", ["boolean", ["feature-state", "hover"], false], 0.95, 0.72]
}
});
map.addLayer({
id: "hh-choropleth-outline",
type: "line",
source: "hh-choropleth",
paint: {
"line-color": "#ffffff",
"line-width": ["case", ["boolean", ["feature-state", "hover"], false], 2, 1]
}
});
map.on("mousemove", "hh-choropleth-fill", (e) => {
if (!e.features.length) return;
const id = e.features[0].id;
if (id === hoveredId) return;
if (hoveredId != null) map.setFeatureState({ source: "hh-choropleth", id: hoveredId }, { hover: false });
hoveredId = id;
map.setFeatureState({ source: "hh-choropleth", id }, { hover: true });
});
map.on("mouseleave", "hh-choropleth-fill", () => {
if (hoveredId != null) map.setFeatureState({ source: "hh-choropleth", id: hoveredId }, { hover: false });
hoveredId = null;
});
map.on("click", "hh-choropleth-fill", (e) => {
if (!e.features.length) return;
new maplibregl.Popup().setLngLat(e.lngLat).setHTML(e.features[0].properties._popup).addTo(map);
});
const bounds = geojsonBoundsHh(map_features_hh);
if (bounds) map.fitBounds(bounds, { padding: 10 });
const stops = [-0.5, -0.25, 0, 0.25, 0.5];
const labels = ["≤ −50%", "−25%", "0%", "+25%", "≥ +50%"];
const swatches = stops.map((v, i) =>
`<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">` +
`<span style="display:inline-block;width:14px;height:14px;border-radius:2px;flex-shrink:0;` +
`background:${getColor(v)};border:1px solid rgba(0,0,0,0.15);"></span>` +
`<span style="font-size:11px;">${labels[i]}</span>` +
`</div>`
).join("");
const legend_html =
`<div style="background:white;padding:10px 12px;border-radius:6px;` +
`box-shadow:0 1px 5px rgba(0,0,0,0.25);line-height:1.5;">` +
`<div style="font-size:12px;font-weight:600;margin-bottom:4px;">Percent Error</div>` +
`<div style="font-size:10px;color:#666;margin-bottom:6px;">` +
`Red = Underpredict | Blue = Overpredict` +
`</div>` +
swatches +
`</div>`;
map.addControl(new HtmlLegendControlHh(legend_html), "bottom-right");
map.resize();
});
const resizeObserver = new ResizeObserver(() => {
if (!loaded) return;
setTimeout(() => {
map.resize();
const bounds = geojsonBoundsHh(map_features_hh);
if (bounds) map.fitBounds(bounds, { padding: 10 });
}, 100);
});
resizeObserver.observe(container);
invalidation.then(() => { map.remove(); resizeObserver.disconnect(); });
}hh_cnt_fmt = vDisplay === "Percentages" ? d3.format(".1%") : d3.format(",.0f")
viewof hh_summary_table = Inputs.table(hh_agg_facet, {
columns: [
"geo_id", "HHSIZE", "WRKS",
vDisplay === "Percentages" ? "Modeled_Pct" : "Modeled_Count",
vDisplay === "Percentages" ? "Observed_Pct" : "Observed_Count",
vDisplay === "Percentages" ? "HTS_Pct" : "HTS_Count",
"Mod_vs_PUMS", "Mod_vs_HTS", "PUMS_vs_HTS"
],
header: {
geo_id: "Geo ID",
HHSIZE: "HH Size",
WRKS: "Workers",
Modeled_Count: "Modeled", Modeled_Pct: "Modeled (%)",
Observed_Count: "PUMS", Observed_Pct: "PUMS (%)",
HTS_Count: "HTS", HTS_Pct: "HTS (%)",
Mod_vs_PUMS: "Mod vs PUMS",
Mod_vs_HTS: "Mod vs HTS",
PUMS_vs_HTS: "PUMS vs HTS"
},
format: {
Modeled_Count: hh_cnt_fmt, Modeled_Pct: hh_cnt_fmt,
Observed_Count: hh_cnt_fmt, Observed_Pct: hh_cnt_fmt,
HTS_Count: hh_cnt_fmt, HTS_Pct: hh_cnt_fmt,
Mod_vs_PUMS: d3.format("+.1%"),
Mod_vs_HTS: d3.format("+.1%"),
PUMS_vs_HTS: d3.format("+.1%")
},
sort: "Mod_vs_PUMS",
reverse: true,
rows: 15,
width: "100%"
})