353 lines
12 KiB
JavaScript
353 lines
12 KiB
JavaScript
import { loadDashboardData } from "./data.js";
|
|
import {
|
|
aggregateOverview,
|
|
compactNumber,
|
|
percent,
|
|
renderAgeRisk,
|
|
renderCohortDotPlot,
|
|
renderCoverageHeatmap,
|
|
renderNoCalibration,
|
|
renderOutcomeTrend,
|
|
renderUtahCoverage,
|
|
} from "./charts.js";
|
|
|
|
const ROUTES = Object.freeze({
|
|
overview: "Overview",
|
|
reliability: "Reliability explorer",
|
|
estimator: "Next-test estimator",
|
|
methods: "Data & methods",
|
|
});
|
|
|
|
const UTAH_COUNTIES = [
|
|
"Beaver",
|
|
"Box Elder",
|
|
"Cache",
|
|
"Carbon",
|
|
"Daggett",
|
|
"Davis",
|
|
"Duchesne",
|
|
"Emery",
|
|
"Garfield",
|
|
"Grand",
|
|
"Iron",
|
|
"Juab",
|
|
"Kane",
|
|
"Millard",
|
|
"Morgan",
|
|
"Piute",
|
|
"Rich",
|
|
"Salt Lake",
|
|
"San Juan",
|
|
"Sanpete",
|
|
"Sevier",
|
|
"Summit",
|
|
"Tooele",
|
|
"Uintah",
|
|
"Utah",
|
|
"Wasatch",
|
|
"Washington",
|
|
"Wayne",
|
|
"Weber",
|
|
];
|
|
|
|
let dashboardData = null;
|
|
let visibleScorecards = [];
|
|
|
|
function byId(id) {
|
|
return document.getElementById(id);
|
|
}
|
|
|
|
function setText(id, value) {
|
|
const element = byId(id);
|
|
if (element) element.textContent = value;
|
|
}
|
|
|
|
function normalizeCounty(value) {
|
|
return String(value).trim().toLowerCase().replaceAll("_", " ").replace(/\s+/g, " ");
|
|
}
|
|
|
|
function displayCategory(value) {
|
|
return String(value)
|
|
.replaceAll("_", " ")
|
|
.toLowerCase()
|
|
.replace(/\b\w/g, (letter) => letter.toUpperCase());
|
|
}
|
|
|
|
function routeFromHash() {
|
|
const candidate = window.location.hash.replace(/^#/, "").toLowerCase();
|
|
return Object.hasOwn(ROUTES, candidate) ? candidate : "overview";
|
|
}
|
|
|
|
function showRoute({ announce = true, focus = false } = {}) {
|
|
const route = routeFromHash();
|
|
for (const view of document.querySelectorAll("[data-view]")) {
|
|
view.hidden = view.dataset.view !== route;
|
|
}
|
|
for (const link of document.querySelectorAll("[data-route]")) {
|
|
if (link.dataset.route === route) link.setAttribute("aria-current", "page");
|
|
else link.removeAttribute("aria-current");
|
|
}
|
|
document.title = `${ROUTES[route]} · Utah Vehicle Health`;
|
|
if (announce) setText("route-announcer", `${ROUTES[route]} view`);
|
|
if (focus) {
|
|
const heading = document.querySelector(`[data-view="${route}"] h1`);
|
|
if (heading) {
|
|
heading.setAttribute("tabindex", "-1");
|
|
heading.focus({ preventScroll: true });
|
|
heading.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
}
|
|
}
|
|
}
|
|
|
|
function initializeRouting() {
|
|
showRoute({ announce: false, focus: false });
|
|
window.addEventListener("hashchange", () => showRoute({ focus: true }));
|
|
}
|
|
|
|
function setHeaderStatus(state, message) {
|
|
const status = byId("header-status");
|
|
if (!status) return;
|
|
status.dataset.state = state;
|
|
const text = status.querySelector("span:last-child");
|
|
if (text) text.textContent = message;
|
|
}
|
|
|
|
function setPreviewBanner(manifest) {
|
|
const preview = manifest.development_preview || !manifest.population_estimate_allowed;
|
|
const banner = byId("preview-banner");
|
|
banner.hidden = !preview;
|
|
if (!preview) return;
|
|
setText("preview-title", "Development preview");
|
|
setText(
|
|
"preview-copy",
|
|
"These suppressed sample aggregates are not population estimates and must not be used for individual decisions.",
|
|
);
|
|
}
|
|
|
|
function setUnavailable(error) {
|
|
const safeMessage =
|
|
error instanceof Error && error.message
|
|
? `${error.message} No estimates are being shown.`
|
|
: "The approved aggregate files could not be validated. No estimates are being shown.";
|
|
for (const state of document.querySelectorAll("[data-unavailable]")) {
|
|
state.hidden = false;
|
|
const copy = state.querySelector("[data-unavailable-message]");
|
|
if (copy) copy.textContent = safeMessage;
|
|
}
|
|
for (const region of document.querySelectorAll("[data-requires-data]")) {
|
|
region.hidden = true;
|
|
}
|
|
const banner = byId("preview-banner");
|
|
banner.hidden = false;
|
|
setText("preview-title", "Data unavailable");
|
|
setText("preview-copy", "This static shell could not validate every required public aggregate. No fallback or estimated values are displayed.");
|
|
setHeaderStatus("error", "Approved data unavailable");
|
|
}
|
|
|
|
function populateSelect(id, values, formatter = displayCategory) {
|
|
const select = byId(id);
|
|
if (!select) return;
|
|
for (const value of values) {
|
|
const option = document.createElement("option");
|
|
option.value = String(value);
|
|
option.textContent = formatter(value);
|
|
select.append(option);
|
|
}
|
|
}
|
|
|
|
function disableUnsupportedFilter(id, explanation) {
|
|
const select = byId(id);
|
|
if (!select) return;
|
|
select.disabled = true;
|
|
select.title = explanation;
|
|
select.options[0].textContent = explanation;
|
|
}
|
|
|
|
function renderCountyList(coveredCounties) {
|
|
const container = byId("county-coverage-list");
|
|
container.replaceChildren();
|
|
const normalized = new Set(coveredCounties.map(normalizeCounty));
|
|
for (const county of UTAH_COUNTIES) {
|
|
const chip = document.createElement("span");
|
|
chip.className = "county-chip";
|
|
chip.dataset.covered = String(normalized.has(normalizeCounty(county)));
|
|
chip.textContent = county;
|
|
container.append(chip);
|
|
}
|
|
}
|
|
|
|
function renderOverview(data) {
|
|
const rows = data.overview.rows;
|
|
const periods = aggregateOverview(rows);
|
|
const totalSupport = rows.reduce((sum, row) => sum + row.support_rounded, 0);
|
|
const weightedNonpass = rows.reduce(
|
|
(sum, row) => sum + row.support_rounded * row.nonpass_rate,
|
|
0,
|
|
);
|
|
const nonpassRate = totalSupport > 0 ? weightedNonpass / totalSupport : 0;
|
|
const counties = [...new Set(rows.map((row) => row.public_county))].sort();
|
|
const latest = periods[periods.length - 1];
|
|
|
|
setText("kpi-eligible", `≈${compactNumber(totalSupport)}`);
|
|
setText("kpi-pass", `≈${percent(1 - nonpassRate)}`);
|
|
setText("kpi-nonpass", `≈${percent(nonpassRate)}`);
|
|
setText("kpi-counties", String(counties.length));
|
|
setText("data-cutoff", latest ? `${latest.year} Q${latest.quarter}` : "Unavailable");
|
|
setText(
|
|
"model-version",
|
|
`Release ${data.manifest.release_id.slice(0, 8)} · ${data.manifest.model_versions.join(" / ")}`,
|
|
);
|
|
renderOutcomeTrend(byId("outcome-trend-chart"), rows);
|
|
setText(
|
|
"outcome-trend-note",
|
|
"The current approved bundle publishes binary non-pass rates only. Four-class outcome mix and blank rates are not inferred or displayed.",
|
|
);
|
|
renderAgeRisk(byId("age-risk-chart"), data.ageRisk.rows);
|
|
renderUtahCoverage(byId("utah-coverage-map"), counties);
|
|
renderCountyList(counties);
|
|
}
|
|
|
|
function scorecardLabel(row) {
|
|
return `${row.prior_make} ${row.prior_model}`.trim();
|
|
}
|
|
|
|
function sortedScorecards(rows, mode) {
|
|
const sorted = [...rows];
|
|
if (mode === "risk-desc") sorted.sort((a, b) => b.nonpass_rate - a.nonpass_rate);
|
|
else if (mode === "risk-asc") sorted.sort((a, b) => a.nonpass_rate - b.nonpass_rate);
|
|
else if (mode === "name") sorted.sort((a, b) => scorecardLabel(a).localeCompare(scorecardLabel(b)));
|
|
else sorted.sort((a, b) => b.support_rounded - a.support_rounded);
|
|
return sorted;
|
|
}
|
|
|
|
function renderScorecardCards(rows) {
|
|
const container = byId("cohort-cards");
|
|
container.replaceChildren();
|
|
for (const row of rows.slice(0, 18)) {
|
|
const article = document.createElement("article");
|
|
article.className = "cohort-card";
|
|
const heading = document.createElement("h3");
|
|
heading.textContent = scorecardLabel(row);
|
|
const meta = document.createElement("div");
|
|
meta.className = "cohort-card__meta";
|
|
meta.textContent = `${compactNumber(row.support_rounded)} rounded support`;
|
|
const bar = document.createElement("div");
|
|
bar.className = "risk-bar";
|
|
bar.setAttribute("aria-hidden", "true");
|
|
const fill = document.createElement("span");
|
|
fill.style.width = `${Math.min(100, row.nonpass_rate * 100)}%`;
|
|
bar.append(fill);
|
|
const value = document.createElement("div");
|
|
value.className = "cohort-card__value";
|
|
const strong = document.createElement("strong");
|
|
strong.textContent = percent(row.nonpass_rate);
|
|
const small = document.createElement("small");
|
|
small.textContent = "Observed non-pass";
|
|
value.append(strong, small);
|
|
article.append(heading, meta, bar, value);
|
|
container.append(article);
|
|
}
|
|
}
|
|
|
|
function updateExplorer() {
|
|
if (!dashboardData) return;
|
|
const query = byId("cohort-search").value.trim().toLowerCase();
|
|
const sortMode = byId("result-sort").value;
|
|
const matches = dashboardData.scorecards.rows.filter((row) =>
|
|
scorecardLabel(row).toLowerCase().includes(query),
|
|
);
|
|
visibleScorecards = sortedScorecards(matches, sortMode);
|
|
setText(
|
|
"result-summary",
|
|
`${visibleScorecards.length} supported cohort${visibleScorecards.length === 1 ? "" : "s"}; showing up to 18 cards and 12 chart rows.`,
|
|
);
|
|
byId("empty-results").hidden = visibleScorecards.length > 0;
|
|
renderCohortDotPlot(byId("cohort-dot-plot"), visibleScorecards);
|
|
renderScorecardCards(visibleScorecards);
|
|
}
|
|
|
|
function initializeExplorer(data) {
|
|
populateSelect("filter-county", data.filters.public_counties);
|
|
populateSelect("filter-age", data.filters.age_bands, (value) => String(value));
|
|
populateSelect(
|
|
"filter-period",
|
|
data.filters.periods.map((period) => period.year),
|
|
(value) => String(value),
|
|
);
|
|
|
|
// Current scorecards are make/model aggregates only. These planned controls
|
|
// remain visible but disabled so the UI never implies unsupported slicing.
|
|
disableUnsupportedFilter("filter-county", "Unavailable at current scorecard grain");
|
|
disableUnsupportedFilter("filter-age", "Unavailable at current scorecard grain");
|
|
disableUnsupportedFilter("filter-fuel", "Fuel not published in this bundle");
|
|
disableUnsupportedFilter("filter-program", "Program not published in this bundle");
|
|
disableUnsupportedFilter("filter-period", "Period not published in this scorecard");
|
|
const adjusted = document.querySelector('input[name="risk-view"][value="adjusted"]');
|
|
adjusted.disabled = true;
|
|
adjusted.parentElement.title = "Model-adjusted cohort scorecards are not published.";
|
|
|
|
byId("cohort-search").addEventListener("input", updateExplorer);
|
|
byId("result-sort").addEventListener("change", updateExplorer);
|
|
byId("reset-filters").addEventListener("click", () => {
|
|
byId("explorer-filters").reset();
|
|
byId("cohort-search").value = "";
|
|
byId("result-sort").value = "support";
|
|
updateExplorer();
|
|
});
|
|
updateExplorer();
|
|
}
|
|
|
|
function chooseDiagnostic(rows) {
|
|
const partitionRank = { calibrate: 3, tune: 2, train: 1 };
|
|
return [...rows].sort((left, right) => {
|
|
const preferredLeft = left.model === "logistic_platt" ? 1 : 0;
|
|
const preferredRight = right.model === "logistic_platt" ? 1 : 0;
|
|
return (
|
|
preferredRight - preferredLeft ||
|
|
(partitionRank[right.partition] ?? 0) - (partitionRank[left.partition] ?? 0)
|
|
);
|
|
})[0];
|
|
}
|
|
|
|
function renderMethods(data) {
|
|
renderCoverageHeatmap(byId("coverage-heatmap"), data.coverage.rows);
|
|
const diagnostic = chooseDiagnostic(data.diagnostics.rows);
|
|
setText(
|
|
"diagnostic-scope-label",
|
|
diagnostic
|
|
? `${diagnostic.partition === "calibrate" ? "Calibration cohort" : "Development diagnostics"} · ${diagnostic.model}`
|
|
: "Development diagnostics · model unavailable",
|
|
);
|
|
setText("diagnostic-ap", diagnostic ? diagnostic.average_precision.toFixed(3) : "—");
|
|
setText("diagnostic-brier", diagnostic ? diagnostic.brier.toFixed(3) : "—");
|
|
setText("diagnostic-n", "Not published");
|
|
renderNoCalibration(byId("calibration-chart"));
|
|
}
|
|
|
|
function renderDashboard(data) {
|
|
dashboardData = data;
|
|
setPreviewBanner(data.manifest);
|
|
renderOverview(data);
|
|
initializeExplorer(data);
|
|
renderMethods(data);
|
|
setHeaderStatus(
|
|
"ready",
|
|
data.manifest.development_preview ? "Validated development aggregates" : "Validated public aggregates",
|
|
);
|
|
}
|
|
|
|
async function initialize() {
|
|
initializeRouting();
|
|
try {
|
|
const data = await loadDashboardData();
|
|
renderDashboard(data);
|
|
} catch (error) {
|
|
console.error("Dashboard data contract rejected the public assets.", error);
|
|
setUnavailable(error);
|
|
}
|
|
}
|
|
|
|
initialize();
|
|
|
|
export { displayCategory, normalizeCounty, routeFromHash };
|